@@ -18,11 +18,14 @@ class EthscriptionTransaction < T::Struct
1818
1919 # Transfer operation fields
2020 prop :ethscription_id , T . nilable ( String )
21+ prop :transfer_ids , T . nilable ( T ::Array [ String ] )
2122 prop :transfer_from_address , T . nilable ( String )
2223 prop :transfer_to_address , T . nilable ( String )
2324 prop :enforced_previous_owner , T . nilable ( String )
24- prop :input_index , T . nilable ( Integer )
25- prop :log_index , T . nilable ( Integer )
25+
26+ # Unified source tracking
27+ prop :source_type , T . nilable ( Symbol ) # :input or :event
28+ prop :source_index , T . nilable ( Integer )
2629
2730 # Debug info (can be removed if not needed)
2831 prop :ethscription_operation , T . nilable ( String ) # 'create', 'transfer', 'transfer_with_previous_owner'
@@ -32,57 +35,24 @@ class EthscriptionTransaction < T::Struct
3235 VALUE = 0
3336 GAS_LIMIT = 1_000_000_000
3437 TO_ADDRESS = SysConfig ::ETHSCRIPTIONS_ADDRESS
35-
36- class << self
37- def reset_seen_creates!
38- @seen_creates = Concurrent ::Set . new
39- end
40-
41- def add_seen_create ( tx_hash )
42- seen_creates . add ( tx_hash )
43- end
44-
45- # Access seen creates set - requires reset_seen_creates! to be called first
46- def seen_creates
47- raise "Must call reset_seen_creates! first" unless @seen_creates
48- @seen_creates
49- end
50- end
51-
52- # Dynamic source hash based on operation type
53- def source_hash
54- case ethscription_operation
55- when 'create'
56- # Creates are one-to-one with transactions
57- eth_transaction . transaction_hash
58- when 'transfer' , 'transfer_with_previous_owner'
59- # Transfers need unique hash within transaction
60- if input_index
61- compute_transfer_source_hash ( :input , input_index )
62- elsif log_index
63- compute_transfer_source_hash ( :event , log_index )
64- else
65- raise "Transfer must have either input_index or log_index"
66- end
67- else
68- raise "Unknown ethscription operation: #{ ethscription_operation } "
69- end
70- end
71-
7238
7339 # Factory method for create operations
7440 def self . create_ethscription (
7541 eth_transaction :,
7642 creator :,
7743 initial_owner :,
78- content_uri :
44+ content_uri :,
45+ source_type :,
46+ source_index :
7947 )
8048 new (
8149 from_address : Address20 . from_hex ( creator . is_a? ( String ) ? creator : creator . to_hex ) ,
8250 eth_transaction : eth_transaction ,
8351 creator : creator ,
8452 initial_owner : initial_owner ,
8553 content_uri : content_uri ,
54+ source_type : source_type &.to_sym ,
55+ source_index : source_index ,
8656 ethscription_operation : 'create'
8757 )
8858 end
@@ -94,8 +64,8 @@ def self.transfer_ethscription(
9464 to_address :,
9565 ethscription_id :,
9666 enforced_previous_owner : nil ,
97- input_index : nil ,
98- log_index : nil
67+ source_type : ,
68+ source_index :
9969 )
10070 operation_type = enforced_previous_owner ? 'transfer_with_previous_owner' : 'transfer'
10171
@@ -106,54 +76,72 @@ def self.transfer_ethscription(
10676 transfer_from_address : from_address ,
10777 transfer_to_address : to_address ,
10878 enforced_previous_owner : enforced_previous_owner ,
109- input_index : input_index ,
110- log_index : log_index ,
79+ source_type : source_type &. to_sym ,
80+ source_index : source_index ,
11181 ethscription_operation : operation_type
11282 )
11383 end
11484
115- # Instance method to compute transfer source hash
116- def compute_transfer_source_hash ( operation_type , index )
117- tag = ( operation_type == :input ) ? 0 : 1
118- payload = ByteString . from_bin (
119- eth_transaction . transaction_hash . to_bin +
120- Eth ::Util . zpad_int ( tag , 32 ) +
121- Eth ::Util . zpad_int ( index , 32 )
122- )
123- bin_val = Eth ::Util . keccak256 (
124- Eth ::Util . zpad_int ( 2 , 32 ) + Eth ::Util . keccak256 ( payload . to_bin )
85+ # Factory method for transferMultipleEthscriptions (inputs only)
86+ def self . transfer_multiple_ethscriptions (
87+ eth_transaction :,
88+ from_address :,
89+ to_address :,
90+ ethscription_ids :,
91+ source_type :,
92+ source_index :
93+ )
94+ new (
95+ from_address : Address20 . from_hex ( from_address . is_a? ( String ) ? from_address : from_address . to_hex ) ,
96+ eth_transaction : eth_transaction ,
97+ transfer_ids : ethscription_ids ,
98+ transfer_from_address : from_address ,
99+ transfer_to_address : to_address ,
100+ source_type : source_type &.to_sym ,
101+ source_index : source_index ,
102+ ethscription_operation : 'transfer'
125103 )
126- Hash32 . from_bin ( bin_val )
127- end
128-
129- # Check if this is a valid, unseen ethscription
130- def valid_and_unseen?
131- valid_ethscription? && !already_seen?
132- end
133-
134- # Mark this transaction as seen (for create deduplication)
135- def mark_as_seen!
136- if ethscription_operation == 'create'
137- self . class . add_seen_create ( source_hash )
138- end
139- end
140-
141- # Check if we've already seen this create transaction
142- def already_seen?
143- return false unless ethscription_operation == 'create'
144- self . class . seen_creates . include? ( source_hash )
145104 end
146105
147- # Check if this is a valid ethscription
148- def valid_ethscription?
149- case ethscription_operation
106+ # Get function selector for this operation
107+ def function_selector
108+ function_signature = case ethscription_operation
150109 when 'create'
151- valid_create?
152- when 'transfer' , 'transfer_with_previous_owner'
153- valid_transfer?
110+ 'createEthscription((bytes32,bytes32,address,bytes,string,string,string,bool,(string,string,string,uint256,uint256,uint256)))'
111+ when 'transfer'
112+ if transfer_ids && transfer_ids . any?
113+ 'transferMultipleEthscriptions(bytes32[],address)'
114+ else
115+ 'transferEthscription(address,bytes32)'
116+ end
117+ when 'transfer_with_previous_owner'
118+ 'transferEthscriptionForPreviousOwner(address,bytes32,address)'
154119 else
155120 raise "Unknown ethscription operation: #{ ethscription_operation } "
156121 end
122+
123+ Eth ::Util . keccak256 ( function_signature ) [ 0 ...4 ]
124+ end
125+
126+ # Unified source hash computation following Optimism pattern
127+ def source_hash
128+ raise "Operation must have source metadata" if source_type . nil? || source_index . nil?
129+
130+ source_tag = source_type . to_s # "input" or "event"
131+ source_tag_hash = Eth ::Util . keccak256 ( source_tag . bytes . pack ( 'C*' ) ) # Hash for constant width
132+
133+ payload = ByteString . from_bin (
134+ eth_transaction . block_hash . to_bin +
135+ source_tag_hash + # 32 bytes (hashed source tag)
136+ function_selector + # 4 bytes (function selector)
137+ Eth ::Util . zpad_int ( source_index , 32 ) # 32 bytes (source_index)
138+ )
139+
140+ bin_val = Eth ::Util . keccak256 (
141+ Eth ::Util . zpad_int ( 0 , 32 ) + Eth ::Util . keccak256 ( payload . to_bin ) # Domain 0 like Optimism
142+ )
143+
144+ Hash32 . from_bin ( bin_val )
157145 end
158146
159147 def valid_create?
@@ -165,7 +153,21 @@ def valid_create?
165153
166154 def valid_transfer?
167155 # Basic field validation - if we extracted the data properly, ABI encoding should work
168- ethscription_id . present? &&
156+ case ethscription_operation
157+ when 'transfer'
158+ if transfer_ids
159+ # Multiple transfer (input-based)
160+ transfer_ids . is_a? ( Array ) && transfer_ids . any?
161+ else
162+ # Single transfer (event-based)
163+ ethscription_id . present?
164+ end
165+ when 'transfer_with_previous_owner'
166+ # Always single transfer (event-based only)
167+ ethscription_id . present?
168+ else
169+ false
170+ end &&
169171 transfer_from_address . present? &&
170172 transfer_to_address . present?
171173 end
@@ -178,7 +180,11 @@ def input
178180 when 'create'
179181 ByteString . from_bin ( build_create_calldata )
180182 when 'transfer'
181- ByteString . from_bin ( build_transfer_calldata )
183+ if transfer_ids && transfer_ids . any?
184+ ByteString . from_bin ( build_transfer_multiple_calldata )
185+ else
186+ ByteString . from_bin ( build_transfer_calldata )
187+ end
182188 when 'transfer_with_previous_owner'
183189 ByteString . from_bin ( build_transfer_with_previous_owner_calldata )
184190 else
@@ -207,9 +213,7 @@ def to_deposit_payload
207213 # Build calldata for create operations (same for both input and event-based)
208214 def build_create_calldata
209215 # Get function selector as binary
210- function_sig = Eth ::Util . keccak256 (
211- 'createEthscription((bytes32,bytes32,address,bytes,string,string,string,bool,(string,string,string,uint256,uint256,uint256)))'
212- ) [ 0 ...4 ] . b
216+ function_sig = function_selector . b
213217
214218 # Both input and event-based creates use data URI format
215219 # Events are "equivalent of an EOA hex-encoding contentURI and putting it in the calldata"
@@ -253,7 +257,7 @@ def build_create_calldata
253257
254258 def build_transfer_calldata
255259 # Get function selector as binary
256- function_sig = Eth :: Util . keccak256 ( 'transferEthscription(address,bytes32)' ) [ 0 ... 4 ] . b
260+ function_sig = function_selector . b
257261
258262 # Convert to binary for ABI
259263 to_bin = address_to_bin ( transfer_to_address )
@@ -267,9 +271,7 @@ def build_transfer_calldata
267271
268272 def build_transfer_with_previous_owner_calldata
269273 # Get function selector as binary
270- function_sig = Eth ::Util . keccak256 (
271- 'transferEthscriptionForPreviousOwner(address,bytes32,address)'
272- ) [ 0 ...4 ] . b
274+ function_sig = function_selector . b
273275
274276 # Convert to binary for ABI
275277 to_bin = address_to_bin ( transfer_to_address )
@@ -282,6 +284,18 @@ def build_transfer_with_previous_owner_calldata
282284 ( function_sig + encoded ) . b
283285 end
284286
287+ def build_transfer_multiple_calldata
288+ # Get function selector as binary
289+ function_sig = function_selector . b
290+
291+ ids_bin = ( transfer_ids || [ ] ) . map { |id | hex_to_bin ( id ) }
292+ to_bin = address_to_bin ( transfer_to_address )
293+
294+ encoded = Eth ::Abi . encode ( [ 'bytes32[]' , 'address' ] , [ ids_bin , to_bin ] )
295+
296+ ( function_sig + encoded ) . b
297+ end
298+
285299 # Helper to convert hex string to binary
286300 def hex_to_bin ( hex_str )
287301 return nil unless hex_str
@@ -302,4 +316,4 @@ def address_to_bin(addr_str)
302316 clean_hex = clean_hex . rjust ( 40 , '0' ) [ -40 ..]
303317 [ clean_hex ] . pack ( 'H*' )
304318 end
305- end
319+ end
0 commit comments