LIP: 0057
Title: Define state and state transitions of PoS module
Author: Maxime Gagnebin <[email protected]>
Nazar Hussain <[email protected]>
Mehmet Egemen Albayrak <[email protected]>
Grigorios Koumoutsos <[email protected]>
Discussions-To: https://research.lisk.com/t/define-state-and-state-transitions-of-pos-module/320
Status: Active
Type: Standards Track
Created: 2021-09-03
Updated: 2024-01-04
Required: 0022, 0023, 0024, 0040, 0044, 0046, 0058, 0059
The PoS (proof-of-stake) module is responsible for handling validator registration, staking, and computing the validator weight. In this LIP, we specify the properties of the PoS module, along with their serialization and initial values. Furthermore, we specify the state transitions logic defined within this module, 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. We also specify the events emitted by the PoS module.
This LIP is licensed under the Creative Commons Zero 1.0 Universal.
The PoS module handles all aspects of the generator selection, this includes the registration of accounts as validators, the staking process, and potential reports of misbehavior.
In this LIP we specify the properties, serialization, initialization, and exposed functions of the PoS module, as well as the protocol logic processed during a block life cycle and the module commands.
This LIP does not introduce significant protocol changes to the generator selection mechanism proposed in LIP 0022 and LIP 0023. It only defines how the commands and processes defined in those LIPs are integrated in the state model used in Lisk. Please see LIP 0022 and LIP 0023 for a thorough rationale regarding the choice of staking system and the inclusion of standby validators. LIP 0022 defines a selection mechanism for 2 standby validators. In this LIP, we slightly extend the specifications to support 0 or 1 standby validator, however, we do not specify how to extend the protocol to more than 2 validators. Introducing more standby validators might require a different source of randomness and it is not the aim of this LIP to describe this topic.
This part of the state store is used to maintain the stakes and recent unstakes of users (the ones for which the unstaked amount has not been unlocked). The entries are keyed by address and contain an array of the current stakes as well as an array of objects representing the tokens waiting to be unlocked.
This part of the state store is used to maintain all information regarding the registered validators. It is keyed by address and contains values for the validator name, last generated height, total stake (i.e., the sum of all amounts staked with that validator), self-stake (i.e., the sum of all self-staked amounts of that validator), misbehavior report heights, and a flag asserting if the validator is banned or not.
This part of the state store is used to maintain a list of all validator names already registered. It allows the protocol to efficiently process the validator registration transaction. The entries are keyed by validator name and the value contains the address of the corresponding validator.
This part of the state store is used to maintain the list of all non-banned validators that have more validator weight than a specified threshold. The validators from this list with the most weight are active validators, and the others are standby validators.
This part of the state store is used to maintain the needed snapshots of the eligible validators. The entries are keyed by round number and contain the addresses and weights of eligible validators at the end of the corresponding round. Based on this information, the sets of active and standby validators are selected two rounds later. Entries for older rounds which are no longer necessary are removed.
This part of the state store is used to maintain information from the genesis block. This information is used to compute if a block is at the end of a round, and to generate the generator list during the bootstrap period.
This part of the state store is used to maintain the timestamp of the last block added to the chain. This is used when calling the function computing missed blocks from the Validators module.
In this LIP, we set the BFT weights of active validators to be proportional to their validator weight. Additionally, to avoid a large concentration of BFT weight in one or only very few validators, we introduce a capping threshold for the BFT weight, i.e., we do not allow one validator to get more than a certain percentage of the total BFT weight. This threshold is specified by the constant MAX_BFT_WEIGHT_CAP
; for the Lisk mainchain, it is set to 10%.
The capping of BFT weights ensures a minimum of decentralization in the validator set, even in the case of a very unbalanced validator weight distribution. In practice, we expect the capping to only effect very few validators, if any, in particular as long as the reward system incentivizes decentralization and does not lead to concentration of huge staked amounts to few validators (this is the case for both reward modules proposed, see LIP 0042 and LIP 0071).
Note that further we choose floor(2/3 * aggregateBFTWeight) + 1
as precommit and certificate threshold for PoS chains, see LIP 0058 for an explanation of these parameters. Here aggregateBFTWeight
is the sum of BFT weights of the active validators. This choice of BFT parameters means that the safety and liveness of the BFT consensus protocol is ensured if <1/3 * aggregateBFTWeight
is malicious (for the case of changing validators the guarantees are slightly weaker). Developers might choose to modify the threshold values for their sidechains by updating the computeThresholds
function. We crucially note, however, that modifying the chosen parameters has consequences for the security guarantees of the chain according to the security-liveness tradeoff proven in Theorem 4.4 of the Lisk-BFT paper. Therefore, we highly recommend not updating those parameters, unless the tradeoff is well-understood and there is some very clear reason to do so for a specific chain.
By setting the BFT weights proportional to validator weight instead of uniform BFT weights for all active validators as done before, we can improve the security guarantees of the BFT consensus protocol. If we neglect the effect of capping, which will likely effect only very few validators by a small percentage, then more than 1/3 of the active validator weight are needed to have more than 1/3 of the BFT weight for an attack of the network. Assuming validators generally have at least 10 % self-staking (i.e., no validator has total stake greater than 10 times their self-stake), this translates to at least 1/3 of the total staked amount for active validators would have to be controlled by the attacker.
In the current system of uniform BFT weights this is not the case. The set of 34 lowest-weight validators may have significantly less than 1/3 of the total validator weight although their total BFT weight is more than 1/3. This means that quite a lot less than 1/3 of the total staked amount for active validators is sufficient to control more than 1/3 of BFT weight, which would be sufficient to perform an attack on the network. Choosing BFT weights proportional to validator weight avoids this issue.
The bootstrap period, defined in LIP 0034, is the initial period when the PoS validator selection mechanism is not active and blocks are generated by a fixed set of validators (specified in genesis block), called initial validators. In LIP 0034, we assumed that after the bootstrap period ends, all validators are selected according to the PoS selection mechanism. One drawback of adopting this approach here is that after the end of the bootstrap period, the change of validators might be too abrupt, possibly leading to breaking the BFT security assumptions and finalizing contradicting blocks or inability of the chain to produce certificates. To avoid those issues, a possible solution would be to not allow blocks to be finalized during the bootstrap period, as in LIP 34. This approach has some drawbacks however, for example, it does not allow sidechains to be interoperable during their bootstrap period, which is not desirable.
To circumvent the aforementioned issues and allow sidechains to produce certificates at any time, we introduce a hybrid period which starts just after the end of the bootstrap period. During the hybrid period, the set of validators is gradually shifting from initial validators to PoS-selected validators. In particular, in each round we decrease the number of initial validators by 1 and increase the number of validators selected from the PoS selection; therefore the hybrid phase lasts for NUMBER_ACTIVE_VALIDATORS - 1
rounds. This way, the change of validators between subsequent rounds will not be too abrupt and the sidechains would be able to finalize blocks and produce certificates during both bootstrap and hybrid periods without additional security risks.
For the rest of this proposal we define the following constants:
Name | Type | Value | Description |
---|---|---|---|
Global constants | |||
ADDRESS_LENGTH |
uint32 | 20 | Length in bytes of type Address . |
BLS_PUBLIC_KEY_LENGTH |
uint32 | 48 | Length in bytes of type PublicKeyBLS . |
BLS_POP_LENGTH |
uint32 | 96 | Length in bytes of type ProofOfPossession . |
ED25519_PUBLIC_KEY_LENGTH |
uint32 | 32 | Length in bytes of type PublicKeyEd25519 . |
SEED_LENGTH |
uint32 | 16 | Length in bytes of a valid seed revealed. |
OWN_CHAIN_ID |
bytes | The chain ID of the chain. | |
TOKEN_ID_LENGTH |
uint32 | 8 | Length in bytes of type TokenID |
INVALID_BLS_KEY |
bytes | 48 bytes all set to 0x00 | The byte value associated with validators that did not register a BLS key. |
PoS store constants | |||
SUBSTORE_PREFIX_STAKER |
bytes | 0x0000 | The substore prefix of the staker substore. |
SUBSTORE_PREFIX_VALIDATOR |
bytes | 0x8000 | The substore prefix of the validator substore. |
SUBSTORE_PREFIX_NAME |
bytes | 0x4000 | The substore prefix of the name substore. |
SUBSTORE_PREFIX_SNAPSHOT |
bytes | 0xc000 | The substore prefix of the snapshot substore. |
SUBSTORE_PREFIX_GENESIS_DATA |
bytes | 0x2000 | The substore prefix of the genesis data substore. |
SUBSTORE_PREFIX_PREVIOUS_TIMESTAMP |
bytes | 0xa000 | The substore prefix of the previous timestamp substore. |
SUBSTORE_PREFIX_ELIGIBLE_VALIDATORS |
bytes | 0x6000 | The substore prefix of the eligible validators substore. |
PoS constants | |||
MODULE_NAME_POS |
string | "pos" | The module name of the PoS module. |
COMMAND_NAME_REGISTER_VALIDATOR |
string | "registerValidator" | The command name of the validator registration transaction. |
COMMAND_NAME_STAKE |
string | "stake" | The command name of the stake transaction. |
COMMAND_NAME_UNLOCK |
string | "unlock" | The command name of the unlock transaction. |
COMMAND_NAME_REPORT_MISBEHAVIOR |
string | "reportMisbehavior" | The command name of the report misbehavior transaction. |
MIN_INIT_ROUNDS |
uint32 | 3 |
The minimum number of rounds for the bootstrap period. |
MAX_NUM_BYTES_Q96 |
uint32 | 24 | The maximal number of bytes of a serialized fractional number in Q96 format (see LIP 0070). |
Configurable Constants | Mainchain Value | ||
TOKEN_ID_POS |
bytes | OWN_CHAIN_ID[0:1] + '00000000000000' |
The token ID of the token used for staking. |
FACTOR_SELF_STAKING |
uint32 | 10 |
The factor multiplying the self-staked amount of a validator for the validator weight computation. |
BASE_STAKING_AMOUNT |
uint32 | 10 * (10)^8 |
The minimum staking amount. All staked amounts should be multiples of this value. |
MAX_LENGTH_NAME |
uint32 | 20 |
The maximum allowed name length for validators. |
MAX_NUMBER_STAKING_SLOTS |
uint32 | 10 |
The maximum size of the stakes array of a staker substore entry. |
MAX_NUMBER_PENDING_UNLOCKS |
uint32 | 20 |
The maximum size of the pendingUnlocks array of a staker substore entry. |
FAIL_SAFE_MISSED_BLOCKS |
uint32 | 50 |
The number of consecutive missed blocks used in the fail safe banning mechanism. |
FAIL_SAFE_INACTIVE_WINDOW |
uint32 | 120,960 = 14 * 24 * 3600 // BLOCK_TIME | The length of the inactivity window used in the fail safe banning mechanism, measured in number of blocks. |
LOCKING_PERIOD_STAKING |
uint32 | 25,920 = 3 * 24 * 3600 // BLOCK_TIME | The locking period for regular staked tokens, measured in number of blocks. |
LOCKING_PERIOD_SELF_STAKING |
uint32 | 241,920 = 28 * 24 * 3600 // BLOCK_TIME | The locking period for self-staked tokens. |
PUNISHMENT_WINDOW_STAKING |
uint32 | 241,920 = 28 * 24 * 3600 // BLOCK_TIME | The punishment period for regular stakes, i.e., how many blocks after misbehavior report stakers can unlock tokens unstaked from a punished validator. |
PUNISHMENT_WINDOW_SELF_STAKING |
uint32 | 725,760 = 3 * PUNISHMENT_WINDOW_STAKING | The punishment period for self-stakes, i.e., how many blocks after misbehavior report punished validators can unlock their self-unstaked tokens. |
REPORT_MISBEHAVIOR_REWARD |
uint32 | 10^8 |
The reward (in TOKEN_ID_POS ) for a sender of a report misbehavior transaction. |
REPORT_MISBEHAVIOR_LIMIT_BANNED |
uint32 | 5 |
The number of report misbehavior transactions against a validator for getting banned. |
MIN_WEIGHT |
uint64 | 1000*(10^8) |
The minimum validator weight required to be selected as a block generator. It should have a non-zero value. |
NUMBER_ACTIVE_VALIDATORS |
uint32 | 101 |
The number of active validators.To be compatible with the interoperability module, the value should be at most MAX_NUM_VALIDATORS ( constant defined in LIP 0045), due to the fact that BLS keys of all active validators should fit into a Cross-Chain Update Transaction (CCU). |
NUMBER_STANDBY_VALIDATORS |
uint32 | 2 |
The number of standby validators. This LIP is specified for the number of standby validators being 0, 1 or 2. |
VALIDATOR_REGISTRATION_FEE |
uint64 | 10*(10^8) |
The extra command fee of the validator registration. |
WEIGHT_SCALE_FACTOR |
uint32 | 1000 * (10)^8 |
It determines the factor by which BFT weights are divided. |
MAX_BFT_WEIGHT_CAP |
uint32 | 1000 |
It determines the maximum BFT weight percentage for a single validator. The percentage is obtained by dividing this value by 100, i.e., a value of 1000 corresponds to 10%. |
INVALID_BLS_KEYS_IN_GENESIS_BLOCK |
uint32 | True |
This value determines if the function registerValidatorKeys or registerValidatorWithoutBLSKey from the validators module should be used when registering validators listed in a genesis block. This value must be set to True for the v4 re-genesis block of the Lisk mainchain. For all other genesis blocks, it should be set to False . |
We also define ROUND_LENGTH
to be the length of a round, i.e., ROUND_LENGTH = NUMBER_ACTIVE_VALIDATORS + NUMBER_STANDBY_VALIDATORS
.
Name | Type | Value | Description |
---|---|---|---|
Event names | |||
EVENT_NAME_VALIDATOR_REGISTERED |
string | "validatorRegistered" | Used for events during validator registration. |
EVENT_NAME_AMOUNT_STAKED |
string | "amountStaked" | Used for events related to staking with a validator. |
EVENT_NAME_VALIDATOR_PUNISHED |
string | "validatorPunished" | Used for events related to punishing a validator. |
EVENT_NAME_VALIDATOR_BANNED |
string | "validatorBanned" | Used for events related to banning a validator. |
Result codes | |||
STAKE_SUCCESSFUL |
uint32 | 0 | Used when a stake succeeds. |
STAKE_FAILED_NON_REGISTERED_VALIDATOR |
uint32 | 1 | Used when a stake transaction fails because the staked account has not registered a validator. |
STAKE_FAILED_INVALID_UNSTAKE_PARAMETERS |
uint32 | 2 | Used when a stake transaction fails because the unstaked amount exceeds the total stakes sent from staker to validator. |
STAKE_FAILED_TOO_MANY_PENDING_UNLOCKS |
uint32 | 3 | Used when a stake transaction fails because it the total number of pending unlocks of staker exceeds MAX_NUMBER_PENDING_UNLOCKS . |
STAKE_FAILED_TOO_MANY_VALIDATORS |
uint32 | 4 | Used when a stake transaction fails because the total number of validators that the user has staked with exceeds MAX_NUMBER_STAKING_SLOTS . |
Name | Type | Validation | Description |
---|---|---|---|
BlockHeader |
object | Must follow the blockHeaderSchema schema defined in LIP 0055. |
An object representing a block header. |
Address |
bytes | Must be of length ADDRESS_LENGTH . |
Address of an account. |
TokenID |
bytes | Must be of length TOKEN_ID_LENGTH . |
Used for token identifiers. |
UnlockObject |
object | Contains 3 elements (address , amount , unstakeHeight ) of types Address , uint64 and uint32 respectively (same as the items in the pendingUnlocks array of the stakerStoreSchema). |
An object containing information regarding unstaking a validator. |
PublicKeyBLS |
bytes | Must be of length BLS_PUBLIC_KEY_LENGTH . |
Used for BLS keys. |
ProofOfPossession |
bytes | Must be of length BLS_POP_LENGTH . |
The proof of possession associated with a BLS key. |
PublicKeyEd25519 |
bytes | Must be of length ED25519_PUBLIC_KEY_LENGTH . |
Used for Ed25519 public keys. |
StakerStoreObject |
object | Must follow the stakerStoreSchema schema. |
Deserialized version of staker substore values. |
ValidatorStoreObject |
object | Must follow the validatorStoreSchema schema. |
Deserialized version of validator substore values. |
EligibleValidatorObject |
object | Contains 2 elements (address , weight ) of types Address and uint64 respectively (same as the items in the validatorWeightSnapshot array of the snapshotStoreSchema). |
An object containing information regarding an eligible validator. |
ValidatorObject |
object | Contains 2 elements (address , bftWeight ) of types Address and uint64 respectively. |
An object containing information regarding a validator. |
Calling a function fct
from another module (named module
) is represented by module.fct(required inputs)
.
The store keys and values of the PoS store are set as follows:
- The store prefix is set to
SUBSTORE_PREFIX_STAKER
. - Each store key is a
ADDRESS_LENGTH
-byteaddress
, representing a user address. - Each store value is the serialization of an object following
stakerStoreSchema
. - Notation: For the rest of this proposal let
stakerStore(address)
be the value stored in the staker substore with store keyaddress
, deserialized usingstakerStoreSchema
.
stakerStoreSchema = {
"type": "object",
"required": ["stakes", "pendingUnlocks"],
"properties": {
"stakes": {
"type": "array",
"fieldNumber": 1,
"items": {
"type": "object",
"required": ["validatorAddress", "amount", "sharingCoefficients"],
"properties": {
"validatorAddress": {
"dataType": "bytes",
"length": ADDRESS_LENGTH,
"fieldNumber": 1
},
"amount": {
"dataType": "uint64",
"fieldNumber": 2
},
"sharingCoefficients": {
"type": "array",
"fieldNumber": 3,
"items":{
"type": "object",
"required": ["tokenID", "coefficient"],
"properties": {
"tokenID": {
"dataType": "bytes",
"length": TOKEN_ID_LENGTH,
"fieldNumber": 1
},
"coefficient":{
"dataType": "bytes",
"maxLength": MAX_NUM_BYTES_Q96,
"fieldNumber": 2
}
}
}
}
}
}
},
"pendingUnlocks": {
"type": "array",
"fieldNumber": 2,
"items": {
"type": "object",
"required": ["validatorAddress", "amount", "unstakeHeight"],
"properties": {
"validatorAddress": {
"dataType": "bytes",
"length": ADDRESS_LENGTH,
"fieldNumber": 1
},
"amount": {
"dataType": "uint64",
"fieldNumber": 2
},
"unstakeHeight": {
"dataType": "uint32",
"fieldNumber": 3
}
}
}
}
}
}
In this section, we describe the properties of the staker substore.
stakes
: stores an array of the current staking information of a user. This array was calledvotes
in LIP 0023. For each validator that the user has staked with, we store their address and the amount of tokens that the user had staked with them; in case reward sharing is enabled, we also store the sharing coefficients at the time of staking (or at the last time rewards for this stake were claimed). This array is updated with a stake command. Thestakes
array is always kept ordered in lexicographical order ofvalidatorAddress
. Its size is at mostMAX_NUMBER_STAKING_SLOTS
, any state transition that would increase it to aboveMAX_NUMBER_STAKING_SLOTS
is invalid. Any element withamount == 0
is removed from the array. For all elements of this array, thesharingCoefficients
array is always kept ordered in lexicographical order oftokenID
pendingUnlocks
: stores an array representing the tokens that have been unstaked, but not yet unlocked. Each unstake generates an object in this array containing the address of the unstaked validator, the amount of the unstake and the height at which the unstake was included in the chain. Objects in this array get removed when the corresponding unlock command is executed. This array was calledunlocking
in LIP 0023. This array is updated with stake and unlock commands. ThependingUnlocks
array is always kept ordered by lexicographical order ofvalidatorAddress
, ties broken by increasingamount
, ties broken by increasingunstakeHeight
. The size of thependingUnlocks
array is at mostMAX_NUMBER_PENDING_UNLOCKS
, any state transition that would increase it to aboveMAX_NUMBER_PENDING_UNLOCKS
is invalid. NB: by construction, all elements of this array will haveamount != 0
.- If any state transition would result in a staker substore entry to have
stakes == []
andpendingUnlocks == []
, the entry is removed from the store.
- The store prefix is set to
SUBSTORE_PREFIX_VALIDATOR
. - Each store key is a
ADDRESS_LENGTH
-byteaddress
, representing a validator address. - Each store value is the serialization of an object following
validatorStoreSchema
. - Notation: For the rest of this proposal let
validatorStore(address)
be the value stored in the validator substore with store keyaddress
, deserialized usingvalidatorStoreSchema
.
validatorStoreSchema = {
"type": "object",
"required": [
"name",
"totalStake",
"selfStake",
"lastGeneratedHeight",
"isBanned",
"reportMisbehaviorHeights",
"consecutiveMissedBlocks",
"commission",
"lastCommissionIncreaseHeight",
"sharingCoefficients"
],
"properties": {
"name": {
"dataType": "string",
"fieldNumber": 1
},
"totalStake": {
"dataType": "uint64",
"fieldNumber": 2
},
"selfStake": {
"dataType": "uint64",
"fieldNumber": 3
},
"lastGeneratedHeight": {
"dataType": "uint32",
"fieldNumber": 4
},
"isBanned": {
"dataType": "boolean",
"fieldNumber": 5
},
"reportMisbehaviorHeights": {
"type": "array",
"fieldNumber": 6,
"items": {"dataType": "uint32"}
},
"consecutiveMissedBlocks": {
"dataType": "uint32",
"fieldNumber": 7
},
"commission": {
"dataType": "uint32",
"fieldNumber": 8
},
"lastCommissionIncreaseHeight": {
"dataType": "uint32",
"fieldNumber": 9
},
"sharingCoefficients": {
"type": "array",
"fieldNumber": 10,
"items":{
"type": "object",
"required": ["tokenID", "coefficient"],
"properties": {
"tokenID": {
"dataType": "bytes",
"length": TOKEN_ID_LENGTH,
"fieldNumber": 1
},
"coefficient":{
"dataType": "bytes",
"maxLength": MAX_NUM_BYTES_Q96,
"fieldNumber": 2
}
}
}
}
}
}
In this section, we describe the properties of the validator substore. Entries in this substore can be created during the execution of the genesis block. When the chain is running, entries in this substore are created by a validator registration command and its value is set during the command execution. It contains information about the validator whose address is the store key.
name
: a string representing the validator name, with a minimum length of1
character and a maximum length ofMAX_LENGTH_NAME
.totalStake
: the total stake of a validator.selfStake
: the total self-stake of a validator.lastGeneratedHeight
: the height at which the validator last generated a block.isBanned
: a Boolean value indicating if the validator is banned or not. Banned validators are never chosen to generate new blocks.reportMisbehaviorHeights
: the heights at which a report misbehavior command was successfully executed with blocks generated by the validator.consecutiveMissedBlocks
: the number of consecutive missed blocks by the validator. This value resets to 0 whenever a block generated by the validator is included in the blockchain.
The following three properties are relevant only in case reward sharing is enabled. See LIP 0070 for more details.
commission
: a number specifying the commission of the validator. We use two decimals precision, i.e., the number will be an integer from 0 to 10000, where the commission percentage is commission/100.lastCommissionIncreaseHeight
: the height at which the validator last increased the commission.sharingCoefficients
: an array containing the value of the sharing coefficients of the validator for each token.
- The store prefix is set to
SUBSTORE_PREFIX_NAME
. - Each store key is a utf8-encoded string
name
, representing a validator name. - Each store value is the serialization of an object following
nameStoreSchema
. - Notation: For the rest of this proposal let
nameStore(name)
be the value stored in the name substore with store keyname
, deserialized usingnameStoreSchema
.
nameStoreSchema = {
"type": "object",
"required": ["validatorAddress"],
"properties": {
"validatorAddress": {
"dataType": "bytes",
"length": ADDRESS_LENGTH,
"fieldNumber": 1
}
}
}
The name substore maintains all registered names, using the name as store key and storing the address of the validator that registered that name in the corresponding store value. Entries in this substore are created during the execution of validator registration command.
- The store prefix is set to
SUBSTORE_PREFIX_ELIGIBLE_VALIDATORS
. - For the entry corresponding to a validator, the store key is the concatenation of the validator weight (represented using the big endian uint64 serialization) and the validator address, i.e.,
weight.to_bytes(8,'big') + address
. - Each store value is the serialization of an object following
eligibleValidatorsStoreSchema
. - Notation: For the rest of this proposal let
eligibleValidatorsStore(key)
be the value stored in the eligible validators substore with store keykey
, deserialized usingeligibleValidatorsStoreSchema
.
eligibleValidatorsStoreSchema = {
"type": "object",
"required": ["lastReportMisbehaviorHeight"],
"properties": {
"lastReportMisbehaviorHeight": {
"dataType": "uint32",
"fieldNumber": 1
}
}
}
In this section, we describe the properties of the eligible validators substore.
lastReportMisbehaviorHeight
: the height of the last report misbehavior transaction against the specified validator. It's default value is 0.
This substore only maintains an entry for validators that have weight more than MIN_WEIGHT
, and that are not banned. It is updated via the updateValidatorEligibility
function.
- The store prefix is set to
SUBSTORE_PREFIX_SNAPSHOT
. - Each store key is
roundNumber.to_bytes(4,'big')
, i.e., the big endian uint32 serialization ofroundNumber
, whereroundNumber
is the number of the round for which the snapshot is supposed to be used for determining the validators. This snapshot is defined at the end of roundroundNumber -3
and is used at the end of roundroundNumber -1
to determine the validators for roundroundNumber
. - Each store value is the serialization of an object following
snapshotStoreSchema
. - Notation: For the rest of this proposal let
snapshotStore(roundNumber)
be the value stored in the snapshot substore with store keyroundNumber.to_bytes(4,'big')
, deserialized usingsnapshotStoreSchema
.
snapshotStoreSchema = {
"type": "object",
"required": ["validatorWeightSnapshot"],
"properties": {
"validatorWeightSnapshot": {
"type": "array",
"fieldNumber": 1,
"items": {
"type": "object",
"required": ["address", "weight"],
"properties": {
"address": {
"dataType": "bytes",
"length": ADDRESS_LENGTH,
"fieldNumber": 1
},
"weight": {
"dataType": "uint64",
"fieldNumber": 2
}
}
}
}
}
}
The validatorWeightSnapshot
array is ordered by decreasing weight
, ties broken by reverse lexicographical ordering of address
.
In this section, we describe the properties of the snapshot substore.
validatorWeightSnapshot
: all validator addresses and weights of all non-banned validators with more thanMIN_WEIGHT
validator weight for the given round number.
The snapshot substore is initially empty.
- The store prefix is set to
SUBSTORE_PREFIX_GENESIS_DATA
. - The store key is set to empty bytes.
- The store value is the serialization of an object following
genesisDataStoreSchema
. - Notation: For the rest of this proposal let:
genesisDataStore.height
be theheight
property of the entry in the genesis data substore.genesisDataStore.initRounds
be theinitRounds
property of the entry in the genesis data substore.genesisDataStore.initValidators
be theinitValidators
property of the entry in the genesis data substore.
genesisDataStoreSchema = {
"type": "object",
"required": [
"height",
"initRounds",
"initValidators"
],
"properties": {
"height": {
"dataType": "uint32",
"fieldNumber": 1
},
"initRounds": {
"dataType": "uint32",
"fieldNumber": 2
},
"initValidators": {
"type": "array",
"fieldNumber": 3,
"items": {
"dataType": "bytes",
"length": ADDRESS_LENGTH
}
}
}
}
The genesis data substore stores information from the genesis block. It is initialized when processing the genesis block.
height
: height of the genesis block.initRounds
: the length of the bootstrap period, also called initial rounds.initRounds
must be at leastMIN_INIT_ROUNDS
.initValidators
: the addresses of the validators to be used during the bootstrap period. This property must have a non-empty value.
- The store prefix is set to
SUBSTORE_PREFIX_PREVIOUS_TIMESTAMP
. - The store key is set to empty bytes.
- The store value is the serialization of an object following
previousTimestampStoreSchema
- Notation: For the rest of this proposal, let
previousTimestamp
be thetimestamp
property of the entry in the previous timestamp substore, deserialized using thepreviousTimestampStoreSchema
schema.
previousTimestampStoreSchema = {
"type": "object",
"required": ["timestamp"],
"properties": {
"timestamp": {
"dataType": "uint32",
"fieldNumber": 1
}
}
}
timestamp
: The timestamp of the last block added to the chain.
Transactions executing this command have:
module = MODULE_NAME_POS
,command = COMMAND_NAME_REGISTER_VALIDATOR
.
registerValidatorTransactionParams = {
"type": "object",
"required": [
"name",
"blsKey",
"proofOfPossession",
"generatorKey"
],
"properties": {
"name": {
"dataType": "string",
"fieldNumber": 1
},
"blsKey": {
"dataType": "bytes",
"length" : BLS_PUBLIC_KEY_LENGTH,
"fieldNumber": 2
},
"proofOfPossession": {
"dataType": "bytes",
"length" : BLS_POP_LENGTH,
"fieldNumber": 3
},
"generatorKey": {
"dataType": "bytes",
"length": ED25519_PUBLIC_KEY_LENGTH,
"fieldNumber": 4
}
}
}
def verify(trs: Transaction) -> None:
trsParams = decode(registerValidatorTransactionParams, trs.params)
validatorAddress = SHA256(trs.senderPublicKey)[:ADDRESS_LENGTH] # Derive validatorAddress from trs.senderPublicKey.
if there exists an entry validatorStore(validatorAddress) in validator substore:
raise Exception('This address has already registered a validator.')
if not isValidatorNameValid(trsParams.name):
raise Exception('Invalid name')
if there exists an entry nameStore(trsParams.name) in name substore:
raise Exception('Name already used by a validator.')
if trs.fee < VALIDATOR_REGISTRATION_FEE:
raise Exception('Insufficient transaction fee.')
When a transaction trs
executing a validator registration command included in a block b
, the logic below is followed:
def execute(trs: Transaction) -> None:
trsParams = decode(registerValidatorTransactionParams, trs.params)
b = block including trs
validatorAddress = SHA256(trs.senderPublicKey)[:ADDRESS_LENGTH] # Derive validatorAddress from trs.senderPublicKey.
validatorName = trsParams.name
# This step also checks that the BLS key has not been used from another validator.
Validators.registerValidatorKeys(validatorAddress,
trsParams.proofOfPossession,
trsParams.generatorKey,
trsParams.blsKey)
# The new validator pays the registration fee.
Fee.payFee(VALIDATOR_REGISTRATION_FEE)
# Update validator substore.
validatorState = {
"name": validatorName,
"totalStake": 0,
"selfStake": 0,
"lastGeneratedHeight": b.header.height,
"isBanned": False,
"reportMisbehaviorHeights": [],
"consecutiveMissedBlocks": 0,
"commission": 10000,
"lastCommissionIncreaseHeight": b.header.height,
"sharingCoefficients": []
}
create an entry in the validator substore with
storeKey = validatorAddress,
storeValue = encode(validatorStoreSchema, validatorState)
# Update name substore.
create an entry in the name substore with
storeKey = validatorName encoded as utf8,
storeValue = encode(nameStoreSchema, {"validatorAddress": validatorAddress})
# Emit event for the successful validator registration.
emitEvent(
module=MODULE_NAME_POS,
name=EVENT_NAME_VALIDATOR_REGISTERED,
data={
"address": validatorAddress,
"name": validatorName
},
topics=[validatorAddress]
)
Transactions executing this command have:
module = MODULE_NAME_POS
command = COMMAND_NAME_STAKE
stakeTransactionParams = {
"type": "object",
"required": ["stakes"],
"properties": {
"stakes": {
"type": "array",
"fieldNumber": 1,
"items": {
"type": "object",
"required": ["validatorAddress", "amount"],
"properties": {
"validatorAddress" : {
"dataType": "bytes",
"length": ADDRESS_LENGTH,
"fieldNumber": 1
},
"amount": {
"dataType": "sint64",
"fieldNumber": 2
}
}
}
}
}
}
Let trsParams = decode(stakeTransactionParams, trs.params)
for the given transaction trs
. Then the following verification steps are performed:
trsParams.stakes
has at most2 * MAX_NUMBER_STAKING_SLOTS
elements. The reason of choosing this bound on the size is to allow a staker to unstake all current validators (which are at mostMAX_NUMBER_STAKING_SLOTS
) and stake withMAX_NUMBER_STAKING_SLOTS
new ones in the same transaction.- A given
validatorAddress
is included in at most one stake from the list of stakes (regardless of the associated amounts). - For all stakes included in
trsParams.stakes
, we have:amount
value is a multiple ofBASE_STAKING_AMOUNT
, i.e.,amount % BASE_STAKING_AMOUNT == 0
. For the Lisk mainchain, whereBASE_STAKING_AMOUNT = 10^9
andTOKEN_ID_POS
is the token ID of the LSK token, this corresponds to multiples of 10 LSK.amount != 0
.
When executing a stake transaction trs
, the logic below is followed
def execute(trs: Transaction) -> None:
trsParams = decode(stakeTransactionParams, trs.params)
b = block including trs
height = b.header.height
senderAddress = SHA256(trs.senderPublicKey)[:ADDRESS_LENGTH] # Derive address from trs.senderPublicKey.
# Sorting the stakes guarantees that we first apply the stakes with negative amounts.
sortedStakes = trsParams.stakes ordered by increasing value of amount
for stake in sortedStakes:
validatorAddress = stake.validatorAddress
if validatorStore(validatorAddress) does not exist:
emitStakeEvent(senderAddress, validatorAddress, stake.amount, STAKE_FAILED_NON_REGISTERED_VALIDATOR)
raise Exception('Invalid stake: no registered validator with the specified address')
if stake.amount < 0: # Case of unstake.
sentStake = element in stakerStore(senderAddress).stakes with sentStake.validatorAddress == validatorAddress
if not sentStake or abs(stake.amount) > sentStake.amount:
emitStakeEvent(senderAddress, validatorAddress, stake.amount, STAKE_FAILED_INVALID_UNSTAKE_PARAMETERS)
raise Exception('Invalid unstake: The unstaked amount exceeds the staked amount for this validator')
# Assign the rewards related to this stake to the sender.
i = index of sentStake in stakerStore(senderAddress).stakes
assignStakingRewards(senderAddress, i)
# Update staker substore.
stakerStore(senderAddress).stakes[i].amount += stake.amount
if stakerStore(senderAddress).stakes[i].amount == 0:
remove sentStake from stakerStore(senderAddress).stakes
# Create unlock object for the unstake.
unlockObject = {
"validatorAddress" : validatorAddress,
"amount" : abs(stake.amount),
"unstakeHeight" : height
}
add unlockOject to stakerStore(senderAddress).pendingUnlocks, keeping the array ordered by lexicographical order of validatorAddress,
ties broken by increasing amount,
ties broken by increasing unstakeHeight
if len(stakerStore(senderAddress).pendingUnlocks) > MAX_NUMBER_PENDING_UNLOCKS:
emitStakeEvent(senderAddress, validatorAddress, stake.amount, STAKE_FAILED_TOO_MANY_PENDING_UNLOCKS)
raise Exception('Sender has reached the maximum number of pending unlocks.')
if stake.amount > 0: # Case of regular stake.
Token.lock(senderAddress, MODULE_NAME_POS, TOKEN_ID_POS, stake.amount) # Lock the staked amount.
# User has already staked this validator.
if there exists an entry oldStake in stakerStore(senderAddress).stakes with oldStake.validatorAddress = validatorAddress:
i = index of oldStake in stakerStore(senderAddress).stakes
assignStakingRewards(senderAddress, i)
# Update staker substore.
stakerStore(senderAddress).stakes[i].amount += stake.amount
else: # New stake.
add {"validatorAddress": validatorAddress, "amount": stake.amount,
"sharingCoefficients": validatorStore(stake.validatorAddress).sharingCoefficients}
to stakerStore(senderAddress).stakes
keeping the array ordered in lexicographical order of validatorAddress
if len(stakerStore(senderAddress).stakes) > MAX_NUMBER_STAKING_SLOTS:
emitStakeEvent(senderAddress, validatorAddress, stake.amount, STAKE_FAILED_TOO_MANY_VALIDATORS)
raise Exception('This address has reached the maximum number of staking slots.')
# Update validator substore.
previousValidatorWeight = getValidatorWeight(validatorAddress)
validatorStore(validatorAddress).totalStake += stake.amount
if senderAddress == validatorAddress:
validatorStore(validatorAddress).selfStake += stake.amount
emitStakeEvent(senderAddress, validatorAddress, stake.amount, STAKE_SUCCESSFUL)
# Update eligible validators substore.
updateValidatorEligibility(validatorAddress, previousValidatorWeight)
def emitStakeEvent(senderAddress: Address, validatorAddress: Address, amount: uint64, result: uint32) -> None:
if result == STAKE_SUCCESSFUL:
emitEvent(
module = MODULE_NAME_POS,
name = EVENT_NAME_AMOUNT_STAKED,
data={
"senderAddress": senderAddress,
"validatorAddress": validatorAddress,
"amount": amount,
"result": STAKE_SUCCESSFUL
},
topics = [senderAddress]
)
else:
emitPersistentEvent(
module = MODULE_NAME_POS,
name = EVENT_NAME_AMOUNT_STAKED,
data={
"senderAddress": senderAddress,
"validatorAddress": validatorAddress,
"amount": amount,
"result": result
},
topics = [senderAddress]
)
Here, the updateValidatorEligibility
function updates the eligible validators substore according to the new weight of the validator the user stakes with. The assignStakingRewards
function is defined in LIP 0070. The calls to this function are needed only in case reward sharing is enabled.
Transactions executing this command have:
module = MODULE_NAME_POS
command = COMMAND_NAME_UNLOCK
unlockTransactionParams = {
"type": "object",
"required": [],
"properties": {}
}
No additional verification is performed for transactions executing this command.
def execute(trs: Transaction) -> None:
senderAddress = SHA256(trs.senderPublicKey)[:ADDRESS_LENGTH] # Derive address from trs.senderPublicKey.
b = block including trs
height = b.header.height
for each unlockObject in stakerStore(senderAddress).pendingUnlocks:
# Check if unstaked amount can be unlocked.
if (isUnlockable(unlockObject, senderAddress, height)
and isCertificateGenerated(unlockObject)):
delete unlockObject from stakerStore(senderAddress).pendingUnlocks
Token.unlock(senderAddress, MODULE_NAME_POS, TOKEN_ID_POS, unlockObject.amount)
# Token module has its own event for successful/failed unlock so no need to add event here.
The definition and rationale for the isCertificateGenerated
function is part of LIP 0059. The function isUnlockable
is defined below. This function has the following input parameters:
unlockObject
: an object with propertiesvalidatorAddress
(the address of the previously staked validator),amount
(the unstaked amount) andunstakeHeight
(the height of the unstake).senderAddress
: Address of the user sending the unlock transaction.height
: the height of the block including the unlock transaction.
def isUnlockable(unlockObject: UnlockObject, senderAddress: Address, height: uint32) -> bool:
validatorAddress = unlockObject.validatorAddress
if validatorAddress == senderAddress:
lockingPeriod = LOCKING_PERIOD_SELF_STAKING
punishmentWindow = PUNISHMENT_WINDOW_SELF_STAKING
else:
lockingPeriod = LOCKING_PERIOD_STAKING
punishmentWindow = PUNISHMENT_WINDOW_STAKING
if height < unlockObject.unstakeHeight + lockingPeriod:
return False
if len(validatorStore(validatorAddress).reportMisbehaviorHeights) > 0: # Validator might be punished.
let lastReportMisbehaviorHeight be the last element of validatorStore(validatorAddress).reportMisbehaviorHeights
# lastReportMisbehaviorHeight is also the largest element of the reportMisbehaviorHeights array.
if height < lastReportMisbehaviorHeight + punishmentWindow and lastReportMisbehaviorHeight < unlockObject.unstakeHeight + lockingPeriod:
return False
return True
In LIP 0023 the locking period for unstaked tokens was set to roughly 5.5 hours (2000 blocks) for the lisk mainchain. In this LIP is to change this value to 3 days, by setting LOCKING_PERIOD_STAKING
to 25.920 = 3 * 24 * 3600 // BLOCK_TIME
blocks. Similarly, the constants LOCKING_PERIOD_SELF_STAKING
, PUNISHMENT_WINDOW_STAKING
are modified to be exactly 4 weeks (28 days) and PUNISHMENT_WINDOW_SELF_STAKING = 3 * PUNISHMENT_WINDOW_STAKING
.
Transactions executing this command have:
module = MODULE_NAME_POS
command = COMMAND_NAME_REPORT_MISBEHAVIOR
reportMisbehaviorParams = {
"type": "object",
"required": ["header1", "header2"],
"properties": {
"header1": {
"dataType": "bytes",
"fieldNumber": 1
},
"header2": {
"dataType": "bytes",
"fieldNumber": 2
}
}
}
Both properties of the parameters must follow the block header schema blockHeaderSchema
defined in LIP 0055. Validity of this transaction was previously specified in LIP 0024. For completeness, we include the pseudocode here.
def verify(trs: Transaction) -> None:
trsParams = decode(reportMisbehaviorParams, trs.params)
b = block including trs
header1 = trsParams.header1
header2 = trsParams.header2
if header1 or header2 do not satisfy blockHeaderSchema schema:
raise Exception('Invalid block header.')
header1 = decode(blockHeaderSchema, header1)
header2 = decode(blockHeaderSchema, header2)
if max(abs(header1.height - b.header.height), abs(header2.height - b.header.height)) >= LOCKING_PERIOD_SELF_STAKING:
raise Exception('Locking period has expired.')
if isPunished(header1.address, b.header.height):
raise Exception('Validator is already punished.')
if validatorStore(header1.address).isBanned:
raise Exception('Validator is banned.')
if verifyBlockSignature(header1) == False or verifyBlockSignature(header2) == False:
raise Exception('Invalid block signature')
if areHeadersContradicting(header1, header2) == False:
raise Exception('Block headers are not contradicting.')
The verifyBlockSignature function is an internal function defined below. The areHeadersContradicting
function is defined in LIP 0058.
Execution of this transaction was previously specified in LIP 0024. Here we update the specifications to be integrated in the state model used in Lisk.
def execute(trs: Transaction) -> None:
trsParams = decode(reportMisbehaviorParams, trs.params)
b = block including trs
h = b.header.height
senderAddress = SHA256(trs.senderPublicKey)[:ADDRESS_LENGTH] # Derive address from trs.senderPublicKey.
header1 = decode(blockHeaderSchema, trsParams.header1)
header2 = decode(blockHeaderSchema, trsParams.header2)
punishedAddress = trsParams.header1.generatorAddress
# Update validator substore.
validatorStore(punishedAddress).reportMisbehaviorHeights.append(h)
# Emit event for the validator punishment.
emitEvent(
module=MODULE_NAME_POS,
name=EVENT_NAME_VALIDATOR_PUNISHED,
data={
"address": validatorAddress
},
topics=[validatorAddress]
)
# Check if the validator should be banned.
if len(validatorStore(punishedAddress).reportMisbehaviorHeights) == REPORT_MISBEHAVIOR_LIMIT_BANNED:
validatorStore(punishedAddress).isBanned = True
# Emit event for the validator banning.
emitEvent(
module=MODULE_NAME_POS,
name=EVENT_NAME_VALIDATOR_BANNED,
data={
"address": validatorAddress
},
topics=[validatorAddress]
)
# Assign the misbehavior report reward to the sender of the transaction.
# The amount is taken from the punished validator account.
senderReward = min(REPORT_MISBEHAVIOR_REWARD, validatorStore(punishedAddress).selfStake)
Token.unlock(punishedAddress, MODULE_NAME_POS, TOKEN_ID_POS, senderReward)
Token.transfer(punishedAddress,
senderAddress,
TOKEN_ID_POS,
senderReward)
# Update validator and staker substores for punished validator.
oldWeight = getValidatorWeight(punishedAddress)
validatorStore(punishedAddress).selfStake -= senderReward
validatorStore(punishedAddress).totalStake -= senderReward
i = index of element in stakerStore(punishedAddress).stakes array with item.validatorAddress == punishedAddress
stakerStore(punishedAddress).stakes[i].amount -= senderReward
# Update eligibility of punished validator, based on new weight and potential ban.
updateValidatorEligibility(punishedAddress, oldWeight)
This event has name = EVENT_NAME_VALIDATOR_REGISTERED
. This event is emitted when a new validator gets registered.
validatorAddress
: the address of the account registering a validator.
validatorRegisteredDataSchema = {
"type": "object",
"required" = [
"address",
"name"
],
"properties": {
"address": {
"dataType": "bytes",
"length": ADDRESS_LENGTH,
"fieldNumber": 1
},
"name": {
"dataType": "string",
"fieldNumber": 2
}
}
}
This event has name = EVENT_NAME_AMOUNT_STAKED
. This event is emitted during the processing of each stake included in a stake transaction.
senderAddress
: the address of the account submitting the stake transaction.validatorAddress
: the address of the account of the validator the user staked with.
stakeDataSchema = {
"type": "object",
"required" = [
"senderAddress",
"validatorAddress",
"amount",
"result"
],
"properties": {
"senderAddress": {
"dataType": "bytes",
"length": ADDRESS_LENGTH,
"fieldNumber": 1
},
"validatorAddress": {
"dataType": "bytes",
"length": ADDRESS_LENGTH,
"fieldNumber": 2
},
"amount": {
"dataType": "sint64",
"fieldNumber": 3
},
"result": {
"dataType": "uint32",
"fieldNumber": 4
}
}
}
This event has name = EVENT_NAME_VALIDATOR_PUNISHED
. This event is emitted when a validator gets punished.
validatorAddress
: the address of the account of the punished validator.
validatorPunishedDataSchema = {
"type": "object",
"required" = [
"address"
],
"properties": {
"address": {
"dataType": "bytes",
"length": ADDRESS_LENGTH,
"fieldNumber": 1
}
}
}
This event has name = EVENT_NAME_VALIDATOR_BANNED
. This event is emitted when a validator gets banned.
validatorAddress
: the address of the account of the banned validator.
validatorPunishedDataSchema = {
"type": "object",
"required" = [
"address"
],
"properties": {
"address": {
"dataType": "bytes",
"length": ADDRESS_LENGTH,
"fieldNumber": 1
}
}
}
All blocks (with the exception of the genesis block) are part of a round. The first block after the genesis block is the first block of the first round, and so on. The round length, i.e. the number of blocks in a round, is specified in a configuration file and is denoted ROUND_LENGTH
. In the block lifecycle, it will be useful to compute the round number to which a block belongs and if the block is the last block of its round. For this, we will use the following functions:
This function returns the round number to which the input height belongs.
def getRoundNumberFromHeight(h: uint32) -> uint32:
return ceiling(h - genesisDataStore.height, ROUND_LENGTH)
Here, ceiling
is an internal function defined below.
This function returns a boolean indicating if the input height is at the end of a round or not.
def isEndOfRound(h: uint32) -> bool:
if (h - genesisDataStore.height) % ROUND_LENGTH == 0:
return True
else:
return False
Returns the ceiling of the division between two positive integers.
def ceiling(x: int,y: int) -> int:
if y == 0:
raise Exception('Can not divide by 0.')
return (x+y-1)//y
This function returns the weight of a given validator (specified by the address).
def getValidatorWeight(address: Address) -> uint64:
return min(validatorStore(address).selfStake * FACTOR_SELF_STAKING,
validatorStore(address).totalStake)
A function to reorder the list of validators as specified in LIP 0003.
The function has the following input parameters in the order given below:
validatorList
: An array of objects with of typeValidatorObject
.randomSeed
: ASEED_LENGTH
-byte value representing a random seed.
This function returns the input list shuffled using the value of randomSeed
.
def shuffleValidatorsList(validatorList: list[ValidatorObject], randomSeed: bytes) -> list[ValidatorObject]:
# Checking pairwise distinct property.
if validatorList != set(validatorList):
raise Exception('Validators list invalid (duplicate values detected)')
roundHash = {}
for item in validatorList:
roundHash[item.address] = SHA256(randomSeed + item.address) # Hashing concatenation of randomSeed and address.
# Reorder the validator list.
shuffledValidatorList = sort validatorList where item1 < item2 if (roundHash(item1.address) < roundHash(item2.address))
or ((roundHash[item1.address] == roundHash[item1.address]) and item1.address < item2.address)
return shuffledValidatorList
A function that updates the eligible validators substore to account for new changes of validator properties.
The function has the following input parameters:
address
: the address of a validator.oldWeight
: the weight of the validator before the eligibility update.
This function does not return.
def updateValidatorEligibility(address: Address, oldWeight: uint64) -> None:
# Always start by removing the old entry from the eligible validators substore.
oldKey = oldWeight.to_bytes(8,'big') + address
if the eligible validator substore contains an entry for key = oldKey:
remove this entry from the store
# If the validator is eligible, add an entry to the eligible validators substore.
weight = getValidatorWeight(address)
if (weight >= MIN_WEIGHT and validatorStore(address).isBanned == False):
newKey = weight.to_bytes(8,'big') + address
lastReportMisbehaviorHeight = validatorStore(validatorAddress).reportMisbehaviorHeights[-1] if len(validatorStore(validatorAddress).reportMisbehaviorHeights) else 0
create an entry in the eligible validators substore with
storeKey = newKey,
storeValue = encode(eligibleValidatorsStoreSchema, {lastReportMisbehaviorHeight})
Checks whether a block header is validly signed.
def verifyBlockSignature(header: BlockHeader) -> bool:
generatorKey = Validators.getValidatorKeys(header.generatorAddress).generatorKey
signature = block.header.signature
# Remove the signature from the block header.
delete header.signature
# Serialize the block header without signature.
serializedUnsignedBlockHeader = encode(unsignedBlockHeaderSchema, header)
return verifyEd25519(generatorKey, "LSK_BH_", OWN_CHAIN_ID, serializedUnsignedBlockHeader, signature)
Here, the function verifyEd25519
verifies the validity of a signature as specified in LIP 0062.
Checks whether a given string would be a valid validator name.
def isValidatorNameValid(validatorName: str) -> bool:
# Name should contain only lower case letters, numbers and symbols `!@$&_.`
# and should be at least 1 character long and at most MAX_LENGTH_NAME characters long.
if (not(all(c.isdigit() or c.islower() or c in ['!','@','$','&','_','.'] for c in validatorName))
or len(validatorName) < 1
or len(validatorName) > MAX_LENGTH_NAME):
return False
return True
This function returns a boolean indicating if a validator is punished at a certain height of not.
def isPunished(address: Address, height: uint32) -> bool:
if validatorStore(address).reportMisbehaviorHeights is empty:
return False
let lastReportMisbehaviorHeight be the last element of validatorStore(address).reportMisbehaviorHeights
if height < lastReportMisbehaviorHeight + PUNISHMENT_WINDOW_SELF_STAKING:
return True
return False
This function computes the set of active validators and their BFT weight based on the input set of eligible validators.
def getActiveValidators(roundNumber: uint32) -> list[ValidatorObject]:
initRounds = genesisDataStore.initRounds
initValidators = genesisDataStore.initValidators
eligibleValidators = snapshotStore(roundNumber)
# During the first NUMBER_ACTIVE_VALIDATORS rounds after the bootstrap period
# the initial validators are only partly replaced by elected validators.
# During this phase, there are no selected standby validators.
if roundNumber < initRounds + NUMBER_ACTIVE_VALIDATORS:
nbrInitValidators = initRounds + NUMBER_ACTIVE_VALIDATORS - roundNumber
nbrElectedValidators = NUMBER_ACTIVE_VALIDATORS - nbrInitValidators
# Recall that eligibleValidators is sorted by validator weight.
electedValidatorAddresses = [item.address for item in eligibleValidators[:nbrElectedValidators]]
chosenInitValidators = [address for address in initValidators if address not in electedValidatorAddresses][:nbrInitValidators]
# Determine active validators and their BFT weight.
activeValidators = [{"address": item.address, "bftWeight": ceiling(item.weight, WEIGHT_SCALE_FACTOR) } for item in eligibleValidators[:nbrElectedValidators]]
if len(activeValidators) > 0:
# average BFT weight can not be 0, since MIN_WEIGHT >= 1, therefore BFT weight of elected validators is at least 1.
averageElectedValidatorBftWeight = sum([item.bftWeight for item in activeValidators]) // len(activeValidators)
activeValidators+= [{"address": item, "bftWeight": averageElectedValidatorBftWeight} for item in chosenInitValidators]
else:
activeValidators+= [{"address": item, "bftWeight": 1} for item in chosenInitValidators]
return activeValidators
else:
# If eligibleValidators contains less than NUMBER_ACTIVE_VALIDATORS entries
# there will be less than NUMBER_ACTIVE_VALIDATORS active validators.
# Recall that eligibleValidators is sorted by validator weight.
activeValidators = [{"address": item.address, "bftWeight": ceiling(item.weight, WEIGHT_SCALE_FACTOR) } for item in eligibleValidators[:NUMBER_ACTIVE_VALIDATORS]]
# Apply capping in weights if necessary.
if len(activeValidators) >= ceiling(10000, MAX_BFT_WEIGHT_CAP):
activeValidators = capWeights(activeValidators, MAX_BFT_WEIGHT_CAP)
return activeValidators
This function selects the standby validators for a round and returns them together with their BFT weight.
def getSelectedStandbyValidators(standbyValidators: list[EligibleValidatorObject], height: uint32) -> list[ValidatorObject]:
# We now compute the randomness used for selecting the first standby validator.
randomSeed1 = random.getRandomBytes(
height +1 - (ROUND_LENGTH*3)//2,
ROUND_LENGTH
)
if NUMBER_STANDBY_VALIDATORS == 2 and len(standbyValidators) >=2:
randomSeed2 = random.getRandomBytes(
height +1 - 2*ROUND_LENGTH,
ROUND_LENGTH
)
selectedStandbyValidators = select 2 address from standbyValidators
as specified in LIP 0022, using the seeds randomSeed1 and randomSeed2
elif NUMBER_STANDBY_VALIDATORS >= 1 and len(standbyValidators) >= 1:
selectedStandbyValidators = select 1 address from standbyValidators
as specified in LIP 0022, using the seed randomSeed1
else: # No standby validators.
selectedStandbyValidators = empty
return [{"address": item.address, "bftWeight": 0} for item in selectedStandbyValidators]
This function caps the values of a sorted array to make sure that no element has value more than a certain percentage of the total value.
validatorList
: An array of objects of typeValidatorObject
array of integers, sorted in decreasing order ofbftWeight
.capValue
: Specifies the percentage in which the values are capped. Takes values from 0 - 10000 corresponding to double decimal precision integers which can be obtained by dividing it by 100, i.e., 1000 means 10%.
def capWeights(validatorList: list[ValidatorObject], capValue: uint32) -> list[ValidatorObject]:
if sorted(validatorList, reverse = True, key = lambda y:y.bftWeight) != validatorList: # List should be ordered in decreasing order of weight.
raise Exception('List is not sorted in decreasing order.')
if capValue == 0 or capValue >= 10000:
raise Exception('Invalid value for capping.')
maxNumCappedElements = ceiling(10000, capValue) - 1
if len(validatorList) <= maxNumCappedElements:
raise Exception('List size not enough to apply capping with specified value.')
partialSum = 0
for i in range(maxNumCappedElements + 1, len(validatorList)):
partialSum += validatorList[i].bftWeight
for i in range(maxNumCappedElements, 0, -1):
partialSum += validatorList[i].bftWeight
cappedWeightRemainingElements = (capValue * partialSum) // (10000 - (capValue * i))
if cappedWeightRemainingElements < validatorList[i-1].bftWeight:
for k in range(i):
validatorList[k].bftWeight = cappedWeightRemainingElements
break
return validatorList
The idea of the function above is that for the validatorList
with elements in descending order of bftWeight
, then you want the following equation to hold for element i
:
x <= (capValue / 10000) ( i * x + sum( [validatorList[k].bftWeight for k in range(i,len(validatorList))])
Here x
is the BFT weight at which the elements validatorList[0].bftWeight, ..., validatorList[i-1].bftWeight
are capped. Solving the above equation for x
yields the value of cappedWeightRemainingElements
in the code. Note that if
cappedWeightRemainingElements > validatorList[i-1].bftWeight
, then you do not need to cap validatorList[i-1].bftWeight
and can reduce the index i
by 1.
This function computes the precommit and certificate thresholds that are forwarded to the consensus layer, see LIP 0058 for details.
def computeThresholds(validatorList: list[ValidatorObject]) -> Tuple[uint64, uint64]:
aggregateBFTWeight = 0
for validator in validatorList:
aggregateBFTWeight += validator.bftWeight
precommitThreshold = (2 * aggregateBFTWeight) // 3 + 1
certificateThreshold = precommitThreshold
return precommitThreshold, certificateThreshold
More functions might be made available during implementation.
def getNumberActiveValidators()-> uint32:
return NUMBER_ACTIVE_VALIDATORS
def getRoundLength()-> uint32:
return ROUND_LENGTH
Returns the stored information relative to the given address.
def getStaker(address: Address)-> StakerStoreObject:
return stakerStore(address)
Returns the stored information relative to the given address.
def getValidator(address: Address)-> ValidatorStoreObject:
return validatorStore(address)
Removes the banning for a validator, given that the reason of banning was not due to misbehaviors. For example, this applies in cases the validator got banned due to inactivity or not registering valid BLS keys.
def unbanValidator(address: Address)-> None:
if there does not exist an entry in validator substore with key == address:
raise Exception('No registered validator with the specified address.')
if validatorStore(address).isBanned == False:
raise Exception('The specified validator is not banned.')
if len(validatorStore(address).reportMisbehaviorHeights) >= REPORT_MISBEHAVIOR_LIMIT_BANNED:
raise Exception('Validator exceeded the maximum allowed number of misbehaviors and is permanently banned.')
validatorStore(address).isBanned = False
updateValidatorEligibility(address,0)
This section specifies the non-trivial or recommended endpoints of the PoS module and does not include all endpoints.
Asserts the availability of a given name for validator registration.
def isNameAvailable(name: str) -> bool:
if (not isValidatorNameValid(name))
or (nameStore(name) exists):
return False
else:
return True
def getPoSTokenID() -> TokenID:
return TOKEN_ID_POS
Same as the getStaker function of the previous section.
Same as the getValidator function of the previous section.
Returns information of all validators.
def getAllValidators()-> list[ValidatorStoreObject]:
return [decode(validatorStoreSchema,validatorStore(address)) for validatorStore(address) in validator substore]
Returns the total amount locked due to participation in staking (active stakes and pending unlocks) for the given address.
def getLockedStakedAmount(address: Address) -> uint64:
lockedStakedAmount = 0
for item in stakerStore(address).stakes:
lockedStakedAmount+= item.amount
for item in stakerStore(address).pendingUnlocks:
lockedStakedAmount+= item.amount
return lockedStakedAmount
Returns the list of pending unlocks for the given address.
def getPendingUnlocks(address: Address)-> UnlockObject:
return stakerStore(address).pendingUnlocks
The following steps are executed as part of the genesis block processing, see LIP 0060 for details.
genesisPoSStoreSchema = {
"type": "object",
"required": ["validators", "stakers", "genesisData"],
"properties": {
"validators": {
"type": "array",
"fieldNumber": 1,
"items": {
"type": "object",
"required": [
"address",
"name",
"blsKey",
"proofOfPossession",
"generatorKey",
"lastGeneratedHeight",
"isBanned",
"reportMisbehaviorHeights",
"consecutiveMissedBlocks",
"commission",
"lastCommissionIncreaseHeight",
"sharingCoefficients"
],
"properties": {
"address": {
"dataType": "bytes",
"length": ADDRESS_LENGTH,
"fieldNumber": 1
},
"name": {
"dataType": "string",
"fieldNumber": 2
},
"blsKey": {
"dataType": "bytes",
"length" : BLS_PUBLIC_KEY_LENGTH,
"fieldNumber": 3
},
"proofOfPossession": {
"dataType": "bytes",
"length" : BLS_POP_LENGTH,
"fieldNumber": 4
},
"generatorKey": {
"dataType": "bytes",
"length": ED25519_PUBLIC_KEY_LENGTH,
"fieldNumber": 5
},
"lastGeneratedHeight": {
"dataType": "uint32",
"fieldNumber": 6
},
"isBanned": {
"dataType": "boolean",
"fieldNumber": 7
},
"reportMisbehaviorHeights": {
"type": "array",
"fieldNumber": 8,
"items": { "dataType": "uint32" }
},
"consecutiveMissedBlocks": {
"dataType": "uint32",
"fieldNumber": 9
},
"commission": {
"dataType": "uint32",
"fieldNumber": 10
},
"lastCommissionIncreaseHeight": {
"dataType": "uint32",
"fieldNumber": 11
},
"sharingCoefficients": {
"type": "array",
"fieldNumber": 12,
"items":{
"type": "object",
"required": ["tokenID", "coefficient"],
"properties": {
"tokenID": {
"dataType": "bytes",
"length": TOKEN_ID_LENGTH,
"fieldNumber": 1
},
"coefficient":{
"dataType": "bytes",
"maxLength": MAX_NUM_BYTES_Q96,
"fieldNumber": 2
}
}
}
}
}
}
},
"stakers": {
"type": "array",
"fieldNumber": 2,
"items": {
"type": "object",
"required": ["address", "stakes", "pendingUnlocks"],
"properties": {
"address": {
"dataType": "bytes",
"fieldNumber": 1
},
"stakes": {
"type": "array",
"fieldNumber": 2,
"items": {
"type": "object",
"required": ["validatorAddress", "amount", "sharingCoefficients"],
"properties": {
"validatorAddress": {
"dataType": "bytes",
"length": ADDRESS_LENGTH,
"fieldNumber": 1
},
"amount": {
"dataType": "uint64",
"fieldNumber": 2
},
"sharingCoefficients": {
"type": "array",
"fieldNumber": 3,
"items":{
"type": "object",
"required": ["tokenID", "coefficient"],
"properties": {
"tokenID": {
"dataType": "bytes",
"length": TOKEN_ID_LENGTH,
"fieldNumber": 1
},
"coefficient":{
"dataType": "bytes",
"maxLength": MAX_NUM_BYTES_Q96,
"fieldNumber": 2
}
}
}
}
}
}
},
"pendingUnlocks": {
"type": "array",
"fieldNumber": 3,
"items": {
"type": "object",
"required": [
"validatorAddress",
"amount",
"unstakeHeight"
],
"properties": {
"validatorAddress": {
"dataType": "bytes",
"length": ADDRESS_LENGTH,
"fieldNumber": 1
},
"amount": {
"dataType": "uint64",
"fieldNumber": 2
},
"unstakeHeight": {
"dataType": "uint32",
"fieldNumber": 3
}
}
}
}
}
}
},
"genesisData": {
"type": "object",
"fieldNumber": 3,
"required": ["initRounds", "initValidators"],
"properties": {
"initRounds": {
"dataType": "uint32",
"fieldNumber": 1
},
"initValidators": {
"type": "array",
"fieldNumber": 2,
"items": {
"dataType": "bytes",
"length": ADDRESS_LENGTH
}
}
}
}
}
}
During the genesis state initialization stage, the following steps are executed. If any step fails, the block is discarded and has no further effect.
Let genesisBlockAssetBytes
be the data
bytes included in the block assets for the PoS module and let genesisBlockAssetObject
be the deserialization of genesisBlockAssetBytes
according to the genesisPoSStoreSchema
schema, given above.
-
Initial checks on the properties of
genesisBlockAssetObject
:genesisBlockAssetObject
should satisfy thegenesisPoSStoreSchema
schema.- Across elements of the
validators
array, alladdress
values must be unique, allname
values must also be unique. - For all elements of the
validators
array,name
values must satisfyisValidatorNameValid(name) == True
. - For all elements of the
validators
array,commission
values must satisfycommission <= 10000
. - For all elements of the
validators
array,lastCommissionIncreaseHeight
values must satisfylastCommissionIncreaseHeight <=
block header height of the genesis block. - For all elements of the
validators
array, thesharingCoefficients
array must be ordered in lexicographic order oftokenID
. - Across elements of the
stakers
array, alladdress
values must be unique. - For all elements of the
stakers
array:- Either
stakes != []
orpendingUnlocks != []
. - All
amounts
properties in elements of thestakes
or thependingUnlocks
arrays must be non-zero. - Across elements of the
stakes
array, allvalidatorAddress
values must be unique. - For each element
sentStake
in thestakes
array, there is an elementvalidator
in thevalidators
array withvalidator.address == sentStake.validatorAddress
. Moreover, for eachitem
insentStake.sharingCoefficients
array, there must be an entryelem
in thevalidator.sharingCoefficients
array such thatitem.tokenID == elem.tokenID
anditem.coefficient <= elem.coefficient
. - For each element
sentStake
in thestakes
array, thesharingCoefficients
must be ordered in lexicographic order oftokenID
. - For each element
sentStake
in thestakes
array, for each item insharingCoefficients
array must be ordered in lexicographic order oftokenID
. stakes
has size is at mostMAX_NUMBER_STAKING_SLOTS
.stakes
must be in lexicographic order ofvalidatorAddress
.pendingUnlocks
has size is at mostMAX_NUMBER_PENDING_UNLOCKS
.pendingUnlocks
must be ordered by lexicographical order ofvalidatorAddress
, ties then broken by increasingamount
, ties finally broken by increasingunstakeHeight
.- For each element
pendingUnlock
in thependingUnlocks
array, there is an elementvalidator
in thevalidators
array withvalidator.address == pendingUnlock.validatorAddress
. - For each element
pendingUnlock
in thependingUnlocks
array,unstakeHeight
value must satisfyunstakeHeight <
block header height of the genesis block.
- Either
- All values of the
genesisData.initValidators
array must be unique and must be equal tovalidator.address
for avalidator
element ofvalidators
. - The
genesisData.initValidators
array must have length equal toNUMBER_ACTIVE_VALIDATORS
and be in lexicographical order. genesisData.initRounds
must be at leastMIN_INIT_ROUNDS
.
-
For each entry
validator
ingenesisBlockAssetObject.validators
, create an entry in the validator substore with:totalStake = 0 for staker in genesisBlockAssetObject.stakers: for sentStake in staker.stakes: if sentStake.validatorAddress == validator.address: totalStake += sentStake.amount if staker.address == validator.address: selfStake = sentStake.amount validatorState = { "name": validator.name, "totalStake": totalStake, "selfStake": selfStake, "lastGeneratedHeight": validator.lastGeneratedHeight, "isBanned": validator.isBanned, "reportMisbehaviorHeights": validator.reportMisbehaviorHeights, "consecutiveMissedBlocks": validator.consecutiveMissedBlocks, "commission" : validator.commission, "lastCommissionIncreaseHeight": validator.lastCommissionIncreaseHeight, "sharingCoefficients": validator.sharingCoefficients } storeKey = validator.address storeValue = encode(validatorStoreSchema, validatorState)
Further, for every entry
validator
ingenesisBlockAssetObject.validators
, also create an entry in the name substore with:validatorState = { "validatorAddress": validator.address } storeKey = validator.name utf8-encoded storeValue = encode(nameStoreSchema, validatorState)
-
For each entry
staker
ingenesisBlockAssetObject.stakers
, create an entry in the staker substore with:stakerState = { "stakes": staker.stakes, "pendingUnlocks": staker.pendingUnlocks } storeKey = staker.address storeValue = encode(stakerStoreSchema, stakerState)
-
Create an entry in the genesis data substore with:
genesisState = { "height": block header height of the genesis block, "initRounds": genesisBlockAssetObject.genesisData.initRounds, "initValidators": genesisBlockAssetObject.genesisData.initValidators } storeKey = EMPTY_BYTES storeValue = encode(genesisDataStoreSchema, genesisState)
-
Create an entry in the previous timestamp substore with:
timestampState = { "timestamp": block header height of the genesis block } storeKey = EMPTY_BYTES storeValue = encode(previousTimestampStoreSchema, timestampState)
To finalize the state of the genesis block the following logic is executed. If any step fails, the block is discarded and has no further effect.
As in the previous point, let genesisBlockAssetBytes
be the data
bytes included in the block assets for the PoS module and let genesisBlockAssetObject
be the deserialization of genesisBlockAssetBytes
according to the genesisPoSStoreSchema
schema, given above. Moreover, let b
be the genesis block. Then:
# Register all validators in the Validators module.
# For the snapshot block for the migration from Lisk Core 3 to Lisk Core 4 on
# Lisk mainnet, apply a special rule. This must be done as validators in
# this snapshot block do not have a BLS key.
if the chain is the mainchain and INVALID_BLS_KEYS_IN_GENESIS_BLOCK == True:
for validator in genesisBlockAssetObject.validators:
Validators.registerValidatorWithoutBLSKey(
validator.address,
validator.generatorKey
)
else:
# For any other genesis block, register validators with a BLS key.
for validator in genesisBlockAssetObject.validators:
Validators.registerValidatorKeys(
validator.address,
validator.proofOfPossession,
validator.generatorKey,
validator.blsKey
)
updateValidatorEligibility(validator.address, 0)
# Check that all stakes and pendingUnlocks correspond to locked tokens.
for address a key of the staker substore:
if there exists an entry in validator substore with key == address:
if Token.getLockedAmount(address, MODULE_NAME_POS, TOKEN_ID_POS) < getLockedStakedAmount(address):
raise Exception('Locked amount is incompatible with specified stakes.')
else:
# For non-validators, the only reason for locking is staking, so the values should match precisely.
if Token.getLockedAmount(address, MODULE_NAME_POS, TOKEN_ID_POS) != getLockedStakedAmount(address):
raise Exception('Locked values do not match.')
# Set the initial validators.
# Recall that initValidator is always in lexicographical order.
validatorList = [
{"address": address, "bftWeight": 1}
for address in genesisBlockAssetObject.genesisData.initValidators
]
# Compute the thresholds for the BFT consensus protocol.
precommitThreshold, certificateThreshold = computeThresholds(validatorList)
Validators.setValidatorParams(precommitThreshold, certificateThreshold, validatorList)
The following steps are executed as part of the (non-genesis) block processing, see LIP 0055 for details.
After the transactions in a block b
are executed, the properties related to missed blocks are updated according to Validator Productivity. This logic is recapitulated below:
def afterTransactionsExecute(b: Block) -> None:
height = b.header.height
updateProductivity(b.header)
# if not last block of round no further updates are needed.
if isEndOfRound(height) == False:
return
# Block b is an end-of-round block. Need to update snapshot and select validators for next round.
# This must be done after the properties related to missed blocks are updated.
updateSnapshotSubstore(height)
nextRound = getRoundNumberFromHeight(height+1)
# No updates in validators and BFT weights are performed during bootstrap period.
if nextRound <= genesisDataStore.initRounds:
return
# Validator selection for next round.
# Calculate the active validators for next round based on the snapshot taken two rounds ago.
validatorList = getActiveValidators(nextRound)
# Select standby validators if bootstrap and hybrid periods have passed.
if nextRound >= initRounds + NUMBER_ACTIVE_VALIDATORS:
standbyValidators = [validator for validator in snapshotStore(nextRound)
if validator["address"] not in validatorList]
selectedStandbyValidators = getSelectedStandbyValidators(standbyValidators, height)
# Add selected standby validators to validators.
validatorList += selectedStandbyValidators
# If there are no eligible validators, validatorList is empty.
# In this case, the BFT parameters and validator list are not updated.
if validatorList is empty:
return
shuffleAndSetValidators(validatorList, height)
We now define the functions updateProductivity
, updateSnapshotSubstore
and shuffleAndSetValidators
.
def updateProductivity(b.header: BlockHeader) -> None:
height = b.header.height
# previousTimestamp is the value in the previous timestamp substore.
missedBlocks = Validators.getGeneratorsBetweenTimestamps(previousTimestamp, b.header.timestamp)
for address in missedBlocks:
validatorStore(address).consecutiveMissedBlocks += missedBlocks[address]
# The below rule was introduced in LIP 0023.
if (validatorStore(address).consecutiveMissedBlocks > FAIL_SAFE_MISSED_BLOCKS
and height - validatorStore(address).lastGeneratedHeight > FAIL_SAFE_INACTIVE_WINDOW):
validatorStore(address).isBanned = True
updateValidatorEligibility(address,getValidatorWeight(address))
validatorStore(b.header.generatorAddress).consecutiveMissedBlocks = 0
validatorStore(b.header.generatorAddress).lastGeneratedHeight = height
# Update previousTimestamp substore.
previousTimestamp = b.header.timestamp
def updateSnapshotSubstore(height: uint32) -> None:
nextRound = getRoundNumberFromHeight(height+1)
snapshotRound = nextRound + 2
# no need to take a snapshot for the first initRounds
if snapshotRound <= genesisDataStore.initRounds:
return
# Punished validators are excluded from the snapshot.
eligibleValidators = [
{"address": key[-ADDRESS_LENGTH:], "weight": int.from_bytes(key[:-ADDRESS_LENGTH], 'big')}
for key a substore key of the eligible validators substore if
height >= eligibleValidatorsStore(key) + PUNISHMENT_WINDOW_SELF_STAKING or
eligibleValidatorsStore(key) == 0
] ordered by weight, ties broken by reverse lexicographical ordering of address
# Notice that the keys in the substore naturally have the right ordering
# when being read from the end to the beginning of the store.
snapshotState = {
"validatorWeightSnapshot": eligibleValidators
}
create an entry in the snapshot substore with
storeKey = snapshotRound.to_bytes(4,'big'),
storeValue = encode(snapshotStoreSchema, snapshotState)
delete any entries from the snapshot substore snapshotStore(x) for x < nextRound
def shuffleAndSetValidators(validatorList: ValidatorObject, height: uint32) -> None:
# Compute the thresholds for the BFT consensus protocol.
precommitThreshold, certificateThreshold = computeThresholds(validatorList)
# Random seed to shuffle validators.
randomSeed = random.getRandomBytes(
height +1 - (ROUND_LENGTH*3)//2,
ROUND_LENGTH
)
shuffledValidatorList = shuffleValidatorsList(validatorList, randomSeed)
Validators.setValidatorParams(precommitThreshold, certificateThreshold, shuffledValidatorList)
This LIP defines a new store interface for the PoS module, which in turn will become part of the state tree and will be authenticated by the state root. As such, it will induce a hardfork.