diff --git a/common/client-libs/validator-client/src/nyxd/contract_traits/mixnet_signing_client.rs b/common/client-libs/validator-client/src/nyxd/contract_traits/mixnet_signing_client.rs index 69aaac59d5..e18e387977 100644 --- a/common/client-libs/validator-client/src/nyxd/contract_traits/mixnet_signing_client.rs +++ b/common/client-libs/validator-client/src/nyxd/contract_traits/mixnet_signing_client.rs @@ -867,6 +867,10 @@ mod tests { MixnetExecuteMsg::TestingResolveAllPendingEvents { .. } => { client.testing_resolve_all_pending_events(None).ignore() } + // not expected to be exposed by the client + ExecuteMsg::AdminMigrateVestedMixNode { .. } + | ExecuteMsg::AdminMigrateVestedDelegation { .. } + | ExecuteMsg::AdminBatchMigrateVestedDelegations { .. } => ().ignore(), }; } } diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/msg.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/msg.rs index f8b34b2898..a6cc8a8c77 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/msg.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/msg.rs @@ -306,6 +306,22 @@ pub enum ExecuteMsg { MigrateVestedDelegation { mix_id: NodeId, }, + /// Admin-only: forcibly migrate the vested mixnode owned by `owner`. + /// Used to drain the last vested entries so the mixnet contract can drop its dependency on the vesting contract. + AdminMigrateVestedMixNode { + owner: String, + }, + /// Admin-only: forcibly migrate the vested delegation `(mix_id, owner)`. + /// Used to drain the last vested entries so the mixnet contract can drop its dependency on the vesting contract. + AdminMigrateVestedDelegation { + mix_id: NodeId, + owner: String, + }, + /// Admin-only: batch variant of [`ExecuteMsg::AdminMigrateVestedDelegation`]. + /// Reverts the entire batch on the first error, so callers should treat it as all-or-nothing. + AdminBatchMigrateVestedDelegations { + entries: Vec, + }, // testing-only #[cfg(feature = "contract-testing")] @@ -395,6 +411,15 @@ impl ExecuteMsg { } ExecuteMsg::MigrateVestedMixNode { .. } => "migrate vested mixnode".into(), ExecuteMsg::MigrateVestedDelegation { .. } => "migrate vested delegation".to_string(), + ExecuteMsg::AdminMigrateVestedMixNode { owner } => { + format!("admin migrating vested mixnode of {owner}") + } + ExecuteMsg::AdminMigrateVestedDelegation { mix_id, owner } => { + format!("admin migrating vested delegation of {owner} on mixnode {mix_id}") + } + ExecuteMsg::AdminBatchMigrateVestedDelegations { entries } => { + format!("admin batch migrating {} vested delegations", entries.len()) + } ExecuteMsg::AssignRoles { .. } => "assigning epoch roles".into(), ExecuteMsg::MigrateMixnode { .. } => "migrating legacy mixnode".into(), ExecuteMsg::MigrateGateway { .. } => "migrating legacy gateway".into(), @@ -882,6 +907,12 @@ pub enum QueryMsg { GetKeyRotationId {}, } +#[cw_serde] +pub struct VestedDelegationMigrationEntry { + pub mix_id: NodeId, + pub owner: String, +} + #[cw_serde] pub struct MigrateMsg { pub unsafe_skip_state_updates: Option, diff --git a/contracts/mixnet/schema/nym-mixnet-contract.json b/contracts/mixnet/schema/nym-mixnet-contract.json index a479011c28..1c41e08746 100644 --- a/contracts/mixnet/schema/nym-mixnet-contract.json +++ b/contracts/mixnet/schema/nym-mixnet-contract.json @@ -1271,6 +1271,81 @@ } }, "additionalProperties": false + }, + { + "description": "Admin-only: forcibly migrate the vested mixnode owned by `owner`. Used to drain the last vested entries so the mixnet contract can drop its dependency on the vesting contract.", + "type": "object", + "required": [ + "admin_migrate_vested_mix_node" + ], + "properties": { + "admin_migrate_vested_mix_node": { + "type": "object", + "required": [ + "owner" + ], + "properties": { + "owner": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Admin-only: forcibly migrate the vested delegation `(mix_id, owner)`. Used to drain the last vested entries so the mixnet contract can drop its dependency on the vesting contract.", + "type": "object", + "required": [ + "admin_migrate_vested_delegation" + ], + "properties": { + "admin_migrate_vested_delegation": { + "type": "object", + "required": [ + "mix_id", + "owner" + ], + "properties": { + "mix_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "owner": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Admin-only: batch variant of [`ExecuteMsg::AdminMigrateVestedDelegation`]. Reverts the entire batch on the first error, so callers should treat it as all-or-nothing.", + "type": "object", + "required": [ + "admin_batch_migrate_vested_delegations" + ], + "properties": { + "admin_batch_migrate_vested_delegations": { + "type": "object", + "required": [ + "entries" + ], + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/VestedDelegationMigrationEntry" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false } ], "definitions": { @@ -1962,6 +2037,24 @@ } }, "additionalProperties": false + }, + "VestedDelegationMigrationEntry": { + "type": "object", + "required": [ + "mix_id", + "owner" + ], + "properties": { + "mix_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "owner": { + "type": "string" + } + }, + "additionalProperties": false } } }, diff --git a/contracts/mixnet/schema/raw/execute.json b/contracts/mixnet/schema/raw/execute.json index 8884a73158..8e4daa0564 100644 --- a/contracts/mixnet/schema/raw/execute.json +++ b/contracts/mixnet/schema/raw/execute.json @@ -976,6 +976,81 @@ } }, "additionalProperties": false + }, + { + "description": "Admin-only: forcibly migrate the vested mixnode owned by `owner`. Used to drain the last vested entries so the mixnet contract can drop its dependency on the vesting contract.", + "type": "object", + "required": [ + "admin_migrate_vested_mix_node" + ], + "properties": { + "admin_migrate_vested_mix_node": { + "type": "object", + "required": [ + "owner" + ], + "properties": { + "owner": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Admin-only: forcibly migrate the vested delegation `(mix_id, owner)`. Used to drain the last vested entries so the mixnet contract can drop its dependency on the vesting contract.", + "type": "object", + "required": [ + "admin_migrate_vested_delegation" + ], + "properties": { + "admin_migrate_vested_delegation": { + "type": "object", + "required": [ + "mix_id", + "owner" + ], + "properties": { + "mix_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "owner": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Admin-only: batch variant of [`ExecuteMsg::AdminMigrateVestedDelegation`]. Reverts the entire batch on the first error, so callers should treat it as all-or-nothing.", + "type": "object", + "required": [ + "admin_batch_migrate_vested_delegations" + ], + "properties": { + "admin_batch_migrate_vested_delegations": { + "type": "object", + "required": [ + "entries" + ], + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/VestedDelegationMigrationEntry" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false } ], "definitions": { @@ -1667,6 +1742,24 @@ } }, "additionalProperties": false + }, + "VestedDelegationMigrationEntry": { + "type": "object", + "required": [ + "mix_id", + "owner" + ], + "properties": { + "mix_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "owner": { + "type": "string" + } + }, + "additionalProperties": false } } } diff --git a/contracts/mixnet/src/contract.rs b/contracts/mixnet/src/contract.rs index 23dfa000b7..f86b9a49f8 100644 --- a/contracts/mixnet/src/contract.rs +++ b/contracts/mixnet/src/contract.rs @@ -307,6 +307,19 @@ pub fn execute( ExecuteMsg::MigrateVestedDelegation { mix_id } => { crate::vesting_migration::try_migrate_vested_delegation(deps, env, info, mix_id) } + ExecuteMsg::AdminMigrateVestedMixNode { owner } => { + crate::vesting_migration::try_admin_migrate_vested_mixnode(deps, info, owner) + } + ExecuteMsg::AdminMigrateVestedDelegation { mix_id, owner } => { + crate::vesting_migration::try_admin_migrate_vested_delegation( + deps, env, info, mix_id, owner, + ) + } + ExecuteMsg::AdminBatchMigrateVestedDelegations { entries } => { + crate::vesting_migration::try_admin_batch_migrate_vested_delegations( + deps, env, info, entries, + ) + } // legacy vesting ExecuteMsg::BondMixnodeOnBehalf { .. } diff --git a/contracts/mixnet/src/vesting_migration.rs b/contracts/mixnet/src/vesting_migration.rs index 683b7a6102..89c0274e65 100644 --- a/contracts/mixnet/src/vesting_migration.rs +++ b/contracts/mixnet/src/vesting_migration.rs @@ -3,24 +3,42 @@ use crate::delegations::storage as delegations_storage; use crate::mixnet_contract_settings::storage as mixnet_params_storage; +use crate::mixnet_contract_settings::storage::ADMIN; use crate::mixnodes::helpers::get_mixnode_details_by_owner; use crate::mixnodes::storage as mixnodes_storage; use crate::rewards::storage as rewards_storage; use crate::support::helpers::{ ensure_bonded, ensure_epoch_in_progress_state, ensure_no_pending_pledge_changes, }; -use cosmwasm_std::{wasm_execute, DepsMut, Env, Event, MessageInfo, Response}; +use cosmwasm_std::{wasm_execute, Addr, DepsMut, Env, Event, MessageInfo, Response}; use mixnet_contract_common::error::MixnetContractError; -use mixnet_contract_common::{Delegation, NodeId}; +use mixnet_contract_common::{Delegation, NodeId, VestedDelegationMigrationEntry}; use vesting_contract_common::messages::ExecuteMsg as VestingExecuteMsg; pub(crate) fn try_migrate_vested_mixnode( deps: DepsMut<'_>, info: MessageInfo, ) -> Result { - let mix_details = get_mixnode_details_by_owner(deps.storage, info.sender.clone())?.ok_or( + migrate_vested_mixnode_for_owner(deps, info.sender) +} + +pub(crate) fn try_admin_migrate_vested_mixnode( + deps: DepsMut<'_>, + info: MessageInfo, + owner: String, +) -> Result { + ADMIN.assert_admin(deps.as_ref(), &info.sender)?; + let owner = deps.api.addr_validate(&owner)?; + migrate_vested_mixnode_for_owner(deps, owner) +} + +fn migrate_vested_mixnode_for_owner( + deps: DepsMut<'_>, + owner: Addr, +) -> Result { + let mix_details = get_mixnode_details_by_owner(deps.storage, owner.clone())?.ok_or( MixnetContractError::NoAssociatedMixNodeBond { - owner: info.sender.clone(), + owner: owner.clone(), }, )?; let mix_id = mix_details.mix_id(); @@ -55,7 +73,7 @@ pub(crate) fn try_migrate_vested_mixnode( .add_message(wasm_execute( vesting_contract, &VestingExecuteMsg::TrackMigratedMixnode { - owner: info.sender.into_string(), + owner: owner.into_string(), }, vec![], )?)) @@ -66,6 +84,46 @@ pub(crate) fn try_migrate_vested_delegation( env: Env, info: MessageInfo, mix_id: NodeId, +) -> Result { + migrate_vested_delegation_for_owner(deps, env, info.sender, mix_id) +} + +pub(crate) fn try_admin_migrate_vested_delegation( + deps: DepsMut<'_>, + env: Env, + info: MessageInfo, + mix_id: NodeId, + owner: String, +) -> Result { + ADMIN.assert_admin(deps.as_ref(), &info.sender)?; + let owner = deps.api.addr_validate(&owner)?; + migrate_vested_delegation_for_owner(deps, env, owner, mix_id) +} + +pub(crate) fn try_admin_batch_migrate_vested_delegations( + mut deps: DepsMut<'_>, + env: Env, + info: MessageInfo, + entries: Vec, +) -> Result { + ADMIN.assert_admin(deps.as_ref(), &info.sender)?; + + let mut response = Response::new(); + for VestedDelegationMigrationEntry { mix_id, owner } in entries { + let owner = deps.api.addr_validate(&owner)?; + let sub = migrate_vested_delegation_for_owner(deps.branch(), env.clone(), owner, mix_id)?; + response = response + .add_submessages(sub.messages) + .add_events(sub.events); + } + Ok(response) +} + +fn migrate_vested_delegation_for_owner( + deps: DepsMut<'_>, + env: Env, + owner: Addr, + mix_id: NodeId, ) -> Result { let mut response = Response::new(); @@ -73,12 +131,16 @@ pub(crate) fn try_migrate_vested_delegation( let vesting_contract = mixnet_params_storage::vesting_contract_address(deps.storage)?; - let storage_key = - Delegation::generate_storage_key(mix_id, &info.sender, Some(&vesting_contract)); + let storage_key = Delegation::generate_storage_key(mix_id, &owner, Some(&vesting_contract)); let Some(vested_delegation) = delegations_storage::delegations().may_load(deps.storage, storage_key.clone())? else { - return Err(MixnetContractError::NotAVestingDelegation); + return Ok(Response::new().add_event( + Event::new("migrate-vested-delegation-noop") + .add_attribute("owner", owner.as_str()) + .add_attribute("mix_id", mix_id.to_string()) + .add_attribute("reason", "no_vested_delegation"), + )); }; // sanity check that's meant to blow up the contract @@ -88,7 +150,7 @@ pub(crate) fn try_migrate_vested_delegation( let mut updated_delegation = vested_delegation.clone(); updated_delegation.proxy = None; - let new_storage_key = Delegation::generate_storage_key(mix_id, &info.sender, None); + let new_storage_key = Delegation::generate_storage_key(mix_id, &owner, None); // remove the old (vested) delegation delegations_storage::delegations().remove(deps.storage, storage_key)?; @@ -205,7 +267,7 @@ pub(crate) fn try_migrate_vested_delegation( Ok(response.add_message(wasm_execute( vesting_contract, &VestingExecuteMsg::TrackMigratedDelegation { - owner: info.sender.into_string(), + owner: owner.into_string(), mix_id, }, vec![], @@ -307,9 +369,10 @@ mod tests { let sender = test.make_sender("owner-without-any-delegations"); - // it simply fails for there is nothing to migrate - let res = try_migrate_vested_delegation(test.deps_mut(), env, sender, 42).unwrap_err(); - assert_eq!(res, MixnetContractError::NotAVestingDelegation); + // nothing to migrate -> idempotent no-op (so admin batches don't fail on stale entries) + let res = try_migrate_vested_delegation(test.deps_mut(), env, sender, 42).unwrap(); + assert!(res.messages.is_empty()); + assert_eq!(res.events[0].ty, "migrate-vested-delegation-noop"); } #[test] @@ -333,10 +396,10 @@ mod tests { let sender = message_info(&delegation.owner, &[]); let mix_id = delegation.node_id; - // it also fails because the method is only allowed for vested delegations - let res = - try_migrate_vested_delegation(test.deps_mut(), env, sender, mix_id).unwrap_err(); - assert_eq!(res, MixnetContractError::NotAVestingDelegation); + // liquid-only delegations are no-ops (nothing vested to migrate) + let res = try_migrate_vested_delegation(test.deps_mut(), env, sender, mix_id).unwrap(); + assert!(res.messages.is_empty()); + assert_eq!(res.events[0].ty, "migrate-vested-delegation-noop"); } #[test] @@ -566,4 +629,329 @@ mod tests { compare_decimals(rewards, new_rewards_twin, Some("0.01".parse().unwrap())) } } + + #[cfg(test)] + mod admin_migrating_vested_mixnode { + use super::*; + use crate::mixnodes::helpers::get_mixnode_details_by_id; + use crate::support::tests::test_helpers::TestSetup; + use cosmwasm_std::testing::message_info; + use cosmwasm_std::{from_json, CosmosMsg, WasmMsg}; + + #[test] + fn rejects_non_admin_caller() { + let mut test = TestSetup::new(); + let owner = test.make_addr("owner"); + let mix_id = test.add_legacy_mixnode_with_legal_proxy(&owner, None); + + let intruder = message_info(&test.make_addr("not-admin"), &[]); + let owner_str = owner.to_string(); + let res = + try_admin_migrate_vested_mixnode(test.deps_mut(), intruder, owner_str).unwrap_err(); + assert!(matches!(res, MixnetContractError::Admin(_))); + + // bond is untouched + let existing = get_mixnode_details_by_id(test.deps().storage, mix_id) + .unwrap() + .unwrap(); + assert!(existing.bond_information.proxy.is_some()); + } + + #[test] + fn admin_can_migrate_someone_elses_vested_node() { + let mut test = TestSetup::new(); + let owner = test.make_addr("owner"); + let mix_id = test.add_legacy_mixnode_with_legal_proxy(&owner, None); + let admin = test.admin(); + + let info = message_info(&admin, &[]); + let owner_str = owner.to_string(); + let res = + try_admin_migrate_vested_mixnode(test.deps_mut(), info, owner_str.clone()).unwrap(); + + let CosmosMsg::Wasm(WasmMsg::Execute { msg, .. }) = &res.messages[0].msg else { + panic!("no track message present") + }; + assert_eq!( + from_json::(msg).unwrap(), + VestingExecuteMsg::TrackMigratedMixnode { owner: owner_str } + ); + + // proxy was cleared on the bond owned by `owner` (not by the admin) + let migrated = get_mixnode_details_by_id(test.deps().storage, mix_id) + .unwrap() + .unwrap(); + assert!(migrated.bond_information.proxy.is_none()); + } + + #[test] + fn rejects_invalid_owner_address() { + let mut test = TestSetup::new(); + let admin = test.admin(); + + let info = message_info(&admin, &[]); + let res = + try_admin_migrate_vested_mixnode(test.deps_mut(), info, "not a bech32".to_string()) + .unwrap_err(); + assert!(matches!(res, MixnetContractError::StdErr { .. })); + } + } + + #[cfg(test)] + mod admin_migrating_vested_delegation { + use super::*; + use crate::delegations::storage::delegations; + use crate::support::tests::test_helpers::TestSetup; + use cosmwasm_std::testing::message_info; + use cosmwasm_std::{from_json, CosmosMsg, Order, WasmMsg}; + + #[test] + fn rejects_non_admin_caller() { + let mut test = TestSetup::new_complex(); + let env = test.env(); + + let vested = delegations() + .range(test.deps().storage, None, None, Order::Ascending) + .filter_map(|d| d.map(|(_, del)| del).ok()) + .find(|d| d.proxy.is_some()) + .unwrap(); + + let intruder = message_info(&test.make_addr("not-admin"), &[]); + let res = try_admin_migrate_vested_delegation( + test.deps_mut(), + env, + intruder, + vested.node_id, + vested.owner.to_string(), + ) + .unwrap_err(); + assert!(matches!(res, MixnetContractError::Admin(_))); + + // delegation is untouched + assert!(delegations() + .may_load(test.deps().storage, vested.storage_key()) + .unwrap() + .is_some()); + } + + #[test] + fn admin_can_migrate_someone_elses_vested_delegation() { + let mut test = TestSetup::new_complex(); + let env = test.env(); + let admin = test.admin(); + + let vested = delegations() + .range(test.deps().storage, None, None, Order::Ascending) + .filter_map(|d| d.map(|(_, del)| del).ok()) + .find(|d| d.proxy.is_some()) + .unwrap(); + + // pick an owner that has no liquid twin so we exercise the simple branch + let has_liquid_twin = delegations() + .range(test.deps().storage, None, None, Order::Ascending) + .filter_map(|d| d.map(|(_, del)| del).ok()) + .any(|d| { + d.proxy.is_none() + && d.owner.as_str() == vested.owner.as_str() + && d.node_id == vested.node_id + }); + assert!(!has_liquid_twin); + + let old_key = vested.storage_key(); + let mut expected_liquid = vested.clone(); + expected_liquid.proxy = None; + let new_key = expected_liquid.storage_key(); + + let info = message_info(&admin, &[]); + let res = try_admin_migrate_vested_delegation( + test.deps_mut(), + env, + info, + vested.node_id, + vested.owner.to_string(), + ) + .unwrap(); + + let CosmosMsg::Wasm(WasmMsg::Execute { msg, .. }) = &res.messages[0].msg else { + panic!("no track message present") + }; + assert_eq!( + from_json::(msg).unwrap(), + VestingExecuteMsg::TrackMigratedDelegation { + owner: vested.owner.to_string(), + mix_id: vested.node_id, + } + ); + + assert!(delegations() + .may_load(test.deps().storage, old_key) + .unwrap() + .is_none()); + assert_eq!( + expected_liquid, + delegations().load(test.deps().storage, new_key).unwrap() + ); + } + } + + #[cfg(test)] + mod admin_batch_migrating_vested_delegations { + use super::*; + use crate::delegations::storage::delegations; + use crate::support::tests::test_helpers::TestSetup; + use cosmwasm_std::testing::message_info; + use cosmwasm_std::{CosmosMsg, Order, WasmMsg}; + + fn collect_vested(test: &TestSetup, n: usize) -> Vec { + delegations() + .range(test.deps().storage, None, None, Order::Ascending) + .filter_map(|d| d.map(|(_, del)| del).ok()) + .filter(|d| d.proxy.is_some()) + .take(n) + .collect() + } + + #[test] + fn rejects_non_admin_caller() { + let mut test = TestSetup::new_complex(); + let env = test.env(); + let vested = collect_vested(&test, 2); + let entries = vested + .iter() + .map(|d| VestedDelegationMigrationEntry { + mix_id: d.node_id, + owner: d.owner.to_string(), + }) + .collect(); + + let intruder = message_info(&test.make_addr("not-admin"), &[]); + let res = + try_admin_batch_migrate_vested_delegations(test.deps_mut(), env, intruder, entries) + .unwrap_err(); + assert!(matches!(res, MixnetContractError::Admin(_))); + + // nothing was touched + for d in &vested { + assert!(delegations() + .may_load(test.deps().storage, d.storage_key()) + .unwrap() + .is_some()); + } + } + + #[test] + fn admin_can_migrate_multiple_entries_in_one_call() { + let mut test = TestSetup::new_complex(); + let env = test.env(); + let admin = test.admin(); + let vested = collect_vested(&test, 3); + assert_eq!(vested.len(), 3, "fixture must have ≥3 vested delegations"); + + let entries: Vec<_> = vested + .iter() + .map(|d| VestedDelegationMigrationEntry { + mix_id: d.node_id, + owner: d.owner.to_string(), + }) + .collect(); + + let info = message_info(&admin, &[]); + let res = + try_admin_batch_migrate_vested_delegations(test.deps_mut(), env, info, entries) + .unwrap(); + + // one TrackMigratedDelegation WasmMsg per entry + let track_msgs: Vec<_> = res + .messages + .iter() + .filter(|sub| matches!(sub.msg, CosmosMsg::Wasm(WasmMsg::Execute { .. }))) + .collect(); + assert_eq!(track_msgs.len(), 3); + + // each vested entry was removed and re-saved under the liquid key + for d in &vested { + assert!(delegations() + .may_load(test.deps().storage, d.storage_key()) + .unwrap() + .is_none()); + let liquid_key = Delegation::generate_storage_key(d.node_id, &d.owner, None); + let liquid = delegations().load(test.deps().storage, liquid_key).unwrap(); + assert!(liquid.proxy.is_none()); + } + } + + #[test] + fn batch_with_noop_entries_still_succeeds() { + // batch contains: 1 valid vested, 1 stale (non-existent) — the stale one is a noop + let mut test = TestSetup::new_complex(); + let env = test.env(); + let admin = test.admin(); + let vested = collect_vested(&test, 1); + let real = &vested[0]; + + let entries = vec![ + VestedDelegationMigrationEntry { + mix_id: real.node_id, + owner: real.owner.to_string(), + }, + VestedDelegationMigrationEntry { + mix_id: 999_999, + owner: test.make_addr("ghost").to_string(), + }, + ]; + + let info = message_info(&admin, &[]); + let res = + try_admin_batch_migrate_vested_delegations(test.deps_mut(), env, info, entries) + .unwrap(); + + // only the real entry dispatched a track message; the ghost was a noop + let track_msgs: Vec<_> = res + .messages + .iter() + .filter(|sub| matches!(sub.msg, CosmosMsg::Wasm(WasmMsg::Execute { .. }))) + .collect(); + assert_eq!(track_msgs.len(), 1); + assert!(res + .events + .iter() + .any(|e| e.ty == "migrate-vested-delegation-noop")); + } + + #[test] + fn bad_owner_address_propagates_as_error() { + // a malformed entry causes the handler to return Err; on-chain this reverts + // the entire batch atomically. (Unit tests use a raw `DepsMut` that does not + // simulate chain-level rollback, so we only assert the error and ensure no + // entry after the bad one was processed.) + let mut test = TestSetup::new_complex(); + let env = test.env(); + let admin = test.admin(); + let vested = collect_vested(&test, 1); + + let entries = vec![ + VestedDelegationMigrationEntry { + mix_id: vested[0].node_id, + owner: "not-a-bech32".to_string(), + }, + VestedDelegationMigrationEntry { + mix_id: vested[0].node_id, + owner: vested[0].owner.to_string(), + }, + ]; + + let info = message_info(&admin, &[]); + let err = + try_admin_batch_migrate_vested_delegations(test.deps_mut(), env, info, entries) + .unwrap_err(); + assert!(matches!(err, MixnetContractError::StdErr { .. })); + + // bailed out before reaching the second (valid) entry, so the vested record + // is still in storage + assert!(delegations() + .may_load(test.deps().storage, vested[0].storage_key()) + .unwrap() + .is_some()); + } + } } diff --git a/contracts/vesting/src/transactions.rs b/contracts/vesting/src/transactions.rs index 09e3ac9a8a..9c37cd8884 100644 --- a/contracts/vesting/src/transactions.rs +++ b/contracts/vesting/src/transactions.rs @@ -17,9 +17,9 @@ 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_migrate_mixnode_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, + new_track_migrate_delegation_event, new_track_migrate_mixnode_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::{Account, PledgeCap, VestingContractError, VestingSpecification}; @@ -247,7 +247,7 @@ pub fn try_track_migrate_delegation( } let account = account_from_address(owner, deps.storage, deps.api)?; account.track_migrated_delegation(mix_id, deps.storage)?; - Ok(Response::new().add_event(new_track_migrate_mixnode_event())) + Ok(Response::new().add_event(new_track_migrate_delegation_event())) } /// Bond a mixnode, sends [mixnet_contract_common::ExecuteMsg::BondMixnodeOnBehalf] to [crate::storage::MIXNET_CONTRACT_ADDRESS].