txcatcher 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b03f8177e761f95c4e004bdee74aa320bb19495799a2752a0fc0cf476840025a
4
- data.tar.gz: 2604d5b41d3d7f8c750c91d06abfaa453bc7d15326ad96484cada3f3bd543d4c
3
+ metadata.gz: f29602463f10ef91c91d32eef97b9a587f9005b7e63c1d4d5fb13585299f3c82
4
+ data.tar.gz: 0b698f2d3528a4ba5395828079904fdf67574d0bdea8660c7583092cf1f903e1
5
5
  SHA512:
6
- metadata.gz: 4cde58e8673c4b62e4acde2cb541fb1dd5e1a4d1fda9b07d10b259ebed15c3191508098561e3cdead7e49858ff3473458d25c01ed95791581e5f8012106babda
7
- data.tar.gz: 12aabc4580072213a7010cb9c7d271aaf77b6829a03a2157420850f7ec1ecb1a7e627f37cea1f74eb64084a984c61c5586ba19d3ae97384ede4c36ef4ce0ca52
6
+ metadata.gz: 2e1a21f9973c1cf4703228958522ffe1d4dd7e7a641aa3cdcbda0df8d825552833bfe7e3d926672d3de8565ab5b85d049fe61fcb70b5bf748e41124a9e1d74ec
7
+ data.tar.gz: 5b9613759bd60fc5a36c2b7a8d7647cc81400928224eaf099abaae66ea4f2b72489d267f1e01df22eee5d49efb7830a7b3609fc07a02b5c0d6c4c618d70b6bfd
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.2.4
1
+ 0.2.6
data/lib/txcatcher.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require 'rubygems'
2
+ require 'digest'
2
3
  require 'yaml'
3
4
  require 'json'
4
5
  require 'thread'
@@ -84,8 +84,16 @@ module TxCatcher
84
84
  LOGGER.report "received tx hex: #{txhex[0..50]}..."
85
85
  @queue["rawtx"] << ( Proc.new {
86
86
  tx = TxCatcher::Transaction.new(hex: txhex)
87
- tx.save
88
- LOGGER.report "tx #{tx.txid} saved (id: #{tx.id}), deposits (outputs):"
87
+ begin
88
+ LOGGER.report "tx #{tx.txid} caught (id: #{tx.id}), deposits (outputs):"
89
+ tx.save
90
+ rescue Sequel::ValidationFailed => e
91
+ if tx.errors[:txid].include?("is already taken")
92
+ LOGGER.report " it's already in DB, no need to save it!"
93
+ else
94
+ raise e
95
+ end
96
+ end
89
97
  tx.deposits.each do |d|
90
98
  LOGGER.report " id: #{d.id}, addr: #{d.address.address}, amount: #{CryptoUnit.new(Config["currency"], d.amount, from_unit: :primary).to_standart}"
91
99
  end
@@ -98,8 +106,15 @@ module TxCatcher
98
106
  height = TxCatcher.current_block_height = block_hash["height"].to_i
99
107
  LOGGER.report "*** Block #{height} mined, transactions received:\n #{transactions.join(" \n")}"
100
108
  @queue["hashblock"] << ( Proc.new {
109
+ existing_transactions = Transaction.where(txid: transactions).map(&:txid)
110
+ LOGGER.report "*** Block #{height} mined, transactions received:\n #{transactions.join(" \n")}"
101
111
  Transaction.where(txid: transactions).update(block_height: height)
102
112
  })
113
+ # Update RBF transactions and deposits if a transaction with lower fee (no associated deposit) got
114
+ # accidentally confirmed.
115
+ TxCatcher::Transaction.where(block_height: height).exclude(rbf_next_transaction_id: nil).each do |t|
116
+ t.force_deposit_association_on_rbf!
117
+ end
103
118
  end
104
119
 
105
120
  end # class Catcher
@@ -7,6 +7,22 @@ module TxCatcher
7
7
  CryptoUnit.new(Config["currency"], self.received, from_unit: :primary).to_standart
8
8
  end
9
9
 
10
+ def self.find_or_catch_and_create(a)
11
+ # Even if there are not transactions to this address yet, we still create it,
12
+ # because calling this method means someone is interested in this address and we need
13
+ # to track it and maybe force catching it not just through mempool and ZeroMQ (see catcher.rb),
14
+ # but also directly through querying RPC (slow, but at least we won't miss it).
15
+ Address.find_or_create(address: a)
16
+
17
+ if addr&.deposits.empty? && addr.created_at < (Time.now - 3600)
18
+ # The address is in the DB, which means someone has been checking it,
19
+ # but no deposits were associated with it and it's been more than 1 hour
20
+ # since someone first got interested. Let's query the RPC directly, see if there were any transactions to this
21
+ # address.
22
+
23
+ end
24
+ end
25
+
10
26
  end
11
27
 
12
28
 
@@ -6,6 +6,12 @@ module TxCatcher
6
6
 
7
7
  attr_accessor :address_string
8
8
 
9
+ plugin :serialization, :json, :rbf_transaction_ids
10
+
11
+ def before_save
12
+ self.rbf_transaction_ids
13
+ end
14
+
9
15
  def before_save
10
16
  if @address_string
11
17
  self.address = Address.find_or_create(address: @address_string)
@@ -5,6 +5,23 @@ module TxCatcher
5
5
  plugin :validation_helpers
6
6
  one_to_many :deposits
7
7
 
8
+ def self.find_or_catch(txid)
9
+ if tx = self.where(txid: txid).first
10
+ tx
11
+ else
12
+ self.catch(txid)
13
+ end
14
+ end
15
+
16
+ def self.catch(txid)
17
+ if txhex = TxCatcher.rpc_node.getrawtransaction(txid)
18
+ LOGGER.report "received tx hex: #{txhex[0..50]}... (fetched via manual RPC request)"
19
+ tx = self.new(hex: txhex)
20
+ tx.save
21
+ tx
22
+ end
23
+ end
24
+
8
25
  # Updates only those transactions that have changed
9
26
  def self.update_all(transactions)
10
27
  transactions_to_update = transactions.select { |t| !t.column_changes.empty? }
@@ -13,14 +30,15 @@ module TxCatcher
13
30
  end
14
31
 
15
32
  def before_validation
16
- return if !self.new? || !self.deposits.empty?
17
- parse_transaction
33
+ return if !self.new? || (!self.deposits.empty? && !self.rbf?)
18
34
  assign_transaction_attrs
19
- @tx_hash["vout"].uniq { |out| out["n"] }.each do |out|
35
+ self.tx_hash["vout"].uniq { |out| out["n"] }.each do |out|
20
36
  amount = CryptoUnit.new(Config["currency"], out["value"], from_unit: :standart).to_i if out["value"]
21
37
  address = out["scriptPubKey"]["addresses"]&.first
22
38
  # Do not create a new deposit unless it actually makes sense to create one
23
- if address && amount && amount > 0
39
+ if rbf?
40
+ self.rbf_previous_transaction.deposits.each { |d| self.deposits << d }
41
+ elsif address && amount && amount > 0
24
42
  self.deposits << Deposit.new(amount: amount, address_string: address)
25
43
  end
26
44
  end
@@ -32,13 +50,19 @@ module TxCatcher
32
50
 
33
51
  def after_create
34
52
  self.deposits.each do |d|
35
- d.transaction = self
53
+ d.transaction_id = self.id
54
+ if self.rbf?
55
+ d.rbf_transaction_ids ||= []
56
+ d.rbf_transaction_ids.push(self.rbf_previous_transaction.id)
57
+ d.rbf_transaction_ids = d.rbf_transaction_ids.uniq
58
+ end
36
59
  d.save
60
+ self.rbf_previous_transaction&.update(rbf_next_transaction_id: self.id)
37
61
  end
38
62
  end
39
63
 
40
64
  def tx_hash
41
- @tx_hash || parse_transaction
65
+ @tx_hash ||= parse_transaction
42
66
  end
43
67
 
44
68
  def confirmations
@@ -80,20 +104,85 @@ module TxCatcher
80
104
  blocks_to_check
81
105
  end
82
106
 
107
+ def rbf?
108
+ return true if self.rbf_previous_transaction_id
109
+ # 1. Find transactions that are like this one (inputs, outputs).
110
+ previous_unmarked_transactions = Transaction.where(inputs_outputs_hash: self.inputs_outputs_hash, block_height: nil, rbf_next_transaction_id: nil)
111
+ .exclude(id: self.id)
112
+ .order(Sequel.desc(:created_at)).eager(:deposits).to_a.select { |t| !t.deposits.empty? }
113
+ unless previous_unmarked_transactions.empty?
114
+ @rbf_previous_transaction = previous_unmarked_transactions.first
115
+ self.rbf_previous_transaction_id = @rbf_previous_transaction.id
116
+ true
117
+ else
118
+ false
119
+ end
120
+ end
121
+
122
+ def rbf_previous_transaction
123
+ @rbf_previous_transaction ||= Transaction.where(id: self.rbf_previous_transaction_id).first
124
+ end
125
+
126
+ def rbf_next_transaction
127
+ @rbf_next_transaction ||= Transaction.where(id: self.rbf_next_transaction_id).first
128
+ end
129
+
130
+ def input_hexes
131
+ @input_hexes ||= self.tx_hash["vin"].map { |input| input["scriptSig"]["hex"] }.compact.sort
132
+ end
133
+
134
+ def output_addresses
135
+ @output_addresses ||= self.tx_hash["vout"].map { |output| output["scriptPubKey"]["addresses"]&.join(",") }.compact.sort
136
+ end
137
+
138
+ # Sometimes, even though an RBF transaction with higher fee was broadcasted,
139
+ # miners accept one the lower-fee transactions instead. However, in txcatcher database, the
140
+ # deposits are already associated with the latest transaction. In this case,
141
+ # we need to find the deposits in the DB set their transaction_id field to current transaction id.
142
+ def force_deposit_association_on_rbf!
143
+ tx = self
144
+ while tx && tx.deposits.empty? do
145
+ tx = tx.rbf_next_transaction
146
+ end
147
+ tx.deposits.each do |d|
148
+ d.update(transaction_id: self.id)
149
+ end
150
+ end
151
+
152
+ def to_json
153
+ #self.tx_hash.to_json
154
+ self.tx_hash.merge(confirmations: self.confirmations, block_height: self.block_height).to_json
155
+ end
156
+
83
157
  private
84
158
 
85
159
  def parse_transaction
86
- @tx_hash = TxCatcher.rpc_node.decoderawtransaction(self.hex)
160
+ TxCatcher.rpc_node.decoderawtransaction(self.hex)
87
161
  end
88
162
 
89
163
  def assign_transaction_attrs
90
- self.txid = @tx_hash["txid"]
164
+ self.txid = self.tx_hash["txid"] unless self.txid
165
+ # In order to be able to identify RBF - those are normally transactions with
166
+ # identical inputs and outputs - we hash inputs and outputs that hash serves
167
+ # as an identifier that we store in our DB and thus can search all
168
+ # previous transactions which the current transaction might be an RBF transaction to.
169
+ #
170
+ # A few comments:
171
+ #
172
+ # 1. Although an RBF transaction may techinically have different outputs as per
173
+ # protocol specification, it is true in most cases that outputs will also be
174
+ # the same (that's how most wallets implement RBF). Thus,
175
+ # we're also incorporating outputs into the hashed value.
176
+ #
177
+ # 2. For inputs, we're using input hexes, because pruned bitcoin-core
178
+ # doesn't provide addresses.
179
+ self.inputs_outputs_hash ||= Digest::SHA256.hexdigest((self.input_hexes + self.output_addresses).join(""))
91
180
  end
92
181
 
93
182
  def validate
94
183
  super
95
184
  validates_unique :txid
96
- errors.add(:base, "No outputs for this transactions") if self.deposits.empty?
185
+ errors.add(:base, "No outputs for this transaction") if !self.rbf? && self.deposits.empty?
97
186
  end
98
187
 
99
188
  end
@@ -23,6 +23,8 @@ module TxCatcher
23
23
  utxo(path)
24
24
  elsif path.start_with? "/addr/"
25
25
  address(path)
26
+ elsif path.start_with? "/tx/"
27
+ tx(path)
26
28
  elsif path.start_with? "/tx/send"
27
29
  broadcast_tx(params["rawtx"])
28
30
  elsif path.start_with? "/feerate"
@@ -39,8 +41,8 @@ module TxCatcher
39
41
  path = path.sub(/\?.*/, '').split("/").delete_if { |i| i.empty? }
40
42
  addr = path.last
41
43
 
42
- address = Address.where(address: addr).first
43
- if address
44
+ address = Address.find_or_create(address: addr)
45
+ unless address.deposits.empty?
44
46
  deposits = Deposit.where(address_id: address.id)
45
47
  deposits_count = deposits.count
46
48
  deposits = deposits.eager(:transaction).limit(params["limit"] || 100)
@@ -49,14 +51,15 @@ module TxCatcher
49
51
  t = d.transaction
50
52
  t.update(protected: true) unless t.protected
51
53
  t.check_block_height!(dont_save: true)
52
-
53
- {
54
+ result = {
54
55
  txid: t.txid,
55
56
  amount: d.amount_in_btc,
56
57
  satoshis: d.amount,
57
58
  confirmations: t.confirmations,
58
- block_height: t.block_height
59
+ block_height: t.block_height,
59
60
  }
61
+ result.merge!({ rbf: "yes", rbf_previous_txid: t.rbf_previous_transaction.txid }) if t.rbf?
62
+ result
60
63
  end
61
64
  return [200, {}, { address: address.address, received: address.received, deposits_count: deposits_count, deposits_shown: deposits.size, deposits: deposits }.to_json]
62
65
  else
@@ -69,8 +72,8 @@ module TxCatcher
69
72
  path.pop
70
73
  addr = path.last
71
74
 
72
- address = Address.where(address: addr).first
73
- return [200, {}, "{}"] unless address
75
+ address = Address.find_or_create(address: addr)
76
+ return [200, {}, "{}"] unless address.deposits.empty?
74
77
  deposits = Deposit.where(address_id: address.id).limit(params["limit"] || 100).eager(:transaction)
75
78
 
76
79
  transactions = deposits.map { |d| d.transaction }
@@ -91,6 +94,10 @@ module TxCatcher
91
94
  outs.map! do |out|
92
95
  out["confirmations"] = t.confirmations || 0
93
96
  out["txid"] = t.txid
97
+ if t.rbf?
98
+ out["rbf"] = "yes"
99
+ out["rbf_previous_txid"] = t.rbf_previous_transaction.txid
100
+ end
94
101
  out
95
102
  end
96
103
  outs
@@ -99,6 +106,19 @@ module TxCatcher
99
106
  return [200, {}, utxos.to_json]
100
107
  end
101
108
 
109
+ def tx(path)
110
+ path = path.sub(/\?.*/, '').split("/").delete_if { |i| i.empty? }
111
+ txid = path.last
112
+ tx = Transaction.find_or_catch(txid)
113
+ tx = Transaction.where(txid: txid).first
114
+
115
+ if tx && !tx.deposits.empty?
116
+ return [200, {}, tx.to_json]
117
+ else
118
+ return [404, {}, "Transaction not found"]
119
+ end
120
+ end
121
+
102
122
  def broadcast_tx(txhex)
103
123
  TxCatcher.rpc_node.sendrawtransaction(txhex)
104
124
  tx = TxCatcher.rpc_node.decoderawtransaction(txhex)
data/spec/cleaner_spec.rb CHANGED
@@ -9,7 +9,7 @@ require_relative '../lib/txcatcher/cleaner'
9
9
  RSpec.describe TxCatcher::Cleaner do
10
10
 
11
11
  before(:each) do
12
- allow(TxCatcher.rpc_node).to receive(:decoderawtransaction).and_return({ "vout" => []})
12
+ # allow(TxCatcher.rpc_node).to receive(:decoderawtransaction).and_return({ "vout" => []})
13
13
  end
14
14
 
15
15
  it "doesn't clean anything if transaction count is below threshold" do
@@ -43,7 +43,7 @@ RSpec.describe TxCatcher::Cleaner do
43
43
  end
44
44
 
45
45
  it "protects checked transactions" do
46
- protected_txs = create_transactions(3, { protected: true })
46
+ protected_txs = create_transactions(3, { protected: true, prefix: "protected" })
47
47
  regular_txs = create_transactions(15)
48
48
  clean_transactions
49
49
  expect(TxCatcher::Transaction.count).to eq(12)
@@ -53,9 +53,11 @@ RSpec.describe TxCatcher::Cleaner do
53
53
 
54
54
 
55
55
  def create_transactions(n, attrs={})
56
+ prefix = attrs.delete(:prefix)
56
57
  (1..n).to_a.map do |i|
57
58
  d = TxCatcher::Deposit.new(address_string: "addr#{i}", amount: 0)
58
- tx = TxCatcher::Transaction.new(attrs)
59
+ tx = TxCatcher::Transaction.new(attrs.merge(hex: File.read(File.dirname(__FILE__) + "/fixtures/transaction.txt").strip))
60
+ tx.txid = "#{prefix}_tx#{i}"
59
61
  tx.deposits << d
60
62
  tx.save
61
63
  tx
data/spec/logger_spec.rb CHANGED
@@ -49,7 +49,7 @@ RSpec.describe TxCatcher::LOGGER do
49
49
  end
50
50
 
51
51
  it "converts Exception into a text for logging" do
52
- expect($stdout).to receive(:print).with("StandardError\n[no backtrace]\n")
52
+ expect($stdout).to receive(:print).with("StandardError - StandardError\n[no backtrace]\n")
53
53
  expect($stdout).to receive(:print).with("\n\n")
54
54
  TxCatcher::LOGGER.report StandardError.new, :error
55
55
  end
@@ -3,8 +3,8 @@ require_relative '../spec_helper'
3
3
  RSpec.describe TxCatcher::Transaction do
4
4
 
5
5
  class TxCatcher::Transaction
6
- def assign_transaction_attrs
7
- self.txid = @tx_hash["txid"] unless self.txid
6
+ def assign_tx_hash(h)
7
+ @tx_hash = h
8
8
  end
9
9
  end
10
10
 
@@ -50,4 +50,52 @@ RSpec.describe TxCatcher::Transaction do
50
50
  ).to eq([transaction2.id, transaction3.id])
51
51
  end
52
52
 
53
+ it "handles an RBF transaction" do
54
+ deposit_ids = @transaction.deposits.map(&:id)
55
+ rbf_tx = TxCatcher::Transaction.new(hex: @hextx)
56
+ rbf_tx_hash = @transaction.tx_hash
57
+ rbf_tx_hash["txid"] = "rbftxid1"
58
+ rbf_tx_hash["locktime"] = "1"
59
+ rbf_tx.assign_tx_hash(rbf_tx_hash)
60
+ rbf_tx.save
61
+ expect(@transaction.reload.rbf_next_transaction_id).to eq(rbf_tx.id)
62
+ expect(rbf_tx.rbf_previous_transaction_id).to eq(@transaction.id)
63
+ expect(@transaction.deposits).to be_empty
64
+ expect(rbf_tx.reload.deposits.map(&:id)).to eq(deposit_ids)
65
+ rbf_tx.deposits.each do |d|
66
+ expect(d.rbf_transaction_ids).to eq([@transaction.id])
67
+ end
68
+
69
+ rbf_tx2 = TxCatcher::Transaction.new(hex: @hextx)
70
+ rbf_tx_hash["txid"] = "rbftxid2"
71
+ rbf_tx_hash["locktime"] = "2"
72
+ rbf_tx2.assign_tx_hash(rbf_tx_hash)
73
+ rbf_tx2.save
74
+ expect(rbf_tx.reload.rbf_next_transaction_id).to eq(rbf_tx2.id)
75
+ expect(rbf_tx2.rbf_previous_transaction_id).to eq(rbf_tx.id)
76
+ expect(rbf_tx.deposits).to be_empty
77
+ expect(rbf_tx2.reload.deposits.map(&:id)).to eq(deposit_ids)
78
+ rbf_tx2.deposits.each do |d|
79
+ expect(d.rbf_transaction_ids).to eq([@transaction.id, rbf_tx.id])
80
+ end
81
+ end
82
+
83
+ it "forces deposit association with itself when confirmed, but another RBF transaction is associated with the deposit" do
84
+ deposit_ids = @transaction.deposits.map(&:id)
85
+ rbf_tx = TxCatcher::Transaction.new(hex: @hextx)
86
+ rbf_tx_hash = @transaction.tx_hash
87
+ rbf_tx_hash["txid"] = "rbftxid1"
88
+ rbf_tx_hash["locktime"] = "1"
89
+ rbf_tx.assign_tx_hash(rbf_tx_hash)
90
+ rbf_tx.save
91
+
92
+ @transaction.reload.force_deposit_association_on_rbf!
93
+ expect(rbf_tx.reload.deposits).to be_empty
94
+ expect(@transaction.reload.deposits.map(&:id)).to eq(deposit_ids)
95
+ @transaction.deposits.each do |d|
96
+ expect(d.rbf_transaction_ids).to eq([@transaction.id])
97
+ end
98
+
99
+ end
100
+
53
101
  end
data/spec/spec_helper.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require 'rubygems'
2
+ require 'digest'
2
3
  require 'yaml'
3
4
  require 'json'
4
5
  require 'crypto-unit'
data/txcatcher.gemspec CHANGED
@@ -1,6 +1,6 @@
1
1
  Gem::Specification.new do |s|
2
2
  s.name = "txcatcher".freeze
3
- s.version = "0.2.4"
3
+ s.version = "0.2.6"
4
4
 
5
5
  s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version=
6
6
  s.require_paths = ["lib".freeze]
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: txcatcher
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.4
4
+ version: 0.2.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Snitko