From 8210799ba79684ae795a482347b5eeb2a708ad71 Mon Sep 17 00:00:00 2001 From: Tom Lehman Date: Tue, 21 Oct 2025 10:58:41 -0400 Subject: [PATCH 1/2] Refactor --- app/models/eth_transaction.rb | 183 +++++++++++++++++- app/models/ethscription_transaction.rb | 86 ++------ .../ethscription_transaction_builder.rb | 21 +- contracts/src/Ethscriptions.sol | 19 +- .../test/EthscriptionsMultiTransfer.t.sol | 20 +- lib/byte_string.rb | 4 + spec/integration/transfer_selector_spec.rb | 128 ++++++++++++ .../ethscription_transaction_builder_spec.rb | 2 +- 8 files changed, 370 insertions(+), 93 deletions(-) create mode 100644 spec/integration/transfer_selector_spec.rb diff --git a/app/models/eth_transaction.rb b/app/models/eth_transaction.rb index 2a8d423..95c26c3 100644 --- a/app/models/eth_transaction.rb +++ b/app/models/eth_transaction.rb @@ -29,7 +29,6 @@ def self.event_signature(event_name) def transaction_hash tx_hash end - sig { params(block_result: T.untyped, receipt_result: T.untyped).returns(T::Array[EthTransaction]) } def self.from_rpc_result(block_result, receipt_result) @@ -66,8 +65,8 @@ def self.ethscription_txs_from_rpc_results(block_results, receipt_results, ethsc eth_txs.sort_by(&:transaction_index).each do |eth_tx| next unless eth_tx.is_success? - # Build deposits using the unified builder - deposits = EthscriptionTransactionBuilder.build_deposits(eth_tx, ethscriptions_block) + # Build deposits directly from this EthTransaction instance + deposits = eth_tx.build_ethscription_deposits(ethscriptions_block) all_deposits.concat(deposits) end @@ -83,4 +82,182 @@ def is_success? def ethscription_source_hash tx_hash end + + # Build deposit transactions (EthscriptionTransaction objects) from this L1 transaction + sig { params(ethscriptions_block: EthscriptionsBlock).returns(T::Array[EthscriptionTransaction]) } + def build_ethscription_deposits(ethscriptions_block) + @transactions = [] + + # 1. Process calldata (try as creation, then as transfer) + process_calldata + + # 2. Process events (creations and transfers) + process_events + + @transactions.compact + end + + private + + def process_calldata + return unless to_address.present? + + try_calldata_creation + try_calldata_transfer + end + + def try_calldata_creation + transaction = EthscriptionTransaction.build_create_ethscription( + eth_transaction: self, + creator: normalize_address(from_address), + initial_owner: normalize_address(to_address), + content_uri: utf8_input, + source_type: :input, + source_index: transaction_index + ) + + @transactions << transaction + end + + def try_calldata_transfer + valid_length = if SysConfig.esip5_enabled?(block_number) + input.bytesize > 0 && input.bytesize % 32 == 0 + else + input.bytesize == 32 + end + + return unless valid_length + + input_hex = input.to_hex.delete_prefix('0x') + + ids = input_hex.scan(/.{64}/).map { |hash_hex| normalize_hash("0x#{hash_hex}") } + + # Create transfer transaction + transaction = EthscriptionTransaction.build_transfer( + eth_transaction: self, + from_address: normalize_address(from_address), + to_address: normalize_address(to_address), + ethscription_ids: ids, + source_type: :input, + source_index: transaction_index + ) + + @transactions << transaction + end + + def process_events + ordered_events.each do |log| + begin + case log['topics']&.first + when CreateEthscriptionEventSig + process_create_event(log) + when Esip1EventSig + process_esip1_transfer_event(log) + when Esip2EventSig + process_esip2_transfer_event(log) + end + rescue Eth::Abi::DecodingError, RangeError => e + Rails.logger.error "Failed to decode event: #{e.message}" + next + end + end + end + + def process_create_event(log) + return unless SysConfig.esip3_enabled?(block_number) + return unless log['topics'].length == 2 + + # Decode event data + initial_owner = Eth::Abi.decode(['address'], log['topics'].second).first + content_uri_data = Eth::Abi.decode(['string'], log['data']).first + content_uri = HexDataProcessor.clean_utf8(content_uri_data) + + transaction = EthscriptionTransaction.build_create_ethscription( + eth_transaction: self, + creator: normalize_address(log['address']), + initial_owner: normalize_address(initial_owner), + content_uri: content_uri, + source_type: :event, + source_index: log['logIndex'].to_i(16) + ) + + @transactions << transaction + end + + def process_esip1_transfer_event(log) + return unless SysConfig.esip1_enabled?(block_number) + return unless log['topics'].length == 3 + + # Decode event data + event_to = Eth::Abi.decode(['address'], log['topics'].second).first + tx_hash_hex = Eth::Util.bin_to_prefixed_hex( + Eth::Abi.decode(['bytes32'], log['topics'].third).first + ) + + ethscription_id = normalize_hash(tx_hash_hex) + + transaction = EthscriptionTransaction.build_transfer( + eth_transaction: self, + from_address: normalize_address(log['address']), + to_address: normalize_address(event_to), + ethscription_ids: ethscription_id, # Single ID, will be wrapped in array + source_type: :event, + source_index: log['logIndex'].to_i(16) + ) + + @transactions << transaction + end + + def process_esip2_transfer_event(log) + return unless SysConfig.esip2_enabled?(block_number) + return unless log['topics'].length == 4 + + event_previous_owner = Eth::Abi.decode(['address'], log['topics'].second).first + event_to = Eth::Abi.decode(['address'], log['topics'].third).first + tx_hash_hex = Eth::Util.bin_to_prefixed_hex( + Eth::Abi.decode(['bytes32'], log['topics'].fourth).first + ) + + ethscription_id = normalize_hash(tx_hash_hex) + + transaction = EthscriptionTransaction.build_transfer( + eth_transaction: self, + from_address: normalize_address(log['address']), + to_address: normalize_address(event_to), + ethscription_ids: ethscription_id, # Single ID, will be wrapped in array + enforced_previous_owner: normalize_address(event_previous_owner), + source_type: :event, + source_index: log['logIndex'].to_i(16) + ) + + @transactions << transaction + end + + def ordered_events + return [] unless logs + + logs.reject { |log| log['removed'] } + .sort_by { |log| log['logIndex'].to_i(16) } + end + + def utf8_input + HexDataProcessor.hex_to_utf8( + input.to_hex, + support_gzip: SysConfig.esip7_enabled?(block_number) + ) + end + + def normalize_address(addr) + return nil unless addr + # Handle both Address20 objects and strings + addr_str = addr.respond_to?(:to_hex) ? addr.to_hex : addr.to_s + addr_str.downcase + end + + def normalize_hash(hash) + return nil unless hash + # Handle both Hash32 objects and strings + hash_str = hash.respond_to?(:to_hex) ? hash.to_hex : hash.to_s + hash_str.downcase + end end diff --git a/app/models/ethscription_transaction.rb b/app/models/ethscription_transaction.rb index 1f7ebc8..4c70d9e 100644 --- a/app/models/ethscription_transaction.rb +++ b/app/models/ethscription_transaction.rb @@ -17,8 +17,7 @@ class EthscriptionTransaction < T::Struct prop :content_uri, T.nilable(String) # Transfer operation fields - prop :ethscription_id, T.nilable(String) - prop :transfer_ids, T.nilable(T::Array[String]) + prop :transfer_ids, T.nilable(T::Array[String]) # Always an array, even for single transfers prop :transfer_from_address, T.nilable(String) prop :transfer_to_address, T.nilable(String) prop :enforced_previous_owner, T.nilable(String) @@ -38,7 +37,7 @@ class EthscriptionTransaction < T::Struct TO_ADDRESS = SysConfig::ETHSCRIPTIONS_ADDRESS # Factory method for create operations - def self.create_ethscription( + def self.build_create_ethscription( eth_transaction:, creator:, initial_owner:, @@ -46,6 +45,8 @@ def self.create_ethscription( source_type:, source_index: ) + return unless DataUri.valid?(content_uri) + new( from_address: Address20.from_hex(creator.is_a?(String) ? creator : creator.to_hex), eth_transaction: eth_transaction, @@ -58,22 +59,26 @@ def self.create_ethscription( ) end - # Factory method for transfer operations - def self.transfer_ethscription( + # Transfer factory - handles single, multiple, and previous owner cases + def self.build_transfer( eth_transaction:, from_address:, to_address:, - ethscription_id:, - enforced_previous_owner: nil, source_type:, - source_index: + source_index:, + ethscription_ids:, # Can be a single ID or an array of IDs + enforced_previous_owner: nil ) + # Normalize to array - accept either single ID or array of IDs + ids = Array.wrap(ethscription_ids) + + # Determine operation type operation_type = enforced_previous_owner ? 'transfer_with_previous_owner' : 'transfer' new( from_address: Address20.from_hex(from_address.is_a?(String) ? from_address : from_address.to_hex), eth_transaction: eth_transaction, - ethscription_id: ethscription_id, + transfer_ids: ids, # Always use array transfer_from_address: from_address, transfer_to_address: to_address, enforced_previous_owner: enforced_previous_owner, @@ -83,35 +88,14 @@ def self.transfer_ethscription( ) end - # Factory method for transferMultipleEthscriptions (inputs only) - def self.transfer_multiple_ethscriptions( - eth_transaction:, - from_address:, - to_address:, - ethscription_ids:, - source_type:, - source_index: - ) - new( - from_address: Address20.from_hex(from_address.is_a?(String) ? from_address : from_address.to_hex), - eth_transaction: eth_transaction, - transfer_ids: ethscription_ids, - transfer_from_address: from_address, - transfer_to_address: to_address, - source_type: source_type&.to_sym, - source_index: source_index, - ethscription_operation: 'transfer' - ) - end - # Get function selector for this operation def function_selector function_signature = case ethscription_operation when 'create' 'createEthscription((bytes32,bytes32,address,bytes,string,bool,(string,string,bytes)))' when 'transfer' - if transfer_ids && transfer_ids.any? - 'transferMultipleEthscriptions(bytes32[],address)' + if transfer_ids.length > 1 + 'transferEthscriptions(address,bytes32[])' else 'transferEthscription(address,bytes32)' end @@ -145,34 +129,6 @@ def source_hash Hash32.from_bin(bin_val) end - def valid_create? - content_uri.present? && - creator.present? && - initial_owner.present? && - DataUri.valid?(content_uri) - end - - def valid_transfer? - # Basic field validation - if we extracted the data properly, ABI encoding should work - case ethscription_operation - when 'transfer' - if transfer_ids - # Multiple transfer (input-based) - transfer_ids.is_a?(Array) && transfer_ids.any? - else - # Single transfer (event-based) - ethscription_id.present? - end - when 'transfer_with_previous_owner' - # Always single transfer (event-based only) - ethscription_id.present? - else - false - end && - transfer_from_address.present? && - transfer_to_address.present? - end - public # Dynamic input method - builds calldata on demand @@ -181,7 +137,7 @@ def input when 'create' ByteString.from_bin(build_create_calldata) when 'transfer' - if transfer_ids && transfer_ids.any? + if transfer_ids.length > 1 ByteString.from_bin(build_transfer_multiple_calldata) else ByteString.from_bin(build_transfer_calldata) @@ -267,7 +223,7 @@ def build_transfer_calldata # Convert to binary for ABI to_bin = address_to_bin(transfer_to_address) - id_bin = hex_to_bin(ethscription_id) + id_bin = hex_to_bin(transfer_ids.first) encoded = Eth::Abi.encode(['address', 'bytes32'], [to_bin, id_bin]) @@ -281,7 +237,7 @@ def build_transfer_with_previous_owner_calldata # Convert to binary for ABI to_bin = address_to_bin(transfer_to_address) - id_bin = hex_to_bin(ethscription_id) + id_bin = hex_to_bin(transfer_ids.first) prev_bin = address_to_bin(enforced_previous_owner) encoded = Eth::Abi.encode(['address', 'bytes32', 'address'], [to_bin, id_bin, prev_bin]) @@ -294,10 +250,10 @@ def build_transfer_multiple_calldata # Get function selector as binary function_sig = function_selector.b - ids_bin = (transfer_ids || []).map { |id| hex_to_bin(id) } to_bin = address_to_bin(transfer_to_address) + ids_bin = transfer_ids.map { |id| hex_to_bin(id) } - encoded = Eth::Abi.encode(['bytes32[]', 'address'], [ids_bin, to_bin]) + encoded = Eth::Abi.encode(['address', 'bytes32[]'], [to_bin, ids_bin]) (function_sig + encoded).b end diff --git a/app/models/ethscription_transaction_builder.rb b/app/models/ethscription_transaction_builder.rb index 44e5756..71e6b20 100644 --- a/app/models/ethscription_transaction_builder.rb +++ b/app/models/ethscription_transaction_builder.rb @@ -106,18 +106,31 @@ def process_input_transfers return unless valid_length - # Parse all 32-byte hashes for a single multi-transfer call + # Parse all 32-byte hashes ids = input_hex.scan(/.{64}/).map { |hash_hex| normalize_hash("0x#{hash_hex}") } return if ids.empty? - transaction = EthscriptionTransaction.transfer_multiple_ethscriptions( + # Common transfer parameters + base_params = { eth_transaction: @eth_tx, from_address: normalize_address(@eth_tx.from_address), to_address: normalize_address(@eth_tx.to_address), - ethscription_ids: ids, source_type: :input, source_index: @eth_tx.transaction_index - ) + } + + # Use appropriate factory method based on count + transaction = if ids.length == 1 + EthscriptionTransaction.transfer_ethscription( + **base_params, + ethscription_id: ids.first + ) + else + EthscriptionTransaction.transfer_multiple_ethscriptions( + **base_params, + ethscription_ids: ids + ) + end @transactions << transaction end diff --git a/contracts/src/Ethscriptions.sol b/contracts/src/Ethscriptions.sol index 562f510..f23602a 100644 --- a/contracts/src/Ethscriptions.sol +++ b/contracts/src/Ethscriptions.sol @@ -263,7 +263,7 @@ contract Ethscriptions is ERC721EthscriptionsUpgradeable { bytes32 transactionHash ) external requireExists(transactionHash) { // Get the ethscription number to use as token ID - Ethscription memory etsc = ethscriptions[transactionHash]; + Ethscription storage etsc = ethscriptions[transactionHash]; uint256 tokenId = etsc.ethscriptionNumber; // Standard ERC721 transfer will handle authorization transferFrom(msg.sender, to, tokenId); @@ -279,16 +279,15 @@ contract Ethscriptions is ERC721EthscriptionsUpgradeable { bytes32 transactionHash, address previousOwner ) external requireExists(transactionHash) { + Ethscription storage etsc = ethscriptions[transactionHash]; + // Verify the previous owner matches - if (ethscriptions[transactionHash].previousOwner != previousOwner) { + if (etsc.previousOwner != previousOwner) { revert PreviousOwnerMismatch(); } - // Get the ethscription number to use as token ID - Ethscription memory etsc = ethscriptions[transactionHash]; - uint256 tokenId = etsc.ethscriptionNumber; // Use transferFrom which now handles burns when to == address(0) - transferFrom(msg.sender, to, tokenId); + transferFrom(msg.sender, to, etsc.ethscriptionNumber); } /// @notice Transfer multiple ethscriptions to a single recipient @@ -296,14 +295,14 @@ contract Ethscriptions is ERC721EthscriptionsUpgradeable { /// @param transactionHashes Array of ethscription hashes to transfer /// @param to The recipient address (can be address(0) for burning) /// @return successCount Number of successful transfers - function transferMultipleEthscriptions( - bytes32[] calldata transactionHashes, - address to + function transferEthscriptions( + address to, + bytes32[] calldata transactionHashes ) external returns (uint256 successCount) { for (uint256 i = 0; i < transactionHashes.length; i++) { // Get the ethscription to find its token ID if (!_ethscriptionExists(transactionHashes[i])) continue; // Skip non-existent ethscriptions - Ethscription memory etsc = ethscriptions[transactionHashes[i]]; + Ethscription storage etsc = ethscriptions[transactionHashes[i]]; uint256 tokenId = etsc.ethscriptionNumber; diff --git a/contracts/test/EthscriptionsMultiTransfer.t.sol b/contracts/test/EthscriptionsMultiTransfer.t.sol index 5928b9b..b472a0a 100644 --- a/contracts/test/EthscriptionsMultiTransfer.t.sol +++ b/contracts/test/EthscriptionsMultiTransfer.t.sol @@ -46,7 +46,7 @@ contract EthscriptionsMultiTransferTest is TestSetup { return txHash; } - function test_TransferMultipleEthscriptions_Success() public { + function test_TransferEthscriptions_Success() public { // Create 5 ethscriptions owned by alice bytes32[] memory hashes = new bytes32[](5); for (uint256 i = 0; i < 5; i++) { @@ -55,7 +55,7 @@ contract EthscriptionsMultiTransferTest is TestSetup { // Alice transfers all 5 to bob vm.prank(alice); - uint256 successCount = ethscriptions.transferMultipleEthscriptions(hashes, bob); + uint256 successCount = ethscriptions.transferEthscriptions(bob, hashes); assertEq(successCount, 5, "Should have 5 successful transfers"); @@ -66,7 +66,7 @@ contract EthscriptionsMultiTransferTest is TestSetup { } } - function test_TransferMultipleEthscriptions_PartialSuccess() public { + function test_TransferEthscriptions_PartialSuccess() public { // Create 5 ethscriptions - 3 owned by alice, 2 owned by bob bytes32[] memory hashes = new bytes32[](5); for (uint256 i = 0; i < 3; i++) { @@ -78,7 +78,7 @@ contract EthscriptionsMultiTransferTest is TestSetup { // Alice tries to transfer all 5 to charlie (but only owns 3) vm.prank(alice); - uint256 successCount = ethscriptions.transferMultipleEthscriptions(hashes, charlie); + uint256 successCount = ethscriptions.transferEthscriptions(charlie, hashes); assertEq(successCount, 3, "Should have 3 successful transfers"); @@ -93,7 +93,7 @@ contract EthscriptionsMultiTransferTest is TestSetup { } } - function test_TransferMultipleEthscriptions_NoSuccessReverts() public { + function test_TransferEthscriptions_NoSuccessReverts() public { // Create 3 ethscriptions owned by bob bytes32[] memory hashes = new bytes32[](3); for (uint256 i = 0; i < 3; i++) { @@ -103,10 +103,10 @@ contract EthscriptionsMultiTransferTest is TestSetup { // Alice tries to transfer them (but owns none) vm.prank(alice); vm.expectRevert(Ethscriptions.NoSuccessfulTransfers.selector); - ethscriptions.transferMultipleEthscriptions(hashes, charlie); + ethscriptions.transferEthscriptions(charlie, hashes); } - function test_TransferMultipleEthscriptions_Burn() public { + function test_TransferEthscriptions_Burn() public { // Create 3 ethscriptions owned by alice bytes32[] memory hashes = new bytes32[](3); for (uint256 i = 0; i < 3; i++) { @@ -115,7 +115,7 @@ contract EthscriptionsMultiTransferTest is TestSetup { // Alice burns all 3 by transferring to address(0) vm.prank(alice); - uint256 successCount = ethscriptions.transferMultipleEthscriptions(hashes, address(0)); + uint256 successCount = ethscriptions.transferEthscriptions(address(0), hashes); assertEq(successCount, 3, "Should have 3 successful burns"); @@ -125,7 +125,7 @@ contract EthscriptionsMultiTransferTest is TestSetup { } } - function test_TransferMultipleEthscriptions_EmitsEvents() public { + function test_TransferEthscriptions_EmitsEvents() public { // Create 2 ethscriptions owned by alice bytes32[] memory hashes = new bytes32[](2); for (uint256 i = 0; i < 2; i++) { @@ -145,7 +145,7 @@ contract EthscriptionsMultiTransferTest is TestSetup { // Alice transfers both to bob vm.prank(alice); - ethscriptions.transferMultipleEthscriptions(hashes, bob); + ethscriptions.transferEthscriptions(bob, hashes); } function test_TokenURI_ReturnsValidJSON() public { diff --git a/lib/byte_string.rb b/lib/byte_string.rb index ba3b2c1..9b440b9 100644 --- a/lib/byte_string.rb +++ b/lib/byte_string.rb @@ -84,6 +84,10 @@ def self.deep_hexify(obj) def keccak256 ByteString.from_bin(Eth::Util.keccak256(self.to_bin)) end + + def bytesize + @bytes.bytesize + end private diff --git a/spec/integration/transfer_selector_spec.rb b/spec/integration/transfer_selector_spec.rb new file mode 100644 index 0000000..30c837e --- /dev/null +++ b/spec/integration/transfer_selector_spec.rb @@ -0,0 +1,128 @@ +require 'rails_helper' + +RSpec.describe "Transfer Selector End-to-End", type: :integration do + include EthscriptionsTestHelper + + let(:alice) { valid_address("alice") } + let(:bob) { valid_address("bob") } + let(:charlie) { valid_address("charlie") } + + describe "Single vs Multiple Transfer Function Selection" do + it "uses transferEthscription for single input transfers" do + # Create an ethscription owned by alice + id1 = create_test_ethscription(alice) + + # Transfer single ethscription via input + results = import_l1_block([ + transfer_input(from: alice, to: bob, id: id1) + ]) + + # Get the transaction that was created + tx = results[:ethscriptions].first + expect(tx).to be_present + + # Verify it used the singular transfer method + expect(tx.ethscription_operation).to eq('transfer') + expect(tx.transfer_ids).to eq([id1]) # Always an array now + + # Check the function selector + selector = tx.function_selector.unpack1('H*') + # This should be the selector for transferEthscription(address,bytes32) + expected_selector = Eth::Util.keccak256('transferEthscription(address,bytes32)')[0...4].unpack1('H*') + expect(selector).to eq(expected_selector) + + # Verify the calldata encoding + calldata = tx.input.to_hex.delete_prefix('0x') + expect(calldata).to start_with(expected_selector) + end + + it "uses transferEthscriptions for multiple input transfers" do + # Create ethscriptions owned by alice + id1 = create_test_ethscription(alice) + id2 = create_test_ethscription(alice) + + # Transfer multiple ethscriptions via input + results = import_l1_block([ + transfer_multi_input(from: alice, to: charlie, ids: [id1, id2]) + ]) + + # Get the transaction that was created + tx = results[:ethscriptions].first + expect(tx).to be_present + + # Verify it used the multiple transfer method + expect(tx.ethscription_operation).to eq('transfer') + expect(tx.transfer_ids).to eq([id1, id2]) + + # Check the function selector + selector = tx.function_selector.unpack1('H*') + # This should be the selector for transferEthscriptions(address,bytes32[]) + expected_selector = Eth::Util.keccak256('transferEthscriptions(address,bytes32[])')[0...4].unpack1('H*') + expect(selector).to eq(expected_selector) + + # Verify the calldata encoding (address first, then array) + calldata = tx.input.to_hex.delete_prefix('0x') + expect(calldata).to start_with(expected_selector) + end + + it "correctly handles parameter order in transferEthscriptions" do + # Create ethscriptions + id1 = create_test_ethscription(alice) + id2 = create_test_ethscription(alice) + id3 = create_test_ethscription(alice) + + # Transfer multiple + results = import_l1_block([ + transfer_multi_input(from: alice, to: bob, ids: [id1, id2, id3]) + ]) + + tx = results[:ethscriptions].first + + # Decode the calldata to verify parameter order + calldata_hex = tx.input.to_hex.delete_prefix('0x') + + # Skip the 4-byte selector + params_hex = calldata_hex[8..] + + # First 32 bytes should be the address (padded) + address_param = params_hex[0...64] + expected_address = bob.downcase.delete_prefix('0x').rjust(64, '0') + expect(address_param).to include(expected_address[24..]) # Address is right-padded in last 20 bytes + end + + it "uses correct selector even with ESIP-5 disabled for single transfers" do + # Test pre-ESIP-5 behavior (single transfers only) + id1 = create_test_ethscription(alice) + + # Import with ESIP-5 disabled (simulating old block) + results = import_l1_block( + [transfer_input(from: alice, to: bob, id: id1)], + esip_overrides: { esip5: false } + ) + + tx = results[:ethscriptions].first + expect(tx).to be_present + + # Should still use singular transfer for single ID + expect(tx.transfer_ids).to eq([id1]) # Always an array now + + # Verify correct function selector + selector = tx.function_selector.unpack1('H*') + expected_selector = Eth::Util.keccak256('transferEthscription(address,bytes32)')[0...4].unpack1('H*') + expect(selector).to eq(expected_selector) + end + end + + private + + def create_test_ethscription(owner) + results = import_l1_block([ + create_input( + creator: owner, + to: owner, + data_uri: "data:,test-#{SecureRandom.hex(4)}" + ) + ]) + results[:ethscription_ids].first + end +end \ No newline at end of file diff --git a/spec/models/ethscription_transaction_builder_spec.rb b/spec/models/ethscription_transaction_builder_spec.rb index bd197ab..e5ca392 100644 --- a/spec/models/ethscription_transaction_builder_spec.rb +++ b/spec/models/ethscription_transaction_builder_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -RSpec.describe EthscriptionTransactionBuilder do +RSpec.describe "EthscriptionTransactionBuilder" do describe '.extract_token_params' do it 'extracts deploy operation params' do content_uri = 'data:,{"p":"erc-20","op":"deploy","tick":"eths","max":"21000000","lim":"1000"}' From 8f833bde67a713358de5740cefaa41ca977114a2 Mon Sep 17 00:00:00 2001 From: Tom Lehman Date: Tue, 21 Oct 2025 11:03:34 -0400 Subject: [PATCH 2/2] Add file --- .../ethscription_transaction_builder.rb | 233 ------------------ 1 file changed, 233 deletions(-) delete mode 100644 app/models/ethscription_transaction_builder.rb diff --git a/app/models/ethscription_transaction_builder.rb b/app/models/ethscription_transaction_builder.rb deleted file mode 100644 index 71e6b20..0000000 --- a/app/models/ethscription_transaction_builder.rb +++ /dev/null @@ -1,233 +0,0 @@ -class EthscriptionTransactionBuilder - include SysConfig - - # Event signatures - ESIP1_SIG = '0x' + Eth::Util.keccak256('ethscriptions_protocol_TransferEthscription(address,bytes32)').unpack1('H*') - ESIP2_SIG = '0x' + Eth::Util.keccak256('ethscriptions_protocol_TransferEthscriptionForPreviousOwner(address,address,bytes32)').unpack1('H*') - CREATE_SIG = '0x' + Eth::Util.keccak256('ethscriptions_protocol_CreateEthscription(address,string)').unpack1('H*') - - # Build deposit transactions from an L1 transaction - def self.build_deposits(eth_transaction, ethscriptions_block) - new(eth_transaction, ethscriptions_block).build_transactions - end - - def initialize(eth_transaction, ethscriptions_block) - @eth_tx = eth_transaction - @ethscriptions_block = ethscriptions_block - @transactions = [] - end - - def build_transactions - # Only process successful transactions - return [] unless @eth_tx.status == 1 - - # 1. Check for creation (from input or events) - detect_creation - - # 2. Process transfers via input (single transfers from genesis, multi from ESIP-5) - process_input_transfers - - # 3. Process transfers via events (ESIP-1/2) - process_event_transfers - - @transactions - end - - private - - def detect_creation - # Try to create from input - content = decoded_input - if @eth_tx.to_address.present? && content - transaction = EthscriptionTransaction.create_ethscription( - eth_transaction: @eth_tx, - creator: normalize_address(@eth_tx.from_address), - initial_owner: normalize_address(@eth_tx.to_address), - content_uri: content, - source_type: :input, - source_index: @eth_tx.transaction_index - ) - - @transactions << transaction if transaction.valid_create? - end - - # Also check for create events (ESIP-3) - if SysConfig.esip3_enabled?(@eth_tx.block_number) && @eth_tx.logs - process_create_events - end - end - - def process_create_events - ordered_events.each do |log| - next unless log['topics']&.first == CREATE_SIG - - begin - # Exact topic length match like original - next unless log['topics'].length == 2 - - # Decode exactly like the original - initial_owner = Eth::Abi.decode(['address'], log['topics'].second).first - content_uri_data = Eth::Abi.decode(['string'], log['data']).first - content_uri = HexDataProcessor.clean_utf8(content_uri_data) - - transaction = EthscriptionTransaction.create_ethscription( - eth_transaction: @eth_tx, - creator: normalize_address(log['address']), - initial_owner: normalize_address(initial_owner), - content_uri: content_uri, - source_type: :event, - source_index: log['logIndex'].to_i(16) - ) - - @transactions << transaction if transaction.valid_create? - rescue Eth::Abi::DecodingError, RangeError => e - Rails.logger.error "Failed to decode create event: #{e.message}" - next - end - end - end - - def process_input_transfers - return unless @eth_tx.to_address.present? - - # ByteString to hex conversion - input_hex = @eth_tx.input.to_hex.delete_prefix('0x') - - # Check for valid transfer input - # Single transfers (64 chars) supported from genesis - # Multi transfers (n*64 chars) supported from ESIP-5 - valid_length = if SysConfig.esip5_enabled?(@eth_tx.block_number) - # ESIP-5: Allow multiple transfers - input_hex.length > 0 && input_hex.length % 64 == 0 - else - # Pre-ESIP-5: Only single transfers (exactly 64 hex chars) - input_hex.length == 64 - end - - return unless valid_length - - # Parse all 32-byte hashes - ids = input_hex.scan(/.{64}/).map { |hash_hex| normalize_hash("0x#{hash_hex}") } - return if ids.empty? - - # Common transfer parameters - base_params = { - eth_transaction: @eth_tx, - from_address: normalize_address(@eth_tx.from_address), - to_address: normalize_address(@eth_tx.to_address), - source_type: :input, - source_index: @eth_tx.transaction_index - } - - # Use appropriate factory method based on count - transaction = if ids.length == 1 - EthscriptionTransaction.transfer_ethscription( - **base_params, - ethscription_id: ids.first - ) - else - EthscriptionTransaction.transfer_multiple_ethscriptions( - **base_params, - ethscription_ids: ids - ) - end - - @transactions << transaction - end - - def process_event_transfers - ordered_events.each do |log| - begin - case log['topics']&.first - when ESIP1_SIG - handle_esip1_event(log) if SysConfig.esip1_enabled?(@eth_tx.block_number) - when ESIP2_SIG - handle_esip2_event(log) if SysConfig.esip2_enabled?(@eth_tx.block_number) - end - rescue Eth::Abi::DecodingError, RangeError => e - Rails.logger.error "Failed to decode transfer event: #{e.message}" - next - end - end - end - - def handle_esip1_event(log) - # Exact topic length match like original - return unless log['topics'].length == 3 - - # Decode exactly like the original - event_to = Eth::Abi.decode(['address'], log['topics'].second).first - tx_hash = Eth::Util.bin_to_prefixed_hex( - Eth::Abi.decode(['bytes32'], log['topics'].third).first - ) - - ethscription_id = normalize_hash(tx_hash) - - transaction = EthscriptionTransaction.transfer_ethscription( - eth_transaction: @eth_tx, - from_address: normalize_address(log['address']), - to_address: normalize_address(event_to), - ethscription_id: ethscription_id, - source_type: :event, - source_index: log['logIndex'].to_i(16) - ) - - @transactions << transaction - end - - def handle_esip2_event(log) - # Exact topic length match like original - return unless log['topics'].length == 4 - - # Decode exactly like the original - event_previous_owner = Eth::Abi.decode(['address'], log['topics'].second).first - event_to = Eth::Abi.decode(['address'], log['topics'].third).first - tx_hash = Eth::Util.bin_to_prefixed_hex( - Eth::Abi.decode(['bytes32'], log['topics'].fourth).first - ) - - ethscription_id = normalize_hash(tx_hash) - - transaction = EthscriptionTransaction.transfer_ethscription( - eth_transaction: @eth_tx, - from_address: normalize_address(log['address']), - to_address: normalize_address(event_to), - ethscription_id: ethscription_id, - enforced_previous_owner: normalize_address(event_previous_owner), - source_type: :event, - source_index: log['logIndex'].to_i(16) - ) - - @transactions << transaction - end - - def ordered_events - # Handle nil or missing logs gracefully - return [] if @eth_tx.logs.nil? - - @eth_tx.logs.reject { |log| log['removed'] } - .sort_by { |log| log['logIndex'].to_i(16) } - end - - def decoded_input - HexDataProcessor.hex_to_utf8( - @eth_tx.input.to_hex, - support_gzip: SysConfig.esip7_enabled?(@eth_tx.block_number) - ) - end - - def normalize_address(addr) - return nil unless addr - # Handle both Address20 objects and strings - addr_str = addr.respond_to?(:to_hex) ? addr.to_hex : addr.to_s - addr_str.downcase - end - - def normalize_hash(hash) - return nil unless hash - # Handle both Hash32 objects and strings - hash_str = hash.respond_to?(:to_hex) ? hash.to_hex : hash.to_s - hash_str.downcase - end - -end