Compare commits

...

7 Commits

Author SHA1 Message Date
Jędrzej Stuczyński f16704a0c5 ci: update 'publish-nym-contracts' runner 2024-10-15 15:08:52 +01:00
Jędrzej Stuczyński 66e4021530 fixed the old integration test struct 2024-10-09 15:24:33 +01:00
Jędrzej Stuczyński 1f089ca6ad regenerated contract schema and made the migration data optional 2024-10-09 15:07:23 +01:00
Jędrzej Stuczyński ae30b0d803 added unit tests for the migration and fixed rounding errors 2024-10-09 14:52:39 +01:00
Jędrzej Stuczyński 2cdf2ec0c9 added unit tests for vested migrations and fixed additional issues 2024-10-09 11:46:36 +01:00
Jędrzej Stuczyński 2eb1862eaa account for operators who have undelegated since 2024-10-09 09:39:44 +01:00
Jędrzej Stuczyński 4383aa84b8 restoring lost delegations 2024-10-08 17:00:28 +01:00
14 changed files with 1575 additions and 32 deletions
@@ -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:
@@ -3,12 +3,13 @@
use cosmwasm_std::{Decimal, StdError, StdResult, Uint128};
#[track_caller]
pub fn compare_decimals(a: Decimal, b: Decimal, epsilon: Option<Decimal>) {
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)
}
}
@@ -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<AffectedDelegator>,
}
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<String>,
pub fix_nodes: Option<Vec<AffectedNode>>,
}
+4 -2
View File
@@ -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",
]
@@ -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,
)
+1
View File
@@ -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]
@@ -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": {
+53 -1
View File
@@ -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"
}
}
}
+1 -1
View File
@@ -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": {
+10 -3
View File
@@ -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<Response, MixnetContractError> {
set_build_information!(deps.storage)?;
@@ -555,7 +556,13 @@ pub fn migrate(
mixnet_params_storage::CONTRACT_STATE.save(deps.storage, &current_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)]
+667
View File
@@ -1,2 +1,669 @@
// Copyright 2022-2024 - Nym Technologies SA <contact@nymtech.net>
// 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<AffectedNode>,
) -> 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);
}
}
}
+275 -4
View File
@@ -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<MixId> {
mixnode_bonds()
.range(self.deps().storage, None, None, Order::Ascending)
.filter_map(|m| m.map(|(_, node)| node.mix_id).ok())
.collect::<Vec<_>>()
}
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<Uint128>,
) -> (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<Uint128>,
) -> MixId {
self.add_dummy_mixnode_with_proxy_and_keypair(owner, stake)
.0
}
pub fn add_dummy_gateway(&mut self, sender: &str, stake: Option<Uint128>) -> 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<Uint128>,
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<S: Into<String>>(
event_type: Option<S>,
attribute: &str,
+484 -15
View File
@@ -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<Response, MixnetContractError> {
let mut response = Response::new();
ensure_epoch_in_progress_state(deps.storage)?;
let vesting_contract = mixnet_params_storage::vesting_contract_address(deps.storage)?;
let storage_key =
Delegation::generate_storage_key(mix_id, &info.sender, Some(&vesting_contract));
let Some(mut delegation) =
let Some(vested_delegation) =
delegations_storage::delegations().may_load(deps.storage, storage_key.clone())?
else {
return Err(MixnetContractError::NotAVestingDelegation);
};
// sanity check that's meant to blow up the contract
assert_eq!(delegation.proxy, Some(vesting_contract.clone()));
assert_eq!(vested_delegation.proxy, Some(vesting_contract.clone()));
// update the delegation and save it under the correct storage key
delegation.proxy = None;
let updated_storage_key = Delegation::generate_storage_key(mix_id, &info.sender, None);
delegations_storage::delegations().remove(deps.storage, storage_key)?;
delegations_storage::delegations().save(deps.storage, updated_storage_key, &delegation)?;
let mut updated_delegation = vested_delegation.clone();
updated_delegation.proxy = None;
Ok(Response::new().add_message(wasm_execute(
let new_storage_key = Delegation::generate_storage_key(mix_id, &info.sender, None);
// remove the old (vested) delegation
delegations_storage::delegations().remove(deps.storage, storage_key)?;
// check if there was already a delegation present under that key (i.e. an old liquid one)
if let Some(existing_liquid_delegation) =
delegations_storage::delegations().may_load(deps.storage, new_storage_key.clone())?
{
// treat it as adding extra stake to the existing delegation, so we need to update the unit reward value
// as well as retrieve any pending rewards
// it replicates part of code from `pending_events::delegate`,
// but without some checks that'd be redundant in this instance
let mut mix_rewarding =
rewards_storage::MIXNODE_REWARDING.load(deps.storage, vested_delegation.mix_id)?;
// calculate rewards separately for the purposes of emitting those in events
let pending_liquid_reward =
mix_rewarding.determine_delegation_reward(&existing_liquid_delegation)?;
let pending_vested_reward =
mix_rewarding.determine_delegation_reward(&vested_delegation)?;
// the calls to 'undelegate' followed by artificial delegate are performed
// to keep the internal `.delegates` field in sync
// (this is due to the fact delegation only holds values up in `Uint128` and lacks the precision of a `Decimal`
// which has to be used for reward accounting)
let liquid_delegation_with_reward =
mix_rewarding.undelegate(&existing_liquid_delegation)?;
let vested_delegation_with_reward = mix_rewarding.undelegate(&vested_delegation)?;
// updated delegation amount consists of:
// - delegated vested tokens
// - delegated liquid tokens
// - pending rewards earned by the delegated vested tokens
// - pending rewards earned by the delegated liquid tokens
let mut updated_total = liquid_delegation_with_reward.clone();
updated_total.amount += vested_delegation_with_reward.amount;
mix_rewarding.add_base_delegation(updated_total.amount)?;
updated_delegation.amount = updated_total;
updated_delegation.height = env.block.height;
updated_delegation.cumulative_reward_ratio = mix_rewarding.total_unit_reward;
rewards_storage::MIXNODE_REWARDING.save(
deps.storage,
vested_delegation.mix_id,
&mix_rewarding,
)?;
// replace the old delegation with the new one
delegations_storage::delegations().replace(
deps.storage,
new_storage_key,
Some(&updated_delegation),
Some(&existing_liquid_delegation),
)?;
// just emit EVERYTHING we can. just in case
response.events.push(
Event::new("migrate-vested-delegation")
.add_attribute("mix_id", mix_id.to_string())
.add_attribute("existing_liquid", "true")
.add_attribute(
"old_vested_unit_reward",
vested_delegation.cumulative_reward_ratio.to_string(),
)
.add_attribute(
"old_vested_delegation_amount",
vested_delegation.amount.to_string(),
)
.add_attribute(
"old_liquid_unit_reward",
existing_liquid_delegation
.cumulative_reward_ratio
.to_string(),
)
.add_attribute(
"old_liquid_delegation_amount",
existing_liquid_delegation.amount.to_string(),
)
.add_attribute(
"new_unit_reward",
updated_delegation.cumulative_reward_ratio.to_string(),
)
.add_attribute(
"new_delegation_amount",
updated_delegation.amount.to_string(),
)
.add_attribute("applied_liquid_reward", pending_liquid_reward.to_string())
.add_attribute("applied_vested_reward", pending_vested_reward.to_string()),
)
} else {
// otherwise, this is as simple as resaving the updated value under the new key
delegations_storage::delegations().save(
deps.storage,
new_storage_key,
&updated_delegation,
)?;
response.events.push(
Event::new("migrate-vested-delegation")
.add_attribute("mix_id", mix_id.to_string())
.add_attribute("existing_liquid", "false")
.add_attribute(
"old_vested_unit_reward",
vested_delegation.cumulative_reward_ratio.to_string(),
)
.add_attribute(
"old_vested_delegation_amount",
vested_delegation.amount.to_string(),
),
)
}
Ok(response.add_message(wasm_execute(
vesting_contract,
&VestingExecuteMsg::TrackMigratedDelegation {
owner: info.sender.into_string(),
@@ -93,3 +211,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::<VestingExecuteMsg>(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::<VestingExecuteMsg>(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::<VestingExecuteMsg>(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(&current_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()))
}
}
}
@@ -108,6 +108,7 @@ impl NetworkManager {
) -> Result<nym_mixnet_contract_common::MigrateMsg, NetworkManagerError> {
Ok(nym_mixnet_contract_common::MigrateMsg {
vesting_contract_address: Some(ctx.network.contracts.vesting.address()?.to_string()),
fix_nodes: None,
})
}