194 lines
12 KiB
Markdown
194 lines
12 KiB
Markdown
# Staking
|
|
|
|
WARNING: All code related to staking is unaudited. Use at your own risk.
|
|
|
|
## Introduction
|
|
|
|
The **Registry** program provides an on-chain mechanism for a group of stakers to
|
|
|
|
* Share rewards proprtionally amongst a staking pool
|
|
* Govern on chain protocols with stake weighted voting
|
|
* Stake and earn locked tokens
|
|
|
|
The program makes little assumptions about the form of stake or rewards.
|
|
In the same way you can make a new SPL token with its own mint, you can create a new stake
|
|
pool. Although the token being staked must be a predefined mint upon pool initialization,
|
|
rewards on a particular pool can be arbitrary SPL tokens, or, in the case of locked rewards,
|
|
program controlled accounts.
|
|
Rewards can come from an arbitrary
|
|
wallet, e.g. automatically from a fee earning program,
|
|
or manually from a wallet owned by an individual. The specifics are token and protocol
|
|
dependent.
|
|
|
|
Similarly, the specifics of governance are not assumed by the staking program. However, a
|
|
governance system can use this program as a primitive to implement stake weighted voting.
|
|
|
|
Here staking is covered at somewhat of a low level with the goal of allowing one
|
|
to understand, contribute to, or modify the code.
|
|
|
|
## Accounts
|
|
|
|
Accounts are the pieces of state owned by a Solana program. For reference while reading, here are all
|
|
accounts used by the **Registry** program.
|
|
|
|
* `Registrar` - Analagous to an SPL token `Mint`, the `Registrar` defines a staking instance. It has its own pool, and it's own set of rewards distributed amongst its own set of stakers.
|
|
* `Member` - Analogous to an SPL token `Account`, `Member` accounts represent a **beneficiary**'s (i.e. a wallet's) stake state. This account has several vaults, all of which represent the funds belonging to an individual user.
|
|
* `PendingWithdrawal` - A transfer out of the staking pool (poorly named since it's not a withdrawal out of the program. But a withdrawal out of the staking pool and into a `Member`'s freely available balances).
|
|
* `RewardVendor` - A reward that has been dropped onto stakers and is distributed pro rata to staked `Member` beneficiaries.
|
|
* `RewardEventQueue` - A ring buffer of all rewards available to stakers. Each entry is the address of a `RewardVendor`.
|
|
|
|
## Creating a member account.
|
|
|
|
Before being able to enter the stake pool, one must create a **Member** account with the
|
|
**Registrar**, providing identity to the **Registry** program. By default, each member has
|
|
four types of token vaults making up a set of balances owned by the program on behalf of a
|
|
**Member**:
|
|
|
|
* Available balances: a zero-interest earning token account with no restrictions.
|
|
* Pending: unstaked funds incurring an unbonding timelock.
|
|
* Stake: the total amount of tokens staked.
|
|
* Stake pool token: the total amount of pool tokens created from staking (`stake = stake-pool-token * stake-pool-token-price`).
|
|
|
|
Each of these vaults provide a unit of balance isolation unique to a **Member**.
|
|
That is, although the stake program appears to provide a pooling mechanism, funds between
|
|
**Member** accounts are not commingled. They do not share SPL token accounts, and the only
|
|
way for funds to move is for a **Member**'s beneficiary to authorize instructions that either exit the
|
|
system or move funds between a **Member**'s own vaults.
|
|
|
|
## Depositing and Withdrawing.
|
|
|
|
Funds initially enter and exit the program through the `Deposit` and `Withdraw` instructions,
|
|
which transfer funds into and out of the **available balances** vault.
|
|
As the name suggests, all funds in this vault are freely available, unrestricted, and
|
|
earn zero interest. The vault is purely a gateway for funds to enter the program.
|
|
|
|
## Staking.
|
|
|
|
Once deposited, a **Member** beneficiary invokes the `Stake` instruction to transfer funds from
|
|
their **available-balances-vault** to one's **stake-vault**, creating newly minted
|
|
**stake-pool-tokens** as proof of the stake deposit. These new tokens represent
|
|
one's proportional right to all rewards distributed to the staking pool and are offered
|
|
by the **Registry** program at a fixed price, e.g., of 500 SPL tokens.
|
|
|
|
## Unstaking
|
|
|
|
Once staked, funds cannot be immediately withdrawn. Rather, the **Registrar** will enforce
|
|
a one week timelock before funds are released. Upon executing the `StartUnstake`
|
|
instruction, three operations execute. 1) The given amount of stake pool tokens will be burned.
|
|
2) Staked funds proportional to the stake pool tokens burned will be transferred from the
|
|
**Member**'s **stake-vault** to the **Member**'s **pending-vault**. 3) A `PendingWithdrawal`
|
|
account will be created as proof of the stake withdrawal, stamping the current block's
|
|
`unix_timestamp` onto the account. When the timelock period ends, a **Member** can invoke the
|
|
`EndUnstake` instruction to complete the transfer out of the `pending-vault` and
|
|
into the `available-balances`, providing the previously printed `PendingWithdrawal`
|
|
receipt to the program as proof that the timelock has passed. At this point, the exit
|
|
from the stake pool is complete, and the funds are ready to be used again.
|
|
|
|
## Reward Design Motivation
|
|
|
|
Feel free to skip this section and jump to the **Reward Vendors** section if you want to
|
|
just see how rewards work.
|
|
|
|
One could imagine several ways to drop rewards onto a staking pool, each with there own downsides.
|
|
Of course what you want is, for a given reward amount, to atomically snapshot the state
|
|
of the staking pool and to distribute it proportionally to all stake holders. Effectively,
|
|
an on chain program such as
|
|
|
|
```python
|
|
for account in stake_pool:
|
|
account.token_amount += total_reward * (account.stake_pool_token.amount / stake_pool_token.supply)
|
|
```
|
|
|
|
Surprisingly, such a mechanism is not immediately obvious.
|
|
|
|
First, the above program is a non starter. Not only does the SPL token
|
|
program not have the ability to iterate through all accounts for a given mint within a program,
|
|
but, since Solana transactions require the specification of all accounts being accessed
|
|
in a transaction (this is how it achieves parallelism), such a transaction's size would be
|
|
well over the limit. So modifying global state atomically in a single transaction is out of the
|
|
question.
|
|
|
|
So if you can't do this on chain, one can try doing it off chain. One could write an program to
|
|
snapshot the pool state, and just airdrop tokens onto the pool. This works, but
|
|
adds an additional layer of trust. Who snapshots the pool state? At what time?
|
|
How do you know they calculated the rewards correctly? What happens if my reward was not given?
|
|
This is not auditable or verifiable. And if you want to answer these questions, requires
|
|
complex off-chain protocols that require either fancy cryptography or effectively
|
|
recreating a BFT system off chain.
|
|
|
|
Another solution considerered was to use a uniswap-style AMM pool (without the swapping).
|
|
This has a lot of advantages. First it's easy to reason about and implement in a single transaction.
|
|
To drop rewards gloablly onto the pool, one can deposit funds directly into the pool, in which case
|
|
the reward is automatically received by owners of the staking pool token upon redemption, a process
|
|
known as "gulping"--since dropping rewards increases the total value of the pool
|
|
while their proportion of the pool remained constant.
|
|
|
|
However, there are enough downsides with using an AMM style pool to offset the convience.
|
|
Unfortunately, it loses the nice balance isolation property **Member** accounts have, because
|
|
tokens have to be pooled into the same vault, which is an additional security concern that could
|
|
easily lead to loss of funds, e.g., if there's a bug in the redemption calculation. Moreover, dropping
|
|
arbitrary tokens onto the pool is a challenge. Not only do you have to create new pool vaults for
|
|
every new token you drop onto the pool, but you also need to have stakers purchase those tokens to enter
|
|
the pool, effectively requiring one to stake other unintended tokens. An additional oddity is that
|
|
as rewards are dropped onto the pool, the price to enter the pool monotonically increases. Remember, entering this
|
|
type of pool requires "creating" pool tokens, i.e., depositing enough tokens so that you don't dilute
|
|
any other member. So if a single pool token represents one SPL token. And if an additional SPL token is dropped onto every
|
|
member of the pool, all the existing member's shares are now worth two SPL tokens. So to enter the pool without
|
|
dilution, one would have to "create" at a price of 2 SPL tokens per share. This means that rewarding
|
|
stakers becomes more expensive over time. One could of course solve this problem by implementing
|
|
arbitrary `n:m` pool token splits, which leads right back to the problem of mutating global account
|
|
state for an SPL token.
|
|
|
|
Furthermore, dropping arbitrary program accounts as rewards hasn't even been covered, for example,
|
|
locked token rewards, which of course can't be dropped directly onto an AMM style pool, since they are not tokens.
|
|
So, if one did go with an AMM style pool, one would need a separate mechanism for handling more general rewards like
|
|
locked token accounts. Ideally, there would be a single mechanism for both.
|
|
|
|
## Reward Vendors
|
|
|
|
Instead of trying to *push* rewards to users via a direct transfer or airdrop, one can use a *polling* model
|
|
where users effectively event source a log on demand, providing a proof one is eligible for the reward.
|
|
|
|
When a reward is created, the program must do two things:
|
|
|
|
1) Create a **Reward Vendor** account with an associated token vault holding the reward.
|
|
2) Assign the **Reward Vendor** the next available position in a **Reward Event Queue**. Then, to retrieve
|
|
a reward, a staker invokes the `ClaimReward` command, providing a proof that the funds were
|
|
staked at the time of the reward being dropped, and in response, the program transfers or,
|
|
some might say, *vends* the proportion of the dropped reward to the polling **Member**. The
|
|
operation completes by incrementing the **Member**'s queue cursor, ensuring that a given
|
|
reward can only be processed once.
|
|
|
|
This allows the program to drop rewards on the stake pool in a way that is
|
|
on chain and verifiable. Of course, it requires an external trigger, some account willing to
|
|
transfer funds to a new **RewardVendor**, but that is outside of the scope of the staking
|
|
program. The reward dropper can be an off chain BFT committee, or it can be an on-chain multisig.
|
|
It can be a charitable individual, or funds can flow directly from a fee paying program such as the DEX,
|
|
which itself can create a Reward Vendor from fees collected. It doesn't matter to the **Registry** program.
|
|
|
|
Note that this solution also allows for rewards to be denominated in any token, not just the token being staked.
|
|
Since rewards are paid out by the vendor immediately and to a token account of the **Member**'s
|
|
choosing, it *just works*. Even more, this extends to arbitrary program accounts, particularly
|
|
**Locked** tokens. A **Reward Vendor** needs to additionally know the accounts and instruction data
|
|
to relay to the program, but otherwise, the mechanism is the same. The details of **Locked** tokens will
|
|
be explained in an additional document.
|
|
|
|
### Realizing Locked Rewards
|
|
|
|
In addition to a vesting schedule, locked rewards are subject to a realization condition defined by the
|
|
staking program. Specifically, locked tokens are **realized** upon completely unstaking. So if one never
|
|
unstakes and incurs the unbonding timelock, one never receives locked token rewards.
|
|
|
|
## Misc
|
|
|
|
### Member Accounts
|
|
|
|
This document describes 4 vault types belonging to **Member** accounts.
|
|
However there are two types of balance groups: locked and unlocked.
|
|
As a result, there are really 8 vaults for each **Member**, 4 types of vaults in 2 separate sets,
|
|
each isolated from the other, so that locked tokens don't get mixed with unlocked tokens.
|
|
|
|
## Future Work
|
|
|
|
* Arbitrary program accounts as rewards. With the current design, it should be straightforward to generalize locked token rewards to arbitrary program accounts from arbitrary programs.
|