module TxCatcher class Transaction < Sequel::Model plugin :validation_helpers one_to_many :deposits attr_accessor :manual_rpc_request def self.find_or_create_from_rpc(txid) if tx = self.where(txid: txid).first tx.update_block_height! tx else self.create_from_rpc(txid) end end def self.create_from_rpc(txid) raise BitcoinRPC::NoTxIndexErorr, "Cannot create transaction from RPC request, (txid: #{txid}) please use -txindex and don't use pruning" unless TxCatcher.rpc_node.txindex_enabled? if tx_from_rpc = TxCatcher.rpc_node.getrawtransaction(txid, 1) tx = self.new(hex: tx_from_rpc["hex"], txid: txid) tx.manual_rpc_request = true tx.update_block_height(confirmations: tx_from_rpc["confirmations"]) tx.save tx end end def log_the_catch! manual_rpc_request_caption = (self.manual_rpc_request ? " (fetched via a manual RPC request) " : " ") LOGGER.report "tx #{self.txid} caught#{manual_rpc_request_caption}and saved to DB (id: #{self.id}), deposits (outputs):" self.deposits.each do |d| LOGGER.report " id: #{d.id}, addr: #{d.address.address}, amount: #{CryptoUnit.new(Config["currency"], d.amount, from_unit: :primary).to_standart}" end end # Updates only those transactions that have changed def self.update_all(transactions) transactions_to_update = transactions.select { |t| !t.column_changes.empty? } transactions_to_update.each(&:save) return transactions_to_update.map(&:id) end def before_validation return if !self.new? || (!self.deposits.empty? && !self.rbf?) assign_transaction_attrs self.tx_hash["vout"].uniq { |out| out["n"] }.each do |out| amount = CryptoUnit.new(Config["currency"], out["value"], from_unit: :standart).to_i if out["value"] address = out["scriptPubKey"]["addresses"]&.first # Do not create a new deposit unless it actually makes sense to create one if rbf? self.rbf_previous_transaction.deposits.each { |d| self.deposits << d } elsif address && amount && amount > 0 self.deposits << Deposit.new(amount: amount, address_string: address) end end end def before_create self.created_at = Time.now end def after_create self.deposits.each do |d| d.transaction_id = self.id if self.rbf? d.rbf_transaction_ids ||= [] d.rbf_transaction_ids.push(self.rbf_previous_transaction.id) d.rbf_transaction_ids = d.rbf_transaction_ids.uniq end d.save end self.rbf_previous_transaction&.update(rbf_next_transaction_id: self.id) self.log_the_catch! end def tx_hash @tx_hash ||= TxCatcher.rpc_node.decoderawtransaction(self.hex) end alias :parse_transaction :tx_hash def confirmations if self.block_height TxCatcher.current_block_height - self.block_height + 1 else 0 end end def update_block_height(confirmations: nil) return false if self.block_height if TxCatcher.rpc_node.txindex_enabled? || !confirmations.nil? update_block_height_from_rpc(confirmations: confirmations) else self.update_block_height_from_latest_blocks end end def update_block_height!(confirmations: nil) return false if self.block_height self.update_block_height(confirmations: confirmations) self.save if self.column_changed?(:block_height) end # Checks the last n blocks to see if current transaction has been included in any of them, # This is for cases when -txindex is not enabled and you can't make an RPC query for a particular # txid, which would be more reliable. def update_block_height_from_latest_blocks blocks = TxCatcher.rpc_node.get_blocks(blocks_to_check_for_inclusion_if_unconfirmed) unless blocks for block in blocks.values do if block["tx"] && block["tx"].include?(self.txid) LOGGER.report "tx #{self.txid} block height updated to #{block["height"]}" self.block_height = block["height"].to_i return block["height"].to_i end end end # Directly queries the RPC, fetches transaction confirmations number and calculates # the block_height. Of confirmations number is provided, doesn't do the RPC request # (used in Transaction.create_from_rpc). def update_block_height_from_rpc(confirmations: nil) begin confirmations ||= TxCatcher.rpc_node.getrawtransaction(self.txid, 1)["confirmations"] self.block_height = confirmations && confirmations > 0 ? TxCatcher.current_block_height - confirmations + 1 : nil rescue BitcoinRPC::JSONRPCError => e if e.message.include?("No such mempool or blockchain transaction") && self.rbf? LOGGER.report "tx #{self.txid} is an RBF transcation with a lower fee, bitcoin RPC says it's not in the mempool anymore. No need to check for confirmations", :warn else raise e end end end # This calculates the approximate number of blocks to check. # So, for example, if transaction is less than 10 minutes old, # there's probably no reason to try and check more than 2-3 blocks back. # However, to make absolute sure, we always bump up this number by 10 blocks. # Over larger periods of time, the avg block per minute value should even out, so # it's probably going to be fine either way. def blocks_to_check_for_inclusion_if_unconfirmed(limit=TxCatcher::Config[:max_blocks_in_memory]) return false if self.block_height created_minutes_ago = ((self.created_at - Time.now).to_i/60) blocks_to_check = (created_minutes_ago / 10).abs + 10 blocks_to_check = limit if blocks_to_check > limit blocks_to_check end def rbf? return true if self.rbf_previous_transaction_id # 1. Find transactions that are like this one (inputs, outputs). previous_unmarked_transactions = Transaction.where(inputs_outputs_hash: self.inputs_outputs_hash, block_height: nil, rbf_next_transaction_id: nil) .exclude(id: self.id) .order(Sequel.desc(:created_at)).eager(:deposits).to_a.select { |t| !t.deposits.empty? } unless previous_unmarked_transactions.empty? @rbf_previous_transaction = previous_unmarked_transactions.first self.rbf_previous_transaction_id = @rbf_previous_transaction.id true else false end end def rbf_previous_transaction @rbf_previous_transaction ||= Transaction.where(id: self.rbf_previous_transaction_id).first end def rbf_next_transaction @rbf_next_transaction ||= Transaction.where(id: self.rbf_next_transaction_id).first end def input_hexes @input_hexes ||= self.tx_hash["vin"].select { |input| !input["scriptSig"].nil? }.map { |input| input["scriptSig"]["hex"] }.compact.sort end def output_addresses @output_addresses ||= self.tx_hash["vout"].map { |output| output["scriptPubKey"]["addresses"]&.join(",") }.compact.sort end # Sometimes, even though an RBF transaction with higher fee was broadcasted, # miners accept the lower-fee transaction instead. However, in txcatcher database, the # deposits are already associated with the latest transaction. In this case, # we need to find the deposits in the DB set their transaction_id field to current transaction id. def force_deposit_association_on_rbf! tx = self while tx && tx.deposits.empty? do tx = tx.rbf_next_transaction end tx.deposits.each do |d| d.rbf_transaction_ids.delete(self.id) d.rbf_transaction_ids.push(d.transaction_id) d.transaction_id = self.id d.save end end def to_json self.tx_hash.merge(confirmations: self.confirmations, block_height: self.block_height).to_json end def assign_transaction_attrs self.txid = self.tx_hash["txid"] unless self.txid self.block_height = self.tx_hash["block_height"] unless self.block_height # In order to be able to identify RBF - those are normally transactions with # identical inputs and outputs - we hash inputs and outputs that hash serves # as an identifier that we store in our DB and thus can search all # previous transactions which the current transaction might be an RBF transaction to. # # A few comments: # # 1. Although an RBF transaction may techinically have different outputs as per # protocol specification, it is true in most cases that outputs will also be # the same (that's how most wallets implement RBF). Thus, # we're also incorporating outputs into the hashed value. # # 2. For inputs, we're using input hexes, because pruned bitcoin-core # doesn't provide addresses. self.inputs_outputs_hash ||= Digest::SHA256.hexdigest((self.input_hexes + self.output_addresses).join("")) end private def validate super validates_unique :txid errors.add(:base, "Duplicate transaction listed as RBF") if self.rbf_previous_transaction&.txid && self&.txid && self.rbf_previous_transaction&.txid == self&.txid errors.add(:base, "No outputs for this transaction") if !self.rbf? && self.deposits.empty? end end end