@@ -28,8 +28,8 @@ class Erc721EthscriptionsCollectionParser
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 mirrors ItemData struct (no ethscriptionId field)
32- abi_type : '((string,string,uint256,string,string,string,string,string,string,string,bytes32),(uint256,string,string,string,(string,string)[],bytes32[]))' ,
31+ # ((CollectionParams),(ItemData)) - ItemData now includes contentHash as first field
32+ abi_type : '((string,string,uint256,string,string,string,string,string,string,string,bytes32),(bytes32, uint256,string,string,string,(string,string)[],bytes32[]))' ,
3333 validators : {
3434 'metadata' => :collection_metadata ,
3535 'item' => :single_item
@@ -38,7 +38,7 @@ class Erc721EthscriptionsCollectionParser
3838 # Legacy alias retained for backwards compatibility
3939 'create_and_add_self' => {
4040 keys : %w[ metadata item ] ,
41- abi_type : '((string,string,uint256,string,string,string,string,string,string,string,bytes32),(uint256,string,string,string,(string,string)[],bytes32[]))' ,
41+ abi_type : '((string,string,uint256,string,string,string,string,string,string,string,bytes32),(bytes32, uint256,string,string,string,(string,string)[],bytes32[]))' ,
4242 validators : {
4343 'metadata' => :collection_metadata ,
4444 'item' => :single_item
@@ -47,7 +47,7 @@ class Erc721EthscriptionsCollectionParser
4747 # New single-item add op; keep legacy batch below for compatibility
4848 'add_self_to_collection' => {
4949 keys : %w[ collection_id item ] ,
50- abi_type : '(bytes32,(uint256,string,string,string,(string,string)[],bytes32[]))' ,
50+ abi_type : '(bytes32,(bytes32, uint256,string,string,string,(string,string)[],bytes32[]))' ,
5151 validators : {
5252 'collection_id' => :bytes32 ,
5353 'item' => :single_item
@@ -101,7 +101,7 @@ class Erc721EthscriptionsCollectionParser
101101 ZERO_HEX_BYTES32 = '0x' + '0' * 64
102102
103103 # Item keys for validation (merkle_proof always present, can be empty array)
104- ITEM_KEYS = %w[ item_index name background_color description attributes merkle_proof ] . freeze
104+ ITEM_KEYS = %w[ content_hash item_index name background_color description attributes merkle_proof ] . freeze
105105
106106 # Attribute keys for NFT metadata
107107 ATTRIBUTE_KEYS = %w[ trait_type value ] . freeze
@@ -111,44 +111,79 @@ class ValidationError < StandardError; end
111111 DEFAULT_ITEMS_PATH = ENV [ 'COLLECTIONS_ITEMS_PATH' ] || Rails . root . join ( 'items_by_ethscription.json' )
112112 DEFAULT_COLLECTIONS_PATH = ENV [ 'COLLECTIONS_META_PATH' ] || Rails . root . join ( 'collections_by_name.json' )
113113
114- def self . extract ( content_uri , ethscription_id : nil )
115- new . extract ( content_uri , ethscription_id : ethscription_id )
114+ # New API: validate and encode protocol params
115+ # Unified interface - accepts all possible parameters, uses what it needs
116+ def self . validate_and_encode ( decoded_content :, operation :, params :, source :, ethscription_id : nil , **_extras )
117+ new . validate_and_encode (
118+ decoded_content : decoded_content ,
119+ operation : operation ,
120+ params : params ,
121+ source : source ,
122+ ethscription_id : ethscription_id
123+ )
116124 end
117125
118- def extract ( content_uri , ethscription_id : nil )
126+ def validate_and_encode ( decoded_content :, operation :, params :, source :, ethscription_id : nil )
127+ # Check import fallback first (if ethscription_id provided)
119128 if ethscription_id
120129 normalized_id = normalize_id ( ethscription_id )
121- if normalized_id && ( preplanned = build_import_encoded_params ( normalized_id ) )
130+ if normalized_id && ( preplanned = build_import_encoded_params ( normalized_id , decoded_content ) )
122131 return preplanned
123132 end
124133 end
125134
126- return DEFAULT_PARAMS unless valid_data_uri? ( content_uri )
127-
128- begin
129- json_str = DataUri . new ( content_uri ) . decoded_data
135+ return DEFAULT_PARAMS unless OPERATION_SCHEMAS . key? ( operation )
130136
131- # TODO: make sure this is safe
132- data = JSON . parse ( json_str )
133- return DEFAULT_PARAMS unless data . is_a? ( Hash )
134- return DEFAULT_PARAMS unless data [ 'p' ] == 'erc-721-ethscriptions-collection'
137+ schema = OPERATION_SCHEMAS [ operation ]
135138
136- operation = data [ 'op' ]
137- return DEFAULT_PARAMS unless OPERATION_SCHEMAS . key? ( operation )
139+ begin
140+ if source == :json
141+ # Strict JSON validation - enforce exact key order
142+ validate_json_structure ( params , operation , schema )
143+ end
138144
139- schema = OPERATION_SCHEMAS [ operation ]
140- expected_keys = [ 'p' , 'op' ] + schema [ :keys ]
141- return DEFAULT_PARAMS unless data . keys == expected_keys
145+ # Extract encoding data (skip 'p' and 'op' for JSON source)
146+ encoding_data = if source == :json
147+ params . reject { |k , _ | k == 'p' || k == 'op' }
148+ else
149+ params
150+ end
151+
152+ # Compute content hash ONLY for operations that need it
153+ content_hash = nil
154+ if [ 'create_collection_and_add_self' , 'create_and_add_self' , 'add_self_to_collection' ] . include? ( operation )
155+ # Calculate keccak256 of decoded content for item verification
156+ hash = Eth ::Util . keccak256 ( decoded_content ) . unpack1 ( 'H*' )
157+ content_hash = '0x' + hash
158+
159+ # Inject content_hash into item data
160+ item_data = encoding_data [ 'item' ]
161+ if item_data . is_a? ( Hash )
162+ # Add content_hash as first key (OrderedHash maintains insertion order)
163+ encoding_data [ 'item' ] = self . class ::OrderedHash [ 'content_hash' , content_hash ] . merge ( item_data )
164+ end
165+ end
142166
143- encoding_data = data . reject { |k , _ | k == 'p' || k == 'op' }
144- encoded_data = encode_operation ( operation , encoding_data , schema )
167+ encoded_data = encode_operation ( operation , encoding_data , schema , content_hash : content_hash )
145168 [ 'erc-721-ethscriptions-collection' . b , operation . b , encoded_data . b ]
146169 rescue JSON ::ParserError , ValidationError => e
147- Rails . logger . debug "Collections extraction failed: #{ e . message } " if defined? ( Rails )
170+ Rails . logger . debug "Collections validation failed: #{ e . message } " if defined? ( Rails )
148171 DEFAULT_PARAMS
149172 end
150173 end
151174
175+ def validate_json_structure ( params , operation , schema )
176+ # For JSON source, enforce strict key ordering
177+ expected_keys = [ 'p' , 'op' ] + schema [ :keys ]
178+ unless params . keys == expected_keys
179+ raise ValidationError , "Invalid key order for #{ operation } "
180+ end
181+ end
182+
183+ # Removed extract() method - use ProtocolParser.for_calldata() instead
184+ # This avoids circular dependencies and keeps the architecture cleaner
185+ # The import fallback logic is now handled in validate_and_encode()
186+
152187 def normalize_id ( value )
153188 case value
154189 when ByteString
@@ -163,7 +198,7 @@ def normalize_id(value)
163198 # -------------------- Import fallback --------------------
164199
165200 # Returns [protocol, operation, encoded_data] or nil
166- def build_import_encoded_params ( id )
201+ def build_import_encoded_params ( id , decoded_content )
167202 data = self . class . load_import_data (
168203 items_path : DEFAULT_ITEMS_PATH ,
169204 collections_path : DEFAULT_COLLECTIONS_PATH
@@ -180,6 +215,10 @@ def build_import_encoded_params(id)
180215
181216 item_index = data [ :zero_index_by_id ] [ id ] || 0
182217
218+ # Always compute content hash from the actual decoded content
219+ hash = Eth ::Util . keccak256 ( decoded_content || '' . b ) . unpack1 ( 'H*' )
220+ content_hash = '0x' + hash
221+
183222 if id == leader_id
184223 raw_metadata = data [ :collections_by_name ] [ coll_name ]
185224 return nil unless raw_metadata
@@ -190,18 +229,18 @@ def build_import_encoded_params(id)
190229 schema = OPERATION_SCHEMAS [ operation ]
191230 encoding_data = {
192231 'metadata' => build_metadata_object ( metadata ) ,
193- 'item' => build_item_object ( item : item , item_index : item_index )
232+ 'item' => build_item_object ( item : item , item_index : item_index , content_hash : content_hash )
194233 }
195- encoded_data = encode_operation ( operation , encoding_data , schema )
234+ encoded_data = encode_operation ( operation , encoding_data , schema , content_hash : content_hash )
196235 [ 'erc-721-ethscriptions-collection' . b , operation . b , encoded_data . b ]
197236 else
198237 operation = 'add_self_to_collection'
199238 schema = OPERATION_SCHEMAS [ operation ]
200239 encoding_data = {
201240 'collection_id' => to_bytes32_hex ( leader_id ) ,
202- 'item' => build_item_object ( item : item , item_index : item_index )
241+ 'item' => build_item_object ( item : item , item_index : item_index , content_hash : content_hash )
203242 }
204- encoded_data = encode_operation ( operation , encoding_data , schema )
243+ encoded_data = encode_operation ( operation , encoding_data , schema , content_hash : content_hash )
205244 [ 'erc-721-ethscriptions-collection' . b , operation . b , encoded_data . b ]
206245 end
207246 end
@@ -285,14 +324,15 @@ def build_metadata_object(meta)
285324 result
286325 end
287326
288- def build_item_object ( item :, item_index :)
327+ def build_item_object ( item :, item_index :, content_hash : )
289328 attrs = Array ( item [ 'attributes' ] ) . map do |a |
290329 OrderedHash [ 'trait_type' , safe_string ( a [ 'trait_type' ] ) , 'value' , safe_string ( a [ 'value' ] ) ]
291330 end
292331
293332 proofs = item . key? ( 'merkle_proof' ) ? Array ( item [ 'merkle_proof' ] ) : [ ]
294333
295334 OrderedHash [
335+ 'content_hash' , content_hash ,
296336 'item_index' , safe_uint_string ( item_index ) ,
297337 'name' , safe_string ( item [ 'name' ] ) ,
298338 'background_color' , safe_string ( item [ 'background_color' ] ) ,
@@ -347,7 +387,7 @@ def valid_data_uri?(uri)
347387 DataUri . valid? ( uri )
348388 end
349389
350- def encode_operation ( operation , data , schema )
390+ def encode_operation ( operation , data , schema , content_hash : nil )
351391 # Validate and transform fields according to schema
352392 validated_data = validate_fields ( data , schema [ :validators ] )
353393
@@ -356,9 +396,9 @@ def encode_operation(operation, data, schema)
356396 when 'create_collection'
357397 build_create_collection_values ( validated_data )
358398 when 'create_collection_and_add_self' , 'create_and_add_self'
359- build_create_and_add_self_values ( validated_data )
399+ build_create_and_add_self_values ( validated_data , content_hash : content_hash )
360400 when 'add_self_to_collection'
361- build_add_self_to_collection_values ( validated_data )
401+ build_add_self_to_collection_values ( validated_data , content_hash : content_hash )
362402 when 'remove_items'
363403 build_remove_items_values ( validated_data )
364404 when 'edit_collection'
@@ -514,6 +554,7 @@ def validate_item(item)
514554 end
515555
516556 {
557+ contentHash : validate_bytes32 ( item [ 'content_hash' ] , 'content_hash' ) ,
517558 itemIndex : validate_uint256 ( item [ 'item_index' ] , 'item_index' ) ,
518559 name : validate_string ( item [ 'name' ] , 'name' ) ,
519560 backgroundColor : validate_string ( item [ 'background_color' ] , 'background_color' ) ,
@@ -568,7 +609,7 @@ def build_create_collection_values(data)
568609 ]
569610 end
570611
571- def build_create_and_add_self_values ( data )
612+ def build_create_and_add_self_values ( data , content_hash : )
572613 meta = data [ 'metadata' ]
573614 item = data [ 'item' ]
574615
@@ -588,7 +629,17 @@ def build_create_and_add_self_values(data)
588629 merkle_root
589630 ]
590631
632+ # Item tuple - contentHash comes first (keccak256 of ethscription content)
633+ # Always use the computed content_hash if provided, otherwise use validated item contentHash
634+ content_hash_bytes = if content_hash
635+ [ content_hash [ 2 ..] ] . pack ( 'H*' )
636+ elsif item [ :contentHash ]
637+ item [ :contentHash ] # Already packed bytes from validate_item
638+ else
639+ raise ValidationError , "Content hash missing"
640+ end
591641 item_tuple = [
642+ content_hash_bytes , # Already packed bytes, don't call to_bytes32_hex
592643 item [ :itemIndex ] ,
593644 item [ :name ] ,
594645 item [ :backgroundColor ] ,
@@ -600,9 +651,20 @@ def build_create_and_add_self_values(data)
600651 [ metadata_tuple , item_tuple ]
601652 end
602653
603- def build_add_self_to_collection_values ( data )
654+ def build_add_self_to_collection_values ( data , content_hash : )
604655 item = data [ 'item' ]
656+
657+ # Item tuple - contentHash comes first (keccak256 of ethscription content)
658+ # Always use the computed content_hash if provided, otherwise use validated item contentHash
659+ content_hash_bytes = if content_hash
660+ [ content_hash [ 2 ..] ] . pack ( 'H*' )
661+ elsif item [ :contentHash ]
662+ item [ :contentHash ] # Already packed bytes from validate_item
663+ else
664+ raise ValidationError , "Content hash missing"
665+ end
605666 item_tuple = [
667+ content_hash_bytes , # Already packed bytes, don't call to_bytes32_hex
606668 item [ :itemIndex ] ,
607669 item [ :name ] ,
608670 item [ :backgroundColor ] ,
0 commit comments