diff --git a/app/models/erc20_fixed_denomination_parser.rb b/app/models/erc20_fixed_denomination_parser.rb index 15accab..5ea77f1 100644 --- a/app/models/erc20_fixed_denomination_parser.rb +++ b/app/models/erc20_fixed_denomination_parser.rb @@ -1,22 +1,36 @@ # Extracts fixed-denomination ERC-20 parameters from strict JSON inscriptions. class Erc20FixedDenominationParser # Constants - DEFAULT_PARAMS = [''.b, ''.b, ''.b, 0, 0, 0].freeze + DEFAULT_PARAMS = [''.b, ''.b, ''.b].freeze UINT256_MAX = 2**256 - 1 - + PROTOCOL = 'erc-20-fixed-denomination'.b + # Exact regex patterns for valid formats # Protocol must be "erc-20" (legacy inscription) or the canonical identifier # Tick must be lowercase letters/numbers, max 28 chars # Numbers must be positive decimals without leading zeros PROTOCOL_PATTERN = '(?:erc-20|erc-20-fixed-denomination)' - DEPLOY_REGEX = /\Adata:,\{"p":"#{PROTOCOL_PATTERN}","op":"deploy","tick":"([a-z0-9]{1,28})","max":"(0|[1-9][0-9]*)","lim":"(0|[1-9][0-9]*)"\}\z/ - MINT_REGEX = /\Adata:,\{"p":"#{PROTOCOL_PATTERN}","op":"mint","tick":"([a-z0-9]{1,28})","id":"(0|[1-9][0-9]*)","amt":"(0|[1-9][0-9]*)"\}\z/ + DEPLOY_REGEX = /\A\{"p":"#{PROTOCOL_PATTERN}","op":"deploy","tick":"([a-z0-9]{1,28})","max":"(0|[1-9][0-9]*)","lim":"(0|[1-9][0-9]*)"\}\z/ + MINT_REGEX = /\A\{"p":"#{PROTOCOL_PATTERN}","op":"mint","tick":"([a-z0-9]{1,28})","id":"(0|[1-9][0-9]*)","amt":"(0|[1-9][0-9]*)"\}\z/ + + # Validate and encode protocol params + # Unified interface - accepts all possible parameters, uses what it needs + def self.validate_and_encode(decoded_content:, operation:, params:, source:, ethscription_id: nil, **_extras) + new.validate_and_encode( + decoded_content: decoded_content, + operation: operation, + params: params, + source: source + ) + end - def self.extract(content_uri) - return DEFAULT_PARAMS unless content_uri.is_a?(String) + def validate_and_encode(decoded_content:, operation:, params:, source:) + # Only support JSON source - no header parameters for ERC-20 + return DEFAULT_PARAMS unless source == :json + return DEFAULT_PARAMS unless decoded_content.is_a?(String) # Try deploy format first - if match = DEPLOY_REGEX.match(content_uri) + if match = DEPLOY_REGEX.match(decoded_content) tick = match[1] # Group 1: tick max = match[2].to_i # Group 2: max lim = match[3].to_i # Group 3: lim @@ -24,11 +38,12 @@ def self.extract(content_uri) # Validate uint256 bounds return DEFAULT_PARAMS if max > UINT256_MAX || lim > UINT256_MAX - return ['deploy'.b, 'erc-20-fixed-denomination'.b, tick.b, max, lim, 0] + encoded = Eth::Abi.encode(['(string,uint256,uint256)'], [[tick.b, max, lim]]) + return [PROTOCOL, 'deploy'.b, encoded.b] end # Try mint format - if match = MINT_REGEX.match(content_uri) + if match = MINT_REGEX.match(decoded_content) tick = match[1] # Group 1: tick id = match[2].to_i # Group 2: id amt = match[3].to_i # Group 3: amt @@ -36,48 +51,11 @@ def self.extract(content_uri) # Validate uint256 bounds return DEFAULT_PARAMS if id > UINT256_MAX || amt > UINT256_MAX - return ['mint'.b, 'erc-20-fixed-denomination'.b, tick.b, id, 0, amt] + encoded = Eth::Abi.encode(['(string,uint256,uint256)'], [[tick.b, id, amt]]) + return [PROTOCOL, 'mint'.b, encoded.b] end # No match - return default DEFAULT_PARAMS end - - # Returns a hash representation of params that downstream services expect. - def self.structured_params(params) - op, _protocol, tick, val1, val2, val3 = params - - case op - when 'deploy'.b - { - op: op, - tick: tick, - max: val1, - lim: val2, - amt: 0 - } - when 'mint'.b - { - op: op, - tick: tick, - id: val1, - amt: val3 - } - else - nil - end - end - - # Encodes params into the ABI tuple required by the manager contract. - def self.encode_calldata(params) - op, _protocol, tick, val1, val2, val3 = params - - if op == 'deploy'.b - Eth::Abi.encode(['(string,uint256,uint256)'], [[tick.b, val1, val2]]) - elsif op == 'mint'.b - Eth::Abi.encode(['(string,uint256,uint256)'], [[tick.b, val1, val3]]) - else - ''.b - end - end -end +end \ No newline at end of file diff --git a/app/models/erc721_ethscriptions_collection_parser.rb b/app/models/erc721_ethscriptions_collection_parser.rb index f5f6b6e..8719372 100644 --- a/app/models/erc721_ethscriptions_collection_parser.rb +++ b/app/models/erc721_ethscriptions_collection_parser.rb @@ -28,8 +28,8 @@ class Erc721EthscriptionsCollectionParser # 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 mirrors ItemData struct (no ethscriptionId field) - abi_type: '((string,string,uint256,string,string,string,string,string,string,string,bytes32),(uint256,string,string,string,(string,string)[],bytes32[]))', + # ((CollectionParams),(ItemData)) - ItemData now includes contentHash as first field + abi_type: '((string,string,uint256,string,string,string,string,string,string,string,bytes32),(bytes32,uint256,string,string,string,(string,string)[],bytes32[]))', validators: { 'metadata' => :collection_metadata, 'item' => :single_item @@ -38,7 +38,7 @@ class Erc721EthscriptionsCollectionParser # 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[]))', + abi_type: '((string,string,uint256,string,string,string,string,string,string,string,bytes32),(bytes32,uint256,string,string,string,(string,string)[],bytes32[]))', validators: { 'metadata' => :collection_metadata, 'item' => :single_item @@ -47,7 +47,7 @@ class Erc721EthscriptionsCollectionParser # 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[]))', + abi_type: '(bytes32,(bytes32,uint256,string,string,string,(string,string)[],bytes32[]))', validators: { 'collection_id' => :bytes32, 'item' => :single_item @@ -101,7 +101,7 @@ class Erc721EthscriptionsCollectionParser ZERO_HEX_BYTES32 = '0x' + '0' * 64 # Item keys for validation (merkle_proof always present, can be empty array) - ITEM_KEYS = %w[item_index name background_color description attributes merkle_proof].freeze + ITEM_KEYS = %w[content_hash item_index name background_color description attributes merkle_proof].freeze # Attribute keys for NFT metadata ATTRIBUTE_KEYS = %w[trait_type value].freeze @@ -111,44 +111,79 @@ class ValidationError < StandardError; end 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) + # New API: validate and encode protocol params + # Unified interface - accepts all possible parameters, uses what it needs + def self.validate_and_encode(decoded_content:, operation:, params:, source:, ethscription_id: nil, **_extras) + new.validate_and_encode( + decoded_content: decoded_content, + operation: operation, + params: params, + source: source, + ethscription_id: ethscription_id + ) end - def extract(content_uri, ethscription_id: nil) + def validate_and_encode(decoded_content:, operation:, params:, source:, ethscription_id: nil) + # Check import fallback first (if ethscription_id provided) if ethscription_id normalized_id = normalize_id(ethscription_id) - if normalized_id && (preplanned = build_import_encoded_params(normalized_id)) + if normalized_id && (preplanned = build_import_encoded_params(normalized_id, decoded_content)) return preplanned end end - return DEFAULT_PARAMS unless valid_data_uri?(content_uri) - - begin - json_str = DataUri.new(content_uri).decoded_data + return DEFAULT_PARAMS unless OPERATION_SCHEMAS.key?(operation) - # TODO: make sure this is safe - data = JSON.parse(json_str) - return DEFAULT_PARAMS unless data.is_a?(Hash) - return DEFAULT_PARAMS unless data['p'] == 'erc-721-ethscriptions-collection' + schema = OPERATION_SCHEMAS[operation] - operation = data['op'] - return DEFAULT_PARAMS unless OPERATION_SCHEMAS.key?(operation) + begin + if source == :json + # Strict JSON validation - enforce exact key order + validate_json_structure(params, operation, schema) + end - schema = OPERATION_SCHEMAS[operation] - expected_keys = ['p', 'op'] + schema[:keys] - return DEFAULT_PARAMS unless data.keys == expected_keys + # Extract encoding data (skip 'p' and 'op' for JSON source) + encoding_data = if source == :json + params.reject { |k, _| k == 'p' || k == 'op' } + else + params + end + + # Compute content hash ONLY for operations that need it + content_hash = nil + if ['create_collection_and_add_self', 'create_and_add_self', 'add_self_to_collection'].include?(operation) + # Calculate keccak256 of decoded content for item verification + hash = Eth::Util.keccak256(decoded_content).unpack1('H*') + content_hash = '0x' + hash + + # Inject content_hash into item data + item_data = encoding_data['item'] + if item_data.is_a?(Hash) + # Add content_hash as first key (OrderedHash maintains insertion order) + encoding_data['item'] = self.class::OrderedHash['content_hash', content_hash].merge(item_data) + end + end - encoding_data = data.reject { |k, _| k == 'p' || k == 'op' } - encoded_data = encode_operation(operation, encoding_data, schema) + encoded_data = encode_operation(operation, encoding_data, schema, content_hash: content_hash) ['erc-721-ethscriptions-collection'.b, operation.b, encoded_data.b] rescue JSON::ParserError, ValidationError => e - Rails.logger.debug "Collections extraction failed: #{e.message}" if defined?(Rails) + Rails.logger.debug "Collections validation failed: #{e.message}" if defined?(Rails) DEFAULT_PARAMS end end + def validate_json_structure(params, operation, schema) + # For JSON source, enforce strict key ordering + expected_keys = ['p', 'op'] + schema[:keys] + unless params.keys == expected_keys + raise ValidationError, "Invalid key order for #{operation}" + end + end + + # Removed extract() method - use ProtocolParser.for_calldata() instead + # This avoids circular dependencies and keeps the architecture cleaner + # The import fallback logic is now handled in validate_and_encode() + def normalize_id(value) case value when ByteString @@ -163,7 +198,7 @@ def normalize_id(value) # -------------------- Import fallback -------------------- # Returns [protocol, operation, encoded_data] or nil - def build_import_encoded_params(id) + def build_import_encoded_params(id, decoded_content) data = self.class.load_import_data( items_path: DEFAULT_ITEMS_PATH, collections_path: DEFAULT_COLLECTIONS_PATH @@ -180,6 +215,10 @@ def build_import_encoded_params(id) item_index = data[:zero_index_by_id][id] || 0 + # Always compute content hash from the actual decoded content + hash = Eth::Util.keccak256(decoded_content || ''.b).unpack1('H*') + content_hash = '0x' + hash + if id == leader_id raw_metadata = data[:collections_by_name][coll_name] return nil unless raw_metadata @@ -190,18 +229,18 @@ def build_import_encoded_params(id) schema = OPERATION_SCHEMAS[operation] encoding_data = { 'metadata' => build_metadata_object(metadata), - 'item' => build_item_object(item: item, item_index: item_index) + 'item' => build_item_object(item: item, item_index: item_index, content_hash: content_hash) } - encoded_data = encode_operation(operation, encoding_data, schema) + encoded_data = encode_operation(operation, encoding_data, schema, content_hash: content_hash) ['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_index: item_index) + 'item' => build_item_object(item: item, item_index: item_index, content_hash: content_hash) } - encoded_data = encode_operation(operation, encoding_data, schema) + encoded_data = encode_operation(operation, encoding_data, schema, content_hash: content_hash) ['erc-721-ethscriptions-collection'.b, operation.b, encoded_data.b] end end @@ -285,7 +324,7 @@ def build_metadata_object(meta) result end - def build_item_object(item:, item_index:) + def build_item_object(item:, item_index:, content_hash:) attrs = Array(item['attributes']).map do |a| OrderedHash['trait_type', safe_string(a['trait_type']), 'value', safe_string(a['value'])] end @@ -293,6 +332,7 @@ def build_item_object(item:, item_index:) proofs = item.key?('merkle_proof') ? Array(item['merkle_proof']) : [] OrderedHash[ + 'content_hash', content_hash, 'item_index', safe_uint_string(item_index), 'name', safe_string(item['name']), 'background_color', safe_string(item['background_color']), @@ -347,7 +387,7 @@ def valid_data_uri?(uri) DataUri.valid?(uri) end - def encode_operation(operation, data, schema) + def encode_operation(operation, data, schema, content_hash: nil) # Validate and transform fields according to schema validated_data = validate_fields(data, schema[:validators]) @@ -356,9 +396,9 @@ def encode_operation(operation, data, schema) when 'create_collection' build_create_collection_values(validated_data) when 'create_collection_and_add_self', 'create_and_add_self' - build_create_and_add_self_values(validated_data) + build_create_and_add_self_values(validated_data, content_hash: content_hash) when 'add_self_to_collection' - build_add_self_to_collection_values(validated_data) + build_add_self_to_collection_values(validated_data, content_hash: content_hash) when 'remove_items' build_remove_items_values(validated_data) when 'edit_collection' @@ -514,6 +554,7 @@ def validate_item(item) end { + contentHash: validate_bytes32(item['content_hash'], 'content_hash'), itemIndex: validate_uint256(item['item_index'], 'item_index'), name: validate_string(item['name'], 'name'), backgroundColor: validate_string(item['background_color'], 'background_color'), @@ -568,7 +609,7 @@ def build_create_collection_values(data) ] end - def build_create_and_add_self_values(data) + def build_create_and_add_self_values(data, content_hash:) meta = data['metadata'] item = data['item'] @@ -588,7 +629,17 @@ def build_create_and_add_self_values(data) merkle_root ] + # Item tuple - contentHash comes first (keccak256 of ethscription content) + # Always use the computed content_hash if provided, otherwise use validated item contentHash + content_hash_bytes = if content_hash + [content_hash[2..]].pack('H*') + elsif item[:contentHash] + item[:contentHash] # Already packed bytes from validate_item + else + raise ValidationError, "Content hash missing" + end item_tuple = [ + content_hash_bytes, # Already packed bytes, don't call to_bytes32_hex item[:itemIndex], item[:name], item[:backgroundColor], @@ -600,9 +651,20 @@ def build_create_and_add_self_values(data) [metadata_tuple, item_tuple] end - def build_add_self_to_collection_values(data) + def build_add_self_to_collection_values(data, content_hash:) item = data['item'] + + # Item tuple - contentHash comes first (keccak256 of ethscription content) + # Always use the computed content_hash if provided, otherwise use validated item contentHash + content_hash_bytes = if content_hash + [content_hash[2..]].pack('H*') + elsif item[:contentHash] + item[:contentHash] # Already packed bytes from validate_item + else + raise ValidationError, "Content hash missing" + end item_tuple = [ + content_hash_bytes, # Already packed bytes, don't call to_bytes32_hex item[:itemIndex], item[:name], item[:backgroundColor], diff --git a/app/models/protocol_parser.rb b/app/models/protocol_parser.rb index fd5eb18..8bf0820 100644 --- a/app/models/protocol_parser.rb +++ b/app/models/protocol_parser.rb @@ -1,68 +1,84 @@ # Unified protocol parser that delegates to specific protocol parsers class ProtocolParser - # Default return values to check if extraction succeeded - TOKEN_DEFAULT_PARAMS = Erc20FixedDenominationParser::DEFAULT_PARAMS - COLLECTIONS_DEFAULT_PARAMS = Erc721EthscriptionsCollectionParser::DEFAULT_PARAMS + # Default return value for all parsers - unified 3-element format + DEFAULT_PARAMS = [''.b, ''.b, ''.b].freeze + + # Protocol name to parser class mapping + PROTOCOL_PARSERS = { + 'word-domains' => WordDomainsParser, + 'erc-20' => Erc20FixedDenominationParser, + 'erc-20-fixed-denomination' => Erc20FixedDenominationParser, + 'erc-721-ethscriptions-collection' => Erc721EthscriptionsCollectionParser + }.freeze def self.extract(content_uri, ethscription_id: nil) - # Try parsers in order of specificity - # 1. Token protocol (most strict - exact character position matters) - # 2. Collections protocol (strict - exact key order required) + # Parse data URI and extract protocol info + parsed = parse_data_uri_and_protocol(content_uri) - # Try token parser first (most strict) - result = try_token_parser(content_uri) - return result if result + # Special case: plain word-domains registration (data:,word) has no protocol markers + if parsed.nil? + # If we have an ethscription_id, try import fallback for collections regardless of content + if ethscription_id + # Get decoded content for import fallback + decoded_content = nil + if content_uri.is_a?(String) && DataUri.valid?(content_uri) + data_uri = DataUri.new(content_uri) + decoded_content = data_uri.decoded_data + end - # Try collections parser next (if enabled) - result = try_collections_parser(content_uri, ethscription_id: ethscription_id) - return result if result + # Try collections parser for import fallback with any content + # Ensure decoded_content is binary to avoid encoding issues + encoded = Erc721EthscriptionsCollectionParser.validate_and_encode( + decoded_content: (decoded_content || '').b, + operation: nil, + params: {}, + source: :json, + ethscription_id: ethscription_id + ) - # No protocol could be extracted - nil - end - - private + if encoded != DEFAULT_PARAMS + protocol, operation, encoded_data = encoded + return { + type: :erc721_ethscriptions_collection, + protocol: protocol, + operation: operation, + params: nil, + encoded_params: encoded_data + } + end + end - def self.try_token_parser(content_uri) - # Erc20FixedDenominationParser uses strict regex and returns DEFAULT_PARAMS if no match - # This enforces non-ESIP6 and exact JSON formatting implicitly. - params = Erc20FixedDenominationParser.extract(content_uri) + return try_plain_word_domains(content_uri) + end - # Check if extraction succeeded (returns non-default params) - return nil if params == TOKEN_DEFAULT_PARAMS + # Direct routing - no "try" needed since we know the protocol + parser_class = PROTOCOL_PARSERS[parsed[:protocol_name]] + return nil unless parser_class - { - type: :erc20_fixed_denomination, - protocol: 'erc-20-fixed-denomination', - operation: params[0], # 'deploy' or 'mint' - params: params, - encoded_params: Erc20FixedDenominationParser.structured_params(params) - } - end - - def self.try_collections_parser(content_uri, ethscription_id: nil) - # Erc721EthscriptionsCollectionParser returns [''.b, ''.b, ''.b] if no match - params = Erc721EthscriptionsCollectionParser.extract( - content_uri, + # Call the same method on all parsers with unified interface + encoded = parser_class.validate_and_encode( + decoded_content: parsed[:decoded_content], + operation: parsed[:operation], + params: parsed[:params], + source: parsed[:source], ethscription_id: ethscription_id ) - return nil if params == COLLECTIONS_DEFAULT_PARAMS + # Check if parsing succeeded + return nil if encoded == DEFAULT_PARAMS - protocol, operation, encoded_data = params + protocol, operation, encoded_data = encoded - # Check if extraction succeeded - if protocol != ''.b && operation != ''.b - { - type: :erc721_ethscriptions_collection, - protocol: protocol, - operation: operation, - params: nil, # Collections doesn't return decoded params - encoded_params: encoded_data - } - else - nil - end + # Derive type from parser class name + type = parser_class.name.underscore.sub(/_parser$/, '').to_sym + + { + type: type, + protocol: protocol, + operation: operation, + params: nil, + encoded_params: encoded_data + } end # Get protocol data formatted for L2 calldata @@ -72,21 +88,163 @@ def self.for_calldata(content_uri, ethscription_id: nil) if result.nil? # No protocol detected - return empty protocol params - [''.b, ''.b, ''.b] - elsif result[:type] == :erc20_fixed_denomination - # Fixed denomination ERC-20 protocol - return in ABI-ready format - protocol = result[:protocol].b - operation = result[:operation] - # For tokens, encode the params properly - encoded_data = Erc20FixedDenominationParser.encode_calldata(result[:params]) - [protocol, operation, encoded_data] - elsif result[:type] == :erc721_ethscriptions_collection - # Collections protocol - already has encoded data - [result[:protocol], result[:operation], result[:encoded_params]] + DEFAULT_PARAMS else - # Unknown parser type - return empty protocol params - [''.b, ''.b, ''.b] + # All parsers return the same format, so we can just extract directly + [result[:protocol], result[:operation], result[:encoded_params]] + end + end + + private + + # Parse data URI and extract protocol information from JSON body or headers + # Returns hash with: decoded_content, protocol_name, operation, params, source + # Note: content_hash removed - parsers compute their own if needed + def self.parse_data_uri_and_protocol(content_uri) + return nil unless content_uri.is_a?(String) + return nil unless DataUri.valid?(content_uri) + + data_uri = DataUri.new(content_uri) + decoded_content = data_uri.decoded_data + return nil unless decoded_content.is_a?(String) + + # Try to extract protocol from JSON body + json_protocol = extract_json_protocol(decoded_content) + + # Try to extract protocol from headers + header_protocol = extract_header_protocol(data_uri) + + # Fail if both present (ambiguous) + return nil if json_protocol && header_protocol + + # Get protocol info from whichever source exists + protocol_info = json_protocol || header_protocol + return nil unless protocol_info + + { + decoded_content: decoded_content, + protocol_name: protocol_info[:protocol], + operation: protocol_info[:operation], + params: protocol_info[:params], + source: protocol_info[:source] + } + end + + # Extract protocol info from JSON body + def self.extract_json_protocol(decoded_content) + return nil unless decoded_content.lstrip.start_with?('{') + + data = JSON.parse(decoded_content) + return nil unless data.is_a?(Hash) + + protocol = data['p'] || data['protocol'] + operation = data['op'] || data['operation'] || data['type'] + + return nil unless protocol.is_a?(String) && operation.is_a?(String) + + # Return protocol info with full JSON data as params + { + protocol: protocol, + operation: operation, + params: data, + source: :json + } + rescue JSON::ParserError + nil + end + + # Extract protocol info from data URI headers (;p=...;op=...;d=...) + def self.extract_header_protocol(data_uri) + params_map = parse_parameters(data_uri.parameters) + + # Must have exactly one 'p' and exactly one 'op' + p_values = params_map['p'] + op_values = params_map['op'] + + return nil unless p_values&.length == 1 && op_values&.length == 1 + + protocol = p_values.first + operation = op_values.first + + # Validate protocol and operation format (lowercase, alphanumeric + dash/underscore, 1-50 chars) + return nil unless protocol.match?(/\A[a-z0-9\-_]{1,50}\z/) + return nil unless operation.match?(/\A[a-z0-9\-_]{1,50}\z/) + + # Optional data parameter (d= or data=) with base64-encoded JSON + d_values = (params_map['d'] || []) + (params_map['data'] || []) + return nil if d_values.length > 1 # Only zero or one allowed + + params_hash = {} + if d_values.length == 1 + begin + raw = Base64.strict_decode64(d_values.first) + parsed = JSON.parse(raw) + params_hash = parsed if parsed.is_a?(Hash) + rescue ArgumentError, JSON::ParserError + return nil # Invalid base64 or JSON + end end + + { + protocol: protocol, + operation: operation, + params: params_hash, + source: :header + } end -end + # Parse data URI parameters into a hash of arrays (supporting multiple values per key) + def self.parse_parameters(parameters) + map = Hash.new { |h, k| h[k] = [] } + + parameters.each do |seg| + next if seg.to_s.empty? + + if (eq = seg.index('=')) + key = seg[0...eq].strip.downcase + val = seg[(eq + 1)..].to_s.strip + map[key] << val + end + # Ignore bare flags (e.g., base64, rule=esip6) + end + + map + end + + def self.try_plain_word_domains(content_uri) + # Try to parse as plain word-domains registration (data:,word) + return nil unless content_uri.is_a?(String) + return nil unless DataUri.valid?(content_uri) + + data_uri = DataUri.new(content_uri) + decoded_content = data_uri.decoded_data + return nil unless decoded_content.is_a?(String) + + # For plain word-domains: must have blank mimetype and no parameters + # Also reject empty content (like 'data:,') + return nil if decoded_content.strip.empty? + mt = data_uri.mimetype.to_s + return nil unless (mt.empty? || mt == 'text/plain') && data_uri.parameters.empty? + + # Special handling for plain word-domains + encoded = WordDomainsParser.validate_and_encode( + decoded_content: decoded_content, + operation: nil, # No operation for plain text + params: {}, + source: :plain, + ethscription_id: nil + ) + + return nil if encoded == DEFAULT_PARAMS + + protocol, operation, encoded_data = encoded + + { + type: :word_domains, + protocol: protocol, + operation: operation, + params: nil, + encoded_params: encoded_data + } + end +end \ No newline at end of file diff --git a/app/models/word_domains_parser.rb b/app/models/word_domains_parser.rb new file mode 100644 index 0000000..645fae5 --- /dev/null +++ b/app/models/word_domains_parser.rb @@ -0,0 +1,73 @@ +# Parser for legacy word-only domain inscriptions and related operations. +class WordDomainsParser + DEFAULT_PARAMS = [''.b, ''.b, ''.b].freeze + + PROTOCOL_STRING = 'word-domains'.freeze + PROTOCOL = PROTOCOL_STRING.b + REGISTER_OP = 'register'.b + SET_PRIMARY_OP = 'set_primary'.b + + NAME_REGEX = /\A[a-z0-9_]{1,30}\z/.freeze + + # Validate and encode protocol params + # Unified interface - accepts all possible parameters, uses what it needs + def self.validate_and_encode(decoded_content:, operation:, params:, source:, ethscription_id: nil, **_extras) + new.validate_and_encode( + decoded_content: decoded_content, + operation: operation, + params: params, + source: source + ) + end + + def validate_and_encode(decoded_content:, operation:, params:, source:) + case source + when :json + # JSON-based protocol (from payload) + validate_json_operation(operation, params) + when :plain + # Legacy plain text registration: data:,word + # The decoded_content should match our name regex + return DEFAULT_PARAMS unless decoded_content.is_a?(String) + return DEFAULT_PARAMS unless NAME_REGEX.match?(decoded_content) + + encoded = Eth::Abi.encode(['string'], [decoded_content]) + [PROTOCOL, REGISTER_OP, encoded.b] + else + # Word domains do not support header parameters + DEFAULT_PARAMS + end + end + + private + + def validate_json_operation(operation, params) + case operation + when 'register' + validate_register_operation(params) + when 'set_primary' + validate_set_primary_operation(params) + else + DEFAULT_PARAMS + end + end + + def validate_register_operation(params) + name = params['name'] + return DEFAULT_PARAMS unless name.is_a?(String) + return DEFAULT_PARAMS unless NAME_REGEX.match?(name) + + encoded = Eth::Abi.encode(['string'], [name]) + [PROTOCOL, REGISTER_OP, encoded.b] + end + + def validate_set_primary_operation(params) + name = params['name'] + return DEFAULT_PARAMS unless name.is_a?(String) + # Set primary allows blank to unset primary + return DEFAULT_PARAMS unless name.empty? || NAME_REGEX.match?(name) + + encoded = Eth::Abi.encode(['string'], [name]) + [PROTOCOL, SET_PRIMARY_OP, encoded.b] + end +end diff --git a/contracts/script/L2Genesis.s.sol b/contracts/script/L2Genesis.s.sol index 1850d52..9b79c89 100644 --- a/contracts/script/L2Genesis.s.sol +++ b/contracts/script/L2Genesis.s.sol @@ -7,6 +7,7 @@ import {Predeploys} from "../src/libraries/Predeploys.sol"; import {Constants} from "../src/libraries/Constants.sol"; import {Ethscriptions} from "../src/Ethscriptions.sol"; import {MetaStoreLib} from "../src/libraries/MetaStoreLib.sol"; +import {NameRegistry} from "../src/NameRegistry.sol"; import "forge-std/console.sol"; /// @title GenesisEthscriptions @@ -225,6 +226,7 @@ contract L2Genesis is Script { // Templates live directly at their implementation addresses (no proxy wrapping) _setCodeAt(Predeploys.ERC20_FIXED_DENOMINATION_IMPLEMENTATION, "ERC20FixedDenomination"); _setCodeAt(Predeploys.ERC721_ETHSCRIPTIONS_COLLECTION_IMPLEMENTATION, "ERC721EthscriptionsCollection"); + _setCodeAt(Predeploys.NAME_REGISTRY, "NameRegistry"); // Create genesis Ethscriptions (writes via proxy to proxy storage) createGenesisEthscriptions(); @@ -242,6 +244,9 @@ contract L2Genesis is Script { 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); + + ethscriptions.registerProtocol("word-domains", Predeploys.NAME_REGISTRY); + console.log("Registered word-domains protocol handler:", Predeploys.NAME_REGISTRY); } /// @notice Deploy L1Block contract (stores L1 block attributes) diff --git a/contracts/src/ERC20FixedDenominationManager.sol b/contracts/src/ERC20FixedDenominationManager.sol index e30c913..f0d3360 100644 --- a/contracts/src/ERC20FixedDenominationManager.sol +++ b/contracts/src/ERC20FixedDenominationManager.sol @@ -8,6 +8,8 @@ import "./libraries/Proxy.sol"; import "./Ethscriptions.sol"; import "./libraries/Predeploys.sol"; import "./interfaces/IProtocolHandler.sol"; +import "./ERC721EthscriptionsCollection.sol"; +import "./ERC721EthscriptionsCollectionManager.sol"; /// @title ERC20FixedDenominationManager /// @notice Manages ERC-20 tokens that move in a fixed denomination per mint/transfer lot. @@ -26,11 +28,13 @@ contract ERC20FixedDenominationManager is IProtocolHandler { uint256 maxSupply; uint256 mintAmount; uint256 totalMinted; + address collectionContract; // ERC-721 collection for this token's notes } struct TokenItem { bytes32 deployEthscriptionId; // Which token this ethscription belongs to uint256 amount; // How many tokens this ethscription represents + uint256 mintId; // Fixed denomination note identifier } struct DeployOperation { @@ -51,6 +55,7 @@ contract ERC20FixedDenominationManager is IProtocolHandler { /// @dev Implementation contract used for proxy deployments address public constant tokenImplementation = Predeploys.ERC20_FIXED_DENOMINATION_IMPLEMENTATION; + address public constant collectionImplementation = Predeploys.ERC721_ETHSCRIPTIONS_COLLECTION_IMPLEMENTATION; address public constant ethscriptions = Predeploys.ETHSCRIPTIONS; string public constant protocolName = "erc-20-fixed-denomination"; @@ -59,9 +64,12 @@ contract ERC20FixedDenominationManager is IProtocolHandler { // STATE VARIABLES // ============================================================= - mapping(bytes32 => TokenInfo) internal tokensByTick; - mapping(bytes32 => bytes32) public deployToTick; + mapping(string => TokenInfo) internal tokensByTick; + mapping(bytes32 => string) internal deployToTick; // deployEthscriptionId => tick mapping(bytes32 => TokenItem) internal tokenItems; + mapping(bytes32 => mapping(uint256 => bytes32)) internal mintIds; // deploy inscription => mint id => ethscriptionId + mapping(address => bytes32) public collectionIdForAddress; // collection address => deployEthscriptionId + mapping(bytes32 => address) public collectionAddressForId; // deployEthscriptionId => collection address // ============================================================= // CUSTOM ERRORS @@ -92,14 +100,22 @@ contract ERC20FixedDenominationManager is IProtocolHandler { bytes32 indexed deployEthscriptionId, address indexed to, uint256 amount, + uint256 mintId, bytes32 ethscriptionId ); + event ERC721CollectionDeployed( + bytes32 indexed deployEthscriptionId, + address indexed collectionAddress, + string tick + ); + event ERC20FixedDenominationTokenTransferred( bytes32 indexed deployEthscriptionId, address indexed from, address indexed to, uint256 amount, + uint256 mintId, bytes32 ethscriptionId ); @@ -122,40 +138,60 @@ contract ERC20FixedDenominationManager is IProtocolHandler { function op_deploy(bytes32 ethscriptionId, bytes calldata data) external virtual onlyEthscriptions { DeployOperation memory deployOp = abi.decode(data, (DeployOperation)); - bytes32 tickKey = _getTickKey(deployOp.tick); - TokenInfo storage token = tokensByTick[tickKey]; + TokenInfo storage token = tokensByTick[deployOp.tick]; if (token.deployEthscriptionId != bytes32(0)) revert TokenAlreadyDeployed(); if (deployOp.maxSupply == 0) revert InvalidMaxSupply(); if (deployOp.mintAmount == 0) revert InvalidMintAmount(); if (deployOp.maxSupply % deployOp.mintAmount != 0) revert MaxSupplyNotDivisibleByMintAmount(); - Proxy tokenProxy = new Proxy{salt: tickKey}(address(this)); + bytes32 erc20Salt = _getContractSalt(deployOp.tick, "erc20"); + Proxy tokenProxy = new Proxy{salt: erc20Salt}(address(this)); string memory name = deployOp.tick; string memory symbol = deployOp.tick.upper(); - bytes memory initCalldata = abi.encodeWithSelector( - ERC20FixedDenomination.initialize.selector, - name, - symbol, - deployOp.maxSupply * 10**18, - ethscriptionId + tokenProxy.upgradeToAndCall(tokenImplementation, abi.encodeWithSelector( + ERC20FixedDenomination.initialize.selector, + name, + symbol, + deployOp.maxSupply * 10**18, + ethscriptionId + ) ); - - tokenProxy.upgradeToAndCall(tokenImplementation, initCalldata); + tokenProxy.changeAdmin(Predeploys.PROXY_ADMIN); - tokensByTick[tickKey] = TokenInfo({ + // Deploy collection for this fixed denomination token + bytes32 erc721Salt = _getContractSalt(deployOp.tick, "erc721"); + Proxy collectionProxy = new Proxy{salt: erc721Salt}(address(this)); + + bytes memory collectionInitCalldata = abi.encodeWithSelector( + ERC721EthscriptionsCollection.initialize.selector, + string.concat(deployOp.tick, " ERC-721"), // Collection name + string.concat(deployOp.tick.upper(), "-ERC-721"), // Collection symbol + address(this), // Manager owns the collection + ethscriptionId // Collection ID is the deploy ethscription ID + ); + + collectionProxy.upgradeToAndCall(collectionImplementation, collectionInitCalldata); + collectionProxy.changeAdmin(Predeploys.PROXY_ADMIN); + + tokensByTick[deployOp.tick] = TokenInfo({ tokenContract: address(tokenProxy), deployEthscriptionId: ethscriptionId, tick: deployOp.tick, maxSupply: deployOp.maxSupply, mintAmount: deployOp.mintAmount, - totalMinted: 0 + totalMinted: 0, + collectionContract: address(collectionProxy) }); - deployToTick[ethscriptionId] = tickKey; + deployToTick[ethscriptionId] = deployOp.tick; + + // Set up collection lookups + collectionIdForAddress[address(collectionProxy)] = ethscriptionId; + collectionAddressForId[ethscriptionId] = address(collectionProxy); emit ERC20FixedDenominationTokenDeployed( ethscriptionId, @@ -164,6 +200,12 @@ contract ERC20FixedDenominationManager is IProtocolHandler { deployOp.maxSupply, deployOp.mintAmount ); + + emit ERC721CollectionDeployed( + ethscriptionId, + address(collectionProxy), + deployOp.tick + ); } /// @notice Processes a mint inscription and mints the fixed denomination to the inscription owner. @@ -172,8 +214,7 @@ contract ERC20FixedDenominationManager is IProtocolHandler { function op_mint(bytes32 ethscriptionId, bytes calldata data) external virtual onlyEthscriptions { MintOperation memory mintOp = abi.decode(data, (MintOperation)); - bytes32 tickKey = _getTickKey(mintOp.tick); - TokenInfo storage token = tokensByTick[tickKey]; + TokenInfo storage token = tokensByTick[mintOp.tick]; if (token.deployEthscriptionId == bytes32(0)) revert TokenNotDeployed(); if (mintOp.amount != token.mintAmount) revert MintAmountMismatch(); @@ -187,13 +228,18 @@ contract ERC20FixedDenominationManager is IProtocolHandler { tokenItems[ethscriptionId] = TokenItem({ deployEthscriptionId: token.deployEthscriptionId, - amount: mintOp.amount + amount: mintOp.amount, + mintId: mintOp.id }); + mintIds[token.deployEthscriptionId][mintOp.id] = ethscriptionId; ERC20FixedDenomination(token.tokenContract).mint(initialOwner, mintOp.amount * 10**18); token.totalMinted += mintOp.amount; - emit ERC20FixedDenominationTokenMinted(token.deployEthscriptionId, initialOwner, mintOp.amount, ethscriptionId); + // Mint collection NFT with tokenId = mintId + ERC721EthscriptionsCollection(token.collectionContract).addMember(ethscriptionId, mintOp.id); + + emit ERC20FixedDenominationTokenMinted(token.deployEthscriptionId, initialOwner, mintOp.amount, mintOp.id, ethscriptionId); } /// @notice Mirrors ERC-20 balances when a mint inscription NFT transfers. @@ -209,12 +255,15 @@ contract ERC20FixedDenominationManager is IProtocolHandler { if (item.deployEthscriptionId == bytes32(0)) return; - bytes32 tickKey = deployToTick[item.deployEthscriptionId]; - TokenInfo storage token = tokensByTick[tickKey]; + string memory tick = deployToTick[item.deployEthscriptionId]; + TokenInfo storage token = tokensByTick[tick]; ERC20FixedDenomination(token.tokenContract).forceTransfer(from, to, item.amount * 10**18); - emit ERC20FixedDenominationTokenTransferred(item.deployEthscriptionId, from, to, item.amount, ethscriptionId); + // Transfer collection NFT with tokenId = mintId + ERC721EthscriptionsCollection(token.collectionContract).forceTransfer(from, to, item.mintId); + + emit ERC20FixedDenominationTokenTransferred(item.deployEthscriptionId, from, to, item.amount, item.mintId, ethscriptionId); } // ============================================================= @@ -222,34 +271,41 @@ contract ERC20FixedDenominationManager is IProtocolHandler { // ============================================================= function getTokenAddress(bytes32 deployEthscriptionId) external view returns (address) { - bytes32 tickKey = deployToTick[deployEthscriptionId]; - return tokensByTick[tickKey].tokenContract; + string memory tick = deployToTick[deployEthscriptionId]; + return tokensByTick[tick].tokenContract; } function getTokenAddressByTick(string memory tick) external view returns (address) { - bytes32 tickKey = _getTickKey(tick); - return tokensByTick[tickKey].tokenContract; + return tokensByTick[tick].tokenContract; } function getTokenInfo(bytes32 deployEthscriptionId) external view returns (TokenInfo memory) { - bytes32 tickKey = deployToTick[deployEthscriptionId]; - return tokensByTick[tickKey]; + string memory tick = deployToTick[deployEthscriptionId]; + return tokensByTick[tick]; } function getTokenInfoByTick(string memory tick) external view returns (TokenInfo memory) { - bytes32 tickKey = _getTickKey(tick); - return tokensByTick[tickKey]; + return tokensByTick[tick]; } function predictTokenAddressByTick(string memory tick) external view returns (address) { - bytes32 tickKey = _getTickKey(tick); + if (tokensByTick[tick].tokenContract != address(0)) { + return tokensByTick[tick].tokenContract; + } - if (tokensByTick[tickKey].tokenContract != address(0)) { - return tokensByTick[tickKey].tokenContract; + bytes32 erc20Salt = _getContractSalt(tick, "erc20"); + bytes memory creationCode = abi.encodePacked(type(Proxy).creationCode, abi.encode(address(this))); + return Create2.computeAddress(erc20Salt, keccak256(creationCode), address(this)); + } + + function predictCollectionAddressByTick(string memory tick) external view returns (address) { + if (tokensByTick[tick].collectionContract != address(0)) { + return tokensByTick[tick].collectionContract; } + bytes32 erc721Salt = _getContractSalt(tick, "erc721"); bytes memory creationCode = abi.encodePacked(type(Proxy).creationCode, abi.encode(address(this))); - return Create2.computeAddress(tickKey, keccak256(creationCode), address(this)); + return Create2.computeAddress(erc721Salt, keccak256(creationCode), address(this)); } function isTokenItem(bytes32 ethscriptionId) external view returns (bool) { @@ -260,11 +316,84 @@ contract ERC20FixedDenominationManager is IProtocolHandler { return tokenItems[ethscriptionId]; } + // ============================================================= + // COLLECTION VIEW FUNCTIONS + // ============================================================= + + /// @notice Get collection metadata for a given collection address + /// @dev Called by ERC721EthscriptionsCollection.contractURI() + function getCollectionByAddress(address collectionAddress) external view returns ( + ERC721EthscriptionsCollectionManager.CollectionMetadata memory + ) { + bytes32 deployId = collectionIdForAddress[collectionAddress]; + require(deployId != bytes32(0), "Collection not found"); + + string memory tick = deployToTick[deployId]; + TokenInfo memory token = tokensByTick[tick]; + + return ERC721EthscriptionsCollectionManager.CollectionMetadata({ + collectionContract: collectionAddress, + locked: false, // Fixed denomination collections are never locked + name: string.concat(token.tick, " ERC-721"), + symbol: string.concat(token.tick, "-ERC-721"), + maxSupply: token.maxSupply / token.mintAmount, // Number of notes, not total tokens + description: string.concat("Fixed denomination notes for ", token.tick), + logoImageUri: "", + bannerImageUri: "", + backgroundColor: "", + websiteLink: "", + twitterLink: "", + discordLink: "", + merkleRoot: bytes32(0) + }); + } + + /// @notice Get collection item data for a specific tokenId + /// @dev Called by ERC721EthscriptionsCollection.tokenURI() + function getCollectionItem(bytes32 collectionId, uint256 tokenId) external view returns ( + ERC721EthscriptionsCollectionManager.CollectionItem memory + ) { + // tokenId is the mintId for fixed denomination tokens + bytes32 ethscriptionId = mintIds[collectionId][tokenId]; + require(ethscriptionId != bytes32(0), "Token does not exist"); + + TokenItem memory item = tokenItems[ethscriptionId]; + string memory tick = deployToTick[collectionId]; + TokenInfo memory token = tokensByTick[tick]; + + // Create attributes array + ERC721EthscriptionsCollectionManager.Attribute[] memory attributes = + new ERC721EthscriptionsCollectionManager.Attribute[](2); + + attributes[0] = ERC721EthscriptionsCollectionManager.Attribute({ + traitType: "Denomination", + value: LibString.toString(token.mintAmount) + }); + + attributes[1] = ERC721EthscriptionsCollectionManager.Attribute({ + traitType: "Token", + value: token.tick + }); + + return ERC721EthscriptionsCollectionManager.CollectionItem({ + itemIndex: tokenId, + name: string.concat(token.tick, " #", LibString.toString(tokenId)), + ethscriptionId: ethscriptionId, + backgroundColor: "", + description: string.concat(LibString.toString(token.mintAmount), " ", token.tick, " note"), + attributes: attributes + }); + } + + function getCollectionAddress(bytes32 deployEthscriptionId) external view returns (address) { + return collectionAddressForId[deployEthscriptionId]; + } + // ============================================================= // PRIVATE FUNCTIONS // ============================================================= - function _getTickKey(string memory tick) private pure returns (bytes32) { - return keccak256(abi.encode(protocolName, tick)); + function _getContractSalt(string memory tick, string memory contractType) private pure returns (bytes32) { + return keccak256(abi.encode(tick, contractType)); } } diff --git a/contracts/src/ERC721EthscriptionsCollection.sol b/contracts/src/ERC721EthscriptionsCollection.sol index 00c1c86..d6188fc 100644 --- a/contracts/src/ERC721EthscriptionsCollection.sol +++ b/contracts/src/ERC721EthscriptionsCollection.sol @@ -21,6 +21,9 @@ contract ERC721EthscriptionsCollection is ERC721EthscriptionsEnumerableUpgradeab /// @notice Manager contract that deployed and controls this collection ERC721EthscriptionsCollectionManager public manager; + /// @notice Collection ID stored locally to avoid callback to manager + bytes32 public collectionId; + // Events event MemberAdded(bytes32 indexed ethscriptionId, uint256 indexed tokenId); event MemberRemoved(bytes32 indexed ethscriptionId, uint256 indexed tokenId); @@ -38,23 +41,28 @@ contract ERC721EthscriptionsCollection is ERC721EthscriptionsEnumerableUpgradeab function initialize( string memory name_, string memory symbol_, - address initialOwner_ + address initialOwner_, + bytes32 collectionId_ ) external initializer { __ERC721_init(name_, symbol_); __Ownable_init(initialOwner_); manager = ERC721EthscriptionsCollectionManager(msg.sender); - } - - /// @notice Lookup collection id via the manager registry - function collectionId() public view returns (bytes32) { - bytes32 id = manager.collectionIdForAddress(address(this)); - if (id == bytes32(0)) revert UnknownCollection(); - return id; + collectionId = collectionId_; } function addMember(bytes32 ethscriptionId, uint256 tokenId) external onlyFactory { address owner = ethscriptions.ownerOf(ethscriptionId); - _mint(owner, tokenId); + + // Handle minting to address(0) - mint to creator first then transfer + if (owner == address(0)) { + Ethscriptions.Ethscription memory ethscription = ethscriptions.getEthscription(ethscriptionId, false); + address creator = ethscription.creator; + _mint(creator, tokenId); + _transfer(creator, address(0), tokenId); + } else { + _mint(owner, tokenId); + } + emit MemberAdded(ethscriptionId, tokenId); } @@ -90,7 +98,7 @@ contract ERC721EthscriptionsCollection is ERC721EthscriptionsEnumerableUpgradeab if (!_tokenExists(tokenId)) revert("Token does not exist"); ERC721EthscriptionsCollectionManager.CollectionItem memory item = - manager.getCollectionItem(collectionId(), tokenId); + manager.getCollectionItem(collectionId, tokenId); if (item.ethscriptionId == bytes32(0)) revert("Token not in collection"); (string memory mediaType, string memory mediaUri) = ethscriptions.getMediaUri(item.ethscriptionId); diff --git a/contracts/src/ERC721EthscriptionsCollectionManager.sol b/contracts/src/ERC721EthscriptionsCollectionManager.sol index bb84a46..7ec39aa 100644 --- a/contracts/src/ERC721EthscriptionsCollectionManager.sol +++ b/contracts/src/ERC721EthscriptionsCollectionManager.sol @@ -76,6 +76,7 @@ contract ERC721EthscriptionsCollectionManager is IProtocolHandler { } struct ItemData { + bytes32 contentHash; // keccak256 of ethscription content (known ahead of time) uint256 itemIndex; string name; string backgroundColor; @@ -347,7 +348,8 @@ contract ERC721EthscriptionsCollectionManager is IProtocolHandler { ERC721EthscriptionsCollection.initialize.selector, metadata.name, metadata.symbol, - ethscriptions.ownerOf(collectionId) + ethscriptions.ownerOf(collectionId), + collectionId )); collectionProxy.changeAdmin(Predeploys.PROXY_ADMIN); @@ -390,12 +392,14 @@ contract ERC721EthscriptionsCollectionManager is IProtocolHandler { bytes32 ethscriptionId, ItemData memory item ) internal { - CollectionRecord storage collection = collectionStore[collectionId]; + Ethscriptions.Ethscription memory ethscription = ethscriptions.getEthscription(ethscriptionId, false); + + require(ethscription.contentHash == item.contentHash, "Content hash mismatch"); require(collection.collectionContract != address(0), "Collection does not exist"); require(!collection.locked, "Collection is locked"); - address sender = _getEthscriptionCreator(ethscriptionId); + address sender = ethscription.creator; ERC721EthscriptionsCollection collectionContract = ERC721EthscriptionsCollection(collection.collectionContract); @@ -452,7 +456,18 @@ contract ERC721EthscriptionsCollectionManager is IProtocolHandler { function _verifyItemMerkleProof(ItemData memory item, bytes32 merkleRoot) private pure { require(merkleRoot != bytes32(0), "Merkle proof required"); - bytes32 leaf = keccak256(abi.encode(item)); + + // Compute leaf from item data (excluding merkleProof itself) + // Includes contentHash to ensure the ethscription content matches what was specified + bytes32 leaf = keccak256(abi.encode( + item.contentHash, + item.itemIndex, + item.name, + item.backgroundColor, + item.description, + item.attributes + )); + require(MerkleProof.verify(item.merkleProof, merkleRoot, leaf), "Invalid Merkle proof"); } diff --git a/contracts/src/Ethscriptions.sol b/contracts/src/Ethscriptions.sol index 4a1cc52..6444708 100644 --- a/contracts/src/Ethscriptions.sol +++ b/contracts/src/Ethscriptions.sol @@ -115,8 +115,8 @@ contract Ethscriptions is ERC721EthscriptionsSequentialEnumerableUpgradeable { // ============================================================= /// @dev Maximum page sizes for pagination helpers - uint256 private constant MAX_PAGE_WITH_CONTENT = 50; - uint256 private constant MAX_PAGE_WITHOUT_CONTENT = 1000; + uint256 private constant MAX_PAGE_WITH_CONTENT = 20; + uint256 private constant MAX_PAGE_WITHOUT_CONTENT = 50; // ============================================================= // STATE VARIABLES diff --git a/contracts/src/NameRegistry.sol b/contracts/src/NameRegistry.sol new file mode 100644 index 0000000..a34d3b1 --- /dev/null +++ b/contracts/src/NameRegistry.sol @@ -0,0 +1,230 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import "@openzeppelin/contracts/utils/Strings.sol"; +import {Base64} from "solady/utils/Base64.sol"; +import {LibString} from "solady/utils/LibString.sol"; +import "./ERC721EthscriptionsEnumerableUpgradeable.sol"; +import "./interfaces/IProtocolHandler.sol"; +import "./Ethscriptions.sol"; +import "./libraries/Predeploys.sol"; + +/// @title NameRegistry +/// @notice Handles legacy word-domain registrations and mirrors ownership as an ERC-721 collection. +contract NameRegistry is ERC721EthscriptionsEnumerableUpgradeable, IProtocolHandler { + using Strings for uint256; + + Ethscriptions public constant ethscriptions = Ethscriptions(Predeploys.ETHSCRIPTIONS); + + string public constant PROTOCOL_NAME = "word-domains"; + + struct DomainRecord { + bytes32 packedName; + bytes32 ethscriptionId; + address owner; + uint256 tokenId; + } + + struct DomainInfo { + string name; + bytes32 ethscriptionId; + address owner; + uint256 tokenId; + } + + mapping(bytes32 => DomainRecord) private domains; + mapping(uint256 => bytes32) private nameKeyByTokenId; + mapping(address => bytes32) internal primaryNameKey; + + error OnlyEthscriptions(); + error NameAlreadyRegistered(); + error EthscriptionAlreadyLinked(); + error InvalidName(); + error DomainNotFound(); + error NotDomainOwner(); + error TransfersDisabled(); + error TokenDoesNotExist(); + + event DomainRegistered(bytes32 indexed nameKey, string name, address indexed owner, bytes32 indexed ethscriptionId, uint256 tokenId); + event PrimarySet(address indexed owner, bytes32 indexed nameKey); + event PrimaryCleared(address indexed owner); + + modifier onlyEthscriptions() { + if (msg.sender != address(ethscriptions)) revert OnlyEthscriptions(); + _; + } + + function name() public view override returns (string memory) { + return "Name Registry"; + } + + function symbol() public view override returns (string memory) { + return "NAME"; + } + + function protocolName() external pure returns (string memory) { + return PROTOCOL_NAME; + } + + // ============================ + // Protocol handler functions + // ============================ + + function op_register(bytes32 ethscriptionId, bytes calldata data) external onlyEthscriptions { + // Ruby indexer already validated and normalized the name, just trust it + string memory name = abi.decode(data, (string)); + + Ethscriptions.Ethscription memory etsc = ethscriptions.getEthscription(ethscriptionId, false); + + bytes32 nameKey = LibString.packOne(name); + DomainRecord storage record = domains[nameKey]; + if (record.ethscriptionId != bytes32(0)) revert NameAlreadyRegistered(); + + address owner = ethscriptions.ownerOf(ethscriptionId); + uint256 tokenId = etsc.ethscriptionNumber; + + domains[nameKey] = DomainRecord({ + packedName: nameKey, + ethscriptionId: ethscriptionId, + owner: owner, + tokenId: tokenId + }); + nameKeyByTokenId[tokenId] = nameKey; + + if (owner == address(0)) { + _mint(etsc.creator, tokenId); + _transfer(etsc.creator, address(0), tokenId); + } else { + _mint(owner, tokenId); + } + + emit DomainRegistered(nameKey, name, owner, ethscriptionId, tokenId); + } + + function op_set_primary(bytes32 ethscriptionId, bytes calldata data) external onlyEthscriptions { + // Ruby indexer already validated and normalized the name, just trust it + string memory name = abi.decode(data, (string)); + address actor = ethscriptions.ownerOf(ethscriptionId); + + if (bytes(name).length == 0) { + primaryNameKey[actor] = bytes32(0); + emit PrimaryCleared(actor); + return; + } + + bytes32 nameKey = LibString.packOne(name); + DomainRecord storage record = domains[nameKey]; + if (record.ethscriptionId == bytes32(0)) revert DomainNotFound(); + if (record.owner != actor) revert NotDomainOwner(); + + primaryNameKey[actor] = nameKey; + emit PrimarySet(actor, nameKey); + } + + function onTransfer(bytes32 ethscriptionId, address from, address to) external override onlyEthscriptions { + uint256 tokenId = ethscriptions.getTokenId(ethscriptionId); + bytes32 nameKey = nameKeyByTokenId[tokenId]; + DomainRecord storage record = domains[nameKey]; + if (record.ethscriptionId == bytes32(0)) return; + + record.owner = to; + + _transfer(from, to, tokenId); + + if (primaryNameKey[from] == nameKey) { + primaryNameKey[from] = bytes32(0); + emit PrimaryCleared(from); + } + } + + // ============================ + // View helpers + // ============================ + + function getDomainInfo(bytes32 nameKey) external view returns (DomainInfo memory info) { + return _domainInfo(nameKey); + } + + function primaryName(address owner) + external + view + returns (string memory name, bytes32 nameKey, bytes32 ethscriptionId) + { + nameKey = primaryNameKey[owner]; + if (nameKey == bytes32(0)) { + return ("", bytes32(0), bytes32(0)); + } + + DomainRecord storage record = domains[nameKey]; + + // Validate ownership - if they don't own it, return empty + if (record.owner != owner) { + return ("", bytes32(0), bytes32(0)); + } + + return (LibString.unpackOne(record.packedName), nameKey, record.ethscriptionId); + } + + function tokenIdForNameKey(bytes32 nameKey) external view returns (uint256) { + DomainRecord storage record = domains[nameKey]; + if (record.ethscriptionId == bytes32(0)) revert DomainNotFound(); + return record.tokenId; + } + + function nameKeyForToken(uint256 tokenId) external view returns (bytes32) { + bytes32 nameKey = nameKeyByTokenId[tokenId]; + if (domains[nameKey].ethscriptionId == bytes32(0)) revert TokenDoesNotExist(); + return nameKey; + } + + // ============================ + // Metadata + // ============================ + + function tokenURI(uint256 tokenId) public view override returns (string memory) { + if (_ownerOf(tokenId) == address(0)) revert TokenDoesNotExist(); + + bytes32 nameKey = nameKeyByTokenId[tokenId]; + DomainRecord storage record = domains[nameKey]; + if (record.ethscriptionId == bytes32(0)) revert TokenDoesNotExist(); + string memory name = LibString.unpackOne(record.packedName); + + bytes memory json = abi.encodePacked( + '{"name":"', + name, + '","description":"Dotless word domain","attributes":[', + '{"trait_type":"Name","value":"', + name, + '"},', + '{"trait_type":"Ethscription","value":"', + _bytes32ToHex(record.ethscriptionId), + '"}', + ']}' + ); + + return string(abi.encodePacked("data:application/json;base64,", Base64.encode(json))); + } + + function _update(address to, uint256 tokenId, address auth) internal override returns (address) { + if (auth != address(0) && auth != address(this)) { + revert TransfersDisabled(); + } + return super._update(to, tokenId, auth); + } + + function _bytes32ToHex(bytes32 data) internal pure returns (string memory) { + return Strings.toHexString(uint256(data), 32); + } + + function _domainInfo(bytes32 nameKey) internal view returns (DomainInfo memory info) { + DomainRecord storage record = domains[nameKey]; + if (record.ethscriptionId == bytes32(0)) revert DomainNotFound(); + + info = DomainInfo({ + name: LibString.unpackOne(record.packedName), + ethscriptionId: record.ethscriptionId, + owner: record.owner, + tokenId: record.tokenId + }); + } +} diff --git a/contracts/src/libraries/Predeploys.sol b/contracts/src/libraries/Predeploys.sol index 2c8814b..58f1459 100644 --- a/contracts/src/libraries/Predeploys.sol +++ b/contracts/src/libraries/Predeploys.sol @@ -41,6 +41,9 @@ library Predeploys { /// @notice ERC721 Ethscriptions collection manager address constant ERC721_ETHSCRIPTIONS_COLLECTION_MANAGER = 0x3300000000000000000000000000000000000006; + + /// @notice Name registry handler for word-domain protocol + address constant NAME_REGISTRY = 0x3300000000000000000000000000000000000007; // ============ Helper Functions ============ diff --git a/contracts/test/CollectionsManager.t.sol b/contracts/test/CollectionsManager.t.sol index 2593959..86eb34d 100644 --- a/contracts/test/CollectionsManager.t.sol +++ b/contracts/test/CollectionsManager.t.sol @@ -108,8 +108,13 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { value: "Alice" }); + // Define content for the ethscription + bytes memory itemContent = bytes("collection and item content"); + bytes32 itemContentHash = keccak256(itemContent); + ERC721EthscriptionsCollectionManager.ItemData memory itemData = ERC721EthscriptionsCollectionManager.ItemData({ + contentHash: itemContentHash, // keccak256 of the ethscription content itemIndex: 0, name: "Genesis Item #0", backgroundColor: "#445566", @@ -128,9 +133,9 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { Ethscriptions.CreateEthscriptionParams memory ethscriptionParams = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: collectionAndItemId, - contentUriSha: sha256(bytes("collection and item content")), + contentUriSha: sha256(itemContent), initialOwner: alice, - content: bytes("collection and item content"), + content: itemContent, mimetype: "text/plain", esip6: false, protocolParams: Ethscriptions.ProtocolParams({ @@ -174,6 +179,7 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { // 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"}'; + bytes32 itemContentHash = keccak256(bytes(itemContent)); // Create item data with attributes ERC721EthscriptionsCollectionManager.Attribute[] memory attributes = new ERC721EthscriptionsCollectionManager.Attribute[](3); @@ -191,6 +197,7 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { }); ERC721EthscriptionsCollectionManager.ItemData memory itemData = ERC721EthscriptionsCollectionManager.ItemData({ + contentHash: itemContentHash, // keccak256 of the ethscription content itemIndex: 0, name: "Test Item #0", backgroundColor: "#0000FF", @@ -395,8 +402,10 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { }); string memory itemName = i == 0 ? "Item #0" : i == 1 ? "Item #1" : "Item #2"; + bytes32 itemContentHash = keccak256(abi.encodePacked("item", i)); ERC721EthscriptionsCollectionManager.ItemData memory itemData = ERC721EthscriptionsCollectionManager.ItemData({ + contentHash: itemContentHash, itemIndex: uint256(i), name: itemName, backgroundColor: "#000000", @@ -450,6 +459,7 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { vm.prank(alice); string memory imageContent = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="; + bytes32 imageContentHash = keccak256(bytes(imageContent)); // Create item data with attributes ERC721EthscriptionsCollectionManager.Attribute[] memory attributes = new ERC721EthscriptionsCollectionManager.Attribute[](4); @@ -471,6 +481,7 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { }); ERC721EthscriptionsCollectionManager.ItemData memory itemData = ERC721EthscriptionsCollectionManager.ItemData({ + contentHash: imageContentHash, itemIndex: 0, name: "Ittybit #0000", backgroundColor: "#648595", @@ -595,7 +606,10 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { attributes[0] = ERC721EthscriptionsCollectionManager.Attribute({traitType: "Hair Color", value: "Brown"}); attributes[1] = ERC721EthscriptionsCollectionManager.Attribute({traitType: "Hair", value: "Blonde Bob"}); + bytes32 itemContentHash = keccak256(bytes("item content")); + ERC721EthscriptionsCollectionManager.ItemData memory itemData = ERC721EthscriptionsCollectionManager.ItemData({ + contentHash: itemContentHash, itemIndex: 0, name: "Test Item #0", backgroundColor: "#FF5733", diff --git a/contracts/test/EthscriptionsToken.t.sol b/contracts/test/EthscriptionsToken.t.sol index 23c230f..819e717 100644 --- a/contracts/test/EthscriptionsToken.t.sol +++ b/contracts/test/EthscriptionsToken.t.sol @@ -163,7 +163,7 @@ contract EthscriptionsTokenTest is TestSetup { function testMultipleMints() public { // Deploy the token testTokenDeploy(); - + address tokenAddress = fixedDenominationManager.getTokenAddressByTick("TEST"); ERC20FixedDenomination token = ERC20FixedDenomination(tokenAddress); @@ -209,6 +209,32 @@ contract EthscriptionsTokenTest is TestSetup { ERC20FixedDenominationManager.TokenInfo memory info = fixedDenominationManager.getTokenInfo(DEPLOY_TX_HASH); assertEq(info.totalMinted, 2000); } + + function testMintIdCannotBeReused() public { + // Deploy and perform initial mint with ID 1 + testTokenMint(); + + // Attempt to mint the same ID again + vm.prank(charlie); + string memory mintContent = 'data:,{"p":"erc-20","op":"mint","tick":"TEST","id":"1","amt":"1000"}'; + ERC20FixedDenominationManager.MintOperation memory mintOp = ERC20FixedDenominationManager.MintOperation({ + tick: "TEST", + id: 1, + amount: 1000 + }); + + Ethscriptions.CreateEthscriptionParams memory params = createTokenParams( + bytes32(uint256(0xDEADFEED)), + charlie, + mintContent, + CANONICAL_PROTOCOL, + "mint", + abi.encode(mintOp) + ); + + vm.expectRevert(Ethscriptions.DuplicateContentUri.selector); + ethscriptions.createEthscription(params); + } function testMaxSupplyEnforcement() public { // Deploy a token with very low max supply @@ -579,4 +605,233 @@ contract EthscriptionsTokenTest is TestSetup { assertEq(token.balanceOf(address(0)), 1000 ether); assertEq(token.totalSupply(), 1000 ether); } + + // ============================================================= + // COLLECTION TESTS + // ============================================================= + + function testCollectionDeployedOnTokenDeploy() public { + // Deploy a token as Alice + vm.prank(alice); + + string memory deployContent = 'data:,{"p":"erc-20","op":"deploy","tick":"COLL","max":"10000","lim":"100"}'; + + ERC20FixedDenominationManager.DeployOperation memory deployOp = ERC20FixedDenominationManager.DeployOperation({ + tick: "COLL", + maxSupply: 10000, + mintAmount: 100 + }); + + Ethscriptions.CreateEthscriptionParams memory params = createTokenParams( + DEPLOY_TX_HASH, + alice, + deployContent, + CANONICAL_PROTOCOL, + "deploy", + abi.encode(deployOp) + ); + + ethscriptions.createEthscription(params); + + // Verify collection was deployed + address collectionAddr = fixedDenominationManager.getCollectionAddress(DEPLOY_TX_HASH); + assertTrue(collectionAddr != address(0), "Collection should be deployed"); + + // Verify collection properties + ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddr); + assertEq(collection.name(), "COLL ERC-721"); + assertEq(collection.symbol(), "COLL-ERC-721"); + assertEq(collection.collectionId(), DEPLOY_TX_HASH); + + // Verify collection lookups work + assertEq(fixedDenominationManager.collectionIdForAddress(collectionAddr), DEPLOY_TX_HASH); + assertEq(fixedDenominationManager.collectionAddressForId(DEPLOY_TX_HASH), collectionAddr); + } + + function testCollectionTokenMintedOnNoteMint() public { + // First deploy + testCollectionDeployedOnTokenDeploy(); + + // Get collection address + address collectionAddr = fixedDenominationManager.getCollectionAddress(DEPLOY_TX_HASH); + ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddr); + + // Mint a note as Bob + string memory mintContent = 'data:,{"p":"erc-20","op":"mint","tick":"COLL","id":"1","amt":"100"}'; + + ERC20FixedDenominationManager.MintOperation memory mintOp = ERC20FixedDenominationManager.MintOperation({ + tick: "COLL", + id: 1, + amount: 100 + }); + + Ethscriptions.CreateEthscriptionParams memory mintParams = createTokenParams( + MINT_TX_HASH_1, + bob, + mintContent, + CANONICAL_PROTOCOL, + "mint", + abi.encode(mintOp) + ); + + vm.prank(bob); + ethscriptions.createEthscription(mintParams); + + // Verify collection NFT was minted with tokenId = mintId + assertEq(collection.ownerOf(1), bob, "Bob should own collection token #1"); + assertEq(collection.totalSupply(), 1, "Collection should have 1 NFT"); + + // Verify ERC-20 tokens were also minted + address tokenAddr = fixedDenominationManager.getTokenAddressByTick("COLL"); + ERC20FixedDenomination token = ERC20FixedDenomination(tokenAddr); + assertEq(token.balanceOf(bob), 100 ether, "Bob should have 100 tokens"); + } + + function testCollectionTokenTransferredOnNoteTransfer() public { + // Setup: Deploy and mint + testCollectionTokenMintedOnNoteMint(); + + // Get collection + address collectionAddr = fixedDenominationManager.getCollectionAddress(DEPLOY_TX_HASH); + ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddr); + + // Get ERC-20 token + address tokenAddr = fixedDenominationManager.getTokenAddressByTick("COLL"); + ERC20FixedDenomination token = ERC20FixedDenomination(tokenAddr); + + // Initial state + assertEq(collection.ownerOf(1), bob); + assertEq(token.balanceOf(bob), 100 ether); + assertEq(token.balanceOf(charlie), 0); + + // Transfer the mint inscription from Bob to Charlie + vm.prank(bob); + ethscriptions.transferEthscription(charlie, MINT_TX_HASH_1); + + // Verify both collection NFT and ERC-20 transferred + assertEq(collection.ownerOf(1), charlie, "Charlie should now own collection token #1"); + assertEq(token.balanceOf(bob), 0, "Bob should have 0 tokens"); + assertEq(token.balanceOf(charlie), 100 ether, "Charlie should have 100 tokens"); + } + + function testCollectionTokenURI() public { + // Setup: Deploy and mint + testCollectionTokenMintedOnNoteMint(); + + // Get collection + address collectionAddr = fixedDenominationManager.getCollectionAddress(DEPLOY_TX_HASH); + ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddr); + + // Get token URI + string memory uri = collection.tokenURI(1); + + // Verify it starts with data URI prefix + bytes memory uriBytes = bytes(uri); + bytes memory expectedPrefix = bytes("data:application/json;base64,"); + for (uint i = 0; i < expectedPrefix.length; i++) { + assertEq(uriBytes[i], expectedPrefix[i], "URI should start with JSON data prefix"); + } + + // Could decode and verify JSON contents but that would require base64 decoding + // Just verify it doesn't revert and returns something + assertTrue(bytes(uri).length > 30, "URI should have content"); + } + + function testCollectionMetadataView() public { + // Setup: Deploy + testCollectionDeployedOnTokenDeploy(); + + // Get collection address + address collectionAddr = fixedDenominationManager.getCollectionAddress(DEPLOY_TX_HASH); + + // Get collection metadata via manager + ERC721EthscriptionsCollectionManager.CollectionMetadata memory metadata = + fixedDenominationManager.getCollectionByAddress(collectionAddr); + + // Verify metadata + assertEq(metadata.name, "COLL ERC-721"); + assertEq(metadata.symbol, "COLL-ERC-721"); + assertEq(metadata.maxSupply, 100); // 10000 maxSupply / 100 mintAmount = 100 notes + assertEq(metadata.description, "Fixed denomination notes for COLL"); + assertEq(metadata.collectionContract, collectionAddr); + assertFalse(metadata.locked); + } + + function testCollectionItemView() public { + // Setup: Deploy and mint + testCollectionTokenMintedOnNoteMint(); + + // Get collection item via manager + ERC721EthscriptionsCollectionManager.CollectionItem memory item = + fixedDenominationManager.getCollectionItem(DEPLOY_TX_HASH, 1); + + // Verify item metadata + assertEq(item.ethscriptionId, MINT_TX_HASH_1); + assertEq(item.name, "COLL #1"); + assertEq(item.description, "100 COLL note"); + assertEq(item.itemIndex, 1); + + // Verify attributes + assertEq(item.attributes.length, 2); + assertEq(item.attributes[0].traitType, "Denomination"); + assertEq(item.attributes[0].value, "100"); + assertEq(item.attributes[1].traitType, "Token"); + assertEq(item.attributes[1].value, "COLL"); + } + + function testCollectionMultipleMints() public { + // Deploy + testCollectionDeployedOnTokenDeploy(); + + // Get collection + address collectionAddr = fixedDenominationManager.getCollectionAddress(DEPLOY_TX_HASH); + ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddr); + + // Mint note #1 as Bob + string memory mintContent1 = 'data:,{"p":"erc-20","op":"mint","tick":"COLL","id":"1","amt":"100"}'; + ERC20FixedDenominationManager.MintOperation memory mintOp1 = ERC20FixedDenominationManager.MintOperation({ + tick: "COLL", + id: 1, + amount: 100 + }); + Ethscriptions.CreateEthscriptionParams memory mintParams1 = createTokenParams( + MINT_TX_HASH_1, + bob, + mintContent1, + CANONICAL_PROTOCOL, + "mint", + abi.encode(mintOp1) + ); + vm.prank(bob); + ethscriptions.createEthscription(mintParams1); + + // Mint note #2 as Charlie + string memory mintContent2 = 'data:,{"p":"erc-20","op":"mint","tick":"COLL","id":"2","amt":"100"}'; + ERC20FixedDenominationManager.MintOperation memory mintOp2 = ERC20FixedDenominationManager.MintOperation({ + tick: "COLL", + id: 2, + amount: 100 + }); + Ethscriptions.CreateEthscriptionParams memory mintParams2 = createTokenParams( + MINT_TX_HASH_2, + charlie, + mintContent2, + CANONICAL_PROTOCOL, + "mint", + abi.encode(mintOp2) + ); + vm.prank(charlie); + ethscriptions.createEthscription(mintParams2); + + // Verify both collection NFTs exist with correct owners + assertEq(collection.ownerOf(1), bob, "Bob should own collection token #1"); + assertEq(collection.ownerOf(2), charlie, "Charlie should own collection token #2"); + assertEq(collection.totalSupply(), 2, "Collection should have 2 NFTs"); + + // Verify both have correct ERC-20 balances + address tokenAddr = fixedDenominationManager.getTokenAddressByTick("COLL"); + ERC20FixedDenomination token = ERC20FixedDenomination(tokenAddr); + assertEq(token.balanceOf(bob), 100 ether); + assertEq(token.balanceOf(charlie), 100 ether); + } } diff --git a/contracts/test/NameRegistry.t.sol b/contracts/test/NameRegistry.t.sol new file mode 100644 index 0000000..55086a0 --- /dev/null +++ b/contracts/test/NameRegistry.t.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import "forge-std/Test.sol"; +import "./TestSetup.sol"; +import "../src/NameRegistry.sol"; + +contract NameRegistryTest is TestSetup { + NameRegistry internal registry; + address internal alice = address(0xA11CE); + address internal bob = address(0xB0B); + + function setUp() public override { + super.setUp(); + registry = NameRegistry(Predeploys.NAME_REGISTRY); + vm.deal(alice, 10 ether); + vm.deal(bob, 10 ether); + } + + function testRegisterWordCreatesToken() public { + bytes32 txHash = keccak256("alpha"); + Ethscriptions.CreateEthscriptionParams memory params = createTestParams(txHash, alice, "data:,alpha", false); + params.protocolParams = Ethscriptions.ProtocolParams({ + protocolName: "word-domains", + operation: "register", + data: abi.encode("alpha") + }); + + vm.prank(alice); + ethscriptions.createEthscription(params); + + uint256 expectedTokenId = ethscriptions.getTokenId(txHash); + bytes32 nameKey = registry.nameKeyForToken(expectedTokenId); + NameRegistry.DomainInfo memory info = registry.getDomainInfo(nameKey); + + assertEq(info.name, "alpha"); + assertEq(info.owner, alice); + assertEq(info.tokenId, expectedTokenId); + assertEq(registry.ownerOf(info.tokenId), alice); + assertEq(info.ethscriptionId, txHash); + assertEq(registry.tokenIdForNameKey(nameKey), expectedTokenId); + assertEq(registry.nameKeyForToken(expectedTokenId), nameKey); + } + + function testPrimarySetAndClear() public { + bytes32 txHash = keccak256("beta"); + Ethscriptions.CreateEthscriptionParams memory registerParams = createTestParams(txHash, alice, "data:,beta", false); + registerParams.protocolParams = Ethscriptions.ProtocolParams({ + protocolName: "word-domains", + operation: "register", + data: abi.encode("beta") + }); + + vm.prank(alice); + ethscriptions.createEthscription(registerParams); + + // Set primary via second inscription + bytes32 setPrimaryTx = keccak256("set-primary"); + Ethscriptions.CreateEthscriptionParams memory primaryParams = createTestParams( + setPrimaryTx, + alice, + 'data:,{"p":"word-domains","op":"set_primary","name":"beta"}', + false + ); + primaryParams.protocolParams = Ethscriptions.ProtocolParams({ + protocolName: "word-domains", + operation: "set_primary", + data: abi.encode("beta") + }); + + vm.prank(alice); + ethscriptions.createEthscription(primaryParams); + + (,, bytes32 primaryEthscription) = registry.primaryName(alice); + assertEq(primaryEthscription, txHash); + + // Clear primary + bytes32 clearTx = keccak256("clear-primary"); + Ethscriptions.CreateEthscriptionParams memory clearParams = createTestParams( + clearTx, + alice, + 'data:,{"p":"word-domains","op":"set_primary","name":""}', + false + ); + clearParams.protocolParams = Ethscriptions.ProtocolParams({ + protocolName: "word-domains", + operation: "set_primary", + data: abi.encode("") + }); + + vm.prank(alice); + ethscriptions.createEthscription(clearParams); + + (,, primaryEthscription) = registry.primaryName(alice); + assertEq(primaryEthscription, bytes32(0)); + } + + function testTransfersMirrorCollection() public { + bytes32 txHash = keccak256("gamma"); + Ethscriptions.CreateEthscriptionParams memory params = createTestParams(txHash, alice, "data:,gamma", false); + params.protocolParams = Ethscriptions.ProtocolParams({ + protocolName: "word-domains", + operation: "register", + data: abi.encode("gamma") + }); + + vm.prank(alice); + ethscriptions.createEthscription(params); + + uint256 tokenId = ethscriptions.getTokenId(txHash); + + vm.prank(alice); + ethscriptions.transferEthscription(bob, txHash); + + assertEq(registry.ownerOf(tokenId), bob); + + vm.prank(bob); + vm.expectRevert(NameRegistry.TransfersDisabled.selector); + registry.transferFrom(bob, alice, tokenId); + } +} diff --git a/contracts/test/TokenURIGas.t.sol b/contracts/test/TokenURIGas.t.sol new file mode 100644 index 0000000..f98fb3a --- /dev/null +++ b/contracts/test/TokenURIGas.t.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import "./TestSetup.sol"; +import "forge-std/console.sol"; + +contract TokenURIGasTest is TestSetup { + address alice = address(0x1); + + function setUp() public override { + super.setUp(); + } + + function testGas_TokenURI_Scaling() public { + console.log("=== TokenURI Gas Cost vs Content Size ==="); + console.log(""); + + // Test 1KB + // measureGasForSize(1_000, "1KB"); + + // Test 10KB + // measureGasForSize(10_000, "10KB"); + + // Test 100KB only to see detailed gas breakdown + measureGasForSize(100_000, "100KB"); + } + + function measureGasForSize(uint256 contentSize, string memory label) internal { + // Create content of specified size + bytes memory content = new bytes(contentSize); + for (uint256 i = 0; i < contentSize; i++) { + content[i] = bytes1(uint8((i * 7) % 256)); // Pseudo-random pattern + } + + bytes32 txHash = keccak256(bytes(label)); + + // Create the ethscription + vm.prank(alice); + ethscriptions.createEthscription(Ethscriptions.CreateEthscriptionParams({ + ethscriptionId: txHash, + contentUriSha: keccak256(bytes(string.concat("data:image/png;base64,", label))), + initialOwner: alice, + content: content, + mimetype: "image/png", + esip6: false, + protocolParams: Ethscriptions.ProtocolParams({ + protocolName: "", + operation: "", + data: "" + }) + })); + + uint256 tokenId = ethscriptions.getTokenId(txHash); + + // Measure gas for tokenURI call + uint256 gasStart = gasleft(); + string memory uri = ethscriptions.tokenURI(tokenId); + uint256 gasUsed = gasStart - gasleft(); + + console.log(string.concat(label, ":")); + console.log(" Gas used:", gasUsed); + console.log(" Gas per byte:", gasUsed / contentSize); + console.log(" URI length:", bytes(uri).length); + console.log(""); + } +} \ No newline at end of file diff --git a/lib/protocol_event_reader.rb b/lib/protocol_event_reader.rb index f243f47..bd77997 100644 --- a/lib/protocol_event_reader.rb +++ b/lib/protocol_event_reader.rb @@ -10,8 +10,9 @@ class ProtocolEventReader # ERC20FixedDenominationManager.sol events 'ERC20FixedDenominationTokenDeployed' => 'ERC20FixedDenominationTokenDeployed(bytes32,address,string,uint256,uint256)', - 'ERC20FixedDenominationTokenMinted' => 'ERC20FixedDenominationTokenMinted(bytes32,address,uint256,bytes32)', - 'ERC20FixedDenominationTokenTransferred' => 'ERC20FixedDenominationTokenTransferred(bytes32,address,address,uint256,bytes32)', + 'ERC20FixedDenominationTokenMinted' => 'ERC20FixedDenominationTokenMinted(bytes32,address,uint256,uint256,bytes32)', + 'ERC20FixedDenominationTokenTransferred' => 'ERC20FixedDenominationTokenTransferred(bytes32,address,address,uint256,uint256,bytes32)', + 'ERC721CollectionDeployed' => 'ERC721CollectionDeployed(bytes32,address,string)', # CollectionsManager.sol events # CollectionsManager.sol events (match actual signatures) @@ -66,6 +67,8 @@ def self.parse_log(log) parse_erc20_fixed_denomination_token_minted(log) when 'ERC20FixedDenominationTokenTransferred', 'FixedFungibleTokenTransferred' parse_erc20_fixed_denomination_token_transferred(log) + when 'ERC721CollectionDeployed' + parse_erc721_collection_deployed(log) when 'CollectionCreated' parse_collection_created(log) when 'ItemsAdded' @@ -161,7 +164,7 @@ def self.parse_protocol_handler_failed(log) reason_bytes = decoded[1] reason = if reason_bytes && reason_bytes.length > 0 # Try to decode as Error(string) - if reason_bytes.start_with?("\x08\xC3y\xA0") # Error(string) selector + if reason_bytes.start_with?("\x08\xC3y\xA0".b) # Error(string) selector # Skip the selector (4 bytes) and decode the string begin Eth::Abi.decode(['string'], reason_bytes[4..-1])[0] @@ -223,19 +226,20 @@ def self.parse_erc20_fixed_denomination_token_deployed(log) end def self.parse_erc20_fixed_denomination_token_minted(log) - # ERC20FixedDenominationTokenMinted(bytes32 indexed deployTxHash, address indexed to, uint256 amount, bytes32 ethscriptionTxHash) + # ERC20FixedDenominationTokenMinted(bytes32 indexed deployEthscriptionId, address indexed to, uint256 amount, uint256 mintId, bytes32 ethscriptionId) deploy_tx_hash = log['topics'][1] to_address = '0x' + log['topics'][2][-40..] if log['topics'][2] # Last 20 bytes data = [log['data'].delete_prefix('0x')].pack('H*') - decoded = Eth::Abi.decode(['uint256', 'bytes32'], data) + decoded = Eth::Abi.decode(['uint256', 'uint256', 'bytes32'], data) { event: 'ERC20FixedDenominationTokenMinted', deploy_tx_hash: deploy_tx_hash, to: to_address, amount: decoded[0], - ethscription_tx_hash: '0x' + decoded[1].unpack1('H*') + mint_id: decoded[1], + ethscription_tx_hash: '0x' + decoded[2].unpack1('H*') } rescue => e Rails.logger.error "Failed to parse ERC20FixedDenominationTokenMinted: #{e.message}" @@ -243,13 +247,13 @@ def self.parse_erc20_fixed_denomination_token_minted(log) end def self.parse_erc20_fixed_denomination_token_transferred(log) - # ERC20FixedDenominationTokenTransferred(bytes32 indexed deployTxHash, address indexed from, address indexed to, uint256 amount, bytes32 ethscriptionTxHash) + # ERC20FixedDenominationTokenTransferred(bytes32 indexed deployEthscriptionId, address indexed from, address indexed to, uint256 amount, uint256 mintId, bytes32 ethscriptionId) deploy_tx_hash = log['topics'][1] from_address = '0x' + log['topics'][2][-40..] if log['topics'][2] # Last 20 bytes to_address = '0x' + log['topics'][3][-40..] if log['topics'][3] data = [log['data'].delete_prefix('0x')].pack('H*') - decoded = Eth::Abi.decode(['uint256', 'bytes32'], data) + decoded = Eth::Abi.decode(['uint256', 'uint256', 'bytes32'], data) { event: 'ERC20FixedDenominationTokenTransferred', @@ -257,13 +261,33 @@ def self.parse_erc20_fixed_denomination_token_transferred(log) from: from_address, to: to_address, amount: decoded[0], - ethscription_tx_hash: '0x' + decoded[1].unpack1('H*') + mint_id: decoded[1], + ethscription_tx_hash: '0x' + decoded[2].unpack1('H*') } rescue => e Rails.logger.error "Failed to parse ERC20FixedDenominationTokenTransferred: #{e.message}" nil end + def self.parse_erc721_collection_deployed(log) + # ERC721CollectionDeployed(bytes32 indexed deployEthscriptionId, address indexed collectionAddress, string tick) + deploy_ethscription_id = log['topics'][1] + collection_address = '0x' + log['topics'][2][-40..] if log['topics'][2] # Last 20 bytes + + data = [log['data'].delete_prefix('0x')].pack('H*') + decoded = Eth::Abi.decode(['string'], data) + + { + event: 'ERC721CollectionDeployed', + deploy_ethscription_id: deploy_ethscription_id, + collection_address: collection_address, + tick: decoded[0] + } + rescue => e + Rails.logger.error "Failed to parse ERC721CollectionDeployed: #{e.message}" + nil + end + def self.parse_collection_created(log) # CollectionCreated(bytes32 indexed collectionId, address indexed collectionContract, string name, string symbol, uint256 maxSize) collection_id = log['topics'][1] diff --git a/spec/integration/tokens_protocol_spec.rb b/spec/integration/tokens_protocol_spec.rb index d020d7d..97ab541 100644 --- a/spec/integration/tokens_protocol_spec.rb +++ b/spec/integration/tokens_protocol_spec.rb @@ -308,8 +308,8 @@ # The token regex requires exact format with no extra spaces expect(stored[:content]).to include('erc-20') - token_params = Erc20FixedDenominationParser.extract(content_uri) - expect(token_params).to eq(Erc20FixedDenominationParser::DEFAULT_PARAMS) + token_params = ProtocolParser.for_calldata(content_uri) + expect(token_params).to eq([''.b, ''.b, ''.b]) end end diff --git a/spec/models/erc20_fixed_denomination_parser_spec.rb b/spec/models/erc20_fixed_denomination_parser_spec.rb index 2c2960c..f81aa44 100644 --- a/spec/models/erc20_fixed_denomination_parser_spec.rb +++ b/spec/models/erc20_fixed_denomination_parser_spec.rb @@ -1,300 +1,335 @@ require 'rails_helper' RSpec.describe Erc20FixedDenominationParser do - let(:default_params) { [''.b, ''.b, ''.b, 0, 0, 0] } + let(:default_params) { [''.b, ''.b, ''.b] } let(:uint256_max) { 2**256 - 1 } - describe '.extract' do + describe 'via ProtocolParser' do context 'valid operations' do it 'extracts deploy operation params with all required fields' do content_uri = 'data:,{"p":"erc-20","op":"deploy","tick":"punk","max":"21000000","lim":"1000"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(['deploy'.b, 'erc-20-fixed-denomination'.b, 'punk'.b, 21000000, 1000, 0]) + result = ProtocolParser.for_calldata(content_uri) + + expect(result[0]).to eq('erc-20-fixed-denomination'.b) + expect(result[1]).to eq('deploy'.b) + decoded = Eth::Abi.decode(['(string,uint256,uint256)'], result[2])[0] + expect(decoded).to eq(['punk', 21000000, 1000]) end it 'extracts mint operation params with all required fields' do content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":"1","amt":"100"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(['mint'.b, 'erc-20-fixed-denomination'.b, 'punk'.b, 1, 0, 100]) + result = ProtocolParser.for_calldata(content_uri) + + expect(result[0]).to eq('erc-20-fixed-denomination'.b) + expect(result[1]).to eq('mint'.b) + decoded = Eth::Abi.decode(['(string,uint256,uint256)'], result[2])[0] + expect(decoded).to eq(['punk', 1, 100]) end it 'handles zero values correctly' do content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":"0","amt":"0"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(['mint'.b, 'erc-20-fixed-denomination'.b, 'punk'.b, 0, 0, 0]) + result = ProtocolParser.for_calldata(content_uri) + + expect(result[0]).to eq('erc-20-fixed-denomination'.b) + expect(result[1]).to eq('mint'.b) + decoded = Eth::Abi.decode(['(string,uint256,uint256)'], result[2])[0] + expect(decoded).to eq(['punk', 0, 0]) end it 'handles single character tick' do content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"a","id":"1","amt":"100"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(['mint'.b, 'erc-20-fixed-denomination'.b, 'a'.b, 1, 0, 100]) + result = ProtocolParser.for_calldata(content_uri) + + expect(result[0]).to eq('erc-20-fixed-denomination'.b) + expect(result[1]).to eq('mint'.b) + decoded = Eth::Abi.decode(['(string,uint256,uint256)'], result[2])[0] + expect(decoded).to eq(['a', 1, 100]) end it 'handles max length tick (28 chars)' do content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"abcdefghijklmnopqrstuvwxyz12","id":"1","amt":"100"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(['mint'.b, 'erc-20-fixed-denomination'.b, 'abcdefghijklmnopqrstuvwxyz12'.b, 1, 0, 100]) + result = ProtocolParser.for_calldata(content_uri) + + expect(result[0]).to eq('erc-20-fixed-denomination'.b) + expect(result[1]).to eq('mint'.b) + decoded = Eth::Abi.decode(['(string,uint256,uint256)'], result[2])[0] + expect(decoded).to eq(['abcdefghijklmnopqrstuvwxyz12', 1, 100]) end it 'handles exactly max uint256 value' do content_uri = "data:,{\"p\":\"erc-20\",\"op\":\"mint\",\"tick\":\"punk\",\"id\":\"1\",\"amt\":\"#{uint256_max}\"}" - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(['mint'.b, 'erc-20-fixed-denomination'.b, 'punk'.b, 1, 0, uint256_max]) + result = ProtocolParser.for_calldata(content_uri) + + expect(result[0]).to eq('erc-20-fixed-denomination'.b) + expect(result[1]).to eq('mint'.b) + decoded = Eth::Abi.decode(['(string,uint256,uint256)'], result[2])[0] + expect(decoded).to eq(['punk', 1, uint256_max]) end it 'handles deploy with zero lim' do content_uri = 'data:,{"p":"erc-20","op":"deploy","tick":"test","max":"1000","lim":"0"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(['deploy'.b, 'erc-20-fixed-denomination'.b, 'test'.b, 1000, 0, 0]) + result = ProtocolParser.for_calldata(content_uri) + + expect(result[0]).to eq('erc-20-fixed-denomination'.b) + expect(result[1]).to eq('deploy'.b) + decoded = Eth::Abi.decode(['(string,uint256,uint256)'], result[2])[0] + expect(decoded).to eq(['test', 1000, 0]) end end context 'strict format requirements' do it 'rejects mint with integer values instead of strings' do content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":1,"amt":100}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'rejects mint with optional fields omitted (id missing)' do content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","amt":"100"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'rejects mint with optional fields omitted (amt missing)' do content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":"1"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'rejects mint with only required protocol fields' do - content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + content_uri = 'data:,{"p":"erc-20","op":"mint"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'rejects extra spaces in JSON' do - content_uri = 'data:,{"p": "erc-20","op": "mint","tick": "punk","id": "1","amt": "100"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + content_uri = 'data:, {"p":"erc-20","op":"mint","tick":"punk","id":"1","amt":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'rejects wrong key order' do content_uri = 'data:,{"op":"mint","p":"erc-20","tick":"punk","id":"1","amt":"100"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'rejects extra fields' do - content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":"1","amt":"100","extra":"bad"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":"1","amt":"100","extra":"field"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end end context 'security and data smuggling prevention' do it 'rejects array in id field (security issue)' do - content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":[831,832],"amt":"1"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":["1"],"amt":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'rejects object in amt field' do - content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":"1","amt":{"value":100}}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":"1","amt":{"value":"100"}}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'rejects SQL injection in tick' do - content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk\nDROP TABLE"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk\'; DROP TABLE users;--","id":"1","amt":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'rejects nested JSON objects' do - content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":"1","amt":"100","meta":{"foo":"bar"}}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":"1","amt":"100","nested":{"key":"value"}}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end end context 'data type validation' do it 'rejects boolean in max field' do - content_uri = 'data:,{"p":"erc-20","op":"deploy","tick":"punk","max":true,"lim":"100"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + content_uri = 'data:,{"p":"erc-20","op":"deploy","tick":"test","max":true,"lim":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'rejects null values in deploy' do - content_uri = 'data:,{"p":"erc-20","op":"deploy","tick":"punk","max":null,"lim":"100"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + content_uri = 'data:,{"p":"erc-20","op":"deploy","tick":"test","max":null,"lim":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'rejects array as op' do - content_uri = 'data:,{"p":"erc-20","op":["deploy"],"tick":"punk","max":"1000","lim":"100"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + content_uri = 'data:,{"p":"erc-20","op":["mint"],"tick":"punk","id":"1","amt":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'rejects non-JSON object (array)' do - content_uri = 'data:,["not","an","object"]' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + content_uri = 'data:,[{"p":"erc-20","op":"mint","tick":"punk","id":"1","amt":"100"}]' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end end context 'number format validation' do it 'rejects non-numeric string' do content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":"abc","amt":"100"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'rejects negative number' do content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":"-1","amt":"100"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'rejects hex number' do - content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":"0x10","amt":"100"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":"0x1","amt":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'rejects float number' do content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":"1.5","amt":"100"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'rejects number with whitespace' do content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":" 1 ","amt":"100"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'rejects leading zeros (except standalone zero)' do content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"punk","id":"01","amt":"100"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'rejects number too large for uint256' do content_uri = "data:,{\"p\":\"erc-20\",\"op\":\"mint\",\"tick\":\"punk\",\"id\":\"1\",\"amt\":\"#{uint256_max + 1}\"}" - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end end context 'tick validation' do it 'rejects tick with uppercase' do content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"PUNK","id":"1","amt":"100"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'rejects tick with special characters (hyphen)' do content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"pu-nk","id":"1","amt":"100"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'rejects tick too long (29 chars)' do content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"abcdefghijklmnopqrstuvwxyz123","id":"1","amt":"100"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'rejects tick with emoji' do - content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"๐Ÿš€moon","id":"1","amt":"100"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"๐Ÿš€","id":"1","amt":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'rejects empty string tick' do content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"","id":"1","amt":"100"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end end context 'protocol and operation validation' do it 'rejects wrong protocol (erc-721)' do content_uri = 'data:,{"p":"erc-721","op":"mint","tick":"punk","id":"1","amt":"100"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'rejects protocol with underscore' do content_uri = 'data:,{"p":"erc_20","op":"mint","tick":"punk","id":"1","amt":"100"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'rejects protocol with uppercase' do content_uri = 'data:,{"p":"ERC-20","op":"mint","tick":"punk","id":"1","amt":"100"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'rejects unknown operations' do - content_uri = 'data:,{"p":"erc-20","op":"transfer","tick":"punk","id":"1","amt":"100"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + content_uri = 'data:,{"p":"erc-20","op":"burn","tick":"punk","id":"1","amt":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'handles unknown operations with protocol/tick' do - content_uri = 'data:,{"p":"new-proto","op":"custom","tick":"test"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + content_uri = 'data:,{"p":"erc-20","op":"unknown","tick":"punk"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end end context 'required fields validation' do it 'rejects missing op field' do - content_uri = 'data:,{"p":"erc-20","tick":"punk","max":"1000","lim":"100"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + content_uri = 'data:,{"p":"erc-20","tick":"punk","id":"1","amt":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'rejects missing protocol field' do content_uri = 'data:,{"op":"mint","tick":"punk","id":"1","amt":"100"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'rejects missing tick field' do content_uri = 'data:,{"p":"erc-20","op":"mint","id":"1","amt":"100"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'rejects deploy missing max' do - content_uri = 'data:,{"p":"erc-20","op":"deploy","tick":"punk","lim":"100"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + content_uri = 'data:,{"p":"erc-20","op":"deploy","tick":"test","lim":"100"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'rejects deploy missing lim' do - content_uri = 'data:,{"p":"erc-20","op":"deploy","tick":"punk","max":"1000"}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + content_uri = 'data:,{"p":"erc-20","op":"deploy","tick":"test","max":"1000"}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end end context 'JSON format validation' do it 'rejects invalid JSON' do - content_uri = 'data:,{broken json' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + content_uri = 'data:,{invalid json}' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'rejects JSON with incorrect format' do - content_uri = 'data:,{"invalid":true}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + content_uri = 'data:,p=erc-20&op=mint&tick=punk&id=1&amt=100' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end end context 'edge cases' do it 'returns default params for empty data URI' do - content_uri = 'data:,{}' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + content_uri = 'data:,' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'returns default params for non-data URI' do - content_uri = 'https://example.com' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + content_uri = 'http://example.com' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'returns default params for nil input' do - expect(Erc20FixedDenominationParser.extract(nil)).to eq(default_params) + expect(ProtocolParser.for_calldata(nil)).to eq(default_params) end it 'returns default params for non-token content' do - content_uri = 'data:,Hello World!' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + content_uri = 'data:,Hello World' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'returns default params for plain text data' do - content_uri = 'data:text/plain,Hello' - expect(Erc20FixedDenominationParser.extract(content_uri)).to eq(default_params) + content_uri = 'data:text/plain,Hello World' + expect(ProtocolParser.for_calldata(content_uri)).to eq(default_params) end it 'returns default params for empty string' do - expect(Erc20FixedDenominationParser.extract('')).to eq(default_params) + expect(ProtocolParser.for_calldata('')).to eq(default_params) end end context 'non-string input types' do it 'returns default params for integer input' do - expect(Erc20FixedDenominationParser.extract(123)).to eq(default_params) + expect(ProtocolParser.for_calldata(123)).to eq(default_params) end it 'returns default params for array input' do - expect(Erc20FixedDenominationParser.extract([])).to eq(default_params) + expect(ProtocolParser.for_calldata([])).to eq(default_params) end it 'returns default params for hash input' do - expect(Erc20FixedDenominationParser.extract({})).to eq(default_params) + expect(ProtocolParser.for_calldata({})).to eq(default_params) end end end -end +end \ No newline at end of file diff --git a/spec/models/erc721_collections_import_fallback_spec.rb b/spec/models/erc721_collections_import_fallback_spec.rb index b1ef800..a88b55b 100644 --- a/spec/models/erc721_collections_import_fallback_spec.rb +++ b/spec/models/erc721_collections_import_fallback_spec.rb @@ -59,7 +59,7 @@ end it 'builds create_collection_and_add_self for the leader via import fallback' do - protocol, operation, encoded = described_class.extract( + protocol, operation, encoded = ProtocolParser.for_calldata( 'data:,{}', ethscription_id: ByteString.from_hex(leader_id) ) @@ -67,7 +67,7 @@ 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[]))' + '((string,string,uint256,string,string,string,string,string,string,string,bytes32),(bytes32,uint256,string,string,string,(string,string)[],bytes32[]))' ], encoded)[0] metadata = decoded[0] @@ -77,13 +77,14 @@ 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 + # Item now has contentHash as first field + expect(item[1]).to eq(0) # item index (now at position 1) + expect(item[2]).to eq('Item Zero') # name (now at position 2) + expect(item[3]).to eq('') # background_color (now at position 3) end it 'builds add_self_to_collection for a member via import fallback' do - protocol, operation, encoded = described_class.extract( + protocol, operation, encoded = ProtocolParser.for_calldata( 'data:,{}', ethscription_id: ByteString.from_hex(member_id) ) @@ -92,16 +93,17 @@ expect(operation).to eq('add_self_to_collection'.b) decoded = Eth::Abi.decode([ - '(bytes32,(uint256,string,string,string,(string,string)[],bytes32[]))' + '(bytes32,(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 + # Item now has contentHash as first field + expect(item[1]).to eq(1) # item index (now at position 1) + expect(item[2]).to eq('Item One') # name (now at position 2) + expect(item[3]).to eq('') # background_color (now at position 3) end it 'parses normal content for add_self_to_collection (non-import)' do @@ -119,21 +121,22 @@ } } - protocol, operation, encoded = described_class.extract('data:,' + content_json.to_json) + protocol, operation, encoded = ProtocolParser.for_calldata('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[]))' + '(bytes32,(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 + # Item now has contentHash as first field + expect(item[1]).to eq(5) # item_index (now at position 1) + expect(item[2]).to eq('Normal Item') # name (now at position 2) + expect(item[3]).to eq('#000000') # background_color (now at position 3) + expect(item[4]).to eq('desc') # description (now at position 4) end end @@ -142,7 +145,7 @@ # This ethscription should be the leader of a collection in the live JSON files specific_id = '0x05aac415994e0e01e66c4970133a51a4cdcea1f3a967743b87e6eb08f2f4d9f9' - protocol, operation, encoded = described_class.extract( + protocol, operation, encoded = ProtocolParser.for_calldata( 'data:,{}', ethscription_id: ByteString.from_hex(specific_id) ) @@ -151,10 +154,10 @@ # 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[]))' + '((string,string,uint256,string,string,string,string,string,string,string,bytes32),(bytes32,uint256,string,string,string,(string,string)[],bytes32[]))' ], encoded)[0] - metadata = decoded[0] + metadata = decoded[0] item = decoded[1] # Should have valid metadata @@ -163,13 +166,14 @@ 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 + # Should have valid item data (contentHash is first field now) + expect(item[0]).to be_a(String) # content_hash (packed bytes) + expect(item[1]).to be_a(Integer) # item_index + expect(item[2]).to be_a(String) # name + expect(item[3]).to be_a(String) # background_color + expect(item[4]).to be_a(String) # description + expect(item[5]).to be_an(Array) # attributes + expect(item[6]).to be_an(Array) # merkle_proof end end @@ -180,7 +184,7 @@ 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 + # Create ethscription with PNG content - should use import fallback tx_spec = l1_tx( creator: creator, to: creator, diff --git a/spec/models/erc721_ethscriptions_collection_parser_spec.rb b/spec/models/erc721_ethscriptions_collection_parser_spec.rb index 4fdc298..77288ed 100644 --- a/spec/models/erc721_ethscriptions_collection_parser_spec.rb +++ b/spec/models/erc721_ethscriptions_collection_parser_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' RSpec.describe Erc721EthscriptionsCollectionParser do - describe '#extract' do + describe 'via ProtocolParser' do let(:default_params) { [''.b, ''.b, ''.b] } let(:zero_merkle_root) { '0x' + '0' * 64 } @@ -9,25 +9,25 @@ # @generic-compatible it 'requires data:, prefix' do json = '{"p":"erc-721-ethscriptions-collection","op":"lock_collection","collection_id":"0x' + 'a' * 64 + '"}' - result = described_class.extract(json) + result = ProtocolParser.for_calldata(json) expect(result).to eq(default_params) end # @generic-compatible it 'requires valid JSON' do - result = described_class.extract('data:,{invalid json}') + result = ProtocolParser.for_calldata('data:,{invalid json}') expect(result).to eq(default_params) end it 'requires p:erc-721-ethscriptions-collection' do json = 'data:,{"p":"other","op":"lock_collection","collection_id":"0x' + 'a' * 64 + '"}' - result = described_class.extract(json) + result = ProtocolParser.for_calldata(json) expect(result).to eq(default_params) end it 'requires known operation' do json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"unknown_op","collection_id":"0x' + 'a' * 64 + '"}' - result = described_class.extract(json) + result = ProtocolParser.for_calldata(json) expect(result).to eq(default_params) end @@ -35,15 +35,15 @@ it 'enforces exact key order with p and op first' do # Wrong order - op before p json1 = 'data:,{"op":"lock_collection","p":"erc-721-ethscriptions-collection","collection_id":"0x' + 'a' * 64 + '"}' - expect(described_class.extract(json1)).to eq(default_params) + expect(ProtocolParser.for_calldata(json1)).to eq(default_params) # Wrong order - collection_id before op json2 = 'data:,{"p":"erc-721-ethscriptions-collection","collection_id":"0x' + 'a' * 64 + '","op":"lock_collection"}' - expect(described_class.extract(json2)).to eq(default_params) + expect(ProtocolParser.for_calldata(json2)).to eq(default_params) # Correct order json3 = 'data:,{"p":"erc-721-ethscriptions-collection","op":"lock_collection","collection_id":"0x' + 'a' * 64 + '"}' - result = described_class.extract(json3) + result = ProtocolParser.for_calldata(json3) expect(result[0]).to eq('erc-721-ethscriptions-collection'.b) expect(result[1]).to eq('lock_collection'.b) end @@ -51,7 +51,7 @@ # @generic-compatible it 'rejects extra keys' do json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"lock_collection","collection_id":"0x' + 'a' * 64 + '","extra":"field"}' - result = described_class.extract(json) + result = ProtocolParser.for_calldata(json) expect(result).to eq(default_params) end @@ -59,12 +59,12 @@ it 'validates uint256 format - no leading zeros' do # Valid 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":"","merkle_root":"#{zero_merkle_root}"}) - result = described_class.extract(valid_json) + result = ProtocolParser.for_calldata(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","max_supply":"01000","description":"","logo_image_uri":"","banner_image_uri":"","background_color":"","website_link":"","twitter_link":"","discord_link":"","merkle_root":"#{zero_merkle_root}"}) - result = described_class.extract(invalid_json) + result = ProtocolParser.for_calldata(invalid_json) expect(result).to eq(default_params) end @@ -72,22 +72,22 @@ it 'validates bytes32 format - lowercase hex only' do # Valid lowercase valid_json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"lock_collection","collection_id":"0x' + 'a' * 64 + '"}' - result = described_class.extract(valid_json) + result = ProtocolParser.for_calldata(valid_json) expect(result[0]).to eq('erc-721-ethscriptions-collection'.b) # Invalid - uppercase invalid_json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"lock_collection","collection_id":"0x' + 'A' * 64 + '"}' - result = described_class.extract(invalid_json) + result = ProtocolParser.for_calldata(invalid_json) expect(result).to eq(default_params) # Invalid - wrong length invalid_json2 = 'data:,{"p":"erc-721-ethscriptions-collection","op":"lock_collection","collection_id":"0x' + 'a' * 63 + '"}' - result = described_class.extract(invalid_json2) + result = ProtocolParser.for_calldata(invalid_json2) expect(result).to eq(default_params) # Invalid - no 0x prefix invalid_json3 = 'data:,{"p":"erc-721-ethscriptions-collection","op":"lock_collection","collection_id":"' + 'a' * 64 + '"}' - result = described_class.extract(invalid_json3) + result = ProtocolParser.for_calldata(invalid_json3) expect(result).to eq(default_params) end end @@ -98,7 +98,7 @@ end it 'encodes create_collection correctly' do - result = described_class.extract(valid_create_json) + result = ProtocolParser.for_calldata(valid_create_json) expect(result[0]).to eq('erc-721-ethscriptions-collection'.b) expect(result[1]).to eq('create_collection'.b) @@ -124,7 +124,7 @@ it 'handles empty optional fields' do 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":"","merkle_root":"#{zero_merkle_root}"}) - result = described_class.extract(json) + result = ProtocolParser.for_calldata(json) expect(result[0]).to eq('erc-721-ethscriptions-collection'.b) expect(result[1]).to eq('create_collection'.b) @@ -146,10 +146,10 @@ too_large = (2**256).to_s # One more than max 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":"","merkle_root":"#{zero_merkle_root}"}) - result = described_class.extract(json) + result = ProtocolParser.for_calldata(json) # Should return default params due to validation failure - expect(result).to eq(described_class::DEFAULT_PARAMS) + expect(result).to eq(default_params) end it 'accepts maximum valid uint256 value' do @@ -157,7 +157,7 @@ max_uint256 = (2**256 - 1).to_s 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":"","merkle_root":"#{zero_merkle_root}"}) - result = described_class.extract(json) + result = ProtocolParser.for_calldata(json) # Should succeed with max value expect(result[0]).to eq('erc-721-ethscriptions-collection'.b) @@ -180,55 +180,56 @@ 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) + result = ProtocolParser.for_calldata(json) expect(result[0]).to eq('erc-721-ethscriptions-collection'.b) expect(result[1]).to eq('add_self_to_collection'.b) decoded = Eth::Abi.decode( - ['(bytes32,(uint256,string,string,string,(string,string)[],bytes32[]))'], + ['(bytes32,(bytes32,uint256,string,string,string,(string,string)[],bytes32[]))'], result[2] )[0] expect(decoded[0].unpack1('H*')).to eq(collection_id_hex[2..]) item = decoded[1] - expect(item[0]).to eq(0) # item_index - 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 + # Note: Item structure now has contentHash as first field + expect(item[1]).to eq(0) # item_index + expect(item[2]).to eq('Item 1') # name + expect(item[3]).to eq('#FF0000') # background_color + expect(item[4]).to eq('First item') # description + expect(item[5]).to eq([["Rarity", "Common"], ["Level", "1"]]) # attributes + expect(item[6]).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_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) + result = ProtocolParser.for_calldata(json) expect(result).to eq(default_params) end it 'handles empty attributes array' 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":"","description":"","attributes":[],"merkle_proof":[]}}' - result = described_class.extract(json) + result = ProtocolParser.for_calldata(json) expect(result[0]).to eq('erc-721-ethscriptions-collection'.b) decoded = Eth::Abi.decode( - ['(bytes32,(uint256,string,string,string,(string,string)[],bytes32[]))'], + ['(bytes32,(bytes32,uint256,string,string,string,(string,string)[],bytes32[]))'], result[2] )[0] item = decoded[1] - expect(item[4]).to eq([]) # Empty attributes - expect(item[5]).to eq([]) # Empty merkle_proof + expect(item[5]).to eq([]) # Empty attributes + expect(item[6]).to eq([]) # Empty merkle_proof end end describe 'remove_items operation' do it 'encodes remove_items correctly' do json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"remove_items","collection_id":"0x' + '1' * 64 + '","ethscription_ids":["0x' + '2' * 64 + '","0x' + '3' * 64 + '"]}' - result = described_class.extract(json) + result = ProtocolParser.for_calldata(json) expect(result[0]).to eq('erc-721-ethscriptions-collection'.b) expect(result[1]).to eq('remove_items'.b) @@ -244,7 +245,7 @@ describe 'edit_collection operation' do it 'encodes edit_collection correctly' do json = %(data:,{"p":"erc-721-ethscriptions-collection","op":"edit_collection","collection_id":"0x#{"1" * 64}","description":"Updated","logo_image_uri":"new_logo","banner_image_uri":"","background_color":"#00FF00","website_link":"https://new.com","twitter_link":"","discord_link":"","merkle_root":"#{zero_merkle_root}"}) - result = described_class.extract(json) + result = ProtocolParser.for_calldata(json) expect(result[0]).to eq('erc-721-ethscriptions-collection'.b) expect(result[1]).to eq('edit_collection'.b) @@ -267,7 +268,7 @@ describe 'edit_collection_item operation' do it 'encodes edit_collection_item correctly' do json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"edit_collection_item","collection_id":"0x' + '1' * 64 + '","item_index":"5","name":"Updated Name","background_color":"#0000FF","description":"Updated desc","attributes":[{"trait_type":"New","value":"Value"}]}' - result = described_class.extract(json) + result = ProtocolParser.for_calldata(json) expect(result[0]).to eq('erc-721-ethscriptions-collection'.b) expect(result[1]).to eq('edit_collection_item'.b) @@ -289,7 +290,7 @@ describe 'lock_collection operation' do it 'encodes lock_collection as single bytes32' do json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"lock_collection","collection_id":"0x' + '1' * 64 + '"}' - result = described_class.extract(json) + result = ProtocolParser.for_calldata(json) expect(result[0]).to eq('erc-721-ethscriptions-collection'.b) expect(result[1]).to eq('lock_collection'.b) @@ -317,7 +318,7 @@ ] test_cases.each do |test_case| - result = described_class.extract(test_case[:json]) + result = ProtocolParser.for_calldata(test_case[:json]) expect(result[0]).not_to eq(''.b) decoded = Eth::Abi.decode([test_case[:abi_type]], result[2]) @@ -338,13 +339,14 @@ test_cases = [ 'data:,{broken json', 'data:,', - 'data:,null', + # Note: 'data:,null' is a valid word-domains registration for the word "null" + # so it's excluded from this test 'data:,[]', 'data:,"string"' ] test_cases.each do |json| - result = described_class.extract(json) + result = ProtocolParser.for_calldata(json) expect(result).to eq(default_params) end end @@ -352,31 +354,31 @@ 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","max_supply":"100","description":"","logo_image_uri":"","banner_image_uri":"","background_color":"","website_link":"","twitter_link":"","discord_link":"","merkle_root":"#{zero_merkle_root}"}) - result = described_class.extract(json_with_null) + result = ProtocolParser.for_calldata(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","max_supply":"100","description":null,"logo_image_uri":"","banner_image_uri":"","background_color":"","website_link":"","twitter_link":"","discord_link":"","merkle_root":"#{zero_merkle_root}"}) - result = described_class.extract(json_with_null_desc) + result = ProtocolParser.for_calldata(json_with_null_desc) expect(result).to eq(default_params) # Test null in item fields json_with_null_item = 'data:,{"p":"erc-721-ethscriptions-collection","op":"add_items_batch","collection_id":"0x' + '1' * 64 + '","items":[{"item_index":"0","name":null,"ethscription_id":"0x' + '2' * 64 + '","background_color":"","description":"","attributes":[]}]}' - result = described_class.extract(json_with_null_item) + result = ProtocolParser.for_calldata(json_with_null_item) expect(result).to eq(default_params) # Test null in attribute fields json_with_null_attr = 'data:,{"p":"erc-721-ethscriptions-collection","op":"add_items_batch","collection_id":"0x' + '1' * 64 + '","items":[{"item_index":"0","name":"Item","ethscription_id":"0x' + '2' * 64 + '","background_color":"","description":"","attributes":[{"trait_type":null,"value":"test"}]}]}' - result = described_class.extract(json_with_null_attr) + result = ProtocolParser.for_calldata(json_with_null_attr) expect(result).to eq(default_params) end it 'returns default params for missing required fields' do # Missing collection_id json = 'data:,{"p":"erc-721-ethscriptions-collection","op":"lock_collection"}' - result = described_class.extract(json) + result = ProtocolParser.for_calldata(json) expect(result).to eq(default_params) end end end -end +end \ No newline at end of file diff --git a/spec/models/ethscription_transaction_builder_spec.rb b/spec/models/ethscription_transaction_builder_spec.rb index 0d3635c..f3b39b9 100644 --- a/spec/models/ethscription_transaction_builder_spec.rb +++ b/spec/models/ethscription_transaction_builder_spec.rb @@ -1,45 +1,52 @@ require 'rails_helper' RSpec.describe "EthscriptionTransactionBuilder" do - describe '.extract_token_params' do + describe 'ERC-20 protocol parsing via ProtocolParser' do it 'extracts deploy operation params' do content_uri = 'data:,{"p":"erc-20","op":"deploy","tick":"eths","max":"21000000","lim":"1000"}' - params = Erc20FixedDenominationParser.extract(content_uri) + params = ProtocolParser.for_calldata(content_uri) - expect(params).to eq(['deploy'.b, 'erc-20-fixed-denomination'.b, 'eths'.b, 21000000, 1000, 0]) + expect(params[0]).to eq('erc-20-fixed-denomination'.b) + expect(params[1]).to eq('deploy'.b) + # params[2] is ABI-encoded (string, uint256, uint256) + decoded = Eth::Abi.decode(['(string,uint256,uint256)'], params[2])[0] + expect(decoded).to eq(['eths', 21000000, 1000]) end it 'extracts mint operation params' do content_uri = 'data:,{"p":"erc-20","op":"mint","tick":"eths","id":"1","amt":"1000"}' - params = Erc20FixedDenominationParser.extract(content_uri) + params = ProtocolParser.for_calldata(content_uri) - expect(params).to eq(['mint'.b, 'erc-20-fixed-denomination'.b, 'eths'.b, 1, 0, 1000]) + expect(params[0]).to eq('erc-20-fixed-denomination'.b) + expect(params[1]).to eq('mint'.b) + decoded = Eth::Abi.decode(['(string,uint256,uint256)'], params[2])[0] + expect(decoded).to eq(['eths', 1, 1000]) end it 'returns default params for non-token content' do content_uri = 'data:,Hello World!' - params = Erc20FixedDenominationParser.extract(content_uri) + params = ProtocolParser.for_calldata(content_uri) - expect(params).to eq([''.b, ''.b, ''.b, 0, 0, 0]) + expect(params).to eq([''.b, ''.b, ''.b]) end it 'returns default params for invalid JSON' do content_uri = 'data:,{invalid json' - params = Erc20FixedDenominationParser.extract(content_uri) + params = ProtocolParser.for_calldata(content_uri) - expect(params).to eq([''.b, ''.b, ''.b, 0, 0, 0]) + expect(params).to eq([''.b, ''.b, ''.b]) end it 'handles unknown operations with protocol/tick' do content_uri = 'data:,{"p":"new-proto","op":"custom","tick":"test"}' - params = Erc20FixedDenominationParser.extract(content_uri) + params = ProtocolParser.for_calldata(content_uri) - expect(params).to eq([''.b, ''.b, ''.b, 0, 0, 0]) + expect(params).to eq([''.b, ''.b, ''.b]) end end end diff --git a/spec/models/word_domains_parser_spec.rb b/spec/models/word_domains_parser_spec.rb new file mode 100644 index 0000000..7cf537b --- /dev/null +++ b/spec/models/word_domains_parser_spec.rb @@ -0,0 +1,49 @@ +require 'rails_helper' + +RSpec.describe WordDomainsParser do + describe 'via ProtocolParser' do + it 'parses raw word inscriptions' do + params = ProtocolParser.for_calldata('data:,pizza') + + expect(params[0]).to eq('word-domains'.b) + expect(params[1]).to eq('register'.b) + decoded = Eth::Abi.decode(['string'], params[2]) + expect(decoded.first).to eq('pizza') + end + + it 'rejects mixed-case words' do + params = ProtocolParser.for_calldata('data:,Pizza') + expect(params).to eq([''.b, ''.b, ''.b]) + end + + it 'rejects disallowed characters' do + params = ProtocolParser.for_calldata('data:,hello.world') + expect(params).to eq([''.b, ''.b, ''.b]) + end + + it 'parses JSON set_primary operations' do + json = 'data:,{"p":"word-domains","op":"set_primary","name":"alpha"}' + params = ProtocolParser.for_calldata(json) + + expect(params[0]).to eq('word-domains'.b) + expect(params[1]).to eq('set_primary'.b) + expect(Eth::Abi.decode(['string'], params[2]).first).to eq('alpha') + end + + it 'allows clearing primary with empty string' do + json = 'data:,{"p":"word-domains","op":"set_primary","name":""}' + params = ProtocolParser.for_calldata(json) + + expect(params[1]).to eq('set_primary'.b) + expect(Eth::Abi.decode(['string'], params[2]).first).to eq('') + end + + it 'accepts 31 character names and rejects longer ones' do + valid = 'a' * 30 + invalid = 'b' * 31 + + expect(ProtocolParser.for_calldata("data:,#{valid}")[1]).to eq('register'.b) + expect(ProtocolParser.for_calldata("data:,#{invalid}")).to eq([''.b, ''.b, ''.b]) + end + end +end