⚠ This page is served via a proxy. Original site: https://github.com
This service does not collect credentials or authentication data.
Skip to content

Conversation

@NikolaMilosa
Copy link
Contributor

This PR introduces the core implementation for the Node Operator Migration feature, which allows node providers to migrate node operator records from one principal to another within the same data center. This addresses the use case where a node operator loses access to their private key and needs to migrate to a new identity while preserving all associated data, removing the need to redeploy nodes (if they are actively participating in subnets).

There is some pending work left that will be done in follow-up PRs to keep the scope of this PR mostly to a single (new) file.

Work remaining to be done:

  • Registry Canister update call: Wasn't done here to make merging this less problematic as this only adds code that isn't utilized still.
  • Integration tests: Needed mostly for real testing of rate limiting.
  • IC-admin support: Obvious why this is needed.

@github-actions github-actions bot added the feat label Jan 15, 2026
Comment on lines +3 to +4
//! This module provides functionality for node providers to migrate node operator
//! records from one principal to another within the same data center.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure why this is called a "migration". IIUC, this just changes an NP's NO in a DC from one principal to another. I mean, I guess any change can be called a "migration", but to me, "migration" implies that a "large" amount of work is required.

Comment on lines +6 to +11
//! ## Use Cases
//!
//! - **Lost Private Key**: When a node operator loses access to their private key,
//! they can migrate to a new node operator identity while preserving all associated data.
//! - **Consolidation**: Merge multiple node operators into a single node operator within
//! the same data center.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if this is needed. I mean, I guess it's not harmful to include this.

The main question //! comments should answer is not really "Why would I want this?" but rather, "What does this do? How do I use it?", not "What do I use this FOR?". That's up to the user to decide.

Comment on lines +30 to +37
/// Minimum age a node operator must have before it can be migrated.
/// This prevents spam by requiring operators to exist for at least this duration
/// before they can be deleted through migration.
const MIGRATION_CAPACITY_INTERVAL_HOURS: u64 = 12;

/// The duration form of [`MIGRATION_CAPACITY_INTERVAL_HOURS`].
const MIGRATION_CAPACITY_INTERVAL: Duration =
Duration::from_secs(MIGRATION_CAPACITY_INTERVAL_HOURS * 60 * 60);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems redundant. I would rather keep the second one, because then, you cannot make mistakes like mass_kg + duration_s, or duration_s + duration_hr. Raw numbers for physical quantities (like amount of time) is bad.

Comment on lines +118 to +123
let timestamp_created = Duration::from_nanos(timestamp_created_nanos);
let now_duration = now
.duration_since(SystemTime::UNIX_EPOCH)
.expect("SystemTime before UNIX EPOCH");

if now_duration < timestamp_created + MIGRATION_CAPACITY_INTERVAL {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let timestamp_created = Duration::from_nanos(timestamp_created_nanos);
let now_duration = now
.duration_since(SystemTime::UNIX_EPOCH)
.expect("SystemTime before UNIX EPOCH");
if now_duration < timestamp_created + MIGRATION_CAPACITY_INTERVAL {
let created_at = SystemTime::UNIX_EPOCH
.checked_add(Duration::from_nanos(timestamp_created_nanos))
.expect(...);
let age = now.duration_since(created_at).expect(...)
if age < MIGRATION_CAPACITY_INTERVAL {

Admittedly, this is not much different from what you did, but to me, this is slightly more readable.

Comment on lines +177 to +186
// The caller must be the owner of both of the node operator
// records.
if caller.to_vec() != old_node_operator_record.node_provider_principal_id {
return Err(MigrateError::CallerMismatch {
caller,
expected: PrincipalId(Principal::from_slice(
&old_node_operator_record.node_provider_principal_id,
)),
});
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like this check should be done as soon as old_node_operator_record is fetched. I mean, I guess it does not make a big difference, but that seems more natural to me, because that is the first point when we might be able to reject based on authorization.

])
}

/// Merges data from the old node operator record into the new node operator record.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

List the fields that get "transferred"?

Comment on lines +241 to +250
.map(|(key, record)| {
upsert(
make_node_record_key(key).as_bytes(),
NodeRecord {
node_operator_id: new_node_operator_id.to_vec(),
..record
}
.encode_to_vec(),
)
})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do as you see fit.

Alternatively,

Suggested change
.map(|(key, record)| {
upsert(
make_node_record_key(key).as_bytes(),
NodeRecord {
node_operator_id: new_node_operator_id.to_vec(),
..record
}
.encode_to_vec(),
)
})
.map(|(key, mut record)| {
record.node_operator_id = new_node_operator_id.to_vec();
upsert(
make_node_record_key(key).as_bytes(),
record.encode_to_vec(),
)
})

/// the data present in the old node operator record.
///
/// If this node operator doesn't exist, it will be created.
#[prost(message, optional, tag = "1")]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this copied from somewhere?? Shouldn't prost stuff be generated by Prost??

DataCenterMismatch { old: String, new: String },

/// The caller is not the node provider that owns the operators.
CallerMismatch {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do as you see fit.

Suggested change
CallerMismatch {
NotAuthorized {

}

#[cfg(test)]
mod tests {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I do not review this soon, please, remind me.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants