Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
165 changes: 86 additions & 79 deletions app/models/ethscription_transaction.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,14 @@ class EthscriptionTransaction < T::Struct

# Transfer operation fields
prop :ethscription_id, T.nilable(String)
prop :transfer_ids, T.nilable(T::Array[String])
prop :transfer_from_address, T.nilable(String)
prop :transfer_to_address, T.nilable(String)
prop :enforced_previous_owner, T.nilable(String)
prop :input_index, T.nilable(Integer)
prop :log_index, T.nilable(Integer)

# Unified source tracking
prop :source_type, T.nilable(Symbol) # :input or :event
prop :source_index, T.nilable(Integer)

# Debug info (can be removed if not needed)
prop :ethscription_operation, T.nilable(String) # 'create', 'transfer', 'transfer_with_previous_owner'
Expand All @@ -33,40 +36,9 @@ class EthscriptionTransaction < T::Struct
GAS_LIMIT = 1_000_000_000
TO_ADDRESS = SysConfig::ETHSCRIPTIONS_ADDRESS

class << self
def reset_seen_creates!
@seen_creates = Concurrent::Set.new
end

def add_seen_create(tx_hash)
seen_creates.add(tx_hash)
end

# Access seen creates set - requires reset_seen_creates! to be called first
def seen_creates
raise "Must call reset_seen_creates! first" unless @seen_creates
@seen_creates
end
end

# Dynamic source hash based on operation type
# Dynamic source hash based on source type and index
def source_hash
case ethscription_operation
when 'create'
# Creates are one-to-one with transactions
eth_transaction.transaction_hash
when 'transfer', 'transfer_with_previous_owner'
# Transfers need unique hash within transaction
if input_index
compute_transfer_source_hash(:input, input_index)
elsif log_index
compute_transfer_source_hash(:event, log_index)
else
raise "Transfer must have either input_index or log_index"
end
else
raise "Unknown ethscription operation: #{ethscription_operation}"
end
compute_source_hash(source_type, source_index)
end


Expand All @@ -75,14 +47,18 @@ def self.create_ethscription(
eth_transaction:,
creator:,
initial_owner:,
content_uri:
content_uri:,
source_type:,
source_index:
)
new(
from_address: Address20.from_hex(creator.is_a?(String) ? creator : creator.to_hex),
eth_transaction: eth_transaction,
creator: creator,
initial_owner: initial_owner,
content_uri: content_uri,
source_type: source_type&.to_sym,
source_index: source_index,
ethscription_operation: 'create'
)
end
Expand All @@ -94,8 +70,8 @@ def self.transfer_ethscription(
to_address:,
ethscription_id:,
enforced_previous_owner: nil,
input_index: nil,
log_index: nil
source_type:,
source_index:
)
operation_type = enforced_previous_owner ? 'transfer_with_previous_owner' : 'transfer'

Expand All @@ -106,54 +82,55 @@ def self.transfer_ethscription(
transfer_from_address: from_address,
transfer_to_address: to_address,
enforced_previous_owner: enforced_previous_owner,
input_index: input_index,
log_index: log_index,
source_type: source_type&.to_sym,
source_index: source_index,
ethscription_operation: operation_type
)
end

# Instance method to compute transfer source hash
def compute_transfer_source_hash(operation_type, index)
tag = (operation_type == :input) ? 0 : 1
payload = ByteString.from_bin(
eth_transaction.transaction_hash.to_bin +
Eth::Util.zpad_int(tag, 32) +
Eth::Util.zpad_int(index, 32)
)
bin_val = Eth::Util.keccak256(
Eth::Util.zpad_int(2, 32) + Eth::Util.keccak256(payload.to_bin)
# Factory method for transferMultipleEthscriptions (inputs only)
def self.transfer_multiple_ethscriptions(
eth_transaction:,
from_address:,
to_address:,
ethscription_ids:,
source_type: :input,
source_index: 0
Comment thread
RogerPodacter marked this conversation as resolved.
Outdated
)
new(
from_address: Address20.from_hex(from_address.is_a?(String) ? from_address : from_address.to_hex),
eth_transaction: eth_transaction,
transfer_ids: ethscription_ids,
transfer_from_address: from_address,
transfer_to_address: to_address,
source_type: source_type&.to_sym,
source_index: source_index,
ethscription_operation: 'transfer'
)
Hash32.from_bin(bin_val)
end

# Check if this is a valid, unseen ethscription
def valid_and_unseen?
valid_ethscription? && !already_seen?
end
# Unified source hash computation following Optimism pattern
def compute_source_hash(operation_source, index)
raise "Operation must have source metadata" if operation_source.nil? || index.nil?

# Mark this transaction as seen (for create deduplication)
def mark_as_seen!
if ethscription_operation == 'create'
self.class.add_seen_create(source_hash)
end
end
source_tag = operation_source.to_s # "input" or "event"
source_tag_hash = Eth::Util.keccak256(source_tag.bytes.pack('C*')) # Hash for constant width

Copilot AI Sep 20, 2025

Copy link

Choose a reason for hiding this comment

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

Converting symbol to string and then hashing on every call is inefficient. Consider using a constant lookup table or memoization for the source tag hashes since there are only two possible values.

Copilot uses AI. Check for mistakes.

# Check if we've already seen this create transaction
def already_seen?
return false unless ethscription_operation == 'create'
self.class.seen_creates.include?(source_hash)
end
# Get function selector from input for operation type safety
function_selector = input.to_bin[0...4]
Comment thread
RogerPodacter marked this conversation as resolved.
Outdated

# Check if this is a valid ethscription
def valid_ethscription?
case ethscription_operation
when 'create'
valid_create?
when 'transfer', 'transfer_with_previous_owner'
valid_transfer?
else
raise "Unknown ethscription operation: #{ethscription_operation}"
end
payload = ByteString.from_bin(
eth_transaction.block_hash.to_bin +

Copilot AI Sep 20, 2025

Copy link

Choose a reason for hiding this comment

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

The method uses eth_transaction.block_hash but the EthTransaction model likely has block_hash as a Hash32 object, not a method that returns binary data. This should probably be eth_transaction.block_hash.to_bin.

Copilot uses AI. Check for mistakes.
source_tag_hash + # 32 bytes (hashed source tag)
function_selector + # 4 bytes (function selector)
Eth::Util.zpad_int(index, 32) # 32 bytes (index)
)

bin_val = Eth::Util.keccak256(
Eth::Util.zpad_int(0, 32) + Eth::Util.keccak256(payload.to_bin) # Domain 0 like Optimism
)

Hash32.from_bin(bin_val)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Transaction Hash Method Error

The compute_source_hash method uses eth_transaction.block_hash, which isn't available on eth_transaction objects, causing a NoMethodError. This should likely be transaction_hash. Using block_hash also impacts source hash uniqueness, potentially leading to collisions for transactions within the same block. Additionally, compute_source_hash has a circular dependency on the input method for the function selector, which can indirectly rely on source_hash.

Fix in Cursor Fix in Web

end

def valid_create?
Expand All @@ -165,7 +142,21 @@ def valid_create?

def valid_transfer?
# Basic field validation - if we extracted the data properly, ABI encoding should work
ethscription_id.present? &&
case ethscription_operation
when 'transfer'
if transfer_ids
# Multiple transfer (input-based)
transfer_ids.is_a?(Array) && transfer_ids.any?
else
# Single transfer (event-based)
ethscription_id.present?
end
Comment on lines +156 to +164

Copilot AI Sep 20, 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 validation logic mixes different transfer types in a single case branch, making it unclear when each branch applies. Consider separating validation for single transfers and multiple transfers into distinct methods for better clarity.

Copilot uses AI. Check for mistakes.
Comment on lines +156 to +164

Copilot AI Sep 20, 2025

Copy link

Choose a reason for hiding this comment

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

The validation logic is inconsistent - it checks transfer_ids.is_a?(Array) but the property is already typed as T.nilable(T::Array[String]). This redundant type check should be removed since Sorbet already enforces the type.

Copilot uses AI. Check for mistakes.
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
Expand All @@ -178,7 +169,11 @@ def input
when 'create'
ByteString.from_bin(build_create_calldata)
when 'transfer'
ByteString.from_bin(build_transfer_calldata)
if transfer_ids && transfer_ids.any?
ByteString.from_bin(build_transfer_multiple_calldata)
else
ByteString.from_bin(build_transfer_calldata)
end
when 'transfer_with_previous_owner'
ByteString.from_bin(build_transfer_with_previous_owner_calldata)
else
Expand Down Expand Up @@ -282,6 +277,18 @@ def build_transfer_with_previous_owner_calldata
(function_sig + encoded).b
end

def build_transfer_multiple_calldata
# Get function selector as binary
function_sig = Eth::Util.keccak256('transferMultipleEthscriptions(bytes32[],address)')[0...4].b

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

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

(function_sig + encoded).b
end

# Helper to convert hex string to binary
def hex_to_bin(hex_str)
return nil unless hex_str
Expand All @@ -302,4 +309,4 @@ def address_to_bin(addr_str)
clean_hex = clean_hex.rjust(40, '0')[-40..]
[clean_hex].pack('H*')
end
end
end
48 changes: 24 additions & 24 deletions app/models/ethscription_transaction_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,12 @@ def detect_creation
eth_transaction: @eth_tx,
creator: normalize_address(@eth_tx.from_address),
initial_owner: normalize_address(@eth_tx.to_address),
content_uri: content
content_uri: content,
source_type: :input,
source_index: @eth_tx.transaction_index
)

if transaction.valid_and_unseen?
transaction.mark_as_seen!
@transactions << transaction
end
@transactions << transaction if transaction.valid_create?
end

# Also check for create events (ESIP-3)
Expand All @@ -75,13 +74,12 @@ def process_create_events
eth_transaction: @eth_tx,
creator: normalize_address(log['address']),
initial_owner: normalize_address(initial_owner),
content_uri: content_uri
content_uri: content_uri,
source_type: :event,
source_index: log['logIndex'].to_i(16)
)

if transaction.valid_and_unseen?
transaction.mark_as_seen!
@transactions << transaction
end
@transactions << transaction if transaction.valid_create?
rescue Eth::Abi::DecodingError => e
Rails.logger.error "Failed to decode create event: #{e.message}"
next
Expand All @@ -108,20 +106,20 @@ def process_input_transfers

return unless valid_length

# Parse each 32-byte hash
input_hex.scan(/.{64}/).each_with_index do |hash_hex, index|
ethscription_id = normalize_hash("0x#{hash_hex}")
# Parse all 32-byte hashes for a single multi-transfer call
ids = input_hex.scan(/.{64}/).map { |hash_hex| normalize_hash("0x#{hash_hex}") }
return if ids.empty?

transaction = EthscriptionTransaction.transfer_ethscription(
eth_transaction: @eth_tx,
from_address: normalize_address(@eth_tx.from_address),
to_address: normalize_address(@eth_tx.to_address),
ethscription_id: ethscription_id,
input_index: index
)
transaction = EthscriptionTransaction.transfer_multiple_ethscriptions(
eth_transaction: @eth_tx,
from_address: normalize_address(@eth_tx.from_address),
to_address: normalize_address(@eth_tx.to_address),
ethscription_ids: ids,
source_type: :input,
source_index: @eth_tx.transaction_index
)

@transactions << transaction
end
@transactions << transaction
Comment on lines +113 to +122

Copilot AI Sep 20, 2025

Copy link

Choose a reason for hiding this comment

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

The method creates a single transaction object for multiple ethscription transfers, but the logic doesn't handle the case where some transfers might be invalid. This could cause valid transfers to be rejected due to one invalid transfer in the batch.

Copilot uses AI. Check for mistakes.
end

def process_event_transfers
Expand Down Expand Up @@ -157,7 +155,8 @@ def handle_esip1_event(log)
from_address: normalize_address(log['address']),
to_address: normalize_address(event_to),
ethscription_id: ethscription_id,
log_index: log['logIndex'].to_i(16)
source_type: :event,
source_index: log['logIndex'].to_i(16)
)

@transactions << transaction
Expand All @@ -182,7 +181,8 @@ def handle_esip2_event(log)
to_address: normalize_address(event_to),
ethscription_id: ethscription_id,
enforced_previous_owner: normalize_address(event_previous_owner),
log_index: log['logIndex'].to_i(16)
source_type: :event,
source_index: log['logIndex'].to_i(16)
)

@transactions << transaction
Expand Down
3 changes: 0 additions & 3 deletions app/services/eth_block_importer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -282,9 +282,6 @@ def current_facet_finalized_block
def import_blocks(block_numbers)
ImportProfiler.start("import_blocks_total")

# Initialize seen creates for this batch (needed by prefetcher threads)
EthscriptionTransaction.reset_seen_creates!

logger.info "Block Importer: importing blocks #{block_numbers.join(', ')}"
start = Time.current

Expand Down
Loading