From cfcb64f7e5bd6fc9e80af8e982645cb82fe819f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Thu, 20 Apr 2023 07:52:10 +0100 Subject: [PATCH] Feature/reduce pledge (#3254) * basic contract work for 'decrease_pledge' functionality note: it doesn't yet return tokens back to the operator * returning extra tokens after decreasing pledge * added vesting message to track pledge decrease * attaching the track message when processing delegation decrease * checking for zero value request * fixed event test * allowing to decrease pledge from the vesting contract * integration test for the feature * reorganised the integration tests * updated nyxd client traits * wallet support * typescript helpers * moved 'pledge more' functionality to operator commands * cli commands for decreasing pledge * changed error variant to make clippy happier * removed unused import * eslint * fixed post-rebase imports * added cargo config * added PendingMixNodeChanges to MixNodeDetails * returning event id after creating it * Streamlined getting mixnode details by identity key * setting pending pledge changes on increase/decrease * clearing the value on resolving the event * checking for correct invariants when clearing events * further pending events unit tests fixes * new unit tests for tx endpoints * queries for pending events (by id) * migration code * using default value for pending changes if unavailable * improved integration test assertions --- .../src/nyxd/traits/mixnet_query_client.rs | 35 +- .../src/nyxd/traits/mixnet_signing_client.rs | 32 + .../src/nyxd/traits/vesting_signing_client.rs | 15 + .../src/validator/mixnet/delegators/mod.rs | 6 - .../operators/mixnode/decrease_pledge.rs | 29 + .../validator/mixnet/operators/mixnode/mod.rs | 12 + .../mixnode}/pledge_more.rs | 0 .../mixnode/vesting_decrease_pledge.rs | 29 + .../mixnode}/vesting_pledge_more.rs | 0 .../mixnet-contract/src/error.rs | 31 +- .../mixnet-contract/src/events.rs | 17 + .../mixnet-contract/src/interval.rs | 29 +- .../mixnet-contract/src/lib.rs | 3 +- .../mixnet-contract/src/mixnode.rs | 50 +- .../mixnet-contract/src/msg.rs | 24 +- .../mixnet-contract/src/pending_events.rs | 6 +- .../vesting-contract/src/events.rs | 10 + .../vesting-contract/src/messages.rs | 9 + common/types/src/pending_events.rs | 11 + contracts/.cargo/config.toml | 6 + contracts/Cargo.lock | 16 + contracts/Cargo.toml | 1 + .../Cargo.toml | 32 + .../src/decrease_mixnode_pledge.rs | 240 ++++++ .../src/support/fixtures.rs | 28 + .../src/support/helpers.rs | 56 ++ .../src/support/mod.rs | 6 + .../src/support/setup.rs | 328 +++++++++ .../src/tests.rs | 5 + contracts/mixnet/src/constants.rs | 3 +- contracts/mixnet/src/contract.rs | 21 +- .../mixnet/src/interval/pending_events.rs | 649 ++++++++++++++-- contracts/mixnet/src/interval/queries.rs | 102 ++- contracts/mixnet/src/interval/storage.rs | 71 +- contracts/mixnet/src/mixnodes/helpers.rs | 235 ++++-- contracts/mixnet/src/mixnodes/queries.rs | 35 +- contracts/mixnet/src/mixnodes/storage.rs | 19 +- contracts/mixnet/src/mixnodes/transactions.rs | 695 ++++++++++++++++-- contracts/mixnet/src/queued_migrations.rs | 40 + contracts/mixnet/src/rewards/transactions.rs | 96 ++- contracts/mixnet/src/support/helpers.rs | 45 ++ contracts/mixnet/src/support/tests/mod.rs | 19 +- contracts/vesting/src/contract.rs | 4 + contracts/vesting/src/errors.rs | 5 +- contracts/vesting/src/lib.rs | 2 +- contracts/vesting/src/storage.rs | 20 +- .../vesting/src/traits/bonding_account.rs | 12 + contracts/vesting/src/transactions.rs | 33 +- .../account/mixnode_bonding_account.rs | 54 +- contracts/vesting/src/vesting/account/mod.rs | 16 +- nym-wallet/src-tauri/src/main.rs | 2 + .../src-tauri/src/operations/mixnet/bond.rs | 27 + .../src-tauri/src/operations/vesting/bond.rs | 27 + nym-wallet/src/requests/actions.ts | 4 + nym-wallet/src/requests/vesting.ts | 6 + nym-wallet/src/types/global.ts | 5 + .../src/validator/mixnet/delegators/mod.rs | 6 - .../mixnet/operators/mixnodes/mod.rs | 12 + 58 files changed, 3021 insertions(+), 310 deletions(-) create mode 100644 common/commands/src/validator/mixnet/operators/mixnode/decrease_pledge.rs rename common/commands/src/validator/mixnet/{delegators => operators/mixnode}/pledge_more.rs (100%) create mode 100644 common/commands/src/validator/mixnet/operators/mixnode/vesting_decrease_pledge.rs rename common/commands/src/validator/mixnet/{delegators => operators/mixnode}/vesting_pledge_more.rs (100%) create mode 100644 contracts/.cargo/config.toml create mode 100644 contracts/mixnet-vesting-integration-tests/Cargo.toml create mode 100644 contracts/mixnet-vesting-integration-tests/src/decrease_mixnode_pledge.rs create mode 100644 contracts/mixnet-vesting-integration-tests/src/support/fixtures.rs create mode 100644 contracts/mixnet-vesting-integration-tests/src/support/helpers.rs create mode 100644 contracts/mixnet-vesting-integration-tests/src/support/mod.rs create mode 100644 contracts/mixnet-vesting-integration-tests/src/support/setup.rs create mode 100644 contracts/mixnet-vesting-integration-tests/src/tests.rs diff --git a/common/client-libs/validator-client/src/nyxd/traits/mixnet_query_client.rs b/common/client-libs/validator-client/src/nyxd/traits/mixnet_query_client.rs index d514bf3f4e..84e4defaee 100644 --- a/common/client-libs/validator-client/src/nyxd/traits/mixnet_query_client.rs +++ b/common/client-libs/validator-client/src/nyxd/traits/mixnet_query_client.rs @@ -24,8 +24,9 @@ use nym_mixnet_contract_common::{ MixOwnershipResponse, MixnodeDetailsResponse, NumberOfPendingEventsResponse, PagedAllDelegationsResponse, PagedDelegatorDelegationsResponse, PagedFamiliesResponse, PagedGatewayResponse, PagedMembersResponse, PagedMixNodeDelegationsResponse, - PagedMixnodeBondsResponse, PagedRewardedSetResponse, PendingEpochEventsResponse, - PendingIntervalEventsResponse, QueryMsg as MixnetQueryMsg, + PagedMixnodeBondsResponse, PagedRewardedSetResponse, PendingEpochEventResponse, + PendingEpochEventsResponse, PendingIntervalEventResponse, PendingIntervalEventsResponse, + QueryMsg as MixnetQueryMsg, }; use serde::Deserialize; @@ -174,6 +175,16 @@ pub trait MixnetQueryClient { .await } + async fn get_mixnode_details_by_identity( + &self, + mix_identity: IdentityKey, + ) -> Result, NyxdError> { + self.query_mixnet_contract(MixnetQueryMsg::GetBondedMixnodeDetailsByIdentity { + mix_identity, + }) + .await + } + async fn get_mixnode_rewarding_details( &self, mix_id: MixId, @@ -374,14 +385,20 @@ pub trait MixnetQueryClient { .await } - async fn get_mixnode_details_by_identity( + async fn get_pending_epoch_event( &self, - mix_identity: IdentityKey, - ) -> Result, NyxdError> { - self.query_mixnet_contract(MixnetQueryMsg::GetBondedMixnodeDetailsByIdentity { - mix_identity, - }) - .await + event_id: EpochEventId, + ) -> Result { + self.query_mixnet_contract(MixnetQueryMsg::GetPendingEpochEvent { event_id }) + .await + } + + async fn get_pending_interval_event( + &self, + event_id: IntervalEventId, + ) -> Result { + self.query_mixnet_contract(MixnetQueryMsg::GetPendingIntervalEvent { event_id }) + .await } async fn get_number_of_pending_events( diff --git a/common/client-libs/validator-client/src/nyxd/traits/mixnet_signing_client.rs b/common/client-libs/validator-client/src/nyxd/traits/mixnet_signing_client.rs index 24747650fe..49fc651d5d 100644 --- a/common/client-libs/validator-client/src/nyxd/traits/mixnet_signing_client.rs +++ b/common/client-libs/validator-client/src/nyxd/traits/mixnet_signing_client.rs @@ -331,6 +331,38 @@ pub trait MixnetSigningClient { .await } + async fn decrease_pledge( + &self, + decrease_by: Coin, + fee: Option, + ) -> Result { + self.execute_mixnet_contract( + fee, + MixnetExecuteMsg::DecreasePledge { + decrease_by: decrease_by.into(), + }, + vec![], + ) + .await + } + + async fn decrease_pledge_on_behalf( + &self, + owner: AccountId, + decrease_by: Coin, + fee: Option, + ) -> Result { + self.execute_mixnet_contract( + fee, + MixnetExecuteMsg::DecreasePledgeOnBehalf { + owner: owner.to_string(), + decrease_by: decrease_by.into(), + }, + vec![], + ) + .await + } + async fn unbond_mixnode(&self, fee: Option) -> Result { self.execute_mixnet_contract(fee, MixnetExecuteMsg::UnbondMixnode {}, vec![]) .await diff --git a/common/client-libs/validator-client/src/nyxd/traits/vesting_signing_client.rs b/common/client-libs/validator-client/src/nyxd/traits/vesting_signing_client.rs index bf50d01d54..aba0d219cf 100644 --- a/common/client-libs/validator-client/src/nyxd/traits/vesting_signing_client.rs +++ b/common/client-libs/validator-client/src/nyxd/traits/vesting_signing_client.rs @@ -91,6 +91,21 @@ pub trait VestingSigningClient { .await } + async fn vesting_decrease_pledge( + &self, + decrease_by: Coin, + fee: Option, + ) -> Result { + self.execute_vesting_contract( + fee, + VestingExecuteMsg::DecreasePledge { + amount: decrease_by.into(), + }, + vec![], + ) + .await + } + async fn vesting_unbond_mixnode(&self, fee: Option) -> Result; async fn vesting_track_unbond_mixnode( diff --git a/common/commands/src/validator/mixnet/delegators/mod.rs b/common/commands/src/validator/mixnet/delegators/mod.rs index 1fa955c5f7..61e87f119d 100644 --- a/common/commands/src/validator/mixnet/delegators/mod.rs +++ b/common/commands/src/validator/mixnet/delegators/mod.rs @@ -6,11 +6,9 @@ use clap::{Args, Subcommand}; pub mod rewards; pub mod delegate_to_mixnode; -pub mod pledge_more; pub mod query_for_delegations; pub mod undelegate_from_mixnode; pub mod vesting_delegate_to_mixnode; -pub mod vesting_pledge_more; pub mod vesting_undelegate_from_mixnode; #[derive(Debug, Args)] @@ -34,8 +32,4 @@ pub enum MixnetDelegatorsCommands { DelegateVesting(vesting_delegate_to_mixnode::Args), /// Undelegate from a mixnode (when originally using locked tokens) UndelegateVesting(vesting_undelegate_from_mixnode::Args), - /// Pledge more - PledgeMore(pledge_more::Args), - /// Pledge more with locked tokens - PledgeMoreVesting(vesting_pledge_more::Args), } diff --git a/common/commands/src/validator/mixnet/operators/mixnode/decrease_pledge.rs b/common/commands/src/validator/mixnet/operators/mixnode/decrease_pledge.rs new file mode 100644 index 0000000000..969e40024a --- /dev/null +++ b/common/commands/src/validator/mixnet/operators/mixnode/decrease_pledge.rs @@ -0,0 +1,29 @@ +// Copyright 2023 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::context::SigningClient; +use clap::Parser; +use log::info; +use nym_mixnet_contract_common::Coin; +use nym_validator_client::nyxd::traits::MixnetSigningClient; + +#[derive(Debug, Parser)] +pub struct Args { + #[clap(long)] + pub decrease_by: u128, +} + +pub async fn decrease_pledge(args: Args, client: SigningClient) { + let denom = client.current_chain_details().mix_denom.base.as_str(); + + info!("Starting to decrease pledge"); + + let coin = Coin::new(args.decrease_by, denom); + + let res = client + .pledge_more(coin.into(), None) + .await + .expect("failed to decrease pledge!"); + + info!("decreasing pledge: {:?}", res); +} diff --git a/common/commands/src/validator/mixnet/operators/mixnode/mod.rs b/common/commands/src/validator/mixnet/operators/mixnode/mod.rs index c3440c6e67..6e5283d77e 100644 --- a/common/commands/src/validator/mixnet/operators/mixnode/mod.rs +++ b/common/commands/src/validator/mixnet/operators/mixnode/mod.rs @@ -4,13 +4,17 @@ use clap::{Args, Subcommand}; pub mod bond_mixnode; +pub mod decrease_pledge; pub mod families; pub mod keys; pub mod mixnode_bonding_sign_payload; +pub mod pledge_more; pub mod rewards; pub mod settings; pub mod unbond_mixnode; pub mod vesting_bond_mixnode; +pub mod vesting_decrease_pledge; +pub mod vesting_pledge_more; pub mod vesting_unbond_mixnode; #[derive(Debug, Args)] @@ -40,4 +44,12 @@ pub enum MixnetOperatorsMixnodeCommands { UnbondVesting(vesting_unbond_mixnode::Args), /// Create base58-encoded payload required for producing valid bonding signature. CreateMixnodeBondingSignPayload(mixnode_bonding_sign_payload::Args), + /// Pledge more + PledgeMore(pledge_more::Args), + /// Pledge more with locked tokens + PledgeMoreVesting(vesting_pledge_more::Args), + /// Decrease pledge + DecreasePledge(decrease_pledge::Args), + /// Decrease pledge with locked tokens + DecreasePledgeVesting(vesting_decrease_pledge::Args), } diff --git a/common/commands/src/validator/mixnet/delegators/pledge_more.rs b/common/commands/src/validator/mixnet/operators/mixnode/pledge_more.rs similarity index 100% rename from common/commands/src/validator/mixnet/delegators/pledge_more.rs rename to common/commands/src/validator/mixnet/operators/mixnode/pledge_more.rs diff --git a/common/commands/src/validator/mixnet/operators/mixnode/vesting_decrease_pledge.rs b/common/commands/src/validator/mixnet/operators/mixnode/vesting_decrease_pledge.rs new file mode 100644 index 0000000000..131e210646 --- /dev/null +++ b/common/commands/src/validator/mixnet/operators/mixnode/vesting_decrease_pledge.rs @@ -0,0 +1,29 @@ +// Copyright 2023 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::context::SigningClient; +use clap::Parser; +use log::info; +use nym_mixnet_contract_common::Coin; +use nym_validator_client::nyxd::VestingSigningClient; + +#[derive(Debug, Parser)] +pub struct Args { + #[clap(long)] + pub decrease_by: u128, +} + +pub async fn vesting_decrease_pledge(args: Args, client: SigningClient) { + let denom = client.current_chain_details().mix_denom.base.as_str(); + + info!("Starting vesting to decrease pledge"); + + let coin = Coin::new(args.decrease_by, denom); + + let res = client + .vesting_decrease_pledge(coin.into(), None) + .await + .expect("failed to vesting decrease pledge!"); + + info!("vesting decreasing pledge: {:?}", res); +} diff --git a/common/commands/src/validator/mixnet/delegators/vesting_pledge_more.rs b/common/commands/src/validator/mixnet/operators/mixnode/vesting_pledge_more.rs similarity index 100% rename from common/commands/src/validator/mixnet/delegators/vesting_pledge_more.rs rename to common/commands/src/validator/mixnet/operators/mixnode/vesting_pledge_more.rs diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/error.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/error.rs index d6b8ba07d5..0b572183ac 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/error.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/error.rs @@ -1,13 +1,16 @@ -// Copyright 2022 - Nym Technologies SA +// Copyright 2022-2023 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use crate::{EpochState, IdentityKey, MixId}; +use crate::{EpochEventId, EpochState, IdentityKey, MixId}; use contracts_common::signing::verifier::ApiVerifierError; -use cosmwasm_std::{Addr, Coin, Decimal}; +use cosmwasm_std::{Addr, Coin, Decimal, Uint128}; use thiserror::Error; #[derive(Error, Debug, PartialEq)] pub enum MixnetContractError { + #[error("could not perform contract migration: {comment}")] + FailedMigration { comment: String }, + #[error("{source}")] StdErr { #[from] @@ -26,6 +29,17 @@ pub enum MixnetContractError { #[error("Not enough funds sent for node pledge. (received {received}, minimum {minimum})")] InsufficientPledge { received: Coin, minimum: Coin }, + #[error("Attempted to reduce node pledge ({current}{denom} - {decrease_by}{denom}) below the minimum amount: {minimum}{denom}")] + InvalidPledgeReduction { + current: Uint128, + decrease_by: Uint128, + minimum: Uint128, + denom: String, + }, + + #[error("A pledge change is already pending in this epoch. The event id: {pending_event_id}")] + PendingPledgeChange { pending_event_id: EpochEventId }, + #[error("Not enough funds sent for node delegation. (received {received}, minimum {minimum})")] InsufficientDelegation { received: Coin, minimum: Coin }, @@ -190,6 +204,9 @@ pub enum MixnetContractError { #[error("epoch duration must be > 0")] EpochDurationZero, + #[error("attempted to perform the operation with 0 coins. This is not allowed")] + ZeroCoinAmount, + #[error("this validator ({current_validator}) is not the one responsible for advancing this epoch. It's responsibility of {chosen_validator}.")] RewardingValidatorMismatch { current_validator: Addr, @@ -226,3 +243,11 @@ pub enum MixnetContractError { source: ApiVerifierError, }, } + +impl MixnetContractError { + pub fn inconsistent_state>(comment: S) -> Self { + MixnetContractError::InconsistentState { + comment: comment.into(), + } + } +} diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/events.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/events.rs index e61e26adb2..67b4ffd96d 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/events.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/events.rs @@ -15,6 +15,8 @@ pub enum MixnetEventType { MixnodeBonding, PendingPledgeIncrease, PledgeIncrease, + PendingPledgeDecrease, + PledgeDecrease, GatewayBonding, GatewayUnbonding, PendingMixnodeUnbonding, @@ -58,6 +60,8 @@ impl ToString for MixnetEventType { MixnetEventType::MixnodeBonding => "mixnode_bonding", MixnetEventType::PendingPledgeIncrease => "pending_pledge_increase", MixnetEventType::PledgeIncrease => "pledge_increase", + MixnetEventType::PendingPledgeDecrease => "pending_pledge_decrease", + MixnetEventType::PledgeDecrease => "pledge_decrease", MixnetEventType::GatewayBonding => "gateway_bonding", MixnetEventType::GatewayUnbonding => "gateway_unbonding", MixnetEventType::PendingMixnodeUnbonding => "pending_mixnode_unbonding", @@ -354,6 +358,19 @@ pub fn new_pledge_increase_event(created_at: BlockHeight, mix_id: MixId, amount: .add_attribute(AMOUNT_KEY, amount.to_string()) } +pub fn new_pending_pledge_decrease_event(mix_id: MixId, amount: &Coin) -> Event { + Event::new(MixnetEventType::PendingPledgeDecrease) + .add_attribute(MIX_ID_KEY, mix_id.to_string()) + .add_attribute(AMOUNT_KEY, amount.to_string()) +} + +pub fn new_pledge_decrease_event(created_at: BlockHeight, mix_id: MixId, amount: &Coin) -> Event { + Event::new(MixnetEventType::PledgeDecrease) + .add_attribute(EVENT_CREATION_HEIGHT_KEY, created_at.to_string()) + .add_attribute(MIX_ID_KEY, mix_id.to_string()) + .add_attribute(AMOUNT_KEY, amount.to_string()) +} + pub fn new_mixnode_unbonding_event(created_at: BlockHeight, mix_id: MixId) -> Event { Event::new(MixnetEventType::MixnodeUnbonding) .add_attribute(EVENT_CREATION_HEIGHT_KEY, created_at.to_string()) diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/interval.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/interval.rs index a7bb6caba1..fc2b14d71e 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/interval.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/interval.rs @@ -3,7 +3,10 @@ use crate::error::MixnetContractError; use crate::pending_events::{PendingEpochEvent, PendingIntervalEvent}; -use crate::{EpochId, IntervalId, MixId}; +use crate::{ + EpochEventId, EpochId, IntervalEventId, IntervalId, MixId, PendingEpochEventData, + PendingIntervalEventData, +}; use cosmwasm_std::{Addr, Env}; use schemars::gen::SchemaGenerator; use schemars::schema::{InstanceType, Schema, SchemaObject}; @@ -528,6 +531,30 @@ impl PendingIntervalEventsResponse { } } +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct PendingEpochEventResponse { + pub event_id: EpochEventId, + pub event: Option, +} + +impl PendingEpochEventResponse { + pub fn new(event_id: EpochEventId, event: Option) -> Self { + PendingEpochEventResponse { event_id, event } + } +} + +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct PendingIntervalEventResponse { + pub event_id: IntervalEventId, + pub event: Option, +} + +impl PendingIntervalEventResponse { + pub fn new(event_id: IntervalEventId, event: Option) -> Self { + PendingIntervalEventResponse { event_id, event } + } +} + #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] pub struct NumberOfPendingEventsResponse { pub epoch_events: u32, diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/lib.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/lib.rs index 3079d03640..e2a4a67a56 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/lib.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/lib.rs @@ -32,7 +32,8 @@ pub use gateway::{ }; pub use interval::{ CurrentIntervalResponse, EpochState, EpochStatus, Interval, NumberOfPendingEventsResponse, - PendingEpochEventsResponse, PendingIntervalEventsResponse, + PendingEpochEventResponse, PendingEpochEventsResponse, PendingIntervalEventResponse, + PendingIntervalEventsResponse, }; pub use mixnode::{ Layer, MixNode, MixNodeBond, MixNodeConfigUpdate, MixNodeCostParams, MixNodeDetails, diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/mixnode.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/mixnode.rs index b2f98f3259..926905ea41 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/mixnode.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/mixnode.rs @@ -10,7 +10,7 @@ use crate::helpers::IntoBaseDecimal; use crate::reward_params::{NodeRewardParams, RewardingParams}; use crate::rewarding::helpers::truncate_reward; use crate::rewarding::RewardDistribution; -use crate::{Delegation, EpochId, IdentityKey, MixId, Percent, SphinxKey}; +use crate::{Delegation, EpochEventId, EpochId, IdentityKey, MixId, Percent, SphinxKey}; use cosmwasm_std::{Addr, Coin, Decimal, StdResult, Uint128}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -37,13 +37,20 @@ impl RewardedSetNodeStatus { pub struct MixNodeDetails { pub bond_information: MixNodeBond, pub rewarding_details: MixNodeRewarding, + #[serde(default)] + pub pending_changes: PendingMixNodeChanges, } impl MixNodeDetails { - pub fn new(bond_information: MixNodeBond, rewarding_details: MixNodeRewarding) -> Self { + pub fn new( + bond_information: MixNodeBond, + rewarding_details: MixNodeRewarding, + pending_changes: PendingMixNodeChanges, + ) -> Self { MixNodeDetails { bond_information, rewarding_details, + pending_changes, } } @@ -73,6 +80,10 @@ impl MixNodeDetails { pub fn total_stake(&self) -> Decimal { self.rewarding_details.node_bond() } + + pub fn pending_pledge_change(&self) -> Option { + self.pending_changes.pledge_change + } } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, JsonSchema)] @@ -332,6 +343,22 @@ impl MixNodeRewarding { Ok(()) } + /// Decreases total pledge of operator by the specified amount. + pub fn decrease_operator_uint128( + &mut self, + amount: Uint128, + ) -> Result<(), MixnetContractError> { + let amount_decimal = amount.into_base_decimal()?; + if self.operator < amount_decimal { + return Err(MixnetContractError::OverflowDecimalSubtraction { + minuend: self.operator, + subtrahend: amount_decimal, + }); + } + self.operator -= amount_decimal; + Ok(()) + } + pub fn increase_delegates_uint128( &mut self, amount: Uint128, @@ -601,6 +628,25 @@ impl From for u8 { } } +#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] +#[cfg_attr( + feature = "generate-ts", + ts(export_to = "ts-packages/types/src/types/rust/PendingMixnodeChanges.ts") +)] +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize, JsonSchema)] +pub struct PendingMixNodeChanges { + pub pledge_change: Option, + // pub cost_params_change: Option, +} + +impl PendingMixNodeChanges { + pub fn new_empty() -> PendingMixNodeChanges { + PendingMixNodeChanges { + pledge_change: None, + } + } +} + #[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] #[cfg_attr( feature = "generate-ts", diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/msg.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/msg.rs index 015d750900..66eda048ff 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/msg.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/msg.rs @@ -10,10 +10,13 @@ use crate::mixnode::{MixNodeConfigUpdate, MixNodeCostParams}; use crate::reward_params::{ IntervalRewardParams, IntervalRewardingParamsUpdate, Performance, RewardingParams, }; -use crate::{delegation, ContractStateParams, Layer, LayerAssignment, MixId, Percent}; +use crate::{ + delegation, ContractStateParams, EpochEventId, IntervalEventId, Layer, LayerAssignment, MixId, + Percent, +}; use crate::{Gateway, IdentityKey, MixNode}; use contracts_common::signing::MessageSignature; -use cosmwasm_std::Decimal; +use cosmwasm_std::{Coin, Decimal}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::time::Duration; @@ -161,6 +164,13 @@ pub enum ExecuteMsg { PledgeMoreOnBehalf { owner: String, }, + DecreasePledge { + decrease_by: Coin, + }, + DecreasePledgeOnBehalf { + owner: String, + decrease_by: Coin, + }, UnbondMixnode {}, UnbondMixnodeOnBehalf { owner: String, @@ -297,6 +307,10 @@ impl ExecuteMsg { } ExecuteMsg::PledgeMore {} => "pledging additional tokens".into(), ExecuteMsg::PledgeMoreOnBehalf { .. } => "pledging additional tokens on behalf".into(), + ExecuteMsg::DecreasePledge { .. } => "decreasing mixnode pledge".into(), + ExecuteMsg::DecreasePledgeOnBehalf { .. } => { + "decreasing mixnode pledge on behalf".into() + } ExecuteMsg::UnbondMixnode { .. } => "unbonding mixnode".into(), ExecuteMsg::UnbondMixnodeOnBehalf { .. } => "unbonding mixnode on behalf".into(), ExecuteMsg::UpdateMixnodeCostParams { .. } => "updating mixnode cost parameters".into(), @@ -506,6 +520,12 @@ pub enum QueryMsg { limit: Option, start_after: Option, }, + GetPendingEpochEvent { + event_id: EpochEventId, + }, + GetPendingIntervalEvent { + event_id: IntervalEventId, + }, GetNumberOfPendingEvents {}, // signing-related diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/pending_events.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/pending_events.rs index 3fe817fcb6..886c5b46b1 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/pending_events.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/pending_events.rs @@ -38,6 +38,10 @@ pub enum PendingEpochEventKind { mix_id: MixId, amount: Coin, }, + DecreasePledge { + mix_id: MixId, + decrease_by: Coin, + }, UnbondMixnode { mix_id: MixId, }, @@ -66,7 +70,7 @@ impl From<(EpochEventId, PendingEpochEventData)> for PendingEpochEvent { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct PendingIntervalEvent { - pub id: EpochEventId, + pub id: IntervalEventId, pub event: PendingIntervalEventData, } diff --git a/common/cosmwasm-smart-contracts/vesting-contract/src/events.rs b/common/cosmwasm-smart-contracts/vesting-contract/src/events.rs index e46cd67159..d7dc757956 100644 --- a/common/cosmwasm-smart-contracts/vesting-contract/src/events.rs +++ b/common/cosmwasm-smart-contracts/vesting-contract/src/events.rs @@ -15,6 +15,7 @@ pub const VESTING_GATEWAY_BONDING_EVENT_TYPE: &str = "vesting_gateway_bonding"; pub const VESTING_GATEWAY_UNBONDING_EVENT_TYPE: &str = "vesting_gateway_unbonding"; pub const VESTING_MIXNODE_BONDING_EVENT_TYPE: &str = "vesting_mixnode_bonding"; pub const VESTING_PLEDGE_MORE_EVENT_TYPE: &str = "vesting_pledge_more"; +pub const VESTING_DECREASE_PLEDGE_EVENT_TYPE: &str = "vesting_pledge_decrease"; pub const VESTING_MIXNODE_UNBONDING_EVENT_TYPE: &str = "vesting_mixnode_unbonding"; pub const VESTING_UPDATE_MIXNODE_CONFIG_EVENT_TYPE: &str = "vesting_update_mixnode_config"; pub const VESTING_UPDATE_GATEWAY_CONFIG_EVENT_TYPE: &str = "vesting_update_gateway_config"; @@ -22,6 +23,7 @@ pub const VESTING_UPDATE_MIXNODE_COST_PARAMS_EVENT_TYPE: &str = "vesting_update_mixnode_cost_params"; pub const TRACK_MIXNODE_UNBOND_EVENT_TYPE: &str = "track_mixnode_unbond"; +pub const TRACK_MIXNODE_PLEDGE_DECREASE_EVENT_TYPE: &str = "track_mixnode_pledge_decrease"; pub const TRACK_GATEWAY_UNBOND_EVENT_TYPE: &str = "track_gateway_unbond"; pub const TRACK_UNDELEGATION_EVENT_TYPE: &str = "track_undelegation"; pub const TRACK_REWARD_EVENT_TYPE: &str = "track_reaward"; @@ -118,6 +120,10 @@ pub fn new_vesting_pledge_more_event() -> Event { Event::new(VESTING_PLEDGE_MORE_EVENT_TYPE) } +pub fn new_vesting_decrease_pledge_event() -> Event { + Event::new(VESTING_DECREASE_PLEDGE_EVENT_TYPE) +} + pub fn new_vesting_update_mixnode_config_event() -> Event { Event::new(VESTING_UPDATE_MIXNODE_CONFIG_EVENT_TYPE) } @@ -146,6 +152,10 @@ pub fn new_track_mixnode_unbond_event() -> Event { Event::new(TRACK_MIXNODE_UNBOND_EVENT_TYPE) } +pub fn new_track_mixnode_pledge_decrease_event() -> Event { + Event::new(TRACK_MIXNODE_PLEDGE_DECREASE_EVENT_TYPE) +} + pub fn new_track_gateway_unbond_event() -> Event { Event::new(TRACK_GATEWAY_UNBOND_EVENT_TYPE) } diff --git a/common/cosmwasm-smart-contracts/vesting-contract/src/messages.rs b/common/cosmwasm-smart-contracts/vesting-contract/src/messages.rs index 0ec3d14304..226f39a9c0 100644 --- a/common/cosmwasm-smart-contracts/vesting-contract/src/messages.rs +++ b/common/cosmwasm-smart-contracts/vesting-contract/src/messages.rs @@ -123,11 +123,18 @@ pub enum ExecuteMsg { PledgeMore { amount: Coin, }, + DecreasePledge { + amount: Coin, + }, UnbondMixnode {}, TrackUnbondMixnode { owner: String, amount: Coin, }, + TrackDecreasePledge { + owner: String, + amount: Coin, + }, BondGateway { gateway: Gateway, owner_signature: MessageSignature, @@ -175,8 +182,10 @@ impl ExecuteMsg { ExecuteMsg::TrackUndelegation { .. } => "VestingExecuteMsg::TrackUndelegation", ExecuteMsg::BondMixnode { .. } => "VestingExecuteMsg::BondMixnode", ExecuteMsg::PledgeMore { .. } => "VestingExecuteMsg::PledgeMore", + ExecuteMsg::DecreasePledge { .. } => "VestingExecuteMsg::DecreasePledge", ExecuteMsg::UnbondMixnode { .. } => "VestingExecuteMsg::UnbondMixnode", ExecuteMsg::TrackUnbondMixnode { .. } => "VestingExecuteMsg::TrackUnbondMixnode", + ExecuteMsg::TrackDecreasePledge { .. } => "VestingExecuteMsg::TrackDecreasePledge", ExecuteMsg::BondGateway { .. } => "VestingExecuteMsg::BondGateway", ExecuteMsg::UnbondGateway { .. } => "VestingExecuteMsg::UnbondGateway", ExecuteMsg::TrackUnbondGateway { .. } => "VestingExecuteMsg::TrackUnbondGateway", diff --git a/common/types/src/pending_events.rs b/common/types/src/pending_events.rs index 8f537fc200..23ecdf4f19 100644 --- a/common/types/src/pending_events.rs +++ b/common/types/src/pending_events.rs @@ -61,6 +61,10 @@ pub enum PendingEpochEventData { mix_id: MixId, amount: DecCoin, }, + DecreasePledge { + mix_id: MixId, + decrease_by: DecCoin, + }, UnbondMixnode { mix_id: MixId, }, @@ -101,6 +105,13 @@ impl PendingEpochEventData { amount: reg.attempt_convert_to_display_dec_coin(amount.into())?, }) } + MixnetContractPendingEpochEventKind::DecreasePledge { + mix_id, + decrease_by, + } => Ok(PendingEpochEventData::DecreasePledge { + mix_id, + decrease_by: reg.attempt_convert_to_display_dec_coin(decrease_by.into())?, + }), MixnetContractPendingEpochEventKind::UnbondMixnode { mix_id } => { Ok(PendingEpochEventData::UnbondMixnode { mix_id }) } diff --git a/contracts/.cargo/config.toml b/contracts/.cargo/config.toml new file mode 100644 index 0000000000..2fbfbb15ec --- /dev/null +++ b/contracts/.cargo/config.toml @@ -0,0 +1,6 @@ +[alias] +wasm = "build --target wasm32-unknown-unknown" + +[build] +rustflags = ["-C", "link-arg=-s"] +#target = "wasm32-unknown-unknown" diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index 8be2ce1711..1f9235fbad 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -949,6 +949,22 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "mixnet-vesting-integration-tests" +version = "0.1.0" +dependencies = [ + "cosmwasm-std", + "cosmwasm-storage", + "cw-multi-test", + "nym-contracts-common", + "nym-crypto", + "nym-mixnet-contract", + "nym-mixnet-contract-common", + "nym-vesting-contract", + "nym-vesting-contract-common", + "rand_chacha 0.2.2", +] + [[package]] name = "num-traits" version = "0.2.15" diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 306dd58aa5..aa19095158 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -4,6 +4,7 @@ members = [ "coconut-dkg", "coconut-test", "mixnet", + "mixnet-vesting-integration-tests", "multisig/cw3-flex-multisig", "multisig/cw4-group", "service-provider-directory", diff --git a/contracts/mixnet-vesting-integration-tests/Cargo.toml b/contracts/mixnet-vesting-integration-tests/Cargo.toml new file mode 100644 index 0000000000..24d2ddd1ab --- /dev/null +++ b/contracts/mixnet-vesting-integration-tests/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "mixnet-vesting-integration-tests" +version = "0.1.0" +edition = "2021" +publish = false + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] + +# cosmwasm dependencies +cosmwasm-std = { workspace = true } +cosmwasm-storage = { workspace = true } +cw-multi-test = { workspace = true } + +# contracts dependencies +nym-mixnet-contract-common = { path = "../../common/cosmwasm-smart-contracts/mixnet-contract" } +nym-vesting-contract-common = { path = "../../common/cosmwasm-smart-contracts/vesting-contract" } +nym-contracts-common = { path = "../../common/cosmwasm-smart-contracts/contracts-common" } + +nym-mixnet-contract = { path = "../mixnet" } +nym-vesting-contract = { path = "../vesting" } + +# other local dependencies +nym-crypto = { path = "../../common/crypto", features = ["asymmetric", "rand"] } + +# external dependencies +rand_chacha = "0.2" + +[[test]] +name = "mixnet-vesting-test" +path = "src/tests.rs" diff --git a/contracts/mixnet-vesting-integration-tests/src/decrease_mixnode_pledge.rs b/contracts/mixnet-vesting-integration-tests/src/decrease_mixnode_pledge.rs new file mode 100644 index 0000000000..383dcd7e5c --- /dev/null +++ b/contracts/mixnet-vesting-integration-tests/src/decrease_mixnode_pledge.rs @@ -0,0 +1,240 @@ +// Copyright 2023 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::support::helpers::{mix_coin, mix_coins, vesting_owner}; +use crate::support::setup::{TestSetup, MIX_DENOM}; +use cosmwasm_std::Addr; +use cw_multi_test::Executor; +use nym_contracts_common::Percent; +use nym_mixnet_contract_common::error::MixnetContractError; +use nym_mixnet_contract_common::{ContractStateParams, MixNodeCostParams}; +use nym_mixnet_contract_common::{MixOwnershipResponse, QueryMsg as MixnetQueryMsg}; +use nym_vesting_contract_common::ExecuteMsg as VestingExecuteMsg; +use vesting_contract::errors::ContractError as VestingContractError; + +#[test] +fn decrease_mixnode_pledge_from_vesting_account_with_minimum_pledge() { + let mut test = TestSetup::new_simple(); + let vesting_account = "vesting-account"; + + // 0. get the minimum pledge amount + let state_params: ContractStateParams = test + .app + .wrap() + .query_wasm_smart(test.mixnet_contract(), &MixnetQueryMsg::GetStateParams {}) + .unwrap(); + let minimum_pledge = state_params.minimum_mixnode_pledge; + + // 1. create vesting account + let create_msg = VestingExecuteMsg::CreateAccount { + owner_address: vesting_account.to_string(), + staking_address: None, + vesting_spec: None, + cap: None, + }; + + test.app + .execute_contract( + vesting_owner(), + test.vesting_contract(), + &create_msg, + &mix_coins(1000_000_000), + ) + .unwrap(); + + // 2. bond mixnode with the vesting account + let pledge = minimum_pledge.clone(); + + let cost_params = MixNodeCostParams { + profit_margin_percent: Percent::from_percentage_value(10).unwrap(), + interval_operating_cost: mix_coin(40_000_000), + }; + + let (mix_node, owner_signature) = test.valid_mixnode_with_sig( + vesting_account, + Some(test.vesting_contract()), + cost_params.clone(), + pledge.clone(), + ); + + let bond_msg = VestingExecuteMsg::BondMixnode { + mix_node, + cost_params, + owner_signature, + amount: pledge.clone(), + }; + test.app + .execute_contract( + Addr::unchecked(vesting_account), + test.vesting_contract(), + &bond_msg, + &[], + ) + .unwrap(); + + // 3. try to decrease the pledge + + // trying to decrease by a zero amount - not valid + let decrease_pledge_msg = VestingExecuteMsg::DecreasePledge { + amount: mix_coin(0), + }; + let res_zero = test + .app + .execute_contract( + Addr::unchecked(vesting_account), + test.vesting_contract(), + &decrease_pledge_msg, + &[], + ) + .unwrap_err(); + + assert_eq!( + VestingContractError::EmptyFunds, + res_zero.downcast().unwrap() + ); + + // trying to go below the cap - also not valid + let amount = mix_coin(50_000); + let decrease_pledge_msg = VestingExecuteMsg::DecreasePledge { + amount: amount.clone(), + }; + let res_below = test + .app + .execute_contract( + Addr::unchecked(vesting_account), + test.vesting_contract(), + &decrease_pledge_msg, + &[], + ) + .unwrap_err(); + assert_eq!( + MixnetContractError::InvalidPledgeReduction { + current: pledge.amount, + decrease_by: amount.amount, + minimum: minimum_pledge.amount, + denom: minimum_pledge.denom + }, + res_below.downcast().unwrap() + ) +} + +#[test] +fn decrease_mixnode_pledge_from_vesting_account_with_sufficient_pledge() { + let mut test = TestSetup::new_simple(); + let vesting_account = "vesting-account"; + + // 1. create vesting account + let create_msg = VestingExecuteMsg::CreateAccount { + owner_address: vesting_account.to_string(), + staking_address: None, + vesting_spec: None, + cap: None, + }; + + test.app + .execute_contract( + vesting_owner(), + test.vesting_contract(), + &create_msg, + &mix_coins(10000_000_000), + ) + .unwrap(); + + // 2. bond mixnode with the vesting account + let pledge = mix_coin(150_000_000); + + let cost_params = MixNodeCostParams { + profit_margin_percent: Percent::from_percentage_value(10).unwrap(), + interval_operating_cost: mix_coin(40_000_000), + }; + + let (mix_node, owner_signature) = test.valid_mixnode_with_sig( + vesting_account, + Some(test.vesting_contract()), + cost_params.clone(), + pledge.clone(), + ); + + let bond_msg = VestingExecuteMsg::BondMixnode { + mix_node, + cost_params, + owner_signature, + amount: pledge, + }; + test.app + .execute_contract( + Addr::unchecked(vesting_account), + test.vesting_contract(), + &bond_msg, + &[], + ) + .unwrap(); + + // 3. try to decrease the pledge + let before: MixOwnershipResponse = test + .app + .wrap() + .query_wasm_smart( + test.mixnet_contract(), + &MixnetQueryMsg::GetOwnedMixnode { + address: vesting_account.to_string(), + }, + ) + .unwrap(); + let balance_before = test + .app + .wrap() + .query_balance(test.vesting_contract(), MIX_DENOM) + .unwrap(); + assert_eq!(balance_before.amount.u128(), 9850_000_000); + + let decrease_pledge_msg = VestingExecuteMsg::DecreasePledge { + amount: mix_coin(50_000_000), + }; + test.app + .execute_contract( + Addr::unchecked(vesting_account), + test.vesting_contract(), + &decrease_pledge_msg, + &[], + ) + .unwrap(); + + let after_decrease: MixOwnershipResponse = test + .app + .wrap() + .query_wasm_smart( + test.mixnet_contract(), + &MixnetQueryMsg::GetOwnedMixnode { + address: vesting_account.to_string(), + }, + ) + .unwrap(); + + // note: nothing has changed with the pledge because the event hasn't been resolved yet! + assert_eq!(before.address, after_decrease.address); + let before_details = before.mixnode_details.unwrap(); + let after_details = after_decrease.mixnode_details.unwrap(); + assert_eq!( + before_details.rewarding_details, + after_details.rewarding_details + ); + assert_eq!( + before_details.bond_information, + after_details.bond_information + ); + + // but we have the pending change saved now! + assert!(before_details.pending_changes.pledge_change.is_none()); + assert_eq!(Some(1), after_details.pending_changes.pledge_change); + + // 4. resolve events + test.advance_mixnet_epoch(); + + let balance_after = test + .app + .wrap() + .query_balance(test.vesting_contract(), MIX_DENOM) + .unwrap(); + assert_eq!(balance_after.amount.u128(), 9900_000_000); +} diff --git a/contracts/mixnet-vesting-integration-tests/src/support/fixtures.rs b/contracts/mixnet-vesting-integration-tests/src/support/fixtures.rs new file mode 100644 index 0000000000..5a97c2cf7e --- /dev/null +++ b/contracts/mixnet-vesting-integration-tests/src/support/fixtures.rs @@ -0,0 +1,28 @@ +// Copyright 2023 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::support::setup::{MIX_DENOM, REWARDING_VALIDATOR}; +use cosmwasm_std::Decimal; +use nym_contracts_common::Percent; +use nym_mixnet_contract_common::InitialRewardingParams; +use std::time::Duration; + +pub fn default_mixnet_init_msg() -> nym_mixnet_contract_common::InstantiateMsg { + nym_mixnet_contract_common::InstantiateMsg { + rewarding_validator_address: REWARDING_VALIDATOR.to_string(), + vesting_contract_address: "placeholder".to_string(), + rewarding_denom: MIX_DENOM.to_string(), + epochs_in_interval: 720, + epoch_duration: Duration::from_secs(60 * 60), + initial_rewarding_params: InitialRewardingParams { + initial_reward_pool: Decimal::from_atomics(250_000_000_000_000u128, 0).unwrap(), + initial_staking_supply: Decimal::from_atomics(223_000_000_000_000u128, 0).unwrap(), + staking_supply_scale_factor: Percent::hundred(), + sybil_resistance: Percent::from_percentage_value(30).unwrap(), + active_set_work_factor: Decimal::from_atomics(10u32, 0).unwrap(), + interval_pool_emission: Percent::from_percentage_value(2).unwrap(), + rewarded_set_size: 240, + active_set_size: 100, + }, + } +} diff --git a/contracts/mixnet-vesting-integration-tests/src/support/helpers.rs b/contracts/mixnet-vesting-integration-tests/src/support/helpers.rs new file mode 100644 index 0000000000..27e252bef7 --- /dev/null +++ b/contracts/mixnet-vesting-integration-tests/src/support/helpers.rs @@ -0,0 +1,56 @@ +// Copyright 2023 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::support::setup::{MIXNET_OWNER, MIX_DENOM, REWARDING_VALIDATOR, VESTING_OWNER}; +use cosmwasm_std::{coin, coins, Addr, Coin, Empty}; +use cw_multi_test::{Contract, ContractWrapper}; +use rand_chacha::rand_core::SeedableRng; +use rand_chacha::ChaCha20Rng; + +#[allow(unused)] +pub fn mixnet_owner() -> Addr { + Addr::unchecked(MIXNET_OWNER) +} + +pub fn vesting_owner() -> Addr { + Addr::unchecked(VESTING_OWNER) +} + +pub fn rewarding_validator() -> Addr { + Addr::unchecked(REWARDING_VALIDATOR) +} + +pub fn mix_coins(amount: u128) -> Vec { + coins(amount.into(), MIX_DENOM) +} + +pub fn mix_coin(amount: u128) -> Coin { + coin(amount, MIX_DENOM) +} + +pub fn test_rng() -> ChaCha20Rng { + let dummy_seed = [42u8; 32]; + ChaCha20Rng::from_seed(dummy_seed) +} + +pub fn mixnet_contract_wrapper() -> Box> { + Box::new( + ContractWrapper::new( + mixnet_contract::contract::execute, + mixnet_contract::contract::instantiate, + mixnet_contract::contract::query, + ) + .with_migrate(mixnet_contract::contract::migrate), + ) +} + +pub fn vesting_contract_wrapper() -> Box> { + Box::new( + ContractWrapper::new( + vesting_contract::contract::execute, + vesting_contract::contract::instantiate, + vesting_contract::contract::query, + ) + .with_migrate(vesting_contract::contract::migrate), + ) +} diff --git a/contracts/mixnet-vesting-integration-tests/src/support/mod.rs b/contracts/mixnet-vesting-integration-tests/src/support/mod.rs new file mode 100644 index 0000000000..ea9fb30983 --- /dev/null +++ b/contracts/mixnet-vesting-integration-tests/src/support/mod.rs @@ -0,0 +1,6 @@ +// Copyright 2023 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +pub mod fixtures; +pub mod helpers; +pub mod setup; diff --git a/contracts/mixnet-vesting-integration-tests/src/support/setup.rs b/contracts/mixnet-vesting-integration-tests/src/support/setup.rs new file mode 100644 index 0000000000..ecc25f459e --- /dev/null +++ b/contracts/mixnet-vesting-integration-tests/src/support/setup.rs @@ -0,0 +1,328 @@ +// Copyright 2023 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::support::fixtures; +use crate::support::helpers::{ + mixnet_contract_wrapper, rewarding_validator, test_rng, vesting_contract_wrapper, +}; +use cosmwasm_std::{coins, Addr, Coin, Timestamp}; +use cw_multi_test::{App, AppBuilder, Executor}; +use nym_contracts_common::signing::{ContractMessageContent, MessageSignature, Nonce}; +use nym_crypto::asymmetric::identity; +use nym_mixnet_contract_common::reward_params::Performance; +use nym_mixnet_contract_common::{ + CurrentIntervalResponse, LayerAssignment, MixNodeCostParams, MixnodeBondingPayload, + PagedRewardedSetResponse, RewardingParams, SignableMixNodeBondingMsg, +}; +use nym_mixnet_contract_common::{ + ExecuteMsg as MixnetExecuteMsg, MixNode, QueryMsg as MixnetQueryMsg, +}; +use rand_chacha::ChaCha20Rng; +use std::collections::HashMap; + +// our global accounts that should always get some coins at the start +pub const MIXNET_OWNER: &str = "mixnet-owner"; +pub const VESTING_OWNER: &str = "vesting-owner"; +pub const REWARDING_VALIDATOR: &str = "rewarding-validator"; +pub const MIX_DENOM: &str = "unym"; + +pub struct ContractInstantiationResult { + mixnet_contract_address: Addr, + vesting_contract_address: Addr, +} + +#[allow(unused)] +pub struct TestSetupBuilder { + mixnet_init_msg: nym_mixnet_contract_common::InstantiateMsg, + initial_balances: HashMap>, +} + +#[allow(unused)] +impl TestSetupBuilder { + pub fn new() -> Self { + TestSetupBuilder { + mixnet_init_msg: fixtures::default_mixnet_init_msg(), + initial_balances: Default::default(), + } + } + + pub fn with_mixnet_init_msg( + mut self, + mixnet_init_msg: nym_mixnet_contract_common::InstantiateMsg, + ) -> Self { + self.mixnet_init_msg = mixnet_init_msg; + self + } + + pub fn with_initial_balances(mut self, initial_balances: HashMap>) -> Self { + self.initial_balances = initial_balances; + self + } + + pub fn with_initial_balance(mut self, addr: impl Into, balance: Vec) -> Self { + self.initial_balances.insert(Addr::unchecked(addr), balance); + self + } + + pub fn build(self) -> TestSetup { + TestSetup::new(self.initial_balances, self.mixnet_init_msg) + } +} + +pub struct TestSetup { + pub app: App, + pub rng: ChaCha20Rng, + + pub mixnet_contract: Addr, + pub vesting_contract: Addr, +} + +impl TestSetup { + pub fn new_simple() -> Self { + TestSetup::new(Default::default(), fixtures::default_mixnet_init_msg()) + } + + pub fn new( + initial_balances: HashMap>, + custom_mixnet_init: nym_mixnet_contract_common::InstantiateMsg, + ) -> Self { + let (app, contracts) = instantiate_contracts(initial_balances, Some(custom_mixnet_init)); + TestSetup { + app, + rng: test_rng(), + mixnet_contract: contracts.mixnet_contract_address, + vesting_contract: contracts.vesting_contract_address, + } + } + + pub fn mixnet_contract(&self) -> Addr { + self.mixnet_contract.clone() + } + + pub fn vesting_contract(&self) -> Addr { + self.vesting_contract.clone() + } + + pub fn skip_to_current_epoch_end(&mut self) { + let current_interval: CurrentIntervalResponse = self + .app + .wrap() + .query_wasm_smart( + self.mixnet_contract(), + &MixnetQueryMsg::GetCurrentIntervalDetails {}, + ) + .unwrap(); + let epoch_end = current_interval.interval.current_epoch_end_unix_timestamp(); + + self.app.update_block(|current_block| { + // skip few blocks just in case + current_block.height += 10; + current_block.time = Timestamp::from_seconds(epoch_end as u64) + }) + } + + pub fn full_mixnet_epoch_operations(&mut self) { + let current_rewarded_set: PagedRewardedSetResponse = self + .app + .wrap() + .query_wasm_smart( + self.mixnet_contract(), + &MixnetQueryMsg::GetRewardedSet { + limit: Some(9999), + start_after: None, + }, + ) + .unwrap(); + let current_params: RewardingParams = self + .app + .wrap() + .query_wasm_smart( + self.mixnet_contract(), + &MixnetQueryMsg::GetRewardingParams {}, + ) + .unwrap(); + // TODO: handle paging + + // begin epoch transition + self.app + .execute_contract( + rewarding_validator(), + self.mixnet_contract(), + &MixnetExecuteMsg::BeginEpochTransition {}, + &[], + ) + .unwrap(); + + // reward + for (mix_id, _status) in ¤t_rewarded_set.nodes { + self.app + .execute_contract( + rewarding_validator(), + self.mixnet_contract(), + &MixnetExecuteMsg::RewardMixnode { + mix_id: *mix_id, + performance: Performance::hundred(), + }, + &[], + ) + .unwrap(); + } + + // events + self.app + .execute_contract( + rewarding_validator(), + self.mixnet_contract(), + &MixnetExecuteMsg::ReconcileEpochEvents { limit: None }, + &[], + ) + .unwrap(); + + // don't bother changing the active set, use the same node for update and advance + let new_rewarded_set = current_rewarded_set + .nodes + .into_iter() + .enumerate() + .map(|(i, (node, _))| { + LayerAssignment::new(node, ((i as u8 % 3) + 1).try_into().unwrap()) + }) + .collect(); + + self.app + .execute_contract( + rewarding_validator(), + self.mixnet_contract(), + &MixnetExecuteMsg::AdvanceCurrentEpoch { + new_rewarded_set, + expected_active_set_size: current_params.active_set_size, + }, + &[], + ) + .unwrap(); + } + + pub fn advance_mixnet_epoch(&mut self) { + self.skip_to_current_epoch_end(); + self.full_mixnet_epoch_operations(); + } + + pub fn valid_mixnode_with_sig( + &mut self, + owner: &str, + proxy: Option, + cost_params: MixNodeCostParams, + stake: Coin, + ) -> (MixNode, MessageSignature) { + let signing_nonce: Nonce = self + .app + .wrap() + .query_wasm_smart( + self.mixnet_contract(), + &MixnetQueryMsg::GetSigningNonce { + address: owner.to_string(), + }, + ) + .unwrap(); + + 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(), + host: "mix.node.org".to_string(), + mix_port: 1789, + verloc_port: 1790, + http_api_port: 8000, + version: "1.1.14".to_string(), + }; + + let payload = MixnodeBondingPayload::new(mixnode.clone(), cost_params); + let content = + ContractMessageContent::new(Addr::unchecked(owner), proxy, vec![stake], payload); + let sign_payload = SignableMixNodeBondingMsg::new(signing_nonce, content); + let plaintext = sign_payload.to_plaintext().unwrap(); + let signature = keypair.private_key().sign(&plaintext); + let msg_signature = MessageSignature::from(signature.to_bytes().as_ref()); + + (mixnode, msg_signature) + } +} + +pub fn instantiate_contracts( + mut initial_funds: HashMap>, + custom_mixnet_init: Option, +) -> (App, ContractInstantiationResult) { + // add our global addresses to the map + initial_funds.insert( + Addr::unchecked(MIXNET_OWNER), + coins(100_000_000_000, MIX_DENOM), + ); + + initial_funds.insert( + Addr::unchecked(VESTING_OWNER), + coins(100_000_000_000, MIX_DENOM), + ); + + initial_funds.insert( + Addr::unchecked(REWARDING_VALIDATOR), + coins(1_000_000_000_000, MIX_DENOM), + ); + + let mut app = AppBuilder::new().build(|router, _api, storage| { + for (addr, funds) in initial_funds { + router + .bank + .init_balance(storage, &addr, funds.clone()) + .unwrap() + } + }); + + let mixnet_code_id = app.store_code(mixnet_contract_wrapper()); + let vesting_code_id = app.store_code(vesting_contract_wrapper()); + + let mixnet_contract_address = app + .instantiate_contract( + mixnet_code_id, + Addr::unchecked(MIXNET_OWNER), + &custom_mixnet_init.unwrap_or(fixtures::default_mixnet_init_msg()), + &[], + "mixnet-contract", + Some(MIXNET_OWNER.to_string()), + ) + .unwrap(); + + let vesting_contract_address = app + .instantiate_contract( + vesting_code_id, + Addr::unchecked(VESTING_OWNER), + &nym_vesting_contract_common::InitMsg { + mixnet_contract_address: mixnet_contract_address.to_string(), + mix_denom: MIX_DENOM.to_string(), + }, + &[], + "vesting-contract", + Some(VESTING_OWNER.to_string()), + ) + .unwrap(); + + // now fix up vesting contract address... + app.migrate_contract( + Addr::unchecked(MIXNET_OWNER), + mixnet_contract_address.clone(), + &nym_mixnet_contract_common::MigrateMsg { + vesting_contract_address: Some(vesting_contract_address.to_string()), + }, + mixnet_code_id, + ) + .unwrap(); + + ( + app, + ContractInstantiationResult { + mixnet_contract_address, + vesting_contract_address, + }, + ) +} diff --git a/contracts/mixnet-vesting-integration-tests/src/tests.rs b/contracts/mixnet-vesting-integration-tests/src/tests.rs new file mode 100644 index 0000000000..d560e38394 --- /dev/null +++ b/contracts/mixnet-vesting-integration-tests/src/tests.rs @@ -0,0 +1,5 @@ +// Copyright 2023 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +mod decrease_mixnode_pledge; +mod support; diff --git a/contracts/mixnet/src/constants.rs b/contracts/mixnet/src/constants.rs index 96698f7084..17a62a25a8 100644 --- a/contracts/mixnet/src/constants.rs +++ b/contracts/mixnet/src/constants.rs @@ -1,4 +1,4 @@ -// Copyright 2022 - Nym Technologies SA +// Copyright 2022-2023 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 use cosmwasm_std::Uint128; @@ -61,6 +61,7 @@ pub const CONTRACT_STATE_KEY: &str = "state"; pub const LAYER_DISTRIBUTION_KEY: &str = "layers"; pub const NODE_ID_COUNTER_KEY: &str = "nic"; +pub const PENDING_MIXNODE_CHANGES_NAMESPACE: &str = "pmc"; pub const MIXNODES_PK_NAMESPACE: &str = "mnn"; pub const MIXNODES_OWNER_IDX_NAMESPACE: &str = "mno"; pub const MIXNODES_IDENTITY_IDX_NAMESPACE: &str = "mni"; diff --git a/contracts/mixnet/src/contract.rs b/contracts/mixnet/src/contract.rs index 7275bacb7e..f380b316f2 100644 --- a/contracts/mixnet/src/contract.rs +++ b/contracts/mixnet/src/contract.rs @@ -251,6 +251,18 @@ pub fn execute( ExecuteMsg::PledgeMoreOnBehalf { owner } => { crate::mixnodes::transactions::try_increase_pledge_on_behalf(deps, env, info, owner) } + ExecuteMsg::DecreasePledge { decrease_by } => { + crate::mixnodes::transactions::try_decrease_pledge(deps, env, info, decrease_by) + } + ExecuteMsg::DecreasePledgeOnBehalf { owner, decrease_by } => { + crate::mixnodes::transactions::try_decrease_pledge_on_behalf( + deps, + env, + info, + decrease_by, + owner, + ) + } ExecuteMsg::UnbondMixnode {} => { crate::mixnodes::transactions::try_remove_mixnode(deps, env, info) } @@ -573,6 +585,12 @@ pub fn query( limit, )?, ), + QueryMsg::GetPendingEpochEvent { event_id } => to_binary( + &crate::interval::queries::query_pending_epoch_event(deps, event_id)?, + ), + QueryMsg::GetPendingIntervalEvent { event_id } => to_binary( + &crate::interval::queries::query_pending_interval_event(deps, event_id)?, + ), QueryMsg::GetNumberOfPendingEvents {} => to_binary( &crate::interval::queries::query_number_of_pending_events(deps)?, ), @@ -586,7 +604,7 @@ pub fn query( #[entry_point] pub fn migrate( - deps: DepsMut<'_>, + mut deps: DepsMut<'_>, _env: Env, msg: MigrateMsg, ) -> Result { @@ -612,6 +630,7 @@ pub fn migrate( // If state structure changed in any contract version in the way migration is needed, it // should occur here, for example anything from `crate::queued_migrations::` + crate::queued_migrations::insert_pending_pledge_changes(deps.branch())?; } // due to circular dependency on contract addresses (i.e. mixnet contract requiring vesting contract address diff --git a/contracts/mixnet/src/interval/pending_events.rs b/contracts/mixnet/src/interval/pending_events.rs index 323107d71e..03e0ea2c46 100644 --- a/contracts/mixnet/src/interval/pending_events.rs +++ b/contracts/mixnet/src/interval/pending_events.rs @@ -1,6 +1,22 @@ // Copyright 2022 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +use cosmwasm_std::{Addr, Coin, DepsMut, Env, Response}; + +use mixnet_contract_common::error::MixnetContractError; +use mixnet_contract_common::events::{ + new_active_set_update_event, new_delegation_event, new_delegation_on_unbonded_node_event, + new_mixnode_cost_params_update_event, new_mixnode_unbonding_event, new_pledge_decrease_event, + new_pledge_increase_event, new_rewarding_params_update_event, new_undelegation_event, +}; +use mixnet_contract_common::mixnode::MixNodeCostParams; +use mixnet_contract_common::pending_events::{ + PendingEpochEventData, PendingEpochEventKind, PendingIntervalEventData, + PendingIntervalEventKind, +}; +use mixnet_contract_common::reward_params::IntervalRewardingParamsUpdate; +use mixnet_contract_common::{BlockHeight, Delegation, MixId}; + use crate::delegations; use crate::delegations::storage as delegations_storage; use crate::interval::helpers::change_interval_config; @@ -9,20 +25,6 @@ use crate::mixnodes::helpers::{cleanup_post_unbond_mixnode_storage, get_mixnode_ use crate::mixnodes::storage as mixnodes_storage; use crate::rewards::storage as rewards_storage; use crate::support::helpers::{send_to_proxy_or_owner, VestingTracking}; -use cosmwasm_std::{Addr, Coin, DepsMut, Env, Response}; -use mixnet_contract_common::error::MixnetContractError; -use mixnet_contract_common::events::{ - new_active_set_update_event, new_delegation_event, new_delegation_on_unbonded_node_event, - new_mixnode_cost_params_update_event, new_mixnode_unbonding_event, new_pledge_increase_event, - new_rewarding_params_update_event, new_undelegation_event, -}; -use mixnet_contract_common::mixnode::MixNodeCostParams; -use mixnet_contract_common::pending_events::{ - PendingEpochEventData, PendingEpochEventKind, PendingIntervalEventData, - PendingIntervalEventKind, -}; -use mixnet_contract_common::reward_params::IntervalRewardingParamsUpdate; -use mixnet_contract_common::{BlockHeight, Delegation, MixId}; pub(crate) trait ContractExecutableEvent { // note: the error only means a HARD error like we failed to read from storage. @@ -146,10 +148,9 @@ pub(crate) fn undelegate( Some(delegation) => delegation, }; let mix_rewarding = - rewards_storage::MIXNODE_REWARDING.may_load(deps.storage, mix_id)?.ok_or(MixnetContractError::InconsistentState { - comment: "mixnode rewarding got removed from the storage whilst there's still an existing delegation" - .into(), - })?; + rewards_storage::MIXNODE_REWARDING.may_load(deps.storage, mix_id)?.ok_or(MixnetContractError::inconsistent_state( + "mixnode rewarding got removed from the storage whilst there's still an existing delegation", + ))?; // this also appropriately adjusts the storage let tokens_to_return = delegations::helpers::undelegate(deps.storage, delegation, mix_rewarding)?; @@ -180,11 +181,15 @@ pub(crate) fn unbond_mixnode( // in unbonding state and thus nothing could have been done to it (such as attempting to double unbond it) // thus the node with all its associated information MUST exist in the storage. let node_details = get_mixnode_details_by_id(deps.storage, mix_id)?.ok_or( - MixnetContractError::InconsistentState { - comment: "mixnode getting processed to get unbonded doesn't exist in the storage" - .into(), - }, + MixnetContractError::inconsistent_state( + "mixnode getting processed to get unbonded doesn't exist in the storage", + ), )?; + if node_details.pending_changes.pledge_change.is_some() { + return Err(MixnetContractError::inconsistent_state( + "attempted to unbond mixnode while there are associated pending pledge changes", + )); + } // the denom on the original pledge was validated at the time of bonding so we can safely reuse it here let rewarding_denom = &node_details.bond_information.original_pledge.denom; @@ -244,12 +249,15 @@ pub(crate) fn increase_pledge( // the target node MUST exist - we have checked it at the time of putting this event onto the queue // we have also verified there were no preceding unbond events let mix_details = get_mixnode_details_by_id(deps.storage, mix_id)?.ok_or( - MixnetContractError::InconsistentState { - comment: - "mixnode getting processed to increase its pledge doesn't exist in the storage" - .into(), - }, + MixnetContractError::inconsistent_state( + "mixnode getting processed to increase its pledge doesn't exist in the storage", + ), )?; + if mix_details.pending_changes.pledge_change.is_none() { + return Err(MixnetContractError::inconsistent_state( + "attempted to increase mixnode pledge while there are no associated pending changes", + )); + } let mut updated_bond = mix_details.bond_information.clone(); let mut updated_rewarding = mix_details.rewarding_details; @@ -257,7 +265,10 @@ pub(crate) fn increase_pledge( updated_bond.original_pledge.amount += increase.amount; updated_rewarding.increase_operator_uint128(increase.amount)?; - // update both, bond information and rewarding details + let mut pending_changes = mix_details.pending_changes; + pending_changes.pledge_change = None; + + // update all: bond information, rewarding details and pending pledge changes mixnodes_storage::mixnode_bonds().replace( deps.storage, mix_id, @@ -265,10 +276,70 @@ pub(crate) fn increase_pledge( Some(&mix_details.bond_information), )?; rewards_storage::MIXNODE_REWARDING.save(deps.storage, mix_id, &updated_rewarding)?; + mixnodes_storage::PENDING_MIXNODE_CHANGES.save(deps.storage, mix_id, &pending_changes)?; Ok(Response::new().add_event(new_pledge_increase_event(created_at, mix_id, &increase))) } +pub(crate) fn decrease_pledge( + deps: DepsMut<'_>, + created_at: BlockHeight, + mix_id: MixId, + decrease_by: Coin, +) -> Result { + // the target node MUST exist - we have checked it at the time of putting this event onto the queue + // we have also verified there were no preceding unbond events + let mix_details = get_mixnode_details_by_id(deps.storage, mix_id)?.ok_or( + MixnetContractError::inconsistent_state( + "mixnode getting processed to increase its pledge doesn't exist in the storage", + ), + )?; + if mix_details.pending_changes.pledge_change.is_none() { + return Err(MixnetContractError::inconsistent_state( + "attempted to decrease mixnode pledge while there are no associated pending changes", + )); + } + + let mut updated_bond = mix_details.bond_information.clone(); + let mut updated_rewarding = mix_details.rewarding_details; + + let mut pending_changes = mix_details.pending_changes; + pending_changes.pledge_change = None; + + // SAFETY: the subtraction here can't overflow as before the event was pushed into the queue, + // we checked that the new value will be higher than minimum pledge (which is also strictly positive) + updated_bond.original_pledge.amount -= decrease_by.amount; + updated_rewarding.decrease_operator_uint128(decrease_by.amount)?; + + let proxy = &mix_details.bond_information.proxy; + let owner = &mix_details.bond_information.owner; + + // send the removed tokens back to the operator + let return_tokens = send_to_proxy_or_owner(proxy, owner, vec![decrease_by.clone()]); + + // update all: bond information, rewarding details and pending pledge changes + mixnodes_storage::mixnode_bonds().replace( + deps.storage, + mix_id, + Some(&updated_bond), + Some(&mix_details.bond_information), + )?; + rewards_storage::MIXNODE_REWARDING.save(deps.storage, mix_id, &updated_rewarding)?; + mixnodes_storage::PENDING_MIXNODE_CHANGES.save(deps.storage, mix_id, &pending_changes)?; + + let response = Response::new() + .add_message(return_tokens) + .add_event(new_pledge_decrease_event(created_at, mix_id, &decrease_by)) + .maybe_add_track_vesting_decrease_mixnode_pledge( + deps.storage, + proxy.clone(), + owner.clone().to_string(), + decrease_by, + )?; + + Ok(response) +} + impl ContractExecutableEvent for PendingEpochEventData { fn execute(self, deps: DepsMut<'_>, env: &Env) -> Result { // note that the basic validation on all those events was already performed before @@ -288,6 +359,10 @@ impl ContractExecutableEvent for PendingEpochEventData { PendingEpochEventKind::PledgeMore { mix_id, amount } => { increase_pledge(deps, self.created_at, mix_id, amount) } + PendingEpochEventKind::DecreasePledge { + mix_id, + decrease_by, + } => decrease_pledge(deps, self.created_at, mix_id, decrease_by), PendingEpochEventKind::UnbondMixnode { mix_id } => { unbond_mixnode(deps, env, self.created_at, mix_id) } @@ -397,26 +472,33 @@ impl ContractExecutableEvent for PendingIntervalEventData { #[cfg(test)] mod tests { - use super::*; + use std::time::Duration; + + use cosmwasm_std::Decimal; + + use mixnet_contract_common::Percent; + use vesting_contract_common::messages::ExecuteMsg as VestingContractExecuteMsg; + use crate::support::tests::test_helpers; use crate::support::tests::test_helpers::{assert_decimals, TestSetup}; - use cosmwasm_std::Decimal; - use mixnet_contract_common::Percent; - use std::time::Duration; - use vesting_contract_common::messages::ExecuteMsg as VestingContractExecuteMsg; + + use super::*; // note that authorization and basic validation has already been performed for all of those // before being pushed onto the event queues #[cfg(test)] mod delegating { - use super::*; + use cosmwasm_std::testing::mock_info; + use cosmwasm_std::{coin, to_binary, CosmosMsg, Decimal, WasmMsg}; + + use mixnet_contract_common::rewarding::helpers::truncate_reward_amount; + use crate::mixnodes::transactions::try_remove_mixnode; use crate::support::tests::fixtures::TEST_COIN_DENOM; use crate::support::tests::test_helpers::get_bank_send_msg; - use cosmwasm_std::testing::mock_info; - use cosmwasm_std::{coin, to_binary, CosmosMsg, Decimal, WasmMsg}; - use mixnet_contract_common::rewarding::helpers::truncate_reward_amount; + + use super::*; #[test] fn returns_the_tokens_if_mixnode_has_unbonded() { @@ -829,7 +911,7 @@ mod tests { res_other_proxy, MixnetContractError::ProxyIsNotVestingContract { received: dummy_proxy, - vesting_contract + vesting_contract, } ); } @@ -837,11 +919,14 @@ mod tests { #[cfg(test)] mod undelegating { - use super::*; + use cosmwasm_std::{coin, to_binary, CosmosMsg, WasmMsg}; + + use mixnet_contract_common::rewarding::helpers::truncate_reward_amount; + use crate::support::tests::fixtures::TEST_COIN_DENOM; use crate::support::tests::test_helpers::get_bank_send_msg; - use cosmwasm_std::{coin, to_binary, CosmosMsg, WasmMsg}; - use mixnet_contract_common::rewarding::helpers::truncate_reward_amount; + + use super::*; #[test] fn doesnt_return_any_tokens_if_it_doesnt_exist() { @@ -1021,7 +1106,7 @@ mod tests { res_other_proxy, MixnetContractError::ProxyIsNotVestingContract { received: dummy_proxy, - vesting_contract + vesting_contract, } ); } @@ -1029,13 +1114,17 @@ mod tests { #[cfg(test)] mod mixnode_unbonding { - use super::*; + use cosmwasm_std::{coin, to_binary, CosmosMsg, Uint128, WasmMsg}; + + use mixnet_contract_common::mixnode::{PendingMixNodeChanges, UnbondedMixnode}; + use mixnet_contract_common::rewarding::helpers::truncate_reward_amount; + use crate::mixnodes::storage as mixnodes_storage; + use crate::mixnodes::transactions::{_try_decrease_pledge, _try_increase_pledge}; use crate::support::tests::fixtures::TEST_COIN_DENOM; use crate::support::tests::test_helpers::get_bank_send_msg; - use cosmwasm_std::{coin, to_binary, CosmosMsg, Uint128, WasmMsg}; - use mixnet_contract_common::mixnode::UnbondedMixnode; - use mixnet_contract_common::rewarding::helpers::truncate_reward_amount; + + use super::*; #[test] fn returns_hard_error_if_mixnode_doesnt_exist() { @@ -1050,6 +1139,71 @@ mod tests { )); } + #[test] + fn returns_hard_error_if_there_are_pending_pledge_changes() { + let mut test = TestSetup::new(); + let env = test.env(); + let change = test.coins(1234); + + // increase + let owner = "mix-owner1"; + let pledge = Uint128::new(250_000_000); + let mix_id = test.add_dummy_mixnode(owner, Some(pledge)); + + _try_increase_pledge( + test.deps_mut(), + env.clone(), + change.clone(), + Addr::unchecked(owner), + None, + ) + .unwrap(); + + let res = unbond_mixnode(test.deps_mut(), &env, 123, mix_id); + assert!(matches!( + res, + Err(MixnetContractError::InconsistentState { .. }) + )); + + // decrease + let owner = "mix-owner2"; + let pledge = Uint128::new(250_000_000); + let mix_id = test.add_dummy_mixnode(owner, Some(pledge)); + + _try_decrease_pledge( + test.deps_mut(), + env.clone(), + change[0].clone(), + Addr::unchecked(owner), + None, + ) + .unwrap(); + + let res = unbond_mixnode(test.deps_mut(), &env, 123, mix_id); + assert!(matches!( + res, + Err(MixnetContractError::InconsistentState { .. }) + )); + + // artificial + let owner = "mix-owner3"; + let pledge = Uint128::new(250_000_000); + let mix_id = test.add_dummy_mixnode(owner, Some(pledge)); + + let changes = PendingMixNodeChanges { + pledge_change: Some(1234), + }; + + mixnodes_storage::PENDING_MIXNODE_CHANGES + .save(test.deps_mut().storage, mix_id, &changes) + .unwrap(); + let res = unbond_mixnode(test.deps_mut(), &env, 123, mix_id); + assert!(matches!( + res, + Err(MixnetContractError::InconsistentState { .. }) + )); + } + #[test] fn returns_original_pledge_alongside_any_earned_rewards() { let mut test = TestSetup::new(); @@ -1178,7 +1332,7 @@ mod tests { res_other_proxy, MixnetContractError::ProxyIsNotVestingContract { received: dummy_proxy, - vesting_contract + vesting_contract, } ); } @@ -1186,10 +1340,12 @@ mod tests { #[cfg(test)] mod increasing_pledge { - use super::*; use cosmwasm_std::Uint128; + use mixnet_contract_common::rewarding::helpers::truncate_reward_amount; + use super::*; + #[test] fn returns_hard_error_if_mixnode_doesnt_exist() { // this should have never happened so hard error MUST be thrown here @@ -1203,10 +1359,27 @@ mod tests { )); } + #[test] + fn returns_hard_error_if_there_are_no_pending_pledge_changes() { + let mut test = TestSetup::new(); + let change = test.coin(1234); + + let owner = "mix-owner"; + let pledge = Uint128::new(250_000_000); + let mix_id = test.add_dummy_mixnode(owner, Some(pledge)); + + let res = increase_pledge(test.deps_mut(), 123, mix_id, change); + assert!(matches!( + res, + Err(MixnetContractError::InconsistentState { .. }) + )); + } + #[test] fn updates_stored_bond_information_and_rewarding_details() { let mut test = TestSetup::new(); let mix_id = test.add_dummy_mixnode("mix-owner", None); + test.set_pending_pledge_change(mix_id, None); let old_details = get_mixnode_details_by_id(test.deps().storage, mix_id) .unwrap() @@ -1239,6 +1412,8 @@ mod tests { let pledge3 = Uint128::new(200_000_000); let mix_id_repledge = test.add_dummy_mixnode("mix-owner1", Some(pledge1)); + test.set_pending_pledge_change(mix_id_repledge, None); + let increase = test.coin(pledge2.u128()); increase_pledge(test.deps_mut(), 123, mix_id_repledge, increase).unwrap(); @@ -1274,6 +1449,7 @@ mod tests { let pledge2 = Uint128::new(50_000_000_000); let mix_id_repledge = test.add_dummy_mixnode("mix-owner1", Some(pledge1)); + test.set_pending_pledge_change(mix_id_repledge, None); test.add_immediate_delegation("alice", 123_456_789_000u128, mix_id_repledge); test.add_immediate_delegation("bob", 500_000_000_000u128, mix_id_repledge); @@ -1346,6 +1522,7 @@ mod tests { let pledge2 = Uint128::new(50_000_000_000); let mix_id_repledge = test.add_dummy_mixnode("mix-owner1", Some(pledge1)); + test.set_pending_pledge_change(mix_id_repledge, None); test.add_immediate_delegation("alice", 123_456_789_000u128, mix_id_repledge); test.add_immediate_delegation("bob", 500_000_000_000u128, mix_id_repledge); @@ -1420,6 +1597,373 @@ mod tests { assert_eq!(dist1, dist2) } } + + #[test] + fn updates_the_pending_pledge_changes_field() { + let mut test = TestSetup::new(); + let mix_id = test.add_dummy_mixnode("mix-owner", None); + test.set_pending_pledge_change(mix_id, None); + + let amount = test.coin(12345); + increase_pledge(test.deps_mut(), 123, mix_id, amount).unwrap(); + let pending = mixnodes_storage::PENDING_MIXNODE_CHANGES + .load(test.deps().storage, mix_id) + .unwrap(); + assert!(pending.pledge_change.is_none()) + } + } + + #[cfg(test)] + mod decreasing_pledge { + use cosmwasm_std::{to_binary, BankMsg, CosmosMsg, Uint128, WasmMsg}; + + use mixnet_contract_common::rewarding::helpers::truncate_reward_amount; + + use super::*; + + #[test] + fn returns_hard_error_if_mixnode_doesnt_exist() { + // this should have never happened so hard error MUST be thrown here + let mut test = TestSetup::new(); + + let amount = test.coin(123); + let res = decrease_pledge(test.deps_mut(), 123, 1, amount); + assert!(matches!( + res, + Err(MixnetContractError::InconsistentState { .. }) + )); + } + + #[test] + fn returns_hard_error_if_there_are_no_pending_pledge_changes() { + let mut test = TestSetup::new(); + let change = test.coin(1234); + + let owner = "mix-owner"; + let pledge = Uint128::new(250_000_000); + let mix_id = test.add_dummy_mixnode(owner, Some(pledge)); + + let res = decrease_pledge(test.deps_mut(), 123, mix_id, change); + assert!(matches!( + res, + Err(MixnetContractError::InconsistentState { .. }) + )); + } + + #[test] + fn updates_stored_bond_information_and_rewarding_details() { + let mut test = TestSetup::new(); + let mix_id = test.add_dummy_mixnode("mix-owner", None); + test.set_pending_pledge_change(mix_id, None); + + let old_details = get_mixnode_details_by_id(test.deps().storage, mix_id) + .unwrap() + .unwrap(); + + let amount = test.coin(12345); + decrease_pledge(test.deps_mut(), 123, mix_id, amount.clone()).unwrap(); + + let updated_details = get_mixnode_details_by_id(test.deps().storage, mix_id) + .unwrap() + .unwrap(); + + assert_eq!( + updated_details.bond_information.original_pledge.amount, + old_details.bond_information.original_pledge.amount - amount.amount + ); + + assert_eq!( + updated_details.rewarding_details.operator, + old_details.rewarding_details.operator + - Decimal::from_atomics(amount.amount, 0).unwrap() + ); + } + + #[test] + fn returns_tokens_back_to_the_owner() { + let mut test = TestSetup::new(); + let owner = "mix-owner"; + let mix_id = test.add_dummy_mixnode(owner, None); + test.set_pending_pledge_change(mix_id, None); + + let amount = test.coin(12345); + let res = decrease_pledge(test.deps_mut(), 123, mix_id, amount.clone()).unwrap(); + + assert_eq!(res.messages.len(), 1); + assert_eq!( + res.messages[0].msg, + CosmosMsg::Bank(BankMsg::Send { + to_address: owner.to_string(), + amount: vec![amount], + }) + ) + } + + #[test] + fn returns_tokens_back_to_the_proxy_if_bonded_with_vesting() { + let mut test = TestSetup::new(); + let owner = "mix-owner"; + let mix_id = test.add_dummy_mixnode_with_legal_proxy(owner, None); + test.set_pending_pledge_change(mix_id, None); + + let vesting_contract = test.vesting_contract(); + + let amount = test.coin(12345); + let res = decrease_pledge(test.deps_mut(), 123, mix_id, amount.clone()).unwrap(); + + assert_eq!(res.messages.len(), 2); + assert_eq!( + res.messages[0].msg, + CosmosMsg::Bank(BankMsg::Send { + to_address: vesting_contract.to_string(), + amount: vec![amount], + }) + ) + } + + #[test] + fn attaches_vesting_track_message() { + let mut test = TestSetup::new(); + let mix_id_no_proxy = test.add_dummy_mixnode("mix-owner1", None); + test.set_pending_pledge_change(mix_id_no_proxy, None); + + let mix_id_proxy = test.add_dummy_mixnode_with_legal_proxy("mix-owner2", None); + test.set_pending_pledge_change(mix_id_proxy, None); + + let vesting_contract = test.vesting_contract(); + + let amount = test.coin(12345); + let res_no_proxy = + decrease_pledge(test.deps_mut(), 123, mix_id_no_proxy, amount.clone()).unwrap(); + + // nothing was attached (apart from bank message tested in `returns_tokens_back_to_the_owner`) + // because it wasn't done with proxy! + assert_eq!(res_no_proxy.messages.len(), 1); + + let res_proxy = + decrease_pledge(test.deps_mut(), 123, mix_id_proxy, amount.clone()).unwrap(); + assert_eq!(res_proxy.messages.len(), 2); + assert_eq!( + res_proxy.messages[1].msg, + CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: vesting_contract.to_string(), + msg: to_binary(&VestingContractExecuteMsg::TrackDecreasePledge { + owner: "mix-owner2".to_string(), + amount, + }) + .unwrap(), + funds: vec![], + }) + ); + } + + #[test] + fn without_any_events_in_between_is_equivalent_to_pledging_the_same_amount_immediately() { + let mut test = TestSetup::new(); + let pledge1 = Uint128::new(200_000_000); + let pledge_change = Uint128::new(50_000_000); + let pledge3 = Uint128::new(150_000_000); + + let mix_id_repledge = test.add_dummy_mixnode("mix-owner1", Some(pledge1)); + test.set_pending_pledge_change(mix_id_repledge, None); + + let decrease = test.coin(pledge_change.u128()); + decrease_pledge(test.deps_mut(), 123, mix_id_repledge, decrease).unwrap(); + + let mix_id_full_pledge = test.add_dummy_mixnode("mix-owner2", Some(pledge3)); + + test.add_immediate_delegation("alice", 123_456_789u128, mix_id_repledge); + test.add_immediate_delegation("bob", 500_000_000u128, mix_id_repledge); + test.add_immediate_delegation("carol", 111_111_111u128, mix_id_repledge); + + test.add_immediate_delegation("alice", 123_456_789u128, mix_id_full_pledge); + test.add_immediate_delegation("bob", 500_000_000u128, mix_id_full_pledge); + test.add_immediate_delegation("carol", 111_111_111u128, mix_id_full_pledge); + + test.skip_to_next_epoch_end(); + test.force_change_rewarded_set(vec![mix_id_repledge, mix_id_full_pledge]); + + let dist1 = test.reward_with_distribution_with_state_bypass( + mix_id_repledge, + test_helpers::performance(100.0), + ); + let dist2 = test.reward_with_distribution_with_state_bypass( + mix_id_full_pledge, + test_helpers::performance(100.0), + ); + + assert_eq!(dist1, dist2) + } + + #[test] + fn correctly_decreases_future_rewards() { + let mut test = TestSetup::new(); + let pledge1 = Uint128::new(200_000_000_000); + let pledge_change = Uint128::new(50_000_000_000); + + let mix_id_repledge = test.add_dummy_mixnode("mix-owner1", Some(pledge1)); + test.set_pending_pledge_change(mix_id_repledge, None); + + test.add_immediate_delegation("alice", 123_456_789_000u128, mix_id_repledge); + test.add_immediate_delegation("bob", 500_000_000_000u128, mix_id_repledge); + test.add_immediate_delegation("carol", 111_111_111_000u128, mix_id_repledge); + + test.skip_to_next_epoch_end(); + test.force_change_rewarded_set(vec![mix_id_repledge]); + + let dist = test.reward_with_distribution_with_state_bypass( + mix_id_repledge, + test_helpers::performance(100.0), + ); + + let decrease = test.coin(pledge_change.u128()); + decrease_pledge(test.deps_mut(), 123, mix_id_repledge, decrease).unwrap(); + + let pledge3 = Uint128::new(150_000_000_000) + truncate_reward_amount(dist.operator); + let mix_id_full_pledge = test.add_dummy_mixnode("mix-owner2", Some(pledge3)); + + test.add_immediate_delegation("alice", 123_456_789_000u128, mix_id_full_pledge); + test.add_immediate_delegation("bob", 500_000_000_000u128, mix_id_full_pledge); + test.add_immediate_delegation("carol", 111_111_111_000u128, mix_id_full_pledge); + + let lost_operator = dist.operator + - Decimal::from_atomics(truncate_reward_amount(dist.operator), 0).unwrap(); + let lost_delegates = dist.delegates + - Decimal::from_atomics(truncate_reward_amount(dist.delegates), 0).unwrap(); + + // add the tiny bit of lost precision manually + let mut mix_rewarding_full = test.mix_rewarding(mix_id_full_pledge); + mix_rewarding_full.delegates += lost_delegates; + mix_rewarding_full.operator += lost_operator; + rewards_storage::MIXNODE_REWARDING + .save( + test.deps_mut().storage, + mix_id_full_pledge, + &mix_rewarding_full, + ) + .unwrap(); + + test.add_immediate_delegation( + "dave", + truncate_reward_amount(dist.delegates).u128(), + mix_id_full_pledge, + ); + + test.skip_to_next_epoch_end(); + test.force_change_rewarded_set(vec![mix_id_repledge, mix_id_full_pledge]); + + // go through few epochs of rewarding + for _ in 0..500 { + test.skip_to_next_epoch_end(); + let dist1 = test.reward_with_distribution_with_state_bypass( + mix_id_repledge, + test_helpers::performance(100.0), + ); + let dist2 = test.reward_with_distribution_with_state_bypass( + mix_id_full_pledge, + test_helpers::performance(100.0), + ); + + assert_eq!(dist1, dist2) + } + } + + #[test] + fn correctly_decreases_future_rewards_with_more_passed_epochs() { + let mut test = TestSetup::new(); + let pledge1 = Uint128::new(200_000_000_000); + let pledge_change = Uint128::new(50_000_000_000); + + let mix_id_repledge = test.add_dummy_mixnode("mix-owner1", Some(pledge1)); + test.set_pending_pledge_change(mix_id_repledge, None); + + test.add_immediate_delegation("alice", 123_456_789_000u128, mix_id_repledge); + test.add_immediate_delegation("bob", 500_000_000_000u128, mix_id_repledge); + test.add_immediate_delegation("carol", 111_111_111_000u128, mix_id_repledge); + + test.skip_to_next_epoch_end(); + test.force_change_rewarded_set(vec![mix_id_repledge]); + + let mut cumulative_op_reward = Decimal::zero(); + let mut cumulative_del_reward = Decimal::zero(); + + // go few epochs of rewarding before decreasing pledge + for _ in 0..500 { + test.skip_to_next_epoch_end(); + let dist = test.reward_with_distribution_with_state_bypass( + mix_id_repledge, + test_helpers::performance(100.0), + ); + cumulative_op_reward += dist.operator; + cumulative_del_reward += dist.delegates; + } + + let decrease = test.coin(pledge_change.u128()); + decrease_pledge(test.deps_mut(), 123, mix_id_repledge, decrease).unwrap(); + + let pledge3 = + Uint128::new(150_000_000_000) + truncate_reward_amount(cumulative_op_reward); + let mix_id_full_pledge = test.add_dummy_mixnode("mix-owner2", Some(pledge3)); + + test.add_immediate_delegation("alice", 123_456_789_000u128, mix_id_full_pledge); + test.add_immediate_delegation("bob", 500_000_000_000u128, mix_id_full_pledge); + test.add_immediate_delegation("carol", 111_111_111_000u128, mix_id_full_pledge); + + let lost_operator = cumulative_op_reward + - Decimal::from_atomics(truncate_reward_amount(cumulative_op_reward), 0).unwrap(); + let lost_delegates = cumulative_del_reward + - Decimal::from_atomics(truncate_reward_amount(cumulative_del_reward), 0).unwrap(); + + // add the tiny bit of lost precision manually + let mut mix_rewarding_full = test.mix_rewarding(mix_id_full_pledge); + mix_rewarding_full.delegates += lost_delegates; + mix_rewarding_full.operator += lost_operator; + rewards_storage::MIXNODE_REWARDING + .save( + test.deps_mut().storage, + mix_id_full_pledge, + &mix_rewarding_full, + ) + .unwrap(); + + test.add_immediate_delegation( + "dave", + truncate_reward_amount(cumulative_del_reward).u128(), + mix_id_full_pledge, + ); + + test.skip_to_next_epoch_end(); + test.force_change_rewarded_set(vec![mix_id_repledge, mix_id_full_pledge]); + + // go through few more epochs of rewarding + for _ in 0..500 { + test.skip_to_next_epoch_end(); + let dist1 = test.reward_with_distribution_with_state_bypass( + mix_id_repledge, + test_helpers::performance(100.0), + ); + let dist2 = test.reward_with_distribution_with_state_bypass( + mix_id_full_pledge, + test_helpers::performance(100.0), + ); + + assert_eq!(dist1, dist2) + } + } + + #[test] + fn updates_the_pending_pledge_changes_field() { + let mut test = TestSetup::new(); + let mix_id = test.add_dummy_mixnode("mix-owner", None); + test.set_pending_pledge_change(mix_id, None); + + let amount = test.coin(12345); + decrease_pledge(test.deps_mut(), 123, mix_id, amount).unwrap(); + let pending = mixnodes_storage::PENDING_MIXNODE_CHANGES + .load(test.deps().storage, mix_id) + .unwrap(); + assert!(pending.pledge_change.is_none()) + } } #[test] @@ -1439,11 +1983,14 @@ mod tests { #[cfg(test)] mod changing_mix_cost_params { - use super::*; - use crate::support::tests::fixtures::TEST_COIN_DENOM; use cosmwasm_std::coin; + use mixnet_contract_common::Percent; + use crate::support::tests::fixtures::TEST_COIN_DENOM; + + use super::*; + #[test] fn doesnt_do_anything_if_mixnode_has_unbonded() { let mut test = TestSetup::new(); @@ -1480,7 +2027,7 @@ mod tests { Response::new().add_event(new_mixnode_cost_params_update_event( 123, mix_id, - &new_params + &new_params, )) ) ); diff --git a/contracts/mixnet/src/interval/queries.rs b/contracts/mixnet/src/interval/queries.rs index e1f6a51a5d..d0b480848f 100644 --- a/contracts/mixnet/src/interval/queries.rs +++ b/contracts/mixnet/src/interval/queries.rs @@ -13,8 +13,8 @@ use mixnet_contract_common::error::MixnetContractError; use mixnet_contract_common::pending_events::{PendingEpochEvent, PendingIntervalEvent}; use mixnet_contract_common::{ CurrentIntervalResponse, EpochEventId, EpochStatus, IntervalEventId, MixId, - NumberOfPendingEventsResponse, PagedRewardedSetResponse, PendingEpochEventsResponse, - PendingIntervalEventsResponse, + NumberOfPendingEventsResponse, PagedRewardedSetResponse, PendingEpochEventResponse, + PendingEpochEventsResponse, PendingIntervalEventResponse, PendingIntervalEventsResponse, }; pub fn query_epoch_status(deps: Deps<'_>) -> StdResult { @@ -112,6 +112,22 @@ pub fn query_pending_interval_events_paged( }) } +pub fn query_pending_epoch_event( + deps: Deps<'_>, + event_id: EpochEventId, +) -> Result { + let event = storage::PENDING_EPOCH_EVENTS.may_load(deps.storage, event_id)?; + Ok(PendingEpochEventResponse { event_id, event }) +} + +pub fn query_pending_interval_event( + deps: Deps<'_>, + event_id: IntervalEventId, +) -> Result { + let event = storage::PENDING_INTERVAL_EVENTS.may_load(deps.storage, event_id)?; + Ok(PendingIntervalEventResponse { event_id, event }) +} + pub fn query_number_of_pending_events( deps: Deps<'_>, ) -> Result { @@ -540,6 +556,88 @@ mod tests { } } + #[test] + fn query_for_pending_epoch_event() { + let mut test = TestSetup::new(); + + // it doesn't exist + let expected = PendingEpochEventResponse { + event_id: 123, + event: None, + }; + assert_eq!( + expected, + query_pending_epoch_event(test.deps(), 123).unwrap() + ); + + // it exists + let dummy_action = PendingEpochEventKind::Undelegate { + owner: Addr::unchecked("foomp"), + mix_id: test.rng.next_u32(), + proxy: None, + }; + let env = test.env(); + storage::push_new_epoch_event(test.deps_mut().storage, &env, dummy_action.clone()).unwrap(); + let expected = PendingEpochEventResponse { + event_id: 1, + event: Some(dummy_action.attach_source_height(env.block.height)), + }; + + assert_eq!(expected, query_pending_epoch_event(test.deps(), 1).unwrap()); + + // it no longer exist (but used to) + test.execute_all_pending_events(); + let expected = PendingEpochEventResponse { + event_id: 1, + event: None, + }; + assert_eq!(expected, query_pending_epoch_event(test.deps(), 1).unwrap()); + } + + #[test] + fn query_for_pending_interval_event() { + let mut test = TestSetup::new(); + + // it doesn't exist + let expected = PendingIntervalEventResponse { + event_id: 123, + event: None, + }; + assert_eq!( + expected, + query_pending_interval_event(test.deps(), 123).unwrap() + ); + + // it exists + let dummy_action = PendingIntervalEventKind::ChangeMixCostParams { + mix_id: test.rng.next_u32(), + new_costs: fixtures::mix_node_cost_params_fixture(), + }; + let env = test.env(); + storage::push_new_interval_event(test.deps_mut().storage, &env, dummy_action.clone()) + .unwrap(); + let expected = PendingIntervalEventResponse { + event_id: 1, + event: Some(dummy_action.attach_source_height(env.block.height)), + }; + + assert_eq!( + expected, + query_pending_interval_event(test.deps(), 1).unwrap() + ); + + // it no longer exist (but used to) + test.execute_all_pending_events(); + let expected = PendingIntervalEventResponse { + event_id: 1, + event: None, + }; + assert_eq!( + expected, + query_pending_interval_event(test.deps(), 1).unwrap() + ); + } + #[test] fn querying_for_number_of_pending_events() { let mut test = TestSetup::new(); diff --git a/contracts/mixnet/src/interval/storage.rs b/contracts/mixnet/src/interval/storage.rs index 4de46d47d2..0e35f3908d 100644 --- a/contracts/mixnet/src/interval/storage.rs +++ b/contracts/mixnet/src/interval/storage.rs @@ -81,7 +81,7 @@ pub(crate) fn push_new_epoch_event( storage: &mut dyn Storage, env: &Env, event: PendingEpochEventKind, -) -> StdResult<()> { +) -> StdResult { // not included in non-test code as it messes with our return types as we expected `StdResult` // from all storage-related operations. // However, the callers MUST HAVE ensured the below invariant @@ -90,14 +90,15 @@ pub(crate) fn push_new_epoch_event( let event_id = next_epoch_event_id_counter(storage)?; let event_data = event.attach_source_height(env.block.height); - PENDING_EPOCH_EVENTS.save(storage, event_id, &event_data) + PENDING_EPOCH_EVENTS.save(storage, event_id, &event_data)?; + Ok(event_id) } pub(crate) fn push_new_interval_event( storage: &mut dyn Storage, env: &Env, event: PendingIntervalEventKind, -) -> StdResult<()> { +) -> StdResult { // not included in non-test code as it messes with our return types as we expected `StdResult` // from all storage-related operations. // However, the callers MUST HAVE ensured the below invariant @@ -106,7 +107,8 @@ pub(crate) fn push_new_interval_event( let event_id = next_interval_event_id_counter(storage)?; let event_data = event.attach_source_height(env.block.height); - PENDING_INTERVAL_EVENTS.save(storage, event_id, &event_data) + PENDING_INTERVAL_EVENTS.save(storage, event_id, &event_data)?; + Ok(event_id) } pub(crate) fn update_rewarded_set( @@ -168,8 +170,11 @@ pub(crate) fn initialise_storage( #[cfg(test)] mod tests { use super::*; + use crate::support::tests::fixtures; + use crate::support::tests::test_helpers::TestSetup; use cosmwasm_std::testing::mock_dependencies; use cosmwasm_std::Order; + use rand_chacha::rand_core::RngCore; fn read_entire_set(storage: &mut dyn Storage) -> HashMap { REWARDED_SET @@ -211,4 +216,62 @@ mod tests { assert!(current_set.get(&7).is_none()); assert!(current_set.get(&1).is_none()); } + + #[test] + fn pushing_new_epoch_event_returns_its_id() { + let mut test = TestSetup::new(); + let env = test.env(); + + for _ in 0..500 { + let dummy_action = PendingEpochEventKind::Undelegate { + owner: Addr::unchecked("foomp"), + mix_id: test.rng.next_u32(), + proxy: None, + }; + let id = push_new_epoch_event(test.deps_mut().storage, &env, dummy_action).unwrap(); + let expected = EPOCH_EVENT_ID_COUNTER.load(test.deps().storage).unwrap(); + assert_eq!(expected, id); + } + + test.execute_all_pending_events(); + + for _ in 0..10 { + let dummy_action = PendingEpochEventKind::Undelegate { + owner: Addr::unchecked("foomp"), + mix_id: test.rng.next_u32(), + proxy: None, + }; + let id = push_new_epoch_event(test.deps_mut().storage, &env, dummy_action).unwrap(); + let expected = EPOCH_EVENT_ID_COUNTER.load(test.deps().storage).unwrap(); + assert_eq!(expected, id); + } + } + + #[test] + fn pushing_new_interval_event_returns_its_id() { + let mut test = TestSetup::new(); + let env = test.env(); + + for _ in 0..500 { + let dummy_action = PendingIntervalEventKind::ChangeMixCostParams { + mix_id: test.rng.next_u32(), + new_costs: fixtures::mix_node_cost_params_fixture(), + }; + let id = push_new_interval_event(test.deps_mut().storage, &env, dummy_action).unwrap(); + let expected = INTERVAL_EVENT_ID_COUNTER.load(test.deps().storage).unwrap(); + assert_eq!(expected, id); + } + + test.execute_all_pending_events(); + + for _ in 0..10 { + let dummy_action = PendingIntervalEventKind::ChangeMixCostParams { + mix_id: test.rng.next_u32(), + new_costs: fixtures::mix_node_cost_params_fixture(), + }; + let id = push_new_interval_event(test.deps_mut().storage, &env, dummy_action).unwrap(); + let expected = INTERVAL_EVENT_ID_COUNTER.load(test.deps().storage).unwrap(); + assert_eq!(expected, id); + } + } } diff --git a/contracts/mixnet/src/mixnodes/helpers.rs b/contracts/mixnet/src/mixnodes/helpers.rs index 383dbc6224..2650283b05 100644 --- a/contracts/mixnet/src/mixnodes/helpers.rs +++ b/contracts/mixnet/src/mixnodes/helpers.rs @@ -10,7 +10,7 @@ use mixnet_contract_common::error::MixnetContractError; use mixnet_contract_common::mixnode::{ MixNodeCostParams, MixNodeDetails, MixNodeRewarding, UnbondedMixnode, }; -use mixnet_contract_common::{Layer, MixId, MixNode, MixNodeBond}; +use mixnet_contract_common::{IdentityKey, Layer, MixId, MixNode, MixNodeBond}; pub(crate) fn must_get_mixnode_bond_by_owner( store: &dyn Storage, @@ -26,18 +26,34 @@ pub(crate) fn must_get_mixnode_bond_by_owner( .1) } +pub(crate) fn attach_mix_details( + store: &dyn Storage, + bond_information: MixNodeBond, +) -> StdResult { + // if bond exists, rewarding details MUST also exist + let rewarding_details = + rewards_storage::MIXNODE_REWARDING.load(store, bond_information.mix_id)?; + + // since this `Map` hasn't existed when contract was instantiated, some mixnodes might not + // have an entry here. But that's fine, because it means they have no pending changes + // (if there were supposed to be any changes, they would have been added during migration) + let pending_changes = storage::PENDING_MIXNODE_CHANGES + .may_load(store, bond_information.mix_id)? + .unwrap_or_default(); + + Ok(MixNodeDetails::new( + bond_information, + rewarding_details, + pending_changes, + )) +} + pub(crate) fn get_mixnode_details_by_id( store: &dyn Storage, mix_id: MixId, ) -> StdResult> { if let Some(bond_information) = storage::mixnode_bonds().may_load(store, mix_id)? { - // if bond exists, rewarding details MUST also exist - let rewarding_details = - rewards_storage::MIXNODE_REWARDING.load(store, bond_information.mix_id)?; - Ok(Some(MixNodeDetails::new( - bond_information, - rewarding_details, - ))) + attach_mix_details(store, bond_information).map(Some) } else { Ok(None) } @@ -53,13 +69,23 @@ pub(crate) fn get_mixnode_details_by_owner( .item(store, address)? .map(|record| record.1) { - // if bond exists, rewarding details MUST also exist - let rewarding_details = - rewards_storage::MIXNODE_REWARDING.load(store, bond_information.mix_id)?; - Ok(Some(MixNodeDetails::new( - bond_information, - rewarding_details, - ))) + attach_mix_details(store, bond_information).map(Some) + } else { + Ok(None) + } +} + +pub(crate) fn get_mixnode_details_by_identity( + store: &dyn Storage, + mix_identity: IdentityKey, +) -> StdResult> { + if let Some(bond_information) = storage::mixnode_bonds() + .idx + .identity_key + .item(store, mix_identity)? + .map(|record| record.1) + { + attach_mix_details(store, bond_information).map(Some) } else { Ok(None) } @@ -153,7 +179,13 @@ pub(crate) mod tests { mix_node_cost_params_fixture, mix_node_fixture, TEST_COIN_DENOM, }; use crate::support::tests::test_helpers::TestSetup; - use cosmwasm_std::coin; + use cosmwasm_std::{coin, Uint128}; + + pub(crate) struct DummyMixnode { + pub mix_id: MixId, + pub owner: Addr, + pub identity: IdentityKey, + } pub(crate) const OWNER_EXISTS: &str = "mix-owner-existing"; pub(crate) const OWNER_UNBONDING: &str = "mix-owner-unbonding"; @@ -161,33 +193,59 @@ pub(crate) mod tests { pub(crate) const OWNER_UNBONDED_LEFTOVER: &str = "mix-owner-unbonded-leftover"; // create a mixnode that is bonded, unbonded, in the process of unbonding and unbonded with leftover mix rewarding details - pub(crate) fn setup_mix_combinations(test: &mut TestSetup) -> Vec { - let mix_id_exists = test.add_dummy_mixnode(OWNER_EXISTS, None); - let mix_id_unbonding = test.add_dummy_mixnode(OWNER_UNBONDING, None); - let mix_id_unbonded = test.add_dummy_mixnode(OWNER_UNBONDED, None); - let mix_id_unbonded_leftover = test.add_dummy_mixnode(OWNER_UNBONDED_LEFTOVER, None); + pub(crate) fn setup_mix_combinations( + test: &mut TestSetup, + stake: Option, + ) -> Vec { + let (mix_id, keypair) = test.add_dummy_mixnode_with_keypair(OWNER_EXISTS, stake); + let mix_exists = DummyMixnode { + mix_id, + owner: Addr::unchecked(OWNER_EXISTS), + identity: keypair.public_key().to_base58_string(), + }; + + let (mix_id, keypair) = test.add_dummy_mixnode_with_keypair(OWNER_UNBONDING, stake); + let mix_unbonding = DummyMixnode { + mix_id, + owner: Addr::unchecked(OWNER_UNBONDING), + identity: keypair.public_key().to_base58_string(), + }; + + let (mix_id, keypair) = test.add_dummy_mixnode_with_keypair(OWNER_UNBONDED, stake); + let mix_unbonded = DummyMixnode { + mix_id, + owner: Addr::unchecked(OWNER_UNBONDED), + identity: keypair.public_key().to_base58_string(), + }; + + let (mix_id, keypair) = test.add_dummy_mixnode_with_keypair(OWNER_UNBONDED_LEFTOVER, stake); + let mix_unbonded_leftover = DummyMixnode { + mix_id, + owner: Addr::unchecked(OWNER_UNBONDED_LEFTOVER), + identity: keypair.public_key().to_base58_string(), + }; // manually adjust delegation info as to indicate the rewarding information shouldnt get removed - let mut rewarding_details = test.mix_rewarding(mix_id_unbonded_leftover); + let mut rewarding_details = test.mix_rewarding(mix_unbonded_leftover.mix_id); rewarding_details.delegates = Decimal::raw(12345); rewarding_details.unique_delegations = 1; rewards_storage::MIXNODE_REWARDING .save( test.deps_mut().storage, - mix_id_unbonded_leftover, + mix_unbonded_leftover.mix_id, &rewarding_details, ) .unwrap(); - test.immediately_unbond_mixnode(mix_id_unbonded); - test.immediately_unbond_mixnode(mix_id_unbonded_leftover); - test.start_unbonding_mixnode(mix_id_unbonding); + test.immediately_unbond_mixnode(mix_unbonded.mix_id); + test.immediately_unbond_mixnode(mix_unbonded_leftover.mix_id); + test.start_unbonding_mixnode(mix_unbonding.mix_id); vec![ - mix_id_exists, - mix_id_unbonding, - mix_id_unbonded, - mix_id_unbonded_leftover, + mix_exists, + mix_unbonding, + mix_unbonded, + mix_unbonded_leftover, ] } @@ -195,37 +253,35 @@ pub(crate) mod tests { fn getting_mixnode_bond_by_owner() { let mut test = TestSetup::new(); - let owner_exists = Addr::unchecked(OWNER_EXISTS); - let owner_unbonding = Addr::unchecked(OWNER_UNBONDING); - let owner_unbonded = Addr::unchecked(OWNER_UNBONDED); - let owner_unbonded_leftover = Addr::unchecked(OWNER_UNBONDED_LEFTOVER); - - let ids = setup_mix_combinations(&mut test); - let mix_id_exists = ids[0]; - let mix_id_unbonding = ids[1]; + let nodes = setup_mix_combinations(&mut test, None); + let mix_exists = &nodes[0]; + let mix_unbonding = &nodes[1]; + let mix_unbonded = &nodes[2]; + let mix_unbonded_leftover = &nodes[3]; // if this is a normally bonded mixnode, all should be fine - let res = must_get_mixnode_bond_by_owner(test.deps().storage, &owner_exists).unwrap(); - assert_eq!(res.mix_id, mix_id_exists); + let res = must_get_mixnode_bond_by_owner(test.deps().storage, &mix_exists.owner).unwrap(); + assert_eq!(res.mix_id, mix_exists.mix_id); // if node is in the process of unbonding, we still should be capable of retrieving its details - let res = must_get_mixnode_bond_by_owner(test.deps().storage, &owner_unbonding).unwrap(); - assert_eq!(res.mix_id, mix_id_unbonding); + let res = + must_get_mixnode_bond_by_owner(test.deps().storage, &mix_unbonding.owner).unwrap(); + assert_eq!(res.mix_id, mix_unbonding.mix_id); // but if node has unbonded, the information is purged and query fails - let res = must_get_mixnode_bond_by_owner(test.deps().storage, &owner_unbonded); + let res = must_get_mixnode_bond_by_owner(test.deps().storage, &mix_unbonded.owner); assert_eq!( res, Err(MixnetContractError::NoAssociatedMixNodeBond { - owner: owner_unbonded + owner: mix_unbonded.owner.clone() }) ); - let res = must_get_mixnode_bond_by_owner(test.deps().storage, &owner_unbonded_leftover); + let res = must_get_mixnode_bond_by_owner(test.deps().storage, &mix_unbonded_leftover.owner); assert_eq!( res, Err(MixnetContractError::NoAssociatedMixNodeBond { - owner: owner_unbonded_leftover + owner: mix_unbonded_leftover.owner.clone() }) ); } @@ -234,26 +290,27 @@ pub(crate) mod tests { fn getting_mixnode_details_by_id() { let mut test = TestSetup::new(); - let ids = setup_mix_combinations(&mut test); - let mix_id_exists = ids[0]; - let mix_id_unbonding = ids[1]; - let mix_id_unbonded = ids[2]; - let mix_id_unbonded_leftover = ids[3]; + let nodes = setup_mix_combinations(&mut test, None); + let mix_exists = &nodes[0]; + let mix_unbonding = &nodes[1]; + let mix_unbonded = &nodes[2]; + let mix_unbonded_leftover = &nodes[3]; - let res = get_mixnode_details_by_id(test.deps().storage, mix_id_exists) + let res = get_mixnode_details_by_id(test.deps().storage, mix_exists.mix_id) .unwrap() .unwrap(); - assert_eq!(res.bond_information.mix_id, mix_id_exists); + assert_eq!(res.bond_information.mix_id, mix_exists.mix_id); - let res = get_mixnode_details_by_id(test.deps().storage, mix_id_unbonding) + let res = get_mixnode_details_by_id(test.deps().storage, mix_unbonding.mix_id) .unwrap() .unwrap(); - assert_eq!(res.bond_information.mix_id, mix_id_unbonding); + assert_eq!(res.bond_information.mix_id, mix_unbonding.mix_id); - let res = get_mixnode_details_by_id(test.deps().storage, mix_id_unbonded).unwrap(); + let res = get_mixnode_details_by_id(test.deps().storage, mix_unbonded.mix_id).unwrap(); assert!(res.is_none()); - let res = get_mixnode_details_by_id(test.deps().storage, mix_id_unbonded_leftover).unwrap(); + let res = + get_mixnode_details_by_id(test.deps().storage, mix_unbonded_leftover.mix_id).unwrap(); assert!(res.is_none()) } @@ -261,33 +318,69 @@ pub(crate) mod tests { fn getting_mixnode_details_by_owner() { let mut test = TestSetup::new(); - let owner_exists = Addr::unchecked(OWNER_EXISTS); - let owner_unbonding = Addr::unchecked(OWNER_UNBONDING); - let owner_unbonded = Addr::unchecked(OWNER_UNBONDED); - let owner_unbonded_leftover = Addr::unchecked(OWNER_UNBONDED_LEFTOVER); - - let ids = setup_mix_combinations(&mut test); - let mix_id_exists = ids[0]; - let mix_id_unbonding = ids[1]; + let nodes = setup_mix_combinations(&mut test, None); + let mix_exists = &nodes[0]; + let mix_unbonding = &nodes[1]; + let mix_unbonded = &nodes[2]; + let mix_unbonded_leftover = &nodes[3]; // if this is a normally bonded mixnode, all should be fine - let res = get_mixnode_details_by_owner(test.deps().storage, owner_exists) + let res = get_mixnode_details_by_owner(test.deps().storage, mix_exists.owner.clone()) .unwrap() .unwrap(); - assert_eq!(res.bond_information.mix_id, mix_id_exists); + assert_eq!(res.bond_information.mix_id, mix_exists.mix_id); // if node is in the process of unbonding, we still should be capable of retrieving its details - let res = get_mixnode_details_by_owner(test.deps().storage, owner_unbonding) + let res = get_mixnode_details_by_owner(test.deps().storage, mix_unbonding.owner.clone()) .unwrap() .unwrap(); - assert_eq!(res.bond_information.mix_id, mix_id_unbonding); + assert_eq!(res.bond_information.mix_id, mix_unbonding.mix_id); // but if node has unbonded, the information is purged and query fails - let res = get_mixnode_details_by_owner(test.deps().storage, owner_unbonded).unwrap(); + let res = + get_mixnode_details_by_owner(test.deps().storage, mix_unbonded.owner.clone()).unwrap(); assert!(res.is_none()); let res = - get_mixnode_details_by_owner(test.deps().storage, owner_unbonded_leftover).unwrap(); + get_mixnode_details_by_owner(test.deps().storage, mix_unbonded_leftover.owner.clone()) + .unwrap(); + assert!(res.is_none()); + } + + #[test] + fn getting_mixnode_details_by_identity() { + let mut test = TestSetup::new(); + + let nodes = setup_mix_combinations(&mut test, None); + let mix_exists = &nodes[0]; + let mix_unbonding = &nodes[1]; + let mix_unbonded = &nodes[2]; + let mix_unbonded_leftover = &nodes[3]; + + // if this is a normally bonded mixnode, all should be fine + let res = get_mixnode_details_by_identity(test.deps().storage, mix_exists.identity.clone()) + .unwrap() + .unwrap(); + assert_eq!(res.bond_information.mix_id, mix_exists.mix_id); + + // if node is in the process of unbonding, we still should be capable of retrieving its details + let res = + get_mixnode_details_by_identity(test.deps().storage, mix_unbonding.identity.clone()) + .unwrap() + .unwrap(); + assert_eq!(res.bond_information.mix_id, mix_unbonding.mix_id); + + // but if node has unbonded, the information is purged and query fails + let res = + get_mixnode_details_by_identity(test.deps().storage, mix_unbonded.identity.clone()) + .unwrap(); + assert!(res.is_none()); + + let res = get_mixnode_details_by_identity( + test.deps().storage, + mix_unbonded_leftover.identity.clone(), + ) + .unwrap(); assert!(res.is_none()); } diff --git a/contracts/mixnet/src/mixnodes/queries.rs b/contracts/mixnet/src/mixnodes/queries.rs index ba244d7c9f..c524ba2204 100644 --- a/contracts/mixnet/src/mixnodes/queries.rs +++ b/contracts/mixnet/src/mixnodes/queries.rs @@ -7,7 +7,10 @@ use crate::constants::{ MIXNODE_DETAILS_DEFAULT_RETRIEVAL_LIMIT, MIXNODE_DETAILS_MAX_RETRIEVAL_LIMIT, UNBONDED_MIXNODES_DEFAULT_RETRIEVAL_LIMIT, UNBONDED_MIXNODES_MAX_RETRIEVAL_LIMIT, }; -use crate::mixnodes::helpers::{get_mixnode_details_by_id, get_mixnode_details_by_owner}; +use crate::mixnodes::helpers::{ + attach_mix_details, get_mixnode_details_by_id, get_mixnode_details_by_identity, + get_mixnode_details_by_owner, +}; use crate::rewards::storage as rewards_storage; use cosmwasm_std::{Deps, Order, StdResult, Storage}; use cw_storage_plus::Bound; @@ -46,18 +49,12 @@ pub fn query_mixnode_bonds_paged( )) } -fn attach_rewarding_info( +fn attach_node_details( storage: &dyn Storage, read_bond: StdResult<(MixId, MixNodeBond)>, ) -> StdResult { match read_bond { - Ok((_, bond)) => { - // if we managed to read the bond we MUST be able to also read rewarding information. - // if we fail, this is a hard error and the query should definitely fail and we should investigate - // the reasons for that. - let mix_rewarding = rewards_storage::MIXNODE_REWARDING.load(storage, bond.mix_id)?; - Ok(MixNodeDetails::new(bond, mix_rewarding)) - } + Ok((_, bond)) => attach_mix_details(storage, bond), Err(err) => Err(err), } } @@ -76,7 +73,7 @@ pub fn query_mixnodes_details_paged( let nodes = storage::mixnode_bonds() .range(deps.storage, start, None, Order::Ascending) .take(limit) - .map(|res| attach_rewarding_info(deps.storage, res)) + .map(|res| attach_node_details(deps.storage, res)) .collect::>>()?; let start_next_after = nodes.last().map(|details| details.mix_id()); @@ -192,26 +189,12 @@ pub fn query_mixnode_details(deps: Deps<'_>, mix_id: MixId) -> StdResult, mix_identity: IdentityKey, ) -> StdResult> { - if let Some(bond_information) = storage::mixnode_bonds() - .idx - .identity_key - .item(deps.storage, mix_identity)? - .map(|record| record.1) - { - // if bond exists, rewarding details MUST also exist - let rewarding_details = - rewards_storage::MIXNODE_REWARDING.load(deps.storage, bond_information.mix_id)?; - Ok(Some(MixNodeDetails::new( - bond_information, - rewarding_details, - ))) - } else { - Ok(None) - } + get_mixnode_details_by_identity(deps.storage, mix_identity) } pub fn query_mixnode_rewarding_details( diff --git a/contracts/mixnet/src/mixnodes/storage.rs b/contracts/mixnet/src/mixnodes/storage.rs index dd9ba56212..fdf744e0bd 100644 --- a/contracts/mixnet/src/mixnodes/storage.rs +++ b/contracts/mixnet/src/mixnodes/storage.rs @@ -1,23 +1,27 @@ -// Copyright 2021-2022 - Nym Technologies SA +// Copyright 2021-2023 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 use crate::constants::{ LAYER_DISTRIBUTION_KEY, MIXNODES_IDENTITY_IDX_NAMESPACE, MIXNODES_OWNER_IDX_NAMESPACE, MIXNODES_PK_NAMESPACE, MIXNODES_SPHINX_IDX_NAMESPACE, NODE_ID_COUNTER_KEY, - UNBONDED_MIXNODES_IDENTITY_IDX_NAMESPACE, UNBONDED_MIXNODES_OWNER_IDX_NAMESPACE, - UNBONDED_MIXNODES_PK_NAMESPACE, + PENDING_MIXNODE_CHANGES_NAMESPACE, UNBONDED_MIXNODES_IDENTITY_IDX_NAMESPACE, + UNBONDED_MIXNODES_OWNER_IDX_NAMESPACE, UNBONDED_MIXNODES_PK_NAMESPACE, }; use cosmwasm_std::{StdResult, Storage}; -use cw_storage_plus::{Index, IndexList, IndexedMap, Item, MultiIndex, UniqueIndex}; +use cw_storage_plus::{Index, IndexList, IndexedMap, Item, Map, MultiIndex, UniqueIndex}; use mixnet_contract_common::error::MixnetContractError; -use mixnet_contract_common::mixnode::UnbondedMixnode; +use mixnet_contract_common::mixnode::{PendingMixNodeChanges, UnbondedMixnode}; use mixnet_contract_common::SphinxKey; use mixnet_contract_common::{Addr, IdentityKey, Layer, LayerDistribution, MixId, MixNodeBond}; +pub const LAYERS: Item<'_, LayerDistribution> = Item::new(LAYER_DISTRIBUTION_KEY); +pub const MIXNODE_ID_COUNTER: Item = Item::new(NODE_ID_COUNTER_KEY); +pub const PENDING_MIXNODE_CHANGES: Map = + Map::new(PENDING_MIXNODE_CHANGES_NAMESPACE); + // keeps track of `node_id -> IdentityKey, Owner, unbonding_height` so we'd known a bit more about past mixnodes // if we ever decide it's too bloaty, we can deprecate it and start removing all data in // subsequent migrations - pub(crate) struct UnbondedMixnodeIndex<'a> { pub(crate) owner: MultiIndex<'a, Addr, UnbondedMixnode, MixId>, @@ -48,9 +52,6 @@ pub(crate) fn unbonded_mixnodes<'a>( IndexedMap::new(UNBONDED_MIXNODES_PK_NAMESPACE, indexes) } -pub(crate) const LAYERS: Item<'_, LayerDistribution> = Item::new(LAYER_DISTRIBUTION_KEY); -pub const MIXNODE_ID_COUNTER: Item = Item::new(NODE_ID_COUNTER_KEY); - pub(crate) struct MixnodeBondIndex<'a> { pub(crate) owner: UniqueIndex<'a, Addr, MixNodeBond>, diff --git a/contracts/mixnet/src/mixnodes/transactions.rs b/contracts/mixnet/src/mixnodes/transactions.rs index 378179a282..9a7d2ac895 100644 --- a/contracts/mixnet/src/mixnodes/transactions.rs +++ b/contracts/mixnet/src/mixnodes/transactions.rs @@ -1,7 +1,19 @@ // Copyright 2021-2022 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use super::storage; +use cosmwasm_std::{coin, Addr, Coin, DepsMut, Env, MessageInfo, Response, Storage}; + +use mixnet_contract_common::error::MixnetContractError; +use mixnet_contract_common::events::{ + new_mixnode_bonding_event, new_mixnode_config_update_event, + new_mixnode_pending_cost_params_update_event, new_pending_mixnode_unbonding_event, + new_pending_pledge_decrease_event, new_pending_pledge_increase_event, +}; +use mixnet_contract_common::mixnode::{MixNodeConfigUpdate, MixNodeCostParams}; +use mixnet_contract_common::pending_events::{PendingEpochEventKind, PendingIntervalEventKind}; +use mixnet_contract_common::{Layer, MixId, MixNode}; +use nym_contracts_common::signing::MessageSignature; + use crate::interval::storage as interval_storage; use crate::interval::storage::push_new_interval_event; use crate::mixnet_contract_settings::storage as mixnet_params_storage; @@ -13,19 +25,11 @@ use crate::mixnodes::signature_helpers::verify_mixnode_bonding_signature; use crate::signing::storage as signing_storage; use crate::support::helpers::{ ensure_bonded, ensure_epoch_in_progress_state, ensure_is_authorized, ensure_no_existing_bond, - ensure_proxy_match, ensure_sent_by_vesting_contract, validate_pledge, + ensure_no_pending_pledge_changes, ensure_proxy_match, ensure_sent_by_vesting_contract, + validate_pledge, }; -use cosmwasm_std::{coin, Addr, Coin, DepsMut, Env, MessageInfo, Response, Storage}; -use mixnet_contract_common::error::MixnetContractError; -use mixnet_contract_common::events::{ - new_mixnode_bonding_event, new_mixnode_config_update_event, - new_mixnode_pending_cost_params_update_event, new_pending_mixnode_unbonding_event, - new_pending_pledge_increase_event, -}; -use mixnet_contract_common::mixnode::{MixNodeConfigUpdate, MixNodeCostParams}; -use mixnet_contract_common::pending_events::{PendingEpochEventKind, PendingIntervalEventKind}; -use mixnet_contract_common::{Layer, MixId, MixNode}; -use nym_contracts_common::signing::MessageSignature; + +use super::storage; pub(crate) fn update_mixnode_layer( mix_id: MixId, @@ -195,6 +199,7 @@ pub fn _try_increase_pledge( ) -> Result { let mix_details = get_mixnode_details_by_owner(deps.storage, owner.clone())? .ok_or(MixnetContractError::NoAssociatedMixNodeBond { owner })?; + let mut pending_changes = mix_details.pending_changes; let mix_id = mix_details.mix_id(); // increasing pledge is only allowed if the epoch is currently not in the process of being advanced @@ -202,6 +207,7 @@ pub fn _try_increase_pledge( ensure_proxy_match(&proxy, &mix_details.bond_information.proxy)?; ensure_bonded(&mix_details.bond_information)?; + ensure_no_pending_pledge_changes(&pending_changes)?; let rewarding_denom = rewarding_denom(deps.storage)?; let pledge_increase = validate_pledge(increase, coin(1, rewarding_denom))?; @@ -213,7 +219,95 @@ pub fn _try_increase_pledge( mix_id, amount: pledge_increase, }; - interval_storage::push_new_epoch_event(deps.storage, &env, epoch_event)?; + let epoch_event_id = interval_storage::push_new_epoch_event(deps.storage, &env, epoch_event)?; + pending_changes.pledge_change = Some(epoch_event_id); + storage::PENDING_MIXNODE_CHANGES.save(deps.storage, mix_id, &pending_changes)?; + + Ok(Response::new().add_event(cosmos_event)) +} + +pub fn try_decrease_pledge( + deps: DepsMut<'_>, + env: Env, + info: MessageInfo, + decrease_by: Coin, +) -> Result { + _try_decrease_pledge(deps, env, decrease_by, info.sender, None) +} + +pub fn try_decrease_pledge_on_behalf( + deps: DepsMut<'_>, + env: Env, + info: MessageInfo, + decrease_by: Coin, + owner: String, +) -> Result { + ensure_sent_by_vesting_contract(&info, deps.storage)?; + + let proxy = info.sender; + let owner = deps.api.addr_validate(&owner)?; + _try_decrease_pledge(deps, env, decrease_by, owner, Some(proxy)) +} + +pub fn _try_decrease_pledge( + deps: DepsMut<'_>, + env: Env, + decrease_by: Coin, + owner: Addr, + proxy: Option, +) -> Result { + let mix_details = get_mixnode_details_by_owner(deps.storage, owner.clone())? + .ok_or(MixnetContractError::NoAssociatedMixNodeBond { owner })?; + let mut pending_changes = mix_details.pending_changes; + let mix_id = mix_details.mix_id(); + + // decreasing pledge is only allowed if the epoch is currently not in the process of being advanced + ensure_epoch_in_progress_state(deps.storage)?; + + ensure_proxy_match(&proxy, &mix_details.bond_information.proxy)?; + ensure_bonded(&mix_details.bond_information)?; + ensure_no_pending_pledge_changes(&pending_changes)?; + + let minimum_pledge = mixnet_params_storage::minimum_mixnode_pledge(deps.storage)?; + + // check that the denomination is correct + if decrease_by.denom != minimum_pledge.denom { + return Err(MixnetContractError::WrongDenom { + received: decrease_by.denom, + expected: minimum_pledge.denom, + }); + } + + // also check if the request contains non-zero amount + // (otherwise it's a no-op and we should we waste gas when resolving events?) + if decrease_by.amount.is_zero() { + return Err(MixnetContractError::ZeroCoinAmount); + } + + // decreasing pledge can't result in the new pledge being lower than the minimum amount + let new_pledge_amount = mix_details + .original_pledge() + .amount + .saturating_sub(decrease_by.amount); + if new_pledge_amount < minimum_pledge.amount { + return Err(MixnetContractError::InvalidPledgeReduction { + current: mix_details.original_pledge().amount, + decrease_by: decrease_by.amount, + minimum: minimum_pledge.amount, + denom: minimum_pledge.denom, + }); + } + + let cosmos_event = new_pending_pledge_decrease_event(mix_id, &decrease_by); + + // push the event to execute it at the end of the epoch + let epoch_event = PendingEpochEventKind::DecreasePledge { + mix_id, + decrease_by, + }; + let epoch_event_id = interval_storage::push_new_epoch_event(deps.storage, &env, epoch_event)?; + pending_changes.pledge_change = Some(epoch_event_id); + storage::PENDING_MIXNODE_CHANGES.save(deps.storage, mix_id, &pending_changes)?; Ok(Response::new().add_event(cosmos_event)) } @@ -246,6 +340,9 @@ pub(crate) fn _try_remove_mixnode( proxy: Option, ) -> Result { let existing_bond = must_get_mixnode_bond_by_owner(deps.storage, &owner)?; + let pending_changes = storage::PENDING_MIXNODE_CHANGES + .may_load(deps.storage, existing_bond.mix_id)? + .unwrap_or_default(); // unbonding is only allowed if the epoch is currently not in the process of being advanced ensure_epoch_in_progress_state(deps.storage)?; @@ -254,6 +351,9 @@ pub(crate) fn _try_remove_mixnode( ensure_proxy_match(&proxy, &existing_bond.proxy)?; ensure_bonded(&existing_bond)?; + // if there are any pending requests to change the pledge, wait for them to resolve before allowing the unbonding + ensure_no_pending_pledge_changes(&pending_changes)?; + // set `is_unbonding` field let mut updated_bond = existing_bond.clone(); updated_bond.is_unbonding = true; @@ -392,18 +492,22 @@ pub(crate) fn _try_update_mixnode_cost_params( #[cfg(test)] pub mod tests { - use super::*; + use cosmwasm_std::testing::mock_info; + use cosmwasm_std::{Order, StdResult, Uint128}; + + use mixnet_contract_common::mixnode::PendingMixNodeChanges; + use mixnet_contract_common::{ + EpochState, EpochStatus, ExecuteMsg, Layer, LayerDistribution, Percent, + }; + use crate::contract::execute; use crate::mixnet_contract_settings::storage::minimum_mixnode_pledge; use crate::mixnodes::helpers::get_mixnode_details_by_id; use crate::support::tests::fixtures::{good_mixnode_pledge, TEST_COIN_DENOM}; use crate::support::tests::test_helpers::TestSetup; use crate::support::tests::{fixtures, test_helpers}; - use cosmwasm_std::testing::mock_info; - use cosmwasm_std::{Order, StdResult, Uint128}; - use mixnet_contract_common::{ - EpochState, EpochStatus, ExecuteMsg, Layer, LayerDistribution, Percent, - }; + + use super::*; #[test] fn mixnode_add() { @@ -601,7 +705,7 @@ pub mod tests { res, MixnetContractError::SenderIsNotVestingContract { received: illegal_proxy, - vesting_contract + vesting_contract, } ) } @@ -669,7 +773,7 @@ pub mod tests { res, Err(MixnetContractError::ProxyMismatch { existing: "None".to_string(), - incoming: vesting_contract.into_string() + incoming: vesting_contract.into_string(), }) ); @@ -717,11 +821,66 @@ pub mod tests { res, MixnetContractError::SenderIsNotVestingContract { received: illegal_proxy, - vesting_contract + vesting_contract, } ) } + #[test] + fn mixnode_remove_is_not_allowed_if_there_are_pending_pledge_changes() { + let mut test = TestSetup::new(); + let env = test.env(); + + // prior increase + let owner = "mix-owner1"; + test.add_dummy_mixnode(owner, None); + let sender = mock_info(owner, &[test.coin(1000)]); + try_increase_pledge(test.deps_mut(), env.clone(), sender.clone()).unwrap(); + + let res = try_remove_mixnode(test.deps_mut(), env.clone(), sender.clone()); + assert_eq!( + res, + Err(MixnetContractError::PendingPledgeChange { + pending_event_id: 1 + }) + ); + + // prior decrease + let owner = "mix-owner2"; + test.add_dummy_mixnode(owner, Some(Uint128::new(10000000000))); + let sender = mock_info(owner, &[]); + let amount = test.coin(1000); + try_decrease_pledge(test.deps_mut(), env.clone(), sender.clone(), amount).unwrap(); + + let sender = mock_info(owner, &[test.coin(1000)]); + let res = try_remove_mixnode(test.deps_mut(), env.clone(), sender.clone()); + assert_eq!( + res, + Err(MixnetContractError::PendingPledgeChange { + pending_event_id: 2 + }) + ); + + // artificial event + let owner = "mix-owner3"; + let mix_id = test.add_dummy_mixnode(owner, None); + let pending_change = PendingMixNodeChanges { + pledge_change: Some(1234), + }; + storage::PENDING_MIXNODE_CHANGES + .save(test.deps_mut().storage, mix_id, &pending_change) + .unwrap(); + + let sender = mock_info(owner, &[test.coin(1000)]); + let res = try_remove_mixnode(test.deps_mut(), env, sender); + assert_eq!( + res, + Err(MixnetContractError::PendingPledgeChange { + pending_event_id: 1234 + }) + ); + } + #[test] fn updating_mixnode_config() { let mut test = TestSetup::new(); @@ -760,7 +919,7 @@ pub mod tests { res, Err(MixnetContractError::ProxyMismatch { existing: "None".to_string(), - incoming: vesting_contract.into_string() + incoming: vesting_contract.into_string(), }) ); // "normal" update succeeds @@ -812,7 +971,7 @@ pub mod tests { res, MixnetContractError::SenderIsNotVestingContract { received: illegal_proxy, - vesting_contract + vesting_contract, } ) } @@ -895,7 +1054,7 @@ pub mod tests { res, Err(MixnetContractError::ProxyMismatch { existing: "None".to_string(), - incoming: vesting_contract.into_string() + incoming: vesting_contract.into_string(), }) ); // "normal" update succeeds @@ -918,7 +1077,7 @@ pub mod tests { assert_eq!( PendingIntervalEventKind::ChangeMixCostParams { mix_id, - new_costs: update.clone() + new_costs: update.clone(), }, event.1.kind ); @@ -967,7 +1126,7 @@ pub mod tests { res, MixnetContractError::SenderIsNotVestingContract { received: illegal_proxy, - vesting_contract + vesting_contract, } ) } @@ -1013,7 +1172,7 @@ pub mod tests { info_alice, mixnode1, cost_params.clone(), - sig1 + sig1, ) .is_ok()); @@ -1025,12 +1184,15 @@ pub mod tests { #[cfg(test)] mod increasing_mixnode_pledge { - use super::*; + use mixnet_contract_common::mixnode::PendingMixNodeChanges; + use mixnet_contract_common::{EpochState, EpochStatus}; + use crate::mixnodes::helpers::tests::{ setup_mix_combinations, OWNER_UNBONDED, OWNER_UNBONDED_LEFTOVER, OWNER_UNBONDING, }; use crate::support::tests::test_helpers::TestSetup; - use mixnet_contract_common::{EpochState, EpochStatus}; + + use super::*; #[test] fn cant_be_performed_if_epoch_transition_is_in_progress() { @@ -1108,7 +1270,7 @@ pub mod tests { res, Err(MixnetContractError::ProxyMismatch { existing: "None".to_string(), - incoming: "proxy".to_string() + incoming: "proxy".to_string(), }) ); @@ -1123,7 +1285,7 @@ pub mod tests { res, Err(MixnetContractError::ProxyMismatch { existing: "proxy".to_string(), - incoming: "None".to_string() + incoming: "None".to_string(), }) ); @@ -1138,7 +1300,7 @@ pub mod tests { res, Err(MixnetContractError::ProxyMismatch { existing: "proxy".to_string(), - incoming: "unrelated-proxy".to_string() + incoming: "unrelated-proxy".to_string(), }) ) } @@ -1154,8 +1316,8 @@ pub mod tests { let owner_unbonded = Addr::unchecked(OWNER_UNBONDED); let owner_unbonded_leftover = Addr::unchecked(OWNER_UNBONDED_LEFTOVER); - let ids = setup_mix_combinations(&mut test); - let mix_id_unbonding = ids[1]; + let ids = setup_mix_combinations(&mut test, None); + let mix_id_unbonding = ids[1].mix_id; let res = try_increase_pledge( test.deps_mut(), @@ -1214,11 +1376,66 @@ pub mod tests { res, Err(MixnetContractError::InsufficientPledge { received: test.coin(0), - minimum: test.coin(1) + minimum: test.coin(1), }) ) } + #[test] + fn is_not_allowed_if_there_are_pending_pledge_changes() { + let mut test = TestSetup::new(); + let env = test.env(); + + // prior increase + let owner = "mix-owner1"; + test.add_dummy_mixnode(owner, None); + let sender = mock_info(owner, &[test.coin(1000)]); + try_increase_pledge(test.deps_mut(), env.clone(), sender.clone()).unwrap(); + + let res = try_increase_pledge(test.deps_mut(), env.clone(), sender.clone()); + assert_eq!( + res, + Err(MixnetContractError::PendingPledgeChange { + pending_event_id: 1 + }) + ); + + // prior decrease + let owner = "mix-owner2"; + test.add_dummy_mixnode(owner, Some(Uint128::new(10000000000))); + let sender = mock_info(owner, &[]); + let amount = test.coin(1000); + try_decrease_pledge(test.deps_mut(), env.clone(), sender.clone(), amount).unwrap(); + + let sender = mock_info(owner, &[test.coin(1000)]); + let res = try_increase_pledge(test.deps_mut(), env.clone(), sender.clone()); + assert_eq!( + res, + Err(MixnetContractError::PendingPledgeChange { + pending_event_id: 2 + }) + ); + + // artificial event + let owner = "mix-owner3"; + let mix_id = test.add_dummy_mixnode(owner, None); + let pending_change = PendingMixNodeChanges { + pledge_change: Some(1234), + }; + storage::PENDING_MIXNODE_CHANGES + .save(test.deps_mut().storage, mix_id, &pending_change) + .unwrap(); + + let sender = mock_info(owner, &[test.coin(1000)]); + let res = try_increase_pledge(test.deps_mut(), env, sender); + assert_eq!( + res, + Err(MixnetContractError::PendingPledgeChange { + pending_event_id: 1234 + }) + ); + } + #[test] fn with_valid_information_creates_pending_event() { let mut test = TestSetup::new(); @@ -1238,38 +1455,398 @@ pub mod tests { events[0].kind, PendingEpochEventKind::PledgeMore { mix_id, - amount: test.coin(1000) + amount: test.coin(1000), } ); } + + #[test] + fn fails_for_illegal_proxy() { + let mut test = TestSetup::new(); + let env = test.env(); + + let illegal_proxy = Addr::unchecked("not-vesting-contract"); + let vesting_contract = test.vesting_contract(); + + let owner = "alice"; + + test.add_dummy_mixnode_with_illegal_proxy(owner, None, illegal_proxy.clone()); + + let res = try_increase_pledge_on_behalf( + test.deps_mut(), + env, + mock_info(illegal_proxy.as_ref(), &[coin(123, TEST_COIN_DENOM)]), + owner.to_string(), + ) + .unwrap_err(); + + assert_eq!( + res, + MixnetContractError::SenderIsNotVestingContract { + received: illegal_proxy, + vesting_contract, + } + ) + } } - #[test] - fn fails_for_illegal_proxy() { - let mut test = TestSetup::new(); - let env = test.env(); + #[cfg(test)] + mod decreasing_mixnode_pledge { + use mixnet_contract_common::mixnode::PendingMixNodeChanges; + use mixnet_contract_common::{EpochState, EpochStatus}; - let illegal_proxy = Addr::unchecked("not-vesting-contract"); - let vesting_contract = test.vesting_contract(); + use crate::mixnodes::helpers::tests::{ + setup_mix_combinations, OWNER_UNBONDED, OWNER_UNBONDED_LEFTOVER, OWNER_UNBONDING, + }; + use crate::support::tests::test_helpers::TestSetup; - let owner = "alice"; + use super::*; - test.add_dummy_mixnode_with_illegal_proxy(owner, None, illegal_proxy.clone()); + #[test] + fn cant_be_performed_if_epoch_transition_is_in_progress() { + let bad_states = vec![ + EpochState::Rewarding { + last_rewarded: 0, + final_node_id: 0, + }, + EpochState::ReconcilingEvents, + EpochState::AdvancingEpoch, + ]; - let res = try_increase_pledge_on_behalf( - test.deps_mut(), - env, - mock_info(illegal_proxy.as_ref(), &[coin(123, TEST_COIN_DENOM)]), - owner.to_string(), - ) - .unwrap_err(); + for bad_state in bad_states { + let mut test = TestSetup::new(); + let env = test.env(); + let owner = "mix-owner"; + let decrease = test.coin(1000); - assert_eq!( - res, - MixnetContractError::SenderIsNotVestingContract { - received: illegal_proxy, - vesting_contract + test.add_dummy_mixnode(owner, Some(Uint128::new(100_000_000_000))); + + let mut status = EpochStatus::new(test.rewarding_validator().sender); + status.state = bad_state; + interval_storage::save_current_epoch_status(test.deps_mut().storage, &status) + .unwrap(); + + let sender = mock_info(owner, &[]); + let res = try_decrease_pledge(test.deps_mut(), env, sender, decrease); + + assert!(matches!( + res, + Err(MixnetContractError::EpochAdvancementInProgress { .. }) + )); } - ) + } + + #[test] + fn is_not_allowed_if_account_doesnt_own_mixnode() { + let mut test = TestSetup::new(); + let env = test.env(); + let sender = mock_info("not-mix-owner", &[]); + let decrease = test.coin(1000); + + let res = try_decrease_pledge(test.deps_mut(), env, sender, decrease); + assert_eq!( + res, + Err(MixnetContractError::NoAssociatedMixNodeBond { + owner: Addr::unchecked("not-mix-owner") + }) + ) + } + + #[test] + fn is_not_allowed_if_theres_proxy_mismatch() { + let mut test = TestSetup::new(); + let env = test.env(); + + let owner_without_proxy = Addr::unchecked("no-proxy"); + let owner_with_proxy = Addr::unchecked("with-proxy"); + let proxy = Addr::unchecked("proxy"); + let wrong_proxy = Addr::unchecked("unrelated-proxy"); + + // just to make sure that after decrease the value would still be above the minimum + let stake = Uint128::new(100_000_000_000); + let decrease = test.coin(1000); + + test.add_dummy_mixnode(owner_without_proxy.as_str(), Some(stake)); + test.add_dummy_mixnode_with_illegal_proxy( + owner_with_proxy.as_str(), + Some(stake), + proxy.clone(), + ); + + let res = _try_decrease_pledge( + test.deps_mut(), + env.clone(), + decrease.clone(), + owner_without_proxy.clone(), + Some(proxy), + ); + assert_eq!( + res, + Err(MixnetContractError::ProxyMismatch { + existing: "None".to_string(), + incoming: "proxy".to_string(), + }) + ); + + let res = _try_decrease_pledge( + test.deps_mut(), + env.clone(), + decrease.clone(), + owner_with_proxy.clone(), + None, + ); + assert_eq!( + res, + Err(MixnetContractError::ProxyMismatch { + existing: "proxy".to_string(), + incoming: "None".to_string(), + }) + ); + + let res = _try_decrease_pledge( + test.deps_mut(), + env, + decrease, + owner_with_proxy.clone(), + Some(wrong_proxy), + ); + assert_eq!( + res, + Err(MixnetContractError::ProxyMismatch { + existing: "proxy".to_string(), + incoming: "unrelated-proxy".to_string(), + }) + ) + } + + #[test] + fn is_not_allowed_if_mixnode_has_unbonded_or_is_unbonding() { + let mut test = TestSetup::new(); + let env = test.env(); + + // just to make sure that after decrease the value would still be above the minimum + let stake = Uint128::new(100_000_000_000); + let decrease = test.coin(1000); + + // TODO: I dislike this cross-test access, but it provides us with exactly what we need + // perhaps it should be refactored a bit? + let owner_unbonding = Addr::unchecked(OWNER_UNBONDING); + let owner_unbonded = Addr::unchecked(OWNER_UNBONDED); + let owner_unbonded_leftover = Addr::unchecked(OWNER_UNBONDED_LEFTOVER); + + let ids = setup_mix_combinations(&mut test, Some(stake)); + let mix_id_unbonding = ids[1].mix_id; + + let res = try_decrease_pledge( + test.deps_mut(), + env.clone(), + mock_info(owner_unbonding.as_str(), &[]), + decrease.clone(), + ); + assert_eq!( + res, + Err(MixnetContractError::MixnodeIsUnbonding { + mix_id: mix_id_unbonding + }) + ); + + // if the nodes are gone we treat them as tey never existed in the first place + // (regardless of if there's some leftover data) + let res = try_decrease_pledge( + test.deps_mut(), + env.clone(), + mock_info(owner_unbonded_leftover.as_str(), &[]), + decrease.clone(), + ); + assert_eq!( + res, + Err(MixnetContractError::NoAssociatedMixNodeBond { + owner: owner_unbonded_leftover + }) + ); + + let res = try_decrease_pledge( + test.deps_mut(), + env, + mock_info(owner_unbonded.as_str(), &[]), + decrease, + ); + assert_eq!( + res, + Err(MixnetContractError::NoAssociatedMixNodeBond { + owner: owner_unbonded + }) + ) + } + + #[test] + fn is_not_allowed_if_it_would_result_going_below_minimum_pledge() { + let mut test = TestSetup::new(); + let env = test.env(); + let owner = "mix-owner"; + + let minimum_pledge = minimum_mixnode_pledge(test.deps().storage).unwrap(); + let pledge_amount = minimum_pledge.amount + Uint128::new(100); + let pledged = test.coin(pledge_amount.u128()); + test.add_dummy_mixnode(owner, Some(pledge_amount)); + + let invalid_decrease = test.coin(150); + let valid_decrease = test.coin(50); + + let sender = mock_info(owner, &[]); + let res = try_decrease_pledge( + test.deps_mut(), + env.clone(), + sender.clone(), + invalid_decrease.clone(), + ); + assert_eq!( + res, + Err(MixnetContractError::InvalidPledgeReduction { + current: pledged.amount, + decrease_by: invalid_decrease.amount, + minimum: minimum_pledge.amount, + denom: minimum_pledge.denom, + }) + ); + + let res = try_decrease_pledge(test.deps_mut(), env.clone(), sender, valid_decrease); + assert!(res.is_ok()) + } + + #[test] + fn provided_amount_has_to_be_nonzero() { + let mut test = TestSetup::new(); + let env = test.env(); + + let stake = Uint128::new(100_000_000_000); + let decrease = test.coin(0); + + let owner = "mix-owner"; + test.add_dummy_mixnode(owner, Some(stake)); + + let sender = mock_info(owner, &[]); + let res = try_decrease_pledge(test.deps_mut(), env, sender, decrease.clone()); + assert_eq!(res, Err(MixnetContractError::ZeroCoinAmount)) + } + + #[test] + fn is_not_allowed_if_there_are_pending_pledge_changes() { + let mut test = TestSetup::new(); + let env = test.env(); + let stake = Uint128::new(100_000_000_000); + let decrease = test.coin(1000); + + // prior increase + let owner = "mix-owner1"; + test.add_dummy_mixnode(owner, Some(stake)); + let sender = mock_info(owner, &[test.coin(1000)]); + try_increase_pledge(test.deps_mut(), env.clone(), sender.clone()).unwrap(); + + let res = try_decrease_pledge(test.deps_mut(), env.clone(), sender, decrease.clone()); + assert_eq!( + res, + Err(MixnetContractError::PendingPledgeChange { + pending_event_id: 1 + }) + ); + + // prior decrease + let owner = "mix-owner2"; + test.add_dummy_mixnode(owner, Some(stake)); + let sender = mock_info(owner, &[]); + let amount = test.coin(1000); + try_decrease_pledge(test.deps_mut(), env.clone(), sender.clone(), amount).unwrap(); + + let sender = mock_info(owner, &[test.coin(1000)]); + let res = try_decrease_pledge(test.deps_mut(), env.clone(), sender, decrease.clone()); + assert_eq!( + res, + Err(MixnetContractError::PendingPledgeChange { + pending_event_id: 2 + }) + ); + + // artificial event + let owner = "mix-owner3"; + let mix_id = test.add_dummy_mixnode(owner, Some(stake)); + let pending_change = PendingMixNodeChanges { + pledge_change: Some(1234), + }; + storage::PENDING_MIXNODE_CHANGES + .save(test.deps_mut().storage, mix_id, &pending_change) + .unwrap(); + + let sender = mock_info(owner, &[test.coin(1000)]); + let res = try_decrease_pledge(test.deps_mut(), env, sender, decrease); + assert_eq!( + res, + Err(MixnetContractError::PendingPledgeChange { + pending_event_id: 1234 + }) + ); + } + + #[test] + fn with_valid_information_creates_pending_event() { + let mut test = TestSetup::new(); + let env = test.env(); + + // just to make sure that after decrease the value would still be above the minimum + let stake = Uint128::new(100_000_000_000); + let decrease = test.coin(1000); + + let owner = "mix-owner"; + let mix_id = test.add_dummy_mixnode(owner, Some(stake)); + + let events = test.pending_epoch_events(); + assert!(events.is_empty()); + + let sender = mock_info(owner, &[]); + try_decrease_pledge(test.deps_mut(), env, sender, decrease.clone()).unwrap(); + + let events = test.pending_epoch_events(); + + assert_eq!( + events[0].kind, + PendingEpochEventKind::DecreasePledge { + mix_id, + decrease_by: decrease, + } + ); + } + + #[test] + fn fails_for_illegal_proxy() { + let mut test = TestSetup::new(); + let env = test.env(); + + let stake = Uint128::new(100_000_000_000); + let decrease = test.coin(1000); + + let illegal_proxy = Addr::unchecked("not-vesting-contract"); + let vesting_contract = test.vesting_contract(); + + let owner = "alice"; + + test.add_dummy_mixnode_with_illegal_proxy(owner, Some(stake), illegal_proxy.clone()); + + let res = try_decrease_pledge_on_behalf( + test.deps_mut(), + env, + mock_info(illegal_proxy.as_ref(), &[coin(123, TEST_COIN_DENOM)]), + decrease, + owner.to_string(), + ) + .unwrap_err(); + + assert_eq!( + res, + MixnetContractError::SenderIsNotVestingContract { + received: illegal_proxy, + vesting_contract, + } + ) + } } } diff --git a/contracts/mixnet/src/queued_migrations.rs b/contracts/mixnet/src/queued_migrations.rs index 8df3ef5d86..732c4c3be2 100644 --- a/contracts/mixnet/src/queued_migrations.rs +++ b/contracts/mixnet/src/queued_migrations.rs @@ -1,2 +1,42 @@ // Copyright 2022-2023 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 + +use crate::interval::storage as interval_storage; +use crate::mixnodes::storage as mixnodes_storage; +use cosmwasm_std::DepsMut; +use mixnet_contract_common::error::MixnetContractError; +use mixnet_contract_common::mixnode::PendingMixNodeChanges; +use mixnet_contract_common::PendingEpochEventKind; +use std::collections::BTreeMap; + +pub fn insert_pending_pledge_changes(deps: DepsMut<'_>) -> Result<(), MixnetContractError> { + let last_executed = interval_storage::LAST_PROCESSED_EPOCH_EVENT.load(deps.storage)?; + let last_inserted = interval_storage::EPOCH_EVENT_ID_COUNTER.load(deps.storage)?; + + let mut new_pending = BTreeMap::new(); + + for event_id in last_executed + 1..=last_inserted { + let event = interval_storage::PENDING_EPOCH_EVENTS.load(deps.storage, event_id)?; + match event.kind { + PendingEpochEventKind::PledgeMore { mix_id, .. } + | PendingEpochEventKind::DecreasePledge { mix_id, .. } => { + if new_pending.insert(mix_id, event_id).is_some() { + return Err(MixnetContractError::FailedMigration { comment: format!("mixnode {mix_id} has more than a single pledge change pending for this epoch. Run this migration again after the epoch has finished.") }); + } + } + _ => (), + } + } + + for (mix_id, event_id) in new_pending { + mixnodes_storage::PENDING_MIXNODE_CHANGES.save( + deps.storage, + mix_id, + &PendingMixNodeChanges { + pledge_change: Some(event_id), + }, + )?; + } + + Ok(()) +} diff --git a/contracts/mixnet/src/rewards/transactions.rs b/contracts/mixnet/src/rewards/transactions.rs index a9ef3b6f5a..43854f62e4 100644 --- a/contracts/mixnet/src/rewards/transactions.rs +++ b/contracts/mixnet/src/rewards/transactions.rs @@ -1,20 +1,8 @@ // Copyright 2021-2023 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use super::storage; -use crate::delegations::storage as delegations_storage; -use crate::interval::storage as interval_storage; -use crate::interval::storage::{push_new_epoch_event, push_new_interval_event}; -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::helpers; -use crate::rewards::helpers::update_and_save_last_rewarded; -use crate::support::helpers::{ - ensure_bonded, ensure_can_advance_epoch, ensure_epoch_in_progress_state, ensure_is_owner, - ensure_proxy_match, ensure_sent_by_vesting_contract, send_to_proxy_or_owner, -}; use cosmwasm_std::{wasm_execute, Addr, DepsMut, Env, MessageInfo, Response}; + use mixnet_contract_common::error::MixnetContractError; use mixnet_contract_common::events::{ new_active_set_update_event, new_mix_rewarding_event, @@ -30,6 +18,21 @@ use mixnet_contract_common::reward_params::{ use mixnet_contract_common::{Delegation, EpochState, MixId}; use vesting_contract_common::messages::ExecuteMsg as VestingContractExecuteMsg; +use crate::delegations::storage as delegations_storage; +use crate::interval::storage as interval_storage; +use crate::interval::storage::{push_new_epoch_event, push_new_interval_event}; +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::helpers; +use crate::rewards::helpers::update_and_save_last_rewarded; +use crate::support::helpers::{ + ensure_bonded, ensure_can_advance_epoch, ensure_epoch_in_progress_state, ensure_is_owner, + ensure_proxy_match, ensure_sent_by_vesting_contract, send_to_proxy_or_owner, +}; + +use super::storage; + pub(crate) fn try_reward_mixnode( deps: DepsMut<'_>, env: Env, @@ -233,23 +236,22 @@ pub(crate) fn _try_withdraw_delegator_reward( mix_id, address: owner.into_string(), proxy: proxy.map(Addr::into_string), - }) + }); } Some(delegation) => delegation, }; // grab associated mixnode rewarding details let mix_rewarding = - storage::MIXNODE_REWARDING.may_load(deps.storage, mix_id)?.ok_or(MixnetContractError::InconsistentState { - comment: "mixnode rewarding got removed from the storage whilst there's still an existing delegation" - .into(), - })?; + storage::MIXNODE_REWARDING.may_load(deps.storage, mix_id)?.ok_or(MixnetContractError::inconsistent_state( + "mixnode rewarding got removed from the storage whilst there's still an existing delegation" + ))?; // see if the mixnode is not in the process of unbonding or whether it has already unbonded // (in that case the expected path of getting your tokens back is via undelegation) match mixnodes_storage::mixnode_bonds().may_load(deps.storage, mix_id)? { Some(mix_bond) if mix_bond.is_unbonding => { - return Err(MixnetContractError::MixnodeIsUnbonding { mix_id }) + return Err(MixnetContractError::MixnodeIsUnbonding { mix_id }); } None => return Err(MixnetContractError::MixnodeHasUnbonded { mix_id }), _ => (), @@ -376,17 +378,17 @@ pub(crate) fn try_update_rewarding_params( #[cfg(test)] pub mod tests { - use super::*; + use cosmwasm_std::testing::mock_info; + use crate::mixnodes::storage as mixnodes_storage; use crate::support::tests::test_helpers; - use cosmwasm_std::testing::mock_info; + + use super::*; #[cfg(test)] mod mixnode_rewarding { - use super::*; - use crate::interval::pending_events; - use crate::support::tests::test_helpers::{find_attribute, TestSetup}; use cosmwasm_std::{Decimal, Uint128}; + use mixnet_contract_common::events::{ MixnetEventType, BOND_NOT_FOUND_VALUE, DELEGATES_REWARD_KEY, NO_REWARD_REASON_KEY, OPERATOR_REWARD_KEY, PRIOR_DELEGATES_KEY, PRIOR_UNIT_REWARD_KEY, @@ -395,11 +397,17 @@ pub mod tests { use mixnet_contract_common::helpers::compare_decimals; use mixnet_contract_common::{EpochStatus, RewardedSetNodeStatus}; + use crate::interval::pending_events; + use crate::support::tests::test_helpers::{find_attribute, TestSetup}; + + use super::*; + #[cfg(test)] mod epoch_state_is_correctly_updated { - use super::*; use mixnet_contract_common::EpochState; + use super::*; + #[test] fn when_target_mixnode_unbonded() { let mut test = TestSetup::new(); @@ -459,7 +467,7 @@ pub mod tests { assert_eq!( EpochState::Rewarding { last_rewarded: mix_id_unbonded, - final_node_id: mix_id_never_existed + final_node_id: mix_id_never_existed, }, interval_storage::current_epoch_status(test.deps().storage) .unwrap() @@ -477,7 +485,7 @@ pub mod tests { assert_eq!( EpochState::Rewarding { last_rewarded: mix_id_unbonded_leftover, - final_node_id: mix_id_never_existed + final_node_id: mix_id_never_existed, }, interval_storage::current_epoch_status(test.deps().storage) .unwrap() @@ -578,7 +586,7 @@ pub mod tests { assert_eq!( EpochState::Rewarding { last_rewarded: mix_id, - final_node_id: 100 + final_node_id: 100, }, current_state ) @@ -1399,12 +1407,15 @@ pub mod tests { #[cfg(test)] mod withdrawing_delegator_reward { - use super::*; + use cosmwasm_std::{coin, BankMsg, CosmosMsg, Decimal, Uint128}; + + use mixnet_contract_common::rewarding::helpers::truncate_reward_amount; + use crate::interval::pending_events; use crate::support::tests::fixtures::TEST_COIN_DENOM; use crate::support::tests::test_helpers::{assert_eq_with_leeway, TestSetup}; - use cosmwasm_std::{coin, BankMsg, CosmosMsg, Decimal, Uint128}; - use mixnet_contract_common::rewarding::helpers::truncate_reward_amount; + + use super::*; #[test] fn can_only_be_done_if_delegation_exists() { @@ -1772,7 +1783,7 @@ pub mod tests { res, MixnetContractError::SenderIsNotVestingContract { received: illegal_proxy, - vesting_contract + vesting_contract, } ) } @@ -1780,11 +1791,13 @@ pub mod tests { #[cfg(test)] mod withdrawing_operator_reward { - use super::*; + use cosmwasm_std::{coin, BankMsg, CosmosMsg, Uint128}; + use crate::interval::pending_events; use crate::support::tests::fixtures::TEST_COIN_DENOM; use crate::support::tests::test_helpers::TestSetup; - use cosmwasm_std::{coin, BankMsg, CosmosMsg, Uint128}; + + use super::*; #[test] fn can_only_be_done_if_bond_exists() { @@ -1929,7 +1942,7 @@ pub mod tests { res, MixnetContractError::SenderIsNotVestingContract { received: illegal_proxy, - vesting_contract + vesting_contract, } ) } @@ -1937,10 +1950,12 @@ pub mod tests { #[cfg(test)] mod updating_active_set { - use super::*; - use crate::support::tests::test_helpers::TestSetup; use mixnet_contract_common::{EpochState, EpochStatus}; + use crate::support::tests::test_helpers::TestSetup; + + use super::*; + #[test] fn cant_be_performed_if_epoch_transition_is_in_progress_unless_forced() { let bad_states = vec![ @@ -2140,11 +2155,14 @@ pub mod tests { #[cfg(test)] mod updating_rewarding_params { - use super::*; - use crate::support::tests::test_helpers::{assert_decimals, TestSetup}; use cosmwasm_std::Decimal; + use mixnet_contract_common::{EpochState, EpochStatus}; + use crate::support::tests::test_helpers::{assert_decimals, TestSetup}; + + use super::*; + #[test] fn cant_be_performed_if_epoch_transition_is_in_progress_unless_forced() { let bad_states = vec![ diff --git a/contracts/mixnet/src/support/helpers.rs b/contracts/mixnet/src/support/helpers.rs index c992e75145..e39541f599 100644 --- a/contracts/mixnet/src/support/helpers.rs +++ b/contracts/mixnet/src/support/helpers.rs @@ -6,6 +6,7 @@ use crate::mixnet_contract_settings::storage as mixnet_params_storage; use crate::mixnodes::storage as mixnodes_storage; use cosmwasm_std::{wasm_execute, Addr, BankMsg, Coin, CosmosMsg, MessageInfo, Response, Storage}; use mixnet_contract_common::error::MixnetContractError; +use mixnet_contract_common::mixnode::PendingMixNodeChanges; use mixnet_contract_common::{EpochState, EpochStatus, IdentityKeyRef, MixId, MixNodeBond}; use vesting_contract_common::messages::ExecuteMsg as VestingContractExecuteMsg; @@ -46,6 +47,14 @@ where owner: String, amount: Coin, ) -> Result; + + fn maybe_add_track_vesting_decrease_mixnode_pledge( + self, + storage: &dyn Storage, + proxy: Option, + owner: String, + amount: Coin, + ) -> Result; } impl VestingTracking for Response { @@ -115,6 +124,33 @@ impl VestingTracking for Response { Ok(self) } } + + fn maybe_add_track_vesting_decrease_mixnode_pledge( + self, + storage: &dyn Storage, + proxy: Option, + owner: String, + amount: Coin, + ) -> Result { + if let Some(proxy) = proxy { + let vesting_contract = mixnet_params_storage::vesting_contract_address(storage)?; + + // exactly the same possible halting behaviour as in `maybe_add_track_vesting_undelegation_message`. + if proxy != vesting_contract { + return Err(MixnetContractError::ProxyIsNotVestingContract { + received: proxy, + vesting_contract, + }); + } + + let msg = VestingContractExecuteMsg::TrackDecreasePledge { owner, amount }; + let track_decrease_pledge_message = wasm_execute(proxy, &msg, vec![])?; + Ok(self.add_message(track_decrease_pledge_message)) + } else { + // there's no proxy so nothing to do + Ok(self) + } + } } // pub fn debug_with_visibility>(api: &dyn Api, msg: S) { @@ -342,6 +378,15 @@ pub(crate) fn ensure_bonded(bond: &MixNodeBond) -> Result<(), MixnetContractErro Ok(()) } +pub(crate) fn ensure_no_pending_pledge_changes( + pending_changes: &PendingMixNodeChanges, +) -> Result<(), MixnetContractError> { + if let Some(pending_event_id) = pending_changes.pledge_change { + return Err(MixnetContractError::PendingPledgeChange { pending_event_id }); + } + Ok(()) +} + // check if the target address has already bonded a mixnode or gateway, // in either case, return an appropriate error pub(crate) fn ensure_no_existing_bond( diff --git a/contracts/mixnet/src/support/tests/mod.rs b/contracts/mixnet/src/support/tests/mod.rs index 1d66286e32..f465e72ede 100644 --- a/contracts/mixnet/src/support/tests/mod.rs +++ b/contracts/mixnet/src/support/tests/mod.rs @@ -45,7 +45,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, Addr, Api, BankMsg, CosmosMsg, Storage}; + use cosmwasm_std::{coin, coins, Addr, Api, BankMsg, CosmosMsg, Storage}; use cosmwasm_std::{Coin, Order}; use cosmwasm_std::{Decimal, Empty, MemoryStorage}; use cosmwasm_std::{Deps, OwnedDeps}; @@ -62,7 +62,7 @@ pub mod test_helpers { use mixnet_contract_common::rewarding::simulator::Simulator; use mixnet_contract_common::rewarding::RewardDistribution; use mixnet_contract_common::{ - construct_family_join_permit, Delegation, EpochState, EpochStatus, Gateway, + construct_family_join_permit, Delegation, EpochEventId, EpochState, EpochStatus, Gateway, GatewayBondingPayload, IdentityKey, IdentityKeyRef, InitialRewardingParams, InstantiateMsg, Interval, MixId, MixNode, MixNodeBond, MixnodeBondingPayload, Percent, RewardedSetNodeStatus, SignableGatewayBondingMsg, SignableMixNodeBondingMsg, @@ -159,6 +159,10 @@ pub mod test_helpers { coin(amount, rewarding_denom(self.deps().storage).unwrap()) } + pub fn coins(&self, amount: u128) -> Vec { + coins(amount, rewarding_denom(self.deps().storage).unwrap()) + } + pub fn current_interval(&self) -> Interval { interval_storage::current_interval(self.deps().storage).unwrap() } @@ -243,6 +247,17 @@ pub mod test_helpers { (mix_id, keys) } + pub fn set_pending_pledge_change(&mut self, mix_id: MixId, event_id: Option) { + let mut changes = mixnodes_storage::PENDING_MIXNODE_CHANGES + .load(self.deps().storage, mix_id) + .unwrap_or_default(); + changes.pledge_change = Some(event_id.unwrap_or(12345)); + + mixnodes_storage::PENDING_MIXNODE_CHANGES + .save(self.deps_mut().storage, mix_id, &changes) + .unwrap(); + } + pub fn add_dummy_mixnode(&mut self, owner: &str, stake: Option) -> MixId { let stake = self.make_mix_pledge(stake); let (mixnode, owner_signature, _) = diff --git a/contracts/vesting/src/contract.rs b/contracts/vesting/src/contract.rs index 268068b53c..54db2d9464 100644 --- a/contracts/vesting/src/contract.rs +++ b/contracts/vesting/src/contract.rs @@ -160,10 +160,14 @@ pub fn execute( deps, ), ExecuteMsg::PledgeMore { amount } => try_pledge_more(deps, env, info, amount), + ExecuteMsg::DecreasePledge { amount } => try_decrease_pledge(deps, info, amount), ExecuteMsg::UnbondMixnode {} => try_unbond_mixnode(info, deps), ExecuteMsg::TrackUnbondMixnode { owner, amount } => { try_track_unbond_mixnode(&owner, amount, info, deps) } + ExecuteMsg::TrackDecreasePledge { owner, amount } => { + try_track_decrease_mixnode_pledge(&owner, amount, info, deps) + } ExecuteMsg::BondGateway { gateway, owner_signature, diff --git a/contracts/vesting/src/errors.rs b/contracts/vesting/src/errors.rs index 195e12e697..2b92be0c8c 100644 --- a/contracts/vesting/src/errors.rs +++ b/contracts/vesting/src/errors.rs @@ -1,5 +1,5 @@ use crate::storage::AccountStorageKey; -use cosmwasm_std::{Addr, OverflowError, StdError, Uint128}; +use cosmwasm_std::{Addr, Coin, OverflowError, StdError, Uint128}; use mixnet_contract_common::MixId; use thiserror::Error; @@ -58,6 +58,9 @@ pub enum ContractError { #[error("VESTING ({}): No bond found for account {0}", line!())] NoBondFound(String), + #[error("VESTING: Attempted to reduce mixnode bond pledge below zero! The current pledge is {current} and we attempted to reduce it by {decrease_by}.")] + InvalidBondPledgeReduction { current: Coin, decrease_by: Coin }, + #[error("VESTING ({}): Action can only be executed by account owner -> {0}", line!())] NotOwner(String), diff --git a/contracts/vesting/src/lib.rs b/contracts/vesting/src/lib.rs index 702530b927..d2238d4d15 100644 --- a/contracts/vesting/src/lib.rs +++ b/contracts/vesting/src/lib.rs @@ -5,7 +5,7 @@ #![warn(clippy::unwrap_used)] pub mod contract; -mod errors; +pub mod errors; mod queries; mod queued_migrations; mod storage; diff --git a/contracts/vesting/src/storage.rs b/contracts/vesting/src/storage.rs index d1c5af31e3..eb49d3c434 100644 --- a/contracts/vesting/src/storage.rs +++ b/contracts/vesting/src/storage.rs @@ -1,7 +1,7 @@ use crate::errors::ContractError; use crate::vesting::Account; -use cosmwasm_std::Order; use cosmwasm_std::{Addr, Api, Storage, Uint128}; +use cosmwasm_std::{Coin, Order}; use cw_storage_plus::{Item, Map}; use mixnet_contract_common::{IdentityKey, MixId}; use vesting_contract_common::PledgeData; @@ -157,6 +157,24 @@ pub fn save_bond_pledge( Ok(()) } +pub fn decrease_bond_pledge( + key: AccountStorageKey, + amount: Coin, + storage: &mut dyn Storage, +) -> Result<(), ContractError> { + let mut existing = BOND_PLEDGES.load(storage, key)?; + if existing.amount.amount <= amount.amount { + // this shouldn't be possible! + // (but check for it anyway... just in case) + return Err(ContractError::InvalidBondPledgeReduction { + current: existing.amount, + decrease_by: amount, + }); + } + existing.amount.amount -= amount.amount; + save_bond_pledge(key, &existing, storage) +} + pub fn load_gateway_pledge( key: AccountStorageKey, storage: &dyn Storage, diff --git a/contracts/vesting/src/traits/bonding_account.rs b/contracts/vesting/src/traits/bonding_account.rs index 402b156924..bf5cac3bf5 100644 --- a/contracts/vesting/src/traits/bonding_account.rs +++ b/contracts/vesting/src/traits/bonding_account.rs @@ -27,6 +27,12 @@ pub trait MixnodeBondingAccount { storage: &mut dyn Storage, ) -> Result; + fn try_decrease_mixnode_pledge( + &self, + amount: Coin, + storage: &mut dyn Storage, + ) -> Result; + fn try_unbond_mixnode(&self, storage: &dyn Storage) -> Result; fn try_track_unbond_mixnode( @@ -35,6 +41,12 @@ pub trait MixnodeBondingAccount { storage: &mut dyn Storage, ) -> Result<(), ContractError>; + fn try_track_decrease_mixnode_pledge( + &self, + amount: Coin, + storage: &mut dyn Storage, + ) -> Result<(), ContractError>; + fn try_update_mixnode_config( &self, new_config: MixNodeConfigUpdate, diff --git a/contracts/vesting/src/transactions.rs b/contracts/vesting/src/transactions.rs index c43e084cff..d4168fa79a 100644 --- a/contracts/vesting/src/transactions.rs +++ b/contracts/vesting/src/transactions.rs @@ -19,8 +19,8 @@ use mixnet_contract_common::{ use vesting_contract_common::events::{ new_ownership_transfer_event, new_periodic_vesting_account_event, new_staking_address_update_event, new_track_gateway_unbond_event, - new_track_mixnode_unbond_event, new_track_reward_event, new_track_undelegation_event, - new_vested_coins_withdraw_event, + new_track_mixnode_pledge_decrease_event, new_track_mixnode_unbond_event, + new_track_reward_event, new_track_undelegation_event, new_vested_coins_withdraw_event, }; use vesting_contract_common::messages::VestingSpecification; use vesting_contract_common::PledgeCap; @@ -278,6 +278,19 @@ pub fn try_pledge_more( account.try_pledge_additional_tokens(additional_pledge, &env, deps.storage) } +pub fn try_decrease_pledge( + deps: DepsMut<'_>, + info: MessageInfo, + amount: Coin, +) -> Result { + let mix_denom = MIX_DENOM.load(deps.storage)?; + // perform basic validation - is it correct demon, is it non-zero, etc. + let decrease = validate_funds(&[amount], mix_denom)?; + + let account = account_from_address(info.sender.as_str(), deps.storage, deps.api)?; + account.try_decrease_mixnode_pledge(decrease, deps.storage) +} + /// Unbond a mixnode, sends [mixnet_contract_common::ExecuteMsg::UnbondMixnodeOnBehalf] to [crate::storage::MIXNET_CONTRACT_ADDRESS]. pub fn try_unbond_mixnode(info: MessageInfo, deps: DepsMut<'_>) -> Result { let account = account_from_address(info.sender.as_str(), deps.storage, deps.api)?; @@ -299,6 +312,22 @@ pub fn try_track_unbond_mixnode( Ok(Response::new().add_event(new_track_mixnode_unbond_event())) } +/// Tracks decreasing mixnode pledge. Invoked by the mixnet contract after successful event reconciliation. +/// A separate BankMsg containing the specified amount was sent in the same transaction. +pub fn try_track_decrease_mixnode_pledge( + owner: &str, + amount: Coin, + info: MessageInfo, + deps: DepsMut<'_>, +) -> Result { + if info.sender != MIXNET_CONTRACT_ADDRESS.load(deps.storage)? { + return Err(ContractError::NotMixnetContract(info.sender)); + } + let account = account_from_address(owner, deps.storage, deps.api)?; + account.try_track_decrease_mixnode_pledge(amount, deps.storage)?; + Ok(Response::new().add_event(new_track_mixnode_pledge_decrease_event())) +} + /// Track reward collection, invoked by the mixnert contract after sucessful reward compounding or claiming pub fn try_track_reward( deps: DepsMut<'_>, diff --git a/contracts/vesting/src/vesting/account/mixnode_bonding_account.rs b/contracts/vesting/src/vesting/account/mixnode_bonding_account.rs index 62aae9716a..6986ac8793 100644 --- a/contracts/vesting/src/vesting/account/mixnode_bonding_account.rs +++ b/contracts/vesting/src/vesting/account/mixnode_bonding_account.rs @@ -11,9 +11,9 @@ use mixnet_contract_common::mixnode::MixNodeConfigUpdate; use mixnet_contract_common::mixnode::MixNodeCostParams; use mixnet_contract_common::{ExecuteMsg as MixnetExecuteMsg, MixNode}; use vesting_contract_common::events::{ - new_vesting_mixnode_bonding_event, new_vesting_mixnode_unbonding_event, - new_vesting_pledge_more_event, new_vesting_update_mixnode_config_event, - new_vesting_update_mixnode_cost_params_event, + new_vesting_decrease_pledge_event, new_vesting_mixnode_bonding_event, + new_vesting_mixnode_unbonding_event, new_vesting_pledge_more_event, + new_vesting_update_mixnode_config_event, new_vesting_update_mixnode_cost_params_event, }; use vesting_contract_common::PledgeData; @@ -109,6 +109,51 @@ impl MixnodeBondingAccount for Account { .add_event(new_vesting_pledge_more_event())) } + fn try_decrease_mixnode_pledge( + &self, + amount: Coin, + storage: &mut dyn Storage, + ) -> Result { + match self.load_mixnode_pledge(storage)? { + Some(pledge) => { + if pledge.amount.amount <= amount.amount { + return Err(ContractError::InvalidBondPledgeReduction { + current: pledge.amount, + decrease_by: amount, + }); + } + } + None => { + return Err(ContractError::NoBondFound( + self.owner_address().as_str().to_string(), + )); + } + } + + let msg = MixnetExecuteMsg::DecreasePledgeOnBehalf { + owner: self.owner_address().into_string(), + decrease_by: amount, + }; + + let decrease_pledge_message = + wasm_execute(MIXNET_CONTRACT_ADDRESS.load(storage)?, &msg, vec![])?; + + Ok(Response::new() + .add_message(decrease_pledge_message) + .add_event(new_vesting_decrease_pledge_event())) + } + + fn try_track_decrease_mixnode_pledge( + &self, + amount: Coin, + storage: &mut dyn Storage, + ) -> Result<(), ContractError> { + let new_balance = Uint128::new(self.load_balance(storage)?.u128() + amount.amount.u128()); + self.save_balance(new_balance, storage)?; + + self.decrease_mixnode_pledge(amount, storage) + } + fn try_unbond_mixnode(&self, storage: &dyn Storage) -> Result { let msg = MixnetExecuteMsg::UnbondMixnodeOnBehalf { owner: self.owner_address().into_string(), @@ -135,8 +180,7 @@ impl MixnodeBondingAccount for Account { let new_balance = Uint128::new(self.load_balance(storage)?.u128() + amount.amount.u128()); self.save_balance(new_balance, storage)?; - self.remove_mixnode_pledge(storage)?; - Ok(()) + self.remove_mixnode_pledge(storage) } fn try_update_mixnode_config( diff --git a/contracts/vesting/src/vesting/account/mod.rs b/contracts/vesting/src/vesting/account/mod.rs index 01151ddc6c..1e0aa25348 100644 --- a/contracts/vesting/src/vesting/account/mod.rs +++ b/contracts/vesting/src/vesting/account/mod.rs @@ -1,10 +1,10 @@ use super::VestingPeriod; use crate::errors::ContractError; use crate::storage::{ - count_subdelegations_for_mix, load_balance, load_bond_pledge, load_delegation_timestamps, - load_gateway_pledge, load_withdrawn, remove_bond_pledge, remove_delegation, - remove_gateway_pledge, save_account, save_balance, save_bond_pledge, save_gateway_pledge, - save_withdrawn, AccountStorageKey, BlockTimestampSecs, DELEGATIONS, KEY, + count_subdelegations_for_mix, decrease_bond_pledge, load_balance, load_bond_pledge, + load_delegation_timestamps, load_gateway_pledge, load_withdrawn, remove_bond_pledge, + remove_delegation, remove_gateway_pledge, save_account, save_balance, save_bond_pledge, + save_gateway_pledge, save_withdrawn, AccountStorageKey, BlockTimestampSecs, DELEGATIONS, KEY, }; use crate::traits::VestingAccount; use cosmwasm_std::{Addr, Coin, Order, Storage, Timestamp, Uint128}; @@ -247,6 +247,14 @@ impl Account { remove_bond_pledge(self.storage_key(), storage) } + pub fn decrease_mixnode_pledge( + &self, + amount: Coin, + storage: &mut dyn Storage, + ) -> Result<(), ContractError> { + decrease_bond_pledge(self.storage_key, amount, storage) + } + pub fn load_gateway_pledge( &self, storage: &dyn Storage, diff --git a/nym-wallet/src-tauri/src/main.rs b/nym-wallet/src-tauri/src/main.rs index b4a553fbcb..8654dcd69e 100644 --- a/nym-wallet/src-tauri/src/main.rs +++ b/nym-wallet/src-tauri/src/main.rs @@ -59,6 +59,7 @@ fn main() { mixnet::bond::bond_gateway, mixnet::bond::bond_mixnode, mixnet::bond::pledge_more, + mixnet::bond::decrease_pledge, mixnet::bond::gateway_bond_details, mixnet::bond::get_pending_operator_rewards, mixnet::bond::mixnode_bond_details, @@ -114,6 +115,7 @@ fn main() { vesting::bond::vesting_bond_gateway, vesting::bond::vesting_bond_mixnode, vesting::bond::vesting_pledge_more, + vesting::bond::vesting_decrease_pledge, vesting::bond::vesting_unbond_gateway, vesting::bond::vesting_unbond_mixnode, vesting::bond::vesting_update_mixnode_cost_params, diff --git a/nym-wallet/src-tauri/src/operations/mixnet/bond.rs b/nym-wallet/src-tauri/src/operations/mixnet/bond.rs index 1fdc625657..dd29b76610 100644 --- a/nym-wallet/src-tauri/src/operations/mixnet/bond.rs +++ b/nym-wallet/src-tauri/src/operations/mixnet/bond.rs @@ -160,6 +160,33 @@ pub async fn pledge_more( )?) } +#[tauri::command] +pub async fn decrease_pledge( + fee: Option, + decrease_by: DecCoin, + state: tauri::State<'_, WalletState>, +) -> Result { + let guard = state.read().await; + let decrease_by_base = guard.attempt_convert_to_base_coin(decrease_by.clone())?; + let fee_amount = guard.convert_tx_fee(fee.as_ref()); + log::info!( + ">>> Decrease pledge, pledge_decrease_display = {}, pledge_decrease_base = {}, fee = {:?}", + decrease_by, + decrease_by_base, + fee, + ); + let res = guard + .current_client()? + .nyxd + .decrease_pledge(decrease_by_base, fee) + .await?; + log::info!("<<< tx hash = {}", res.transaction_hash); + log::trace!("<<< {:?}", res); + Ok(TransactionExecuteResult::from_execute_result( + res, fee_amount, + )?) +} + #[tauri::command] pub async fn unbond_mixnode( fee: Option, diff --git a/nym-wallet/src-tauri/src/operations/vesting/bond.rs b/nym-wallet/src-tauri/src/operations/vesting/bond.rs index 5acb454390..53571f91f8 100644 --- a/nym-wallet/src-tauri/src/operations/vesting/bond.rs +++ b/nym-wallet/src-tauri/src/operations/vesting/bond.rs @@ -151,6 +151,33 @@ pub async fn vesting_pledge_more( )?) } +#[tauri::command] +pub async fn vesting_decrease_pledge( + fee: Option, + decrease_by: DecCoin, + state: tauri::State<'_, WalletState>, +) -> Result { + let guard = state.read().await; + let decrease_by_base = guard.attempt_convert_to_base_coin(decrease_by.clone())?; + let fee_amount = guard.convert_tx_fee(fee.as_ref()); + log::info!( + ">>> Decrease pledge with locked tokens, pledge_decrease_display = {}, pledge_decrease_base = {}, fee = {:?}", + decrease_by, + decrease_by_base, + fee, + ); + let res = guard + .current_client()? + .nyxd + .vesting_decrease_pledge(decrease_by_base, fee) + .await?; + log::info!("<<< tx hash = {}", res.transaction_hash); + log::trace!("<<< {:?}", res); + Ok(TransactionExecuteResult::from_execute_result( + res, fee_amount, + )?) +} + #[tauri::command] pub async fn vesting_unbond_mixnode( fee: Option, diff --git a/nym-wallet/src/requests/actions.ts b/nym-wallet/src/requests/actions.ts index f26438b5b0..949b62d8a5 100644 --- a/nym-wallet/src/requests/actions.ts +++ b/nym-wallet/src/requests/actions.ts @@ -14,6 +14,7 @@ import { TBondMixNodeArgs, TBondMixnodeSignatureArgs, TBondMoreArgs, + TDecreaseBondArgs, } from '../types'; import { invokeWrapper } from './wrapper'; @@ -51,3 +52,6 @@ export const unbond = async (type: EnumNodeType) => { }; export const bondMore = async (args: TBondMoreArgs) => invokeWrapper('pledge_more', args); + +export const decreaseBond = async (args: TDecreaseBondArgs) => + invokeWrapper('decrease_pledge', args); diff --git a/nym-wallet/src/requests/vesting.ts b/nym-wallet/src/requests/vesting.ts index fe62a69949..3e330ff9fa 100644 --- a/nym-wallet/src/requests/vesting.ts +++ b/nym-wallet/src/requests/vesting.ts @@ -126,3 +126,9 @@ export const vestingBondMore = async ({ fee, additionalPledge }: { fee?: Fee; ad fee, additionalPledge, }); + +export const vestingDecreaseBond = async ({ fee, decreaseBy }: { fee?: Fee; decreaseBy: DecCoin }) => + invokeWrapper('vesting_decrease_pledge', { + fee, + decreaseBy, + }); diff --git a/nym-wallet/src/types/global.ts b/nym-wallet/src/types/global.ts index 08b44c9785..3cbf5bfc93 100644 --- a/nym-wallet/src/types/global.ts +++ b/nym-wallet/src/types/global.ts @@ -59,6 +59,11 @@ export type TBondMoreArgs = { fee?: Fee; }; +export type TDecreaseBondArgs = { + decreaseBy: DecCoin; + fee?: Fee; +}; + export type TNodeDescription = { name: string; description: string; diff --git a/tools/nym-cli/src/validator/mixnet/delegators/mod.rs b/tools/nym-cli/src/validator/mixnet/delegators/mod.rs index 1a33949e5b..b7c561cf70 100644 --- a/tools/nym-cli/src/validator/mixnet/delegators/mod.rs +++ b/tools/nym-cli/src/validator/mixnet/delegators/mod.rs @@ -23,12 +23,6 @@ pub(crate) async fn execute( nym_cli_commands::validator::mixnet::delegators::MixnetDelegatorsCommands::DelegateVesting(args) => { nym_cli_commands::validator::mixnet::delegators::vesting_delegate_to_mixnode::vesting_delegate_to_mixnode(args, create_signing_client(global_args, network_details)?).await } - nym_cli_commands::validator::mixnet::delegators::MixnetDelegatorsCommands::PledgeMore(args) => { - nym_cli_commands::validator::mixnet::delegators::pledge_more::pledge_more(args, create_signing_client(global_args, network_details)?).await - } - nym_cli_commands::validator::mixnet::delegators::MixnetDelegatorsCommands::PledgeMoreVesting(args) => { - nym_cli_commands::validator::mixnet::delegators::vesting_pledge_more::vesting_pledge_more(args, create_signing_client(global_args, network_details)?).await - } nym_cli_commands::validator::mixnet::delegators::MixnetDelegatorsCommands::Undelegate(args) => { nym_cli_commands::validator::mixnet::delegators::undelegate_from_mixnode::undelegate_from_mixnode(args, create_signing_client(global_args, network_details)?).await } diff --git a/tools/nym-cli/src/validator/mixnet/operators/mixnodes/mod.rs b/tools/nym-cli/src/validator/mixnet/operators/mixnodes/mod.rs index 6100d54b4a..b3cf6d4e28 100644 --- a/tools/nym-cli/src/validator/mixnet/operators/mixnodes/mod.rs +++ b/tools/nym-cli/src/validator/mixnet/operators/mixnodes/mod.rs @@ -36,6 +36,18 @@ pub(crate) async fn execute( nym_cli_commands::validator::mixnet::operators::mixnode::MixnetOperatorsMixnodeCommands::CreateMixnodeBondingSignPayload(args) => { nym_cli_commands::validator::mixnet::operators::mixnode::mixnode_bonding_sign_payload::create_payload(args,create_signing_client(global_args, network_details)?).await } + nym_cli_commands::validator::mixnet::operators::mixnode::MixnetOperatorsMixnodeCommands::PledgeMore(args) => { + nym_cli_commands::validator::mixnet::operators::mixnode::pledge_more::pledge_more(args, create_signing_client(global_args, network_details)?).await + } + nym_cli_commands::validator::mixnet::operators::mixnode::MixnetOperatorsMixnodeCommands::PledgeMoreVesting(args) => { + nym_cli_commands::validator::mixnet::operators::mixnode::vesting_pledge_more::vesting_pledge_more(args, create_signing_client(global_args, network_details)?).await + } + nym_cli_commands::validator::mixnet::operators::mixnode::MixnetOperatorsMixnodeCommands::DecreasePledge(args) => { + nym_cli_commands::validator::mixnet::operators::mixnode::decrease_pledge::decrease_pledge(args, create_signing_client(global_args, network_details)?).await + } + nym_cli_commands::validator::mixnet::operators::mixnode::MixnetOperatorsMixnodeCommands::DecreasePledgeVesting(args) => { + nym_cli_commands::validator::mixnet::operators::mixnode::vesting_decrease_pledge::vesting_decrease_pledge(args, create_signing_client(global_args, network_details)?).await + } _ => unreachable!(), } Ok(())