Class: Sibit

Inherits:
Object
  • Object
show all
Defined in:
lib/sibit.rb,
lib/sibit/tx.rb,
lib/sibit/bin.rb,
lib/sibit/key.rb,
lib/sibit/http.rb,
lib/sibit/coins.rb,
lib/sibit/error.rb,
lib/sibit/base58.rb,
lib/sibit/bech32.rb,
lib/sibit/script.rb,
lib/sibit/version.rb,
lib/sibit/httpproxy.rb,
lib/sibit/txbuilder.rb

Overview

Sibit main class.

Defined Under Namespace

Classes: Base58, Bech32, BestOf, Bin, Bitcoinchain, Blockchain, Blockchair, Btc, Cex, Coins, Cryptoapis, Dry, Error, Fake, FirstOf, Http, HttpProxy, Json, Key, NotSupportedError, Script, Sochain, Tx, TxBuilder

Constant Summary collapse

MIN_SATOSHI_PER_BYTE =
1
VERSION =
'0.34.2'

Instance Method Summary collapse

Constructor Details

#initialize(log: Loog::NULL, api: Sibit::Blockchain.new(log: log)) ⇒ Sibit

Constructor.

You may provide the log you want to see the messages in. If you don't provide anything, the console will be used. The object you provide has to respond to the method info or puts in order to receive logging messages.

It is recommended to wrap the API in a RetriableProxy from retriable_proxy gem and to configure it to retry on Sibit::Error:

RetriableProxy.for_object(api, on: Sibit::Error)

This will help you avoid some temporary network issues.

The api argument can be an object or an array of objects. If an array is provided, we will make an attempt to try them one by one, until one of them succeeds.



42
43
44
45
# File 'lib/sibit.rb', line 42

def initialize(log: Loog::NULL, api: Sibit::Blockchain.new(log: log))
  @log = log
  @api = api
end

Instance Method Details

#balance(address, trust: 0) ⇒ Object

Gets the balance of the address, in satoshi. When trust is greater than zero, only UTXOs with at least that many confirmations are summed, which requires the underlying API to support utxos.

Raises:



69
70
71
72
73
74
75
76
# File 'lib/sibit.rb', line 69

def balance(address, trust: 0)
  raise(Error, "Invalid address #{address.inspect}") unless /^[0-9a-zA-Z]+$/.match?(address)
  unless trust.is_a?(Integer) && !trust.negative?
    raise(Error, "Trust must be a non-negative Integer, got #{trust.inspect}")
  end
  return @api.balance(address) if trust.zero?
  @api.utxos([address]).sum { |u| (u[:confirmations] || 0) >= trust ? u[:value] : 0 }
end

#create(pvt) ⇒ Object

Creates Bitcoin address using the private key in Hash160 format.

Raises:



61
62
63
64
# File 'lib/sibit.rb', line 61

def create(pvt)
  raise(Error, 'Invalid private key (must be 64 hex chars)') unless /^[0-9a-f]{64}$/.match?(pvt)
  Key.new(pvt).bech32
end

#feesObject

Get recommended fees, in satoshi per byte. The method returns a hash: { S: 12, M: 45, L: 100, XL: 200 }



92
93
94
# File 'lib/sibit.rb', line 92

def fees
  @api.fees
end

#generateObject

Generates new Bitcoin private key and returns in Hash160 format.



54
55
56
57
58
# File 'lib/sibit.rb', line 54

def generate
  key = Key.generate.priv
  @log.debug("Bitcoin private key generated: #{key.ellipsized(8)}...")
  key
end

#height(hash) ⇒ Object

Get the height of the block.

Raises:



79
80
81
82
# File 'lib/sibit.rb', line 79

def height(hash)
  raise(Error, "Invalid block hash #{hash.inspect}") unless /^[0-9a-f]{64}$/.match?(hash)
  @api.height(hash)
end

#latestObject

Gets the hash of the latest block.



231
232
233
# File 'lib/sibit.rb', line 231

def latest
  @api.latest
end

#next_of(hash) ⇒ Object

Get the hash of the next block.

Raises:



85
86
87
88
# File 'lib/sibit.rb', line 85

def next_of(hash)
  raise(Error, "Invalid block hash #{hash.inspect}") unless /^[0-9a-f]{64}$/.match?(hash)
  @api.next_of(hash)
end

#pay(amount, fee, sources, target, change, skip_utxo: [], network: nil, base58: false, price: nil, trust: 0) ⇒ Object

Sends a payment and returns the transaction hash.

If the payment can't be signed (the key is wrong, for example) or the previous transaction is not found, or there is a network error, or any other reason, you will get an exception. You may retry, but only while no earlier attempt could have reached the network: inputs are selected deterministically, so an immediate retry rebuilds a conflicting transaction that the network rejects as a double-spend. Once an earlier attempt might have confirmed, the wallet holds a fresh set of UTXOs and a blind retry may pay the target twice; check the target address (or the previous transaction hash) before re-sending.

If there are more than 1000 UTXOs in the address where you are trying to send bitcoins from, this method won't be helpful.

amount: the amount either in satoshis or ending with 'BTC', like '0.7BTC' fee: the miners fee in satoshis (as integer) or S/M/X/XL as a string sources: the list of private bitcoin keys where the coins are now target: the target address to send to change: the address where the change has to be sent to network: optional network override (:mainnet, :testnet, :regtest) price: optional BTC price in USD (skips API fetch if provided)

Raises:



118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
# File 'lib/sibit.rb', line 118

def pay(
  amount, fee, sources, target, change, skip_utxo: [], network: nil, base58: false,
  price: nil, trust: 0
)
  unless amount.is_a?(Integer) || amount.is_a?(String)
    raise(Error, "The amount #{amount.inspect} must be Integer or String")
  end
  raise(Error, 'The amount must be positive') if amount.is_a?(Integer) && amount.negative?
  raise(Error, 'The sources must be an Array') unless sources.is_a?(Array)
  raise(Error, 'The target must be a String') unless target.is_a?(String)
  raise(Error, 'The change must be a String') unless change.is_a?(String)
  unless trust.is_a?(Integer) && !trust.negative?
    raise(Error, "Trust must be a non-negative Integer, got #{trust.inspect}")
  end
  p = price || price('USD')
  keys =
    sources.map do |k|
      raise(Error, 'Each source private key must be a String') unless k.is_a?(String)
      wif = /^[5KLc][1-9A-HJ-NP-Za-km-z]{50,51}$/.match?(k)
      unless /^[0-9a-f]{64}$/i.match?(k) || wif
        raise(Error, "Invalid private key format: #{k.inspect.ellipsized(8)}")
      end
      Key.new(k, network: network)
    end
  network = keys.first&.network || :mainnet
  sources =
    keys.to_h do |k|
      pub =
        if base58
          k.base58
        else
          k.bech32
        end
      @log.debug("Private key #{k.priv.ellipsized(8).inspect} is public as #{pub}:")
      [pub, k.priv]
    end
  satoshi = satoshi(amount)
  builder = TxBuilder.new(network)
  unspent = 0
  size = 100
  utxos = @api.utxos(sources.keys).sort_by { |u| [u[:hash], u[:index]] }
  @log.debug("#{utxos.count} UTXOs found, these will be used (value/confirmations at tx_hash):")
  utxos.each do |utxo|
    if skip_utxo.include?(utxo[:hash])
      @log.debug("UTXO skipped: #{utxo[:hash]}")
      next
    end
    unless utxo[:confirmations]&.positive?
      @log.debug("UTXO with no confirmations: #{utxo[:hash]}")
      next
    end
    if utxo[:confirmations] < trust
      @log.debug("UTXO below trust=#{trust} (#{utxo[:confirmations]} conf): #{utxo[:hash]}")
      next
    end
    unspent += utxo[:value]
    builder.input do |i|
      i.prev_out(utxo[:hash])
      i.prev_out_index(utxo[:index])
      i.prev_out_script = script_hex(utxo[:script])
      i.prev_out_value(utxo[:value])
      address = Script.new(script_hex(utxo[:script])).address(network)
      k = sources[address]
      raise(Error, "UTXO arrived to #{address} is incorrect") unless k
      i.signature_key(key(k))
    end
    size += 180
    @log.debug("  #{num(utxo[:value], p)}/#{utxo[:confirmations]} at #{utxo[:hash]}")
    break if unspent > satoshi
  end
  if unspent < satoshi
    raise(Error, "Not enough funds to send #{num(satoshi, p)}, only #{num(unspent, p)} left")
  end
  f = mfee(fee, size)
  if f.negative?
    satoshi += f
    f = -f
  end
  raise(Error, "The fee #{f.abs} covers the entire amount") if satoshi.zero?
  raise(Error, "The fee #{f.abs} is bigger than the amount #{satoshi}") if satoshi.negative?
  builder.output(satoshi, target)
  tx = builder.tx(
    input_value: unspent,
    leave_fee: true,
    extra_fee: [f, Integer(size * MIN_SATOSHI_PER_BYTE)].max,
    change_address: change
  )
  @log.debug(
    [
      "A new Bitcoin transaction #{tx.hash} prepared:",
      "#{tx.in.count} input#{'s' if tx.in.count > 1}:",
      tx.inputs.map do |i|
        "  in: #{i.prev_out.unpack1('H*')}:#{i.prev_out_index}"
      end,
      "#{tx.out.count} output#{'s' if tx.out.count > 1}:",
      tx.outputs.map do |o|
        "  out: #{o.script_hex} / #{num(o.value, p)}"
      end,
      "Min fee: #{num(MIN_SATOSHI_PER_BYTE, p)} /byte",
      "Fee requested: #{num(f, p)} as \"#{fee}\"",
      "Fee actually paid: #{num(unspent - tx.outputs.sum(&:value), p)}",
      "Tx size: #{size} bytes",
      "Unspent: #{num(unspent, p)}",
      "Amount: #{num(satoshi, p)}",
      "Target address: #{target}",
      ("Change address: #{change}" if tx.out.count > 1)
    ].flatten.compact.join("\n")
  )
  @api.push(tx.to_payload.bth)
  tx.hash
end

#price(currency = 'USD') ⇒ Object

Current price of 1 BTC in USD (or another currency), float returned.

Raises:



48
49
50
51
# File 'lib/sibit.rb', line 48

def price(currency = 'USD')
  raise(Error, "Invalid currency #{currency.inspect}") unless /^[A-Z]{3}$/.match?(currency)
  @api.price(currency)
end

#scan(start, max: 4) ⇒ Object

You call this method and provide a callback. You provide the hash of the starting block. The method will go through the Blockchain, fetch blocks and find transactions, one by one, passing them to the callback provided. When finished, the method returns the hash of a new block, not scanned yet or NIL if it's the end of Blockchain.

The callback will be called with three arguments:

  1. Bitcoin address of the receiver, 2) transaction hash, 3) amount in satoshi. The callback should return non-false if the transaction found was useful.

Raises:



245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
# File 'lib/sibit.rb', line 245

def scan(start, max: 4)
  raise(Error, "Invalid block hash #{start.inspect}") unless /^[0-9a-f]{64}$/.match?(start)
  raise(Error, "The max number must be above zero: #{max}") if max < 1
  block = start
  count = 0
  json = {}
  loop do
    json = @api.block(block)
    if json[:orphan]
      steps = 4
      @log.debug("Orphan block found at #{block}, moving #{steps} steps back...")
      steps.times do
        block = json[:previous]
        @log.debug("Moved back to #{block}")
        json = @api.block(block)
      end
      next
    end
    checked = 0
    outputs = 0
    json[:txns].each do |t|
      t[:outputs].each_with_index do |o, i|
        address = o[:address]
        outputs += 1
        hash = "#{t[:hash]}:#{i}"
        satoshi = o[:value]
        if yield(address, hash, satoshi)
          @log.debug("Bitcoin tx found at #{hash} for #{satoshi} sent to #{address}")
        end
      end
      checked += 1
    end
    count += 1
    @log.debug(
      "Checked #{checked} txns and #{outputs} outputs " \
      "in block #{block} (by #{json[:provider]})"
    )
    block = json[:next]
    begin
      if block.nil?
        @log.debug("The next_block is empty in #{json[:hash]}, this may be the end...")
        block = @api.next_of(json[:hash])
      end
    rescue Sibit::Error => e
      @log.debug("Failed to get the next_of(#{json[:hash]}), quitting: #{e.message}")
      break
    end
    if block.nil?
      @log.debug("The block #{json[:hash]} is definitely the end of Blockchain, we stop.")
      break
    end
    if count >= max
      @log.debug("Too many blocks (#{count}) in one go, let's get back to it next time")
      break
    end
  end
  @log.debug("Scanned from #{start} to #{json[:hash]} (#{count} blocks)")
  json[:hash]
end