1+ # Strict extractor for Collections protocol with canonical JSON validation
2+ class CollectionsParamsExtractor
3+ # Default return for invalid input
4+ DEFAULT_PARAMS = [ '' . b , '' . b , '' . b ] . freeze
5+
6+ # Maximum value for uint256
7+ UINT256_MAX = 2 **256 - 1
8+
9+ # Operation schemas defining exact structure and ABI encoding
10+ OPERATION_SCHEMAS = {
11+ 'create_collection' => {
12+ keys : %w[ name symbol total_supply description logo_image_uri banner_image_uri background_color website_link twitter_link discord_link ] ,
13+ abi_type : '(string,string,uint256,string,string,string,string,string,string,string)' ,
14+ validators : {
15+ 'name' => :string ,
16+ 'symbol' => :string ,
17+ 'total_supply' => :uint256 ,
18+ 'description' => :string ,
19+ 'logo_image_uri' => :string ,
20+ 'banner_image_uri' => :string ,
21+ 'background_color' => :string ,
22+ 'website_link' => :string ,
23+ 'twitter_link' => :string ,
24+ 'discord_link' => :string
25+ }
26+ } ,
27+ 'add_items_batch' => {
28+ keys : %w[ collection_id items ] ,
29+ abi_type : '(bytes32,(uint256,string,bytes32,string,string,(string,string)[])[])' ,
30+ validators : {
31+ 'collection_id' => :bytes32 ,
32+ 'items' => :items_array
33+ }
34+ } ,
35+ 'remove_items' => {
36+ keys : %w[ collection_id ethscription_ids ] ,
37+ abi_type : '(bytes32,bytes32[])' ,
38+ validators : {
39+ 'collection_id' => :bytes32 ,
40+ 'ethscription_ids' => :bytes32_array
41+ }
42+ } ,
43+ 'edit_collection' => {
44+ keys : %w[ collection_id description logo_image_uri banner_image_uri background_color website_link twitter_link discord_link ] ,
45+ abi_type : '(bytes32,string,string,string,string,string,string,string)' ,
46+ validators : {
47+ 'collection_id' => :bytes32 ,
48+ 'description' => :string ,
49+ 'logo_image_uri' => :string ,
50+ 'banner_image_uri' => :string ,
51+ 'background_color' => :string ,
52+ 'website_link' => :string ,
53+ 'twitter_link' => :string ,
54+ 'discord_link' => :string
55+ }
56+ } ,
57+ 'edit_collection_item' => {
58+ keys : %w[ collection_id item_index name background_color description attributes ] ,
59+ abi_type : '(bytes32,uint256,string,string,string,(string,string)[])' ,
60+ validators : {
61+ 'collection_id' => :bytes32 ,
62+ 'item_index' => :uint256 ,
63+ 'name' => :string ,
64+ 'background_color' => :string ,
65+ 'description' => :string ,
66+ 'attributes' => :attributes_array
67+ }
68+ } ,
69+ 'lock_collection' => {
70+ keys : %w[ collection_id ] ,
71+ abi_type : 'bytes32' , # Not a tuple
72+ validators : {
73+ 'collection_id' => :bytes32
74+ }
75+ } ,
76+ 'sync_ownership' => {
77+ keys : %w[ collection_id ethscription_ids ] ,
78+ abi_type : '(bytes32,bytes32[])' ,
79+ validators : {
80+ 'collection_id' => :bytes32 ,
81+ 'ethscription_ids' => :bytes32_array
82+ }
83+ }
84+ } . freeze
85+
86+ # Item keys for add_items_batch validation
87+ ITEM_KEYS = %w[ item_index name ethscription_id background_color description attributes ] . freeze
88+
89+ # Attribute keys for NFT metadata
90+ ATTRIBUTE_KEYS = %w[ trait_type value ] . freeze
91+
92+ class ValidationError < StandardError ; end
93+
94+ def self . extract ( content_uri )
95+ new . extract ( content_uri )
96+ end
97+
98+ def extract ( content_uri )
99+ return DEFAULT_PARAMS unless valid_data_uri? ( content_uri )
100+
101+ begin
102+ # Parse JSON (preserves key order)
103+ json_str = content_uri [ 6 ..] # Remove 'data:,'
104+ data = JSON . parse ( json_str )
105+
106+ # Must be an object
107+ return DEFAULT_PARAMS unless data . is_a? ( Hash )
108+
109+ # Check protocol
110+ return DEFAULT_PARAMS unless data [ 'p' ] == 'collections'
111+
112+ # Get operation
113+ operation = data [ 'op' ]
114+ return DEFAULT_PARAMS unless OPERATION_SCHEMAS . key? ( operation )
115+
116+ # Validate exact key order (including p and op at start)
117+ schema = OPERATION_SCHEMAS [ operation ]
118+ expected_keys = [ 'p' , 'op' ] + schema [ :keys ]
119+ return DEFAULT_PARAMS unless data . keys == expected_keys
120+
121+ # Remove protocol fields for encoding
122+ encoding_data = data . reject { |k , _ | k == 'p' || k == 'op' }
123+
124+ # Validate field types and encode
125+ encoded_data = encode_operation ( operation , encoding_data , schema )
126+
127+ [ 'collections' . b , operation . b , encoded_data . b ]
128+
129+ rescue JSON ::ParserError , ValidationError => e
130+ Rails . logger . debug "Collections extraction failed: #{ e . message } " if defined? ( Rails )
131+ DEFAULT_PARAMS
132+ end
133+ end
134+
135+ private
136+
137+ def valid_data_uri? ( uri )
138+ uri . is_a? ( String ) && uri . start_with? ( 'data:,' )
139+ end
140+
141+ def encode_operation ( operation , data , schema )
142+ # Validate and transform fields according to schema
143+ validated_data = validate_fields ( data , schema [ :validators ] )
144+
145+ # Build values array based on operation
146+ values = case operation
147+ when 'create_collection'
148+ build_create_collection_values ( validated_data )
149+ when 'add_items_batch'
150+ build_add_items_batch_values ( validated_data )
151+ when 'remove_items'
152+ build_remove_items_values ( validated_data )
153+ when 'edit_collection'
154+ build_edit_collection_values ( validated_data )
155+ when 'edit_collection_item'
156+ build_edit_collection_item_values ( validated_data )
157+ when 'lock_collection'
158+ build_lock_collection_values ( validated_data )
159+ when 'sync_ownership'
160+ build_sync_ownership_values ( validated_data )
161+ else
162+ raise ValidationError , "Unknown operation: #{ operation } "
163+ end
164+
165+ # Use ABI type from schema for encoding
166+ Eth ::Abi . encode ( [ schema [ :abi_type ] ] , [ values ] )
167+ end
168+
169+ def validate_fields ( data , validators )
170+ validated = { }
171+
172+ data . each do |key , value |
173+ validator = validators [ key ]
174+
175+ # All fields must have explicit validators - no silent coercion
176+ unless validator
177+ raise ValidationError , "No validator defined for field: #{ key } "
178+ end
179+
180+ validated [ key ] = send ( "validate_#{ validator } " , value , key )
181+ end
182+
183+ validated
184+ end
185+
186+ # Validators
187+
188+ def validate_string ( value , field_name )
189+ unless value . is_a? ( String )
190+ raise ValidationError , "Field #{ field_name } must be a string, got #{ value . class . name } "
191+ end
192+ value
193+ end
194+
195+ def validate_uint256 ( value , field_name )
196+ unless value . is_a? ( String ) && value . match? ( /\A (0|[1-9]\d *)\z / )
197+ raise ValidationError , "Invalid uint256 for #{ field_name } : #{ value } "
198+ end
199+
200+ num = value . to_i
201+ if num > UINT256_MAX
202+ raise ValidationError , "Value exceeds uint256 maximum for #{ field_name } : #{ value } "
203+ end
204+
205+ num
206+ end
207+
208+ def validate_bytes32 ( value , field_name )
209+ unless value . is_a? ( String ) && value . match? ( /\A 0x[0-9a-f]{64}\z / )
210+ raise ValidationError , "Invalid bytes32 for #{ field_name } : #{ value } "
211+ end
212+ # Return as packed bytes for ABI encoding
213+ [ value [ 2 ..] ] . pack ( 'H*' )
214+ end
215+
216+ def validate_bytes32_array ( value , field_name )
217+ unless value . is_a? ( Array )
218+ raise ValidationError , "Expected array for #{ field_name } "
219+ end
220+
221+ value . map do |item |
222+ unless item . is_a? ( String ) && item . match? ( /\A 0x[0-9a-f]{64}\z / )
223+ raise ValidationError , "Invalid bytes32 in array: #{ item } "
224+ end
225+ [ item [ 2 ..] ] . pack ( 'H*' )
226+ end
227+ end
228+
229+ def validate_items_array ( value , field_name )
230+ unless value . is_a? ( Array )
231+ raise ValidationError , "Expected array for #{ field_name } "
232+ end
233+
234+ value . map do |item |
235+ validate_item ( item )
236+ end
237+ end
238+
239+ def validate_item ( item )
240+ unless item . is_a? ( Hash )
241+ raise ValidationError , "Item must be an object"
242+ end
243+
244+ # Check exact key order
245+ unless item . keys == ITEM_KEYS
246+ raise ValidationError , "Invalid item keys or order. Expected: #{ ITEM_KEYS . join ( ',' ) } , got: #{ item . keys . join ( ',' ) } "
247+ end
248+
249+ # Validate each field - return in internal format for encoding
250+ {
251+ itemIndex : validate_uint256 ( item [ 'item_index' ] , 'item_index' ) ,
252+ name : validate_string ( item [ 'name' ] , 'name' ) ,
253+ ethscriptionId : validate_bytes32 ( item [ 'ethscription_id' ] , 'ethscription_id' ) ,
254+ backgroundColor : validate_string ( item [ 'background_color' ] , 'background_color' ) ,
255+ description : validate_string ( item [ 'description' ] , 'description' ) ,
256+ attributes : validate_attributes_array ( item [ 'attributes' ] , 'attributes' )
257+ }
258+ end
259+
260+ def validate_attributes_array ( value , field_name )
261+ unless value . is_a? ( Array )
262+ raise ValidationError , "Expected array for #{ field_name } "
263+ end
264+
265+ value . map do |attr |
266+ validate_attribute ( attr )
267+ end
268+ end
269+
270+ def validate_attribute ( attr )
271+ unless attr . is_a? ( Hash )
272+ raise ValidationError , "Attribute must be an object"
273+ end
274+
275+ # Check exact key order
276+ unless attr . keys == ATTRIBUTE_KEYS
277+ raise ValidationError , "Invalid attribute keys or order. Expected: #{ ATTRIBUTE_KEYS . join ( ',' ) } , got: #{ attr . keys . join ( ',' ) } "
278+ end
279+
280+ # Both must be strings - no coercion
281+ [
282+ validate_string ( attr [ 'trait_type' ] , 'trait_type' ) ,
283+ validate_string ( attr [ 'value' ] , 'value' )
284+ ]
285+ end
286+
287+ # Encoders
288+
289+ def build_create_collection_values ( data )
290+ [
291+ data [ 'name' ] ,
292+ data [ 'symbol' ] ,
293+ data [ 'total_supply' ] ,
294+ data [ 'description' ] ,
295+ data [ 'logo_image_uri' ] ,
296+ data [ 'banner_image_uri' ] ,
297+ data [ 'background_color' ] ,
298+ data [ 'website_link' ] ,
299+ data [ 'twitter_link' ] ,
300+ data [ 'discord_link' ]
301+ ]
302+ end
303+
304+ def build_add_items_batch_values ( data )
305+ # Transform items to array format for encoding
306+ items_array = data [ 'items' ] . map do |item |
307+ [
308+ item [ :itemIndex ] ,
309+ item [ :name ] ,
310+ item [ :ethscriptionId ] ,
311+ item [ :backgroundColor ] ,
312+ item [ :description ] ,
313+ item [ :attributes ]
314+ ]
315+ end
316+
317+ [ data [ 'collection_id' ] , items_array ]
318+ end
319+
320+ def build_remove_items_values ( data )
321+ [ data [ 'collection_id' ] , data [ 'ethscription_ids' ] ]
322+ end
323+
324+ def build_edit_collection_values ( data )
325+ [
326+ data [ 'collection_id' ] ,
327+ data [ 'description' ] ,
328+ data [ 'logo_image_uri' ] ,
329+ data [ 'banner_image_uri' ] ,
330+ data [ 'background_color' ] ,
331+ data [ 'website_link' ] ,
332+ data [ 'twitter_link' ] ,
333+ data [ 'discord_link' ]
334+ ]
335+ end
336+
337+ def build_edit_collection_item_values ( data )
338+ [
339+ data [ 'collection_id' ] ,
340+ data [ 'item_index' ] ,
341+ data [ 'name' ] ,
342+ data [ 'background_color' ] ,
343+ data [ 'description' ] ,
344+ data [ 'attributes' ]
345+ ]
346+ end
347+
348+ def build_lock_collection_values ( data )
349+ # Single bytes32, not a tuple - but we need to return just the value
350+ data [ 'collection_id' ]
351+ end
352+
353+ def build_sync_ownership_values ( data )
354+ [ data [ 'collection_id' ] , data [ 'ethscription_ids' ] ]
355+ end
356+ end
0 commit comments