Skip to content
This repository has been archived by the owner on Jul 9, 2021. It is now read-only.

New staking mechanics #2118

Merged
merged 14 commits into from
Sep 6, 2019
Merged

Conversation

hysz
Copy link
Contributor

@hysz hysz commented Aug 31, 2019

Description

This PR introduces a new mechanism for managing stake and tracking rewards. It is more flexible than the original implementation, allowing for a better user experience.

Note - Skip the Typescript in this review. There is a separate PR (#2126) that updates client-side code and adds tests. It will be merged into this PR before merging into 3.0.

Design Principles

  1. Freshly minted stake is active.
  2. Delegating, un-delegating and re-delegating stake comes into effect next epoch.
  3. Users can freely adjust the distribution of their stake for the next epoch.
  4. Stake can be withdrawn after it is inactive for one epoch.

Stake State Algorithm

There are three states that stake can exist in: Active, Inactive or Delegated. Each state has three fields:

  1. How much stake is currently in this state (cur)
  2. How much stake is in this state next epoch (next)
  3. The last time this state was stored
    1. This allows us to compute the correct values at any given epoch without user intervention

Inactive stake includes a Withdrawable field (W) that reflects how much stake can be withdrawn at any given time.

Example

StakingWithWithdrawals (3) (1)

Reward Tracking Mechanics

Screen Shot 2019-08-30 at 8 16 33 PM

Reward Vault vs Eth Vault

The reward vault tracks the balance of each pool: how much belongs to the operator and how much is to be split between the delegators. ETH is deposited into this vault when an epoch is finalized.

The ETH Vault stores balances on a per-address basis. Value is moved from the reward vault to the ETH vault when a delegator changes their stake in a given pool.

Testing instructions

Checklist:

  • Prefix PR title with [WIP] if necessary.
  • Add tests to cover changes as needed.
  • Update documentation as needed.
  • Add new entries to the relevant CHANGELOG.jsons.

@hysz hysz requested a review from abandeali1 as a code owner August 31, 2019 02:25
@hysz hysz changed the title New staking mechanics: delay on entry opposed to exit New staking mechanics Aug 31, 2019
@hysz hysz force-pushed the feature/staking/NewMechanicsSolidityOnly-Squashed branch 2 times, most recently from bdd3d14 to d355356 Compare August 31, 2019 20:19
Copy link
Contributor

@jalextowle jalextowle left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic seems solid for the most part. Really nice work on the redesigned logic, and all things considered, you got this out quickly!

I had some nits and comments, which I think should be addressed before merging this PR.

uint256 totalStakeDelegatedToPool = getTotalStakeDelegatedToPool(poolId);
uint256 stakeHeldByPoolOperator = getActivatedAndUndelegatedStake(getStakingPoolOperator(poolId));
uint256 totalStakeDelegatedToPool = getTotalStakeDelegatedToPool(poolId).current;
uint256 stakeHeldByPoolOperator = getActiveStake(getStakingPoolOperator(poolId)).current; // @TODO Update
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What needs to be updated here? Can this "TODO" be removed?

@@ -218,9 +223,20 @@ contract MixinExchangeFees is
);

// record reward in vault
_recordDepositInStakingPoolRewardVault(activePools[i].poolId, reward);
bool rewardForOperatorOnly = activePools[i].delegatedStake == 0;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this used anywhere else? I like the fact that we're naming this boolean, but I think a comment in the call to recordDepositFor would be sufficient and it would save us from needing to use another stack slot. Alternatively, I think it could make sense to start making use of Solidity's block scoping features to make sure that the stack stays clean.

Ex.

uint256 poolPortion;
{
    bool rewardForOperatorOnly = activePools[i].delegatedStake == 0; // This will get dropped after being used.
    (, poolPortion) = rewardVault.recordDepositFor(activePools[i].poolId, reward, rewardForOperatorOnly);
}

The reason I call this out is that I think that reducing the number of stack variables in the top of a function's scope can be a boon to code maintenance since it reduces the likelihood that adding a stack variable will push the function over the stack limit.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I actually thought that Solidity would stop managing stack variables once they were finished being used without needing an explicit scope ~ just verified this is not the case. I'm cool with inlining.

@@ -42,4 +42,6 @@ contract MixinConstants is
uint64 constant internal INITIAL_EPOCH = 0;

uint64 constant internal INITIAL_TIMELOCK_PERIOD = INITIAL_EPOCH;

uint256 constant internal MIN_TOKEN_VALUE = 1000000000000000000; // 10**18
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It turns out that 10 ** 18 will not be inlined by the Solidity compiler. I've verified this by debugging a transaction through the Remix debugger that uses this constant. With this in mind:

Nit: Use 10 ** 18 for readability.


// registered 0x Exchange contracts
mapping (address => bool) internal validExchanges;

// ZRX vault
IZrxVault internal zrxVault;

// Rebate Vault
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Can you update this comment? It isn't immediately clear to me what the difference between this "Rebate Vault" and the below "Rebate Vault", aside from the fact that I assume that this vault stores ETH.

@@ -0,0 +1,71 @@
/*

Copyright 2018 ZeroEx Intl.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Update to 2019 :)

)
internal
private
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand why this is being made private, but it may be wise to leave this as internal for the purposes of unit testing. Thoughts?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think you're right. For unit testing these will have to be made internal =\

internal
{
IZrxVault _zrxVault = zrxVault;
require(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason not to use Rich Errors in this contract?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function doesn't do much other than call _zrxVault.depositFrom(owner, amount). I'd be in favor of removing it completely.

@@ -0,0 +1,196 @@
/*

Copyright 2018 ZeroEx Intl.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit:

Suggested change
Copyright 2018 ZeroEx Intl.
Copyright 2019 ZeroEx Intl.

/// @param balancePtrA first storage pointer.
/// @param balancePtrB second storage pointer.
/// @return true iff pointers are equal.
function _arePointersEqual(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

IStructs.DelayedBalance storage balancePtr,
IStructs.DelayedBalance memory balance
)
private
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Same as above re testing

Copy link
Contributor Author

@hysz hysz Sep 4, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(see above)

@hysz hysz mentioned this pull request Sep 3, 2019
4 tasks
Copy link
Member

@abandeali1 abandeali1 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm probably about 1/4 - 1/3 through this review but won't be able to get to the rest tonight. Will add the rest tomorrow morning!

@@ -218,9 +223,20 @@ contract MixinExchangeFees is
);

// record reward in vault
_recordDepositInStakingPoolRewardVault(activePools[i].poolId, reward);
bool rewardForOperatorOnly = activePools[i].delegatedStake == 0;
(, uint256 poolPortion) = rewardVault.recordDepositFor(activePools[i].poolId, reward, rewardForOperatorOnly);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a pretty long line. We can break it down into multiple lines, which would also make it easier to inline the rewardForOperatorOnly calculation with a comment to the right.

// fees collected this epoch
mapping (bytes32 => uint256) internal protocolFeesThisEpochByPool;

// pools that were active in the current epoch
bytes32[] internal activePoolsThisEpoch;

// mapping from POol Id to Shadow Rewards
mapping (bytes32 => uint256) internal shadowRewardsByPoolId;
// reward ratios by epoch
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: spell out mapping from Pool Id to Epoch to Reward Ratio



/// @dev This mixin contains logic for managing ZRX tokens and Stake.
/// Stake is minted when ZRX is deposited and burned when ZRX is withdrawn.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The diagrams will live in the spec ideally.

uint256 currentWithdrawableStake = getWithdrawableStake(owner);
require(
amount <= currentWithdrawableStake,
"INSUFFICIENT_FUNDS"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rich revert?

@@ -25,6 +25,9 @@ import "../src/interfaces/IStructs.sol";


contract TestStorageLayout is
MixinDeploymentConstants,
Ownable,
MixinConstants,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are already inherited by MixinStorage (I suspect you are using a script to generate these).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, to remove any ambiguities during linearization it will print out the full ancestral tree. Ugly, but it works. I'll create a task to remove frivolous imports for readability to see if we can make this a bit prettier. In the meantime I'll cleanup the ones in this PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW this is an example demonstrating why explicitly inheriting the ancestral tree works:

Ex:
contract A {}
contract B {}
contract C {}
contract D is A, B {}

Suppose now that there is a contract E that will inherit from B{} and D{}
The following follows "most base-like" to "most-derived":
contract E is B, D {}
However, it unfolds to: B{}, A{}, B{}, D{}, E{} - which is wrong because B{} appears both before and after A{}.
The correct contract inheritance for E{} should be:
contract E is A{}, B{}, D{}

So, even though contract E{} does not directly depend on A{} - it still must be included in the dependency list.

uint256 newMembersBalance = uint256(balance.membersBalance).safeAdd(poolPortion);

// save new balances
balance.operatorBalance = LibSafeDowncast.downcastToUint96(newOperatorBalance);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can use newOperatorBalance.downcastToUint96 here

using LibSafeMath for uint256;

// mapping from Owner to ETH balance
mapping (address => uint256) internal balances;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: _balances

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: we chatted offline. This will be done in a separate PR.


/// @dev This vault manages ETH.
contract EthVault is
Authorizable,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All of these are unnecessary exccept IEthVault and MixinVaultCore

/// Note that only the Staking contract can call this.
/// Note that this can only be called when *not* in Catostrophic Failure mode.
/// @param amount of ETH to withdraw.
function withdraw(uint256 amount)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A lot of these functions are exactly the same as in the ZrxVault. Maybe the repeat functions should live in MixinVaultCore?

@@ -36,6 +36,7 @@ import "../interfaces/IStakingEvents.sol";
contract MixinScheduler is
IStakingEvents,
MixinDeploymentConstants,
Ownable,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be removed.

@hysz hysz force-pushed the feature/staking/NewMechanicsSolidityOnly-Squashed branch from e6d4dbf to dbbcf66 Compare September 4, 2019 04:14
@hysz hysz requested a review from fabioberger as a code owner September 4, 2019 05:53
@hysz hysz force-pushed the feature/staking/NewMechanicsSolidityOnly-Squashed branch from 68c2ab6 to 20cabc1 Compare September 4, 2019 07:19
@@ -443,4 +455,35 @@ library LibStakingRichErrors {
poolId
);
}

function EthVaultNotSet()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All of these rich reverts should end with Error.

/// @dev Stake ZRX tokens. Tokens are deposited into the ZRX Vault. Unstake to retrieve the ZRX.
/// Stake is in the 'Active' state.
/// @param amount of ZRX to stake.
function stake(uint256 amount)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't we want newly minted stake to only become active at the next epoch as well? Otherwise one can stake before the very end of an epoch and dilute rewards for everyone else.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: we chatted about this offline, but basically this won't dilute rewards because the stake isn't delegated.

external
{
_mintStake(msg.sender, amount);
address payable owner = msg.sender;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This probably happens elsewhere too if I had to guess. We might want to do a search for owner usage throughout the staking contracts.

}

/// @dev States that stake can exist in.
enum StakeState {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally like Status > State here. I feel like State implies storage to some degree (also consistent with other enums).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm yeah I like that better too. Although a "status machine" doesn't quite have the same ring as a "state machine" 🌝

/// @dev Info used to describe a state.
/// @param state of the stake.
/// @param poolId Unique Id of pool. This is set when state=DELEGATED.
struct StakeStateInfo {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could probably just be StakeInfo

{
// sanity check on eth vault
IEthVault _ethVault = ethVault;
if (address(_ethVault) == address(0)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hoping we can remove this check too if _ethVault is set in the constructor.

@@ -6,6 +6,8 @@ import "./LibSafeMathRichErrors.sol";

library LibSafeMath {

using LibSafeMath for uint256;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is used anywhere.

@@ -2,7 +2,7 @@

/*

Copyright 2018 ZeroEx Intl.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we still need this file at all?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's removed by #2109

// mapping from Owner to Amount Staked
mapping (address => uint256) internal stakeByOwner;
// mapping from Owner to Amount of Active Stake
mapping (address => IStructs.DelayedBalance) internal activeStakeByOwner;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: all of these variables should be prefixed with _. Probably cleaner to do this in another PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, I've created a task for this.

// value is always zero in these two scenarios:
// 1. The current epoch is zero: delegation begins at epoch 1
// 2. The owner's delegated is current as of this epoch: their rewards have been moved to the ETH vault.
if (currentEpoch == 0 || delegatedStake.lastStored == currentEpoch) return 0;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These checks should probably be reversed, since we don't need currentEpoch == 0 to be checked after epoch 0.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very good eye! I agree!

@hysz hysz force-pushed the feature/staking/NewMechanicsSolidityOnly-Squashed branch 2 times, most recently from cd23a16 to b202a74 Compare September 5, 2019 13:37
packages/order-utils/src/staking_revert_errors.ts Outdated Show resolved Hide resolved
(, uint256 poolPortion) = rewardVault.recordDepositFor(
activePools[i].poolId,
reward,
activePools[i].delegatedStake == 0 // true -> reward is for operator only
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, this comment makes up for the variable IMHO 👍

MixinStorage,
MixinScheduler
{

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I don't think this line should be here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, yeah I think you're right. Seems to be 50/50 whether we have a newline here or not lol.

amount,
getActivatedStake(owner)
));
// cache the current withdrawal amoiunt, which may change if we're moving out of the inactive status.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// cache the current withdrawal amoiunt, which may change if we're moving out of the inactive status.
// cache the current withdrawal amount, which may change if we're moving out of the inactive status.

address payable owner,
uint256 amount
)
private
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A lot of these functions have been made private. We should make them internal for unit testing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: we chatted about this offline. These will be set to internal on as needed, unless a better alternative arises in the meantime.

IStructs.Fraction memory mostRecentCumulativeRewards = cumulativeRewardsByPoolPtr[cumulativeRewardsLastStored];

// compute new cumulative reward
(uint256 numerator, uint256 denominator) = LibFractions.addFractions(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make more sense for addFractions to return a fraction? It seems like we create a Fraction object a significant amount of the time after calling this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mm idk, since this is a general util library I'd prefer to not force a struct on future users. We actually only convert it to the Fraction struct in this fn for storage.

@hysz hysz force-pushed the feature/staking/NewMechanicsSolidityOnly-Squashed branch from b202a74 to da83f75 Compare September 5, 2019 19:12
…olidityOnly-Squashed-Tests-Squashed

Tests for new staking mechanics
@coveralls
Copy link

Coverage Status

Coverage remained the same at 75.804% when pulling fc7f2e7 on feature/staking/NewMechanicsSolidityOnly-Squashed into 88e5635 on 3.0.

Copy link
Member

@abandeali1 abandeali1 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, some awesome readability improvements in here!

Copy link
Contributor

@jalextowle jalextowle left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work bud! You definitely deserve a Chimay after this one 👍

Just a couple of comments, but they are not merge-blocking.

{
// update balance
uint256 amount = msg.value;
balances[owner] = balances[owner].safeAdd(msg.value);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might as well use amount here since msg.value was cached there, right? This said, CALLVALUE is typically actually cheaper than using a stack variable (it's a Gbase opcode), so I'd be in favor of just using msg.value for both uses and removing amount entirely, unless there is a readability concern.

// update balance
// note that this call will revert if trying to withdraw more
// than the current balance
balances[owner] = balances[owner].safeSub(amount);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice reentrancy protection 👍

@hysz hysz merged commit f477c0f into 3.0 Sep 6, 2019
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants