Skip to content

Commit 24b4aa6

Browse files
committed
Simplify
1 parent 823f06e commit 24b4aa6

19 files changed

+2866
-150
lines changed
Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
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?(/\A0x[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?(/\A0x[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

Comments
 (0)