inertia186/drotto

View on GitHub
lib/drotto/chain.rb

Summary

Maintainability
F
1 wk
Test Coverage
module DrOtto
  require 'drotto/utils'
  
  VOTE_RECHARGE_PER_DAY = 20.0
  VOTE_RECHARGE_PER_HOUR = VOTE_RECHARGE_PER_DAY / 24
  VOTE_RECHARGE_PER_MINUTE = VOTE_RECHARGE_PER_HOUR / 60
  VOTE_RECHARGE_PER_SEC = VOTE_RECHARGE_PER_MINUTE / 60
  
  module Chain
    include Krang::Chain
    include Config
    include Utils
    
    def head_block
      case block_mode
      when 'head' then properties.head_block_number
      when 'irreversible' then properties.last_irreversible_block_num
      else; raise "Unknown block mode: #{block_mode}"
      end
    end
    
    # This method assumes that voting is in progress if current voting power has
    # reached 100 % or the latest vote cast is less than one minute ago.
    def voting_in_progress?
      return true if current_voting_power(log: false) == 100.0
        
      account = api.get_accounts([voter_account_name]) do |accounts|
        accounts.first
      end
      
      last_vote_time = Time.parse(account.last_vote_time + 'Z')
      elapsed = Time.now.utc - last_vote_time
      
      elapsed < 120
    end
    
    def voted?(comment)
      return false if comment.nil?
      voters = comment.active_votes
      
      if voters.map(&:voter).include? voter_account_name
        debug "Already voted for: #{comment.author}/#{comment.permlink} (id: #{comment.id})"
        true
      else
        # debug "No vote found for: #{comment.author}/#{comment.permlink} (id: #{comment.id})"
        false
      end
    end
    
    # Check to see if it's even possible to vote on a post.  Possible reasons
    # to return false include:
    #
    # * Already voted.
    # * Post does not exist.
    # * API temporarily cannot locate post.
    # * Post does not allow votes.
    # * Cashout time already passed.
    # * Cashout time is passed the threshold (to avoid 12-hour lock-out).
    # * Blacklisted.
    # * When comment (reply) bids are disabled.
    def can_vote?(comment)
      return false if comment.nil?
      return false if voted?(comment)
      return false if comment.author == ''
      return false if blacklist.include? comment.author
      return false unless comment.allow_votes
      
      if !allow_comment_bids && comment.parent_author != ''
        debug "Cannot vote for comment (slug: @#{comment.author}/#{comment.permlink})"
        return false
      end
      
      true
    end
    
    def too_old?(comment, options = {use_cashout_time: false})
      return false if comment.nil?
      
      use_cashout_time = options[:use_cashout_time] || false
      cashout_time = Time.parse(comment.cashout_time + 'Z')
      
      if use_cashout_time
        too_old = cashout_time < Time.now.utc
       
        debug "Cashout Time Passed: #{too_old} (slug: @#{comment.author}/#{comment.permlink})"
        
        too_old
      else
        created = Time.parse(comment.created + 'Z')
        too_old = Time.now.utc - created > (max_age * 60)
        cashout_hours_from_now = ((cashout_time - Time.now.utc) / 60.0 / 60.0)
        
        if cashout_hours_from_now < 0
          debug "Too old: #{too_old} (slug: @#{comment.author}/#{comment.permlink})"
        else
          debug "Too old: #{too_old} (slug: @#{comment.author}/#{comment.permlink}); hours remaining: #{('%.1f' % cashout_hours_from_now)}"
        end
        
        too_old
      end
    end
    
    def vote(bids)
      result = {}
      
      # Vote stacking is where multiple bids are created for the same post.  Any
      # number of transfers from any number of accounts can bid on the same
      # post.
      stacked_bids = {}
      
      # If we find a bid that qualifis as maximum, only this bid is processed in
      # the current window and all others are processed later.
      max_bid = nil
      
      bids.each do |bid|
        stacked_bids[bid[:author] => bid[:permlink]] ||= {}
        stacked_bid = stacked_bids[bid[:author] => bid[:permlink]]
        
        amount = if bid[:amount].split(' ').last == minimum_bid_asset
          bid[:amount]
        else
          a, amount_asset = bid[:amount].split(' ')
          a = a.to_f
          ratio = base_to_debt_ratio
          
          market_amount = case amount_asset
          when 'STEEM', 'GOLOS'
            "%.3f #{minimum_bid_asset}" % (a * ratio)
          when 'SBD', 'GBG'
            "%.3f #{minimum_bid_asset}" % (a / ratio)
          else
            error 'Unsupported asset for bid.', bid
            "0.000 #{minimum_bid_asset}"
          end
          
          info "Evaluating bid at #{bid[:amount]} as #{market_amount} (ratio: #{ratio})"
          
          market_amount
        end
        
        if stacked_bid.empty?
          stacked_bid[:trx_id] = bid[:trx_id]
          stacked_bid[:from] = [bid[:from]]
          stacked_bid[:amount] = [amount]
          stacked_bid[:invert_vote_weight] = [bid[:invert_vote_weight]]
          stacked_bid[:author] = bid[:author]
          stacked_bid[:permlink] = bid[:permlink]
          stacked_bid[:parent_permlink] = bid[:parent_permlink]
          stacked_bid[:parent_author] = bid[:parent_author]
          stacked_bid[:permlink] = bid[:permlink]
          stacked_bid[:timestamp] = bid[:timestamp]
        else
          stacked_bid[:from] << bid[:from]
          stacked_bid[:amount] << amount
          stacked_bid[:invert_vote_weight] << bid[:invert_vote_weight]
        end
      end
      
      bids = stacked_bids.values.sort_by do |b|
        b[:amount].map do |a|
          a.split(' ').first.to_f
        end.reduce(0, :+)
      end.reverse
      
      # First, we need a total of all bids for this batch.  This will be used to
      # figure out how much each bid is allocated.
      total = bids.map do |bid|
        bid[:amount].map do |a|
          a.split(' ').first.to_f
        end.reduce(0, :+)
      end.reduce(0, :+)
      
      start = Time.now.utc.to_i
      total_weight = reserve_vote_weight
      
      # Initial pass to remove bids that don't meet the criteria.  Doing this in
      # a separate pass speeds up processing when, for example, there are spam
      # bids with very low impact.
      bids = bids.map do |bid|
        amount = bid[:amount].map{ |a| a.split(' ').first.to_f }.reduce(0, :+)
        coeff = (amount.to_f / total.to_f)
        effective_weight = (weight = batch_vote_weight * coeff).to_i.abs
        
        if bid[:invert_vote_weight].uniq.size > 1
          info "Removing bid from #{bid[:from].join(', ')}, in-window-flag-war detected."
          total -= amount.to_f
          next
        end
        
        if effective_weight < min_effective_weight
          # Bid didn't meet min_effective_weight, remove it from the total so it
          # doesn't impact everybody else's bids in the same batch.
          info "Removing bid from #{bid[:from].join(', ')}, effective_weight too low: #{effective_weight}"
          total -= amount.to_f
          next
        end
        
        if max_effective_weight > 0.0 && effective_weight >= max_effective_weight
          # Setting this value only once, in order of bid receipt.
          info "Only processing bid from #{bid[:from].join(', ')}, effective_weight maximum found: #{effective_weight} (max_effective_weight: #{max_effective_weight})."
          
          max_bid ||= bid
        end
        
        bid # This bid is accepted.
      end.compact
      
      if !!max_bid
        # Max bid override now in effect; all other bids shall be rescinded.
        total = max_bid[:amount].map{ |a| a.split(' ').first.to_f }.reduce(0, :+)
  
        bids = [max_bid]
      end
      
      reset_vote_schedule
      
      # Final pass, actual voting.
      bids.each do |bid|
        amount = bid[:amount].map{ |a| a.split(' ').first.to_f }.reduce(0, :+)
        invert_vote_weight = bid[:invert_vote_weight].uniq.last
        coeff = (amount.to_f / total.to_f)
        effective_weight = (weight = batch_vote_weight * coeff).to_i
        weight = invert_vote_weight ? -weight : weight
        
        total_weight += effective_weight
        break if total_weight > batch_vote_weight.abs
        
        info "Total: #{total}; amount: #{amount};"
        info "total_weight: #{total_weight}; effective_weight: #{effective_weight}; reserve_vote_weight: #{reserve_vote_weight}"
        
        # We are using asynchronous voting because sometimes the blockchain
        # rejects votes that happen too quickly.
        thread = Thread.new do
          sleep vote_schedule
          
          # while vote_latch
          #   puts "Sleeping ..."
          #   sleep 3
          # end
          
          from = bid[:from]
          author = bid[:author]
          permlink = bid[:permlink]
          parent_permlink = bid[:parent_permlink]
          parent_author = bid[:parent_author]
          timestamp = bid[:timestamp]
            
          if invert_vote_weight
            info "Flagging #{author}/#{permlink} with a coefficnent of #{coeff}."
          else
            info "Voting for #{author}/#{permlink} with a coefficnent of #{coeff}."
          end
        
          loop do
            if BounceJob.new.bounced?(bid[:trx_id])
              warning "Bid was bounce just before voting: @#{author}/#{permlink}"
              break
            end
            
            elapsed = Time.now.utc.to_i - start
            break if (base_block_span * 3) < elapsed
            
            vote = {
              type: :vote,
              voter: account_name,
              author: author,
              permlink: permlink,
              weight: invert_vote_weight ? -effective_weight : effective_weight
            }
            
            merge_options = {
              markup: :html,
              content_type: parent_author == '' ? 'post' : 'comment',
              vote_weight_percent: ("%.2f" % (weight / 100)),
              vote_type: weight > 0 ? 'upvote' : 'downvote',
              account_name: account_name,
              from: from
            }
            
            comment = {
              type: :comment,
              parent_permlink: permlink,
              author: account_name,
              permlink: "re-#{author.gsub(/[^a-z0-9\-]+/, '-')}-#{permlink}-#{Time.now.utc.strftime('%Y%m%dt%H%M%S%Lz')}", # e.g.: 20170225t235138025z
              title: '',
              body: merge(merge_options),
              json_metadata: "{\"tags\":[\"#{parent_permlink}\"],\"app\":\"#{DrOtto::AGENT_ID}\"}",
              parent_author: author
            }
            
            voting_tx = nil
            tx = Radiator::Transaction.new(chain_options.merge(wif: posting_wif))
            tx.operations << vote
            tx.operations << comment unless (no_comment & from).any?
            
            if account_name != voter_account_name
              voting_tx = Radiator::Transaction.new(chain_options.merge(wif: voting_wif))
              voting_tx.operations << {
                type: :vote,
                voter: voter_account_name,
                author: author,
                permlink: permlink,
                weight: invert_vote_weight ? -effective_weight : effective_weight
              }
            end
            
            response = nil
            
            if !!voting_tx
              begin
                semaphore.synchronize do
                  response = voting_tx.process(true)
                end
              rescue => e
                warning "Unable to vote: #{e}", e
                break
              end
              
              if !!response && !!response.error
                message = response.error.message
                if message.to_s =~ /You have already voted in a similar way./
                  error "Failed vote: duplicate vote."
                  # break
                elsif message.to_s =~ /Can only vote once every 3 seconds./
                  warning "Retrying vote: voting too quickly."
                  sleep Random.rand(3..6) # stagger retry
                  redo
                elsif message.to_s =~ /Voting weight is too small, please accumulate more voting power or steem power./
                  error "Failed vote: voting weight too small"
                  break
                elsif message.to_s =~ /Vote weight cannot be 0/
                  error "Failed vote: vote weight cannot be zero."
                  break
                elsif message.to_s =~ /STEEMIT_UPVOTE_LOCKOUT_HF17/
                  error "Failed vote: upvote lockout (last twelve hours before payout)"
                  if auto_bounce_on_lockout && !(no_bounce.include?(from))
                    BounceJob.new.force_bounce!(bid[:trx_id])
                  end
                  break
                elsif message.to_s =~ /missing required posting authority/
                  error "Failed vote: Check posting key."
                  break
                elsif message.to_s =~ /unknown key/
                  error "Failed vote: unknown key (testing?)"
                  break
                elsif message.to_s =~ /tapos_block_summary/
                  warning "Retrying vote/comment: tapos_block_summary (?)"
                  redo
                elsif message.to_s =~ /now < trx.expiration/
                  warning "Retrying vote/comment: now < trx.expiration (?)"
                  redo
                elsif message.to_s =~ /transaction expiration exception/
                  warning "Retrying vote/comment: transaction expiration exception"
                  redo
                elsif message.to_s =~ /!check_max_block_age( _max_block_age ):/
                  warning "Retrying vote/comment: !check_max_block_age( _max_block_age ):"
                  redo
                elsif message.to_s =~ /signature is not canonical/
                  warning "Retrying vote/comment: signature was not canonical (bug in Radiator?)"
                  redo
                end
              end
            end
            
            info response unless response.nil?
            
            begin
              semaphore.synchronize do
                response = tx.process(true)
              end
            rescue => e
              warning "Unable to vote and comment, retrying with just vote: #{e}", e
            end
            
            if !!response && !!response.error
              message = response.error.message
              if message.to_s =~ /You have already voted in a similar way./
                error "Failed vote: duplicate vote."
                break
              elsif message.to_s =~ /You may only comment once every 20 seconds./
                warning "Retrying vote/comment: commenting too quickly."
                sleep Random.rand(20..40) # stagger retry
                redo
              elsif message.to_s =~ /Can only vote once every 3 seconds./
                warning "Retrying vote: voting too quickly."
                sleep Random.rand(3..6) # stagger retry
                redo
              elsif message.to_s =~ /STEEMIT_MAX_PERMLINK_LENGTH: permlink is too long/
                error "Failed comment: permlink too long; only vote"
                # just flunking comment
              elsif message.to_s =~ /Voting weight is too small, please accumulate more voting power or steem power./
                error "Failed vote: voting weight too small"
                break
              elsif message.to_s =~ /Vote weight cannot be 0/
                error "Failed vote: vote weight cannot be zero."
                break
              elsif message.to_s =~ /STEEMIT_UPVOTE_LOCKOUT_HF17/
                error "Failed vote: upvote lockout (last twelve hours before payout)"
                if auto_bounce_on_lockout && !(no_bounce.include?(from))
                  BounceJob.new.force_bounce!(bid[:trx_id])
                end
                break
              elsif message.to_s =~ /missing required posting authority/
                error "Failed vote: Check posting key."
                break
              elsif message.to_s =~ /unknown key/
                error "Failed vote: unknown key (testing?)"
                break
              elsif message.to_s =~ /tapos_block_summary/
                warning "Retrying vote/comment: tapos_block_summary (?)"
                redo
              elsif message.to_s =~ /now < trx.expiration/
                warning "Retrying vote/comment: now < trx.expiration (?)"
                redo
              elsif message.to_s =~ /transaction expiration exception/
                warning "Retrying vote/comment: transaction expiration exception"
                redo
              elsif message.to_s =~ /!check_max_block_age( _max_block_age ):/
                warning "Retrying vote/comment: !check_max_block_age( _max_block_age ):"
                redo
              elsif message.to_s =~ /signature is not canonical/
                warning "Retrying vote/comment: signature was not canonical (bug in Radiator?)"
                redo
              end
            end

            if response.nil? || !!response.error
              if !!response && !!response.result && !!response.result.trx_id
                warning "Problem while voting, but the transaction was found."
                response.delete(:error)
              else
                warning "Problem while voting.  Retrying with just vote: #{response}"
                tx.operations = [vote]
                
                begin
                  semaphore.synchronize do
                    response = tx.process(true)
                  end
                rescue => e
                  error "Unable to vote: #{e}", e
                end
              end
            end
            
            info response unless response.nil?
            
            block_nums = []
            block_nums << @last_broadcast_block.to_i if !!@last_broadcast_block
            block_nums << response.result.block_num.to_i if !!response.result
            @last_broadcast_block = block_nums.max
            
            break
          end
        end
        
        result[bid] = thread
      end
      
      result
    end
    
    def current_voting_power(options = {log: true})
      account = api.get_accounts([voting_power_account_name]) do |accounts|
        accounts.first
      end
      
      voting_power = account.voting_power / 100.0
      last_vote_time = Time.parse(account.last_vote_time + 'Z')
      voting_elapse = Time.now.utc - last_vote_time
      current_voting_power = voting_power + (voting_elapse * VOTE_RECHARGE_PER_SEC)
      current_voting_power = [100.0, current_voting_power].min
      diff = current_voting_power - voting_power
      recharge = ((100.0 - current_voting_power) / VOTE_RECHARGE_PER_SEC) / 60
      
      if !!options[:log]
        info "Remaining voting power: #{('%.2f' % current_voting_power)} % (recharged #{('%.2f' % diff)} % since last vote)"
      
        if voting_elapse > 0 && recharge > 0
          info "Last vote: #{voting_elapse.to_i / 60} minutes ago; #{('%.1f' % recharge)} minutes remain until 100.00 %"
        else
          if voting_elapse > 0
            info "Last vote: #{voting_elapse.to_i / 60} minutes ago; #{('%.1f' % recharge.abs)} minutes of recharge power unused in 100.00 %"
          end
        end
      end
      
      current_voting_power
    end
    
    def base_to_debt_ratio
      @last_base_to_debt_ratio = market_history_api.get_ticker do |ticker|
        latest = ticker.latest.to_f
        bid = ticker.highest_bid.to_f
        ask = ticker.lowest_ask.to_f
        [latest, bid, ask].reduce(0, :+) / 3.0
      end
    rescue => e
      warning "Unable to query market data.", e
      reset_market_history_api
    ensure
      @last_base_to_debt_ratio || 1.0
    end
    
    def reset_market_history_api
      @market_history_api = nil
    end
    
    def market_history_api
      @market_history_api ||= Radiator::MarketHistoryApi.new(chain_options)
    end
    
    def accepted_asset?(amount)
      ([minimum_bid_asset] + alternative_assets).include? amount.split(' ').last
    end
    
    def reset_vote_schedule
      @last_vote_schedule = nil
      @current_vote_schedule = nil
      @last_vote_schedule = nil
    end
    
    def vote_schedule
      @last_vote_schedule ||= 0.0
      @current_vote_schedule ||= 0.0
      @current_vote_schedule += 20.0
      @current_vote_schedule
    end
    
    def vote_latch
      reset_properties
      
      @last_broadcast_block.to_i + 7 > properties.head_block_number
    end
  end
end