|
1 | 1 | require 'rails_helper' |
| 2 | +require_relative '../../lib/protocol_event_reader' |
2 | 3 |
|
3 | 4 | RSpec.describe "Collections Protocol End-to-End", type: :integration do |
4 | 5 | include EthscriptionsTestHelper |
@@ -310,4 +311,188 @@ def create_and_validate_ethscription(creator:, to:, data_uri:) |
310 | 311 | expect(item1[:attributes][0]).to eq(["Type", "Rare"]) |
311 | 312 | end |
312 | 313 | 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 |
313 | 498 | end |
0 commit comments