1- # Strict extractor for the ERC-721 Ethscriptions collection protocol with canonical JSON validation
1+ # Strict parser for the ERC-721 Ethscriptions collection protocol with canonical JSON validation
22class Erc721EthscriptionsCollectionParser
33 # Default return for invalid input
44 DEFAULT_PARAMS = [ '' . b , '' . b , '' . b ] . freeze
@@ -9,8 +9,7 @@ class Erc721EthscriptionsCollectionParser
99 # Operation schemas defining exact structure and ABI encoding
1010 OPERATION_SCHEMAS = {
1111 'create_collection' => {
12- keys : %w[ name symbol max_supply description logo_image_uri banner_image_uri background_color website_link twitter_link discord_link ] ,
13- # Contract expects an extra bytes32 merkleRoot at the end. We append zero when omitted.
12+ keys : %w[ name symbol max_supply description logo_image_uri banner_image_uri background_color website_link twitter_link discord_link merkle_root ] ,
1413 abi_type : '(string,string,uint256,string,string,string,string,string,string,string,bytes32)' ,
1514 validators : {
1615 'name' => :string ,
@@ -22,13 +21,14 @@ class Erc721EthscriptionsCollectionParser
2221 'background_color' => :string ,
2322 'website_link' => :string ,
2423 'twitter_link' => :string ,
25- 'discord_link' => :string
24+ 'discord_link' => :string ,
25+ 'merkle_root' => :bytes32
2626 }
2727 } ,
2828 # New combined create op name used by the contract; keep legacy alias below
2929 'create_collection_and_add_self' => {
3030 keys : %w[ metadata item ] ,
31- # ((CollectionParams),(ItemData)) - ItemData without ethscription_id (item refers to itself )
31+ # ((CollectionParams),(ItemData)) - ItemData mirrors ItemData struct (no ethscriptionId field )
3232 abi_type : '((string,string,uint256,string,string,string,string,string,string,string,bytes32),(uint256,string,string,string,(string,string)[],bytes32[]))' ,
3333 validators : {
3434 'metadata' => :collection_metadata ,
@@ -62,8 +62,7 @@ class Erc721EthscriptionsCollectionParser
6262 }
6363 } ,
6464 'edit_collection' => {
65- keys : %w[ collection_id description logo_image_uri banner_image_uri background_color website_link twitter_link discord_link ] ,
66- # Contract includes a bytes32 merkleRoot; append zero when omitted
65+ keys : %w[ collection_id description logo_image_uri banner_image_uri background_color website_link twitter_link discord_link merkle_root ] ,
6766 abi_type : '(bytes32,string,string,string,string,string,string,string,bytes32)' ,
6867 validators : {
6968 'collection_id' => :bytes32 ,
@@ -73,7 +72,8 @@ class Erc721EthscriptionsCollectionParser
7372 'background_color' => :string ,
7473 'website_link' => :string ,
7574 'twitter_link' => :string ,
76- 'discord_link' => :string
75+ 'discord_link' => :string ,
76+ 'merkle_root' => :bytes32
7777 }
7878 } ,
7979 'edit_collection_item' => {
@@ -97,8 +97,10 @@ class Erc721EthscriptionsCollectionParser
9797 }
9898 } . freeze
9999
100- # Item keys for validation (ethscription_id removed - item refers to itself)
101- # merkle_proof is always required (can be empty array)
100+ ZERO_BYTES32 = [ "" . ljust ( 64 , '0' ) ] . pack ( 'H*' ) . freeze
101+ ZERO_HEX_BYTES32 = '0x' + '0' * 64
102+
103+ # Item keys for validation (merkle_proof always present, can be empty array)
102104 ITEM_KEYS = %w[ item_index name background_color description attributes merkle_proof ] . freeze
103105
104106 # Attribute keys for NFT metadata
@@ -114,56 +116,50 @@ def self.extract(content_uri, ethscription_id: nil)
114116 end
115117
116118 def extract ( content_uri , ethscription_id : nil )
117- # Import-aware path takes precedence if id is provided
118119 if ethscription_id
119- if ( encoded = build_import_encoded_params ( ethscription_id . to_hex ) )
120- return encoded
120+ normalized_id = normalize_id ( ethscription_id )
121+ if normalized_id && ( preplanned = build_import_encoded_params ( normalized_id ) )
122+ return preplanned
121123 end
122124 end
123125
124126 return DEFAULT_PARAMS unless valid_data_uri? ( content_uri )
125127
126128 begin
127- # Parse JSON (preserves key order)
128- # Use DataUri to correctly handle optional parameters like ESIP6
129- json_str = if content_uri . start_with? ( "data:,{" )
130- content_uri . sub ( /\A data:,/ , '' )
131- else
132- DataUri . new ( content_uri ) . decoded_data
133- end
134-
129+ json_str = DataUri . new ( content_uri ) . decoded_data
130+
135131 # TODO: make sure this is safe
136132 data = JSON . parse ( json_str )
137-
138- # Must be an object
139133 return DEFAULT_PARAMS unless data . is_a? ( Hash )
140-
141- # Check protocol
142134 return DEFAULT_PARAMS unless data [ 'p' ] == 'erc-721-ethscriptions-collection'
143135
144- # Get operation
145136 operation = data [ 'op' ]
146137 return DEFAULT_PARAMS unless OPERATION_SCHEMAS . key? ( operation )
147138
148- # Validate exact key order (including p and op at start)
149139 schema = OPERATION_SCHEMAS [ operation ]
150140 expected_keys = [ 'p' , 'op' ] + schema [ :keys ]
151141 return DEFAULT_PARAMS unless data . keys == expected_keys
152142
153- # Remove protocol fields for encoding
154143 encoding_data = data . reject { |k , _ | k == 'p' || k == 'op' }
155-
156- # Validate field types and encode
157144 encoded_data = encode_operation ( operation , encoding_data , schema )
158-
159145 [ 'erc-721-ethscriptions-collection' . b , operation . b , encoded_data . b ]
160-
161146 rescue JSON ::ParserError , ValidationError => e
162147 Rails . logger . debug "Collections extraction failed: #{ e . message } " if defined? ( Rails )
163148 DEFAULT_PARAMS
164149 end
165150 end
166151
152+ def normalize_id ( value )
153+ case value
154+ when ByteString
155+ value . to_hex . downcase
156+ when String
157+ value . downcase
158+ else
159+ nil
160+ end
161+ end
162+
167163 # -------------------- Import fallback --------------------
168164
169165 # Returns [protocol, operation, encoded_data] or nil
@@ -185,13 +181,16 @@ def build_import_encoded_params(id)
185181 item_index = data [ :zero_index_by_id ] [ id ] || 0
186182
187183 if id == leader_id
188- metadata = data [ :collections_by_name ] [ coll_name ]
189- return nil unless metadata
184+ raw_metadata = data [ :collections_by_name ] [ coll_name ]
185+ return nil unless raw_metadata
186+ metadata = raw_metadata . merge (
187+ 'merkle_root' => raw_metadata [ 'merkle_root' ] || ZERO_HEX_BYTES32
188+ )
190189 operation = 'create_collection_and_add_self'
191190 schema = OPERATION_SCHEMAS [ operation ]
192191 encoding_data = {
193192 'metadata' => build_metadata_object ( metadata ) ,
194- 'item' => build_item_object ( item : item , item_id : id , item_index : item_index )
193+ 'item' => build_item_object ( item : item , item_index : item_index )
195194 }
196195 encoded_data = encode_operation ( operation , encoding_data , schema )
197196 [ 'erc-721-ethscriptions-collection' . b , operation . b , encoded_data . b ]
@@ -200,7 +199,7 @@ def build_import_encoded_params(id)
200199 schema = OPERATION_SCHEMAS [ operation ]
201200 encoding_data = {
202201 'collection_id' => to_bytes32_hex ( leader_id ) ,
203- 'item' => build_item_object ( item : item , item_id : id , item_index : item_index )
202+ 'item' => build_item_object ( item : item , item_index : item_index )
204203 }
205204 encoded_data = encode_operation ( operation , encoding_data , schema )
206205 [ 'erc-721-ethscriptions-collection' . b , operation . b , encoded_data . b ]
@@ -269,7 +268,7 @@ def build_metadata_object(meta)
269268 twitter_link = safe_string ( meta [ 'twitter_link' ] )
270269 discord_link = safe_string ( meta [ 'discord_link' ] )
271270
272- OrderedHash [
271+ result = OrderedHash [
273272 'name' , name ,
274273 'symbol' , symbol ,
275274 'max_supply' , max_supply ,
@@ -281,20 +280,25 @@ def build_metadata_object(meta)
281280 'twitter_link' , twitter_link ,
282281 'discord_link' , discord_link
283282 ]
283+ merkle_root = meta . fetch ( 'merkle_root' )
284+ result [ 'merkle_root' ] = to_bytes32_hex ( merkle_root )
285+ result
284286 end
285287
286- def build_item_object ( item :, item_id : , item_index :)
288+ def build_item_object ( item :, item_index :)
287289 attrs = Array ( item [ 'attributes' ] ) . map do |a |
288290 OrderedHash [ 'trait_type' , safe_string ( a [ 'trait_type' ] ) , 'value' , safe_string ( a [ 'value' ] ) ]
289291 end
290292
293+ proofs = item . key? ( 'merkle_proof' ) ? Array ( item [ 'merkle_proof' ] ) : [ ]
294+
291295 OrderedHash [
292296 'item_index' , safe_uint_string ( item_index ) ,
293297 'name' , safe_string ( item [ 'name' ] ) ,
294298 'background_color' , safe_string ( item [ 'background_color' ] ) ,
295299 'description' , safe_string ( item [ 'description' ] ) ,
296300 'attributes' , attrs ,
297- 'merkle_proof' , [ ]
301+ 'merkle_proof' , proofs
298302 ]
299303 end
300304
@@ -479,8 +483,8 @@ def validate_collection_metadata(value, field_name)
479483 raise ValidationError , "Expected object for #{ field_name } "
480484 end
481485 # Expected keys for metadata (merkle_root optional)
482- expected_min_keys = %w[ name symbol max_supply description logo_image_uri banner_image_uri background_color website_link twitter_link discord_link ]
483- unless value . keys == expected_min_keys || value . keys == ( expected_min_keys + [ 'merkle_root' ] )
486+ expected_keys = %w[ name symbol max_supply description logo_image_uri banner_image_uri background_color website_link twitter_link discord_link merkle_root ]
487+ unless value . keys == expected_keys
484488 raise ValidationError , "Invalid metadata keys or order"
485489 end
486490
@@ -495,7 +499,7 @@ def validate_collection_metadata(value, field_name)
495499 websiteLink : validate_string ( value [ 'website_link' ] , 'website_link' ) ,
496500 twitterLink : validate_string ( value [ 'twitter_link' ] , 'twitter_link' ) ,
497501 discordLink : validate_string ( value [ 'discord_link' ] , 'discord_link' ) ,
498- merkleRoot : value . key? ( 'merkle_root' ) ? validate_bytes32 ( value [ 'merkle_root' ] , 'merkle_root' ) : nil
502+ merkleRoot : validate_bytes32 ( value [ 'merkle_root' ] , 'merkle_root' )
499503 }
500504 end
501505
@@ -504,13 +508,11 @@ def validate_item(item)
504508 raise ValidationError , "Item must be an object"
505509 end
506510
507- # Check exact key order - merkle_proof is always required
508511 unless item . keys == ITEM_KEYS
509512 expected = "[#{ ITEM_KEYS . join ( ', ' ) } ]"
510513 raise ValidationError , "Invalid item keys or order. Expected: #{ expected } , got: [#{ item . keys . join ( ', ' ) } ]"
511514 end
512515
513- # Validate each field - return in internal format for encoding
514516 {
515517 itemIndex : validate_uint256 ( item [ 'item_index' ] , 'item_index' ) ,
516518 name : validate_string ( item [ 'name' ] , 'name' ) ,
@@ -562,8 +564,7 @@ def build_create_collection_values(data)
562564 data [ 'website_link' ] ,
563565 data [ 'twitter_link' ] ,
564566 data [ 'discord_link' ] ,
565- # Append zero merkle root to satisfy contract struct shape
566- [ "" . ljust ( 64 , '0' ) ] . pack ( 'H*' )
567+ data [ 'merkle_root' ]
567568 ]
568569 end
569570
@@ -612,22 +613,6 @@ def build_add_self_to_collection_values(data)
612613 [ data [ 'collection_id' ] , item_tuple ]
613614 end
614615
615- def build_add_items_batch_values ( data )
616- # Transform items to array format for encoding
617- items_array = data [ 'items' ] . map do |item |
618- [
619- item [ :itemIndex ] ,
620- item [ :name ] ,
621- item [ :backgroundColor ] ,
622- item [ :description ] ,
623- item [ :attributes ] ,
624- item [ :merkleProof ]
625- ]
626- end
627-
628- [ data [ 'collection_id' ] , items_array ]
629- end
630-
631616 def build_remove_items_values ( data )
632617 [ data [ 'collection_id' ] , data [ 'ethscription_ids' ] ]
633618 end
@@ -644,8 +629,7 @@ def build_edit_collection_values(data)
644629 data [ 'discord_link' ]
645630 ]
646631
647- # Append zero merkle root if not provided in payload (parser schema omits it)
648- values << [ "" . ljust ( 64 , '0' ) ] . pack ( 'H*' )
632+ values << data [ 'merkle_root' ]
649633 values
650634 end
651635
0 commit comments