Consider pooled model of delegation
Background (The issue)
The chain triggers a subnet's emission calculation and step depending on its tempo. This means every few blocks we run a calculation to determine the correct emission for every subnet based on the current StakeMap. The calculation of emissions has been taking a long time, slowing block times.
This calculation appears to be slowed by the large number of entries in the StakeMap. To remedy this, two suggestions were made:
- https://github.com/opentensor/subtensor/pull/361 - Set a minimum stake amount for each nomination entry
- https://github.com/opentensor/subtensor/pull/367 - Limit the number of nominators for each entry
The accepted solution (should be live on the chain Apr 29th) is (1). This means we greatly reduce the number of delegator entries in the StakeMap at the cost of limiting participation to TAO stake holders with more than the limit of k TAO (dynamic value).
The issue
The issue with this, as mentioned, is this change reduces the participation of holders to only those that hold over a certain threshold of TAO.
What is a good solution (Acceptance Criteria)
A good solution would mean we still reduce the runtime of the calculation during each subnet tempo step, while retaining as much participation among as many stake-holders as possible.
Existing Solutions
Polkadot implements an approach called Nomination Pools[^1] which addresses a similar issue by pooling stakers into one account, reducing the size of their stake entries while maximizing participation of individual (read: small) holders.
[^1]: https://github.com/paritytech/polkadot-sdk/blob/master/substrate/frame/nomination-pools/src/lib.rs For the polkadot/FRAME impl
Regarding their implementation, some elements of their design are unclear, i.e., I am not sure why they chose to:
- separate rewards from stake
- rebalance shares (aka "points") on each join/leave
Though I think this may be because of Polkadot's stake bonding time requirements (Bittensor lacks this lock period). I imagine they decided to rebalance on every change to the pool in order to maintain the 1:1 (points to funds) ratio because fractional ownerships are imprecise and can cause future issues. I think we could do the same with not much more hit to performance.
Proposed Solution
We could represent delegation as shares in the a stake pool, similar to the Polkadot[^1] solution. Then on subnet tempo steps, we have less accounts to consider in the StakeMap. This would change the way delegation works, i.e., delegates would operate as stake pools, where the corresponding entry in the StakeMap is the pool's total stake.
- On emission to a pool, increase the pool stake and increase owner shares (or rebalance)
- On
add_stake, update the shares of the staker (or all the shares) - On
remove_stake, update the shares of the unstaker (or all the shares)
Considerations
It is useful to consider when the shares are adjusted versus the pool's stake (i.e. the ratio of shares to stake). There are currently two ideas:
- Adjust the ratio on every change to the total stake a. i.e. maintain the 1:1 ratio of shares to TAO
- Do not adjust the ratio at all a. increase the pool balance on emission b. i.e. commission to the pool owner increases their shares c. (de)issue new shares on (un)stake The example python code implements the latter (2).
There is a trade-off here. (2.) is less precise due to fractional ownership of the pool's stake, but requires less operations (e.g. rebalances) to occur. Whereas (1.) is exactly precise, but will require rebalancing of the shares on every update to the pool, which may not improve runtime at all (yet to be tested).
An example in python:
from typing import Dict
class NominationPool:
owner: str
shareMap: Dict[str, float] = {}
shareSum: float = 0
commission: float
poolStake: float = 0
def __init__(self, owner: str, comission: float = 0.18):
self.owner = owner
self.shareMap[owner] = 0.0
self.shareSum = 0.0
self.comission = comission
def addNomination(self, nominator: str, amount: float):
# Find how much this amount will increase the pool
if self.poolStake == 0:
increase = 1
# Issue initial shares
new_shares = 1
else:
increase = amount / self.poolStake
# Find the amount of shares issued
new_shares = increase * self.shareSum
# Issue the shares
if nominator not in self.shareMap:
self.shareMap[nominator] = 0
self.shareMap[nominator] += new_shares
self.shareSum += new_shares
# Increase the pool stake
self.poolStake += amount
def getNomination(self, nominator: str):
return self.shareMap[nominator]
def getStake(self, nominator: str):
# Find the amount of stake the nominator has
if self.shareMap.get(nominator, 0.0) == 0:
return 0.0
return (self.shareMap[nominator]/self.shareSum) * self.poolStake
def getCommission(self):
return self.commission
def getPoolStake(self):
return self.poolStake
def getPoolSize(self):
return len(self.shareMap)
def getShareOfPool(self, nominator: str):
return self.shareMap[nominator] / self.shareSum
def emitThroughPool(self, amount: float):
# Find the owner commission
commission = amount * self.comission
# Give the rest to the pool
left_amount = amount - commission
self.poolStake += left_amount
# Add the commission to the owner
self.addNomination(self.owner, commission)
def removeStake(self, nominator: str, amount: float):
# Find the amount of shares to remove
decrease = amount / self.poolStake
# Find the amount of shares to remove
remove_shares = decrease * self.shareSum
# Remove the shares
if self.shareMap[nominator] < remove_shares:
raise ValueError("The nominator does not have enough shares")
self.shareMap[nominator] -= remove_shares
self.shareSum -= remove_shares
# Decrease the pool stake
self.poolStake -= amount
Regarding their implementation, some elements of their design are unclear, i.e., I am not sure why they chose to:
- separate rewards from stake
- rebalance shares (aka "points") on each join/leave
Though I think this may be because of Polkadot's stake bonding time requirements (Bittensor lacks this lock period). I imagine they decided to rebalance on every change to the pool in order to maintain the 1:1 (points to funds) ratio because fractional ownerships are imprecise and can cause future issues. I think we could do the same with not much more hit to performance.
I suppose points exist as a solution for efficient accounting w/ the presence of potential slashing? With "the points system" established, after a pool gets slashed, instead of applying the penalty to all affected pool members immediately (by reducing their balances), runtime only needs to decrease the overall balance, which is much cheaper. The slash can then be applied lazily, like when a member withdraws, with the help of ratio of member_points / overall_points. I guess that's why they need to keep the points of members proportional: member_points / overall_points = member_balance / overall_balance?
Afaik, it's not too expensive to maintain. Like when some funds is bonded, the member's points can be calculated by balance * (overall_points / overall_balance), and then increase the overall balance and points.
As for the the rewards, I guess the staking pallet doesn't need to know the specifics of how rewards of individual members are calculated? which involves the points and some reward "counters" (to get the coefficient for calculating the rewards).
On hold and back to research. Current issue is that we have local shares (per each subnet) and global shares (nominator's share of stake across all subnets). Local is something people can unstake, but global is something that determines how much local grows on each emission. And that's the problem because we can't just increase the counter of how much we have in the local pool. We need to re-balance the shares too because they are not growing evenly, which is the same amount of computation.
Shelved due to complexity. Potentially an approach using pools as a staker-type in the maps makes more sense. Then increase the individual staker min-stake to a sufficient reduction in map size.