Skip to content

Latest commit

 

History

History
383 lines (275 loc) · 12.2 KB

rfc-17-undelegation.adoc

File metadata and controls

383 lines (275 loc) · 12.2 KB

RFC 17: Stake delegation and undelegation

1. Background

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.

2. Proposal

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.

2.1. Goal

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.

2.2. Implementation

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.

2.2.1. Parameters

Operator initialization period

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.

Undelegation period

E.g. 800,000 (roughly 3 months)

The staking contract guarantees that an undelegated operator’s stakes will stay locked for a number of blocks after undelegation, and thus available as collateral for any work the operator is engaged in.

2.2.2. Stored information

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.

2.2.3. Operator status

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.

Non-existent

The operator doesn’t exist.

operators[operator] == nil

Not ready

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

Active

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)

Winding down

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)

Finished

Undelegating the operator has finished. The backing tokens are unlocked and can be returned to the owner.

An operator is Finished if the current block is greater than the undelegation block plus the undelegation period.

block.number > operator.undelegatedAt + undelegationPeriod

2.2.4. Work selection eligibility

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

2.2.5. Actions

Staking

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;
}
Cancelling staking

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

Undelegating

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

Recovering tokens

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.

2.3. Limitations

The amount of tokens delegated to an operator cannot be changed afterwards.

3. Future Work

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.

4. Open Questions

The operator initialization period provides an appreciable level of security against work selection manipulation. Whether other mitigations are worth implementing has not been thoroughly examined.