Skip to content

Commit

Permalink
feat: account_info_v2 APY (#4112)
Browse files Browse the repository at this point in the history
* Added APY for a user to account_info_v2 call.
Added integration tests to ensure the numbers are calculated correctly

* Fixed test

* Minor improvement to address PR comment

* Fixed a bug where APY is capped at 100%
  • Loading branch information
syan095 authored Oct 17, 2023
1 parent 0a09832 commit bee7e7f
Show file tree
Hide file tree
Showing 10 changed files with 203 additions and 19 deletions.
4 changes: 2 additions & 2 deletions state-chain/cf-integration-tests/src/authorities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ use state_chain_runtime::{
};

// Helper function that creates a network, funds backup nodes, and have them join the auction.
fn fund_authorities_and_join_auction(
pub fn fund_authorities_and_join_auction(
max_authorities: AuthorityCount,
) -> (network::Network, BTreeSet<NodeId>, BTreeSet<NodeId>) {
// Create MAX_AUTHORITIES backup nodes and fund them above our genesis
// authorities The result will be our newly created nodes will be authorities
// and the genesis authorities will become backup nodes
let genesis_authorities = Validator::current_authorities();
let genesis_authorities: BTreeSet<AccountId32> = Validator::current_authorities();
let (mut testnet, init_backup_nodes) =
network::Network::create(max_authorities as u8, &genesis_authorities);

Expand Down
141 changes: 139 additions & 2 deletions state-chain/cf-integration-tests/src/funding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ use crate::{

use super::{genesis, network, *};
use cf_primitives::{AccountRole, GENESIS_EPOCH};
use cf_traits::{offence_reporting::OffenceReporter, EpochInfo};
use cf_traits::{offence_reporting::OffenceReporter, AccountInfo, Bid, EpochInfo};
use mock_runtime::MIN_FUNDING;
use pallet_cf_funding::pallet::Error;
use pallet_cf_validator::{Backups, CurrentRotationPhase};
use state_chain_runtime::chainflip::Offence;
use sp_runtime::{FixedPointNumber, FixedU64};
use state_chain_runtime::chainflip::{
backup_node_rewards::calculate_backup_rewards, calculate_account_apy, Offence,
};

#[test]
// Nodes cannot redeem when we are out of the redeeming period (50% of the epoch)
Expand Down Expand Up @@ -136,3 +139,137 @@ fn funded_node_is_added_to_backups() {
assert_eq!(backups_map.get(&new_backup).unwrap(), &NEW_FUNDING_AMOUNT);
});
}

#[test]
fn backup_reward_is_calculated_linearly() {
const EPOCH_BLOCKS: u32 = 1_000;
const MAX_AUTHORITIES: u32 = 10;
const NUM_BACKUPS: u32 = 20;
super::genesis::default()
.blocks_per_epoch(EPOCH_BLOCKS)
.max_authorities(MAX_AUTHORITIES)
.build()
.execute_with(|| {
let (mut network, _, _) =
crate::authorities::fund_authorities_and_join_auction(NUM_BACKUPS);
network.move_to_the_next_epoch();

// 3 backup will split the backup reward.
assert_eq!(Validator::highest_funded_qualified_backup_node_bids().count(), 3);

let rewards_per_block = &calculate_backup_rewards::<AccountId, FlipBalance>(
Validator::highest_funded_qualified_backup_node_bids().collect::<Vec<_>>(),
Validator::bond(),
1u128,
Emissions::backup_node_emission_per_block(),
Emissions::current_authority_emission_per_block(),
Validator::current_authority_count() as u128,
);

let rewards_per_heartbeat = &calculate_backup_rewards::<AccountId, FlipBalance>(
Validator::highest_funded_qualified_backup_node_bids().collect::<Vec<_>>(),
Validator::bond(),
HEARTBEAT_BLOCK_INTERVAL as u128,
Emissions::backup_node_emission_per_block(),
Emissions::current_authority_emission_per_block(),
Validator::current_authority_count() as u128,
);

for i in 0..rewards_per_block.len() {
// Validator account should match
assert_eq!(rewards_per_block[i].0, rewards_per_heartbeat[i].0);
// Reward per heartbeat should be scaled linearly.
assert_eq!(
rewards_per_heartbeat[i].1,
rewards_per_block[i].1 * HEARTBEAT_BLOCK_INTERVAL as u128
);
}
});
}

#[test]
fn can_calculate_account_apy() {
const EPOCH_BLOCKS: u32 = 1_000;
const MAX_AUTHORITIES: u32 = 10;
const NUM_BACKUPS: u32 = 20;
super::genesis::default()
.blocks_per_epoch(EPOCH_BLOCKS)
.max_authorities(MAX_AUTHORITIES)
.build()
.execute_with(|| {
let (mut network, _, _) =
crate::authorities::fund_authorities_and_join_auction(NUM_BACKUPS);
network.move_to_the_next_epoch();

let mut backup_earning_rewards = Validator::highest_funded_qualified_backup_node_bids();
let all_backups = Validator::backups();
let validator = Validator::current_authorities().into_iter().next().unwrap();
let Bid { bidder_id: backup, amount: backup_staked } =
backup_earning_rewards.next().unwrap();

// Normal account returns None
let no_reward = AccountId::from([0xff; 32]);
assert!(!Validator::current_authorities().contains(&no_reward));
assert!(!Validator::backups().contains_key(&no_reward));
assert!(calculate_account_apy(&no_reward).is_none());

// Backups that are not qualified to earn rewards are returned None
let backup_no_reward = AccountId::from([0x01; 32]);
assert!(all_backups.contains_key(&backup_no_reward));
assert!(!backup_earning_rewards
.any(|Bid { bidder_id, amount: _ }| bidder_id == backup_no_reward));
assert!(calculate_account_apy(&backup_no_reward).is_none());

// APY rate is correct for current Authority
let total = Flip::balance(&validator);
let reward = Emissions::current_authority_emission_per_block() * YEAR as u128 / 10u128;
let apy_basis_point =
FixedU64::from_rational(reward, total).checked_mul_int(10_000u32).unwrap();
assert_eq!(apy_basis_point, 49u32);
assert_eq!(calculate_account_apy(&validator), Some(apy_basis_point));

// APY rate is correct for backup that are earning rewards.
// Since all 3 backup validators has the same staked amount, and the award is capped by
// Emission rewards are split evenly between 3 validators.
let reward = Emissions::backup_node_emission_per_block() / 3u128 * YEAR as u128;
let apy_basis_point = FixedU64::from_rational(reward, backup_staked)
.checked_mul_int(10_000u32)
.unwrap();
assert_eq!(apy_basis_point, 35u32);
assert_eq!(calculate_account_apy(&backup), Some(apy_basis_point));
});
}

#[test]
fn apy_can_be_above_100_percent() {
const EPOCH_BLOCKS: u32 = 1_000;
const MAX_AUTHORITIES: u32 = 1;
const NUM_BACKUPS: u32 = 1;
super::genesis::default()
.blocks_per_epoch(EPOCH_BLOCKS)
.max_authorities(MAX_AUTHORITIES)
.build()
.execute_with(|| {
let (mut network, _, _) =
crate::authorities::fund_authorities_and_join_auction(NUM_BACKUPS);
network.move_to_the_next_epoch();

let validator = Validator::current_authorities().into_iter().next().unwrap();

// Set the validator yield to very high
assert_ok!(Emissions::update_current_authority_emission_inflation(
pallet_cf_governance::RawOrigin::GovernanceApproval.into(),
1_000_000_000u32
));

network.move_to_the_next_epoch();

// APY rate of > 100% can be calculated correctly.
let total = Flip::balance(&validator);
let reward = Emissions::current_authority_emission_per_block() * YEAR as u128;
let apy_basis_point =
FixedU64::from_rational(reward, total).checked_mul_int(10_000u32).unwrap();
assert_eq!(apy_basis_point, 242_543_802u32);
assert_eq!(calculate_account_apy(&validator), Some(apy_basis_point));
});
}
6 changes: 6 additions & 0 deletions state-chain/custom-rpc/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ pub enum RpcAccountInfo {
is_online: bool,
is_bidding: bool,
bound_redeem_address: Option<EthereumAddress>,
apy_bp: Option<u32>,
restricted_balances: BTreeMap<EthereumAddress, NumberOrHex>,
},
}
Expand Down Expand Up @@ -114,6 +115,7 @@ impl RpcAccountInfo {
is_online: info.is_online,
is_bidding: info.is_bidding,
bound_redeem_address: info.bound_redeem_address,
apy_bp: info.apy_bp,
restricted_balances: info
.restricted_balances
.into_iter()
Expand All @@ -137,6 +139,7 @@ pub struct RpcAccountInfoV2 {
pub is_online: bool,
pub is_bidding: bool,
pub bound_redeem_address: Option<EthereumAddress>,
pub apy_bp: Option<u32>,
pub restricted_balances: BTreeMap<EthereumAddress, u128>,
}

Expand Down Expand Up @@ -588,6 +591,7 @@ where
is_online: account_info.is_online,
is_bidding: account_info.is_bidding,
bound_redeem_address: account_info.bound_redeem_address,
apy_bp: account_info.apy_bp,
restricted_balances: account_info.restricted_balances,
})
}
Expand Down Expand Up @@ -1010,6 +1014,7 @@ mod test {
is_online: true,
is_qualified: true,
bound_redeem_address: Some(H160::from([1; 20])),
apy_bp: Some(100u32),
restricted_balances: BTreeMap::from_iter(vec![(H160::from([1; 20]), 10u128.pow(18))]),
});
assert_eq!(
Expand All @@ -1028,6 +1033,7 @@ mod test {
"online_credits": 0,
"reputation_points": 0,
"role": "validator",
"apy_bp": 100,
"restricted_balances": {
"0x0101010101010101010101010101010101010101": "0xde0b6b3a7640000"
}
Expand Down
2 changes: 1 addition & 1 deletion state-chain/pallets/cf-emissions/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ pub mod pallet {
#[pallet::constant]
type CompoundingInterval: Get<BlockNumberFor<Self>>;

/// Something that can provide the state chain gatweay address.
/// Something that can provide the state chain gateway address.
type EthEnvironment: EthEnvironmentProvider;

/// The interface for accessing the amount of Flip we want burn.
Expand Down
5 changes: 2 additions & 3 deletions state-chain/pallets/cf-emissions/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ fn no_update_of_update_total_supply_during_safe_mode_code_red() {
<MockRuntimeSafeMode as SetSafeMode<MockRuntimeSafeMode>>::set_code_green();
// Try send a broadcast to update the total supply
Emissions::on_initialize((SUPPLY_UPDATE_INTERVAL * 2).into());
// Expect the broadcast to be sendt
// Expect the broadcast to be sent
assert_eq!(
MockBroadcast::get_called().unwrap().new_total_supply,
Flip::<Test>::total_issuance()
Expand All @@ -126,7 +126,7 @@ fn no_update_of_update_total_supply_during_safe_mode_code_red() {
}

#[test]
fn test_example_block_reward_calcaulation() {
fn test_example_block_reward_calculation() {
use crate::calculate_inflation_to_block_reward;
let issuance: u128 = 100_000_000_000_000_000_000_000_000; // 100m Flip
let inflation: u128 = 2720; // perbill
Expand All @@ -135,7 +135,6 @@ fn test_example_block_reward_calcaulation() {
}

const BLOCKS_PER_YEAR: u64 = (365 * 24 + 6) * 60 * 60 / SECONDS_PER_BLOCK;

#[test]
fn rewards_calculation_compounding() {
const INITIAL_ISSUANCE: u128 = 100_000_000_000_000_000_000_000_000; // 100m Flip
Expand Down
52 changes: 46 additions & 6 deletions state-chain/runtime/src/chainflip.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! Configuration, utilities and helpers for the Chainflip runtime.
pub mod address_derivation;
pub mod all_vaults_rotator;
mod backup_node_rewards;
pub mod backup_node_rewards;
pub mod chain_instances;
pub mod decompose_recompose;
pub mod epoch_transition;
Expand All @@ -12,7 +12,7 @@ use crate::{
AccountId, AccountRoles, Authorship, BitcoinChainTracking, BitcoinIngressEgress, BitcoinVault,
BlockNumber, Emissions, Environment, EthereumBroadcaster, EthereumChainTracking,
EthereumIngressEgress, Flip, FlipBalance, PolkadotBroadcaster, PolkadotChainTracking,
PolkadotIngressEgress, Runtime, RuntimeCall, System, Validator,
PolkadotIngressEgress, Runtime, RuntimeCall, System, Validator, YEAR,
};
use backup_node_rewards::calculate_backup_rewards;
use cf_chains::{
Expand Down Expand Up @@ -44,16 +44,16 @@ use cf_chains::{
};
use cf_primitives::{chains::assets, AccountRole, Asset, BasisPoints, ChannelId, EgressId};
use cf_traits::{
AccountRoleRegistry, BlockEmissions, BroadcastAnyChainGovKey, Broadcaster, Chainflip,
CommKeyBroadcaster, DepositApi, DepositHandler, EgressApi, EpochInfo, Heartbeat, Issuance,
KeyProvider, OnBroadcastReady, QualifyNode, RewardsDistribution, RuntimeUpgrade,
AccountInfo, AccountRoleRegistry, BlockEmissions, BroadcastAnyChainGovKey, Broadcaster,
Chainflip, CommKeyBroadcaster, DepositApi, DepositHandler, EgressApi, EpochInfo, Heartbeat,
Issuance, KeyProvider, OnBroadcastReady, QualifyNode, RewardsDistribution, RuntimeUpgrade,
};
use codec::{Decode, Encode};
use frame_support::{
dispatch::{DispatchError, DispatchErrorWithPostInfo, PostDispatchInfo},
sp_runtime::{
traits::{BlockNumberProvider, One, UniqueSaturatedFrom, UniqueSaturatedInto},
FixedU64,
FixedPointNumber, FixedU64,
},
traits::Get,
};
Expand Down Expand Up @@ -601,3 +601,43 @@ impl QualifyNode<<Runtime as Chainflip>::ValidatorId> for ValidatorRoleQualifica
AccountRoles::has_account_role(id, AccountRole::Validator)
}
}

// Calculates the APY of a given account, returned in Basis Points (1 b.p. = 0.01%)
// Returns Some(APY) if the account is a Validator/backup validator.
// Otherwise returns None.
pub fn calculate_account_apy(account_id: &AccountId) -> Option<u32> {
if pallet_cf_validator::CurrentAuthorities::<Runtime>::get().contains(account_id) {
// Authority: reward is earned by authoring a block.
Some(
Emissions::current_authority_emission_per_block() * YEAR as u128 /
pallet_cf_validator::CurrentAuthorities::<Runtime>::decode_len()
.expect("Current authorities must exists and non-empty.") as u128,
)
} else {
let backups_earning_rewards =
Validator::highest_funded_qualified_backup_node_bids().collect::<Vec<_>>();
if backups_earning_rewards.iter().any(|bid| bid.bidder_id == *account_id) {
// Calculate backup validator reward for the current block, then scaled linearly into
// YEAR.
calculate_backup_rewards::<AccountId, FlipBalance>(
backups_earning_rewards,
Validator::bond(),
One::one(),
Emissions::backup_node_emission_per_block(),
Emissions::current_authority_emission_per_block(),
u128::from(Validator::current_authority_count()),
)
.into_iter()
.find(|(id, _reward)| *id == *account_id)
.map(|(_id, reward)| reward * YEAR as u128)
} else {
None
}
}
.map(|reward_pa| {
// Convert Permill to Basis Point.
FixedU64::from_rational(reward_pa, Flip::balance(account_id))
.checked_mul_int(10_000u32)
.unwrap_or_default()
})
}
2 changes: 1 addition & 1 deletion state-chain/runtime/src/chainflip/backup_node_rewards.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use cf_traits::Bid;
use frame_support::sp_runtime::{helpers_128bit::multiply_by_rational_with_rounding, Rounding};
use sp_std::{cmp::min, prelude::*};

//TODO: The u128 is not big enough for some calculations (for example this one) which involve
// TODO: The u128 is not big enough for some calculations (for example this one) which involve
// intermediate steps of the calculation create values that saturate the u128. In this and in
// similar cases we might have to convert the values to BigInt for calculation and then convert it
// back to u128 after calculation. In this case, the saturation problem can lead to upto 0.03 - 0.05
Expand Down
1 change: 1 addition & 0 deletions state-chain/runtime/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ pub mod common {
pub const MINUTES: BlockNumber = 60_000 / (MILLISECONDS_PER_BLOCK as BlockNumber);
pub const HOURS: BlockNumber = MINUTES * 60;
pub const DAYS: BlockNumber = HOURS * 24;
pub const YEAR: BlockNumber = DAYS * 365;

pub const EXPIRY_SPAN_IN_SECONDS: u64 = 80000;

Expand Down
8 changes: 4 additions & 4 deletions state-chain/runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ pub mod safe_mode;
pub mod test_runner;
mod weights;
use crate::{
chainflip::Offence,
chainflip::{calculate_account_apy, Offence},
runtime_apis::{AuctionState, LiquidityProviderInfo, RuntimeApiPenalty},
};
use cf_amm::{
Expand Down Expand Up @@ -82,7 +82,7 @@ use sp_version::RuntimeVersion;
pub use cf_primitives::{
AccountRole, Asset, AssetAmount, BlockNumber, FlipBalance, SemVer, SwapOutput,
};
pub use cf_traits::{EpochInfo, QualifyNode, SessionKeysRegistered, SwappingApi};
pub use cf_traits::{AccountInfo, EpochInfo, QualifyNode, SessionKeysRegistered, SwappingApi};

pub use chainflip::chain_instances::*;
use chainflip::{
Expand Down Expand Up @@ -913,18 +913,17 @@ impl_runtime_apis! {
})
.collect()
}

fn cf_account_flip_balance(account_id: &AccountId) -> u128 {
pallet_cf_flip::Account::<Runtime>::get(account_id).total()
}

fn cf_account_info_v2(account_id: &AccountId) -> RuntimeApiAccountInfoV2 {
let is_current_backup = pallet_cf_validator::Backups::<Runtime>::get().contains_key(account_id);
let key_holder_epochs = pallet_cf_validator::HistoricalActiveEpochs::<Runtime>::get(account_id);
let is_qualified = <<Runtime as pallet_cf_validator::Config>::KeygenQualification as QualifyNode<_>>::is_qualified(account_id);
let is_current_authority = pallet_cf_validator::CurrentAuthorities::<Runtime>::get().contains(account_id);
let is_bidding = pallet_cf_funding::ActiveBidder::<Runtime>::get(account_id);
let bound_redeem_address = pallet_cf_funding::BoundRedeemAddress::<Runtime>::get(account_id);
let apy_bp = calculate_account_apy(account_id);
let reputation_info = pallet_cf_reputation::Reputations::<Runtime>::get(account_id);
let account_info = pallet_cf_flip::Account::<Runtime>::get(account_id);
let restricted_balances = pallet_cf_funding::RestrictedBalances::<Runtime>::get(account_id);
Expand All @@ -941,6 +940,7 @@ impl_runtime_apis! {
is_online: Reputation::is_qualified(account_id),
is_bidding,
bound_redeem_address,
apy_bp,
restricted_balances,
}
}
Expand Down
Loading

0 comments on commit bee7e7f

Please sign in to comment.