sibit 0.34.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f9835f6fa0e5abbd809c650561dc79b4bddd69281bcc6f34c28d703c1ff7e27e
4
- data.tar.gz: f64fa06396983ea189f42d7d1028842831f082c6c12660858916111b62f6be28
3
+ metadata.gz: 469905d53f542975d0fe59c8b429289423762f4b36aca8b703928d54f5ba394f
4
+ data.tar.gz: c68a4237d70f25a11ea2e98c36d76c7b0917a6355353e6d23c22336eedd94f31
5
5
  SHA512:
6
- metadata.gz: bc3cc86f77d6418d88c327f87758de829b98644adf439f736c4c6a8db24e7f0f15a1faad58dc3f36a45b3ac5693995dcda0bb117ece14178fc0d437131139ab8
7
- data.tar.gz: 49982a64fedb0281e093d4dd2176e649bc721f56b8743819c536ab28ac324e230097c5e4d741553da6a7b25e248b32f0933a14b8052d2fd705afc1222feec53e
6
+ metadata.gz: f5aa717192c0417c572226c3a0f79924f61e5d70b0bc84efe53d0cc760c16515f3cd6ef63b00de57e4228dbcd82fdb60a269f35df858b0c1ff8bec81006db6bc
7
+ data.tar.gz: fe9f402e207051f02e31b54d0a4e41cca76efd2a0260fbc6dfb3fb69f1aa543af2a9af6045590bd653c6c26472c97fdb6d5ba3a8b5a98f4ca282e351d88f53ee
data/Gemfile.lock CHANGED
@@ -72,7 +72,7 @@ GEM
72
72
  loog (~> 0.6)
73
73
  tago (~> 0.1)
74
74
  ellipsized (0.3.0)
75
- erb (6.0.4)
75
+ erb (6.0.6)
76
76
  ffi (1.17.4-arm64-darwin)
77
77
  ffi (1.17.4-x86_64-linux-gnu)
78
78
  hashdiff (1.2.1)
@@ -83,8 +83,8 @@ GEM
83
83
  rdoc (>= 4.0.0)
84
84
  reline (>= 0.4.2)
85
85
  iri (0.11.8)
86
- json (2.19.9)
87
- language_server-protocol (3.17.0.5)
86
+ json (2.21.1)
87
+ language_server-protocol (3.17.0.6)
88
88
  lint_roller (1.1.0)
89
89
  logger (1.7.0)
90
90
  loog (0.8.0)
@@ -108,10 +108,10 @@ GEM
108
108
  openssl (3.3.3)
109
109
  os (1.1.4)
110
110
  parallel (2.1.0)
111
- parser (3.3.11.1)
111
+ parser (3.3.12.0)
112
112
  ast (~> 2.4.1)
113
113
  racc
114
- pp (0.6.3)
114
+ pp (0.6.4)
115
115
  prettyprint
116
116
  prettyprint (0.2.0)
117
117
  prism (1.9.0)
@@ -144,7 +144,7 @@ GEM
144
144
  diff-lcs (>= 1.2.0, < 2.0)
145
145
  rspec-support (~> 3.13.0)
146
146
  rspec-support (3.13.7)
147
- rubocop (1.88.0)
147
+ rubocop (1.88.2)
148
148
  json (~> 2.3)
149
149
  language_server-protocol (~> 3.17.0.2)
150
150
  lint_roller (~> 1.1.0)
@@ -155,13 +155,13 @@ GEM
155
155
  rubocop-ast (>= 1.49.0, < 2.0)
156
156
  ruby-progressbar (~> 1.7)
157
157
  unicode-display_width (>= 2.4.0, < 4.0)
158
- rubocop-ast (1.49.1)
158
+ rubocop-ast (1.50.0)
159
159
  parser (>= 3.3.7.2)
160
160
  prism (~> 1.7)
161
- rubocop-elegant (0.6.0)
161
+ rubocop-elegant (0.7.1)
162
162
  lint_roller (~> 1.1)
163
163
  rubocop (~> 1.75)
164
- rubocop-minitest (0.39.1)
164
+ rubocop-minitest (0.40.0)
165
165
  lint_roller (~> 1.1)
166
166
  rubocop (>= 1.75.0, < 2.0)
167
167
  rubocop-ast (>= 1.38.0, < 2.0)
@@ -177,7 +177,7 @@ GEM
177
177
  docile (~> 1.1)
178
178
  simplecov-html (~> 0.11)
179
179
  simplecov_json_formatter (~> 0.1)
180
- simplecov-cobertura (3.1.0)
180
+ simplecov-cobertura (3.2.0)
181
181
  rexml
182
182
  simplecov (~> 0.19)
183
183
  simplecov-html (0.13.2)
data/README.md CHANGED
@@ -115,6 +115,11 @@ The proxy address may include authentication credentials:
115
115
  sibit --proxy=user:password@host:port balance 1PfsYNygsuVL8fvBarJNQnHytkg4rGih1U
116
116
  ```
117
117
 
118
+ Some providers accept an API key, read from an environment variable:
119
+ `SIBIT_BLOCKCHAIR_KEY` for [Blockchair.com] and
120
+ `SIBIT_CRYPTOAPIS_KEY` for [Cryptoapis.io].
121
+ When the variable is set, requests to that provider use the authenticated tier.
122
+
118
123
  All operations are performed through the [Blockchain API].
119
124
  Transactions are pushed to the Bitcoin network via [this relay].
120
125
 
data/cross/centos.df CHANGED
@@ -3,11 +3,6 @@
3
3
 
4
4
  FROM quay.io/centos/centos:stream9
5
5
 
6
- RUN dnf install -y epel-release && \
7
- dnf config-manager --set-enabled crb && \
8
- dnf install -y gcc make openssl-devel libyaml-devel libffi-devel readline-devel zlib-devel tar && \
9
- curl -fsSL https://github.com/rbenv/ruby-build/archive/refs/heads/master.tar.gz | tar -xz && \
10
- PREFIX=/usr/local ./ruby-build-master/install.sh && \
11
- ruby-build 3.2.6 /usr/local && \
12
- rm -rf ruby-build-master && \
6
+ RUN dnf module enable -y ruby:3.3 && \
7
+ dnf install -y ruby ruby-devel gcc make openssl-devel redhat-rpm-config && \
13
8
  dnf clean all
data/cross/opensuse.df CHANGED
@@ -3,7 +3,15 @@
3
3
 
4
4
  FROM opensuse/tumbleweed
5
5
 
6
- RUN zypper --non-interactive install ruby ruby-devel gcc make libopenssl-devel bash && \
6
+ RUN ok=; \
7
+ for i in 1 2 3 4 5; do \
8
+ if zypper --non-interactive --gpg-auto-import-keys refresh && \
9
+ zypper --non-interactive install ruby ruby-devel gcc make libopenssl-devel bash; then \
10
+ ok=1; break; \
11
+ fi; \
12
+ echo "zypper attempt $i failed, retrying in 15s"; sleep 15; \
13
+ done; \
14
+ test -n "$ok" && \
7
15
  zypper clean --all && \
8
16
  gem update --system --no-document && \
9
17
  sed -i '/format.executable/d' /etc/gemrc
data/cross/rocky.df CHANGED
@@ -3,11 +3,6 @@
3
3
 
4
4
  FROM rockylinux:9
5
5
 
6
- RUN dnf install -y epel-release && \
7
- dnf config-manager --set-enabled crb && \
8
- dnf install -y gcc make openssl-devel libyaml-devel libffi-devel readline-devel zlib-devel tar && \
9
- curl -fsSL https://github.com/rbenv/ruby-build/archive/refs/heads/master.tar.gz | tar -xz && \
10
- PREFIX=/usr/local ./ruby-build-master/install.sh && \
11
- ruby-build 3.2.6 /usr/local && \
12
- rm -rf ruby-build-master && \
6
+ RUN dnf module enable -y ruby:3.3 && \
7
+ dnf install -y ruby ruby-devel gcc make openssl-devel redhat-rpm-config && \
13
8
  dnf clean all
data/lib/sibit/bech32.rb CHANGED
@@ -66,8 +66,19 @@ class Sibit
66
66
 
67
67
  def witness
68
68
  hrp, data = parse
69
+ ver = data[0]
70
+ unless ver.between?(0, 16)
71
+ raise(Sibit::Error, "Unsupported witness version #{ver} in '#{@addr}'")
72
+ end
69
73
  raise(Sibit::Error, "Invalid Bech32 checksum in '#{@addr}'") unless verified?(hrp, data)
70
- convert(data[1..-7], 5, 8, false).pack('C*').unpack1('H*')
74
+ prog = convert(data[1..-7], 5, 8)
75
+ if ver.zero? && [20, 32].none?(prog.length)
76
+ raise(Sibit::Error, "Witness v0 program in '#{@addr}' must be 20 or 32 bytes")
77
+ end
78
+ unless prog.length.between?(2, 40)
79
+ raise(Sibit::Error, "Witness program in '#{@addr}' must be 2 to 40 bytes")
80
+ end
81
+ prog.pack('C*').unpack1('H*')
71
82
  end
72
83
 
73
84
  def version
@@ -88,7 +99,7 @@ class Sibit
88
99
  end
89
100
 
90
101
  def verified?(hrp, data)
91
- [1, 0x2bc830a3].include?(polymod(expand(hrp) + data))
102
+ polymod(expand(hrp) + data) == (data[0].zero? ? 1 : 0x2bc830a3)
92
103
  end
93
104
 
94
105
  def expand(hrp)
@@ -105,7 +116,7 @@ class Sibit
105
116
  chk
106
117
  end
107
118
 
108
- def convert(data, frombits, tobits, pad)
119
+ def convert(data, frombits, tobits)
109
120
  acc = 0
110
121
  bits = 0
111
122
  result = []
@@ -118,7 +129,9 @@ class Sibit
118
129
  result << ((acc >> bits) & maxv)
119
130
  end
120
131
  end
121
- result << ((acc << (tobits - bits)) & maxv) if pad && bits.positive?
132
+ if bits >= frombits || (acc << (tobits - bits)).anybits?(maxv)
133
+ raise(Sibit::Error, "Non-zero padding in Bech32 address '#{@addr}'")
134
+ end
122
135
  result
123
136
  end
124
137
  end
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.
@@ -84,17 +99,17 @@ class Sibit::BestOf
84
99
 
85
100
  def best_of(method)
86
101
  return yield(@list) unless @list.is_a?(Array)
87
- results = []
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
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
114
  raise(
100
115
  Sibit::Error,
@@ -102,6 +117,18 @@ class Sibit::BestOf
102
117
  "#{@list.map { |a| a.class.name }.join(', ')}"
103
118
  )
104
119
  end
105
- results.group_by(&:to_s).values.max_by(&:size)[0]
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
+ )
131
+ end
132
+ winner[0][1]
106
133
  end
107
134
  end
data/lib/sibit/bin.rb CHANGED
@@ -101,7 +101,8 @@ class Sibit
101
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'
104
+ sweep = amount.upcase == 'MAX'
105
+ if sweep
105
106
  addrs =
106
107
  keys.map do |k|
107
108
  kk = Sibit::Key.new(k)
@@ -111,6 +112,7 @@ class Sibit
111
112
  end
112
113
  amount = Integer(amount, 10) if amount.is_a?(String) && /^[0-9]+$/.match?(amount)
113
114
  fee = Integer(fee, 10) if /^[0-9]+$/.match?(fee)
115
+ amount -= fee if sweep && fee.is_a?(Integer)
114
116
  args = [amount, fee, keys, target, change]
115
117
  kwargs = {
116
118
  skip_utxo: options[:skip_utxo], base58: options[:base58],
@@ -157,7 +159,7 @@ class Sibit
157
159
  when 'bitcoinchain'
158
160
  Sibit::Bitcoinchain.new(http: http, log: log)
159
161
  when 'blockchair'
160
- Sibit::Blockchair.new(http: http, log: log)
162
+ Sibit::Blockchair.new(key: ENV.fetch('SIBIT_BLOCKCHAIR_KEY', nil), http: http, log: log)
161
163
  when 'cex'
162
164
  Sibit::Cex.new(http: http, log: log)
163
165
  when 'cryptoapis'
@@ -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'
@@ -58,8 +59,7 @@ 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 = Integer(b)
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
@@ -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
@@ -48,6 +49,7 @@ class Sibit::Blockchain
48
49
  h = Sibit::Json.new(http: @http, log: @log).get(
49
50
  Iri.new('https://blockchain.info/rawblock').append(hash)
50
51
  )['height']
52
+ raise(Sibit::Error, "The block #{hash} found but the height is absent") if h.nil?
51
53
  @log.debug("The height of #{hash} is #{h}")
52
54
  h
53
55
  end
@@ -55,10 +57,10 @@ class Sibit::Blockchain
55
57
  # Gets the balance of the address, in satoshi.
56
58
  def balance(address)
57
59
  json = Sibit::Json.new(http: @http, log: @log).get(
58
- Iri.new('https://blockchain.info/rawaddr').append(address).add(limit: 0),
59
- accept: [200, 500]
60
+ Iri.new('https://blockchain.info/rawaddr').append(address).add(limit: 0)
60
61
  )
61
62
  b = json['final_balance']
63
+ raise(Sibit::Error, "The balance of #{address} is absent") unless b.is_a?(Integer)
62
64
  @log.debug("The balance of #{address} is #{b} satoshi (#{json['n_tx']} txns)")
63
65
  b
64
66
  end
@@ -75,7 +77,7 @@ class Sibit::Blockchain
75
77
  ].join
76
78
  )
77
79
  {
78
- S: json['regular'] / 3,
80
+ S: (json['regular'] / 3.0).ceil,
79
81
  M: json['regular'],
80
82
  L: json['priority'],
81
83
  XL: json['limits']['max']
@@ -100,7 +102,9 @@ class Sibit::Blockchain
100
102
 
101
103
  # Push this transaction (in hex format) to the network.
102
104
  def push(hex)
103
- Sibit::Json.new(http: @http, log: @log).post(Iri.new('https://blockchain.info/pushtx'), hex)
105
+ Sibit::Json.new(http: @http, log: @log).post(
106
+ Iri.new('https://blockchain.info/pushtx'), "tx=#{CGI.escape(hex)}"
107
+ )
104
108
  end
105
109
 
106
110
  # Gets the hash of the latest block.
data/lib/sibit/btc.rb CHANGED
@@ -39,10 +39,7 @@ class Sibit::Btc
39
39
  return 0
40
40
  end
41
41
  data = json['data']
42
- if data.nil?
43
- @log.debug("The balance of #{address} is probably zero (not found)")
44
- return 0
45
- end
42
+ raise(Sibit::Error, "The address #{address} not found") if data.nil?
46
43
  txns = data['list']
47
44
  if txns.nil?
48
45
  @log.debug("The balance of #{address} is probably zero (not found)")
@@ -103,19 +100,19 @@ class Sibit::Btc
103
100
  list = data['list']
104
101
  next if list.nil?
105
102
  list.each do |u|
106
- outs = Sibit::Json.new(http: @http, log: @log).get(
103
+ index = u['tx_output_n']
104
+ raise(Sibit::Error, "The unspent output of #{hash} has no tx_output_n") if index.nil?
105
+ out = Sibit::Json.new(http: @http, log: @log).get(
107
106
  Iri.new('https://chain.api.btc.com/v3/tx').append(u['tx_hash']).add(verbose: 3)
108
- )['data']['outputs']
109
- outs.each_with_index do |o, i|
110
- next unless o['addresses'].include?(hash)
111
- results << {
112
- value: o['value'],
113
- hash: u['tx_hash'],
114
- index: i,
115
- confirmations: u['confirmations'],
116
- script: [o['script_hex']].pack('H*')
117
- }
118
- end
107
+ )['data']['outputs'][index]
108
+ raise(Sibit::Error, "The output #{index} of #{u['tx_hash']} is absent") if out.nil?
109
+ results << {
110
+ value: u['value'],
111
+ hash: u['tx_hash'],
112
+ index: index,
113
+ confirmations: u['confirmations'],
114
+ script: [out['script_hex']].pack('H*')
115
+ }
119
116
  end
120
117
  end
121
118
  results
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: Copyright (c) 2019-2026 Yegor Bugayenko
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ require 'bigdecimal'
7
+ require_relative 'error'
8
+
9
+ # Sibit main class.
10
+ class Sibit
11
+ # An amount of bitcoins, convertible to an exact number of satoshi.
12
+ #
13
+ # It parses the amount through +BigDecimal+ instead of a binary +Float+,
14
+ # so decimal values like 0.29 map to exactly 29,000,000 satoshi. An amount
15
+ # with sub-satoshi precision is rejected instead of being truncated.
16
+ #
17
+ # Author:: Yegor Bugayenko (yegor256@gmail.com)
18
+ # Copyright:: Copyright (c) 2019-2026 Yegor Bugayenko
19
+ # License:: MIT
20
+ class Coins
21
+ def initialize(btc)
22
+ @btc = btc
23
+ end
24
+
25
+ def satoshi
26
+ sat = BigDecimal(@btc.to_s) * 100_000_000
27
+ unless sat.frac.zero?
28
+ raise(Sibit::Error, "The amount #{@btc.inspect} is finer than one satoshi")
29
+ end
30
+ Integer(sat)
31
+ rescue ArgumentError, TypeError
32
+ raise(Sibit::Error, "The amount #{@btc.inspect} is not a valid number of bitcoins")
33
+ end
34
+ end
35
+ end
@@ -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'
@@ -32,10 +33,13 @@ class Sibit::Cryptoapis
32
33
 
33
34
  # Get hash of the block after this one.
34
35
  def next_of(hash)
35
- nxt = Sibit::Json.new(http: @http, log: @log).get(
36
+ head = Sibit::Json.new(http: @http, log: @log).get(
36
37
  Iri.new('https://api.cryptoapis.io/v1/bc/btc/mainnet/blocks').append(hash),
37
38
  headers: headers
38
- )['payload']['hash']
39
+ )['payload']
40
+ raise(Sibit::Error, "The block #{hash} not found") if head.nil?
41
+ nxt = head['nextblockhash']
42
+ nxt = nil if nxt == '0000000000000000000000000000000000000000000000000000000000000000'
39
43
  @log.debug("The block #{hash} is the latest, there is no next block") if nxt.nil?
40
44
  @log.debug("The next block of #{hash} is #{nxt}") unless nxt.nil?
41
45
  nxt
@@ -53,14 +57,12 @@ class Sibit::Cryptoapis
53
57
 
54
58
  # Gets the balance of the address, in satoshi.
55
59
  def balance(address)
56
- b = Integer(
57
- Float(
58
- Sibit::Json.new(http: @http, log: @log).get(
59
- Iri.new('https://api.cryptoapis.io/v1/bc/btc/mainnet/address').append(address),
60
- headers: headers
61
- )['payload']['balance']
62
- ) * 100_000_000
63
- )
60
+ b = Sibit::Coins.new(
61
+ Sibit::Json.new(http: @http, log: @log).get(
62
+ Iri.new('https://api.cryptoapis.io/v1/bc/btc/mainnet/address').append(address),
63
+ headers: headers
64
+ )['payload']['balance']
65
+ ).satoshi
64
66
  @log.debug("The balance of #{address} is #{b} satoshi")
65
67
  b
66
68
  end
@@ -90,7 +92,7 @@ class Sibit::Cryptoapis
90
92
  Sibit::Json.new(http: @http, log: @log).post(
91
93
  Iri.new('https://api.cryptoapis.io/v1/bc/btc/mainnet/txs/send'),
92
94
  JSON.pretty_generate(hex: hex),
93
- headers: headers
95
+ headers: headers.merge('Content-Type' => 'application/json')
94
96
  )
95
97
  end
96
98
 
@@ -135,7 +137,7 @@ class Sibit::Cryptoapis
135
137
  outputs: t['txouts'].map do |o|
136
138
  {
137
139
  address: o['addresses'][0],
138
- value: Float(o['amount'] || 0) * 100_000_000
140
+ value: Sibit::Coins.new(o['amount'] || 0).satoshi
139
141
  }
140
142
  end
141
143
  }
data/lib/sibit/json.rb CHANGED
@@ -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 'elapsed'
8
7
  require 'json'
9
8
  require 'loog'
@@ -62,7 +61,7 @@ class Sibit::Json
62
61
  elapsed(@log) do
63
62
  res = @http.client(uri).post(
64
63
  "#{uri.path.empty? ? '/' : uri.path}#{"?#{uri.query}" if uri.query}",
65
- "tx=#{CGI.escape(body)}",
64
+ body,
66
65
  {
67
66
  'Accept' => 'text/plain',
68
67
  'User-Agent' => user_agent,
data/lib/sibit/sochain.rb CHANGED
@@ -111,9 +111,9 @@ class Sibit::Sochain
111
111
  data = Sibit::Json.new(http: @http, log: @log).get(
112
112
  Iri.new('https://sochain.com/api/v2/get_tx_unspent').append(@network).append(address)
113
113
  )['data']
114
- next if data.nil?
114
+ raise(Sibit::Error, "The address #{address} unspent lookup returned no data") if data.nil?
115
115
  txs = data['txs']
116
- next if txs.nil?
116
+ raise(Sibit::Error, "The address #{address} unspent lookup returned no txs") if txs.nil?
117
117
  txs.each do |u|
118
118
  out << {
119
119
  value: Integer((Float(u['value']) * 100_000_000).round),
@@ -162,9 +162,12 @@ class Sibit::Sochain
162
162
  next: nxt,
163
163
  previous: data['previous_blockhash'],
164
164
  txns: (data['txs'] || []).map do |t|
165
+ unless t.is_a?(Hash)
166
+ raise(Sibit::NotSupportedError, "SoChain returned bare txid #{t} without outputs")
167
+ end
165
168
  {
166
- hash: t['txid'] || t,
167
- outputs: ((t.is_a?(Hash) ? t['outputs'] : nil) || []).map do |o|
169
+ hash: t['txid'],
170
+ outputs: (t['outputs'] || []).map do |o|
168
171
  {
169
172
  address: o['address'],
170
173
  value: Integer((Float(o['value']) * 100_000_000).round)
data/lib/sibit/tx.rb CHANGED
@@ -38,12 +38,16 @@ class Sibit
38
38
  end
39
39
 
40
40
  def hash
41
- Digest::SHA256.hexdigest(Digest::SHA256.digest(payload)).reverse.scan(/../).join
41
+ sign_inputs
42
+ Digest::SHA256.hexdigest(
43
+ Digest::SHA256.digest(serialize(witness: false))
44
+ ).scan(/../).reverse.join
42
45
  end
43
46
 
44
47
  def payload
48
+ return @payload if @payload
45
49
  sign_inputs
46
- serialize
50
+ @payload = serialize
47
51
  end
48
52
 
49
53
  def hex
@@ -121,12 +125,24 @@ class Sibit
121
125
 
122
126
  private
123
127
 
128
+ def decoded
129
+ return @decoded if @decoded
130
+ hex = Base58.new(@address).decode
131
+ unless hex.length == 50
132
+ raise(Sibit::Error, "Address '#{@address}' does not decode to 25 bytes")
133
+ end
134
+ unless hex[-8..] == Base58.new(hex[0..-9]).check
135
+ raise(Sibit::Error, "Address '#{@address}' fails its Base58 checksum")
136
+ end
137
+ @decoded = hex
138
+ end
139
+
124
140
  def version
125
- Base58.new(@address).decode[0, 2]
141
+ decoded[0, 2]
126
142
  end
127
143
 
128
144
  def hash160
129
- [Base58.new(@address).decode[2, 40]].pack('H*')
145
+ [decoded[2, 40]].pack('H*')
130
146
  end
131
147
 
132
148
  def p2pkh_script
@@ -138,8 +154,13 @@ class Sibit
138
154
  end
139
155
 
140
156
  def segwit_script
141
- witness = Bech32.new(@address).witness
142
- [0x00, (witness.length / 2)].pack('C*') + [witness].pack('H*')
157
+ bech = Bech32.new(@address)
158
+ program = bech.witness
159
+ [opcode(bech.version), program.length / 2].pack('C*') + [program].pack('H*')
160
+ end
161
+
162
+ def opcode(version)
163
+ version.zero? ? 0x00 : 0x50 + version
143
164
  end
144
165
  end
145
166
 
@@ -150,6 +171,7 @@ class Sibit
150
171
  end
151
172
 
152
173
  def sign_inputs
174
+ return if @signed
153
175
  @inputs.each_with_index do |input, idx|
154
176
  sig = sign(input.key, (input.segwit? ? segwit_sighash(idx) : legacy_sighash(idx)))
155
177
  pubkey = [input.key.pub].pack('H*')
@@ -159,6 +181,7 @@ class Sibit
159
181
  input.script_sig = der_sig(sig) + pubkey_script(pubkey)
160
182
  end
161
183
  end
184
+ @signed = true
162
185
  end
163
186
 
164
187
  def legacy_sighash(idx)
@@ -244,9 +267,9 @@ class Sibit
244
267
  [pubkey.length].pack('C') + pubkey
245
268
  end
246
269
 
247
- def serialize
270
+ def serialize(witness: witness?)
248
271
  result = [VERSION].pack('V')
249
- result += [0x00, 0x01].pack('CC') if witness?
272
+ result += [0x00, 0x01].pack('CC') if witness
250
273
  result += varint(@inputs.length)
251
274
  @inputs.each do |input|
252
275
  result += [input.hash].pack('H*').reverse
@@ -262,7 +285,7 @@ class Sibit
262
285
  result += varint(script.length)
263
286
  result += script
264
287
  end
265
- result += serialize_witness if witness?
288
+ result += serialize_witness if witness
266
289
  result += [0].pack('V')
267
290
  result
268
291
  end
@@ -3,6 +3,7 @@
3
3
  # SPDX-FileCopyrightText: Copyright (c) 2019-2026 Yegor Bugayenko
4
4
  # SPDX-License-Identifier: MIT
5
5
 
6
+ require_relative 'error'
6
7
  require_relative 'key'
7
8
  require_relative 'tx'
8
9
 
@@ -17,6 +18,8 @@ class Sibit
17
18
  # Copyright:: Copyright (c) 2019-2026 Yegor Bugayenko
18
19
  # License:: MIT
19
20
  class TxBuilder
21
+ DUST = 546
22
+
20
23
  def initialize
21
24
  @inputs = []
22
25
  @outputs = []
@@ -47,6 +50,15 @@ class Sibit
47
50
  @outputs.each { |o| txn.add_output(o[:value], o[:address]) }
48
51
  if leave_fee
49
52
  change = input_value - total - extra_fee
53
+ if change.negative?
54
+ raise(
55
+ Sibit::Error,
56
+ "The inputs of #{input_value} cannot cover outputs of #{total} plus fee of #{extra_fee}"
57
+ )
58
+ end
59
+ if change.positive? && change < DUST
60
+ raise(Sibit::Error, "The change of #{change} is below the dust limit of #{DUST}")
61
+ end
50
62
  txn.add_output(change, change_address) if change.positive?
51
63
  end
52
64
  Built.new(txn, @inputs, @outputs)
data/lib/sibit/version.rb CHANGED
@@ -8,5 +8,5 @@
8
8
  # Copyright:: Copyright (c) 2019-2026 Yegor Bugayenko
9
9
  # License:: MIT
10
10
  class Sibit
11
- VERSION = '0.34.0' unless defined?(VERSION)
11
+ VERSION = '0.34.1' unless defined?(VERSION)
12
12
  end
data/lib/sibit.rb CHANGED
@@ -7,6 +7,7 @@ require 'ellipsized'
7
7
  require 'loog'
8
8
  require_relative 'sibit/base58'
9
9
  require_relative 'sibit/blockchain'
10
+ require_relative 'sibit/coins'
10
11
  require_relative 'sibit/key'
11
12
  require_relative 'sibit/script'
12
13
  require_relative 'sibit/tx'
@@ -19,7 +20,7 @@ require_relative 'sibit/version'
19
20
  # Copyright:: Copyright (c) 2019-2026 Yegor Bugayenko
20
21
  # License:: MIT
21
22
  class Sibit
22
- MIN_SATOSHI_PER_BYTE = 0.1
23
+ MIN_SATOSHI_PER_BYTE = 1
23
24
 
24
25
  # Constructor.
25
26
  #
@@ -96,9 +97,13 @@ class Sibit
96
97
  #
97
98
  # If the payment can't be signed (the key is wrong, for example) or the
98
99
  # previous transaction is not found, or there is a network error, or
99
- # any other reason, you will get an exception. In this case, just try again.
100
- # It's safe to try as many times as you need. Don't worry about duplicating
101
- # 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.
102
107
  #
103
108
  # If there are more than 1000 UTXOs in the address where you are trying
104
109
  # to send bitcoins from, this method won't be helpful.
@@ -150,7 +155,7 @@ class Sibit
150
155
  builder = TxBuilder.new
151
156
  unspent = 0
152
157
  size = 100
153
- utxos = @api.utxos(sources.keys)
158
+ utxos = @api.utxos(sources.keys).sort_by { |u| [u[:hash], u[:index]] }
154
159
  @log.debug("#{utxos.count} UTXOs found, these will be used (value/confirmations at tx_hash):")
155
160
  utxos.each do |utxo|
156
161
  if skip_utxo.include?(utxo[:hash])
@@ -313,7 +318,7 @@ class Sibit
313
318
  unless amount.is_a?(String)
314
319
  raise(Error, "Amount should either be a String or Integer, #{amount.class.name} provided")
315
320
  end
316
- return Integer(Float(amount.gsub(/BTC$/, '')) * 100_000_000) if amount.end_with?('BTC')
321
+ return Coins.new(amount.gsub(/BTC$/, '')).satoshi if amount.end_with?('BTC')
317
322
  raise(Error, "Can't understand the amount #{amount.inspect}")
318
323
  end
319
324
 
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.34.0
4
+ version: 0.34.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yegor Bugayenko
@@ -192,6 +192,7 @@ files:
192
192
  - lib/sibit/blockchair.rb
193
193
  - lib/sibit/btc.rb
194
194
  - lib/sibit/cex.rb
195
+ - lib/sibit/coins.rb
195
196
  - lib/sibit/cryptoapis.rb
196
197
  - lib/sibit/dry.rb
197
198
  - lib/sibit/error.rb