Repository: Near-One/mpc
Pull request #414: Add migration logic 
Author: login: kuksag
Created: 2025-05-13T20:58:44Z | Merged: 2025-05-16T10:22:05Z

stacked on: https://github.com/Near-One/mpc/pull/408

Current testnet logic runs on this binary https://github.com/Near-One/mpc/releases/tag/2.0.0-rc.1, which runs on 7c277d73b9f69679c8f2b0a58bed51617a333e3c
It can not migrate to the current version, because migration logic has to be updated. This PR addresses this problem. 

Migration from 7c277d73b9f69679c8f2b0a58bed51617a333e3c to the latest commit was succeffully tested.

### Overview:
There are new semantics around V0 and V1 and. 
TLDR: Store only migration for the last incompatible change. 

### Rationale: 
Other approach would be to store each state there was for each breaking change + corresponding migration logic. 
That results in lots of dead code. I propose storing only migration from the last breaking change. If for whatever reason we have to migrate from an older version, we checkout some specific commit in history for "older" -> "recent breaking change" migration, and do "recent" -> "current breaking change"  migration




Diff:
diff --git a/libs/chain-signatures/compiled-contracts/last-breaking-changes.wasm b/libs/chain-signatures/compiled-contracts/last-breaking-changes.wasm
old mode 100644
new mode 100755
index 05b0bb88ff..704a6d872c
Binary files a/libs/chain-signatures/compiled-contracts/last-breaking-changes.wasm and b/libs/chain-signatures/compiled-contracts/last-breaking-changes.wasm differ
diff --git a/libs/chain-signatures/compiled-contracts/v2.0.0-rc.1.wasm b/libs/chain-signatures/compiled-contracts/v2.0.0-rc.1.wasm
index ae727849ed..757badda16 100644
Binary files a/libs/chain-signatures/compiled-contracts/v2.0.0-rc.1.wasm and b/libs/chain-signatures/compiled-contracts/v2.0.0-rc.1.wasm differ
diff --git a/libs/chain-signatures/contract/src/lib.rs b/libs/chain-signatures/contract/src/lib.rs
index e702985b2a..25dcb51837 100644
--- a/libs/chain-signatures/contract/src/lib.rs
+++ b/libs/chain-signatures/contract/src/lib.rs
@@ -54,10 +54,14 @@ const UPDATE_CONFIG_GAS: Gas = Gas::from_tgas(5);
 // Maximum time after which TEE MPC nodes must be upgraded to the latest version
 const TEE_UPGRADE_PERIOD: BlockHeight = 604800; // ~7 days
 
+/// Store two version of the MPC contract for migration and backward compatibility purposes.
+/// Note: Probably, you don't need to change this struct.
 #[near_bindgen]
 #[derive(BorshDeserialize, BorshSerialize, Debug)]
 pub enum VersionedMpcContract {
+    /// Previous breaking changes version. We call `migration()` on it to transit into v1
     V0(MpcContractV0),
+    /// Current actual version
     V1(MpcContract),
 }
 
@@ -154,22 +158,6 @@ pub struct MpcContract {
     tee_state: TeeState,
 }
 
-impl From<v0_state::MpcContractV0> for MpcContract {
-    fn from(value: v0_state::MpcContractV0) -> Self {
-        Self {
-            protocol_state: value.protocol_state.into(),
-            pending_requests: value.pending_requests,
-            proposed_updates: ProposedUpdates::default(),
-            config: value.config,
-            tee_state: TeeState {
-                allowed_code_hashes: AllowedCodeHashes::default(),
-                historical_code_hashes: vec![],
-                votes: CodeHashesVotes::default(),
-            },
-        }
-    }
-}
-
 impl MpcContract {
     pub(crate) fn public_key_extended(
         &self,
diff --git a/libs/chain-signatures/contract/src/state/mod.rs b/libs/chain-signatures/contract/src/state/mod.rs
index 9b19037af9..9a52d74932 100644
--- a/libs/chain-signatures/contract/src/state/mod.rs
+++ b/libs/chain-signatures/contract/src/state/mod.rs
@@ -10,9 +10,8 @@ use crate::primitives::{
     key_state::{AuthenticatedParticipantId, EpochId, KeyEventId},
     thresholds::{Threshold, ThresholdParameters},
 };
-use crate::v0_state;
 use initializing::InitializingContractState;
-use near_sdk::{env, near};
+use near_sdk::near;
 use resharing::ResharingContractState;
 use running::RunningContractState;
 
@@ -26,17 +25,6 @@ pub enum ProtocolContractState {
     Resharing(ResharingContractState),
 }
 
-impl From<v0_state::ProtocolContractState> for ProtocolContractState {
-    fn from(value: v0_state::ProtocolContractState) -> Self {
-        match value {
-            v0_state::ProtocolContractState::Running(running) => {
-                ProtocolContractState::Running(running.into())
-            }
-            _ => env::panic_str("not supported"),
-        }
-    }
-}
-
 impl ProtocolContractState {
     pub fn domain_registry(&self) -> Result<&DomainRegistry, Error> {
         let domain_registry = match self {
diff --git a/libs/chain-signatures/contract/src/state/running.rs b/libs/chain-signatures/contract/src/state/running.rs
index 5f81209673..8487640824 100644
--- a/libs/chain-signatures/contract/src/state/running.rs
+++ b/libs/chain-signatures/contract/src/state/running.rs
@@ -1,7 +1,6 @@
 use super::initializing::InitializingContractState;
 use super::key_event::KeyEvent;
 use super::resharing::ResharingContractState;
-use super::v0_state;
 use crate::crypto_shared::types::PublicKeyExtended;
 use crate::errors::{DomainError, Error, InvalidParameters, VoteError};
 use crate::legacy_contract_state;
@@ -42,18 +41,6 @@ pub struct RunningContractState {
     pub add_domains_votes: AddDomainsVotes,
 }
 
-impl From<v0_state::RunningContractState> for RunningContractState {
-    fn from(value: v0_state::RunningContractState) -> Self {
-        RunningContractState {
-            domains: value.domains,
-            keyset: value.keyset,
-            parameters: value.parameters,
-            parameters_votes: ThresholdParametersVotes::default(),
-            add_domains_votes: value.add_domains_votes,
-        }
-    }
-}
-
 impl From<&legacy_contract_state::RunningContractState> for RunningContractState {
     fn from(state: &legacy_contract_state::RunningContractState) -> Self {
         let key = match state.public_key.curve_type() {
diff --git a/libs/chain-signatures/contract/src/v0_state/mod.rs b/libs/chain-signatures/contract/src/v0_state/mod.rs
index b72a445590..cfac2ccccb 100644
--- a/libs/chain-signatures/contract/src/v0_state/mod.rs
+++ b/libs/chain-signatures/contract/src/v0_state/mod.rs
@@ -1,17 +1,28 @@
-use std::collections::BTreeMap;
-
-use near_sdk::{near, store::LookupMap};
+//! ## Overview
+//! This module stores the previous contract state—the one you want to migrate from.
+//! The goal is to describe the data layout _exactly_ as it existed before.
+//!
+//! ## Guideline
+//! In theory, you could copy-paste every struct from the specific commit you're migrating from.
+//! However, this approach (a) requires manual effort from a developer and (b) increases the binary size.
+//! A better approach: only copy the structures that have changed and import the rest from the existing codebase.
+use near_account_id::AccountId;
+use near_sdk::store::IterableMap;
+use near_sdk::{env, near, store::LookupMap};
+use std::collections::{BTreeMap, HashSet};
 
+use crate::legacy_contract_state::ConfigV1;
+use crate::primitives::code_hash::CodeHashesVotes;
+use crate::primitives::domain::{AddDomainsVotes, DomainRegistry};
+use crate::primitives::key_state::{AuthenticatedParticipantId, KeyForDomain, Keyset};
+use crate::primitives::thresholds::ThresholdParameters;
+use crate::state::initializing::InitializingContractState;
+use crate::state::key_event::KeyEvent;
+use crate::update::UpdateId;
 use crate::{
     config::Config,
-    primitives::{
-        domain::{AddDomainsVotes, DomainRegistry},
-        key_state::{AuthenticatedParticipantId, KeyForDomain, Keyset},
-        signature::{SignatureRequest, YieldIndex},
-        thresholds::ThresholdParameters,
-    },
-    state::{initializing::InitializingContractState, key_event::KeyEvent},
-    update::ProposedUpdates,
+    primitives::signature::{SignatureRequest, YieldIndex},
+    AllowedCodeHashes, MpcContract, TeeState,
 };
 
 #[near(serializers=[borsh, json])]
@@ -22,6 +33,7 @@ pub struct ResharingContractState {
     pub reshared_keys: Vec<KeyForDomain>,
     pub resharing_key: KeyEvent,
 }
+
 #[near(serializers=[borsh, json])]
 #[derive(Debug, Default, PartialEq)]
 #[cfg_attr(feature = "dev-utils", derive(Clone))]
@@ -54,6 +66,31 @@ pub enum ProtocolContractState {
     Running(RunningContractState),
     Resharing(ResharingContractState),
 }
+
+#[allow(clippy::large_enum_variant)]
+#[near(serializers=[borsh])]
+#[derive(Debug, serde::Serialize, serde::Deserialize)]
+pub enum Update {
+    Config(Config),
+    Contract(Vec<u8>),
+    ConfigV1(ConfigV1),
+}
+
+#[near(serializers=[borsh])]
+#[derive(Debug)]
+struct UpdateEntry {
+    updates: Vec<Update>,
+    votes: HashSet<AccountId>,
+    bytes_used: u128,
+}
+
+#[near(serializers=[borsh])]
+#[derive(Debug)]
+pub struct ProposedUpdates {
+    entries: IterableMap<UpdateId, UpdateEntry>,
+    id: UpdateId,
+}
+
 #[near(serializers=[borsh])]
 #[derive(Debug)]
 pub struct MpcContractV0 {
@@ -62,3 +99,42 @@ pub struct MpcContractV0 {
     pub proposed_updates: ProposedUpdates,
     pub config: Config,
 }
+
+impl From<RunningContractState> for crate::RunningContractState {
+    fn from(value: RunningContractState) -> Self {
+        Self {
+            domains: value.domains,
+            keyset: value.keyset,
+            parameters: value.parameters,
+            parameters_votes: crate::primitives::votes::ThresholdParametersVotes::default(),
+            add_domains_votes: value.add_domains_votes,
+        }
+    }
+}
+
+impl From<ProtocolContractState> for crate::ProtocolContractState {
+    fn from(value: ProtocolContractState) -> Self {
+        match value {
+            ProtocolContractState::Running(running) => {
+                crate::ProtocolContractState::Running(running.into())
+            }
+            _ => env::panic_str("not supported"),
+        }
+    }
+}
+
+impl From<MpcContractV0> for MpcContract {
+    fn from(value: MpcContractV0) -> Self {
+        Self {
+            protocol_state: value.protocol_state.into(),
+            pending_requests: value.pending_requests,
+            proposed_updates: crate::ProposedUpdates::default(),
+            config: value.config,
+            tee_state: TeeState {
+                allowed_code_hashes: AllowedCodeHashes::default(),
+                historical_code_hashes: vec![],
+                votes: CodeHashesVotes::default(),
+            },
+        }
+    }
+}


Reviews:
user: login: kevindeforth
body: Thank you for fixing the migration of the `Update` struct. 
I would expect the failing test to pass after migration of `ThresholdParametersVotes` has been re-enabled.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T06:00:03Z
user: login: kevindeforth
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T06:02:27Z
user: login: kevindeforth
body: Thank You very much!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T09:29:22Z

Review comments:
diff_hunk: @@ -62,3 +53,19 @@ pub struct MpcContractV0 {
     pub proposed_updates: ProposedUpdates,
     pub config: Config,
 }
+
+impl From<MpcContractV0> for MpcContract {
+    fn from(value: MpcContractV0) -> Self {
+        Self {
+            protocol_state: value.protocol_state,
path: libs/chain-signatures/contract/src/v0_state/mod.rs
user: login: kevindeforth
body: I don't think this will work. We changed the struct `ThresholdParametersVotes` in PR #372.

previously (what is released on testnet):
```Rust
pub struct ThresholdParametersVotes {
    proposal_by_account: BTreeMap<AuthenticatedParticipantId, ThresholdParameters>,
}
```

now:
```Rust
pub struct ThresholdParametersVotes {
    proposal_by_account: BTreeMap<AuthenticatedAccountId, ThresholdParameters>,
}
```
created_at: 2025-05-14T05:53:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -154,22 +158,6 @@ pub struct MpcContract {
     tee_state: TeeState,
 }
 
-impl From<v0_state::MpcContractV0> for MpcContract {
path: libs/chain-signatures/contract/src/lib.rs
user: login: kevindeforth
body: (this was the logic migrating the `ThresholdParametersVotes` struct)
created_at: 2025-05-14T06:02:27Z
author_association: CONTRIBUTOR

PR comments:
user: login: kuksag
created_at: 2025-05-14T04:49:02Z
body: @kevindeforth `test_update_v2_running` is failing now, because of my new semantics. Please let me know how do you find my proposal.

If we agree on that, then this test seems obsolete due to new back compatibility unit test.
Otherwise, they rightfully co-exists with each other.
author_association: COLLABORATOR
user: login: kuksag
created_at: 2025-05-15T08:00:44Z
body: Let me supply the "proper" v2.0.0, I want to check how the tests are going to behave, to verify a hypothesis of mine (I'm too lazy to compile neard and other stuff locally for pytests). Will revisit later.
author_association: COLLABORATOR
user: login: kuksag
created_at: 2025-05-16T03:31:31Z
body: > I would expect the failing test to pass after migration of ThresholdParametersVotes has been re-enabled

Yes, you are totally right Kevin 👍 . I was confused why the unit test that I wrote was succeeding... TLDR some non-trivial state has to be supplied. I loaded off some thoughts in https://github.com/Near-One/mpc/issues/415
author_association: COLLABORATOR