txcatcher 0.2.4 → 0.2.6

Sign up to get free protection for your applications and to get access to all the features.
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