Skip to content

Commit 64230ae

Browse files
committed
Implement Ownership Transfer and Renouncement in ERC721 Ethscriptions
- Added `transfer_ownership` and `renounce_ownership` operations to the `Erc721EthscriptionsCollectionParser` for managing collection ownership. - Introduced corresponding methods in the `ERC721EthscriptionsCollectionManager` to handle ownership transfers and renouncements, including validation for zero addresses. - Updated tests to cover new ownership functionalities, ensuring correct encoding and behavior for both operations. - Enhanced error handling for invalid ownership transfer attempts.
1 parent 9f25b1a commit 64230ae

8 files changed

+588
-16
lines changed

app/models/erc721_ethscriptions_collection_parser.rb

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,21 @@ class Erc721EthscriptionsCollectionParser
5353
'item' => :single_item
5454
}
5555
},
56+
'transfer_ownership' => {
57+
keys: %w[collection_id new_owner],
58+
abi_type: '(bytes32,address)',
59+
validators: {
60+
'collection_id' => :bytes32,
61+
'new_owner' => :address
62+
}
63+
},
64+
'renounce_ownership' => {
65+
keys: %w[collection_id],
66+
abi_type: 'bytes32',
67+
validators: {
68+
'collection_id' => :bytes32
69+
}
70+
},
5671
'remove_items' => {
5772
keys: %w[collection_id ethscription_ids],
5873
abi_type: '(bytes32,bytes32[])',
@@ -399,6 +414,10 @@ def encode_operation(operation, data, schema, content_hash: nil)
399414
build_create_and_add_self_values(validated_data, content_hash: content_hash)
400415
when 'add_self_to_collection'
401416
build_add_self_to_collection_values(validated_data, content_hash: content_hash)
417+
when 'transfer_ownership'
418+
build_transfer_ownership_values(validated_data)
419+
when 'renounce_ownership'
420+
build_renounce_ownership_values(validated_data)
402421
when 'remove_items'
403422
build_remove_items_values(validated_data)
404423
when 'edit_collection'
@@ -488,6 +507,18 @@ def validate_bytes32(value, field_name)
488507
[value[2..]].pack('H*')
489508
end
490509

510+
def validate_address(value, field_name)
511+
unless value.is_a?(String) && value.match?(/\A0x[0-9a-f]{40}\z/)
512+
raise ValidationError, "Invalid address for #{field_name}: #{value}"
513+
end
514+
515+
if value == '0x0000000000000000000000000000000000000000'
516+
raise ValidationError, "Address cannot be zero for #{field_name}"
517+
end
518+
519+
value.downcase
520+
end
521+
491522
def validate_bytes32_array(value, field_name)
492523
unless value.is_a?(Array)
493524
raise ValidationError, "Expected array for #{field_name}"
@@ -675,6 +706,14 @@ def build_add_self_to_collection_values(data, content_hash:)
675706
[data['collection_id'], item_tuple]
676707
end
677708

709+
def build_transfer_ownership_values(data)
710+
[data['collection_id'], data['new_owner']]
711+
end
712+
713+
def build_renounce_ownership_values(data)
714+
data['collection_id']
715+
end
716+
678717
def build_remove_items_values(data)
679718
[data['collection_id'], data['ethscription_ids']]
680719
end

contracts/src/ERC721EthscriptionsCollection.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ contract ERC721EthscriptionsCollection is ERC721EthscriptionsEnumerableUpgradeab
124124
',"',
125125
mediaType,
126126
'":"',
127-
mediaUri.escapeJSON(),
127+
mediaUri,
128128
'"'
129129
);
130130

contracts/src/ERC721EthscriptionsCollectionManager.sol

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ contract ERC721EthscriptionsCollectionManager is IProtocolHandler {
152152
event ItemsRemoved(bytes32 indexed collectionId, uint256 count, bytes32 updateTxHash);
153153
event CollectionEdited(bytes32 indexed collectionId);
154154
event CollectionLocked(bytes32 indexed collectionId);
155+
event OwnershipTransferred(bytes32 indexed collectionId, address indexed previousOwner, address indexed newOwner);
155156

156157
modifier onlyEthscriptions() {
157158
require(msg.sender == address(ethscriptions), "Only Ethscriptions contract");
@@ -184,6 +185,17 @@ contract ERC721EthscriptionsCollectionManager is IProtocolHandler {
184185
_addSingleItem(op.collectionId, ethscriptionId, op.item);
185186
}
186187

188+
function op_transfer_ownership(bytes32 ethscriptionId, bytes calldata data) external onlyEthscriptions {
189+
(bytes32 collectionId, address newOwner) = abi.decode(data, (bytes32, address));
190+
require(newOwner != address(0), "New owner cannot be zero address");
191+
_transferCollectionOwnership(ethscriptionId, collectionId, newOwner);
192+
}
193+
194+
function op_renounce_ownership(bytes32 ethscriptionId, bytes calldata data) external onlyEthscriptions {
195+
bytes32 collectionId = abi.decode(data, (bytes32));
196+
_transferCollectionOwnership(ethscriptionId, collectionId, address(0));
197+
}
198+
187199
function op_remove_items(bytes32 ethscriptionId, bytes calldata data) external onlyEthscriptions {
188200
RemoveItemsOperation memory removeOp = abi.decode(data, (RemoveItemsOperation));
189201
CollectionRecord storage collection = collectionStore[removeOp.collectionId];
@@ -266,12 +278,6 @@ contract ERC721EthscriptionsCollectionManager is IProtocolHandler {
266278
}
267279

268280
function onTransfer(bytes32 ethscriptionId, address from, address to) external override onlyEthscriptions {
269-
if (collectionExists(ethscriptionId)) {
270-
ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionStore[ethscriptionId].collectionContract);
271-
collection.factoryTransferOwnership(to);
272-
return;
273-
}
274-
275281
Membership storage membership = membershipOfEthscription[ethscriptionId];
276282

277283
if (!collectionExists(membership.collectionId)) {
@@ -454,6 +460,23 @@ contract ERC721EthscriptionsCollectionManager is IProtocolHandler {
454460
require(currentOwner == sender, errorMessage);
455461
}
456462

463+
function _transferCollectionOwnership(bytes32 ethscriptionId, bytes32 collectionId, address newOwner) private {
464+
CollectionRecord storage collection = collectionStore[collectionId];
465+
require(collection.collectionContract != address(0), "Collection does not exist");
466+
467+
address sender = _getEthscriptionCreator(ethscriptionId);
468+
ERC721EthscriptionsCollection collectionContract = ERC721EthscriptionsCollection(collection.collectionContract);
469+
address currentOwner = collectionContract.owner();
470+
require(currentOwner == sender, "Only collection owner can transfer");
471+
472+
if (newOwner == currentOwner) {
473+
revert("New owner must differ");
474+
}
475+
476+
collectionContract.factoryTransferOwnership(newOwner);
477+
emit OwnershipTransferred(collectionId, currentOwner, newOwner);
478+
}
479+
457480
function _verifyItemMerkleProof(ItemData memory item, bytes32 merkleRoot) private pure {
458481
require(merkleRoot != bytes32(0), "Merkle proof required");
459482

@@ -488,4 +511,3 @@ contract ERC721EthscriptionsCollectionManager is IProtocolHandler {
488511
return getCollection(collectionId);
489512
}
490513
}
491-

contracts/src/NameRegistry.sol

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
1212
/// @title NameRegistry
1313
/// @notice Handles legacy word-domain registrations and mirrors ownership as an ERC-721 collection.
1414
contract NameRegistry is ERC721EthscriptionsEnumerableUpgradeable, IProtocolHandler {
15-
using LibString for uint256;
15+
using LibString for *;
1616

1717
Ethscriptions public constant ethscriptions = Ethscriptions(Predeploys.ETHSCRIPTIONS);
1818

@@ -188,20 +188,27 @@ contract NameRegistry is ERC721EthscriptionsEnumerableUpgradeable, IProtocolHand
188188
// Get the ethscription data to extract the ethscription number
189189
Ethscriptions.Ethscription memory ethscription = ethscriptions.getEthscription(record.ethscriptionId, false);
190190

191+
// Get the media URI from the ethscription
192+
(string memory mediaType, string memory mediaUri) = ethscriptions.getMediaUri(record.ethscriptionId);
193+
191194
// Convert ethscriptionId to hex string (0x prefixed)
192195
string memory ethscriptionIdHex = uint256(record.ethscriptionId).toHexString(32);
193196

194197
bytes memory json = abi.encodePacked(
195198
'{"name":"',
196-
name,
199+
name.escapeJSON(),
197200
'","description":"Dotless word domain"',
198201
',"ethscription_id":"',
199202
ethscriptionIdHex,
200203
'","ethscription_number":',
201204
ethscription.ethscriptionNumber.toString(),
202-
',"attributes":[',
205+
',"',
206+
mediaType,
207+
'":"',
208+
mediaUri,
209+
'","attributes":[',
203210
'{"trait_type":"Name","value":"',
204-
name,
211+
name.escapeJSON(),
205212
'"}',
206213
']}'
207214
);

spec/integration/collections_protocol_e2e_spec.rb

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
require 'rails_helper'
2+
require_relative '../../lib/protocol_event_reader'
23

34
RSpec.describe "Collections Protocol End-to-End", type: :integration do
45
include EthscriptionsTestHelper
@@ -310,4 +311,188 @@ def create_and_validate_ethscription(creator:, to:, data_uri:)
310311
expect(item1[:attributes][0]).to eq(["Type", "Rare"])
311312
end
312313
end
314+
315+
describe "Merkle Root Enforcement" do
316+
let(:owner_merkle_root) { '0x' + '1' * 64 }
317+
318+
it "allows the collection owner to add an item without a proof when the root is set" do
319+
collection_data = {
320+
"p" => "erc-721-ethscriptions-collection",
321+
"op" => "create_collection",
322+
"name" => "Owner Only",
323+
"symbol" => "OWNR",
324+
"max_supply" => "10",
325+
"description" => "Testing owner bypass",
326+
"logo_image_uri" => "",
327+
"banner_image_uri" => "",
328+
"background_color" => "",
329+
"website_link" => "",
330+
"twitter_link" => "",
331+
"discord_link" => "",
332+
"merkle_root" => owner_merkle_root
333+
}
334+
335+
collection_spec = create_input(
336+
creator: alice,
337+
to: alice,
338+
data_uri: "data:," + JSON.generate(collection_data)
339+
)
340+
341+
collection_results = import_l1_block([collection_spec], esip_overrides: { esip6_is_enabled: true })
342+
expect(collection_results[:ethscription_ids]).not_to be_empty
343+
collection_id = collection_results[:ethscription_ids].first
344+
345+
owner_item = {
346+
"p" => "erc-721-ethscriptions-collection",
347+
"op" => "add_self_to_collection",
348+
"collection_id" => collection_id,
349+
"item" => {
350+
"item_index" => "0",
351+
"name" => "Owner Item #0",
352+
"background_color" => "#123456",
353+
"description" => "Inserted by the owner without a proof",
354+
"attributes" => [
355+
{"trait_type" => "Tier", "value" => "Owner"}
356+
],
357+
"merkle_proof" => []
358+
}
359+
}
360+
361+
owner_spec = create_input(
362+
creator: alice,
363+
to: alice,
364+
data_uri: "data:," + JSON.generate(owner_item)
365+
)
366+
367+
owner_results = import_l1_block([owner_spec], esip_overrides: { esip6_is_enabled: true })
368+
expect(owner_results[:ethscription_ids]).not_to be_empty
369+
owner_item_id = owner_results[:ethscription_ids].first
370+
371+
receipt = owner_results[:l2_receipts].first
372+
events = ProtocolEventReader.parse_receipt_events(receipt)
373+
expect(events.any? { |e| e[:event] == 'ProtocolHandlerFailed' }).to eq(false)
374+
expect(events.any? { |e| e[:event] == 'ProtocolHandlerSuccess' }).to eq(true)
375+
376+
added_event = events.find { |e| e[:event] == 'ItemsAdded' }
377+
expect(added_event).not_to be_nil
378+
expect(added_event[:count]).to eq(1)
379+
380+
stored_item = get_collection_item(collection_id, 0)
381+
expect(stored_item[:ethscriptionId]).to eq(owner_item_id)
382+
expect(stored_item[:name]).to eq("Owner Item #0")
383+
end
384+
385+
it "updates the merkle root via edit_collection to allow a non-owner add" do
386+
initial_merkle_root = zero_merkle_root
387+
collection_data = {
388+
"p" => "erc-721-ethscriptions-collection",
389+
"op" => "create_collection",
390+
"name" => "Editable Root",
391+
"symbol" => "EDIT",
392+
"max_supply" => "10",
393+
"description" => "Testing merkle root edits",
394+
"logo_image_uri" => "",
395+
"banner_image_uri" => "",
396+
"background_color" => "",
397+
"website_link" => "",
398+
"twitter_link" => "",
399+
"discord_link" => "",
400+
"merkle_root" => initial_merkle_root
401+
}
402+
403+
collection_spec = create_input(
404+
creator: alice,
405+
to: alice,
406+
data_uri: "data:," + JSON.generate(collection_data)
407+
)
408+
409+
collection_results = import_l1_block([collection_spec], esip_overrides: { esip6_is_enabled: true })
410+
collection_id = collection_results[:ethscription_ids].first
411+
expect(collection_id).to be_present
412+
metadata_before_edit = get_collection_metadata(collection_id)
413+
expect(metadata_before_edit[:merkleRoot].downcase).to eq(initial_merkle_root.downcase)
414+
415+
allowlist_attributes = [{"trait_type" => "Tier", "value" => "Founder"}]
416+
item_template = {
417+
"p" => "erc-721-ethscriptions-collection",
418+
"op" => "add_self_to_collection",
419+
"collection_id" => collection_id,
420+
"item" => {
421+
"item_index" => "0",
422+
"name" => "Allowlisted Item #0",
423+
"background_color" => "#abcdef",
424+
"description" => "Non-owner entry gated by the root",
425+
"attributes" => allowlist_attributes,
426+
"merkle_proof" => []
427+
}
428+
}
429+
430+
item_json = JSON.generate(item_template)
431+
content_hash_hex = "0x#{Eth::Util.keccak256(item_json).unpack1('H*')}"
432+
attribute_pairs = allowlist_attributes.map { |attr| [attr["trait_type"], attr["value"]] }
433+
computed_root = compute_single_leaf_root(
434+
content_hash_hex: content_hash_hex,
435+
item_index: 0,
436+
name: item_template["item"]["name"],
437+
background_color: item_template["item"]["background_color"],
438+
description: item_template["item"]["description"],
439+
attributes: attribute_pairs
440+
)
441+
442+
edit_payload = {
443+
"p" => "erc-721-ethscriptions-collection",
444+
"op" => "edit_collection",
445+
"collection_id" => collection_id,
446+
"description" => "",
447+
"logo_image_uri" => "",
448+
"banner_image_uri" => "",
449+
"background_color" => "",
450+
"website_link" => "",
451+
"twitter_link" => "",
452+
"discord_link" => "",
453+
"merkle_root" => computed_root
454+
}
455+
456+
edit_spec = create_input(
457+
creator: alice,
458+
to: alice,
459+
data_uri: "data:," + JSON.generate(edit_payload)
460+
)
461+
462+
edit_results = import_l1_block([edit_spec], esip_overrides: { esip6_is_enabled: true })
463+
expect(edit_results[:l2_receipts].first[:status]).to eq('0x1')
464+
465+
metadata_after_edit = get_collection_metadata(collection_id)
466+
expect(metadata_after_edit[:merkleRoot].downcase).to eq(computed_root.downcase)
467+
468+
second_spec = create_input(
469+
creator: bob,
470+
to: bob,
471+
data_uri: "data:," + item_json
472+
)
473+
474+
success_results = import_l1_block([second_spec], esip_overrides: { esip6_is_enabled: true })
475+
success_receipt = success_results[:l2_receipts].first
476+
success_events = ProtocolEventReader.parse_receipt_events(success_receipt)
477+
expect(success_events.any? { |e| e[:event] == 'ProtocolHandlerSuccess' }).to eq(true)
478+
added_event = success_events.find { |e| e[:event] == 'ItemsAdded' }
479+
expect(added_event).not_to be_nil
480+
expect(added_event[:count]).to eq(1)
481+
482+
added_item_id = success_results[:ethscription_ids].first
483+
stored_item = get_collection_item(collection_id, 0)
484+
expect(stored_item[:ethscriptionId]).to eq(added_item_id)
485+
486+
expect(get_collection_metadata(collection_id)[:merkleRoot].downcase).to eq(computed_root.downcase)
487+
end
488+
end
489+
490+
def compute_single_leaf_root(content_hash_hex:, item_index:, name:, background_color:, description:, attributes:)
491+
content_hash_bytes = [content_hash_hex.delete_prefix('0x')].pack('H*')
492+
encoded = Eth::Abi.encode(
493+
['bytes32', 'uint256', 'string', 'string', 'string', '(string,string)[]'],
494+
[content_hash_bytes, item_index, name, background_color, description, attributes]
495+
)
496+
"0x#{Eth::Util.keccak256(encoded).unpack1('H*')}"
497+
end
313498
end

0 commit comments

Comments
 (0)