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
192 changes: 103 additions & 89 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 @@ -32,57 +35,24 @@ class EthscriptionTransaction < T::Struct
VALUE = 0
GAS_LIMIT = 1_000_000_000
TO_ADDRESS = SysConfig::ETHSCRIPTIONS_ADDRESS

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

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

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

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


# Factory method for create operations
def self.create_ethscription(
eth_transaction:,
creator:,
initial_owner:,
content_uri:
content_uri:,
source_type:,
source_index:
)
new(
from_address: Address20.from_hex(creator.is_a?(String) ? creator : creator.to_hex),
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 +64,8 @@ def self.transfer_ethscription(
to_address:,
ethscription_id:,
enforced_previous_owner: nil,
input_index: nil,
log_index: nil
source_type:,
source_index:
)
operation_type = enforced_previous_owner ? 'transfer_with_previous_owner' : 'transfer'

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

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

# Check if this is a valid, unseen ethscription
def valid_and_unseen?
valid_ethscription? && !already_seen?
end

# Mark this transaction as seen (for create deduplication)
def mark_as_seen!
if ethscription_operation == 'create'
self.class.add_seen_create(source_hash)
end
end

# Check if we've already seen this create transaction
def already_seen?
return false unless ethscription_operation == 'create'
self.class.seen_creates.include?(source_hash)
end

# Check if this is a valid ethscription
def valid_ethscription?
case ethscription_operation
# Get function selector for this operation
def function_selector
function_signature = case ethscription_operation
when 'create'
valid_create?
when 'transfer', 'transfer_with_previous_owner'
valid_transfer?
'createEthscription((bytes32,bytes32,address,bytes,string,string,string,bool,(string,string,string,uint256,uint256,uint256)))'
when 'transfer'
if transfer_ids && transfer_ids.any?
'transferMultipleEthscriptions(bytes32[],address)'
else
'transferEthscription(address,bytes32)'
end
when 'transfer_with_previous_owner'
'transferEthscriptionForPreviousOwner(address,bytes32,address)'
else
raise "Unknown ethscription operation: #{ethscription_operation}"
end

Eth::Util.keccak256(function_signature)[0...4]
end

# Unified source hash computation following Optimism pattern
def source_hash
raise "Operation must have source metadata" if source_type.nil? || source_index.nil?

source_tag = source_type.to_s # "input" or "event"
source_tag_hash = Eth::Util.keccak256(source_tag.bytes.pack('C*')) # Hash for constant width

payload = ByteString.from_bin(
eth_transaction.block_hash.to_bin +

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(source_index, 32) # 32 bytes (source_index)
)

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

Hash32.from_bin(bin_val)

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 +153,21 @@ def valid_create?

def valid_transfer?
# Basic field validation - if we extracted the data properly, ABI encoding should work
ethscription_id.present? &&
case ethscription_operation
when 'transfer'
if transfer_ids
# Multiple transfer (input-based)
transfer_ids.is_a?(Array) && transfer_ids.any?
else
# Single transfer (event-based)
ethscription_id.present?
end
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 +180,11 @@ def input
when 'create'
ByteString.from_bin(build_create_calldata)
when 'transfer'
ByteString.from_bin(build_transfer_calldata)
if transfer_ids && transfer_ids.any?
ByteString.from_bin(build_transfer_multiple_calldata)
else
ByteString.from_bin(build_transfer_calldata)
end
when 'transfer_with_previous_owner'
ByteString.from_bin(build_transfer_with_previous_owner_calldata)
else
Expand Down Expand Up @@ -207,9 +213,7 @@ def to_deposit_payload
# Build calldata for create operations (same for both input and event-based)
def build_create_calldata
# Get function selector as binary
function_sig = Eth::Util.keccak256(
'createEthscription((bytes32,bytes32,address,bytes,string,string,string,bool,(string,string,string,uint256,uint256,uint256)))'
)[0...4].b
function_sig = function_selector.b

# Both input and event-based creates use data URI format
# Events are "equivalent of an EOA hex-encoding contentURI and putting it in the calldata"
Expand Down Expand Up @@ -253,7 +257,7 @@ def build_create_calldata

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

# Convert to binary for ABI
to_bin = address_to_bin(transfer_to_address)
Expand All @@ -267,9 +271,7 @@ def build_transfer_calldata

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

# Convert to binary for ABI
to_bin = address_to_bin(transfer_to_address)
Expand All @@ -282,6 +284,18 @@ def build_transfer_with_previous_owner_calldata
(function_sig + encoded).b
end

def build_transfer_multiple_calldata
# Get function selector as binary
function_sig = function_selector.b

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

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

(function_sig + encoded).b
end

# Helper to convert hex string to binary
def hex_to_bin(hex_str)
return nil unless hex_str
Expand All @@ -302,4 +316,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