diff --git a/Cargo.lock b/Cargo.lock index a128f88d4..bca0f74f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8199,9 +8199,16 @@ dependencies = [ name = "serai-genesis-liquidity-pallet" version = "0.1.0" dependencies = [ + "ciphersuite", "frame-support", "frame-system", + "frost-schnorrkel", + "modular-frost", + "pallet-babe", + "pallet-grandpa", + "pallet-timestamp", "parity-scale-codec", + "rand_core", "scale-info", "serai-coins-pallet", "serai-dex-pallet", @@ -8212,7 +8219,10 @@ dependencies = [ "serai-validator-sets-primitives", "sp-application-crypto", "sp-core", + "sp-io", + "sp-runtime", "sp-std", + "zeroize", ] [[package]] diff --git a/substrate/client/tests/common/genesis_liquidity.rs b/substrate/client/tests/common/genesis_liquidity.rs deleted file mode 100644 index 55824d368..000000000 --- a/substrate/client/tests/common/genesis_liquidity.rs +++ /dev/null @@ -1,118 +0,0 @@ -use std::collections::HashMap; - -use rand_core::{RngCore, OsRng}; -use zeroize::Zeroizing; - -use ciphersuite::{Ciphersuite, Ristretto}; -use frost::dkg::musig::musig; -use schnorrkel::Schnorrkel; - -use sp_core::{sr25519::Signature, Pair as PairTrait}; - -use serai_abi::{ - genesis_liquidity::primitives::{oraclize_values_message, Values}, - in_instructions::primitives::{Batch, InInstruction, InInstructionWithBalance}, - primitives::{ - insecure_pair_from_name, Amount, ExternalBalance, BlockHash, ExternalCoin, ExternalNetworkId, - NetworkId, SeraiAddress, EXTERNAL_COINS, - }, - validator_sets::primitives::{musig_context, Session, ValidatorSet}, -}; - -use serai_client::{Serai, SeraiGenesisLiquidity}; - -use crate::common::{in_instructions::provide_batch, tx::publish_tx}; - -#[allow(dead_code)] -pub async fn set_up_genesis( - serai: &Serai, - values: &HashMap, -) -> (HashMap>, HashMap) { - // make accounts with amounts - let mut accounts = HashMap::new(); - for coin in EXTERNAL_COINS { - // make 5 accounts per coin - let mut values = vec![]; - for _ in 0 .. 5 { - let mut address = SeraiAddress::new([0; 32]); - OsRng.fill_bytes(&mut address.0); - values.push((address, Amount(OsRng.next_u64() % 10u64.pow(coin.decimals())))); - } - accounts.insert(coin, values); - } - - // send a batch per coin - let mut batch_ids: HashMap = HashMap::new(); - for coin in EXTERNAL_COINS { - // set up instructions - let instructions = accounts[&coin] - .iter() - .map(|(addr, amount)| InInstructionWithBalance { - instruction: InInstruction::GenesisLiquidity(*addr), - balance: ExternalBalance { coin, amount: *amount }, - }) - .collect::>(); - - // set up bloch hash - let mut block = BlockHash([0; 32]); - OsRng.fill_bytes(&mut block.0); - - // set up batch id - batch_ids - .entry(coin.network()) - .and_modify(|v| { - *v += 1; - }) - .or_insert(0); - - let batch = - Batch { network: coin.network(), id: batch_ids[&coin.network()], block, instructions }; - provide_batch(serai, batch).await; - } - - // set values relative to each other. We can do that without checking for genesis period blocks - // since we are running in test(fast-epoch) mode. - // TODO: Random values here - let values = Values { - monero: values[&ExternalCoin::Monero], - ether: values[&ExternalCoin::Ether], - dai: values[&ExternalCoin::Dai], - }; - set_values(serai, &values).await; - - (accounts, batch_ids) -} - -#[allow(dead_code)] -async fn set_values(serai: &Serai, values: &Values) { - // prepare a Musig tx to oraclize the relative values - let pair = insecure_pair_from_name("Alice"); - let public = pair.public(); - // we publish the tx in set 1 - let set = ValidatorSet { session: Session(1), network: NetworkId::Serai }; - - let public_key = ::read_G::<&[u8]>(&mut public.0.as_ref()).unwrap(); - let secret_key = ::read_F::<&[u8]>( - &mut pair.as_ref().secret.to_bytes()[.. 32].as_ref(), - ) - .unwrap(); - - assert_eq!(Ristretto::generator() * secret_key, public_key); - let threshold_keys = - musig::(&musig_context(set), &Zeroizing::new(secret_key), &[public_key]).unwrap(); - - let sig = frost::tests::sign_without_caching( - &mut OsRng, - frost::tests::algorithm_machines( - &mut OsRng, - &Schnorrkel::new(b"substrate"), - &HashMap::from([(threshold_keys.params().i(), threshold_keys.into())]), - ), - &oraclize_values_message(&set, values), - ); - - // oraclize values - let _ = - publish_tx(serai, &SeraiGenesisLiquidity::oraclize_values(*values, Signature(sig.to_bytes()))) - .await; -} diff --git a/substrate/client/tests/common/mod.rs b/substrate/client/tests/common/mod.rs index 7dda7d0a2..e9d88594c 100644 --- a/substrate/client/tests/common/mod.rs +++ b/substrate/client/tests/common/mod.rs @@ -2,7 +2,6 @@ pub mod tx; pub mod validator_sets; pub mod in_instructions; pub mod dex; -pub mod genesis_liquidity; #[macro_export] macro_rules! serai_test { diff --git a/substrate/client/tests/emissions.rs b/substrate/client/tests/emissions.rs deleted file mode 100644 index 3e2b46f23..000000000 --- a/substrate/client/tests/emissions.rs +++ /dev/null @@ -1,251 +0,0 @@ -use std::{time::Duration, collections::HashMap}; -use rand_core::{RngCore, OsRng}; - -use serai_client::TemporalSerai; - -use serai_abi::{ - emissions::primitives::{INITIAL_REWARD_PER_BLOCK, SECURE_BY}, - in_instructions::primitives::Batch, - primitives::{ - BlockHash, ExternalBalance, ExternalCoin, ExternalNetworkId, EXTERNAL_NETWORKS, - FAST_EPOCH_DURATION, FAST_EPOCH_INITIAL_PERIOD, NETWORKS, TARGET_BLOCK_TIME, Amount, NetworkId, - }, - validator_sets::primitives::Session, -}; - -use serai_client::Serai; - -mod common; -use common::{genesis_liquidity::set_up_genesis, in_instructions::provide_batch}; - -serai_test_fast_epoch!( - emissions: (|serai: Serai| async move { - test_emissions(serai).await; - }) -); - -async fn send_batches(serai: &Serai, ids: &mut HashMap) { - for network in EXTERNAL_NETWORKS { - // set up batch id - ids - .entry(network) - .and_modify(|v| { - *v += 1; - }) - .or_insert(0); - - // set up block hash - let mut block = BlockHash([0; 32]); - OsRng.fill_bytes(&mut block.0); - - provide_batch(serai, Batch { network, id: ids[&network], block, instructions: vec![] }).await; - } -} - -async fn test_emissions(serai: Serai) { - // set up the genesis - let values = HashMap::from([ - (ExternalCoin::Monero, 184100), - (ExternalCoin::Ether, 4785000), - (ExternalCoin::Dai, 1500), - ]); - let (_, mut batch_ids) = set_up_genesis(&serai, &values).await; - - // wait until genesis is complete - let mut genesis_complete_block = None; - while genesis_complete_block.is_none() { - tokio::time::sleep(Duration::from_secs(1)).await; - genesis_complete_block = serai - .as_of_latest_finalized_block() - .await - .unwrap() - .genesis_liquidity() - .genesis_complete_block() - .await - .unwrap(); - } - - for _ in 0 .. 3 { - // get current stakes - let mut current_stake = HashMap::new(); - for n in NETWORKS { - // TODO: investigate why serai network TAS isn't visible at session 0. - let stake = serai - .as_of_latest_finalized_block() - .await - .unwrap() - .validator_sets() - .total_allocated_stake(n) - .await - .unwrap() - .unwrap_or(Amount(0)) - .0; - current_stake.insert(n, stake); - } - - // wait for a session change - let current_session = wait_for_session_change(&serai).await; - - // get last block - let last_block = serai.latest_finalized_block().await.unwrap(); - let serai_latest = serai.as_of(last_block.hash()); - let change_block_number = last_block.number(); - - // get distances to ec security & block count of the previous session - let (distances, total_distance) = get_distances(&serai_latest, ¤t_stake).await; - let block_count = get_session_blocks(&serai_latest, current_session - 1).await; - - // calculate how much reward in this session - let reward_this_epoch = - if change_block_number < (genesis_complete_block.unwrap() + FAST_EPOCH_INITIAL_PERIOD) { - block_count * INITIAL_REWARD_PER_BLOCK - } else { - let blocks_until = SECURE_BY - change_block_number; - let block_reward = total_distance / blocks_until; - block_count * block_reward - }; - - let reward_per_network = distances - .into_iter() - .map(|(n, distance)| { - let reward = u64::try_from( - u128::from(reward_this_epoch).saturating_mul(u128::from(distance)) / - u128::from(total_distance), - ) - .unwrap(); - (n, reward) - }) - .collect::>(); - - // retire the prev-set so that TotalAllocatedStake updated. - send_batches(&serai, &mut batch_ids).await; - - for (n, reward) in reward_per_network { - let stake = serai - .as_of_latest_finalized_block() - .await - .unwrap() - .validator_sets() - .total_allocated_stake(n) - .await - .unwrap() - .unwrap_or(Amount(0)) - .0; - - // all reward should automatically staked for the network since we are in initial period. - assert_eq!(stake, *current_stake.get(&n).unwrap() + reward); - } - - // TODO: check stake per address? - // TODO: check post ec security era - } -} - -/// Returns the required stake in terms SRI for a given `Balance`. -async fn required_stake(serai: &TemporalSerai<'_>, balance: ExternalBalance) -> u64 { - // This is inclusive to an increase in accuracy - let sri_per_coin = serai.dex().oracle_value(balance.coin).await.unwrap().unwrap_or(Amount(0)); - - // See dex-pallet for the reasoning on these - let coin_decimals = balance.coin.decimals().max(5); - let accuracy_increase = u128::from(10u64.pow(coin_decimals)); - - let total_coin_value = - u64::try_from(u128::from(balance.amount.0) * u128::from(sri_per_coin.0) / accuracy_increase) - .unwrap_or(u64::MAX); - - // required stake formula (COIN_VALUE * 1.5) + margin(20%) - let required_stake = total_coin_value.saturating_mul(3).saturating_div(2); - required_stake.saturating_add(total_coin_value.saturating_div(5)) -} - -async fn wait_for_session_change(serai: &Serai) -> u32 { - let current_session = serai - .as_of_latest_finalized_block() - .await - .unwrap() - .validator_sets() - .session(NetworkId::Serai) - .await - .unwrap() - .unwrap() - .0; - let next_session = current_session + 1; - - // lets wait double the epoch time. - tokio::time::timeout( - tokio::time::Duration::from_secs(FAST_EPOCH_DURATION * TARGET_BLOCK_TIME * 2), - async { - while serai - .as_of_latest_finalized_block() - .await - .unwrap() - .validator_sets() - .session(NetworkId::Serai) - .await - .unwrap() - .unwrap() - .0 < - next_session - { - tokio::time::sleep(Duration::from_secs(6)).await; - } - }, - ) - .await - .unwrap(); - - next_session -} - -async fn get_distances( - serai: &TemporalSerai<'_>, - current_stake: &HashMap, -) -> (HashMap, u64) { - // we should be in the initial period, so calculate how much each network supposedly get.. - // we can check the supply to see how much coin hence liability we have. - let mut distances: HashMap = HashMap::new(); - let mut total_distance = 0; - for n in EXTERNAL_NETWORKS { - let mut required = 0; - for c in n.coins() { - let amount = serai.coins().coin_supply(c.into()).await.unwrap(); - required += required_stake(serai, ExternalBalance { coin: c, amount }).await; - } - - let mut current = *current_stake.get(&n.into()).unwrap(); - if current > required { - current = required; - } - - let distance = required - current; - total_distance += distance; - - distances.insert(n.into(), distance); - } - - // add serai network portion(20%) - let new_total_distance = total_distance.saturating_mul(10) / 8; - distances.insert(NetworkId::Serai, new_total_distance - total_distance); - total_distance = new_total_distance; - - (distances, total_distance) -} - -async fn get_session_blocks(serai: &TemporalSerai<'_>, session: u32) -> u64 { - let begin_block = serai - .validator_sets() - .session_begin_block(NetworkId::Serai, Session(session)) - .await - .unwrap() - .unwrap(); - - let next_begin_block = serai - .validator_sets() - .session_begin_block(NetworkId::Serai, Session(session + 1)) - .await - .unwrap() - .unwrap(); - - next_begin_block.saturating_sub(begin_block) -} diff --git a/substrate/client/tests/genesis_liquidity.rs b/substrate/client/tests/genesis_liquidity.rs deleted file mode 100644 index e2a593cf1..000000000 --- a/substrate/client/tests/genesis_liquidity.rs +++ /dev/null @@ -1,111 +0,0 @@ -use std::{time::Duration, collections::HashMap}; - -use serai_client::Serai; - -use serai_abi::primitives::{Amount, Coin, ExternalCoin, COINS, EXTERNAL_COINS, GENESIS_SRI}; - -use serai_client::genesis_liquidity::primitives::{ - GENESIS_LIQUIDITY_ACCOUNT, INITIAL_GENESIS_LP_SHARES, -}; - -mod common; -use common::genesis_liquidity::set_up_genesis; - -serai_test_fast_epoch!( - genesis_liquidity: (|serai: Serai| async move { - test_genesis_liquidity(serai).await; - }) -); - -pub async fn test_genesis_liquidity(serai: Serai) { - // set up the genesis - let values = HashMap::from([ - (ExternalCoin::Monero, 184100), - (ExternalCoin::Ether, 4785000), - (ExternalCoin::Dai, 1500), - ]); - let (accounts, _) = set_up_genesis(&serai, &values).await; - - // wait until genesis is complete - while serai - .as_of_latest_finalized_block() - .await - .unwrap() - .genesis_liquidity() - .genesis_complete_block() - .await - .unwrap() - .is_none() - { - tokio::time::sleep(Duration::from_secs(1)).await; - } - - // check total SRI supply is +100M - // there are 6 endowed accounts in dev-net. Take this into consideration when checking - // for the total sri minted at this time. - let serai = serai.as_of_latest_finalized_block().await.unwrap(); - let sri = serai.coins().coin_supply(Coin::Serai).await.unwrap(); - let endowed_amount: u64 = 1 << 60; - let total_sri = (6 * endowed_amount) + GENESIS_SRI; - assert_eq!(sri, Amount(total_sri)); - - // check genesis account has no coins, all transferred to pools. - for coin in COINS { - let amount = serai.coins().coin_balance(coin, GENESIS_LIQUIDITY_ACCOUNT).await.unwrap(); - assert_eq!(amount.0, 0); - } - - // check pools has proper liquidity - let mut pool_amounts = HashMap::new(); - let mut total_value = 0u128; - for coin in EXTERNAL_COINS { - let total_coin = accounts[&coin].iter().fold(0u128, |acc, value| acc + u128::from(value.1 .0)); - let value = if coin != ExternalCoin::Bitcoin { - (total_coin * u128::from(values[&coin])) / 10u128.pow(coin.decimals()) - } else { - total_coin - }; - - total_value += value; - pool_amounts.insert(coin, (total_coin, value)); - } - - // check distributed SRI per pool - let mut total_sri_distributed = 0u128; - for coin in EXTERNAL_COINS { - let sri = if coin == *EXTERNAL_COINS.last().unwrap() { - u128::from(GENESIS_SRI).checked_sub(total_sri_distributed).unwrap() - } else { - (pool_amounts[&coin].1 * u128::from(GENESIS_SRI)) / total_value - }; - total_sri_distributed += sri; - - let reserves = serai.dex().get_reserves(coin).await.unwrap().unwrap(); - assert_eq!(u128::from(reserves.0 .0), pool_amounts[&coin].0); // coin side - assert_eq!(u128::from(reserves.1 .0), sri); // SRI side - } - - // check each liquidity provider got liquidity tokens proportional to their value - for coin in EXTERNAL_COINS { - let liq_supply = serai.genesis_liquidity().supply(coin).await.unwrap(); - for (acc, amount) in &accounts[&coin] { - let acc_liq_shares = serai.genesis_liquidity().liquidity(acc, coin).await.unwrap().shares; - - // since we can't test the ratios directly(due to integer division giving 0) - // we test whether they give the same result when multiplied by another constant. - // Following test ensures the account in fact has the right amount of shares. - let mut shares_ratio = (INITIAL_GENESIS_LP_SHARES * acc_liq_shares) / liq_supply.shares; - let amounts_ratio = - (INITIAL_GENESIS_LP_SHARES * amount.0) / u64::try_from(pool_amounts[&coin].0).unwrap(); - - // we can tolerate 1 unit diff between them due to integer division. - if shares_ratio.abs_diff(amounts_ratio) == 1 { - shares_ratio = amounts_ratio; - } - - assert_eq!(shares_ratio, amounts_ratio); - } - } - - // TODO: test remove the liq before/after genesis ended. -} diff --git a/substrate/genesis-liquidity/pallet/Cargo.toml b/substrate/genesis-liquidity/pallet/Cargo.toml index 3668b9953..59cafb91f 100644 --- a/substrate/genesis-liquidity/pallet/Cargo.toml +++ b/substrate/genesis-liquidity/pallet/Cargo.toml @@ -39,6 +39,21 @@ serai-primitives = { path = "../../primitives", default-features = false } genesis-liquidity-primitives = { package = "serai-genesis-liquidity-primitives", path = "../primitives", default-features = false } validator-sets-primitives = { package = "serai-validator-sets-primitives", path = "../../validator-sets/primitives", default-features = false } +[dev-dependencies] +pallet-babe = { git = "https://github.com/serai-dex/substrate", default-features = false } +pallet-grandpa = { git = "https://github.com/serai-dex/substrate", default-features = false } +pallet-timestamp = { git = "https://github.com/serai-dex/substrate", default-features = false } + +sp-io = { git = "https://github.com/serai-dex/substrate", default-features = false } +sp-runtime = { git = "https://github.com/serai-dex/substrate", default-features = false } + +ciphersuite = { path = "../../../crypto/ciphersuite", features = ["ristretto"] } +frost = { package = "modular-frost", path = "../../../crypto/frost", features = ["tests"] } +schnorrkel = { path = "../../../crypto/schnorrkel", package = "frost-schnorrkel" } + +zeroize = "^1.5" +rand_core = "0.6" + [features] std = [ "scale/std", @@ -49,6 +64,8 @@ std = [ "sp-std/std", "sp-core/std", + "sp-io/std", + "sp-runtime/std", "sp-application-crypto/std", "coins-pallet/std", @@ -60,8 +77,20 @@ std = [ "serai-primitives/std", "genesis-liquidity-primitives/std", "validator-sets-primitives/std", + + "pallet-babe/std", + "pallet-grandpa/std", + "pallet-timestamp/std", ] -try-runtime = [] # TODO + +try-runtime = [ + "frame-system/try-runtime", + "frame-support/try-runtime", + + "sp-runtime/try-runtime", +] + + fast-epoch = [] default = ["std"] diff --git a/substrate/genesis-liquidity/pallet/src/lib.rs b/substrate/genesis-liquidity/pallet/src/lib.rs index 3a78e4930..7cd5c1cd1 100644 --- a/substrate/genesis-liquidity/pallet/src/lib.rs +++ b/substrate/genesis-liquidity/pallet/src/lib.rs @@ -1,5 +1,11 @@ #![cfg_attr(not(feature = "std"), no_std)] +#[cfg(test)] +mod mock; + +#[cfg(test)] +mod tests; + #[allow( unreachable_patterns, clippy::cast_possible_truncation, @@ -64,6 +70,7 @@ pub mod pallet { /// Keeps shares and the amount of coins per account. #[pallet::storage] + #[pallet::getter(fn liquidity)] pub(crate) type Liquidity = StorageDoubleMap< _, Identity, @@ -76,6 +83,7 @@ pub mod pallet { /// Keeps the total shares and the total amount of coins per coin. #[pallet::storage] + #[pallet::getter(fn supply)] pub(crate) type Supply = StorageMap<_, Identity, ExternalCoin, LiquidityAmount, OptionQuery>; @@ -89,14 +97,8 @@ pub mod pallet { #[pallet::hooks] impl Hooks> for Pallet { fn on_initialize(n: BlockNumberFor) -> Weight { - #[cfg(feature = "fast-epoch")] - let final_block = 10u64; - - #[cfg(not(feature = "fast-epoch"))] - let final_block = MONTHS; - // Distribute the genesis sri to pools after a month - if (n.saturated_into::() >= final_block) && + if (n.saturated_into::() >= MONTHS) && Self::oraclization_is_done() && GenesisCompleteBlock::::get().is_none() { @@ -285,15 +287,18 @@ pub mod pallet { let (new_liquidity, new_supply) = if Self::genesis_ended() { // see how much liq tokens we have let total_liq_tokens = - LiquidityTokens::::balance(GENESIS_LIQUIDITY_ACCOUNT.into(), Coin::Serai).0; + LiquidityTokens::::balance(GENESIS_LIQUIDITY_ACCOUNT.into(), Coin::from(balance.coin)) + .0; // get how much user wants to remove let LiquidityAmount { shares, coins } = Liquidity::::get(balance.coin, account).unwrap_or(LiquidityAmount::zero()); let total_shares = Supply::::get(balance.coin).unwrap_or(LiquidityAmount::zero()).shares; let user_liq_tokens = Self::mul_div(total_liq_tokens, shares, total_shares)?; - let amount_to_remove = + let amount_to_remove_liq_tokens = Self::mul_div(user_liq_tokens, balance.amount.0, INITIAL_GENESIS_LP_SHARES)?; + let amount_to_remove_shares = + Self::mul_div(shares, balance.amount.0, INITIAL_GENESIS_LP_SHARES)?; // remove liquidity from pool let prev_sri = Coins::::balance(GENESIS_LIQUIDITY_ACCOUNT.into(), Coin::Serai); @@ -301,7 +306,7 @@ pub mod pallet { Dex::::remove_liquidity( origin.clone().into(), balance.coin, - amount_to_remove, + amount_to_remove_liq_tokens, 1, 1, GENESIS_LIQUIDITY_ACCOUNT.into(), @@ -315,14 +320,7 @@ pub mod pallet { let mut sri: u64 = current_sri.0.saturating_sub(prev_sri.0); let distance_to_full_pay = GENESIS_SRI_TRICKLE_FEED.saturating_sub(Self::blocks_since_ec_security().unwrap_or(0)); - let burn_sri_amount = u64::try_from( - u128::from(sri) - .checked_mul(u128::from(distance_to_full_pay)) - .ok_or(Error::::AmountOverflowed)? - .checked_div(u128::from(GENESIS_SRI_TRICKLE_FEED)) - .ok_or(Error::::AmountOverflowed)?, - ) - .map_err(|_| Error::::AmountOverflowed)?; + let burn_sri_amount = Self::mul_div(sri, distance_to_full_pay, GENESIS_SRI_TRICKLE_FEED)?; Coins::::burn( origin.clone().into(), Balance { coin: Coin::Serai, amount: Amount(burn_sri_amount) }, @@ -345,13 +343,15 @@ pub mod pallet { // return new amounts ( LiquidityAmount { - shares: shares.checked_sub(amount_to_remove).ok_or(Error::::AmountOverflowed)?, + shares: shares + .checked_sub(amount_to_remove_shares) + .ok_or(Error::::AmountOverflowed)?, coins: coins.checked_sub(coin_out).ok_or(Error::::AmountOverflowed)?, }, LiquidityAmount { shares: supply .shares - .checked_sub(amount_to_remove) + .checked_sub(amount_to_remove_shares) .ok_or(Error::::AmountOverflowed)?, coins: supply.coins.checked_sub(coin_out).ok_or(Error::::AmountOverflowed)?, }, @@ -437,9 +437,7 @@ pub mod pallet { Err(InvalidTransaction::Custom(1))?; } - // make sure signers settings the value at the end of the genesis period. - // we don't need this check for tests. - #[cfg(not(feature = "fast-epoch"))] + // check we waited for a month before setting the values if >::block_number().saturated_into::() < MONTHS { Err(InvalidTransaction::Custom(2))?; } diff --git a/substrate/genesis-liquidity/pallet/src/mock.rs b/substrate/genesis-liquidity/pallet/src/mock.rs new file mode 100644 index 000000000..083d4b868 --- /dev/null +++ b/substrate/genesis-liquidity/pallet/src/mock.rs @@ -0,0 +1,186 @@ +//! Test environment for GenesisLiquidity pallet. + +use super::*; + +use std::collections::HashMap; + +use frame_support::{ + construct_runtime, + traits::{ConstU16, ConstU32, ConstU64}, +}; + +use sp_core::{H256, Pair, sr25519::Public}; +use sp_runtime::{ + traits::{BlakeTwo256, IdentityLookup}, + BuildStorage, +}; + +use serai_primitives::*; +use validator_sets::{primitives::MAX_KEY_SHARES_PER_SET, MembershipProof}; + +pub use crate as genesis_liquidity; +pub use coins_pallet as coins; +pub use validator_sets_pallet as validator_sets; +pub use dex_pallet as dex; +pub use pallet_babe as babe; +pub use pallet_grandpa as grandpa; +pub use pallet_timestamp as timestamp; +pub use economic_security_pallet as economic_security; + +type Block = frame_system::mocking::MockBlock; +// Maximum number of authorities per session. +pub type MaxAuthorities = ConstU32<{ MAX_KEY_SHARES_PER_SET }>; + +pub const MEDIAN_PRICE_WINDOW_LENGTH: u16 = 10; + +construct_runtime!( + pub enum Test + { + System: frame_system, + Timestamp: timestamp, + Coins: coins, + LiquidityTokens: coins::::{Pallet, Call, Storage, Event}, + ValidatorSets: validator_sets, + GenesisLiquidity: genesis_liquidity, + Dex: dex, + Babe: babe, + Grandpa: grandpa, + EconomicSecurity: economic_security, + } +); + +impl frame_system::Config for Test { + type BaseCallFilter = frame_support::traits::Everything; + type BlockWeights = (); + type BlockLength = (); + type RuntimeOrigin = RuntimeOrigin; + type RuntimeCall = RuntimeCall; + type Nonce = u64; + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = Public; + type Lookup = IdentityLookup; + type Block = Block; + type RuntimeEvent = RuntimeEvent; + type BlockHashCount = ConstU64<250>; + type DbWeight = (); + type Version = (); + type PalletInfo = PalletInfo; + type AccountData = (); + type OnNewAccount = (); + type OnKilledAccount = (); + type SystemWeightInfo = (); + type SS58Prefix = (); + type OnSetCode = (); + type MaxConsumers = ConstU32<16>; +} + +impl timestamp::Config for Test { + type Moment = u64; + type OnTimestampSet = Babe; + type MinimumPeriod = ConstU64<{ (TARGET_BLOCK_TIME * 1000) / 2 }>; + type WeightInfo = (); +} + +impl babe::Config for Test { + type EpochDuration = ConstU64<{ FAST_EPOCH_DURATION }>; + + type ExpectedBlockTime = ConstU64<{ TARGET_BLOCK_TIME * 1000 }>; + type EpochChangeTrigger = babe::ExternalTrigger; + type DisabledValidators = ValidatorSets; + + type WeightInfo = (); + type MaxAuthorities = MaxAuthorities; + + type KeyOwnerProof = MembershipProof; + type EquivocationReportSystem = (); +} + +impl grandpa::Config for Test { + type RuntimeEvent = RuntimeEvent; + + type WeightInfo = (); + type MaxAuthorities = MaxAuthorities; + + type MaxSetIdSessionEntries = ConstU64<0>; + type KeyOwnerProof = MembershipProof; + type EquivocationReportSystem = (); +} + +impl coins::Config for Test { + type RuntimeEvent = RuntimeEvent; + type AllowMint = ValidatorSets; +} + +impl coins::Config for Test { + type RuntimeEvent = RuntimeEvent; + type AllowMint = (); +} + +impl dex::Config for Test { + type RuntimeEvent = RuntimeEvent; + + type LPFee = ConstU32<3>; // 0.3% + type MintMinLiquidity = ConstU64<10000>; + + type MaxSwapPathLength = ConstU32<3>; // coin1 -> SRI -> coin2 + + type MedianPriceWindowLength = ConstU16<{ MEDIAN_PRICE_WINDOW_LENGTH }>; + + type WeightInfo = dex::weights::SubstrateWeight; +} + +impl validator_sets::Config for Test { + type RuntimeEvent = RuntimeEvent; + type ShouldEndSession = Babe; +} + +impl economic_security::Config for Test { + type RuntimeEvent = RuntimeEvent; +} + +impl Config for Test { + type RuntimeEvent = RuntimeEvent; +} + +pub fn key_shares() -> HashMap { + HashMap::from([ + (NetworkId::Serai, Amount(50_000 * 10_u64.pow(8))), + (NetworkId::External(ExternalNetworkId::Bitcoin), Amount(1_000_000 * 10_u64.pow(8))), + (NetworkId::External(ExternalNetworkId::Ethereum), Amount(1_000_000 * 10_u64.pow(8))), + (NetworkId::External(ExternalNetworkId::Monero), Amount(100_000 * 10_u64.pow(8))), + ]) +} + +pub(crate) fn new_test_ext() -> sp_io::TestExternalities { + let mut t = frame_system::GenesisConfig::::default().build_storage().unwrap(); + let networks: Vec<(NetworkId, Amount)> = key_shares().into_iter().collect::>(); + + let validators: Vec = vec![ + insecure_pair_from_name("Alice").public(), + insecure_pair_from_name("Bob").public(), + insecure_pair_from_name("Charlie").public(), + insecure_pair_from_name("Dave").public(), + insecure_pair_from_name("Eve").public(), + insecure_pair_from_name("Ferdie").public(), + ]; + + coins::GenesisConfig:: { + accounts: validators + .clone() + .into_iter() + .map(|a| (a, Balance { coin: Coin::Serai, amount: Amount(1 << 60) })) + .collect(), + _ignore: Default::default(), + } + .assimilate_storage(&mut t) + .unwrap(); + + validator_sets::GenesisConfig:: { networks, participants: validators } + .assimilate_storage(&mut t) + .unwrap(); + + let mut ext = sp_io::TestExternalities::new(t); + ext.execute_with(|| System::set_block_number(0)); + ext +} diff --git a/substrate/genesis-liquidity/pallet/src/tests.rs b/substrate/genesis-liquidity/pallet/src/tests.rs new file mode 100644 index 000000000..fa234a7de --- /dev/null +++ b/substrate/genesis-liquidity/pallet/src/tests.rs @@ -0,0 +1,460 @@ +use crate::{mock::*, pallet, primitives::*}; + +use std::collections::HashMap; + +use ciphersuite::{Ciphersuite, Ristretto}; +use frost::dkg::musig::musig; +use schnorrkel::Schnorrkel; + +use rand_core::{RngCore, OsRng}; +use zeroize::Zeroizing; + +use frame_system::RawOrigin; +use frame_support::{ + assert_noop, assert_ok, + pallet_prelude::{TransactionSource, InvalidTransaction}, + traits::Hooks, +}; + +use sp_core::{ + sr25519::{Pair, Signature}, + Pair as PairTrait, +}; +use sp_runtime::{traits::ValidateUnsigned, BoundedVec}; + +use validator_sets_primitives::{ValidatorSet, Session, KeyPair, musig_context}; +use serai_primitives::*; + +fn set_up_genesis( + values: &HashMap, +) -> (HashMap>, u64) { + // make accounts with amounts + let mut accounts = HashMap::new(); + for coin in EXTERNAL_COINS { + // make 5 accounts per coin + let mut values = vec![]; + for _ in 0 .. 5 { + let mut address = SeraiAddress::new([0; 32]); + OsRng.fill_bytes(&mut address.0); + values.push((address, Amount(OsRng.next_u64() % (10_000 * 10u64.pow(coin.decimals()))))); + } + accounts.insert(coin, values); + } + + // add some genesis liquidity + for (coin, amounts) in &accounts { + for (address, amount) in amounts { + let balance = ExternalBalance { coin: *coin, amount: *amount }; + + Coins::mint(GENESIS_LIQUIDITY_ACCOUNT.into(), balance.into()).unwrap(); + GenesisLiquidity::add_coin_liquidity((*address).into(), balance).unwrap(); + } + } + + // make genesis liquidity event happen + let block_number = MONTHS; + let values = Values { + monero: values[&ExternalCoin::Monero], + ether: values[&ExternalCoin::Ether], + dai: values[&ExternalCoin::Dai], + }; + GenesisLiquidity::oraclize_values(RawOrigin::None.into(), values, Signature([0u8; 64])).unwrap(); + GenesisLiquidity::on_initialize(block_number); + System::set_block_number(block_number); + + // populate the coin values + Dex::on_finalize(block_number); + + (accounts, block_number) +} + +// TODO: make this fn belong to the pallet itself use it there as well? +// The problem with that would be if there is a problem with this function +// tests can't catch it since it would the same fn? +fn distances() -> (HashMap, u64) { + let mut distances = HashMap::new(); + let mut total_distance: u64 = 0; + + // calculate distance to economic security per network + for n in EXTERNAL_NETWORKS { + let required = ValidatorSets::required_stake_for_network(n); + let mut current = + ValidatorSets::total_allocated_stake(NetworkId::from(n)).unwrap_or(Amount(0)).0; + if current > required { + current = required; + } + + let distance = required - current; + distances.insert(n.into(), distance); + total_distance = total_distance.saturating_add(distance); + } + + // add serai network portion (20%) + let new_total_distance = total_distance.saturating_mul(100) / (100 - 20); + distances.insert(NetworkId::Serai, new_total_distance - total_distance); + total_distance = new_total_distance; + + (distances, total_distance) +} + +fn set_keys_for_session() { + for network in EXTERNAL_NETWORKS { + ValidatorSets::set_keys( + RawOrigin::None.into(), + network, + BoundedVec::new(), + KeyPair(insecure_pair_from_name("Alice").public(), vec![].try_into().unwrap()), + Signature([0u8; 64]), + ) + .unwrap(); + } +} + +fn make_networks_reach_economic_security(block_number: u64) { + set_keys_for_session(); + let (distances, _) = distances(); + for (network, distance) in distances { + if network == NetworkId::Serai { + continue; + } + + let participants = ValidatorSets::participants_for_latest_decided_set(network).unwrap(); + let al_per_key_share = ValidatorSets::allocation_per_key_share(network).unwrap().0; + + // we want some unused capacity so we stake more SRI than necessary + let mut key_shares = (distance / al_per_key_share) + 1; + + 'outer: while key_shares > 0 { + for (account, _) in &participants { + ValidatorSets::distribute_block_rewards(network, *account, Amount(al_per_key_share)) + .unwrap(); + + if key_shares > 0 { + key_shares -= 1; + } else { + break 'outer; + } + } + } + } + + // update TAS + ValidatorSets::new_session(); + for network in NETWORKS { + ValidatorSets::retire_set(ValidatorSet { session: Session(0), network }); + } + + // make sure we reached economic security + EconomicSecurity::on_initialize(block_number); + for n in EXTERNAL_NETWORKS { + EconomicSecurity::economic_security_block(n).unwrap(); + } +} + +fn oraclize_values_signature(set: ValidatorSet, values: &Values, pairs: &[Pair]) -> Signature { + let mut pub_keys = vec![]; + for pair in pairs { + let public_key = + ::read_G::<&[u8]>(&mut pair.public().0.as_ref()).unwrap(); + pub_keys.push(public_key); + } + + let mut threshold_keys = vec![]; + for i in 0 .. pairs.len() { + let secret_key = ::read_F::<&[u8]>( + &mut pairs[i].as_ref().secret.to_bytes()[.. 32].as_ref(), + ) + .unwrap(); + assert_eq!(Ristretto::generator() * secret_key, pub_keys[i]); + + threshold_keys.push( + musig::(&musig_context(set), &Zeroizing::new(secret_key), &pub_keys).unwrap(), + ); + } + + let mut musig_keys = HashMap::new(); + for tk in threshold_keys { + musig_keys.insert(tk.params().i(), tk.into()); + } + + let sig = frost::tests::sign_without_caching( + &mut OsRng, + frost::tests::algorithm_machines(&mut OsRng, &Schnorrkel::new(b"substrate"), &musig_keys), + &oraclize_values_message(&set, values), + ); + + Signature(sig.to_bytes()) +} + +fn get_ordered_keys(network: NetworkId, participants: &[Pair]) -> Vec { + // retrieve the current session validators so that we know the order of the keys + // that is necessary for the correct musig signature. + let validators = ValidatorSets::participants_for_latest_decided_set(network).unwrap(); + + // collect the pairs of the validators + let mut pairs = vec![]; + for (v, _) in validators { + let p = participants.iter().find(|pair| pair.public() == v).unwrap().clone(); + pairs.push(p); + } + + pairs +} + +#[test] +fn genesis_liquidity() { + new_test_ext().execute_with(|| { + let values = HashMap::from([ + (ExternalCoin::Monero, 184100), + (ExternalCoin::Ether, 4785000), + (ExternalCoin::Dai, 1500), + ]); + let (accounts, block_number) = set_up_genesis(&values); + + // check that we minted the correct SRI amount + // there are 6 endowed accounts in this mock runtime. + let endowed_amount: u64 = 1 << 60; + let total_sri = (6 * endowed_amount) + GENESIS_SRI; + assert_eq!(Coins::supply(Coin::Serai), total_sri); + + // check genesis account has no coins, all transferred to pools. + for coin in COINS { + assert_eq!(Coins::balance(GENESIS_LIQUIDITY_ACCOUNT.into(), coin).0, 0); + } + + // get total pool coins and it's values + let mut pool_amounts = HashMap::new(); + let mut total_value = 0u128; + for (coin, amounts) in &accounts { + let total_coin = amounts.iter().fold(0u128, |acc, value| acc + u128::from(value.1 .0)); + let value = if *coin != ExternalCoin::Bitcoin { + (total_coin * u128::from(values[coin])) / 10u128.pow(coin.decimals()) + } else { + total_coin + }; + + total_value += value; + pool_amounts.insert(coin, (total_coin, value)); + } + + // check distributed SRI per pool + let mut total_sri_distributed = 0u128; + for coin in EXTERNAL_COINS { + let sri = if &coin == EXTERNAL_COINS.last().unwrap() { + u128::from(GENESIS_SRI).checked_sub(total_sri_distributed).unwrap() + } else { + (pool_amounts[&coin].1 * u128::from(GENESIS_SRI)) / total_value + }; + total_sri_distributed += sri; + + let reserves = Dex::get_reserves(&coin.into(), &Coin::Serai).unwrap(); + assert_eq!(u128::from(reserves.0), pool_amounts[&coin].0); // coin side + assert_eq!(u128::from(reserves.1), sri); // SRI side + } + + // check each liquidity provider got liquidity tokens proportional to their value + for coin in EXTERNAL_COINS { + let liq_supply = GenesisLiquidity::supply(coin).unwrap(); + for (acc, amount) in &accounts[&coin] { + let public: PublicKey = (*acc).into(); + let acc_liq_shares = GenesisLiquidity::liquidity(coin, public).unwrap().shares; + + // since we can't test the ratios directly(due to integer division giving 0) + // we test whether they give the same result when multiplied by another constant. + // Following test ensures the account in fact has the right amount of shares. + let mut shares_ratio = (INITIAL_GENESIS_LP_SHARES * acc_liq_shares) / liq_supply.shares; + let amounts_ratio = u64::try_from( + (u128::from(INITIAL_GENESIS_LP_SHARES) * u128::from(amount.0)) / pool_amounts[&coin].0, + ) + .unwrap(); + + // we can tolerate 1 unit diff between them due to integer division. + if shares_ratio.abs_diff(amounts_ratio) == 1 { + shares_ratio = amounts_ratio; + } + + assert_eq!(shares_ratio, amounts_ratio); + } + } + + // make sure we have genesis complete block set + assert_eq!(GenesisLiquidity::genesis_complete_block().unwrap(), block_number); + }); +} + +#[test] +fn remove_coin_liquidity_genesis_period() { + new_test_ext().execute_with(|| { + let account = insecure_pair_from_name("random1").public(); + let coin = ExternalCoin::Bitcoin; + let balance = ExternalBalance { coin, amount: Amount(10u64.pow(coin.decimals())) }; + + // add some genesis liquidity + Coins::mint(GENESIS_LIQUIDITY_ACCOUNT.into(), balance.into()).unwrap(); + GenesisLiquidity::add_coin_liquidity(account, balance).unwrap(); + + // amount has to be full amount if removing during genesis period + assert_noop!( + GenesisLiquidity::remove_coin_liquidity( + RawOrigin::Signed(account).into(), + ExternalBalance { coin, amount: Amount(1_000) } + ), + genesis_liquidity::Error::::CanOnlyRemoveFullAmount + ); + + assert_ok!(GenesisLiquidity::remove_coin_liquidity( + RawOrigin::Signed(account).into(), + ExternalBalance { coin, amount: Amount(INITIAL_GENESIS_LP_SHARES) } + )); + + // check that user got back the coins + assert_eq!(Coins::balance(GENESIS_LIQUIDITY_ACCOUNT.into(), coin.into()), Amount(0)); + assert_eq!(Coins::balance(account, coin.into()), balance.amount); + }) +} + +#[test] +fn remove_coin_liquidity_after_genesis_period() { + new_test_ext().execute_with(|| { + // set up genesis + let coin = ExternalCoin::Monero; + let values = HashMap::from([ + (ExternalCoin::Monero, 184100), + (ExternalCoin::Ether, 4785000), + (ExternalCoin::Dai, 1500), + ]); + let (accounts, mut block_number) = set_up_genesis(&values); + + // make sure no economic security achieved for the network + assert!(EconomicSecurity::economic_security_block(coin.network()).is_none()); + + let account: PublicKey = accounts[&coin][0].0.into(); + // let account_liquidity = accounts[&coin][0].1 .0; + let account_sri_balance = Coins::balance(account, Coin::Serai).0; + let account_coin_balance = Coins::balance(account, coin.into()).0; + + // try to remove liquidity + assert_ok!(GenesisLiquidity::remove_coin_liquidity( + RawOrigin::Signed(account).into(), + ExternalBalance { coin, amount: Amount(INITIAL_GENESIS_LP_SHARES / 2) }, + )); + + // since there is no economic security we shouldn't have received any SRI + // and should receive only half the coins since we removed half. + assert_eq!(Coins::balance(account, Coin::Serai).0, account_sri_balance); + + // TODO: this doesn't exactly line up with `account_liquidity / 2`. Prob due to all the integer + // mul_divs? There is no pool movement to attribute it to. + // assert_eq!(Coins::balance(account, coin).0 - account_coin_balance, account_liquidity / 2); + assert!(Coins::balance(account, coin.into()).0 > account_coin_balance); + + // make networks reach economic security + make_networks_reach_economic_security(block_number); + + // move the block number it has been some time since economic security + block_number += MONTHS; + System::set_block_number(block_number); + + let coin = ExternalCoin::Ether; + let account: PublicKey = accounts[&coin][0].0.into(); + // let account_liquidity = accounts[&coin][0].1 .0; + let account_sri_balance = Coins::balance(account, Coin::Serai).0; + let account_coin_balance = Coins::balance(account, coin.into()).0; + + // try to remove liquidity + assert_ok!(GenesisLiquidity::remove_coin_liquidity( + RawOrigin::Signed(account).into(), + ExternalBalance { coin, amount: Amount(INITIAL_GENESIS_LP_SHARES / 2) }, + )); + + // TODO: this doesn't exactly line up with `account_liquidity / 2`. Prob due to all the integer + // mul_divs? There is no pool movement to attribute it to. + // let pool_sri = Coins::balance(Dex::get_pool_account(coin), Coin::Serai).0; + // let total_pool_coins = + // accounts[&coin].iter().fold(0u128, |acc, value| acc + u128::from(value.1 .0)); + // let genesis_sri_for_account = + // (u128::from(pool_sri) * u128::from(account_liquidity)) / total_pool_coins; + + // // we should receive only half of genesis SRI minted for us + // let genesis_sri_for_account = genesis_sri_for_account / 2; + + // let distance_to_full_pay = GENESIS_SRI_TRICKLE_FEED.saturating_sub(MONTHS); + // let burn_sri_amount = (genesis_sri_for_account * u128::from(distance_to_full_pay)) / + // u128::from(GENESIS_SRI_TRICKLE_FEED); + // let sri_received = genesis_sri_for_account - burn_sri_amount; + // assert_eq!( + // Coins::balance(account, Coin::Serai).0 - account_sri_balance, + // u64::try_from(sri_received).unwrap() + // ); + assert!(Coins::balance(account, Coin::Serai).0 > account_sri_balance); + + // TODO: this doesn't exactly line up with `account_liquidity / 2`. Prob due to all the integer + // mul_divs? There is no pool movement to attribute it to. + // assert_eq!(Coins::balance(account, coin).0 - account_coin_balance, account_liquidity / 2); + assert!(Coins::balance(account, coin.into()).0 > account_coin_balance); + }) +} + +#[test] +fn validate_oraclize_values_already_done() { + new_test_ext().execute_with(|| { + let values = Values { monero: 184100, ether: 4785000, dai: 1500 }; + + // set the oraclization + GenesisLiquidity::oraclize_values(RawOrigin::None.into(), values, Signature([0u8; 64])) + .unwrap(); + + // trying to oraclize again should fail + let call = pallet::Call::::oraclize_values { values, signature: Signature([0u8; 64]) }; + assert_eq!( + GenesisLiquidity::validate_unsigned(TransactionSource::External, &call), + InvalidTransaction::Custom(1).into() + ); + }) +} + +#[test] +fn validate_oraclize_values_submit_before_a_month() { + new_test_ext().execute_with(|| { + let values = Values { monero: 184100, ether: 4785000, dai: 1500 }; + let call = pallet::Call::::oraclize_values { values, signature: Signature([0u8; 64]) }; + + // we should wait for a month before setting the values + assert_eq!( + GenesisLiquidity::validate_unsigned(TransactionSource::External, &call), + InvalidTransaction::Custom(2).into() + ); + }) +} + +#[test] +fn validate_oraclize_values_invalid_signature() { + new_test_ext().execute_with(|| { + let genesis_participants = vec![ + insecure_pair_from_name("Alice"), + insecure_pair_from_name("Bob"), + insecure_pair_from_name("Charlie"), + insecure_pair_from_name("Dave"), + insecure_pair_from_name("Eve"), + insecure_pair_from_name("Ferdie"), + ]; + let network = NetworkId::Serai; + let values = Values { monero: 184100, ether: 4785000, dai: 1500 }; + + // invalid signature should fail + System::set_block_number(MONTHS); + let call = pallet::Call::::oraclize_values { values, signature: Signature([0u8; 64]) }; + assert_eq!( + GenesisLiquidity::validate_unsigned(TransactionSource::External, &call), + InvalidTransaction::BadProof.into() + ); + + let pairs = get_ordered_keys(network, &genesis_participants); + let signature = + oraclize_values_signature(ValidatorSet { session: Session(0), network }, &values, &pairs); + let call = pallet::Call::::oraclize_values { values, signature }; + + // valid signature should pass + GenesisLiquidity::validate_unsigned(TransactionSource::External, &call).unwrap(); + }) +} diff --git a/substrate/validator-sets/pallet/src/lib.rs b/substrate/validator-sets/pallet/src/lib.rs index 383f94a7d..cf87abfe3 100644 --- a/substrate/validator-sets/pallet/src/lib.rs +++ b/substrate/validator-sets/pallet/src/lib.rs @@ -713,7 +713,7 @@ pub mod pallet { })) } - fn new_session() { + pub fn new_session() { for network in serai_primitives::NETWORKS { // If this network hasn't started sessions yet, don't start one now let Some(current_session) = Self::session(network) else { continue };