From 2cdf2ec0c95451cfae0637fa709ca4f33a916e79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Wed, 9 Oct 2024 11:46:36 +0100 Subject: [PATCH] added unit tests for vested migrations and fixed additional issues --- .../mixnet-contract/src/helpers.rs | 1 + contracts/Cargo.lock | 6 +- contracts/mixnet/Cargo.toml | 1 + contracts/mixnet/src/contract.rs | 2 +- contracts/mixnet/src/support/tests/mod.rs | 142 +++- contracts/mixnet/src/vesting_migration.rs | 617 +++++++++++++++++- 6 files changed, 748 insertions(+), 21 deletions(-) diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/helpers.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/helpers.rs index fc0a05892b..95fb67692b 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/helpers.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/helpers.rs @@ -3,6 +3,7 @@ use cosmwasm_std::{Decimal, StdError, StdResult, Uint128}; +#[track_caller] pub fn compare_decimals(a: Decimal, b: Decimal, epsilon: Option) { let epsilon = epsilon.unwrap_or_else(|| Decimal::from_ratio(1u128, 100_000_000u128)); if a > b { diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index 6185826bbf..0fe447395d 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -1283,6 +1283,7 @@ dependencies = [ "nym-crypto", "nym-mixnet-contract-common", "nym-vesting-contract-common", + "rand", "rand_chacha", "serde", "thiserror", @@ -1748,11 +1749,12 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.116" +version = "1.0.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e17db7126d17feb94eb3fad46bf1a96b034e8aacbc2e775fe81505f8b0b2813" +checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" dependencies = [ "itoa", + "memchr", "ryu", "serde", ] diff --git a/contracts/mixnet/Cargo.toml b/contracts/mixnet/Cargo.toml index 957f981f2e..c7d942a4ed 100644 --- a/contracts/mixnet/Cargo.toml +++ b/contracts/mixnet/Cargo.toml @@ -45,6 +45,7 @@ time = { version = "0.3", features = ["macros"] } [dev-dependencies] rand_chacha = "0.3" +rand = "0.8.5" nym-crypto = { path = "../../common/crypto", features = ["asymmetric", "rand"] } [features] diff --git a/contracts/mixnet/src/contract.rs b/contracts/mixnet/src/contract.rs index ffdbebce3e..9913da3265 100644 --- a/contracts/mixnet/src/contract.rs +++ b/contracts/mixnet/src/contract.rs @@ -273,7 +273,7 @@ pub fn execute( crate::vesting_migration::try_migrate_vested_mixnode(deps, info) } ExecuteMsg::MigrateVestedDelegation { mix_id } => { - crate::vesting_migration::try_migrate_vested_delegation(deps, info, mix_id) + crate::vesting_migration::try_migrate_vested_delegation(deps, env, info, mix_id) } // legacy vesting diff --git a/contracts/mixnet/src/support/tests/mod.rs b/contracts/mixnet/src/support/tests/mod.rs index dee121b2f0..728e6fcb83 100644 --- a/contracts/mixnet/src/support/tests/mod.rs +++ b/contracts/mixnet/src/support/tests/mod.rs @@ -24,9 +24,10 @@ pub mod test_helpers { minimum_gateway_pledge, minimum_mixnode_pledge, rewarding_denom, rewarding_validator_address, }; - use crate::mixnodes::storage as mixnodes_storage; - use crate::mixnodes::storage::mixnode_bonds; + use crate::mixnodes::helpers::get_mixnode_details_by_id; + use crate::mixnodes::storage::{assign_layer, mixnode_bonds, next_mixnode_id_counter}; use crate::mixnodes::transactions::{try_add_mixnode, try_remove_mixnode}; + use crate::mixnodes::{storage as mixnodes_storage, storage}; use crate::rewards::queries::{ query_pending_delegator_reward, query_pending_mixnode_operator_reward, }; @@ -42,7 +43,7 @@ pub mod test_helpers { use cosmwasm_std::testing::mock_info; use cosmwasm_std::testing::MockApi; use cosmwasm_std::testing::MockQuerier; - use cosmwasm_std::{coin, coins, Addr, BankMsg, CosmosMsg, Storage}; + use cosmwasm_std::{coin, coins, Addr, Api, BankMsg, CosmosMsg, Storage}; use cosmwasm_std::{Coin, Order}; use cosmwasm_std::{Decimal, Empty, MemoryStorage}; use cosmwasm_std::{Deps, OwnedDeps}; @@ -120,6 +121,10 @@ pub mod test_helpers { } } + pub fn random_address(&mut self) -> String { + format!("n1foomp{}", self.rng.next_u64()) + } + pub fn deps(&self) -> Deps<'_> { self.deps.as_ref() } @@ -146,6 +151,13 @@ pub mod test_helpers { self.owner.clone() } + pub fn vesting_contract(&self) -> Addr { + mixnet_params_storage::CONTRACT_STATE + .load(self.deps().storage) + .unwrap() + .vesting_contract_address + } + pub fn coin(&self, amount: u128) -> Coin { coin(amount, rewarding_denom(self.deps().storage).unwrap()) } @@ -266,6 +278,72 @@ pub mod test_helpers { current_id_counter + 1 } + pub fn add_dummy_mixnode_with_proxy_and_keypair( + &mut self, + owner: &str, + stake: Option, + ) -> (MixId, identity::KeyPair) { + let pledge = self.make_mix_pledge(stake).pop().unwrap(); + + let proxy = self.vesting_contract(); + + let keypair = identity::KeyPair::new(&mut self.rng); + let identity_key = keypair.public_key().to_base58_string(); + let legit_sphinx_keys = nym_crypto::asymmetric::encryption::KeyPair::new(&mut self.rng); + + let mixnode = MixNode { + identity_key, + sphinx_key: legit_sphinx_keys.public_key().to_base58_string(), + ..tests::fixtures::mix_node_fixture() + }; + + let height = self.env.block.height; + let storage = self.deps_mut().storage; + + // manually unroll `save_new_mixnode` to allow for proxy usage + let layer = assign_layer(storage).unwrap(); + let mix_id = next_mixnode_id_counter(storage).unwrap(); + + let current_epoch = interval_storage::current_interval(storage) + .unwrap() + .current_epoch_absolute_id(); + + let mixnode_rewarding = MixNodeRewarding::initialise_new( + tests::fixtures::mix_node_cost_params_fixture(), + &pledge, + current_epoch, + ) + .unwrap(); + let mixnode_bond = MixNodeBond { + mix_id, + owner: Addr::unchecked(owner), + original_pledge: pledge, + layer, + mix_node: mixnode, + proxy: Some(proxy), + bonding_height: height, + is_unbonding: false, + }; + + mixnode_bonds() + .save(storage, mix_id, &mixnode_bond) + .unwrap(); + rewards_storage::MIXNODE_REWARDING + .save(storage, mix_id, &mixnode_rewarding) + .unwrap(); + + (mix_id, keypair) + } + + pub fn add_dummy_mixnode_with_legal_proxy( + &mut self, + owner: &str, + stake: Option, + ) -> MixId { + self.add_dummy_mixnode_with_proxy_and_keypair(owner, stake) + .0 + } + pub fn add_dummy_gateway(&mut self, sender: &str, stake: Option) -> IdentityKey { let stake = self.make_gateway_pledge(stake); let (gateway, owner_signature) = @@ -454,6 +532,55 @@ pub mod test_helpers { .unwrap(); } + pub fn add_immediate_delegation_with_legal_proxy( + &mut self, + delegator: &str, + amount: impl Into, + target: MixId, + ) { + let denom = rewarding_denom(self.deps().storage).unwrap(); + let amount = Coin { + denom, + amount: amount.into(), + }; + let proxy = self.vesting_contract(); + + let owner = self.deps.api.addr_validate(delegator).unwrap(); + let storage_key = Delegation::generate_storage_key(target, &owner, Some(&proxy)); + + let mut mix_rewarding = self.mix_rewarding(target); + + let mut stored_delegation_amount = amount; + + if let Some(existing_delegation) = delegations_storage::delegations() + .may_load(&self.deps.storage, storage_key.clone()) + .unwrap() + { + let og_with_reward = mix_rewarding.undelegate(&existing_delegation).unwrap(); + stored_delegation_amount.amount += og_with_reward.amount; + } + + mix_rewarding + .add_base_delegation(stored_delegation_amount.amount) + .unwrap(); + + let delegation = Delegation { + owner, + mix_id: target, + cumulative_reward_ratio: mix_rewarding.total_unit_reward, + amount: stored_delegation_amount, + height: self.env.block.height, + proxy: Some(proxy), + }; + + delegations_storage::delegations() + .save(&mut self.deps.storage, storage_key, &delegation) + .unwrap(); + rewards_storage::MIXNODE_REWARDING + .save(&mut self.deps.storage, target, &mix_rewarding) + .unwrap(); + } + #[allow(unused)] pub fn add_delegation( &mut self, @@ -639,6 +766,14 @@ pub mod test_helpers { let res = try_reward_mixnode(self.deps_mut(), env, sender, mix_id, performance).unwrap(); + + if performance.is_zero() { + return RewardDistribution { + operator: Decimal::zero(), + delegates: Decimal::zero(), + }; + } + let operator: Decimal = find_attribute( Some(MixnetEventType::MixnodeRewarding.to_string()), OPERATOR_REWARD_KEY, @@ -749,6 +884,7 @@ pub mod test_helpers { None } + #[track_caller] pub fn find_attribute>( event_type: Option, attribute: &str, diff --git a/contracts/mixnet/src/vesting_migration.rs b/contracts/mixnet/src/vesting_migration.rs index 3faf826ec0..e1526ec7c1 100644 --- a/contracts/mixnet/src/vesting_migration.rs +++ b/contracts/mixnet/src/vesting_migration.rs @@ -5,10 +5,11 @@ use crate::delegations::storage as delegations_storage; use crate::mixnet_contract_settings::storage as mixnet_params_storage; use crate::mixnodes::helpers::get_mixnode_details_by_owner; use crate::mixnodes::storage as mixnodes_storage; +use crate::rewards::storage as rewards_storage; use crate::support::helpers::{ ensure_bonded, ensure_epoch_in_progress_state, ensure_no_pending_pledge_changes, }; -use cosmwasm_std::{wasm_execute, DepsMut, MessageInfo, Response}; +use cosmwasm_std::{wasm_execute, DepsMut, Env, Event, MessageInfo, Response}; use mixnet_contract_common::error::MixnetContractError; use mixnet_contract_common::{Delegation, MixId}; use vesting_contract_common::messages::ExecuteMsg as VestingExecuteMsg; @@ -49,42 +50,159 @@ pub(crate) fn try_migrate_vested_mixnode( Some(&mix_details.bond_information), )?; - Ok(Response::new().add_message(wasm_execute( - vesting_contract, - &VestingExecuteMsg::TrackMigratedMixnode { - owner: info.sender.into_string(), - }, - vec![], - )?)) + Ok(Response::new() + .add_event(Event::new("migrate-vested-mixnode").add_attribute("mix_id", mix_id.to_string())) + .add_message(wasm_execute( + vesting_contract, + &VestingExecuteMsg::TrackMigratedMixnode { + owner: info.sender.into_string(), + }, + vec![], + )?)) } pub(crate) fn try_migrate_vested_delegation( deps: DepsMut<'_>, + env: Env, info: MessageInfo, mix_id: MixId, ) -> Result { + let mut response = Response::new(); + ensure_epoch_in_progress_state(deps.storage)?; let vesting_contract = mixnet_params_storage::vesting_contract_address(deps.storage)?; let storage_key = Delegation::generate_storage_key(mix_id, &info.sender, Some(&vesting_contract)); - let Some(mut delegation) = + let Some(vested_delegation) = delegations_storage::delegations().may_load(deps.storage, storage_key.clone())? else { return Err(MixnetContractError::NotAVestingDelegation); }; // sanity check that's meant to blow up the contract - assert_eq!(delegation.proxy, Some(vesting_contract.clone())); + assert_eq!(vested_delegation.proxy, Some(vesting_contract.clone())); // update the delegation and save it under the correct storage key - delegation.proxy = None; - let updated_storage_key = Delegation::generate_storage_key(mix_id, &info.sender, None); - delegations_storage::delegations().remove(deps.storage, storage_key)?; - delegations_storage::delegations().save(deps.storage, updated_storage_key, &delegation)?; + let mut updated_delegation = vested_delegation.clone(); + updated_delegation.proxy = None; - Ok(Response::new().add_message(wasm_execute( + let new_storage_key = Delegation::generate_storage_key(mix_id, &info.sender, None); + + // remove the old (vested) delegation + delegations_storage::delegations().remove(deps.storage, storage_key)?; + + // check if there was already a delegation present under that key (i.e. an old liquid one) + if let Some(existing_liquid_delegation) = + delegations_storage::delegations().may_load(deps.storage, new_storage_key.clone())? + { + // treat it as adding extra stake to the existing delegation, so we need to update the unit reward value + // as well as retrieve any pending rewards + // it replicates part of code from `pending_events::delegate`, + // but without some checks that'd be redundant in this instance + let mut mix_rewarding = + rewards_storage::MIXNODE_REWARDING.load(deps.storage, vested_delegation.mix_id)?; + + // calculate rewards separately for the purposes of emitting those in events + let pending_liquid_reward = + mix_rewarding.determine_delegation_reward(&existing_liquid_delegation)?; + let pending_vested_reward = + mix_rewarding.determine_delegation_reward(&vested_delegation)?; + + // the calls to 'undelegate' followed by artificial delegate are performed + // to keep the internal `.delegates` field in sync + // (this is due to the fact delegation only holds values up in `Uint128` and lacks the precision of a `Decimal` + // which has to be used for reward accounting) + let liquid_delegation_with_reward = + mix_rewarding.undelegate(&existing_liquid_delegation)?; + let vested_delegation_with_reward = mix_rewarding.undelegate(&vested_delegation)?; + + // updated delegation amount consists of: + // - delegated vested tokens + // - delegated liquid tokens + // - pending rewards earned by the delegated vested tokens + // - pending rewards earned by the delegated liquid tokens + let mut updated_total = liquid_delegation_with_reward.clone(); + updated_total.amount += vested_delegation_with_reward.amount; + mix_rewarding.add_base_delegation(updated_total.amount)?; + + updated_delegation.amount = updated_total; + updated_delegation.height = env.block.height; + updated_delegation.cumulative_reward_ratio = mix_rewarding.total_unit_reward; + + rewards_storage::MIXNODE_REWARDING.save( + deps.storage, + vested_delegation.mix_id, + &mix_rewarding, + )?; + + // replace the old delegation with the new one + delegations_storage::delegations().replace( + deps.storage, + new_storage_key, + Some(&updated_delegation), + Some(&existing_liquid_delegation), + )?; + + // just emit EVERYTHING we can. just in case + response.events.push( + Event::new("migrate-vested-delegation") + .add_attribute("mix_id", mix_id.to_string()) + .add_attribute("existing_liquid", "true") + .add_attribute( + "old_vested_unit_reward", + vested_delegation.cumulative_reward_ratio.to_string(), + ) + .add_attribute( + "old_vested_delegation_amount", + vested_delegation.amount.to_string(), + ) + .add_attribute( + "old_liquid_unit_reward", + existing_liquid_delegation + .cumulative_reward_ratio + .to_string(), + ) + .add_attribute( + "old_liquid_delegation_amount", + existing_liquid_delegation.amount.to_string(), + ) + .add_attribute( + "new_unit_reward", + updated_delegation.cumulative_reward_ratio.to_string(), + ) + .add_attribute( + "new_delegation_amount", + updated_delegation.amount.to_string(), + ) + .add_attribute("applied_liquid_reward", pending_liquid_reward.to_string()) + .add_attribute("applied_vested_reward", pending_vested_reward.to_string()), + ) + } else { + // otherwise, this is as simple as resaving the updated value under the new key + delegations_storage::delegations().save( + deps.storage, + new_storage_key, + &updated_delegation, + )?; + + response.events.push( + Event::new("migrate-vested-delegation") + .add_attribute("mix_id", mix_id.to_string()) + .add_attribute("existing_liquid", "false") + .add_attribute( + "old_vested_unit_reward", + vested_delegation.cumulative_reward_ratio.to_string(), + ) + .add_attribute( + "old_vested_delegation_amount", + vested_delegation.amount.to_string(), + ), + ) + } + + Ok(response.add_message(wasm_execute( vesting_contract, &VestingExecuteMsg::TrackMigratedDelegation { owner: info.sender.into_string(), @@ -93,3 +211,472 @@ pub(crate) fn try_migrate_vested_delegation( vec![], )?)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(test)] + mod migrating_vested_mixnode { + use super::*; + use crate::mixnodes::helpers::get_mixnode_details_by_id; + use crate::support::tests::test_helpers::TestSetup; + use cosmwasm_std::testing::mock_info; + use cosmwasm_std::{from_binary, Addr, CosmosMsg, WasmMsg}; + + #[test] + fn with_no_bonded_nodes() { + let mut test = TestSetup::new(); + + let sender = mock_info("owner", &[]); + let deps = test.deps_mut(); + + // nothing happens + let res = try_migrate_vested_mixnode(deps, sender).unwrap_err(); + assert_eq!( + res, + MixnetContractError::NoAssociatedMixNodeBond { + owner: Addr::unchecked("owner") + } + ) + } + + #[test] + fn with_liquid_node_bonded() { + let mut test = TestSetup::new(); + test.add_dummy_mixnode("owner", None); + + let sender = mock_info("owner", &[]); + let deps = test.deps_mut(); + + // nothing happens + let res = try_migrate_vested_mixnode(deps, sender).unwrap_err(); + assert_eq!(res, MixnetContractError::NotAVestingMixnode) + } + + #[test] + fn with_vested_node_bonded() { + let mut test = TestSetup::new(); + let mix_id = test.add_dummy_mixnode_with_legal_proxy("owner", None); + + let sender = mock_info("owner", &[]); + let deps = test.deps_mut(); + + let existing_node = get_mixnode_details_by_id(deps.storage, mix_id) + .unwrap() + .unwrap(); + assert!(existing_node.bond_information.proxy.is_some()); + + let mut expected = existing_node.clone(); + expected.bond_information.proxy = None; + + // node is simply resaved with proxy data removed and a track message is sent into the vesting contract + let res = try_migrate_vested_mixnode(deps, sender).unwrap(); + let CosmosMsg::Wasm(WasmMsg::Execute { msg, .. }) = &res.messages[0].msg else { + panic!("no track message present") + }; + + assert_eq!( + from_binary::(msg).unwrap(), + VestingExecuteMsg::TrackMigratedMixnode { + owner: "owner".to_string() + } + ); + } + } + + #[cfg(test)] + mod migrating_vested_delegation { + use super::*; + use crate::delegations::storage::delegations; + use crate::mixnodes::storage::mixnode_bonds; + use crate::support::tests::test_helpers::{assert_eq_with_leeway, TestSetup}; + use cosmwasm_std::testing::mock_info; + use cosmwasm_std::{from_binary, Addr, CosmosMsg, Decimal, Order, Uint128, WasmMsg}; + use mixnet_contract_common::helpers::compare_decimals; + use mixnet_contract_common::reward_params::Performance; + use mixnet_contract_common::rewarding::helpers::truncate_reward; + use rand::distributions::Distribution; + use rand::distributions::WeightedIndex; + use rand::prelude::SliceRandom; + use rand::RngCore; + + fn setup_state() -> TestSetup { + let mut test = TestSetup::new(); + + let mut nodes = Vec::new(); + + let problematic_delegator = "n1unhappydelegator"; + let problematic_delegator_twin = "n1anotherunhappydelegator"; + + let choices = [true, false]; + + // every epoch there's a 2% chance of somebody bonding a node + let bonding_weights = [2, 98]; + + // and 15% of making a delegation + let delegation_weights = [15, 85]; + + // and 1% of making a VESTED delegation + let vested_delegation_weights = [1, 99]; + + let bonding_dist = WeightedIndex::new(bonding_weights).unwrap(); + let delegation_dist = WeightedIndex::new(delegation_weights).unwrap(); + let vested_delegation_dist = WeightedIndex::new(vested_delegation_weights).unwrap(); + + // make sure we have at least a single node at the beginning + let owner = test.random_address(); + let mix_id = test.add_dummy_mixnode(&owner, None); + nodes.push(mix_id); + + // create a bunch of nodes and delegations and progress through epochs + for epoch_id in 0..1000 { + // go through 1000 epochs + + let owner = test.random_address(); + let min_stake = 100_000_000; + // u32 has max value of 4B, which is ~4k nym tokens, which is a realistic amount somebody could bond/delegate + let variance = test.rng.next_u32(); + let stake = Uint128::new(min_stake as u128 + variance as u128); + + if choices[bonding_dist.sample(&mut test.rng)] { + // bond + let mix_id = test.add_dummy_mixnode(&owner, Some(stake)); + nodes.push(mix_id); + } + + if choices[delegation_dist.sample(&mut test.rng)] { + // uniformly choose a random node to delegate to + let node = nodes.choose(&mut test.rng).unwrap(); + test.add_immediate_delegation(&owner, stake, *node) + } + + if choices[vested_delegation_dist.sample(&mut test.rng)] { + // uniformly choose a random node to make vested delegation to + let node = nodes.choose(&mut test.rng).unwrap(); + test.add_immediate_delegation_with_legal_proxy(&owner, stake, *node) + } + + // make sure we cover our edge case of somebody having both liquid and vested delegation towards the same node + if epoch_id == 123 { + test.add_immediate_delegation(problematic_delegator, stake, 4); + test.add_immediate_delegation(problematic_delegator_twin, stake, 4); + } + + if epoch_id == 666 { + test.add_immediate_delegation_with_legal_proxy(problematic_delegator, stake, 4); + test.add_immediate_delegation_with_legal_proxy( + problematic_delegator_twin, + stake, + 4, + ); + } + + test.skip_to_next_epoch_end(); + test.force_change_rewarded_set(nodes.clone()); + test.start_epoch_transition(); + + // reward each node + for node in &nodes { + let performance = test.rng.next_u64() % 100; + test.reward_with_distribution( + *node, + Performance::from_percentage_value(performance).unwrap(), + ); + } + + test.set_epoch_in_progress_state(); + } + + test + } + + #[test] + fn with_no_delegation() { + let mut test = setup_state(); + let env = test.env(); + + let sender = mock_info("owner-without-any-delegations", &[]); + + // it simply fails for there is nothing to migrate + let res = try_migrate_vested_delegation(test.deps_mut(), env, sender, 42).unwrap_err(); + assert_eq!(res, MixnetContractError::NotAVestingDelegation); + } + + #[test] + fn with_just_liquid_delegation() { + let mut test = setup_state(); + let env = test.env(); + + // find a valid delegation + let delegation = delegations() + .range(test.deps().storage, None, None, Order::Ascending) + .filter_map(|d| d.map(|(_, del)| del).ok()) + .find(|d| d.proxy.is_none()) + .unwrap(); + + // make sure we haven't chosen somebody that also has a vested delegation because that would have invalidated the test + assert!(!delegations() + .range(test.deps().storage, None, None, Order::Ascending) + .filter_map(|d| d.map(|(_, del)| del).ok()) + .any(|d| d.proxy.is_some() && d.owner.as_str() == delegation.owner.as_str())); + + let sender = mock_info(delegation.owner.as_str(), &[]); + let mix_id = delegation.mix_id; + + // it also fails because the method is only allowed for vested delegations + let res = + try_migrate_vested_delegation(test.deps_mut(), env, sender, mix_id).unwrap_err(); + assert_eq!(res, MixnetContractError::NotAVestingDelegation); + } + + #[test] + fn with_just_vested_delegation() { + let mut test = setup_state(); + let env = test.env(); + + // find a valid delegation + let delegation = delegations() + .range(test.deps().storage, None, None, Order::Ascending) + .filter_map(|d| d.map(|(_, del)| del).ok()) + .find(|d| d.proxy.is_some()) + .unwrap(); + + // make sure we haven't chosen somebody that also has a liquid delegation because that would have invalidated the test + assert!(!delegations() + .range(test.deps().storage, None, None, Order::Ascending) + .filter_map(|d| d.map(|(_, del)| del).ok()) + .any(|d| d.proxy.is_none() && d.owner.as_str() == delegation.owner.as_str())); + + let storage_key = delegation.storage_key(); + let mut expected_liquid = delegation.clone(); + expected_liquid.proxy = None; + let expected_new_storage_key = expected_liquid.storage_key(); + + let sender = mock_info(delegation.owner.as_str(), &[]); + let mix_id = delegation.mix_id; + + // a track message is sent into the vesting contract + let res = try_migrate_vested_delegation(test.deps_mut(), env, sender, mix_id).unwrap(); + let CosmosMsg::Wasm(WasmMsg::Execute { msg, .. }) = &res.messages[0].msg else { + panic!("no track message present") + }; + + assert_eq!( + from_binary::(msg).unwrap(), + VestingExecuteMsg::TrackMigratedDelegation { + owner: delegation.owner.to_string(), + mix_id, + } + ); + + // the entry is gone from the old storage key + assert!(delegations() + .may_load(test.deps().storage, storage_key) + .unwrap() + .is_none()); + + // and is resaved (without proxy) under the new key + assert_eq!( + expected_liquid, + delegations() + .load(test.deps().storage, expected_new_storage_key) + .unwrap() + ); + } + + #[test] + fn with_both_liquid_and_vested_delegation() { + #[track_caller] + fn ensure_delegation_sync(test: &TestSetup, mix_id: MixId) { + let mix_info = test.mix_rewarding(mix_id); + let epsilon = "0.001".parse().unwrap(); + + let subtotal: Decimal = delegations() + .prefix(mix_id) + .range(test.deps().storage, None, None, Order::Ascending) + .filter_map(|d| { + d.map(|(_, del)| { + let pending_rewards = + mix_info.determine_delegation_reward(&del).unwrap(); + pending_rewards + del.dec_amount().unwrap() + }) + .ok() + }) + .sum(); + + compare_decimals(mix_info.delegates, subtotal, Some(epsilon)) + } + + let mut test = setup_state(); + let env = test.env(); + + let problematic_delegator = "n1unhappydelegator"; + let problematic_delegator_twin = "n1anotherunhappydelegator"; + let mix_id = 4; + + let liquid_storage_key = Delegation::generate_storage_key( + mix_id, + &Addr::unchecked(problematic_delegator), + None, + ); + let vested_storage_key = Delegation::generate_storage_key( + mix_id, + &Addr::unchecked(problematic_delegator), + Some(&test.vesting_contract()), + ); + + let liquid_delegation = delegations() + .load(test.deps().storage, liquid_storage_key.clone()) + .unwrap(); + let vested_delegation = delegations() + .load(test.deps().storage, vested_storage_key.clone()) + .unwrap(); + let mix_info = test.mix_rewarding(mix_id); + let unclaimed_liquid_reward = mix_info + .determine_delegation_reward(&liquid_delegation) + .unwrap(); + let unclaimed_vested_reward = mix_info + .determine_delegation_reward(&vested_delegation) + .unwrap(); + + // sanity check before doing anything + ensure_delegation_sync(&test, mix_id); + + // a track message is sent into the vesting contract + let sender = mock_info(problematic_delegator, &[]); + let res = try_migrate_vested_delegation(test.deps_mut(), env, sender, mix_id).unwrap(); + let CosmosMsg::Wasm(WasmMsg::Execute { msg, .. }) = &res.messages[0].msg else { + panic!("no track message present") + }; + + assert_eq!( + from_binary::(msg).unwrap(), + VestingExecuteMsg::TrackMigratedDelegation { + owner: problematic_delegator.to_string(), + mix_id, + } + ); + + let updated_mix_info = test.mix_rewarding(mix_id); + assert_eq!( + mix_info.unique_delegations - 1, + updated_mix_info.unique_delegations + ); + + // the vested delegation is gone + assert!(delegations() + .may_load(test.deps().storage, vested_storage_key) + .unwrap() + .is_none()); + + let updated_liquid_delegation = delegations() + .load(test.deps().storage, liquid_storage_key.clone()) + .unwrap(); + + assert!(updated_liquid_delegation.proxy.is_none()); + assert_eq!( + updated_liquid_delegation.cumulative_reward_ratio, + updated_mix_info.total_unit_reward + ); + + let expected_amount = truncate_reward( + vested_delegation.dec_amount().unwrap() + + liquid_delegation.dec_amount().unwrap() + + unclaimed_liquid_reward + + unclaimed_vested_reward, + "unym", + ); + // due to rounding we can expect and tolerate a single token of difference + assert_eq_with_leeway( + updated_liquid_delegation.amount.amount, + expected_amount.amount, + Uint128::one(), + ); + + // this assertion must still hold + ensure_delegation_sync(&test, mix_id); + + // go through few more rewarding epochs to make sure the rewards and accounting + // would be the same as if the delegations remained separate + let all_nodes = mixnode_bonds() + .range(test.deps().storage, None, None, Order::Ascending) + .filter_map(|m| m.map(|(_, node)| node.mix_id).ok()) + .collect::>(); + + let twin_liquid_storage_key = Delegation::generate_storage_key( + mix_id, + &Addr::unchecked(problematic_delegator_twin), + None, + ); + let twin_vested_storage_key = Delegation::generate_storage_key( + mix_id, + &Addr::unchecked(problematic_delegator_twin), + Some(&test.vesting_contract()), + ); + + let twin_liquid_delegation = delegations() + .load(test.deps().storage, twin_liquid_storage_key.clone()) + .unwrap(); + let twin_vested_delegation = delegations() + .load(test.deps().storage, twin_vested_storage_key.clone()) + .unwrap(); + + let info = test.mix_rewarding(mix_id); + + let unclaimed_rewards_twin_liquid = info + .determine_delegation_reward(&twin_liquid_delegation) + .unwrap(); + let unclaimed_rewards_twin_vested = info + .determine_delegation_reward(&twin_vested_delegation) + .unwrap(); + + for _ in 0..100 { + test.skip_to_next_epoch_end(); + test.force_change_rewarded_set(all_nodes.clone()); + test.start_epoch_transition(); + + // reward each node + for node in &all_nodes { + let performance = test.rng.next_u64() % 100; + test.reward_with_distribution( + *node, + Performance::from_percentage_value(performance).unwrap(), + ); + } + + test.set_epoch_in_progress_state(); + } + + // this assertion must still hold + ensure_delegation_sync(&test, mix_id); + + let info = test.mix_rewarding(mix_id); + + let current_liquid = delegations() + .load(test.deps().storage, liquid_storage_key) + .unwrap(); + let rewards = info.determine_delegation_reward(¤t_liquid).unwrap(); + + let twin_liquid_delegation = delegations() + .load(test.deps().storage, twin_liquid_storage_key.clone()) + .unwrap(); + let twin_vested_delegation = delegations() + .load(test.deps().storage, twin_vested_storage_key.clone()) + .unwrap(); + + let rewards_twin_liquid = info + .determine_delegation_reward(&twin_liquid_delegation) + .unwrap(); + let rewards_twin_vested = info + .determine_delegation_reward(&twin_vested_delegation) + .unwrap(); + + let new_rewards_twin = rewards_twin_liquid + rewards_twin_vested + - unclaimed_rewards_twin_liquid + - unclaimed_rewards_twin_vested; + + compare_decimals(rewards, new_rewards_twin, Some("0.01".parse().unwrap())) + } + } +}