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 +4 -4
- data/VERSION +1 -1
- data/lib/txcatcher.rb +1 -0
- data/lib/txcatcher/catcher.rb +17 -2
- data/lib/txcatcher/models/address.rb +16 -0
- data/lib/txcatcher/models/deposit.rb +6 -0
- data/lib/txcatcher/models/transaction.rb +98 -9
- data/lib/txcatcher/server.rb +27 -7
- data/spec/cleaner_spec.rb +5 -3
- data/spec/logger_spec.rb +1 -1
- data/spec/models/transaction_spec.rb +50 -2
- data/spec/spec_helper.rb +1 -0
- data/txcatcher.gemspec +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: f29602463f10ef91c91d32eef97b9a587f9005b7e63c1d4d5fb13585299f3c82
|
4
|
+
data.tar.gz: 0b698f2d3528a4ba5395828079904fdf67574d0bdea8660c7583092cf1f903e1
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 2e1a21f9973c1cf4703228958522ffe1d4dd7e7a641aa3cdcbda0df8d825552833bfe7e3d926672d3de8565ab5b85d049fe61fcb70b5bf748e41124a9e1d74ec
|
7
|
+
data.tar.gz: 5b9613759bd60fc5a36c2b7a8d7647cc81400928224eaf099abaae66ea4f2b72489d267f1e01df22eee5d49efb7830a7b3609fc07a02b5c0d6c4c618d70b6bfd
|
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
0.2.
|
1
|
+
0.2.6
|
data/lib/txcatcher.rb
CHANGED
data/lib/txcatcher/catcher.rb
CHANGED
@@ -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
|
-
|
88
|
-
|
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
|
-
|
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
|
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.
|
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
|
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
|
-
|
160
|
+
TxCatcher.rpc_node.decoderawtransaction(self.hex)
|
87
161
|
end
|
88
162
|
|
89
163
|
def assign_transaction_attrs
|
90
|
-
self.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
|
185
|
+
errors.add(:base, "No outputs for this transaction") if !self.rbf? && self.deposits.empty?
|
97
186
|
end
|
98
187
|
|
99
188
|
end
|
data/lib/txcatcher/server.rb
CHANGED
@@ -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.
|
43
|
-
|
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.
|
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
|
7
|
-
|
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
data/txcatcher.gemspec
CHANGED