-
Notifications
You must be signed in to change notification settings - Fork 25
Handle duplicate ethscription prevention at the contract level #106
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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' | ||
|
|
@@ -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 | ||
|
|
@@ -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' | ||
|
|
||
|
|
@@ -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 + | ||
| 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: Transaction Hash Method ErrorThe |
||
| end | ||
|
|
||
| def valid_create? | ||
|
|
@@ -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
|
||
| 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 | ||
|
|
@@ -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 | ||
|
|
@@ -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" | ||
|
|
@@ -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) | ||
|
|
@@ -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) | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
|
@@ -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 | ||
|
|
@@ -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
|
||
| end | ||
|
|
||
| def process_event_transfers | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
||
There was a problem hiding this comment.
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_hashbut the EthTransaction model likely hasblock_hashas a Hash32 object, not a method that returns binary data. This should probably beeth_transaction.block_hash.to_bin.