Configurable options for vote weight scaling factors (#7)

Configurable options for vote weight scaling and lockup saturation

Co-authored-by: microwavedcola1 <microwavedcola@gmail.com>
This commit is contained in:
Christian Kamm 2021-12-09 11:58:15 +01:00 committed by GitHub
parent 317b7168eb
commit ddf37c4de0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 418 additions and 208 deletions

View File

@ -54,4 +54,8 @@ pub enum ErrorCode {
InvalidTokenOwnerRecord,
#[msg("")]
InvalidRealmAuthority,
#[msg("")]
VoterWeightOverflow,
#[msg("")]
LockupSaturationMustBePositive,
}

View File

@ -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<Account<'info, Registrar>>,
@ -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<ConfigureVotingMint>,
idx: u16,
rate: u64,
decimals: u8,
digit_shift: i8,
deposit_scaled_factor: u64,
lockup_scaled_factor: u64,
lockup_saturation_secs: u64,
grant_authority: Option<Pubkey>,
) -> 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(())
}

View File

@ -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<CreateRegistrar>,
vote_weight_decimals: u8,
registrar_bump: u8,
) -> Result<()> {
pub fn create_registrar(ctx: Context<CreateRegistrar>, 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"

View File

@ -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<UpdateMaxVoteWeight>) -> Result<()> {
let registrar = &ctx.accounts.registrar;
let _max_vote_weight = {
let total: Result<u64> = ctx
.remaining_accounts
.iter()
.map(|acc| Account::<Mint>::try_from(acc))
.collect::<std::result::Result<Vec<Account<Mint>>, 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.

View File

@ -58,22 +58,28 @@ declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
pub mod voter_stake_registry {
use super::*;
pub fn create_registrar(
ctx: Context<CreateRegistrar>,
vote_weight_decimals: u8,
registrar_bump: u8,
) -> Result<()> {
instructions::create_registrar(ctx, vote_weight_decimals, registrar_bump)
pub fn create_registrar(ctx: Context<CreateRegistrar>, registrar_bump: u8) -> Result<()> {
instructions::create_registrar(ctx, registrar_bump)
}
pub fn configure_voting_mint(
ctx: Context<ConfigureVotingMint>,
idx: u16,
rate: u64,
decimals: u8,
digit_shift: i8,
deposit_scaled_factor: u64,
lockup_scaled_factor: u64,
lockup_saturation_secs: u64,
grant_authority: Option<Pubkey>,
) -> 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(

View File

@ -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<u64> {
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<u64> {
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<u64> {
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<u64> {
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<u64> {
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<u64> {
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)

View File

@ -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<Self> {
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 {

View File

@ -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<Pubkey>,
) -> Result<VotingMintConfig> {
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<u64> {
self.voting_mints
.iter()
.try_fold(0u64, |mut sum, voting_mint_config| -> Result<u64> {
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::<Mint>::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]

View File

@ -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<u64> {
let compute = || -> Option<u64> {
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<u64> {
let compute = || -> Option<u64> {
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<u64> {
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<u64> {
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
}
}

View File

@ -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<Pubkey>,
) -> 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,

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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(&registrar, &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(&registrar, &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(&registrar, &realm_authority, 71 * 60 * 60)
.await;

View File

@ -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(&registrar, &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(&registrar, &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);

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;