lib/sibit.rb in sibit-0.12.5 vs lib/sibit.rb in sibit-0.12.6

- old
+ new

@@ -18,135 +18,75 @@ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -require 'net/http' -require 'uri' require 'bitcoin' -require 'json' -require 'cgi' require_relative 'sibit/version' +require_relative 'sibit/log' +require_relative 'sibit/blockchain' # Sibit main class. # -# It works through the Blockchain API at the moment: -# https://www.blockchain.com/api/blockchain_api -# # Author:: Yegor Bugayenko (yegor256@gmail.com) # Copyright:: Copyright (c) 2019 Yegor Bugayenko # License:: MIT class Sibit - # If something goes wrong. - class Error < StandardError; end - - # Fake one, which is useful for testing. - class Fake - def price(_cur = 'USD') - 4_000 - end - - def fees - { S: 12, M: 45, L: 100, XL: 200 } - end - - def generate - 'fd2333686f49d8647e1ce8d5ef39c304520b08f3c756b67068b30a3db217dcb2' - end - - def create(_pvt) - '1JvCsJtLmCxEk7ddZFnVkGXpr9uhxZPmJi' - end - - def balance(_address) - 100_000_000 - end - - def pay(_amount, _fee, _sources, _target, _change) - '9dfe55a30b5ee732005158c589179a398117117a68d21531fb6c78b85b544c54' - end - - def latest - '00000000000000000008df8a6e1b61d1136803ac9791b8725235c9f780b4ed71' - end - - def get_json(_uri) - {} - end - end - - # This HTTP client will be used by default. - def self.default_http - http = Net::HTTP.new('blockchain.info', 443) - http.use_ssl = true - http - end - - # This HTTP client with proxy. - def self.proxy_http(addr) - host, port = addr.split(':') - http = Net::HTTP.new('blockchain.info', 443, host, port.to_i) - http.use_ssl = true - http - end - # 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. - def initialize(log: STDOUT, http: Sibit.default_http, dry: false, attempts: 1) - @log = log - @http = http - @dry = dry - @attempts = attempts + # + # 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 succeedes. + def initialize(log: STDOUT, api: Sibit::Blockchain.new(log: Sibit::Log.new(log))) + @log = Sibit::Log.new(log) + @api = api end - # Current price of 1 BTC. - def price(cur = 'USD') - h = get_json('/ticker')[cur.upcase] - raise Error, "Unrecognized currency #{cur}" if h.nil? - h['15m'] + # Current price of 1 BTC in USD (or another currency), float returned. + def price(currency = 'USD') + first_one do |api| + api.price(currency) + end end # Generates new Bitcon private key and returns in Hash160 format. def generate key = Bitcoin::Key.generate.priv - info("Bitcoin private key generated: #{key[0..8]}...") + @log.info("Bitcoin private key generated: #{key[0..8]}...") key end # Creates Bitcon address using the private key in Hash160 format. def create(pvt) - key(pvt).addr + key = Bitcoin::Key.new + key.priv = pvt + key.addr end # Gets the balance of the address, in satoshi. def balance(address) - json = get_json("/rawaddr/#{address}") - info("Total transactions: #{json['n_tx']}") - info("Received/sent: #{json['total_received']}/#{json['total_sent']}") - json['final_balance'] + first_one do |api| + api.balance(address) + end end # Get recommended fees, in satoshi per byte. The method returns # a hash: { S: 12, M: 45, L: 100, XL: 200 } def fees - json = JSON.parse( - Net::HTTP.get( - URI('https://bitcoinfees.earn.com/api/v1/fees/recommended') - ) - ) - info("Current recommended Bitcoin fees: \ -#{json['hourFee']}/#{json['halfHourFee']}/#{json['fastestFee']} sat/byte") - { - S: json['hourFee'] / 3, - M: json['hourFee'], - L: json['halfHourFee'], - XL: json['fastestFee'] - } + first_one(&:fees) end # Sends a payment and returns the transaction hash. # # If the payment can't be signed (the key is wrong, for example) or the @@ -163,194 +103,34 @@ # +sources+: the hashmap of bitcoin addresses where the coins are now, with # their addresses as keys and private keys as values # +target+: the target address to send to # +change+: the address where the change has to be sent to def pay(amount, fee, sources, target, change) - p = price - satoshi = satoshi(amount) - f = mfee(fee, size_of(amount, sources)) - satoshi += f if f.negative? - 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 = Bitcoin::Builder::TxBuilder.new - unspent = 0 - size = 100 - utxos = get_json( - "/unspent?active=#{sources.keys.join('|')}&limit=1000" - )['unspent_outputs'] - info("#{utxos.count} UTXOs found, these will be used \ -(value/confirmations at tx_hash):") - utxos.each do |utxo| - unspent += utxo['value'] - builder.input do |i| - i.prev_out(utxo['tx_hash_big_endian']) - i.prev_out_index(utxo['tx_output_n']) - i.prev_out_script = [utxo['script']].pack('H*') - address = Bitcoin::Script.new([utxo['script']].pack('H*')).get_address - i.signature_key(key(sources[address])) - end - size += 180 - info(" #{num(utxo['value'], p)}/#{utxo['confirmations']} at #{utxo['tx_hash_big_endian']}") - break if unspent > satoshi + first_one do |api| + api.pay(amount, fee, sources, target, change) end - if unspent < satoshi - raise Error, "Not enough funds to send #{num(satoshi, p)}, only #{num(unspent, p)} left" - end - builder.output(satoshi, target) - f = mfee(fee, size) - tx = builder.tx( - input_value: unspent, - leave_fee: true, - extra_fee: [f, Bitcoin.network[:min_tx_fee]].max, - change_address: change - ) - left = unspent - tx.outputs.map(&:value).inject(&:+) - info("A new Bitcoin transaction #{tx.hash} prepared: - #{tx.in.count} input#{tx.in.count > 1 ? 's' : ''}: - #{tx.inputs.map { |i| " in: #{i.prev_out.bth}:#{i.prev_out_index}" }.join("\n ")} - #{tx.out.count} output#{tx.out.count > 1 ? 's' : ''}: - #{tx.outputs.map { |o| "out: #{o.script.bth} / #{num(o.value, p)}" }.join("\n ")} - Min tx fee: #{num(Bitcoin.network[:min_tx_fee], p)} - Fee requested: #{num(f, p)} as \"#{fee}\" - Fee left: #{num(left, p)} - Tx size: #{size} bytes - Unspent: #{num(unspent, p)} - Amount: #{num(satoshi, p)} - Target address: #{target} - Change address is #{change}") - post_tx(tx.to_payload.bth) unless @dry - tx.hash end # Gets the hash of the latest block. def latest - get_json('/latestblock')['hash'] + first_one(&:latest) end - # Send GET request to the Blockchain API and return JSON response. - # This method will also log the process and will validate the - # response for correctness. - def get_json(uri) - start = Time.now - attempt = 0 - begin - res = @http.get( - uri, - 'Accept' => 'text/plain', - 'User-Agent' => user_agent, - 'Accept-Encoding' => '' - ) - raise Error, "Failed to retrieve #{uri} (#{res.code}): #{res.body}" unless res.code == '200' - info("GET #{uri}: #{res.code}/#{res.body.length}b in #{age(start)}") - JSON.parse(res.body) - rescue StandardError => e - attempt += 1 - raise e if attempt >= @attempts - retry - end - end - private - def num(satoshi, usd) - format( - '%<satoshi>ss/$%<dollars>0.2f', - satoshi: satoshi.to_s.gsub(/\d(?=(...)+$)/, '\0,'), - dollars: satoshi * usd / 100_000_000 - ) - end - - # Convert text to amount. - def satoshi(amount) - return amount if amount.is_a?(Integer) - raise Error, 'Amount should either be a String or Integer' unless amount.is_a?(String) - return (amount.gsub(/BTC$/, '').to_f * 100_000_000).to_i if amount.end_with?('BTC') - raise Error, "Can't understand the amount #{amount.inspect}" - end - - # Calculates a fee in satoshi for the transaction of the specified size. - # The +fee+ argument could be a number in satoshi, in which case it will - # be returned as is, or a string like "XL" or "S", in which case the - # fee will be calculated using the +size+ argument (which is the size - # of the transaction in bytes). - def mfee(fee, size) - return fee.to_i if fee.is_a?(Integer) - raise Error, 'Fee should either be a String or Integer' unless fee.is_a?(String) - mul = 1 - if fee.start_with?('+', '-') - mul = -1 if fee.start_with?('-') - fee = fee[1..-1] - end - sat = fees[fee.to_sym] - raise Error, "Can't understand the fee: #{fee.inspect}" if sat.nil? - mul * sat * size - end - - # Make key from private key string in Hash160. - def key(hash160) - key = Bitcoin::Key.new - key.priv = hash160 - key - end - - def age(start) - "#{((Time.now - start) * 1000).round}ms" - end - - def post_tx(body) - start = Time.now - attempt = 0 - begin - uri = '/pushtx' - res = @http.post( - '/pushtx', - "tx=#{CGI.escape(body)}", - 'Accept' => 'text/plain', - 'User-Agent' => user_agent, - 'Accept-Encoding' => '', - 'Content-Type' => 'application/x-www-form-urlencoded' - ) - raise Error, "Failed to post tx to #{uri}: #{res.code}\n#{res.body}" unless res.code == '200' - info("POST #{uri}: #{res.code} in #{age(start)}") - rescue StandardError => e - attempt += 1 - raise e if attempt >= @attempts - retry - end - end - - # Calculate an approximate size of the transaction. - def size_of(amount, sources) - satoshi = satoshi(amount) - builder = Bitcoin::Builder::TxBuilder.new - unspent = 0 - size = 100 - utxos = get_json( - "/unspent?active=#{sources.keys.join('|')}&limit=1000" - )['unspent_outputs'] - utxos.each do |utxo| - unspent += utxo['value'] - builder.input do |i| - i.prev_out(utxo['tx_hash_big_endian']) - i.prev_out_index(utxo['tx_output_n']) - i.prev_out_script = [utxo['script']].pack('H*') - address = Bitcoin::Script.new([utxo['script']].pack('H*')).get_address - i.signature_key(key(sources[address])) + def first_one + return yield @api unless @api.is_a?(Array) + done = false + result = nil + @api.each do |api| + begin + result = yield api + done = true + break + rescue Sibit::Error => e + @log.info("The API #{api.class.name} failed: #{e.message}") end - size += 180 - break if unspent > satoshi end - size - end - - def info(msg) - if @log.respond_to?(:info) - @log.info(msg) - elsif @log.respond_to?(:puts) - @log.puts(msg) - end - end - - def user_agent - "Anonymous/#{Sibit::VERSION}" + raise Sibit::Error, 'No APIs managed to succeed' unless done + result end end