From ddf37c4de06e1ade585a521e7d38bfd391b4335f Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Thu, 9 Dec 2021 11:58:15 +0100 Subject: [PATCH] Configurable options for vote weight scaling factors (#7) Configurable options for vote weight scaling and lockup saturation Co-authored-by: microwavedcola1 --- programs/voter-stake-registry/src/error.rs | 4 + .../src/instructions/configure_voting_mint.rs | 78 +++++++-- .../src/instructions/create_registrar.rs | 9 +- .../instructions/update_max_vote_weight.rs | 20 +-- programs/voter-stake-registry/src/lib.rs | 24 +-- .../src/state/deposit_entry.rs | 150 ++++++++++-------- .../voter-stake-registry/src/state/lockup.rs | 87 +++++++--- .../src/state/registrar.rs | 50 +++--- .../src/state/voting_mint_config.rs | 82 +++++++--- .../tests/program_test/addin.rs | 23 +-- .../tests/test_all_deposits.rs | 5 +- .../voter-stake-registry/tests/test_basic.rs | 5 +- .../tests/test_clawback.rs | 5 +- .../tests/test_deposit_cliff.rs | 23 ++- .../tests/test_deposit_daily_vesting.rs | 36 ++++- .../tests/test_deposit_monthly_vesting.rs | 5 +- .../tests/test_deposit_no_locking.rs | 5 +- .../voter-stake-registry/tests/test_grants.rs | 5 +- .../tests/test_reset_lockup.rs | 5 +- .../voter-stake-registry/tests/test_voting.rs | 5 +- 20 files changed, 418 insertions(+), 208 deletions(-) diff --git a/programs/voter-stake-registry/src/error.rs b/programs/voter-stake-registry/src/error.rs index 709b72c..6760921 100644 --- a/programs/voter-stake-registry/src/error.rs +++ b/programs/voter-stake-registry/src/error.rs @@ -54,4 +54,8 @@ pub enum ErrorCode { InvalidTokenOwnerRecord, #[msg("")] InvalidRealmAuthority, + #[msg("")] + VoterWeightOverflow, + #[msg("")] + LockupSaturationMustBePositive, } diff --git a/programs/voter-stake-registry/src/instructions/configure_voting_mint.rs b/programs/voter-stake-registry/src/instructions/configure_voting_mint.rs index 335a233..1198833 100644 --- a/programs/voter-stake-registry/src/instructions/configure_voting_mint.rs +++ b/programs/voter-stake-registry/src/instructions/configure_voting_mint.rs @@ -4,8 +4,9 @@ use anchor_lang::prelude::*; use anchor_spl::associated_token::AssociatedToken; use anchor_spl::token::{Mint, Token, TokenAccount}; +// Remaining accounts must be all the token mints that have registered +// as voting mints, including the newly registered one. #[derive(Accounts)] -#[instruction(idx: u16, rate: u64, decimals: u8)] pub struct ConfigureVotingMint<'info> { #[account(mut, has_one = realm_authority)] pub registrar: Box>, @@ -35,36 +36,81 @@ pub struct ConfigureVotingMint<'info> { /// deposit the mint in exchange for vote weight. There can only be a single /// exchange rate per mint. /// -/// `idx`: index of the rate to be set -/// `rate`: multiplier to apply for converting tokens to vote weight -/// `decimals`: number of decimals of mint that make one unit of token +/// * `idx`: index of the rate to be set +/// * `digit_shift`: how many digits to shift the native token amount, see below +/// * `deposit_scaled_factor`: vote weight factor for deposits, in 1/1e9 units +/// * `lockup_scaled_factor`: max extra weight for lockups, in 1/1e9 units +/// * `lockup_saturation_secs`: lockup duration at which the full vote weight +/// bonus is given to locked up deposits /// -/// The vote weight for one native token will be: +/// The vote weight for `amount` of native tokens will be /// ``` -/// rate * 10^vote_weight_decimals / 10^decimals +/// vote_weight = +/// amount * 10^(digit_shift) +/// * (deposit_scaled_factor/1e9 +/// + lockup_duration_factor * lockup_scaled_factor/1e9) /// ``` +/// where lockup_duration_factor is a value between 0 and 1, depending on how long +/// the amount is locked up. It is 1 when the lockup duration is greater or equal +/// lockup_saturation_secs. +/// +/// Warning: Choose values that ensure that the vote weight will not overflow the +/// u64 limit! There is a check based on the supply of all configured mints, but +/// do your own checking too. +/// +/// If you use a single mint, prefer digit_shift=0 and deposit_scaled_factor + +/// lockup_scaled_factor <= 1e9. That way you won't have issues with overflow no +/// matter the size of the mint's supply. +/// +/// Digit shifting is particularly useful when using several voting token mints +/// that have a different number of decimals. It can be used to align them to +/// a common number of decimals. +/// +/// Example: If you have token A with 6 decimals and token B with 9 decimals, you +/// could set up: +/// * A with digit_shift=0, deposit_scaled_factor=2e9, lockup_scaled_factor=0 +/// * B with digit_shift=-3, deposit_scaled_factor=1e9, lockup_scaled_factor=1e9 +/// +/// That would make 1.0 decimaled tokens of A as valuable as 2.0 decimaled tokens +/// of B when unlocked. B tokens could be locked up to double their vote weight. As +/// long as A's and B's supplies are below 2^63, there could be no overflow. +/// +/// Note that in this example, you need 1000 native B tokens before receiving 1 +/// unit of vote weight. If the supplies were significantly lower, you could use +/// * A with digit_shift=3, deposit_scaled_factor=2e9, lockup_scaled_factor=0 +/// * B with digit_shift=0, deposit_scaled_factor=1e9, lockup_scaled_factor=1e9 +/// to not lose precision on B tokens. +/// pub fn configure_voting_mint( ctx: Context, idx: u16, - rate: u64, - decimals: u8, + digit_shift: i8, + deposit_scaled_factor: u64, + lockup_scaled_factor: u64, + lockup_saturation_secs: u64, grant_authority: Option, ) -> Result<()> { - require!(rate > 0, InvalidRate); + require!(lockup_saturation_secs > 0, LockupSaturationMustBePositive); let registrar = &mut ctx.accounts.registrar; require!( (idx as usize) < registrar.voting_mints.len(), OutOfBoundsVotingMintConfigIndex ); require!( - registrar.voting_mints[idx as usize].rate == 0, + !registrar.voting_mints[idx as usize].in_use(), VotingMintConfigIndexAlreadyInUse ); - registrar.voting_mints[idx as usize] = registrar.new_voting_mint_config( - ctx.accounts.mint.key(), - decimals, - rate, - grant_authority, - )?; + registrar.voting_mints[idx as usize] = VotingMintConfig { + mint: ctx.accounts.mint.key(), + digit_shift, + deposit_scaled_factor, + lockup_scaled_factor, + lockup_saturation_secs, + grant_authority: grant_authority.unwrap_or(Pubkey::new_from_array([0; 32])), + }; + + // Check for overflow in vote weight + registrar.max_vote_weight(ctx.remaining_accounts)?; + Ok(()) } diff --git a/programs/voter-stake-registry/src/instructions/create_registrar.rs b/programs/voter-stake-registry/src/instructions/create_registrar.rs index 29ea283..aeea833 100644 --- a/programs/voter-stake-registry/src/instructions/create_registrar.rs +++ b/programs/voter-stake-registry/src/instructions/create_registrar.rs @@ -6,7 +6,7 @@ use spl_governance::state::realm; use std::mem::size_of; #[derive(Accounts)] -#[instruction(vote_weight_decimals: u8, registrar_bump: u8)] +#[instruction(registrar_bump: u8)] pub struct CreateRegistrar<'info> { /// The voting registrar. There can only be a single registrar /// per governance realm and governing mint. @@ -51,11 +51,7 @@ pub struct CreateRegistrar<'info> { /// /// To use the registrar, call ConfigVotingMint to register token mints that may be /// used for voting. -pub fn create_registrar( - ctx: Context, - vote_weight_decimals: u8, - registrar_bump: u8, -) -> Result<()> { +pub fn create_registrar(ctx: Context, registrar_bump: u8) -> Result<()> { let registrar = &mut ctx.accounts.registrar; registrar.bump = registrar_bump; registrar.governance_program_id = ctx.accounts.governance_program_id.key(); @@ -63,7 +59,6 @@ pub fn create_registrar( registrar.realm_governing_token_mint = ctx.accounts.realm_governing_token_mint.key(); registrar.realm_authority = ctx.accounts.realm_authority.key(); registrar.clawback_authority = ctx.accounts.clawback_authority.key(); - registrar.vote_weight_decimals = vote_weight_decimals; registrar.time_offset = 0; // Verify that "realm_authority" is the expected authority on "realm" diff --git a/programs/voter-stake-registry/src/instructions/update_max_vote_weight.rs b/programs/voter-stake-registry/src/instructions/update_max_vote_weight.rs index 1a94858..c5ac98c 100644 --- a/programs/voter-stake-registry/src/instructions/update_max_vote_weight.rs +++ b/programs/voter-stake-registry/src/instructions/update_max_vote_weight.rs @@ -1,7 +1,6 @@ use crate::error::*; use crate::state::*; use anchor_lang::prelude::*; -use anchor_spl::token::Mint; // Remaining accounts should all the token mints that have registered // exchange rates. @@ -21,24 +20,7 @@ pub struct UpdateMaxVoteWeight<'info> { /// defined by the registrar's `rate_decimal` field. pub fn update_max_vote_weight<'info>(ctx: Context) -> Result<()> { let registrar = &ctx.accounts.registrar; - let _max_vote_weight = { - let total: Result = ctx - .remaining_accounts - .iter() - .map(|acc| Account::::try_from(acc)) - .collect::>, ProgramError>>()? - .iter() - .try_fold(0u64, |sum, m| { - let mint_idx = registrar.voting_mint_config_index(m.key())?; - let mint_config = registrar.voting_mints[mint_idx]; - let amount = mint_config.convert(m.supply); - let total = sum.checked_add(amount).unwrap(); - Ok(total) - }); - total? - .checked_mul(FIXED_VOTE_WEIGHT_FACTOR + LOCKING_VOTE_WEIGHT_FACTOR) - .unwrap() - }; + let _max_vote_weight = registrar.max_vote_weight(ctx.remaining_accounts)?; // TODO: SPL governance has not yet implemented this feature. // When it has, probably need to write the result into an account, // similar to VoterWeightRecord. diff --git a/programs/voter-stake-registry/src/lib.rs b/programs/voter-stake-registry/src/lib.rs index 6a928c6..500ce68 100644 --- a/programs/voter-stake-registry/src/lib.rs +++ b/programs/voter-stake-registry/src/lib.rs @@ -58,22 +58,28 @@ declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS"); pub mod voter_stake_registry { use super::*; - pub fn create_registrar( - ctx: Context, - vote_weight_decimals: u8, - registrar_bump: u8, - ) -> Result<()> { - instructions::create_registrar(ctx, vote_weight_decimals, registrar_bump) + pub fn create_registrar(ctx: Context, registrar_bump: u8) -> Result<()> { + instructions::create_registrar(ctx, registrar_bump) } pub fn configure_voting_mint( ctx: Context, idx: u16, - rate: u64, - decimals: u8, + digit_shift: i8, + deposit_scaled_factor: u64, + lockup_scaled_factor: u64, + lockup_saturation_secs: u64, grant_authority: Option, ) -> Result<()> { - instructions::configure_voting_mint(ctx, idx, rate, decimals, grant_authority) + instructions::configure_voting_mint( + ctx, + idx, + digit_shift, + deposit_scaled_factor, + lockup_scaled_factor, + lockup_saturation_secs, + grant_authority, + ) } pub fn create_voter( diff --git a/programs/voter-stake-registry/src/state/deposit_entry.rs b/programs/voter-stake-registry/src/state/deposit_entry.rs index f4f733a..d627dc2 100644 --- a/programs/voter-stake-registry/src/state/deposit_entry.rs +++ b/programs/voter-stake-registry/src/state/deposit_entry.rs @@ -2,15 +2,9 @@ use crate::error::*; use crate::state::lockup::{Lockup, LockupKind}; use crate::state::voting_mint_config::VotingMintConfig; use anchor_lang::prelude::*; +use std::cmp::min; use std::convert::TryFrom; -/// Vote weight is amount * FIXED_VOTE_WEIGHT_FACTOR + -/// LOCKING_VOTE_WEIGHT_FACTOR * amount * time / max time -pub const FIXED_VOTE_WEIGHT_FACTOR: u64 = 1; -pub const LOCKING_VOTE_WEIGHT_FACTOR: u64 = 0; - -pub const MAX_SECS_LOCKED: u64 = 7 * 365 * 24 * 60 * 60; - /// Bookkeeping for a single deposit for a given mint and lockup schedule. #[zero_copy] #[derive(Default)] @@ -53,13 +47,14 @@ impl DepositEntry { /// For each cliff-locked token, the vote weight is: /// /// ``` - /// voting_power = amount * (fixed_factor + locking_factor * time_factor) + /// voting_power = deposit_vote_weight + /// + lockup_duration_factor * max_lockup_vote_weight /// ``` /// /// with - /// fixed_factor = FIXED_VOTE_WEIGHT_FACTOR - /// locking_factor = LOCKING_VOTE_WEIGHT_FACTOR - /// time_factor = lockup_time_remaining / max_lockup_time + /// deposit_vote_weight and max_lockup_vote_weight from the + /// VotingMintConfig + /// lockup_duration_factor = lockup_time_remaining / max_lockup_time /// /// Linear vesting schedules can be thought of as a sequence of cliff- /// locked tokens and have the matching voting weight. @@ -90,59 +85,71 @@ impl DepositEntry { /// voting_power_linear_vesting() below. /// pub fn voting_power(&self, voting_mint_config: &VotingMintConfig, curr_ts: i64) -> Result { - let fixed_contribution = voting_mint_config - .convert(self.amount_deposited_native) - .checked_mul(FIXED_VOTE_WEIGHT_FACTOR) - .unwrap(); - if LOCKING_VOTE_WEIGHT_FACTOR == 0 { - return Ok(fixed_contribution); - } - - let max_locked_contribution = - voting_mint_config.convert(self.amount_initially_locked_native); - Ok(fixed_contribution - + self - .voting_power_locked(curr_ts, max_locked_contribution)? - .checked_mul(LOCKING_VOTE_WEIGHT_FACTOR) - .unwrap()) + let deposit_vote_weight = + voting_mint_config.deposit_vote_weight(self.amount_deposited_native)?; + let max_locked_vote_weight = + voting_mint_config.max_lockup_vote_weight(self.amount_initially_locked_native)?; + deposit_vote_weight + .checked_add(self.voting_power_locked( + curr_ts, + max_locked_vote_weight, + voting_mint_config.lockup_saturation_secs, + )?) + .ok_or(Error::ErrorCode(ErrorCode::VoterWeightOverflow)) } /// Vote power contribution from locked funds only. - /// Not scaled by LOCKING_VOTE_WEIGHT_FACTOR yet. - pub fn voting_power_locked(&self, curr_ts: i64, max_locked_contribution: u64) -> Result { - if curr_ts >= self.lockup.end_ts { + pub fn voting_power_locked( + &self, + curr_ts: i64, + max_locked_vote_weight: u64, + lockup_saturation_secs: u64, + ) -> Result { + if curr_ts >= self.lockup.end_ts || max_locked_vote_weight == 0 { return Ok(0); } match self.lockup.kind { LockupKind::None => Ok(0), - LockupKind::Daily => self.voting_power_linear_vesting(curr_ts, max_locked_contribution), - LockupKind::Monthly => { - self.voting_power_linear_vesting(curr_ts, max_locked_contribution) + LockupKind::Daily => self.voting_power_linear_vesting( + curr_ts, + max_locked_vote_weight, + lockup_saturation_secs, + ), + LockupKind::Monthly => self.voting_power_linear_vesting( + curr_ts, + max_locked_vote_weight, + lockup_saturation_secs, + ), + LockupKind::Cliff => { + self.voting_power_cliff(curr_ts, max_locked_vote_weight, lockup_saturation_secs) } - LockupKind::Cliff => self.voting_power_cliff(curr_ts, max_locked_contribution), } } /// Vote power contribution from funds with linear vesting. - /// Not scaled by LOCKING_VOTE_WEIGHT_FACTOR yet. - fn voting_power_cliff(&self, curr_ts: i64, max_locked_contribution: u64) -> Result { - let remaining = self.lockup.seconds_left(curr_ts); + fn voting_power_cliff( + &self, + curr_ts: i64, + max_locked_vote_weight: u64, + lockup_saturation_secs: u64, + ) -> Result { + let remaining = min(self.lockup.seconds_left(curr_ts), lockup_saturation_secs); Ok(u64::try_from( - (max_locked_contribution as u128) + (max_locked_vote_weight as u128) .checked_mul(remaining as u128) .unwrap() - .checked_div(MAX_SECS_LOCKED as u128) + .checked_div(lockup_saturation_secs as u128) .unwrap(), ) .unwrap()) } /// Vote power contribution from cliff-locked funds. - /// Not scaled by LOCKING_VOTE_WEIGHT_FACTOR yet. fn voting_power_linear_vesting( &self, curr_ts: i64, - max_locked_contribution: u64, + max_locked_vote_weight: u64, + lockup_saturation_secs: u64, ) -> Result { let periods_left = self.lockup.periods_left(curr_ts)?; let periods_total = self.lockup.periods_total()?; @@ -157,32 +164,54 @@ impl DepositEntry { // // For example, if there were 5 vesting periods, with 3 of them left // (i.e. two have already vested and their tokens are no longer locked) - // we'd have (max_contribution / 5) weight in each of them, and the - // voting weight would be: - // (max_locked_contribution/5) * secs_left_for_cliff_1 / MAX_SECS_LOCKED - // + (max_locked_contribution/5) * secs_left_for_cliff_2 / MAX_SECS_LOCKED - // + (max_locked_contribution/5) * secs_left_for_cliff_3 / MAX_SECS_LOCKED + // we'd have (max_locked_vote_weight / 5) weight in each of them, and the + // voting power would be: + // (max_locked_vote_weight/5) * secs_left_for_cliff_1 / lockup_saturation_secs + // + (max_locked_vote_weight/5) * secs_left_for_cliff_2 / lockup_saturation_secs + // + (max_locked_vote_weight/5) * secs_left_for_cliff_3 / lockup_saturation_secs // // Or more simply: - // max_locked_contribution * (\sum_p secs_left_for_cliff_p) / (5 * MAX_SECS_LOCKED) - // = max_locked_contribution * lockup_secs / denominator + // max_locked_vote_weight * (\sum_p secs_left_for_cliff_p) / (5 * lockup_saturation_secs) + // = max_locked_vote_weight * lockup_secs / denominator // // The value secs_left_for_cliff_p splits up as - // secs_left_for_cliff_p = secs_to_closest_cliff + (p-1) * period_secs + // secs_left_for_cliff_p = min( + // secs_to_closest_cliff + (p-1) * period_secs, + // lockup_saturation_secs) // - // So + // We can split the sum into the part before saturation and the part after: + // Let q be the largest integer <= periods_left where + // secs_to_closest_cliff + (q-1) * period_secs < lockup_saturation_secs + // => q < (lockup_saturation_secs + period_secs - secs_to_closest_cliff) / period_secs + // and r be the integer where q + r = periods_left, then: // lockup_secs := \sum_p secs_left_for_cliff_p - // = periods_left * secs_to_closest_cliff - // + period_secs * \sum_0^periods_left (p-1) + // = \sum_{p<=q} secs_left_for_cliff_p + // + r * lockup_saturation_secs + // = q * secs_to_closest_cliff + // + period_secs * \sum_0^q (p-1) + // + r * lockup_saturation_secs // - // Where the sum of full periods has a formula: + // Where the sum can be expanded to: // - // sum_full_periods := \sum_0^periods_left (p-1) - // = periods_left * (periods_left - 1) / 2 + // sum_full_periods := \sum_0^q (p-1) + // = q * (q - 1) / 2 // // In the example above, periods_total was 5. - let denominator = periods_total * MAX_SECS_LOCKED; + let denominator = periods_total * lockup_saturation_secs; + + let secs_to_closest_cliff = + u64::try_from(self.lockup.end_ts - (period_secs * (periods_left - 1)) as i64 - curr_ts) + .unwrap(); + + let lockup_saturation_periods = + (lockup_saturation_secs + period_secs - secs_to_closest_cliff) / period_secs; + let q = min(lockup_saturation_periods, periods_left); + let r = if q < periods_left { + periods_left - q + } else { + 0 + }; // Sum of the full periods left for all remaining vesting cliffs. // @@ -193,17 +222,14 @@ impl DepositEntry { // and the next has two full periods left // so sums to 3 = 3 * 2 / 2 // - if there's only one period left, the sum is 0 - let sum_full_periods = periods_left * (periods_left - 1) / 2; - - let secs_to_closest_cliff = - u64::try_from(self.lockup.end_ts - (period_secs * (periods_left - 1)) as i64 - curr_ts) - .unwrap(); + let sum_full_periods = q * (q - 1) / 2; // Total number of seconds left over all periods_left remaining vesting cliffs - let lockup_secs = periods_left * secs_to_closest_cliff + sum_full_periods * period_secs; + let lockup_secs = + q * secs_to_closest_cliff + sum_full_periods * period_secs + r * lockup_saturation_secs; Ok(u64::try_from( - (max_locked_contribution as u128) + (max_locked_vote_weight as u128) .checked_mul(lockup_secs as u128) .unwrap() .checked_div(denominator as u128) diff --git a/programs/voter-stake-registry/src/state/lockup.rs b/programs/voter-stake-registry/src/state/lockup.rs index 391bbd8..003966f 100644 --- a/programs/voter-stake-registry/src/state/lockup.rs +++ b/programs/voter-stake-registry/src/state/lockup.rs @@ -20,14 +20,6 @@ pub const SECS_PER_MONTH: i64 = 10; #[cfg(not(feature = "localnet"))] pub const SECS_PER_MONTH: i64 = 365 * SECS_PER_DAY / 12; -/// Maximum number of days one can lock for. -/// TODO: Must match up with MAX_SECS_LOCKED etc -pub const MAX_DAYS_LOCKED: u64 = 7 * 365; - -/// Maximum number of months one can lock for. -/// TODO: Must match up with MAX_SECS_LOCKED etc -pub const MAX_MONTHS_LOCKED: u64 = 7 * 12; - #[zero_copy] #[derive(AnchorSerialize, AnchorDeserialize)] pub struct Lockup { @@ -61,7 +53,6 @@ impl Default for Lockup { impl Lockup { /// Create lockup for a given period pub fn new_from_periods(kind: LockupKind, start_ts: i64, periods: u32) -> Result { - require!(periods as u64 <= kind.max_periods(), InvalidDays); Ok(Self { kind, start_ts, @@ -130,6 +121,10 @@ pub enum LockupKind { } impl LockupKind { + /// The lockup length is specified by passing the number of lockup periods + /// to create_deposit_entry. This describes a period's length. + /// + /// For vesting lockups, the period length is also the vesting period. pub fn period_secs(&self) -> i64 { match self { LockupKind::None => 0, @@ -138,15 +133,6 @@ impl LockupKind { LockupKind::Cliff => SECS_PER_DAY, // arbitrary choice } } - - pub fn max_periods(&self) -> u64 { - match self { - LockupKind::None => 0, - LockupKind::Daily => MAX_DAYS_LOCKED, - LockupKind::Monthly => MAX_MONTHS_LOCKED, - LockupKind::Cliff => MAX_DAYS_LOCKED, - } - } } #[cfg(test)] @@ -154,6 +140,10 @@ mod tests { use super::*; use crate::state::deposit_entry::DepositEntry; + // intentionally not a multiple of a day + const MAX_SECS_LOCKED: u64 = 365 * 24 * 60 * 60 + 7 * 60 * 60; + const MAX_DAYS_LOCKED: f64 = MAX_SECS_LOCKED as f64 / (24.0 * 60.0 * 60.0); + #[test] pub fn days_left_start() -> Result<()> { run_test_days_left(TestDaysLeft { @@ -297,7 +287,7 @@ mod tests { pub fn voting_power_cliff_start() -> Result<()> { // 10 tokens with 6 decimals. let amount_deposited = 10 * 1_000_000; - let expected_voting_power = (10 * amount_deposited) / MAX_DAYS_LOCKED; + let expected_voting_power = locked_cliff_power(amount_deposited, 10.0); run_test_voting_power(TestVotingPower { expected_voting_power, amount_deposited, @@ -353,7 +343,7 @@ mod tests { pub fn voting_power_cliff_one_day() -> Result<()> { // 10 tokens with 6 decimals. let amount_deposited = 10 * 1_000_000; - let expected_voting_power = (9 * amount_deposited) / MAX_DAYS_LOCKED; + let expected_voting_power = locked_cliff_power(amount_deposited, 9.0); run_test_voting_power(TestVotingPower { expected_voting_power, amount_deposited, @@ -382,7 +372,7 @@ mod tests { // 10 tokens with 6 decimals. let amount_deposited = 10 * 1_000_000; // (8/2555) * deposit w/ 6 decimals. - let expected_voting_power = (8 * amount_deposited) / MAX_DAYS_LOCKED; + let expected_voting_power = locked_cliff_power(amount_deposited, 8.0); run_test_voting_power(TestVotingPower { expected_voting_power, amount_deposited, @@ -591,6 +581,48 @@ mod tests { }) } + #[test] + pub fn voting_power_daily_saturation() -> Result<()> { + let days = MAX_DAYS_LOCKED.floor() as u64; + let amount_deposited = days * 1_000_000; + let expected_voting_power = locked_daily_power(amount_deposited, 0.0, days); + run_test_voting_power(TestVotingPower { + expected_voting_power, + amount_deposited, + days_total: MAX_DAYS_LOCKED.floor(), + curr_day: 0.0, + kind: LockupKind::Daily, + }) + } + + #[test] + pub fn voting_power_daily_above_saturation1() -> Result<()> { + let days = (MAX_DAYS_LOCKED + 10.0).floor() as u64; + let amount_deposited = days * 1_000_000; + let expected_voting_power = locked_daily_power(amount_deposited, 0.0, days); + run_test_voting_power(TestVotingPower { + expected_voting_power, + amount_deposited, + days_total: (MAX_DAYS_LOCKED + 10.0).floor(), + curr_day: 0.0, + kind: LockupKind::Daily, + }) + } + + #[test] + pub fn voting_power_daily_above_saturation2() -> Result<()> { + let days = (MAX_DAYS_LOCKED + 10.0).floor() as u64; + let amount_deposited = days * 1_000_000; + let expected_voting_power = locked_daily_power(amount_deposited, 0.5, days); + run_test_voting_power(TestVotingPower { + expected_voting_power, + amount_deposited, + days_total: (MAX_DAYS_LOCKED + 10.0).floor(), + curr_day: 0.5, + kind: LockupKind::Daily, + }) + } + struct TestDaysLeft { expected_days_left: u64, days_total: f64, @@ -658,7 +690,7 @@ mod tests { }, }; let curr_ts = start_ts + days_to_secs(t.curr_day); - let power = d.voting_power_locked(curr_ts, t.amount_deposited)?; + let power = d.voting_power_locked(curr_ts, t.amount_deposited, MAX_SECS_LOCKED)?; assert_eq!(power, t.expected_voting_power); Ok(()) } @@ -691,11 +723,18 @@ mod tests { let remaining_days = total_days as f64 - day - k as f64; total += locked_cliff_power_float(amount / total_days, remaining_days); } - total.floor() as u64 + // the test code uses floats to compute the voting power; avoid + // getting incurrect expected results due to floating point rounding + (total + 0.0001).floor() as u64 } fn locked_cliff_power_float(amount: u64, remaining_days: f64) -> f64 { - (amount as f64) * remaining_days / (MAX_DAYS_LOCKED as f64) + let relevant_days = if remaining_days < MAX_DAYS_LOCKED as f64 { + remaining_days + } else { + MAX_DAYS_LOCKED as f64 + }; + (amount as f64) * relevant_days / (MAX_DAYS_LOCKED as f64) } fn locked_cliff_power(amount: u64, remaining_days: f64) -> u64 { diff --git a/programs/voter-stake-registry/src/state/registrar.rs b/programs/voter-stake-registry/src/state/registrar.rs index a8a1476..9d6e59e 100644 --- a/programs/voter-stake-registry/src/state/registrar.rs +++ b/programs/voter-stake-registry/src/state/registrar.rs @@ -1,6 +1,7 @@ use crate::error::*; use crate::state::voting_mint_config::VotingMintConfig; use anchor_lang::prelude::*; +use anchor_spl::token::Mint; /// Instance of a voting rights distributor. #[account] @@ -15,38 +16,11 @@ pub struct Registrar { // The length should be adjusted for one's use case. pub voting_mints: [VotingMintConfig; 2], - /// The decimals to use when converting deposits into a common currency. - /// - /// This must be larger or equal to the max of decimals over all accepted - /// token mints. - pub vote_weight_decimals: u8, - /// Debug only: time offset, to allow tests to move forward in time. pub time_offset: i64, } impl Registrar { - pub fn new_voting_mint_config( - &self, - mint: Pubkey, - mint_decimals: u8, - rate: u64, - grant_authority: Option, - ) -> Result { - require!(self.vote_weight_decimals >= mint_decimals, InvalidDecimals); - let decimal_diff = self - .vote_weight_decimals - .checked_sub(mint_decimals) - .unwrap(); - Ok(VotingMintConfig { - mint, - rate, - mint_decimals, - conversion_factor: rate.checked_mul(10u64.pow(decimal_diff.into())).unwrap(), - grant_authority: grant_authority.unwrap_or(Pubkey::new_from_array([0; 32])), - }) - } - pub fn clock_unix_timestamp(&self) -> i64 { Clock::get().unwrap().unix_timestamp + self.time_offset } @@ -57,6 +31,28 @@ impl Registrar { .position(|r| r.mint == mint) .ok_or(Error::ErrorCode(ErrorCode::VotingMintNotFound)) } + + pub fn max_vote_weight(&self, mint_accounts: &[AccountInfo]) -> Result { + self.voting_mints + .iter() + .try_fold(0u64, |mut sum, voting_mint_config| -> Result { + if !voting_mint_config.in_use() { + return Ok(sum); + } + let mint_account = mint_accounts + .iter() + .find(|a| a.key() == voting_mint_config.mint) + .ok_or(Error::ErrorCode(ErrorCode::VotingMintNotFound))?; + let mint = Account::::try_from(mint_account)?; + sum = sum + .checked_add(voting_mint_config.deposit_vote_weight(mint.supply)?) + .ok_or(Error::ErrorCode(ErrorCode::VoterWeightOverflow))?; + sum = sum + .checked_add(voting_mint_config.max_lockup_vote_weight(mint.supply)?) + .ok_or(Error::ErrorCode(ErrorCode::VoterWeightOverflow))?; + Ok(sum) + }) + } } #[macro_export] diff --git a/programs/voter-stake-registry/src/state/voting_mint_config.rs b/programs/voter-stake-registry/src/state/voting_mint_config.rs index 8008429..a7b3c92 100644 --- a/programs/voter-stake-registry/src/state/voting_mint_config.rs +++ b/programs/voter-stake-registry/src/state/voting_mint_config.rs @@ -1,31 +1,31 @@ +use crate::error::*; use anchor_lang::__private::bytemuck::Zeroable; use anchor_lang::prelude::*; +use std::convert::TryFrom; + +const SCALED_FACTOR_BASE: u64 = 1_000_000_000; /// Exchange rate for an asset that can be used to mint voting rights. +/// +/// See documentation of configure_voting_mint for details on how +/// native token amounts convert to vote weight. #[zero_copy] #[derive(AnchorSerialize, AnchorDeserialize, Default)] pub struct VotingMintConfig { /// Mint for this entry. pub mint: Pubkey, - /// Mint decimals. - pub mint_decimals: u8, + /// Number of digits to shift native amounts, applying a 10^digit_shift factor. + pub digit_shift: i8, - /// Exchange rate for 1.0 decimal-respecting unit of mint currency - /// into the common vote currency. - /// - /// Example: If rate=2, then 1.000 of mint currency has a vote weight - /// of 2.000000 in common vote currency. In the example mint decimals - /// was 3 and common_decimals was 6. - pub rate: u64, + /// Vote weight factor for deposits, in 1/SCALED_FACTOR_BASE units. + pub deposit_scaled_factor: u64, - /// Factor for converting mint native currency to common vote currency, - /// including decimal handling. - /// - /// Examples: - /// - if common and mint have the same number of decimals, this is the same as 'rate' - /// - common decimals = 6, mint decimals = 3, rate = 5 -> 5000. - pub conversion_factor: u64, + /// Maximum vote weight factor for lockups, in 1/SCALED_FACTOR_BASE units. + pub lockup_scaled_factor: u64, + + /// Number of seconds of lockup needed to reach the maximum lockup bonus. + pub lockup_saturation_secs: u64, /// The authority that is allowed to push grants into voters pub grant_authority: Pubkey, @@ -33,9 +33,53 @@ pub struct VotingMintConfig { impl VotingMintConfig { /// Converts an amount in this voting mints's native currency - /// to the equivalent common registrar vote currency amount. - pub fn convert(&self, amount_native: u64) -> u64 { - amount_native.checked_mul(self.conversion_factor).unwrap() + /// to the base vote weight (without the deposit or lockup scalings) + /// by applying the digit_shift factor. + pub fn base_vote_weight(&self, amount_native: u64) -> Result { + let compute = || -> Option { + let val = if self.digit_shift < 0 { + (amount_native as u128).checked_div(10u128.pow((-self.digit_shift) as u32))? + } else { + (amount_native as u128).checked_mul(10u128.pow(self.digit_shift as u32))? + }; + u64::try_from(val).ok() + }; + compute().ok_or(Error::ErrorCode(ErrorCode::VoterWeightOverflow)) + } + + /// Apply a factor in SCALED_FACTOR_BASE units. + fn apply_factor(base_vote_weight: u64, factor: u64) -> Result { + let compute = || -> Option { + u64::try_from( + (base_vote_weight as u128) + .checked_mul(factor as u128)? + .checked_div(SCALED_FACTOR_BASE as u128)?, + ) + .ok() + }; + compute().ok_or(Error::ErrorCode(ErrorCode::VoterWeightOverflow)) + } + + /// The vote weight a deposit of a number of native tokens should have. + pub fn deposit_vote_weight(&self, amount_native: u64) -> Result { + Self::apply_factor( + self.base_vote_weight(amount_native)?, + self.deposit_scaled_factor, + ) + } + + /// The maximum vote weight a number of locked up native tokens can have. + /// Will be multiplied with a factor between 0 and 1 for the lockup duration. + pub fn max_lockup_vote_weight(&self, amount_native: u64) -> Result { + Self::apply_factor( + self.base_vote_weight(amount_native)?, + self.lockup_scaled_factor, + ) + } + + /// Whether this voting mint is configured. + pub fn in_use(&self) -> bool { + self.lockup_saturation_secs > 0 } } diff --git a/programs/voter-stake-registry/tests/program_test/addin.rs b/programs/voter-stake-registry/tests/program_test/addin.rs index 754c1e3..2b5d3bb 100644 --- a/programs/voter-stake-registry/tests/program_test/addin.rs +++ b/programs/voter-stake-registry/tests/program_test/addin.rs @@ -52,12 +52,8 @@ impl AddinCookie { &self.program_id, ); - let vote_weight_decimals = 6; let data = anchor_lang::InstructionData::data( - &voter_stake_registry::instruction::CreateRegistrar { - vote_weight_decimals, - registrar_bump, - }, + &voter_stake_registry::instruction::CreateRegistrar { registrar_bump }, ); let accounts = anchor_lang::ToAccountMetas::to_account_metas( @@ -104,7 +100,10 @@ impl AddinCookie { payer: &Keypair, index: u16, mint: &MintCookie, - rate: u64, + digit_shift: i8, + deposit_scaled_factor: f64, + lockup_scaled_factor: f64, + lockup_saturation_secs: u64, grant_authority: Option, ) -> VotingMintConfigCookie { let deposit_mint = mint.pubkey.unwrap(); @@ -116,13 +115,15 @@ impl AddinCookie { let data = anchor_lang::InstructionData::data( &voter_stake_registry::instruction::ConfigureVotingMint { idx: index, - rate, - decimals: mint.decimals, + digit_shift, + deposit_scaled_factor: (deposit_scaled_factor * 1e9) as u64, + lockup_scaled_factor: (lockup_scaled_factor * 1e9) as u64, + lockup_saturation_secs, grant_authority, }, ); - let accounts = anchor_lang::ToAccountMetas::to_account_metas( + let mut accounts = anchor_lang::ToAccountMetas::to_account_metas( &voter_stake_registry::accounts::ConfigureVotingMint { vault, mint: deposit_mint, @@ -136,6 +137,10 @@ impl AddinCookie { }, None, ); + accounts.push(anchor_lang::prelude::AccountMeta::new_readonly( + deposit_mint, + false, + )); let instructions = vec![Instruction { program_id: self.program_id, diff --git a/programs/voter-stake-registry/tests/test_all_deposits.rs b/programs/voter-stake-registry/tests/test_all_deposits.rs index 4806c37..35c2522 100644 --- a/programs/voter-stake-registry/tests/test_all_deposits.rs +++ b/programs/voter-stake-registry/tests/test_all_deposits.rs @@ -41,7 +41,10 @@ async fn test_all_deposits() -> Result<(), TransportError> { payer, 0, &context.mints[0], - 1, + 0, + 1.0, + 0.0, + 5 * 365 * 24 * 60 * 60, None, ) .await; diff --git a/programs/voter-stake-registry/tests/test_basic.rs b/programs/voter-stake-registry/tests/test_basic.rs index 16436ce..2624ceb 100644 --- a/programs/voter-stake-registry/tests/test_basic.rs +++ b/programs/voter-stake-registry/tests/test_basic.rs @@ -41,7 +41,10 @@ async fn test_basic() -> Result<(), TransportError> { payer, 0, &context.mints[0], - 1, + 0, + 1.0, + 0.0, + 5 * 365 * 24 * 60 * 60, None, ) .await; diff --git a/programs/voter-stake-registry/tests/test_clawback.rs b/programs/voter-stake-registry/tests/test_clawback.rs index e61d7ca..2a4c417 100644 --- a/programs/voter-stake-registry/tests/test_clawback.rs +++ b/programs/voter-stake-registry/tests/test_clawback.rs @@ -49,7 +49,10 @@ async fn test_clawback() -> Result<(), TransportError> { realm_authority, 0, community_token_mint, - 1, + 0, + 1.0, + 0.0, + 5 * 365 * 24 * 60 * 60, None, ) .await; diff --git a/programs/voter-stake-registry/tests/test_deposit_cliff.rs b/programs/voter-stake-registry/tests/test_deposit_cliff.rs index 0ae1a3a..db34f27 100644 --- a/programs/voter-stake-registry/tests/test_deposit_cliff.rs +++ b/programs/voter-stake-registry/tests/test_deposit_cliff.rs @@ -74,7 +74,10 @@ async fn test_deposit_cliff() -> Result<(), TransportError> { payer, 0, &context.mints[0], - 1, + 0, + 1.0, + 1.0, + 2 * 24 * 60 * 60, None, ) .await; @@ -140,14 +143,28 @@ async fn test_deposit_cliff() -> Result<(), TransportError> { let after_deposit = get_balances(0).await; assert_eq!(initial.token, after_deposit.token + after_deposit.vault); - assert_eq!(after_deposit.voter_weight, 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); // cannot withdraw yet, nothing is vested withdraw(1).await.expect_err("nothing vested yet"); - // advance almost three days + // advance a day + addin + .set_time_offset(®istrar, &realm_authority, 24 * 60 * 60) + .await; + let after_day1 = get_balances(0).await; + assert_eq!(after_day1.voter_weight, 2 * after_day1.vault); // still saturated + + // advance a second day + addin + .set_time_offset(®istrar, &realm_authority, 48 * 60 * 60) + .await; + let after_day2 = get_balances(0).await; + assert_eq!(after_day2.voter_weight, 3 * after_day2.vault / 2); // locking half done + + // advance to almost three days addin .set_time_offset(®istrar, &realm_authority, 71 * 60 * 60) .await; diff --git a/programs/voter-stake-registry/tests/test_deposit_daily_vesting.rs b/programs/voter-stake-registry/tests/test_deposit_daily_vesting.rs index eb362bb..9bdd5eb 100644 --- a/programs/voter-stake-registry/tests/test_deposit_daily_vesting.rs +++ b/programs/voter-stake-registry/tests/test_deposit_daily_vesting.rs @@ -75,7 +75,10 @@ async fn test_deposit_daily_vesting() -> Result<(), TransportError> { payer, 0, &context.mints[0], - 1, + 0, + 1.0, + 0.5, + 60 * 60 * 60, // 60h / 2.5d None, ) .await; @@ -141,13 +144,27 @@ async fn test_deposit_daily_vesting() -> Result<(), TransportError> { let after_deposit = get_balances(0).await; assert_eq!(initial.token, after_deposit.token + after_deposit.vault); - assert_eq!(after_deposit.voter_weight, after_deposit.vault); + // The vesting parts are locked for 72, 48 and 24h. Lockup saturates at 60h. + assert_eq!( + after_deposit.voter_weight, + ((after_deposit.vault as f64) * (1.0 + 0.5 * (60.0 + 48.0 + 24.0) / 60.0 / 3.0)) as u64 + ); assert_eq!(after_deposit.vault, 9000); assert_eq!(after_deposit.deposit, 9000); // cannot withdraw yet, nothing is vested withdraw(1).await.expect_err("nothing vested yet"); + // check vote weight reduction after an hour + addin + .set_time_offset(®istrar, &realm_authority, 60 * 60) + .await; + let after_hour = get_balances(0).await; + assert_eq!( + after_hour.voter_weight, + ((after_hour.vault as f64) * (1.0 + 0.5 * (60.0 + 47.0 + 23.0) / 60.0 / 3.0)) as u64 + ); + // advance a day addin .set_time_offset(®istrar, &realm_authority, 25 * 60 * 60) @@ -159,7 +176,10 @@ async fn test_deposit_daily_vesting() -> Result<(), TransportError> { let after_withdraw = get_balances(0).await; assert_eq!(initial.token, after_withdraw.token + after_withdraw.vault); - assert_eq!(after_withdraw.voter_weight, after_withdraw.vault); + assert_eq!( + after_withdraw.voter_weight, + ((after_withdraw.vault as f64) * (1.0 + 0.5 * (47.0 + 23.0) / 60.0 / 2.0)) as u64 + ); assert_eq!(after_withdraw.vault, 6000); assert_eq!(after_withdraw.deposit, 6000); @@ -169,7 +189,10 @@ async fn test_deposit_daily_vesting() -> Result<(), TransportError> { let after_deposit = get_balances(0).await; assert_eq!(initial.token, after_deposit.token + after_deposit.vault); - assert_eq!(after_deposit.voter_weight, after_deposit.vault); + assert_eq!( + after_deposit.voter_weight, + ((after_deposit.vault as f64) * (1.0 + 0.5 * (47.0 + 23.0) / 60.0 / 2.0)) as u64 + ); assert_eq!(after_deposit.vault, 11000); assert_eq!(after_deposit.deposit, 11000); @@ -193,7 +216,10 @@ async fn test_deposit_daily_vesting() -> Result<(), TransportError> { let after_withdraw = get_balances(0).await; assert_eq!(initial.token, after_withdraw.token + after_withdraw.vault); - assert_eq!(after_withdraw.voter_weight, after_withdraw.vault); + assert_eq!( + after_withdraw.voter_weight, + ((after_withdraw.vault as f64) * (1.0 + 0.5 * 23.0 / 60.0)) as u64 + ); assert_eq!(after_withdraw.vault, 6500); assert_eq!(after_withdraw.deposit, 6500); diff --git a/programs/voter-stake-registry/tests/test_deposit_monthly_vesting.rs b/programs/voter-stake-registry/tests/test_deposit_monthly_vesting.rs index 56a1472..3c5136e 100644 --- a/programs/voter-stake-registry/tests/test_deposit_monthly_vesting.rs +++ b/programs/voter-stake-registry/tests/test_deposit_monthly_vesting.rs @@ -74,7 +74,10 @@ async fn test_deposit_monthly_vesting() -> Result<(), TransportError> { payer, 0, &context.mints[0], - 1, + 0, + 1.0, + 0.0, + 5 * 365 * 24 * 60 * 60, None, ) .await; diff --git a/programs/voter-stake-registry/tests/test_deposit_no_locking.rs b/programs/voter-stake-registry/tests/test_deposit_no_locking.rs index a42c708..6ed80bf 100644 --- a/programs/voter-stake-registry/tests/test_deposit_no_locking.rs +++ b/programs/voter-stake-registry/tests/test_deposit_no_locking.rs @@ -80,7 +80,10 @@ async fn test_deposit_no_locking() -> Result<(), TransportError> { payer, 0, &context.mints[0], - 1, + 0, + 1.0, + 10.0, // no locking, so has no effect + 5 * 365 * 24 * 60 * 60, None, ) .await; diff --git a/programs/voter-stake-registry/tests/test_grants.rs b/programs/voter-stake-registry/tests/test_grants.rs index d101616..d7bdda4 100644 --- a/programs/voter-stake-registry/tests/test_grants.rs +++ b/programs/voter-stake-registry/tests/test_grants.rs @@ -45,7 +45,10 @@ async fn test_grants() -> Result<(), TransportError> { payer, 0, &context.mints[0], - 2, + 0, + 2.0, + 0.0, + 5 * 365 * 24 * 60 * 60, Some(grant_authority.pubkey()), ) .await; diff --git a/programs/voter-stake-registry/tests/test_reset_lockup.rs b/programs/voter-stake-registry/tests/test_reset_lockup.rs index 060a499..7ce8252 100644 --- a/programs/voter-stake-registry/tests/test_reset_lockup.rs +++ b/programs/voter-stake-registry/tests/test_reset_lockup.rs @@ -63,7 +63,10 @@ async fn test_reset_lockup() -> Result<(), TransportError> { payer, 0, &context.mints[0], - 1, + 0, + 1.0, + 0.0, + 5 * 365 * 24 * 60 * 60, None, ) .await; diff --git a/programs/voter-stake-registry/tests/test_voting.rs b/programs/voter-stake-registry/tests/test_voting.rs index b13a428..8858212 100644 --- a/programs/voter-stake-registry/tests/test_voting.rs +++ b/programs/voter-stake-registry/tests/test_voting.rs @@ -45,7 +45,10 @@ async fn test_voting() -> Result<(), TransportError> { payer, 0, &context.mints[0], - 2, + 0, + 2.0, + 0.0, + 5 * 365 * 24 * 60 * 60, None, ) .await;