zold-io/zold

View on GitHub
lib/zold/patch.rb

Summary

Maintainability
D
2 days
Test Coverage
# frozen_string_literal: true

# Copyright (c) 2018-2023 Zerocracy
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the 'Software'), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFINGEMENT. IN NO EVENT SHALL THE
# 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 'openssl'
require_relative 'log'
require_relative 'wallet'
require_relative 'signature'

# Patch.
#
# Author:: Yegor Bugayenko (yegor256@gmail.com)
# Copyright:: Copyright (c) 2018 Yegor Bugayenko
# License:: MIT
module Zold
  # A patch
  class Patch
    def initialize(wallets, log: Log::NULL)
      @wallets = wallets
      @txns = []
      @log = log
    end

    def to_s
      return 'nothing' if @txns.empty?
      "#{@txns.count} txns"
    end

    # Add legacy transactions first, since they are negative and can't
    # be deleted ever. This method is called by merge.rb in order to add
    # legacy negative transactions to the patch before everything else. They
    # are not supposed to be disputed, ever.
    def legacy(wallet, hours: 24)
      raise 'You can\'t add legacy to a non-empty patch' unless @id.nil?
      wallet.txns.each do |txn|
        @txns << txn if txn.amount.negative? && txn.date < Time.now - (hours * 60 * 60)
      end
    end

    # Joins a new wallet on top of existing patch. An attempt is made to
    # copy as many transactions from the newcoming wallet to the existing
    # set of transactions, avoiding mistakes and duplicates.
    #
    # A block has to be given. It will be called, if a paying wallet is absent.
    # The block will have to return either TRUE or FALSE. TRUE will mean that
    # the paying wallet has to be present and we just tried to pull it. If it's
    # not present, it's a failure, don't accept the transaction. FALSE will mean
    # that the transaction should be accepted, even if the paying wallet is
    # absent.
    #
    # The "baseline" flag, when set to TRUE, means that we should NOT validate
    # the presence of positive incoming transactions in their correspondent
    # wallets. We shall just trust them.
    #
    # If the "master" flag is set, this copy is coming from a master node
    # and we should allow it to overwrite negative transactions.
    def join(wallet, ledger: '/dev/null', baseline: false, master: false)
      if @id.nil?
        @id = wallet.id
        @key = wallet.key
        @network = wallet.network
      end
      unless wallet.network == @network
        @log.error("The wallet is from a different network '#{wallet.network}', ours is '#{@network}'")
        return
      end
      unless wallet.key == @key
        @log.error('Public key mismatch')
        return
      end
      unless wallet.id == @id
        @log.error("Wallet ID mismatch, ours is #{@id}, theirs is #{wallet.id}")
        return
      end
      seen = 0
      added = 0
      pulled = []
      wallet.txns.each do |txn|
        next if @txns.find { |t| t == txn }
        seen += 1
        if txn.amount.negative?
          dup = @txns.find { |t| t.id == txn.id && t.amount.negative? }
          if dup && !master
            @log.error("An attempt to overwrite existing transaction #{dup.to_text.inspect} \
with a new one #{txn.to_text.inspect} from #{wallet.mnemo}")
            next
          end
          if dup && master
            @log.debug("An overwrite to the existing transaction #{dup.to_text.inspect} \
is coming from a master node: #{txn.to_text.inspect} from #{wallet.mnemo}")
            @txns.reject! { |t| t.id == txn.id && t.amount.negative? }
          end
          unless Signature.new(@network).valid?(@key, wallet.id, txn)
            @log.error("Invalid RSA signature at the transaction ##{txn.id} of #{wallet.id}: #{txn.to_text.inspect}")
            next
          end
        else
          if Id::BANNED.include?(txn.bnf.to_s)
            @log.debug("The paying wallet is banned, #{wallet.id} can't accept this: #{txn.to_text.inspect}")
            next
          end
          dup = @txns.find { |t| t.id == txn.id && t.bnf == txn.bnf && t.amount.positive? }
          if dup
            @log.error("Overwriting #{dup.to_text.inspect} with #{txn.to_text.inspect} \
from #{wallet.mnemo} (same ID/BNF)")
            next
          end
          if !txn.sign.nil? && !txn.sign.empty?
            @log.error("RSA signature is redundant at ##{txn.id} of #{wallet.id}: #{txn.to_text.inspect}")
            next
          end
          unless wallet.prefix?(txn.prefix)
            @log.debug("Payment prefix '#{txn.prefix}' doesn't match \
with the key of #{wallet.id}: #{txn.to_text.inspect}")
            next
          end
          unless @wallets.acq(txn.bnf, &:exists?)
            if baseline
              @log.debug("Paying wallet #{txn.bnf} is absent, \
but the txn in in the baseline: #{txn.to_text.inspect}")
            else
              next if pulled.include?(txn.bnf)
              pulled << txn.bnf
              if yield(txn) && !@wallets.acq(txn.bnf, &:exists?)
                @log.error("Paying wallet #{txn.bnf} file is absent even after PULL: #{txn.to_text.inspect}")
                next
              end
            end
          end
          if @wallets.acq(txn.bnf, &:exists?) &&
            !@wallets.acq(txn.bnf) { |p| p.includes_negative?(txn.id, wallet.id) }
            if baseline
              @log.debug("The beneficiary #{@wallets.acq(txn.bnf, &:mnemo)} of #{@id} \
doesn't have this transaction, but we trust it, since it's a baseline: #{txn.to_text.inspect}")
            else
              if pulled.include?(txn.bnf)
                @log.debug("The beneficiary #{@wallets.acq(txn.bnf, &:mnemo)} of #{@id} \
doesn't have this transaction: #{txn.to_text.inspect}")
                next
              end
              pulled << txn.bnf
              yield(txn)
              unless @wallets.acq(txn.bnf) { |p| p.includes_negative?(txn.id, wallet.id) }
                @log.debug("The beneficiary #{@wallets.acq(txn.bnf, &:mnemo)} of #{@id} \
doesn't have this transaction: #{txn.to_text.inspect}")
                next
              end
            end
          end
        end
        @txns << txn
        added += 1
        next unless txn.amount.negative?
        File.open(ledger, 'a') do |f|
          msg = [
            Time.now.utc.iso8601,
            txn.id,
            txn.date.utc.iso8601,
            wallet.id,
            txn.bnf,
            txn.amount.to_i * -1,
            txn.prefix,
            txn.details
          ].map(&:to_s).join(';')
          f.puts("#{msg}\n")
        end
      end
    end

    def empty?
      @id.nil?
    end

    # Returns TRUE if the file was actually modified
    def save(file, overwrite: false, allow_negative_balance: false)
      raise 'You have to join at least one wallet in' if empty?
      before = ''
      wallet = Wallet.new(file)
      before = wallet.digest if wallet.exists?
      Tempfile.open([@id, Wallet::EXT]) do |f|
        temp = Wallet.new(f.path)
        temp.init(@id, @key, overwrite: overwrite, network: @network)
        File.open(f.path, 'a') do |t|
          @txns.each do |txn|
            next if Id::BANNED.include?(txn.bnf.to_s)
            t.print "#{txn}\n"
          end
        end
        temp.refurbish
        if temp.balance.negative? && !temp.id.root? && !allow_negative_balance
          if wallet.exists?
            @log.info("The balance is negative, won't merge #{temp.mnemo} on top of #{wallet.mnemo}")
          else
            @log.info("The balance is negative, won't save #{temp.mnemo}")
          end
        else
          FileUtils.mkdir_p(File.dirname(file))
          File.write(file, File.read(f.path))
        end
      end
      before != wallet.digest
    end
  end
end