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/common/cosmwasm-smart-contracts/mixnet-contract/src/helpers.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/helpers.rs index fc0a05892b8..e9905f6c44a 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/helpers.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/helpers.rs @@ -3,12 +3,13 @@ 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 { - 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/common/cosmwasm-smart-contracts/mixnet-contract/src/msg.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/msg.rs index 863fdc03279..81c214b1ca9 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/msg.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/msg.rs @@ -532,7 +532,7 @@ pub enum QueryMsg { /// Gets the basic list of all unbonded mixnodes that belonged to a particular owner. #[cfg_attr(feature = "schema", returns(PagedUnbondedMixnodesResponse))] GetUnbondedMixNodesByOwner { - /// The address of the owner of the the mixnodes used for the query. + /// The address of the owner of the mixnodes used for the query. owner: String, /// Controls the maximum number of entries returned by the query. Note that too large values will be overwritten by a saner default. @@ -783,7 +783,26 @@ pub enum QueryMsg { }, } +#[cw_serde] +pub struct AffectedDelegator { + pub address: String, + pub missing_ratio: Decimal, +} + +#[cw_serde] +pub struct AffectedNode { + pub mix_id: MixId, + pub delegators: Vec, +} + +impl AffectedNode { + pub fn total_ratio(&self) -> Decimal { + self.delegators.iter().map(|d| d.missing_ratio).sum() + } +} + #[cw_serde] pub struct MigrateMsg { pub vesting_contract_address: Option, + pub fix_nodes: Option>, } diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index 6185826bbf5..0fe447395d5 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-vesting-integration-tests/src/support/setup.rs b/contracts/mixnet-vesting-integration-tests/src/support/setup.rs index 4ef1b04cbac..aa47d602af7 100644 --- a/contracts/mixnet-vesting-integration-tests/src/support/setup.rs +++ b/contracts/mixnet-vesting-integration-tests/src/support/setup.rs @@ -308,6 +308,7 @@ pub fn instantiate_contracts( mixnet_contract_address.clone(), &nym_mixnet_contract_common::MigrateMsg { vesting_contract_address: Some(vesting_contract_address.to_string()), + fix_nodes: None, }, mixnet_code_id, ) diff --git a/contracts/mixnet/Cargo.toml b/contracts/mixnet/Cargo.toml index 957f981f2e0..c7d942a4ed7 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/schema/nym-mixnet-contract.json b/contracts/mixnet/schema/nym-mixnet-contract.json index c754e65fdb5..926d840d258 100644 --- a/contracts/mixnet/schema/nym-mixnet-contract.json +++ b/contracts/mixnet/schema/nym-mixnet-contract.json @@ -2150,7 +2150,7 @@ "minimum": 0.0 }, "owner": { - "description": "The address of the owner of the the mixnodes used for the query.", + "description": "The address of the owner of the mixnodes used for the query.", "type": "string" }, "start_after": { @@ -2961,6 +2961,15 @@ "title": "MigrateMsg", "type": "object", "properties": { + "fix_nodes": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/AffectedNode" + } + }, "vesting_contract_address": { "type": [ "string", @@ -2968,7 +2977,50 @@ ] } }, - "additionalProperties": false + "additionalProperties": false, + "definitions": { + "AffectedDelegator": { + "type": "object", + "required": [ + "address", + "missing_ratio" + ], + "properties": { + "address": { + "type": "string" + }, + "missing_ratio": { + "$ref": "#/definitions/Decimal" + } + }, + "additionalProperties": false + }, + "AffectedNode": { + "type": "object", + "required": [ + "delegators", + "mix_id" + ], + "properties": { + "delegators": { + "type": "array", + "items": { + "$ref": "#/definitions/AffectedDelegator" + } + }, + "mix_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "Decimal": { + "description": "A fixed-point decimal value with 18 fractional digits, i.e. Decimal(1_000_000_000_000_000_000) == 1.0\n\nThe greatest possible value that can be represented is 340282366920938463463.374607431768211455 (which is (2^128 - 1) / 10^18)", + "type": "string" + } + } }, "sudo": null, "responses": { diff --git a/contracts/mixnet/schema/raw/migrate.json b/contracts/mixnet/schema/raw/migrate.json index e9962aaf925..b78992418b9 100644 --- a/contracts/mixnet/schema/raw/migrate.json +++ b/contracts/mixnet/schema/raw/migrate.json @@ -3,6 +3,15 @@ "title": "MigrateMsg", "type": "object", "properties": { + "fix_nodes": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/AffectedNode" + } + }, "vesting_contract_address": { "type": [ "string", @@ -10,5 +19,48 @@ ] } }, - "additionalProperties": false + "additionalProperties": false, + "definitions": { + "AffectedDelegator": { + "type": "object", + "required": [ + "address", + "missing_ratio" + ], + "properties": { + "address": { + "type": "string" + }, + "missing_ratio": { + "$ref": "#/definitions/Decimal" + } + }, + "additionalProperties": false + }, + "AffectedNode": { + "type": "object", + "required": [ + "delegators", + "mix_id" + ], + "properties": { + "delegators": { + "type": "array", + "items": { + "$ref": "#/definitions/AffectedDelegator" + } + }, + "mix_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "Decimal": { + "description": "A fixed-point decimal value with 18 fractional digits, i.e. Decimal(1_000_000_000_000_000_000) == 1.0\n\nThe greatest possible value that can be represented is 340282366920938463463.374607431768211455 (which is (2^128 - 1) / 10^18)", + "type": "string" + } + } } diff --git a/contracts/mixnet/schema/raw/query.json b/contracts/mixnet/schema/raw/query.json index c938a56f643..8bfd1346065 100644 --- a/contracts/mixnet/schema/raw/query.json +++ b/contracts/mixnet/schema/raw/query.json @@ -438,7 +438,7 @@ "minimum": 0.0 }, "owner": { - "description": "The address of the owner of the the mixnodes used for the query.", + "description": "The address of the owner of the mixnodes used for the query.", "type": "string" }, "start_after": { diff --git a/contracts/mixnet/src/contract.rs b/contracts/mixnet/src/contract.rs index cda8264afa5..67cabed1526 100644 --- a/contracts/mixnet/src/contract.rs +++ b/contracts/mixnet/src/contract.rs @@ -5,6 +5,7 @@ use crate::constants::{INITIAL_GATEWAY_PLEDGE_AMOUNT, INITIAL_MIXNODE_PLEDGE_AMO use crate::interval::storage as interval_storage; use crate::mixnet_contract_settings::storage as mixnet_params_storage; use crate::mixnodes::storage as mixnode_storage; +use crate::queued_migrations; use crate::rewards::storage as rewards_storage; use cosmwasm_std::{ entry_point, to_binary, Addr, Coin, Deps, DepsMut, Env, MessageInfo, QueryResponse, Response, @@ -272,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 @@ -539,7 +540,7 @@ pub fn query( #[entry_point] pub fn migrate( deps: DepsMut<'_>, - _env: Env, + env: Env, msg: MigrateMsg, ) -> Result { set_build_information!(deps.storage)?; @@ -555,7 +556,13 @@ pub fn migrate( mixnet_params_storage::CONTRACT_STATE.save(deps.storage, ¤t_state)?; } - Ok(Default::default()) + let mut response = Response::new(); + + if let Some(nodes_to_fix) = msg.fix_nodes { + queued_migrations::restore_vested_delegations(&mut response, deps, env, nodes_to_fix)?; + } + + Ok(response) } #[cfg(test)] diff --git a/contracts/mixnet/src/queued_migrations.rs b/contracts/mixnet/src/queued_migrations.rs index a0273196bee..7e7982230f7 100644 --- a/contracts/mixnet/src/queued_migrations.rs +++ b/contracts/mixnet/src/queued_migrations.rs @@ -1,2 +1,669 @@ // Copyright 2022-2024 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 + +use crate::delegations::storage::delegations; +use crate::rewards::storage::MIXNODE_REWARDING; +use cosmwasm_std::{Addr, Decimal, DepsMut, Env, Event, Order, Response}; +use mixnet_contract_common::error::MixnetContractError; +use mixnet_contract_common::helpers::IntoBaseDecimal; +use mixnet_contract_common::rewarding::helpers::truncate_reward; +use mixnet_contract_common::{AffectedNode, Delegation}; +use std::collections::BTreeMap; + +fn fix_affected_node( + response: &mut Response, + deps: DepsMut<'_>, + env: &Env, + node: AffectedNode, +) -> Result<(), MixnetContractError> { + let total_ratio = node.total_ratio(); + let one = Decimal::one(); + + // the total ratio has to be equal to 1 (or be extremely close to it, because it can be affected by rounding) + // if it doesn't it means we passed an invalid migrate msg and we HAVE TO fail the migration if that's the case + let epsilon = Decimal::from_ratio(1u128, 100_000_000u128); + + if total_ratio > one { + if total_ratio - one >= epsilon { + return Err(MixnetContractError::FailedMigration { + comment: format!( + "the total delegation ratio for node {} does not sum up to 1", + node.mix_id + ), + }); + } + } else if one - total_ratio >= epsilon { + return Err(MixnetContractError::FailedMigration { + comment: format!( + "the total delegation ratio for node {} does not sum up to 1", + node.mix_id + ), + }); + } + + let mut total_accounted_for = Decimal::zero(); + let mut mix_rewarding = MIXNODE_REWARDING.load(deps.storage, node.mix_id)?; + + let mut cached_delegations = BTreeMap::new(); + + // determine all the stake accounted for, i.e. all delegations and their pending rewards + for entry in delegations() + .idx + .mixnode + .prefix(node.mix_id) + .range(deps.storage, None, None, Order::Ascending) + .map(|record| record.map(|r| r.1)) + { + let delegation = entry?; + let base_delegation = delegation.dec_amount()?; + let pending_reward = mix_rewarding.determine_delegation_reward(&delegation)?; + + // cache the delegation and reward for the lookup in the next loop + if node + .delegators + .iter() + .any(|d| d.address == delegation.owner.as_str()) + { + cached_delegations.insert(delegation.owner.to_string(), (delegation, pending_reward)); + } + + total_accounted_for += base_delegation; + total_accounted_for += pending_reward; + } + + // sanity check + assert!(cached_delegations.len() <= node.delegators.len()); + + // the missing stake equals to the difference between total node delegation (which includes all rewards, etc.) + // and the value we managed to just account for + let node_missing = mix_rewarding.delegates - total_accounted_for; + + let mut distributed = Decimal::zero(); + + // finally split the missing stake among the affected delegators according to the ratios + // provided in the migration which were very painstakingly determined by scraping different + // sources of chain data + for delegator in node.delegators { + let restored = node_missing * delegator.missing_ratio; + distributed += restored; + + // we have two scenarios to cover here: + // 1. somebody performed vested migration and then undelegated the tokens (*sigh*) + // - in that case we have to create brand-new delegation with the restored amount + // 2. the delegation still exists + // - in that case we have to increase the existing delegation. essentially treat it as if somebody delegated extra tokens + + if let Some((old_liquid_delegation, pending_reward)) = + cached_delegations.remove(&delegator.address) + { + // delegation still exists + + assert!(old_liquid_delegation.proxy.is_none()); + + let old_liquid = old_liquid_delegation.dec_amount()? + pending_reward; + let updated_amount_dec = old_liquid + restored; + let updated_amount = + truncate_reward(updated_amount_dec, &old_liquid_delegation.amount.denom); + + // take the truncation into consideration for the purposes of future accounting + let truncated_delta = updated_amount_dec - updated_amount.amount.into_base_decimal()?; + mix_rewarding.delegates -= truncated_delta; + + // just emit EVERYTHING we can. just in case + response.events.push( + Event::new("delegation_restoration") + .add_attribute("delegator", delegator.address) + .add_attribute("delegator_ratio", delegator.missing_ratio.to_string()) + .add_attribute("mix_id", node.mix_id.to_string()) + .add_attribute("restored_amount_dec", restored.to_string()) + .add_attribute("node_delegates", mix_rewarding.delegates.to_string()) + .add_attribute("total_node_delegations", total_accounted_for.to_string()) + .add_attribute("total_missing_delegations", node_missing.to_string()) + .add_attribute("updated_amount_dec", updated_amount_dec.to_string()) + .add_attribute("updated_amount", updated_amount.to_string()) + .add_attribute("liquid_delegation_existed", "true") + .add_attribute( + "old_liquid_delegation_unit_reward", + old_liquid_delegation.cumulative_reward_ratio.to_string(), + ) + .add_attribute( + "old_liquid_delegation_amount", + old_liquid_delegation.amount.to_string(), + ) + .add_attribute( + "old_liquid_delegation_pending_reward", + pending_reward.to_string(), + ) + .add_attribute("truncated_amount", truncated_delta.to_string()), + ); + + // create new delegation with the updated amount + // and also, what's very important, with correct unit reward amount + let updated_delegation = Delegation::new( + old_liquid_delegation.owner.clone(), + node.mix_id, + mix_rewarding.total_unit_reward, + updated_amount, + env.block.height, + ); + + // replace the value stored under the existing key + let delegation_storage_key = old_liquid_delegation.storage_key(); + delegations().replace( + deps.storage, + delegation_storage_key, + Some(&updated_delegation), + Some(&old_liquid_delegation), + )?; + } else { + let restored_amount = truncate_reward(restored, "unym"); + + // take the truncation into consideration for the purposes of future accounting + let truncated_delta = restored - restored_amount.amount.into_base_decimal()?; + mix_rewarding.delegates -= truncated_delta; + + // delegation is now gone - create a new one with the restored amount + let delegation = Delegation::new( + Addr::unchecked(&delegator.address), + node.mix_id, + mix_rewarding.total_unit_reward, + restored_amount, + env.block.height, + ); + + let delegation_storage_key = delegation.storage_key(); + delegations().save(deps.storage, delegation_storage_key, &delegation)?; + + response.events.push( + Event::new("delegation_restoration") + .add_attribute("delegator", delegator.address) + .add_attribute("delegator_ratio", delegator.missing_ratio.to_string()) + .add_attribute("mix_id", node.mix_id.to_string()) + .add_attribute("restored_amount_dec", restored.to_string()) + .add_attribute("node_delegates", mix_rewarding.delegates.to_string()) + .add_attribute("total_node_delegations", total_accounted_for.to_string()) + .add_attribute("total_missing_delegations", node_missing.to_string()) + .add_attribute("updated_amount_dec", restored.to_string()) + .add_attribute("updated_amount", delegation.amount.to_string()) + .add_attribute("liquid_delegation_existed", "false") + .add_attribute("truncated_amount", truncated_delta.to_string()), + ); + } + + // the vested and liquid delegations got combined into one + mix_rewarding.unique_delegations -= 1; + MIXNODE_REWARDING.save(deps.storage, node.mix_id, &mix_rewarding)?; + } + + response.events.push( + Event::new("node_delegation_restoration") + .add_attribute("mix_id", node.mix_id.to_string()) + .add_attribute("node_delegates", mix_rewarding.delegates.to_string()) + .add_attribute("total_node_delegations", total_accounted_for.to_string()) + .add_attribute("total_missing_delegations", node_missing.to_string()) + .add_attribute("total_redistributed", distributed.to_string()), + ); + + // another sanity check + assert!(distributed <= node_missing); + Ok(()) +} + +pub fn restore_vested_delegations( + response: &mut Response, + mut deps: DepsMut<'_>, + env: Env, + affected_nodes: Vec, +) -> Result<(), MixnetContractError> { + for node in affected_nodes { + fix_affected_node(response, deps.branch(), &env, node)? + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(test)] + mod restoring_vested_delegations { + use super::*; + use crate::support::tests::test_helpers::{assert_eq_with_leeway, TestSetup}; + use crate::vesting_migration::try_migrate_vested_delegation; + use cosmwasm_std::testing::mock_info; + use cosmwasm_std::Uint128; + use mixnet_contract_common::reward_params::Performance; + use mixnet_contract_common::rewarding::helpers::truncate_reward_amount; + use mixnet_contract_common::AffectedDelegator; + use nym_contracts_common::truncate_decimal; + use rand::RngCore; + + #[test] + fn for_node_with_single_affected_delegator_without_undelegating() { + let mut test = TestSetup::new_complex(); + + let problematic_delegator = "n1foomp"; + let problematic_delegator_twin = "n1bar"; + let mix_id = 4; + + // "accidentally" overwrite the delegation + 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 vested_delegation = delegations() + .load(test.deps().storage, vested_storage_key.clone()) + .unwrap(); + let mut bad_liquid_delegation = vested_delegation.clone(); + bad_liquid_delegation.proxy = None; + + delegations() + .remove(test.deps_mut().storage, vested_storage_key) + .unwrap(); + delegations() + .save( + test.deps_mut().storage, + liquid_storage_key, + &bad_liquid_delegation, + ) + .unwrap(); + + // go through few rewarding cycles... + let all_nodes = test.all_mixnodes(); + 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(); + } + + // restoring problematic delegator should be equivalent to the delegator twin just migrating + let env = test.env(); + fix_affected_node( + &mut Response::new(), + test.deps_mut(), + &env, + AffectedNode { + mix_id, + delegators: vec![AffectedDelegator { + address: problematic_delegator.to_string(), + missing_ratio: Decimal::one(), + }], + }, + ) + .unwrap(); + + try_migrate_vested_delegation( + test.deps_mut(), + env, + mock_info(problematic_delegator_twin, &[]), + mix_id, + ) + .unwrap(); + + let liquid_storage_key = Delegation::generate_storage_key( + mix_id, + &Addr::unchecked(problematic_delegator), + None, + ); + let liquid_storage_key_twin = Delegation::generate_storage_key( + mix_id, + &Addr::unchecked(problematic_delegator_twin), + None, + ); + + let liquid_delegation = delegations() + .load(test.deps().storage, liquid_storage_key) + .unwrap(); + let liquid_delegation_alt = delegations() + .load(test.deps().storage, liquid_storage_key_twin) + .unwrap(); + assert_eq!( + liquid_delegation.cumulative_reward_ratio, + liquid_delegation_alt.cumulative_reward_ratio + ); + assert_eq_with_leeway( + liquid_delegation.amount.amount, + liquid_delegation_alt.amount.amount, + Uint128::one(), + ); + } + + #[test] + fn for_node_with_single_affected_delegator_after_undelegating() { + let mut test = TestSetup::new_complex(); + + let problematic_delegator = "n1foomp"; + let problematic_delegator_twin = "n1bar"; + let mix_id = 4; + + // "accidentally" overwrite the delegation + 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 vested_delegation = delegations() + .load(test.deps().storage, vested_storage_key.clone()) + .unwrap(); + let mut bad_liquid_delegation = vested_delegation.clone(); + bad_liquid_delegation.proxy = None; + + delegations() + .remove(test.deps_mut().storage, vested_storage_key) + .unwrap(); + delegations() + .save( + test.deps_mut().storage, + liquid_storage_key, + &bad_liquid_delegation, + ) + .unwrap(); + + // go through few rewarding cycles... + let all_nodes = test.all_mixnodes(); + 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(); + } + + // they got scared and undelegated (the removed part is their vested delegation) + test.remove_immediate_delegation(problematic_delegator, mix_id); + + // go through some more rewarding + 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(); + } + + // the restored amount should be equivalent to the liquid part (+ rewards) of the twin delegator + let env = test.env(); + fix_affected_node( + &mut Response::new(), + test.deps_mut(), + &env, + AffectedNode { + mix_id, + delegators: vec![AffectedDelegator { + address: problematic_delegator.to_string(), + missing_ratio: Decimal::one(), + }], + }, + ) + .unwrap(); + + let liquid_storage_key = Delegation::generate_storage_key( + mix_id, + &Addr::unchecked(problematic_delegator), + None, + ); + let liquid_storage_key_twin = Delegation::generate_storage_key( + mix_id, + &Addr::unchecked(problematic_delegator_twin), + None, + ); + + let liquid_delegation = delegations() + .load(test.deps().storage, liquid_storage_key) + .unwrap(); + let liquid_delegation_alt = delegations() + .load(test.deps().storage, liquid_storage_key_twin) + .unwrap(); + let mix_info = test.mix_rewarding(mix_id); + let pending_twin_reward = mix_info + .determine_delegation_reward(&liquid_delegation_alt) + .unwrap(); + + assert_eq!( + liquid_delegation.cumulative_reward_ratio, + mix_info.total_unit_reward + ); + assert_eq_with_leeway( + liquid_delegation.amount.amount, + liquid_delegation_alt.amount.amount + truncate_reward_amount(pending_twin_reward), + Uint128::one(), + ); + } + + #[test] + fn for_node_with_multiple_affected_delegators() { + let mut test = TestSetup::new_complex(); + + // some random delegator + let problematic_delegator = "n1foomp"; + + // another delegator that made DIFFERENT delegations as the previous ones BUT to the same node + let problematic_delegator_alt_twin = "n1whatever"; + + let mix_id = 4; + let mix_info_start = test.mix_rewarding(mix_id); + + // "accidentally" overwrite the delegations + let liquid_storage_key1 = Delegation::generate_storage_key( + mix_id, + &Addr::unchecked(problematic_delegator), + None, + ); + let vested_storage_key1 = Delegation::generate_storage_key( + mix_id, + &Addr::unchecked(problematic_delegator), + Some(&test.vesting_contract()), + ); + let liquid_delegation1 = delegations() + .load(test.deps().storage, liquid_storage_key1.clone()) + .unwrap(); + let vested_delegation1 = delegations() + .load(test.deps().storage, vested_storage_key1.clone()) + .unwrap(); + + // keep track of the 'lost' tokens for test assertions + let lost1 = liquid_delegation1.dec_amount().unwrap() + + mix_info_start + .determine_delegation_reward(&liquid_delegation1) + .unwrap(); + + let mut bad_liquid_delegation1 = vested_delegation1.clone(); + bad_liquid_delegation1.proxy = None; + + delegations() + .remove(test.deps_mut().storage, vested_storage_key1) + .unwrap(); + delegations() + .save( + test.deps_mut().storage, + liquid_storage_key1.clone(), + &bad_liquid_delegation1, + ) + .unwrap(); + + let liquid_storage_key2 = Delegation::generate_storage_key( + mix_id, + &Addr::unchecked(problematic_delegator_alt_twin), + None, + ); + let vested_storage_key2 = Delegation::generate_storage_key( + mix_id, + &Addr::unchecked(problematic_delegator_alt_twin), + Some(&test.vesting_contract()), + ); + let liquid_delegation2 = delegations() + .load(test.deps().storage, liquid_storage_key2.clone()) + .unwrap(); + let vested_delegation2 = delegations() + .load(test.deps().storage, vested_storage_key2.clone()) + .unwrap(); + let lost2 = liquid_delegation2.dec_amount().unwrap() + + mix_info_start + .determine_delegation_reward(&liquid_delegation2) + .unwrap(); + + let mut bad_liquid_delegation2 = vested_delegation2.clone(); + bad_liquid_delegation2.proxy = None; + + delegations() + .remove(test.deps_mut().storage, vested_storage_key2) + .unwrap(); + delegations() + .save( + test.deps_mut().storage, + liquid_storage_key2.clone(), + &bad_liquid_delegation2, + ) + .unwrap(); + + // go through few rewarding cycles... + let all_nodes = test.all_mixnodes(); + + 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(); + } + + // those ratios got determined externally. in this test we unfortunately use purely artificial values + let ratio1: Decimal = "0.45326524362".parse().unwrap(); + let ratio2 = Decimal::one() - ratio1; + + let mix_info = test.mix_rewarding(mix_id); + let liquid_delegation_before = delegations() + .load(test.deps().storage, liquid_storage_key1.clone()) + .unwrap(); + let liquid_reward_before = mix_info + .determine_delegation_reward(&liquid_delegation_before) + .unwrap(); + + let liquid_delegation_alt_before = delegations() + .load(test.deps().storage, liquid_storage_key2.clone()) + .unwrap(); + let liquid_reward_alt_before = mix_info + .determine_delegation_reward(&liquid_delegation_alt_before) + .unwrap(); + + let env = test.env(); + let mut res = Response::new(); + fix_affected_node( + &mut res, + test.deps_mut(), + &env, + AffectedNode { + mix_id, + delegators: vec![ + AffectedDelegator { + address: problematic_delegator.to_string(), + missing_ratio: ratio1, + }, + AffectedDelegator { + address: problematic_delegator_alt_twin.to_string(), + missing_ratio: ratio2, + }, + ], + }, + ) + .unwrap(); + + let liquid_delegation = delegations() + .load(test.deps().storage, liquid_storage_key1) + .unwrap(); + let liquid_delegation_alt = delegations() + .load(test.deps().storage, liquid_storage_key2) + .unwrap(); + + // the total amount recovered must be equal to what has been lost (approximately) + let total_lost = lost1 + lost2; + // determine the compounded rewards on the lost tokens + // (just unroll `MixNodeRewarding::determine_delegation_reward(...)`) + let starting_ratio = mix_info_start.total_unit_reward; + let ending_ratio = mix_info.full_reward_ratio(); + let adjust = starting_ratio + mix_info.unit_delegation; + let compounded_lost_reward = (ending_ratio - starting_ratio) * total_lost / adjust; + + let before = liquid_delegation_before.dec_amount().unwrap() + + liquid_delegation_alt_before.dec_amount().unwrap() + + liquid_reward_before + + liquid_reward_alt_before; + + let after = liquid_delegation.amount.amount + liquid_delegation_alt.amount.amount; + let expected_before = truncate_decimal(total_lost + compounded_lost_reward + before); + + assert_eq_with_leeway(after, expected_before, Uint128::one()); + + test.ensure_delegation_sync(mix_id); + + // more rewarding + 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(); + } + + test.ensure_delegation_sync(mix_id); + } + } +} diff --git a/contracts/mixnet/src/support/tests/mod.rs b/contracts/mixnet/src/support/tests/mod.rs index dee121b2f07..cc535b1382c 100644 --- a/contracts/mixnet/src/support/tests/mod.rs +++ b/contracts/mixnet/src/support/tests/mod.rs @@ -12,6 +12,7 @@ pub mod test_helpers { use crate::contract::instantiate; use crate::delegations::queries::query_mixnode_delegations_paged; use crate::delegations::storage as delegations_storage; + use crate::delegations::storage::delegations; use crate::delegations::transactions::try_delegate_to_mixnode; use crate::families::transactions::{try_create_family, try_join_family}; use crate::gateways::transactions::try_add_gateway; @@ -25,7 +26,7 @@ pub mod test_helpers { rewarding_validator_address, }; use crate::mixnodes::storage as mixnodes_storage; - use crate::mixnodes::storage::mixnode_bonds; + use crate::mixnodes::storage::{assign_layer, mixnode_bonds, next_mixnode_id_counter}; use crate::mixnodes::transactions::{try_add_mixnode, try_remove_mixnode}; 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}; @@ -52,6 +53,7 @@ pub mod test_helpers { may_find_attribute, MixnetEventType, DELEGATES_REWARD_KEY, OPERATOR_REWARD_KEY, }; use mixnet_contract_common::families::FamilyHead; + use mixnet_contract_common::helpers::compare_decimals; use mixnet_contract_common::mixnode::{MixNodeRewarding, UnbondedMixnode}; use mixnet_contract_common::pending_events::{PendingEpochEventData, PendingIntervalEventData}; use mixnet_contract_common::reward_params::{Performance, RewardingParams}; @@ -69,19 +71,23 @@ 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; use std::time::Duration; + #[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 { @@ -120,6 +126,133 @@ 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_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, + ); + } + + 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(); + 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 + } + + #[track_caller] + pub fn ensure_delegation_sync(&self, mix_id: MixId) { + 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() } @@ -146,6 +279,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()) } @@ -266,6 +413,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 +667,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 +901,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 +1019,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 3faf826ec05..0cffa63f7d6 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); + 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.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,354 @@ 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::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::reward_params::Performance; + use mixnet_contract_common::rewarding::helpers::truncate_reward; + 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.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 = 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.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() { + 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(); + 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 + 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())) + } + } +} diff --git a/tools/internal/testnet-manager/src/manager/network_init.rs b/tools/internal/testnet-manager/src/manager/network_init.rs index 568632f7345..38135424e12 100644 --- a/tools/internal/testnet-manager/src/manager/network_init.rs +++ b/tools/internal/testnet-manager/src/manager/network_init.rs @@ -108,6 +108,7 @@ impl NetworkManager { ) -> Result { Ok(nym_mixnet_contract_common::MigrateMsg { vesting_contract_address: Some(ctx.network.contracts.vesting.address()?.to_string()), + fix_nodes: None, }) }