Token owners delegate staked tokens to operators. Owners need a way to cease staking at their discretion, subject to the controls necessary for the intended functioning of the Keep network.
Undelegation can be initiated by either the operator, or the owner of the tokens delegated to the operator. After a defined waiting period is over, the owner can recover the previously delegated tokens.
Delegation and undelegation should be conceptually simple and easy to understand. Operator contracts should be able to determine an operator’s eligibility for work selection inexpensively and safely.
The staking contract records two time (blockheight) fields for each operator: the block the operator was created, and the block undelegating began.
Operators can be:
-
non-existent
-
not ready for work selection because they were created too recently
-
active and eligible for work selection
-
winding down and ineligible for work selection but finishing earlier work
-
finished undelegation so the owner can recover their tokens
Using the systemwide constant undelegation period, the operator’s status can be determined from the creation and undelegation blocks.
Operators are uniquely identified by their address and operator addresses cannot be reused, even after returning the tokens to the owner.
To reduce the impact of transaction reordering, both delegating and undelegating take effect on the next block after the block the transaction is processed in.
E.g. 50,000 (roughly 6 days)
To avoid certain attacks on work selection, recently created operators must wait for a specific period of time before being eligible for work selection. This waiting period must be greater than the highest permissible time between the making of a beacon entry request and the request being served. In the ideal case, multiple entries would be requested and generated within the initialization period.
If the initialization period is insufficiently long, the pseudorandom work selection process can be subverted by creating operators whose identifiers (addresses) are calculated to yield advantageous outputs in the selection function. This can let the adversary control the majority in the new signing group.
If the new group is in line to sign the next entry, the adversary could choose the group’s private key so that the following entry also gets signed by a group controlled by the same adversary. With sufficient calculation capability, this can be repeated n times at the cost of roughly O(kn) calculations where k equals the number of active groups divided by the number of active adversary-controlled groups. If another signing group is created within this time, it can be similarly controlled. This can eventually lead to the adversary controlling the entire network.
With the initialization period, the adversary has to create the operators in advance long before they become eligible for work selection. Thus the adversary has to be able to predict each entry generated during the initialization period. With an unreasonably powerful adversary that can arbitrarily frontrun 50% of all entries, generating n entries within the initialization period provides 2n security against this attack.
mapping(address => Operator) operators; struct Operator { uint128 stakedAmount; uint64 createdAt; uint64 undelegatedAt; address owner; address beneficiary; address authorizer; }
Each operator stores the addresses of its owner, beneficiary and authorizer, the amount of tokens delegated to the operator, the block it was created at, and the block it was undelegated at if applicable.
Ethereum produces a block roughly every 10 seconds,
or around 3 million blocks a year (~222).
Thus, uint64
should be more than sufficient for blockheights.
With 18 decimals (260) and 1 billion tokens in circulation (230),
any applicable amount of KEEP tokens can be stored safely in a uint128
.
The staked amount and creation/undelegation blocks
can thus be packed in a single storage field.
This makes it slightly cheaper for operator contracts
to determine the operator’s eligibility for work selection.
The exact types are a recommendation, and the implementation is free to use larger unsigned integers if it yields favorable performance outcomes.
enum Status { NonExistent, NotReady, Active, WindingDown, Finished } operatorStatus(address operator) -> Status
An operator’s status determines what actions are available for the operator and the owner the delegated tokens.
The operator has been created in the same block the query was performed in. The operator is ineligible for work selection.
An operator is NotReady
if the current block is equal or less than
the creation block plus the initialization period.
block.number =< operator.createdAt + initializationPeriod
The owner has delegated staked tokens to the operator, and the operator is eligible for work selection.
An operator is Active
if the current block is greater than
the creation block plus initialization period,
and the undelegation block is either 0 or equal or greater than the current block.
block.number > operator.createdAt + initializationPeriod && (block.number =< operator.undelegatedAt || operator.undelegatedAt == 0)
The operator has been undelegated and is not eligible for work selection, and the operator is finishing any work they were selected for earlier. The operator’s backing tokens continue to be locked as collateral.
An operator is WindingDown
if the current block is greater than the undelegation block,
but at most the undelegation block plus the undelegation period.
operator.undelegatedAt < block.number =< (operator.undelegatedAt + undelegationPeriod)
eligibleStake(address operator, uint block) → uint
Operators are eligible for work selection based on their status in the block the work selection started in. In some situations an operator’s status may have changed after work selection started, but before the operator contract queries it. For these cases the staking contract must provide a way to determine the operator’s eligibility for work selection that started in an earlier block.
It is the responsibility of each operator contract to query operator eligibility with the correct block number. Failure to use the correct block leads to minor manipulation opportunities. For example, querying an operator’s eligibility on the current block when they submit a ticket means that an ineligible operator whose initialization period is almost over could wait to submit their ticket until they become eligible for work selection.
To make determining an operator’s eligibility for work selection
simpler and cheaper,
the staking contract must provide the eligibleStake()
function
which returns the number of KEEP tokens available for use as collateral.
When calling eligibleStake()
,
the staking contract assumes msg.sender
is an operator contract.
eligibleStake()
does not return meaningful results
when called by an address that doesn’t correspond to an operator contract.
If the operator
is ineligible for work selection on msg.sender
,
eligibleStake()
returns 0
.
Otherwise eligibleStake()
returns operator.stakedAmount
.
operatorExists = operators[operator] != nil senderAuthorized = authorized[operator.authorizer][msg.sender] == True operatorReady = block > operator.createdAt + initializationPeriod notUndelegated = block =< operator.undelegatedAt || operator.undelegatedAt == 0 if operatorExists && senderAuthorized && operatorReady && notUndelegated: return operator.stakedAmount else: return 0
stake(uint amount, address operator, address beneficiary, address authorizer)
Staking tokens delegates them to the operator,
who can then use them as collateral for performing work.
Staking is performed by the owner of the tokens,
who must have authorized the staking contract
to transfer amount
KEEP to itself
(e.g. via approveAndCall()
).
token.allowance(msg.sender, stakingContract) >= amount
The nominated operator must not already exist.
operators[operator] == nil
The staking contract transfers amount
KEEP from msg.sender
to itself,
and creates a stake delegation relationship,
with the operator becoming Active
in the next block.
operators[operator] = Operator { stakedAmount = amount; createdAt = block.number; undelegatedAt = 0; owner = msg.sender; beneficiary = beneficiary; authorizer = authorizer; }
cancelStake(address operator)
The owner can cancel staking within the operator initialization period without being subjected to the token lockup for the undelegation period. This can be used to undo mistaken delegation to the wrong operator address.
msg.sender == operator.owner
block.number =< operator.createdAt + initializationPeriod
If staking is cancelled, the staked tokens are immediately returned to the owner, and the undelegation time is set to the present.
operator.stakedAmount = 0
operator.undelegatedAt = block.number
undelegate(address operator)
Undelegating sets the operator to WindingDown
status
so that the backing tokens can later be recovered by the owner.
Undelegating can be performed by either the owner or the operator.
msg.sender == (operator || operator.owner)
Undelegating can only be performed on a currently active operator.
operatorStatus(operator) == Active
The staking contract sets the undelegation block of the operator to equal the current block, making the operator ineligible for any work selection in the future. Work selection performed earlier in the same block shall proceed as normal.
operator.undelegatedAt = block.number
recoverStake(address operator) → uint
Recovering staked tokens transfers them back to the owner. Recovering tokens can only be performed by the owner, when the operator is finished undelegating.
msg.sender == operator.owner
operatorStatus(operator) == Finished
The staking contract sets the staked amount of the operator to zero, and transfers the previously delegated tokens (or however much was remaining) back to the owner.
operator.stakedAmount = 0
The staking contract may additionally clean up the owner, beneficiary and authorizer addresses for the gas refund. However, the staking contract must not delete the creation and undelegation times, as this would enable reuse of the same operator address.
The definition of Active
operators
permits setting undelegatedAt
to an arbitrary date in the future.
This can be used to e.g. delegate stake to an operator in a time-limited way.
There is no obvious reason why undelegation couldn’t be cancelled by the owner.
The authorization queries by eligibleStake()
can be cached to save some gas.