Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 26 additions & 9 deletions app/models/facet_batch_constants.rb
Original file line number Diff line number Diff line change
@@ -1,25 +1,42 @@
# Constants for Facet Batch V2 protocol
module FacetBatchConstants
# Magic prefix to identify batch payloads
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 = 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 # 36 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'
BLOB = 'blob'
end
end
end
18 changes: 8 additions & 10 deletions app/models/parsed_batch.rb
Original file line number Diff line number Diff line change
@@ -1,26 +1,24 @@
# 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?
role == FacetBatchConstants::Role::PRIORITY
end

sig { returns(T::Boolean) }
def is_forced?
role == FacetBatchConstants::Role::FORCED
def is_permissionless?
role == FacetBatchConstants::Role::PERMISSIONLESS
end

sig { returns(Integer) }
Expand Down
2 changes: 2 additions & 0 deletions app/models/standard_l2_transaction.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
123 changes: 44 additions & 79 deletions app/services/batch_signature_verifier.rb
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +46 to +49
Copy link

Copilot AI Oct 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error class selection logic is repeated in the signature_error? method. Consider extracting this into a helper method or using a consistent approach for signature error handling.

Copilot uses AI. Check for mistakes.

v = v_normalised + 27

# Create signature for recovery
# The eth.rb gem expects r (32 bytes) + s (32 bytes) + v (variable length hex)
Expand All @@ -110,4 +65,14 @@ def recover_signer(message_hash, sig_bytes)

Address20.from_hex(address)
end
end

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
Comment on lines +69 to +77
Copy link

Copilot AI Oct 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The signature error detection logic is complex and fragile due to dynamic constant checking. Consider defining specific error classes or using a more explicit error handling approach.

Copilot uses AI. Check for mistakes.
end
2 changes: 0 additions & 2 deletions app/services/facet_batch_collector.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading