Skip to content

Commit 6d02714

Browse files
Merge pull request #140 from ethscriptions-protocol/fix_collections
Refactor Protocol Parsing and Introduce Word Domains Support
2 parents 71d2611 + 1aa1898 commit 6d02714

22 files changed

Lines changed: 1620 additions & 382 deletions
Lines changed: 27 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,61 @@
11
# Extracts fixed-denomination ERC-20 parameters from strict JSON inscriptions.
22
class Erc20FixedDenominationParser
33
# Constants
4-
DEFAULT_PARAMS = [''.b, ''.b, ''.b, 0, 0, 0].freeze
4+
DEFAULT_PARAMS = [''.b, ''.b, ''.b].freeze
55
UINT256_MAX = 2**256 - 1
6-
6+
PROTOCOL = 'erc-20-fixed-denomination'.b
7+
78
# Exact regex patterns for valid formats
89
# Protocol must be "erc-20" (legacy inscription) or the canonical identifier
910
# Tick must be lowercase letters/numbers, max 28 chars
1011
# Numbers must be positive decimals without leading zeros
1112
PROTOCOL_PATTERN = '(?:erc-20|erc-20-fixed-denomination)'
12-
DEPLOY_REGEX = /\Adata:,\{"p":"#{PROTOCOL_PATTERN}","op":"deploy","tick":"([a-z0-9]{1,28})","max":"(0|[1-9][0-9]*)","lim":"(0|[1-9][0-9]*)"\}\z/
13-
MINT_REGEX = /\Adata:,\{"p":"#{PROTOCOL_PATTERN}","op":"mint","tick":"([a-z0-9]{1,28})","id":"(0|[1-9][0-9]*)","amt":"(0|[1-9][0-9]*)"\}\z/
13+
DEPLOY_REGEX = /\A\{"p":"#{PROTOCOL_PATTERN}","op":"deploy","tick":"([a-z0-9]{1,28})","max":"(0|[1-9][0-9]*)","lim":"(0|[1-9][0-9]*)"\}\z/
14+
MINT_REGEX = /\A\{"p":"#{PROTOCOL_PATTERN}","op":"mint","tick":"([a-z0-9]{1,28})","id":"(0|[1-9][0-9]*)","amt":"(0|[1-9][0-9]*)"\}\z/
15+
16+
# Validate and encode protocol params
17+
# Unified interface - accepts all possible parameters, uses what it needs
18+
def self.validate_and_encode(decoded_content:, operation:, params:, source:, ethscription_id: nil, **_extras)
19+
new.validate_and_encode(
20+
decoded_content: decoded_content,
21+
operation: operation,
22+
params: params,
23+
source: source
24+
)
25+
end
1426

15-
def self.extract(content_uri)
16-
return DEFAULT_PARAMS unless content_uri.is_a?(String)
27+
def validate_and_encode(decoded_content:, operation:, params:, source:)
28+
# Only support JSON source - no header parameters for ERC-20
29+
return DEFAULT_PARAMS unless source == :json
30+
return DEFAULT_PARAMS unless decoded_content.is_a?(String)
1731

1832
# Try deploy format first
19-
if match = DEPLOY_REGEX.match(content_uri)
33+
if match = DEPLOY_REGEX.match(decoded_content)
2034
tick = match[1] # Group 1: tick
2135
max = match[2].to_i # Group 2: max
2236
lim = match[3].to_i # Group 3: lim
2337

2438
# Validate uint256 bounds
2539
return DEFAULT_PARAMS if max > UINT256_MAX || lim > UINT256_MAX
2640

27-
return ['deploy'.b, 'erc-20-fixed-denomination'.b, tick.b, max, lim, 0]
41+
encoded = Eth::Abi.encode(['(string,uint256,uint256)'], [[tick.b, max, lim]])
42+
return [PROTOCOL, 'deploy'.b, encoded.b]
2843
end
2944

3045
# Try mint format
31-
if match = MINT_REGEX.match(content_uri)
46+
if match = MINT_REGEX.match(decoded_content)
3247
tick = match[1] # Group 1: tick
3348
id = match[2].to_i # Group 2: id
3449
amt = match[3].to_i # Group 3: amt
3550

3651
# Validate uint256 bounds
3752
return DEFAULT_PARAMS if id > UINT256_MAX || amt > UINT256_MAX
3853

39-
return ['mint'.b, 'erc-20-fixed-denomination'.b, tick.b, id, 0, amt]
54+
encoded = Eth::Abi.encode(['(string,uint256,uint256)'], [[tick.b, id, amt]])
55+
return [PROTOCOL, 'mint'.b, encoded.b]
4056
end
4157

4258
# No match - return default
4359
DEFAULT_PARAMS
4460
end
45-
46-
# Returns a hash representation of params that downstream services expect.
47-
def self.structured_params(params)
48-
op, _protocol, tick, val1, val2, val3 = params
49-
50-
case op
51-
when 'deploy'.b
52-
{
53-
op: op,
54-
tick: tick,
55-
max: val1,
56-
lim: val2,
57-
amt: 0
58-
}
59-
when 'mint'.b
60-
{
61-
op: op,
62-
tick: tick,
63-
id: val1,
64-
amt: val3
65-
}
66-
else
67-
nil
68-
end
69-
end
70-
71-
# Encodes params into the ABI tuple required by the manager contract.
72-
def self.encode_calldata(params)
73-
op, _protocol, tick, val1, val2, val3 = params
74-
75-
if op == 'deploy'.b
76-
Eth::Abi.encode(['(string,uint256,uint256)'], [[tick.b, val1, val2]])
77-
elsif op == 'mint'.b
78-
Eth::Abi.encode(['(string,uint256,uint256)'], [[tick.b, val1, val3]])
79-
else
80-
''.b
81-
end
82-
end
83-
end
61+
end

app/models/erc721_ethscriptions_collection_parser.rb

Lines changed: 98 additions & 36 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)) - 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

Comments
 (0)