Add constant-maturity lockup

Adds LockupKind::Constant, extends the reset_lockup instruction and
adds the internal_transfer instruction to allow working with constant
maturity lockups.
This commit is contained in:
Christian Kamm 2021-12-10 12:46:45 +01:00
parent b41dfae916
commit 49e137eb51
14 changed files with 842 additions and 41 deletions

View File

@ -68,6 +68,34 @@ realm authority to:
3. If necessary, later make a proposal to call `Clawback` on their deposit to
retrieve all remaining locked tokens.
## Manage Constant Maturity Deposits
Constant maturity deposits are useful when there's a vote weight bonus for
locking up tokens: With cliff or daily/monthly vested deposits the remaining
lockup period decreases as the time of maturity approaches and thus the vote
weight decreases over time as well.
Constant maturity lockup keeps tokens at a fixed maturity. That guarantees a
fixed vote weight, but also means they need to be manually transitioned to a
different lockup type before they can eventually be withdrawn.
Setting up a constant maturity lockup is easy:
1. Create a deposit entry of `Constant` lockup type with the chosen number of
days.
2. `Deposit` tokens into it.
3. Use it to vote.
If you want access to the tokens again, you need to start the unlocking process
by either
- changing the whole deposit entry to `Cliff` with `ResetLockup`, or
- creating a new `Cliff` deposit entry and transfering some locked tokens from
your `Constant` deposit entry over with `InternalTransfer`.
In both cases you'll need to wait for the cliff to be reached before being able
to access the tokens again.
# Instruction Overview
## Setup
@ -103,7 +131,13 @@ realm authority to:
- [`ResetLockup`](programs/voter-stake-registry/src/instructions/reset_lockup.rs)
Re-lock tokens where the lockup has expired, or increase the duration of the lockup.
Re-lock tokens where the lockup has expired, or increase the duration of the lockup or
change the lockup kind.
- [`InternalTransfer`](programs/voter-stake-registry/src/instructions/internal_transfer.rs)
Transfer locked tokens from one deposit entry to another. Useful for splitting off a
chunk of a "constant" lockup deposit entry that you want to start the unlock process on.
- [`UpdateVoterWeightRecord`](programs/voter-stake-registry/src/instructions/update_voter_weight_record.rs)

View File

@ -62,4 +62,12 @@ pub enum ErrorCode {
VotingMintConfiguredWithDifferentIndex,
#[msg("")]
InternalProgramError,
#[msg("")]
InsufficientLockedTokens,
#[msg("")]
MustKeepTokensLocked,
#[msg("")]
InvalidLockupKind,
#[msg("")]
InvalidChangeToClawbackDepositEntry,
}

View File

@ -79,18 +79,15 @@ pub fn deposit(ctx: Context<Deposit>, deposit_entry_index: u8, amount: u64) -> R
// - add the new funds to the locked up token count, so they will vest over
// the remaining periods.
let curr_ts = registrar.clock_unix_timestamp();
let vested_amount = d_entry.vested(curr_ts)?;
require!(
vested_amount <= d_entry.amount_initially_locked_native,
InternalProgramError
);
d_entry.amount_initially_locked_native -= vested_amount;
d_entry.lockup.remove_past_periods(curr_ts)?;
d_entry.resolve_vesting(curr_ts)?;
// Deposit tokens into the registrar and increase the lockup amount too.
token::transfer(ctx.accounts.transfer_ctx(), amount)?;
d_entry.amount_deposited_native += amount;
d_entry.amount_initially_locked_native += amount;
d_entry.amount_deposited_native = d_entry.amount_deposited_native.checked_add(amount).unwrap();
d_entry.amount_initially_locked_native = d_entry
.amount_initially_locked_native
.checked_add(amount)
.unwrap();
msg!(
"Deposited amount {} at deposit index {} with lockup kind {:?} and {} seconds left",

View File

@ -0,0 +1,87 @@
use crate::error::*;
use crate::state::*;
use anchor_lang::prelude::*;
#[derive(Accounts)]
pub struct InternalTransfer<'info> {
pub registrar: AccountLoader<'info, Registrar>,
// checking the PDA address it just an extra precaution,
// the other constraints must be exhaustive
#[account(
mut,
seeds = [registrar.key().as_ref(), b"voter".as_ref(), voter_authority.key().as_ref()],
bump = voter.load()?.voter_bump,
has_one = voter_authority,
has_one = registrar)]
pub voter: AccountLoader<'info, Voter>,
pub voter_authority: Signer<'info>,
}
/// Transfers locked tokens from the source deposit entry to the target deposit entry.
///
/// The target deposit entry must have equal or longer lockup period, and be of a kind
/// that is at least equally strict.
///
/// Note that this never transfers withdrawable tokens, only tokens that are still
/// locked up.
///
/// The primary usecases are:
/// - consolidating multiple small deposit entries into a single big one for cleanup
/// - transfering a small part of a big "constant" lockup deposit entry into a "cliff"
/// locked deposit entry to start the unlocking process (reset_lockup could only
/// change the whole deposit entry to "cliff")
pub fn internal_transfer(
ctx: Context<InternalTransfer>,
source_deposit_entry_index: u8,
target_deposit_entry_index: u8,
amount: u64,
) -> Result<()> {
let registrar = &ctx.accounts.registrar.load()?;
let voter = &mut ctx.accounts.voter.load_mut()?;
let curr_ts = registrar.clock_unix_timestamp();
let source = voter.active_deposit_mut(source_deposit_entry_index)?;
source.resolve_vesting(curr_ts)?;
let source_seconds_left = source.lockup.seconds_left(curr_ts);
let source_strictness = source.lockup.kind.strictness();
let source_mint_idx = source.voting_mint_config_idx;
// Allowing transfers from clawback-enabled deposits could be used to avoid
// clawback by making proposal instructions target the wrong entry index.
require!(!source.allow_clawback, InvalidChangeToClawbackDepositEntry);
// Reduce source amounts
require!(
amount <= source.amount_initially_locked_native,
InsufficientLockedTokens
);
source.amount_deposited_native = source.amount_deposited_native.checked_sub(amount).unwrap();
source.amount_initially_locked_native =
source.amount_initially_locked_native.saturating_sub(amount);
// Check target compatibility
let target = voter.active_deposit_mut(target_deposit_entry_index)?;
target.resolve_vesting(curr_ts)?;
require!(
target.voting_mint_config_idx == source_mint_idx,
InvalidMint
);
require!(
target.lockup.seconds_left(curr_ts) >= source_seconds_left,
InvalidLockupPeriod
);
require!(
target.lockup.kind.strictness() >= source_strictness,
InvalidLockupKind
);
// Add target amounts
target.amount_deposited_native = target.amount_deposited_native.checked_add(amount).unwrap();
target.amount_initially_locked_native = target
.amount_initially_locked_native
.checked_add(amount)
.unwrap();
Ok(())
}

View File

@ -7,6 +7,7 @@ pub use create_registrar::*;
pub use create_voter::*;
pub use deposit::*;
pub use grant::*;
pub use internal_transfer::*;
pub use reset_lockup::*;
pub use set_time_offset::*;
pub use update_max_vote_weight::*;
@ -22,6 +23,7 @@ mod create_registrar;
mod create_voter;
mod deposit;
mod grant;
mod internal_transfer;
mod reset_lockup;
mod set_time_offset;
mod update_max_vote_weight;

View File

@ -4,9 +4,10 @@ use anchor_lang::prelude::*;
#[derive(Accounts)]
pub struct ResetLockup<'info> {
pub registrar: AccountLoader<'info, Registrar>,
// checking the PDA address it just an extra precaution,
// the other constraints must be exhaustive
pub registrar: AccountLoader<'info, Registrar>,
#[account(
mut,
seeds = [registrar.key().as_ref(), b"voter".as_ref(), voter_authority.key().as_ref()],
@ -23,23 +24,33 @@ pub struct ResetLockup<'info> {
pub fn reset_lockup(
ctx: Context<ResetLockup>,
deposit_entry_index: u8,
kind: LockupKind,
periods: u32,
) -> Result<()> {
let registrar = &ctx.accounts.registrar.load()?;
let voter = &mut ctx.accounts.voter.load_mut()?;
let d = voter.active_deposit_mut(deposit_entry_index)?;
// The lockup period can only be increased.
let curr_ts = registrar.clock_unix_timestamp();
require!(
periods as u64 >= d.lockup.periods_left(curr_ts)?,
InvalidDays
);
require!(periods > 0, InvalidDays);
// Lock up every deposited token again
d.amount_initially_locked_native = d.amount_deposited_native;
d.lockup = Lockup::new_from_periods(d.lockup.kind, curr_ts, periods)?;
let source = voter.active_deposit_mut(deposit_entry_index)?;
// Must not decrease duration or strictness
require!(
(periods as u64).checked_mul(kind.period_secs()).unwrap()
>= source.lockup.seconds_left(curr_ts),
InvalidLockupPeriod
);
require!(
kind.strictness() >= source.lockup.kind.strictness(),
InvalidLockupKind
);
// Don't re-lock clawback deposits. Users must withdraw and create a new one.
require!(!source.allow_clawback, InvalidChangeToClawbackDepositEntry);
// Change the deposit entry.
let d_entry = voter.active_deposit_mut(deposit_entry_index)?;
d_entry.amount_initially_locked_native = d_entry.amount_deposited_native;
d_entry.lockup = Lockup::new_from_periods(kind, curr_ts, periods)?;
Ok(())
}

View File

@ -104,7 +104,10 @@ pub fn withdraw(ctx: Context<Withdraw>, deposit_entry_index: u8, amount: u64) ->
amount <= deposit_entry.amount_deposited_native,
InternalProgramError
);
deposit_entry.amount_deposited_native -= amount;
deposit_entry.amount_deposited_native = deposit_entry
.amount_deposited_native
.checked_sub(amount)
.unwrap();
// Transfer the tokens to withdraw.
let registrar_seeds = registrar_seeds!(registrar);

View File

@ -145,9 +145,24 @@ pub mod voter_stake_registry {
pub fn reset_lockup(
ctx: Context<ResetLockup>,
deposit_entry_index: u8,
kind: LockupKind,
periods: u32,
) -> Result<()> {
instructions::reset_lockup(ctx, deposit_entry_index, periods)
instructions::reset_lockup(ctx, deposit_entry_index, kind, periods)
}
pub fn internal_transfer(
ctx: Context<InternalTransfer>,
source_deposit_entry_index: u8,
target_deposit_entry_index: u8,
amount: u64,
) -> Result<()> {
instructions::internal_transfer(
ctx,
source_deposit_entry_index,
target_deposit_entry_index,
amount,
)
}
pub fn update_voter_weight_record(ctx: Context<UpdateVoterWeightRecord>) -> Result<()> {

View File

@ -127,6 +127,9 @@ impl DepositEntry {
LockupKind::Cliff => {
self.voting_power_cliff(curr_ts, max_locked_vote_weight, lockup_saturation_secs)
}
LockupKind::Constant => {
self.voting_power_cliff(curr_ts, max_locked_vote_weight, lockup_saturation_secs)
}
}
}
@ -255,6 +258,7 @@ impl DepositEntry {
LockupKind::Daily => self.vested_linearly(curr_ts),
LockupKind::Monthly => self.vested_linearly(curr_ts),
LockupKind::Cliff => Ok(0),
LockupKind::Constant => Ok(0),
}
}
@ -292,4 +296,106 @@ impl DepositEntry {
.checked_sub(self.amount_locked(curr_ts))
.unwrap()
}
/// Adjusts the deposit and remaining lockup periods such that
/// no parts of amount_initially_locked_native have vested.
///
/// That makes it easier to deal with changes to the locked
/// amount because amount_initially_locked_native represents
/// exactly the amount that is locked.
///
/// Example:
/// If 30 tokens are locked up over 3 months, vesting each month,
/// then after month 2:
/// amount_initially_locked_native = 30
/// amount_deposited_native = 30
/// vested() = 20
/// period_current() = 2
/// periods_total() = 3
/// And after this function was called:
/// amount_initially_locked_native = 10
/// amount_deposited_native = 30
/// vested() = 0
/// period_current() = 0
/// periods_total() = 1
pub fn resolve_vesting(&mut self, curr_ts: i64) -> Result<()> {
let vested_amount = self.vested(curr_ts)?;
require!(
vested_amount <= self.amount_initially_locked_native,
InternalProgramError
);
self.amount_initially_locked_native -= vested_amount;
self.lockup.remove_past_periods(curr_ts)?;
require!(self.vested(curr_ts)? == 0, InternalProgramError);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
pub fn resolve_vesting() -> Result<()> {
let mut deposit = DepositEntry {
amount_deposited_native: 35,
amount_initially_locked_native: 30,
lockup: Lockup::new_from_periods(LockupKind::Monthly, 1000, 3).unwrap(),
is_used: true,
allow_clawback: false,
voting_mint_config_idx: 0,
padding: [0; 13],
};
let initial_deposit = deposit.clone();
let month = deposit.lockup.kind.period_secs() as i64;
// function to avoid unaligned references when used with assert!()
let amount_initially_locked =
|deposit: &DepositEntry| deposit.amount_initially_locked_native;
let mut time = 1001;
assert_eq!(deposit.vested(time).unwrap(), 0);
assert_eq!(deposit.amount_withdrawable(time), 5);
deposit.resolve_vesting(time).unwrap(); // no effect
assert_eq!(deposit.vested(time).unwrap(), 0);
assert_eq!(deposit.amount_withdrawable(time), 5);
assert_eq!(
deposit.lockup.seconds_left(time),
initial_deposit.lockup.seconds_left(time)
);
assert_eq!(deposit.lockup.period_current(time).unwrap(), 0);
assert_eq!(deposit.lockup.periods_total().unwrap(), 3);
assert_eq!(amount_initially_locked(&deposit), 30);
time = 1001 + month;
assert_eq!(deposit.vested(time).unwrap(), 10);
assert_eq!(deposit.lockup.period_current(time).unwrap(), 1);
assert_eq!(deposit.lockup.periods_total().unwrap(), 3);
deposit.resolve_vesting(time).unwrap();
assert_eq!(deposit.vested(time).unwrap(), 0);
assert_eq!(deposit.amount_withdrawable(time), 15);
assert_eq!(
deposit.lockup.seconds_left(time),
initial_deposit.lockup.seconds_left(time)
);
assert_eq!(deposit.lockup.period_current(time).unwrap(), 0);
assert_eq!(deposit.lockup.periods_total().unwrap(), 2);
assert_eq!(amount_initially_locked(&deposit), 20);
time = 1001 + 3 * month;
assert_eq!(deposit.vested(time).unwrap(), 20);
assert_eq!(deposit.lockup.period_current(time).unwrap(), 2);
assert_eq!(deposit.lockup.periods_total().unwrap(), 2);
deposit.resolve_vesting(time).unwrap();
assert_eq!(deposit.vested(time).unwrap(), 0);
assert_eq!(deposit.amount_withdrawable(time), 35);
assert_eq!(
deposit.lockup.seconds_left(time),
initial_deposit.lockup.seconds_left(time)
);
assert_eq!(deposit.lockup.period_current(time).unwrap(), 0);
assert_eq!(deposit.lockup.periods_total().unwrap(), 0);
assert_eq!(amount_initially_locked(&deposit), 0);
Ok(())
}
}

View File

@ -76,7 +76,10 @@ impl Lockup {
/// Number of seconds left in the lockup.
/// May be more than end_ts-start_ts if curr_ts < start_ts.
pub fn seconds_left(&self, curr_ts: i64) -> u64 {
pub fn seconds_left(&self, mut curr_ts: i64) -> u64 {
if self.kind == LockupKind::Constant {
curr_ts = self.start_ts;
}
if curr_ts >= self.end_ts {
0
} else {
@ -135,10 +138,21 @@ impl Lockup {
#[repr(u8)]
#[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, Copy, PartialEq)]
pub enum LockupKind {
/// No lockup, tokens can be withdrawn as long as not engaged in a proposal.
None,
/// Lock up for a number of days, where a linear fraction vests each day.
Daily,
/// Lock up for a number of months, where a linear fraction vests each month.
Monthly,
/// Lock up for a number of days, no vesting.
Cliff,
/// Lock up permanently. The number of days specified becomes the minimum
/// unlock period when the deposit (or a part of it) is changed to Cliff.
Constant,
}
impl LockupKind {
@ -152,6 +166,18 @@ impl LockupKind {
LockupKind::Daily => SECS_PER_DAY,
LockupKind::Monthly => SECS_PER_MONTH,
LockupKind::Cliff => SECS_PER_DAY, // arbitrary choice
LockupKind::Constant => SECS_PER_DAY, // arbitrary choice
}
}
/// Lockups cannot decrease in strictness
pub fn strictness(&self) -> u8 {
match self {
LockupKind::None => 0,
LockupKind::Daily => 1,
LockupKind::Monthly => 2,
LockupKind::Cliff => 3, // can freely move between Cliff and Constant
LockupKind::Constant => 3,
}
}
}

View File

@ -566,11 +566,13 @@ impl AddinCookie {
voter: &VoterCookie,
authority: &Keypair,
deposit_entry_index: u8,
kind: voter_stake_registry::state::LockupKind,
periods: u32,
) -> Result<(), TransportError> {
let data =
anchor_lang::InstructionData::data(&voter_stake_registry::instruction::ResetLockup {
deposit_entry_index,
kind,
periods,
});
@ -597,6 +599,47 @@ impl AddinCookie {
.await
}
#[allow(dead_code)]
pub async fn internal_transfer(
&self,
registrar: &RegistrarCookie,
voter: &VoterCookie,
authority: &Keypair,
source_deposit_entry_index: u8,
target_deposit_entry_index: u8,
amount: u64,
) -> Result<(), TransportError> {
let data = anchor_lang::InstructionData::data(
&voter_stake_registry::instruction::InternalTransfer {
source_deposit_entry_index,
target_deposit_entry_index,
amount,
},
);
let accounts = anchor_lang::ToAccountMetas::to_account_metas(
&voter_stake_registry::accounts::InternalTransfer {
registrar: registrar.address,
voter: voter.address,
voter_authority: authority.pubkey(),
},
None,
);
let instructions = vec![Instruction {
program_id: self.program_id,
accounts,
data,
}];
// clone the secrets
let signer = Keypair::from_base58_string(&authority.to_base58_string());
self.solana
.process_transaction(&instructions, Some(&[&signer]))
.await
}
#[allow(dead_code)]
pub async fn set_time_offset(
&self,

View File

@ -0,0 +1,211 @@
use anchor_spl::token::TokenAccount;
use program_test::*;
use solana_program_test::*;
use solana_sdk::{pubkey::Pubkey, signature::Keypair, signer::Signer, transport::TransportError};
mod program_test;
struct Balances {
token: u64,
vault: u64,
deposit: u64,
voter_weight: u64,
}
async fn balances(
context: &TestContext,
registrar: &RegistrarCookie,
address: Pubkey,
voter: &VoterCookie,
voting_mint: &VotingMintConfigCookie,
deposit_id: u8,
) -> Balances {
// Advance slots to avoid caching of the UpdateVoterWeightRecord call
// TODO: Is this something that could be an issue on a live node?
context.solana.advance_clock_by_slots(2).await;
let token = context.solana.token_account_balance(address).await;
let vault = voting_mint.vault_balance(&context.solana).await;
let deposit = voter.deposit_amount(&context.solana, deposit_id).await;
let vwr = context
.addin
.update_voter_weight_record(&registrar, &voter)
.await
.unwrap();
Balances {
token,
vault,
deposit,
voter_weight: vwr.voter_weight,
}
}
#[allow(unaligned_references)]
#[tokio::test]
async fn test_deposit_constant() -> Result<(), TransportError> {
let context = TestContext::new().await;
let addin = &context.addin;
let payer = &context.users[0].key;
let realm_authority = Keypair::new();
let realm = context
.governance
.create_realm(
"testrealm",
realm_authority.pubkey(),
&context.mints[0],
&payer,
&context.addin.program_id,
)
.await;
let voter_authority = &context.users[1].key;
let token_owner_record = realm
.create_token_owner_record(voter_authority.pubkey(), &payer)
.await;
let registrar = addin
.create_registrar(&realm, &realm_authority, payer)
.await;
let mngo_voting_mint = addin
.configure_voting_mint(
&registrar,
&realm_authority,
payer,
0,
&context.mints[0],
0,
1.0,
1.0,
2 * 24 * 60 * 60,
None,
)
.await;
let voter = addin
.create_voter(&registrar, &token_owner_record, &voter_authority, &payer)
.await;
let reference_account = context.users[1].token_accounts[0];
let get_balances = |depot_id| {
balances(
&context,
&registrar,
reference_account,
&voter,
&mngo_voting_mint,
depot_id,
)
};
let withdraw = |amount: u64| {
addin.withdraw(
&registrar,
&voter,
&mngo_voting_mint,
&voter_authority,
reference_account,
0,
amount,
)
};
let deposit = |amount: u64| {
addin.deposit(
&registrar,
&voter,
&mngo_voting_mint,
&voter_authority,
reference_account,
0,
amount,
)
};
// test deposit and withdraw
let initial = get_balances(0).await;
assert_eq!(initial.vault, 0);
assert_eq!(initial.deposit, 0);
addin
.create_deposit_entry(
&registrar,
&voter,
&voter_authority,
&mngo_voting_mint,
0,
voter_stake_registry::state::LockupKind::Constant,
2, // days
false,
)
.await
.unwrap();
deposit(9000).await.unwrap();
let after_deposit = get_balances(0).await;
assert_eq!(initial.token, after_deposit.token + after_deposit.vault);
assert_eq!(after_deposit.voter_weight, 2 * after_deposit.vault); // saturated locking bonus
assert_eq!(after_deposit.vault, 9000);
assert_eq!(after_deposit.deposit, 9000);
withdraw(1).await.expect_err("all locked up");
// advance three days
addin
.set_time_offset(&registrar, &realm_authority, 3 * 24 * 60 * 60)
.await;
let after_day3 = get_balances(0).await;
assert_eq!(after_day3.voter_weight, after_deposit.voter_weight); // unchanged
withdraw(1).await.expect_err("all locked up");
deposit(1000).await.unwrap();
let after_deposit = get_balances(0).await;
assert_eq!(initial.token, after_deposit.token + after_deposit.vault);
assert_eq!(after_deposit.voter_weight, 2 * after_deposit.vault); // saturated locking bonus
assert_eq!(after_deposit.vault, 10000);
assert_eq!(after_deposit.deposit, 10000);
withdraw(1).await.expect_err("all locked up");
// Change the whole thing to cliff lockup
addin
.reset_lockup(
&registrar,
&voter,
&voter_authority,
0,
voter_stake_registry::state::LockupKind::Cliff,
1,
)
.await
.expect_err("can't reduce period");
addin
.reset_lockup(
&registrar,
&voter,
&voter_authority,
0,
voter_stake_registry::state::LockupKind::Cliff,
2,
)
.await
.unwrap();
let after_reset = get_balances(0).await;
assert_eq!(initial.token, after_reset.token + after_reset.vault);
assert_eq!(after_reset.voter_weight, 2 * after_reset.vault); // saturated locking bonus
assert_eq!(after_reset.vault, 10000);
assert_eq!(after_reset.deposit, 10000);
withdraw(1).await.expect_err("all locked up");
// advance to six days
addin
.set_time_offset(&registrar, &realm_authority, 6 * 24 * 60 * 60)
.await;
withdraw(10000).await.unwrap();
Ok(())
}

View File

@ -0,0 +1,241 @@
use anchor_spl::token::TokenAccount;
use program_test::*;
use solana_program_test::*;
use solana_sdk::{pubkey::Pubkey, signature::Keypair, signer::Signer, transport::TransportError};
use std::cell::RefCell;
use std::sync::Arc;
use voter_stake_registry::state::LockupKind;
mod program_test;
async fn get_lockup_data(
solana: &SolanaCookie,
voter: Pubkey,
index: u8,
time_offset: i64,
) -> (u64, u64, u64, u64, u64) {
let now = solana.get_clock().await.unix_timestamp + time_offset;
let voter = solana
.get_account::<voter_stake_registry::state::Voter>(voter)
.await;
let d = voter.deposits[index as usize];
let duration = d.lockup.periods_total().unwrap() * d.lockup.kind.period_secs();
(
// time since lockup start (saturating at "duration")
(duration - d.lockup.seconds_left(now)) as u64,
// duration of lockup
duration,
d.amount_initially_locked_native,
d.amount_deposited_native,
d.amount_withdrawable(now),
)
}
#[allow(unaligned_references)]
#[tokio::test]
async fn test_internal_transfer() -> Result<(), TransportError> {
let context = TestContext::new().await;
let addin = &context.addin;
let payer = &context.users[0].key;
let realm_authority = Keypair::new();
let realm = context
.governance
.create_realm(
"testrealm",
realm_authority.pubkey(),
&context.mints[0],
&payer,
&context.addin.program_id,
)
.await;
let voter_authority = &context.users[1].key;
let token_owner_record = realm
.create_token_owner_record(voter_authority.pubkey(), &payer)
.await;
let registrar = addin
.create_registrar(&realm, &realm_authority, payer)
.await;
let mngo_voting_mint = addin
.configure_voting_mint(
&registrar,
&realm_authority,
payer,
0,
&context.mints[0],
0,
1.0,
0.0,
5 * 365 * 24 * 60 * 60,
None,
)
.await;
let voter = addin
.create_voter(&registrar, &token_owner_record, &voter_authority, &payer)
.await;
let reference_account = context.users[1].token_accounts[0];
let deposit = |index: u8, amount: u64| {
addin.deposit(
&registrar,
&voter,
&mngo_voting_mint,
&voter_authority,
reference_account,
index,
amount,
)
};
let internal_transfer = |source: u8, target: u8, amount: u64| {
addin.internal_transfer(&registrar, &voter, &voter_authority, source, target, amount)
};
let time_offset = Arc::new(RefCell::new(0i64));
let advance_time = |extra: u64| {
*time_offset.borrow_mut() += extra as i64;
addin.set_time_offset(&registrar, &realm_authority, *time_offset.borrow())
};
let lockup_status =
|index: u8| get_lockup_data(&context.solana, voter.address, index, *time_offset.borrow());
let month = LockupKind::Monthly.period_secs();
let day = 24 * 60 * 60;
let hour = 60 * 60;
//
// test transfering locked funds from a partially vested deposit to another one
//
addin
.create_deposit_entry(
&registrar,
&voter,
&voter_authority,
&mngo_voting_mint,
0,
LockupKind::Monthly,
3,
false,
)
.await
.unwrap();
deposit(0, 300).await.unwrap();
advance_time(month + hour).await;
addin
.create_deposit_entry(
&registrar,
&voter,
&voter_authority,
&mngo_voting_mint,
1,
LockupKind::Daily,
3,
false,
)
.await
.unwrap();
deposit(1, 30).await.unwrap();
// both deposits have vested one period
advance_time(day + hour).await;
assert_eq!(
lockup_status(0).await,
(month + day + 2 * hour, 3 * month, 300, 300, 100)
);
assert_eq!(lockup_status(1).await, (day + hour, 3 * day, 30, 30, 10));
internal_transfer(0, 1, 1)
.await
.expect_err("can't make less strict/period");
internal_transfer(1, 0, 21)
.await
.expect_err("can only transfer locked");
internal_transfer(1, 0, 10).await.unwrap();
context.solana.advance_clock_by_slots(2).await;
assert_eq!(
lockup_status(0).await,
(day + 2 * hour, 2 * month, 210, 310, 100)
);
assert_eq!(lockup_status(1).await, (hour, 2 * day, 10, 20, 10));
//
// test partially moving tokens from constant deposit to cliff
//
addin
.create_deposit_entry(
&registrar,
&voter,
&voter_authority,
&mngo_voting_mint,
2,
LockupKind::Constant,
5,
false,
)
.await
.unwrap();
deposit(2, 1000).await.unwrap();
addin
.create_deposit_entry(
&registrar,
&voter,
&voter_authority,
&mngo_voting_mint,
3,
LockupKind::Cliff,
5,
false,
)
.await
.unwrap();
assert_eq!(lockup_status(2).await, (0, 5 * day, 1000, 1000, 0));
assert_eq!(lockup_status(3).await, (0, 5 * day, 0, 0, 0));
internal_transfer(2, 3, 100).await.unwrap();
context.solana.advance_clock_by_slots(2).await;
assert_eq!(lockup_status(2).await, (0, 5 * day, 900, 900, 0));
assert_eq!(lockup_status(3).await, (0, 5 * day, 100, 100, 0));
advance_time(2 * day + hour).await;
internal_transfer(2, 3, 100)
.await
.expect_err("target deposit has not enough period left");
addin
.create_deposit_entry(
&registrar,
&voter,
&voter_authority,
&mngo_voting_mint,
4,
LockupKind::Cliff,
8,
false,
)
.await
.unwrap();
internal_transfer(2, 4, 100).await.unwrap();
assert_eq!(lockup_status(2).await, (0, 5 * day, 800, 800, 0));
assert_eq!(
lockup_status(3).await,
(2 * day + hour, 5 * day, 100, 100, 0)
);
assert_eq!(lockup_status(4).await, (0, 8 * day, 100, 100, 0));
advance_time(day + hour).await;
context.solana.advance_clock_by_slots(2).await;
// still ok, cliff deposit 4 still has 7 days of lockup left, which is >= 5
internal_transfer(2, 4, 800).await.unwrap();
assert_eq!(lockup_status(2).await, (0, 5 * day, 0, 0, 0));
assert_eq!(lockup_status(4).await, (hour, 7 * day, 900, 900, 0));
Ok(())
}

View File

@ -4,6 +4,7 @@ use solana_program_test::*;
use solana_sdk::{pubkey::Pubkey, signature::Keypair, signer::Signer, transport::TransportError};
use std::cell::RefCell;
use std::sync::Arc;
use voter_stake_registry::state::LockupKind;
mod program_test;
@ -99,8 +100,8 @@ async fn test_reset_lockup() -> Result<(), TransportError> {
amount,
)
};
let reset_lockup = |index: u8, periods: u32| {
addin.reset_lockup(&registrar, &voter, &voter_authority, index, periods)
let reset_lockup = |index: u8, periods: u32, kind: LockupKind| {
addin.reset_lockup(&registrar, &voter, &voter_authority, index, kind, periods)
};
let time_offset = Arc::new(RefCell::new(0i64));
let advance_time = |extra: u64| {
@ -110,6 +111,7 @@ async fn test_reset_lockup() -> Result<(), TransportError> {
let lockup_status =
|index: u8| get_lockup_data(&context.solana, voter.address, index, *time_offset.borrow());
let month = LockupKind::Monthly.period_secs();
let day = 24 * 60 * 60;
let hour = 60 * 60;
@ -121,7 +123,7 @@ async fn test_reset_lockup() -> Result<(), TransportError> {
&voter_authority,
&mngo_voting_mint,
7,
voter_stake_registry::state::LockupKind::Daily,
LockupKind::Daily,
3,
false,
)
@ -131,10 +133,10 @@ async fn test_reset_lockup() -> Result<(), TransportError> {
assert_eq!(lockup_status(7).await, (0, 3 * day, 80, 80, 0));
deposit(7, 10).await.unwrap();
assert_eq!(lockup_status(7).await, (0, 3 * day, 90, 90, 0));
reset_lockup(7, 2)
reset_lockup(7, 2, LockupKind::Daily)
.await
.expect_err("can't relock for less periods");
reset_lockup(7, 3).await.unwrap(); // just resets start to current timestamp
reset_lockup(7, 3, LockupKind::Daily).await.unwrap(); // just resets start to current timestamp
assert_eq!(lockup_status(7).await, (0, 3 * day, 90, 90, 0));
// advance more than a day
@ -144,7 +146,7 @@ async fn test_reset_lockup() -> Result<(), TransportError> {
assert_eq!(lockup_status(7).await, (day + hour, 3 * day, 90, 90, 30));
deposit(7, 10).await.unwrap();
assert_eq!(lockup_status(7).await, (hour, 2 * day, 70, 100, 30));
reset_lockup(7, 10).await.unwrap();
reset_lockup(7, 10, LockupKind::Daily).await.unwrap();
assert_eq!(lockup_status(7).await, (0, 10 * day, 100, 100, 0));
// advance four more days
@ -160,12 +162,12 @@ async fn test_reset_lockup() -> Result<(), TransportError> {
lockup_status(7).await,
(4 * day + hour, 10 * day, 100, 80, 20)
);
reset_lockup(7, 5)
reset_lockup(7, 5, LockupKind::Daily)
.await
.expect_err("can't relock for less periods");
reset_lockup(7, 6).await.unwrap();
reset_lockup(7, 6, LockupKind::Daily).await.unwrap();
assert_eq!(lockup_status(7).await, (0, 6 * day, 80, 80, 0));
reset_lockup(7, 8).await.unwrap();
reset_lockup(7, 8, LockupKind::Daily).await.unwrap();
assert_eq!(lockup_status(7).await, (0, 8 * day, 80, 80, 0));
// advance three more days
@ -183,9 +185,24 @@ async fn test_reset_lockup() -> Result<(), TransportError> {
withdraw(7, 20).await.unwrap(); // partially withdraw vested
assert_eq!(lockup_status(7).await, (hour, 5 * day, 60, 70, 10));
reset_lockup(7, 10).await.unwrap();
reset_lockup(7, 10, LockupKind::Daily).await.unwrap();
assert_eq!(lockup_status(7).await, (0, 10 * day, 70, 70, 0));
reset_lockup(7, 1, LockupKind::Monthly).await.unwrap();
assert_eq!(lockup_status(7).await, (0, 1 * month, 70, 70, 0));
reset_lockup(7, 31, LockupKind::Daily)
.await
.expect_err("decreasing strictness");
reset_lockup(7, 31, LockupKind::None)
.await
.expect_err("decreasing strictness");
reset_lockup(7, 30, LockupKind::Cliff)
.await
.expect_err("period shortnend");
reset_lockup(7, 31, LockupKind::Cliff).await.unwrap();
assert_eq!(lockup_status(7).await, (0, 31 * day, 70, 70, 0));
// tests for cliff vesting
addin
.create_deposit_entry(
@ -194,7 +211,7 @@ async fn test_reset_lockup() -> Result<(), TransportError> {
&voter_authority,
&mngo_voting_mint,
5,
voter_stake_registry::state::LockupKind::Cliff,
LockupKind::Cliff,
3,
false,
)
@ -202,12 +219,12 @@ async fn test_reset_lockup() -> Result<(), TransportError> {
.unwrap();
deposit(5, 80).await.unwrap();
assert_eq!(lockup_status(5).await, (0, 3 * day, 80, 80, 0));
reset_lockup(5, 2)
reset_lockup(5, 2, LockupKind::Cliff)
.await
.expect_err("can't relock for less periods");
reset_lockup(5, 3).await.unwrap(); // just resets start to current timestamp
reset_lockup(5, 3, LockupKind::Cliff).await.unwrap(); // just resets start to current timestamp
assert_eq!(lockup_status(5).await, (0, 3 * day, 80, 80, 0));
reset_lockup(5, 4).await.unwrap();
reset_lockup(5, 4, LockupKind::Cliff).await.unwrap();
assert_eq!(lockup_status(5).await, (0, 4 * day, 80, 80, 0));
// advance to end of cliff
@ -215,7 +232,7 @@ async fn test_reset_lockup() -> Result<(), TransportError> {
context.solana.advance_clock_by_slots(2).await;
assert_eq!(lockup_status(5).await, (4 * day, 4 * day, 80, 80, 80));
reset_lockup(5, 1).await.unwrap();
reset_lockup(5, 1, LockupKind::Cliff).await.unwrap();
assert_eq!(lockup_status(5).await, (0, 1 * day, 80, 80, 0));
withdraw(5, 10).await.expect_err("nothing unlocked");
@ -228,7 +245,7 @@ async fn test_reset_lockup() -> Result<(), TransportError> {
assert_eq!(lockup_status(5).await, (day, day, 80, 70, 70));
deposit(5, 5).await.unwrap();
assert_eq!(lockup_status(5).await, (0, 0, 5, 75, 75));
reset_lockup(5, 1).await.unwrap();
reset_lockup(5, 1, LockupKind::Cliff).await.unwrap();
assert_eq!(lockup_status(5).await, (0, 1 * day, 75, 75, 0));
deposit(5, 15).await.unwrap();
assert_eq!(lockup_status(5).await, (0, 1 * day, 90, 90, 0));