Skip to content

Commit

Permalink
Refactoring + accusation payout toggle
Browse files Browse the repository at this point in the history
When someone double bakes or endorses, anyone can submit an on-chain
proof of equivocation. The bakers that includes the proof gets rewarded
with half of the security deposit of the faulty baker.

It is important to note that equivocation has been very rare on Tezos so
this PR does not change anything to TRD in the vast majority of cases.

Out of the discussions leading to PR #415 being merged, it has emerged
that TRD pays out accusation rewards inconsistently: RPC reward model
will rely on the on-chain calculation of rewards, which includes
accusation rewards. Tzstats does not include accusation rewards. Tzkt
used to, but #470 changed the tzkt code to not do it. We agreed to make
it configurable.

This PR does it. It has turned into a major refactoring: the logic to
calculate rewards used to be isolated per provider: tzkt, tzstats, rpc
would make their own calculations and return the reward amount via the
rewards model.

It makes more sense to have this calculation done at payment producer
level. To make this possible: I extended the model. While it was
previously returning only one value, it now returns several values
(rewards from baking, endorsing, fees and revelations; accusation
rewards; double baking losses). Various providers populate the same
fields with custom logic. We end up with the below core logic
executed independly of the provider chosen:

```
if rewards_type.isEstimated():
    logger.info("Using estimated rewards for payouts calculations")
    total_estimated_block_reward = reward_model.num_baking_rights * block_reward
    total_estimated_endorsement_reward = reward_model.num_endorsing_rights * endorsement_reward
    total_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:
        total_reward_amount = reward_model.total_reward_amount
    else:
        # omit denunciation rewards
        total_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:
        total_reward_amount = reward_model.total_reward_amount + reward_model.offline_losses
    else:
        # omit denunciation rewards and double baking loss
        total_reward_amount = reward_model.rewards_and_fees + reward_model.offline_losses
```

payout_accusation_rewards is the new configuration option. It defaults
to false, but you must set it to true if you use RPC provider otherwise you
get an error: RPC can't distinguish between an accusation reward and a
baking reward unless you parse every block (which is the job of an
indexer). In other terms, RPC provider will populate
`total_reward_amount` but not `rewards_and_fees` or `accusation_rewards`
- it will leave these fields to "None".

Extending the model required several changes to tests as well.
Specifically tzkt tests continue to be a pain. In PR #501 we had
hardcoded the accusation rewards. Now, with our extended model, we can
compare apples to apples again, and I removed these hardcoded values.

Configuration
-------------

I modified configure.py to set the new flag. All flags are currently
mandatory, so I did not change that, but anyone upgrading TRD will have
to modify their config, which is not great. What does the team think?
Should we default to false when the config flag is absent?

Testing
-------

I did some manual testing with the following baker:

```
baking_address: tz1WnfXMPaNTBmH7DBPwqCWs9cPDJdkGBTZ8
```

On cycle 78. This baker was rewarded for denunciating a double baking on
this cycle. I observed that the rewards were higher when the new setting
was set to "True". I also observed that the rewards value was consistent
between tzkt and tzstats.
  • Loading branch information
nicolasochem committed Oct 29, 2021
1 parent c11cf96 commit e1c4899
Show file tree
Hide file tree
Showing 30 changed files with 334 additions and 187 deletions.
7 changes: 7 additions & 0 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,13 @@ Available configuration parameters are:

delegator_pays_ra_fee : False

**pay_denunciation_rewards**
True/False - Baker may get rewarded for denunciating another baker's equivocation (double baking or double endorsing). The protocol rewards the baker including the denunciation. When True, these rewards will be distributed. When False, they will remain in the baker's account, allowing the baker to reimburse the party at fault if they desire. Must be set to True when using RPC backend as RPC is not able to itemize rewards.

Example::

pay_denunciation_rewards: True

**rules_map**
The rules_map is needed to redirect payments. A pre-defined source (left side) is mindelegation. Pre-defined destinations (right side) are: TOF = to founders balance, TOB = to bakers balance, and TOE = to everyone. Variable sources and destinations are PKHs. New since v8.0 PKH: Dexter enables payouts to Dexter liquidity pools.

Expand Down
4 changes: 2 additions & 2 deletions src/calc/calculate_phase0.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def __init__(self, reward_provider_model) -> None:
def calculate(self, reward_logs=None, total_reward_amount=None):
"""
:param reward_logs: Nothing is expected. This value is not used. reward_logs are generated from provider object.
:param total_reward_amount: Nothing is expected. This value is not used. total amount is taken from provider object.
:param total_reward_amount: Nothing is expected. This value is not used. total amount is calculated in calling function.
:return: tuple (reward_logs, total reward amount)
reward_logs is a list of RewardLog objects. Last item is owners_parent record.
Expand Down Expand Up @@ -66,4 +66,4 @@ def calculate(self, reward_logs=None, total_reward_amount=None):

reward_logs.append(owners_rl)

return reward_logs, self.reward_provider_model.total_reward_amount
return reward_logs
4 changes: 2 additions & 2 deletions src/calc/phased_payment_calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ def __init__(self, founders_map, owners_map, service_fee_calculator, min_delegat
# owners reward = owners payment = total reward - delegators reward
# founders reward = delegators fee = total reward - delegators reward
####
def calculate(self, reward_provider_model):
def calculate(self, reward_provider_model, total_rwrd_amnt):

phase0 = CalculatePhase0(reward_provider_model)
rwrd_logs, total_rwrd_amnt = phase0.calculate()
rwrd_logs = phase0.calculate()

logger.info("Total rewards before processing is {:,} mutez.".format(total_rwrd_amnt))
if total_rwrd_amnt == 0:
Expand Down
21 changes: 19 additions & 2 deletions src/configure.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from log_config import main_logger, init
from model.baking_conf import BakingConf, BAKING_ADDRESS, PAYMENT_ADDRESS, SERVICE_FEE, FOUNDERS_MAP, OWNERS_MAP, \
MIN_DELEGATION_AMT, RULES_MAP, MIN_DELEGATION_KEY, DELEGATOR_PAYS_XFER_FEE, DELEGATOR_PAYS_RA_FEE, \
REACTIVATE_ZEROED, SPECIALS_MAP, SUPPORTERS_SET, REWARDS_TYPE
REACTIVATE_ZEROED, SPECIALS_MAP, SUPPORTERS_SET, REWARDS_TYPE, PAY_DENUNCIATION_REWARDS
from util.address_validator import AddressValidator
from util.fee_validator import FeeValidator

Expand All @@ -43,6 +43,7 @@
'reactivatezeroed': "If a destination address has 0 balance, should burn fee be paid to reactivate? 1 for Yes, 0 for No. Type enter for Yes",
'delegatorpaysxfrfee': "Who is going to pay for transfer fees: 0 for delegator, 1 for delegate. Type enter for delegator",
'delegatorpaysrafee': "Who is going to pay for 0 balance reactivation/burn fee: 0 for delegator, 1 for delegate. Type enter for delegator",
'paydenunciationrewards': "If you denounce another baker for baking or endorsing, you will get rewarded. Distribute denunciation rewards to your delegators? 1 for Yes, 0 for No. Type enter for No",
'supporters': "Add supporter address. Supporters do not pay bakery fee. Type enter to skip",
'specials': "Add any addresses with a special fee in form of 'PKH,fee'. Type enter to skip",
'noplugins': "No plugins are enabled by default. If you wish to use the email, twitter, or telegram plugins, please read the documentation and edit the configuration file manually."
Expand Down Expand Up @@ -312,6 +313,20 @@ def ondelegatorpaysrafee(input):
fsm.go()


def onpaydenunciationrewards(input):
try:
if not input:
input = "1"
if input != "0" and input != "1":
raise Exception("Please enter '0' or '1'")
global parser
parser.set(PAY_DENUNCIATION_REWARDS, input != "1")
except Exception as e:
printe("Invalid input: {}".format(str(e)))
return
fsm.go()


def onreactivatezeroed(input):
try:
if not input:
Expand Down Expand Up @@ -344,6 +359,7 @@ def onprefinal(input):
'reactivatezeroed': onreactivatezeroed,
'delegatorpaysxfrfee': ondelegatorpaysxfrfee,
'delegatorpaysrafee': ondelegatorpaysrafee,
'paydenunciationrewards': onpaydenunciationrewards,
'supporters': onsupporters,
'specials': onspecials,
'prefinal': onprefinal,
Expand All @@ -364,7 +380,8 @@ def onprefinal(input):
{'name': 'go', 'src': 'redirect', 'dst': 'reactivatezeroed'},
{'name': 'go', 'src': 'reactivatezeroed', 'dst': 'delegatorpaysrafee'},
{'name': 'go', 'src': 'delegatorpaysrafee', 'dst': 'delegatorpaysxfrfee'},
{'name': 'go', 'src': 'delegatorpaysxfrfee', 'dst': 'specials'},
{'name': 'go', 'src': 'delegatorpaysxfrfee', 'dst': 'paydenunciationrewards'},
{'name': 'go', 'src': 'paydenunciationrewards', 'dst': 'specials'},
{'name': 'go', 'src': 'specials', 'dst': 'supporters'},
{'name': 'go', 'src': 'supporters', 'dst': 'final'}],
'callbacks': {
Expand Down
4 changes: 4 additions & 0 deletions src/model/baking_conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
DELEGATOR_PAYS_RA_FEE = 'delegator_pays_ra_fee'
PLUGINS_CONF = 'plugins'
REWARDS_TYPE = 'rewards_type'
PAY_DENUNCIATION_REWARDS = 'pay_denunciation_rewards'

# extensions
FULL_SUPPORTERS_SET = "__full_supporters_set"
Expand Down Expand Up @@ -103,5 +104,8 @@ def get_plugins_conf(self):
def get_rewards_type(self):
return self.get_attribute(REWARDS_TYPE)

def get_pay_denunciation_rewards(self):
return self.get_attribute(PAY_DENUNCIATION_REWARDS)

def __repr__(self) -> str:
return json.dumps(self.__dict__, cls=CustomJsonEncoder, indent=1)
22 changes: 20 additions & 2 deletions src/model/reward_provider_model.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
class RewardProviderModel:
def __init__(self, delegate_staking_balance, total_reward_amount, delegator_balance_dict) -> None:
def __init__(self, delegate_staking_balance, num_baking_rights, num_endorsing_rights,
total_reward_amount, rewards_and_fees, equivocation_losses, denunciation_rewards, offline_losses, delegator_balance_dict) -> None:
super().__init__()
self.delegator_balance_dict = delegator_balance_dict
self.total_reward_amount = total_reward_amount
self.delegate_staking_balance = delegate_staking_balance
self.num_baking_rights = num_baking_rights
self.num_endorsing_rights = num_endorsing_rights
# rewards that should have been earned, had the baker been online
self.offline_losses = offline_losses

# total reward as recorded in-protocol
self.total_reward_amount = total_reward_amount

# When using indexers, the total amount above can be itemized as follows:

# * baking rewards, fees, revelation rewards
self.rewards_and_fees = rewards_and_fees

# * losses from double baking/endorsing
self.equivocation_losses = equivocation_losses

# * rewards from denunciating other people's double baking/endorsing
self.denunciation_rewards = denunciation_rewards
33 changes: 28 additions & 5 deletions src/pay/payment_producer.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def __init__(self, name, initial_payment_cycle, network_config, payments_dir, ca
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

Expand Down Expand Up @@ -170,6 +171,12 @@ def run(self):
# 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:
Expand All @@ -192,7 +199,7 @@ def run(self):
continue # Break/Repeat loop

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

if result:
# single run is done. Do not continue.
Expand Down Expand Up @@ -246,7 +253,7 @@ def run(self):

return

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

Expand All @@ -258,17 +265,33 @@ def try_to_pay(self, pymnt_cycle, rewards_type):
return True

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

block_reward = network_config["BLOCK_REWARD"]
endorsement_reward = network_config["ENDORSEMENT_REWARD"]

if rewards_type.isEstimated():
logger.info("Using estimated rewards for payouts calculations")
total_estimated_block_reward = reward_model.num_baking_rights * block_reward
total_estimated_endorsement_reward = reward_model.num_endorsing_rights * endorsement_reward
total_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:
total_reward_amount = reward_model.total_reward_amount
else:
# omit denunciation rewards
total_reward_amount = reward_model.rewards_and_fees - reward_model.equivocation_losses
elif rewards_type.isIdeal():
logger.info("Using ideal rewards for payouts calculations")

reward_model = self.reward_api.get_rewards_for_cycle_map(pymnt_cycle, rewards_type)
if self.pay_denunciation_rewards:
total_reward_amount = reward_model.total_reward_amount + reward_model.offline_losses
else:
# omit denunciation rewards and double baking loss
total_reward_amount = reward_model.rewards_and_fees + reward_model.offline_losses

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

# 3- set cycle info
for rl in reward_logs:
Expand Down
91 changes: 45 additions & 46 deletions src/rpc/rpc_reward_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,64 +76,61 @@ def get_rewards_for_cycle_map(self, cycle, rewards_type):
.format(cycle, self.preserved_cycles, self.blocks_per_cycle,
level_of_first_block_in_preserved_cycles, level_of_last_block_in_unfreeze_cycle))

# Decide on if paying actual rewards earned, or paying estimated/ideal rewards
if rewards_type.isEstimated():

# Determine how many priority 0 baking rights delegate had
nb_blocks = len([r for r in self.__get_baking_rights(cycle, level_of_first_block_in_preserved_cycles) if r["priority"] == 0])
nb_endorsements = sum([len(r["slots"]) for r in self.__get_endorsement_rights(cycle, level_of_first_block_in_preserved_cycles)])

logger.debug("Number of 0 priority blocks: {}, Number of endorsements: {}".format(nb_blocks, nb_endorsements))
logger.debug("Block reward: {}, Endorsement Reward: {}".format(self.block_reward, self.endorsement_reward))

# "ideally", the baker baked every priority 0 block they had rights for,
# and every block they baked contained 32 endorsements
total_block_reward = nb_blocks * self.block_reward
total_endorsement_reward = nb_endorsements * self.endorsement_reward

logger.info("Estimated rewards for cycle {:d}, {:,} block rewards ({:d} blocks), {:,} endorsing rewards ({:d} slots)".format(
cycle, total_block_reward, nb_blocks, total_endorsement_reward, nb_endorsements))

reward_data["total_rewards"] = total_block_reward + total_endorsement_reward

# Calculate actual rewards
else:

# Determine how many priority 0 baking rights delegate had
baking_rights = self.__get_baking_rights(cycle, level_of_first_block_in_preserved_cycles)
endorsement_rights = self.__get_endorsement_rights(cycle, level_of_first_block_in_preserved_cycles)
nb_blocks = len([r for r in baking_rights if r["priority"] == 0])
nb_endorsements = sum([len(r["slots"]) for r in endorsement_rights])

total_reward_amount = None
if not rewards_type.isEstimated():
# Calculate actual rewards
if current_level - level_of_last_block_in_unfreeze_cycle >= 0:
unfrozen_fees, unfrozen_rewards = self.__get_unfrozen_rewards(level_of_last_block_in_unfreeze_cycle, cycle)
total_actual_rewards = unfrozen_fees + unfrozen_rewards
total_reward_amount = unfrozen_fees + unfrozen_rewards
else:
frozen_fees, frozen_rewards = self.__get_frozen_rewards(cycle, current_level)
total_actual_rewards = frozen_fees + frozen_rewards
if rewards_type.isActual():
reward_data["total_rewards"] = total_actual_rewards
elif rewards_type.isIdeal():
missed_baking_income = 0
for r in self.__get_baking_rights(cycle, level_of_first_block_in_preserved_cycles):
if r["priority"] == 0:
if self.__get_block_author(r["level"]) != self.baking_address:
logger.warning("Found missed baking slot {}, adding {} mutez reward anyway.".format(r, self.block_reward))
missed_baking_income += self.block_reward
missed_endorsing_income = 0
for r in self.__get_endorsement_rights(cycle, level_of_first_block_in_preserved_cycles):
authored_endorsement_slots = self.__get_authored_endorsement_slots_by_level(r["level"] + 1)
if authored_endorsement_slots != r["slots"]:
mutez_to_add = self.endorsement_reward * len(r["slots"])
logger.warning("Found {} missed endorsement(s) at level {}, adding {} mutez reward anyway.".format(len(r["slots"]), r["level"], mutez_to_add))
missed_endorsing_income += mutez_to_add
logger.warning("total rewards %s" % (total_actual_rewards + missed_baking_income + missed_endorsing_income))
reward_data["total_rewards"] = total_actual_rewards + missed_baking_income + missed_endorsing_income
total_reward_amount = frozen_fees + frozen_rewards

# Without an indexer, it is not possible to itemize rewards
# so setting these values below to "None"
rewards_and_fees = None
equivocation_losses = None
denunciation_rewards = None

offline_losses = None
if rewards_type.isIdeal():
# Calculate offline losses
missed_baking_income = 0
for count, r in enumerate(baking_rights):
if (count % 10 == 0):
logger.info("Verifying bake ({}/{}).".format(count, len(baking_rights)))
if r["priority"] == 0:
if self.__get_block_author(r["level"]) != self.baking_address:
logger.warning("Found missed baking slot {}, adding {} mutez reward anyway.".format(r, self.block_reward))
missed_baking_income += self.block_reward
missed_endorsing_income = 0
for count, r in enumerate(endorsement_rights):
if (count % 10 == 0):
logger.info("Verifying endorsement ({}/{}).".format(count, len(endorsement_rights)))
authored_endorsement_slots = self.__get_authored_endorsement_slots_by_level(r["level"] + 1)
if authored_endorsement_slots != r["slots"]:
mutez_to_add = self.endorsement_reward * len(r["slots"])
logger.warning("Found {} missed endorsement(s) at level {}, adding {} mutez reward anyway.".format(len(r["slots"]), r["level"], mutez_to_add))
missed_endorsing_income += mutez_to_add
offline_losses = missed_baking_income + missed_endorsing_income

# TODO: support Dexter for RPC
# _, snapshot_level = self.__get_roll_snapshot_block_level(cycle, current_level)
# for delegator in self.dexter_contracts_set:
# dxtz.process_original_delegators_map(reward_data["delegators"], delegator, snapshot_level)

reward_model = RewardProviderModel(reward_data["delegate_staking_balance"], reward_data["total_rewards"],
reward_model = RewardProviderModel(reward_data["delegate_staking_balance"],
nb_blocks, nb_endorsements, total_reward_amount, rewards_and_fees, equivocation_losses, denunciation_rewards, offline_losses,
reward_data["delegators"])

logger.debug("delegate_staking_balance = {:d}, total_rewards = {:f}"
.format(reward_data["delegate_staking_balance"], reward_data["total_rewards"]))
logger.debug("delegate_staking_balance = {:d}"
.format(reward_data["delegate_staking_balance"]))
logger.debug("delegators = {}".format(reward_data["delegators"]))

return reward_model
Expand Down Expand Up @@ -420,6 +417,8 @@ def __get_delegators_and_delgators_balances(self, cycle, current_level):

logger.debug("Delegator info ({}/{}) fetched: address {}, staked balance {}, current balance {} "
.format(idx + 1, d_a_len, delegator, d_info["staking_balance"], d_info["current_balance"]))
if (idx % 10 == 0):
logger.info("Delegator info ({}/{}) fetched.".format(idx + 1, d_a_len))

# "append" to master dict
delegators[delegator] = d_info
Expand Down
Loading

0 comments on commit e1c4899

Please sign in to comment.