Skip to content
This repository has been archived by the owner on Jun 11, 2024. It is now read-only.

Latest commit

Β 

History

History
586 lines (413 loc) Β· 32.6 KB

lip-0070.md

File metadata and controls

586 lines (413 loc) Β· 32.6 KB
LIP: 0070
Title: Introduce reward sharing mechanism
Author: Grigorios Koumoutsos <grigorios.koumoutsos@lightcurve.io>
Discussions-To: https://research.lisk.com/t/introduce-reward-sharing-mechanism/386
Status: Active
Type: Standards Track
Created: 2022-10-26
Updated: 2024-01-04
Required: 0022, 0023, 0057

Abstract

We define an on-chain reward sharing mechanism for the Lisk ecosystem. This mechanism is defined as an additional part of the PoS module. In this LIP, we specify the state transitions logic defined within the PoS module due to reward sharing, i.e. the commands, the protocol logic injected during the block lifecycle, and the functions that can be called from other modules or off-chain services.

Copyright

This LIP is licensed under the Creative Commons Zero 1.0 Universal.

Motivation

In Lisk protocol, validators receive rewards for generating blocks. The set of validators that generate blocks is chosen based on the total stake of each validator (self-stake and sum of staked amounts from stakers), according to the selection mechanism specified in LIP 0022 and LIP 0023. In contrast to validators, stakers do not receive any rewards by the protocol for locking their tokens to support their preferred validators; in other words, there are no economic incentives for users to participate in staking.

In practice, off-chain tools have been developed (see, e.g., here) where validators promise to share a certain part of their rewards to their stakers. However this communication is outside the protocol and perhaps unknown to many users. Moreover, there is no guarantee that the validators will indeed share the promised rewards. Furthermore, validators who want to share the rewards with their stakers need to manually submit the transfer transactions to all stakers, which increases their workload and also costs transaction fees.

This LIP introduces an on-chain reward sharing mechanism in the Lisk protocol. Rewards are shared between validators and stakers as follows: a certain part of the rewards is awarded to the validator as a commission (the precise percentage of the commission is chosen by each validator); then, the remaining rewards are shared by the validator and the stakers proportionally to their staked amounts.

Providing an on-chain reward sharing mechanism has several advantages. For stakers, it incentives them to participate in staking, since it provides a guarantee on the receipt of the rewards. Moreover, it provides an easy-to-access interface to decide on the validators to stake with, based on their commission. For these reasons, it is expected that this will increase the participation in staking, leading to an increase in the total amount locked for staking and therefore the security of the network. For validators, it will simplify their tasks since they will not need to perform external communication and submit themselves the transactions for sharing the rewards.

Specification

The reward sharing mechanism is part of the PoS module. This LIP extends the PoS module to support reward sharing.

The values of all constants related to commissions are between 0 and 10000, corresponding to percentages with two decimal places, which can be obtained by dividing the value by 100. For example, a commission value of 1000, corresponds to a 10% commission.

High-level description. 2 new commands are introduced: One for claiming rewards and one for changing the commission value. Initially, after registration the commission of a validator is initialized with 100 %. Afterwards, it can be decreased by submitting a commission change transaction. For Lisk Mainnet, as part of the migration process, we also initialize the commission of all already registered validators with 100 %, see LIP 0063 for details. To support reward sharing, the stake command execution specified in LIP 0057 is modified to send the rewards related to validators that are unstaked or re-staked. The staker and user substores of the PoS module are modified appropriately to contain all necessary new parameters.

Constants

Here we define the constants related to reward sharing. All other PoS module constants are defined in LIP 0057.

Name Type Value Description
COMMAND_NAME_CLAIM_REWARDS string "claimRewards" The command name for the claim rewards transaction
COMMAND_NAME_COMMISSION_CHANGE string "changeCommission" The command name of the commission change transaction.
MAX_NUM_BYTES_Q96 uint32 24 The maximal number of bytes of a serialized fractional number in Q96 format.
Configurable Constants Mainchain Value
COMMISSION_INCREASE_PERIOD uint32 241,920 = 28 * 24 * 3600 // BLOCK_TIME Determines how frequently (after how many blocks) a validator can increase their commission.
MAX_COMMISSION_INCREASE uint32 500 Determines the maximum allowed increase on the commission per transaction.

Event Names

Name Type Value Description
EVENT_NAME_COMMISSION_CHANGE string "commissionChange" Used for events emitted whenever validators change their commission.
EVENT_NAME_REWARDS_ASSIGNED string "rewardsAssigned" Used for events emitted whenever shared rewards are assigned to stakers.

Types

Here we define the newly introduced types. All other types used in this LIP are defined in LIP 0057.

Name Type Validation Description
Q96 integer Must be non-negative Internal representation of a Q96 number.
Stake object Contains 3 elements (address, amount, sharingCoefficients) of types Address, uint64 and array respectively (same as the items in the stakes array of the stakerStoreSchema). Object containing information regarding a stake.

Q96 Type Conversion

For the sharing coefficient, we use the Q96 unsigned numbers with 96 bits for integer and 96 bits for fractional parts. The arithmetic is defined formally in the Appendix. The Q96 numbers are stored as byte arrays of maximal length MAX_NUM_BYTES_Q96, however whenever they are used in the PoS module they are treated as objects of type Q96 defined as in the table above. We define two functions to serialize and deserialize Q96 numbers to type bytes, bytesToQ96 and q96ToBytes. The functions check that the length of byte representation does not exceed MAX_NUM_BYTES_Q96.

def bytesToQ96(numberBytes: bytes) -> Q96:
    if length(numberBytes) > MAX_NUM_BYTES_Q96:
        raise Exception()
    if numberBytes is empty:
        return Q96(0)
    return big-endian decoding of numberBytes

def q96ToBytes(numberQ96: Q96) -> bytes:
    result = big-endian encoding of numberQ96 as integer
    if length(result) > MAX_NUM_BYTES_Q96:
        raise Exception("Overflow when serializing a Q96 number")
    return result

Note that a Q96 representation of number 0 is serialized to empty bytes, and empty bytes are deserialized to a Q96 representation of 0.

Functions from Other Modules

Calling a function fct from another module (named module) is represented by module.fct(required inputs).

PoS Module Store

The PoS module store is defined in LIP 0057.

Commands

We define the two new commands added to the PoS module in order to support reward sharing. All other PoS module commands are defined in LIP 0057.

Change Commission

Transactions executing this command have:

  • module = MODULE_NAME_POS
  • command = COMMAND_NAME_COMMISSION_CHANGE
Parameters
changeCommissionParams = {
    "type": "object",
    "required": ["newCommission"],
    "properties": {
        "newCommission": {
            "dataType": "uint32",
            "fieldNumber": 1
        }
    }
}
Verification
def verify(trs: Transaction) -> None:
    trsParams = decode(changeCommissionParams, trs.params)
    
    senderAddress = SHA256(trs.senderPublicKey)[:ADDRESS_LENGTH] # Derive address from trs.senderPublicKey.
    b = block including trs
    h = b.header.height

    if there does not exist an entry validatorStore(senderAddress) in the validator substore:
        raise Exception('Transaction sender has not registered as a validator.')

    # Check valid commission value.
    if trsParams.newCommission > 10000:
        raise Exception('Invalid commission value, not within range 0 to 10000.')

    # In case of increase in commission, check validity of new value.
    oldCommission = validatorStore(senderAddress).commission

    if trsParams.newCommission > oldCommission and
       h - validatorStore(senderAddress).lastCommissionIncreaseHeight < COMMISSION_INCREASE_PERIOD:
        raise Exception('Can only increase the commission again COMMISSION_INCREASE_PERIOD blocks after the last commission increase.')

    if (trsParams.newCommission - oldCommission) > MAX_COMMISSION_INCREASE:
        raise Exception('Invalid argument: Commission increase larger than MAX_COMMISSION_INCREASE.')
Execution
def execute(trs: Transaction) -> None:
    trsParams = decode(changeCommissionParams, trs.params)
    senderAddress = SHA256(trs.senderPublicKey)[:ADDRESS_LENGTH] # Derive address from trs.senderPublicKey.

    oldCommission = validatorStore(senderAddress).commission

    validatorStore(senderAddress).commission = trsParams.newCommission
    emitEvent(
        module = MODULE_NAME_POS,
        name = EVENT_NAME_COMMISSION_CHANGE,
        data={
            "validatorAddress": senderAddress,
            "oldCommission": oldCommission,
            "newCommission": trsParams.newCommission
        },
        topics = [validatorAddress]
    )

    if trsParams.newCommission >= oldCommission:
        b = block including trs
        validatorStore(senderAddress).lastCommissionIncreaseHeight = b.header.height

Claim Rewards

Transactions executing this command have:

  • module = MODULE_NAME_POS
  • command = COMMAND_NAME_CLAIM_REWARDS
Parameters
claimRewardsParams = {
    "type": "object",
    "required": [],
    "properties": {}
}
Verification

No additional verification is performed for transactions executing this command.

Execution
def execute(trs: Transaction) -> None:
    senderAddress = SHA256(trs.senderPublicKey)[:ADDRESS_LENGTH] # Derive address from trs.senderPublicKey.

    for i in range(len(stakerStore(senderAddress).stakes)):
        assignStakingRewards(senderAddress, i)

The internal function assignStakingRewards assigns the rewards of a specified stake to the staker.

Events

We define the events of PoS Module related to reward sharing. Those events are added to the PoS module.

commissionChange

This event has name = EVENT_NAME_COMMISSION_CHANGE. This event is emitted whenever a validator changes their commission.

Topics
  • validatorAddress: The address of the validator changing the commission.
Data
commissionChangeEventParams = {
  "type": "object",
  "required": ["validatorAddress", "oldCommission", "newCommission"]
  "properties": {
        "validatorAddress": {
            "dataType": "bytes",
            "length": ADDRESS_LENGTH,
            "fieldNumber": 1
        },
        "oldCommission": {
            "dataType": "uint32",
            "fieldNumber": 2
        },
        "newCommission": {
            "dataType": "uint32",
            "fieldNumber": 3
        }
    }
}

rewardsAssigned

This event has name = EVENT_NAME_REWARDS_ASSIGNED. This event is emitted whenever rewards are assigned to a staker.

Topics
  • stakerAddress : The address of the staker receiving the rewards.
Data
rewardsAssignedEventParams = {
  "type": "object",
  "required": ["stakerAddress", "validatorAddress", "tokenID", "amount"]
  "properties": {
        "stakerAddress": {
            "dataType": "bytes",
            "length": ADDRESS_LENGTH,
            "fieldNumber": 1
        },
        "validatorAddress": {
            "dataType": "bytes",
            "length": ADDRESS_LENGTH,
            "fieldNumber": 2
        },
        "tokenID": {
            "dataType": "bytes",
            "length": TOKEN_ID_LENGTH,
            "fieldNumber": 3
        },
        "amount": {
            "dataType": "uint64",
            "fieldNumber": 4
        }
    }
}

Internal Functions

We define the internal functions added in the PoS module to support reward sharing. All other internal functions of the PoS module are defined in LIP 0057.

calculateStakingRewards

This function calculates the current reward of a particular stake. The input is a Stake object, i.e., an object similar to the ones stored in the stakes array of the staker substore, containing validatorAddress, amount and sharingCoefficients.

def calculateStakingRewards(stake: Stake,tokenID: TokenID):
    i = index of item in validatorStore(stake.validatorAddress).sharingCoefficients with item.tokenID == tokenID

    # Transform variables to Q96.
    validatorSharingCoefficient = bytesToQ96(validatorStore(stake.validatorAddress).sharingCoefficients[i].coefficient)
    amount = Q96(stake.amount)
    sharingCoefficient = bytesToQ96(stake.sharingCoefficients[i].coefficient)

    # Calculate reward.
    reward = mul_96(amount, sub_96(validatorSharingCoefficient, sharingCoefficient))

    return Q_96_ToInt(reward)

Note that here i specifies the index of entries for token with id tokenID in the sharing coefficients array in both for the validator and the staker substore. The fact that both arrays have the same tokenID in the same position is guaranteed from the fact that both arrays are sorted in lexicographic order. For the validator substore array this is done in the updateSharedRewards function and for the staker substore in the assignStakingRewards function.

assignStakingRewards

This function assigns the rewards to the specified staker for a specific stake, the ith stake stored in the stakes array of stakerStore(address).

def assignStakingRewards(stakerAddress: Address, i: uint32) -> None:
    stake = stakerStore(stakerAddress).stakes[i]
    # Self-stakes are excluded from reward sharing.
    if stake.validatorAddress == stakerAddress:
        return

    # Assign rewards for each token separately.
    for elem in validatorStore(stake.validatorAddress).sharingCoefficients:
        if there does not exist an item in stake.sharingCoefficients with item.tokenID == elem.tokenID:
            stake.sharingCoefficients.append({"tokenID": item.tokenID, "coefficient": q96ToBytes(Q96(0))})
            keep the stake.sharingCoefficients array ordered in lexicographic order of tokenID
            # This makes sure that the order is the same as in validator substore.

        tokenID = elem.tokenID
        reward = calculateStakingRewards(stake, tokenID)

        if reward > 0:
            # Unlock and send tokens to staker.
            Token.unlock(stake.validatorAddress,
                         MODULE_NAME_POS,
                         tokenID,
                         reward)
            Token.transfer(stake.validatorAddress,
                          stakerAddress,
                          tokenID,
                          reward)

            emitEvent(
                module = MODULE_NAME_POS,
                name = EVENT_NAME_REWARDS_ASSIGNED,
                data={
                    "stakerAddress": stakerAddress,
                    "validatorAddress": stake.validatorAddress,
                    "tokenID": tokenID,
                    "amount": reward
                },
                topics = [stakerAddress]
            )

    # Update sharing coefficients.
    stake.sharingCoefficients = validatorStore(stake.validatorAddress).sharingCoefficients

Protocol Logic for Other Modules

updateSharedRewards

This function is called after a reward is assigned to a validator. It locks the amount of the reward which will be shared to stakers and updates the validator's sharing coefficient.

def updateSharedRewards(generatorAddress: Address, tokenID: TokenID, reward: uint64) -> None
    if validatorStore(generatorAddress).totalStake == 0: # If sharing coefficient can not be defined, we return.
        return

    # Use Q96 numbers.
    rewardFraction = sub_96(Q96(1), div_96(Q96(validatorStore(generatorAddress).commission), Q96(10000)))
    selfStake = Q96(validatorStore(generatorAddress).selfStake)
    totalStake = Q96(validatorStore(generatorAddress).totalStake)

    if there does not exist an item in validatorStore(generatorAddress).sharingCoefficients with item.tokenID == tokenID:
        validatorStore(generatorAddress).sharingCoefficients.append({"tokenID":tokenID,
                                                                    "sharingCoefficient": q96ToBytes(Q96(0))})
                                                                    # Initialize sharing coefficient for the specified token.
        keep the validatorStore(generatorAddress).sharingCoefficients array ordered
        in lexicographic order of tokenID # Sharing coefficients are sorted in lexicographic order of tokenID.

    i = index of item in validatorStore(generatorAddress).sharingCoefficients with item.tokenID == tokenID
    oldSharingCoefficient = bytesToQ96(validatorStore(generatorAddress).sharingCoefficients[i].coefficient)

    # Calculate the increase in sharing coefficient.
    sharingCoefficientIncrease = muldiv_96(Q96(reward), rewardFraction, totalStake)
    # Lock the amount that needs to be shared.
    sharedRewards = mul_96(sharingCoefficientIncrease, sub_96(totalStake, selfStake))
    sharedRewards = min(Q_96_ToIntRoundUp(sharedRewards), reward)      # shared rewards can not exceed total reward in case of rounding issue
    Token.lock(generatorAddress, MODULE_NAME_POS, tokenID, sharedRewards)

    # Update sharing coefficient.
    newSharingCoefficient = add_96(oldSharingCoefficient, sharingCoefficientIncrease)
    validatorStore(generatorAddress).sharingCoefficients[i].coefficient = q96ToBytes(newSharingCoefficient)

Endpoints for Off-Chain Services

This section specifies the non-trivial or recommended endpoints of the PoS module related to the reward sharing mechanism and does not necessarily include all endpoints.

getLockedRewards

This function returns the amount of rewards in the specified validator's account that is locked in order to be shared to the stakers.

def getLockedRewards(validatorAddress: Address, tokenID: TokenID) -> uint64:
    if tokenID == TOKEN_ID_POS:
        return Token.getLockedAmount(validatorAddress, MODULE_NAME_POS, tokenID) - getLockedStakedAmount(validatorAddress)
    return Token.getLockedAmount(validatorAddress, MODULE_NAME_POS, tokenID)

getClaimableRewards

This function returns the rewards that a user can claim.

def getClaimableRewards(stakerAddress: Address) -> dict[TokenID, uint64]:
    rewards: dict[TokenID, uint64] = {}
    for i in range(len(stakerStore(stakerAddress).stakes)):
        stake = stakerStore(stakerAddress).stakes[i]
        validatorAddress = stake.validatorAddress
        # Self-stakes are excluded.
        if validatorAddress != stakerAddress:
            for elem in validatorStore(validatorAddress).sharingCoefficients:
                if there does not exist an item in stake.sharingCoefficients
                with item.tokenID == elem.tokenID:

                    stake.sharingCoefficients.append({"tokenID": item.tokenID,
                                                         "coefficient": q96ToBytes(Q96(0))})
                    keep the stake.sharingCoefficients array ordered in lexicographic order of tokenID
                    # This makes sure that the order is the same as in validator substore.

                if elem.tokenID in rewards:
                    rewards[elem.tokenID]+= calculateStakingRewards(stake, elem.tokenID)
                else:
                    rewards[elem.tokenID] = calculateStakingRewards(stake, elem.tokenID)

    return rewards

getExpectedSharedRewards

This function calculates the expected reward for staking with a particular validator.

Parameters
  • validatorAddress: The address of the validator considering to stake with.
  • validatorReward: The hypothetical reward for the validator.
  • stake: The amount the user is considering to stake with the specified validator.
def getExpectedSharedRewards(validatorAddress: Address, validatorReward: uint64, stake: uint64) -> uint64:

    # Use Q96 numbers.
    validatorReward = Q96(validatorReward)
    rewardFraction = sub_96(Q96(1), div_96(Q96(validatorStore(validatorAddress).commission), Q96(10000)))
    totalStake = Q96(validatorStore(validatorAddress).totalStake + stake)

    rewardPerUnitStaked = muldiv_96(validatorReward, rewardFraction, totalStake) 
    return mul_96(rewardPerUnitStaked, stakeAmount) 

Rationale

Commission

The commission defines the part of the rewards that are assigned to the validator. We allow validators to set their own commission. The reason is to provide flexibility to validators. For example, it makes it easier for new validators to enter in the ecosystem and attract stakers: by setting the commission to a relatively small value compared to other validators, it becomes more attractive for stakers to stake for this validator. Moreover, it allows validators to choose if they want to share rewards or not: by setting a commission 100% a validator can essentially disable reward sharing for the stakers. Furthermore, it allows validators to adapt their commission based on external factors (e.g., change in maintenance cost, change in value of the token).

Constraints on Commission Increase

Validators can change their commission using a change commission transaction. This might cause reward losses and bad user experience for stakers in case of abrupt commission increases. For example, consider the scenario where a user stakes with a validator with small commission (e.g., 5%), expecting a certain amount of rewards; shortly afterwards, the validator increases highly the commission (e.g.,from 5% to 50%). Then, even if the staker realizes this change quickly, then switching to some other validator(s) requires unstaking and choose again which validators to stake for; this situation could lead to bad user experience if it occurs repeatedly. Even worse, in case the staker does not realize the change in commission fast enough, the rewards obtained for supporting this validator are significantly decreased.

To avoid such cases and provide guarantees to stakers on their expected rewards, we introduce two constraints on the increase of the validator commission:

  • The increase of the commission should be at most MAX_COMMISSION_INCREASE. For the mainchain this is set to 5%.
  • After increasing the commission, a validator can not increase it again for the next COMMISSION_INCREASE_PERIOD blocks. For the mainchain, this is set to 241,920 blocks, i.e., 28 days.

Those two constraints provide the guarantee to the stakers that if they check the changes in validators' commissions reasonably often (e.g., once a month for the mainchain), then the potential loses in expected rewards of the stakers are quite limited.

Distribution of Rewards

Whenever rewards are attributed to a validator, the part of rewards corresponding to the commission are sent to this validator; the rest part of the rewards belongs to validators and stakers according to their staked amount. Therefore, if a validator receives a reward $r$ and has commission percentage $c$, the overall reward assigned to the validator is:

$$ r \cdot \frac{c}{100} + r \cdot (1- \frac{c}{100}) \cdot \frac{selfStake}{totalStake} $$

and the reward for a staker who has staked and amount $myStake$ for this validator is:

$$ r \cdot (1- \frac{c}{100}) \cdot \frac{myStake}{totalStake}. $$

At first glance it might seem unclear why two types of rewards are attributed to validators, one for commission and one for self-stakes. It might look simpler if the validators just get the commission and the rest is attributed to stakers. The reason for choosing the current formulation is twofold:

  • First, it makes it easier for stakers to calculate and compare their expected rewards for staking validators. In particular, to calculate the rewards for staking an amount myStake with a validator, only two values are needed, the commission and total stake of this validator. Otherwise, the self-stakes of the validator would be also needed to calculate the rewards.
  • Second, the current formulation allows validators to increase their rewards by increasing their self-stakes by any amount they wish; this can not be done by increasing the commission due to the constaints on commission increase.

Efficient Calculation of Rewards

Each validator might have too many stakers. Therefore it would be extremely inefficient to calculate the rewards for all stakers at the time of generating a block. To avoid un-necessary calculations, we define a special transaction for claiming pending rewards. Stakers can claim their rewards by submitting such a transaction. Rewards are calculated only at times when it is needed in order to credit the rewards to the staker. Assume a staker stakes an amount $myStake$ with a validator at height $h_{stake}$ and submits a claim rewards transaction at height $h_{claim}$. The rewards attributed for this stake equal:

$$ \sum_{i = h_{stake}}^{h_{claim}-1} r_i \cdot (1 - \frac{c_i}{100}) \cdot \frac{myStake}{totalStake(i)} = myStake \cdot \sum_{i = h_{stake}}^{h_{claim}-1} \frac{r_i \cdot (1 - \frac{c_i}{100})}{totalStake(i)} $$

where $r_i$ is the reward, $c_i$ the commission and $totalStake(i)$ the total stake for the validator at height $i$. In order to be able to calculate this quantity efficiently, we define:

$$ F(h) = \sum_{i = 0}^{h - 1} \frac{r_i \cdot (1 - \frac{c_i}{100})}{totalStake(i)}, $$

which we call sharing coefficient of a validator at height $h$. The reward is then equal to:

$$ myStake \cdot ( F(h_{claim}) - F(h_{stake})) $$

This way, to calculate the reward we just need to have access to the values $F(h_{claim})$ and $F(h_{stake})$. To achieve this, we store the current value of the sharing coefficient in the validator's account in the validator substore and update it any time the validator receives rewards. The value of the sharing coefficient at the time of staking, $F(h_{stake})$ is stored in the staker's account in the staker substore. When rewards are claimed, $F(h_{claim})$ is recovered from the validators substore and $F(h_{stake})$ from the stakers substore. After a claim rewards transaction is submitted and the rewards are assigned to the staker, then the sharing coefficient of the stake $F(h_{stake})$ is updated to the current value of the validator sharing coefficient. Note the robustness of the sharing coefficient quantity: it is able to incorporate dynamic change of rewards per block, commission of the validator and total stake for the validator.

Assigning unclaimed rewards. The reward calculation above assumes that the staked amount myStake of staker for the validator is fixed. This might not be the case in general, since the staked amount could be decreased/increased by the staker. In order to be able to support efficient calculation of rewards using the sharing coefficient, before the modifying the staked amount, all unclaimed rewards of this stake are credited to the staker. Then, the updated staked amount (sum of previous amount plus newly staked/unstaked) is treated as a new staked amount from the reward sharing point of view and the sharing coefficient $F(h_{stake})$ is updated with the current value for the specified validator.

Number representation. Note that the sharing coefficient is a fractional number, since we need to divide by the total stake of the validator. The fractional number representation has to be completely deterministic and transparent for implementation. This guarantees that all the correct implementations of the reward sharing mechanism update the state in exactly the same way for the same inputs. Thus we rely on fixed point arithmetic, which is specified in the Appendix.

In practice, we work with Q96 unsigned numbers with 96 bits for integer and 96 bits for fractional parts (also see the Wikipedia page about Q number format). Note that the intermediate results of arithmetic computations with Q96 numbers may need more memory space, e.g. the implementation of div_n(a, b) = (a << n) // b needs to store the result of a << n.

Supporting Rewards in Various Tokens

In the Lisk ecosystem, there are different reasons for which validators receive rewards and perhaps those rewards might even be in different tokens. For example, in a sidechain both block rewards and transaction fees awarded to validators might be shared with stakers; in general, the tokens used for those rewards might be different (e.g., block rewards in its native token and transaction fees in LSK). More generally, depending on the application many other kinds of rewards might be applicable. For example, a DeFi sidechain where block rewards are assigned in a native token, can incentivize validator participation to increase the security of the chain by providing occasionally additional rewards using more valuable tokens (e.g., LSK token).

Our reward sharing mechanism allows an arbitrary number of different rewards and different tokens. To achieve this, instead of storing one sharing coefficient for each validator, we store an array containing multiple sharing coefficients, one for each token used for rewards (i.e., the value $F(h)$ for each token). Similarly, in the staker substore, we store an array for sharing coefficients where each entry contains $F(h_{stake})$ for the corresponding token used for rewards.

Backwards Compatibility

This LIP adds extra functionality (commands, events, protocol logic) to the PoS module and therefore implies a hard fork.

Reference Implementation

Update DPoS module with dynamic block reward and automatic reward sharing

Appendix

Fixed Point Arithmetic

Definition

We represent a positive real number r as a Qn by the positive integer Qn(r) = floor(r*2^n) (inspired by the Q number format for signed numbers). In this representation, we have n bits of fractional precision. We do not limit the size of r as modern libraries can handle integers of arbitrary size; note that all the Qn conversions assume no loss of significant digits.

Operations on Integers

For an integer a in the Qn format, we define:

  • Rounding down: roundDown_n(a) = a >> n

  • Rounding up:

def roundUp_n(a):
    if a % (1 << n) == 0:
        return a >> n
    else:
        return (a >> n) + 1

Qn Arithmetic Operations

In the following definition, * is the integer multiplication, // is the integer division (rounded down). Division by zero is not permitted and should raise an error in any implementation.

For two numbers a,b and c in Qn format, we define the following arithmetic operations which all return a Qn:

  • Addition: add_n(a, b) = a + b

  • Subtraction: sub_n(a,b) = a - b, only defined if a >= b

  • Multiplication: mul_n(a, b) = (a * b) >> n

  • Division: div_n(a, b) = (a << n) // b

  • Multiplication and then division: muldiv_n(a, b, c) = roundDown_n(((a * b) << n) // c)

  • Multiplication and then division, rounding up: muldiv_n_RoundUp(a, b, c) = roundUp_n(((a * b) << n) // c)

  • Convert to integer rounding down: Q_n_ToInt(a) = roundDown_n(a)

  • Convert to integer rounding up: Q_n_ToIntRoundUp(a) = roundUp_n(a)

  • Inversion in the decimal precision space: inv_n(a) = div_n(1 << n, a)