This module updates the state (and stats) for a validator when the protocol interacts with the validator viz.:
creates a new delegation,
pays commission,
requests a withdrawal, or,
when it reserves shares for payout.
This module also calculates the performance statistics of a validator. These statistics are used to calculate how much to delegate in a validator. Tortuga Protocol may start out with permissioned scoring and switch to permissionless scoring after sufficient data has been collected. Hence, this module may remain upgradable for longer than the rest of the protocol while the permissionless scoring is being fine-tuned.
This modules handles both permissioned and permissionless scoring.
use 0x1::error;
use 0x1::option;
use 0x1::reconfiguration;
use 0x1::signer;
use 0x1::timestamp;
use 0xc0ded0c0::circular_buffer;
use 0xc0ded0c0::iterable_table_custom;
use 0xc0ded0c0::math;
Struct Observation
An observation of on online stats oracle. Effective reward rate for a validator is calculated after they have charged their commission, and after accounting for their performance degradations, like missed proposals.
struct Observation has copy, drop, store
Fields
timestamp: u64The time the observation was made.effective_reward_rate: u128The effective reward rate of the validator at the time of the observation.
Struct AssociatedValidator
This struct stores the balances and stats for an associated validator.
struct AssociatedValidator has store
Fields
last_update_timestamp: u64Timestamp when the last checkpoint was made.last_reconfiguration_timestamp: u64The last recorded reconfiguration timestamp from the aptos_framework module.balance_at_last_update_unreserved: u64Unreserved balance when the last update was made.balance_at_last_update_reserved: u64Reserved balance when the last update was made.time_averaged_effective_reward_rate: u128The time-averaged effective reward rate of the validator.observations: circular_buffer::CircularBuffer<validator_states::Observation>Observations which will be used to calculate the effective reward.observation_begin_timestamp: u64The earliest timestamp when an observation was made in the current buffer.score: u128Score of the validatorpermissioned_score: u128Permissioned score of the validator. It is used only when allow_permissionless_scoring is set to false.ramp_up_start_at: u64Scoring may in future be ramped up starting from this timestamp.
Resource ValidatorSystem
This struct stores the aggregate values across all validators associated with the protocol.
struct ValidatorSystem has key
Fields
total_balance_with_validators: u64Total amount in APT in stakes with validatorstotal_unlocking_balance: u64Total balance which has been reserved for payoutsassociated_validators: iterable_table_custom::IterableTableCustom<address, validator_states::AssociatedValidator>Table of all associated validatorstotal_score: u128Sum of scores of all the validators
Resource StatsConfig
This stores the on-chain oracle's config information. Careful choices for these values will be needed at genesis.
struct StatsConfig has key
Fields
initial_time_averaged_effective_reward_rate: u128Initial time averaged effective reward rate to assign to a new validator.min_span_between_observations_sec: u64Minimum time difference between reward rate checks.max_number_of_observations: u64Max number of observations to keep track of.initial_delegation_target: u64The initial amount of delegate to give to a validator. A nonzero value ensure that stats start to populate. *Note*: Should be around 10 APT.rate_normalizer: u128The normalizer for the rate of yield on the chain. If the chain reward rate is going to be ~ 10% per annum, then effective_reward_rate would be about 0.000011 per hour, or if we check validators every day and use the following normalize of 10^6, reward rate would be at most a four digit number.time_normalizer: u128The constant used to normalize the time. We will normalize observations to about 5 hours: 5 * 3600 = 18000.max_time_averaged_effective_reward_rate: u128Maximum possible time averaged effective reward rate. this should equal:
Initializes the module. Returns an UpdateCapability with the address of the sender.
Abort conditions
If the ValidatorSystem struct is already initialized for sender.
public fun initialize_validator_states(sender: &signer): validator_states::UpdateCapability
Implementation
public fun initialize_validator_states(sender: &signer): UpdateCapability {
assert!(
!exists<ValidatorSystem>(signer::address_of(sender)),
error::already_exists(EALREADY_EXISTS)
);
move_to(sender, ValidatorSystem {
total_balance_with_validators: 0,
total_unlocking_balance: 0,
associated_validators:
iterable_table_custom::new<address, AssociatedValidator>(),
total_score: 0,
});
// The Aptos' reward rate will be about 0.0011 per hour
// `max_time_averaged_effective_reward_rate` should be calculated using
// this rate at genesis.
move_to(sender, StatsConfig {
// Validators start with 0 score.
initial_time_averaged_effective_reward_rate: 0,
// 5 hours
min_span_between_observations_sec: 5 * 3600,
// We keep track of reward rate for 500 hours (right now we can only
// increase this number after init)
max_number_of_observations: 10,
// Set the initial delegation target to 10 APT to kickoff stats
// calculations.
initial_delegation_target: 1000000000,
// Larger the `*_normalizer`, better the precision of the scores.
rate_normalizer: 1000000000000000, // large for better precision
time_normalizer: 3600 * 5, // 5*3600 five hours ; ideal genesis value
// TODO: May update.
// 0.0011 * 1000000000000000 * 3600*5 / 3600
// This must be > 0.
max_time_averaged_effective_reward_rate: 5500000000000,
// We start permissionless scoring sometime after the launch
allow_permissionless_scoring: false,
// We start with no ramp up
ramp_up_duration: 0,
});
UpdateCapability {
state_storage_address: signer::address_of(sender),
}
}
Function get_total_unlocking_balance
Returns the ValidatorSystem struct stored at the address of state_storage_address.
public fun get_total_unlocking_balance(state_storage_address: address): u64
Implementation
public fun get_total_unlocking_balance(
state_storage_address: address
): u64 acquires ValidatorSystem {
let validator_system = borrow_global<ValidatorSystem>(
state_storage_address
);
validator_system.total_unlocking_balance
}
Function get_total_balance_with_validators
Returns validator_system.total_balance_with_validators stored at the address state_storage_address.
public fun get_total_balance_with_validators(state_storage_address: address): u64
Implementation
public fun get_total_balance_with_validators(
state_storage_address: address
): u64 acquires ValidatorSystem {
let validator_system = borrow_global<ValidatorSystem>(
state_storage_address
);
validator_system.total_balance_with_validators
}
Function get_balance_at_last_update
Returns the total balance with the managed staking pool at managed_pool_address.
public fun get_balance_at_last_update(state_storage_address: address, managed_pool_address: address): u64
Implementation
public fun get_balance_at_last_update(
state_storage_address: address,
managed_pool_address: address
): u64 acquires ValidatorSystem {
let validator_system = borrow_global<ValidatorSystem>(
state_storage_address
);
let validator =
iterable_table_custom::borrow<address, AssociatedValidator>(
&validator_system.associated_validators,
managed_pool_address
);
validator.balance_at_last_update_reserved +
validator.balance_at_last_update_unreserved
}
Function get_target_delegation
This returns the target delegation that the managed staking pool at managed_pool_address should receive from the protocol, given the performance of the validator.
This is used in module stake_router to allow permissionless staking. If allow_permissionless_scoring is set to false, the protocol determines the score that target_delegation depends on.
Abort conditions
If the managed_pool_address is not an associated validator.
public fun get_target_delegation(state_storage_address: address, managed_pool_address: address, required_tvl: u64): u64
Implementation
public fun get_target_delegation(
state_storage_address: address,
managed_pool_address: address,
required_tvl: u64,
): u64 acquires ValidatorSystem, StatsConfig {
let validator_system = borrow_global<ValidatorSystem>(
state_storage_address
);
let stats_config = borrow_global<StatsConfig>(state_storage_address);
let validator =
iterable_table_custom::borrow<address, AssociatedValidator>(
&validator_system.associated_validators,
managed_pool_address
);
if (stats_config.allow_permissionless_scoring) {
if (validator_system.total_score == 0) {
stats_config.initial_delegation_target
} else {
(
(
validator.score *
(required_tvl as u128) /
validator_system.total_score
) as u64
) + stats_config.initial_delegation_target
}
} else {
(
(
validator.permissioned_score *
(required_tvl as u128) /
PERMISSIONED_SCORING_NORMALIZER
) as u64
)
}
}
Function get_score
Get the score of the validator at managed_pool_address.
Abort conditions
If the managed_pool_address is not an associated validator.
public fun get_score(state_storage_address: address, managed_pool_address: address): u128
Implementation
public fun get_score(
state_storage_address: address,
managed_pool_address: address
): u128 acquires ValidatorSystem {
let validator_system = borrow_global<ValidatorSystem>(
state_storage_address
);
let validator =
iterable_table_custom::borrow<address, AssociatedValidator>(
&validator_system.associated_validators,
managed_pool_address
);
validator.score
}
Function get_permissioned_score
Get permissioned score of managed_pool_address from ValidatorSystem stored at state_storage_address.
This score will be used if StatsConfig.allow_permissionless_scoring is false.
public fun get_permissioned_score(state_storage_address: address, managed_pool_address: address): u128
Implementation
public fun get_permissioned_score(
state_storage_address: address,
managed_pool_address: address
): u128 acquires ValidatorSystem {
let validator_system = borrow_global<ValidatorSystem>(
state_storage_address
);
let validator =
iterable_table_custom::borrow<address, AssociatedValidator>(
&validator_system.associated_validators,
managed_pool_address
);
validator.permissioned_score
}
Function get_time_averaged_effective_reward_rate
Returns the time averaged effective reward rate of the validator at managed_pool_address from the ValidatorSystem stored at the address state_storage_address.
public fun get_time_averaged_effective_reward_rate(state_storage_address: address, managed_pool_address: address): u128
Implementation
public fun get_time_averaged_effective_reward_rate(
state_storage_address: address,
managed_pool_address: address
): u128 acquires ValidatorSystem {
let validator_system = borrow_global<ValidatorSystem>(
state_storage_address
);
let validator =
iterable_table_custom::borrow<address, AssociatedValidator>(
&validator_system.associated_validators,
managed_pool_address
);
validator.time_averaged_effective_reward_rate
}
Function get_last_update_timestamp
Returns the last time the validator at managed_pool_address was updated from the ValidatorSystem stored at the address state_storage_address.
public fun get_last_update_timestamp(state_storage_address: address, managed_pool_address: address): u64
Implementation
public fun get_last_update_timestamp(
state_storage_address: address,
managed_pool_address: address
): u64 acquires ValidatorSystem {
let validator_system = borrow_global<ValidatorSystem>(
state_storage_address
);
let validator =
iterable_table_custom::borrow<address, AssociatedValidator>(
&validator_system.associated_validators,
managed_pool_address
);
validator.last_update_timestamp
}
Function reset_config
This function can be used to reset the StatsConfig configuration stored at the address update_cap.state_storage_address post initialization.
StatsConfig may be reset initially at launch to fine tune parameters. Config should not be changed after it has been fine tuned for a network.
Restrictions
Can only be called with the UpdateCapability for the the address state_storage_address.
Abort conditions
If the max_number_of_observations is less than the currently set value.
Set score for the validator at managed_pool_address in the ValidatorSystem stored at the address update_cap.state_storage_address to value.
It would not affect target_delegations unless StatsConfig.allow_permissionless_scoring is set to false.
Restrictions
Can only be called with the UpdateCapability for the the desired address.
public fun set_permissioned_score(update_cap: &validator_states::UpdateCapability, managed_pool_address: address, value: u128)
Implementation
public fun set_permissioned_score(
update_cap: &UpdateCapability,
managed_pool_address: address,
value: u128,
) acquires ValidatorSystem {
let validator_system = borrow_global_mut<ValidatorSystem>(
update_cap.state_storage_address
);
let validator =
iterable_table_custom::borrow_mut<address, AssociatedValidator>(
&mut validator_system.associated_validators,
managed_pool_address
);
validator.permissioned_score = value;
}
Function validator_signup_internal
Add a new associated validator at managed_pool_address to the ValidatorSystem stored at the address update_cap.state_storage_address.
Restrictions
Can only be called with the UpdateCapability for the desired address.
public fun validator_signup_internal(managed_pool_address: address, update_cap: &validator_states::UpdateCapability)
Implementation
public fun validator_signup_internal(
managed_pool_address: address,
update_cap: &UpdateCapability,
) acquires ValidatorSystem, StatsConfig {
let validator_system = borrow_global_mut<ValidatorSystem>(
update_cap.state_storage_address
);
let stats_config = borrow_global<StatsConfig>(
update_cap.state_storage_address
);
let current_time = timestamp::now_seconds();
// last reconfiguration time in seconds
let reconfiguration_time =
reconfiguration::last_reconfiguration_time() / 1000000;
let validator = AssociatedValidator {
last_update_timestamp: current_time,
last_reconfiguration_timestamp: reconfiguration_time,
balance_at_last_update_unreserved: 0,
balance_at_last_update_reserved: 0,
time_averaged_effective_reward_rate:
stats_config.initial_time_averaged_effective_reward_rate,
observations: circular_buffer::empty<Observation>(),
observation_begin_timestamp: current_time,
score: 0,
permissioned_score: PERMISSIONED_SCORING_NORMALIZER,
ramp_up_start_at: current_time,
};
iterable_table_custom::add<address, AssociatedValidator>(
&mut validator_system.associated_validators,
managed_pool_address, validator
);
}
Function validator_removal_internal
Remove an associated validator at managed_pool_address from the ValidatorSystem stored at the address update_cap.state_storage_address.
Restrictions
Can only be called with the UpdateCapability for the desired address.
Abort conditions
If either the reserved or unreserved balance with the validator is non-zero.
public fun validator_removal_internal(managed_pool_address: address, update_cap: &validator_states::UpdateCapability)
Implementation
public fun validator_removal_internal(
managed_pool_address: address,
update_cap: &UpdateCapability,
) acquires ValidatorSystem {
let validator_system = borrow_global_mut<ValidatorSystem>(
update_cap.state_storage_address
);
let validator =
iterable_table_custom::remove<address, AssociatedValidator>(
&mut validator_system.associated_validators,
managed_pool_address
);
// The balances should be able to be reset permissionlessly via
// `validator_router::pay_commission_to_the_validator`.
assert!(
validator.balance_at_last_update_unreserved == 0 &&
validator.balance_at_last_update_reserved == 0,
error::invalid_state(ENONZERO_BALANCE_AT_LAST_UPDATE)
);
burn_associated_validator(validator);
}
Function update_validator_and_total_internal
This function is called at every interaction with an associated validator, to update balances and stats. Commissions must be paid to the associated validator before calling this function.
Restrictions
Can only be called with the UpdateCapability for the desired address.
public fun update_validator_and_total_internal(
managed_pool_address: address,
update_cap: &UpdateCapability,
current_balance_reserved: u64,
current_balance_unreserved: u64,
amount_in: u64,
amount_out: u64,
amount_reserved: u64,
validator_specific_scoring_multiplier: u64,
shift_amount: u64,
): Option<u128> acquires ValidatorSystem, StatsConfig {
// MODIFY THE BALANCES FIRST
// only at most one of the amounts field could be nonzero:
// 1. `amount_in > 0` if the money just went into the
// `managed_stake_pool` just before state modification via a delegate
// call,
// 2. `amount_out > 0` if the money just went out of the
// `managed_stake_pool` just before state modification via a
// `withdraw_to_reserve` call,
// 3. `amount_reserved > 0` if an amount was reserved for withdrawal in
// the `managed_stake_pool` just before state modification via a
// `reserve_shares_for_payout` call, or
// 4. all three amount fields are zero, if only
// `pay_commission_to_owner` was called
// We calculate current balances
let validator_system = borrow_global_mut<ValidatorSystem>(
update_cap.state_storage_address
);
let validator =
iterable_table_custom::borrow_mut<address, AssociatedValidator>(
&mut validator_system.associated_validators,
managed_pool_address
);
let current_balance =
current_balance_reserved + current_balance_unreserved;
let balance_at_last_update =
validator.balance_at_last_update_reserved +
validator.balance_at_last_update_unreserved;
let (
total_rewards,
total_negative_rewards,
reserved_rewards,
reserved_negative_rewards,
) = calculate_rewards(
current_balance,
balance_at_last_update,
current_balance_reserved,
validator.balance_at_last_update_reserved,
amount_in,
amount_out,
amount_reserved,
);
if (amount_in > 0) {
// we now calculate the rewards on balance which is unlocking
// We use if else to avoid any floating point catastrophe
validator_system.total_balance_with_validators =
math::add_possibly_negative(
validator_system.total_balance_with_validators + amount_in,
total_rewards,
total_negative_rewards
);
validator_system.total_unlocking_balance =
math::add_possibly_negative(
validator_system.total_unlocking_balance,
reserved_rewards,
reserved_negative_rewards
);
} else if (amount_out > 0) {
// The order of `safe_sub` and `add_possibly_negative` is important
// here. Reversing it can result in a nonzero entry for
// `total_balance_with_validators`, while it should actually be
// zero.
validator_system.total_balance_with_validators = math::safe_sub(
math::add_possibly_negative(
validator_system.total_balance_with_validators,
total_rewards,
total_negative_rewards
),
amount_out
);
// It is important to not subtract `amount_out` as the old state
// did not know about amount_out
validator_system.total_unlocking_balance = math::safe_sub(
validator_system.total_unlocking_balance,
validator.balance_at_last_update_reserved
);
} else {
validator_system.total_balance_with_validators =
math::add_possibly_negative(
validator_system.total_balance_with_validators,
total_rewards,
total_negative_rewards
);
validator_system.total_unlocking_balance =
math::add_possibly_negative(
validator_system.total_unlocking_balance + amount_reserved,
reserved_rewards,
reserved_negative_rewards
);
};
validator.balance_at_last_update_reserved = current_balance_reserved;
validator.balance_at_last_update_unreserved =
current_balance_unreserved;
// Finally, we update the validator stats
let stats_config = borrow_global<StatsConfig>(
update_cap.state_storage_address
);
let observation_option = modify_validator_score(
stats_config,
validator_system,
managed_pool_address,
balance_at_last_update,
total_rewards,
validator_specific_scoring_multiplier,
shift_amount,
);
// Return a value
if (option::is_none(&observation_option)) {
option::destroy_none(observation_option);
option::none()
} else {
let a = option::destroy_some(observation_option);
option::some(a.effective_reward_rate)
}
}
Function calculate_rewards
Returns the total_rewards, total_negative_rewards, reserved_rewards, reserved_negative_rewards given the current and previous balances and the amount of tokens that were added or removed.
Calculates effective reward rate based on, new rewards, balance_at_last_update and time_delta between the updates.
The stats_config is needed to determine the normalizers.
fun effective_reward_rate(stats_config: &validator_states::StatsConfig, rewards: u128, balance_at_last_update: u128, time_delta: u128): u128
Implementation
fun effective_reward_rate(
stats_config: &StatsConfig,
rewards: u128,
balance_at_last_update: u128,
time_delta: u128,
): u128 {
// The order of operations is important to ensure highest precision.
(rewards * stats_config.rate_normalizer / balance_at_last_update) *
stats_config.time_normalizer / time_delta
}
Function time_averaged_effective_reward_rate
Returns the time averaged effective reward rate given the tail_sum, the new head and the total_time span the observations cover.
fun time_averaged_effective_reward_rate(tail_sum: u128, head: u128, total_time: u128): u128
This function calculates the score of a validator based on the the time_averaged_effective_reward_rate (a) and the maximum possible time_averaged_effective_reward_rate on the Aptos chain (b).
As the name suggests, this function behaves like a cliff, with a steep drop as a diverges from b.
fun cliff(
a: u128,
b: u128,
twar_multiplier: u128,
ramp_up_multiplier: u128,
shift_amount: u128,
): u128 {
let multiplier_precision_u128 = (MULTIPLIER_PRECISION as u128);
a = a * twar_multiplier / multiplier_precision_u128;
let pct = a * CLIFF_PRECISION / b;
let cutoff_top = 96 * CLIFF_PRECISION / 100;
let cutoff_mid = 92 * CLIFF_PRECISION / 100;
let cutoff_bot = 88 * CLIFF_PRECISION / 100;
let raw_score: u128;
if (a >= b) {
raw_score = SCORE_BUCKET_TOP;
} else if (pct >= cutoff_top) {
// if a is equal to b, then the return value is SCORE_BUCKET_TOP
// if a = 0.96 b, then the return value is SCORE_BUCKET_MID
let den = math::max_u128(
math::safe_sub_u128(2476 * b, 2475 * a),
1
);
raw_score = b * SCORE_BUCKET_TOP / den;
} else if (pct >= cutoff_mid) {
// if a = 0.96 b, then the return value is SCORE_BUCKET_MID
// if a = 0.92 b, then the return value us is SCORE_BUCKET_BOT
let den = math::max_u128(
math::safe_sub_u128(2377 * b, 2475 * a),
1
);
raw_score = b * SCORE_BUCKET_MID / den;
} else if (pct >= cutoff_bot) {
// if a = 0.92 b, then the return is SCORE_BUCKET_BOT
// if a = 0.88 b, then the return value is is SCORE_BUCKET_BOT / 100
let den = math::max_u128(
math::safe_sub_u128(2278 * b, 2475 * a),
1
);
raw_score = b * SCORE_BUCKET_BOT / den;
} else {
raw_score = 0;
};
// We get raw score.
// mul_div not needed here as everything is u128 anyway.
let raw_score = ramp_up_multiplier * raw_score / multiplier_precision_u128;
// return the score after shifting
apply_shift(
raw_score,
shift_amount
)
}
Function apply_shift
fun apply_shift(raw_score: u128, shift_amount: u128): u128