sibit 0.33.0 → 0.34.1

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.
data/lib/sibit.rb CHANGED
@@ -6,11 +6,12 @@
6
6
  require 'ellipsized'
7
7
  require 'loog'
8
8
  require_relative 'sibit/base58'
9
+ require_relative 'sibit/blockchain'
10
+ require_relative 'sibit/coins'
9
11
  require_relative 'sibit/key'
10
12
  require_relative 'sibit/script'
11
13
  require_relative 'sibit/tx'
12
14
  require_relative 'sibit/txbuilder'
13
- require_relative 'sibit/blockchain'
14
15
  require_relative 'sibit/version'
15
16
 
16
17
  # Sibit main class.
@@ -19,8 +20,7 @@ require_relative 'sibit/version'
19
20
  # Copyright:: Copyright (c) 2019-2026 Yegor Bugayenko
20
21
  # License:: MIT
21
22
  class Sibit
22
- # Minimum fee we must pay for transaction processing:
23
- MIN_SATOSHI_PER_BYTE = 0.1
23
+ MIN_SATOSHI_PER_BYTE = 1
24
24
 
25
25
  # Constructor.
26
26
  #
@@ -46,7 +46,7 @@ class Sibit
46
46
 
47
47
  # Current price of 1 BTC in USD (or another currency), float returned.
48
48
  def price(currency = 'USD')
49
- raise Error, "Invalid currency #{currency.inspect}" unless /^[A-Z]{3}$/.match?(currency)
49
+ raise(Error, "Invalid currency #{currency.inspect}") unless /^[A-Z]{3}$/.match?(currency)
50
50
  @api.price(currency)
51
51
  end
52
52
 
@@ -59,25 +59,31 @@ class Sibit
59
59
 
60
60
  # Creates Bitcoin address using the private key in Hash160 format.
61
61
  def create(pvt)
62
- raise Error, 'Invalid private key (must be 64 hex chars)' unless /^[0-9a-f]{64}$/.match?(pvt)
62
+ raise(Error, 'Invalid private key (must be 64 hex chars)') unless /^[0-9a-f]{64}$/.match?(pvt)
63
63
  Key.new(pvt).bech32
64
64
  end
65
65
 
66
- # Gets the balance of the address, in satoshi.
67
- def balance(address)
68
- raise Error, "Invalid address #{address.inspect}" unless /^[0-9a-zA-Z]+$/.match?(address)
69
- @api.balance(address)
66
+ # Gets the balance of the address, in satoshi. When +trust+ is greater
67
+ # than zero, only UTXOs with at least that many confirmations are summed,
68
+ # which requires the underlying API to support +utxos+.
69
+ def balance(address, trust: 0)
70
+ raise(Error, "Invalid address #{address.inspect}") unless /^[0-9a-zA-Z]+$/.match?(address)
71
+ unless trust.is_a?(Integer) && !trust.negative?
72
+ raise(Error, "Trust must be a non-negative Integer, got #{trust.inspect}")
73
+ end
74
+ return @api.balance(address) if trust.zero?
75
+ @api.utxos([address]).sum { |u| (u[:confirmations] || 0) >= trust ? u[:value] : 0 }
70
76
  end
71
77
 
72
78
  # Get the height of the block.
73
79
  def height(hash)
74
- raise Error, "Invalid block hash #{hash.inspect}" unless /^[0-9a-f]{64}$/.match?(hash)
80
+ raise(Error, "Invalid block hash #{hash.inspect}") unless /^[0-9a-f]{64}$/.match?(hash)
75
81
  @api.height(hash)
76
82
  end
77
83
 
78
84
  # Get the hash of the next block.
79
85
  def next_of(hash)
80
- raise Error, "Invalid block hash #{hash.inspect}" unless /^[0-9a-f]{64}$/.match?(hash)
86
+ raise(Error, "Invalid block hash #{hash.inspect}") unless /^[0-9a-f]{64}$/.match?(hash)
81
87
  @api.next_of(hash)
82
88
  end
83
89
 
@@ -91,9 +97,13 @@ class Sibit
91
97
  #
92
98
  # If the payment can't be signed (the key is wrong, for example) or the
93
99
  # previous transaction is not found, or there is a network error, or
94
- # any other reason, you will get an exception. In this case, just try again.
95
- # It's safe to try as many times as you need. Don't worry about duplicating
96
- # your transaction, the Bitcoin network will filter duplicates out.
100
+ # any other reason, you will get an exception. You may retry, but only
101
+ # while no earlier attempt could have reached the network: inputs are
102
+ # selected deterministically, so an immediate retry rebuilds a conflicting
103
+ # transaction that the network rejects as a double-spend. Once an earlier
104
+ # attempt might have confirmed, the wallet holds a fresh set of UTXOs and
105
+ # a blind retry may pay the target twice; check the target address (or the
106
+ # previous transaction hash) before re-sending.
97
107
  #
98
108
  # If there are more than 1000 UTXOs in the address where you are trying
99
109
  # to send bitcoins from, this method won't be helpful.
@@ -105,39 +115,47 @@ class Sibit
105
115
  # +change+: the address where the change has to be sent to
106
116
  # +network+: optional network override (:mainnet, :testnet, :regtest)
107
117
  # +price+: optional BTC price in USD (skips API fetch if provided)
108
- def pay(amount, fee, sources, target, change, skip_utxo: [], network: nil, base58: false,
109
- price: nil)
118
+ def pay(
119
+ amount, fee, sources, target, change, skip_utxo: [], network: nil, base58: false,
120
+ price: nil, trust: 0
121
+ )
110
122
  unless amount.is_a?(Integer) || amount.is_a?(String)
111
- raise Error, "The amount #{amount.inspect} must be Integer or String"
123
+ raise(Error, "The amount #{amount.inspect} must be Integer or String")
112
124
  end
113
- raise Error, 'The amount must be positive' if amount.is_a?(Integer) && amount.negative?
114
- raise Error, 'The sources must be an Array' unless sources.is_a?(Array)
115
- raise Error, 'The target must be a String' unless target.is_a?(String)
116
- raise Error, 'The change must be a String' unless change.is_a?(String)
117
- p = price || price('USD')
118
- keys = sources.map do |k|
119
- raise Error, 'Each source private key must be a String' unless k.is_a?(String)
120
- hex = /^[0-9a-f]{64}$/i.match?(k)
121
- wif = /^[5KLc][1-9A-HJ-NP-Za-km-z]{50,51}$/.match?(k)
122
- raise Error, "Invalid private key format: #{k.inspect.ellipsized(8)}" unless hex || wif
123
- Key.new(k, network: network)
125
+ raise(Error, 'The amount must be positive') if amount.is_a?(Integer) && amount.negative?
126
+ raise(Error, 'The sources must be an Array') unless sources.is_a?(Array)
127
+ raise(Error, 'The target must be a String') unless target.is_a?(String)
128
+ raise(Error, 'The change must be a String') unless change.is_a?(String)
129
+ unless trust.is_a?(Integer) && !trust.negative?
130
+ raise(Error, "Trust must be a non-negative Integer, got #{trust.inspect}")
124
131
  end
125
- network = keys.first&.network || :mainnet
126
- sources = keys.to_h do |k|
127
- pub =
128
- if base58
129
- k.base58
130
- else
131
- k.bech32
132
+ p = price || price('USD')
133
+ keys =
134
+ sources.map do |k|
135
+ raise(Error, 'Each source private key must be a String') unless k.is_a?(String)
136
+ wif = /^[5KLc][1-9A-HJ-NP-Za-km-z]{50,51}$/.match?(k)
137
+ unless /^[0-9a-f]{64}$/i.match?(k) || wif
138
+ raise(Error, "Invalid private key format: #{k.inspect.ellipsized(8)}")
132
139
  end
133
- @log.debug("Private key #{k.priv.ellipsized(8).inspect} is public as #{pub}:")
134
- [pub, k.priv]
135
- end
140
+ Key.new(k, network: network)
141
+ end
142
+ network = keys.first&.network || :mainnet
143
+ sources =
144
+ keys.to_h do |k|
145
+ pub =
146
+ if base58
147
+ k.base58
148
+ else
149
+ k.bech32
150
+ end
151
+ @log.debug("Private key #{k.priv.ellipsized(8).inspect} is public as #{pub}:")
152
+ [pub, k.priv]
153
+ end
136
154
  satoshi = satoshi(amount)
137
155
  builder = TxBuilder.new
138
156
  unspent = 0
139
157
  size = 100
140
- utxos = @api.utxos(sources.keys)
158
+ utxos = @api.utxos(sources.keys).sort_by { |u| [u[:hash], u[:index]] }
141
159
  @log.debug("#{utxos.count} UTXOs found, these will be used (value/confirmations at tx_hash):")
142
160
  utxos.each do |utxo|
143
161
  if skip_utxo.include?(utxo[:hash])
@@ -148,6 +166,10 @@ class Sibit
148
166
  @log.debug("UTXO with no confirmations: #{utxo[:hash]}")
149
167
  next
150
168
  end
169
+ if utxo[:confirmations] < trust
170
+ @log.debug("UTXO below trust=#{trust} (#{utxo[:confirmations]} conf): #{utxo[:hash]}")
171
+ next
172
+ end
151
173
  unspent += utxo[:value]
152
174
  builder.input do |i|
153
175
  i.prev_out(utxo[:hash])
@@ -156,34 +178,30 @@ class Sibit
156
178
  i.prev_out_value(utxo[:value])
157
179
  address = Script.new(script_hex(utxo[:script])).address(network)
158
180
  k = sources[address]
159
- raise Error, "UTXO arrived to #{address} is incorrect" unless k
181
+ raise(Error, "UTXO arrived to #{address} is incorrect") unless k
160
182
  i.signature_key(key(k))
161
183
  end
162
184
  size += 180
163
- @log.debug(
164
- " #{num(utxo[:value], p)}/#{utxo[:confirmations]} at #{utxo[:hash]}"
165
- )
185
+ @log.debug(" #{num(utxo[:value], p)}/#{utxo[:confirmations]} at #{utxo[:hash]}")
166
186
  break if unspent > satoshi
167
187
  end
168
188
  if unspent < satoshi
169
- raise Error, "Not enough funds to send #{num(satoshi, p)}, only #{num(unspent, p)} left"
189
+ raise(Error, "Not enough funds to send #{num(satoshi, p)}, only #{num(unspent, p)} left")
170
190
  end
171
191
  f = mfee(fee, size)
172
192
  if f.negative?
173
193
  satoshi += f
174
194
  f = -f
175
195
  end
176
- raise Error, "The fee #{f.abs} covers the entire amount" if satoshi.zero?
177
- raise Error, "The fee #{f.abs} is bigger than the amount #{satoshi}" if satoshi.negative?
196
+ raise(Error, "The fee #{f.abs} covers the entire amount") if satoshi.zero?
197
+ raise(Error, "The fee #{f.abs} is bigger than the amount #{satoshi}") if satoshi.negative?
178
198
  builder.output(satoshi, target)
179
199
  tx = builder.tx(
180
200
  input_value: unspent,
181
201
  leave_fee: true,
182
- extra_fee: [f, (size * MIN_SATOSHI_PER_BYTE).to_i].max,
202
+ extra_fee: [f, Integer(size * MIN_SATOSHI_PER_BYTE)].max,
183
203
  change_address: change
184
204
  )
185
- left = unspent - tx.outputs.sum(&:value)
186
- has_change = tx.out.count > 1
187
205
  @log.debug(
188
206
  [
189
207
  "A new Bitcoin transaction #{tx.hash} prepared:",
@@ -197,12 +215,12 @@ class Sibit
197
215
  end,
198
216
  "Min fee: #{num(MIN_SATOSHI_PER_BYTE, p)} /byte",
199
217
  "Fee requested: #{num(f, p)} as \"#{fee}\"",
200
- "Fee actually paid: #{num(left, p)}",
218
+ "Fee actually paid: #{num(unspent - tx.outputs.sum(&:value), p)}",
201
219
  "Tx size: #{size} bytes",
202
220
  "Unspent: #{num(unspent, p)}",
203
221
  "Amount: #{num(satoshi, p)}",
204
222
  "Target address: #{target}",
205
- ("Change address: #{change}" if has_change)
223
+ ("Change address: #{change}" if tx.out.count > 1)
206
224
  ].flatten.compact.join("\n")
207
225
  )
208
226
  @api.push(tx.to_payload.bth)
@@ -225,8 +243,8 @@ class Sibit
225
243
  # in satoshi. The callback should return non-false if the transaction
226
244
  # found was useful.
227
245
  def scan(start, max: 4)
228
- raise Error, "Invalid block hash #{start.inspect}" unless /^[0-9a-f]{64}$/.match?(start)
229
- raise Error, "The max number must be above zero: #{max}" if max < 1
246
+ raise(Error, "Invalid block hash #{start.inspect}") unless /^[0-9a-f]{64}$/.match?(start)
247
+ raise(Error, "The max number must be above zero: #{max}") if max < 1
230
248
  block = start
231
249
  count = 0
232
250
  json = {}
@@ -243,11 +261,11 @@ class Sibit
243
261
  next
244
262
  end
245
263
  checked = 0
246
- checked_outputs = 0
264
+ outputs = 0
247
265
  json[:txns].each do |t|
248
266
  t[:outputs].each_with_index do |o, i|
249
267
  address = o[:address]
250
- checked_outputs += 1
268
+ outputs += 1
251
269
  hash = "#{t[:hash]}:#{i}"
252
270
  satoshi = o[:value]
253
271
  if yield(address, hash, satoshi)
@@ -258,7 +276,7 @@ class Sibit
258
276
  end
259
277
  count += 1
260
278
  @log.debug(
261
- "Checked #{checked} txns and #{checked_outputs} outputs " \
279
+ "Checked #{checked} txns and #{outputs} outputs " \
262
280
  "in block #{block} (by #{json[:provider]})"
263
281
  )
264
282
  block = json[:next]
@@ -298,10 +316,10 @@ class Sibit
298
316
  def satoshi(amount)
299
317
  return amount if amount.is_a?(Integer)
300
318
  unless amount.is_a?(String)
301
- raise Error, "Amount should either be a String or Integer, #{amount.class.name} provided"
319
+ raise(Error, "Amount should either be a String or Integer, #{amount.class.name} provided")
302
320
  end
303
- return (amount.gsub(/BTC$/, '').to_f * 100_000_000).to_i if amount.end_with?('BTC')
304
- raise Error, "Can't understand the amount #{amount.inspect}"
321
+ return Coins.new(amount.gsub(/BTC$/, '')).satoshi if amount.end_with?('BTC')
322
+ raise(Error, "Can't understand the amount #{amount.inspect}")
305
323
  end
306
324
 
307
325
  # Calculates a fee in satoshi for the transaction of the specified size.
@@ -310,15 +328,15 @@ class Sibit
310
328
  # fee will be calculated using the +size+ argument (which is the size
311
329
  # of the transaction in bytes).
312
330
  def mfee(fee, size)
313
- return fee.to_i if fee.is_a?(Integer)
314
- raise Error, 'Fee should either be a String or Integer' unless fee.is_a?(String)
331
+ return fee if fee.is_a?(Integer)
332
+ raise(Error, 'Fee should either be a String or Integer') unless fee.is_a?(String)
315
333
  mul = 1
316
334
  if fee.end_with?('+', '-')
317
335
  mul = -1 if fee.end_with?('-')
318
336
  fee = fee[0..-2]
319
337
  end
320
338
  sat = fees[fee.to_sym]
321
- raise Error, "Can't understand the fee: #{fee.inspect}" if sat.nil?
339
+ raise(Error, "Can't understand the fee: #{fee.inspect}") if sat.nil?
322
340
  f = mul * sat * size
323
341
  @log.debug("Fee calculated as #{mul} * #{sat}s * #{size} = #{f}s")
324
342
  f
data/sibit.gemspec CHANGED
@@ -9,7 +9,7 @@ lib = File.expand_path('lib', __dir__)
9
9
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
10
10
  require_relative 'lib/sibit/version'
11
11
  Gem::Specification.new do |s|
12
- if s.respond_to? :required_rubygems_version=
12
+ if s.respond_to?(:required_rubygems_version=)
13
13
  s.required_rubygems_version = Gem::Requirement.new('>= 0')
14
14
  end
15
15
  s.required_ruby_version = '>= 3.0'
@@ -29,14 +29,14 @@ Gem::Specification.new do |s|
29
29
  s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) }
30
30
  s.rdoc_options = ['--charset=UTF-8']
31
31
  s.extra_rdoc_files = ['README.md', 'LICENSE.txt']
32
- s.add_dependency 'backtrace', '~> 0.3'
33
- s.add_dependency 'decoor', '~> 0.1'
34
- s.add_dependency 'elapsed', '~> 0.2'
35
- s.add_dependency 'ellipsized', '~> 0.3'
36
- s.add_dependency 'iri', '~> 0.5'
37
- s.add_dependency 'json', '~> 2.18'
38
- s.add_dependency 'loog', '~> 0.6'
39
- s.add_dependency 'openssl', '~> 3.0'
40
- s.add_dependency 'retriable_proxy', '~> 1.0'
41
- s.add_dependency 'thor', '~> 1.3'
32
+ s.add_dependency('backtrace', '~> 0.3')
33
+ s.add_dependency('decoor', '~> 0.1')
34
+ s.add_dependency('elapsed', '~> 0.2')
35
+ s.add_dependency('ellipsized', '~> 0.3')
36
+ s.add_dependency('iri', '~> 0.5')
37
+ s.add_dependency('json', '~> 2.18')
38
+ s.add_dependency('loog', '~> 0.6')
39
+ s.add_dependency('openssl', '~> 3.0')
40
+ s.add_dependency('retriable_proxy', '~> 1.0')
41
+ s.add_dependency('thor', '~> 1.3')
42
42
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sibit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.33.0
4
+ version: 0.34.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yegor Bugayenko
@@ -168,6 +168,13 @@ files:
168
168
  - REUSE.toml
169
169
  - Rakefile
170
170
  - bin/sibit
171
+ - cross/alpine.df
172
+ - cross/centos.df
173
+ - cross/debian.df
174
+ - cross/fedora.df
175
+ - cross/opensuse.df
176
+ - cross/rocky.df
177
+ - cross/ubuntu.df
171
178
  - cucumber.yml
172
179
  - features/cli.feature
173
180
  - features/dry.feature
@@ -185,6 +192,7 @@ files:
185
192
  - lib/sibit/blockchair.rb
186
193
  - lib/sibit/btc.rb
187
194
  - lib/sibit/cex.rb
195
+ - lib/sibit/coins.rb
188
196
  - lib/sibit/cryptoapis.rb
189
197
  - lib/sibit/dry.rb
190
198
  - lib/sibit/error.rb
@@ -195,6 +203,7 @@ files:
195
203
  - lib/sibit/json.rb
196
204
  - lib/sibit/key.rb
197
205
  - lib/sibit/script.rb
206
+ - lib/sibit/sochain.rb
198
207
  - lib/sibit/tx.rb
199
208
  - lib/sibit/txbuilder.rb
200
209
  - lib/sibit/version.rb