Contract implementation - envoynetwork/staking-contract-v2 Wiki

Contract implementation

Contract state

The contract is implemented to keep track of two states in 2 mappings of structs:

State for each stakeholder

The state of each staker, mapping the stakeholder's address to a struct containing:

State of the period

The state of each interval for which rewards are divided, mapping the sequential number of the reward period to a struct containing:

The key is how many reward periods have passed during the life time of the contract. The current period is calculated based on two state variables (the start time of the contract and the duration of a reward period set in the constructor) and the current block time. The formula is the difference of the start time and the current time, divided by the duration of a period. The formula is triggered by the currentPeriod function. When a period ends, a struct for the next period will be initialized in the mapping, based on the previous period and all changes to be applied. This happens in the handleNewPeriod function. The function can be triggered manually, but is also triggered when users interact with the contract to update the state (for example when .staking, updating weight, claiming or initializing a withdraw). Not all periods need to be handled in one transaction (to avoid having transactions that revert due to gas costs), so handleNewPeriod takes an integer as argument which tells the function until which reward period updates need to be triggered. The latest reward period that is handled already is saved in a global variable.

Global contract variables

These 2 states are the most important other global variables are:

Handeling time

Time is expressed in reward periods, for which the duration is set in the constructor and cannot be changed afterwards. The current period is how many full reward periods have passed since the deployment of the contract. This value can be fetched with the currentPeriodfunction. There is a hard limit on how any reward periods will be rewarded, defined by the maxNumberOfPeriods state variable. Once this is reached, the current period will stay at this value. The value can be increased by the owner if the lifetime of the contract is expanded.

When the reward period is over, users can claim the rewards they earned. As compounding is used, the rewards earned here need to be taken into account for the periods to come. To avoid forcing users to manually claim each period, the state of each period is kept. When a stakeholder has the history of total balances and rewards per period in the past, a staker can calculate his earned rewards on any number of past periods.

The function used to to this, handleNewPeriod, takes one integer endPeriod as an argument. It will handle all new periods in between the last period for which the state was saved and endPeriod. End period cannot exceed the current period. Functions that update the state (e.g. stake, claimRewards, increaseWeight, requestWithdraw,...) first trigger this function with currentPeriod() as argument to update all passed periods. After the past states are saved, the current state is updated by the changes applied by the functions.

For each period handled, the reward per period, extra reward multiplier and maximum weight are copied. Initially, they are the same. When one of these values is updated, only the last period is updated so we keep the history of the earlier state.

The total staking balance is also updated for each weight. The newly added stake of previous period (for which no rewards were rewarded) is added to the staking balance (because from this period and onwards it will be included in the reward calculation). The total amount of rewards is past period is also added to the staking balance. The total staking balance will increase with the total reward distributed in the previous period. For each period i and weight w, the formula becomes:

totalStake(i, w) += w * totalStake(i-1, w) * (rewardPerPeriod(i-1) / totalWeightedStakingBalance(i-1))

The step above only happens when the total total weighted staking balance for the latest period is not equal to 0. In the edge-case situation that this value equals 0, there were no stakers and no rewards were distributed. Hence, the total stake for this period should not increase.

If the release date for the locked tokens is not yet reached, the total locked reward for each weight is also updated. The reward from the formula above is multiplied by the multiplier from previous period and added to the totalLockedRewards mapping. At the moment the release date is reached, it is added to the staking balance for this period and totalLockedRewards is set to 0.

Checking active users

Checking if a user is active can be done with the activeStakeholder function, which takes the user as an argument. A user is active if he has one of following things:

We keep track of active users in the weightCounts mapping. This mapping stores a distribution of the weights. When a user becomes active, the count for his weight increases. When he withdraws all funds, he gets deactivated and the weight count decreases. To get a count of all users, loop over this mapping from 0 to the maxWeight property of the last user.

Checking if a user is active happens when:

Calculating the total stake

Calculating the total stake for a period is necessary to calculate the rewards. For reward calculation, we have to keep track of the total weighted stake for a period. This equals to the sum of the balance of each weight, multiplied by the weight. In order to make this calculation, stake is stored:

The totalStakingBalance function handles this calculation. The arguments expected are:

and it returns the total (weighted) stake for a period. When called with latestRewardPeriod as argument, you should get the current state (here, it is assumed handleNewPeriod was triggered and the contract state is up to date, otherwise trigger it first).

The total locked rewards are also kept in a mapping for each weight, but not for each period. It increases, until the unlock date is reached. Then it is added to the stake and reset to 0. To get the total locked reward for each weight, loop over the totalLockedRewards mapping from 0 to maxWeight of the last period in the rewardPeriods mapping.

Staking new funds

Staking new funds can be done via the stake function, which only takes the amount to stake as argument. As it adjusts the state, previous rewards are handled first with the old state using claimRewards(currentPeriod(), false). Before triggering this function, the staking contract should also be approved to transfer the amount tokens from the staker to the staking address.

The function will transfer the tokens to the contract and update the newStake of the user. It will also increase the total new stake for the stakeholders' weight and this period with this amount.

If a staker was not an active staker yet, he is initialized:

Updating stakeholder weights

There are 2 functions to update the weight for a specific user.

Update your own weight as stakeholder

The first one, increaseWeight is for the stakeholders themselves. To do so, they need a signature that verifies that they are allowed to update their weight. The signature is calculated off-chain based on a hash of:

and is signed by the private key of signatureAddress. Only the enitity behind the staking contract has this private key, the resulting address is stored in the contract. When updating weight, the signature the user provides will be checked; if it was not singed by signatureAddress, the action reverts. This ensures only allowed stakeholders update their weight.

Manage the weights as contract owner

The other function updateWeightsBatch is for the contract owner. It takes an array with addresses and an array with weights as input, but doesn't require the signature. For each value in the arrays (which should have equal length), the weight is updated. In the end, a possible decrease of maxWeight is checked. When no stakers have the highest weight anymore, the highest weight is updated to the next highest weight based on the weightCounts mapping.

The steps taken to update weight are the same in both functions:

In the end, the weight of the stakeholder is updated so he receives rewards based on the new weight in the future.

Rewards

Calculating rewards

The calculateRewards function calculates the rewards for a stakeholder. It is a view function, so it doesn't update the state and can be called without gas costs. The function takes the address of the stakeholder as input, and also the end period for the reward calculation. The formula for reward calculation is:

reward = rewardsPerPeriod * (userStakingBalance * userWeight) / totalWeightedStakingBalance

The function will loop over all period between start and end date for the calculation and apply this function. The start date for the calculation is always the last period the stakeholder claimed. The end date can be any date specified in the function arguments:

As this function is restricted to view, the handleNewPeriod function cannot be triggered before applying the calculation. The loop for reward calculation works like this:

When a user added new tokens to his staking balance in the previous period, it is skipped for the reward calculation. After rewarding the first period, the new stake is added to the total stake used for calculation.

The funcion is also used to calculate the locked tokens this user earned, by multiplying the reward for each period by the extraRewardsMultiplier of the same period. If the unlock date is not yet reached, the locked token balance of the user is increased. Once the unlock date is reached, the total locked balance is added to the staking balance and the locked token balance is reset to 0.

In the end, the function returns:

Claiming rewards

The internal handleRewards function is responsible for reward claiming. It can only be claimed by wrapper functions inside the contract itself. It first calls the handleNewPeriod to make sure the state for past periods is correct. Then it makes use of the calculateRewards function above to assign the rewards. The arguments are:

If the stakeholder is not active at the moment the function is called, the function returns. If there is no stake whatsoever, the function returns as well. It does not revert, because it is also called by other contract functions, after which calculations should continue.

If the function did not return yet, it calls the calculateRewards function. It takes the rewards, locked token reward and new stakeholder stake and writes them to the contract state. Afterwards, it updates the lastClaimed function of the user to the endDate function argument.

Handle rewards is called by 2 functions:

Withdrawing funds

Funds can leave the contract in 3 ways:

To first option is explained in the claiming rewards section. The other two are explained below.

Stakeholder withdrawals

To withdraw funds as a stakeholder, you have to first call the requestWithdrawal. This function takes the amount to withdraw, a boolean instant and a boolean claimFirst as function arguments (more on this later) and initiates a withdrawal. Afterwards, the withdrawFunds can be called to finalize the withdrawal. There are 3 options to do so:

If cooldown equals 0, all options are the same and instant is automatically set to true.

Requesting a withdrawal

Requesting a withdrawal normally updates the previous period and calculates the rewards for the user first. This can be skipped by setting the claimRewardsFirst option to false. Doing so will result in forfaiting the rewards that were not claimed yet. It should always be set to true, unless the contract is broken and further periods cannot be calculated anymore. This is a failsafe to always be able to withdraw the stakeholders funds, even when the period or reward calculation somehow breaks.

After claiming all rewards, the amount to withdraw is subtracked from the stakeholder balance in this order:

  1. The new stake that has not been staked for a period
  2. The staking balance of the owner that has been staked for more than a period

If he requested amount to withdraw is bigger than the sum of the balances above, it is reduced to the sum of the balances. A stakeholder can only withdraw what he owns. The total balance is subtrackted by this amount as well, as it also decreases.

If the stakeholder does not have any funds left anymore, he is deactivated by setting the startdate to 0 and decreasing the weightCounts mapping for the stakeholder's weight by 1. handleDecreasingMaxWeight if the stakeholder was the only stakeholder with the maximum weight.

The releaseDate for the owner is set to the current blocktime increased by the cooldown period. It is used in the calculation for the early withdraw fee as described in the previous section. The releaseAmount for the stakeholder is set to the capped amount to withdraw.

If instant is st to true, the withdrawFunds function is called at the end of the function. Otherwise, the tokens remain in the contract, but will not generate rewards anymore.

Finalizing withdrawal

If requestWithrawal is called with instant set to false, the withdrawFunds function needs to be triggered manually to extract the tokens from the contract. If the request function was not called first, the release date and release amount of the stakeholder equal 0 and this function reverts.

Afterwards, the function calculates the fee to be paid for early withdrawal as described two sections back. The result might be 0, depending on the timing of the function call, the value of the fee and the cooldown period.

Afterwards, the tokens are transferred from the contract:

When the tokens are transferred, the releaseDate and releaseAmount are reset for the stakeholder.

Withdrawing as contract owner

The owner can withdraw funds from the contract as well with withdrawFundsAsOwner. This function only takes the amount as argument. However, the owner can only withdraw funds that are:

This ensures the stakeholders do not need to trust the owner: the owner can never steal the funds of the stakeholders.

Events

The contract sends events when certain actions happen. The actions are:

ERC20 compatibility

The fields name, symbol and decimals are included to display your staking balance tools expecting the ERC20 interface, e.g. metamask. The function balanceOf is implemented as well and will return the sum of:

Other ERC20 functions are not included, as the contract is not real token and will not be treated as such.