Skip to content

Commit 7e55b6c

Browse files
committed
Enhance ERC721 Ethscriptions Collection Parser with Initial Owner Support
- Updated the `Erc721EthscriptionsCollectionParser` to include `initial_owner` in the `create_collection` operation schema and ABI type. - Modified the `validate_and_encode` method to accept an `eth_transaction` parameter for determining the initial owner context. - Enhanced metadata handling to support ownership renouncement and validation of optional addresses. - Updated tests to reflect changes in the collection creation process, ensuring proper handling of the new `initial_owner` field.
1 parent 035e154 commit 7e55b6c

14 files changed

+148
-64
lines changed

app/models/erc721_ethscriptions_collection_parser.rb

Lines changed: 56 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ class Erc721EthscriptionsCollectionParser
1212
# Operation schemas defining exact structure and ABI encoding
1313
OPERATION_SCHEMAS = {
1414
'create_collection' => {
15-
keys: %w[name symbol max_supply description logo_image_uri banner_image_uri background_color website_link twitter_link discord_link merkle_root],
16-
abi_type: '(string,string,uint256,string,string,string,string,string,string,string,bytes32)',
15+
keys: %w[name symbol max_supply description logo_image_uri banner_image_uri background_color website_link twitter_link discord_link merkle_root initial_owner],
16+
abi_type: '(string,string,uint256,string,string,string,string,string,string,string,bytes32,address)',
1717
validators: {
1818
'name' => :string,
1919
'symbol' => :string,
@@ -25,14 +25,15 @@ class Erc721EthscriptionsCollectionParser
2525
'website_link' => :string,
2626
'twitter_link' => :string,
2727
'discord_link' => :string,
28-
'merkle_root' => :bytes32
28+
'merkle_root' => :bytes32,
29+
'initial_owner' => :optional_address
2930
}
3031
},
3132
# New combined create op name used by the contract; keep legacy alias below
3233
'create_collection_and_add_self' => {
3334
keys: %w[metadata item],
34-
# ((CollectionParams),(ItemData)) - ItemData now includes contentHash as first field
35-
abi_type: '((string,string,uint256,string,string,string,string,string,string,string,bytes32),(bytes32,uint256,string,string,string,(string,string)[],bytes32[]))',
35+
# ((CollectionParams),(ItemData)) - CollectionParams now includes initialOwner
36+
abi_type: '((string,string,uint256,string,string,string,string,string,string,string,bytes32,address),(bytes32,uint256,string,string,string,(string,string)[],bytes32[]))',
3637
validators: {
3738
'metadata' => :collection_metadata,
3839
'item' => :single_item
@@ -41,7 +42,7 @@ class Erc721EthscriptionsCollectionParser
4142
# Legacy alias retained for backwards compatibility
4243
'create_and_add_self' => {
4344
keys: %w[metadata item],
44-
abi_type: '((string,string,uint256,string,string,string,string,string,string,string,bytes32),(bytes32,uint256,string,string,string,(string,string)[],bytes32[]))',
45+
abi_type: '((string,string,uint256,string,string,string,string,string,string,string,bytes32,address),(bytes32,uint256,string,string,string,(string,string)[],bytes32[]))',
4546
validators: {
4647
'metadata' => :collection_metadata,
4748
'item' => :single_item
@@ -132,21 +133,22 @@ class ValidationError < StandardError; end
132133

133134
# New API: validate and encode protocol params
134135
# Unified interface - accepts all possible parameters, uses what it needs
135-
def self.validate_and_encode(decoded_content:, operation:, params:, source:, ethscription_id: nil, **_extras)
136+
def self.validate_and_encode(decoded_content:, operation:, params:, source:, ethscription_id: nil, eth_transaction: nil, **_extras)
136137
new.validate_and_encode(
137138
decoded_content: decoded_content,
138139
operation: operation,
139140
params: params,
140141
source: source,
141-
ethscription_id: ethscription_id
142+
ethscription_id: ethscription_id,
143+
eth_transaction: eth_transaction
142144
)
143145
end
144146

145-
def validate_and_encode(decoded_content:, operation:, params:, source:, ethscription_id: nil)
147+
def validate_and_encode(decoded_content:, operation:, params:, source:, ethscription_id: nil, eth_transaction: nil)
146148
# Check import fallback first (if ethscription_id provided)
147149
if ethscription_id
148150
normalized_id = normalize_id(ethscription_id)
149-
if normalized_id && (preplanned = build_import_encoded_params(normalized_id, decoded_content))
151+
if normalized_id && (preplanned = build_import_encoded_params(normalized_id, decoded_content, eth_transaction))
150152
return preplanned
151153
end
152154
end
@@ -217,7 +219,7 @@ def normalize_id(value)
217219
# -------------------- Import fallback --------------------
218220

219221
# Returns [protocol, operation, encoded_data] or nil
220-
def build_import_encoded_params(id, decoded_content)
222+
def build_import_encoded_params(id, decoded_content, eth_transaction = nil)
221223
data = self.class.load_import_data(
222224
items_path: DEFAULT_ITEMS_PATH,
223225
collections_path: DEFAULT_COLLECTIONS_PATH
@@ -247,7 +249,7 @@ def build_import_encoded_params(id, decoded_content)
247249
operation = 'create_collection_and_add_self'
248250
schema = OPERATION_SCHEMAS[operation]
249251
encoding_data = {
250-
'metadata' => build_metadata_object(metadata),
252+
'metadata' => build_metadata_object(metadata, eth_transaction: eth_transaction),
251253
'item' => build_item_object(item: item, item_index: item_index, content_hash: content_hash)
252254
}
253255
encoded_data = encode_operation(operation, encoding_data, schema, content_hash: content_hash)
@@ -355,7 +357,7 @@ def load_import_data(items_path:, collections_path:)
355357
end
356358

357359
# Build ordered JSON objects to match strict parser expectations
358-
def build_metadata_object(meta)
360+
def build_metadata_object(meta, eth_transaction: nil)
359361
name = safe_string(meta['name'])
360362
symbol = safe_string(meta['symbol'] || meta['slug'] || meta['name'])
361363
max_supply = safe_uint_string(meta['max_supply'] || meta['total_supply'] || 0)
@@ -381,6 +383,24 @@ def build_metadata_object(meta)
381383
]
382384
merkle_root = meta.fetch('merkle_root')
383385
result['merkle_root'] = to_bytes32_hex(merkle_root)
386+
387+
# Handle initial_owner based on should_renounce flag
388+
if meta['should_renounce'] == true
389+
# address(0) means renounce ownership
390+
result['initial_owner'] = '0x0000000000000000000000000000000000000000'
391+
elsif meta['initial_owner']
392+
# Use explicitly specified initial owner
393+
result['initial_owner'] = to_address_hex(meta['initial_owner'])
394+
elsif eth_transaction && eth_transaction.respond_to?(:from_address)
395+
# Use the transaction sender as the actual owner
396+
result['initial_owner'] = to_address_hex(eth_transaction.from_address)
397+
else
398+
# No transaction context - this shouldn't happen in production
399+
# For import, we always have the transaction
400+
# Return nil to indicate we can't determine the owner
401+
raise ValidationError, "Cannot determine initial owner without transaction context"
402+
end
403+
384404
result
385405
end
386406

@@ -408,6 +428,12 @@ def to_bytes32_hex(val)
408428
h
409429
end
410430

431+
def to_address_hex(val)
432+
h = safe_string(val).downcase
433+
raise ValidationError, "Invalid address hex: #{val}" unless h.match?(/\A0x[0-9a-f]{40}\z/)
434+
h
435+
end
436+
411437
# Integer coercion helper for import computations
412438
def safe_uint(val)
413439
case val
@@ -564,6 +590,14 @@ def validate_address(value, field_name)
564590
value.downcase
565591
end
566592

593+
def validate_optional_address(value, field_name)
594+
unless value.is_a?(String) && value.match?(/\A0x[0-9a-f]{40}\z/i)
595+
raise ValidationError, "Invalid address for #{field_name}: #{value}"
596+
end
597+
# Allow address(0) for renouncement
598+
value.downcase
599+
end
600+
567601
def validate_bytes32_array(value, field_name)
568602
unless value.is_a?(Array)
569603
raise ValidationError, "Expected array for #{field_name}"
@@ -598,8 +632,8 @@ def validate_collection_metadata(value, field_name)
598632
unless value.is_a?(Hash)
599633
raise ValidationError, "Expected object for #{field_name}"
600634
end
601-
# Expected keys for metadata (merkle_root optional)
602-
expected_keys = %w[name symbol max_supply description logo_image_uri banner_image_uri background_color website_link twitter_link discord_link merkle_root]
635+
# Expected keys for metadata (now includes initial_owner)
636+
expected_keys = %w[name symbol max_supply description logo_image_uri banner_image_uri background_color website_link twitter_link discord_link merkle_root initial_owner]
603637
unless value.keys == expected_keys
604638
raise ValidationError, "Invalid metadata keys or order"
605639
end
@@ -615,7 +649,8 @@ def validate_collection_metadata(value, field_name)
615649
websiteLink: validate_string(value['website_link'], 'website_link'),
616650
twitterLink: validate_string(value['twitter_link'], 'twitter_link'),
617651
discordLink: validate_string(value['discord_link'], 'discord_link'),
618-
merkleRoot: validate_bytes32(value['merkle_root'], 'merkle_root')
652+
merkleRoot: validate_bytes32(value['merkle_root'], 'merkle_root'),
653+
initialOwner: validate_optional_address(value['initial_owner'], 'initial_owner')
619654
}
620655
end
621656

@@ -681,15 +716,16 @@ def build_create_collection_values(data)
681716
data['website_link'],
682717
data['twitter_link'],
683718
data['discord_link'],
684-
data['merkle_root']
719+
data['merkle_root'],
720+
data['initial_owner']
685721
]
686722
end
687723

688724
def build_create_and_add_self_values(data, content_hash:)
689725
meta = data['metadata']
690726
item = data['item']
691727

692-
# Metadata tuple with optional merkleRoot
728+
# Metadata tuple with merkleRoot and initialOwner
693729
merkle_root = meta[:merkleRoot] || ["".ljust(64, '0')].pack('H*')
694730
metadata_tuple = [
695731
meta[:name],
@@ -702,7 +738,8 @@ def build_create_and_add_self_values(data, content_hash:)
702738
meta[:websiteLink],
703739
meta[:twitterLink],
704740
meta[:discordLink],
705-
merkle_root
741+
merkle_root,
742+
meta[:initialOwner]
706743
]
707744

708745
# Item tuple - contentHash comes first (keccak256 of ethscription content)

app/models/ethscription_transaction.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,10 +180,10 @@ def build_create_calldata
180180
esip6 = DataUri.esip6?(content_uri) || false
181181

182182
# Extract protocol params - returns [protocol, operation, encoded_data]
183-
# Pass the ethscription_id context so parsers can inject it when needed
183+
# Pass the eth_transaction for context (includes from_address and transaction_hash)
184184
protocol, operation, encoded_data = ProtocolParser.for_calldata(
185185
content_uri,
186-
ethscription_id: eth_transaction.transaction_hash
186+
eth_transaction: eth_transaction
187187
)
188188

189189
# Hash the content for protocol uniqueness

app/models/protocol_parser.rb

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ class ProtocolParser
1111
'erc-721-ethscriptions-collection' => Erc721EthscriptionsCollectionParser
1212
}.freeze
1313

14-
def self.extract(content_uri, ethscription_id: nil)
14+
def self.extract(content_uri, eth_transaction: nil, ethscription_id: nil)
1515
# Parse data URI and extract protocol info
1616
parsed = parse_data_uri_and_protocol(content_uri)
1717

@@ -33,7 +33,8 @@ def self.extract(content_uri, ethscription_id: nil)
3333
operation: nil,
3434
params: {},
3535
source: :json,
36-
ethscription_id: ethscription_id
36+
ethscription_id: ethscription_id,
37+
eth_transaction: eth_transaction
3738
)
3839

3940
if encoded != DEFAULT_PARAMS
@@ -61,7 +62,8 @@ def self.extract(content_uri, ethscription_id: nil)
6162
operation: parsed[:operation],
6263
params: parsed[:params],
6364
source: parsed[:source],
64-
ethscription_id: ethscription_id
65+
ethscription_id: ethscription_id,
66+
eth_transaction: eth_transaction
6567
)
6668

6769
# Check if parsing succeeded
@@ -83,8 +85,10 @@ def self.extract(content_uri, ethscription_id: nil)
8385

8486
# Get protocol data formatted for L2 calldata
8587
# Returns [protocol, operation, encoded_data] for contract consumption
86-
def self.for_calldata(content_uri, ethscription_id: nil)
87-
result = extract(content_uri, ethscription_id: ethscription_id)
88+
def self.for_calldata(content_uri, eth_transaction: nil, ethscription_id: nil)
89+
# Support both for backward compatibility
90+
ethscription_id ||= eth_transaction&.transaction_hash
91+
result = extract(content_uri, eth_transaction: eth_transaction, ethscription_id: ethscription_id)
8892

8993
if result.nil?
9094
# No protocol detected - return empty protocol params

contracts/src/ERC721EthscriptionsCollection.sol

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,14 @@ contract ERC721EthscriptionsCollection is ERC721EthscriptionsEnumerableUpgradeab
4545
bytes32 collectionId_
4646
) external initializer {
4747
__ERC721_init(name_, symbol_);
48-
__Ownable_init(initialOwner_);
48+
49+
if (initialOwner_ == address(0)) {
50+
__Ownable_init(address(1));
51+
_transferOwnership(address(0));
52+
} else {
53+
__Ownable_init(initialOwner_);
54+
}
55+
4956
manager = ERC721EthscriptionsCollectionManager(msg.sender);
5057
collectionId = collectionId_;
5158
}

contracts/src/ERC721EthscriptionsCollectionManager.sol

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ contract ERC721EthscriptionsCollectionManager is IProtocolHandler {
3131
string twitterLink;
3232
string discordLink;
3333
bytes32 merkleRoot;
34+
address initialOwner;
3435
}
3536

3637
struct CollectionRecord {
@@ -345,21 +346,20 @@ contract ERC721EthscriptionsCollectionManager is IProtocolHandler {
345346

346347
// -------------------- Helpers --------------------
347348

348-
function _createCollection(bytes32 collectionId, CollectionParams memory metadata) internal {
349-
require(!collectionExists(collectionId), "Collection already exists");
349+
function _initializeCollection(Proxy collectionProxy, bytes32 collectionId, CollectionParams memory metadata) private {
350350

351-
Proxy collectionProxy = new Proxy{salt: collectionId}(address(this));
352-
353351
collectionProxy.upgradeToAndCall(collectionsImplementation, abi.encodeWithSelector(
354352
ERC721EthscriptionsCollection.initialize.selector,
355353
metadata.name,
356354
metadata.symbol,
357-
ethscriptions.ownerOf(collectionId),
355+
metadata.initialOwner,
358356
collectionId
359357
));
360-
358+
361359
collectionProxy.changeAdmin(Predeploys.PROXY_ADMIN);
360+
}
362361

362+
function _storeCollectionData(bytes32 collectionId, address collectionContract, CollectionParams memory metadata) private {
363363
// Store string fields using DedupedBlobStore
364364
(, bytes32 nameRef) = DedupedBlobStore.storeMemory(bytes(metadata.name), collectionBlobStorage);
365365
(, bytes32 symbolRef) = DedupedBlobStore.storeMemory(bytes(metadata.symbol), collectionBlobStorage);
@@ -372,7 +372,7 @@ contract ERC721EthscriptionsCollectionManager is IProtocolHandler {
372372
(, bytes32 discordLinkRef) = DedupedBlobStore.storeMemory(bytes(metadata.discordLink), collectionBlobStorage);
373373

374374
collectionStore[collectionId] = CollectionRecord({
375-
collectionContract: address(collectionProxy),
375+
collectionContract: collectionContract,
376376
locked: false,
377377
nameRef: nameRef,
378378
symbolRef: symbolRef,
@@ -386,7 +386,19 @@ contract ERC721EthscriptionsCollectionManager is IProtocolHandler {
386386
discordLinkRef: discordLinkRef,
387387
merkleRoot: metadata.merkleRoot
388388
});
389-
389+
}
390+
391+
function _createCollection(bytes32 collectionId, CollectionParams memory metadata) internal {
392+
require(!collectionExists(collectionId), "Collection already exists");
393+
394+
Proxy collectionProxy = new Proxy{salt: collectionId}(address(this));
395+
396+
// Initialize the collection
397+
_initializeCollection(collectionProxy, collectionId, metadata);
398+
399+
// Store collection metadata
400+
_storeCollectionData(collectionId, address(collectionProxy), metadata);
401+
390402
collectionAddressToId[address(collectionProxy)] = collectionId;
391403
collectionIds.push(collectionId);
392404

contracts/test/AddressPrediction.t.sol

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ contract AddressPredictionTest is TestSetup {
7373
websiteLink: "https://example.com",
7474
twitterLink: "",
7575
discordLink: "",
76-
merkleRoot: bytes32(0)
76+
merkleRoot: bytes32(0),
77+
initialOwner: address(this) // Use test contract as owner
7778
});
7879

7980
// Manually compute predicted proxy address

contracts/test/CollectionURIResolution.t.sol

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,8 @@ contract CollectionURIResolutionTest is TestSetup {
170170
websiteLink: "",
171171
twitterLink: "",
172172
discordLink: "",
173-
merkleRoot: bytes32(0)
173+
merkleRoot: bytes32(0),
174+
initialOwner: alice // Use alice as owner
174175
});
175176

176177
string memory collectionContent = string.concat(

contracts/test/CollectionsManager.t.sol

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup {
4040
websiteLink: "https://example.com",
4141
twitterLink: "https://twitter.com/test",
4242
discordLink: "https://discord.gg/test",
43-
merkleRoot: bytes32(0)
43+
merkleRoot: bytes32(0),
44+
initialOwner: alice // Use alice as owner
4445
});
4546

4647
Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({
@@ -94,7 +95,8 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup {
9495
websiteLink: "https://example.com",
9596
twitterLink: "",
9697
discordLink: "",
97-
merkleRoot: bytes32(0)
98+
merkleRoot: bytes32(0),
99+
initialOwner: alice // Use alice as owner
98100
});
99101

100102
// Prepare item data
@@ -1317,7 +1319,8 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup {
13171319
websiteLink: "",
13181320
twitterLink: "",
13191321
discordLink: "",
1320-
merkleRoot: merkleRoot
1322+
merkleRoot: merkleRoot,
1323+
initialOwner: alice // Use alice as owner
13211324
});
13221325

13231326
string memory collectionContent =

0 commit comments

Comments
 (0)