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.
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: Copyright (c) 2019-2026 Yegor Bugayenko
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ require 'iri'
7
+ require 'json'
8
+ require 'loog'
9
+ require 'uri'
10
+ require_relative 'error'
11
+ require_relative 'http'
12
+ require_relative 'json'
13
+ require_relative 'version'
14
+
15
+ # SoChain API (formerly known as Block.io).
16
+ #
17
+ # Documentation: https://sochain.com/api
18
+ #
19
+ # Author:: Yegor Bugayenko (yegor256@gmail.com)
20
+ # Copyright:: Copyright (c) 2019-2026 Yegor Bugayenko
21
+ # License:: MIT
22
+ class Sibit::Sochain
23
+ # Constructor.
24
+ def initialize(log: Loog::NULL, http: Sibit::Http.new, network: 'BTC')
25
+ @http = http
26
+ @log = log
27
+ @network = network
28
+ end
29
+
30
+ # Current price of BTC in USD (float returned).
31
+ def price(currency = 'USD')
32
+ data = Sibit::Json.new(http: @http, log: @log).get(
33
+ Iri.new('https://sochain.com/api/v2/get_price').append(@network).append(currency)
34
+ )['data']
35
+ raise(Sibit::Error, "No price data returned for #{@network}/#{currency}") if data.nil?
36
+ prices = data['prices']
37
+ if prices.nil? || prices.empty?
38
+ raise(Sibit::Error, "No price quotes for #{@network}/#{currency}")
39
+ end
40
+ price = Float(prices[0]['price'])
41
+ @log.debug("The price of #{@network} is #{price} #{currency}")
42
+ price
43
+ end
44
+
45
+ # The height of the block, identified by hash.
46
+ def height(hash)
47
+ data = Sibit::Json.new(http: @http, log: @log).get(
48
+ Iri.new('https://sochain.com/api/v2/block').append(@network).append(hash)
49
+ )['data']
50
+ raise(Sibit::Error, "The block #{hash} not found") if data.nil?
51
+ h = data['block_no']
52
+ raise(Sibit::Error, "The block #{hash} found but the height is absent") if h.nil?
53
+ @log.debug("The height of #{hash} is #{h}")
54
+ Integer(h)
55
+ end
56
+
57
+ # Get hash of the block after this one.
58
+ def next_of(hash)
59
+ data = Sibit::Json.new(http: @http, log: @log).get(
60
+ Iri.new('https://sochain.com/api/v2/block').append(@network).append(hash)
61
+ )['data']
62
+ raise(Sibit::Error, "The block #{hash} not found") if data.nil?
63
+ nxt = data['next_blockhash']
64
+ nxt = nil if nxt == '0000000000000000000000000000000000000000000000000000000000000000'
65
+ @log.debug("In SoChain the block #{hash} is the latest, there is no next block") if nxt.nil?
66
+ @log.debug("The next block of #{hash} is #{nxt}") unless nxt.nil?
67
+ nxt
68
+ end
69
+
70
+ # Gets the balance of the address, in satoshi.
71
+ def balance(address)
72
+ data = Sibit::Json.new(http: @http, log: @log).get(
73
+ Iri.new('https://sochain.com/api/v2/get_address_balance').append(@network).append(address)
74
+ )['data']
75
+ if data.nil?
76
+ @log.debug("The balance of #{address} is probably zero (not found)")
77
+ return 0
78
+ end
79
+ confirmed = data['confirmed_balance']
80
+ if confirmed.nil?
81
+ @log.debug("The balance of #{address} is probably zero (no confirmed_balance)")
82
+ return 0
83
+ end
84
+ b = Integer((Float(confirmed) * 100_000_000).round)
85
+ @log.debug("The balance of #{address} is #{b} satoshi")
86
+ b
87
+ end
88
+
89
+ # Get recommended fees, in satoshi per byte.
90
+ def fees
91
+ raise(Sibit::NotSupportedError, 'SoChain doesn\'t provide recommended fees')
92
+ end
93
+
94
+ # Gets the hash of the latest block.
95
+ def latest
96
+ data = Sibit::Json.new(http: @http, log: @log).get(
97
+ Iri.new('https://sochain.com/api/v2/get_info').append(@network)
98
+ )['data']
99
+ raise(Sibit::Error, 'The latest block info not found') if data.nil?
100
+ hash = data['blockhash']
101
+ raise(Sibit::Error, 'The latest block hash is absent') if hash.nil?
102
+ @log.debug("The latest block hash is #{hash}")
103
+ hash
104
+ end
105
+
106
+ # Fetch all unspent outputs per address. The argument is an array
107
+ # of Bitcoin addresses.
108
+ def utxos(sources)
109
+ out = []
110
+ sources.each do |address|
111
+ data = Sibit::Json.new(http: @http, log: @log).get(
112
+ Iri.new('https://sochain.com/api/v2/get_tx_unspent').append(@network).append(address)
113
+ )['data']
114
+ raise(Sibit::Error, "The address #{address} unspent lookup returned no data") if data.nil?
115
+ txs = data['txs']
116
+ raise(Sibit::Error, "The address #{address} unspent lookup returned no txs") if txs.nil?
117
+ txs.each do |u|
118
+ out << {
119
+ value: Integer((Float(u['value']) * 100_000_000).round),
120
+ hash: u['txid'],
121
+ index: u['output_no'],
122
+ confirmations: u['confirmations'],
123
+ script: [u['script_hex']].pack('H*')
124
+ }
125
+ end
126
+ end
127
+ out
128
+ end
129
+
130
+ # Push this transaction (in hex format) to the network. SoChain expects a
131
+ # JSON payload with the +tx_hex+ field on POST /send_tx/{network}.
132
+ def push(hex)
133
+ uri = URI(Iri.new('https://sochain.com/api/v2/send_tx').append(@network).to_s)
134
+ res = @http.client(uri).post(
135
+ uri.path,
136
+ JSON.generate(tx_hex: hex),
137
+ {
138
+ 'Accept' => 'application/json',
139
+ 'Content-Type' => 'application/json',
140
+ 'Accept-Charset' => 'UTF-8',
141
+ 'Accept-Encoding' => ''
142
+ }
143
+ )
144
+ unless res.code == '200'
145
+ raise(Sibit::Error, "Failed to push transaction to #{uri}: #{res.code}\n#{res.body}")
146
+ end
147
+ @log.debug("Transaction (#{hex.length} chars in hex) has been pushed to SoChain")
148
+ end
149
+
150
+ # This method should fetch a block and return it as a hash.
151
+ def block(hash)
152
+ data = Sibit::Json.new(http: @http, log: @log).get(
153
+ Iri.new('https://sochain.com/api/v2/block').append(@network).append(hash)
154
+ )['data']
155
+ raise(Sibit::Error, "The block #{hash} not found") if data.nil?
156
+ nxt = data['next_blockhash']
157
+ nxt = nil if nxt == '0000000000000000000000000000000000000000000000000000000000000000'
158
+ {
159
+ provider: self.class.name,
160
+ hash: data['blockhash'],
161
+ orphan: data['is_orphan'] == true,
162
+ next: nxt,
163
+ previous: data['previous_blockhash'],
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
168
+ {
169
+ hash: t['txid'],
170
+ outputs: (t['outputs'] || []).map do |o|
171
+ {
172
+ address: o['address'],
173
+ value: Integer((Float(o['value']) * 100_000_000).round)
174
+ }
175
+ end
176
+ }
177
+ end
178
+ }
179
+ end
180
+ end
data/lib/sibit/tx.rb CHANGED
@@ -6,6 +6,7 @@
6
6
  require 'digest'
7
7
  require_relative 'base58'
8
8
  require_relative 'bech32'
9
+ require_relative 'error'
9
10
  require_relative 'key'
10
11
  require_relative 'script'
11
12
 
@@ -37,12 +38,16 @@ class Sibit
37
38
  end
38
39
 
39
40
  def hash
40
- 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
41
45
  end
42
46
 
43
47
  def payload
48
+ return @payload if @payload
44
49
  sign_inputs
45
- serialize
50
+ @payload = serialize
46
51
  end
47
52
 
48
53
  def hex
@@ -105,7 +110,9 @@ class Sibit
105
110
 
106
111
  def script
107
112
  return segwit_script if segwit?
108
- p2pkh_script
113
+ return p2pkh_script if %w[00 6f].include?(version)
114
+ return p2sh_script if %w[05 c4].include?(version)
115
+ raise(Sibit::Error, "Address '#{@address}' has an unsupported version byte 0x#{version}")
109
116
  end
110
117
 
111
118
  def segwit?
@@ -118,17 +125,42 @@ class Sibit
118
125
 
119
126
  private
120
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
+
140
+ def version
141
+ decoded[0, 2]
142
+ end
143
+
144
+ def hash160
145
+ [decoded[2, 40]].pack('H*')
146
+ end
147
+
121
148
  def p2pkh_script
122
- decoded = Base58.new(@address).decode
123
- hash = decoded[2..41]
124
- [0x76, 0xa9, 0x14].pack('C*') + [hash].pack('H*') + [0x88, 0xac].pack('C*')
149
+ [0x76, 0xa9, 0x14].pack('C*') + hash160 + [0x88, 0xac].pack('C*')
150
+ end
151
+
152
+ def p2sh_script
153
+ [0xa9, 0x14].pack('C*') + hash160 + [0x87].pack('C*')
125
154
  end
126
155
 
127
156
  def segwit_script
128
157
  bech = Bech32.new(@address)
129
- witness = bech.witness
130
- len = witness.length / 2
131
- [0x00, len].pack('C*') + [witness].pack('H*')
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
132
164
  end
133
165
  end
134
166
 
@@ -139,23 +171,23 @@ class Sibit
139
171
  end
140
172
 
141
173
  def sign_inputs
174
+ return if @signed
142
175
  @inputs.each_with_index do |input, idx|
143
- sighash = input.segwit? ? segwit_sighash(idx) : legacy_sighash(idx)
144
- sig = sign(input.key, sighash)
176
+ sig = sign(input.key, (input.segwit? ? segwit_sighash(idx) : legacy_sighash(idx)))
145
177
  pubkey = [input.key.pub].pack('H*')
146
178
  if input.segwit?
147
- witness_sig = sig + [SIGHASH_ALL].pack('C')
148
- input.witness = [witness_sig.bytes, pubkey.bytes]
179
+ input.witness = [(sig + [SIGHASH_ALL].pack('C')).bytes, pubkey.bytes]
149
180
  else
150
181
  input.script_sig = der_sig(sig) + pubkey_script(pubkey)
151
182
  end
152
183
  end
184
+ @signed = true
153
185
  end
154
186
 
155
187
  def legacy_sighash(idx)
156
- tx_copy = serialize_for_signing(idx)
157
- hash_type = [SIGHASH_ALL].pack('V')
158
- Digest::SHA256.digest(Digest::SHA256.digest(tx_copy + hash_type))
188
+ Digest::SHA256.digest(
189
+ Digest::SHA256.digest(serialize_for_signing(idx) + [SIGHASH_ALL].pack('V'))
190
+ )
159
191
  end
160
192
 
161
193
  def segwit_sighash(idx)
@@ -175,48 +207,55 @@ class Sibit
175
207
  end
176
208
 
177
209
  def hash_prevouts
178
- data = @inputs.map { |i| [i.hash].pack('H*').reverse + [i.index].pack('V') }.join
179
- Digest::SHA256.digest(Digest::SHA256.digest(data))
210
+ Digest::SHA256.digest(
211
+ Digest::SHA256.digest(
212
+ @inputs.map do |i|
213
+ [i.hash].pack('H*').reverse + [i.index].pack('V')
214
+ end.join
215
+ )
216
+ )
180
217
  end
181
218
 
182
219
  def hash_sequence
183
- data = @inputs.map { [SEQUENCE].pack('V') }.join
184
- Digest::SHA256.digest(Digest::SHA256.digest(data))
220
+ Digest::SHA256.digest(Digest::SHA256.digest(@inputs.map { [SEQUENCE].pack('V') }.join))
185
221
  end
186
222
 
187
223
  def hash_outputs
188
- data = @outputs.map { |o| [o.value].pack('Q<') + varint(o.script.length) + o.script }.join
189
- Digest::SHA256.digest(Digest::SHA256.digest(data))
224
+ Digest::SHA256.digest(
225
+ Digest::SHA256.digest(
226
+ @outputs.map do |o|
227
+ [o.value].pack('Q<') + varint(o.script.length) + o.script
228
+ end.join
229
+ )
230
+ )
190
231
  end
191
232
 
192
233
  def script_code(input)
193
- hash160 = input.prev_script[4..]
194
- code = [0x76, 0xa9, 0x14].pack('C*') + [hash160].pack('H*') + [0x88, 0xac].pack('C*')
234
+ code = [
235
+ 0x76, 0xa9,
236
+ 0x14
237
+ ].pack('C*') + [input.prev_script[4..]].pack('H*') + [0x88, 0xac].pack('C*')
195
238
  varint(code.length) + code
196
239
  end
197
240
 
198
241
  def sign(key, hash)
199
- der = key.sign(hash)
200
- repack(der)
242
+ repack(key.sign(hash))
201
243
  end
202
244
 
203
245
  def repack(der)
204
246
  return der if low_s?(der)
205
247
  seq = OpenSSL::ASN1.decode(der)
206
- r = seq.value[0].value.to_i
207
- s = seq.value[1].value.to_i
248
+ s = Integer(seq.value[1].value)
208
249
  order = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
209
250
  s = order - s if s > order / 2
210
251
  OpenSSL::ASN1::Sequence.new(
211
- [OpenSSL::ASN1::Integer.new(r), OpenSSL::ASN1::Integer.new(s)]
252
+ [OpenSSL::ASN1::Integer.new(Integer(seq.value[0].value)), OpenSSL::ASN1::Integer.new(s)]
212
253
  ).to_der
213
254
  end
214
255
 
215
256
  def low_s?(der)
216
- seq = OpenSSL::ASN1.decode(der)
217
- s = seq.value[1].value.to_i
218
- order = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
219
- s <= order / 2
257
+ Integer(OpenSSL::ASN1.decode(der).value[1].value) <=
258
+ 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 / 2
220
259
  end
221
260
 
222
261
  def der_sig(sig)
@@ -228,9 +267,9 @@ class Sibit
228
267
  [pubkey.length].pack('C') + pubkey
229
268
  end
230
269
 
231
- def serialize
270
+ def serialize(witness: witness?)
232
271
  result = [VERSION].pack('V')
233
- result += [0x00, 0x01].pack('CC') if witness?
272
+ result += [0x00, 0x01].pack('CC') if witness
234
273
  result += varint(@inputs.length)
235
274
  @inputs.each do |input|
236
275
  result += [input.hash].pack('H*').reverse
@@ -246,7 +285,7 @@ class Sibit
246
285
  result += varint(script.length)
247
286
  result += script
248
287
  end
249
- result += serialize_witness if witness?
288
+ result += serialize_witness if witness
250
289
  result += [0].pack('V')
251
290
  result
252
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 = []
@@ -24,7 +27,7 @@ class Sibit
24
27
 
25
28
  def input
26
29
  inp = Input.new
27
- yield inp
30
+ yield(inp)
28
31
  @inputs << inp
29
32
  end
30
33
 
@@ -43,10 +46,19 @@ class Sibit
43
46
  value: inp.amount
44
47
  )
45
48
  end
46
- total_out = @outputs.sum { |o| o[:value] }
49
+ total = @outputs.sum { |o| o[:value] }
47
50
  @outputs.each { |o| txn.add_output(o[:value], o[:address]) }
48
51
  if leave_fee
49
- change = input_value - total_out - extra_fee
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,6 +8,5 @@
8
8
  # Copyright:: Copyright (c) 2019-2026 Yegor Bugayenko
9
9
  # License:: MIT
10
10
  class Sibit
11
- # Current version of the library.
12
- VERSION = '0.33.0' unless defined?(VERSION)
11
+ VERSION = '0.34.1' unless defined?(VERSION)
13
12
  end