diff --git a/.github/workflows/ci-contracts-upload-binaries.yml b/.github/workflows/ci-contracts-upload-binaries.yml index 4873d468859..9cb7116dcad 100644 --- a/.github/workflows/ci-contracts-upload-binaries.yml +++ b/.github/workflows/ci-contracts-upload-binaries.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - platform: arc-ubuntu-20.04 + platform: [ arc-ubuntu-20.04 ] runs-on: ${{ matrix.platform }} env: diff --git a/Cargo.lock b/Cargo.lock index 4e882a3dadc..1db9eb26e4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5055,6 +5055,7 @@ dependencies = [ "anyhow", "axum 0.7.7", "chrono", + "clap 4.5.18", "nym-bin-common", "nym-network-defaults", "nym-node-requests", diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/helpers.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/helpers.rs index 31e8e820d46..092f35e80ca 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/helpers.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/helpers.rs @@ -10,12 +10,13 @@ use crate::{ use contracts_common::IdentityKeyRef; use cosmwasm_std::{Coin, 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 { - assert!(a - b < epsilon, "{a} != {b}") + assert!(a - b < epsilon, "{a} != {b}, delta: {}", a - b) } else { - assert!(b - a < epsilon, "{a} != {b}") + assert!(b - a < epsilon, "{a} != {b}, delta: {}", b - a) } } diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index e15bd4a459b..44f70d3fc43 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -1284,6 +1284,7 @@ dependencies = [ "nym-crypto", "nym-mixnet-contract-common", "nym-vesting-contract-common", + "rand", "rand_chacha", "serde", "thiserror", @@ -1750,11 +1751,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 67f63d10072..796c6afb3f0 100644 --- a/contracts/mixnet/Cargo.toml +++ b/contracts/mixnet/Cargo.toml @@ -46,6 +46,7 @@ time = { version = "0.3", features = ["macros"] } [dev-dependencies] anyhow.workspace = true 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 25bca25919e..694eb256a04 100644 --- a/contracts/mixnet/src/contract.rs +++ b/contracts/mixnet/src/contract.rs @@ -269,7 +269,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/mixnodes/helpers.rs b/contracts/mixnet/src/mixnodes/helpers.rs index fc2d56254f8..ef78ffb741a 100644 --- a/contracts/mixnet/src/mixnodes/helpers.rs +++ b/contracts/mixnet/src/mixnodes/helpers.rs @@ -156,28 +156,29 @@ pub(crate) mod tests { test: &mut TestSetup, stake: Option, ) -> Vec { - let (mix_id, keypair) = test.add_dummy_mixnode_with_keypair(OWNER_EXISTS, stake); + let (mix_id, keypair) = test.add_legacy_mixnode_with_keypair(OWNER_EXISTS, stake); let mix_exists = DummyMixnode { mix_id, owner: Addr::unchecked(OWNER_EXISTS), identity: keypair.public_key().to_base58_string(), }; - let (mix_id, keypair) = test.add_dummy_mixnode_with_keypair(OWNER_UNBONDING, stake); + let (mix_id, keypair) = test.add_legacy_mixnode_with_keypair(OWNER_UNBONDING, stake); let mix_unbonding = DummyMixnode { mix_id, owner: Addr::unchecked(OWNER_UNBONDING), identity: keypair.public_key().to_base58_string(), }; - let (mix_id, keypair) = test.add_dummy_mixnode_with_keypair(OWNER_UNBONDED, stake); + let (mix_id, keypair) = test.add_legacy_mixnode_with_keypair(OWNER_UNBONDED, stake); let mix_unbonded = DummyMixnode { mix_id, owner: Addr::unchecked(OWNER_UNBONDED), identity: keypair.public_key().to_base58_string(), }; - let (mix_id, keypair) = test.add_dummy_mixnode_with_keypair(OWNER_UNBONDED_LEFTOVER, stake); + let (mix_id, keypair) = + test.add_legacy_mixnode_with_keypair(OWNER_UNBONDED_LEFTOVER, stake); let mix_unbonded_leftover = DummyMixnode { mix_id, owner: Addr::unchecked(OWNER_UNBONDED_LEFTOVER), diff --git a/contracts/mixnet/src/mixnodes/queries.rs b/contracts/mixnet/src/mixnodes/queries.rs index 8300edf5f11..8f127ed9f5f 100644 --- a/contracts/mixnet/src/mixnodes/queries.rs +++ b/contracts/mixnet/src/mixnodes/queries.rs @@ -266,7 +266,7 @@ pub(crate) mod tests { #[test] fn obeys_limits() { let mut test = TestSetup::new(); - test.add_dummy_mixnodes(1000); + test.add_legacy_mixnodes(1000); let limit = 2; let page1 = query_mixnode_bonds_paged(test.deps(), None, Some(limit)).unwrap(); @@ -276,7 +276,7 @@ pub(crate) mod tests { #[test] fn has_default_limit() { let mut test = TestSetup::new(); - test.add_dummy_mixnodes(1000); + test.add_legacy_mixnodes(1000); // query without explicitly setting a limit let page1 = query_mixnode_bonds_paged(test.deps(), None, None).unwrap(); @@ -290,7 +290,7 @@ pub(crate) mod tests { #[test] fn has_max_limit() { let mut test = TestSetup::new(); - test.add_dummy_mixnodes(1000); + test.add_legacy_mixnodes(1000); // query with a crazily high limit in an attempt to use too many resources let crazy_limit = 1000; @@ -353,7 +353,7 @@ pub(crate) mod tests { #[test] fn obeys_limits() { let mut test = TestSetup::new(); - test.add_dummy_mixnodes(1000); + test.add_legacy_mixnodes(1000); let limit = 2; let page1 = query_mixnodes_details_paged(test.deps(), None, Some(limit)).unwrap(); @@ -363,7 +363,7 @@ pub(crate) mod tests { #[test] fn has_default_limit() { let mut test = TestSetup::new(); - test.add_dummy_mixnodes(1000); + test.add_legacy_mixnodes(1000); // query without explicitly setting a limit let page1 = query_mixnodes_details_paged(test.deps(), None, None).unwrap(); @@ -377,7 +377,7 @@ pub(crate) mod tests { #[test] fn has_max_limit() { let mut test = TestSetup::new(); - test.add_dummy_mixnodes(1000); + test.add_legacy_mixnodes(1000); // query with a crazily high limit in an attempt to use too many resources let crazy_limit = 1000; diff --git a/contracts/mixnet/src/support/tests/mod.rs b/contracts/mixnet/src/support/tests/mod.rs index ba0eecaabbb..ed59e8a19c9 100644 --- a/contracts/mixnet/src/support/tests/mod.rs +++ b/contracts/mixnet/src/support/tests/mod.rs @@ -11,6 +11,7 @@ pub mod test_helpers { use crate::contract::{execute, instantiate}; use crate::delegations::queries::query_node_delegations_paged; use crate::delegations::storage as delegations_storage; + use crate::delegations::storage::delegations; use crate::delegations::transactions::try_delegate_to_node; use crate::interval::transactions::{ perform_pending_epoch_actions, perform_pending_interval_actions, try_begin_epoch_transition, @@ -31,7 +32,9 @@ pub mod test_helpers { use crate::nodes::storage as nymnodes_storage; use crate::nodes::storage::helpers::RoleStorageBucket; use crate::nodes::storage::rewarded_set::{ACTIVE_ROLES_BUCKET, ROLES, ROLES_METADATA}; - use crate::nodes::storage::{read_assigned_roles, save_assignment, swap_active_role_bucket}; + use crate::nodes::storage::{ + next_nymnode_id_counter, read_assigned_roles, save_assignment, swap_active_role_bucket, + }; use crate::nodes::transactions::{try_add_nym_node, try_remove_nym_node}; use crate::rewards::helpers::expensive_role_lookup; use crate::rewards::queries::{ @@ -52,7 +55,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}; @@ -62,6 +65,7 @@ pub mod test_helpers { use mixnet_contract_common::events::{ may_find_attribute, MixnetEventType, DELEGATES_REWARD_KEY, OPERATOR_REWARD_KEY, }; + use mixnet_contract_common::helpers::compare_decimals; use mixnet_contract_common::mixnode::{NodeRewarding, UnbondedMixnode}; use mixnet_contract_common::nym_node::{RewardedSetMetadata, Role}; use mixnet_contract_common::pending_events::{PendingEpochEventData, PendingIntervalEventData}; @@ -84,6 +88,8 @@ pub mod test_helpers { }; use nym_crypto::asymmetric::identity; use nym_crypto::asymmetric::identity::KeyPair; + use rand::distributions::WeightedIndex; + use rand::prelude::*; use rand_chacha::rand_core::{CryptoRng, RngCore, SeedableRng}; use rand_chacha::ChaCha20Rng; use serde::Serialize; @@ -133,14 +139,16 @@ pub mod test_helpers { } } + #[track_caller] pub fn assert_eq_with_leeway(a: Uint128, b: Uint128, leeway: Uint128) { if a > b { - assert!(a - b <= leeway) + assert!(a - b <= leeway, "{} != {}", a, b) } else { - assert!(b - a <= leeway) + assert!(b - a <= leeway, "{} != {}", a, b) } } + #[track_caller] pub fn assert_decimals(a: Decimal, b: Decimal) { let epsilon = Decimal::from_ratio(1u128, 100_000_000u128); if a > b { @@ -180,6 +188,141 @@ pub mod test_helpers { } } + pub fn new_complex() -> Self { + let mut test = TestSetup::new(); + + let mut nodes = Vec::new(); + + let problematic_delegator = "n1foomp"; + let problematic_delegator_twin = "n1bar"; + let problematic_delegator_alt_twin = "n1whatever"; + + 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_legacy_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_legacy_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, + ); + } + + if epoch_id == 234 { + test.add_immediate_delegation(problematic_delegator_alt_twin, stake, 4); + } + + if epoch_id == 420 { + test.add_immediate_delegation_with_legal_proxy( + problematic_delegator_alt_twin, + stake, + 4, + ); + } + + test.skip_to_next_epoch_end(); + // it doesn't matter that they're on the same layer here, we just need to make sure they're rewarded + test.force_assign_rewarded_set(vec![RoleAssignment { + role: Role::Layer1, + nodes: nodes.clone(), + }]); + test.start_epoch_transition(); + + // reward each node + for node in &nodes { + let performance = test.rng.next_u64() % 100; + let work_factor = test.active_node_work(); + test.reward_with_distribution( + *node, + NodeRewardingParameters { + performance: Performance::from_percentage_value(performance).unwrap(), + work_factor, + }, + ); + } + + test.set_epoch_in_progress_state(); + } + + test + } + + #[track_caller] + pub fn ensure_delegation_sync(&self, mix_id: NodeId) { + let mix_info = self.mix_rewarding(mix_id); + let epsilon = "0.001".parse().unwrap(); + + let subtotal: Decimal = delegations() + .prefix(mix_id) + .range(self.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)) + } + + pub fn random_address(&mut self) -> String { + format!("n1foomp{}", self.rng.next_u64()) + } + pub fn deps(&self) -> Deps<'_> { self.deps.as_ref() } @@ -326,6 +469,20 @@ 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 all_mixnodes(&self) -> Vec { + mixnode_bonds() + .range(self.deps().storage, None, None, Order::Ascending) + .filter_map(|m| m.map(|(_, node)| node.mix_id).ok()) + .collect::>() + } + pub fn coin(&self, amount: u128) -> Coin { coin(amount, rewarding_denom(self.deps().storage).unwrap()) } @@ -565,6 +722,70 @@ pub mod test_helpers { node_id } + pub fn add_legacy_mixnode_with_proxy_and_keypair( + &mut self, + owner: &str, + stake: Option, + ) -> (NodeId, 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 mix_id = next_nymnode_id_counter(storage).unwrap(); + + let current_epoch = interval_storage::current_interval(storage) + .unwrap() + .current_epoch_absolute_id(); + + let mixnode_rewarding = NodeRewarding::initialise_new( + tests::fixtures::node_cost_params_fixture(), + &pledge, + current_epoch, + ) + .unwrap(); + let mixnode_bond = MixNodeBond { + mix_id, + owner: Addr::unchecked(owner), + original_pledge: pledge, + 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_legacy_mixnode_with_legal_proxy( + &mut self, + owner: &str, + stake: Option, + ) -> NodeId { + self.add_legacy_mixnode_with_proxy_and_keypair(owner, stake) + .0 + } + pub fn add_rewarded_legacy_mixnode( &mut self, owner: &str, @@ -610,7 +831,7 @@ pub mod test_helpers { .unwrap(); } - pub fn add_dummy_mixnodes(&mut self, n: usize) { + pub fn add_legacy_mixnodes(&mut self, n: usize) { for i in 0..n { self.add_legacy_mixnode(&format!("owner{i}"), None); } @@ -661,7 +882,7 @@ pub mod test_helpers { ed25519_sign_message(msg, key) } - pub fn add_dummy_mixnode_with_keypair( + pub fn add_legacy_mixnode_with_keypair( &mut self, owner: &str, stake: Option, @@ -832,6 +1053,55 @@ pub mod test_helpers { .unwrap(); } + pub fn add_immediate_delegation_with_legal_proxy( + &mut self, + delegator: &str, + amount: impl Into, + target: NodeId, + ) { + 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, + node_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, @@ -1170,6 +1440,13 @@ pub mod test_helpers { let res = try_reward_node(self.deps_mut(), env, sender, node_id, rewarding_params).unwrap(); + + if rewarding_params.is_zero() { + return RewardDistribution { + operator: Decimal::zero(), + delegates: Decimal::zero(), + }; + } let operator: Decimal = find_attribute( Some(MixnetEventType::NodeRewarding.to_string()), OPERATOR_REWARD_KEY, @@ -1286,6 +1563,7 @@ pub mod test_helpers { None } + #[track_caller] pub fn find_attribute>( event_type: Option, attribute: &str, @@ -1386,55 +1664,6 @@ pub mod test_helpers { perform_pending_interval_actions(deps.branch(), &env, None).unwrap(); } - // pub fn mixnode_with_signature( - // mut rng: impl RngCore + CryptoRng, - // deps: Deps<'_>, - // sender: &str, - // stake: Option>, - // ) -> (MixNode, MessageSignature, KeyPair) { - // // hehe stupid workaround for bypassing the immutable borrow and removing duplicate code - // - // let stake = stake.unwrap_or(good_mixnode_pledge()); - // - // let keypair = identity::KeyPair::new(&mut rng); - // let identity_key = keypair.public_key().to_base58_string(); - // let legit_sphinx_keys = nym_crypto::asymmetric::encryption::KeyPair::new(&mut rng); - // - // let mixnode = MixNode { - // identity_key, - // sphinx_key: legit_sphinx_keys.public_key().to_base58_string(), - // ..tests::fixtures::mix_node_fixture() - // }; - // let msg = mixnode_bonding_sign_payload(deps, sender, None, mixnode.clone(), stake.clone()); - // let owner_signature = ed25519_sign_message(msg, keypair.private_key()); - // - // (mixnode, owner_signature, keypair) - // } - - // pub fn gateway_with_signature( - // mut rng: impl RngCore + CryptoRng, - // deps: Deps<'_>, - // sender: &str, - // stake: Option>, - // ) -> (Gateway, MessageSignature) { - // let stake = stake.unwrap_or(good_gateway_pledge()); - // - // let keypair = identity::KeyPair::new(&mut rng); - // let identity_key = keypair.public_key().to_base58_string(); - // let legit_sphinx_keys = nym_crypto::asymmetric::encryption::KeyPair::new(&mut rng); - // - // let gateway = Gateway { - // identity_key, - // sphinx_key: legit_sphinx_keys.public_key().to_base58_string(), - // ..tests::fixtures::gateway_fixture() - // }; - // - // let msg = gateway_bonding_sign_payload(deps, sender, None, gateway.clone(), stake.clone()); - // let owner_signature = ed25519_sign_message(msg, keypair.private_key()); - // - // (gateway, owner_signature) - // } - pub fn add_dummy_delegations(mut deps: DepsMut<'_>, env: Env, mix_id: NodeId, n: usize) { for i in 0..n { pending_events::delegate( @@ -1449,23 +1678,6 @@ pub mod test_helpers { } } - // pub fn add_dummy_mixnodes( - // mut rng: impl RngCore + CryptoRng, - // mut deps: DepsMut<'_>, - // env: Env, - // n: usize, - // ) { - // for i in 0..n { - // add_mixnode( - // &mut rng, - // deps.branch(), - // env.clone(), - // &format!("owner{}", i), - // tests::fixtures::good_mixnode_pledge(), - // ); - // } - // } - pub fn add_dummy_unbonded_mixnodes( mut rng: impl RngCore + CryptoRng, mut deps: DepsMut<'_>, diff --git a/contracts/mixnet/src/vesting_migration.rs b/contracts/mixnet/src/vesting_migration.rs index cfdc18fcf61..c67a87518ef 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, NodeId}; 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: NodeId, ) -> 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); + let mut updated_delegation = vested_delegation.clone(); + updated_delegation.proxy = None; + + 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)?; - delegations_storage::delegations().save(deps.storage, updated_storage_key, &delegation)?; - Ok(Response::new().add_message(wasm_execute( + // 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.node_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.node_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,364 @@ 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_legacy_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_legacy_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::support::tests::test_helpers::{assert_eq_with_leeway, TestSetup}; + use cosmwasm_std::testing::mock_info; + use cosmwasm_std::{from_binary, Addr, CosmosMsg, Order, Uint128, WasmMsg}; + use mixnet_contract_common::helpers::compare_decimals; + use mixnet_contract_common::nym_node::Role; + use mixnet_contract_common::reward_params::{NodeRewardingParameters, Performance}; + use mixnet_contract_common::rewarding::helpers::truncate_reward; + use mixnet_contract_common::RoleAssignment; + use rand::RngCore; + + #[test] + fn with_no_delegation() { + let mut test = TestSetup::new_complex(); + 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 = TestSetup::new_complex(); + 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.node_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 = TestSetup::new_complex(); + 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.node_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() { + let mut test = TestSetup::new_complex(); + let env = test.env(); + + let problematic_delegator = "n1foomp"; + let problematic_delegator_twin = "n1bar"; + 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 + test.ensure_delegation_sync(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 + test.ensure_delegation_sync(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 = test.all_mixnodes(); + + 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(); + // it doesn't matter that they're on the same layer here, we just need to make sure they're rewarded + test.force_assign_rewarded_set(vec![RoleAssignment { + role: Role::Layer1, + nodes: all_nodes.clone(), + }]); + test.start_epoch_transition(); + + // reward each node + for node in &all_nodes { + let performance = test.rng.next_u64() % 100; + let work_factor = test.active_node_work(); + test.reward_with_distribution( + *node, + NodeRewardingParameters { + performance: Performance::from_percentage_value(performance).unwrap(), + work_factor, + }, + ); + } + + test.set_epoch_in_progress_state(); + } + + // this assertion must still hold + test.ensure_delegation_sync(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())) + } + } +}