Skip to content

Commit 466e4fc

Browse files
committed
Refactor ERC721 Ethscriptions Collection Parser and Protocol Extractor
- Removed `ethscription_id` from item data structures in the `Erc721EthscriptionsCollectionParser` to streamline item management. - Updated ABI types for various operations to reflect changes in item data handling. - Enhanced error logging for ABI encoding issues in both `Erc721EthscriptionsCollectionParser` and `EthscriptionTransaction`. - Simplified the `ProtocolExtractor` by removing unnecessary validations and directly extracting protocol parameters. - Improved test coverage for new item handling and collection operations, ensuring robust integration with the collections protocol.
1 parent 2ffa46e commit 466e4fc

9 files changed

Lines changed: 386 additions & 217 deletions

app/models/erc721_ethscriptions_collection_parser.rb

Lines changed: 103 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -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)) with ethscription_id in ItemData
32-
abi_type: '((string,string,uint256,string,string,string,string,string,string,string,bytes32),(uint256,string,bytes32,string,string,(string,string)[],bytes32[]))',
31+
# ((CollectionParams),(ItemData)) - ItemData without ethscription_id (item refers to itself)
32+
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,
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,bytes32,string,string,(string,string)[],bytes32[]))',
41+
abi_type: '((string,string,uint256,string,string,string,string,string,string,string,bytes32),(uint256,string,string,string,(string,string)[],bytes32[]))',
4242
validators: {
4343
'metadata' => :collection_metadata,
4444
'item' => :single_item
@@ -47,21 +47,12 @@ 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,bytes32,string,string,(string,string)[],bytes32[]))',
50+
abi_type: '(bytes32,(uint256,string,string,string,(string,string)[],bytes32[]))',
5151
validators: {
5252
'collection_id' => :bytes32,
5353
'item' => :single_item
5454
}
5555
},
56-
'add_items_batch' => {
57-
keys: %w[collection_id items],
58-
# Includes ethscription_id in each item
59-
abi_type: '(bytes32,(uint256,string,bytes32,string,string,(string,string)[],bytes32[])[])',
60-
validators: {
61-
'collection_id' => :bytes32,
62-
'items' => :items_array
63-
}
64-
},
6556
'remove_items' => {
6657
keys: %w[collection_id ethscription_ids],
6758
abi_type: '(bytes32,bytes32[])',
@@ -103,37 +94,29 @@ class Erc721EthscriptionsCollectionParser
10394
validators: {
10495
'collection_id' => :bytes32
10596
}
106-
},
107-
'sync_ownership' => {
108-
keys: %w[collection_id ethscription_ids],
109-
abi_type: '(bytes32,bytes32[])',
110-
validators: {
111-
'collection_id' => :bytes32,
112-
'ethscription_ids' => :bytes32_array
113-
}
11497
}
11598
}.freeze
11699

117-
# Item keys for validation (includes ethscription_id; item must refer to itself)
118-
ITEM_KEYS_MIN = %w[item_index name ethscription_id background_color description attributes].freeze
119-
ITEM_KEYS_WITH_PROOF = %w[item_index name ethscription_id background_color description attributes merkle_proof].freeze
100+
# Item keys for validation (ethscription_id removed - item refers to itself)
101+
# merkle_proof is always required (can be empty array)
102+
ITEM_KEYS = %w[item_index name background_color description attributes merkle_proof].freeze
120103

121104
# Attribute keys for NFT metadata
122105
ATTRIBUTE_KEYS = %w[trait_type value].freeze
123106

124107
class ValidationError < StandardError; end
125108

126-
DEFAULT_ITEMS_PATH = ENV['COLLECTIONS_ITEMS_PATH'] || 'items_by_ethscription.json'
127-
DEFAULT_COLLECTIONS_PATH = ENV['COLLECTIONS_META_PATH'] || 'collections_by_name.json'
109+
DEFAULT_ITEMS_PATH = ENV['COLLECTIONS_ITEMS_PATH'] || Rails.root.join('items_by_ethscription.json')
110+
DEFAULT_COLLECTIONS_PATH = ENV['COLLECTIONS_META_PATH'] || Rails.root.join('collections_by_name.json')
128111

129-
def self.extract(content_uri, ethscription_id: nil, import_fallback: false, items_path: DEFAULT_ITEMS_PATH, collections_path: DEFAULT_COLLECTIONS_PATH, cutoff_number: nil)
130-
new.extract(content_uri, ethscription_id: ethscription_id, import_fallback: import_fallback, items_path: items_path, collections_path: collections_path, cutoff_number: cutoff_number)
112+
def self.extract(content_uri, ethscription_id: nil)
113+
new.extract(content_uri, ethscription_id: ethscription_id)
131114
end
132115

133-
def extract(content_uri, ethscription_id: nil, import_fallback: false, items_path: DEFAULT_ITEMS_PATH, collections_path: DEFAULT_COLLECTIONS_PATH, cutoff_number: nil)
134-
# Import-aware path takes precedence if enabled and id is provided
135-
if import_fallback && ethscription_id
136-
if (encoded = build_import_encoded_params(ethscription_id.to_s.downcase, items_path: items_path, collections_path: collections_path, cutoff_number: cutoff_number))
116+
def extract(content_uri, ethscription_id: nil)
117+
# Import-aware path takes precedence if id is provided
118+
if ethscription_id
119+
if (encoded = build_import_encoded_params(ethscription_id.to_hex))
137120
return encoded
138121
end
139122
end
@@ -169,7 +152,7 @@ def extract(content_uri, ethscription_id: nil, import_fallback: false, items_pat
169152

170153
# Remove protocol fields for encoding
171154
encoding_data = data.reject { |k, _| k == 'p' || k == 'op' }
172-
155+
173156
# Validate field types and encode
174157
encoded_data = encode_operation(operation, encoding_data, schema)
175158

@@ -181,31 +164,28 @@ def extract(content_uri, ethscription_id: nil, import_fallback: false, items_pat
181164
end
182165
end
183166

184-
private
185-
186167
# -------------------- Import fallback --------------------
187168

188169
# Returns [protocol, operation, encoded_data] or nil
189-
def build_import_encoded_params(id, items_path:, collections_path:, cutoff_number: nil)
190-
load_import_data(items_path: items_path, collections_path: collections_path)
170+
def build_import_encoded_params(id)
171+
data = self.class.load_import_data(
172+
items_path: DEFAULT_ITEMS_PATH,
173+
collections_path: DEFAULT_COLLECTIONS_PATH
174+
)
191175

192-
item = @__items_by_id[id]
176+
item = data[:items_by_id][id]
193177
return nil unless item
194178

195-
if cutoff_number && safe_uint(item['ethscription_number']) > cutoff_number
196-
return nil
197-
end
198-
199179
coll_name = item['collection_name']
200180
return nil unless coll_name
201181

202-
leader_id = @__leader_by_collection[coll_name]
182+
leader_id = data[:leader_by_collection][coll_name]
203183
return nil unless leader_id
204184

205-
item_index = @__zero_index_by_id[id] || 0
185+
item_index = data[:zero_index_by_id][id] || 0
206186

207187
if id == leader_id
208-
metadata = @__collections_by_name[coll_name]
188+
metadata = data[:collections_by_name][coll_name]
209189
return nil unless metadata
210190
operation = 'create_collection_and_add_self'
211191
schema = OPERATION_SCHEMAS[operation]
@@ -227,50 +207,53 @@ def build_import_encoded_params(id, items_path:, collections_path:, cutoff_numbe
227207
end
228208
end
229209

230-
def load_import_data(items_path:, collections_path:)
231-
# Memoize parsed JSON and derived leader/index maps
232-
if @__loaded_items_path == items_path && @__loaded_collections_path == collections_path && @__items_by_id
233-
return
234-
end
210+
class << self
211+
include Memery
235212

236-
items = JSON.parse(File.read(items_path))
237-
collections = JSON.parse(File.read(collections_path))
213+
def load_import_data(items_path:, collections_path:)
214+
items = JSON.parse(File.read(items_path))
215+
collections = JSON.parse(File.read(collections_path))
238216

239-
@__items_by_id = {}
240-
items.each { |k, v| @__items_by_id[k.to_s.downcase] = v }
241-
@__collections_by_name = collections
217+
items_by_id = {}
218+
items.each { |k, v| items_by_id[k.to_s.downcase] = v }
242219

243-
# Group items by collection and derive leader (min ethscription_number)
244-
groups = Hash.new { |h, k| h[k] = [] }
245-
@__items_by_id.each do |iid, it|
246-
cname = it['collection_name']
247-
next unless cname.is_a?(String) && !cname.empty?
248-
num = safe_uint(it['ethscription_number'])
249-
groups[cname] << [iid, num]
250-
end
220+
# Group items by collection and derive leader (min ethscription_number)
221+
groups = Hash.new { |h, k| h[k] = [] }
222+
items_by_id.each do |iid, it|
223+
cname = it['collection_name']
224+
next unless cname.is_a?(String) && !cname.empty?
225+
num = it['ethscription_number'].to_i
226+
groups[cname] << [iid, num]
227+
end
251228

252-
@__leader_by_collection = {}
253-
groups.each do |cname, pairs|
254-
next if pairs.empty?
255-
@__leader_by_collection[cname] = pairs.min_by { |_id, num| num }[0]
256-
end
229+
leader_by_collection = {}
230+
groups.each do |cname, pairs|
231+
next if pairs.empty?
232+
leader_by_collection[cname] = pairs.min_by { |_id, num| num }[0]
233+
end
257234

258-
# Normalize item indices to zero-based
259-
@__zero_index_by_id = {}
260-
groups.each do |_cname, pairs|
261-
explicit = pairs.map { |(iid, _)| [iid, @__items_by_id[iid]['index']] }
262-
explicit_indices = explicit.filter_map { |_iid, idx| idx if idx.is_a?(Integer) }
263-
if explicit_indices.size == pairs.size
264-
min_idx = explicit_indices.min
265-
offset = (min_idx == 0) ? 0 : 1
266-
explicit.each { |iid, idx| @__zero_index_by_id[iid] = [idx - offset, 0].max }
267-
else
268-
pairs.sort_by { |_iid, num| num }.each_with_index { |(iid, _), i| @__zero_index_by_id[iid] = i }
235+
# Normalize item indices to zero-based
236+
zero_index_by_id = {}
237+
groups.each do |_cname, pairs|
238+
explicit = pairs.map { |(iid, _)| [iid, items_by_id[iid]['index']] }
239+
explicit_indices = explicit.filter_map { |_iid, idx| idx if idx.is_a?(Integer) }
240+
if explicit_indices.size == pairs.size
241+
min_idx = explicit_indices.min
242+
offset = (min_idx == 0) ? 0 : 1
243+
explicit.each { |iid, idx| zero_index_by_id[iid] = [idx - offset, 0].max }
244+
else
245+
pairs.sort_by { |_iid, num| num }.each_with_index { |(iid, _), i| zero_index_by_id[iid] = i }
246+
end
269247
end
270-
end
271248

272-
@__loaded_items_path = items_path
273-
@__loaded_collections_path = collections_path
249+
{
250+
items_by_id: items_by_id,
251+
collections_by_name: collections,
252+
leader_by_collection: leader_by_collection,
253+
zero_index_by_id: zero_index_by_id
254+
}
255+
end
256+
memoize :load_import_data
274257
end
275258

276259
# Build ordered JSON objects to match strict parser expectations
@@ -308,10 +291,10 @@ def build_item_object(item:, item_id:, item_index:)
308291
OrderedHash[
309292
'item_index', safe_uint_string(item_index),
310293
'name', safe_string(item['name']),
311-
'ethscription_id', to_bytes32_hex(item_id),
312294
'background_color', safe_string(item['background_color']),
313295
'description', safe_string(item['description']),
314-
'attributes', attrs
296+
'attributes', attrs,
297+
'merkle_proof', []
315298
]
316299
end
317300

@@ -370,8 +353,6 @@ def encode_operation(operation, data, schema)
370353
build_create_collection_values(validated_data)
371354
when 'create_collection_and_add_self', 'create_and_add_self'
372355
build_create_and_add_self_values(validated_data)
373-
when 'add_items_batch'
374-
build_add_items_batch_values(validated_data)
375356
when 'add_self_to_collection'
376357
build_add_self_to_collection_values(validated_data)
377358
when 'remove_items'
@@ -382,14 +363,38 @@ def encode_operation(operation, data, schema)
382363
build_edit_collection_item_values(validated_data)
383364
when 'lock_collection'
384365
build_lock_collection_values(validated_data)
385-
when 'sync_ownership'
386-
build_sync_ownership_values(validated_data)
387366
else
388367
raise ValidationError, "Unknown operation: #{operation}"
389368
end
390369

391370
# Use ABI type from schema for encoding
392-
Eth::Abi.encode([schema[:abi_type]], [values])
371+
begin
372+
Eth::Abi.encode([schema[:abi_type]], [values])
373+
rescue Encoding::CompatibilityError => e
374+
Rails.logger.error "=== Collection ABI Encoding Error ==="
375+
Rails.logger.error "Error: #{e.message}"
376+
Rails.logger.error "operation: #{operation}"
377+
Rails.logger.error "schema abi_type: #{schema[:abi_type]}"
378+
Rails.logger.error "values inspection:"
379+
log_encoding_details(values)
380+
raise
381+
end
382+
end
383+
384+
def log_encoding_details(obj, indent = 0)
385+
prefix = " " * indent
386+
case obj
387+
when Array
388+
Rails.logger.error "#{prefix}Array[#{obj.size}]:"
389+
obj.each_with_index do |item, idx|
390+
Rails.logger.error "#{prefix} [#{idx}]:"
391+
log_encoding_details(item, indent + 2)
392+
end
393+
when String
394+
Rails.logger.error "#{prefix}String: #{obj.inspect[0..100]}, encoding: #{obj.encoding.name}, bytesize: #{obj.bytesize}"
395+
else
396+
Rails.logger.error "#{prefix}#{obj.class}: #{obj.inspect[0..100]}"
397+
end
393398
end
394399

395400
def validate_fields(data, validators)
@@ -415,7 +420,7 @@ def validate_string(value, field_name)
415420
unless value.is_a?(String)
416421
raise ValidationError, "Field #{field_name} must be a string, got #{value.class.name}"
417422
end
418-
value
423+
value.b
419424
end
420425

421426
def validate_uint256(value, field_name)
@@ -499,31 +504,21 @@ def validate_item(item)
499504
raise ValidationError, "Item must be an object"
500505
end
501506

502-
# Check exact key order; allow optional merkle_proof
503-
has_proof =
504-
if item.keys == ITEM_KEYS_WITH_PROOF
505-
true
506-
elsif item.keys == ITEM_KEYS_MIN
507-
false
508-
else
509-
expected = "[#{ITEM_KEYS_MIN.join(', ')}] or [#{ITEM_KEYS_WITH_PROOF.join(', ')}]"
510-
raise ValidationError, "Invalid item keys or order. Expected: #{expected}, got: [#{item.keys.join(', ')}]"
511-
end
507+
# Check exact key order - merkle_proof is always required
508+
unless item.keys == ITEM_KEYS
509+
expected = "[#{ITEM_KEYS.join(', ')}]"
510+
raise ValidationError, "Invalid item keys or order. Expected: #{expected}, got: [#{item.keys.join(', ')}]"
511+
end
512512

513513
# Validate each field - return in internal format for encoding
514-
result = {
514+
{
515515
itemIndex: validate_uint256(item['item_index'], 'item_index'),
516516
name: validate_string(item['name'], 'name'),
517-
ethscriptionId: validate_bytes32(item['ethscription_id'], 'ethscription_id'),
518517
backgroundColor: validate_string(item['background_color'], 'background_color'),
519518
description: validate_string(item['description'], 'description'),
520-
attributes: validate_attributes_array(item['attributes'], 'attributes')
519+
attributes: validate_attributes_array(item['attributes'], 'attributes'),
520+
merkleProof: validate_bytes32_array(item['merkle_proof'], 'merkle_proof')
521521
}
522-
523-
# Optional merkle proof; always include as bytes32[] (empty when omitted)
524-
result[:merkleProof] = has_proof ? validate_bytes32_array(item['merkle_proof'], 'merkle_proof') : []
525-
526-
result
527522
end
528523

529524
def validate_attributes_array(value, field_name)
@@ -595,7 +590,6 @@ def build_create_and_add_self_values(data)
595590
item_tuple = [
596591
item[:itemIndex],
597592
item[:name],
598-
item[:ethscriptionId],
599593
item[:backgroundColor],
600594
item[:description],
601595
item[:attributes],
@@ -610,7 +604,6 @@ def build_add_self_to_collection_values(data)
610604
item_tuple = [
611605
item[:itemIndex],
612606
item[:name],
613-
item[:ethscriptionId],
614607
item[:backgroundColor],
615608
item[:description],
616609
item[:attributes],
@@ -625,7 +618,6 @@ def build_add_items_batch_values(data)
625618
[
626619
item[:itemIndex],
627620
item[:name],
628-
item[:ethscriptionId],
629621
item[:backgroundColor],
630622
item[:description],
631623
item[:attributes],
@@ -672,8 +664,4 @@ def build_lock_collection_values(data)
672664
# Single bytes32, not a tuple - but we need to return just the value
673665
data['collection_id']
674666
end
675-
676-
def build_sync_ownership_values(data)
677-
[data['collection_id'], data['ethscription_ids']]
678-
end
679667
end

0 commit comments

Comments
 (0)