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,"
+
+ 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("")
+ 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