From 23ad60641a04506cd7c5a198a8c33db21c0f0e75 Mon Sep 17 00:00:00 2001 From: metalurgical <97008724+metalurgical@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:45:45 +0200 Subject: [PATCH 01/10] feat: cancel_node_migration() function in contract Implement cancel_node_migration() function in contract, which clears the ongoing migration record. Add tests. Update migration-service documentation. --- crates/contract/src/lib.rs | 68 +++++++++++++ crates/e2e-tests/src/cluster.rs | 11 +++ crates/e2e-tests/tests/common.rs | 1 + crates/e2e-tests/tests/migration_service.rs | 95 +++++++++++++++++++ .../src/method_names.rs | 1 + docs/migration-service.md | 8 +- 6 files changed, 180 insertions(+), 4 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index e4868d3ed..7b8323c6f 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -2845,6 +2845,32 @@ impl MpcContract { Ok(()) } + /// Cancels a previously started node migration for the calling account. + /// + /// Removes the caller's pending `DestinationNodeInfo` record. This is useful if the new node + /// is not functioning correctly or the wrong information was provided when calling + /// [`Self::start_node_migration`]. + /// + /// This function is callable regardless of whether the protocol is in a `Running` state or + /// whether the signer is a current participant. + /// + /// # Errors + /// - `NodeMigrationError::MigrationNotFound`: if no migration record exists for the caller + #[handle_result] + pub fn cancel_node_migration(&mut self) -> Result<(), Error> { + let account_id = Self::assert_caller_is_signer(); + + match self.node_migrations.remove_migration(&account_id) { + Some(destination_node_info) => log!( + "cancel_node_migration: signer={:?}, destination_node_info={:?}", + account_id, + destination_node_info + ), + None => return Err(errors::NodeMigrationError::MigrationNotFound.into()), + } + Ok(()) + } + /// Updates the calling participant's registered URL, keeping the TLS key and participant ID. /// /// Requires a deposit of at least [`MINIMUM_NODE_MANAGEMENT_DEPOSIT`] (excess is refunded), so @@ -8935,4 +8961,46 @@ mod tests { {WORST_CASE_ENTRY_COST_CEILING} at today's storage price" ); } + + #[test] + fn test_cancel_node_migration() { + let running_state = ProtocolContractState::Running(gen_running_state(NUM_DOMAINS)); + let mut contract = MpcContract::new_from_protocol_state(running_state); + let participants = { + let ProtocolContractState::Running(running) = &contract.protocol_state else { + panic!("expected running state"); + }; + running.parameters.participants().clone() + }; + let (account_id, _, _) = participants + .participants() + .first() + .expect("expected at least one participant") + .clone(); + let mut test_env = Environment::new(None, None, None); + test_env.set_signer(&account_id); + let destination_node_info = gen_random_destination_info(); + contract + .start_node_migration(destination_node_info.clone()) + .expect("participant should be able to start node migration"); + assert_eq!( + migration_info(&contract, &account_id), + (account_id.clone(), None, Some(destination_node_info)) + ); + // Cancel the migration + contract + .cancel_node_migration() + .expect("caller should be able to cancel their pending migration"); + assert_eq!( + migration_info(&contract, &account_id), + (account_id.clone(), None, None) + ); + // Check migration was cancelled + assert!(contract.migration_info().is_empty()); + let res = contract.cancel_node_migration(); + assert_matches!( + res.unwrap_err(), + Error::NodeMigrationError(NodeMigrationError::MigrationNotFound) + ); + } } diff --git a/crates/e2e-tests/src/cluster.rs b/crates/e2e-tests/src/cluster.rs index 0293d2356..ad28c36c7 100644 --- a/crates/e2e-tests/src/cluster.rs +++ b/crates/e2e-tests/src/cluster.rs @@ -1002,6 +1002,17 @@ impl MpcCluster { .context("failed to start node migration") } + /// Cancel a previously started node migration for a specific node. + pub async fn cancel_node_migration( + &self, + node_index: usize, + ) -> anyhow::Result { + let client = self.operator_client_for(node_index)?; + self.contract + .call_from(&client, method_names::CANCEL_NODE_MIGRATION, json!({})) + .await + } + /// Update the registered URL of a specific node, called from that node's own operator account. pub async fn update_participant_url( &self, diff --git a/crates/e2e-tests/tests/common.rs b/crates/e2e-tests/tests/common.rs index 7f82dcc4e..75b900386 100644 --- a/crates/e2e-tests/tests/common.rs +++ b/crates/e2e-tests/tests/common.rs @@ -41,6 +41,7 @@ pub const DISTINCT_RECONSTRUCTION_THRESHOLDS_PORT_SEED: u16 = 23; pub const UPDATE_PARTICIPANT_URL_PORT_SEED: u16 = 24; pub const AVAILABLE_FOREIGN_CHAINS_PORT_SEED: u16 = 25; pub const BACKUP_SERVICE_RUN_PORT_SEED: u16 = 26; +pub const CANCEL_NODE_MIGRATION_PORT_SEED: u16 = 27; /// Start a cluster, wait for Running state and presignatures to buffer. /// diff --git a/crates/e2e-tests/tests/migration_service.rs b/crates/e2e-tests/tests/migration_service.rs index df02ffc6a..395b35f60 100644 --- a/crates/e2e-tests/tests/migration_service.rs +++ b/crates/e2e-tests/tests/migration_service.rs @@ -996,3 +996,98 @@ async fn migration_service__should_handle_back_migration_a_to_b_to_a() { .await .expect("ckd request failed after back-migration"); } + +/// Test to ensure `cancel_node_migration` removes a pending migration for the +/// calling account. +#[tokio::test] +#[expect(non_snake_case)] +async fn migration_service__cancel_node_migration_clears_ongoing_migration_info() { + // Given: a cluster with 2 participants and 2 migration targets. + let (cluster, _running) = + common::must_setup_cluster(common::CANCEL_NODE_MIGRATION_PORT_SEED, |c| { + c.num_nodes = 2; + c.threshold = 2; + c.migration_targets = vec![0, 1]; + }) + .await; + let source_idx = 0; + let target_idx = 2; + let source_account_id = cluster.nodes[source_idx].account_id().to_string(); + assert_eq!( + cluster.nodes[target_idx].account_id().to_string(), + source_account_id, + "migration target must share the source account" + ); + + // When: the source registers a migration destination. + start_migration_and_wait(&cluster, source_idx, target_idx) + .await + .expect("start_migration_and_wait failed"); + + // Then: migration_info reports a pending destination for the source. + (|| async { + let info: serde_json::Value = cluster + .view_migration_info() + .await + .context("failed to view migration info")?; + let entry = info.get(&source_account_id); + anyhow::ensure!( + entry.is_some_and(|e| !e.get(1).unwrap_or(&serde_json::Value::Null).is_null()), + "contract has not indexed migration information yet" + ); + Ok(()) + }) + .retry( + ConstantBuilder::default() + .with_delay(common::POLL_INTERVAL) + .with_max_times( + (INDEXER_SYNC_TIMEOUT.as_millis() / common::POLL_INTERVAL.as_millis()) as usize, + ), + ) + .await + .expect("timed out waiting for migration info"); + + // When: the source cancels the migration. + let outcome = cluster + .cancel_node_migration(source_idx) + .await + .expect("failed to call cancel_node_migration"); + assert!( + outcome.is_success(), + "cancel_node_migration failed: {:?}", + outcome.failure_message() + ); + + // Then: migration_info no longer shows a destination for that account. + (|| async { + let info: serde_json::Value = cluster + .view_migration_info() + .await + .context("failed to view migration info")?; + let entry = info.get(&source_account_id); + anyhow::ensure!( + entry.is_none(), + "expected destination to be cleared after cancel, got {entry:?}" + ); + Ok(()) + }) + .retry( + ConstantBuilder::default() + .with_delay(common::POLL_INTERVAL) + .with_max_times( + (INDEXER_SYNC_TIMEOUT.as_millis() / common::POLL_INTERVAL.as_millis()) as usize, + ), + ) + .await + .expect("timed out waiting for contract to reflect cancelled migration"); + + // And: cancelling again fails — the record was already removed + let outcome = cluster + .cancel_node_migration(source_idx) + .await + .expect("failed to call cancel_node_migration a second time"); + assert!( + !outcome.is_success(), + "expected the second cancel_node_migration call to fail, but it succeeded" + ); +} diff --git a/crates/near-mpc-contract-interface/src/method_names.rs b/crates/near-mpc-contract-interface/src/method_names.rs index c1f83c8d9..0af3b10de 100644 --- a/crates/near-mpc-contract-interface/src/method_names.rs +++ b/crates/near-mpc-contract-interface/src/method_names.rs @@ -52,6 +52,7 @@ pub const UPDATE_PARTICIPANT_URL: &str = "update_participant_url"; pub const VERIFY_TEE: &str = "verify_tee"; pub const CONCLUDE_NODE_MIGRATION: &str = "conclude_node_migration"; pub const START_NODE_MIGRATION: &str = "start_node_migration"; +pub const CANCEL_NODE_MIGRATION: &str = "cancel_node_migration"; pub const REGISTER_BACKUP_SERVICE: &str = "register_backup_service"; pub const CLEANUP_ORPHANED_NODE_MIGRATIONS: &str = "cleanup_orphaned_node_migrations"; pub const CLEAN_TEE_STATUS: &str = "clean_tee_status"; diff --git a/docs/migration-service.md b/docs/migration-service.md index 9b6027e12..2cb7b3ac3 100644 --- a/docs/migration-service.md +++ b/docs/migration-service.md @@ -321,7 +321,7 @@ flowchart TD For security reasons and to avoid edge cases and race conditions, the MPC network allows migration of nodes only while the protocol is in a `Running` state (as opposed to `Resharing` or `Initializing`, which are the two other well-defined states). Note that starting a migration workflow does not require a signing quorum. Instead, each participant can migrate their node at their own discretion. However, to avoid making the migration process a DoS attack vector, protocol state changes must have priority over any ongoing migrations. -If the protocol state changes into a `Resharing` or `Initializing` state, any ongoing migration processes will simply be cancelled. +If the protocol state changes into a `Resharing` or `Initializing` state, the pending `OngoingNodeMigration` record itself is **not** cleared by the transition and remains unless the operator explicitly withdraws it with `cancel_node_migration` or starts a new migration which will replace it. ## Implementation Details @@ -431,7 +431,7 @@ The contract provides the following methods: - **`start_node_migration(destination_node_info: ParticipantInfo)`** - Initiates a node migration: - Called by the node operator - - Creates an `OngoingNodeMigration` record for the given `AccountId` + - Creates an `OngoingNodeMigration` record for the node operator's account. - Stores the destination node's `ParticipantInfo` (new TLS keys, etc.) - Can be called multiple times to update the destination node info (only the last value is retained) - Returns an error if the protocol is not in `Running` state @@ -439,7 +439,7 @@ The contract provides the following methods: - **`cancel_node_migration()`** - Cancels an ongoing node migration: - Called by the node operator - - Removes the `OngoingNodeMigration` record for the given `AccountId` + - Removes the `OngoingNodeMigration` record for the node operator's account. - Useful if the new node is not functioning correctly or wrong information was provided - **`conclude_node_migration(keyset: &Keyset)`** - Finalizes a node migration: @@ -461,7 +461,7 @@ The contract provides the following methods: #### Migration Related Behavior -- The `OngoingNodeMigration` records are automatically cleared when the protocol transitions from `Running` state to `Resharing` or `Initializing` state, effectively cancelling any in-progress migrations. +- The `OngoingNodeMigration` records are **not** automatically cleared when the protocol transitions from `Running` state to `Resharing` or `Initializing` state. - **Future Enhancement**: It may be desirable for the contract to verify that calls to `conclude_node_migration(keyset)` come from the actual onboarding node by checking the transaction signer's public key _(see [(#1086)](https://github.com/near/mpc/issues/1086))_. This would prevent ill-behaved decommissioned nodes from making spurious migration calls. This would require: - Comparing `env::signer_account_pk()` with the public key associated with the participant (note: this is different from the TLS key currently stored as [`signer_pk`](https://github.com/near/mpc/blob/b5a9d1b2eef4de47d19b66cb25b577da2b897560/crates/contract/src/tee/tee_state.rs#L32) in TEEState) - Including this public key in the TEE attestation From 2a0127364a1b50c9850dcacbcf8e0c257c470c8f Mon Sep 17 00:00:00 2001 From: metalurgical <97008724+metalurgical@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:31:22 +0200 Subject: [PATCH 02/10] fix: add deposit to test and regenerate snap --- crates/contract/src/lib.rs | 12 ++++++++++-- .../tests/snapshots/abi__abi_has_not_changed.snap | 11 +++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 7b8323c6f..efc0ccc3c 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -8977,8 +8977,13 @@ mod tests { .first() .expect("expected at least one participant") .clone(); - let mut test_env = Environment::new(None, None, None); - test_env.set_signer(&account_id); + + testing_env!( + VMContextBuilder::new() + .signer_account_id(account_id.clone()) + .predecessor_account_id(account_id.clone()) + .attached_deposit(NearToken::from_yoctonear(1)) + .build()); let destination_node_info = gen_random_destination_info(); contract .start_node_migration(destination_node_info.clone()) @@ -8987,6 +8992,9 @@ mod tests { migration_info(&contract, &account_id), (account_id.clone(), None, Some(destination_node_info)) ); + + let mut test_env = Environment::new(None, Some(account_id.clone()), None); + test_env.set_signer(&account_id); // Cancel the migration contract .cancel_node_migration() diff --git a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap index 64a2500e0..54e55380e 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -431,6 +431,17 @@ expression: abi } } }, + { + "name": "cancel_node_migration", + "doc": " Cancels a previously started node migration for the calling account.\n\n Removes the caller's pending `DestinationNodeInfo` record. This is useful if the new node\n is not functioning correctly or the wrong information was provided when calling\n [`Self::start_node_migration`].\n\n This function is callable regardless of whether the protocol is in a `Running` state or\n whether the signer is a current participant.\n\n # Errors\n - `NodeMigrationError::MigrationNotFound`: if no migration record exists for the caller", + "kind": "call", + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } + }, { "name": "clean_foreign_chain_data", "doc": " Private endpoint to clean up foreign chain policy votes and node configurations\n for non-participants after resharing.", From d702b6da5653121519d7726cc7962cb6236decd2 Mon Sep 17 00:00:00 2001 From: metalurgical <97008724+metalurgical@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:32:50 +0200 Subject: [PATCH 03/10] lint: cargo fmt --- crates/contract/src/lib.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index efc0ccc3c..6a03ddc23 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -8979,11 +8979,12 @@ mod tests { .clone(); testing_env!( - VMContextBuilder::new() - .signer_account_id(account_id.clone()) - .predecessor_account_id(account_id.clone()) - .attached_deposit(NearToken::from_yoctonear(1)) - .build()); + VMContextBuilder::new() + .signer_account_id(account_id.clone()) + .predecessor_account_id(account_id.clone()) + .attached_deposit(NearToken::from_yoctonear(1)) + .build() + ); let destination_node_info = gen_random_destination_info(); contract .start_node_migration(destination_node_info.clone()) From d9fca27223850789ca5930e1cd534473c5c158e9 Mon Sep 17 00:00:00 2001 From: metalurgical <97008724+metalurgical@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:37:19 +0200 Subject: [PATCH 04/10] shorten comments --- crates/contract/src/lib.rs | 10 ---------- crates/e2e-tests/tests/migration_service.rs | 2 -- 2 files changed, 12 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index b860faf6b..64d4dc705 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -2202,16 +2202,6 @@ impl MpcContract { } /// Cancels a previously started node migration for the calling account. - /// - /// Removes the caller's pending `DestinationNodeInfo` record. This is useful if the new node - /// is not functioning correctly or the wrong information was provided when calling - /// [`Self::start_node_migration`]. - /// - /// This function is callable regardless of whether the protocol is in a `Running` state or - /// whether the signer is a current participant. - /// - /// # Errors - /// - `NodeMigrationError::MigrationNotFound`: if no migration record exists for the caller #[handle_result] pub fn cancel_node_migration(&mut self) -> Result<(), Error> { let account_id = Self::assert_caller_is_signer(); diff --git a/crates/e2e-tests/tests/migration_service.rs b/crates/e2e-tests/tests/migration_service.rs index 395b35f60..ad3a2eb24 100644 --- a/crates/e2e-tests/tests/migration_service.rs +++ b/crates/e2e-tests/tests/migration_service.rs @@ -997,8 +997,6 @@ async fn migration_service__should_handle_back_migration_a_to_b_to_a() { .expect("ckd request failed after back-migration"); } -/// Test to ensure `cancel_node_migration` removes a pending migration for the -/// calling account. #[tokio::test] #[expect(non_snake_case)] async fn migration_service__cancel_node_migration_clears_ongoing_migration_info() { From 56cc37ffbdbcfa96e97e011a51607ff074164cc6 Mon Sep 17 00:00:00 2001 From: metalurgical <97008724+metalurgical@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:15:41 +0200 Subject: [PATCH 05/10] update regenerate snap file update code for latest refactors --- .../tests/snapshots/abi__abi_has_not_changed.snap | 2 +- crates/e2e-tests/src/cluster.rs | 5 +++-- crates/near-mpc-contract-interface/src/client.rs | 13 ++++++++++++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap index 7691a1f0b..8acc3ed0e 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -433,7 +433,7 @@ expression: abi }, { "name": "cancel_node_migration", - "doc": " Cancels a previously started node migration for the calling account.\n\n Removes the caller's pending `DestinationNodeInfo` record. This is useful if the new node\n is not functioning correctly or the wrong information was provided when calling\n [`Self::start_node_migration`].\n\n This function is callable regardless of whether the protocol is in a `Running` state or\n whether the signer is a current participant.\n\n # Errors\n - `NodeMigrationError::MigrationNotFound`: if no migration record exists for the caller", + "doc": " Cancels a previously started node migration for the calling account.", "kind": "call", "result": { "serialization_type": "json", diff --git a/crates/e2e-tests/src/cluster.rs b/crates/e2e-tests/src/cluster.rs index d958c0a62..dfbde81cc 100644 --- a/crates/e2e-tests/src/cluster.rs +++ b/crates/e2e-tests/src/cluster.rs @@ -1003,10 +1003,11 @@ impl MpcCluster { &self, node_index: usize, ) -> anyhow::Result { - let client = self.operator_client_for(node_index)?; self.contract - .call_from(&client, method_names::CANCEL_NODE_MIGRATION, json!({})) + .handle_for(self.operator_client_for(node_index)?) + .cancel_node_migration() .await + .context("failed to cancel node migration") } /// Update the registered URL of a specific node, called from that node's own operator account. diff --git a/crates/near-mpc-contract-interface/src/client.rs b/crates/near-mpc-contract-interface/src/client.rs index 801934cae..7eb70a0b9 100644 --- a/crates/near-mpc-contract-interface/src/client.rs +++ b/crates/near-mpc-contract-interface/src/client.rs @@ -17,7 +17,7 @@ use crate::deposits::{ STORAGE_BYTE_COST_YOCTONEAR, propose_update_required_deposit_yoctonear, }; use crate::method_names::{ - PROPOSE_UPDATE, REGISTER_BACKUP_SERVICE, REGISTER_FOREIGN_CHAIN_SUPPORT, + CANCEL_NODE_MIGRATION, PROPOSE_UPDATE, REGISTER_BACKUP_SERVICE, REGISTER_FOREIGN_CHAIN_SUPPORT, REQUEST_APP_PRIVATE_KEY, SIGN, START_NODE_MIGRATION, SUBMIT_PARTICIPANT_INFO, UPDATE_PARTICIPANT_URL, VERIFY_FOREIGN_TRANSACTION, VERIFY_TEE, VOTE_ADD_DOMAINS, VOTE_CANCEL_KEYGEN, VOTE_CANCEL_RESHARING, VOTE_NEW_PARAMETERS, VOTE_UPDATE, @@ -241,6 +241,17 @@ impl MpcContractHandle { .await } + pub async fn cancel_node_migration( + &self, + ) -> Result> { + self.call(FunctionCallArgs::no_deposit( + CANCEL_NODE_MIGRATION, + b"{}".to_vec(), + MAX_GAS, + )) + .await + } + pub async fn register_foreign_chain_support( &self, foreign_chain_support: SupportedForeignChains, From 63e0c9aa538cbc96c4f28ad2ffd1f9938605fc37 Mon Sep 17 00:00:00 2001 From: metalurgical <97008724+metalurgical@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:08:12 +0200 Subject: [PATCH 06/10] update ensure log for all calls to cancel node migration --- crates/contract/src/lib.rs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index c6009f04c..b9a15605b 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -2022,14 +2022,9 @@ impl MpcContract { #[handle_result] pub fn cancel_node_migration(&mut self) -> Result<(), Error> { let account_id = Self::assert_caller_is_signer(); - - match self.node_migrations.remove_migration(&account_id) { - Some(destination_node_info) => log!( - "cancel_node_migration: signer={:?}, destination_node_info={:?}", - account_id, - destination_node_info - ), - None => return Err(errors::NodeMigrationError::MigrationNotFound.into()), + log!("cancel_node_migration: signer={:?}", account_id); + if self.node_migrations.remove_migration(&account_id).is_none() { + return Err(errors::NodeMigrationError::MigrationNotFound.into()); } Ok(()) } From 3bf76a86d6a3cf43a2b00dec371d879c92e99f85 Mon Sep 17 00:00:00 2001 From: metalurgical <97008724+metalurgical@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:53:30 +0200 Subject: [PATCH 07/10] resolve blocking review items --- crates/contract/src/lib.rs | 70 ++++++++++++++----- .../snapshots/abi__abi_has_not_changed.snap | 5 +- .../near-mpc-contract-interface/src/client.rs | 4 +- 3 files changed, 59 insertions(+), 20 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index b9a15605b..618cf035e 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -92,7 +92,7 @@ use tee::{ }; /// Minimum deposit required for the operator-authenticated node-management methods -/// (`register_backup_service`, `start_node_migration`, `update_participant_url`). +/// (`register_backup_service`, `start_node_migration`, `update_participant_url`, 'cancel_node_migration'). /// /// A non-zero deposit forces the call to be signed by a full-access key: the node's own key /// is registered as a function-call access key, which cannot attach a deposit, so a leaked @@ -2019,10 +2019,16 @@ impl MpcContract { } /// Cancels a previously started node migration for the calling account. + /// + /// Requires a deposit of at least [`MINIMUM_NODE_MANAGEMENT_DEPOSIT`] (excess is refunded), so + /// the call must be signed by a full-access key rather than the node's function-call access + /// key. #[handle_result] + #[payable] pub fn cancel_node_migration(&mut self) -> Result<(), Error> { let account_id = Self::assert_caller_is_signer(); log!("cancel_node_migration: signer={:?}", account_id); + require_deposit(MINIMUM_NODE_MANAGEMENT_DEPOSIT, &account_id); if self.node_migrations.remove_migration(&account_id).is_none() { return Err(errors::NodeMigrationError::MigrationNotFound.into()); } @@ -7054,7 +7060,8 @@ mod tests { } #[test] - fn test_cancel_node_migration() { + #[should_panic(expected = "Attached deposit is lower than required")] + fn cancel_node_migration__should_reject_when_no_deposit_attached() { let running_state = ProtocolContractState::Running(gen_running_state(NUM_DOMAINS)); let mut contract = MpcContract::new_from_protocol_state(running_state); let participants = { @@ -7068,14 +7075,51 @@ mod tests { .first() .expect("expected at least one participant") .clone(); + let _ = Environment::new(None, Some(account_id.clone()), None); + let _ = contract.cancel_node_migration(); + } - testing_env!( - VMContextBuilder::new() - .signer_account_id(account_id.clone()) - .predecessor_account_id(account_id.clone()) - .attached_deposit(NearToken::from_yoctonear(1)) - .build() + #[test] + fn cancel_node_migration__should_reject_when_no_migration_info_is_found() { + let running_state = ProtocolContractState::Running(gen_running_state(NUM_DOMAINS)); + let mut contract = MpcContract::new_from_protocol_state(running_state); + let participants = { + let ProtocolContractState::Running(running) = &contract.protocol_state else { + panic!("expected running state"); + }; + running.parameters.participants().clone() + }; + let (account_id, _, _) = participants + .participants() + .first() + .expect("expected at least one participant") + .clone(); + let mut test_env = Environment::new(None, Some(account_id.clone()), None); + test_env.set_deposit(NearToken::from_yoctonear(1)); + assert!(contract.migration_info().is_empty()); + assert_matches!( + contract.cancel_node_migration().unwrap_err(), + Error::NodeMigrationError(NodeMigrationError::MigrationNotFound) ); + } + + #[test] + fn cancel_node_migration__should_cancel_node_migration() { + let running_state = ProtocolContractState::Running(gen_running_state(NUM_DOMAINS)); + let mut contract = MpcContract::new_from_protocol_state(running_state); + let participants = { + let ProtocolContractState::Running(running) = &contract.protocol_state else { + panic!("expected running state"); + }; + running.parameters.participants().clone() + }; + let (account_id, _, _) = participants + .participants() + .first() + .expect("expected at least one participant") + .clone(); + let mut test_env = Environment::new(None, Some(account_id.clone()), None); + test_env.set_deposit(NearToken::from_yoctonear(2)); let destination_node_info = gen_random_destination_info(); contract .start_node_migration(destination_node_info.clone()) @@ -7084,10 +7128,6 @@ mod tests { migration_info(&contract, &account_id), (account_id.clone(), None, Some(destination_node_info)) ); - - let mut test_env = Environment::new(None, Some(account_id.clone()), None); - test_env.set_signer(&account_id); - // Cancel the migration contract .cancel_node_migration() .expect("caller should be able to cancel their pending migration"); @@ -7095,12 +7135,6 @@ mod tests { migration_info(&contract, &account_id), (account_id.clone(), None, None) ); - // Check migration was cancelled assert!(contract.migration_info().is_empty()); - let res = contract.cancel_node_migration(); - assert_matches!( - res.unwrap_err(), - Error::NodeMigrationError(NodeMigrationError::MigrationNotFound) - ); } } diff --git a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap index 8acc3ed0e..878ef35d7 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -433,8 +433,11 @@ expression: abi }, { "name": "cancel_node_migration", - "doc": " Cancels a previously started node migration for the calling account.", + "doc": " Cancels a previously started node migration for the calling account.\n\n Requires a deposit of at least [`MINIMUM_NODE_MANAGEMENT_DEPOSIT`] (excess is refunded), so\n the call must be signed by a full-access key rather than the node's function-call access\n key.", "kind": "call", + "modifiers": [ + "payable" + ], "result": { "serialization_type": "json", "type_schema": { diff --git a/crates/near-mpc-contract-interface/src/client.rs b/crates/near-mpc-contract-interface/src/client.rs index 7eb70a0b9..45ab5cef6 100644 --- a/crates/near-mpc-contract-interface/src/client.rs +++ b/crates/near-mpc-contract-interface/src/client.rs @@ -244,10 +244,11 @@ impl MpcContractHandle { pub async fn cancel_node_migration( &self, ) -> Result> { - self.call(FunctionCallArgs::no_deposit( + self.call(FunctionCallArgs::new( CANCEL_NODE_MIGRATION, b"{}".to_vec(), MAX_GAS, + NearToken::from_yoctonear(MINIMUM_NODE_MANAGEMENT_DEPOSIT_YOCTONEAR), )) .await } @@ -530,6 +531,7 @@ mod tests { }) .await .unwrap(); + handle.cancel_node_migration().await.unwrap(); handle .register_foreign_chain_support(BTreeSet::from([ForeignChain::Bitcoin]).into()) .await From e78dbf2f9f19ed2ce9b47843e045f387344fead4 Mon Sep 17 00:00:00 2001 From: metalurgical <97008724+metalurgical@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:42:24 +0200 Subject: [PATCH 08/10] update: nit after conflict resolution in merge --- crates/contract/src/api/node_migration.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/contract/src/api/node_migration.rs b/crates/contract/src/api/node_migration.rs index cd6ee0c1b..2b843230e 100644 --- a/crates/contract/src/api/node_migration.rs +++ b/crates/contract/src/api/node_migration.rs @@ -112,7 +112,7 @@ impl MpcContract { /// Cancels a previously started node migration for the calling account. /// - /// Requires a deposit of at least [`crate::MINIMUM_NODE_MANAGEMENT_DEPOSIT`] (excess is refunded), so + /// Requires a deposit of at least [`MINIMUM_NODE_MANAGEMENT_DEPOSIT`] (excess is refunded), so /// the call must be signed by a full-access key rather than the node's function-call access /// key. #[handle_result] @@ -120,7 +120,7 @@ impl MpcContract { pub fn cancel_node_migration(&mut self) -> Result<(), Error> { let account_id = Self::assert_caller_is_signer(); log!("cancel_node_migration: signer={:?}", account_id); - require_deposit(crate::MINIMUM_NODE_MANAGEMENT_DEPOSIT, &account_id); + require_deposit(MINIMUM_NODE_MANAGEMENT_DEPOSIT, &account_id); if self.node_migrations.remove_migration(&account_id).is_none() { return Err(errors::NodeMigrationError::MigrationNotFound.into()); } From 3140610e2eca1133850873f874586a2084886686 Mon Sep 17 00:00:00 2001 From: metalurgical <97008724+metalurgical@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:01:21 +0200 Subject: [PATCH 09/10] fix test --- crates/near-mpc-contract-interface/src/client.rs | 2 +- ...ntract_handle__should_match_the_wire_format_catalog.snap | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/near-mpc-contract-interface/src/client.rs b/crates/near-mpc-contract-interface/src/client.rs index 1342b683a..c31bf6fd3 100644 --- a/crates/near-mpc-contract-interface/src/client.rs +++ b/crates/near-mpc-contract-interface/src/client.rs @@ -572,7 +572,7 @@ mod tests { // Then let calls = caller.calls.lock().unwrap(); - assert_eq!(calls.len(), 18); + assert_eq!(calls.len(), 19); let catalog = calls .iter() .map(|(contract_id, call)| render(contract_id, call)) diff --git a/crates/near-mpc-contract-interface/src/snapshots/near_mpc_contract_interface__client__tests__mpc_contract_handle__should_match_the_wire_format_catalog.snap b/crates/near-mpc-contract-interface/src/snapshots/near_mpc_contract_interface__client__tests__mpc_contract_handle__should_match_the_wire_format_catalog.snap index 208abb74b..68107d5a1 100644 --- a/crates/near-mpc-contract-interface/src/snapshots/near_mpc_contract_interface__client__tests__mpc_contract_handle__should_match_the_wire_format_catalog.snap +++ b/crates/near-mpc-contract-interface/src/snapshots/near_mpc_contract_interface__client__tests__mpc_contract_handle__should_match_the_wire_format_catalog.snap @@ -86,6 +86,12 @@ gas: 300.0 Tgas deposit: 1 yoctoNEAR args: {"destination_node_info":{"signer_account_pk":"ed25519:US517G5965aydkZ46HS38QLi7UQiSojurfbQfKCELFx","destination_node_info":{"url":"http://localhost:7","tls_public_key":"ed25519:US517G5965aydkZ46HS38QLi7UQiSojurfbQfKCELFx"}}} +contract: mpc.near +method: cancel_node_migration +gas: 300.0 Tgas +deposit: 1 yoctoNEAR +args: {} + contract: mpc.near method: register_foreign_chain_support gas: 300.0 Tgas From 8dd29697e439880860aeb3328479a0dadd2bc3d3 Mon Sep 17 00:00:00 2001 From: metalurgical <97008724+metalurgical@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:43:09 +0200 Subject: [PATCH 10/10] update --- crates/e2e-tests/src/cluster.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/e2e-tests/src/cluster.rs b/crates/e2e-tests/src/cluster.rs index f64f9a5c2..d8712d505 100644 --- a/crates/e2e-tests/src/cluster.rs +++ b/crates/e2e-tests/src/cluster.rs @@ -1014,8 +1014,8 @@ impl MpcCluster { &self, node_index: usize, ) -> anyhow::Result { - self.contract - .handle_for(self.operator_client_for(node_index)?) + self.operator_client_for(node_index)? + .call_mpc(self.contract_id()) .cancel_node_migration() .await .context("failed to cancel node migration")