diff --git a/app/models/eth_block.rb b/app/models/eth_block.rb index 3cfdff9..f18afd9 100644 --- a/app/models/eth_block.rb +++ b/app/models/eth_block.rb @@ -7,7 +7,11 @@ class BlockNotReadyToImportError < StandardError; end inverse_of: :eth_block has_many :ethscription_transfers, foreign_key: :block_number, primary_key: :block_number, inverse_of: :eth_block + has_many :ethscription_ownership_versions, foreign_key: :block_number, primary_key: :block_number, + inverse_of: :eth_block + before_validation :generate_attestation_hash, if: -> { imported_at.present? } + def self.genesis_blocks blocks = if ENV.fetch('ETHEREUM_NETWORK') == "eth-mainnet" [1608625, 3369985, 3981254, 5873780, 8205613, 9046950, @@ -236,4 +240,46 @@ def as_json(options = {}) ) ).with_indifferent_access end + + def generate_attestation_hash + hash = Digest::SHA256.new + + parent_state_hash = EthBlock.where(block_number: block_number - 1). + limit(1).pluck(:state_hash).first + + hash << (parent_state_hash || "NULL") + hash << hashable_attributes(self.class).map { |attr| send(attr) }. + map { |record| record.nil? ? 'NULL' : record }.join + + associations_to_hash.each do |association| + hashable_attributes = quoted_hashable_attributes(association.klass) + records = association_scope(association).pluck(*hashable_attributes) + + records.map! { |record| record.nil? ? 'NULL' : record } + hash << records.join + end + + self.state_hash = "0x" + hash.hexdigest + self.parent_state_hash = parent_state_hash + end + + def association_scope(association) + association.klass.oldest_first.where(block_number: block_number) + end + + def associations_to_hash + self.class.reflect_on_all_associations(:has_many) + end + + def hashable_attributes(klass) + klass.columns_hash.reject do |k, v| + v.type == :datetime || ['id'].include?(k) + end.keys.sort + end + + def quoted_hashable_attributes(klass) + hashable_attributes(klass).map do |attr| + Arel.sql("encode(digest(#{klass.connection.quote_column_name(attr)}::text, 'sha256'), 'hex')") + end + end end diff --git a/app/models/eth_transaction.rb b/app/models/eth_transaction.rb index bf27748..8d1b470 100644 --- a/app/models/eth_transaction.rb +++ b/app/models/eth_transaction.rb @@ -1,15 +1,27 @@ class EthTransaction < ApplicationRecord belongs_to :eth_block, foreign_key: :block_number, primary_key: :block_number, optional: true, - inverse_of: :eth_transaction + inverse_of: :eth_block has_one :ethscription, foreign_key: :transaction_hash, primary_key: :transaction_hash, - inverse_of: :eth_transaction, dependent: :destroy - + inverse_of: :eth_transaction has_many :ethscription_transfers, foreign_key: :transaction_hash, - primary_key: :transaction_hash, dependent: :destroy, inverse_of: :eth_transaction + primary_key: :transaction_hash, inverse_of: :eth_transaction + has_many :ethscription_ownership_versions, foreign_key: :transaction_hash, + primary_key: :transaction_hash, inverse_of: :eth_transaction attr_accessor :transfer_index + scope :newest_first, -> { order(block_number: :desc, transaction_index: :desc) } + scope :oldest_first, -> { order(block_number: :asc, transaction_index: :asc) } + + def self.event_signature(event_name) + "0x" + Digest::Keccak256.hexdigest(event_name) + end + + CreateEthscriptionEventSig = event_signature("ethscriptions_protocol_CreateEthscription(address,string)") + Esip2EventSig = event_signature("ethscriptions_protocol_TransferEthscriptionForPreviousOwner(address,address,bytes32)") + Esip1EventSig = event_signature("ethscriptions_protocol_TransferEthscription(address,bytes32)") + def possibly_relevant? status != 0 && (possibly_creates_ethscription? || possibly_transfers_ethscription?) @@ -104,7 +116,7 @@ def ethscription_creation_events return [] unless EthTransaction.esip3_enabled?(block_number) ordered_events.select do |log| - EthTransaction.contracts_create_ethscription_event_sig == log['topics'].first + CreateEthscriptionEventSig == log['topics'].first end end @@ -143,16 +155,17 @@ def create_ethscription_transfers_from_events! topics = log['topics'] event_type = topics.first - if event_type == EthTransaction.esip1_transfer_event_signature + if event_type == Esip1EventSig begin - event_to = Eth::Abi.decode(['address'], topics.third).first + event_to = Eth::Abi.decode(['address'], topics.second).first + tx_hash = Eth::Util.bin_to_prefixed_hex( + Eth::Abi.decode(['bytes32'], topics.third).first + ) rescue Eth::Abi::DecodingError next end - next unless valid_bytes32?(topics.third) - - target_ethscription = Ethscription.find_by(transaction_hash: topics.third) + target_ethscription = Ethscription.find_by(transaction_hash: tx_hash) if target_ethscription.present? ethscription_transfers.create!( @@ -165,17 +178,18 @@ def create_ethscription_transfers_from_events! }.merge(transfer_attrs) ) end - elsif event_type == EthTransaction.esip2_transfer_event_signature + elsif event_type == Esip2EventSig begin event_previous_owner = Eth::Abi.decode(['address'], topics.second).first event_to = Eth::Abi.decode(['address'], topics.third).first + tx_hash = Eth::Util.bin_to_prefixed_hex( + Eth::Abi.decode(['bytes32'], topics.fourth).first + ) rescue Eth::Abi::DecodingError next end - next unless valid_bytes32?(topics.fourth) - - target_ethscription = Ethscription.find_by(transaction_hash: topics.fourth) + target_ethscription = Ethscription.find_by(transaction_hash: tx_hash) if target_ethscription.present? ethscription_transfers.create!( @@ -277,40 +291,11 @@ def self.esip1_enabled?(block_number) block_number >= 17672762 end - def valid_bytes32?(value) - /\A0x[0-9a-f]{64}\z/i.match?(value.to_s) - end - def self.contract_transfer_event_signatures(block_number) [].tap do |res| - res << esip1_transfer_event_signature if esip1_enabled?(block_number) - res << esip2_transfer_event_signature if esip2_enabled?(block_number) - end - end - - class << self - extend Memoist - - def contracts_create_ethscription_event_sig - "0x" + Digest::Keccak256.hexdigest( - "ethscriptions_protocol_CreateEthscription(address,string)" - ) - end - memoize :contracts_create_ethscription_event_sig - - def esip2_transfer_event_signature - "0x" + Digest::Keccak256.hexdigest( - "ethscriptions_protocol_TransferEthscriptionForPreviousOwner(address,address,bytes32)" - ) - end - memoize :esip2_transfer_event_signature - - def esip1_transfer_event_signature - "0x" + Digest::Keccak256.hexdigest( - "ethscriptions_protocol_TransferEthscription(address,bytes32)" - ) + res << Esip1EventSig if esip1_enabled?(block_number) + res << Esip2EventSig if esip2_enabled?(block_number) end - memoize :esip1_transfer_event_signature end def self.prune_transactions diff --git a/app/models/ethscription_ownership_version.rb b/app/models/ethscription_ownership_version.rb index 9c75a79..d01a93f 100644 --- a/app/models/ethscription_ownership_version.rb +++ b/app/models/ethscription_ownership_version.rb @@ -1,6 +1,6 @@ class EthscriptionOwnershipVersion < ApplicationRecord - belongs_to :eth_block, foreign_key: :block_number, primary_key: :block_number, optional: true, - inverse_of: :ethscription_ownership_versions + belongs_to :eth_block, foreign_key: :block_number, primary_key: :block_number, + optional: true, inverse_of: :ethscription_ownership_versions belongs_to :eth_transaction, foreign_key: :transaction_hash, primary_key: :transaction_hash, optional: true, @@ -15,4 +15,10 @@ class EthscriptionOwnershipVersion < ApplicationRecord transaction_index: :desc, transfer_index: :desc )} + + scope :oldest_first, -> { order( + block_number: :asc, + transaction_index: :asc, + transfer_index: :asc + )} end \ No newline at end of file diff --git a/app/models/ethscription_transfer.rb b/app/models/ethscription_transfer.rb index 32a98cd..0baed12 100644 --- a/app/models/ethscription_transfer.rb +++ b/app/models/ethscription_transfer.rb @@ -8,6 +8,18 @@ class EthscriptionTransfer < ApplicationRecord after_create :create_ownership_version!, :notify_eth_transaction + scope :newest_first, -> { order( + block_number: :desc, + transaction_index: :desc, + transfer_index: :desc + )} + + scope :oldest_first, -> { order( + block_number: :asc, + transaction_index: :asc, + transfer_index: :asc + )} + def notify_eth_transaction if eth_transaction.transfer_index.nil? raise "Need eth_transaction.transfer_index" diff --git a/db/migrate/20231216161930_create_eth_blocks.rb b/db/migrate/20231216161930_create_eth_blocks.rb index 38c2bf1..9c1be38 100644 --- a/db/migrate/20231216161930_create_eth_blocks.rb +++ b/db/migrate/20231216161930_create_eth_blocks.rb @@ -1,22 +1,31 @@ class CreateEthBlocks < ActiveRecord::Migration[7.1] def change + enable_extension 'pgcrypto' unless extension_enabled?('pgcrypto') + create_table :eth_blocks, force: :cascade do |t| t.bigint :block_number, null: false t.bigint :timestamp, null: false t.string :blockhash, null: false t.string :parent_blockhash, null: false t.datetime :imported_at + t.string :state_hash + t.string :parent_state_hash t.boolean :is_genesis_block, null: false t.index :block_number, unique: true t.index :blockhash, unique: true t.index :imported_at + t.index [:imported_at, :block_number] t.index :parent_blockhash, unique: true + t.index :state_hash, unique: true + t.index :parent_state_hash, unique: true t.index :timestamp t.check_constraint "blockhash ~ '^0x[a-f0-9]{64}$'" t.check_constraint "parent_blockhash ~ '^0x[a-f0-9]{64}$'" + t.check_constraint "state_hash ~ '^0x[a-f0-9]{64}$'" + t.check_constraint "parent_state_hash ~ '^0x[a-f0-9]{64}$'" t.timestamps end @@ -46,6 +55,29 @@ def change FOR EACH ROW EXECUTE FUNCTION check_block_order(); SQL + execute <<~SQL + CREATE OR REPLACE FUNCTION check_block_order_on_update() + RETURNS TRIGGER AS $$ + BEGIN + IF NEW.imported_at IS NOT NULL AND NEW.state_hash IS NULL THEN + RAISE EXCEPTION 'state_hash must be set when imported_at is set'; + END IF; + + IF NEW.is_genesis_block = false AND + NEW.parent_state_hash <> (SELECT state_hash FROM eth_blocks WHERE block_number = NEW.block_number - 1 AND imported_at IS NOT NULL) THEN + RAISE EXCEPTION 'Parent state hash does not match the state hash of the previous block'; + END IF; + + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + + CREATE TRIGGER trigger_check_block_order_on_update + BEFORE UPDATE OF imported_at ON eth_blocks + FOR EACH ROW WHEN (NEW.imported_at IS NOT NULL) + EXECUTE FUNCTION check_block_order_on_update(); + SQL + execute <<-SQL CREATE OR REPLACE FUNCTION delete_later_blocks() RETURNS TRIGGER AS $$ diff --git a/db/migrate/20231216163233_create_eth_transactions.rb b/db/migrate/20231216163233_create_eth_transactions.rb index 9bf4189..0f5f732 100644 --- a/db/migrate/20231216163233_create_eth_transactions.rb +++ b/db/migrate/20231216163233_create_eth_transactions.rb @@ -18,10 +18,12 @@ def change t.index [:block_number, :transaction_index], unique: true t.index :block_number + t.index :block_timestamp t.index :from_address t.index :status t.index :to_address t.index :transaction_hash, unique: true + t.index :logs, using: :gin t.check_constraint "block_number <= 4370000 AND status IS NULL OR block_number > 4370000 AND status = 1", name: "status_check" t.check_constraint "created_contract_address IS NULL AND to_address IS NOT NULL OR @@ -29,8 +31,8 @@ def change t.check_constraint "transaction_hash ~ '^0x[a-f0-9]{64}$'" t.check_constraint "from_address ~ '^0x[a-f0-9]{40}$'" - t.check_constraint "to_address IS NULL OR to_address ~ '^0x[a-f0-9]{40}$'" - t.check_constraint "created_contract_address IS NULL OR created_contract_address ~ '^0x[a-f0-9]{40}$'" + t.check_constraint "to_address ~ '^0x[a-f0-9]{40}$'" + t.check_constraint "created_contract_address ~ '^0x[a-f0-9]{40}$'" t.foreign_key :eth_blocks, column: :block_number, primary_key: :block_number, on_delete: :cascade diff --git a/db/migrate/20231216164707_create_ethscriptions.rb b/db/migrate/20231216164707_create_ethscriptions.rb index 9a5cc42..1767fbe 100644 --- a/db/migrate/20231216164707_create_ethscriptions.rb +++ b/db/migrate/20231216164707_create_ethscriptions.rb @@ -7,16 +7,14 @@ def change t.bigint :block_timestamp, null: false t.bigint :event_log_index - t.bigint :ethscription_number#, null: false + t.bigint :ethscription_number, null: false t.string :creator, null: false t.string :initial_owner, null: false t.string :current_owner, null: false t.string :previous_owner, null: false - # t.boolean :valid_data_uri, null: false t.text :content_uri, null: false t.string :content_sha, null: false - # t.boolean :content_unique t.boolean :esip6, null: false t.string :mimetype, null: false t.string :media_type, null: false @@ -35,20 +33,17 @@ def change t.index :creator t.index :current_owner t.index :ethscription_number, unique: true - # t.index [:content_unique, :valid_data_uri] - # t.index :content_unique, where: "(content_unique IS NOT NULL)" t.index :content_sha t.index :content_sha, unique: true, where: "(esip6 = false)", name: :index_ethscriptions_on_content_sha_unique - # t.index :valid_data_uri t.index :initial_owner t.index :media_type t.index :mime_subtype t.index :mimetype t.index :previous_owner t.index :transaction_index + t.index :esip6 - # t.check_constraint "esip6 = true OR content_unique IS NOT NULL" t.check_constraint "content_sha ~ '^0x[a-f0-9]{64}$'" t.check_constraint "transaction_hash ~ '^0x[a-f0-9]{64}$'" t.check_constraint "creator ~ '^0x[a-f0-9]{40}$'" @@ -65,27 +60,31 @@ def change reversible do |dir| dir.up do execute <<-SQL - CREATE OR REPLACE FUNCTION check_ethscription_order() + DROP TRIGGER IF EXISTS trigger_check_ethscription_order ON ethscriptions; + DROP FUNCTION IF EXISTS check_ethscription_order(); + + CREATE OR REPLACE FUNCTION check_ethscription_order_and_sequence() RETURNS TRIGGER AS $$ BEGIN IF NEW.block_number < (SELECT MAX(block_number) FROM ethscriptions) OR (NEW.block_number = (SELECT MAX(block_number) FROM ethscriptions) AND NEW.transaction_index <= (SELECT MAX(transaction_index) FROM ethscriptions WHERE block_number = NEW.block_number)) THEN RAISE EXCEPTION 'Ethscriptions must be created in order'; END IF; + NEW.ethscription_number := (SELECT COALESCE(MAX(ethscription_number), -1) + 1 FROM ethscriptions); RETURN NEW; END; $$ LANGUAGE plpgsql; - CREATE TRIGGER trigger_check_ethscription_order + CREATE TRIGGER trigger_check_ethscription_order_and_sequence BEFORE INSERT ON ethscriptions - FOR EACH ROW EXECUTE FUNCTION check_ethscription_order(); + FOR EACH ROW EXECUTE FUNCTION check_ethscription_order_and_sequence(); SQL end dir.down do execute <<-SQL - DROP TRIGGER IF EXISTS trigger_check_ethscription_order ON ethscriptions; - DROP FUNCTION IF EXISTS check_ethscription_order(); + DROP TRIGGER IF EXISTS trigger_check_ethscription_order_and_sequence ON ethscriptions; + DROP FUNCTION IF EXISTS check_ethscription_order_and_sequence(); SQL end end diff --git a/db/migrate/20231216213103_create_ethscription_transfers.rb b/db/migrate/20231216213103_create_ethscription_transfers.rb index 86da902..c900af2 100644 --- a/db/migrate/20231216213103_create_ethscription_transfers.rb +++ b/db/migrate/20231216213103_create_ethscription_transfers.rb @@ -10,9 +10,9 @@ def change t.bigint :event_log_index t.bigint :transfer_index, null: false t.bigint :transaction_index, null: false - # t.integer :creation_esip, null: false t.string :enforced_previous_owner + t.index :ethscription_transaction_hash t.index :block_number t.index :from_address t.index :to_address diff --git a/db/migrate/20231216215348_create_ethscription_ownership_versions.rb b/db/migrate/20231216215348_create_ethscription_ownership_versions.rb index b1f9cff..4e92706 100644 --- a/db/migrate/20231216215348_create_ethscription_ownership_versions.rb +++ b/db/migrate/20231216215348_create_ethscription_ownership_versions.rb @@ -11,8 +11,12 @@ def change t.string :current_owner, null: false t.string :previous_owner, null: false + t.index :current_owner + t.index :previous_owner + t.index [:current_owner, :previous_owner] t.index :ethscription_transaction_hash t.index :transaction_hash + t.index :block_number t.index [:transaction_hash, :transfer_index], unique: true t.index [:block_number, :transaction_index, :transfer_index], unique: true t.index [:ethscription_transaction_hash, :block_number, :transaction_index, :transfer_index], unique: true diff --git a/db/migrate/20231219131956_set_ethscription_numbers_non_nullable.rb b/db/migrate/20231219131956_set_ethscription_numbers_non_nullable.rb deleted file mode 100644 index 5d749bd..0000000 --- a/db/migrate/20231219131956_set_ethscription_numbers_non_nullable.rb +++ /dev/null @@ -1,37 +0,0 @@ -class SetEthscriptionNumbersNonNullable < ActiveRecord::Migration[7.1] - def change - change_column_null :ethscriptions, :ethscription_number, false - - reversible do |dir| - dir.up do - execute <<-SQL - DROP TRIGGER IF EXISTS trigger_check_ethscription_order ON ethscriptions; - DROP FUNCTION IF EXISTS check_ethscription_order(); - - CREATE OR REPLACE FUNCTION check_ethscription_order_and_sequence() - RETURNS TRIGGER AS $$ - BEGIN - IF NEW.block_number < (SELECT MAX(block_number) FROM ethscriptions) OR - (NEW.block_number = (SELECT MAX(block_number) FROM ethscriptions) AND NEW.transaction_index <= (SELECT MAX(transaction_index) FROM ethscriptions WHERE block_number = NEW.block_number)) THEN - RAISE EXCEPTION 'Ethscriptions must be created in order'; - END IF; - NEW.ethscription_number := (SELECT COALESCE(MAX(ethscription_number), -1) + 1 FROM ethscriptions); - RETURN NEW; - END; - $$ LANGUAGE plpgsql; - - CREATE TRIGGER trigger_check_ethscription_order_and_sequence - BEFORE INSERT ON ethscriptions - FOR EACH ROW EXECUTE FUNCTION check_ethscription_order_and_sequence(); - SQL - end - - dir.down do - execute <<-SQL - DROP TRIGGER IF EXISTS trigger_check_ethscription_order_and_sequence ON ethscriptions; - DROP FUNCTION IF EXISTS check_ethscription_order_and_sequence(); - SQL - end - end - end -end \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index f0393cf..d2f4fb9 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -10,24 +10,17 @@ SET client_min_messages = warning; SET row_security = off; -- --- Name: heroku_ext; Type: SCHEMA; Schema: -; Owner: - +-- Name: pgcrypto; Type: EXTENSION; Schema: -; Owner: - -- -CREATE SCHEMA heroku_ext; +CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public; -- --- Name: pg_stat_statements; Type: EXTENSION; Schema: -; Owner: - +-- Name: EXTENSION pgcrypto; Type: COMMENT; Schema: -; Owner: - -- -CREATE EXTENSION IF NOT EXISTS pg_stat_statements WITH SCHEMA public; - - --- --- Name: EXTENSION pg_stat_statements; Type: COMMENT; Schema: -; Owner: - --- - -COMMENT ON EXTENSION pg_stat_statements IS 'track planning and execution statistics of all SQL statements executed'; +COMMENT ON EXTENSION pgcrypto IS 'cryptographic functions'; -- @@ -77,6 +70,28 @@ CREATE FUNCTION public.check_block_order() RETURNS trigger $$; +-- +-- Name: check_block_order_on_update(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.check_block_order_on_update() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF NEW.imported_at IS NOT NULL AND NEW.state_hash IS NULL THEN + RAISE EXCEPTION 'state_hash must be set when imported_at is set'; + END IF; + + IF NEW.is_genesis_block = false AND + NEW.parent_state_hash <> (SELECT state_hash FROM eth_blocks WHERE block_number = NEW.block_number - 1 AND imported_at IS NOT NULL) THEN + RAISE EXCEPTION 'Parent state hash does not match the state hash of the previous block'; + END IF; + + RETURN NEW; +END; +$$; + + -- -- Name: check_ethscription_order_and_sequence(); Type: FUNCTION; Schema: public; Owner: - -- @@ -215,10 +230,14 @@ CREATE TABLE public.eth_blocks ( blockhash character varying NOT NULL, parent_blockhash character varying NOT NULL, imported_at timestamp(6) without time zone, + state_hash character varying, + parent_state_hash character varying, is_genesis_block boolean NOT NULL, created_at timestamp(6) without time zone NOT NULL, updated_at timestamp(6) without time zone NOT NULL, CONSTRAINT chk_rails_1c105acdac CHECK (((parent_blockhash)::text ~ '^0x[a-f0-9]{64}$'::text)), + CONSTRAINT chk_rails_319237323b CHECK (((state_hash)::text ~ '^0x[a-f0-9]{64}$'::text)), + CONSTRAINT chk_rails_7126b7c9d3 CHECK (((parent_state_hash)::text ~ '^0x[a-f0-9]{64}$'::text)), CONSTRAINT chk_rails_7e9881ece2 CHECK (((blockhash)::text ~ '^0x[a-f0-9]{64}$'::text)) ); @@ -264,10 +283,10 @@ CREATE TABLE public.eth_transactions ( value numeric NOT NULL, created_at timestamp(6) without time zone NOT NULL, updated_at timestamp(6) without time zone NOT NULL, - CONSTRAINT chk_rails_51be5c1aa9 CHECK (((to_address IS NULL) OR ((to_address)::text ~ '^0x[a-f0-9]{40}$'::text))), - CONSTRAINT chk_rails_93b41d08e7 CHECK (((created_contract_address IS NULL) OR ((created_contract_address)::text ~ '^0x[a-f0-9]{40}$'::text))), + CONSTRAINT chk_rails_37ed5d6017 CHECK (((to_address)::text ~ '^0x[a-f0-9]{40}$'::text)), CONSTRAINT chk_rails_9cdbd3b1ad CHECK (((transaction_hash)::text ~ '^0x[a-f0-9]{64}$'::text)), CONSTRAINT chk_rails_a4d3f41974 CHECK (((from_address)::text ~ '^0x[a-f0-9]{40}$'::text)), + CONSTRAINT chk_rails_d460e80110 CHECK (((created_contract_address)::text ~ '^0x[a-f0-9]{40}$'::text)), CONSTRAINT contract_to_check CHECK ((((created_contract_address IS NULL) AND (to_address IS NOT NULL)) OR ((created_contract_address IS NOT NULL) AND (to_address IS NULL)))), CONSTRAINT status_check CHECK ((((block_number <= 4370000) AND (status IS NULL)) OR ((block_number > 4370000) AND (status = 1)))) ); @@ -584,6 +603,13 @@ CREATE UNIQUE INDEX idx_on_block_number_transaction_index_transfer_inde_8090d24b CREATE UNIQUE INDEX idx_on_block_number_transaction_index_transfer_inde_fc9ee59957 ON public.ethscription_transfers USING btree (block_number, transaction_index, transfer_index); +-- +-- Name: idx_on_current_owner_previous_owner_7bb4bbf3cf; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_on_current_owner_previous_owner_7bb4bbf3cf ON public.ethscription_ownership_versions USING btree (current_owner, previous_owner); + + -- -- Name: idx_on_ethscription_transaction_hash_block_number_t_a807d2b571; Type: INDEX; Schema: public; Owner: - -- @@ -640,6 +666,13 @@ CREATE UNIQUE INDEX index_eth_blocks_on_blockhash ON public.eth_blocks USING btr CREATE INDEX index_eth_blocks_on_imported_at ON public.eth_blocks USING btree (imported_at); +-- +-- Name: index_eth_blocks_on_imported_at_and_block_number; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_eth_blocks_on_imported_at_and_block_number ON public.eth_blocks USING btree (imported_at, block_number); + + -- -- Name: index_eth_blocks_on_parent_blockhash; Type: INDEX; Schema: public; Owner: - -- @@ -647,6 +680,20 @@ CREATE INDEX index_eth_blocks_on_imported_at ON public.eth_blocks USING btree (i CREATE UNIQUE INDEX index_eth_blocks_on_parent_blockhash ON public.eth_blocks USING btree (parent_blockhash); +-- +-- Name: index_eth_blocks_on_parent_state_hash; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_eth_blocks_on_parent_state_hash ON public.eth_blocks USING btree (parent_state_hash); + + +-- +-- Name: index_eth_blocks_on_state_hash; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_eth_blocks_on_state_hash ON public.eth_blocks USING btree (state_hash); + + -- -- Name: index_eth_blocks_on_timestamp; Type: INDEX; Schema: public; Owner: - -- @@ -668,6 +715,13 @@ CREATE INDEX index_eth_transactions_on_block_number ON public.eth_transactions U CREATE UNIQUE INDEX index_eth_transactions_on_block_number_and_transaction_index ON public.eth_transactions USING btree (block_number, transaction_index); +-- +-- Name: index_eth_transactions_on_block_timestamp; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_eth_transactions_on_block_timestamp ON public.eth_transactions USING btree (block_timestamp); + + -- -- Name: index_eth_transactions_on_from_address; Type: INDEX; Schema: public; Owner: - -- @@ -675,6 +729,13 @@ CREATE UNIQUE INDEX index_eth_transactions_on_block_number_and_transaction_index CREATE INDEX index_eth_transactions_on_from_address ON public.eth_transactions USING btree (from_address); +-- +-- Name: index_eth_transactions_on_logs; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_eth_transactions_on_logs ON public.eth_transactions USING gin (logs); + + -- -- Name: index_eth_transactions_on_status; Type: INDEX; Schema: public; Owner: - -- @@ -696,6 +757,27 @@ CREATE INDEX index_eth_transactions_on_to_address ON public.eth_transactions USI CREATE UNIQUE INDEX index_eth_transactions_on_transaction_hash ON public.eth_transactions USING btree (transaction_hash); +-- +-- Name: index_ethscription_ownership_versions_on_block_number; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_ethscription_ownership_versions_on_block_number ON public.ethscription_ownership_versions USING btree (block_number); + + +-- +-- Name: index_ethscription_ownership_versions_on_current_owner; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_ethscription_ownership_versions_on_current_owner ON public.ethscription_ownership_versions USING btree (current_owner); + + +-- +-- Name: index_ethscription_ownership_versions_on_previous_owner; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_ethscription_ownership_versions_on_previous_owner ON public.ethscription_ownership_versions USING btree (previous_owner); + + -- -- Name: index_ethscription_ownership_versions_on_transaction_hash; Type: INDEX; Schema: public; Owner: - -- @@ -710,6 +792,13 @@ CREATE INDEX index_ethscription_ownership_versions_on_transaction_hash ON public CREATE INDEX index_ethscription_transfers_on_block_number ON public.ethscription_transfers USING btree (block_number); +-- +-- Name: index_ethscription_transfers_on_ethscription_transaction_hash; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_ethscription_transfers_on_ethscription_transaction_hash ON public.ethscription_transfers USING btree (ethscription_transaction_hash); + + -- -- Name: index_ethscription_transfers_on_from_address; Type: INDEX; Schema: public; Owner: - -- @@ -780,6 +869,13 @@ CREATE INDEX index_ethscriptions_on_creator ON public.ethscriptions USING btree CREATE INDEX index_ethscriptions_on_current_owner ON public.ethscriptions USING btree (current_owner); +-- +-- Name: index_ethscriptions_on_esip6; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_ethscriptions_on_esip6 ON public.ethscriptions USING btree (esip6); + + -- -- Name: index_ethscriptions_on_ethscription_number; Type: INDEX; Schema: public; Owner: - -- @@ -850,6 +946,13 @@ CREATE TRIGGER check_block_imported_at_trigger BEFORE UPDATE OF imported_at ON p CREATE TRIGGER trigger_check_block_order BEFORE INSERT ON public.eth_blocks FOR EACH ROW EXECUTE FUNCTION public.check_block_order(); +-- +-- Name: eth_blocks trigger_check_block_order_on_update; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER trigger_check_block_order_on_update BEFORE UPDATE OF imported_at ON public.eth_blocks FOR EACH ROW WHEN ((new.imported_at IS NOT NULL)) EXECUTE FUNCTION public.check_block_order_on_update(); + + -- -- Name: ethscriptions trigger_check_ethscription_order_and_sequence; Type: TRIGGER; Schema: public; Owner: - -- @@ -950,7 +1053,6 @@ ALTER TABLE ONLY public.ethscription_ownership_versions SET search_path TO "$user", public; INSERT INTO "schema_migrations" (version) VALUES -('20231219131956'), ('20231217190431'), ('20231216215348'), ('20231216213103'), diff --git a/lib/ethscription_test_helper.rb b/lib/ethscription_test_helper.rb index 639630e..9fc0f47 100644 --- a/lib/ethscription_test_helper.rb +++ b/lib/ethscription_test_helper.rb @@ -1,9 +1,29 @@ module EthscriptionTestHelper + def self.create_from_hash(hash) + resp = AlchemyClient.query_api( + method: 'eth_getTransactionByHash', + params: [hash] + )['result'] + + resp2 = AlchemyClient.query_api( + method: 'eth_getTransactionReceipt', + params: [hash] + )['result'] + + create_eth_transaction( + input: resp['input'], + to: resp['to'], + from: resp['from'], + logs: resp2['logs'] + ) + end + def self.create_eth_transaction( input:, from:, to:, - logs: + logs: [], + tx_hash: nil ) existing = Ethscription.newest_first.first @@ -12,8 +32,11 @@ def self.create_eth_transaction( transaction_index = existing&.transaction_index.to_i + 1 overall_order_number = block_number * 1e8 + transaction_index - hex_input = input.bytes.map { |byte| byte.to_s(16).rjust(2, '0') }.join - hex_input = "0x" + hex_input + hex_input = if input.match?(/\A0x([a-f0-9]{2})+\z/i) + input.downcase + else + "0x" + input.bytes.map { |byte| byte.to_s(16).rjust(2, '0') }.join + end if EthBlock.exists? parent_block = EthBlock.order(block_number: :desc).first @@ -33,7 +56,7 @@ def self.create_eth_transaction( tx = EthTransaction.create!( block_number: block_number, block_timestamp: eth_block.timestamp, - transaction_hash: "0x" + SecureRandom.hex(32), + transaction_hash: tx_hash || "0x" + SecureRandom.hex(32), from_address: from.downcase, to_address: to.downcase, transaction_index: transaction_index, @@ -49,6 +72,7 @@ def self.create_eth_transaction( tx.process! eth_block.update!(imported_at: Time.current) + tx end def self.t diff --git a/spec/models/eth_transaction_spec.rb b/spec/models/eth_transaction_spec.rb index cfa7d77..f64151a 100644 --- a/spec/models/eth_transaction_spec.rb +++ b/spec/models/eth_transaction_spec.rb @@ -88,7 +88,7 @@ logs: [ { 'topics' => [ - EthTransaction.contracts_create_ethscription_event_sig, + EthTransaction::CreateEthscriptionEventSig, Eth::Abi.encode(['address'], ['0xc2172a6315c1d7f6855768f843c420ebb36eda97']).unpack1('H*'), ], 'data' => Eth::Abi.encode(['string'], ['data:,test-log']).unpack1('H*'), @@ -97,7 +97,7 @@ }, { 'topics' => [ - EthTransaction.contracts_create_ethscription_event_sig, + EthTransaction::CreateEthscriptionEventSig, Eth::Abi.encode(['address'], ['0xc2172a6315c1d7f6855768f843c420ebb36eda97']).unpack1('H*'), ], 'data' => Eth::Abi.encode(['string'], ['data:,test-log-2']).unpack1('H*'), @@ -126,7 +126,7 @@ logs: [ { 'topics' => [ - EthTransaction.contracts_create_ethscription_event_sig, + EthTransaction::CreateEthscriptionEventSig, Eth::Abi.encode(['address'], ['0xc2172a6315c1d7f6855768f843c420ebb36eda97']).unpack1('H*'), ], 'data' => Eth::Abi.encode(['string'], ['data:,test-log']).unpack1('H*'), @@ -135,7 +135,7 @@ }, { 'topics' => [ - EthTransaction.contracts_create_ethscription_event_sig, + EthTransaction::CreateEthscriptionEventSig, Eth::Abi.encode(['address'], ['0xc2172a6315c1d7f6855768f843c420ebb36eda97']).unpack1('H*'), ], 'data' => Eth::Abi.encode(['string'], ['data:,test-log']).unpack1('H*'), @@ -163,7 +163,7 @@ logs: [ { 'topics' => [ - EthTransaction.contracts_create_ethscription_event_sig, + EthTransaction::CreateEthscriptionEventSig, Eth::Abi.encode(['address'], ['0xc2172a6315c1d7f6855768f843c420ebb36eda97']).unpack1('H*'), ], 'data' => Eth::Abi.encode(['string'], ['invalid']).unpack1('H*'), @@ -172,7 +172,7 @@ }, { 'topics' => [ - EthTransaction.contracts_create_ethscription_event_sig, + EthTransaction::CreateEthscriptionEventSig, Eth::Abi.encode(['address'], ['0xc2172a6315c1d7f6855768f843c420ebb36eda97']).unpack1('H*'), ], 'data' => Eth::Abi.encode(['string'], ['data:,test-log']).unpack1('H*'), @@ -201,7 +201,7 @@ logs: [ { 'topics' => [ - EthTransaction.contracts_create_ethscription_event_sig, + EthTransaction::CreateEthscriptionEventSig, Eth::Abi.encode(['address'], ['0xc2172a6315c1d7f6855768f843c420ebb36eda97']).unpack1('H*'), ], 'data' => Eth::Abi.encode(['string'], ['data:,test1']).unpack1('H*'), @@ -210,7 +210,7 @@ }, { 'topics' => [ - EthTransaction.contracts_create_ethscription_event_sig, + EthTransaction::CreateEthscriptionEventSig, Eth::Abi.encode(['address'], ['0xc2172a6315c1d7f6855768f843c420ebb36eda97']).unpack1('H*'), ], 'data' => Eth::Abi.encode(['string'], ['data:,test2']).unpack1('H*'), @@ -242,7 +242,7 @@ }, { 'topics' => [ - EthTransaction.contracts_create_ethscription_event_sig, + EthTransaction::CreateEthscriptionEventSig, Eth::Abi.encode(['address'], ['0xc2172a6315c1d7f6855768f843c420ebb36eda97']).unpack1('H*'), ], 'data' => Eth::Abi.encode(['string'], ['data:,test']).unpack1('H*'), @@ -268,7 +268,7 @@ logs: [ { 'topics' => [ - EthTransaction.contracts_create_ethscription_event_sig, + EthTransaction::CreateEthscriptionEventSig, Eth::Abi.encode(['address'], ['0xc2172a6315c1d7f6855768f843c420ebb36eda97']).unpack1('H*'), ], 'data' => Eth::Abi.encode(['string'], ['data:,test']).unpack1('H*'), @@ -301,7 +301,7 @@ }, { 'topics' => [ - EthTransaction.contracts_create_ethscription_event_sig, + EthTransaction::CreateEthscriptionEventSig, Eth::Abi.encode(['address'], ['0xc2172a6315c1d7f6855768f843c420ebb36eda97']).unpack1('H*'), ], 'data' => Eth::Abi.encode(['string'], ['data:,test']).unpack1('H*'), @@ -325,7 +325,7 @@ logs: [ { 'topics' => [ - EthTransaction.contracts_create_ethscription_event_sig, + EthTransaction::CreateEthscriptionEventSig, Eth::Abi.encode(['address'], ['0xc2172a6315c1d7f6855768f843c420ebb36eda97']).unpack1('H*'), Eth::Abi.encode(['address'], ['0xc2172a6315c1d7f6855768f843c420ebb36eda97']).unpack1('H*'), ], @@ -334,7 +334,7 @@ }, { 'topics' => [ - EthTransaction.contracts_create_ethscription_event_sig, + EthTransaction::CreateEthscriptionEventSig, Eth::Abi.encode(['address'], ['0xc2172a6315c1d7f6855768f843c420ebb36eda97']).unpack1('H*'), ], 'data' => Eth::Abi.encode(['string'], ['data:,test-log-2']).unpack1('H*'), diff --git a/spec/models/ethscription_transfer_spec.rb b/spec/models/ethscription_transfer_spec.rb new file mode 100644 index 0000000..c0af8d4 --- /dev/null +++ b/spec/models/ethscription_transfer_spec.rb @@ -0,0 +1,152 @@ +require 'rails_helper' +require 'ethscription_test_helper' + +RSpec.describe EthscriptionTransfer, type: :model do + before do + allow(EthTransaction).to receive(:esip3_enabled?).and_return(true) + allow(EthTransaction).to receive(:esip5_enabled?).and_return(true) + allow(EthTransaction).to receive(:esip2_enabled?).and_return(true) + allow(EthTransaction).to receive(:esip1_enabled?).and_return(true) + end + + context 'when an ethscription is transferred' do + it 'handles a single transfer' do + tx = EthscriptionTestHelper.create_eth_transaction( + input: "data:,test", + from: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", + to: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", + logs: [] + ) + + ethscription = tx.ethscription + + EthscriptionTestHelper.create_eth_transaction( + from: "0xC2172a6315c1D7f6855768F843c420EbB36eDa97", + to: "0x104a84b87e1e7054c48b63077b8b7ccd62de9260", + input: ethscription.transaction_hash, + logs: [ + { + 'topics' => [ + EthTransaction::Esip1EventSig, + Eth::Abi.encode(['address'], ['0xc2172a6315c1d7f6855768f843c420ebb36eda97']).unpack1('H*'), + Eth::Abi.encode(['address'], ['0x104a84b87e1e7054c48b63077b8b7ccd62de9260']).unpack1('H*'), + ], + 'data' => Eth::Abi.encode(['bytes32'], [ethscription.transaction_hash]).unpack1('H*'), + 'logIndex' => 1.to_s(16), + 'address' => '0xe7dfe249c262a6a9b57651782d57296d2e4bccc9' + } + ] + ) + + ethscription.reload + + expect(ethscription.current_owner).to eq("0x104a84b87e1e7054c48b63077b8b7ccd62de9260") + end + + it 'handles invalid transfers' do + tx = EthscriptionTestHelper.create_eth_transaction( + input: 'data:,{"p":"erc-20","op":"mint","tick":"gwei","id":"6359","amt":"1000"}', + from: "0x9C80cb4b2c8311C3070f62C9e9B4f40C43291E8d", + to: "0x9C80cb4b2c8311C3070f62C9e9B4f40C43291E8d", + tx_hash: '0x6a8f9706637f16c9a93a7bac072bbb291530d9d59f1eba43e28fb5bc2cf12a22' + ) + + eths = tx.ethscription + + EthscriptionTestHelper.create_eth_transaction( + input: '0x6a8f9706637f16c9a93a7bac072bbb291530d9d59f1eba43e28fb5bc2cf12a22', + from: "0xD729A94d6366a4fEac4A6869C8b3573cEe4701A9", + to: "0x0000000000000000000000000000000000000000", + ) + + eths.reload + + expect(eths.current_owner).to eq("0x9C80cb4b2c8311C3070f62C9e9B4f40C43291E8d".downcase) + end + + it 'handles a sequence of transfers' do + + tx = EthscriptionTestHelper.create_eth_transaction( + input: 'data:,{"p":"erc-20","op":"mint","tick":"gwei","id":"6359","amt":"1000"}', + from: "0x9C80cb4b2c8311C3070f62C9e9B4f40C43291E8d", + to: "0x9C80cb4b2c8311C3070f62C9e9B4f40C43291E8d", + tx_hash: '0x6a8f9706637f16c9a93a7bac072bbb291530d9d59f1eba43e28fb5bc2cf12a22' + ) + + eths = tx.ethscription + + EthscriptionTestHelper.create_eth_transaction( + input: '0x6a8f9706637f16c9a93a7bac072bbb291530d9d59f1eba43e28fb5bc2cf12a22', + from: "0x9C80cb4b2c8311C3070f62C9e9B4f40C43291E8d", + to: "0x36442bda6780c95113d7c38dd17cdd94be611de8", + ) + + EthscriptionTestHelper.create_eth_transaction( + input: '0x6a8f9706637f16c9a93a7bac072bbb291530d9d59f1eba43e28fb5bc2cf12a22', + from: "0x36442bda6780c95113d7c38dd17cdd94be611de8", + to: "0xD729A94d6366a4fEac4A6869C8b3573cEe4701A9", + ) + + eths.reload + + expect(eths.current_owner).to eq("0xD729A94d6366a4fEac4A6869C8b3573cEe4701A9".downcase) + expect(eths.previous_owner).to eq("0x36442bda6780c95113d7c38dd17cdd94be611de8".downcase) + + EthscriptionTestHelper.create_eth_transaction( + input: "0xccad70f16a8f9706637f16c9a93a7bac072bbb291530d9d59f1eba43e28fb5bc2cf12a22", + from: "0x8558dB5F3f9201492028fad05087B6a1d9C11273", + to: "0xD729A94d6366a4fEac4A6869C8b3573cEe4701A9", + logs: [ + { + 'topics' => [ + EthTransaction::Esip1EventSig, + Eth::Abi.encode(['address'], ['0x8558dB5F3f9201492028fad05087B6a1d9C11273']).unpack1('H*'), + Eth::Abi.encode(['bytes32'], ['0x6A8F9706637F16C9A93A7BAC072BBB291530D9D59F1EBA43E28FB5BC2CF12A22']).unpack1('H*'), + ], + 'logIndex' => 214.to_s(16), + 'address' => '0xd729a94d6366a4feac4a6869c8b3573cee4701a9' + } + ] + ) + + eths.reload + + expect(eths.current_owner).to eq("0x8558dB5F3f9201492028fad05087B6a1d9C11273".downcase) + expect(eths.previous_owner).to eq("0xd729a94d6366a4feac4a6869c8b3573cee4701a9".downcase) + + EthscriptionTestHelper.create_eth_transaction( + input: "0x6a8f9706637f16c9a93a7bac072bbb291530d9d59f1eba43e28fb5bc2cf12a22", + from: "0x8558dB5F3f9201492028fad05087B6a1d9C11273", + to: "0x57b8792c775D34Aa96092400983c3e112fCbC296", + ) + + eths.reload + + expect(eths.current_owner).to eq("0x57b8792c775D34Aa96092400983c3e112fCbC296".downcase) + expect(eths.previous_owner).to eq("0x8558dB5F3f9201492028fad05087B6a1d9C11273".downcase) + + EthscriptionTestHelper.create_eth_transaction( + input: "0x6a8f9706637f16c9a93a7bac072bbb291530d9d59f1eba43e28fb5bc2cf12a22", + from: "0x8558dB5F3f9201492028fad05087B6a1d9C11273", + to: "0x57b8792c775D34Aa96092400983c3e112fCbC296", + logs: [ + { + 'topics' => [ + EthTransaction::Esip2EventSig, + Eth::Abi.encode(['address'], ['0x8558dB5F3f9201492028fad05087B6a1d9C11273']).unpack1('H*'), + Eth::Abi.encode(['address'], ['0x8D5b48934c0C408ADC25F14174c7307922F6Aa60']).unpack1('H*'), + Eth::Abi.encode(['bytes32'], ['6A8F9706637F16C9A93A7BAC072BBB291530D9D59F1EBA43E28FB5BC2CF12A22']).unpack1('H*'), + ], + 'logIndex' => 543.to_s(16), + 'address' => '0x57b8792c775d34aa96092400983c3e112fcbc296' + } + ] + ) + + eths.reload + + expect(eths.current_owner).to eq("0x8d5b48934c0c408adc25f14174c7307922f6aa60".downcase) + expect(eths.previous_owner).to eq("0x57b8792c775D34Aa96092400983c3e112fCbC296".downcase) + end + end +end