From da9739e719b4d543c5b8903de132b864e2442ebc Mon Sep 17 00:00:00 2001 From: Tom Lehman Date: Tue, 23 Sep 2025 18:39:35 -0400 Subject: [PATCH] Add tests --- app/services/eth_block_importer.rb | 2 +- .../ethscription_transfers_spec.rb | 392 ++++++++++ .../ethscriptions_creation_spec.rb | 596 +++++++++++++++ spec/support/ethscriptions_test_helper.rb | 687 ++++++++++++++++++ 4 files changed, 1676 insertions(+), 1 deletion(-) create mode 100644 spec/integration/ethscription_transfers_spec.rb create mode 100644 spec/integration/ethscriptions_creation_spec.rb create mode 100644 spec/support/ethscriptions_test_helper.rb diff --git a/app/services/eth_block_importer.rb b/app/services/eth_block_importer.rb index 9bbae6a..7759363 100644 --- a/app/services/eth_block_importer.rb +++ b/app/services/eth_block_importer.rb @@ -9,7 +9,7 @@ class ReorgDetectedError < StandardError; end # Raised when validation failure is detected (should stop system permanently) class ValidationFailureError < StandardError; end - attr_accessor :ethscriptions_block_cache, :ethereum_client, :eth_block_cache, :geth_driver + attr_accessor :ethscriptions_block_cache, :ethereum_client, :eth_block_cache, :geth_driver, :prefetcher def initialize @ethscriptions_block_cache = {} diff --git a/spec/integration/ethscription_transfers_spec.rb b/spec/integration/ethscription_transfers_spec.rb new file mode 100644 index 0000000..f93c16e --- /dev/null +++ b/spec/integration/ethscription_transfers_spec.rb @@ -0,0 +1,392 @@ +require 'rails_helper' + +RSpec.describe "Ethscription Transfers", type: :integration do + include EthscriptionsTestHelper + + let(:alice) { valid_address("alice") } + let(:bob) { valid_address("bob") } + let(:charlie) { valid_address("charlie") } + let(:zero_address) { "0x0000000000000000000000000000000000000000" } + + # Helper for transfer tests - creates ethscription using new DSL + def create_test_ethscription(creator:, to:, content:) + result = expect_ethscription_success( + create_input(creator: creator, to: to, data_uri: "data:text/plain;charset=utf-8,#{content}") + ) + result[:ethscription_ids].first + end + + describe "Single transfer via input" do + it "transfers from owner (happy path)" do + # Setup: create ethscription + create_result = expect_ethscription_success( + create_input(creator: alice, to: alice, data_uri: "data:text/plain;charset=utf-8,Test content") + ) + id1 = create_result[:ethscription_ids].first + + # Transfer to bob + expect_transfer_success( + transfer_input(from: alice, to: bob, id: id1), + id1, + bob + ) + end + + it "reverts transfer by non-owner" do + # Setup: create ethscription owned by bob + create_result = expect_ethscription_success( + create_input(creator: alice, to: bob, data_uri: "data:text/plain;charset=utf-8,Test content2") + ) + id1 = create_result[:ethscription_ids].first + + # Alice tries to transfer bob's ethscription - should revert + expect_transfer_failure( + transfer_input(from: alice, to: charlie, id: id1), + id1, + reason: :revert + ) + end + + it "reverts transfer of nonexistent ID" do + # Random nonexistent ID + fake_id = generate_tx_hash(999) + + results = import_l1_block([ + transfer_input(from: alice, to: bob, id: fake_id) + ]) + + # Should revert for nonexistent ID + expect(results[:l2_receipts].first[:status]).to eq('0x0'), "Should revert for nonexistent ID" + end + end + + describe "Multi-transfer via input (ESIP-5)" do + it "transfers all valid IDs" do + # Setup: create two ethscriptions owned by alice + create1 = expect_ethscription_success( + create_input(creator: alice, to: alice, data_uri: "data:text/plain;charset=utf-8,Content 1") + ) + create2 = expect_ethscription_success( + create_input(creator: alice, to: alice, data_uri: "data:text/plain;charset=utf-8,Content 2") + ) + id1 = create1[:ethscription_ids].first + id2 = create2[:ethscription_ids].first + + # Multi-transfer both to bob + results = import_l1_block([ + transfer_multi_input(from: alice, to: bob, ids: [id1, id2]) + ]) + + # Verify L2 transaction succeeded + expect(results[:l2_receipts].first[:status]).to eq('0x1'), "Multi-transfer should succeed" + + # Verify both ownership changes + expect(get_ethscription_owner(id1).downcase).to eq(bob.downcase) + expect(get_ethscription_owner(id2).downcase).to eq(bob.downcase) + + # Verify content unchanged + expect(get_ethscription_content(id1)[:content]).to eq("Content 1") + expect(get_ethscription_content(id2)[:content]).to eq("Content 2") + end + + it "partial success when some IDs not owned by sender" do + # Setup: id1 owned by alice, id2 owned by bob + id1 = create_test_ethscription(creator: alice, to: alice, content: "Content 3") + id2 = create_test_ethscription(creator: bob, to: bob, content: "Content 4") + + # Alice tries to transfer both (can only transfer id1) + results = import_l1_block([ + transfer_multi_input(from: alice, to: charlie, ids: [id1, id2]) + ]) + + # Verify L2 transaction succeeded (partial success) + expect(results[:l2_receipts].first[:status]).to eq('0x1'), "Multi-transfer should succeed" + + # Only id1 should transfer + expect(get_ethscription_owner(id1).downcase).to eq(charlie.downcase) + expect(get_ethscription_owner(id2).downcase).to eq(bob.downcase) # Unchanged + end + + it "reverts when all IDs invalid" do + # Setup: both ids owned by bob, alice tries to transfer + create1 = expect_ethscription_success( + create_input(creator: bob, to: bob, data_uri: "data:text/plain;charset=utf-8,Content 5") + ) + create2 = expect_ethscription_success( + create_input(creator: bob, to: bob, data_uri: "data:text/plain;charset=utf-8,Content 6") + ) + id1 = create1[:ethscription_ids].first + id2 = create2[:ethscription_ids].first + + results = import_l1_block([ + transfer_multi_input(from: alice, to: charlie, ids: [id1, id2]) + ]) + + # Should revert when no successful transfers + expect(results[:l2_receipts].first[:status]).to eq('0x0'), "Should revert with no successful transfers" + + # No ownership changes + expect(get_ethscription_owner(id1).downcase).to eq(bob.downcase) + expect(get_ethscription_owner(id2).downcase).to eq(bob.downcase) + end + end + + describe "Transfer via event (ESIP-1)" do + it "transfers via event (happy path)" do + # Setup: create ethscription + id1 = create_test_ethscription(creator: alice, to: alice, content: "Test content2a") +# binding.irb + # Transfer via ESIP-1 event + expect_transfer_success( + transfer_event(from: alice, to: bob, id: id1), + id1, + bob + ) + end + + it "ignores event with removed=true" do + id1 = create_test_ethscription(creator: alice, to: alice, content: "Test content3") + + expect_transfer_failure( + l1_tx( + creator: alice, + to: bob, + logs: [ + build_transfer_event(from: alice, to: bob, id: id1).merge('removed' => true) + ] + ), + id1, + reason: :ignored + ) + end + + it "ignores event with wrong topics length" do + id1 = create_test_ethscription(creator: alice, to: alice, content: "Test content4") + + expect_transfer_failure( + l1_tx( + creator: alice, + to: bob, + logs: [ + { + 'address' => alice, + 'topics' => [EthTransaction::Esip1EventSig], # Missing to and id topics + 'data' => '0x', + 'logIndex' => '0x0', + 'removed' => false + } + ] + ), + id1, + reason: :ignored + ) + end + end + + describe "Transfer for previous owner (ESIP-2)" do + it "transfers with correct previous owner" do + # Setup: create ethscription, transfer to bob, then use ESIP-2 + id1 = create_test_ethscription(creator: alice, to: alice, content: "Test content5") + + # First transfer alice -> bob + expect_transfer_success( + transfer_input(from: alice, to: bob, id: id1), + id1, + bob + ) + + # ESIP-2 transfer bob -> charlie with alice as previous owner + expect_transfer_success( + transfer_prev_event(current_owner: bob, prev_owner: alice, to: charlie, id: id1), + id1, + charlie + ) + end + + it "ignores transfer with wrong previous owner" do + # Setup: create ethscription owned by bob + id1 = create_test_ethscription(creator: alice, to: bob, content: "Test content6") + + # ESIP-2 transfer with wrong previous owner (charlie instead of alice) + expect_transfer_failure( + transfer_prev_event(current_owner: charlie, prev_owner: charlie, to: alice, id: id1), + id1, + reason: :revert + ) + end + end + + describe "Burn to zero address" do + it "burns via input transfer to zero address" do + # Setup: create ethscription + id1 = create_test_ethscription(creator: alice, to: alice, content: "Burn test") + + # Transfer to zero address (burn) + expect_transfer_success( + transfer_input(from: alice, to: zero_address, id: id1), + id1, + zero_address + ) + end + + it "burns via ESIP-1 event to zero address" do + # Setup: create ethscription + id1 = create_test_ethscription(creator: alice, to: alice, content: "Burn test2") + + # Transfer to zero via event + expect_transfer_success( + transfer_event(from: alice, to: zero_address, id: id1), + id1, + zero_address + ) + end + end + + describe "In-transaction ordering: create then transfer" do + it "create via input, transfer via input in same transaction" do + data_uri = "data:text/plain;charset=utf-8,Create then transfer test" + + results = import_l1_block([ + l1_tx( + creator: alice, + to: alice, + input: string_to_hex(data_uri) + ) + ]) + + # Get the created ethscription ID + id1 = results[:ethscription_ids].first + + # Now transfer it in a separate transaction + expect_transfer_success( + transfer_input(from: alice, to: bob, id: id1), + id1, + bob + ) + end + + it "create via input, transfer via event in same L1 block" do + data_uri = "data:text/plain;charset=utf-8,Create then transfer via event" + + # Create in first transaction + create_result = expect_ethscription_success( + create_input(creator: alice, to: alice, data_uri: data_uri) + ) + id1 = create_result[:ethscription_ids].first + + # Transfer via event in second transaction of same block + expect_transfer_success( + transfer_event(from: alice, to: bob, id: id1), + id1, + bob + ) + end + end + + describe "ESIP feature gating for transfers" do + it "ignores ESIP-1 events when disabled" do + id1 = create_test_ethscription(creator: alice, to: alice, content: "Test content2aaa") + + expect_transfer_failure( + transfer_event(from: alice, to: bob, id: id1), + id1, + reason: :ignored, + esip_overrides: { esip1_enabled: false } + ) + end + + it "ignores ESIP-2 events when disabled" do + id1 = create_test_ethscription(creator: alice, to: bob, content: "Test content3a") + + expect_transfer_failure( + transfer_prev_event(current_owner: bob, prev_owner: alice, to: charlie, id: id1), + id1, + reason: :ignored, + esip_overrides: { esip2_enabled: false } + ) + end + + it "rejects multi-transfer when ESIP-5 disabled" do + id1 = create_test_ethscription(creator: alice, to: alice, content: "Content 1aaaaa") + id2 = create_test_ethscription(creator: alice, to: alice, content: "Content 2aaaaa") + + expect_transfer_failure( + transfer_multi_input(from: alice, to: bob, ids: [id1, id2]), + id1, + reason: :ignored, + esip_overrides: { esip5_enabled: false } + ) + end + end + + describe "Self-transfer scenarios" do + it "succeeds self-transfer via input" do + id1 = create_test_ethscription(creator: alice, to: alice, content: "Self transfer test") + + expect_transfer_success( + transfer_input(from: alice, to: alice, id: id1), + id1, + alice + ) + end + + it "succeeds self-transfer via event" do + id1 = create_test_ethscription(creator: alice, to: alice, content: "Self transfer testaaa") + + expect_transfer_success( + transfer_event(from: alice, to: alice, id: id1), + id1, + alice + ) + end + end + + describe "Transfer chain scenarios" do + it "chains multiple transfers in same L1 block" do + # Setup: create ethscription + id1 = create_test_ethscription(creator: alice, to: alice, content: "Chain testaaaaaa") + + # Transfer alice -> bob -> charlie in same L1 block + results = import_l1_block([ + transfer_input(from: alice, to: bob, id: id1), + transfer_input(from: bob, to: charlie, id: id1) + ]) + + # Both transfers should succeed + expect(results[:l2_receipts].size).to eq(2) + results[:l2_receipts].each do |receipt| + expect(receipt[:status]).to eq('0x1') + end + + # Final owner should be charlie + owner = get_ethscription_owner(id1) + expect(owner.downcase).to eq(charlie.downcase) + end + + it "transfer after prior transfer respects new owner" do + # Setup: create ethscription owned by alice + id1 = create_test_ethscription(creator: alice, to: alice, content: "Chain testbbbb") + + # First: alice -> bob + expect_transfer_success( + transfer_input(from: alice, to: bob, id: id1), + id1, + bob + ) + + # Second: bob -> charlie (should succeed) + expect_transfer_success( + transfer_input(from: bob, to: charlie, id: id1), + id1, + charlie + ) + + # Third: alice tries to transfer (should fail - no longer owner) + expect_transfer_failure( + transfer_input(from: alice, to: bob, id: id1), + id1, + reason: :revert + ) + end + end +end \ No newline at end of file diff --git a/spec/integration/ethscriptions_creation_spec.rb b/spec/integration/ethscriptions_creation_spec.rb new file mode 100644 index 0000000..4a7bb33 --- /dev/null +++ b/spec/integration/ethscriptions_creation_spec.rb @@ -0,0 +1,596 @@ +require 'rails_helper' + +RSpec.describe "Ethscription Creation", type: :integration do + include EthscriptionsTestHelper + + let(:alice) { valid_address("alice") } + let(:bob) { valid_address("bob") } + let(:charlie) { valid_address("charlie") } + + describe "Creation by Input" do + describe "Valid data URIs" do + it "creates text/plain ethscription" do + expect_ethscription_success( + create_input( + creator: alice, + to: bob, + data_uri: "data:text/plain;charset=utf-8,Hello, Ethscriptions World!" + ) + ) + end + + it "creates application/json ethscription" do + expect_ethscription_success( + create_input( + creator: alice, + to: bob, + data_uri: 'data:application/json,{"op":"deploy","tick":"TEST","max":"21000000"}' + ) + ) + end + + it "creates image/svg+xml ethscription" do + expect_ethscription_success( + create_input( + creator: alice, + to: bob, + data_uri: 'data:image/svg+xml,' + ) + ) + end + end + + describe "Base64 data URIs" do + it "creates image/png with base64 encoding" do + expect_ethscription_success( + create_input( + creator: alice, + to: bob, + data_uri: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==" + ) + ) + end + + it "creates with custom charset in base64 data URI" do + expect_ethscription_success( + create_input( + creator: alice, + to: bob, + data_uri: "data:image/png;charset=utf-8;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==" + ) + ) + end + end + + describe "GZIP compression" do + it "rejects GZIP input pre-ESIP-7" do + compressed_data_uri = Zlib.gzip("data:text/plain;charset=utf-8,Hello World") # Placeholder for GZIP data + + expect_ethscription_failure( + create_input( + creator: alice, + to: bob, + data_uri: compressed_data_uri + ), + reason: :ignored, + esip_overrides: { esip7_enabled: false } + ) + end + + it "accepts GZIP input post-ESIP-7" do + compressed_data_uri = Zlib.gzip("data:text/plain;charset=utf-8,Hello World") # Placeholder for GZIP data + + expect_ethscription_success( + create_input( + creator: alice, + to: bob, + data_uri: compressed_data_uri + ), + esip_overrides: { esip7_enabled: true } + ) + end + end + + describe "Invalid data URIs" do + it "rejects missing data: prefix" do + expect_ethscription_failure( + create_input( + creator: alice, + to: bob, + data_uri: "text/plain;charset=utf-8,Hello World" # Missing "data:" + ), + reason: :ignored + ) + end + + it "rejects malformed data uri" do + expect_ethscription_failure( + create_input( + creator: alice, + to: bob, + data_uri: "data:invalid-mimetype,Hello World" + ), + reason: :ignored + ) + end + + it "rejects bad encoding" do + expect_ethscription_failure( + create_input( + creator: alice, + to: bob, + data_uri: "data:text/plain;base64,invalid-base64-!@#$%" + ), + reason: :ignored + ) + end + end + + describe "Non-UTF8 and edge cases" do + it "rejects non-UTF8 raw input" do + expect_ethscription_failure( + { + creator: alice, + to: bob, + input: "0x" + "ff" * 100 # Invalid UTF-8 bytes + }, + reason: :ignored + ) + end + + it "rejects empty input" do + expect_ethscription_failure( + { + creator: alice, + to: bob, + input: "0x" + }, + reason: :ignored + ) + end + + it "rejects null input" do + expect_ethscription_failure( + { + creator: alice, + to: bob, + input: "" + }, + reason: :ignored + ) + end + end + + describe "Invalid addresses" do + it "allows input to null address" do + expect_ethscription_success( + create_input( + creator: alice, + to: "0x0000000000000000000000000000000000000000", + data_uri: "data:text/plain;charset=utf-8,Hello World33333" + ) + ) + end + + it "rejects contract creation (no to address)" do + expect_ethscription_failure( + { + creator: alice, + to: nil, # Contract creation + input: string_to_hex("data:text/plain;charset=utf-8,Hello World") + }, + reason: :ignored + ) + end + end + + describe "Multiple creates in same transaction" do + it "input takes precedence over event in same transaction" do + # Create a transaction with both input creation AND event creation + # Only input should succeed, event should be ignored per protocol rules + results = import_l1_block([ + l1_tx( + creator: alice, + to: bob, + input: string_to_hex("data:text/plain;charset=utf-8,Hello from Input"), + logs: [ + build_create_event( + creator: alice, + initial_owner: bob, + content_uri: "data:text/plain;charset=utf-8,Hello from Event" + ) + ] + ) + ]) +# binding.irb + # Should only create one ethscription (via input, not event) + # expect(results[:ethscription_ids].size).to eq(1) + expect(results[:l2_receipts].first[:status]).to eq('0x1') + expect(results[:l2_receipts].second[:status]).to eq('0x0') + + # Verify it used the input content, not the event content + stored = get_ethscription_content(results[:ethscription_ids].first) + expect(stored[:content]).to include("Hello from Input") + end + end + + describe "Multiple creates in same L1 block" do + it "creates multiple ethscriptions successfully" do + results = import_l1_block([ + create_input( + creator: alice, + to: bob, + data_uri: 'data:application/json,{"op":"deploy","tick":"TEST","max":"5"}' + ), + create_input( + creator: charlie, + to: alice, + data_uri: "data:text/plain;charset=utf-8,Second ethscription in same block" + ) + ]) + + # Both should succeed + expect(results[:ethscription_ids].size).to eq(2) + results[:l2_receipts].each do |receipt| + expect(receipt[:status]).to eq('0x1') + end + end + end + end + + describe "Creation by Event (ESIP-3)" do + describe "Valid CreateEthscription events" do + it "creates ethscription via event" do + expect_ethscription_success( + create_event( + creator: alice, + initial_owner: bob, + data_uri: "data:text/plain;charset=utf-8,Hello via Eve22nt!" + ) + ) + end + + it "creates with JSON content via event" do + expect_ethscription_success( + create_event( + creator: alice, + initial_owner: bob, + data_uri: 'data:application/json,{"message":"Hello via Eve44nt"}' + ) + ) + end + end + + describe "ESIP-3 feature gating" do + it "ignores events when ESIP-3 disabled" do + expect_ethscription_failure( + create_event( + creator: alice, + initial_owner: bob, + data_uri: "data:text/plain;charset=utf-8,Should be ignored" + ), + reason: :ignored, + esip_overrides: { esip3_enabled: false } + ) + end + + it "processes events when ESIP-3 enabled" do + expect_ethscription_success( + create_event( + creator: alice, + initial_owner: bob, + data_uri: "data:text/plain;charset=utf-8,Should work" + ), + esip_overrides: { esip3_enabled: true } + ) + end + end + + describe "Invalid events" do + it "ignores malformed event (wrong topic length)" do + expect_ethscription_failure( + { + creator: alice, + to: bob, + input: "0x", + logs: [ + { + 'address' => alice, + 'topics' => [EthTransaction::CreateEthscriptionEventSig], # Missing initial_owner topic + 'data' => string_to_hex("data:text/plain,Hello"), + 'logIndex' => '0x0', + 'removed' => false + } + ] + }, + reason: :ignored + ) + end + + it "ignores removed=true logs" do + expect_ethscription_failure( + l1_tx( + creator: alice, + to: bob, + logs: [ + { + 'address' => alice, + 'topics' => [ + EthTransaction::CreateEthscriptionEventSig, + "0x#{Eth::Abi.encode(['address'], [bob]).unpack1('H*')}" + ], + 'data' => "0x#{Eth::Abi.encode(['string'], ['data:text/plain,Hello']).unpack1('H*')}", + 'logIndex' => '0x0', + 'removed' => true # Should be ignored + } + ] + ), + reason: :ignored + ) + end + end + + describe "Event content validation" do + it "sanitizes and accepts valid data URI from non-UTF8 event data" do + # Event data that becomes valid after sanitization + expect_ethscription_success( + create_event( + creator: alice, + initial_owner: bob, + data_uri: "data:text/plain;charset=utf-8,Hello\x00World" # Contains null byte + ) + ) + end + + it "rejects event with completely invalid content" do + expect_ethscription_failure( + l1_tx( + creator: alice, + to: bob, + logs: [ + { + 'address' => alice, + 'topics' => [ + EthTransaction::CreateEthscriptionEventSig, + "0x#{Eth::Abi.encode(['address'], [bob]).unpack1('H*')}" + ], + 'data' => "0x" + "ff" * 100, # Invalid UTF-8 that doesn't sanitize to data URI + 'logIndex' => '0x0', + 'removed' => false + } + ] + ), + reason: :ignored + ) + end + end + + describe "ESIP-6 (duplicate content) end-to-end" do + it "reverts duplicate content without ESIP-6" do + content_uri = "data:text/plain;charset=utf-8,Duplicate content test" + + # First creation should succeed + expect_ethscription_success( + create_input(creator: alice, to: bob, data_uri: content_uri) + ) + + # Second creation with same content should revert + expect_ethscription_failure( + create_input(creator: charlie, to: alice, data_uri: content_uri), + reason: :revert + ) + end + + it "succeeds duplicate content with ESIP-6" do + base_content = "Duplicate content test" + normal_uri = "data:text/plain;charset=utf-8,#{base_content}" + esip6_uri = "data:text/plain;charset=utf-8;rule=esip6,#{base_content}" + + # First creation without ESIP-6 + first_result = expect_ethscription_success( + create_input(creator: alice, to: bob, data_uri: esip6_uri) + ) + + # Second creation with ESIP-6 rule should succeed + second_result = expect_ethscription_success( + create_input(creator: charlie, to: alice, data_uri: esip6_uri) + ) + + # Verify both have same content URI hash but second has esip6: true + first_stored = get_ethscription_content(first_result[:ethscription_ids].first) + second_stored = get_ethscription_content(second_result[:ethscription_ids].first) + + expect(first_stored[:content_uri_hash]).to eq(second_stored[:content_uri_hash]) + + expect(first_stored[:esip6]).to be_truthy + expect(second_stored[:esip6]).to be_truthy + end + end + + describe "Multiple create events in same transaction" do + it "processes only first event, ignores second" do + results = import_l1_block([ + l1_tx( + creator: alice, + to: bob, + logs: [ + build_create_event( + creator: alice, + initial_owner: bob, + content_uri: "data:text/plain;charset=utf-8,First event" + ).merge('logIndex' => '0x0'), + build_create_event( + creator: alice, + initial_owner: charlie, + content_uri: "data:text/plain;charset=utf-8,Second event" + ).merge('logIndex' => '0x1') + ] + ) + ]) + + # Should only create one ethscription (from first event) + expect(results[:l2_receipts].size).to eq(2) + expect(results[:l2_receipts].map { |r| r[:status] }).to eq(['0x1', '0x0']) + + # Verify content is from first event + stored = get_ethscription_content(results[:ethscription_ids].first) + expect(stored[:content]).to include("First event") + end + + it "respects logIndex order when choosing first event" do + results = import_l1_block([ + l1_tx( + creator: alice, + to: bob, + logs: [ + build_create_event( + creator: alice, + initial_owner: charlie, + content_uri: "data:text/plain;charset=utf-8,Second by logIndex" + ).merge('logIndex' => '0x1'), + build_create_event( + creator: alice, + initial_owner: bob, + content_uri: "data:text/plain;charset=utf-8,First by logIndex" + ).merge('logIndex' => '0x0') + ] + ) + ]) + + # Should process event with logIndex 0x0 first + expect(results[:ethscription_ids].size).to eq(1) + stored = get_ethscription_content(results[:ethscription_ids].first) + expect(stored[:content]).to include("First by logIndex") + end + end + + describe "Empty content data URI" do + it "succeeds with empty data URI via input" do + expect_ethscription_success( + create_input( + creator: alice, + to: bob, + data_uri: "data:," + ) + ) do |results| + stored = get_ethscription_content(results[:ethscription_ids].first) + expect(stored[:content]).to be_empty + # TODO: Verify mimetype defaults + end + end + end + + describe "Creator/owner edge cases (events)" do + it "handles initialOwner = zero address" do + expect_ethscription_success( + create_event( + creator: alice, + initial_owner: "0x0000000000000000000000000000000000000000", + data_uri: "data:text/plain;charset=utf-8,Transfer to zero test" + ) + ) do |results| + # Should end up owned by zero address + owner = get_ethscription_owner(results[:ethscription_ids].first) + expect(owner.downcase).to eq("0x0000000000000000000000000000000000000000") + end + end + + it "rejects event with creator = zero address" do + expect_ethscription_failure( + l1_tx( + creator: "0x0000000000000000000000000000000000000000", + to: bob, + logs: [ + { + 'address' => "0x0000000000000000000000000000000000000000", + 'topics' => [ + EthTransaction::CreateEthscriptionEventSig, + "0x#{Eth::Abi.encode(['address'], [bob]).unpack1('H*')}" + ], + 'data' => "0x#{Eth::Abi.encode(['string'], ['data:text/plain,Hello']).unpack1('H*')}", + 'logIndex' => '0x0', + 'removed' => false + } + ] + ), + reason: :revert + ) + end + end + + describe "Storage field correctness" do + it "stores correct metadata for input creation" do + content_uri = "data:image/svg+xml;charset=utf-8,test" + + expect_ethscription_success( + create_input(creator: alice, to: bob, data_uri: content_uri) + ) do |results| + stored = get_ethscription_content(results[:ethscription_ids].first) + + # Verify content fields + expect(stored[:content]).to eq("test") + expect(stored[:mimetype]).to eq("image/svg+xml") + expect(stored[:media_type]).to eq("image") + expect(stored[:mime_subtype]).to eq("svg+xml") + + # Verify content URI hash + expected_hash = Digest::SHA256.hexdigest(content_uri) + expect(stored[:content_uri_hash]).to eq("0x#{expected_hash}") + + # Verify block references are set + expect(stored[:l1_block_number]).to be > 0 + expect(stored[:l2_block_number]).to be > 0 + expect(stored[:l1_block_hash]).to match(/^0x[0-9a-f]{64}$/i) + end + end + + it "stores correct metadata for event creation" do + content_uri = "data:application/json,{\"test\":\"data\"}" + + expect_ethscription_success( + create_event(creator: alice, initial_owner: bob, data_uri: content_uri) + ) do |results| + stored = get_ethscription_content(results[:ethscription_ids].first) + + expect(stored[:mimetype]).to eq("application/json") + expect(stored[:media_type]).to eq("application") + expect(stored[:mime_subtype]).to eq("json") + + # Verify content URI hash matches + expected_hash = Digest::SHA256.hexdigest(content_uri) + expect(stored[:content_uri_hash]).to eq("0x#{expected_hash}") + end + end + end + + describe "Mixed input/event with same content" do + it "input takes precedence, stores input content exactly" do + input_content = "data:text/plain;charset=utf-8,Hello from Input123" + event_content = "data:text/plain;charset=utf-8,Hello from Event123" + + results = import_l1_block([ + l1_tx( + creator: alice, + to: bob, + input: string_to_hex(input_content), + logs: [ + build_create_event( + creator: alice, + initial_owner: bob, + content_uri: event_content + ) + ] + ) + ]) + + # Should only create one ethscription (via input) + expect(results[:ethscription_ids].size).to eq(1) + expect(results[:l2_receipts].first[:status]).to eq('0x1') + + # Verify stored content exactly matches input URI + stored = get_ethscription_content(results[:ethscription_ids].first) + expect(stored[:content]).to eq("Hello from Input123") + end + end + end +end diff --git a/spec/support/ethscriptions_test_helper.rb b/spec/support/ethscriptions_test_helper.rb new file mode 100644 index 0000000..b580053 --- /dev/null +++ b/spec/support/ethscriptions_test_helper.rb @@ -0,0 +1,687 @@ +module EthscriptionsTestHelper + def generate_ethscription_data(params = {}) + content_type = params[:content_type] + content = params[:content] + + "data:#{content_type};charset=utf-8,#{content}" + end + + def build_ethscription_input(params = {}) + raw_input = params[:input] || params[:data] + return normalize_to_hex(raw_input) if raw_input + + data_uri = generate_ethscription_data(params) + string_to_hex(data_uri) + end + + def normalize_to_hex(value) + return nil if value.nil? + + if value.respond_to?(:to_hex) + return value.to_hex + end + + string = value.to_s + return string if string.start_with?('0x') || string.start_with?('0X') + + string_to_hex(string) + end + + def string_to_hex(string) + "0x#{string.to_s.unpack1('H*')}" + end + + def normalize_address(address) + return nil if address.nil? + + address = address.is_a?(Address20) ? address : Address20.from_hex(address) + + address.to_hex.downcase + end + + # Generate a simple image ethscription + def generate_image_ethscription(**params) + params.merge( + content_type: "image/svg+xml", + content: '' + ) + end + + # Generate a JSON ethscription (like a token) + def generate_json_ethscription(**params) + json_content = params[:json] || { op: "mint", tick: "test", amt: "1000" } + params.merge( + content_type: "application/json", + content: json_content.to_json + ) + end + + # Validate ethscription was stored correctly in contract + def verify_ethscription_in_contract(tx_hash, expected_creator: nil, expected_content: nil, block_tag: 'latest') + stored = StorageReader.get_ethscription_with_content(tx_hash, block_tag: block_tag) + + expect(stored).to be_present, "Ethscription #{tx_hash} not found in contract storage" + + if expected_creator + expect(stored[:creator].downcase).to eq(expected_creator.downcase) + end + + if expected_content + expect(stored[:content]).to eq(expected_content) + end + + stored + end + + # Make static call to contract to verify state + def get_ethscription_owner(tx_hash) + StorageReader.get_owner(tx_hash) + end + + def get_ethscription_content(tx_hash, block_tag: 'latest') + StorageReader.get_ethscription_with_content(tx_hash, block_tag: block_tag) + end + + # Generate a valid Ethereum address from a seed string + def valid_address(seed) + "0x#{Digest::SHA256.hexdigest(seed.to_s)[0,40]}" + end + + # Minimal DSL for transaction descriptors + def create_input(creator:, to:, data_uri:, expect: :success) + { + type: :create_input, + creator: creator, + to: to, + input: data_uri, + expect: expect + } + end + + def create_event(creator:, initial_owner:, data_uri:, expect: :success) + { + type: :create_event, + creator: creator, + to: initial_owner, + input: "0x", + logs: [build_create_event(creator: creator, initial_owner: initial_owner, content_uri: data_uri)], + expect: expect + } + end + + # Low-level transaction builder - compose input and logs + def l1_tx(creator:, to: nil, input: "0x", logs: [], tx_hash: nil, status: '0x1', expect: :success) + { + type: :custom, + creator: creator, + to: to, + input: input, + logs: logs, + tx_hash: tx_hash, + status: status, + expect: expect + } + end + + def transfer_input(from:, to:, id:, expect: :success) + { + type: :transfer_input, + creator: from, + to: to, + input: normalize_to_hex(id), + expect: expect + } + end + + def transfer_multi_input(from:, to:, ids:, expect: :success) + # Join multiple 32-byte hashes (remove 0x prefixes and concatenate) + combined_ids = ids.map { |id| id.delete_prefix('0x') }.join('') + + { + type: :transfer_multi_input, + creator: from, + to: to, + input: "0x#{combined_ids}", + expect: expect + } + end + + def transfer_event(from:, to:, id:, expect: :success) + { + type: :transfer_event, + creator: from, + to: to, + input: "0x", + logs: [build_transfer_event(from: from, to: to, id: id)], + expect: expect + } + end + + def transfer_prev_event(current_owner:, prev_owner:, to:, id:, expect: :success) + { + type: :transfer_prev_event, + creator: prev_owner, + to: to, + input: "0x", + logs: [build_transfer_prev_event(current_owner: current_owner, prev_owner: prev_owner, to: to, id: id)], + expect: expect + } + end + + # Event builders + def build_create_event(creator:, initial_owner:, content_uri:) + encoded_data = Eth::Abi.encode(['string'], [content_uri]) + encoded_owner = Eth::Abi.encode(['address'], [initial_owner]) + + { + 'address' => normalize_address(creator), + 'topics' => [ + EthTransaction::CreateEthscriptionEventSig, + "0x#{encoded_owner.unpack1('H*')}" + ], + 'data' => "0x#{encoded_data.unpack1('H*')}", + 'logIndex' => '0x0', + 'removed' => false + } + end + + def build_transfer_event(from:, to:, id:) + encoded_to = Eth::Abi.encode(['address'], [to]) + encoded_id = Eth::Abi.encode(['bytes32'], [ByteString.from_hex(id).to_bin]) + + { + 'address' => normalize_address(from), + 'topics' => [ + EthTransaction::Esip1EventSig, + "0x#{encoded_to.unpack1('H*')}", + "0x#{encoded_id.unpack1('H*')}" + ], + 'data' => '0x', + 'logIndex' => '0x0', + 'removed' => false + } + end + + def build_transfer_prev_event(prev_owner:, current_owner:, to:, id:) + encoded_to = Eth::Abi.encode(['address'], [to]) + encoded_prev = Eth::Abi.encode(['address'], [prev_owner]) + encoded_id = Eth::Abi.encode(['bytes32'], [ByteString.from_hex(id).to_bin]) + + { + 'address' => normalize_address(current_owner), + 'topics' => [ + EthTransaction::Esip2EventSig, + "0x#{encoded_prev.unpack1('H*')}", # previousOwner first + "0x#{encoded_to.unpack1('H*')}", # then to + "0x#{encoded_id.unpack1('H*')}" # then id + ], + 'data' => '0x', + 'logIndex' => '0x0', + 'removed' => false + } + end + + # Main entry point: import L1 block with transaction descriptors + def import_l1_block(tx_descriptors, esip_overrides: {}) + # Convert descriptors to L1 transactions + l1_transactions = tx_descriptors.map.with_index do |descriptor, index| + build_l1_transaction(descriptor, index) + end + + # Apply ESIP stubs before the import process + esip_stubs = setup_esip_stubs(esip_overrides) + + begin + # Import and return structured results + results = import_l1_block_with_geth(l1_transactions) + + # Add mapping information for easier assertion + results[:tx_descriptors] = tx_descriptors + results[:mapping] = build_mapping(tx_descriptors, results) + + results + ensure + # Clean up stubs + cleanup_esip_stubs(esip_stubs) + end + end + + # Helper: create ethscription and return its ID for use in transfers + def create_ethscription_for_transfer(**params) + results = expect_ethscription_creation_success([params]) + results[:ethscription_ids].first + end + + private + + def setup_esip_stubs(overrides = {}) + # Default all ESIPs to enabled unless overridden + defaults = { + esip1_enabled: true, # Event-based transfers + esip2_enabled: true, # Transfer for previous owner + esip3_enabled: true, # Event-based creation + esip5_enabled: true, # Multi-transfer input + esip7_enabled: true # GZIP compression + } + + settings = defaults.merge(overrides) + + # Store original methods for cleanup + original_methods = {} + + # Stub the SysConfig methods and store originals + %w[esip1_enabled? esip2_enabled? esip3_enabled? esip5_enabled? esip7_enabled?].each do |method| + original_methods[method] = SysConfig.method(method) if SysConfig.respond_to?(method) + end + + allow(SysConfig).to receive(:esip1_enabled?).and_return(settings[:esip1_enabled]) + allow(SysConfig).to receive(:esip2_enabled?).and_return(settings[:esip2_enabled]) + allow(SysConfig).to receive(:esip3_enabled?).and_return(settings[:esip3_enabled]) + allow(SysConfig).to receive(:esip5_enabled?).and_return(settings[:esip5_enabled]) + allow(SysConfig).to receive(:esip7_enabled?).and_return(settings[:esip7_enabled]) + + original_methods + end + + def cleanup_esip_stubs(original_methods) + # Restore original methods + original_methods.each do |method_name, original_method| + if original_method + allow(SysConfig).to receive(method_name).and_call_original + end + end + end + + def build_l1_transaction(descriptor, index) + # binding.irb + { + transaction_hash: descriptor[:tx_hash] || generate_tx_hash(rand(1000000)), + from_address: normalize_address(descriptor[:creator]), + to_address: normalize_address(descriptor[:to]), + input: normalize_to_hex(descriptor.fetch(:input)), + value: 0, + gas_used: descriptor[:gas_used] || 21000, + transaction_index: index, + logs: descriptor[:logs] || [] + } + end + + def build_mapping(tx_descriptors, results) + # Map each descriptor to its corresponding L2 transaction results + tx_descriptors.map.with_index do |descriptor, index| + l2_receipt = results[:l2_receipts][index] + { + descriptor: descriptor, + l2_receipt: l2_receipt, + success: l2_receipt && l2_receipt[:status] == '0x1' + } + end + end + + # Assertion helpers for the new DSL + def expect_ethscription_success(tx_spec, esip_overrides: {}, &block) + results = import_l1_block([tx_spec], esip_overrides: esip_overrides) + + # Find the ethscription ID + ethscription_id = results[:ethscription_ids].first + expect(ethscription_id).to be_present, "Expected ethscription to be created" + + # Check L2 receipt status + expect(results[:l2_receipts].first[:status]).to eq('0x1'), "L2 transaction should succeed" + + # Check contract storage + stored = get_ethscription_content(ethscription_id) + expect(stored).to be_present, "Ethscription should be stored in contract" + + yield results if block_given? + results + end + + def expect_ethscription_failure(tx_spec, reason: nil, esip_overrides: {}) + results = import_l1_block([tx_spec], esip_overrides: esip_overrides) + + case reason + when :revert + # L2 transaction fails + expect(results[:l2_receipts].first[:status]).to eq('0x0'), "L2 transaction should revert" + # expect(results[:ethscription_ids]).to be_empty, "No ethscriptions should be created on revert" + when :ignored + # Feature disabled or transaction ignored + expect(results[:l2_receipts]).to be_empty, "No L2 transaction should be created when ignored" + # expect(results[:ethscription_ids]).to be_empty, "No ethscriptions should be created when ignored" + else + # Default: expect failure + raise "invalid reason" + end + + results + end + + # Assertion helper specifically for transfers + def expect_transfer_success(tx_spec, ethscription_id, expected_new_owner, esip_overrides: {}, &block) + # Get pre-transfer state + pre_owner = get_ethscription_owner(ethscription_id) + pre_content = get_ethscription_content(ethscription_id) + + results = import_l1_block([tx_spec], esip_overrides: esip_overrides) + + # Check L2 receipt status + expect(results[:l2_receipts].first[:status]).to eq('0x1'), "L2 transaction should succeed" + + # Verify ownership changed + new_owner = get_ethscription_owner(ethscription_id) + expect(new_owner.downcase).to eq(expected_new_owner.downcase), "Ownership should change to #{expected_new_owner}" + + # Verify content unchanged + current_content = get_ethscription_content(ethscription_id) + expect(current_content[:content]).to eq(pre_content[:content]), "Content should remain unchanged" + + yield results if block_given? + results + end + + def expect_transfer_failure(tx_spec, ethscription_id, reason: :revert, esip_overrides: {}, &block) + # Get pre-transfer state + pre_owner = get_ethscription_owner(ethscription_id) + pre_content = get_ethscription_content(ethscription_id) + + results = import_l1_block([tx_spec], esip_overrides: esip_overrides) + + case reason + when :revert + # L2 transaction fails + expect(results[:l2_receipts].first[:status]).to eq('0x0'), "L2 transaction should revert" + when :ignored + # Feature disabled or transaction ignored + expect(results[:l2_receipts]).to be_empty, "No L2 transaction should be created when ignored" + end + + # Verify ownership and content unchanged + current_owner = get_ethscription_owner(ethscription_id) + expect(current_owner).to eq(pre_owner), "Ownership should remain unchanged on failure" + + current_content = get_ethscription_content(ethscription_id) + expect(current_content[:content]).to eq(pre_content[:content]), "Content should remain unchanged" + + yield results if block_given? + results + end + + # Helper: create input-based transfer + def create_input_transfer(ethscription_id, from:, to:) + { + creator: from, + to: to, + input: ethscription_id # 32-byte hash for single transfer + } + end + + # Helper: create event-based transfer (ESIP-1) + def create_event_transfer(ethscription_id, from:, to:) + # ABI encode all topics properly + encoded_to = Eth::Abi.encode(['address'], [to]) + encoded_id = Eth::Abi.encode(['bytes32'], [ByteString.from_hex(ethscription_id).to_bin]) + + { + creator: from, + logs: [ + { + 'address' => from, + 'topics' => [ + EthTransaction::Esip1EventSig, + "0x#{encoded_to.unpack1('H*')}", + "0x#{encoded_id.unpack1('H*')}" + ], + 'data' => '0x', + 'logIndex' => '0x0', + 'removed' => false + } + ] + } + end + + # Helper: create event-based transfer with previous owner (ESIP-2) + def create_esip2_transfer(ethscription_id, from:, to:, previous_owner:) + # ABI encode all topics properly + encoded_to = Eth::Abi.encode(['address'], [to]) + encoded_previous = Eth::Abi.encode(['address'], [previous_owner]) + encoded_id = Eth::Abi.encode(['bytes32'], [ByteString.from_hex(ethscription_id).to_bin]) + + { + creator: from, + logs: [ + { + 'address' => from, + 'topics' => [ + EthTransaction::Esip2EventSig, + "0x#{encoded_previous.unpack1('H*')}", + "0x#{encoded_to.unpack1('H*')}", + "0x#{encoded_id.unpack1('H*')}" + ], + 'data' => '0x', + 'logIndex' => '0x0', + 'removed' => false + } + ] + } + end + + # Helper: create ethscription via event (ESIP-3) + def create_ethscription_via_event(creator:, initial_owner:, content:) + # ABI encode the content properly for the CreateEthscription event + encoded_data = Eth::Abi.encode(['string'], [content]) + # ABI encode the initial_owner address for the topic + encoded_owner_topic = Eth::Abi.encode(['address'], [initial_owner]) + + { + creator: creator, + to: initial_owner, # Event-based transactions still need a to_address + input: "0x", # Empty input for event-only transactions + logs: [ + { + 'address' => creator, + 'topics' => [ + EthTransaction::CreateEthscriptionEventSig, + "0x#{encoded_owner_topic.unpack1('H*')}" + ], + 'data' => "0x#{encoded_data.unpack1('H*')}", + 'logIndex' => '0x0', + 'removed' => false + } + ] + } + end + + private + + def import_l1_block_with_geth(l1_transactions) + # Get current importer state + importer = ImporterSingleton.instance + current_max_eth_block = importer.current_max_eth_block + block_number = current_max_eth_block.number + 1 + + # Create mock L1 block data + block_data = build_mock_l1_block(l1_transactions, current_max_eth_block) + receipts_data = l1_transactions.map { |tx| build_mock_receipt(tx) } + + # Rebuild the EthscriptionTransaction objects exactly the way the importer does + eth_block = EthBlock.from_rpc_result(block_data) + template_ethscriptions_block = EthscriptionsBlock.from_eth_block(eth_block) + + ethscription_transactions = EthTransaction.ethscription_txs_from_rpc_results( + block_data, + receipts_data, + template_ethscriptions_block + ) + ethscription_transactions.each { |tx| tx.ethscriptions_block = template_ethscriptions_block } + + # Mock the L1 client but keep L2 client real + mock_ethereum_client = instance_double(EthRpcClient) + + mock_ethereum_client = instance_double(EthRpcClient, + get_block_number: block_number, + get_block: block_data, + get_transaction_receipts: receipts_data + ) + + # Mock the prefetcher to return our mock data in the correct format + eth_block = EthBlock.from_rpc_result(block_data) + ethscriptions_block = EthscriptionsBlock.from_eth_block(eth_block) + + mock_prefetcher_response = { + error: nil, + eth_block: eth_block, + ethscriptions_block: ethscriptions_block, + ethscription_txs: ethscription_transactions + } + + mock_prefetcher = instance_double(L1RpcPrefetcher) + allow(mock_prefetcher).to receive(:fetch).with(block_number).and_return(mock_prefetcher_response) + allow(mock_prefetcher).to receive(:ensure_prefetched) + allow(mock_prefetcher).to receive(:clear_older_than) + + # Replace both client and prefetcher with mocks + old_client = importer.ethereum_client + old_prefetcher = importer.prefetcher + + importer.ethereum_client = mock_ethereum_client + importer.instance_variable_set(:@prefetcher, mock_prefetcher) + + l2_blocks, eth_blocks = importer.import_next_block + + # Get the latest L2 block that was created + latest_l2_block = EthRpcClient.l2.get_block("latest", true) + + # Get L2 transaction receipts for verification (excluding system/L1 attributes transactions) + l2_transactions = latest_l2_block ? latest_l2_block['transactions'] || [] : [] + l2_receipts = l2_transactions + .reject { |l2_tx| l2_tx['from']&.downcase == SysConfig::SYSTEM_ADDRESS.to_hex.downcase } # Exclude system transactions + .map do |l2_tx| + receipt = EthRpcClient.l2.get_transaction_receipt(l2_tx['hash']) + { + tx_hash: l2_tx['hash'], + status: receipt['status'], + gas_used: receipt['gasUsed'], + logs: receipt['logs'] + } + end + + # Return all ethscription transactions (both successful and failed) + imported_ethscriptions = ethscription_transactions + ethscription_ids = imported_ethscriptions.flat_map do |tx| + case tx.ethscription_operation + when :create + [tx.eth_transaction.tx_hash.to_hex] + when :transfer + if tx.transfer_ids.present? + tx.transfer_ids # Multi-transfer + else + [tx.ethscription_id] # Single transfer + end + else + [tx.eth_transaction.tx_hash.to_hex] # Fallback + end + end.compact.uniq + + { + l2_blocks: l2_blocks, + eth_blocks: eth_blocks, + ethscriptions: imported_ethscriptions, + ethscription_ids: ethscription_ids, + l1_transactions: l1_transactions, + l2_receipts: l2_receipts, + l2_block_data: latest_l2_block + } + ensure + importer.ethereum_client = old_client + importer.prefetcher = old_prefetcher + end + + def build_mock_l1_block(l1_transactions, current_max_eth_block) + block_number = current_max_eth_block.number + 1 + next_timestamp = current_max_eth_block.timestamp + 12 + + { + 'number' => "0x#{block_number.to_s(16)}", + 'hash' => generate_block_hash(block_number), + 'parentHash' => current_max_eth_block.block_hash.to_hex, + 'timestamp' => "0x#{next_timestamp.to_s(16)}", + 'baseFeePerGas' => "0x#{1.gwei.to_s(16)}", + 'mixHash' => generate_block_hash(block_number + 1000), + 'transactions' => l1_transactions.map { |tx| format_transaction_for_rpc(tx) } + } + end + + def build_mock_receipt(tx) + { + 'transactionHash' => tx[:transaction_hash], + 'transactionIndex' => tx[:transaction_index], + 'status' => tx[:status] || '0x1', + 'gasUsed' => "0x#{tx[:gas_used].to_s(16)}", + 'logs' => tx[:logs] || [] # Include logs from the original transaction + } + end + + def format_transaction_for_rpc(tx) + { + 'hash' => tx[:transaction_hash], + 'transactionIndex' => "0x#{tx[:transaction_index].to_s(16)}", + 'from' => tx[:from_address], + 'to' => tx[:to_address], + 'input' => tx[:input], + 'value' => "0x#{tx[:value].to_s(16)}" + } + end + + def format_receipt_for_rpc(tx) + { + 'transactionHash' => tx[:transaction_hash], + 'transactionIndex' => tx[:transaction_index], + 'status' => '0x1', + 'gasUsed' => "0x#{tx[:gas_used].to_s(16)}", + 'logs' => [] + } + end + + def generate_tx_hash(index) + "0x#{Digest::SHA256.hexdigest("tx_hash_#{index}").first(64)}" + end + + def generate_address(index) + "0x#{Digest::SHA256.hexdigest("addr_#{index}")[0..39]}" + end + + def generate_block_hash(block_number) + "0x#{Digest::SHA256.hexdigest("block_#{block_number}")}" + end + + def combine_transaction_data(receipt, tx_data) + combined = receipt.merge(tx_data) do |key, receipt_val, tx_val| + if receipt_val != tx_val + [receipt_val, tx_val] + else + receipt_val + end + end + + # Convert hex strings to integers where appropriate + %w[blockNumber gasUsed cumulativeGasUsed effectiveGasPrice status transactionIndex nonce value gas depositNonce mint depositReceiptVersion gasPrice].each do |key| + combined[key] = combined[key].to_i(16) if combined[key].is_a?(String) && combined[key].start_with?('0x') + end + + # Remove duplicate keys with different casing + combined.delete('transactionHash') # Keep 'transactionHash' instead + + obj = OpenStruct.new(combined) + + def obj.method_missing(method, *args, &block) + if respond_to?(method.to_s.camelize(:lower)) + send(method.to_s.camelize(:lower), *args, &block) + else + super + end + end + + obj + end +end