Init repo
This commit is contained in:
commit
3032a09886
|
@ -0,0 +1,2 @@
|
||||||
|
.anchor
|
||||||
|
target
|
|
@ -0,0 +1,53 @@
|
||||||
|
dist: bionic
|
||||||
|
language: rust
|
||||||
|
rust:
|
||||||
|
- nightly
|
||||||
|
cache: cargo
|
||||||
|
env:
|
||||||
|
- NODE_VERSION="14.7.0"
|
||||||
|
|
||||||
|
before_deploy:
|
||||||
|
- anchor build --verifiable
|
||||||
|
- echo "### SHA256 Checksums" > release_notes.md
|
||||||
|
- sha256sum target/deploy/lockup.so > lockup_binary.txt
|
||||||
|
- sha256sum target/idl/lockup.json > lockup_idl.txt
|
||||||
|
- sha256sum target/deploy/registry.so > registry_binary.txt
|
||||||
|
- sha256sum target/idl/registry.json > registry_idl.txt
|
||||||
|
- cat *.txt >> release_notes.md
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
provider: releases
|
||||||
|
edge: true
|
||||||
|
file:
|
||||||
|
- "target/deploy/lockup.so"
|
||||||
|
- "target/deploy/registry.so"
|
||||||
|
- "target/idl/lockup.json"
|
||||||
|
- "target/idl/registry.json"
|
||||||
|
release_notes_file: release_notes.md
|
||||||
|
skip_cleanup: true
|
||||||
|
on:
|
||||||
|
tags: true
|
||||||
|
api_key:
|
||||||
|
secure: MRahuKj/FhxUwkkvqiI3wJYWKzJ0PVl25ZfFhp5lA7xyYYj/heQOdX1rE8I3MkyBOWlSNAN89JXKQ61czOrkpjK/vjBt7/49iCkWuBd+ZQ0SOjrdFubAMl4ypd3C56v28Q/Rh5bAgm8IiJNeCidfWjiu36ibjAHMAxkwAssp76AV0hboWMJx6i4i8W/iFC8hQhiFa4npkTkrCtL4Vt8qY0fwqNRRpMZBIz22ZglYbhWpkaPMeikFun7Fjn9dvT0PM/xtcjTYOf4sxdjItpYjR0fUF+thuR+z4McgeYko3AZG9Sv8RMvw6yU1Hpq/Okk1wXcxNHyDtz/YriwiPgVzcIW2SGW2YxXh8YZEQFJuVodM8udYjFuNHy+qcNDiCvcoNIj2zYP3iWEVpiv4a3Hr33T/+iGTqLkjlnHcLKI8m2ykbHFtmNEmg6P4faayYkDSeEKRMZSDuA+CKh07LVlBQFyIRB3tfw3+tdBGQXQojgxAwuxnfANhScMpSdjZtdJCS912ijzVeSGa6C33+/fpAqQtCQqwJx+Bl6Bytvq+nBSjojWJZUqvE53IFwD5/bSd6FUIyAQMnQ6t8dOF+OWx0a1rFtpfLYYKZwei8kZlWNd4BLs+V0jkyHTzy0Cztre/EcmXdAHxAX8XrcrWt9sC1gwpApdrtZ20zZhEHsdgY/k=
|
||||||
|
|
||||||
|
_defaults: &defaults
|
||||||
|
before_install:
|
||||||
|
- nvm install $NODE_VERSION
|
||||||
|
- npm install -g mocha
|
||||||
|
- npm install -g @project-serum/anchor
|
||||||
|
- npm install -g @project-serum/serum
|
||||||
|
- npm install -g @project-serum/common
|
||||||
|
- npm install -g @solana/spl-token
|
||||||
|
- sudo apt-get install -y pkg-config build-essential libudev-dev
|
||||||
|
- sh -c "$(curl -sSfL https://release.solana.com/v1.5.5/install)"
|
||||||
|
- export PATH="/home/travis/.local/share/solana/install/active_release/bin:$PATH"
|
||||||
|
- export NODE_PATH="/home/travis/.nvm/versions/node/v$NODE_VERSION/lib/node_modules/:$NODE_PATH"
|
||||||
|
- yes | solana-keygen new
|
||||||
|
- cargo install --git https://github.com/project-serum/anchor anchor-cli --locked
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
include:
|
||||||
|
- <<: *defaults
|
||||||
|
name: Runs the tests
|
||||||
|
script:
|
||||||
|
- anchor test
|
|
@ -0,0 +1,2 @@
|
||||||
|
cluster = "localnet"
|
||||||
|
wallet = "~/.config/solana/id.json"
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,4 @@
|
||||||
|
[workspace]
|
||||||
|
members = [
|
||||||
|
"programs/*"
|
||||||
|
]
|
|
@ -0,0 +1,38 @@
|
||||||
|
# Stake
|
||||||
|
|
||||||
|
Programs for staking and lockups. For a technical introduction, see the [docs](./docs).
|
||||||
|
|
||||||
|
## Note
|
||||||
|
|
||||||
|
* **This code is unaudited. Use at your own risk.**
|
||||||
|
|
||||||
|
## Developing
|
||||||
|
|
||||||
|
[Anchor](https://github.com/project-serum/anchor) is used for developoment, and it's
|
||||||
|
recommended workflow is used here. To get started, see the [guide](https://project-serum.github.io/anchor/getting-started/introduction.html).
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```
|
||||||
|
anchor build --verifiable
|
||||||
|
```
|
||||||
|
|
||||||
|
The `--verifiable` flag should be used before deploying so that your build artifacts
|
||||||
|
can be deterministically generated with docker.
|
||||||
|
|
||||||
|
### Test
|
||||||
|
|
||||||
|
```
|
||||||
|
anchor test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
|
||||||
|
To verify the program deployed on Solana matches your local source code, change directory
|
||||||
|
into the program you want to verify, e.g., `cd programs/registry`, and run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
anchor verify <program-id | write-buffer>
|
||||||
|
```
|
||||||
|
|
||||||
|
A list of build artifacts can be found under [releases](https://github.com/project-serum/stake/releases).
|
|
@ -0,0 +1,127 @@
|
||||||
|
# Lockups
|
||||||
|
|
||||||
|
WARNING: All code related to Lockups is unaudited. Use at your own risk.
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
The **Lockup** program provides a simple mechanism to lockup tokens
|
||||||
|
of any mint, and release those funds over time as defined by a vesting schedule.
|
||||||
|
Although these lockups track a target **beneficiary**, who will eventually receive the
|
||||||
|
funds upon vesting, a proper deployment of the program will ensure this **beneficiary**
|
||||||
|
can never actually retrieve tokens before vesting. Funds are *never* in an SPL
|
||||||
|
token wallet owned by a user, and are completely program controlled.
|
||||||
|
|
||||||
|
## Accounts
|
||||||
|
|
||||||
|
There is a single account type used by the program.
|
||||||
|
|
||||||
|
* `Vesting` - An account defining a vesting schedule, realization condition, and vault holding the tokens to be released over time.
|
||||||
|
|
||||||
|
## Creating a Vesting Account
|
||||||
|
|
||||||
|
Lockup occurs when tokens are transferred into the program creating a **Vesting**
|
||||||
|
account on behalf of a **beneficiary** via the `CreateVesting` instruction.
|
||||||
|
There are three parameters to specify:
|
||||||
|
|
||||||
|
* Start timestamp - unix timestamp (in seconds) of the time when vesting begins.
|
||||||
|
* End timestamp - unix timestamp (in seconds) of the time when all tokens will unlock.
|
||||||
|
* Period count - the amount of times vesting should occur.
|
||||||
|
* Deposit amount - the total amount to vest.
|
||||||
|
* Realizer - the program defining if and when vested tokens can be distributed to a beneficiary.
|
||||||
|
|
||||||
|
Together these parameters form a linearly unlocked vesting schedule. For example,
|
||||||
|
if one wanted to lock 100 SPL tokens that unlocked twice, 50 each time, over the next year, one
|
||||||
|
would use the following parameters (in JavaScript).
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const startTimestamp = Date.now()/1000;
|
||||||
|
const endTimestamp = Date.now()/1000 + 60*60*24*365;
|
||||||
|
const periodCount = 2;
|
||||||
|
const depositAmount = 100 * 10**6; // 6 decimal places.
|
||||||
|
const realizer = null; // No realizer in this example.
|
||||||
|
```
|
||||||
|
|
||||||
|
From these five parameters, one can deduce the total amount vested at any given time.
|
||||||
|
|
||||||
|
Once created, a **Vesting** account's schedule cannot be mutated.
|
||||||
|
|
||||||
|
## Withdrawing from a Vesting Account
|
||||||
|
|
||||||
|
Withdrawing is straightforward. Simply invoke the `Withdraw` instruction, specifying an
|
||||||
|
amount to withdraw from a **Vesting** account. The **beneficiary** of the
|
||||||
|
**Vesting** account must sign the transaction, but if enough time has passed for an
|
||||||
|
amount to be vested, and, if the funds are indeed held in the lockup program's vault
|
||||||
|
(a point mentioned below) then the program will release the funds.
|
||||||
|
|
||||||
|
## Realizing Locked Tokens
|
||||||
|
|
||||||
|
Optionally, vesting accounts can be created with a `realizer` program, which is
|
||||||
|
a program implementing the lockup program's `RealizeLock` trait. In
|
||||||
|
addition to the vesting schedule, a `realizer` program determines if and when a
|
||||||
|
beneficiary can ever seize control over locked funds. It's effectively a function
|
||||||
|
returning a boolean: is realized or not.
|
||||||
|
|
||||||
|
The uses cases for a realizer are application specific.
|
||||||
|
For example, in the case of the staking program, when a vesting account is distributed as a reward,
|
||||||
|
the staking program sets itself as the realizor, ensuring that the only way for the vesting account
|
||||||
|
to be realized is if the beneficiary completely unstakes and incurs the unbonding timelock alongside
|
||||||
|
any other consequences of unstaking (e.g., the inability to vote on governance proposals).
|
||||||
|
This implies that, if one never unstakes, one never receives locked token rewards, adding
|
||||||
|
an additional consideration when managing one's stake.
|
||||||
|
|
||||||
|
If no such `realizer` exists, tokens are realized upon account creation.
|
||||||
|
|
||||||
|
## Whitelisted Programs
|
||||||
|
|
||||||
|
Although funds cannot be freely withdrawn prior to vesting, they can be sent to/from
|
||||||
|
other programs that are part of a **Whitelist**. These programs are completely trusted.
|
||||||
|
Any bug or flaw in the design of a whitelisted program can lead to locked tokens being released
|
||||||
|
ahead of schedule, so it's important to take great care when whitelisting any program.
|
||||||
|
|
||||||
|
This of course begs the question, who approves the whitelist? The **Lockup** program doesn't
|
||||||
|
care. There simply exists an **authority** key that can, for example, be a democratic multisig,
|
||||||
|
a single admin, or the zero address--in which case the authority ceases to exist, as the
|
||||||
|
program will reject transactions signing from that address. Although the **authority** can never
|
||||||
|
move a **Vesting** account's funds, whoever controls the **authority** key
|
||||||
|
controls the whitelist. So when using the **Lockup** program, one should always be
|
||||||
|
cognizant of it's whitelist governance, which ultimately anchors one's trust in the program,
|
||||||
|
if any at all.
|
||||||
|
|
||||||
|
## Creating a Whitelisted Program
|
||||||
|
|
||||||
|
To create a whitelisted program that receives withdrawals/deposits from/to the Lockup program,
|
||||||
|
one needs to implement the whitelist transfer interface, which assumes nothing about the
|
||||||
|
`instruction_data` but requires accounts to be provided in a specific [order](https://github.com/project-serum/serum-dex/blob/master/registry/program/src/deposit.rs#L18).
|
||||||
|
|
||||||
|
Take staking locked tokens as a working example.
|
||||||
|
|
||||||
|
### Staking Locked Tokens
|
||||||
|
|
||||||
|
Suppose you have a vesting account with some funds you want to stake.
|
||||||
|
|
||||||
|
First, one must add the staking **Registry** as a whitelisted program, so that the Lockup program
|
||||||
|
allows the movement of funds. This is done by the `WhitelistAdd` instruction.
|
||||||
|
|
||||||
|
Once whitelisted, **Vesting** accounts can transfer funds out of the **Lockup** program and
|
||||||
|
into the **Registry** program by invoking the **Lockup** program's `WhitelistWithdraw`
|
||||||
|
instruction, which, other than access control, simply relays the instruction from the
|
||||||
|
**Lockup** program to the **Registry** program along with accounts, signing the
|
||||||
|
Cross-Program-Invocation (CPI) with the **Lockup**'s program-derived-address to allow
|
||||||
|
the transfer of funds, which ultimately is done by the **Registry**. *It is the Registry's responsibility
|
||||||
|
to track where these funds came from, keep them locked, and eventually send them back.*
|
||||||
|
|
||||||
|
When creating this instruction on the client, there are two parameters to provide:
|
||||||
|
the maximum `amount` available for transfer and the opaque CPI `instruction_data`.
|
||||||
|
In the example, here, it would be the Borsh serialized instruction data for the
|
||||||
|
**Registry**'s `Deposit` instruction.
|
||||||
|
|
||||||
|
The other direction follows, similarly. One invokes the `WhitelistDeposit` instruction
|
||||||
|
on the **LockupProgram**, relaying the transaction to the **Registry**, which ultimately
|
||||||
|
transfer funds back into the lockup program on behalf of the **Vesting** account.
|
||||||
|
|
||||||
|
## Major version upgrades.
|
||||||
|
|
||||||
|
Assuming the `authority` account is set on the **Lockup** program, one can use this Whitelist
|
||||||
|
mechanism to do major version upgrades of the lockup program. One can whitelist the
|
||||||
|
new **Lockup** program, and then all **Vesting** accounts would invidiually perform the migration
|
||||||
|
by transferring their funds to the new proigram via the `WhitelistWithdraw` instruction.
|
|
@ -0,0 +1,193 @@
|
||||||
|
# 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 their 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.
|
|
@ -0,0 +1,179 @@
|
||||||
|
// deploy.js is a simple deploy script to initialize a program. This is run
|
||||||
|
// immediately after a deploy.
|
||||||
|
|
||||||
|
const serumCmn = require("@project-serum/common");
|
||||||
|
const anchor = require("@project-serum/anchor");
|
||||||
|
const PublicKey = anchor.web3.PublicKey;
|
||||||
|
|
||||||
|
module.exports = async function (provider) {
|
||||||
|
// Configure client to use the provider.
|
||||||
|
anchor.setProvider(provider);
|
||||||
|
|
||||||
|
// Setup genesis state.
|
||||||
|
const registrarConfigs = await genesis(provider);
|
||||||
|
|
||||||
|
// Program clients.
|
||||||
|
const lockup = anchor.workspace.Lockup;
|
||||||
|
const registry = anchor.workspace.Registry;
|
||||||
|
|
||||||
|
// Registry state constructor.
|
||||||
|
await registry.state.rpc.new({
|
||||||
|
accounts: {
|
||||||
|
lockupProgram: lockup.programId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lockup state constructor.
|
||||||
|
await lockup.state.rpc.new({
|
||||||
|
accounts: {
|
||||||
|
authority: provider.wallet.publicKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete the default whitelist entries.
|
||||||
|
const defaultEntry = { programId: new anchor.web3.PublicKey() };
|
||||||
|
await lockup.state.rpc.whitelistDelete(defaultEntry, {
|
||||||
|
accounts: {
|
||||||
|
authority: provider.wallet.publicKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Whitelist the registry.
|
||||||
|
await lockup.state.rpc.whitelistAdd(
|
||||||
|
{ programId: registry.programId },
|
||||||
|
{
|
||||||
|
accounts: {
|
||||||
|
authority: provider.wallet.publicKey,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initialize all registrars.
|
||||||
|
const cfgKeys = Object.keys(registrarConfigs);
|
||||||
|
for (let k = 0; k < cfgKeys.length; k += 1) {
|
||||||
|
let r = registrarConfigs[cfgKeys[k]];
|
||||||
|
const registrar = await registrarInit(
|
||||||
|
registry,
|
||||||
|
r.withdrawalTimelock,
|
||||||
|
r.stakeRate,
|
||||||
|
r.rewardQLen,
|
||||||
|
new anchor.web3.PublicKey(r.mint)
|
||||||
|
);
|
||||||
|
r["registrar"] = registrar.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate code for whitelisting on UIs.
|
||||||
|
const code = generateCode(registry, lockup, registrarConfigs);
|
||||||
|
console.log("Generated whitelisted UI addresses:", code);
|
||||||
|
};
|
||||||
|
|
||||||
|
function generateCode(registry, lockup, registrarConfigs) {
|
||||||
|
const registrars = Object.keys(registrarConfigs)
|
||||||
|
.map((cfg) => `${cfg}: new PublicKey('${registrarConfigs[cfg].registrar}')`)
|
||||||
|
.join(",");
|
||||||
|
|
||||||
|
const mints = Object.keys(registrarConfigs)
|
||||||
|
.map((cfg) => `${cfg}: new PublicKey('${registrarConfigs[cfg].mint}')`)
|
||||||
|
.join(",");
|
||||||
|
|
||||||
|
return `{
|
||||||
|
registryProgramId: new PublicKey('${registry.programId}'),
|
||||||
|
lockupProgramId: new PublicKey('${lockup.programId}'),
|
||||||
|
registrars: { ${registrars} },
|
||||||
|
mints: { ${mints} },
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function genesis(provider) {
|
||||||
|
if (
|
||||||
|
provider.connection._rpcEndpoint === "https://api.mainnet-beta.solana.com"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
srm: {
|
||||||
|
withdrawalTimelock: 60 * 60 * 24 * 7, // 1 week.
|
||||||
|
stakeRate: 500 * 10 ** 6, // 500 SRM.
|
||||||
|
rewardQLen: 150,
|
||||||
|
mint: "SRMuApVNdxXokk5GT7XD5cUUgXMBCoAz2LHeuAoKWRt",
|
||||||
|
},
|
||||||
|
msrm: {
|
||||||
|
withdrawalTimelock: 60 * 60 * 24 * 7, // 1 week.
|
||||||
|
stakeRate: 1,
|
||||||
|
rewardQLen: 150,
|
||||||
|
mint: "MSRMcoVyrFxnSgo5uXwone5SKcGhT1KEJMFEkMEWf9L",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const [token1Mint, _god1] = await serumCmn.createMintAndVault(
|
||||||
|
provider,
|
||||||
|
new anchor.BN(10000000000000),
|
||||||
|
undefined,
|
||||||
|
6
|
||||||
|
);
|
||||||
|
const [token2Mint, _god2] = await serumCmn.createMintAndVault(
|
||||||
|
provider,
|
||||||
|
new anchor.BN(10000000000),
|
||||||
|
undefined,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
token1: {
|
||||||
|
withdrawalTimelock: 60 * 60 * 24 * 7,
|
||||||
|
stakeRate: 1000 * 10 ** 6,
|
||||||
|
rewardQLen: 150,
|
||||||
|
mint: token1Mint.toString(),
|
||||||
|
},
|
||||||
|
token2: {
|
||||||
|
withdrawalTimelock: 60 * 60 * 24 * 7,
|
||||||
|
stakeRate: 1,
|
||||||
|
rewardQLen: 150,
|
||||||
|
mint: token2Mint.toString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registrarInit(
|
||||||
|
registry,
|
||||||
|
_withdrawalTimelock,
|
||||||
|
_stakeRate,
|
||||||
|
rewardQLen,
|
||||||
|
mint
|
||||||
|
) {
|
||||||
|
const registrar = new anchor.web3.Account();
|
||||||
|
const rewardQ = new anchor.web3.Account();
|
||||||
|
const withdrawalTimelock = new anchor.BN(_withdrawalTimelock);
|
||||||
|
const stakeRate = new anchor.BN(_stakeRate);
|
||||||
|
const [
|
||||||
|
registrarSigner,
|
||||||
|
nonce,
|
||||||
|
] = await anchor.web3.PublicKey.findProgramAddress(
|
||||||
|
[registrar.publicKey.toBuffer()],
|
||||||
|
registry.programId
|
||||||
|
);
|
||||||
|
const poolMint = await serumCmn.createMint(
|
||||||
|
registry.provider,
|
||||||
|
registrarSigner
|
||||||
|
);
|
||||||
|
await registry.rpc.initialize(
|
||||||
|
mint,
|
||||||
|
registry.provider.wallet.publicKey,
|
||||||
|
nonce,
|
||||||
|
withdrawalTimelock,
|
||||||
|
stakeRate,
|
||||||
|
rewardQLen,
|
||||||
|
{
|
||||||
|
accounts: {
|
||||||
|
registrar: registrar.publicKey,
|
||||||
|
poolMint,
|
||||||
|
rewardEventQ: rewardQ.publicKey,
|
||||||
|
rent: anchor.web3.SYSVAR_RENT_PUBKEY,
|
||||||
|
},
|
||||||
|
signers: [registrar, rewardQ],
|
||||||
|
instructions: [
|
||||||
|
await registry.account.registrar.createInstruction(registrar),
|
||||||
|
await registry.account.rewardQueue.createInstruction(rewardQ, 8250),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return registrar.publicKey;
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
[package]
|
||||||
|
name = "lockup"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Created with Anchor"
|
||||||
|
edition = "2018"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "lib"]
|
||||||
|
name = "lockup"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
no-entrypoint = []
|
||||||
|
cpi = ["no-entrypoint"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anchor-lang = "0.2.1"
|
||||||
|
anchor-spl = "0.2.1"
|
|
@ -0,0 +1,2 @@
|
||||||
|
[target.bpfel-unknown-unknown.dependencies.std]
|
||||||
|
features = []
|
|
@ -0,0 +1,84 @@
|
||||||
|
//! Utility functions for calculating unlock schedules for a vesting account.
|
||||||
|
|
||||||
|
use crate::Vesting;
|
||||||
|
|
||||||
|
pub fn available_for_withdrawal(vesting: &Vesting, current_ts: i64) -> u64 {
|
||||||
|
std::cmp::min(outstanding_vested(vesting, current_ts), balance(vesting))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The amount of funds currently in the vault.
|
||||||
|
fn balance(vesting: &Vesting) -> u64 {
|
||||||
|
vesting
|
||||||
|
.outstanding
|
||||||
|
.checked_sub(vesting.whitelist_owned)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The amount of outstanding locked tokens vested. Note that these
|
||||||
|
// tokens might have been transferred to whitelisted programs.
|
||||||
|
fn outstanding_vested(vesting: &Vesting, current_ts: i64) -> u64 {
|
||||||
|
total_vested(vesting, current_ts)
|
||||||
|
.checked_sub(withdrawn_amount(vesting))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the amount withdrawn from this vesting account.
|
||||||
|
fn withdrawn_amount(vesting: &Vesting) -> u64 {
|
||||||
|
vesting
|
||||||
|
.start_balance
|
||||||
|
.checked_sub(vesting.outstanding)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the total vested amount up to the given ts, assuming zero
|
||||||
|
// withdrawals and zero funds sent to other programs.
|
||||||
|
fn total_vested(vesting: &Vesting, current_ts: i64) -> u64 {
|
||||||
|
if current_ts < vesting.start_ts {
|
||||||
|
0
|
||||||
|
} else if current_ts >= vesting.end_ts {
|
||||||
|
vesting.start_balance
|
||||||
|
} else {
|
||||||
|
linear_unlock(vesting, current_ts).unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn linear_unlock(vesting: &Vesting, current_ts: i64) -> Option<u64> {
|
||||||
|
// Signed division not supported.
|
||||||
|
let current_ts = current_ts as u64;
|
||||||
|
let start_ts = vesting.start_ts as u64;
|
||||||
|
let end_ts = vesting.end_ts as u64;
|
||||||
|
|
||||||
|
// If we can't perfectly partition the vesting window,
|
||||||
|
// push the start of the window back so that we can.
|
||||||
|
//
|
||||||
|
// This has the effect of making the first vesting period shorter
|
||||||
|
// than the rest.
|
||||||
|
let shifted_start_ts =
|
||||||
|
start_ts.checked_sub(end_ts.checked_sub(start_ts)? % vesting.period_count)?;
|
||||||
|
|
||||||
|
// Similarly, if we can't perfectly divide up the vesting rewards
|
||||||
|
// then make the first period act as a cliff, earning slightly more than
|
||||||
|
// subsequent periods.
|
||||||
|
let reward_overflow = vesting.start_balance % vesting.period_count;
|
||||||
|
|
||||||
|
// Reward per period ignoring the overflow.
|
||||||
|
let reward_per_period =
|
||||||
|
(vesting.start_balance.checked_sub(reward_overflow)?).checked_div(vesting.period_count)?;
|
||||||
|
|
||||||
|
// Number of vesting periods that have passed.
|
||||||
|
let current_period = {
|
||||||
|
let period_secs =
|
||||||
|
(end_ts.checked_sub(shifted_start_ts)?).checked_div(vesting.period_count)?;
|
||||||
|
let current_period_count =
|
||||||
|
(current_ts.checked_sub(shifted_start_ts)?).checked_div(period_secs)?;
|
||||||
|
std::cmp::min(current_period_count, vesting.period_count)
|
||||||
|
};
|
||||||
|
|
||||||
|
if current_period == 0 {
|
||||||
|
return Some(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
current_period
|
||||||
|
.checked_mul(reward_per_period)?
|
||||||
|
.checked_add(reward_overflow)
|
||||||
|
}
|
|
@ -0,0 +1,534 @@
|
||||||
|
//! A relatively advanced example of a lockup program. If you're new to Anchor,
|
||||||
|
//! it's suggested to start with the other examples.
|
||||||
|
|
||||||
|
#![feature(proc_macro_hygiene)]
|
||||||
|
|
||||||
|
use anchor_lang::prelude::*;
|
||||||
|
use anchor_lang::solana_program::instruction::Instruction;
|
||||||
|
use anchor_lang::solana_program::program;
|
||||||
|
use anchor_spl::token::{self, TokenAccount, Transfer};
|
||||||
|
|
||||||
|
mod calculator;
|
||||||
|
|
||||||
|
#[program]
|
||||||
|
pub mod lockup {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[state]
|
||||||
|
pub struct Lockup {
|
||||||
|
/// The key with the ability to change the whitelist.
|
||||||
|
pub authority: Pubkey,
|
||||||
|
/// List of programs locked tokens can be sent to. These programs
|
||||||
|
/// are completely trusted to maintain the locked property.
|
||||||
|
pub whitelist: Vec<WhitelistEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Lockup {
|
||||||
|
pub const WHITELIST_SIZE: usize = 10;
|
||||||
|
|
||||||
|
pub fn new(ctx: Context<Auth>) -> Result<Self> {
|
||||||
|
let mut whitelist = vec![];
|
||||||
|
whitelist.resize(Self::WHITELIST_SIZE, Default::default());
|
||||||
|
Ok(Lockup {
|
||||||
|
authority: *ctx.accounts.authority.key,
|
||||||
|
whitelist,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[access_control(whitelist_auth(self, &ctx))]
|
||||||
|
pub fn whitelist_add(&mut self, ctx: Context<Auth>, entry: WhitelistEntry) -> Result<()> {
|
||||||
|
if self.whitelist.len() == Self::WHITELIST_SIZE {
|
||||||
|
return Err(ErrorCode::WhitelistFull.into());
|
||||||
|
}
|
||||||
|
if self.whitelist.contains(&entry) {
|
||||||
|
return Err(ErrorCode::WhitelistEntryAlreadyExists.into());
|
||||||
|
}
|
||||||
|
self.whitelist.push(entry);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[access_control(whitelist_auth(self, &ctx))]
|
||||||
|
pub fn whitelist_delete(
|
||||||
|
&mut self,
|
||||||
|
ctx: Context<Auth>,
|
||||||
|
entry: WhitelistEntry,
|
||||||
|
) -> Result<()> {
|
||||||
|
if !self.whitelist.contains(&entry) {
|
||||||
|
return Err(ErrorCode::WhitelistEntryNotFound.into());
|
||||||
|
}
|
||||||
|
self.whitelist.retain(|e| e != &entry);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[access_control(whitelist_auth(self, &ctx))]
|
||||||
|
pub fn set_authority(&mut self, ctx: Context<Auth>, new_authority: Pubkey) -> Result<()> {
|
||||||
|
self.authority = new_authority;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[access_control(CreateVesting::accounts(&ctx, nonce))]
|
||||||
|
pub fn create_vesting(
|
||||||
|
ctx: Context<CreateVesting>,
|
||||||
|
beneficiary: Pubkey,
|
||||||
|
deposit_amount: u64,
|
||||||
|
nonce: u8,
|
||||||
|
start_ts: i64,
|
||||||
|
end_ts: i64,
|
||||||
|
period_count: u64,
|
||||||
|
realizor: Option<Realizor>,
|
||||||
|
) -> Result<()> {
|
||||||
|
if deposit_amount == 0 {
|
||||||
|
return Err(ErrorCode::InvalidDepositAmount.into());
|
||||||
|
}
|
||||||
|
if !is_valid_schedule(start_ts, end_ts, period_count) {
|
||||||
|
return Err(ErrorCode::InvalidSchedule.into());
|
||||||
|
}
|
||||||
|
let vesting = &mut ctx.accounts.vesting;
|
||||||
|
vesting.beneficiary = beneficiary;
|
||||||
|
vesting.mint = ctx.accounts.vault.mint;
|
||||||
|
vesting.vault = *ctx.accounts.vault.to_account_info().key;
|
||||||
|
vesting.period_count = period_count;
|
||||||
|
vesting.start_balance = deposit_amount;
|
||||||
|
vesting.end_ts = end_ts;
|
||||||
|
vesting.start_ts = start_ts;
|
||||||
|
vesting.created_ts = ctx.accounts.clock.unix_timestamp;
|
||||||
|
vesting.outstanding = deposit_amount;
|
||||||
|
vesting.whitelist_owned = 0;
|
||||||
|
vesting.grantor = *ctx.accounts.depositor_authority.key;
|
||||||
|
vesting.nonce = nonce;
|
||||||
|
vesting.realizor = realizor;
|
||||||
|
|
||||||
|
token::transfer(ctx.accounts.into(), deposit_amount)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[access_control(is_realized(&ctx))]
|
||||||
|
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
|
||||||
|
// Has the given amount vested?
|
||||||
|
if amount
|
||||||
|
> calculator::available_for_withdrawal(
|
||||||
|
&ctx.accounts.vesting,
|
||||||
|
ctx.accounts.clock.unix_timestamp,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return Err(ErrorCode::InsufficientWithdrawalBalance.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transfer funds out.
|
||||||
|
let seeds = &[
|
||||||
|
ctx.accounts.vesting.to_account_info().key.as_ref(),
|
||||||
|
&[ctx.accounts.vesting.nonce],
|
||||||
|
];
|
||||||
|
let signer = &[&seeds[..]];
|
||||||
|
let cpi_ctx = CpiContext::from(&*ctx.accounts).with_signer(signer);
|
||||||
|
token::transfer(cpi_ctx, amount)?;
|
||||||
|
|
||||||
|
// Bookeeping.
|
||||||
|
let vesting = &mut ctx.accounts.vesting;
|
||||||
|
vesting.outstanding -= amount;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sends funds from the lockup program to a whitelisted program.
|
||||||
|
pub fn whitelist_withdraw<'a, 'b, 'c, 'info>(
|
||||||
|
ctx: Context<'a, 'b, 'c, 'info, WhitelistWithdraw<'info>>,
|
||||||
|
instruction_data: Vec<u8>,
|
||||||
|
amount: u64,
|
||||||
|
) -> Result<()> {
|
||||||
|
let before_amount = ctx.accounts.transfer.vault.amount;
|
||||||
|
whitelist_relay_cpi(
|
||||||
|
&ctx.accounts.transfer,
|
||||||
|
ctx.remaining_accounts,
|
||||||
|
instruction_data,
|
||||||
|
)?;
|
||||||
|
let after_amount = ctx.accounts.transfer.vault.reload()?.amount;
|
||||||
|
|
||||||
|
// CPI safety checks.
|
||||||
|
let withdraw_amount = before_amount - after_amount;
|
||||||
|
if withdraw_amount > amount {
|
||||||
|
return Err(ErrorCode::WhitelistWithdrawLimit)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bookeeping.
|
||||||
|
ctx.accounts.transfer.vesting.whitelist_owned += withdraw_amount;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sends funds from a whitelisted program back to the lockup program.
|
||||||
|
pub fn whitelist_deposit<'a, 'b, 'c, 'info>(
|
||||||
|
ctx: Context<'a, 'b, 'c, 'info, WhitelistDeposit<'info>>,
|
||||||
|
instruction_data: Vec<u8>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let before_amount = ctx.accounts.transfer.vault.amount;
|
||||||
|
whitelist_relay_cpi(
|
||||||
|
&ctx.accounts.transfer,
|
||||||
|
ctx.remaining_accounts,
|
||||||
|
instruction_data,
|
||||||
|
)?;
|
||||||
|
let after_amount = ctx.accounts.transfer.vault.reload()?.amount;
|
||||||
|
|
||||||
|
// CPI safety checks.
|
||||||
|
let deposit_amount = after_amount - before_amount;
|
||||||
|
if deposit_amount <= 0 {
|
||||||
|
return Err(ErrorCode::InsufficientWhitelistDepositAmount)?;
|
||||||
|
}
|
||||||
|
if deposit_amount > ctx.accounts.transfer.vesting.whitelist_owned {
|
||||||
|
return Err(ErrorCode::WhitelistDepositOverflow)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bookkeeping.
|
||||||
|
ctx.accounts.transfer.vesting.whitelist_owned -= deposit_amount;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience function for UI's to calculate the withdrawable amount.
|
||||||
|
pub fn available_for_withdrawal(ctx: Context<AvailableForWithdrawal>) -> Result<()> {
|
||||||
|
let available = calculator::available_for_withdrawal(
|
||||||
|
&ctx.accounts.vesting,
|
||||||
|
ctx.accounts.clock.unix_timestamp,
|
||||||
|
);
|
||||||
|
// Log as string so that JS can read as a BN.
|
||||||
|
msg!(&format!("{{ \"result\": \"{}\" }}", available));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Accounts)]
|
||||||
|
pub struct Auth<'info> {
|
||||||
|
#[account(signer)]
|
||||||
|
authority: AccountInfo<'info>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Accounts)]
|
||||||
|
pub struct CreateVesting<'info> {
|
||||||
|
// Vesting.
|
||||||
|
#[account(init)]
|
||||||
|
vesting: ProgramAccount<'info, Vesting>,
|
||||||
|
#[account(mut)]
|
||||||
|
vault: CpiAccount<'info, TokenAccount>,
|
||||||
|
// Depositor.
|
||||||
|
#[account(mut)]
|
||||||
|
depositor: AccountInfo<'info>,
|
||||||
|
#[account(signer)]
|
||||||
|
depositor_authority: AccountInfo<'info>,
|
||||||
|
// Misc.
|
||||||
|
#[account("token_program.key == &token::ID")]
|
||||||
|
token_program: AccountInfo<'info>,
|
||||||
|
rent: Sysvar<'info, Rent>,
|
||||||
|
clock: Sysvar<'info, Clock>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'info> CreateVesting<'info> {
|
||||||
|
fn accounts(ctx: &Context<CreateVesting>, nonce: u8) -> Result<()> {
|
||||||
|
let vault_authority = Pubkey::create_program_address(
|
||||||
|
&[
|
||||||
|
ctx.accounts.vesting.to_account_info().key.as_ref(),
|
||||||
|
&[nonce],
|
||||||
|
],
|
||||||
|
ctx.program_id,
|
||||||
|
)
|
||||||
|
.map_err(|_| ErrorCode::InvalidProgramAddress)?;
|
||||||
|
if ctx.accounts.vault.owner != vault_authority {
|
||||||
|
return Err(ErrorCode::InvalidVaultOwner)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All accounts not included here, i.e., the "remaining accounts" should be
|
||||||
|
// ordered according to the realization interface.
|
||||||
|
#[derive(Accounts)]
|
||||||
|
pub struct Withdraw<'info> {
|
||||||
|
// Vesting.
|
||||||
|
#[account(mut, has_one = beneficiary, has_one = vault)]
|
||||||
|
vesting: ProgramAccount<'info, Vesting>,
|
||||||
|
#[account(signer)]
|
||||||
|
beneficiary: AccountInfo<'info>,
|
||||||
|
#[account(mut)]
|
||||||
|
vault: CpiAccount<'info, TokenAccount>,
|
||||||
|
#[account(seeds = [vesting.to_account_info().key.as_ref(), &[vesting.nonce]])]
|
||||||
|
vesting_signer: AccountInfo<'info>,
|
||||||
|
// Withdraw receiving target..
|
||||||
|
#[account(mut)]
|
||||||
|
token: CpiAccount<'info, TokenAccount>,
|
||||||
|
// Misc.
|
||||||
|
#[account("token_program.key == &token::ID")]
|
||||||
|
token_program: AccountInfo<'info>,
|
||||||
|
clock: Sysvar<'info, Clock>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Accounts)]
|
||||||
|
pub struct WhitelistWithdraw<'info> {
|
||||||
|
transfer: WhitelistTransfer<'info>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Accounts)]
|
||||||
|
pub struct WhitelistDeposit<'info> {
|
||||||
|
transfer: WhitelistTransfer<'info>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Accounts)]
|
||||||
|
pub struct WhitelistTransfer<'info> {
|
||||||
|
lockup: ProgramState<'info, Lockup>,
|
||||||
|
#[account(signer)]
|
||||||
|
beneficiary: AccountInfo<'info>,
|
||||||
|
whitelisted_program: AccountInfo<'info>,
|
||||||
|
|
||||||
|
// Whitelist interface.
|
||||||
|
#[account(mut, has_one = beneficiary, has_one = vault)]
|
||||||
|
vesting: ProgramAccount<'info, Vesting>,
|
||||||
|
#[account(mut, "&vault.owner == vesting_signer.key")]
|
||||||
|
vault: CpiAccount<'info, TokenAccount>,
|
||||||
|
#[account(seeds = [vesting.to_account_info().key.as_ref(), &[vesting.nonce]])]
|
||||||
|
vesting_signer: AccountInfo<'info>,
|
||||||
|
#[account("token_program.key == &token::ID")]
|
||||||
|
token_program: AccountInfo<'info>,
|
||||||
|
#[account(mut)]
|
||||||
|
whitelisted_program_vault: AccountInfo<'info>,
|
||||||
|
whitelisted_program_vault_authority: AccountInfo<'info>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Accounts)]
|
||||||
|
pub struct AvailableForWithdrawal<'info> {
|
||||||
|
vesting: ProgramAccount<'info, Vesting>,
|
||||||
|
clock: Sysvar<'info, Clock>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[account]
|
||||||
|
pub struct Vesting {
|
||||||
|
/// The owner of this Vesting account.
|
||||||
|
pub beneficiary: Pubkey,
|
||||||
|
/// The mint of the SPL token locked up.
|
||||||
|
pub mint: Pubkey,
|
||||||
|
/// Address of the account's token vault.
|
||||||
|
pub vault: Pubkey,
|
||||||
|
/// The owner of the token account funding this account.
|
||||||
|
pub grantor: Pubkey,
|
||||||
|
/// The outstanding SRM deposit backing this vesting account. All
|
||||||
|
/// withdrawals will deduct this balance.
|
||||||
|
pub outstanding: u64,
|
||||||
|
/// The starting balance of this vesting account, i.e., how much was
|
||||||
|
/// originally deposited.
|
||||||
|
pub start_balance: u64,
|
||||||
|
/// The unix timestamp at which this vesting account was created.
|
||||||
|
pub created_ts: i64,
|
||||||
|
/// The time at which vesting begins.
|
||||||
|
pub start_ts: i64,
|
||||||
|
/// The time at which all tokens are vested.
|
||||||
|
pub end_ts: i64,
|
||||||
|
/// The number of times vesting will occur. For example, if vesting
|
||||||
|
/// is once a year over seven years, this will be 7.
|
||||||
|
pub period_count: u64,
|
||||||
|
/// The amount of tokens in custody of whitelisted programs.
|
||||||
|
pub whitelist_owned: u64,
|
||||||
|
/// Signer nonce.
|
||||||
|
pub nonce: u8,
|
||||||
|
/// The program that determines when the locked account is **realized**.
|
||||||
|
/// In addition to the lockup schedule, the program provides the ability
|
||||||
|
/// for applications to determine when locked tokens are considered earned.
|
||||||
|
/// For example, when earning locked tokens via the staking program, one
|
||||||
|
/// cannot receive the tokens until unstaking. As a result, if one never
|
||||||
|
/// unstakes, one would never actually receive the locked tokens.
|
||||||
|
pub realizor: Option<Realizor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
|
||||||
|
pub struct Realizor {
|
||||||
|
/// Program to invoke to check a realization condition. This program must
|
||||||
|
/// implement the `RealizeLock` trait.
|
||||||
|
pub program: Pubkey,
|
||||||
|
/// Address of an arbitrary piece of metadata interpretable by the realizor
|
||||||
|
/// program. For example, when a vesting account is allocated, the program
|
||||||
|
/// can define its realization condition as a function of some account
|
||||||
|
/// state. The metadata is the address of that account.
|
||||||
|
///
|
||||||
|
/// In the case of staking, the metadata is a `Member` account address. When
|
||||||
|
/// the realization condition is checked, the staking program will check the
|
||||||
|
/// `Member` account defined by the `metadata` has no staked tokens.
|
||||||
|
pub metadata: Pubkey,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(AnchorSerialize, AnchorDeserialize, PartialEq, Default, Copy, Clone)]
|
||||||
|
pub struct WhitelistEntry {
|
||||||
|
pub program_id: Pubkey,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[error]
|
||||||
|
pub enum ErrorCode {
|
||||||
|
#[msg("Vesting end must be greater than the current unix timestamp.")]
|
||||||
|
InvalidTimestamp,
|
||||||
|
#[msg("The number of vesting periods must be greater than zero.")]
|
||||||
|
InvalidPeriod,
|
||||||
|
#[msg("The vesting deposit amount must be greater than zero.")]
|
||||||
|
InvalidDepositAmount,
|
||||||
|
#[msg("The Whitelist entry is not a valid program address.")]
|
||||||
|
InvalidWhitelistEntry,
|
||||||
|
#[msg("Invalid program address. Did you provide the correct nonce?")]
|
||||||
|
InvalidProgramAddress,
|
||||||
|
#[msg("Invalid vault owner.")]
|
||||||
|
InvalidVaultOwner,
|
||||||
|
#[msg("Vault amount must be zero.")]
|
||||||
|
InvalidVaultAmount,
|
||||||
|
#[msg("Insufficient withdrawal balance.")]
|
||||||
|
InsufficientWithdrawalBalance,
|
||||||
|
#[msg("Whitelist is full")]
|
||||||
|
WhitelistFull,
|
||||||
|
#[msg("Whitelist entry already exists")]
|
||||||
|
WhitelistEntryAlreadyExists,
|
||||||
|
#[msg("Balance must go up when performing a whitelist deposit")]
|
||||||
|
InsufficientWhitelistDepositAmount,
|
||||||
|
#[msg("Cannot deposit more than withdrawn")]
|
||||||
|
WhitelistDepositOverflow,
|
||||||
|
#[msg("Tried to withdraw over the specified limit")]
|
||||||
|
WhitelistWithdrawLimit,
|
||||||
|
#[msg("Whitelist entry not found.")]
|
||||||
|
WhitelistEntryNotFound,
|
||||||
|
#[msg("You do not have sufficient permissions to perform this action.")]
|
||||||
|
Unauthorized,
|
||||||
|
#[msg("You are unable to realize projected rewards until unstaking.")]
|
||||||
|
UnableToWithdrawWhileStaked,
|
||||||
|
#[msg("The given lock realizor doesn't match the vesting account.")]
|
||||||
|
InvalidLockRealizor,
|
||||||
|
#[msg("You have not realized this vesting account.")]
|
||||||
|
UnrealizedVesting,
|
||||||
|
#[msg("Invalid vesting schedule given.")]
|
||||||
|
InvalidSchedule,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, 'b, 'c, 'info> From<&mut CreateVesting<'info>>
|
||||||
|
for CpiContext<'a, 'b, 'c, 'info, Transfer<'info>>
|
||||||
|
{
|
||||||
|
fn from(accounts: &mut CreateVesting<'info>) -> CpiContext<'a, 'b, 'c, 'info, Transfer<'info>> {
|
||||||
|
let cpi_accounts = Transfer {
|
||||||
|
from: accounts.depositor.clone(),
|
||||||
|
to: accounts.vault.to_account_info(),
|
||||||
|
authority: accounts.depositor_authority.clone(),
|
||||||
|
};
|
||||||
|
let cpi_program = accounts.token_program.clone();
|
||||||
|
CpiContext::new(cpi_program, cpi_accounts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, 'b, 'c, 'info> From<&Withdraw<'info>> for CpiContext<'a, 'b, 'c, 'info, Transfer<'info>> {
|
||||||
|
fn from(accounts: &Withdraw<'info>) -> CpiContext<'a, 'b, 'c, 'info, Transfer<'info>> {
|
||||||
|
let cpi_accounts = Transfer {
|
||||||
|
from: accounts.vault.to_account_info(),
|
||||||
|
to: accounts.token.to_account_info(),
|
||||||
|
authority: accounts.vesting_signer.to_account_info(),
|
||||||
|
};
|
||||||
|
let cpi_program = accounts.token_program.to_account_info();
|
||||||
|
CpiContext::new(cpi_program, cpi_accounts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[access_control(is_whitelisted(transfer))]
|
||||||
|
pub fn whitelist_relay_cpi<'info>(
|
||||||
|
transfer: &WhitelistTransfer<'info>,
|
||||||
|
remaining_accounts: &[AccountInfo<'info>],
|
||||||
|
instruction_data: Vec<u8>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut meta_accounts = vec![
|
||||||
|
AccountMeta::new_readonly(*transfer.vesting.to_account_info().key, false),
|
||||||
|
AccountMeta::new(*transfer.vault.to_account_info().key, false),
|
||||||
|
AccountMeta::new_readonly(*transfer.vesting_signer.to_account_info().key, true),
|
||||||
|
AccountMeta::new_readonly(*transfer.token_program.to_account_info().key, false),
|
||||||
|
AccountMeta::new(
|
||||||
|
*transfer.whitelisted_program_vault.to_account_info().key,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
AccountMeta::new_readonly(
|
||||||
|
*transfer
|
||||||
|
.whitelisted_program_vault_authority
|
||||||
|
.to_account_info()
|
||||||
|
.key,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
meta_accounts.extend(remaining_accounts.iter().map(|a| {
|
||||||
|
if a.is_writable {
|
||||||
|
AccountMeta::new(*a.key, a.is_signer)
|
||||||
|
} else {
|
||||||
|
AccountMeta::new_readonly(*a.key, a.is_signer)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
let relay_instruction = Instruction {
|
||||||
|
program_id: *transfer.whitelisted_program.to_account_info().key,
|
||||||
|
accounts: meta_accounts,
|
||||||
|
data: instruction_data.to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let seeds = &[
|
||||||
|
transfer.vesting.to_account_info().key.as_ref(),
|
||||||
|
&[transfer.vesting.nonce],
|
||||||
|
];
|
||||||
|
let signer = &[&seeds[..]];
|
||||||
|
let mut accounts = transfer.to_account_infos();
|
||||||
|
accounts.extend_from_slice(&remaining_accounts);
|
||||||
|
program::invoke_signed(&relay_instruction, &accounts, signer).map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_whitelisted<'info>(transfer: &WhitelistTransfer<'info>) -> Result<()> {
|
||||||
|
if !transfer.lockup.whitelist.contains(&WhitelistEntry {
|
||||||
|
program_id: *transfer.whitelisted_program.key,
|
||||||
|
}) {
|
||||||
|
return Err(ErrorCode::WhitelistEntryNotFound.into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn whitelist_auth(lockup: &Lockup, ctx: &Context<Auth>) -> Result<()> {
|
||||||
|
if &lockup.authority != ctx.accounts.authority.key {
|
||||||
|
return Err(ErrorCode::Unauthorized.into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_valid_schedule(start_ts: i64, end_ts: i64, period_count: u64) -> bool {
|
||||||
|
if end_ts <= start_ts {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if period_count > (end_ts - start_ts) as u64 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if period_count == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns Ok if the locked vesting account has been "realized". Realization
|
||||||
|
// is application dependent. For example, in the case of staking, one must first
|
||||||
|
// unstake before being able to earn locked tokens.
|
||||||
|
fn is_realized(ctx: &Context<Withdraw>) -> Result<()> {
|
||||||
|
if let Some(realizor) = &ctx.accounts.vesting.realizor {
|
||||||
|
let cpi_program = {
|
||||||
|
let p = ctx.remaining_accounts[0].clone();
|
||||||
|
if p.key != &realizor.program {
|
||||||
|
return Err(ErrorCode::InvalidLockRealizor.into());
|
||||||
|
}
|
||||||
|
p
|
||||||
|
};
|
||||||
|
let cpi_accounts = ctx.remaining_accounts.to_vec()[1..].to_vec();
|
||||||
|
let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts);
|
||||||
|
let vesting = (*ctx.accounts.vesting).clone();
|
||||||
|
realize_lock::is_realized(cpi_ctx, vesting).map_err(|_| ErrorCode::UnrealizedVesting)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RealizeLock defines the interface an external program must implement if
|
||||||
|
/// they want to define a "realization condition" on a locked vesting account.
|
||||||
|
/// This condition must be satisfied *even if a vesting schedule has
|
||||||
|
/// completed*. Otherwise the user can never earn the locked funds. For example,
|
||||||
|
/// in the case of the staking program, one cannot received a locked reward
|
||||||
|
/// until one has completely unstaked.
|
||||||
|
#[interface]
|
||||||
|
pub trait RealizeLock<'info, T: Accounts<'info>> {
|
||||||
|
fn is_realized(ctx: Context<T>, v: Vesting) -> ProgramResult;
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
[package]
|
||||||
|
name = "registry"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Created with Anchor"
|
||||||
|
edition = "2018"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "lib"]
|
||||||
|
name = "registry"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
no-entrypoint = []
|
||||||
|
cpi = ["no-entrypoint"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anchor-lang = "0.2.1"
|
||||||
|
anchor-spl = "0.2.1"
|
||||||
|
lockup = { path = "../lockup", features = ["cpi"] }
|
|
@ -0,0 +1,2 @@
|
||||||
|
[target.bpfel-unknown-unknown.dependencies.std]
|
||||||
|
features = []
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,946 @@
|
||||||
|
const assert = require("assert");
|
||||||
|
const anchor = require("@project-serum/anchor");
|
||||||
|
const serumCmn = require("@project-serum/common");
|
||||||
|
const TokenInstructions = require("@project-serum/serum").TokenInstructions;
|
||||||
|
const utils = require("./utils");
|
||||||
|
|
||||||
|
describe("Lockup and Registry", () => {
|
||||||
|
// Read the provider from the configured environmnet.
|
||||||
|
const provider = anchor.Provider.env();
|
||||||
|
|
||||||
|
// Configure the client to use the provider.
|
||||||
|
anchor.setProvider(provider);
|
||||||
|
|
||||||
|
const lockup = anchor.workspace.Lockup;
|
||||||
|
const registry = anchor.workspace.Registry;
|
||||||
|
|
||||||
|
let lockupAddress = null;
|
||||||
|
const WHITELIST_SIZE = 10;
|
||||||
|
|
||||||
|
let mint = null;
|
||||||
|
let god = null;
|
||||||
|
|
||||||
|
it("Sets up initial test state", async () => {
|
||||||
|
const [_mint, _god] = await serumCmn.createMintAndVault(
|
||||||
|
provider,
|
||||||
|
new anchor.BN(1000000)
|
||||||
|
);
|
||||||
|
mint = _mint;
|
||||||
|
god = _god;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Is initialized!", async () => {
|
||||||
|
await lockup.state.rpc.new({
|
||||||
|
accounts: {
|
||||||
|
authority: provider.wallet.publicKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
lockupAddress = await lockup.state.address();
|
||||||
|
const lockupAccount = await lockup.state();
|
||||||
|
|
||||||
|
assert.ok(lockupAccount.authority.equals(provider.wallet.publicKey));
|
||||||
|
assert.ok(lockupAccount.whitelist.length === WHITELIST_SIZE);
|
||||||
|
lockupAccount.whitelist.forEach((e) => {
|
||||||
|
assert.ok(e.programId.equals(new anchor.web3.PublicKey()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Deletes the default whitelisted addresses", async () => {
|
||||||
|
const defaultEntry = { programId: new anchor.web3.PublicKey() };
|
||||||
|
await lockup.state.rpc.whitelistDelete(defaultEntry, {
|
||||||
|
accounts: {
|
||||||
|
authority: provider.wallet.publicKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Sets a new authority", async () => {
|
||||||
|
const newAuthority = new anchor.web3.Account();
|
||||||
|
await lockup.state.rpc.setAuthority(newAuthority.publicKey, {
|
||||||
|
accounts: {
|
||||||
|
authority: provider.wallet.publicKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let lockupAccount = await lockup.state();
|
||||||
|
assert.ok(lockupAccount.authority.equals(newAuthority.publicKey));
|
||||||
|
|
||||||
|
await lockup.state.rpc.setAuthority(provider.wallet.publicKey, {
|
||||||
|
accounts: {
|
||||||
|
authority: newAuthority.publicKey,
|
||||||
|
},
|
||||||
|
signers: [newAuthority],
|
||||||
|
});
|
||||||
|
|
||||||
|
lockupAccount = await lockup.state();
|
||||||
|
assert.ok(lockupAccount.authority.equals(provider.wallet.publicKey));
|
||||||
|
});
|
||||||
|
|
||||||
|
const entries = [];
|
||||||
|
|
||||||
|
it("Adds to the whitelist", async () => {
|
||||||
|
const generateEntry = async () => {
|
||||||
|
let programId = new anchor.web3.Account().publicKey;
|
||||||
|
return {
|
||||||
|
programId,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let k = 0; k < WHITELIST_SIZE; k += 1) {
|
||||||
|
entries.push(await generateEntry());
|
||||||
|
}
|
||||||
|
|
||||||
|
const accounts = {
|
||||||
|
authority: provider.wallet.publicKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
await lockup.state.rpc.whitelistAdd(entries[0], { accounts });
|
||||||
|
|
||||||
|
let lockupAccount = await lockup.state();
|
||||||
|
|
||||||
|
assert.ok(lockupAccount.whitelist.length === 1);
|
||||||
|
assert.deepEqual(lockupAccount.whitelist, [entries[0]]);
|
||||||
|
|
||||||
|
for (let k = 1; k < WHITELIST_SIZE; k += 1) {
|
||||||
|
await lockup.state.rpc.whitelistAdd(entries[k], { accounts });
|
||||||
|
}
|
||||||
|
|
||||||
|
lockupAccount = await lockup.state();
|
||||||
|
|
||||||
|
assert.deepEqual(lockupAccount.whitelist, entries);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
async () => {
|
||||||
|
const e = await generateEntry();
|
||||||
|
await lockup.state.rpc.whitelistAdd(e, { accounts });
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
assert.equal(err.code, 108);
|
||||||
|
assert.equal(err.msg, "Whitelist is full");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Removes from the whitelist", async () => {
|
||||||
|
await lockup.state.rpc.whitelistDelete(entries[0], {
|
||||||
|
accounts: {
|
||||||
|
authority: provider.wallet.publicKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let lockupAccount = await lockup.state();
|
||||||
|
assert.deepEqual(lockupAccount.whitelist, entries.slice(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
const vesting = new anchor.web3.Account();
|
||||||
|
let vestingAccount = null;
|
||||||
|
let vestingSigner = null;
|
||||||
|
|
||||||
|
it("Creates a vesting account", async () => {
|
||||||
|
const startTs = new anchor.BN(Date.now() / 1000);
|
||||||
|
const endTs = new anchor.BN(startTs.toNumber() + 5);
|
||||||
|
const periodCount = new anchor.BN(2);
|
||||||
|
const beneficiary = provider.wallet.publicKey;
|
||||||
|
const depositAmount = new anchor.BN(100);
|
||||||
|
|
||||||
|
const vault = new anchor.web3.Account();
|
||||||
|
let [
|
||||||
|
_vestingSigner,
|
||||||
|
nonce,
|
||||||
|
] = await anchor.web3.PublicKey.findProgramAddress(
|
||||||
|
[vesting.publicKey.toBuffer()],
|
||||||
|
lockup.programId
|
||||||
|
);
|
||||||
|
vestingSigner = _vestingSigner;
|
||||||
|
|
||||||
|
await lockup.rpc.createVesting(
|
||||||
|
beneficiary,
|
||||||
|
depositAmount,
|
||||||
|
nonce,
|
||||||
|
startTs,
|
||||||
|
endTs,
|
||||||
|
periodCount,
|
||||||
|
null, // Lock realizor is None.
|
||||||
|
{
|
||||||
|
accounts: {
|
||||||
|
vesting: vesting.publicKey,
|
||||||
|
vault: vault.publicKey,
|
||||||
|
depositor: god,
|
||||||
|
depositorAuthority: provider.wallet.publicKey,
|
||||||
|
tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID,
|
||||||
|
rent: anchor.web3.SYSVAR_RENT_PUBKEY,
|
||||||
|
clock: anchor.web3.SYSVAR_CLOCK_PUBKEY,
|
||||||
|
},
|
||||||
|
signers: [vesting, vault],
|
||||||
|
instructions: [
|
||||||
|
await lockup.account.vesting.createInstruction(vesting),
|
||||||
|
...(await serumCmn.createTokenAccountInstrs(
|
||||||
|
provider,
|
||||||
|
vault.publicKey,
|
||||||
|
mint,
|
||||||
|
vestingSigner
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
vestingAccount = await lockup.account.vesting(vesting.publicKey);
|
||||||
|
|
||||||
|
assert.ok(vestingAccount.beneficiary.equals(provider.wallet.publicKey));
|
||||||
|
assert.ok(vestingAccount.mint.equals(mint));
|
||||||
|
assert.ok(vestingAccount.grantor.equals(provider.wallet.publicKey));
|
||||||
|
assert.ok(vestingAccount.outstanding.eq(depositAmount));
|
||||||
|
assert.ok(vestingAccount.startBalance.eq(depositAmount));
|
||||||
|
assert.ok(vestingAccount.whitelistOwned.eq(new anchor.BN(0)));
|
||||||
|
assert.equal(vestingAccount.nonce, nonce);
|
||||||
|
assert.ok(vestingAccount.createdTs.gt(new anchor.BN(0)));
|
||||||
|
assert.ok(vestingAccount.startTs.eq(startTs));
|
||||||
|
assert.ok(vestingAccount.endTs.eq(endTs));
|
||||||
|
assert.ok(vestingAccount.realizor === null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Fails to withdraw from a vesting account before vesting", async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
async () => {
|
||||||
|
await lockup.rpc.withdraw(new anchor.BN(100), {
|
||||||
|
accounts: {
|
||||||
|
vesting: vesting.publicKey,
|
||||||
|
beneficiary: provider.wallet.publicKey,
|
||||||
|
token: god,
|
||||||
|
vault: vestingAccount.vault,
|
||||||
|
vestingSigner: vestingSigner,
|
||||||
|
tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID,
|
||||||
|
clock: anchor.web3.SYSVAR_CLOCK_PUBKEY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
assert.equal(err.code, 107);
|
||||||
|
assert.equal(err.msg, "Insufficient withdrawal balance.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Waits for a vesting period to pass", async () => {
|
||||||
|
await serumCmn.sleep(10 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Withdraws from the vesting account", async () => {
|
||||||
|
const token = await serumCmn.createTokenAccount(
|
||||||
|
provider,
|
||||||
|
mint,
|
||||||
|
provider.wallet.publicKey
|
||||||
|
);
|
||||||
|
|
||||||
|
await lockup.rpc.withdraw(new anchor.BN(100), {
|
||||||
|
accounts: {
|
||||||
|
vesting: vesting.publicKey,
|
||||||
|
beneficiary: provider.wallet.publicKey,
|
||||||
|
token,
|
||||||
|
vault: vestingAccount.vault,
|
||||||
|
vestingSigner,
|
||||||
|
tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID,
|
||||||
|
clock: anchor.web3.SYSVAR_CLOCK_PUBKEY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
vestingAccount = await lockup.account.vesting(vesting.publicKey);
|
||||||
|
assert.ok(vestingAccount.outstanding.eq(new anchor.BN(0)));
|
||||||
|
|
||||||
|
const vaultAccount = await serumCmn.getTokenAccount(
|
||||||
|
provider,
|
||||||
|
vestingAccount.vault
|
||||||
|
);
|
||||||
|
assert.ok(vaultAccount.amount.eq(new anchor.BN(0)));
|
||||||
|
|
||||||
|
const tokenAccount = await serumCmn.getTokenAccount(provider, token);
|
||||||
|
assert.ok(tokenAccount.amount.eq(new anchor.BN(100)));
|
||||||
|
});
|
||||||
|
|
||||||
|
const registrar = new anchor.web3.Account();
|
||||||
|
const rewardQ = new anchor.web3.Account();
|
||||||
|
const withdrawalTimelock = new anchor.BN(4);
|
||||||
|
const stakeRate = new anchor.BN(2);
|
||||||
|
const rewardQLen = 170;
|
||||||
|
let registrarAccount = null;
|
||||||
|
let registrarSigner = null;
|
||||||
|
let nonce = null;
|
||||||
|
let poolMint = null;
|
||||||
|
|
||||||
|
it("Creates registry genesis", async () => {
|
||||||
|
const [
|
||||||
|
_registrarSigner,
|
||||||
|
_nonce,
|
||||||
|
] = await anchor.web3.PublicKey.findProgramAddress(
|
||||||
|
[registrar.publicKey.toBuffer()],
|
||||||
|
registry.programId
|
||||||
|
);
|
||||||
|
registrarSigner = _registrarSigner;
|
||||||
|
nonce = _nonce;
|
||||||
|
poolMint = await serumCmn.createMint(provider, registrarSigner);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Initializes registry's global state", async () => {
|
||||||
|
await registry.state.rpc.new({
|
||||||
|
accounts: { lockupProgram: lockup.programId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = await registry.state();
|
||||||
|
assert.ok(state.lockupProgram.equals(lockup.programId));
|
||||||
|
|
||||||
|
// Should not allow a second initializatoin.
|
||||||
|
await assert.rejects(
|
||||||
|
async () => {
|
||||||
|
await registry.state.rpc.new(lockup.programId);
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Initializes the registrar", async () => {
|
||||||
|
await registry.rpc.initialize(
|
||||||
|
mint,
|
||||||
|
provider.wallet.publicKey,
|
||||||
|
nonce,
|
||||||
|
withdrawalTimelock,
|
||||||
|
stakeRate,
|
||||||
|
rewardQLen,
|
||||||
|
{
|
||||||
|
accounts: {
|
||||||
|
registrar: registrar.publicKey,
|
||||||
|
poolMint,
|
||||||
|
rewardEventQ: rewardQ.publicKey,
|
||||||
|
rent: anchor.web3.SYSVAR_RENT_PUBKEY,
|
||||||
|
},
|
||||||
|
signers: [registrar, rewardQ],
|
||||||
|
instructions: [
|
||||||
|
await registry.account.registrar.createInstruction(registrar),
|
||||||
|
await registry.account.rewardQueue.createInstruction(rewardQ, 8250),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
registrarAccount = await registry.account.registrar(registrar.publicKey);
|
||||||
|
|
||||||
|
assert.ok(registrarAccount.authority.equals(provider.wallet.publicKey));
|
||||||
|
assert.equal(registrarAccount.nonce, nonce);
|
||||||
|
assert.ok(registrarAccount.mint.equals(mint));
|
||||||
|
assert.ok(registrarAccount.poolMint.equals(poolMint));
|
||||||
|
assert.ok(registrarAccount.stakeRate.eq(stakeRate));
|
||||||
|
assert.ok(registrarAccount.rewardEventQ.equals(rewardQ.publicKey));
|
||||||
|
assert.ok(registrarAccount.withdrawalTimelock.eq(withdrawalTimelock));
|
||||||
|
});
|
||||||
|
|
||||||
|
const member = new anchor.web3.Account();
|
||||||
|
let memberAccount = null;
|
||||||
|
let memberSigner = null;
|
||||||
|
let balances = null;
|
||||||
|
let balancesLocked = null;
|
||||||
|
|
||||||
|
it("Creates a member", async () => {
|
||||||
|
const [
|
||||||
|
_memberSigner,
|
||||||
|
nonce,
|
||||||
|
] = await anchor.web3.PublicKey.findProgramAddress(
|
||||||
|
[registrar.publicKey.toBuffer(), member.publicKey.toBuffer()],
|
||||||
|
registry.programId
|
||||||
|
);
|
||||||
|
memberSigner = _memberSigner;
|
||||||
|
|
||||||
|
const [mainTx, _balances] = await utils.createBalanceSandbox(
|
||||||
|
provider,
|
||||||
|
registrarAccount,
|
||||||
|
memberSigner
|
||||||
|
);
|
||||||
|
const [lockedTx, _balancesLocked] = await utils.createBalanceSandbox(
|
||||||
|
provider,
|
||||||
|
registrarAccount,
|
||||||
|
memberSigner
|
||||||
|
);
|
||||||
|
|
||||||
|
balances = _balances;
|
||||||
|
balancesLocked = _balancesLocked;
|
||||||
|
|
||||||
|
const tx = registry.transaction.createMember(nonce, {
|
||||||
|
accounts: {
|
||||||
|
registrar: registrar.publicKey,
|
||||||
|
member: member.publicKey,
|
||||||
|
beneficiary: provider.wallet.publicKey,
|
||||||
|
memberSigner,
|
||||||
|
balances,
|
||||||
|
balancesLocked,
|
||||||
|
tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID,
|
||||||
|
rent: anchor.web3.SYSVAR_RENT_PUBKEY,
|
||||||
|
},
|
||||||
|
instructions: [await registry.account.member.createInstruction(member)],
|
||||||
|
});
|
||||||
|
|
||||||
|
const signers = [member, provider.wallet.payer];
|
||||||
|
|
||||||
|
const allTxs = [mainTx, lockedTx, { tx, signers }];
|
||||||
|
|
||||||
|
let txSigs = await provider.sendAll(allTxs);
|
||||||
|
|
||||||
|
memberAccount = await registry.account.member(member.publicKey);
|
||||||
|
|
||||||
|
assert.ok(memberAccount.registrar.equals(registrar.publicKey));
|
||||||
|
assert.ok(memberAccount.beneficiary.equals(provider.wallet.publicKey));
|
||||||
|
assert.ok(memberAccount.metadata.equals(new anchor.web3.PublicKey()));
|
||||||
|
assert.equal(
|
||||||
|
JSON.stringify(memberAccount.balances),
|
||||||
|
JSON.stringify(balances)
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
JSON.stringify(memberAccount.balancesLocked),
|
||||||
|
JSON.stringify(balancesLocked)
|
||||||
|
);
|
||||||
|
assert.ok(memberAccount.rewardsCursor === 0);
|
||||||
|
assert.ok(memberAccount.lastStakeTs.eq(new anchor.BN(0)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Deposits (unlocked) to a member", async () => {
|
||||||
|
const depositAmount = new anchor.BN(120);
|
||||||
|
await registry.rpc.deposit(depositAmount, {
|
||||||
|
accounts: {
|
||||||
|
depositor: god,
|
||||||
|
depositorAuthority: provider.wallet.publicKey,
|
||||||
|
tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID,
|
||||||
|
vault: memberAccount.balances.vault,
|
||||||
|
beneficiary: provider.wallet.publicKey,
|
||||||
|
member: member.publicKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const memberVault = await serumCmn.getTokenAccount(
|
||||||
|
provider,
|
||||||
|
memberAccount.balances.vault
|
||||||
|
);
|
||||||
|
assert.ok(memberVault.amount.eq(depositAmount));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Stakes to a member (unlocked)", async () => {
|
||||||
|
const stakeAmount = new anchor.BN(10);
|
||||||
|
await registry.rpc.stake(stakeAmount, false, {
|
||||||
|
accounts: {
|
||||||
|
// Stake instance.
|
||||||
|
registrar: registrar.publicKey,
|
||||||
|
rewardEventQ: rewardQ.publicKey,
|
||||||
|
poolMint,
|
||||||
|
// Member.
|
||||||
|
member: member.publicKey,
|
||||||
|
beneficiary: provider.wallet.publicKey,
|
||||||
|
balances,
|
||||||
|
balancesLocked,
|
||||||
|
// Program signers.
|
||||||
|
memberSigner,
|
||||||
|
registrarSigner,
|
||||||
|
// Misc.
|
||||||
|
clock: anchor.web3.SYSVAR_CLOCK_PUBKEY,
|
||||||
|
tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const vault = await serumCmn.getTokenAccount(
|
||||||
|
provider,
|
||||||
|
memberAccount.balances.vault
|
||||||
|
);
|
||||||
|
const vaultStake = await serumCmn.getTokenAccount(
|
||||||
|
provider,
|
||||||
|
memberAccount.balances.vaultStake
|
||||||
|
);
|
||||||
|
const spt = await serumCmn.getTokenAccount(
|
||||||
|
provider,
|
||||||
|
memberAccount.balances.spt
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(vault.amount.eq(new anchor.BN(100)));
|
||||||
|
assert.ok(vaultStake.amount.eq(new anchor.BN(20)));
|
||||||
|
assert.ok(spt.amount.eq(new anchor.BN(10)));
|
||||||
|
});
|
||||||
|
|
||||||
|
const unlockedVendor = new anchor.web3.Account();
|
||||||
|
const unlockedVendorVault = new anchor.web3.Account();
|
||||||
|
let unlockedVendorSigner = null;
|
||||||
|
|
||||||
|
it("Drops an unlocked reward", async () => {
|
||||||
|
const rewardKind = {
|
||||||
|
unlocked: {},
|
||||||
|
};
|
||||||
|
const rewardAmount = new anchor.BN(200);
|
||||||
|
const expiry = new anchor.BN(Date.now() / 1000 + 5);
|
||||||
|
const [
|
||||||
|
_vendorSigner,
|
||||||
|
nonce,
|
||||||
|
] = await anchor.web3.PublicKey.findProgramAddress(
|
||||||
|
[registrar.publicKey.toBuffer(), unlockedVendor.publicKey.toBuffer()],
|
||||||
|
registry.programId
|
||||||
|
);
|
||||||
|
unlockedVendorSigner = _vendorSigner;
|
||||||
|
|
||||||
|
await registry.rpc.dropReward(
|
||||||
|
rewardKind,
|
||||||
|
rewardAmount,
|
||||||
|
expiry,
|
||||||
|
provider.wallet.publicKey,
|
||||||
|
nonce,
|
||||||
|
{
|
||||||
|
accounts: {
|
||||||
|
registrar: registrar.publicKey,
|
||||||
|
rewardEventQ: rewardQ.publicKey,
|
||||||
|
poolMint,
|
||||||
|
|
||||||
|
vendor: unlockedVendor.publicKey,
|
||||||
|
vendorVault: unlockedVendorVault.publicKey,
|
||||||
|
|
||||||
|
depositor: god,
|
||||||
|
depositorAuthority: provider.wallet.publicKey,
|
||||||
|
|
||||||
|
tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID,
|
||||||
|
clock: anchor.web3.SYSVAR_CLOCK_PUBKEY,
|
||||||
|
rent: anchor.web3.SYSVAR_RENT_PUBKEY,
|
||||||
|
},
|
||||||
|
signers: [unlockedVendorVault, unlockedVendor],
|
||||||
|
instructions: [
|
||||||
|
...(await serumCmn.createTokenAccountInstrs(
|
||||||
|
provider,
|
||||||
|
unlockedVendorVault.publicKey,
|
||||||
|
mint,
|
||||||
|
unlockedVendorSigner
|
||||||
|
)),
|
||||||
|
await registry.account.rewardVendor.createInstruction(unlockedVendor),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const vendorAccount = await registry.account.rewardVendor(
|
||||||
|
unlockedVendor.publicKey
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(vendorAccount.registrar.equals(registrar.publicKey));
|
||||||
|
assert.ok(vendorAccount.vault.equals(unlockedVendorVault.publicKey));
|
||||||
|
assert.ok(vendorAccount.nonce === nonce);
|
||||||
|
assert.ok(vendorAccount.poolTokenSupply.eq(new anchor.BN(10)));
|
||||||
|
assert.ok(vendorAccount.expiryTs.eq(expiry));
|
||||||
|
assert.ok(vendorAccount.expiryReceiver.equals(provider.wallet.publicKey));
|
||||||
|
assert.ok(vendorAccount.total.eq(rewardAmount));
|
||||||
|
assert.ok(vendorAccount.expired === false);
|
||||||
|
assert.ok(vendorAccount.rewardEventQCursor === 0);
|
||||||
|
assert.deepEqual(vendorAccount.kind, rewardKind);
|
||||||
|
|
||||||
|
const rewardQAccount = await registry.account.rewardQueue(
|
||||||
|
rewardQ.publicKey
|
||||||
|
);
|
||||||
|
assert.ok(rewardQAccount.head === 1);
|
||||||
|
assert.ok(rewardQAccount.tail === 0);
|
||||||
|
const e = rewardQAccount.events[0];
|
||||||
|
assert.ok(e.vendor.equals(unlockedVendor.publicKey));
|
||||||
|
assert.equal(e.locked, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Collects an unlocked reward", async () => {
|
||||||
|
const token = await serumCmn.createTokenAccount(
|
||||||
|
provider,
|
||||||
|
mint,
|
||||||
|
provider.wallet.publicKey
|
||||||
|
);
|
||||||
|
await registry.rpc.claimReward({
|
||||||
|
accounts: {
|
||||||
|
to: token,
|
||||||
|
cmn: {
|
||||||
|
registrar: registrar.publicKey,
|
||||||
|
|
||||||
|
member: member.publicKey,
|
||||||
|
beneficiary: provider.wallet.publicKey,
|
||||||
|
balances,
|
||||||
|
balancesLocked,
|
||||||
|
|
||||||
|
vendor: unlockedVendor.publicKey,
|
||||||
|
vault: unlockedVendorVault.publicKey,
|
||||||
|
vendorSigner: unlockedVendorSigner,
|
||||||
|
|
||||||
|
tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID,
|
||||||
|
clock: anchor.web3.SYSVAR_CLOCK_PUBKEY,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let tokenAccount = await serumCmn.getTokenAccount(provider, token);
|
||||||
|
assert.ok(tokenAccount.amount.eq(new anchor.BN(200)));
|
||||||
|
|
||||||
|
const memberAccount = await registry.account.member(member.publicKey);
|
||||||
|
assert.ok(memberAccount.rewardsCursor == 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const lockedVendor = new anchor.web3.Account();
|
||||||
|
const lockedVendorVault = new anchor.web3.Account();
|
||||||
|
let lockedVendorSigner = null;
|
||||||
|
let lockedRewardAmount = null;
|
||||||
|
let lockedRewardKind = null;
|
||||||
|
|
||||||
|
it("Drops a locked reward", async () => {
|
||||||
|
lockedRewardKind = {
|
||||||
|
locked: {
|
||||||
|
startTs: new anchor.BN(Date.now() / 1000),
|
||||||
|
endTs: new anchor.BN(Date.now() / 1000 + 6),
|
||||||
|
periodCount: new anchor.BN(2),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
lockedRewardAmount = new anchor.BN(200);
|
||||||
|
const expiry = new anchor.BN(Date.now() / 1000 + 5);
|
||||||
|
const [
|
||||||
|
_vendorSigner,
|
||||||
|
nonce,
|
||||||
|
] = await anchor.web3.PublicKey.findProgramAddress(
|
||||||
|
[registrar.publicKey.toBuffer(), lockedVendor.publicKey.toBuffer()],
|
||||||
|
registry.programId
|
||||||
|
);
|
||||||
|
lockedVendorSigner = _vendorSigner;
|
||||||
|
|
||||||
|
await registry.rpc.dropReward(
|
||||||
|
lockedRewardKind,
|
||||||
|
lockedRewardAmount,
|
||||||
|
expiry,
|
||||||
|
provider.wallet.publicKey,
|
||||||
|
nonce,
|
||||||
|
{
|
||||||
|
accounts: {
|
||||||
|
registrar: registrar.publicKey,
|
||||||
|
rewardEventQ: rewardQ.publicKey,
|
||||||
|
poolMint,
|
||||||
|
|
||||||
|
vendor: lockedVendor.publicKey,
|
||||||
|
vendorVault: lockedVendorVault.publicKey,
|
||||||
|
|
||||||
|
depositor: god,
|
||||||
|
depositorAuthority: provider.wallet.publicKey,
|
||||||
|
|
||||||
|
tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID,
|
||||||
|
clock: anchor.web3.SYSVAR_CLOCK_PUBKEY,
|
||||||
|
rent: anchor.web3.SYSVAR_RENT_PUBKEY,
|
||||||
|
},
|
||||||
|
signers: [lockedVendorVault, lockedVendor],
|
||||||
|
instructions: [
|
||||||
|
...(await serumCmn.createTokenAccountInstrs(
|
||||||
|
provider,
|
||||||
|
lockedVendorVault.publicKey,
|
||||||
|
mint,
|
||||||
|
lockedVendorSigner
|
||||||
|
)),
|
||||||
|
await registry.account.rewardVendor.createInstruction(lockedVendor),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const vendorAccount = await registry.account.rewardVendor(
|
||||||
|
lockedVendor.publicKey
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(vendorAccount.registrar.equals(registrar.publicKey));
|
||||||
|
assert.ok(vendorAccount.vault.equals(lockedVendorVault.publicKey));
|
||||||
|
assert.ok(vendorAccount.nonce === nonce);
|
||||||
|
assert.ok(vendorAccount.poolTokenSupply.eq(new anchor.BN(10)));
|
||||||
|
assert.ok(vendorAccount.expiryTs.eq(expiry));
|
||||||
|
assert.ok(vendorAccount.expiryReceiver.equals(provider.wallet.publicKey));
|
||||||
|
assert.ok(vendorAccount.total.eq(lockedRewardAmount));
|
||||||
|
assert.ok(vendorAccount.expired === false);
|
||||||
|
assert.ok(vendorAccount.rewardEventQCursor === 1);
|
||||||
|
assert.equal(
|
||||||
|
JSON.stringify(vendorAccount.kind),
|
||||||
|
JSON.stringify(lockedRewardKind)
|
||||||
|
);
|
||||||
|
|
||||||
|
const rewardQAccount = await registry.account.rewardQueue(
|
||||||
|
rewardQ.publicKey
|
||||||
|
);
|
||||||
|
assert.ok(rewardQAccount.head === 2);
|
||||||
|
assert.ok(rewardQAccount.tail === 0);
|
||||||
|
const e = rewardQAccount.events[1];
|
||||||
|
assert.ok(e.vendor.equals(lockedVendor.publicKey));
|
||||||
|
assert.ok(e.locked === true);
|
||||||
|
});
|
||||||
|
|
||||||
|
let vendoredVesting = null;
|
||||||
|
let vendoredVestingVault = null;
|
||||||
|
let vendoredVestingSigner = null;
|
||||||
|
|
||||||
|
it("Claims a locked reward", async () => {
|
||||||
|
vendoredVesting = new anchor.web3.Account();
|
||||||
|
vendoredVestingVault = new anchor.web3.Account();
|
||||||
|
let [
|
||||||
|
_vendoredVestingSigner,
|
||||||
|
nonce,
|
||||||
|
] = await anchor.web3.PublicKey.findProgramAddress(
|
||||||
|
[vendoredVesting.publicKey.toBuffer()],
|
||||||
|
lockup.programId
|
||||||
|
);
|
||||||
|
vendoredVestingSigner = _vendoredVestingSigner;
|
||||||
|
const remainingAccounts = lockup.instruction.createVesting
|
||||||
|
.accounts({
|
||||||
|
vesting: vendoredVesting.publicKey,
|
||||||
|
vault: vendoredVestingVault.publicKey,
|
||||||
|
depositor: lockedVendorVault.publicKey,
|
||||||
|
depositorAuthority: lockedVendorSigner,
|
||||||
|
tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID,
|
||||||
|
rent: anchor.web3.SYSVAR_RENT_PUBKEY,
|
||||||
|
clock: anchor.web3.SYSVAR_CLOCK_PUBKEY,
|
||||||
|
})
|
||||||
|
// Change the signer status on the vendor signer since it's signed by the program, not the
|
||||||
|
// client.
|
||||||
|
.map((meta) =>
|
||||||
|
meta.pubkey === lockedVendorSigner ? { ...meta, isSigner: false } : meta
|
||||||
|
);
|
||||||
|
|
||||||
|
await registry.rpc.claimRewardLocked(nonce, {
|
||||||
|
accounts: {
|
||||||
|
registry: await registry.state.address(),
|
||||||
|
lockupProgram: lockup.programId,
|
||||||
|
cmn: {
|
||||||
|
registrar: registrar.publicKey,
|
||||||
|
|
||||||
|
member: member.publicKey,
|
||||||
|
beneficiary: provider.wallet.publicKey,
|
||||||
|
balances,
|
||||||
|
balancesLocked,
|
||||||
|
|
||||||
|
vendor: lockedVendor.publicKey,
|
||||||
|
vault: lockedVendorVault.publicKey,
|
||||||
|
vendorSigner: lockedVendorSigner,
|
||||||
|
|
||||||
|
tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID,
|
||||||
|
clock: anchor.web3.SYSVAR_CLOCK_PUBKEY,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
remainingAccounts,
|
||||||
|
signers: [vendoredVesting, vendoredVestingVault],
|
||||||
|
instructions: [
|
||||||
|
await lockup.account.vesting.createInstruction(vendoredVesting),
|
||||||
|
...(await serumCmn.createTokenAccountInstrs(
|
||||||
|
provider,
|
||||||
|
vendoredVestingVault.publicKey,
|
||||||
|
mint,
|
||||||
|
vendoredVestingSigner
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const lockupAccount = await lockup.account.vesting(
|
||||||
|
vendoredVesting.publicKey
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(lockupAccount.beneficiary.equals(provider.wallet.publicKey));
|
||||||
|
assert.ok(lockupAccount.mint.equals(mint));
|
||||||
|
assert.ok(lockupAccount.vault.equals(vendoredVestingVault.publicKey));
|
||||||
|
assert.ok(lockupAccount.outstanding.eq(lockedRewardAmount));
|
||||||
|
assert.ok(lockupAccount.startBalance.eq(lockedRewardAmount));
|
||||||
|
assert.ok(lockupAccount.endTs.eq(lockedRewardKind.locked.endTs));
|
||||||
|
assert.ok(
|
||||||
|
lockupAccount.periodCount.eq(lockedRewardKind.locked.periodCount)
|
||||||
|
);
|
||||||
|
assert.ok(lockupAccount.whitelistOwned.eq(new anchor.BN(0)));
|
||||||
|
assert.ok(lockupAccount.realizor.program.equals(registry.programId));
|
||||||
|
assert.ok(lockupAccount.realizor.metadata.equals(member.publicKey));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Waits for the lockup period to pass", async () => {
|
||||||
|
await serumCmn.sleep(10 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Should fail to unlock an unrealized lockup reward", async () => {
|
||||||
|
const token = await serumCmn.createTokenAccount(
|
||||||
|
provider,
|
||||||
|
mint,
|
||||||
|
provider.wallet.publicKey
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
async () => {
|
||||||
|
const withdrawAmount = new anchor.BN(10);
|
||||||
|
await lockup.rpc.withdraw(withdrawAmount, {
|
||||||
|
accounts: {
|
||||||
|
vesting: vendoredVesting.publicKey,
|
||||||
|
beneficiary: provider.wallet.publicKey,
|
||||||
|
token,
|
||||||
|
vault: vendoredVestingVault.publicKey,
|
||||||
|
vestingSigner: vendoredVestingSigner,
|
||||||
|
tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID,
|
||||||
|
clock: anchor.web3.SYSVAR_CLOCK_PUBKEY,
|
||||||
|
},
|
||||||
|
// TODO: trait methods generated on the client. Until then, we need to manually
|
||||||
|
// specify the account metas here.
|
||||||
|
remainingAccounts: [
|
||||||
|
{ pubkey: registry.programId, isWritable: false, isSigner: false },
|
||||||
|
{ pubkey: member.publicKey, isWritable: false, isSigner: false },
|
||||||
|
{ pubkey: balances.spt, isWritable: false, isSigner: false },
|
||||||
|
{ pubkey: balancesLocked.spt, isWritable: false, isSigner: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
// Solana doesn't propagate errors across CPI. So we receive the registry's error code,
|
||||||
|
// not the lockup's.
|
||||||
|
const errorCode = "custom program error: 0x78";
|
||||||
|
assert.ok(err.toString().split(errorCode).length === 2);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const pendingWithdrawal = new anchor.web3.Account();
|
||||||
|
|
||||||
|
it("Unstakes (unlocked)", async () => {
|
||||||
|
const unstakeAmount = new anchor.BN(10);
|
||||||
|
|
||||||
|
await registry.rpc.startUnstake(unstakeAmount, false, {
|
||||||
|
accounts: {
|
||||||
|
registrar: registrar.publicKey,
|
||||||
|
rewardEventQ: rewardQ.publicKey,
|
||||||
|
poolMint,
|
||||||
|
|
||||||
|
pendingWithdrawal: pendingWithdrawal.publicKey,
|
||||||
|
member: member.publicKey,
|
||||||
|
beneficiary: provider.wallet.publicKey,
|
||||||
|
balances,
|
||||||
|
balancesLocked,
|
||||||
|
|
||||||
|
memberSigner,
|
||||||
|
|
||||||
|
tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID,
|
||||||
|
clock: anchor.web3.SYSVAR_CLOCK_PUBKEY,
|
||||||
|
rent: anchor.web3.SYSVAR_RENT_PUBKEY,
|
||||||
|
},
|
||||||
|
signers: [pendingWithdrawal],
|
||||||
|
instructions: [
|
||||||
|
await registry.account.pendingWithdrawal.createInstruction(
|
||||||
|
pendingWithdrawal
|
||||||
|
),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const vaultPw = await serumCmn.getTokenAccount(
|
||||||
|
provider,
|
||||||
|
memberAccount.balances.vaultPw
|
||||||
|
);
|
||||||
|
const vaultStake = await serumCmn.getTokenAccount(
|
||||||
|
provider,
|
||||||
|
memberAccount.balances.vaultStake
|
||||||
|
);
|
||||||
|
const spt = await serumCmn.getTokenAccount(
|
||||||
|
provider,
|
||||||
|
memberAccount.balances.spt
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(vaultPw.amount.eq(new anchor.BN(20)));
|
||||||
|
assert.ok(vaultStake.amount.eq(new anchor.BN(0)));
|
||||||
|
assert.ok(spt.amount.eq(new anchor.BN(0)));
|
||||||
|
});
|
||||||
|
|
||||||
|
const tryEndUnstake = async () => {
|
||||||
|
await registry.rpc.endUnstake({
|
||||||
|
accounts: {
|
||||||
|
registrar: registrar.publicKey,
|
||||||
|
|
||||||
|
member: member.publicKey,
|
||||||
|
beneficiary: provider.wallet.publicKey,
|
||||||
|
pendingWithdrawal: pendingWithdrawal.publicKey,
|
||||||
|
|
||||||
|
vault: balances.vault,
|
||||||
|
vaultPw: balances.vaultPw,
|
||||||
|
|
||||||
|
memberSigner,
|
||||||
|
|
||||||
|
clock: anchor.web3.SYSVAR_CLOCK_PUBKEY,
|
||||||
|
tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
it("Fails to end unstaking before timelock", async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
async () => {
|
||||||
|
await tryEndUnstake();
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
assert.equal(err.code, 109);
|
||||||
|
assert.equal(err.msg, "The unstake timelock has not yet expired.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Waits for the unstake period to end", async () => {
|
||||||
|
await serumCmn.sleep(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Unstake finalizes (unlocked)", async () => {
|
||||||
|
await tryEndUnstake();
|
||||||
|
|
||||||
|
const vault = await serumCmn.getTokenAccount(
|
||||||
|
provider,
|
||||||
|
memberAccount.balances.vault
|
||||||
|
);
|
||||||
|
const vaultPw = await serumCmn.getTokenAccount(
|
||||||
|
provider,
|
||||||
|
memberAccount.balances.vaultPw
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(vault.amount.eq(new anchor.BN(120)));
|
||||||
|
assert.ok(vaultPw.amount.eq(new anchor.BN(0)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Withdraws deposits (unlocked)", async () => {
|
||||||
|
const token = await serumCmn.createTokenAccount(
|
||||||
|
provider,
|
||||||
|
mint,
|
||||||
|
provider.wallet.publicKey
|
||||||
|
);
|
||||||
|
const withdrawAmount = new anchor.BN(100);
|
||||||
|
await registry.rpc.withdraw(withdrawAmount, {
|
||||||
|
accounts: {
|
||||||
|
registrar: registrar.publicKey,
|
||||||
|
member: member.publicKey,
|
||||||
|
beneficiary: provider.wallet.publicKey,
|
||||||
|
vault: memberAccount.balances.vault,
|
||||||
|
memberSigner,
|
||||||
|
depositor: token,
|
||||||
|
tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenAccount = await serumCmn.getTokenAccount(provider, token);
|
||||||
|
assert.ok(tokenAccount.amount.eq(withdrawAmount));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Should succesfully unlock a locked reward after unstaking", async () => {
|
||||||
|
const token = await serumCmn.createTokenAccount(
|
||||||
|
provider,
|
||||||
|
mint,
|
||||||
|
provider.wallet.publicKey
|
||||||
|
);
|
||||||
|
|
||||||
|
const withdrawAmount = new anchor.BN(7);
|
||||||
|
await lockup.rpc.withdraw(withdrawAmount, {
|
||||||
|
accounts: {
|
||||||
|
vesting: vendoredVesting.publicKey,
|
||||||
|
beneficiary: provider.wallet.publicKey,
|
||||||
|
token,
|
||||||
|
vault: vendoredVestingVault.publicKey,
|
||||||
|
vestingSigner: vendoredVestingSigner,
|
||||||
|
tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID,
|
||||||
|
clock: anchor.web3.SYSVAR_CLOCK_PUBKEY,
|
||||||
|
},
|
||||||
|
// TODO: trait methods generated on the client. Until then, we need to manually
|
||||||
|
// specify the account metas here.
|
||||||
|
remainingAccounts: [
|
||||||
|
{ pubkey: registry.programId, isWritable: false, isSigner: false },
|
||||||
|
{ pubkey: member.publicKey, isWritable: false, isSigner: false },
|
||||||
|
{ pubkey: balances.spt, isWritable: false, isSigner: false },
|
||||||
|
{ pubkey: balancesLocked.spt, isWritable: false, isSigner: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const tokenAccount = await serumCmn.getTokenAccount(provider, token);
|
||||||
|
assert.ok(tokenAccount.amount.eq(withdrawAmount));
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,66 @@
|
||||||
|
const anchor = require("@project-serum/anchor");
|
||||||
|
const serumCmn = require("@project-serum/common");
|
||||||
|
|
||||||
|
async function createBalanceSandbox(provider, r, registrySigner) {
|
||||||
|
const spt = new anchor.web3.Account();
|
||||||
|
const vault = new anchor.web3.Account();
|
||||||
|
const vaultStake = new anchor.web3.Account();
|
||||||
|
const vaultPw = new anchor.web3.Account();
|
||||||
|
|
||||||
|
const lamports = await provider.connection.getMinimumBalanceForRentExemption(
|
||||||
|
165
|
||||||
|
);
|
||||||
|
|
||||||
|
const createSptIx = await serumCmn.createTokenAccountInstrs(
|
||||||
|
provider,
|
||||||
|
spt.publicKey,
|
||||||
|
r.poolMint,
|
||||||
|
registrySigner,
|
||||||
|
lamports
|
||||||
|
);
|
||||||
|
const createVaultIx = await serumCmn.createTokenAccountInstrs(
|
||||||
|
provider,
|
||||||
|
vault.publicKey,
|
||||||
|
r.mint,
|
||||||
|
registrySigner,
|
||||||
|
lamports
|
||||||
|
);
|
||||||
|
const createVaultStakeIx = await serumCmn.createTokenAccountInstrs(
|
||||||
|
provider,
|
||||||
|
vaultStake.publicKey,
|
||||||
|
r.mint,
|
||||||
|
registrySigner,
|
||||||
|
lamports
|
||||||
|
);
|
||||||
|
const createVaultPwIx = await serumCmn.createTokenAccountInstrs(
|
||||||
|
provider,
|
||||||
|
vaultPw.publicKey,
|
||||||
|
r.mint,
|
||||||
|
registrySigner,
|
||||||
|
lamports
|
||||||
|
);
|
||||||
|
let tx0 = new anchor.web3.Transaction();
|
||||||
|
tx0.add(
|
||||||
|
...createSptIx,
|
||||||
|
...createVaultIx,
|
||||||
|
...createVaultStakeIx,
|
||||||
|
...createVaultPwIx
|
||||||
|
);
|
||||||
|
let signers0 = [spt, vault, vaultStake, vaultPw];
|
||||||
|
|
||||||
|
const tx = { tx: tx0, signers: signers0 };
|
||||||
|
|
||||||
|
return [
|
||||||
|
tx,
|
||||||
|
{
|
||||||
|
spt: spt.publicKey,
|
||||||
|
vault: vault.publicKey,
|
||||||
|
vaultStake: vaultStake.publicKey,
|
||||||
|
vaultPw: vaultPw.publicKey,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createBalanceSandbox,
|
||||||
|
};
|
Loading…
Reference in New Issue