From b99394463e558017ee91f41d268802d3c11cc444 Mon Sep 17 00:00:00 2001 From: Tom Lehman Date: Wed, 1 Oct 2025 12:00:16 -0400 Subject: [PATCH 1/3] Improve batch wire format --- app/models/facet_batch_constants.rb | 31 +- app/models/parsed_batch.rb | 18 +- app/models/standard_l2_transaction.rb | 2 + app/services/batch_signature_verifier.rb | 123 +++---- app/services/facet_batch_collector.rb | 2 - app/services/facet_batch_parser.rb | 309 +++++++++--------- sequencer/src/batch/maker.ts | 109 +++--- sequencer/src/db/schema.ts | 2 - sequencer/src/l1/monitor.ts | 28 +- spec/integration/blob_end_to_end_spec.rb | 58 ++-- spec/integration/forced_tx_filtering_spec.rb | 27 +- spec/mixed_transaction_types_spec.rb | 57 ++-- ..._l2_transaction_signature_recovery_spec.rb | 5 +- .../services/batch_signature_verifier_spec.rb | 60 ++++ spec/services/blob_aggregation_spec.rb | 66 ++-- spec/services/facet_batch_collector_spec.rb | 34 +- spec/services/facet_batch_parser_spec.rb | 300 +++++++++-------- spec/services/facet_block_builder_spec.rb | 16 +- spec/support/blob_test_helper.rb | 51 ++- 19 files changed, 674 insertions(+), 624 deletions(-) create mode 100644 spec/services/batch_signature_verifier_spec.rb diff --git a/app/models/facet_batch_constants.rb b/app/models/facet_batch_constants.rb index eff4c2f..6f391b2 100644 --- a/app/models/facet_batch_constants.rb +++ b/app/models/facet_batch_constants.rb @@ -1,22 +1,39 @@ # Constants for Facet Batch V2 protocol module FacetBatchConstants - # Magic prefix to identify batch payloads + # Magic prefix to identify batch payloads (8 bytes) MAGIC_PREFIX = ByteString.from_hex("0x0000000000012345") - + # Protocol version VERSION = 1 - + + # Wire format header sizes (in bytes) + MAGIC_SIZE = 8 + CHAIN_ID_SIZE = 8 # uint64 + VERSION_SIZE = 1 # uint8 + ROLE_SIZE = 1 # uint8 + LENGTH_SIZE = 4 # uint32 + HEADER_SIZE = MAGIC_SIZE + CHAIN_ID_SIZE + VERSION_SIZE + ROLE_SIZE + LENGTH_SIZE # 22 bytes + SIGNATURE_SIZE = 65 # secp256k1: r(32) + s(32) + v(1) + + # Wire format offsets + MAGIC_OFFSET = 0 + CHAIN_ID_OFFSET = MAGIC_SIZE + VERSION_OFFSET = CHAIN_ID_OFFSET + CHAIN_ID_SIZE + ROLE_OFFSET = VERSION_OFFSET + VERSION_SIZE + LENGTH_OFFSET = ROLE_OFFSET + ROLE_SIZE + RLP_OFFSET = HEADER_SIZE + # Size limits MAX_BATCH_BYTES = Integer(ENV.fetch('MAX_BATCH_BYTES', 131_072)) # 128KB default MAX_TXS_PER_BATCH = Integer(ENV.fetch('MAX_TXS_PER_BATCH', 1000)) MAX_BATCHES_PER_PAYLOAD = Integer(ENV.fetch('MAX_BATCHES_PER_PAYLOAD', 10)) - + # Batch roles module Role - FORCED = 0x00 # Anyone can post, no signature required - PRIORITY = 0x01 # Requires authorized signature + PERMISSIONLESS = 0x00 # Anyone can post, no signature required (formerly FORCED) + PRIORITY = 0x01 # Requires authorized signature end - + # Source types for tracking where batch came from module Source CALLDATA = 'calldata' diff --git a/app/models/parsed_batch.rb b/app/models/parsed_batch.rb index 387de77..38deb06 100644 --- a/app/models/parsed_batch.rb +++ b/app/models/parsed_batch.rb @@ -1,17 +1,15 @@ # Represents a parsed and validated Facet batch class ParsedBatch < T::Struct extend T::Sig - - const :role, Integer # FORCED or PRIORITY - const :signer, T.nilable(Address20) # Signer address (nil if not verified or forced) - const :target_l1_block, Integer # L1 block this batch targets + + const :role, Integer # PERMISSIONLESS or PRIORITY + const :signer, T.nilable(Address20) # Signer address (nil if not verified or permissionless) const :l1_tx_index, Integer # Transaction index in L1 block - const :source, String # Where batch came from (calldata/event/blob) + const :source, String # Where batch came from (calldata/blob) const :source_details, T::Hash[Symbol, T.untyped] # Additional source info (tx_hash, blob_index, etc.) const :transactions, T::Array[ByteString] # Array of EIP-2718 typed transaction bytes - const :content_hash, Hash32 # Keccak256 of encoded batch for deduplication - const :chain_id, Integer # Chain ID from batch - const :extra_data, T.nilable(ByteString) # Optional extra data field + const :content_hash, Hash32 # Keccak256 of RLP_TX_LIST for deduplication + const :chain_id, Integer # Chain ID from batch header sig { returns(T::Boolean) } def is_priority? @@ -19,8 +17,8 @@ def is_priority? end sig { returns(T::Boolean) } - def is_forced? - role == FacetBatchConstants::Role::FORCED + def is_permissionless? + role == FacetBatchConstants::Role::PERMISSIONLESS end sig { returns(Integer) } diff --git a/app/models/standard_l2_transaction.rb b/app/models/standard_l2_transaction.rb index 97394ac..bb71e5e 100644 --- a/app/models/standard_l2_transaction.rb +++ b/app/models/standard_l2_transaction.rb @@ -199,6 +199,8 @@ def self.recover_address_eip1559(decoded, v, r, s, chain_id) # Handle both string and Eth::Address object returns address_hex = address.is_a?(String) ? address : address.to_s Address20.from_hex(address_hex) + rescue Secp256k1::DeserializationError => e + raise DecodeError, "Failed to recover EIP-1559 address: #{e.message}" end def self.recover_address_eip2930(decoded, v, r, s, chain_id) diff --git a/app/services/batch_signature_verifier.rb b/app/services/batch_signature_verifier.rb index bc2fdf2..a1ddc83 100644 --- a/app/services/batch_signature_verifier.rb +++ b/app/services/batch_signature_verifier.rb @@ -1,99 +1,54 @@ -# EIP-712 signature verification for Facet batches +# Signature verification for Facet batches class BatchSignatureVerifier include SysConfig - - # EIP-712 domain - DOMAIN_NAME = "FacetBatch" - DOMAIN_VERSION = "1" - - # Type hash for FacetBatchData - # struct FacetBatchData { - # uint8 version; - # uint256 chainId; - # uint8 role; - # uint64 targetL1Block; - # bytes[] transactions; - # bytes extraData; - # } - BATCH_DATA_TYPE_HASH = Eth::Util.keccak256( - "FacetBatchData(uint8 version,uint256 chainId,uint8 role,uint64 targetL1Block,bytes[] transactions,bytes extraData)" - ) - + attr_reader :chain_id - + def initialize(chain_id: ChainIdManager.current_l2_chain_id) @chain_id = chain_id end - - # Verify a batch signature and return the signer address - # Returns nil if signature is invalid or missing - # batch_data_rlp: The RLP array [version, chainId, role, targetL1Block, transactions[], extraData] - def verify(batch_data_rlp, signature) + + # Verify signature for new wire format + # signed_data: [CHAIN_ID:8][VERSION:1][ROLE:1][RLP_TX_LIST] + # signature: 65-byte secp256k1 signature + def verify_wire_format(signed_data, signature) return nil unless signature - + sig_bytes = signature.is_a?(ByteString) ? signature.to_bin : signature return nil unless sig_bytes.length == 65 - - # Calculate EIP-712 hash of the RLP-encoded batch data - message_hash = eip712_hash_rlp(batch_data_rlp) - + + # Hash the signed data + message_hash = Eth::Util.keccak256(signed_data) + # Recover signer from signature recover_signer(message_hash, sig_bytes) - rescue => e - Rails.logger.debug "Signature verification failed: #{e.message}" - nil - end - - private - - def domain_separator - # EIP-712 domain separator - @domain_separator ||= begin - domain_type_hash = Eth::Util.keccak256( - "EIP712Domain(string name,string version,uint256 chainId)" - ) - - encoded = [ - domain_type_hash, - Eth::Util.keccak256(DOMAIN_NAME), - Eth::Util.keccak256(DOMAIN_VERSION), - Eth::Util.zpad_int(chain_id, 32) - ].join - - Eth::Util.keccak256(encoded) + rescue StandardError => e + if signature_error?(e) + Rails.logger.debug "Signature verification failed: #{e.message}" + nil + else + raise end end - - def eip712_hash_rlp(batch_data_rlp) - # For RLP batches, we sign the keccak256 of the RLP-encoded FacetBatchData - # This is simpler and more standard than EIP-712 structured data - batch_data_encoded = Eth::Rlp.encode(batch_data_rlp) - - # Create the message to sign: Ethereum signed message prefix + hash - message_hash = Eth::Util.keccak256(batch_data_encoded) - - # Apply EIP-191 personal message signing format - # "\x19Ethereum Signed Message:\n32" + message_hash - prefix = "\x19Ethereum Signed Message:\n32" - Eth::Util.keccak256(prefix + message_hash) - end - - def hash_transactions_array(transactions) - # Hash array of transactions according to EIP-712 - # Each transaction is hashed, then the array of hashes is hashed - tx_hashes = transactions.map { |tx| Eth::Util.keccak256(tx.to_bin) } - encoded = tx_hashes.join - Eth::Util.keccak256(encoded) - end + + private def recover_signer(message_hash, sig_bytes) # Extract r, s, v from signature r = sig_bytes[0, 32] s = sig_bytes[32, 32] - v = sig_bytes[64].ord - - # Adjust v for EIP-155 - v = v < 27 ? v + 27 : v + raw_v = sig_bytes[64].ord + + # Normalise recovery id so both {0,1} and {27,28} inputs are accepted + v_normalised = raw_v + v_normalised -= 27 if v_normalised >= 27 + + unless [0, 1].include?(v_normalised) + error_class = defined?(Eth::Signature::SignatureError) ? Eth::Signature::SignatureError : StandardError + raise error_class, "Invalid recovery id #{raw_v}" + end + + v = v_normalised + 27 # Create signature for recovery # The eth.rb gem expects r (32 bytes) + s (32 bytes) + v (variable length hex) @@ -110,4 +65,14 @@ def recover_signer(message_hash, sig_bytes) Address20.from_hex(address) end -end \ No newline at end of file + + def signature_error?(error) + return true if defined?(Eth::Signature::SignatureError) && error.is_a?(Eth::Signature::SignatureError) + + if defined?(Secp256k1) && Secp256k1.const_defined?(:Error) + return true if error.is_a?(Secp256k1.const_get(:Error)) + end + + false + end +end diff --git a/app/services/facet_batch_collector.rb b/app/services/facet_batch_collector.rb index c2cdeb4..6c2ac7d 100644 --- a/app/services/facet_batch_collector.rb +++ b/app/services/facet_batch_collector.rb @@ -150,7 +150,6 @@ def collect_batches_from_calldata(tx, tx_index) parser.parse_payload( input, - eth_block['number'].to_i(16), tx_index, FacetBatchConstants::Source::CALLDATA, source_details @@ -188,7 +187,6 @@ def collect_batches_from_blobs batch_list = parser.parse_payload( blob_data, - block_number, carrier[:tx_index], FacetBatchConstants::Source::BLOB, source_details diff --git a/app/services/facet_batch_parser.rb b/app/services/facet_batch_parser.rb index 6172865..6002987 100644 --- a/app/services/facet_batch_parser.rb +++ b/app/services/facet_batch_parser.rb @@ -14,51 +14,74 @@ def initialize(chain_id: ChainIdManager.current_l2_chain_id, logger: Rails.logge # Parse a payload (calldata, event data, or blob) for batches # Returns array of ParsedBatch objects - def parse_payload(payload, l1_block_number, l1_tx_index, source, source_details = {}) + def parse_payload(payload, l1_tx_index, source, source_details = {}) return [] unless payload - - # logger.debug "FacetBatchParser: Parsing payload of length #{payload.is_a?(ByteString) ? payload.to_bin.length : payload.length} for block #{l1_block_number}" - + + # logger.debug "FacetBatchParser: Parsing payload of length #{payload.is_a?(ByteString) ? payload.to_bin.length : payload.length}" + batches = [] data = payload.is_a?(ByteString) ? payload.to_bin : payload - + # Scan for magic prefix at any offset offset = 0 - magic_len = FacetBatchConstants::MAGIC_PREFIX.to_bin.length - + while (index = data.index(FacetBatchConstants::MAGIC_PREFIX.to_bin, offset)) logger.debug "FacetBatchParser: Found magic prefix at offset #{index}" begin - # Read length field to know how much to skip - length_pos = index + magic_len - if length_pos + 4 <= data.length - length = data[length_pos, 4].unpack1('N') - - batch = parse_batch_at_offset(data, index, l1_block_number, l1_tx_index, source, source_details) - batches << batch if batch - - # Enforce max batches per payload - if batches.length >= FacetBatchConstants::MAX_BATCHES_PER_PAYLOAD - logger.warn "Max batches per payload reached (#{FacetBatchConstants::MAX_BATCHES_PER_PAYLOAD})" - break - end - - # Move past this entire batch (magic + length field + batch data) - offset = index + magic_len + 4 + length - else - # Not enough data for length field + # Need at least full header to proceed + if index + FacetBatchConstants::HEADER_SIZE > data.length break end + + # Read and validate chain ID early (before expensive RLP parsing) + chain_id_offset = index + FacetBatchConstants::CHAIN_ID_OFFSET + wire_chain_id = data[chain_id_offset, FacetBatchConstants::CHAIN_ID_SIZE].unpack1('Q>') # uint64 big-endian + + # Skip if wrong chain ID + if wire_chain_id != chain_id + logger.debug "Skipping batch for chain #{wire_chain_id} (expected #{chain_id})" + # Read length to skip entire batch efficiently + length_offset = index + FacetBatchConstants::LENGTH_OFFSET + length = data[length_offset, FacetBatchConstants::LENGTH_SIZE].unpack1('N') # uint32 big-endian + offset = index + FacetBatchConstants::HEADER_SIZE + length + # Add signature size if priority batch + role_offset = index + FacetBatchConstants::ROLE_OFFSET + role = data[role_offset, FacetBatchConstants::ROLE_SIZE].unpack1('C') + offset += FacetBatchConstants::SIGNATURE_SIZE if role == FacetBatchConstants::Role::PRIORITY + next + end + + batch = parse_batch_at_offset(data, index, l1_tx_index, source, source_details) + batches << batch if batch + + # Enforce max batches per payload + if batches.length >= FacetBatchConstants::MAX_BATCHES_PER_PAYLOAD + logger.warn "Max batches per payload reached (#{FacetBatchConstants::MAX_BATCHES_PER_PAYLOAD})" + break + end + + # Move past this entire batch + # Read length to know how much to skip + length_offset = index + FacetBatchConstants::LENGTH_OFFSET + length = data[length_offset, FacetBatchConstants::LENGTH_SIZE].unpack1('N') + offset = index + FacetBatchConstants::HEADER_SIZE + length + # Add signature size if priority batch + role_offset = index + FacetBatchConstants::ROLE_OFFSET + role = data[role_offset, FacetBatchConstants::ROLE_SIZE].unpack1('C') + offset += FacetBatchConstants::SIGNATURE_SIZE if role == FacetBatchConstants::Role::PRIORITY rescue ParseError, ValidationError => e logger.debug "Failed to parse batch at offset #{index}: #{e.message}" - # If we got a valid length, skip past the entire claimed batch to avoid O(N²) scanning - if length_pos + 4 <= data.length - length = data[length_pos, 4].unpack1('N') + # Try to skip past this batch + if index + FacetBatchConstants::HEADER_SIZE <= data.length + length_offset = index + FacetBatchConstants::LENGTH_OFFSET + length = data[length_offset, FacetBatchConstants::LENGTH_SIZE].unpack1('N') if length > 0 && length <= FacetBatchConstants::MAX_BATCH_BYTES - # Skip past the entire malformed batch - offset = index + magic_len + 4 + length + offset = index + FacetBatchConstants::HEADER_SIZE + length + # Check for priority batch signature + role_offset = index + FacetBatchConstants::ROLE_OFFSET + role = data[role_offset, FacetBatchConstants::ROLE_SIZE].unpack1('C') + offset += FacetBatchConstants::SIGNATURE_SIZE if role == FacetBatchConstants::Role::PRIORITY else - # Invalid length, just skip past magic offset = index + 1 end else @@ -66,161 +89,141 @@ def parse_payload(payload, l1_block_number, l1_tx_index, source, source_details end end end - + batches end private - def parse_batch_at_offset(data, offset, l1_block_number, l1_tx_index, source, source_details) - # Skip magic prefix - pos = offset + FacetBatchConstants::MAGIC_PREFIX.to_bin.length - - # Read length field (uint32) - return nil if pos + 4 > data.length - length = data[pos, 4].unpack1('N') # Network byte order (big-endian) - pos += 4 - - # Bounds check + def parse_batch_at_offset(data, offset, l1_tx_index, source, source_details) + # Read the fixed header fields + # [MAGIC:8][CHAIN_ID:8][VERSION:1][ROLE:1][LENGTH:4] + pos = offset + + # Magic prefix (already validated by caller) + pos += FacetBatchConstants::MAGIC_SIZE + + # Chain ID (uint64 big-endian) + return nil if pos + FacetBatchConstants::CHAIN_ID_SIZE > data.length + wire_chain_id = data[pos, FacetBatchConstants::CHAIN_ID_SIZE].unpack1('Q>') + pos += FacetBatchConstants::CHAIN_ID_SIZE + + # Version (uint8) + return nil if pos + FacetBatchConstants::VERSION_SIZE > data.length + version = data[pos, FacetBatchConstants::VERSION_SIZE].unpack1('C') + pos += FacetBatchConstants::VERSION_SIZE + + # Role (uint8) + return nil if pos + FacetBatchConstants::ROLE_SIZE > data.length + role = data[pos, FacetBatchConstants::ROLE_SIZE].unpack1('C') + pos += FacetBatchConstants::ROLE_SIZE + + # Length (uint32 big-endian) + return nil if pos + FacetBatchConstants::LENGTH_SIZE > data.length + length = data[pos, FacetBatchConstants::LENGTH_SIZE].unpack1('N') + pos += FacetBatchConstants::LENGTH_SIZE + + # Validate header fields + if version != FacetBatchConstants::VERSION + raise ValidationError, "Invalid batch version: #{version} != #{FacetBatchConstants::VERSION}" + end + + if wire_chain_id != chain_id + raise ValidationError, "Invalid chain ID: #{wire_chain_id} != #{chain_id}" + end + + unless [FacetBatchConstants::Role::PERMISSIONLESS, FacetBatchConstants::Role::PRIORITY].include?(role) + raise ValidationError, "Invalid role: #{role}" + end + if length > FacetBatchConstants::MAX_BATCH_BYTES raise ParseError, "Batch too large: #{length} > #{FacetBatchConstants::MAX_BATCH_BYTES}" end - + + # Read RLP_TX_LIST if pos + length > data.length - raise ParseError, "Batch extends beyond payload: need #{length} bytes, have #{data.length - pos}" + raise ParseError, "RLP data extends beyond payload: need #{length} bytes, have #{data.length - pos}" + end + rlp_tx_list = data[pos, length] + pos += length + + # Read signature if priority batch + signature = nil + if role == FacetBatchConstants::Role::PRIORITY + if pos + FacetBatchConstants::SIGNATURE_SIZE > data.length + raise ParseError, "Signature extends beyond payload for priority batch" + end + signature = data[pos, FacetBatchConstants::SIGNATURE_SIZE] end - - # Extract batch data - batch_data = data[pos, length] - - # Decode RLP-encoded FacetBatch - decoded = decode_facet_batch_rlp(batch_data) - - # Validate batch - validate_batch(decoded, l1_block_number) - + + # Decode RLP transaction list + transactions = decode_transaction_list(rlp_tx_list) + + # Calculate content hash from CHAIN_ID + VERSION + ROLE + RLP_TX_LIST + SIGNATURE + # Including signature ensures batches with different signatures (e.g., invalid vs valid) don't deduplicate + content_data = [wire_chain_id].pack('Q>') + [version].pack('C') + [role].pack('C') + rlp_tx_list + if signature + content_data += signature + end + content_hash = Hash32.from_bin(Eth::Util.keccak256(content_data)) + # Verify signature if enabled and priority batch signer = nil - if decoded[:role] == FacetBatchConstants::Role::PRIORITY + if role == FacetBatchConstants::Role::PRIORITY if SysConfig.enable_sig_verify? - signer = verify_signature(decoded[:batch_data], decoded[:signature]) + # Construct data to sign: [CHAIN_ID:8][VERSION:1][ROLE:1][RLP_TX_LIST] + signed_data = [wire_chain_id].pack('Q>') + [version].pack('C') + [role].pack('C') + rlp_tx_list + signer = verify_signature(signed_data, signature) raise ValidationError, "Invalid signature for priority batch" unless signer else # For testing without signatures logger.debug "Signature verification disabled for priority batch" end end - + # Create ParsedBatch ParsedBatch.new( - role: decoded[:role], + role: role, signer: signer, - target_l1_block: decoded[:target_l1_block], l1_tx_index: l1_tx_index, source: source, source_details: source_details, - transactions: decoded[:transactions], - content_hash: decoded[:content_hash], - chain_id: decoded[:chain_id], - extra_data: decoded[:extra_data] - ) - end - - def decode_facet_batch_rlp(data) - # RLP decode: [FacetBatchData, signature?] - # FacetBatchData = [version, chainId, role, targetL1Block, transactions[], extraData] - - decoded = Eth::Rlp.decode(data) - - unless decoded.is_a?(Array) && (decoded.length == 1 || decoded.length == 2) - raise ParseError, "Invalid batch structure: expected [FacetBatchData] or [FacetBatchData, signature]" - end - - batch_data_rlp = decoded[0] - # For forced batches, signature can be omitted (length=1) or empty string (length=2) - signature = decoded.length == 2 ? decoded[1] : '' - - unless batch_data_rlp.is_a?(Array) && batch_data_rlp.length == 6 - raise ParseError, "Invalid FacetBatchData: expected 6 fields, got #{batch_data_rlp.length}" - end - - # Parse FacetBatchData fields - version = deserialize_rlp_int(batch_data_rlp[0]) - chain_id = deserialize_rlp_int(batch_data_rlp[1]) - role = deserialize_rlp_int(batch_data_rlp[2]) - target_l1_block = deserialize_rlp_int(batch_data_rlp[3]) - - # Transactions array - each element is raw EIP-2718 typed tx bytes - unless batch_data_rlp[4].is_a?(Array) - raise ParseError, "Invalid transactions field: expected array" - end - transactions = batch_data_rlp[4].map { |tx| ByteString.from_bin(tx) } - - # Extra data - extra_data = batch_data_rlp[5].empty? ? nil : ByteString.from_bin(batch_data_rlp[5]) - - # Calculate content hash from FacetBatchData only (excluding signature) - batch_data_encoded = Eth::Rlp.encode(batch_data_rlp) - content_hash = Hash32.from_bin(Eth::Util.keccak256(batch_data_encoded)) - - { - version: version, - chain_id: chain_id, - role: role, - target_l1_block: target_l1_block, transactions: transactions, - extra_data: extra_data, content_hash: content_hash, - batch_data: batch_data_rlp, # Keep for signature verification - signature: signature ? ByteString.from_bin(signature.b) : nil - } - rescue => e - raise ParseError, "Failed to decode RLP batch: #{e.message}" - end - - # Deserialize RLP integer with same logic as FacetTransaction - def deserialize_rlp_int(data) - return 0 if data.empty? - - # Check for leading zeros (invalid in RLP) - if data.length > 1 && data[0] == "\x00" - raise ParseError, "Invalid RLP integer: leading zeros" - end - - data.unpack1('H*').to_i(16) + chain_id: wire_chain_id + ) end - def validate_batch(decoded, l1_block_number) - # Check version - if decoded[:version] != FacetBatchConstants::VERSION - raise ValidationError, "Invalid batch version: #{decoded[:version]} != #{FacetBatchConstants::VERSION}" - end - - # Check chain ID - if decoded[:chain_id] != chain_id - raise ValidationError, "Invalid chain ID: #{decoded[:chain_id]} != #{chain_id}" + def decode_transaction_list(rlp_data) + # RLP decode transaction list - expecting an array of raw transaction bytes + decoded = Eth::Rlp.decode(rlp_data) + + unless decoded.is_a?(Array) + raise ParseError, "Invalid transaction list: expected RLP array" end - - # TODO: make work or discard - # Check target block - # if decoded[:target_l1_block] != l1_block_number - # raise ValidationError, "Invalid target block: #{decoded[:target_l1_block]} != #{l1_block_number}" - # end - - # Check transaction count - if decoded[:transactions].length > FacetBatchConstants::MAX_TXS_PER_BATCH - raise ValidationError, "Too many transactions: #{decoded[:transactions].length} > #{FacetBatchConstants::MAX_TXS_PER_BATCH}" + + decoded.each_with_index do |tx, index| + unless tx.is_a?(String) + raise ParseError, "Invalid transaction entry at index #{index}: expected byte string" + end end - - # Check role - unless [FacetBatchConstants::Role::FORCED, FacetBatchConstants::Role::PRIORITY].include?(decoded[:role]) - raise ValidationError, "Invalid role: #{decoded[:role]}" + + # Validate transaction count + if decoded.length > FacetBatchConstants::MAX_TXS_PER_BATCH + raise ValidationError, "Too many transactions: #{decoded.length} > #{FacetBatchConstants::MAX_TXS_PER_BATCH}" end + + # Each element should be raw transaction bytes (already EIP-2718 encoded) + decoded.map { |tx| ByteString.from_bin(tx) } + rescue StandardError => e + raise ParseError, "Failed to decode RLP transaction list: #{e.message}" end - def verify_signature(data, signature) - # TODO: Implement EIP-712 signature verification - # For now, return nil (signature not verified) - nil + def verify_signature(signed_data, signature) + return nil unless signature + + # Use BatchSignatureVerifier to verify the signature + verifier = BatchSignatureVerifier.new(chain_id: chain_id) + verifier.verify_wire_format(signed_data, signature) end end diff --git a/sequencer/src/batch/maker.ts b/sequencer/src/batch/maker.ts index 5a8ba51..112a9e9 100644 --- a/sequencer/src/batch/maker.ts +++ b/sequencer/src/batch/maker.ts @@ -33,7 +33,6 @@ export class BatchMaker { const database = this.db.getDatabase(); // Get L1 data before starting the transaction - const targetL1Block = await this.getNextL1Block(); const gasBid = await this.calculateGasBid(); return database.transaction(() => { @@ -50,10 +49,13 @@ export class BatchMaker { // Apply selection criteria const selected = this.selectTransactions(candidates, maxBytes, maxCount); if (selected.length === 0) return null; - + + const role = 0; // 0 = permissionless + const signature: Hex | undefined = undefined; + // Create Facet batch wire format - const wireFormat = this.createFacetWireFormat(selected, targetL1Block); - const contentHash = this.calculateContentHash(selected, targetL1Block); + const wireFormat = this.createFacetWireFormat(selected, role, signature); + const contentHash = this.calculateContentHash(selected, role, signature); // Check for duplicate batch const existing = database.prepare( @@ -72,15 +74,14 @@ export class BatchMaker { // Create batch record with tx_hashes as JSON const batchResult = database.prepare(` - INSERT INTO batches (content_hash, wire_format, state, blob_size, gas_bid, tx_count, target_l1_block, tx_hashes) - VALUES (?, ?, 'open', ?, ?, ?, ?, ?) + INSERT INTO batches (content_hash, wire_format, state, blob_size, gas_bid, tx_count, tx_hashes) + VALUES (?, ?, 'open', ?, ?, ?, ?) `).run( contentHash, wireFormat, wireFormat.length, gasBid.toString(), selected.length, - Number(targetL1Block), txHashesJson ); @@ -102,11 +103,10 @@ export class BatchMaker { 'UPDATE batches SET state = ?, sealed_at = ? WHERE id = ?' ).run('sealed', Date.now(), batchId); - logger.info({ - batchId, + logger.info({ + batchId, txCount: selected.length, - size: wireFormat.length, - targetL1Block: targetL1Block.toString() + size: wireFormat.length }, 'Batch created'); return batchId; @@ -142,56 +142,55 @@ export class BatchMaker { return selected; } - private createFacetWireFormat(transactions: Transaction[], targetL1Block: bigint): Buffer { - // Build FacetBatchData structure - const batchData = [ - toHex(1), // version - toHex(this.L2_CHAIN_ID), // chainId - "0x" as Hex, // role (0 = FORCED) - toHex(targetL1Block), // targetL1Block - transactions.map(tx => ('0x' + tx.raw.toString('hex')) as Hex), // raw transaction bytes - '0x' as Hex // extraData + private createFacetWireFormat(transactions: Transaction[], role: number, signature?: Hex): Buffer { + // RLP encode transaction list only (array of raw transaction bytes) + const txList = transactions.map(tx => ('0x' + tx.raw.toString('hex')) as Hex); + const rlpTxList = toRlp(txList); + + // Build new wire format: [MAGIC:8][CHAIN_ID:8][VERSION:1][ROLE:1][LENGTH:4][RLP_TX_LIST] + const rlpSize = size(rlpTxList); + const parts: Hex[] = [ + this.FACET_MAGIC_PREFIX, // MAGIC: 8 bytes + encodePacked(['uint64'], [this.L2_CHAIN_ID]), // CHAIN_ID: 8 bytes big-endian + encodePacked(['uint8'], [1]), // VERSION: 1 byte + encodePacked(['uint8'], [role]), // ROLE: 1 byte + toHex(rlpSize, { size: 4 }), // LENGTH: 4 bytes big-endian + rlpTxList // RLP_TX_LIST ]; - - // For forced batches, wrap in outer array: [FacetBatchData] - // For priority batches, it would be: [FacetBatchData, signature] - const wrappedBatch = [batchData]; - - // RLP encode the wrapped batch - const batchRlp = toRlp(wrappedBatch); - - // Create wire format: magic || uint32_be(length) || rlp(batch) - const lengthBytes = toHex(size(batchRlp), { size: 4 }); - const wireFormatHex = concatHex([ - this.FACET_MAGIC_PREFIX, - lengthBytes, - batchRlp - ]); - + + if (signature) { + parts.push(signature); + } + + const wireFormatHex = concatHex(parts); + return Buffer.from(wireFormatHex.slice(2), 'hex'); } - - private calculateContentHash(transactions: Transaction[], targetL1Block: bigint): Buffer { - // Calculate content hash for deduplication - const batchData = [ - toHex(1), // version - toHex(this.L2_CHAIN_ID), // chainId - "0x" as Hex, // role (0 = FORCED) - toHex(targetL1Block), // targetL1Block - transactions.map(tx => ('0x' + tx.raw.toString('hex')) as Hex), - '0x' as Hex + + private calculateContentHash(transactions: Transaction[], role: number, signature?: Hex): Buffer { + // Calculate content hash from CHAIN_ID + VERSION + ROLE + RLP_TX_LIST + SIGNATURE + // Including signature ensures batches with different signatures don't deduplicate + // For permissionless batches (no signature), hash is just chain_id + version + role + txs + const txList = transactions.map(tx => ('0x' + tx.raw.toString('hex')) as Hex); + const rlpTxList = toRlp(txList); + + const parts: Hex[] = [ + encodePacked(['uint64'], [this.L2_CHAIN_ID]), // CHAIN_ID: 8 bytes + encodePacked(['uint8'], [1]), // VERSION: 1 byte + encodePacked(['uint8'], [role]), // ROLE: 1 byte + rlpTxList // RLP_TX_LIST ]; - - const hash = keccak256(toRlp(batchData)); + + if (signature) { + parts.push(signature); + } + + const contentData = concatHex(parts); + + const hash = keccak256(contentData); return Buffer.from(hash.slice(2), 'hex'); } - private async getNextL1Block(): Promise { - // Get the actual next L1 block number - const currentBlock = await this.l1Client.getBlockNumber(); - return currentBlock + 1n; - } - private async calculateGasBid(): Promise { // Get actual gas prices from L1 const fees = await this.l1Client.estimateFeesPerGas(); @@ -232,4 +231,4 @@ export class BatchMaker { // In production, adjust based on L1 congestion return 200; } -} \ No newline at end of file +} diff --git a/sequencer/src/db/schema.ts b/sequencer/src/db/schema.ts index 21e50ae..2eb4486 100644 --- a/sequencer/src/db/schema.ts +++ b/sequencer/src/db/schema.ts @@ -27,7 +27,6 @@ export interface Batch { blob_size: number; gas_bid: string; tx_count: number; - target_l1_block?: number; tx_hashes: string; // JSON array of transaction hashes } @@ -89,7 +88,6 @@ export const createSchema = (db: Database.Database) => { blob_size INTEGER NOT NULL, gas_bid TEXT NOT NULL, tx_count INTEGER NOT NULL, - target_l1_block INTEGER, tx_hashes JSON NOT NULL DEFAULT '[]' -- JSON array of transaction hashes in order ); diff --git a/sequencer/src/l1/monitor.ts b/sequencer/src/l1/monitor.ts index 93b3875..d837d30 100644 --- a/sequencer/src/l1/monitor.ts +++ b/sequencer/src/l1/monitor.ts @@ -219,27 +219,29 @@ export class InclusionMonitor { private checkForDroppedTransactions(l2BlockNumber: number): void { const database = this.db.getDatabase(); - - // Transactions submitted more than 100 L2 blocks ago but not included - const threshold = l2BlockNumber - 100; - + + // Transactions submitted more than 10 minutes ago but not included + const tenMinutesAgo = Date.now() - (10 * 60 * 1000); + const dropped = database.prepare(` SELECT t.hash, t.batch_id FROM transactions t JOIN batches b ON t.batch_id = b.id - WHERE t.state = 'submitted' - AND b.target_l1_block < ? - `).all(threshold) as Array<{ hash: Buffer; batch_id: number }>; - + JOIN post_attempts pa ON pa.batch_id = b.id + WHERE t.state = 'submitted' + AND pa.status = 'mined' + AND pa.confirmed_at < ? + `).all(tenMinutesAgo) as Array<{ hash: Buffer; batch_id: number }>; + for (const tx of dropped) { database.prepare(` - UPDATE transactions - SET state = 'dropped', drop_reason = 'Not included after 100 blocks' + UPDATE transactions + SET state = 'dropped', drop_reason = 'Not included after 10 minutes' WHERE hash = ? `).run(tx.hash); - - logger.warn({ - txHash: '0x' + tx.hash.toString('hex') + + logger.warn({ + txHash: '0x' + tx.hash.toString('hex') }, 'Transaction dropped'); } } diff --git a/spec/integration/blob_end_to_end_spec.rb b/spec/integration/blob_end_to_end_spec.rb index 57d1a1c..ddb25c5 100644 --- a/spec/integration/blob_end_to_end_spec.rb +++ b/spec/integration/blob_end_to_end_spec.rb @@ -25,17 +25,21 @@ # Step 2: Create a Facet batch puts "\n=== Creating Facet batch ===" - batch_data = create_test_batch_data(transactions) - puts " Batch size: #{batch_data.bytesize} bytes" + rlp_tx_list = create_test_batch_data(transactions) + puts " Transaction list size: #{rlp_tx_list.bytesize} bytes" puts " Batch contains #{transactions.length} transactions" - + # Step 3: Create blob with Facet data (simulating DA Builder aggregation) puts "\n=== Encoding to EIP-4844 blob ===" - - # Add magic prefix and length header + + # Build complete wire format + chain_id = ChainIdManager.current_l2_chain_id facet_payload = FacetBatchConstants::MAGIC_PREFIX.to_bin - facet_payload += [batch_data.length].pack('N') - facet_payload += batch_data + facet_payload += [chain_id].pack('Q>') # uint64 big-endian + facet_payload += [FacetBatchConstants::VERSION].pack('C') + facet_payload += [FacetBatchConstants::Role::PERMISSIONLESS].pack('C') + facet_payload += [rlp_tx_list.length].pack('N') + facet_payload += rlp_tx_list # Simulate aggregation with other data other_rollup_data = "\xDE\xAD\xBE\xEF".b * 1000 # 4KB of other data @@ -74,7 +78,6 @@ # Parse batches parsed_batches = parser.parse_payload( decoded_bytes, - 12345, # l1_block_number 0, # l1_tx_index FacetBatchConstants::Source::BLOB, { versioned_hash: versioned_hash } @@ -89,12 +92,12 @@ batch = parsed_batches.first expect(batch.transactions.length).to eq(3) expect(batch.source).to eq(FacetBatchConstants::Source::BLOB) - expect(batch.role).to eq(FacetBatchConstants::Role::FORCED) + expect(batch.role).to eq(FacetBatchConstants::Role::PERMISSIONLESS) - puts " ✓ Batch role: #{batch.role == 1 ? 'FORCED' : 'SEQUENCER'}" + puts " ✓ Batch role: #{batch.role == FacetBatchConstants::Role::PRIORITY ? 'PRIORITY' : 'PERMISSIONLESS'}" puts " ✓ Transaction count: #{batch.transactions.length}" puts " ✓ Source: #{batch.source_description}" - puts " ✓ Target L1 block: #{batch.target_l1_block}" + puts " ✓ Chain ID: #{batch.chain_id}" # Verify transaction details batch.transactions.each_with_index do |tx, i| @@ -115,13 +118,29 @@ # Create two separate batches batch1_txs = [create_test_transaction(value: 100, nonce: 0)] batch2_txs = [create_test_transaction(value: 200, nonce: 1)] - - batch1_data = create_test_batch_data(batch1_txs) - batch2_data = create_test_batch_data(batch2_txs) - - # Create payloads with magic prefix - payload1 = FacetBatchConstants::MAGIC_PREFIX.to_bin + [batch1_data.length].pack('N') + batch1_data - payload2 = FacetBatchConstants::MAGIC_PREFIX.to_bin + [batch2_data.length].pack('N') + batch2_data + + # Create RLP transaction lists + rlp_tx_list1 = create_test_batch_data(batch1_txs) + rlp_tx_list2 = create_test_batch_data(batch2_txs) + + # Build complete wire format for each batch + chain_id = ChainIdManager.current_l2_chain_id + + # First batch + payload1 = FacetBatchConstants::MAGIC_PREFIX.to_bin + payload1 += [chain_id].pack('Q>') # uint64 big-endian + payload1 += [FacetBatchConstants::VERSION].pack('C') + payload1 += [FacetBatchConstants::Role::PERMISSIONLESS].pack('C') + payload1 += [rlp_tx_list1.length].pack('N') + payload1 += rlp_tx_list1 + + # Second batch + payload2 = FacetBatchConstants::MAGIC_PREFIX.to_bin + payload2 += [chain_id].pack('Q>') # uint64 big-endian + payload2 += [FacetBatchConstants::VERSION].pack('C') + payload2 += [FacetBatchConstants::Role::PERMISSIONLESS].pack('C') + payload2 += [rlp_tx_list2.length].pack('N') + payload2 += rlp_tx_list2 # Aggregate with padding aggregated = payload1 + ("\x00".b * 1000) + payload2 @@ -135,7 +154,6 @@ parsed_batches = parser.parse_payload( decoded_bytes, - 12345, 0, FacetBatchConstants::Source::BLOB ) @@ -211,7 +229,7 @@ decoded = BlobUtils.from_blobs(blobs: blobs) decoded_bytes = ByteString.from_hex(decoded) - batches = parser.parse_payload(decoded_bytes, 12345, 0, FacetBatchConstants::Source::BLOB) + batches = parser.parse_payload(decoded_bytes, 0, FacetBatchConstants::Source::BLOB) expect(batches).to be_empty puts " ✓ Correctly ignored batch with bad magic" diff --git a/spec/integration/forced_tx_filtering_spec.rb b/spec/integration/forced_tx_filtering_spec.rb index 8a025aa..a7d2740 100644 --- a/spec/integration/forced_tx_filtering_spec.rb +++ b/spec/integration/forced_tx_filtering_spec.rb @@ -113,20 +113,17 @@ def create_eip1559_transaction(private_key:, to:, value:, gas_limit:, nonce: nil def create_forced_batch_payload(transactions:, target_l1_block:) chain_id = ChainIdManager.current_l2_chain_id - batch_data = [ - Eth::Util.serialize_int_to_big_endian(1), # version - Eth::Util.serialize_int_to_big_endian(chain_id), - Eth::Util.serialize_int_to_big_endian(FacetBatchConstants::Role::FORCED), - Eth::Util.serialize_int_to_big_endian(target_l1_block), - transactions.map(&:to_bin), - '' - ] - - facet_batch = [batch_data, ''] - rlp_encoded = Eth::Rlp.encode(facet_batch) - - magic = FacetBatchConstants::MAGIC_PREFIX.to_bin - length = [rlp_encoded.length].pack('N') - ByteString.from_bin(magic + length + rlp_encoded) + # Create RLP-encoded transaction list + rlp_tx_list = Eth::Rlp.encode(transactions.map(&:to_bin)) + + # Build wire format: [MAGIC:8][CHAIN_ID:8][VERSION:1][ROLE:1][LENGTH:4][RLP_TX_LIST] + payload = FacetBatchConstants::MAGIC_PREFIX.to_bin + payload += [chain_id].pack('Q>') # uint64 big-endian + payload += [FacetBatchConstants::VERSION].pack('C') + payload += [FacetBatchConstants::Role::PERMISSIONLESS].pack('C') + payload += [rlp_tx_list.length].pack('N') + payload += rlp_tx_list + + ByteString.from_bin(payload) end end diff --git a/spec/mixed_transaction_types_spec.rb b/spec/mixed_transaction_types_spec.rb index a26c18c..061aff5 100644 --- a/spec/mixed_transaction_types_spec.rb +++ b/spec/mixed_transaction_types_spec.rb @@ -73,16 +73,17 @@ target_block = current_max_eth_block.number + 2 # +2 because we imported funding block batch_payload = create_batch_payload( transactions: [eip1559_tx], - role: FacetBatchConstants::Role::FORCED, + role: FacetBatchConstants::Role::PERMISSIONLESS, target_l1_block: target_block ) puts "Target L1 block for batch: #{target_block}" puts "Batch should contain #{[eip1559_tx].length} transaction(s)" - # Debug the batch structure - test_decode = Eth::Rlp.decode(batch_payload.to_bin[12..-1]) # Skip magic + length - puts "Decoded batch has #{test_decode[0][4].length} transactions" + # Debug the batch structure - new format has 22-byte header + # [MAGIC:8][CHAIN_ID:8][VERSION:1][ROLE:1][LENGTH:4][RLP_TX_LIST] + test_decode = Eth::Rlp.decode(batch_payload.to_bin[22..-1]) # Skip header to get RLP_TX_LIST + puts "Decoded batch has #{test_decode.length} transactions" puts "Batch payload length: #{batch_payload.to_bin.length} bytes" puts "Batch payload hex (first 100 chars): #{batch_payload.to_hex[0..100]}" @@ -219,7 +220,7 @@ forced_batch = create_batch_payload( transactions: [forced_tx], - role: FacetBatchConstants::Role::FORCED, + role: FacetBatchConstants::Role::PERMISSIONLESS, target_l1_block: current_max_eth_block.number + 1 ) @@ -352,35 +353,25 @@ def create_eip1559_transaction(private_key:, to:, value:, gas_limit:, nonce: nil def create_batch_payload(transactions:, role:, target_l1_block:, sign: false) chain_id = ChainIdManager.current_l2_chain_id - - # FacetBatchData = [version, chainId, role, targetL1Block, transactions[], extraData] - batch_data = [ - Eth::Util.serialize_int_to_big_endian(1), # version - Eth::Util.serialize_int_to_big_endian(chain_id), # chainId - Eth::Util.serialize_int_to_big_endian(role), # role - Eth::Util.serialize_int_to_big_endian(target_l1_block), # targetL1Block - transactions.map(&:to_bin), # transactions array - ACTUALLY include them! - '' # extraData - ] - - # FacetBatch = [FacetBatchData, signature] - # Always include signature field (can be empty string for non-priority) + + # Create RLP-encoded transaction list + rlp_tx_list = Eth::Rlp.encode(transactions.map(&:to_bin)) + + # Build wire format: [MAGIC:8][CHAIN_ID:8][VERSION:1][ROLE:1][LENGTH:4][RLP_TX_LIST][SIGNATURE:65]? + payload = FacetBatchConstants::MAGIC_PREFIX.to_bin + payload += [chain_id].pack('Q>') # uint64 big-endian + payload += [FacetBatchConstants::VERSION].pack('C') + payload += [role].pack('C') + payload += [rlp_tx_list.length].pack('N') + payload += rlp_tx_list + + # Add signature for priority batches if sign && role == FacetBatchConstants::Role::PRIORITY - # Add dummy signature for priority batches - signature = "\x00" * 64 + "\x01" # 65 bytes - else - signature = '' # Empty signature for forced batches + # Add dummy signature for priority batches (65 bytes: r:32, s:32, v:1) + signature = "\x00" * 32 + "\x00" * 32 + "\x01" + payload += signature end - - facet_batch = [batch_data, signature] # Always 2 elements - - # Encode with RLP - rlp_encoded = Eth::Rlp.encode(facet_batch) - - # Add wire format header - magic = FacetBatchConstants::MAGIC_PREFIX.to_bin - length = [rlp_encoded.length].pack('N') - - ByteString.from_bin(magic + length + rlp_encoded) + + ByteString.from_bin(payload) end end \ No newline at end of file diff --git a/spec/models/standard_l2_transaction_signature_recovery_spec.rb b/spec/models/standard_l2_transaction_signature_recovery_spec.rb index 11ead14..5fb03a6 100644 --- a/spec/models/standard_l2_transaction_signature_recovery_spec.rb +++ b/spec/models/standard_l2_transaction_signature_recovery_spec.rb @@ -79,10 +79,7 @@ invalid_s = "\x00" * 32 tx_data = [chain_id, 1, 100000, 200000, 21000, to_address, 1000000, "", []] - recovered = StandardL2Transaction.recover_address_eip1559(tx_data, 0, invalid_r, invalid_s, chain_id) - - # Should return null address without crashing - expect(recovered.to_hex).to eq("0x" + "0" * 40) + expect { StandardL2Transaction.recover_address_eip1559(tx_data, 0, invalid_r, invalid_s, chain_id) }.to raise_error(StandardL2Transaction::DecodeError) end end diff --git a/spec/services/batch_signature_verifier_spec.rb b/spec/services/batch_signature_verifier_spec.rb new file mode 100644 index 0000000..3063a4d --- /dev/null +++ b/spec/services/batch_signature_verifier_spec.rb @@ -0,0 +1,60 @@ +require 'rails_helper' + +RSpec.describe BatchSignatureVerifier do + let(:chain_id) { ChainIdManager.current_l2_chain_id } + let(:verifier) { described_class.new(chain_id: chain_id) } + let(:key) { Eth::Key.new } + + def build_signed_data(role: FacetBatchConstants::Role::PRIORITY) + tx_list = Eth::Rlp.encode([]) + [ + [chain_id].pack('Q>'), + [FacetBatchConstants::VERSION].pack('C'), + [role].pack('C'), + tx_list + ].join + end + + def build_signature(message_hash) + signature_hex = key.sign(message_hash).sub(/^0x/, '') + [signature_hex].pack('H*') + end + + describe '#verify_wire_format' do + it 'accepts signatures with legacy v values (27/28)' do + signed_data = build_signed_data + message_hash = Eth::Util.keccak256(signed_data) + signature = build_signature(message_hash) + + signer = verifier.verify_wire_format(signed_data, signature) + + expect(signer.to_hex.downcase).to eq(key.address.to_s.downcase) + end + + it 'accepts signatures with normalised v values (0/1)' do + signed_data = build_signed_data + message_hash = Eth::Util.keccak256(signed_data) + signature = build_signature(message_hash) + + normalised_signature = signature.dup + normalised_signature.setbyte(64, normalised_signature.getbyte(64) - 27) + + signer = verifier.verify_wire_format(signed_data, normalised_signature) + + expect(signer.to_hex.downcase).to eq(key.address.to_s.downcase) + end + + it 'returns nil for signatures with invalid recovery ids' do + signed_data = build_signed_data + message_hash = Eth::Util.keccak256(signed_data) + signature = build_signature(message_hash) + + invalid_signature = signature.dup + invalid_signature.setbyte(64, 5) + + signer = verifier.verify_wire_format(signed_data, invalid_signature) + + expect(signer).to be_nil + end + end +end diff --git a/spec/services/blob_aggregation_spec.rb b/spec/services/blob_aggregation_spec.rb index 54c4476..d714919 100644 --- a/spec/services/blob_aggregation_spec.rb +++ b/spec/services/blob_aggregation_spec.rb @@ -61,11 +61,14 @@ end it 'handles batch that claims size beyond blob boundary' do - # Create batch that claims to be huge - magic = FacetBatchConstants::MAGIC_PREFIX.to_bin - huge_size = [200_000].pack('N') # Claims 200KB but blob is only 128KB - - blob_data = magic + huge_size + ("\x00".b * 100) + # Create batch header that claims to be huge + chain_id = ChainIdManager.current_l2_chain_id + + blob_data = FacetBatchConstants::MAGIC_PREFIX.to_bin + blob_data += [chain_id].pack('Q>') # uint64 big-endian + blob_data += [FacetBatchConstants::VERSION].pack('C') + blob_data += [FacetBatchConstants::Role::PERMISSIONLESS].pack('C') + blob_data += [200_000].pack('N') # Claims 200KB but blob is only 128KB blob_data += "\x00".b * (131_072 - blob_data.length) blob = ByteString.from_bin(blob_data) @@ -82,37 +85,19 @@ create_test_transaction(nonce: i, value: 1000 * (i + 1)) end - # Create batch - batch = ParsedBatch.new( - role: FacetBatchConstants::Role::FORCED, - signer: nil, - target_l1_block: 12345, - l1_tx_index: 0, - source: FacetBatchConstants::Source::BLOB, - source_details: {}, - transactions: transactions, - content_hash: Hash32.from_bin(Eth::Util.keccak256("test")), - chain_id: ChainIdManager.current_l2_chain_id, - extra_data: ByteString.from_bin("".b) - ) - - # Encode for blob - batch_data = [ - Eth::Util.serialize_int_to_big_endian(1), - Eth::Util.serialize_int_to_big_endian(batch.chain_id), - Eth::Util.serialize_int_to_big_endian(batch.role), - Eth::Util.serialize_int_to_big_endian(batch.target_l1_block), - batch.transactions.map(&:to_bin), - '' - ] - - facet_batch = [batch_data, ''] - rlp_encoded = Eth::Rlp.encode(facet_batch) - - # Add wire format + # Create batch in new wire format + chain_id = ChainIdManager.current_l2_chain_id + + # Create RLP-encoded transaction list + rlp_tx_list = Eth::Rlp.encode(transactions.map(&:to_bin)) + + # Construct wire format: [MAGIC:8][CHAIN_ID:8][VERSION:1][ROLE:1][LENGTH:4][RLP_TX_LIST] payload = FacetBatchConstants::MAGIC_PREFIX.to_bin - payload += [rlp_encoded.length].pack('N') - payload += rlp_encoded + payload += [chain_id].pack('Q>') # uint64 big-endian + payload += [FacetBatchConstants::VERSION].pack('C') + payload += [FacetBatchConstants::Role::PERMISSIONLESS].pack('C') + payload += [rlp_tx_list.length].pack('N') + payload += rlp_tx_list # Embed in blob blob_data = payload + ("\x00".b * (131_072 - payload.length)) @@ -122,16 +107,15 @@ parser = FacetBatchParser.new parsed_batches = parser.parse_payload( blob, - batch.target_l1_block, - 0, + 0, # l1_tx_index FacetBatchConstants::Source::BLOB ) - + expect(parsed_batches.length).to eq(1) parsed = parsed_batches.first - - expect(parsed.role).to eq(batch.role) - expect(parsed.target_l1_block).to eq(batch.target_l1_block) + + expect(parsed.role).to eq(FacetBatchConstants::Role::PERMISSIONLESS) + expect(parsed.chain_id).to eq(chain_id) expect(parsed.transactions.length).to eq(3) expect(parsed.transactions.map(&:to_bin)).to eq(transactions.map(&:to_bin)) end diff --git a/spec/services/facet_batch_collector_spec.rb b/spec/services/facet_batch_collector_spec.rb index 83025f4..9d004f8 100644 --- a/spec/services/facet_batch_collector_spec.rb +++ b/spec/services/facet_batch_collector_spec.rb @@ -262,29 +262,19 @@ def create_v1_tx_payload end def create_batch_payload - # Create a valid RLP batch payload with magic prefix + # Create a valid batch in new wire format chain_id = ChainIdManager.current_l2_chain_id - - # FacetBatchData = [version, chainId, role, targetL1Block, transactions[], extraData] - batch_data = [ - Eth::Util.serialize_int_to_big_endian(1), # version - Eth::Util.serialize_int_to_big_endian(chain_id), # chainId - Eth::Util.serialize_int_to_big_endian(FacetBatchConstants::Role::FORCED), # role - Eth::Util.serialize_int_to_big_endian(block_number), # targetL1Block - [], # transactions (empty array) - '' # extraData (empty) - ] - - # FacetBatch = [FacetBatchData, signature] - facet_batch = [batch_data, ''] # Empty signature for forced batch - - # Encode with RLP - rlp_encoded = Eth::Rlp.encode(facet_batch) - - # Add wire format header + + # Create empty transaction list + rlp_tx_list = Eth::Rlp.encode([]) + + # Construct wire format: [MAGIC:8][CHAIN_ID:8][VERSION:1][ROLE:1][LENGTH:4][RLP_TX_LIST] magic = FacetBatchConstants::MAGIC_PREFIX.to_bin - length = [rlp_encoded.length].pack('N') - - ByteString.from_bin(magic + length + rlp_encoded) + chain_id_bytes = [chain_id].pack('Q>') # uint64 big-endian + version_byte = [FacetBatchConstants::VERSION].pack('C') # uint8 + role_byte = [FacetBatchConstants::Role::PERMISSIONLESS].pack('C') # uint8 + length_bytes = [rlp_tx_list.length].pack('N') # uint32 big-endian + + ByteString.from_bin(magic + chain_id_bytes + version_byte + role_byte + length_bytes + rlp_tx_list) end end \ No newline at end of file diff --git a/spec/services/facet_batch_parser_spec.rb b/spec/services/facet_batch_parser_spec.rb index 902b9d9..d1caa0d 100644 --- a/spec/services/facet_batch_parser_spec.rb +++ b/spec/services/facet_batch_parser_spec.rb @@ -7,154 +7,139 @@ let(:l1_tx_index) { 5 } describe '#parse_payload' do - context 'with valid batch' do - let(:batch_data) do - # RLP encoding for testing - batch_data = [ - Eth::Util.serialize_int_to_big_endian(1), # version - Eth::Util.serialize_int_to_big_endian(chain_id), # chainId - Eth::Util.serialize_int_to_big_endian(FacetBatchConstants::Role::FORCED), # role - Eth::Util.serialize_int_to_big_endian(l1_block_number), # targetL1Block - [], # transactions (empty array) - '' # extraData (empty) - ] - - # FacetBatch = [FacetBatchData, signature] - Eth::Rlp.encode([batch_data, '']) # Empty signature for forced batch + context 'with valid permissionless batch' do + let(:rlp_tx_list) do + # Empty transaction list for testing + Eth::Rlp.encode([]) end - + let(:payload) do + # Construct wire format: [MAGIC:8][CHAIN_ID:8][VERSION:1][ROLE:1][LENGTH:4][RLP_TX_LIST] magic = FacetBatchConstants::MAGIC_PREFIX.to_bin - length = [batch_data.length].pack('N') # uint32 big-endian - - ByteString.from_bin(magic + length + batch_data) + chain_id_bytes = [chain_id].pack('Q>') # uint64 big-endian + version_byte = [FacetBatchConstants::VERSION].pack('C') # uint8 + role_byte = [FacetBatchConstants::Role::PERMISSIONLESS].pack('C') # uint8 + length_bytes = [rlp_tx_list.length].pack('N') # uint32 big-endian + + ByteString.from_bin(magic + chain_id_bytes + version_byte + role_byte + length_bytes + rlp_tx_list) end - + it 'parses a valid batch' do - batches = parser.parse_payload(payload, l1_block_number, l1_tx_index, FacetBatchConstants::Source::CALLDATA) - + batches = parser.parse_payload(payload, l1_tx_index, FacetBatchConstants::Source::CALLDATA) + expect(batches.length).to eq(1) batch = batches.first - - expect(batch.role).to eq(FacetBatchConstants::Role::FORCED) - expect(batch.target_l1_block).to eq(l1_block_number) + + expect(batch.role).to eq(FacetBatchConstants::Role::PERMISSIONLESS) expect(batch.l1_tx_index).to eq(l1_tx_index) expect(batch.chain_id).to eq(chain_id) expect(batch.transactions).to be_empty + expect(batch.signer).to be_nil end end context 'with invalid version' do - let(:batch_data) do - batch_data = [ - Eth::Util.serialize_int_to_big_endian(2), # Wrong version - Eth::Util.serialize_int_to_big_endian(chain_id), - Eth::Util.serialize_int_to_big_endian(FacetBatchConstants::Role::FORCED), - Eth::Util.serialize_int_to_big_endian(l1_block_number), - [], - '' - ] - Eth::Rlp.encode([batch_data]) + let(:rlp_tx_list) do + Eth::Rlp.encode([]) end - + let(:payload) do magic = FacetBatchConstants::MAGIC_PREFIX.to_bin - length = [batch_data.length].pack('N') - ByteString.from_bin(magic + length + batch_data) + chain_id_bytes = [chain_id].pack('Q>') + version_byte = [2].pack('C') # Wrong version + role_byte = [FacetBatchConstants::Role::PERMISSIONLESS].pack('C') + length_bytes = [rlp_tx_list.length].pack('N') + + ByteString.from_bin(magic + chain_id_bytes + version_byte + role_byte + length_bytes + rlp_tx_list) end - + it 'rejects batch with wrong version' do - batches = parser.parse_payload(payload, l1_block_number, l1_tx_index, FacetBatchConstants::Source::CALLDATA) + batches = parser.parse_payload(payload, l1_tx_index, FacetBatchConstants::Source::CALLDATA) expect(batches).to be_empty end end context 'with wrong chain ID' do - let(:batch_data) do - batch_data = [ - Eth::Util.serialize_int_to_big_endian(1), # version - Eth::Util.serialize_int_to_big_endian(999999), # Wrong chain ID - Eth::Util.serialize_int_to_big_endian(FacetBatchConstants::Role::FORCED), # role - Eth::Util.serialize_int_to_big_endian(l1_block_number), # targetL1Block - [], # transactions - '' # extraData - ] - Eth::Rlp.encode([batch_data]) + let(:rlp_tx_list) do + Eth::Rlp.encode([]) end - + let(:payload) do magic = FacetBatchConstants::MAGIC_PREFIX.to_bin - length = [batch_data.length].pack('N') - ByteString.from_bin(magic + length + batch_data) + chain_id_bytes = [999999].pack('Q>') # Wrong chain ID + version_byte = [FacetBatchConstants::VERSION].pack('C') + role_byte = [FacetBatchConstants::Role::PERMISSIONLESS].pack('C') + length_bytes = [rlp_tx_list.length].pack('N') + + ByteString.from_bin(magic + chain_id_bytes + version_byte + role_byte + length_bytes + rlp_tx_list) end - - it 'rejects batch with wrong chain ID' do - batches = parser.parse_payload(payload, l1_block_number, l1_tx_index, FacetBatchConstants::Source::CALLDATA) + + it 'skips batch with wrong chain ID without parsing RLP' do + batches = parser.parse_payload(payload, l1_tx_index, FacetBatchConstants::Source::CALLDATA) expect(batches).to be_empty end end - context 'with wrong target block' do - let(:batch_data) do - batch_data = [ - Eth::Util.serialize_int_to_big_endian(1), # version - Eth::Util.serialize_int_to_big_endian(chain_id), # chainId - Eth::Util.serialize_int_to_big_endian(FacetBatchConstants::Role::FORCED), # role - Eth::Util.serialize_int_to_big_endian(99999), # Wrong target block - [], # transactions - '' # extraData - ] - Eth::Rlp.encode([batch_data]) + context 'with multiple batches in payload' do + let(:rlp_tx_list) { Eth::Rlp.encode([]) } + + let(:batch1) do + create_valid_wire_batch(chain_id, FacetBatchConstants::Role::PERMISSIONLESS, rlp_tx_list) end - - let(:payload) do - magic = FacetBatchConstants::MAGIC_PREFIX.to_bin - length = [batch_data.length].pack('N') - ByteString.from_bin(magic + length + batch_data) + + let(:batch2) do + create_valid_wire_batch(chain_id, FacetBatchConstants::Role::PERMISSIONLESS, rlp_tx_list) end - - # TODO - # it 'rejects batch with wrong target block' do - # batches = parser.parse_payload(payload, l1_block_number, l1_tx_index, FacetBatchConstants::Source::CALLDATA) - # expect(batches).to be_empty - # end - end - - context 'with multiple batches in payload' do - let(:batch1) { create_valid_batch_data } - let(:batch2) { create_valid_batch_data } - + let(:payload) do - magic = FacetBatchConstants::MAGIC_PREFIX.to_bin - - batch1_with_header = magic + [batch1.length].pack('N') + batch1 - batch2_with_header = magic + [batch2.length].pack('N') + batch2 - # Add some padding between batches - ByteString.from_bin(batch1_with_header + "\x00" * 10 + batch2_with_header) + ByteString.from_bin(batch1 + "\x00" * 10 + batch2) end - + it 'finds multiple batches' do - batches = parser.parse_payload(payload, l1_block_number, l1_tx_index, FacetBatchConstants::Source::CALLDATA) + batches = parser.parse_payload(payload, l1_tx_index, FacetBatchConstants::Source::CALLDATA) expect(batches.length).to eq(2) end end context 'with batch exceeding max size' do - let(:oversized_data) { "\x00" * (FacetBatchConstants::MAX_BATCH_BYTES + 1) } - + let(:oversized_rlp) { Eth::Rlp.encode(["\x00" * (FacetBatchConstants::MAX_BATCH_BYTES + 1)]) } + let(:payload) do magic = FacetBatchConstants::MAGIC_PREFIX.to_bin - length = [oversized_data.length].pack('N') - ByteString.from_bin(magic + length + oversized_data) + chain_id_bytes = [chain_id].pack('Q>') + version_byte = [FacetBatchConstants::VERSION].pack('C') + role_byte = [FacetBatchConstants::Role::PERMISSIONLESS].pack('C') + length_bytes = [oversized_rlp.length].pack('N') + + ByteString.from_bin(magic + chain_id_bytes + version_byte + role_byte + length_bytes + oversized_rlp) end - + it 'rejects oversized batch' do - batches = parser.parse_payload(payload, l1_block_number, l1_tx_index, FacetBatchConstants::Source::CALLDATA) + batches = parser.parse_payload(payload, l1_tx_index, FacetBatchConstants::Source::CALLDATA) expect(batches).to be_empty end end - + + context 'with nested transaction entry' do + let(:malformed_rlp) { Eth::Rlp.encode([["nested"]]) } + + let(:payload) do + magic = FacetBatchConstants::MAGIC_PREFIX.to_bin + chain_id_bytes = [chain_id].pack('Q>') + version_byte = [FacetBatchConstants::VERSION].pack('C') + role_byte = [FacetBatchConstants::Role::PERMISSIONLESS].pack('C') + length_bytes = [malformed_rlp.length].pack('N') + + ByteString.from_bin(magic + chain_id_bytes + version_byte + role_byte + length_bytes + malformed_rlp) + end + + it 'rejects transaction lists with non byte-string entries' do + batches = parser.parse_payload(payload, l1_tx_index, FacetBatchConstants::Source::CALLDATA) + expect(batches).to be_empty + end + end + context 'with malformed length field' do let(:payload) do magic = FacetBatchConstants::MAGIC_PREFIX.to_bin @@ -163,7 +148,7 @@ end it 'handles malformed length gracefully' do - batches = parser.parse_payload(payload, l1_block_number, l1_tx_index, FacetBatchConstants::Source::CALLDATA) + batches = parser.parse_payload(payload, l1_tx_index, FacetBatchConstants::Source::CALLDATA) expect(batches).to be_empty end end @@ -171,38 +156,51 @@ private - def create_valid_batch_data - # Create valid RLP-encoded batch data - batch_data = [ - Eth::Util.serialize_int_to_big_endian(1), # version - Eth::Util.serialize_int_to_big_endian(chain_id), # chainId - Eth::Util.serialize_int_to_big_endian(FacetBatchConstants::Role::FORCED), # role - Eth::Util.serialize_int_to_big_endian(l1_block_number), # targetL1Block - [], # transactions (empty array) - '' # extraData (empty) - ] - - # FacetBatch = [FacetBatchData, signature] - facet_batch = [batch_data, ''] # Empty signature for forced batch - - # Return RLP-encoded batch - Eth::Rlp.encode(facet_batch) + def create_valid_wire_batch(chain_id, role, rlp_tx_list, signature = nil) + # Create valid wire format batch + magic = FacetBatchConstants::MAGIC_PREFIX.to_bin + chain_id_bytes = [chain_id].pack('Q>') # uint64 big-endian + version_byte = [FacetBatchConstants::VERSION].pack('C') # uint8 + role_byte = [role].pack('C') # uint8 + length_bytes = [rlp_tx_list.length].pack('N') # uint32 big-endian + + batch = magic + chain_id_bytes + version_byte + role_byte + length_bytes + rlp_tx_list + + # Add signature for priority batches + if role == FacetBatchConstants::Role::PRIORITY && signature + batch += signature + end + + batch end describe 'real blob parsing' do - # This test uses real blob data from block 1193381 - # Original test was in test_blob_parse.rb - it 'parses real blob data from block 1193381' do - # Real blob data (already decoded from blob format via BlobUtils.from_blobs) - blob_hex = '0x00000000000123450000008df88bf8890183face7b008408baf03af87bb87902f87683face7b8084773594008504a817c8008252089470997970c51812dc3a010c7d01b50e0d17dc79c888016345785d8a000080c080a09319812cf80571eaf0ff69a17e27537b4faf857c4268717ada7c2645fb0efab6a077e333b17b54b397972c1920bb1088d4de3c6a705061988a35d331d6e4c2ab6c80' - - decoded_bytes = ByteString.from_hex(blob_hex) - parser = described_class.new(chain_id: 0xface7b) - - # Parse the blob + it 'parses batch with real transaction in new format' do + # Create a real EIP-1559 transaction (from the old format test) + # This is the same transaction that was in the old blob, now in new format + tx_hex = '0x02f87683face7b8084773594008504a817c8008252089470997970c51812dc3a010c7d01b50e0d17dc79c888016345785d8a000080c080a09319812cf80571eaf0ff69a17e27537b4faf857c4268717ada7c2645fb0efab6a077e333b17b54b397972c1920bb1088d4de3c6a705061988a35d331d6e4c2ab60' + + # Create RLP-encoded transaction list + tx_bytes = ByteString.from_hex(tx_hex).to_bin + rlp_tx_list = Eth::Rlp.encode([tx_bytes]) + + # Build wire format batch for chain_id 0xface7b (16436859) + chain_id = 0xface7b + + # Construct wire format: [MAGIC:8][CHAIN_ID:8][VERSION:1][ROLE:1][LENGTH:4][RLP_TX_LIST] + magic = FacetBatchConstants::MAGIC_PREFIX.to_bin + chain_id_bytes = [chain_id].pack('Q>') # uint64 big-endian + version_byte = [FacetBatchConstants::VERSION].pack('C') + role_byte = [FacetBatchConstants::Role::PERMISSIONLESS].pack('C') + length_bytes = [rlp_tx_list.length].pack('N') # uint32 big-endian + + wire_batch = magic + chain_id_bytes + version_byte + role_byte + length_bytes + rlp_tx_list + + parser = described_class.new(chain_id: chain_id) + + # Parse the batch batches = parser.parse_payload( - decoded_bytes, - 1193381, + ByteString.from_bin(wire_batch), 0, FacetBatchConstants::Source::BLOB, {} @@ -212,7 +210,7 @@ def create_valid_batch_data expect(batches.length).to eq(1) batch = batches.first - expect(batch.role).to eq(FacetBatchConstants::Role::FORCED) + expect(batch.role).to eq(FacetBatchConstants::Role::PERMISSIONLESS) expect(batch.transactions).to be_an(Array) expect(batch.transactions.length).to eq(1) @@ -225,5 +223,49 @@ def create_valid_batch_data expect(decoded_tx).to be_a(Eth::Tx::Eip1559) expect(decoded_tx.chain_id).to eq(0xface7b) end + + it 'parses priority batch with signature' do + # Create a simple transaction + tx_hex = '0x02f87683face7b8084773594008504a817c8008252089470997970c51812dc3a010c7d01b50e0d17dc79c888016345785d8a000080c080a09319812cf80571eaf0ff69a17e27537b4faf857c4268717ada7c2645fb0efab6a077e333b17b54b397972c1920bb1088d4de3c6a705061988a35d331d6e4c2ab60' + + # Create RLP-encoded transaction list + tx_bytes = ByteString.from_hex(tx_hex).to_bin + rlp_tx_list = Eth::Rlp.encode([tx_bytes]) + + chain_id = 0xface7b + + # Construct wire format for priority batch: [MAGIC:8][CHAIN_ID:8][VERSION:1][ROLE:1][LENGTH:4][RLP_TX_LIST][SIGNATURE:65] + magic = FacetBatchConstants::MAGIC_PREFIX.to_bin + chain_id_bytes = [chain_id].pack('Q>') + version_byte = [FacetBatchConstants::VERSION].pack('C') + role_byte = [FacetBatchConstants::Role::PRIORITY].pack('C') + length_bytes = [rlp_tx_list.length].pack('N') + + # Create a dummy 65-byte signature (r: 32, s: 32, v: 1) + signature = "\x00" * 32 + "\x00" * 32 + "\x1b" # v=27 (0x1b) + + wire_batch = magic + chain_id_bytes + version_byte + role_byte + length_bytes + rlp_tx_list + signature + + parser = described_class.new(chain_id: chain_id) + + # Disable signature verification for this test + allow(SysConfig).to receive(:enable_sig_verify?).and_return(false) + + # Parse the batch + batches = parser.parse_payload( + ByteString.from_bin(wire_batch), + 0, + FacetBatchConstants::Source::BLOB, + {} + ) + + expect(batches).not_to be_empty + expect(batches.length).to eq(1) + + batch = batches.first + expect(batch.role).to eq(FacetBatchConstants::Role::PRIORITY) + expect(batch.transactions.length).to eq(1) + expect(batch.signer).to be_nil # Since we disabled verification + end end -end \ No newline at end of file +end diff --git a/spec/services/facet_block_builder_spec.rb b/spec/services/facet_block_builder_spec.rb index e49227d..19179e2 100644 --- a/spec/services/facet_block_builder_spec.rb +++ b/spec/services/facet_block_builder_spec.rb @@ -189,35 +189,31 @@ def create_single_tx(l1_tx_index:) def create_forced_batch(l1_tx_index:, tx_count:) transactions = tx_count.times.map { create_tx_bytes } - + ParsedBatch.new( - role: FacetBatchConstants::Role::FORCED, + role: FacetBatchConstants::Role::PERMISSIONLESS, signer: nil, - target_l1_block: l1_block_number, l1_tx_index: l1_tx_index, source: FacetBatchConstants::Source::CALLDATA, source_details: {}, transactions: transactions, content_hash: Hash32.from_bin(Eth::Util.keccak256(rand.to_s)), - chain_id: ChainIdManager.current_l2_chain_id, - extra_data: nil + chain_id: ChainIdManager.current_l2_chain_id ) end - + def create_priority_batch(l1_tx_index:, tx_count:, signer:) transactions = tx_count.times.map { create_tx_bytes } - + ParsedBatch.new( role: FacetBatchConstants::Role::PRIORITY, signer: signer, - target_l1_block: l1_block_number, l1_tx_index: l1_tx_index, source: FacetBatchConstants::Source::CALLDATA, source_details: {}, transactions: transactions, content_hash: Hash32.from_bin(Eth::Util.keccak256(rand.to_s)), - chain_id: ChainIdManager.current_l2_chain_id, - extra_data: nil + chain_id: ChainIdManager.current_l2_chain_id ) end diff --git a/spec/support/blob_test_helper.rb b/spec/support/blob_test_helper.rb index a2dc67b..c249ef0 100644 --- a/spec/support/blob_test_helper.rb +++ b/spec/support/blob_test_helper.rb @@ -4,13 +4,17 @@ module BlobTestHelper # Create a test blob with Facet batch data embedded using proper EIP-4844 encoding def create_test_blob_with_facet_data(transactions: [], position: :start) - # Create a valid Facet batch - batch_data = create_test_batch_data(transactions) - - # Add magic prefix and length header + # Create RLP transaction list + rlp_tx_list = create_test_batch_data(transactions) + + # Build complete wire format + chain_id = ChainIdManager.current_l2_chain_id facet_payload = FacetBatchConstants::MAGIC_PREFIX.to_bin - facet_payload += [batch_data.length].pack('N') - facet_payload += batch_data + facet_payload += [chain_id].pack('Q>') # uint64 big-endian + facet_payload += [FacetBatchConstants::VERSION].pack('C') + facet_payload += [FacetBatchConstants::Role::PERMISSIONLESS].pack('C') + facet_payload += [rlp_tx_list.length].pack('N') + facet_payload += rlp_tx_list # Create aggregated data based on position aggregated_data = case position @@ -28,10 +32,15 @@ def create_test_blob_with_facet_data(transactions: [], position: :start) padding + facet_payload when :multiple # Multiple Facet batches in same blob - second_batch = create_test_batch_data([create_test_transaction]) + second_rlp_tx_list = create_test_batch_data([create_test_transaction]) + + # Build complete wire format for second batch second_payload = FacetBatchConstants::MAGIC_PREFIX.to_bin - second_payload += [second_batch.length].pack('N') - second_payload += second_batch + second_payload += [chain_id].pack('Q>') # uint64 big-endian + second_payload += [FacetBatchConstants::VERSION].pack('C') + second_payload += [FacetBatchConstants::Role::PERMISSIONLESS].pack('C') + second_payload += [second_rlp_tx_list.length].pack('N') + second_payload += second_rlp_tx_list # Put both batches with padding between first_part = facet_payload @@ -50,30 +59,15 @@ def create_test_blob_with_facet_data(transactions: [], position: :start) ByteString.from_hex(blobs.first) end - # Create test batch data in RLP format + # Create test batch data (RLP-encoded transaction list) def create_test_batch_data(transactions = []) - chain_id = ChainIdManager.current_l2_chain_id - # Default to one test transaction if none provided if transactions.empty? transactions = [create_test_transaction] end - - # FacetBatchData = [version, chainId, role, targetL1Block, transactions[], extraData] - batch_data = [ - Eth::Util.serialize_int_to_big_endian(1), # version - Eth::Util.serialize_int_to_big_endian(chain_id), # chainId - Eth::Util.serialize_int_to_big_endian(FacetBatchConstants::Role::FORCED), # role - Eth::Util.serialize_int_to_big_endian(12345), # targetL1Block - transactions.map(&:to_bin), # transactions - '' # extraData - ] - - # FacetBatch = [FacetBatchData, signature] - facet_batch = [batch_data, ''] # Empty signature for forced batch - - # Return RLP-encoded batch - Eth::Rlp.encode(facet_batch) + + # Return RLP-encoded transaction list + Eth::Rlp.encode(transactions.map(&:to_bin)) end # Create a test EIP-1559 transaction @@ -178,7 +172,6 @@ def extract_facet_batches_from_blob(blob_data) parser = FacetBatchParser.new parser.parse_payload( decoded_data.is_a?(String) ? ByteString.from_hex(decoded_data) : decoded_data, - 12345, # l1_block_number 0, # l1_tx_index FacetBatchConstants::Source::BLOB, { versioned_hash: "0x" + "a" * 64 } From 2c2d8eb72aa9733143e4459f91e1b0dbb6c6c00f Mon Sep 17 00:00:00 2001 From: Tom Lehman Date: Wed, 1 Oct 2025 12:07:51 -0400 Subject: [PATCH 2/3] Fix parsing bug --- app/services/facet_batch_parser.rb | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/app/services/facet_batch_parser.rb b/app/services/facet_batch_parser.rb index 6002987..1a401a6 100644 --- a/app/services/facet_batch_parser.rb +++ b/app/services/facet_batch_parser.rb @@ -40,13 +40,15 @@ def parse_payload(payload, l1_tx_index, source, source_details = {}) # Skip if wrong chain ID if wire_chain_id != chain_id logger.debug "Skipping batch for chain #{wire_chain_id} (expected #{chain_id})" + + role_offset = index + FacetBatchConstants::ROLE_OFFSET + role = data[role_offset, FacetBatchConstants::ROLE_SIZE].unpack1('C') + # Read length to skip entire batch efficiently length_offset = index + FacetBatchConstants::LENGTH_OFFSET length = data[length_offset, FacetBatchConstants::LENGTH_SIZE].unpack1('N') # uint32 big-endian + offset = index + FacetBatchConstants::HEADER_SIZE + length - # Add signature size if priority batch - role_offset = index + FacetBatchConstants::ROLE_OFFSET - role = data[role_offset, FacetBatchConstants::ROLE_SIZE].unpack1('C') offset += FacetBatchConstants::SIGNATURE_SIZE if role == FacetBatchConstants::Role::PRIORITY next end @@ -62,24 +64,23 @@ def parse_payload(payload, l1_tx_index, source, source_details = {}) # Move past this entire batch # Read length to know how much to skip + role_offset = index + FacetBatchConstants::ROLE_OFFSET + role = data[role_offset, FacetBatchConstants::ROLE_SIZE].unpack1('C') length_offset = index + FacetBatchConstants::LENGTH_OFFSET length = data[length_offset, FacetBatchConstants::LENGTH_SIZE].unpack1('N') offset = index + FacetBatchConstants::HEADER_SIZE + length - # Add signature size if priority batch - role_offset = index + FacetBatchConstants::ROLE_OFFSET - role = data[role_offset, FacetBatchConstants::ROLE_SIZE].unpack1('C') offset += FacetBatchConstants::SIGNATURE_SIZE if role == FacetBatchConstants::Role::PRIORITY rescue ParseError, ValidationError => e logger.debug "Failed to parse batch at offset #{index}: #{e.message}" # Try to skip past this batch if index + FacetBatchConstants::HEADER_SIZE <= data.length + role_offset = index + FacetBatchConstants::ROLE_OFFSET + role = data[role_offset, FacetBatchConstants::ROLE_SIZE].unpack1('C') + length_offset = index + FacetBatchConstants::LENGTH_OFFSET length = data[length_offset, FacetBatchConstants::LENGTH_SIZE].unpack1('N') if length > 0 && length <= FacetBatchConstants::MAX_BATCH_BYTES offset = index + FacetBatchConstants::HEADER_SIZE + length - # Check for priority batch signature - role_offset = index + FacetBatchConstants::ROLE_OFFSET - role = data[role_offset, FacetBatchConstants::ROLE_SIZE].unpack1('C') offset += FacetBatchConstants::SIGNATURE_SIZE if role == FacetBatchConstants::Role::PRIORITY else offset = index + 1 From 9a88f84c432504f7dbaee60a60ef7fc9b25bc53e Mon Sep 17 00:00:00 2001 From: Tom Lehman Date: Wed, 1 Oct 2025 13:36:23 -0400 Subject: [PATCH 3/3] Improve batch parsing --- app/models/facet_batch_constants.rb | 10 +++--- app/services/facet_batch_parser.rb | 37 ++++---------------- sequencer/src/batch/maker.ts | 6 ++-- sequencer/src/l1/monitor.ts | 16 +++++---- spec/integration/forced_tx_filtering_spec.rb | 2 +- spec/mixed_transaction_types_spec.rb | 10 +++--- spec/services/blob_aggregation_spec.rb | 4 +-- spec/services/facet_batch_collector_spec.rb | 4 +-- spec/services/facet_batch_parser_spec.rb | 6 ++-- 9 files changed, 38 insertions(+), 57 deletions(-) diff --git a/app/models/facet_batch_constants.rb b/app/models/facet_batch_constants.rb index 6f391b2..b3ccc46 100644 --- a/app/models/facet_batch_constants.rb +++ b/app/models/facet_batch_constants.rb @@ -1,18 +1,18 @@ # Constants for Facet Batch V2 protocol module FacetBatchConstants - # Magic prefix to identify batch payloads (8 bytes) - MAGIC_PREFIX = ByteString.from_hex("0x0000000000012345") + # Magic prefix ("unstoppable sequencing" ASCII -> hex) + MAGIC_PREFIX = ByteString.from_hex("0x756e73746f707061626c652073657175656e63696e67") # Protocol version VERSION = 1 # Wire format header sizes (in bytes) - MAGIC_SIZE = 8 + MAGIC_SIZE = MAGIC_PREFIX.to_bin.bytesize CHAIN_ID_SIZE = 8 # uint64 VERSION_SIZE = 1 # uint8 ROLE_SIZE = 1 # uint8 LENGTH_SIZE = 4 # uint32 - HEADER_SIZE = MAGIC_SIZE + CHAIN_ID_SIZE + VERSION_SIZE + ROLE_SIZE + LENGTH_SIZE # 22 bytes + HEADER_SIZE = MAGIC_SIZE + CHAIN_ID_SIZE + VERSION_SIZE + ROLE_SIZE + LENGTH_SIZE # 36 bytes SIGNATURE_SIZE = 65 # secp256k1: r(32) + s(32) + v(1) # Wire format offsets @@ -39,4 +39,4 @@ module Source CALLDATA = 'calldata' BLOB = 'blob' end -end \ No newline at end of file +end diff --git a/app/services/facet_batch_parser.rb b/app/services/facet_batch_parser.rb index 1a401a6..e0e6339 100644 --- a/app/services/facet_batch_parser.rb +++ b/app/services/facet_batch_parser.rb @@ -37,19 +37,10 @@ def parse_payload(payload, l1_tx_index, source, source_details = {}) chain_id_offset = index + FacetBatchConstants::CHAIN_ID_OFFSET wire_chain_id = data[chain_id_offset, FacetBatchConstants::CHAIN_ID_SIZE].unpack1('Q>') # uint64 big-endian - # Skip if wrong chain ID + # Skip if wrong chain ID – move past the magic and keep scanning if wire_chain_id != chain_id logger.debug "Skipping batch for chain #{wire_chain_id} (expected #{chain_id})" - - role_offset = index + FacetBatchConstants::ROLE_OFFSET - role = data[role_offset, FacetBatchConstants::ROLE_SIZE].unpack1('C') - - # Read length to skip entire batch efficiently - length_offset = index + FacetBatchConstants::LENGTH_OFFSET - length = data[length_offset, FacetBatchConstants::LENGTH_SIZE].unpack1('N') # uint32 big-endian - - offset = index + FacetBatchConstants::HEADER_SIZE + length - offset += FacetBatchConstants::SIGNATURE_SIZE if role == FacetBatchConstants::Role::PRIORITY + offset = index + FacetBatchConstants::MAGIC_SIZE next end @@ -68,26 +59,12 @@ def parse_payload(payload, l1_tx_index, source, source_details = {}) role = data[role_offset, FacetBatchConstants::ROLE_SIZE].unpack1('C') length_offset = index + FacetBatchConstants::LENGTH_OFFSET length = data[length_offset, FacetBatchConstants::LENGTH_SIZE].unpack1('N') - offset = index + FacetBatchConstants::HEADER_SIZE + length - offset += FacetBatchConstants::SIGNATURE_SIZE if role == FacetBatchConstants::Role::PRIORITY + total_size = FacetBatchConstants::HEADER_SIZE + length + total_size += FacetBatchConstants::SIGNATURE_SIZE if role == FacetBatchConstants::Role::PRIORITY + offset = index + total_size rescue ParseError, ValidationError => e logger.debug "Failed to parse batch at offset #{index}: #{e.message}" - # Try to skip past this batch - if index + FacetBatchConstants::HEADER_SIZE <= data.length - role_offset = index + FacetBatchConstants::ROLE_OFFSET - role = data[role_offset, FacetBatchConstants::ROLE_SIZE].unpack1('C') - - length_offset = index + FacetBatchConstants::LENGTH_OFFSET - length = data[length_offset, FacetBatchConstants::LENGTH_SIZE].unpack1('N') - if length > 0 && length <= FacetBatchConstants::MAX_BATCH_BYTES - offset = index + FacetBatchConstants::HEADER_SIZE + length - offset += FacetBatchConstants::SIGNATURE_SIZE if role == FacetBatchConstants::Role::PRIORITY - else - offset = index + 1 - end - else - offset = index + 1 - end + offset = index + FacetBatchConstants::MAGIC_SIZE end end @@ -98,7 +75,7 @@ def parse_payload(payload, l1_tx_index, source, source_details = {}) def parse_batch_at_offset(data, offset, l1_tx_index, source, source_details) # Read the fixed header fields - # [MAGIC:8][CHAIN_ID:8][VERSION:1][ROLE:1][LENGTH:4] + # [MAGIC:22][CHAIN_ID:8][VERSION:1][ROLE:1][LENGTH:4] pos = offset # Magic prefix (already validated by caller) diff --git a/sequencer/src/batch/maker.ts b/sequencer/src/batch/maker.ts index 112a9e9..38a44ba 100644 --- a/sequencer/src/batch/maker.ts +++ b/sequencer/src/batch/maker.ts @@ -16,7 +16,7 @@ interface Transaction { export class BatchMaker { private readonly MAX_PER_SENDER = 10; private readonly MAX_BATCH_GAS = 30_000_000; - private readonly FACET_MAGIC_PREFIX = '0x0000000000012345' as Hex; + private readonly FACET_MAGIC_PREFIX = '0x756e73746f707061626c652073657175656e63696e67' as Hex; // "unstoppable sequencing" private readonly L2_CHAIN_ID: bigint; private readonly MAX_BLOB_SIZE = 131072; // 128KB private lastBatchTime = Date.now(); @@ -147,10 +147,10 @@ export class BatchMaker { const txList = transactions.map(tx => ('0x' + tx.raw.toString('hex')) as Hex); const rlpTxList = toRlp(txList); - // Build new wire format: [MAGIC:8][CHAIN_ID:8][VERSION:1][ROLE:1][LENGTH:4][RLP_TX_LIST] + // Build new wire format: [MAGIC:22][CHAIN_ID:8][VERSION:1][ROLE:1][LENGTH:4][RLP_TX_LIST] const rlpSize = size(rlpTxList); const parts: Hex[] = [ - this.FACET_MAGIC_PREFIX, // MAGIC: 8 bytes + this.FACET_MAGIC_PREFIX, // MAGIC: 12 bytes encodePacked(['uint64'], [this.L2_CHAIN_ID]), // CHAIN_ID: 8 bytes big-endian encodePacked(['uint8'], [1]), // VERSION: 1 byte encodePacked(['uint8'], [role]), // ROLE: 1 byte diff --git a/sequencer/src/l1/monitor.ts b/sequencer/src/l1/monitor.ts index d837d30..9823231 100644 --- a/sequencer/src/l1/monitor.ts +++ b/sequencer/src/l1/monitor.ts @@ -15,7 +15,7 @@ import { logger } from '../utils/logger.js'; export class InclusionMonitor { private l1Client: PublicClient; private l2Client: PublicClient; - private readonly FACET_MAGIC_PREFIX = '0x0000000000012345'; + private readonly FACET_MAGIC_PREFIX = '0x756e73746f707061626c652073657175656e63696e67'; private isMonitoring = false; constructor( @@ -224,13 +224,17 @@ export class InclusionMonitor { const tenMinutesAgo = Date.now() - (10 * 60 * 1000); const dropped = database.prepare(` - SELECT t.hash, t.batch_id + SELECT DISTINCT t.hash, t.batch_id FROM transactions t JOIN batches b ON t.batch_id = b.id - JOIN post_attempts pa ON pa.batch_id = b.id WHERE t.state = 'submitted' - AND pa.status = 'mined' - AND pa.confirmed_at < ? + AND EXISTS ( + SELECT 1 + FROM post_attempts pa + WHERE pa.batch_id = b.id + AND pa.status = 'mined' + AND pa.confirmed_at < ? + ) `).all(tenMinutesAgo) as Array<{ hash: Buffer; batch_id: number }>; for (const tx of dropped) { @@ -305,4 +309,4 @@ export class InclusionMonitor { blockNumber: attempt.block_number }, 'Reorg detected, reverting batch'); } -} \ No newline at end of file +} diff --git a/spec/integration/forced_tx_filtering_spec.rb b/spec/integration/forced_tx_filtering_spec.rb index a7d2740..5bd3a3d 100644 --- a/spec/integration/forced_tx_filtering_spec.rb +++ b/spec/integration/forced_tx_filtering_spec.rb @@ -116,7 +116,7 @@ def create_forced_batch_payload(transactions:, target_l1_block:) # Create RLP-encoded transaction list rlp_tx_list = Eth::Rlp.encode(transactions.map(&:to_bin)) - # Build wire format: [MAGIC:8][CHAIN_ID:8][VERSION:1][ROLE:1][LENGTH:4][RLP_TX_LIST] + # Build wire format: [MAGIC:#{FacetBatchConstants::MAGIC_SIZE}][CHAIN_ID:8][VERSION:1][ROLE:1][LENGTH:4][RLP_TX_LIST] payload = FacetBatchConstants::MAGIC_PREFIX.to_bin payload += [chain_id].pack('Q>') # uint64 big-endian payload += [FacetBatchConstants::VERSION].pack('C') diff --git a/spec/mixed_transaction_types_spec.rb b/spec/mixed_transaction_types_spec.rb index 061aff5..5cb68ac 100644 --- a/spec/mixed_transaction_types_spec.rb +++ b/spec/mixed_transaction_types_spec.rb @@ -80,9 +80,9 @@ puts "Target L1 block for batch: #{target_block}" puts "Batch should contain #{[eip1559_tx].length} transaction(s)" - # Debug the batch structure - new format has 22-byte header - # [MAGIC:8][CHAIN_ID:8][VERSION:1][ROLE:1][LENGTH:4][RLP_TX_LIST] - test_decode = Eth::Rlp.decode(batch_payload.to_bin[22..-1]) # Skip header to get RLP_TX_LIST + # Debug the batch structure - new format has FacetBatchConstants::HEADER_SIZE bytes + # [MAGIC:#{FacetBatchConstants::MAGIC_SIZE}][CHAIN_ID:8][VERSION:1][ROLE:1][LENGTH:4][RLP_TX_LIST] + test_decode = Eth::Rlp.decode(batch_payload.to_bin[FacetBatchConstants::HEADER_SIZE..-1]) # Skip header to get RLP_TX_LIST puts "Decoded batch has #{test_decode.length} transactions" puts "Batch payload length: #{batch_payload.to_bin.length} bytes" @@ -357,7 +357,7 @@ def create_batch_payload(transactions:, role:, target_l1_block:, sign: false) # Create RLP-encoded transaction list rlp_tx_list = Eth::Rlp.encode(transactions.map(&:to_bin)) - # Build wire format: [MAGIC:8][CHAIN_ID:8][VERSION:1][ROLE:1][LENGTH:4][RLP_TX_LIST][SIGNATURE:65]? + # Build wire format: [MAGIC:#{FacetBatchConstants::MAGIC_SIZE}][CHAIN_ID:8][VERSION:1][ROLE:1][LENGTH:4][RLP_TX_LIST][SIGNATURE:65]? payload = FacetBatchConstants::MAGIC_PREFIX.to_bin payload += [chain_id].pack('Q>') # uint64 big-endian payload += [FacetBatchConstants::VERSION].pack('C') @@ -374,4 +374,4 @@ def create_batch_payload(transactions:, role:, target_l1_block:, sign: false) ByteString.from_bin(payload) end -end \ No newline at end of file +end diff --git a/spec/services/blob_aggregation_spec.rb b/spec/services/blob_aggregation_spec.rb index d714919..a38da02 100644 --- a/spec/services/blob_aggregation_spec.rb +++ b/spec/services/blob_aggregation_spec.rb @@ -91,7 +91,7 @@ # Create RLP-encoded transaction list rlp_tx_list = Eth::Rlp.encode(transactions.map(&:to_bin)) - # Construct wire format: [MAGIC:8][CHAIN_ID:8][VERSION:1][ROLE:1][LENGTH:4][RLP_TX_LIST] + # Construct wire format: [MAGIC:#{FacetBatchConstants::MAGIC_SIZE}][CHAIN_ID:8][VERSION:1][ROLE:1][LENGTH:4][RLP_TX_LIST] payload = FacetBatchConstants::MAGIC_PREFIX.to_bin payload += [chain_id].pack('Q>') # uint64 big-endian payload += [FacetBatchConstants::VERSION].pack('C') @@ -198,4 +198,4 @@ expect(batches).not_to be_empty end end -end \ No newline at end of file +end diff --git a/spec/services/facet_batch_collector_spec.rb b/spec/services/facet_batch_collector_spec.rb index 9d004f8..f455b58 100644 --- a/spec/services/facet_batch_collector_spec.rb +++ b/spec/services/facet_batch_collector_spec.rb @@ -268,7 +268,7 @@ def create_batch_payload # Create empty transaction list rlp_tx_list = Eth::Rlp.encode([]) - # Construct wire format: [MAGIC:8][CHAIN_ID:8][VERSION:1][ROLE:1][LENGTH:4][RLP_TX_LIST] + # Construct wire format: [MAGIC:#{FacetBatchConstants::MAGIC_SIZE}][CHAIN_ID:8][VERSION:1][ROLE:1][LENGTH:4][RLP_TX_LIST] magic = FacetBatchConstants::MAGIC_PREFIX.to_bin chain_id_bytes = [chain_id].pack('Q>') # uint64 big-endian version_byte = [FacetBatchConstants::VERSION].pack('C') # uint8 @@ -277,4 +277,4 @@ def create_batch_payload ByteString.from_bin(magic + chain_id_bytes + version_byte + role_byte + length_bytes + rlp_tx_list) end -end \ No newline at end of file +end diff --git a/spec/services/facet_batch_parser_spec.rb b/spec/services/facet_batch_parser_spec.rb index d1caa0d..12bf08d 100644 --- a/spec/services/facet_batch_parser_spec.rb +++ b/spec/services/facet_batch_parser_spec.rb @@ -14,7 +14,7 @@ end let(:payload) do - # Construct wire format: [MAGIC:8][CHAIN_ID:8][VERSION:1][ROLE:1][LENGTH:4][RLP_TX_LIST] + # Construct wire format: [MAGIC:#{FacetBatchConstants::MAGIC_SIZE}][CHAIN_ID:8][VERSION:1][ROLE:1][LENGTH:4][RLP_TX_LIST] magic = FacetBatchConstants::MAGIC_PREFIX.to_bin chain_id_bytes = [chain_id].pack('Q>') # uint64 big-endian version_byte = [FacetBatchConstants::VERSION].pack('C') # uint8 @@ -187,7 +187,7 @@ def create_valid_wire_batch(chain_id, role, rlp_tx_list, signature = nil) # Build wire format batch for chain_id 0xface7b (16436859) chain_id = 0xface7b - # Construct wire format: [MAGIC:8][CHAIN_ID:8][VERSION:1][ROLE:1][LENGTH:4][RLP_TX_LIST] + # Construct wire format: [MAGIC:#{FacetBatchConstants::MAGIC_SIZE}][CHAIN_ID:8][VERSION:1][ROLE:1][LENGTH:4][RLP_TX_LIST] magic = FacetBatchConstants::MAGIC_PREFIX.to_bin chain_id_bytes = [chain_id].pack('Q>') # uint64 big-endian version_byte = [FacetBatchConstants::VERSION].pack('C') @@ -234,7 +234,7 @@ def create_valid_wire_batch(chain_id, role, rlp_tx_list, signature = nil) chain_id = 0xface7b - # Construct wire format for priority batch: [MAGIC:8][CHAIN_ID:8][VERSION:1][ROLE:1][LENGTH:4][RLP_TX_LIST][SIGNATURE:65] + # Construct wire format for priority batch: [MAGIC:#{FacetBatchConstants::MAGIC_SIZE}][CHAIN_ID:8][VERSION:1][ROLE:1][LENGTH:4][RLP_TX_LIST][SIGNATURE:65] magic = FacetBatchConstants::MAGIC_PREFIX.to_bin chain_id_bytes = [chain_id].pack('Q>') version_byte = [FacetBatchConstants::VERSION].pack('C')