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
183 changes: 180 additions & 3 deletions app/models/eth_transaction.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ def self.event_signature(event_name)
def transaction_hash
tx_hash
end


sig { params(block_result: T.untyped, receipt_result: T.untyped).returns(T::Array[EthTransaction]) }
def self.from_rpc_result(block_result, receipt_result)
Expand Down Expand Up @@ -66,8 +65,8 @@ def self.ethscription_txs_from_rpc_results(block_results, receipt_results, ethsc
eth_txs.sort_by(&:transaction_index).each do |eth_tx|
next unless eth_tx.is_success?

# Build deposits using the unified builder
deposits = EthscriptionTransactionBuilder.build_deposits(eth_tx, ethscriptions_block)
# Build deposits directly from this EthTransaction instance
deposits = eth_tx.build_ethscription_deposits(ethscriptions_block)
all_deposits.concat(deposits)
end

Expand All @@ -83,4 +82,182 @@ def is_success?
def ethscription_source_hash
tx_hash
end

# Build deposit transactions (EthscriptionTransaction objects) from this L1 transaction
sig { params(ethscriptions_block: EthscriptionsBlock).returns(T::Array[EthscriptionTransaction]) }
def build_ethscription_deposits(ethscriptions_block)
@transactions = []

# 1. Process calldata (try as creation, then as transfer)
process_calldata

# 2. Process events (creations and transfers)
process_events

@transactions.compact
end

private

Copilot AI Oct 21, 2025

Copy link

Choose a reason for hiding this comment

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

[nitpick] The private keyword is placed after the public method build_ethscription_deposits, but several other public methods (transaction_hash, is_success?, ethscription_source_hash) are defined before the private section. Consider moving all public methods before the private keyword to maintain consistent visibility organization.

Copilot uses AI. Check for mistakes.

def process_calldata
return unless to_address.present?

try_calldata_creation
try_calldata_transfer
Comment on lines +89 to +106

Copilot AI Oct 21, 2025

Copy link

Choose a reason for hiding this comment

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

Instance variable @transactions is initialized in build_ethscription_deposits but could cause issues if the same EthTransaction instance is used concurrently or if the method is called multiple times. Consider using a local variable instead or documenting this constraint.

Suggested change
@transactions = []
# 1. Process calldata (try as creation, then as transfer)
process_calldata
# 2. Process events (creations and transfers)
process_events
@transactions.compact
end
private
def process_calldata
return unless to_address.present?
try_calldata_creation
try_calldata_transfer
transactions = []
# 1. Process calldata (try as creation, then as transfer)
process_calldata(transactions)
# 2. Process events (creations and transfers)
process_events(transactions)
transactions.compact
end
private
def process_calldata(transactions)
# ... (implementation not shown, but replace @transactions with transactions)
end
return unless to_address.present?
try_calldata_creation(transactions)
try_calldata_transfer(transactions)

Copilot uses AI. Check for mistakes.
end

def try_calldata_creation
transaction = EthscriptionTransaction.build_create_ethscription(
eth_transaction: self,
creator: normalize_address(from_address),
initial_owner: normalize_address(to_address),
content_uri: utf8_input,
source_type: :input,
source_index: transaction_index
)

@transactions << transaction

Copilot AI Oct 21, 2025

Copy link

Choose a reason for hiding this comment

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

The build_create_ethscription factory method now returns nil when DataUri.valid? fails (line 48 in ethscription_transaction.rb). This will add nil values to @transactions, which could cause issues. Add a guard to only append non-nil transactions: @transactions << transaction if transaction.

Suggested change
@transactions << transaction
@transactions << transaction if transaction

Copilot uses AI. Check for mistakes.
end

def try_calldata_transfer
valid_length = if SysConfig.esip5_enabled?(block_number)
input.bytesize > 0 && input.bytesize % 32 == 0
else
input.bytesize == 32
end

return unless valid_length

input_hex = input.to_hex.delete_prefix('0x')

ids = input_hex.scan(/.{64}/).map { |hash_hex| normalize_hash("0x#{hash_hex}") }

# Create transfer transaction
transaction = EthscriptionTransaction.build_transfer(
eth_transaction: self,
from_address: normalize_address(from_address),
to_address: normalize_address(to_address),
ethscription_ids: ids,
source_type: :input,
source_index: transaction_index
)

@transactions << transaction

Copilot AI Oct 21, 2025

Copy link

Choose a reason for hiding this comment

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

[nitpick] Similar to the creation case, this appends the transaction without checking if it's nil. Since build_transfer always returns a transaction object currently, this may be safe, but for consistency and defensive programming, consider adding a nil check: @transactions << transaction if transaction.

Suggested change
@transactions << transaction
@transactions << transaction if transaction

Copilot uses AI. Check for mistakes.
end

def process_events
ordered_events.each do |log|
begin
case log['topics']&.first
when CreateEthscriptionEventSig
process_create_event(log)
when Esip1EventSig
process_esip1_transfer_event(log)
when Esip2EventSig
process_esip2_transfer_event(log)
end
rescue Eth::Abi::DecodingError, RangeError => e
Rails.logger.error "Failed to decode event: #{e.message}"
next
end
end
end

def process_create_event(log)
return unless SysConfig.esip3_enabled?(block_number)
return unless log['topics'].length == 2

# Decode event data
initial_owner = Eth::Abi.decode(['address'], log['topics'].second).first
content_uri_data = Eth::Abi.decode(['string'], log['data']).first
content_uri = HexDataProcessor.clean_utf8(content_uri_data)

transaction = EthscriptionTransaction.build_create_ethscription(
eth_transaction: self,
creator: normalize_address(log['address']),
initial_owner: normalize_address(initial_owner),
content_uri: content_uri,
source_type: :event,
source_index: log['logIndex'].to_i(16)
)

@transactions << transaction

Copilot AI Oct 21, 2025

Copy link

Choose a reason for hiding this comment

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

The build_create_ethscription method can return nil if the content_uri is invalid. This will add nil to @transactions. Add a guard: @transactions << transaction if transaction.

Suggested change
@transactions << transaction
@transactions << transaction if transaction

Copilot uses AI. Check for mistakes.
end

def process_esip1_transfer_event(log)
return unless SysConfig.esip1_enabled?(block_number)
return unless log['topics'].length == 3

# Decode event data
event_to = Eth::Abi.decode(['address'], log['topics'].second).first
tx_hash_hex = Eth::Util.bin_to_prefixed_hex(
Eth::Abi.decode(['bytes32'], log['topics'].third).first
)

ethscription_id = normalize_hash(tx_hash_hex)

transaction = EthscriptionTransaction.build_transfer(
eth_transaction: self,
from_address: normalize_address(log['address']),
to_address: normalize_address(event_to),
ethscription_ids: ethscription_id, # Single ID, will be wrapped in array
source_type: :event,
source_index: log['logIndex'].to_i(16)
)

@transactions << transaction
end

def process_esip2_transfer_event(log)
return unless SysConfig.esip2_enabled?(block_number)
return unless log['topics'].length == 4

event_previous_owner = Eth::Abi.decode(['address'], log['topics'].second).first
event_to = Eth::Abi.decode(['address'], log['topics'].third).first
tx_hash_hex = Eth::Util.bin_to_prefixed_hex(
Eth::Abi.decode(['bytes32'], log['topics'].fourth).first
)

ethscription_id = normalize_hash(tx_hash_hex)

transaction = EthscriptionTransaction.build_transfer(
eth_transaction: self,
from_address: normalize_address(log['address']),
to_address: normalize_address(event_to),
ethscription_ids: ethscription_id, # Single ID, will be wrapped in array
enforced_previous_owner: normalize_address(event_previous_owner),
source_type: :event,
source_index: log['logIndex'].to_i(16)
)

@transactions << transaction
end

def ordered_events
return [] unless logs

logs.reject { |log| log['removed'] }
.sort_by { |log| log['logIndex'].to_i(16) }
end

def utf8_input
HexDataProcessor.hex_to_utf8(
input.to_hex,
support_gzip: SysConfig.esip7_enabled?(block_number)
)
end

def normalize_address(addr)
return nil unless addr
# Handle both Address20 objects and strings
addr_str = addr.respond_to?(:to_hex) ? addr.to_hex : addr.to_s
addr_str.downcase
end

def normalize_hash(hash)
return nil unless hash
# Handle both Hash32 objects and strings
hash_str = hash.respond_to?(:to_hex) ? hash.to_hex : hash.to_s
hash_str.downcase
end
end
86 changes: 21 additions & 65 deletions app/models/ethscription_transaction.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@ class EthscriptionTransaction < T::Struct
prop :content_uri, T.nilable(String)

# Transfer operation fields
prop :ethscription_id, T.nilable(String)
prop :transfer_ids, T.nilable(T::Array[String])
prop :transfer_ids, T.nilable(T::Array[String]) # Always an array, even for single transfers
prop :transfer_from_address, T.nilable(String)
prop :transfer_to_address, T.nilable(String)
prop :enforced_previous_owner, T.nilable(String)
Expand All @@ -38,14 +37,16 @@ class EthscriptionTransaction < T::Struct
TO_ADDRESS = SysConfig::ETHSCRIPTIONS_ADDRESS

# Factory method for create operations
def self.create_ethscription(
def self.build_create_ethscription(
eth_transaction:,
creator:,
initial_owner:,
content_uri:,
source_type:,
source_index:
)
return unless DataUri.valid?(content_uri)

new(
from_address: Address20.from_hex(creator.is_a?(String) ? creator : creator.to_hex),
eth_transaction: eth_transaction,
Expand All @@ -58,22 +59,26 @@ def self.create_ethscription(
)
end

# Factory method for transfer operations
def self.transfer_ethscription(
# Transfer factory - handles single, multiple, and previous owner cases
def self.build_transfer(
eth_transaction:,
from_address:,
to_address:,
ethscription_id:,
enforced_previous_owner: nil,
source_type:,
source_index:
source_index:,
ethscription_ids:, # Can be a single ID or an array of IDs
enforced_previous_owner: nil
)
# Normalize to array - accept either single ID or array of IDs
ids = Array.wrap(ethscription_ids)

# Determine operation type
operation_type = enforced_previous_owner ? 'transfer_with_previous_owner' : 'transfer'

new(
from_address: Address20.from_hex(from_address.is_a?(String) ? from_address : from_address.to_hex),
eth_transaction: eth_transaction,
ethscription_id: ethscription_id,
transfer_ids: ids, # Always use array
transfer_from_address: from_address,
transfer_to_address: to_address,
enforced_previous_owner: enforced_previous_owner,
Expand All @@ -83,35 +88,14 @@ def self.transfer_ethscription(
)
end

# Factory method for transferMultipleEthscriptions (inputs only)
def self.transfer_multiple_ethscriptions(
eth_transaction:,
from_address:,
to_address:,
ethscription_ids:,
source_type:,
source_index:
)
new(
from_address: Address20.from_hex(from_address.is_a?(String) ? from_address : from_address.to_hex),
eth_transaction: eth_transaction,
transfer_ids: ethscription_ids,
transfer_from_address: from_address,
transfer_to_address: to_address,
source_type: source_type&.to_sym,
source_index: source_index,
ethscription_operation: 'transfer'
)
end

# Get function selector for this operation
def function_selector
function_signature = case ethscription_operation
when 'create'
'createEthscription((bytes32,bytes32,address,bytes,string,bool,(string,string,bytes)))'
when 'transfer'
if transfer_ids && transfer_ids.any?
'transferMultipleEthscriptions(bytes32[],address)'
if transfer_ids.length > 1
'transferEthscriptions(address,bytes32[])'
else
'transferEthscription(address,bytes32)'
end
Expand Down Expand Up @@ -145,34 +129,6 @@ def source_hash
Hash32.from_bin(bin_val)
end

def valid_create?
content_uri.present? &&
creator.present? &&
initial_owner.present? &&
DataUri.valid?(content_uri)
end

def valid_transfer?
# Basic field validation - if we extracted the data properly, ABI encoding should work
case ethscription_operation
when 'transfer'
if transfer_ids
# Multiple transfer (input-based)
transfer_ids.is_a?(Array) && transfer_ids.any?
else
# Single transfer (event-based)
ethscription_id.present?
end
when 'transfer_with_previous_owner'
# Always single transfer (event-based only)
ethscription_id.present?
else
false
end &&
transfer_from_address.present? &&
transfer_to_address.present?
end

public

# Dynamic input method - builds calldata on demand
Expand All @@ -181,7 +137,7 @@ def input
when 'create'
ByteString.from_bin(build_create_calldata)
when 'transfer'
if transfer_ids && transfer_ids.any?
if transfer_ids.length > 1
ByteString.from_bin(build_transfer_multiple_calldata)
else
ByteString.from_bin(build_transfer_calldata)
Expand Down Expand Up @@ -267,7 +223,7 @@ def build_transfer_calldata

# Convert to binary for ABI
to_bin = address_to_bin(transfer_to_address)
id_bin = hex_to_bin(ethscription_id)
id_bin = hex_to_bin(transfer_ids.first)

encoded = Eth::Abi.encode(['address', 'bytes32'], [to_bin, id_bin])

Expand All @@ -281,7 +237,7 @@ def build_transfer_with_previous_owner_calldata

# Convert to binary for ABI
to_bin = address_to_bin(transfer_to_address)
id_bin = hex_to_bin(ethscription_id)
id_bin = hex_to_bin(transfer_ids.first)
prev_bin = address_to_bin(enforced_previous_owner)

encoded = Eth::Abi.encode(['address', 'bytes32', 'address'], [to_bin, id_bin, prev_bin])
Expand All @@ -294,10 +250,10 @@ def build_transfer_multiple_calldata
# Get function selector as binary
function_sig = function_selector.b

ids_bin = (transfer_ids || []).map { |id| hex_to_bin(id) }
to_bin = address_to_bin(transfer_to_address)
ids_bin = transfer_ids.map { |id| hex_to_bin(id) }

encoded = Eth::Abi.encode(['bytes32[]', 'address'], [ids_bin, to_bin])
encoded = Eth::Abi.encode(['address', 'bytes32[]'], [to_bin, ids_bin])

(function_sig + encoded).b
end
Expand Down
Loading