Skip to content

Commit 0b40c0c

Browse files
Merge pull request #106 from ethscriptions-protocol/remove_already_seen
Handle duplicate ethscription prevention at the contract level
2 parents 1386b82 + 56ac4c1 commit 0b40c0c

4 files changed

Lines changed: 261 additions & 116 deletions

File tree

app/models/ethscription_transaction.rb

Lines changed: 103 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -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

app/models/ethscription_transaction_builder.rb

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,12 @@ def detect_creation
4343
eth_transaction: @eth_tx,
4444
creator: normalize_address(@eth_tx.from_address),
4545
initial_owner: normalize_address(@eth_tx.to_address),
46-
content_uri: content
46+
content_uri: content,
47+
source_type: :input,
48+
source_index: @eth_tx.transaction_index
4749
)
4850

49-
if transaction.valid_and_unseen?
50-
transaction.mark_as_seen!
51-
@transactions << transaction
52-
end
51+
@transactions << transaction if transaction.valid_create?
5352
end
5453

5554
# Also check for create events (ESIP-3)
@@ -75,13 +74,12 @@ def process_create_events
7574
eth_transaction: @eth_tx,
7675
creator: normalize_address(log['address']),
7776
initial_owner: normalize_address(initial_owner),
78-
content_uri: content_uri
77+
content_uri: content_uri,
78+
source_type: :event,
79+
source_index: log['logIndex'].to_i(16)
7980
)
8081

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

109107
return unless valid_length
110108

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

115-
transaction = EthscriptionTransaction.transfer_ethscription(
116-
eth_transaction: @eth_tx,
117-
from_address: normalize_address(@eth_tx.from_address),
118-
to_address: normalize_address(@eth_tx.to_address),
119-
ethscription_id: ethscription_id,
120-
input_index: index
121-
)
113+
transaction = EthscriptionTransaction.transfer_multiple_ethscriptions(
114+
eth_transaction: @eth_tx,
115+
from_address: normalize_address(@eth_tx.from_address),
116+
to_address: normalize_address(@eth_tx.to_address),
117+
ethscription_ids: ids,
118+
source_type: :input,
119+
source_index: @eth_tx.transaction_index
120+
)
122121

123-
@transactions << transaction
124-
end
122+
@transactions << transaction
125123
end
126124

127125
def process_event_transfers
@@ -157,7 +155,8 @@ def handle_esip1_event(log)
157155
from_address: normalize_address(log['address']),
158156
to_address: normalize_address(event_to),
159157
ethscription_id: ethscription_id,
160-
log_index: log['logIndex'].to_i(16)
158+
source_type: :event,
159+
source_index: log['logIndex'].to_i(16)
161160
)
162161

163162
@transactions << transaction
@@ -182,7 +181,8 @@ def handle_esip2_event(log)
182181
to_address: normalize_address(event_to),
183182
ethscription_id: ethscription_id,
184183
enforced_previous_owner: normalize_address(event_previous_owner),
185-
log_index: log['logIndex'].to_i(16)
184+
source_type: :event,
185+
source_index: log['logIndex'].to_i(16)
186186
)
187187

188188
@transactions << transaction

app/services/eth_block_importer.rb

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -282,9 +282,6 @@ def current_facet_finalized_block
282282
def import_blocks(block_numbers)
283283
ImportProfiler.start("import_blocks_total")
284284

285-
# Initialize seen creates for this batch (needed by prefetcher threads)
286-
EthscriptionTransaction.reset_seen_creates!
287-
288285
logger.info "Block Importer: importing blocks #{block_numbers.join(', ')}"
289286
start = Time.current
290287

0 commit comments

Comments
 (0)