diff --git a/app/models/ethscription_transaction.rb b/app/models/ethscription_transaction.rb index ef710bc..816a168 100644 --- a/app/models/ethscription_transaction.rb +++ b/app/models/ethscription_transaction.rb @@ -18,11 +18,14 @@ class EthscriptionTransaction < T::Struct # Transfer operation fields prop :ethscription_id, T.nilable(String) + prop :transfer_ids, T.nilable(T::Array[String]) prop :transfer_from_address, T.nilable(String) prop :transfer_to_address, T.nilable(String) prop :enforced_previous_owner, T.nilable(String) - prop :input_index, T.nilable(Integer) - prop :log_index, T.nilable(Integer) + + # Unified source tracking + prop :source_type, T.nilable(Symbol) # :input or :event + prop :source_index, T.nilable(Integer) # Debug info (can be removed if not needed) prop :ethscription_operation, T.nilable(String) # 'create', 'transfer', 'transfer_with_previous_owner' @@ -32,50 +35,15 @@ class EthscriptionTransaction < T::Struct VALUE = 0 GAS_LIMIT = 1_000_000_000 TO_ADDRESS = SysConfig::ETHSCRIPTIONS_ADDRESS - - class << self - def reset_seen_creates! - @seen_creates = Concurrent::Set.new - end - - def add_seen_create(tx_hash) - seen_creates.add(tx_hash) - end - - # Access seen creates set - requires reset_seen_creates! to be called first - def seen_creates - raise "Must call reset_seen_creates! first" unless @seen_creates - @seen_creates - end - end - - # Dynamic source hash based on operation type - def source_hash - case ethscription_operation - when 'create' - # Creates are one-to-one with transactions - eth_transaction.transaction_hash - when 'transfer', 'transfer_with_previous_owner' - # Transfers need unique hash within transaction - if input_index - compute_transfer_source_hash(:input, input_index) - elsif log_index - compute_transfer_source_hash(:event, log_index) - else - raise "Transfer must have either input_index or log_index" - end - else - raise "Unknown ethscription operation: #{ethscription_operation}" - end - end - # Factory method for create operations def self.create_ethscription( eth_transaction:, creator:, initial_owner:, - content_uri: + content_uri:, + source_type:, + source_index: ) new( from_address: Address20.from_hex(creator.is_a?(String) ? creator : creator.to_hex), @@ -83,6 +51,8 @@ def self.create_ethscription( creator: creator, initial_owner: initial_owner, content_uri: content_uri, + source_type: source_type&.to_sym, + source_index: source_index, ethscription_operation: 'create' ) end @@ -94,8 +64,8 @@ def self.transfer_ethscription( to_address:, ethscription_id:, enforced_previous_owner: nil, - input_index: nil, - log_index: nil + source_type:, + source_index: ) operation_type = enforced_previous_owner ? 'transfer_with_previous_owner' : 'transfer' @@ -106,54 +76,72 @@ def self.transfer_ethscription( transfer_from_address: from_address, transfer_to_address: to_address, enforced_previous_owner: enforced_previous_owner, - input_index: input_index, - log_index: log_index, + source_type: source_type&.to_sym, + source_index: source_index, ethscription_operation: operation_type ) end - # Instance method to compute transfer source hash - def compute_transfer_source_hash(operation_type, index) - tag = (operation_type == :input) ? 0 : 1 - payload = ByteString.from_bin( - eth_transaction.transaction_hash.to_bin + - Eth::Util.zpad_int(tag, 32) + - Eth::Util.zpad_int(index, 32) - ) - bin_val = Eth::Util.keccak256( - Eth::Util.zpad_int(2, 32) + Eth::Util.keccak256(payload.to_bin) + # 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' ) - Hash32.from_bin(bin_val) - end - - # Check if this is a valid, unseen ethscription - def valid_and_unseen? - valid_ethscription? && !already_seen? - end - - # Mark this transaction as seen (for create deduplication) - def mark_as_seen! - if ethscription_operation == 'create' - self.class.add_seen_create(source_hash) - end - end - - # Check if we've already seen this create transaction - def already_seen? - return false unless ethscription_operation == 'create' - self.class.seen_creates.include?(source_hash) end - # Check if this is a valid ethscription - def valid_ethscription? - case ethscription_operation + # Get function selector for this operation + def function_selector + function_signature = case ethscription_operation when 'create' - valid_create? - when 'transfer', 'transfer_with_previous_owner' - valid_transfer? + 'createEthscription((bytes32,bytes32,address,bytes,string,string,string,bool,(string,string,string,uint256,uint256,uint256)))' + when 'transfer' + if transfer_ids && transfer_ids.any? + 'transferMultipleEthscriptions(bytes32[],address)' + else + 'transferEthscription(address,bytes32)' + end + when 'transfer_with_previous_owner' + 'transferEthscriptionForPreviousOwner(address,bytes32,address)' else raise "Unknown ethscription operation: #{ethscription_operation}" end + + Eth::Util.keccak256(function_signature)[0...4] + end + + # Unified source hash computation following Optimism pattern + def source_hash + raise "Operation must have source metadata" if source_type.nil? || source_index.nil? + + source_tag = source_type.to_s # "input" or "event" + source_tag_hash = Eth::Util.keccak256(source_tag.bytes.pack('C*')) # Hash for constant width + + payload = ByteString.from_bin( + eth_transaction.block_hash.to_bin + + source_tag_hash + # 32 bytes (hashed source tag) + function_selector + # 4 bytes (function selector) + Eth::Util.zpad_int(source_index, 32) # 32 bytes (source_index) + ) + + bin_val = Eth::Util.keccak256( + Eth::Util.zpad_int(0, 32) + Eth::Util.keccak256(payload.to_bin) # Domain 0 like Optimism + ) + + Hash32.from_bin(bin_val) end def valid_create? @@ -165,7 +153,21 @@ def valid_create? def valid_transfer? # Basic field validation - if we extracted the data properly, ABI encoding should work - ethscription_id.present? && + 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 @@ -178,7 +180,11 @@ def input when 'create' ByteString.from_bin(build_create_calldata) when 'transfer' - ByteString.from_bin(build_transfer_calldata) + if transfer_ids && transfer_ids.any? + ByteString.from_bin(build_transfer_multiple_calldata) + else + ByteString.from_bin(build_transfer_calldata) + end when 'transfer_with_previous_owner' ByteString.from_bin(build_transfer_with_previous_owner_calldata) else @@ -207,9 +213,7 @@ def to_deposit_payload # Build calldata for create operations (same for both input and event-based) def build_create_calldata # Get function selector as binary - function_sig = Eth::Util.keccak256( - 'createEthscription((bytes32,bytes32,address,bytes,string,string,string,bool,(string,string,string,uint256,uint256,uint256)))' - )[0...4].b + function_sig = function_selector.b # Both input and event-based creates use data URI format # Events are "equivalent of an EOA hex-encoding contentURI and putting it in the calldata" @@ -253,7 +257,7 @@ def build_create_calldata def build_transfer_calldata # Get function selector as binary - function_sig = Eth::Util.keccak256('transferEthscription(address,bytes32)')[0...4].b + function_sig = function_selector.b # Convert to binary for ABI to_bin = address_to_bin(transfer_to_address) @@ -267,9 +271,7 @@ def build_transfer_calldata def build_transfer_with_previous_owner_calldata # Get function selector as binary - function_sig = Eth::Util.keccak256( - 'transferEthscriptionForPreviousOwner(address,bytes32,address)' - )[0...4].b + function_sig = function_selector.b # Convert to binary for ABI to_bin = address_to_bin(transfer_to_address) @@ -282,6 +284,18 @@ def build_transfer_with_previous_owner_calldata (function_sig + encoded).b end + 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) + + encoded = Eth::Abi.encode(['bytes32[]', 'address'], [ids_bin, to_bin]) + + (function_sig + encoded).b + end + # Helper to convert hex string to binary def hex_to_bin(hex_str) return nil unless hex_str @@ -302,4 +316,4 @@ def address_to_bin(addr_str) clean_hex = clean_hex.rjust(40, '0')[-40..] [clean_hex].pack('H*') end -end \ No newline at end of file +end diff --git a/app/models/ethscription_transaction_builder.rb b/app/models/ethscription_transaction_builder.rb index 45b22ca..fa6befe 100644 --- a/app/models/ethscription_transaction_builder.rb +++ b/app/models/ethscription_transaction_builder.rb @@ -43,13 +43,12 @@ def detect_creation eth_transaction: @eth_tx, creator: normalize_address(@eth_tx.from_address), initial_owner: normalize_address(@eth_tx.to_address), - content_uri: content + content_uri: content, + source_type: :input, + source_index: @eth_tx.transaction_index ) - if transaction.valid_and_unseen? - transaction.mark_as_seen! - @transactions << transaction - end + @transactions << transaction if transaction.valid_create? end # Also check for create events (ESIP-3) @@ -75,13 +74,12 @@ def process_create_events eth_transaction: @eth_tx, creator: normalize_address(log['address']), initial_owner: normalize_address(initial_owner), - content_uri: content_uri + content_uri: content_uri, + source_type: :event, + source_index: log['logIndex'].to_i(16) ) - if transaction.valid_and_unseen? - transaction.mark_as_seen! - @transactions << transaction - end + @transactions << transaction if transaction.valid_create? rescue Eth::Abi::DecodingError => e Rails.logger.error "Failed to decode create event: #{e.message}" next @@ -108,20 +106,20 @@ def process_input_transfers return unless valid_length - # Parse each 32-byte hash - input_hex.scan(/.{64}/).each_with_index do |hash_hex, index| - ethscription_id = normalize_hash("0x#{hash_hex}") + # Parse all 32-byte hashes for a single multi-transfer call + ids = input_hex.scan(/.{64}/).map { |hash_hex| normalize_hash("0x#{hash_hex}") } + return if ids.empty? - transaction = EthscriptionTransaction.transfer_ethscription( - eth_transaction: @eth_tx, - from_address: normalize_address(@eth_tx.from_address), - to_address: normalize_address(@eth_tx.to_address), - ethscription_id: ethscription_id, - input_index: index - ) + transaction = EthscriptionTransaction.transfer_multiple_ethscriptions( + 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 + ) - @transactions << transaction - end + @transactions << transaction end def process_event_transfers @@ -157,7 +155,8 @@ def handle_esip1_event(log) from_address: normalize_address(log['address']), to_address: normalize_address(event_to), ethscription_id: ethscription_id, - log_index: log['logIndex'].to_i(16) + source_type: :event, + source_index: log['logIndex'].to_i(16) ) @transactions << transaction @@ -182,7 +181,8 @@ def handle_esip2_event(log) to_address: normalize_address(event_to), ethscription_id: ethscription_id, enforced_previous_owner: normalize_address(event_previous_owner), - log_index: log['logIndex'].to_i(16) + source_type: :event, + source_index: log['logIndex'].to_i(16) ) @transactions << transaction diff --git a/app/services/eth_block_importer.rb b/app/services/eth_block_importer.rb index 22bca6d..05e0521 100644 --- a/app/services/eth_block_importer.rb +++ b/app/services/eth_block_importer.rb @@ -282,9 +282,6 @@ def current_facet_finalized_block def import_blocks(block_numbers) ImportProfiler.start("import_blocks_total") - # Initialize seen creates for this batch (needed by prefetcher threads) - EthscriptionTransaction.reset_seen_creates! - logger.info "Block Importer: importing blocks #{block_numbers.join(', ')}" start = Time.current diff --git a/contracts/test/EthscriptionsWithContent.t.sol b/contracts/test/EthscriptionsWithContent.t.sol new file mode 100644 index 0000000..56b10ce --- /dev/null +++ b/contracts/test/EthscriptionsWithContent.t.sol @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import "./TestSetup.sol"; + +contract EthscriptionsWithContentTest is TestSetup { + + function testGetEthscriptionWithContent() public { + // Create a test ethscription first + bytes32 txHash = bytes32(uint256(12345)); + address creator = address(0x1); + address initialOwner = address(0x2); + string memory testContent = "Hello, World!"; + + // Create the ethscription + vm.prank(creator); + Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ + transactionHash: txHash, + contentUriHash: keccak256(bytes("data:text/plain,Hello, World!")), + initialOwner: initialOwner, + content: bytes(testContent), + mimetype: "text/plain", + mediaType: "text", + mimeSubtype: "plain", + esip6: false, + tokenParams: Ethscriptions.TokenParams({ + op: "", + protocol: "", + tick: "", + max: 0, + lim: 0, + amt: 0 + }) + }); + + uint256 tokenId = ethscriptions.createEthscription(params); + + // Test the new combined method + (Ethscriptions.Ethscription memory ethscription, bytes memory content) = ethscriptions.getEthscriptionWithContent(txHash); + + // Verify ethscription data + assertEq(ethscription.creator, creator); + assertEq(ethscription.initialOwner, initialOwner); + assertEq(ethscription.previousOwner, creator); + assertEq(ethscription.ethscriptionNumber, tokenId); + assertEq(ethscription.content.mimetype, "text/plain"); + assertEq(ethscription.content.mediaType, "text"); + assertEq(ethscription.content.mimeSubtype, "plain"); + assertEq(ethscription.content.esip6, false); + + // Verify content + assertEq(content, bytes(testContent)); + + // Compare with individual method calls to ensure they return the same data + Ethscriptions.Ethscription memory ethscriptionSeparate = ethscriptions.getEthscription(txHash); + bytes memory contentSeparate = ethscriptions.getEthscriptionContent(txHash); + + // Compare structs (we'll compare individual fields since struct comparison isn't directly supported) + assertEq(ethscription.creator, ethscriptionSeparate.creator); + assertEq(ethscription.initialOwner, ethscriptionSeparate.initialOwner); + assertEq(ethscription.previousOwner, ethscriptionSeparate.previousOwner); + assertEq(ethscription.ethscriptionNumber, ethscriptionSeparate.ethscriptionNumber); + assertEq(ethscription.createdAt, ethscriptionSeparate.createdAt); + assertEq(ethscription.l1BlockNumber, ethscriptionSeparate.l1BlockNumber); + assertEq(ethscription.l2BlockNumber, ethscriptionSeparate.l2BlockNumber); + assertEq(ethscription.l1BlockHash, ethscriptionSeparate.l1BlockHash); + + // Compare content info + assertEq(ethscription.content.contentUriHash, ethscriptionSeparate.content.contentUriHash); + assertEq(ethscription.content.contentSha, ethscriptionSeparate.content.contentSha); + assertEq(ethscription.content.mimetype, ethscriptionSeparate.content.mimetype); + assertEq(ethscription.content.mediaType, ethscriptionSeparate.content.mediaType); + assertEq(ethscription.content.mimeSubtype, ethscriptionSeparate.content.mimeSubtype); + assertEq(ethscription.content.esip6, ethscriptionSeparate.content.esip6); + + // Compare content + assertEq(content, contentSeparate); + } + + function testGetEthscriptionWithContentNonExistent() public { + bytes32 nonExistentTxHash = bytes32(uint256(99999)); + + // Should revert with EthscriptionDoesNotExist + vm.expectRevert(Ethscriptions.EthscriptionDoesNotExist.selector); + ethscriptions.getEthscriptionWithContent(nonExistentTxHash); + } + + function testGetEthscriptionWithContentLargeContent() public { + // Test with content that requires multiple SSTORE2 chunks + bytes32 txHash = bytes32(uint256(54321)); + address creator = address(0x3); + address initialOwner = address(0x4); + + // Create content larger than CHUNK_SIZE (24575 bytes) + bytes memory largeContent = new bytes(30000); + for (uint256 i = 0; i < 30000; i++) { + largeContent[i] = bytes1(uint8(i % 256)); + } + + // Create the ethscription + vm.prank(creator); + Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ + transactionHash: txHash, + contentUriHash: keccak256(bytes("data:application/octet-stream,")), + initialOwner: initialOwner, + content: largeContent, + mimetype: "application/octet-stream", + mediaType: "application", + mimeSubtype: "octet-stream", + esip6: false, + tokenParams: Ethscriptions.TokenParams({ + op: "", + protocol: "", + tick: "", + max: 0, + lim: 0, + amt: 0 + }) + }); + + ethscriptions.createEthscription(params); + + // Test the combined method with large content + (Ethscriptions.Ethscription memory ethscription, bytes memory content) = ethscriptions.getEthscriptionWithContent(txHash); + + // Verify content is correct + assertEq(content.length, 30000); + assertEq(content, largeContent); + + // Verify ethscription data + assertEq(ethscription.creator, creator); + assertEq(ethscription.initialOwner, initialOwner); + } +} \ No newline at end of file