Beispiel #1
0
    def test_retry_failed_payments(self):
        payment_queue = queue.Queue(100)

        retry_producer = RetryProducer(payment_queue, _DummyRpcRewardApi(), _TestPaymentProducer(),
                                       TEST_REPORT_TEMP_DIR)
        retry_producer.retry_failed_payments()

        self.assertEqual(1, len(payment_queue.queue))

        payment_batch = payment_queue.get()

        self.assertEqual(10, payment_batch.cycle)
        self.assertEqual(31, len(payment_batch.batch))
        self.assertEqual(5, len([row for row in payment_batch.batch if row.paid == PaymentStatus.FAIL]))

        nw = dict({'BLOCK_TIME_IN_SEC': 64})
        payment_consumer = self.create_consumer(nw, payment_queue)
        payment_consumer._consume_batch(payment_batch)

        success_report = payment_report_file_path(TEST_REPORT_TEMP_DIR, 10, 0)
        self.assertTrue(os.path.isfile(success_report))

        success_report_rows = CsvPaymentFileParser().parse(success_report, 10)
        nb_success = len([row for row in success_report_rows if row.paid == PaymentStatus.PAID])
        nb_hash_xxx_op_hash = len([row for row in success_report_rows if row.hash == 'xxx_op_hash'])

        self.assertEqual(31, nb_success)
        self.assertEqual(5, nb_hash_xxx_op_hash)
    def test_retry_failed_payments(self):
        """This is a test about retrying failed operations.
        Input is a past payment report which contains 31 payment items,
        26 of them were successful and 5 of them were failed. The final report
        should report 31 successful transactions.
        """
        payment_queue = queue.Queue(100)

        retry_producer = RetryProducer(
            payment_queue,
            _DummyRpcRewardApi(),
            _TestPaymentProducer(),
            TEST_REPORT_TEMP_DIR,
            10,
        )
        retry_producer.retry_failed_payments()

        self.assertEqual(1, len(payment_queue.queue))

        payment_batch = payment_queue.get()

        self.assertEqual(10, payment_batch.cycle)
        self.assertEqual(31, len(payment_batch.batch))
        self.assertEqual(
            5,
            len([
                row for row in payment_batch.batch
                if row.paid == PaymentStatus.FAIL
            ]),
        )

        nw = dict({"MINIMAL_BLOCK_DELAY": 30})
        payment_consumer = self.create_consumer(nw, payment_queue)
        payment_consumer._consume_batch(payment_batch)

        success_report = get_payment_report_file_path(TEST_REPORT_TEMP_DIR, 10,
                                                      0)
        self.assertTrue(os.path.isfile(success_report))

        success_report_rows = CsvPaymentFileParser().parse(success_report, 10)
        success_count = len([row for row in success_report_rows])
        hash_xxx_op_count = len(
            [row for row in success_report_rows if row.hash == "xxx_op_hash"])
        failed_reports_count = len([
            file for file in os.listdir(
                os.path.join(TEST_REPORT_TEMP_DIR, "failed"))
            if os.path.isfile(file)
        ])

        # Success is defined when the transactions are saved in the done folder
        self.assertEqual(31, success_count)
        self.assertEqual(5, hash_xxx_op_count)
        self.assertEqual(0, failed_reports_count)
Beispiel #3
0
    def test_retry_failed_payments_before_initial_cycle(self):
        """This is a test about retrying failed operations in a cycle
        before initial_cycle passed at parameter.
        Input is a past payment report with failues at cycle 10.
        Initial cycle is set to 11.
        It should NOT trigger any payment.
        """
        payment_queue = queue.Queue(100)

        retry_producer = RetryProducer(
            payment_queue,
            _DummyRpcRewardApi(),
            _TestPaymentProducer(),
            TEST_REPORT_TEMP_DIR,
            11,
        )
        retry_producer.retry_failed_payments()

        self.assertEqual(0, len(payment_queue.queue))
Beispiel #4
0
    def __init__(self,
                 name,
                 initial_payment_cycle,
                 network_config,
                 payments_dir,
                 calculations_dir,
                 run_mode,
                 service_fee_calc,
                 release_override,
                 payment_offset,
                 baking_cfg,
                 payments_queue,
                 life_cycle,
                 dry_run,
                 client_manager,
                 node_url,
                 reward_data_provider,
                 node_url_public='',
                 api_base_url=None,
                 retry_injected=False):
        super(PaymentProducer, self).__init__()

        self.rules_model = RulesModel(baking_cfg.get_excluded_set_tob(),
                                      baking_cfg.get_excluded_set_toe(),
                                      baking_cfg.get_excluded_set_tof(),
                                      baking_cfg.get_dest_map())
        self.baking_address = baking_cfg.get_baking_address()
        self.owners_map = baking_cfg.get_owners_map()
        self.founders_map = baking_cfg.get_founders_map()
        self.min_delegation_amt_in_mutez = baking_cfg.get_min_delegation_amount(
        ) * MUTEZ
        self.delegator_pays_xfer_fee = baking_cfg.get_delegator_pays_xfer_fee()
        self.provider_factory = ProviderFactory(reward_data_provider)
        self.name = name

        self.node_url = node_url
        self.client_manager = client_manager
        self.reward_api = self.provider_factory.newRewardApi(
            network_config, self.baking_address, self.node_url,
            node_url_public, api_base_url)
        self.block_api = self.provider_factory.newBlockApi(
            network_config, self.node_url, api_base_url)

        dexter_contracts_set = baking_cfg.get_contracts_set()
        if len(dexter_contracts_set) > 0 and not (self.reward_api.name
                                                  == 'tzstats'):
            logger.warning(
                "The Dexter functionality is currently only supported using tzstats."
                "The contract address will be treated as a normal delegator.")
        else:
            self.reward_api.set_dexter_contracts_set(dexter_contracts_set)

        self.rewards_type = baking_cfg.get_rewards_type()
        self.fee_calc = service_fee_calc
        self.initial_payment_cycle = initial_payment_cycle

        if self.initial_payment_cycle is None:
            recent = get_latest_report_file(payments_dir)
            # if payment logs exists set initial cycle to following cycle
            # if payment logs does not exists, set initial cycle to 0, so that payment starts from last released rewards
            self.initial_payment_cycle = 0 if recent is None else int(
                recent) + 1

        logger.info("initial_cycle set to {}".format(
            self.initial_payment_cycle))

        self.nw_config = network_config
        self.payments_root = payments_dir
        self.calculations_dir = calculations_dir
        self.run_mode = run_mode
        self.exiting = False

        self.release_override = release_override
        self.payment_offset = payment_offset
        self.payments_queue = payments_queue
        self.life_cycle = life_cycle
        self.dry_run = dry_run

        self.payment_calc = PhasedPaymentCalculator(
            self.founders_map, self.owners_map, self.fee_calc,
            self.min_delegation_amt_in_mutez, self.rules_model)

        self.retry_fail_thread = threading.Thread(target=self.retry_fail_run,
                                                  name=self.name +
                                                  "_retry_fail")
        self.retry_fail_event = threading.Event()
        self.retry_injected = retry_injected

        self.retry_producer = RetryProducer(self.payments_queue,
                                            self.reward_api, self,
                                            self.payments_root,
                                            self.retry_injected)

        logger.info('Producer "{}" started'.format(self.name))
Beispiel #5
0
class PaymentProducer(threading.Thread, PaymentProducerABC):
    def __init__(self,
                 name,
                 initial_payment_cycle,
                 network_config,
                 payments_dir,
                 calculations_dir,
                 run_mode,
                 service_fee_calc,
                 release_override,
                 payment_offset,
                 baking_cfg,
                 payments_queue,
                 life_cycle,
                 dry_run,
                 client_manager,
                 node_url,
                 reward_data_provider,
                 node_url_public='',
                 api_base_url=None,
                 retry_injected=False):
        super(PaymentProducer, self).__init__()

        self.rules_model = RulesModel(baking_cfg.get_excluded_set_tob(),
                                      baking_cfg.get_excluded_set_toe(),
                                      baking_cfg.get_excluded_set_tof(),
                                      baking_cfg.get_dest_map())
        self.baking_address = baking_cfg.get_baking_address()
        self.owners_map = baking_cfg.get_owners_map()
        self.founders_map = baking_cfg.get_founders_map()
        self.min_delegation_amt_in_mutez = baking_cfg.get_min_delegation_amount(
        ) * MUTEZ
        self.delegator_pays_xfer_fee = baking_cfg.get_delegator_pays_xfer_fee()
        self.provider_factory = ProviderFactory(reward_data_provider)
        self.name = name

        self.node_url = node_url
        self.client_manager = client_manager
        self.reward_api = self.provider_factory.newRewardApi(
            network_config, self.baking_address, self.node_url,
            node_url_public, api_base_url)
        self.block_api = self.provider_factory.newBlockApi(
            network_config, self.node_url, api_base_url)

        dexter_contracts_set = baking_cfg.get_contracts_set()
        if len(dexter_contracts_set) > 0 and not (self.reward_api.name
                                                  == 'tzstats'):
            logger.warning(
                "The Dexter functionality is currently only supported using tzstats."
                "The contract address will be treated as a normal delegator.")
        else:
            self.reward_api.set_dexter_contracts_set(dexter_contracts_set)

        self.rewards_type = baking_cfg.get_rewards_type()
        self.fee_calc = service_fee_calc
        self.initial_payment_cycle = initial_payment_cycle

        if self.initial_payment_cycle is None:
            recent = get_latest_report_file(payments_dir)
            # if payment logs exists set initial cycle to following cycle
            # if payment logs does not exists, set initial cycle to 0, so that payment starts from last released rewards
            self.initial_payment_cycle = 0 if recent is None else int(
                recent) + 1

        logger.info("initial_cycle set to {}".format(
            self.initial_payment_cycle))

        self.nw_config = network_config
        self.payments_root = payments_dir
        self.calculations_dir = calculations_dir
        self.run_mode = run_mode
        self.exiting = False

        self.release_override = release_override
        self.payment_offset = payment_offset
        self.payments_queue = payments_queue
        self.life_cycle = life_cycle
        self.dry_run = dry_run

        self.payment_calc = PhasedPaymentCalculator(
            self.founders_map, self.owners_map, self.fee_calc,
            self.min_delegation_amt_in_mutez, self.rules_model)

        self.retry_fail_thread = threading.Thread(target=self.retry_fail_run,
                                                  name=self.name +
                                                  "_retry_fail")
        self.retry_fail_event = threading.Event()
        self.retry_injected = retry_injected

        self.retry_producer = RetryProducer(self.payments_queue,
                                            self.reward_api, self,
                                            self.payments_root,
                                            self.retry_injected)

        logger.info('Producer "{}" started'.format(self.name))

    def exit(self):
        if not self.exiting:
            self.payments_queue.put(
                PaymentBatch(self, 0, [self.create_exit_payment()]))
            self.exiting = True

            if self.life_cycle.is_running() and threading.current_thread(
            ) is not threading.main_thread():
                _thread.interrupt_main()

            if self.retry_fail_event:
                self.retry_fail_event.set()

    def retry_fail_run(self):
        logger.info('Retry Fail thread "{}" started'.format(
            self.retry_fail_thread.name))

        sleep(60)  # producer thread already tried once, wait for next try

        while not self.exiting and self.life_cycle.is_running():
            self.retry_producer.retry_failed_payments()

            try:
                # prepare to wait on event
                self.retry_fail_event.clear()

                # this will either return with timeout or set from parent producer thread
                self.retry_fail_event.wait(60 * 60)  # 1 hour
            except RuntimeError:
                pass

    def run(self):
        # call first retry if not in onetime mode.
        # retry_failed script is more suitable for one time cases.
        if not self.run_mode == RunMode.ONETIME:
            self.retry_producer.retry_failed_payments()

            if self.run_mode == RunMode.RETRY_FAILED:
                sleep(5)
                self.exit()
                return

        # first retry is done by producer thread, start retry thread for further retries
        if self.run_mode == RunMode.FOREVER:
            self.retry_fail_thread.start()

        try:
            current_cycle = self.block_api.get_current_cycle()
            pymnt_cycle = self.initial_payment_cycle
        except ApiProviderException as a:
            logger.error(
                "Unable to fetch current cycle, {:s}. Exiting.".format(str(a)))
            self.exit()
            return

        # if non-positive initial_payment_cycle, set initial_payment_cycle to
        # 'current cycle - abs(initial_cycle) - (NB_FREEZE_CYCLE+1)'
        if self.initial_payment_cycle <= 0:
            pymnt_cycle = current_cycle - abs(self.initial_payment_cycle) - (
                self.nw_config['NB_FREEZE_CYCLE'] + 1)
            logger.debug("Payment cycle is set to {}".format(pymnt_cycle))

        get_verbose_log_helper().reset(pymnt_cycle)

        while not self.exiting and self.life_cycle.is_running():

            # take a breath
            sleep(5)

            try:

                # Check if local node is bootstrapped; sleep if needed; restart loop
                if not self.node_is_bootstrapped():
                    logger.info(
                        "Local node, {}, is not in sync with the Tezos network. Will sleep for {} blocks and check again."
                        .format(self.node_url, BOOTSTRAP_SLEEP))
                    self.wait_for_blocks(BOOTSTRAP_SLEEP)
                    continue

                # Local node is ready
                current_level = self.block_api.get_current_level()
                current_cycle = self.block_api.level_to_cycle(current_level)
                level_in_cycle = self.block_api.level_in_cycle(current_level)

                # create reports dir
                if self.calculations_dir and not os.path.exists(
                        self.calculations_dir):
                    os.makedirs(self.calculations_dir)

                logger.debug(
                    "Checking for pending payments : payment_cycle <= current_cycle - (self.nw_config['NB_FREEZE_CYCLE'] + 1) - self.release_override"
                )
                logger.info(
                    "Checking for pending payments : checking {} <= {} - ({} + 1) - {}"
                    .format(pymnt_cycle, current_cycle,
                            self.nw_config['NB_FREEZE_CYCLE'],
                            self.release_override))

                # payments should not pass beyond last released reward cycle
                if pymnt_cycle <= current_cycle - (
                        self.nw_config['NB_FREEZE_CYCLE'] +
                        1) - self.release_override:
                    if not self.payments_queue.full():

                        # Paying upcoming cycles (-R in [-6, -11] )
                        if pymnt_cycle >= current_cycle:
                            logger.warn(
                                "Please note that you are doing payouts for future rewards!!! These rewards are not earned yet, they are an estimation."
                            )
                            if not self.rewards_type.isIdeal():
                                logger.error(
                                    "For future rewards payout, you must configure the payout type to 'Ideal', see documentation"
                                )
                                self.exit()
                                break

                        # Paying cycles with frozen rewards (-R in [-1, -5] )
                        elif pymnt_cycle >= current_cycle - self.nw_config[
                                'NB_FREEZE_CYCLE']:
                            logger.warn(
                                "Please note that you are doing payouts for frozen rewards!!!"
                            )
                            if (not self.rewards_type.isIdeal()
                                ) and self.reward_api.name == 'RPC':
                                logger.error(
                                    "Paying out frozen rewards with Node RPC API and rewards type 'Actual' is unsupported, you must use TzKT or tzstats API"
                                )
                                self.exit()
                                break

                        # If user wants to offset payments within a cycle, check here
                        if level_in_cycle < self.payment_offset:
                            wait_offset_blocks = self.payment_offset - level_in_cycle
                            logger.info(
                                "Current level within the cycle is {}; Requested offset is {}; Waiting for {} more blocks."
                                .format(level_in_cycle, self.payment_offset,
                                        wait_offset_blocks))
                            self.wait_for_blocks(wait_offset_blocks)
                            continue  # Break/Repeat loop

                        else:
                            result = self.try_to_pay(
                                pymnt_cycle,
                                expected_rewards=self.rewards_type.isIdeal())

                        if result:
                            # single run is done. Do not continue.
                            if self.run_mode == RunMode.ONETIME:
                                logger.info(
                                    "Run mode ONETIME satisfied. Terminating ..."
                                )
                                self.exit()
                                break
                            else:
                                pymnt_cycle = pymnt_cycle + 1
                                get_verbose_log_helper().reset(pymnt_cycle)

                    # end of queue size check
                    else:
                        logger.debug("Wait a few minutes, queue is full")
                        # wait a few minutes to let payments finish
                        sleep(60 * 3)

                # end of payment cycle check
                else:
                    logger.info(
                        "No pending payments for cycle {}, current cycle is {}"
                        .format(pymnt_cycle, current_cycle))

                    # pending payments done. Do not wait any more.
                    if self.run_mode == RunMode.PENDING:
                        logger.info(
                            "Run mode PENDING satisfied. Terminating ...")
                        self.exit()
                        break

                    sleep(10)

                    # calculate number of blocks until end of current cycle
                    nb_blocks_remaining = (
                        current_cycle +
                        1) * self.nw_config['BLOCKS_PER_CYCLE'] - current_level

                    # plus offset. cycle beginnings may be busy, move payments forward
                    nb_blocks_remaining = nb_blocks_remaining + self.payment_offset

                    logger.debug(
                        "Waiting until next cycle; {} blocks remaining".format(
                            nb_blocks_remaining))

                    # wait until current cycle ends
                    self.wait_for_blocks(nb_blocks_remaining)

            except (ApiProviderException, ReadTimeout, ConnectTimeout) as e:
                logger.debug(
                    "{:s} error at payment producer loop: '{:s}'".format(
                        self.reward_api.name, str(e)),
                    exc_info=True)
                logger.error(
                    "{:s} error at payment producer loop: '{:s}', will try again."
                    .format(self.reward_api.name, str(e)))

            except Exception as e:
                logger.debug(
                    "Unknown error in payment producer loop: {:s}".format(
                        str(e)),
                    exc_info=True)
                logger.error(
                    "Unknown error in payment producer loop: {:s}, will try again."
                    .format(str(e)))

        # end of endless loop
        logger.info("Producer returning ...")

        # ensure consumer exits
        self.exit()

        return

    def try_to_pay(self, pymnt_cycle, expected_rewards=False):
        try:
            logger.info("Payment cycle is " + str(pymnt_cycle))

            # 0- check for past payment evidence for current cycle
            past_payment_state = check_past_payment(self.payments_root,
                                                    pymnt_cycle)

            if not self.dry_run and past_payment_state:
                logger.warn(past_payment_state)
                return True

            # 1- get reward data
            if expected_rewards:
                logger.info(
                    "Using expected/ideal rewards for payouts calculations")
            else:
                logger.info("Using actual rewards for payouts calculations")

            reward_model = self.reward_api.get_rewards_for_cycle_map(
                pymnt_cycle, expected_rewards)

            # 2- calculate rewards
            reward_logs, total_amount = self.payment_calc.calculate(
                reward_model)

            # 3- set cycle info
            for rl in reward_logs:
                rl.cycle = pymnt_cycle
            total_amount_to_pay = sum(
                [rl.amount for rl in reward_logs if rl.payable])

            # 4- if total_rewards > 0, proceed with payment
            if total_amount_to_pay > 0:
                report_file_path = get_calculation_report_file(
                    self.calculations_dir, pymnt_cycle)

                # 5- send to payment consumer
                self.payments_queue.put(
                    PaymentBatch(self, pymnt_cycle, reward_logs))

                # logger.info("Total payment amount is {:,} mutez. %s".format(total_amount_to_pay),
                #            "" if self.delegator_pays_xfer_fee else "(Transfer fee is not included)")

                logger.debug("Creating calculation report (%s)",
                             report_file_path)

                sleep(5.0)

                # 6- create calculations report file. This file contains calculations details
                self.create_calculations_report(reward_logs, report_file_path,
                                                total_amount, expected_rewards)

                # 7- processing of cycle is done
                logger.info(
                    "Reward creation is done for cycle {}, created {} rewards."
                    .format(pymnt_cycle, len(reward_logs)))

            elif total_amount_to_pay == 0:
                logger.info("Total payment amount is 0. Nothing to pay!")

        except ApiProviderException as a:
            logger.error("[try_to_pay] API provider error {:s}".format(str(a)))
            raise a from a
        except Exception as e:
            logger.error("[try_to_pay] Generic exception {:s}".format(str(e)))
            raise e from e

        # Either succeeded or raised exception
        return True

    def wait_for_blocks(self, nb_blocks_remaining):
        for x in range(nb_blocks_remaining):
            sleep(self.nw_config['BLOCK_TIME_IN_SEC'])

            # if shutting down, exit
            if not self.life_cycle.is_running():
                self.exit()
                break

    def node_is_bootstrapped(self):
        # Get RPC node's (-A) bootstrap time. If bootstrap time + 2 minutes is
        # before local time, node is not bootstrapped.
        #
        # clnt_mngr is a super class of SimpleClientManager which interfaces
        # with the tezos-node used for txn forging/signing/injection. This is the
        # node which we need to determine bootstrapped state
        try:
            boot_time = self.client_manager.get_bootstrapped()
            utc_time = datetime.utcnow()
            if (boot_time + timedelta(minutes=2)) < utc_time:
                logger.info(
                    "Current time is '{}', latest block of local node is '{}'."
                    .format(utc_time, boot_time))
                return False
        except ValueError:
            logger.error(
                "Unable to determine local node's bootstrap status. Continuing..."
            )
        return True

    def create_calculations_report(self, payment_logs, report_file_path,
                                   total_rewards, expected_rewards):

        rt = "I" if expected_rewards else "A"

        # Open reports file and write; auto-closes file
        with open(report_file_path, 'w', newline='') as f:

            writer = csv.writer(f,
                                delimiter=',',
                                quotechar='"',
                                quoting=csv.QUOTE_MINIMAL)

            # write headers and total rewards
            writer.writerow([
                "address", "type", "staked_balance", "current_balance",
                "ratio", "fee_ratio", "amount", "fee_amount", "fee_rate",
                "payable", "skipped", "atphase", "desc", "payment_address",
                "rewards_type"
            ])

            # First row is for the baker
            writer.writerow([
                self.baking_address, "B",
                sum([pl.staking_balance for pl in payment_logs]),
                "{0:f}".format(1.0), "{0:f}".format(1.0), "{0:f}".format(0.0),
                "{0:f}".format(total_rewards), "{0:f}".format(0.0),
                "{0:f}".format(0.0), "0", "0", "-1", "Baker", "None", rt
            ])

            for pymnt_log in payment_logs:
                # write row to csv file
                array = [
                    pymnt_log.address, pymnt_log.type,
                    pymnt_log.staking_balance, pymnt_log.current_balance,
                    "{0:.10f}".format(pymnt_log.ratio),
                    "{0:.10f}".format(pymnt_log.service_fee_ratio),
                    "{0:f}".format(pymnt_log.amount),
                    "{0:f}".format(pymnt_log.service_fee_amount),
                    "{0:f}".format(pymnt_log.service_fee_rate),
                    "1" if pymnt_log.payable else "0",
                    "1" if pymnt_log.skipped else "0",
                    pymnt_log.skippedatphase if pymnt_log.skipped else "-1",
                    pymnt_log.desc if pymnt_log.desc else "None",
                    pymnt_log.paymentaddress, rt
                ]
                writer.writerow(array)

                logger.debug(
                    "Reward created for {:s} type: {:s}, stake bal: {:>10.2f}, cur bal: {:>10.2f}, ratio: {:.6f}, fee_ratio: {:.6f}, "
                    "amount: {:>10.6f}, fee_amount: {:>4.6f}, fee_rate: {:.2f}, payable: {:s}, skipped: {:s}, at-phase: {:d}, "
                    "desc: {:s}, pay_addr: {:s}, type: {:s}".format(
                        pymnt_log.address, pymnt_log.type,
                        pymnt_log.staking_balance / MUTEZ,
                        pymnt_log.current_balance / MUTEZ, pymnt_log.ratio,
                        pymnt_log.service_fee_ratio, pymnt_log.amount / MUTEZ,
                        pymnt_log.service_fee_amount / MUTEZ,
                        pymnt_log.service_fee_rate,
                        "Y" if pymnt_log.payable else "N",
                        "Y" if pymnt_log.skipped else "N",
                        pymnt_log.skippedatphase, pymnt_log.desc,
                        pymnt_log.paymentaddress, rt))

        logger.info(
            "Calculation report is created at '{}'".format(report_file_path))

    @staticmethod
    def create_exit_payment():
        return RewardLog.ExitInstance()

    def notify_retry_fail_thread(self):
        self.retry_fail_event.set()

    # upon success retry failed payments if present
    # success may indicate what went wrong in past is fixed.
    def on_success(self, pymnt_batch):
        self.notify_retry_fail_thread()

    def on_fail(self, pymnt_batch):
        pass
class PaymentProducer(threading.Thread, PaymentProducerABC):
    def __init__(
        self,
        name,
        initial_payment_cycle,
        network_config,
        payments_dir,
        calculations_dir,
        run_mode,
        service_fee_calc,
        release_override,
        payment_offset,
        baking_cfg,
        payments_queue,
        life_cycle,
        dry_run,
        client_manager,
        node_url,
        reward_data_provider,
        node_url_public="",
        api_base_url=None,
        retry_injected=False,
    ):
        super(PaymentProducer, self).__init__()
        self.event = threading.Event()
        self.rules_model = RulesModel(
            baking_cfg.get_excluded_set_tob(),
            baking_cfg.get_excluded_set_toe(),
            baking_cfg.get_excluded_set_tof(),
            baking_cfg.get_dest_map(),
        )
        self.baking_address = baking_cfg.get_baking_address()
        self.owners_map = baking_cfg.get_owners_map()
        self.founders_map = baking_cfg.get_founders_map()
        self.min_delegation_amt_in_mutez = (
            baking_cfg.get_min_delegation_amount() * MUTEZ)
        self.delegator_pays_xfer_fee = baking_cfg.get_delegator_pays_xfer_fee()
        self.provider_factory = ProviderFactory(reward_data_provider)
        self.name = name

        self.node_url = node_url
        self.client_manager = client_manager
        self.reward_api = self.provider_factory.newRewardApi(
            network_config,
            self.baking_address,
            self.node_url,
            node_url_public,
            api_base_url,
        )
        self.block_api = self.provider_factory.newBlockApi(
            network_config, self.node_url, api_base_url)

        dexter_contracts_set = baking_cfg.get_contracts_set()
        if len(dexter_contracts_set) > 0 and not (self.reward_api.name
                                                  == "tzstats"):
            logger.warning(
                "The Dexter functionality is currently only supported using tzstats."
                "The contract address will be treated as a normal delegator.")
        else:
            self.reward_api.set_dexter_contracts_set(dexter_contracts_set)

        self.rewards_type = baking_cfg.get_rewards_type()
        self.pay_denunciation_rewards = baking_cfg.get_pay_denunciation_rewards(
        )
        self.fee_calc = service_fee_calc
        self.initial_payment_cycle = initial_payment_cycle

        logger.info("Initial cycle set to {}".format(
            self.initial_payment_cycle))

        self.nw_config = network_config
        self.payments_root = payments_dir
        self.calculations_dir = calculations_dir
        self.run_mode = run_mode
        self.exiting = False

        self.release_override = release_override
        self.payment_offset = payment_offset
        self.payments_queue = payments_queue
        self.life_cycle = life_cycle
        self.dry_run = dry_run

        self.payment_calc = PhasedPaymentCalculator(
            self.founders_map,
            self.owners_map,
            self.fee_calc,
            self.min_delegation_amt_in_mutez,
            self.rules_model,
        )

        self.retry_fail_thread = threading.Thread(target=self.retry_fail_run,
                                                  name=self.name +
                                                  "_retry_fail")
        self.retry_fail_event = threading.Event()
        self.retry_injected = retry_injected

        self.retry_producer = RetryProducer(
            self.payments_queue,
            self.reward_api,
            self,
            self.payments_root,
            self.initial_payment_cycle,
            self.retry_injected,
        )

        logger.debug('Producer "{}" started'.format(self.name))

    def exit(self):
        if not self.exiting:
            self.payments_queue.put(
                PaymentBatch(self, 0, [self.create_exit_payment()]))
            self.exiting = True

            if (self.life_cycle.is_running() and threading.current_thread()
                    is not threading.main_thread()):
                _thread.interrupt_main()

            if self.retry_fail_event:
                self.retry_fail_event.set()

    def retry_fail_run(self):
        logger.debug('Retry Fail thread "{}" started'.format(
            self.retry_fail_thread.name))

        sleep(60)  # producer thread already tried once, wait for next try

        while not self.exiting and self.life_cycle.is_running():
            self.retry_producer.retry_failed_payments()

            try:
                # prepare to wait on event
                self.retry_fail_event.clear()

                # this will either return with timeout or set from parent producer thread
                self.retry_fail_event.wait(60 * 60)  # 1 hour
            except RuntimeError:
                pass

    def run(self):
        # call first retry if not in onetime mode.
        # retry_failed script is more suitable for one time cases.
        if not self.run_mode == RunMode.ONETIME:
            self.retry_producer.retry_failed_payments()

            if self.run_mode == RunMode.RETRY_FAILED:
                sleep(5)
                self.exit()
                return

        # first retry is done by producer thread, start retry thread for further retries
        if self.run_mode == RunMode.FOREVER:
            self.retry_fail_thread.start()

        try:
            (
                current_cycle,
                current_level,
            ) = self.block_api.get_current_cycle_and_level()
        except ApiProviderException as a:
            logger.error(
                "Unable to fetch current cycle, {:s}. Exiting.".format(str(a)))
            self.exit()
            return

        # if initial_payment_cycle has the default value of -1 resulting in the last released cycle
        if self.initial_payment_cycle == -1:
            pymnt_cycle = (current_cycle -
                           (self.nw_config["NB_FREEZE_CYCLE"] + 1) -
                           self.release_override)
            if pymnt_cycle < 0:
                logger.error(
                    "Payment cycle cannot be < 0 but configuration results to {}"
                    .format(pymnt_cycle))
            else:
                logger.debug(
                    "Payment cycle is set to last released cycle {}".format(
                        pymnt_cycle))
        else:
            pymnt_cycle = self.initial_payment_cycle

        get_verbose_log_helper().reset(pymnt_cycle)

        while not self.exiting and self.life_cycle.is_running():

            # take a breath
            sleep(5)

            try:

                # Exit if disk is full
                # https://github.com/tezos-reward-distributor-organization/tezos-reward-distributor/issues/504
                if disk_is_full():
                    self.exit()
                    break

                # Check if local node is bootstrapped; sleep if needed; restart loop
                if not self.node_is_bootstrapped():
                    logger.info(
                        "Local node {} is not in sync with the Tezos network. Will sleep for {} blocks and check again."
                        .format(self.node_url, BOOTSTRAP_SLEEP))
                    self.wait_for_blocks(BOOTSTRAP_SLEEP)
                    continue

                # Local node is ready
                (
                    current_cycle,
                    current_level,
                ) = self.block_api.get_current_cycle_and_level()
                level_in_cycle = self.block_api.level_in_cycle(current_level)

                # create reports dir
                if self.calculations_dir and not os.path.exists(
                        self.calculations_dir):
                    os.makedirs(self.calculations_dir)

                logger.debug(
                    "Checking for pending payments: payment_cycle <= current_cycle - (self.nw_config['NB_FREEZE_CYCLE'] + 1) - self.release_override"
                )
                logger.info(
                    "Checking for pending payments: checking {} <= {} - ({} + 1) - {}"
                    .format(
                        pymnt_cycle,
                        current_cycle,
                        self.nw_config["NB_FREEZE_CYCLE"],
                        self.release_override,
                    ))

                # payments should not pass beyond last released reward cycle
                if (pymnt_cycle <= current_cycle -
                    (self.nw_config["NB_FREEZE_CYCLE"] + 1) -
                        self.release_override):
                    if not self.payments_queue.full():
                        if (not self.pay_denunciation_rewards
                            ) and self.reward_api.name == "RPC":
                            logger.info(
                                "Error: pay_denunciation_rewards=False requires an indexer since it is not possible to distinguish reward source using RPC"
                            )
                            e = "You must set 'pay_denunciation_rewards' to True when using RPC provider."
                            logger.error(e)
                            self.exit()
                            break

                        # Paying upcoming cycles (-R in [-6, -11] )
                        if pymnt_cycle >= current_cycle:
                            logger.warn(
                                "Please note that you are doing payouts for future rewards!!! These rewards are not earned yet, they are an estimation."
                            )
                            if not self.rewards_type.isEstimated():
                                logger.error(
                                    "For future rewards payout, you must configure the payout type to 'Estimated', see documentation"
                                )
                                self.exit()
                                break

                        # Paying cycles with frozen rewards (-R in [-1, -5] )
                        elif (pymnt_cycle >= current_cycle -
                              self.nw_config["NB_FREEZE_CYCLE"]):
                            logger.warn(
                                "Please note that you are doing payouts for frozen rewards!!!"
                            )

                        # If user wants to offset payments within a cycle, check here
                        if level_in_cycle < self.payment_offset:
                            wait_offset_blocks = self.payment_offset - level_in_cycle
                            wait_offset_minutes = (
                                wait_offset_blocks *
                                self.nw_config["MINIMAL_BLOCK_DELAY"]) / 60
                            logger.info(
                                "Current level within the cycle is {}; Requested offset is {}; Waiting for {} more blocks (~{} minutes)"
                                .format(
                                    level_in_cycle,
                                    self.payment_offset,
                                    wait_offset_blocks,
                                    wait_offset_minutes,
                                ))
                            self.wait_for_blocks(wait_offset_blocks)
                            continue  # Break/Repeat loop

                        else:
                            result = self.try_to_pay(pymnt_cycle,
                                                     self.rewards_type,
                                                     self.nw_config)

                        if result:
                            # single run is done. Do not continue.
                            if self.run_mode == RunMode.ONETIME:
                                logger.info(
                                    "Run mode ONETIME satisfied. Terminating..."
                                )
                                self.exit()
                                break
                            else:
                                pymnt_cycle = pymnt_cycle + 1
                                get_verbose_log_helper().reset(pymnt_cycle)

                    # end of queue size check
                    else:
                        logger.debug("Wait a few minutes, queue is full")
                        # wait a few minutes to let payments finish
                        sleep(60 * 3)

                # end of payment cycle check
                else:
                    logger.info(
                        "No pending payments for cycle {}, current cycle is {}"
                        .format(pymnt_cycle, current_cycle))

                    # pending payments done. Do not wait any more.
                    if self.run_mode == RunMode.PENDING:
                        logger.info(
                            "Run mode PENDING satisfied. Terminating...")
                        self.exit()
                        break

                    sleep(10)

                    # calculate number of blocks until end of current cycle plus user-defined offset
                    nb_blocks_remaining = (self.nw_config["BLOCKS_PER_CYCLE"] -
                                           level_in_cycle +
                                           self.payment_offset)
                    logger.debug(
                        "Waiting until next cycle; {} blocks remaining".format(
                            nb_blocks_remaining))

                    # wait until current cycle ends
                    self.wait_for_blocks(nb_blocks_remaining)

            except (ApiProviderException, ReadTimeout, ConnectTimeout) as e:
                logger.debug(
                    "{:s} error at payment producer loop: '{:s}'".format(
                        self.reward_api.name, str(e)),
                    exc_info=True,
                )
                logger.error(
                    "{:s} error at payment producer loop: '{:s}', will try again."
                    .format(self.reward_api.name, str(e)))

            except Exception as e:
                logger.debug(
                    "Unknown error in payment producer loop: {:s}".format(
                        str(e)),
                    exc_info=True,
                )
                logger.error(
                    "Unknown error in payment producer loop: {:s}, will try again."
                    .format(str(e)))

        # end of endless loop
        logger.debug("Producer returning...")

        # ensure consumer exits
        self.exit()

        return

    def stop(self):
        self.exit()
        self.event.set()

    def compute_rewards(self, reward_model, rewards_type, network_config):
        if rewards_type.isEstimated():
            logger.info("Using estimated rewards for payouts calculations")
            block_reward = network_config["BLOCK_REWARD"]
            endorsement_reward = network_config["ENDORSEMENT_REWARD"]
            total_estimated_block_reward = reward_model.num_baking_rights * block_reward
            total_estimated_endorsement_reward = (
                reward_model.num_endorsing_rights * endorsement_reward)
            computed_reward_amount = (total_estimated_block_reward +
                                      total_estimated_endorsement_reward)
        elif rewards_type.isActual():
            logger.info("Using actual rewards for payouts calculations")
            if self.pay_denunciation_rewards:
                computed_reward_amount = reward_model.total_reward_amount
            else:
                # omit denunciation rewards
                computed_reward_amount = (reward_model.rewards_and_fees -
                                          reward_model.equivocation_losses)
        elif rewards_type.isIdeal():
            logger.info("Using ideal rewards for payouts calculations")
            if self.pay_denunciation_rewards:
                computed_reward_amount = (reward_model.total_reward_amount +
                                          reward_model.offline_losses)
            else:
                # omit denunciation rewards and double baking loss
                computed_reward_amount = (reward_model.rewards_and_fees +
                                          reward_model.offline_losses)
        return computed_reward_amount

    def try_to_pay(self, pymnt_cycle, rewards_type, network_config):
        try:
            logger.info("Payment cycle is {:s}".format(str(pymnt_cycle)))

            # 0- check for past payment evidence for current cycle
            past_payment_state = check_past_payment(self.payments_root,
                                                    pymnt_cycle)

            if past_payment_state:
                logger.warn(past_payment_state)
                return True

            # 1- get reward data
            reward_model = self.reward_api.get_rewards_for_cycle_map(
                pymnt_cycle, rewards_type)

            # 2- compute reward amount to distribute based on configuration
            reward_model.computed_reward_amount = self.compute_rewards(
                reward_model, rewards_type, network_config)

            # 3- calculate rewards for delegators
            reward_logs, total_amount = self.payment_calc.calculate(
                reward_model)

            # 4- set cycle info
            for rl in reward_logs:
                rl.cycle = pymnt_cycle
            total_amount_to_pay = sum(
                [rl.amount for rl in reward_logs if rl.payable])

            # 5- if total_rewards > 0, proceed with payment
            if total_amount_to_pay > 0:

                # 6- send to payment consumer
                self.payments_queue.put(
                    PaymentBatch(self, pymnt_cycle, reward_logs))

                sleep(5.0)

                # 7- create calculations report file. This file contains calculations details
                report_file_path = get_calculation_report_file(
                    self.calculations_dir, pymnt_cycle)
                logger.debug("Creating calculation report (%s)",
                             report_file_path)
                self.create_calculations_report(reward_logs, report_file_path,
                                                total_amount, rewards_type)

                # 8- processing of cycle is done
                logger.info(
                    "Reward creation is done for cycle {}, created {} rewards."
                    .format(pymnt_cycle, len(reward_logs)))

            elif total_amount_to_pay == 0:
                logger.info("Total payment amount is 0. Nothing to pay!")

        except ApiProviderException as a:
            logger.error("[try_to_pay] API provider error {:s}".format(str(a)))
            raise a from a
        except Exception as e:
            logger.error("[try_to_pay] Generic exception {:s}".format(str(e)))
            raise e from e

        # Either succeeded or raised exception
        return True

    def wait_for_blocks(self, nb_blocks_remaining):
        for x in range(nb_blocks_remaining):
            sleep(self.nw_config["MINIMAL_BLOCK_DELAY"])

            # if shutting down, exit
            if not self.life_cycle.is_running():
                self.exit()
                break

    def node_is_bootstrapped(self):
        # Get RPC node's (-A) bootstrap time. If bootstrap time + 2 minutes is
        # before local time, node is not bootstrapped.
        #
        # clnt_mngr is a super class of SimpleClientManager which interfaces
        # with the tezos-node used for txn forging/signing/injection. This is the
        # node which we need to determine bootstrapped state
        try:
            boot_time = self.client_manager.get_bootstrapped()
            utc_time = datetime.utcnow()
            if (boot_time + timedelta(minutes=2)) < utc_time:
                logger.debug(
                    "Current time is '{}', latest block of local node is '{}'."
                    .format(utc_time, boot_time))
                return False
        except ValueError:
            logger.error(
                "Unable to determine local node's bootstrap status. Continuing..."
            )
        return True

    def create_calculations_report(self, payment_logs, report_file_path,
                                   total_rewards, rewards_type):

        if rewards_type.isEstimated():
            rt = "E"
        elif rewards_type.isActual():
            rt = "A"
        elif rewards_type.isIdeal():
            rt = "I"

        try:

            # Open reports file and write; auto-closes file
            with open(report_file_path, "w", newline="") as f:

                writer = csv.writer(f,
                                    delimiter=",",
                                    quotechar='"',
                                    quoting=csv.QUOTE_MINIMAL)

                # write headers and total rewards
                writer.writerow([
                    "address",
                    "type",
                    "staked_balance",
                    "current_balance",
                    "ratio",
                    "fee_ratio",
                    "amount",
                    "fee_amount",
                    "fee_rate",
                    "payable",
                    "skipped",
                    "atphase",
                    "desc",
                    "payment_address",
                    "rewards_type",
                ])

                # First row is for the baker
                writer.writerow([
                    self.baking_address,
                    "B",
                    sum([pl.staking_balance for pl in payment_logs]),
                    "{0:f}".format(1.0),
                    "{0:f}".format(1.0),
                    "{0:f}".format(0.0),
                    "{0:f}".format(total_rewards),
                    "{0:f}".format(0.0),
                    "{0:f}".format(0.0),
                    "0",
                    "0",
                    "-1",
                    "Baker",
                    "None",
                    rt,
                ])

                for pymnt_log in payment_logs:
                    # write row to csv file
                    array = [
                        pymnt_log.address,
                        pymnt_log.type,
                        pymnt_log.staking_balance,
                        pymnt_log.current_balance,
                        "{0:.10f}".format(pymnt_log.ratio),
                        "{0:.10f}".format(pymnt_log.service_fee_ratio),
                        "{0:f}".format(pymnt_log.amount),
                        "{0:f}".format(pymnt_log.service_fee_amount),
                        "{0:f}".format(pymnt_log.service_fee_rate),
                        "1" if pymnt_log.payable else "0",
                        "1" if pymnt_log.skipped else "0",
                        pymnt_log.skippedatphase
                        if pymnt_log.skipped else "-1",
                        pymnt_log.desc if pymnt_log.desc else "None",
                        pymnt_log.paymentaddress,
                        rt,
                    ]
                    writer.writerow(array)

                    logger.debug(
                        "Reward created for {:s} type: {:s}, stake bal: {:>10.2f}, cur bal: {:>10.2f}, ratio: {:.6f}, fee_ratio: {:.6f}, "
                        "amount: {:>10.6f}, fee_amount: {:>4.6f}, fee_rate: {:.2f}, payable: {:s}, skipped: {:s}, at-phase: {:d}, "
                        "desc: {:s}, pay_addr: {:s}, type: {:s}".format(
                            pymnt_log.address,
                            pymnt_log.type,
                            pymnt_log.staking_balance / MUTEZ,
                            pymnt_log.current_balance / MUTEZ,
                            pymnt_log.ratio,
                            pymnt_log.service_fee_ratio,
                            pymnt_log.amount / MUTEZ,
                            pymnt_log.service_fee_amount / MUTEZ,
                            pymnt_log.service_fee_rate,
                            "Y" if pymnt_log.payable else "N",
                            "Y" if pymnt_log.skipped else "N",
                            pymnt_log.skippedatphase,
                            pymnt_log.desc,
                            pymnt_log.paymentaddress,
                            rt,
                        ))

        except Exception as e:
            import errno

            print("Exception during write operation invoked: {}".format(e))
            if e.errno == errno.ENOSPC:
                print("Not enough space on device!")
            exit()

        logger.info(
            "Calculation report is created at '{}'".format(report_file_path))

    @staticmethod
    def create_exit_payment():
        return RewardLog.ExitInstance()

    def notify_retry_fail_thread(self):
        self.retry_fail_event.set()

    # upon success retry failed payments if present
    # success may indicate what went wrong in past is fixed.
    def on_success(self, pymnt_batch):
        self.notify_retry_fail_thread()

    def on_fail(self, pymnt_batch):
        pass
class PaymentProducer(threading.Thread, PaymentProducerABC):
    def __init__(
        self,
        name,
        initial_payment_cycle,
        network_config,
        payments_dir,
        calculations_dir,
        run_mode,
        service_fee_calc,
        release_override,
        payment_offset,
        baking_cfg,
        payments_queue,
        life_cycle,
        dry_run,
        client_manager,
        node_url,
        reward_data_provider,
        node_url_public="",
        api_base_url=None,
        retry_injected=False,
    ):
        super(PaymentProducer, self).__init__()
        self.event = threading.Event()
        self.rules_model = RulesModel(
            baking_cfg.get_excluded_set_tob(),
            baking_cfg.get_excluded_set_toe(),
            baking_cfg.get_excluded_set_tof(),
            baking_cfg.get_dest_map(),
        )
        self.baking_address = baking_cfg.get_baking_address()
        self.owners_map = baking_cfg.get_owners_map()
        self.founders_map = baking_cfg.get_founders_map()
        self.min_delegation_amt_in_mutez = int(
            baking_cfg.get_min_delegation_amount() * MUTEZ_PER_TEZ)
        self.delegator_pays_xfer_fee = baking_cfg.get_delegator_pays_xfer_fee()
        self.provider_factory = ProviderFactory(reward_data_provider)
        self.name = name

        self.node_url = node_url
        self.client_manager = client_manager
        self.reward_api = self.provider_factory.newRewardApi(
            network_config,
            self.baking_address,
            self.node_url,
            node_url_public,
            api_base_url,
        )
        self.block_api = self.provider_factory.newBlockApi(
            network_config, self.node_url, api_base_url)

        dexter_contracts_set = baking_cfg.get_contracts_set()
        if len(dexter_contracts_set) > 0 and not (self.reward_api.name
                                                  == "tzstats"):
            logger.warning(
                "The Dexter functionality is currently only supported using tzstats."
                "The contract address will be treated as a normal delegator.")
        else:
            self.reward_api.set_dexter_contracts_set(dexter_contracts_set)

        self.rewards_type = baking_cfg.get_rewards_type()
        self.pay_denunciation_rewards = baking_cfg.get_pay_denunciation_rewards(
        )
        self.fee_calc = service_fee_calc
        self.initial_payment_cycle = initial_payment_cycle

        logger.info("Initial cycle set to {}".format(
            self.initial_payment_cycle))

        self.nw_config = network_config
        self.payments_root = payments_dir
        self.calculations_dir = calculations_dir
        self.run_mode = run_mode
        self.exiting = False

        self.release_override = release_override
        self.payment_offset = payment_offset
        self.payments_queue = payments_queue
        self.life_cycle = life_cycle
        self.dry_run = dry_run

        self.payment_calc = PhasedPaymentCalculator(
            self.founders_map,
            self.owners_map,
            self.fee_calc,
            self.min_delegation_amt_in_mutez,
            self.rules_model,
        )

        self.retry_fail_thread = threading.Thread(target=self.retry_fail_run,
                                                  name=self.name +
                                                  "_retry_fail")
        self.retry_fail_event = threading.Event()
        self.retry_injected = retry_injected

        self.retry_producer = RetryProducer(
            self.payments_queue,
            self.reward_api,
            self,
            self.payments_root,
            self.initial_payment_cycle,
            self.retry_injected,
        )

        logger.debug('Producer "{}" started'.format(self.name))

    def exit(self):
        if not self.exiting:
            self.payments_queue.put(
                PaymentBatch(self, 0, [self.create_exit_payment()]))
            self.exiting = True

            if (self.life_cycle.is_running() and threading.current_thread()
                    is not threading.main_thread()):
                _thread.interrupt_main()
                logger.info("Sending KeyboardInterrupt signal.")

            if self.retry_fail_event:
                self.retry_fail_event.set()

    def retry_fail_run(self):
        logger.debug('Retry Fail thread "{}" started'.format(
            self.retry_fail_thread.name))

        sleep(60)  # producer thread already tried once, wait for next try

        while not self.exiting and self.life_cycle.is_running():
            self.retry_producer.retry_failed_payments()

            try:
                # prepare to wait on event
                self.retry_fail_event.clear()

                # this will either return with timeout or set from parent producer thread
                self.retry_fail_event.wait(60 * 60)  # 1 hour
            except RuntimeError:
                pass

    def run(self):
        # call first retry if not in onetime mode.
        # retry_failed script is more suitable for one time cases.
        if not self.run_mode == RunMode.ONETIME:
            self.retry_producer.retry_failed_payments()

            if self.run_mode == RunMode.RETRY_FAILED:
                sleep(5)
                self.exit()
                return

        # first retry is done by producer thread, start retry thread for further retries
        if self.run_mode == RunMode.FOREVER:
            self.retry_fail_thread.start()

        try:
            (
                current_cycle,
                current_level,
            ) = self.block_api.get_current_cycle_and_level()
        except ApiProviderException as a:
            logger.error(
                "Unable to fetch current cycle, {:s}. Exiting.".format(str(a)))
            self.exit()
            return

        # if initial_payment_cycle has the default value of -1 resulting in the last released cycle
        if self.initial_payment_cycle == -1:
            pymnt_cycle = (current_cycle -
                           (self.nw_config["NB_FREEZE_CYCLE"] + 1) -
                           self.release_override)
            if pymnt_cycle < 0:
                logger.error(
                    "Payment cycle cannot be < 0 but configuration results to {}"
                    .format(pymnt_cycle))
            else:
                logger.debug(
                    "Payment cycle is set to last released cycle {}".format(
                        pymnt_cycle))
        else:
            pymnt_cycle = self.initial_payment_cycle

        get_verbose_log_helper().reset(pymnt_cycle)

        while not self.exiting and self.life_cycle.is_running():

            # take a breath
            sleep(5)

            try:

                # Exit if disk is full
                # https://github.com/tezos-reward-distributor-organization/tezos-reward-distributor/issues/504
                if disk_is_full():
                    self.exit()
                    break

                # Check if local node is bootstrapped; sleep if needed; restart loop
                if not self.node_is_bootstrapped():
                    logger.info(
                        "Local node {} is not in sync with the Tezos network. Will sleep for {} blocks and check again."
                        .format(self.node_url, BOOTSTRAP_SLEEP))
                    self.wait_for_blocks(BOOTSTRAP_SLEEP)
                    continue

                # Local node is ready
                (
                    current_cycle,
                    current_level,
                ) = self.block_api.get_current_cycle_and_level()
                level_in_cycle = self.block_api.level_in_cycle(current_level)

                # create reports dir
                if self.calculations_dir and not os.path.exists(
                        self.calculations_dir):
                    os.makedirs(self.calculations_dir)

                logger.debug(
                    "Checking for pending payments: payment_cycle <= current_cycle - (self.nw_config['NB_FREEZE_CYCLE'] + 1) - self.release_override"
                )
                logger.info(
                    "Checking for pending payments: checking {} <= {} - ({} + 1) - {}"
                    .format(
                        pymnt_cycle,
                        current_cycle,
                        self.nw_config["NB_FREEZE_CYCLE"],
                        self.release_override,
                    ))

                # payments should not pass beyond last released reward cycle
                if (pymnt_cycle <= current_cycle -
                    (self.nw_config["NB_FREEZE_CYCLE"] + 1) -
                        self.release_override):
                    if not self.payments_queue.full():
                        if (not self.pay_denunciation_rewards
                            ) and self.reward_api.name == "RPC":
                            logger.info(
                                "Error: pay_denunciation_rewards=False requires an indexer since it is not possible to distinguish reward source using RPC"
                            )
                            e = "You must set 'pay_denunciation_rewards' to True when using RPC provider."
                            logger.error(e)
                            self.exit()
                            break

                        # Paying upcoming cycles (-R set to -11 )
                        if pymnt_cycle >= current_cycle:
                            logger.warn(
                                "Please note that you are doing payouts for future rewards!!! These rewards are not earned yet, they are an estimation."
                            )
                            logger.warn(
                                "TRD will attempt to adjust the amount after the cycle runs, but it may not work."
                            )

                        # Paying cycles with frozen rewards (-R set to -5 )
                        elif (pymnt_cycle >= current_cycle -
                              self.nw_config["NB_FREEZE_CYCLE"]):
                            logger.warn(
                                "Please note that you are doing payouts for frozen rewards!!!"
                            )

                        # If user wants to offset payments within a cycle, check here
                        if level_in_cycle < self.payment_offset:
                            wait_offset_blocks = self.payment_offset - level_in_cycle
                            wait_offset_minutes = (
                                wait_offset_blocks *
                                self.nw_config["MINIMAL_BLOCK_DELAY"]) / 60
                            logger.info(
                                "Current level within the cycle is {}; Requested offset is {}; Waiting for {} more blocks (~{} minutes)"
                                .format(
                                    level_in_cycle,
                                    self.payment_offset,
                                    wait_offset_blocks,
                                    wait_offset_minutes,
                                ))
                            self.wait_for_blocks(wait_offset_blocks)
                            continue  # Break/Repeat loop

                        else:
                            result = self.try_to_pay(
                                pymnt_cycle,
                                self.rewards_type,
                                self.nw_config,
                                current_cycle,
                            )

                        if result:
                            # single run is done. Do not continue.
                            if self.run_mode == RunMode.ONETIME:
                                logger.info(
                                    "Run mode ONETIME satisfied. Terminating..."
                                )
                                self.exit()
                                break
                            else:
                                pymnt_cycle = pymnt_cycle + 1
                                get_verbose_log_helper().reset(pymnt_cycle)

                    # end of queue size check
                    else:
                        logger.debug("Wait a few minutes, queue is full")
                        # wait a few minutes to let payments finish
                        sleep(60 * 3)

                # end of payment cycle check
                else:
                    logger.info(
                        "No pending payments for cycle {}, current cycle is {}"
                        .format(pymnt_cycle, current_cycle))

                    # pending payments done. Do not wait any more.
                    if self.run_mode == RunMode.PENDING:
                        logger.info(
                            "Run mode PENDING satisfied. Terminating...")
                        self.exit()
                        break

                    sleep(10)

                    # calculate number of blocks until end of current cycle plus user-defined offset
                    nb_blocks_remaining = (self.nw_config["BLOCKS_PER_CYCLE"] -
                                           level_in_cycle +
                                           self.payment_offset)
                    logger.debug(
                        "Waiting until next cycle; {} blocks remaining".format(
                            nb_blocks_remaining))

                    # wait until current cycle ends
                    self.wait_for_blocks(nb_blocks_remaining)

            except (ApiProviderException, ReadTimeout, ConnectTimeout) as e:
                logger.debug(
                    "{:s} error at payment producer loop: '{:s}'".format(
                        self.reward_api.name, str(e)),
                    exc_info=True,
                )
                logger.error(
                    "{:s} error at payment producer loop: '{:s}', will try again."
                    .format(self.reward_api.name, str(e)))

            except Exception as e:
                logger.debug(
                    "Unknown error in payment producer loop: {:s}".format(
                        str(e)),
                    exc_info=True,
                )
                logger.error(
                    "Unknown error in payment producer loop: {:s}, will try again."
                    .format(str(e)))

        # end of endless loop
        logger.debug("Producer returning...")

        # ensure consumer exits
        self.exit()

        return

    def stop(self):
        self.exit()
        self.event.set()

    def compute_rewards(self,
                        pymnt_cycle,
                        computation_type,
                        network_config,
                        adjustments={}):
        """
        Compute total rewards based on computation type and policy, then
        calls payment_call.calculate to calculate rewards per delegator.

        :param pymnt_cycle: the cycle for which rewards are being calculated
        :param computation_type: calculate estimated, actual or ideal rewards
        :param network_config: configuration of the current tezos network, needed to calc rewards.
        :param adjustments: a map of adjustments per address. We add to amount to compute adjusted_amount
        :return: rewards per delegator (reward logs) and total amount
        """
        # does calculation report file already exist for this cycle?

        logger.info("Computing rewards for cycle {:s}.".format(
            str(pymnt_cycle)))
        reward_model = self.reward_api.get_rewards_for_cycle_map(
            pymnt_cycle, computation_type)
        if computation_type.isEstimated():
            logger.info("Using estimated rewards for payouts calculations")
            block_reward = network_config["BLOCK_REWARD"]
            endorsement_reward = network_config["ENDORSEMENT_REWARD"]
            total_estimated_block_reward = reward_model.num_baking_rights * block_reward
            total_estimated_endorsement_reward = (
                reward_model.num_endorsing_rights * endorsement_reward)
            reward_model.computed_reward_amount = (
                total_estimated_block_reward +
                total_estimated_endorsement_reward)
        elif computation_type.isActual():
            logger.info("Using actual rewards for payouts calculations")
            if self.pay_denunciation_rewards:
                reward_model.computed_reward_amount = reward_model.total_reward_amount
            else:
                # omit denunciation rewards
                reward_model.computed_reward_amount = (
                    reward_model.rewards_and_fees -
                    reward_model.equivocation_losses)
        elif computation_type.isIdeal():
            logger.info("Using ideal rewards for payouts calculations")
            if self.pay_denunciation_rewards:
                reward_model.computed_reward_amount = (
                    reward_model.total_reward_amount +
                    reward_model.offline_losses)
            else:
                # omit denunciation rewards and double baking loss
                reward_model.computed_reward_amount = (
                    reward_model.rewards_and_fees +
                    reward_model.offline_losses)

        # 3- calculate rewards for delegators
        return self.payment_calc.calculate(reward_model, adjustments)

    def recompute_rewards(self, completed_cycle, computation_type,
                          network_config):
        """
        In case of early payout, the payout is already done when the cycle runs.
        After a cycle has run, we redo the computations. If we find overpayemnt
        (overestimate) or underpayment (negative overestimate), we record it in the
        calculation report csv file and return an adjustment map.

        :param completed_cycle: the cycle for which rewards are being recalculated
        :param computation_type: calculate estimated, actual or ideal rewards
        :param network_config: configuration of the current tezos network, needed to calc rewards.
        :return: adjustments map showing negative of overestimates per delegator
        """
        logger.info(
            "Checking for potential adjustment for recently completed cycle {:s}."
            .format(str(completed_cycle)))
        completed_cycle_report_file_path = get_calculation_report_file_path(
            self.calculations_dir, completed_cycle)
        adjustments = {}
        if os.path.isfile(completed_cycle_report_file_path):
            logger.info(
                "TRD ran for cycle: {:s}, calculating adjustments.".format(
                    str(completed_cycle)))
            (
                reward_logs_from_report,
                total_amount_from_report,
                rewards_type_from_report,
                _,
            ) = CsvCalculationFileParser().parse(
                completed_cycle_report_file_path, self.baking_address)
            # check that the overestimate has not been computed yet
            if sum([rl.overestimate or 0
                    for rl in reward_logs_from_report]) > 0:
                logger.info(
                    "Overestimate has already been calculated for cycle {:s}, not calculating it again."
                    .format(str(completed_cycle)))
                completed_cycle_reward_logs, completed_cycle_total_amount = (
                    reward_logs_from_report,
                    total_amount_from_report,
                )
            else:
                (
                    completed_cycle_reward_logs,
                    completed_cycle_total_amount,
                ) = self.compute_rewards(completed_cycle, computation_type,
                                         network_config)
            overestimate = int(total_amount_from_report -
                               completed_cycle_total_amount)
            logger.info(
                "We {:s}estimated payout for cycle {:s} by {:<,d} mutez, will attempt to adjust."
                .format(
                    ("over" if overestimate > 0 else "under"),
                    str(completed_cycle),
                    abs(overestimate),
                ))
            for rl in reward_logs_from_report:
                # overwrite only overestimate in report csv file, leave the rest alone
                rl.overestimate = int(
                    Decimal(rl.ratio * overestimate).to_integral_value(
                        rounding=ROUND_HALF_DOWN))
                # we adjust the cycle we are paying out with the overestimate of the
                # just completed cycle
                adjustments[rl.address] = rl.overestimate
                logger.debug(
                    f"Will try to recover {adjustments[rl.address]} mutez for {rl.address} based on past overpayment"
                )

            CsvCalculationFileParser().write(
                reward_logs_from_report,
                completed_cycle_report_file_path,
                total_amount_from_report,
                rewards_type_from_report,
                self.baking_address,
                False,
            )
        return adjustments

    def try_to_pay(self, pymnt_cycle, rewards_type, network_config,
                   current_cycle):
        try:
            logger.info("Payment cycle is {:s}".format(str(pymnt_cycle)))

            # 0- check for past payment evidence for current cycle
            past_payment_state = check_past_payment(self.payments_root,
                                                    pymnt_cycle)

            if past_payment_state:
                logger.warn(past_payment_state)
                return True

            adjustments = {}
            early_payout = False
            current_cycle_rewards_type = rewards_type
            # 1- adjust past cycle if necessary
            if self.release_override == -11 and pymnt_cycle >= current_cycle:
                early_payout = True
                completed_cycle = pymnt_cycle - 6
                adjustments = self.recompute_rewards(completed_cycle,
                                                     rewards_type,
                                                     network_config)
                # payout for current cycle will be estimated since we don't know actual rewards yet
                current_cycle_rewards_type = RewardsType.ESTIMATED

            # 2- get reward data and compute how to distribute them
            reward_logs, total_amount = self.compute_rewards(
                pymnt_cycle, current_cycle_rewards_type, network_config,
                adjustments)
            total_recovered_adjustments = int(
                sum([rl.adjustment for rl in reward_logs]))
            total_adjustments_to_recover = int(sum(adjustments.values()))
            if total_adjustments_to_recover > 0:
                logger.debug(
                    "Total adjustments to recover is {:<,d} mutez, total recovered adjustment is {:<,d} mutez."
                    .format(total_adjustments_to_recover,
                            total_recovered_adjustments))
                logger.info(
                    "After early payout of cycle {:s}, {:<,d} mutez were not recovered."
                    .format(
                        str(completed_cycle),
                        total_adjustments_to_recover +
                        total_recovered_adjustments,
                    ))

            # 3- create calculations report file. This file contains calculations details
            report_file_path = get_calculation_report_file_path(
                self.calculations_dir, pymnt_cycle)
            logger.debug("Creating calculation report (%s)", report_file_path)
            CsvCalculationFileParser().write(
                reward_logs,
                report_file_path,
                total_amount,
                current_cycle_rewards_type,
                self.baking_address,
                early_payout,
            )

            # 4- set cycle info
            for reward_log in reward_logs:
                reward_log.cycle = pymnt_cycle
            total_amount_to_pay = int(
                sum([
                    reward_log.adjusted_amount for reward_log in reward_logs
                    if reward_log.payable
                ]))

            # 5- if total_rewards > 0, proceed with payment
            if total_amount_to_pay > 0:

                self.payments_queue.put(
                    PaymentBatch(self, pymnt_cycle, reward_logs))

                sleep(5.0)

                # 6- processing of cycle is done
                logger.info(
                    "Reward creation is done for cycle {}, created {} rewards."
                    .format(pymnt_cycle, len(reward_logs)))

            elif total_amount_to_pay == 0:
                logger.info("Total payment amount is 0. Nothing to pay!")

        except ApiProviderException as a:
            logger.error("[try_to_pay] API provider error {:s}".format(str(a)))
            raise a from a
        except Exception as e:
            logger.error("[try_to_pay] Generic exception {:s}".format(str(e)))
            raise e from e

        # Either succeeded or raised exception
        return True

    def wait_for_blocks(self, nb_blocks_remaining):
        for x in range(nb_blocks_remaining):
            sleep(self.nw_config["MINIMAL_BLOCK_DELAY"])

            # if shutting down, exit
            if not self.life_cycle.is_running():
                self.exit()
                break

    def node_is_bootstrapped(self):
        # Get RPC node's (-A) bootstrap time. If bootstrap time + 2 minutes is
        # before local time, node is not bootstrapped.
        #
        # clnt_mngr is a super class of SimpleClientManager which interfaces
        # with the tezos-node used for txn forging/signing/injection. This is the
        # node which we need to determine bootstrapped state
        try:
            boot_time = self.client_manager.get_bootstrapped()
            utc_time = datetime.utcnow()
            if (boot_time + timedelta(minutes=2)) < utc_time:
                logger.debug(
                    "Current time is '{}', latest block of local node is '{}'."
                    .format(utc_time, boot_time))
                return False
        except ValueError:
            logger.error(
                "Unable to determine local node's bootstrap status. Continuing..."
            )
        return True

    @staticmethod
    def create_exit_payment():
        return RewardLog.ExitInstance()

    def notify_retry_fail_thread(self):
        self.retry_fail_event.set()

    # upon success retry failed payments if present
    # success may indicate what went wrong in past is fixed.
    def on_success(self, pymnt_batch):
        self.notify_retry_fail_thread()

    def on_fail(self, pymnt_batch):
        pass