From bee7e7f541394aebcf4f8f184ba542bbc1a984cf Mon Sep 17 00:00:00 2001 From: Roy Yang Date: Tue, 17 Oct 2023 23:45:34 +1300 Subject: [PATCH] feat: account_info_v2 APY (#4112) * 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% --- .../cf-integration-tests/src/authorities.rs | 4 +- .../cf-integration-tests/src/funding.rs | 141 +++++++++++++++++- state-chain/custom-rpc/src/lib.rs | 6 + state-chain/pallets/cf-emissions/src/lib.rs | 2 +- state-chain/pallets/cf-emissions/src/tests.rs | 5 +- state-chain/runtime/src/chainflip.rs | 52 ++++++- .../src/chainflip/backup_node_rewards.rs | 2 +- state-chain/runtime/src/constants.rs | 1 + state-chain/runtime/src/lib.rs | 8 +- state-chain/runtime/src/runtime_apis.rs | 1 + 10 files changed, 203 insertions(+), 19 deletions(-) diff --git a/state-chain/cf-integration-tests/src/authorities.rs b/state-chain/cf-integration-tests/src/authorities.rs index a4c97d3464..71ae0830cf 100644 --- a/state-chain/cf-integration-tests/src/authorities.rs +++ b/state-chain/cf-integration-tests/src/authorities.rs @@ -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, BTreeSet) { // 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 = Validator::current_authorities(); let (mut testnet, init_backup_nodes) = network::Network::create(max_authorities as u8, &genesis_authorities); diff --git a/state-chain/cf-integration-tests/src/funding.rs b/state-chain/cf-integration-tests/src/funding.rs index 82dc6b2afe..80a35139c2 100644 --- a/state-chain/cf-integration-tests/src/funding.rs +++ b/state-chain/cf-integration-tests/src/funding.rs @@ -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) @@ -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::( + Validator::highest_funded_qualified_backup_node_bids().collect::>(), + 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::( + Validator::highest_funded_qualified_backup_node_bids().collect::>(), + 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)); + }); +} diff --git a/state-chain/custom-rpc/src/lib.rs b/state-chain/custom-rpc/src/lib.rs index 91dc192e9b..6b55f36bda 100644 --- a/state-chain/custom-rpc/src/lib.rs +++ b/state-chain/custom-rpc/src/lib.rs @@ -59,6 +59,7 @@ pub enum RpcAccountInfo { is_online: bool, is_bidding: bool, bound_redeem_address: Option, + apy_bp: Option, restricted_balances: BTreeMap, }, } @@ -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() @@ -137,6 +139,7 @@ pub struct RpcAccountInfoV2 { pub is_online: bool, pub is_bidding: bool, pub bound_redeem_address: Option, + pub apy_bp: Option, pub restricted_balances: BTreeMap, } @@ -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, }) } @@ -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!( @@ -1028,6 +1033,7 @@ mod test { "online_credits": 0, "reputation_points": 0, "role": "validator", + "apy_bp": 100, "restricted_balances": { "0x0101010101010101010101010101010101010101": "0xde0b6b3a7640000" } diff --git a/state-chain/pallets/cf-emissions/src/lib.rs b/state-chain/pallets/cf-emissions/src/lib.rs index 36f7dedbe2..549b723a7d 100644 --- a/state-chain/pallets/cf-emissions/src/lib.rs +++ b/state-chain/pallets/cf-emissions/src/lib.rs @@ -91,7 +91,7 @@ pub mod pallet { #[pallet::constant] type CompoundingInterval: Get>; - /// 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. diff --git a/state-chain/pallets/cf-emissions/src/tests.rs b/state-chain/pallets/cf-emissions/src/tests.rs index 7ef30eb5f3..89478d4107 100644 --- a/state-chain/pallets/cf-emissions/src/tests.rs +++ b/state-chain/pallets/cf-emissions/src/tests.rs @@ -117,7 +117,7 @@ fn no_update_of_update_total_supply_during_safe_mode_code_red() { >::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::::total_issuance() @@ -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 @@ -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 diff --git a/state-chain/runtime/src/chainflip.rs b/state-chain/runtime/src/chainflip.rs index d42f1eed1d..7902d951aa 100644 --- a/state-chain/runtime/src/chainflip.rs +++ b/state-chain/runtime/src/chainflip.rs @@ -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; @@ -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::{ @@ -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, }; @@ -601,3 +601,43 @@ impl QualifyNode<::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 { + if pallet_cf_validator::CurrentAuthorities::::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::::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::>(); + 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::( + 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() + }) +} diff --git a/state-chain/runtime/src/chainflip/backup_node_rewards.rs b/state-chain/runtime/src/chainflip/backup_node_rewards.rs index 02ee5ea281..875a7f69a4 100644 --- a/state-chain/runtime/src/chainflip/backup_node_rewards.rs +++ b/state-chain/runtime/src/chainflip/backup_node_rewards.rs @@ -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 diff --git a/state-chain/runtime/src/constants.rs b/state-chain/runtime/src/constants.rs index 5a2ad4560d..4ff6af7dc4 100644 --- a/state-chain/runtime/src/constants.rs +++ b/state-chain/runtime/src/constants.rs @@ -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; diff --git a/state-chain/runtime/src/lib.rs b/state-chain/runtime/src/lib.rs index fd16b909c4..3452885b4e 100644 --- a/state-chain/runtime/src/lib.rs +++ b/state-chain/runtime/src/lib.rs @@ -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::{ @@ -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::{ @@ -913,11 +913,9 @@ impl_runtime_apis! { }) .collect() } - fn cf_account_flip_balance(account_id: &AccountId) -> u128 { pallet_cf_flip::Account::::get(account_id).total() } - fn cf_account_info_v2(account_id: &AccountId) -> RuntimeApiAccountInfoV2 { let is_current_backup = pallet_cf_validator::Backups::::get().contains_key(account_id); let key_holder_epochs = pallet_cf_validator::HistoricalActiveEpochs::::get(account_id); @@ -925,6 +923,7 @@ impl_runtime_apis! { let is_current_authority = pallet_cf_validator::CurrentAuthorities::::get().contains(account_id); let is_bidding = pallet_cf_funding::ActiveBidder::::get(account_id); let bound_redeem_address = pallet_cf_funding::BoundRedeemAddress::::get(account_id); + let apy_bp = calculate_account_apy(account_id); let reputation_info = pallet_cf_reputation::Reputations::::get(account_id); let account_info = pallet_cf_flip::Account::::get(account_id); let restricted_balances = pallet_cf_funding::RestrictedBalances::::get(account_id); @@ -941,6 +940,7 @@ impl_runtime_apis! { is_online: Reputation::is_qualified(account_id), is_bidding, bound_redeem_address, + apy_bp, restricted_balances, } } diff --git a/state-chain/runtime/src/runtime_apis.rs b/state-chain/runtime/src/runtime_apis.rs index a02a0e7838..69fd5a98bb 100644 --- a/state-chain/runtime/src/runtime_apis.rs +++ b/state-chain/runtime/src/runtime_apis.rs @@ -49,6 +49,7 @@ pub struct RuntimeApiAccountInfoV2 { pub is_online: bool, pub is_bidding: bool, pub bound_redeem_address: Option, + pub apy_bp: Option, // APY for validator/back only. In Basis points. pub restricted_balances: BTreeMap, }