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/bestof.rb CHANGED
@@ -66,11 +66,26 @@ class Sibit::BestOf
66
66
  best_of('latest', &:latest)
67
67
  end
68
68
 
69
- # Push this transaction (in hex format) to the network.
69
+ # Push this transaction (in hex format) to the network. This one does
70
+ # not vote: pushing is a side effect, so it hands the transaction to
71
+ # each API in turn and stops at the first that accepts it.
70
72
  def push(hex)
71
- best_of('push') do |api|
72
- api.push(hex)
73
+ return @list.push(hex) unless @list.is_a?(Array)
74
+ errors = []
75
+ @list.each do |api|
76
+ return api.push(hex)
77
+ rescue Sibit::NotSupportedError
78
+ nil
79
+ rescue Sibit::Error => e
80
+ errors << e
81
+ @log.debug("The API #{api.class.name} failed at push(): #{e.message}") if @verbose
73
82
  end
83
+ errors.each { |e| @log.debug(Backtrace.new(e).to_s) }
84
+ raise(
85
+ Sibit::Error,
86
+ "No APIs out of #{@list.length} managed to succeed at push(): " \
87
+ "#{@list.map { |a| a.class.name }.join(', ')}"
88
+ )
74
89
  end
75
90
 
76
91
  # This method should fetch a block and return as a hash.
@@ -83,22 +98,37 @@ class Sibit::BestOf
83
98
  private
84
99
 
85
100
  def best_of(method)
86
- return yield @list unless @list.is_a?(Array)
87
- results = []
101
+ return yield(@list) unless @list.is_a?(Array)
102
+ votes = []
88
103
  errors = []
89
104
  @list.each do |api|
90
- results << yield(api)
105
+ votes << [api.class.name, yield(api)]
91
106
  rescue Sibit::NotSupportedError
92
- # Just ignore it
107
+ nil
93
108
  rescue Sibit::Error => e
94
109
  errors << e
95
110
  @log.debug("The API #{api.class.name} failed at #{method}(): #{e.message}") if @verbose
96
111
  end
97
- if results.empty?
112
+ if votes.empty?
98
113
  errors.each { |e| @log.debug(Backtrace.new(e).to_s) }
99
- raise Sibit::Error, "No APIs out of #{@list.length} managed to succeed at #{method}(): \
100
- #{@list.map { |a| a.class.name }.join(', ')}"
114
+ raise(
115
+ Sibit::Error,
116
+ "No APIs out of #{@list.length} managed to succeed at #{method}(): " \
117
+ "#{@list.map { |a| a.class.name }.join(', ')}"
118
+ )
119
+ end
120
+ majority(method, votes)
121
+ end
122
+
123
+ def majority(method, votes)
124
+ winner = votes.group_by { |v| v[1].to_s }.values.max_by(&:size)
125
+ if winner.size <= votes.size / 2
126
+ raise(
127
+ Sibit::Error,
128
+ "No strict majority among #{votes.size} answers at #{method}(): " \
129
+ "#{votes.map { |name, answer| "#{name}=#{answer}" }.join(', ')}"
130
+ )
101
131
  end
102
- results.group_by(&:to_s).values.max_by(&:size)[0]
132
+ winner[0][1]
103
133
  end
104
134
  end
data/lib/sibit/bin.rb CHANGED
@@ -22,16 +22,13 @@ class Sibit
22
22
  class Bin < Thor
23
23
  stop_on_unknown_option!
24
24
 
25
- class_option :proxy, type: :string,
26
- desc: 'HTTPS proxy for all requests, e.g. "localhost:3128"'
25
+ class_option :proxy, type: :string, desc: 'HTTPS proxy for all requests, e.g. "localhost:3128"'
27
26
  class_option :attempts, type: :numeric, default: 1,
28
27
  desc: 'How many times should we try before failing'
29
28
  class_option :dry, type: :boolean, default: false,
30
29
  desc: "Don't send a real payment, run in a read-only mode"
31
- class_option :verbose, type: :boolean, default: false,
32
- desc: 'Print all possible debug messages'
33
- class_option :quiet, type: :boolean, default: false,
34
- desc: 'Print only informative messages'
30
+ class_option :verbose, type: :boolean, default: false, desc: 'Print all possible debug messages'
31
+ class_option :quiet, type: :boolean, default: false, desc: 'Print only informative messages'
35
32
  class_option :api, type: :array, default: %w[blockchain btc bitcoinchain blockchair cex],
36
33
  desc: 'Ordered List of APIs to use, e.g. "blockchain,btc,bitcoinchain"'
37
34
  class_option :base58, type: :boolean, default: false,
@@ -45,10 +42,10 @@ class Sibit
45
42
  return new.help(command.name) if args.include?('--help') || args.include?('-h')
46
43
  unknown = args.find { |a| a.start_with?('-') }
47
44
  if unknown
48
- warn "Unknown option: #{unknown}"
49
- exit 1
45
+ warn("Unknown option: #{unknown}")
46
+ exit(1)
50
47
  end
51
- raise error
48
+ raise(error)
52
49
  end
53
50
 
54
51
  desc 'price', 'Get current price of BTC in USD'
@@ -60,12 +57,12 @@ class Sibit
60
57
  def fees
61
58
  sibit = client
62
59
  fees = sibit.fees
63
- text = %i[S M L XL].map do |m|
64
- sat = fees[m] * 250
65
- usd = sat * sibit.price / 100_000_000
66
- "#{m}: #{sat}sat / $#{format('%<usd>.02f', usd: usd)}"
67
- end.join("\n")
68
- log.info(text)
60
+ log.info(
61
+ %i[S M L XL].map do |m|
62
+ sat = fees[m] * 250
63
+ "#{m}: #{sat}sat / $#{format('%<usd>.02f', usd: (sat * sibit.price / 100_000_000))}"
64
+ end.join("\n")
65
+ )
69
66
  end
70
67
 
71
68
  desc 'latest', 'Get hash of the latest block'
@@ -86,8 +83,10 @@ class Sibit
86
83
  end
87
84
 
88
85
  desc 'balance ADDRESS', 'Check the balance of the Bitcoin address'
86
+ option :trust, type: :numeric, default: 0,
87
+ desc: 'Minimum number of confirmations required for a UTXO to count toward the balance'
89
88
  def balance(address)
90
- log.info(client.balance(address))
89
+ log.info(client.balance(address, trust: options[:trust]))
91
90
  end
92
91
 
93
92
  desc \
@@ -97,26 +96,34 @@ class Sibit
97
96
  desc: 'List of UTXO that must be skipped while paying'
98
97
  option :yes, type: :boolean, default: false,
99
98
  desc: 'Skip confirmation prompt and send the payment immediately'
100
- option :price, type: :numeric,
101
- desc: 'BTC price in USD (skips API price fetch if provided)'
99
+ option :price, type: :numeric, desc: 'BTC price in USD (skips API price fetch if provided)'
100
+ option :trust, type: :numeric, default: 0,
101
+ desc: 'Minimum number of confirmations required on a UTXO before it can be spent'
102
102
  def pay(amount, fee, sources, target, change)
103
103
  keys = sources.split(',')
104
- if amount.upcase == 'MAX'
105
- addrs = keys.map do |k|
106
- kk = Sibit::Key.new(k)
107
- options[:base58] ? kk.base58 : kk.bech32
108
- end
109
- amount = addrs.sum { |a| client.balance(a) }
104
+ sweep = amount.upcase == 'MAX'
105
+ if sweep
106
+ addrs =
107
+ keys.map do |k|
108
+ kk = Sibit::Key.new(k)
109
+ options[:base58] ? kk.base58 : kk.bech32
110
+ end
111
+ amount = addrs.sum { |a| client.balance(a, trust: options[:trust]) }
110
112
  end
111
- amount = amount.to_i if amount.is_a?(String) && /^[0-9]+$/.match?(amount)
112
- fee = fee.to_i if /^[0-9]+$/.match?(fee)
113
+ amount = Integer(amount, 10) if amount.is_a?(String) && /^[0-9]+$/.match?(amount)
114
+ fee = Integer(fee, 10) if /^[0-9]+$/.match?(fee)
115
+ amount -= fee if sweep && fee.is_a?(Integer)
113
116
  args = [amount, fee, keys, target, change]
114
- kwargs = { skip_utxo: options[:skip_utxo], base58: options[:base58], price: options[:price] }
117
+ kwargs = {
118
+ skip_utxo: options[:skip_utxo], base58: options[:base58],
119
+ price: options[:price], trust: options[:trust]
120
+ }
115
121
  unless options[:yes] || options[:dry]
116
122
  client(dry: true).pay(*args, **kwargs)
117
- print 'Do you confirm this payment? (yes/no): '
118
- answer = $stdin.gets&.strip&.downcase
119
- raise Sibit::Error, 'Payment cancelled by user' unless answer == 'yes'
123
+ print('Do you confirm this payment? (yes/no): ')
124
+ unless $stdin.gets&.strip&.downcase == 'yes'
125
+ raise(Sibit::Error, 'Payment cancelled by user')
126
+ end
120
127
  end
121
128
  log.info(client.pay(*args, **kwargs))
122
129
  end
@@ -129,36 +136,43 @@ class Sibit
129
136
  private
130
137
 
131
138
  def log
132
- @log ||= begin
133
- verbose = !options[:quiet] &&
134
- (options[:verbose] || ENV.fetch('SIBIT_VERBOSE', nil))
135
- verbose ? Loog::VERBOSE : Loog::REGULAR
136
- end
139
+ return @log if @log
140
+ @log =
141
+ if !options[:quiet] && (options[:verbose] || ENV.fetch('SIBIT_VERBOSE', nil))
142
+ Loog::VERBOSE
143
+ else
144
+ Loog::REGULAR
145
+ end
137
146
  end
138
147
 
139
148
  def client(dry: false)
140
149
  proxy = options[:proxy] || ENV.fetch('SIBIT_PROXY', nil)
141
150
  http = proxy ? Sibit::HttpProxy.new(proxy) : Sibit::Http.new
142
151
  log.debug("Using proxy at #{http.host}") if proxy
143
- apis = options[:api].flat_map { |a| a.split(',') }.map(&:downcase).map do |a|
144
- case a
145
- when 'blockchain'
146
- Sibit::Blockchain.new(http: http, log: log)
147
- when 'btc'
148
- Sibit::Btc.new(http: http, log: log)
149
- when 'bitcoinchain'
150
- Sibit::Bitcoinchain.new(http: http, log: log)
151
- when 'blockchair'
152
- Sibit::Blockchair.new(http: http, log: log)
153
- when 'cex'
154
- Sibit::Cex.new(http: http, log: log)
155
- when 'fake'
156
- Sibit::Fake.new
157
- else
158
- raise Sibit::Error, "Unknown API \"#{a}\""
159
- end
160
- end
161
- api = Sibit::FirstOf.new(apis, log: log, verbose: true)
152
+ api = Sibit::FirstOf.new(
153
+ options[:api].flat_map { |a| a.split(',') }.map(&:downcase).map do |a|
154
+ case a
155
+ when 'blockchain'
156
+ Sibit::Blockchain.new(http: http, log: log)
157
+ when 'btc'
158
+ Sibit::Btc.new(http: http, log: log)
159
+ when 'bitcoinchain'
160
+ Sibit::Bitcoinchain.new(http: http, log: log)
161
+ when 'blockchair'
162
+ Sibit::Blockchair.new(key: ENV.fetch('SIBIT_BLOCKCHAIR_KEY', nil), http: http, log: log)
163
+ when 'cex'
164
+ Sibit::Cex.new(http: http, log: log)
165
+ when 'cryptoapis'
166
+ Sibit::Cryptoapis.new(ENV.fetch('SIBIT_CRYPTOAPIS_KEY', nil), http: http, log: log)
167
+ when 'sochain'
168
+ Sibit::Sochain.new(http: http, log: log)
169
+ when 'fake'
170
+ Sibit::Fake.new
171
+ else
172
+ raise(Sibit::Error, "Unknown API \"#{a}\"")
173
+ end
174
+ end, log: log, verbose: true
175
+ )
162
176
  api = Sibit::Dry.new(api, log: log) if options[:dry] || dry
163
177
  api = RetriableProxy.for_object(api, on: Sibit::Error) if options[:attempts] > 1
164
178
  Sibit.new(log: log, api: api)
@@ -7,6 +7,7 @@ require 'iri'
7
7
  require 'json'
8
8
  require 'loog'
9
9
  require 'uri'
10
+ require_relative 'coins'
10
11
  require_relative 'error'
11
12
  require_relative 'http'
12
13
  require_relative 'json'
@@ -26,12 +27,12 @@ class Sibit::Bitcoinchain
26
27
 
27
28
  # Current price of BTC in USD (float returned).
28
29
  def price(_currency = 'USD')
29
- raise Sibit::NotSupportedError, 'Bitcoinchain API doesn\'t provide BTC price'
30
+ raise(Sibit::NotSupportedError, 'Bitcoinchain API doesn\'t provide BTC price')
30
31
  end
31
32
 
32
33
  # The height of the block.
33
34
  def height(_hash)
34
- raise Sibit::NotSupportedError, 'Bitcoinchain API doesn\'t provide height()'
35
+ raise(Sibit::NotSupportedError, 'Bitcoinchain API doesn\'t provide height()')
35
36
  end
36
37
 
37
38
  # Get hash of the block after this one.
@@ -39,7 +40,7 @@ class Sibit::Bitcoinchain
39
40
  block = Sibit::Json.new(http: @http, log: @log).get(
40
41
  Iri.new('https://api-r.bitcoinchain.com/v1/block').append(hash)
41
42
  )[0]
42
- raise Sibit::Error, "Block #{hash} not found" if block.nil?
43
+ raise(Sibit::Error, "Block #{hash} not found") if block.nil?
43
44
  nxt = block['next_block']
44
45
  nxt = nil if nxt == '0000000000000000000000000000000000000000000000000000000000000000'
45
46
  @log.debug("The block #{hash} is the latest, there is no next block") if nxt.nil?
@@ -58,15 +59,14 @@ class Sibit::Bitcoinchain
58
59
  @log.debug("The balance of #{address} is not visible")
59
60
  return 0
60
61
  end
61
- b *= 100_000_000
62
- b = b.to_i
62
+ b = Sibit::Coins.new(b).satoshi
63
63
  @log.debug("The balance of #{address} is #{b} satoshi (#{json['transactions']} txns)")
64
64
  b
65
65
  end
66
66
 
67
67
  # Get recommended fees, in satoshi per byte.
68
68
  def fees
69
- raise Sibit::NotSupportedError, 'Not implemented yet'
69
+ raise(Sibit::NotSupportedError, 'Not implemented yet')
70
70
  end
71
71
 
72
72
  # Gets the hash of the latest block.
@@ -80,12 +80,12 @@ class Sibit::Bitcoinchain
80
80
 
81
81
  # Fetch all unspent outputs per address.
82
82
  def utxos(_sources)
83
- raise Sibit::NotSupportedError, 'Not implemented yet'
83
+ raise(Sibit::NotSupportedError, 'Not implemented yet')
84
84
  end
85
85
 
86
86
  # Push this transaction (in hex format) to the network.
87
87
  def push(_hex)
88
- raise Sibit::NotSupportedError, 'Not implemented yet'
88
+ raise(Sibit::NotSupportedError, 'Not implemented yet')
89
89
  end
90
90
 
91
91
  # This method should fetch a Blockchain block and return as a hash. Raises
@@ -94,7 +94,7 @@ class Sibit::Bitcoinchain
94
94
  head = Sibit::Json.new(http: @http, log: @log).get(
95
95
  Iri.new('https://api-r.bitcoinchain.com/v1/block').append(hash)
96
96
  )[0]
97
- raise Sibit::Error, "The block #{hash} is not found" if head.nil?
97
+ raise(Sibit::Error, "The block #{hash} is not found") if head.nil?
98
98
  txs = Sibit::Json.new(http: @http, log: @log).get(
99
99
  Iri.new('https://api-r.bitcoinchain.com/v1/block/txs').append(hash)
100
100
  )
@@ -112,7 +112,7 @@ class Sibit::Bitcoinchain
112
112
  outputs: t['outputs'].map do |o|
113
113
  {
114
114
  address: o['receiver'],
115
- value: o['value'] * 100_000_000
115
+ value: Sibit::Coins.new(o['value']).satoshi
116
116
  }
117
117
  end
118
118
  }
@@ -3,6 +3,7 @@
3
3
  # SPDX-FileCopyrightText: Copyright (c) 2019-2026 Yegor Bugayenko
4
4
  # SPDX-License-Identifier: MIT
5
5
 
6
+ require 'cgi'
6
7
  require 'iri'
7
8
  require 'json'
8
9
  require 'loog'
@@ -32,7 +33,7 @@ class Sibit::Blockchain
32
33
  h = Sibit::Json.new(http: @http, log: @log).get(
33
34
  Iri.new('https://blockchain.info/ticker')
34
35
  )[currency]
35
- raise Error, "Unrecognized currency #{currency}" if h.nil?
36
+ raise(Sibit::Error, "Unrecognized currency #{currency}") if h.nil?
36
37
  price = h['15m']
37
38
  @log.debug("The price of BTC is #{price} USD")
38
39
  price
@@ -40,25 +41,15 @@ class Sibit::Blockchain
40
41
 
41
42
  # Get hash of the block after this one.
42
43
  def next_of(_hash)
43
- raise Sibit::NotSupportedError, 'next_of() in Blockchain API is broken, always returns NULL'
44
- # json = Sibit::Json.new(http: @http, log: @log).get(
45
- # Iri.new('https://blockchain.info/rawblock').append(hash)
46
- # )
47
- # nxt = json['next_block'][0]
48
- # if nxt.nil?
49
- # @log.debug("There is no block after #{hash}")
50
- # else
51
- # @log.debug("The next block of #{hash} is #{nxt}")
52
- # end
53
- # nxt
44
+ raise(Sibit::NotSupportedError, 'next_of() in Blockchain API is broken, always returns NULL')
54
45
  end
55
46
 
56
47
  # The height of the block.
57
48
  def height(hash)
58
- json = Sibit::Json.new(http: @http, log: @log).get(
49
+ h = Sibit::Json.new(http: @http, log: @log).get(
59
50
  Iri.new('https://blockchain.info/rawblock').append(hash)
60
- )
61
- h = json['height']
51
+ )['height']
52
+ raise(Sibit::Error, "The block #{hash} found but the height is absent") if h.nil?
62
53
  @log.debug("The height of #{hash} is #{h}")
63
54
  h
64
55
  end
@@ -66,10 +57,10 @@ class Sibit::Blockchain
66
57
  # Gets the balance of the address, in satoshi.
67
58
  def balance(address)
68
59
  json = Sibit::Json.new(http: @http, log: @log).get(
69
- Iri.new('https://blockchain.info/rawaddr').append(address).add(limit: 0),
70
- accept: [200, 500]
60
+ Iri.new('https://blockchain.info/rawaddr').append(address).add(limit: 0)
71
61
  )
72
62
  b = json['final_balance']
63
+ raise(Sibit::Error, "The balance of #{address} is absent") unless b.is_a?(Integer)
73
64
  @log.debug("The balance of #{address} is #{b} satoshi (#{json['n_tx']} txns)")
74
65
  b
75
66
  end
@@ -86,7 +77,7 @@ class Sibit::Blockchain
86
77
  ].join
87
78
  )
88
79
  {
89
- S: json['regular'] / 3,
80
+ S: (json['regular'] / 3.0).ceil,
90
81
  M: json['regular'],
91
82
  L: json['priority'],
92
83
  XL: json['limits']['max']
@@ -112,8 +103,7 @@ class Sibit::Blockchain
112
103
  # Push this transaction (in hex format) to the network.
113
104
  def push(hex)
114
105
  Sibit::Json.new(http: @http, log: @log).post(
115
- Iri.new('https://blockchain.info/pushtx'),
116
- hex
106
+ Iri.new('https://blockchain.info/pushtx'), "tx=#{CGI.escape(hex)}"
117
107
  )
118
108
  end
119
109
 
@@ -3,7 +3,6 @@
3
3
  # SPDX-FileCopyrightText: Copyright (c) 2019-2026 Yegor Bugayenko
4
4
  # SPDX-License-Identifier: MIT
5
5
 
6
- require 'cgi'
7
6
  require 'iri'
8
7
  require 'json'
9
8
  require 'loog'
@@ -28,67 +27,58 @@ class Sibit::Blockchair
28
27
 
29
28
  # Current price of BTC in USD (float returned).
30
29
  def price(_currency = 'USD')
31
- raise Sibit::NotSupportedError, 'Blockchair doesn\'t provide BTC price'
30
+ raise(Sibit::NotSupportedError, 'Blockchair doesn\'t provide BTC price')
32
31
  end
33
32
 
34
33
  # The height of the block.
35
34
  def height(_hash)
36
- raise Sibit::NotSupportedError, 'Blockchair API doesn\'t provide height()'
35
+ raise(Sibit::NotSupportedError, 'Blockchair API doesn\'t provide height()')
37
36
  end
38
37
 
39
38
  # Get hash of the block after this one.
40
39
  def next_of(_hash)
41
- # They don't provide next block hash
42
- raise Sibit::NotSupportedError, 'Blockchair API doesn\'t provide next_of()'
40
+ raise(Sibit::NotSupportedError, 'Blockchair API doesn\'t provide next_of()')
43
41
  end
44
42
 
45
43
  # Gets the balance of the address, in satoshi.
46
44
  def balance(address)
47
- json = Sibit::Json.new(http: @http, log: @log).get(
48
- Iri.new('https://api.blockchair.com/bitcoin/dashboards/address').append(address).fragment(the_key)
49
- )['data'][address]
45
+ uri = Iri.new('https://api.blockchair.com/bitcoin/dashboards/address').append(address)
46
+ uri = uri.add(key: @key) unless @key.nil?
47
+ json = Sibit::Json.new(http: @http, log: @log).get(uri)['data'][address]
50
48
  if json.nil?
51
49
  @log.debug("Address #{address} not found")
52
50
  return 0
53
51
  end
54
- a = json['address']
55
- b = a['balance']
52
+ b = json['address']['balance']
56
53
  @log.debug("The balance of #{address} is #{b} satoshi")
57
54
  b
58
55
  end
59
56
 
60
57
  # Get recommended fees, in satoshi per byte.
61
58
  def fees
62
- raise Sibit::NotSupportedError, 'Blockchair doesn\'t implement fees()'
59
+ raise(Sibit::NotSupportedError, 'Blockchair doesn\'t implement fees()')
63
60
  end
64
61
 
65
62
  # Gets the hash of the latest block.
66
63
  def latest
67
- raise Sibit::NotSupportedError, 'Blockchair doesn\'t implement latest()'
64
+ raise(Sibit::NotSupportedError, 'Blockchair doesn\'t implement latest()')
68
65
  end
69
66
 
70
67
  # Fetch all unspent outputs per address.
71
68
  def utxos(_sources)
72
- raise Sibit::NotSupportedError, 'Blockchair doesn\'t implement utxos()'
69
+ raise(Sibit::NotSupportedError, 'Blockchair doesn\'t implement utxos()')
73
70
  end
74
71
 
75
72
  # Push this transaction (in hex format) to the network.
76
73
  def push(hex)
77
- Sibit::Json.new(http: @http, log: @log).post(
78
- Iri.new('https://api.blockchair.com/bitcoin/push/transaction').fragment(the_key),
79
- "data=#{hex}"
80
- )
74
+ uri = Iri.new('https://api.blockchair.com/bitcoin/push/transaction')
75
+ uri = uri.add(key: @key) unless @key.nil?
76
+ Sibit::Json.new(http: @http, log: @log).post(uri, "data=#{hex}")
81
77
  @log.debug("Transaction (#{hex.length} in hex) has been pushed to Blockchair")
82
78
  end
83
79
 
84
80
  # This method should fetch a Blockchain block and return as a hash.
85
81
  def block(_hash)
86
- raise Sibit::NotSupportedError, 'Blockchair doesn\'t implement block()'
87
- end
88
-
89
- private
90
-
91
- def the_key
92
- @key.nil? ? '' : "key=#{CGI.escape(@key)}"
82
+ raise(Sibit::NotSupportedError, 'Blockchair doesn\'t implement block()')
93
83
  end
94
84
  end