diff --git a/rs/nervous_system/integration_tests/src/pocket_ic_helpers.rs b/rs/nervous_system/integration_tests/src/pocket_ic_helpers.rs index ceee91c722d..d6a1c694f3d 100644 --- a/rs/nervous_system/integration_tests/src/pocket_ic_helpers.rs +++ b/rs/nervous_system/integration_tests/src/pocket_ic_helpers.rs @@ -1432,6 +1432,7 @@ pub mod sns { use ic_sns_governance::governance::UPGRADE_STEPS_INTERVAL_REFRESH_BACKOFF_SECONDS; use ic_sns_governance::pb::v1::get_neuron_response; use pocket_ic::ErrorCode; + use sns_pb::UpgradeSnsControlledCanister; pub const EXPECTED_UPGRADE_DURATION_MAX_SECONDS: u64 = 1000; pub const EXPECTED_UPGRADE_STEPS_REFRESH_MAX_SECONDS: u64 = @@ -1726,6 +1727,44 @@ pub mod sns { assert!(proposal_data.executed_timestamp_seconds > 0); } + pub async fn propose_to_upgrade_sns_controlled_canister_and_wait( + pocket_ic: &PocketIc, + sns_governance_canister_id: PrincipalId, + upgrade: UpgradeSnsControlledCanister, + ) { + // Get an ID of an SNS neuron that can submit proposals. We rely on the fact that this + // neuron either holds the majority of the voting power or the follow graph is set up + // s.t. when this neuron submits a proposal, that proposal gets through without the need + // for any voting. + let (sns_neuron_id, sns_neuron_principal_id) = + find_neuron_with_majority_voting_power(pocket_ic, sns_governance_canister_id) + .await + .expect("cannot find SNS neuron with dissolve delay over 6 months."); + + let proposal_data = propose_and_wait( + pocket_ic, + sns_governance_canister_id, + sns_neuron_principal_id, + sns_neuron_id.clone(), + sns_pb::Proposal { + title: "Upgrade SNS controlled canister.".to_string(), + summary: "".to_string(), + url: "".to_string(), + action: Some(sns_pb::proposal::Action::UpgradeSnsControlledCanister( + upgrade, + )), + }, + ) + .await + .unwrap(); + + // Check 1: The upgrade proposal did not fail. + assert_eq!(proposal_data.failure_reason, None); + + // Check 2: The upgrade proposal succeeded. + assert!(proposal_data.executed_timestamp_seconds > 0); + } + /// Get the neuron with the given ID from the SNS Governance canister. #[allow(dead_code)] async fn get_neuron( diff --git a/rs/nervous_system/integration_tests/tests/upgrade_sns_controlled_canister_with_large_wasm.rs b/rs/nervous_system/integration_tests/tests/upgrade_sns_controlled_canister_with_large_wasm.rs index b0617beb5e0..0fa5a0cb732 100644 --- a/rs/nervous_system/integration_tests/tests/upgrade_sns_controlled_canister_with_large_wasm.rs +++ b/rs/nervous_system/integration_tests/tests/upgrade_sns_controlled_canister_with_large_wasm.rs @@ -2,7 +2,6 @@ use std::collections::BTreeSet; use candid::Principal; use canister_test::Wasm; -use ic_base_types::PrincipalId; use ic_management_canister_types::CanisterInstallMode; use ic_nervous_system_integration_tests::pocket_ic_helpers::{ await_with_timeout, install_canister_on_subnet, nns, sns, @@ -11,10 +10,9 @@ use ic_nervous_system_integration_tests::{ create_service_nervous_system_builder::CreateServiceNervousSystemBuilder, pocket_ic_helpers::{add_wasms_to_sns_wasm, install_nns_canisters}, }; -use ic_nervous_system_root::change_canister::ChangeCanisterRequest; -use ic_nervous_system_root::change_canister::ChunkedCanisterWasm; use ic_nns_constants::ROOT_CANISTER_ID; use ic_nns_test_utils::common::modify_wasm_bytes; +use ic_sns_governance::pb::v1::{ChunkedCanisterWasm, UpgradeSnsControlledCanister}; use ic_sns_swap::pb::v1::Lifecycle; use pocket_ic::nonblocking::PocketIc; use pocket_ic::PocketIcBuilder; @@ -24,11 +22,14 @@ const MAX_INSTALL_CHUNKED_CODE_TIME_SECONDS: u64 = 5 * 60; const CHUNK_SIZE: usize = 1024 * 1024; // 1 MiB -#[tokio::test] -async fn test_store_same_as_target() { - let store_same_as_target = true; - run_test(store_same_as_target).await; -} +// TODO: Figure out how to best support uploading chunks into the target itself, which has +// SNS Root as the controller but not SNS Governance. +// +// #[tokio::test] +// async fn test_store_same_as_target() { +// let store_same_as_target = true; +// run_test(store_same_as_target).await; +// } #[tokio::test] async fn test_store_different_from_target() { @@ -36,40 +37,6 @@ async fn test_store_different_from_target() { run_test(store_same_as_target).await; } -mod interim_sns_helpers { - use super::*; - - use candid::{Decode, Encode}; - use pocket_ic::nonblocking::PocketIc; - use pocket_ic::WasmResult; - - /// Interim test function for calling Root.change_canister. - /// - /// This function is not in src/pocket_ic_helpers.rs because it's going to be replaced with - /// a proposal with the same effect. It should not be used in any other tests. - pub async fn change_canister( - pocket_ic: &PocketIc, - canister_id: PrincipalId, - sender: PrincipalId, - request: ChangeCanisterRequest, - ) { - let result = pocket_ic - .update_call( - canister_id.into(), - sender.into(), - "change_canister", - Encode!(&request).unwrap(), - ) - .await - .unwrap(); - let result = match result { - WasmResult::Reply(result) => result, - WasmResult::Reject(s) => panic!("Call to change_canister failed: {:#?}", s), - }; - Decode!(&result, ()).unwrap() - } -} - fn very_large_wasm_bytes() -> Vec { let image_classification_canister_wasm_path = std::env::var("IMAGE_CLASSIFICATION_CANISTER_WASM_PATH") @@ -239,25 +206,19 @@ async fn run_test(store_same_as_target: bool) { .await; // 2. Run code under test. - interim_sns_helpers::change_canister( + sns::governance::propose_to_upgrade_sns_controlled_canister_and_wait( &pocket_ic, - sns.root.canister_id, sns.governance.canister_id, - ChangeCanisterRequest { - stop_before_installing: true, - mode: CanisterInstallMode::Upgrade, - canister_id: target_canister_id, - // This is the old field being generalized. - wasm_module: vec![], - // This is the new field we want to test. + UpgradeSnsControlledCanister { + canister_id: Some(target_canister_id.get()), + new_canister_wasm: vec![], + canister_upgrade_arg: None, + mode: Some(CanisterInstallMode::Upgrade as i32), chunked_canister_wasm: Some(ChunkedCanisterWasm { wasm_module_hash: new_wasm_hash.clone().to_vec(), - store_canister_id, + store_canister_id: Some(store_canister_id.get()), chunk_hashes_list, }), - arg: vec![], - compute_allocation: None, - memory_allocation: None, }, ) .await; diff --git a/rs/nervous_system/root/src/change_canister.rs b/rs/nervous_system/root/src/change_canister.rs index e96dfefc4df..d32dea8927c 100644 --- a/rs/nervous_system/root/src/change_canister.rs +++ b/rs/nervous_system/root/src/change_canister.rs @@ -64,7 +64,7 @@ pub struct ChangeCanisterRequest { #[serde(with = "serde_bytes")] pub wasm_module: Vec, - /// If the entire WASM does not into the 2 MiB ingress limit, then `new_canister_wasm` + /// If the entire WASM does not fit into the 2 MiB ingress limit, then `wasm_module` /// should be empty, and this field should be set instead. pub chunked_canister_wasm: Option, diff --git a/rs/sns/governance/BUILD.bazel b/rs/sns/governance/BUILD.bazel index 763d4df9757..0553c44e059 100644 --- a/rs/sns/governance/BUILD.bazel +++ b/rs/sns/governance/BUILD.bazel @@ -181,6 +181,15 @@ rust_test( deps = [":governance"] + DEPENDENCIES + DEV_DEPENDENCIES + [":build_script"], ) +rust_test( + name = "governance_test--test-feature", + srcs = glob(["src/**/*.rs"]), + aliases = ALIASES, + crate_features = ["test"], + proc_macro_deps = MACRO_DEPENDENCIES + MACRO_DEV_DEPENDENCIES, + deps = [":governance"] + DEPENDENCIES + DEV_DEPENDENCIES + [":build_script"], +) + rust_test( name = "canister_unit_test", srcs = glob(["canister/**/*.rs"]), diff --git a/rs/sns/governance/api/src/ic_sns_governance.pb.v1.rs b/rs/sns/governance/api/src/ic_sns_governance.pb.v1.rs index 09f66dafdbe..a61ba5f9dc4 100644 --- a/rs/sns/governance/api/src/ic_sns_governance.pb.v1.rs +++ b/rs/sns/governance/api/src/ic_sns_governance.pb.v1.rs @@ -259,6 +259,29 @@ pub struct Motion { /// The text of the motion, which can at most be 100kib. pub motion_text: String, } + +/// Represents a WASM split into smaller chunks, each of which can safely be sent around the ICP. +#[derive( + candid::CandidType, + candid::Deserialize, + comparable::Comparable, + Clone, + PartialEq, + ::prost::Message, +)] +pub struct ChunkedCanisterWasm { + /// Obligatory check sum of the overall WASM to be reassembled from chunks. + #[prost(bytes = "vec", tag = "1")] + pub wasm_module_hash: ::prost::alloc::vec::Vec, + /// Obligatory; indicates which canister stores the WASM chunks. + #[prost(message, optional, tag = "2")] + pub store_canister_id: ::core::option::Option<::ic_base_types::PrincipalId>, + /// Specifies a list of hash values for the chunks that comprise this WASM. Must contain at least + /// one chunk. + #[prost(bytes = "vec", repeated, tag = "3")] + pub chunk_hashes_list: ::prost::alloc::vec::Vec<::prost::alloc::vec::Vec>, +} + /// A proposal function that upgrades a canister that is controlled by the /// SNS governance canister. #[derive(Default, candid::CandidType, candid::Deserialize, Debug, Clone, PartialEq)] @@ -273,6 +296,9 @@ pub struct UpgradeSnsControlledCanister { pub canister_upgrade_arg: Option>, /// Canister install_code mode. pub mode: Option, + /// If the entire WASM does not fit into the 2 MiB ingress limit, then `new_canister_wasm` should be + /// an empty, and this field should be set instead. + pub chunked_canister_wasm: ::core::option::Option, } /// A proposal to transfer SNS treasury funds to (optionally a Subaccount of) the /// target principal. diff --git a/rs/sns/governance/canister/governance.did b/rs/sns/governance/canister/governance.did index b379d8687eb..84315144e6c 100644 --- a/rs/sns/governance/canister/governance.did +++ b/rs/sns/governance/canister/governance.did @@ -711,8 +711,15 @@ type PendingVersion = record { target_version : opt Version; }; +type ChunkedCanisterWasm = record { + wasm_module_hash : blob; + store_canister_id : opt principal; + chunk_hashes_list : vec blob; +}; + type UpgradeSnsControlledCanister = record { new_canister_wasm : blob; + chunked_canister_wasm : opt ChunkedCanisterWasm; mode : opt int32; canister_id : opt principal; canister_upgrade_arg : opt blob; diff --git a/rs/sns/governance/canister/governance_test.did b/rs/sns/governance/canister/governance_test.did index 4ba441aabda..a5ce02422b6 100644 --- a/rs/sns/governance/canister/governance_test.did +++ b/rs/sns/governance/canister/governance_test.did @@ -725,8 +725,15 @@ type PendingVersion = record { target_version : opt Version; }; +type ChunkedCanisterWasm = record { + wasm_module_hash : blob; + store_canister_id : opt principal; + chunk_hashes_list : vec blob; +}; + type UpgradeSnsControlledCanister = record { new_canister_wasm : blob; + chunked_canister_wasm : opt ChunkedCanisterWasm; mode : opt int32; canister_id : opt principal; canister_upgrade_arg : opt blob; diff --git a/rs/sns/governance/proto/ic_sns_governance/pb/v1/governance.proto b/rs/sns/governance/proto/ic_sns_governance/pb/v1/governance.proto index f26318098b2..df928e80f43 100644 --- a/rs/sns/governance/proto/ic_sns_governance/pb/v1/governance.proto +++ b/rs/sns/governance/proto/ic_sns_governance/pb/v1/governance.proto @@ -312,6 +312,17 @@ message Motion { string motion_text = 1; } +// Represents a WASM split into smaller chunks, each of which can safely be sent around the ICP. +message ChunkedCanisterWasm { + // Obligatory check sum of the overall WASM to be reassembled from chunks. + bytes wasm_module_hash = 1; + // Obligatory; indicates which canister stores the WASM chunks. + ic_base_types.pb.v1.PrincipalId store_canister_id = 2; + // Specifies a list of hash values for the chunks that comprise this WASM. Must contain at least + // one chunk. + repeated bytes chunk_hashes_list = 3; +} + // A proposal function that upgrades a canister that is controlled by the // SNS governance canister. message UpgradeSnsControlledCanister { @@ -323,6 +334,9 @@ message UpgradeSnsControlledCanister { optional bytes canister_upgrade_arg = 3; // Canister install_code mode. optional types.v1.CanisterInstallMode mode = 4; + // If the entire WASM does not fit into the 2 MiB ingress limit, then `new_canister_wasm` should be + // an empty, and this field should be set instead. + optional ChunkedCanisterWasm chunked_canister_wasm = 5; } // A proposal to transfer SNS treasury funds to (optionally a Subaccount of) the diff --git a/rs/sns/governance/src/gen/ic_sns_governance.pb.v1.rs b/rs/sns/governance/src/gen/ic_sns_governance.pb.v1.rs index 543f636048e..00ca7c5b6c7 100644 --- a/rs/sns/governance/src/gen/ic_sns_governance.pb.v1.rs +++ b/rs/sns/governance/src/gen/ic_sns_governance.pb.v1.rs @@ -366,6 +366,27 @@ pub struct Motion { #[prost(string, tag = "1")] pub motion_text: ::prost::alloc::string::String, } +/// Represents a WASM split into smaller chunks, each of which can safely be sent around the ICP. +#[derive( + candid::CandidType, + candid::Deserialize, + comparable::Comparable, + Clone, + PartialEq, + ::prost::Message, +)] +pub struct ChunkedCanisterWasm { + /// Obligatory check sum of the overall WASM to be reassembled from chunks. + #[prost(bytes = "vec", tag = "1")] + pub wasm_module_hash: ::prost::alloc::vec::Vec, + /// Obligatory; indicates which canister stores the WASM chunks. + #[prost(message, optional, tag = "2")] + pub store_canister_id: ::core::option::Option<::ic_base_types::PrincipalId>, + /// Specifies a list of hash values for the chunks that comprise this WASM. Must contain at least + /// one chunk. + #[prost(bytes = "vec", repeated, tag = "3")] + pub chunk_hashes_list: ::prost::alloc::vec::Vec<::prost::alloc::vec::Vec>, +} /// A proposal function that upgrades a canister that is controlled by the /// SNS governance canister. #[derive( @@ -395,6 +416,10 @@ pub struct UpgradeSnsControlledCanister { tag = "4" )] pub mode: ::core::option::Option, + /// If the entire WASM does not fit into the 2 MiB ingress limit, then `new_canister_wasm` should be + /// an empty, and this field should be set instead. + #[prost(message, optional, tag = "5")] + pub chunked_canister_wasm: ::core::option::Option, } /// A proposal to transfer SNS treasury funds to (optionally a Subaccount of) the /// target principal. diff --git a/rs/sns/governance/src/governance.rs b/rs/sns/governance/src/governance.rs index 2f254ed42af..6df5ae12ff9 100644 --- a/rs/sns/governance/src/governance.rs +++ b/rs/sns/governance/src/governance.rs @@ -68,7 +68,7 @@ use crate::{ }, types::{ function_id_to_proposal_criticality, is_registered_function_id, Environment, - HeapGrowthPotential, LedgerUpdateLock, + HeapGrowthPotential, LedgerUpdateLock, Wasm, }, }; use candid::{Decode, Encode}; @@ -2517,9 +2517,12 @@ impl Governance { let mode = upgrade.mode_or_upgrade() as i32; + let wasm = Wasm::try_from(&upgrade) + .map_err(|err| GovernanceError::new_with_message(ErrorType::InvalidCommand, err))?; + self.upgrade_non_root_canister( target_canister_id, - upgrade.new_canister_wasm, + wasm, upgrade .canister_upgrade_arg .unwrap_or_else(|| Encode!().unwrap()), @@ -2531,7 +2534,7 @@ impl Governance { async fn upgrade_non_root_canister( &mut self, target_canister_id: CanisterId, - wasm: Vec, + wasm: Wasm, arg: Vec, mode: CanisterInstallMode, ) -> Result<(), GovernanceError> { @@ -2545,12 +2548,28 @@ impl Governance { // stop_before_installing field in ChangeCanisterRequest. let stop_before_installing = true; - let change_canister_arg = + let mut change_canister_arg = ChangeCanisterRequest::new(stop_before_installing, mode, target_canister_id) - .with_wasm(wasm) .with_arg(arg) .with_mode(mode); + match wasm { + Wasm::Bytes(bytes) => { + change_canister_arg = change_canister_arg.with_wasm(bytes); + } + Wasm::Chunked { + wasm_module_hash, + store_canister_id, + chunk_hashes_list, + } => { + change_canister_arg = change_canister_arg.with_chunked_wasm( + wasm_module_hash, + store_canister_id, + chunk_hashes_list, + ); + } + }; + Encode!(&change_canister_arg).unwrap() }; @@ -2700,7 +2719,7 @@ impl Governance { for target_canister_id in canister_ids_to_upgrade { self.upgrade_non_root_canister( target_canister_id, - target_wasm.clone(), + Wasm::Bytes(target_wasm.clone()), Encode!().unwrap(), CanisterInstallMode::Upgrade, ) @@ -2767,7 +2786,7 @@ impl Governance { for target_canister_id in canister_ids_to_upgrade { self.upgrade_non_root_canister( target_canister_id, - target_wasm.clone(), + Wasm::Bytes(target_wasm.clone()), Encode!().unwrap(), CanisterInstallMode::Upgrade, ) @@ -2964,7 +2983,7 @@ impl Governance { self.upgrade_non_root_canister( ledger_canister_id, - ledger_wasm, + Wasm::Bytes(ledger_wasm), ledger_upgrade_arg, CanisterInstallMode::Upgrade, ) diff --git a/rs/sns/governance/src/governance/assorted_governance_tests.rs b/rs/sns/governance/src/governance/assorted_governance_tests.rs index 013f7cdcbcf..a076bde6f13 100644 --- a/rs/sns/governance/src/governance/assorted_governance_tests.rs +++ b/rs/sns/governance/src/governance/assorted_governance_tests.rs @@ -2914,6 +2914,7 @@ fn test_sns_controlled_canister_upgrade_only_upgrades_dapp_canisters() { new_canister_wasm: vec![0, 0x61, 0x73, 0x6D, 2, 0, 0, 0], canister_upgrade_arg: None, mode: Some(CanisterInstallModeProto::Upgrade.into()), + chunked_canister_wasm: None, }); // Upgrade Proposal diff --git a/rs/sns/governance/src/pb/conversions.rs b/rs/sns/governance/src/pb/conversions.rs index 7a14fbd1ad9..cf5724be075 100644 --- a/rs/sns/governance/src/pb/conversions.rs +++ b/rs/sns/governance/src/pb/conversions.rs @@ -291,9 +291,33 @@ impl From for pb_api::UpgradeSnsControlledCani new_canister_wasm: item.new_canister_wasm, canister_upgrade_arg: item.canister_upgrade_arg, mode: item.mode, + chunked_canister_wasm: item + .chunked_canister_wasm + .map(pb_api::ChunkedCanisterWasm::from), } } } + +impl From for pb::ChunkedCanisterWasm { + fn from(item: pb_api::ChunkedCanisterWasm) -> Self { + Self { + wasm_module_hash: item.wasm_module_hash, + store_canister_id: item.store_canister_id, + chunk_hashes_list: item.chunk_hashes_list, + } + } +} + +impl From for pb_api::ChunkedCanisterWasm { + fn from(item: pb::ChunkedCanisterWasm) -> Self { + Self { + wasm_module_hash: item.wasm_module_hash, + store_canister_id: item.store_canister_id, + chunk_hashes_list: item.chunk_hashes_list, + } + } +} + impl From for pb::UpgradeSnsControlledCanister { fn from(item: pb_api::UpgradeSnsControlledCanister) -> Self { Self { @@ -301,6 +325,9 @@ impl From for pb::UpgradeSnsControlledCani new_canister_wasm: item.new_canister_wasm, canister_upgrade_arg: item.canister_upgrade_arg, mode: item.mode, + chunked_canister_wasm: item + .chunked_canister_wasm + .map(pb::ChunkedCanisterWasm::from), } } } diff --git a/rs/sns/governance/src/proposal.rs b/rs/sns/governance/src/proposal.rs index 6aaae38eddc..eb99082247a 100644 --- a/rs/sns/governance/src/proposal.rs +++ b/rs/sns/governance/src/proposal.rs @@ -1,5 +1,6 @@ use crate::cached_upgrade_steps::render_two_versions_as_markdown_table; use crate::pb::v1::AdvanceSnsTargetVersion; +use crate::types::Wasm; use crate::{ canister_control::perform_execute_generic_nervous_system_function_validate_and_render_call, governance::{ @@ -40,6 +41,7 @@ use ic_nervous_system_common::{ use ic_nervous_system_proto::pb::v1::Percentage; use ic_nervous_system_timestamp::format_timestamp_for_humans; use ic_protobuf::types::v1::CanisterInstallMode; +use ic_sns_governance_api::format_full_hash; use ic_sns_governance_proposals_amount_total_limit::{ // TODO(NNS1-2982): Uncomment. mint_sns_tokens_7_day_total_upper_bound_tokens, transfer_sns_treasury_funds_7_day_total_upper_bound_tokens, @@ -92,12 +94,6 @@ pub const EXECUTED_TRANSFER_SNS_TREASURY_FUNDS_PROPOSAL_RETENTION_DURATION_SECON /// the same, but we keep separate constants, because we consider this to be a coincidence. pub const EXECUTED_MINT_SNS_TOKENS_PROPOSAL_RETENTION_DURATION_SECONDS: u64 = 7 * ONE_DAY_SECONDS; -/// The maximum message size for inter-canister calls to a different subnet -/// is 2MiB and thus we restrict the maximum joint size of the canister WASM -/// and argument to 2MB (2,000,000B) to leave some slack for Candid overhead -/// and a few constant-size fields (e.g., compute and memory allocation). -pub const MAX_INSTALL_CODE_WASM_AND_ARG_SIZE: usize = 2_000_000; // 2MB - impl Proposal { /// Returns whether a proposal is allowed to be submitted when /// the heap growth potential is low. @@ -411,7 +407,8 @@ pub(crate) async fn validate_and_render_action( validate_and_render_manage_nervous_system_parameters(manage, current_parameters) } proposal::Action::UpgradeSnsControlledCanister(upgrade) => { - validate_and_render_upgrade_sns_controlled_canister(upgrade) + validate_and_render_upgrade_sns_controlled_canister(upgrade, env, root_canister_id) + .await } Action::UpgradeSnsToNextVersion(upgrade_sns) => { match governance_proto.deployed_version_or_err() { @@ -1037,17 +1034,22 @@ impl TokenProposalAction for MintSnsTokens { } /// Validates and renders a proposal with action UpgradeSnsControlledCanister. -fn validate_and_render_upgrade_sns_controlled_canister( +async fn validate_and_render_upgrade_sns_controlled_canister( upgrade: &UpgradeSnsControlledCanister, + env: &dyn Environment, + root_canister_id: CanisterId, ) -> Result { let mut defects = vec![]; let UpgradeSnsControlledCanister { - canister_id: _, - new_canister_wasm, + canister_id, canister_upgrade_arg, mode, + // The WASM-related fields are extracted separately. + chunked_canister_wasm: _, + new_canister_wasm: _, } = upgrade; + // Make sure `mode` is not None, and not an invalid/unknown value. if let Some(mode) = mode { if let Err(err) = CanisterInstallMode::try_from(*mode) { @@ -1058,77 +1060,87 @@ fn validate_and_render_upgrade_sns_controlled_canister( let mode = upgrade.mode_or_upgrade(); // Inspect canister_id. - let mut canister_id = PrincipalId::new_user_test_id(0xDEADBEEF); // Initialize to garbage. This won't get used later. - match validate_required_field("canister_id", &upgrade.canister_id) { + let canister_id = match validate_required_field("canister_id", canister_id) { Err(err) => { defects.push(err); + None } - Ok(id) => { - canister_id = *id; - } - } + Ok(principal_id) => match CanisterId::try_from_principal_id(*principal_id) { + Ok(canister_id) => Some(canister_id), + Err(err) => { + let defect = format!( + "UpgradeSnsControlledCanister.canister_id is invalid: {:?}", + err + ); + defects.push(defect); + None + } + }, + }; // Inspect wasm. - const RAW_WASM_HEADER: [u8; 4] = [0, 0x61, 0x73, 0x6d]; - // see https://ic-interface-spec.netlify.app/#canister-module-format - const GZIPPED_WASM_HEADER: [u8; 3] = [0x1f, 0x8b, 0x08]; - - if new_canister_wasm.len() < 4 - || new_canister_wasm[..4] != RAW_WASM_HEADER[..] - && new_canister_wasm[..3] != GZIPPED_WASM_HEADER[..] - { - defects.push("new_canister_wasm lacks the magic value in its header.".into()); - } - - if new_canister_wasm.len().saturating_add( - canister_upgrade_arg - .as_ref() - .map(|arg| arg.len()) - .unwrap_or_default(), - ) >= MAX_INSTALL_CODE_WASM_AND_ARG_SIZE - { - defects.push(format!( - "the maximum canister WASM and argument size \ - for UpgradeSnsControlledCanister is {} bytes.", - MAX_INSTALL_CODE_WASM_AND_ARG_SIZE - )); - } + let wasm_info = match Wasm::try_from(upgrade) { + Err(err) => { + defects.push(err); + None + } + Ok(wasm) => match wasm.validate(env, canister_upgrade_arg).await { + Err(new_defects) => { + defects.extend(new_defects.into_iter()); + None + } + Ok(_) => Some(wasm.description()), + }, + }; // Generate final report. if !defects.is_empty() { + let tip = if upgrade.chunked_canister_wasm.is_some() { + format!( + "\nPlease make sure that both Governance ({}) and Root ({}) are controllers of \ + the Wasm store canister.", + env.canister_id().get(), + root_canister_id.get(), + ) + } else { + "".to_string() + }; return Err(format!( - "UpgradeSnsControlledCanister was invalid for the following reason(s):\n{}", + "UpgradeSnsControlledCanister was invalid for the following reason(s):\n{}{tip}", defects.join("\n"), )); } - let canister_wasm_sha256 = { - let mut state = Sha256::new(); - state.write(new_canister_wasm); - let sha = state.finish(); - hex::encode(sha) - }; + // If this is reached, then defects is empty. In that case, it is safe to unwrap the values + // required for rendering the proposal. + let canister_id = canister_id.unwrap().get(); + let wasm_info = wasm_info.unwrap(); - let upgrade_args_sha_256 = canister_upgrade_arg + let args_info = canister_upgrade_arg .as_ref() .map(|arg| { - let mut state = Sha256::new(); - state.write(arg); - let sha = state.finish(); - format!("Upgrade arg sha256: {}", hex::encode(sha)) + format!( + "Upgrade argument with {} bytes and SHA256 `{}`.", + arg.len(), + format_full_hash(arg), + ) }) - .unwrap_or_else(|| "No upgrade arg".to_string()); + .unwrap_or_else(|| "No upgrade argument.".to_string()); Ok(format!( - r"# Proposal to upgrade SNS controlled canister: + r"# Proposal to Upgrade an SNS Controlled Canister + +## Target canister: {canister_id:?} -## Canister id: {canister_id:?} +## Wasm info -## Canister wasm sha256: {canister_wasm_sha256} +{wasm_info} ## Mode: {mode:?} -## {upgrade_args_sha_256}", +## Argument info + +{args_info}", )) } @@ -2553,8 +2565,8 @@ mod tests { use crate::{ pb::v1::{ governance::{self, Version}, - Ballot, Empty, Governance as GovernanceProto, NeuronId, Proposal, ProposalId, - Subaccount, WaitForQuietState, + Ballot, ChunkedCanisterWasm, Empty, Governance as GovernanceProto, NeuronId, Proposal, + ProposalId, Subaccount, WaitForQuietState, }, sns_upgrade::{ CanisterSummary, GetNextSnsVersionRequest, GetNextSnsVersionResponse, @@ -2568,6 +2580,7 @@ mod tests { use futures::FutureExt; use ic_base_types::{NumBytes, PrincipalId}; use ic_crypto_sha2::Sha256; + use ic_management_canister_types::{CanisterIdRecord, ChunkHash, StoredChunksReply}; use ic_nervous_system_clients::canister_status::{CanisterStatusResultV2, CanisterStatusType}; use ic_nervous_system_common_test_keys::TEST_USER1_PRINCIPAL; use ic_nns_constants::SNS_WASM_CANISTER_ID; @@ -2651,6 +2664,10 @@ mod tests { PrincipalId::try_from(vec![42_u8]).unwrap() } + fn basic_canister_id() -> PrincipalId { + canister_test_id(42).get() + } + fn basic_motion_proposal() -> Proposal { let result = Proposal { title: "title".into(), @@ -2770,78 +2787,303 @@ mod tests { } } - #[test] - fn render_upgrade_sns_controlled_canister_proposal() { + #[tokio::test] + async fn render_upgrade_sns_controlled_canister_proposal() { let upgrade = UpgradeSnsControlledCanister { - canister_id: Some(basic_principal_id()), + canister_id: Some(basic_canister_id()), new_canister_wasm: vec![0, 0x61, 0x73, 0x6D, 1, 0, 0, 0], canister_upgrade_arg: None, mode: Some(CanisterInstallModeProto::Upgrade.into()), + chunked_canister_wasm: None, }; - let text = validate_and_render_upgrade_sns_controlled_canister(&upgrade).unwrap(); + let env = setup_for_upgrade_sns_controlled_canister_tests(&upgrade); + let text = validate_and_render_upgrade_sns_controlled_canister( + &upgrade, + &env, + canister_test_id(55), + ) + .await + .unwrap(); assert_eq!( text, - r#"# Proposal to upgrade SNS controlled canister: + r#"# Proposal to Upgrade an SNS Controlled Canister -## Canister id: bg4sm-wzk +## Target canister: xbgkv-fyaaa-aaaaa-aaava-cai -## Canister wasm sha256: 93a44bbb96c751218e4c00d479e4c14358122a389acca16205b1e4d0dc5f9476 +## Wasm info + +Embedded module with 8 bytes and SHA256 `93a44bbb96c751218e4c00d479e4c14358122a389acca16205b1e4d0dc5f9476`. ## Mode: Upgrade -## No upgrade arg"# +## Argument info + +No upgrade argument."# .to_string() ); } - #[test] - fn render_upgrade_sns_controlled_canister_proposal_with_upgrade_args() { + #[tokio::test] + async fn render_upgrade_sns_controlled_canister_proposal_with_chunked_wasm() { let upgrade = UpgradeSnsControlledCanister { - canister_id: Some(basic_principal_id()), + canister_id: Some(basic_canister_id()), + new_canister_wasm: vec![], + canister_upgrade_arg: None, + mode: Some(CanisterInstallModeProto::Upgrade.into()), + chunked_canister_wasm: Some(ChunkedCanisterWasm { + wasm_module_hash: vec![1, 2, 3], + store_canister_id: Some(canister_test_id(111).get()), + chunk_hashes_list: vec![vec![1, 1, 1], vec![2, 2, 2], vec![3, 3, 3]], + }), + }; + let env = setup_for_upgrade_sns_controlled_canister_tests(&upgrade); + let text = validate_and_render_upgrade_sns_controlled_canister( + &upgrade, + &env, + canister_test_id(55), + ) + .await + .unwrap(); + + assert_eq!( + text, + r#"# Proposal to Upgrade an SNS Controlled Canister + +## Target canister: xbgkv-fyaaa-aaaaa-aaava-cai + +## Wasm info + +Remote module stored on canister zyo6l-paaaa-aaaaa-aabxq-cai with SHA256 `010203`. Split into 3 chunks: + - `010101` + - `020202` + - `030303` + +## Mode: Upgrade + +## Argument info + +No upgrade argument."# + .to_string() + ); + } + + // TODO[NNS1-3550]: Enable this test for all compilations. + #[cfg(feature = "test")] + #[tokio::test] + async fn render_upgrade_sns_controlled_canister_proposal_with_unexpected_chunk() { + let mut chunked_canister_wasm = ChunkedCanisterWasm { + wasm_module_hash: vec![1, 2, 3], + store_canister_id: Some(canister_test_id(111).get()), + chunk_hashes_list: vec![vec![1, 1, 1], vec![2, 2, 2], vec![3, 3, 3]], + }; + let mut upgrade = UpgradeSnsControlledCanister { + canister_id: Some(basic_canister_id()), + new_canister_wasm: vec![], + canister_upgrade_arg: None, + mode: Some(CanisterInstallModeProto::Upgrade.into()), + chunked_canister_wasm: Some(chunked_canister_wasm.clone()), + }; + + let env = setup_for_upgrade_sns_controlled_canister_tests(&upgrade); + + // Modify the update payload to make it invalid (unexpected chunk). + { + chunked_canister_wasm.chunk_hashes_list.push(vec![4, 4, 4]); + upgrade.chunked_canister_wasm.replace(chunked_canister_wasm); + } + + let err = validate_and_render_upgrade_sns_controlled_canister( + &upgrade, + &env, + canister_test_id(55), + ) + .await + .unwrap_err(); + + assert!(err.contains( + "1 out of 4 expected WASM chunks were not uploaded to the store canister: 040404" + )); + } + + #[tokio::test] + async fn render_upgrade_sns_controlled_canister_proposal_with_chunk_hash_mismatch() { + let mut chunked_canister_wasm = ChunkedCanisterWasm { + wasm_module_hash: vec![1, 1, 1], + store_canister_id: Some(canister_test_id(111).get()), + chunk_hashes_list: vec![vec![1, 1, 1]], + }; + let mut upgrade = UpgradeSnsControlledCanister { + canister_id: Some(basic_canister_id()), + new_canister_wasm: vec![], + canister_upgrade_arg: None, + mode: Some(CanisterInstallModeProto::Upgrade.into()), + chunked_canister_wasm: Some(chunked_canister_wasm.clone()), + }; + + let env = setup_for_upgrade_sns_controlled_canister_tests(&upgrade); + + // Modify the update payload to make it invalid (mismatch between chunk_hashes_list + // and wasm_module_hash). + { + chunked_canister_wasm.chunk_hashes_list = vec![vec![2, 2, 2]]; + upgrade.chunked_canister_wasm.replace(chunked_canister_wasm); + } + + let err = validate_and_render_upgrade_sns_controlled_canister( + &upgrade, + &env, + canister_test_id(55), + ) + .await + .unwrap_err(); + + assert!(err.contains( + "chunked_canister_wasm.chunk_hashes_list specifies only one hash (020202), \ + but it differs from chunked_canister_wasm.wasm_module_hash (010101)" + ),); + } + + #[tokio::test] + async fn render_upgrade_sns_controlled_canister_proposal_with_chunks_but_no_store_canister() { + let mut chunked_canister_wasm = ChunkedCanisterWasm { + wasm_module_hash: vec![1, 1, 1], + store_canister_id: Some(canister_test_id(111).get()), + chunk_hashes_list: vec![vec![1, 1, 1]], + }; + let mut upgrade = UpgradeSnsControlledCanister { + canister_id: Some(basic_canister_id()), + new_canister_wasm: vec![], + canister_upgrade_arg: None, + mode: Some(CanisterInstallModeProto::Upgrade.into()), + chunked_canister_wasm: Some(chunked_canister_wasm.clone()), + }; + + let env = setup_for_upgrade_sns_controlled_canister_tests(&upgrade); + + // Modify the update payload to make it invalid (store_canister_id not set). + { + chunked_canister_wasm.store_canister_id = None; + upgrade.chunked_canister_wasm.replace(chunked_canister_wasm); + } + + let err = validate_and_render_upgrade_sns_controlled_canister( + &upgrade, + &env, + canister_test_id(55), + ) + .await + .unwrap_err(); + + assert!(err.contains("chunked_canister_wasm.store_canister_id must be specified.")); + } + + #[tokio::test] + async fn render_upgrade_sns_controlled_canister_proposal_with_empty_chunks_list() { + let mut chunked_canister_wasm = ChunkedCanisterWasm { + wasm_module_hash: vec![1, 1, 1], + store_canister_id: Some(canister_test_id(111).get()), + chunk_hashes_list: vec![vec![1, 1, 1]], + }; + let mut upgrade = UpgradeSnsControlledCanister { + canister_id: Some(basic_canister_id()), + new_canister_wasm: vec![], + canister_upgrade_arg: None, + mode: Some(CanisterInstallModeProto::Upgrade.into()), + chunked_canister_wasm: Some(chunked_canister_wasm.clone()), + }; + + let env = setup_for_upgrade_sns_controlled_canister_tests(&upgrade); + + // Modify the update payload to make it invalid (empty chunk_hashes_list). + { + chunked_canister_wasm.chunk_hashes_list = vec![]; + upgrade.chunked_canister_wasm.replace(chunked_canister_wasm); + } + + let err = validate_and_render_upgrade_sns_controlled_canister( + &upgrade, + &env, + canister_test_id(55), + ) + .await + .unwrap_err(); + + assert!(err.contains("chunked_canister_wasm.chunk_hashes_list cannot be empty.")); + } + + #[tokio::test] + async fn render_upgrade_sns_controlled_canister_proposal_with_upgrade_args() { + let upgrade = UpgradeSnsControlledCanister { + canister_id: Some(basic_canister_id()), new_canister_wasm: vec![0, 0x61, 0x73, 0x6D, 1, 0, 0, 0], canister_upgrade_arg: Some(vec![10, 20, 30, 40, 50, 60, 70, 80]), mode: Some(CanisterInstallModeProto::Upgrade.into()), + chunked_canister_wasm: None, }; - let text = validate_and_render_upgrade_sns_controlled_canister(&upgrade).unwrap(); + let env = setup_for_upgrade_sns_controlled_canister_tests(&upgrade); + let text = validate_and_render_upgrade_sns_controlled_canister( + &upgrade, + &env, + canister_test_id(55), + ) + .await + .unwrap(); assert_eq!( text, - r#"# Proposal to upgrade SNS controlled canister: + r#"# Proposal to Upgrade an SNS Controlled Canister + +## Target canister: xbgkv-fyaaa-aaaaa-aaava-cai -## Canister id: bg4sm-wzk +## Wasm info -## Canister wasm sha256: 93a44bbb96c751218e4c00d479e4c14358122a389acca16205b1e4d0dc5f9476 +Embedded module with 8 bytes and SHA256 `93a44bbb96c751218e4c00d479e4c14358122a389acca16205b1e4d0dc5f9476`. ## Mode: Upgrade -## Upgrade arg sha256: 73f1171adc7e49b09423da2515a1077e3cc63e3fabcb9846cac437d044ac57ec"# +## Argument info + +Upgrade argument with 8 bytes and SHA256 `0a141e28323c4650`."# .to_string() ); } - #[test] - fn render_upgrade_sns_controlled_canister_proposal_validates_mode() { + #[tokio::test] + async fn render_upgrade_sns_controlled_canister_proposal_validates_mode() { let upgrade = UpgradeSnsControlledCanister { - canister_id: Some(basic_principal_id()), + canister_id: Some(basic_canister_id()), new_canister_wasm: vec![0, 0x61, 0x73, 0x6D, 1, 0, 0, 0], canister_upgrade_arg: None, mode: Some(100), // 100 is not a valid mode + chunked_canister_wasm: None, }; - let text = validate_and_render_upgrade_sns_controlled_canister(&upgrade).unwrap_err(); + let env = setup_for_upgrade_sns_controlled_canister_tests(&upgrade); + let text = validate_and_render_upgrade_sns_controlled_canister( + &upgrade, + &env, + canister_test_id(55), + ) + .await + .unwrap_err(); assert!(text.contains("Invalid mode")); } - fn basic_upgrade_sns_controlled_canister_proposal() -> Proposal { + async fn basic_upgrade_sns_controlled_canister_proposal() -> Proposal { let upgrade = UpgradeSnsControlledCanister { - canister_id: Some(basic_principal_id()), + canister_id: Some(basic_canister_id()), new_canister_wasm: vec![0, 0x61, 0x73, 0x6D, 1, 0, 0, 0], canister_upgrade_arg: None, mode: Some(CanisterInstallModeProto::Upgrade.into()), + chunked_canister_wasm: None, }; - assert_is_ok(validate_and_render_upgrade_sns_controlled_canister( + let result = validate_and_render_upgrade_sns_controlled_canister( &upgrade, - )); + &new_environment_that_expects_no_canister_calls(), + canister_test_id(55), + ) + .await; + assert_is_ok(result); let mut result = basic_motion_proposal(); result.action = Some(proposal::Action::UpgradeSnsControlledCanister(upgrade)); @@ -2852,82 +3094,122 @@ mod tests { result } - fn assert_validate_upgrade_sns_controlled_canister_is_err(proposal: &Proposal) { + async fn assert_validate_upgrade_sns_controlled_canister_is_err( + proposal: &Proposal, + env: &dyn Environment, + ) { assert_is_err(validate_default_proposal(proposal)); assert_is_err(validate_default_action(&proposal.action)); match proposal.action.as_ref().unwrap() { proposal::Action::UpgradeSnsControlledCanister(upgrade) => { - assert_is_err(validate_and_render_upgrade_sns_controlled_canister(upgrade)) + let result = validate_and_render_upgrade_sns_controlled_canister( + upgrade, + env, + canister_test_id(55), + ) + .await; + assert_is_err(result) } _ => panic!("Proposal.action is not an UpgradeSnsControlledCanister."), } } - #[test] - fn upgrade_must_have_canister_id() { - let mut proposal = basic_upgrade_sns_controlled_canister_proposal(); + #[tokio::test] + async fn upgrade_must_have_canister_id() { + let mut proposal = basic_upgrade_sns_controlled_canister_proposal().await; // Create a defect. - match proposal.action.as_mut().unwrap() { + let env = match proposal.action.as_mut().unwrap() { proposal::Action::UpgradeSnsControlledCanister(upgrade) => { + let env = setup_for_upgrade_sns_controlled_canister_tests(upgrade); upgrade.canister_id = None; - assert_is_err(validate_and_render_upgrade_sns_controlled_canister(upgrade)); + let result = validate_and_render_upgrade_sns_controlled_canister( + upgrade, + &env, + canister_test_id(55), + ) + .await; + assert_is_err(result); + env } _ => panic!("Proposal.action is not an UpgradeSnsControlledCanister."), - } + }; - assert_validate_upgrade_sns_controlled_canister_is_err(&proposal); + assert_validate_upgrade_sns_controlled_canister_is_err(&proposal, &env).await; } /// The minimum WASM is 8 bytes long. Therefore, we must not allow the /// new_canister_wasm field to be empty. - #[test] - fn upgrade_wasm_must_be_non_empty() { - let mut proposal = basic_upgrade_sns_controlled_canister_proposal(); + #[tokio::test] + async fn upgrade_wasm_must_be_non_empty() { + let mut proposal = basic_upgrade_sns_controlled_canister_proposal().await; // Create a defect. - match proposal.action.as_mut().unwrap() { + let env = match proposal.action.as_mut().unwrap() { proposal::Action::UpgradeSnsControlledCanister(upgrade) => { + let env = setup_for_upgrade_sns_controlled_canister_tests(upgrade); upgrade.new_canister_wasm = vec![]; - assert_is_err(validate_and_render_upgrade_sns_controlled_canister(upgrade)); + let result = validate_and_render_upgrade_sns_controlled_canister( + upgrade, + &env, + canister_test_id(55), + ) + .await; + assert_is_err(result); + env } _ => panic!("Proposal.action is not an UpgradeSnsControlledCanister."), - } + }; - assert_validate_upgrade_sns_controlled_canister_is_err(&proposal); + assert_validate_upgrade_sns_controlled_canister_is_err(&proposal, &env).await; } - #[test] - fn upgrade_wasm_must_not_be_dead_beef() { - let mut proposal = basic_upgrade_sns_controlled_canister_proposal(); + #[tokio::test] + async fn upgrade_wasm_must_not_be_dead_beef() { + let mut proposal = basic_upgrade_sns_controlled_canister_proposal().await; // Create a defect. - match proposal.action.as_mut().unwrap() { + let env = match proposal.action.as_mut().unwrap() { proposal::Action::UpgradeSnsControlledCanister(upgrade) => { + let env = setup_for_upgrade_sns_controlled_canister_tests(upgrade); // This is invalid, because it does not have the magical first // four bytes that a WASM is supposed to have. (Instead, the // first four bytes of this Vec are 0xDeadBeef.) upgrade.new_canister_wasm = vec![0xde, 0xad, 0xbe, 0xef, 1, 0, 0, 0]; assert!(upgrade.new_canister_wasm.len() == 8); // The minimum wasm len. - assert_is_err(validate_and_render_upgrade_sns_controlled_canister(upgrade)); + let result = validate_and_render_upgrade_sns_controlled_canister( + upgrade, + &env, + canister_test_id(55), + ) + .await; + assert_is_err(result); + env } _ => panic!("Proposal.action is not an UpgradeSnsControlledCanister."), - } + }; - assert_validate_upgrade_sns_controlled_canister_is_err(&proposal); + assert_validate_upgrade_sns_controlled_canister_is_err(&proposal, &env).await; } - #[test] - fn upgrade_wasm_can_be_gzipped() { - let mut proposal = basic_upgrade_sns_controlled_canister_proposal(); + #[tokio::test] + async fn upgrade_wasm_can_be_gzipped() { + let mut proposal = basic_upgrade_sns_controlled_canister_proposal().await; match proposal.action.as_mut().unwrap() { proposal::Action::UpgradeSnsControlledCanister(upgrade) => { + let env = setup_for_upgrade_sns_controlled_canister_tests(upgrade); upgrade.new_canister_wasm = vec![0x1f, 0x8b, 0x08, 0x08, 0xa3, 0x8e, 0xcf, 0x63, 0, 0x03]; assert!(upgrade.new_canister_wasm.len() >= 8); // The minimum wasm len. - assert_is_ok(validate_and_render_upgrade_sns_controlled_canister(upgrade)); + let result = validate_and_render_upgrade_sns_controlled_canister( + upgrade, + &env, + canister_test_id(55), + ) + .await; + assert_is_ok(result); } _ => panic!("Proposal.action is not an UpgradeSnsControlledCanister."), } @@ -3238,6 +3520,51 @@ mod tests { ) } + fn new_environment_that_expects_no_canister_calls() -> NativeEnvironment { + let governance_canister_id = *SNS_GOVERNANCE_CANISTER_ID; + let mut env = NativeEnvironment::new(Some(governance_canister_id)); + env.default_canister_call_response = Err((Some(1), "Unexpected call!".to_string())); + env + } + + fn setup_for_upgrade_sns_controlled_canister_tests( + upgrade: &UpgradeSnsControlledCanister, + ) -> NativeEnvironment { + let UpgradeSnsControlledCanister { + chunked_canister_wasm, + .. + } = upgrade; + + let governance_canister_id = *SNS_GOVERNANCE_CANISTER_ID; + let mut env = NativeEnvironment::new(Some(governance_canister_id)); + + env.default_canister_call_response = + Err((Some(1), "Oh no something was not covered!".to_string())); + + if let Some(ChunkedCanisterWasm { + wasm_module_hash: _, + store_canister_id, + chunk_hashes_list, + }) = chunked_canister_wasm + { + let canister_id = CanisterId::unchecked_from_principal((*store_canister_id).unwrap()); + env.set_call_canister_response( + CanisterId::ic_00(), + "stored_chunks", + Encode!(&CanisterIdRecord::from(canister_id)).unwrap(), + Ok(Encode!(&StoredChunksReply( + chunk_hashes_list + .iter() + .map(|hash| { ChunkHash { hash: hash.clone() } }) + .collect() + )) + .unwrap()), + ); + }; + + env + } + /// This assumes that the current_version is: /// SnsVersion { /// root_wasm_hash: Sha256::hash(&[1]), @@ -4835,6 +5162,7 @@ Payload rendering here"# new_canister_wasm: vec![0, 1, 2, 3], canister_upgrade_arg: Some(vec![4, 5, 6, 7]), mode: Some(1), + chunked_canister_wasm: None, }, )), ..Default::default() @@ -4855,6 +5183,7 @@ Payload rendering here"# new_canister_wasm: vec![], canister_upgrade_arg: Some(vec![4, 5, 6, 7]), mode: Some(1), + chunked_canister_wasm: None, }, )), ..Default::default() diff --git a/rs/sns/governance/src/types.rs b/rs/sns/governance/src/types.rs index 343afd39b6d..e9f3487896e 100644 --- a/rs/sns/governance/src/types.rs +++ b/rs/sns/governance/src/types.rs @@ -26,31 +26,33 @@ use crate::{ nervous_system_function::FunctionType, neuron::Followees, proposal::Action, - ClaimSwapNeuronsError, ClaimSwapNeuronsResponse, ClaimedSwapNeuronStatus, - DefaultFollowees, DeregisterDappCanisters, Empty, ExecuteGenericNervousSystemFunction, - GovernanceError, ManageDappCanisterSettings, ManageLedgerParameters, - ManageNeuronResponse, ManageSnsMetadata, MintSnsTokens, Motion, NervousSystemFunction, - NervousSystemParameters, Neuron, NeuronId, NeuronIds, NeuronPermission, - NeuronPermissionList, NeuronPermissionType, ProposalId, RegisterDappCanisters, - RewardEvent, SnsVersion, TransferSnsTreasuryFunds, UpgradeSnsControlledCanister, - UpgradeSnsToNextVersion, Vote, VotingRewardsParameters, + ChunkedCanisterWasm, ClaimSwapNeuronsError, ClaimSwapNeuronsResponse, + ClaimedSwapNeuronStatus, DefaultFollowees, DeregisterDappCanisters, Empty, + ExecuteGenericNervousSystemFunction, GovernanceError, ManageDappCanisterSettings, + ManageLedgerParameters, ManageNeuronResponse, ManageSnsMetadata, MintSnsTokens, Motion, + NervousSystemFunction, NervousSystemParameters, Neuron, NeuronId, NeuronIds, + NeuronPermission, NeuronPermissionList, NeuronPermissionType, ProposalId, + RegisterDappCanisters, RewardEvent, SnsVersion, TransferSnsTreasuryFunds, + UpgradeSnsControlledCanister, UpgradeSnsToNextVersion, Vote, VotingRewardsParameters, }, }, proposal::ValidGenericNervousSystemFunction, }; use async_trait::async_trait; +use candid::{Decode, Encode}; use ic_base_types::CanisterId; use ic_canister_log::log; use ic_crypto_sha2::Sha256; use ic_icrc1_ledger::UpgradeArgs as LedgerUpgradeArgs; use ic_ledger_core::tokens::TOKEN_SUBDIVIDABLE_BY; -use ic_management_canister_types::CanisterInstallModeError; +use ic_management_canister_types::{CanisterIdRecord, CanisterInstallModeError, StoredChunksReply}; use ic_nervous_system_common::{ hash_to_hex_string, ledger_validation::MAX_LOGO_LENGTH, NervousSystemError, DEFAULT_TRANSFER_FEE, ONE_DAY_SECONDS, ONE_MONTH_SECONDS, ONE_YEAR_SECONDS, }; use ic_nervous_system_common_validation::validate_proposal_url; use ic_nervous_system_proto::pb::v1::{Duration as PbDuration, Percentage}; +use ic_sns_governance_api::format_full_hash; use ic_sns_governance_proposal_criticality::{ ProposalCriticality, VotingDurationParameters, VotingPowerThresholds, }; @@ -72,6 +74,12 @@ const PROPOSAL_EXECUTE_SNS_FUNCTION_PAYLOAD_BYTES_MAX: usize = 70000; /// The number of e8s per governance token; pub const E8S_PER_TOKEN: u64 = TOKEN_SUBDIVIDABLE_BY; +/// The maximum message size for inter-canister calls to a different subnet +/// is 2MiB and thus we restrict the maximum joint size of the canister WASM +/// and argument to 2MB (2,000,000B) to leave some slack for Candid overhead +/// and a few constant-size fields (e.g., compute and memory allocation). +pub const MAX_INSTALL_CODE_WASM_AND_ARG_SIZE: usize = 2_000_000; // 2MB + /// The Governance spec gives each Action a u64 equivalent identifier. This module gives /// those u64 values a human-readable const variable for use in the SNS. pub mod native_action_ids { @@ -1744,6 +1752,7 @@ impl UpgradeSnsControlledCanister { .as_ref() .map(|blob| summarize_blob_field(blob)), mode: self.mode, + chunked_canister_wasm: self.chunked_canister_wasm.clone(), } } @@ -1754,6 +1763,7 @@ impl UpgradeSnsControlledCanister { canister_upgrade_arg: self.canister_upgrade_arg.clone(), mode: self.mode, new_canister_wasm: Vec::new(), + chunked_canister_wasm: None, } } } @@ -2527,6 +2537,256 @@ impl From for Action { } } +pub enum Wasm { + Bytes(Vec), + Chunked { + wasm_module_hash: Vec, + store_canister_id: CanisterId, + chunk_hashes_list: Vec>, + }, +} + +/// Validates that the specified byte sequence meets the following requirements: +/// 1. `new_canister_wasm` starts with Wasm or Gzip magic bytes. +/// 2. Combined length of `new_canister_wasm` and `new_canister_wasm` is within ICP message limits. +fn validate_wasm_bytes( + new_canister_wasm: &[u8], + canister_upgrade_arg: &Option>, +) -> Result<(), Vec> { + let mut defects = vec![]; + + // https://internetcomputer.org/docs/current/references/ic-interface-spec#canister-module-format + const RAW_WASM_HEADER: [u8; 4] = [0, 0x61, 0x73, 0x6d]; + const GZIPPED_WASM_HEADER: [u8; 3] = [0x1f, 0x8b, 0x08]; + + if new_canister_wasm.len() < 4 + || new_canister_wasm[..4] != RAW_WASM_HEADER[..] + && new_canister_wasm[..3] != GZIPPED_WASM_HEADER[..] + { + defects.push("new_canister_wasm lacks the magic value in its header.".into()); + } + + if new_canister_wasm.len().saturating_add( + canister_upgrade_arg + .as_ref() + .map(|arg| arg.len()) + .unwrap_or_default(), + ) >= MAX_INSTALL_CODE_WASM_AND_ARG_SIZE + { + defects.push(format!( + "the maximum canister WASM and argument size \ + for UpgradeSnsControlledCanister is {} bytes.", + MAX_INSTALL_CODE_WASM_AND_ARG_SIZE + )); + } + + if !defects.is_empty() { + return Err(defects); + } + + Ok(()) +} + +async fn validate_chunked_wasm( + env: &dyn Environment, + wasm_module_hash: &Vec, + store_canister_id: CanisterId, + chunk_hashes_list: &[Vec], +) -> Result<(), Vec> { + let mut defects = vec![]; + + match chunk_hashes_list { + [] => { + let defect = "chunked_canister_wasm.chunk_hashes_list cannot be empty.".to_string(); + defects.push(defect); + } + [chunk_hash] if wasm_module_hash != chunk_hash => { + let defect = format!( + "chunked_canister_wasm.chunk_hashes_list specifies only one hash ({}), but \ + it differs from chunked_canister_wasm.wasm_module_hash ({}).", + format_full_hash(chunk_hash), + format_full_hash(&wasm_module_hash[..]), + ); + defects.push(defect); + } + _ => (), + } + + let arg = match Encode!(&CanisterIdRecord::from(store_canister_id)) { + Ok(arg) => arg, + Err(err) => { + let defect = format!("Cannot encode stored_chunks arg: {err}"); + defects.push(defect); + return Err(defects); + } + }; + + // TODO[NNS1-3550]: Enable stored chunks validation on mainnet. + #[cfg(feature = "test")] + let validate_stored_chunks: bool = true; + #[cfg(not(feature = "test"))] + let validate_stored_chunks: bool = false; + if validate_stored_chunks { + // TODO[NNS1-3550]: Switch this call to best-effort. + let stored_chunks_response = env + .call_canister(CanisterId::ic_00(), "stored_chunks", arg) + .await; + + let stored_chunks_response = match stored_chunks_response { + Ok(stored_chunks_response) => stored_chunks_response, + Err(err) => { + let defect = format!("Cannot call stored_chunks for {store_canister_id}: {err:?}"); + defects.push(defect); + return Err(defects); + } + }; + + let stored_chunks_response = match Decode!(&stored_chunks_response, StoredChunksReply) { + Ok(stored_chunks_response) => stored_chunks_response, + Err(err) => { + let defect = format!( + "Cannot decode response from calling stored_chunks for {store_canister_id}: {err}" + ); + defects.push(defect); + return Err(defects); + } + }; + + // Finally, check that the expected chunks were successfully uploaded to the store canister. + let available_chunks = stored_chunks_response + .0 + .iter() + .map(|chunk| format_full_hash(&chunk.hash)) + .collect::>(); + let required_chunks = chunk_hashes_list + .iter() + .map(|chunk| format_full_hash(chunk)) + .collect::>(); + + let missing_chunks = required_chunks + .difference(&available_chunks) + .cloned() + .collect::>(); + if !missing_chunks.is_empty() { + let defect = format!( + "{} out of {} expected WASM chunks were not uploaded to the store canister: {}", + missing_chunks.len(), + required_chunks.len(), + missing_chunks.join(", ") + ); + defects.push(defect); + } + } + + if !defects.is_empty() { + return Err(defects); + } + + Ok(()) +} + +impl Wasm { + /// Returns the list of defects of this Wasm in Err result. + pub async fn validate( + &self, + env: &dyn Environment, + canister_upgrade_arg: &Option>, + ) -> Result<(), Vec> { + match self { + Self::Bytes(bytes) => validate_wasm_bytes(bytes, canister_upgrade_arg), + Self::Chunked { + wasm_module_hash, + store_canister_id, + chunk_hashes_list, + } => { + validate_chunked_wasm(env, wasm_module_hash, *store_canister_id, chunk_hashes_list) + .await + } + } + } + + pub fn description(&self) -> String { + match self { + Self::Bytes(bytes) => { + let canister_wasm_sha256 = { + let mut state = Sha256::new(); + state.write(&bytes[..]); + let sha = state.finish(); + sha.to_vec() + }; + format!( + "Embedded module with {} bytes and SHA256 `{}`.", + bytes.len(), + format_full_hash(&canister_wasm_sha256) + ) + } + Self::Chunked { + wasm_module_hash, + store_canister_id, + chunk_hashes_list, + } => { + format!( + "Remote module stored on canister {} with SHA256 `{}`. \ + Split into {} chunks:\n - {}", + store_canister_id.get(), + format_full_hash(wasm_module_hash), + chunk_hashes_list.len(), + chunk_hashes_list + .iter() + .map(|chunk_hash| { format!("`{}`", format_full_hash(chunk_hash)) }) + .collect::>() + .join("\n - "), + ) + } + } + } +} + +impl TryFrom<&UpgradeSnsControlledCanister> for Wasm { + type Error = String; + + fn try_from(upgrade: &UpgradeSnsControlledCanister) -> Result { + const ERR_PREFIX: &str = "Invalid UpgradeSnsControlledCanister"; + + match ( + &upgrade.new_canister_wasm[..], + &upgrade.chunked_canister_wasm, + ) { + ( + [], + Some(ChunkedCanisterWasm { + wasm_module_hash, + store_canister_id, + chunk_hashes_list, + }), + ) => { + let Some(store_canister_id) = store_canister_id else { + return Err(format!( + "{ERR_PREFIX}.chunked_canister_wasm.store_canister_id must be \ + specified." + )); + }; + + let store_canister_id = CanisterId::try_from_principal_id(*store_canister_id) + .map_err(|err| { + format!("{ERR_PREFIX}.chunked_canister_wasm.store_canister_id: {err}") + })?; + + Ok(Self::Chunked { + wasm_module_hash: wasm_module_hash.clone(), + store_canister_id, + chunk_hashes_list: chunk_hashes_list.clone(), + }) + } + (bytes, None) => Ok(Self::Bytes(bytes.to_vec())), + _ => Err(format!( + "{ERR_PREFIX}: Either .new_canister_wasm or \ + .chunked_canister_wasm (but not both) must be specified." + )), + } + } +} + impl UpgradeSnsControlledCanister { // Gets the install mode if it is set, otherwise defaults to Upgrade. // This function is not called `mode_or_default` because `or_default` usually @@ -2833,1210 +3093,4 @@ pub mod test_helpers { } } #[cfg(test)] -pub(crate) mod tests { - use super::*; - use crate::pb::v1::{ - claim_swap_neurons_request::neuron_recipe, - governance::Mode::PreInitializationSwap, - nervous_system_function::{FunctionType, GenericNervousSystemFunction}, - neuron::Followees, - ExecuteGenericNervousSystemFunction, Proposal, ProposalData, VotingRewardsParameters, - }; - use ic_base_types::PrincipalId; - use ic_nervous_system_common_test_keys::TEST_USER1_PRINCIPAL; - use ic_nervous_system_proto::pb::v1::Principals; - use lazy_static::lazy_static; - use maplit::{btreemap, hashset}; - use std::convert::TryInto; - - #[test] - fn test_voting_period_parameters() { - let non_critical_action = Action::Motion(Default::default()); - let critical_action = Action::TransferSnsTreasuryFunds(Default::default()); - - let normal_nervous_system_parameters = NervousSystemParameters { - initial_voting_period_seconds: Some(4 * ONE_DAY_SECONDS), - wait_for_quiet_deadline_increase_seconds: Some(2 * ONE_DAY_SECONDS), - ..Default::default() - }; - assert_eq!( - non_critical_action.voting_duration_parameters(&normal_nervous_system_parameters), - VotingDurationParameters { - initial_voting_period: PbDuration { - seconds: Some(4 * ONE_DAY_SECONDS), - }, - wait_for_quiet_deadline_increase: PbDuration { - seconds: Some(2 * ONE_DAY_SECONDS), - } - }, - ); - assert_eq!( - critical_action.voting_duration_parameters(&normal_nervous_system_parameters), - VotingDurationParameters { - initial_voting_period: PbDuration { - seconds: Some(5 * ONE_DAY_SECONDS), - }, - wait_for_quiet_deadline_increase: PbDuration { - seconds: Some(2 * ONE_DAY_SECONDS + ONE_DAY_SECONDS / 2), - } - }, - ); - - // This is even slower than the hard-coded values (5 days initial and 2.5 days wait for - // quiet) for critical proposals. Therefore, these values are used for both normal and - // critical proposals. - let slow_nervous_system_parameters = NervousSystemParameters { - initial_voting_period_seconds: Some(7 * ONE_DAY_SECONDS), - wait_for_quiet_deadline_increase_seconds: Some(4 * ONE_DAY_SECONDS), - ..Default::default() - }; - assert_eq!( - non_critical_action.voting_duration_parameters(&slow_nervous_system_parameters), - VotingDurationParameters { - initial_voting_period: PbDuration { - seconds: Some(7 * ONE_DAY_SECONDS), - }, - wait_for_quiet_deadline_increase: PbDuration { - seconds: Some(4 * ONE_DAY_SECONDS), - } - }, - ); - assert_eq!( - critical_action.voting_duration_parameters(&slow_nervous_system_parameters), - VotingDurationParameters { - initial_voting_period: PbDuration { - seconds: Some(7 * ONE_DAY_SECONDS), - }, - wait_for_quiet_deadline_increase: PbDuration { - seconds: Some(4 * ONE_DAY_SECONDS), - } - }, - ); - } - - #[test] - fn test_nervous_system_parameters_validate() { - NervousSystemParameters::with_default_values() - .validate() - .unwrap(); - - let invalid_params = vec![ - NervousSystemParameters { - neuron_minimum_stake_e8s: None, - ..NervousSystemParameters::with_default_values() - }, - NervousSystemParameters { - transaction_fee_e8s: Some(100), - neuron_minimum_stake_e8s: Some(10), - ..NervousSystemParameters::with_default_values() - }, - NervousSystemParameters { - transaction_fee_e8s: None, - ..NervousSystemParameters::with_default_values() - }, - NervousSystemParameters { - max_proposals_to_keep_per_action: None, - ..NervousSystemParameters::with_default_values() - }, - NervousSystemParameters { - max_proposals_to_keep_per_action: Some(0), - ..NervousSystemParameters::with_default_values() - }, - NervousSystemParameters { - max_proposals_to_keep_per_action: Some( - NervousSystemParameters::MAX_PROPOSALS_TO_KEEP_PER_ACTION_CEILING + 1, - ), - ..NervousSystemParameters::with_default_values() - }, - NervousSystemParameters { - initial_voting_period_seconds: None, - ..NervousSystemParameters::with_default_values() - }, - NervousSystemParameters { - initial_voting_period_seconds: Some( - NervousSystemParameters::INITIAL_VOTING_PERIOD_SECONDS_FLOOR - 1, - ), - ..NervousSystemParameters::with_default_values() - }, - NervousSystemParameters { - initial_voting_period_seconds: Some( - NervousSystemParameters::INITIAL_VOTING_PERIOD_SECONDS_CEILING + 1, - ), - ..NervousSystemParameters::with_default_values() - }, - NervousSystemParameters { - default_followees: None, - ..NervousSystemParameters::with_default_values() - }, - NervousSystemParameters { - max_number_of_neurons: None, - ..NervousSystemParameters::with_default_values() - }, - NervousSystemParameters { - max_number_of_neurons: Some(0), - ..NervousSystemParameters::with_default_values() - }, - NervousSystemParameters { - max_number_of_neurons: Some( - NervousSystemParameters::MAX_NUMBER_OF_NEURONS_CEILING + 1, - ), - ..NervousSystemParameters::with_default_values() - }, - NervousSystemParameters { - neuron_minimum_dissolve_delay_to_vote_seconds: None, - ..NervousSystemParameters::with_default_values() - }, - NervousSystemParameters { - max_dissolve_delay_seconds: Some(10), - neuron_minimum_dissolve_delay_to_vote_seconds: Some(20), - ..NervousSystemParameters::with_default_values() - }, - NervousSystemParameters { - max_followees_per_function: None, - ..NervousSystemParameters::with_default_values() - }, - NervousSystemParameters { - max_followees_per_function: Some( - NervousSystemParameters::MAX_FOLLOWEES_PER_FUNCTION_CEILING + 1, - ), - ..NervousSystemParameters::with_default_values() - }, - NervousSystemParameters { - max_dissolve_delay_seconds: None, - ..NervousSystemParameters::with_default_values() - }, - NervousSystemParameters { - max_neuron_age_for_age_bonus: None, - ..NervousSystemParameters::with_default_values() - }, - NervousSystemParameters { - max_number_of_proposals_with_ballots: None, - ..NervousSystemParameters::with_default_values() - }, - NervousSystemParameters { - max_number_of_proposals_with_ballots: Some(0), - ..NervousSystemParameters::with_default_values() - }, - NervousSystemParameters { - max_number_of_proposals_with_ballots: Some( - NervousSystemParameters::MAX_NUMBER_OF_PROPOSALS_WITH_BALLOTS_CEILING + 1, - ), - ..NervousSystemParameters::with_default_values() - }, - NervousSystemParameters { - neuron_claimer_permissions: Some(NeuronPermissionList { - permissions: vec![NeuronPermissionType::Vote as i32], - }), - ..NervousSystemParameters::with_default_values() - }, - NervousSystemParameters { - neuron_claimer_permissions: None, - ..NervousSystemParameters::with_default_values() - }, - NervousSystemParameters { - neuron_grantable_permissions: None, - ..NervousSystemParameters::with_default_values() - }, - NervousSystemParameters { - max_number_of_principals_per_neuron: Some(0), - ..NervousSystemParameters::with_default_values() - }, - NervousSystemParameters { - max_number_of_principals_per_neuron: Some(1000), - ..NervousSystemParameters::with_default_values() - }, - NervousSystemParameters { - voting_rewards_parameters: Some(VotingRewardsParameters { - round_duration_seconds: None, - ..Default::default() - }), - ..NervousSystemParameters::with_default_values() - }, - NervousSystemParameters { - max_number_of_principals_per_neuron: Some(4), - ..NervousSystemParameters::with_default_values() - }, - ]; - - for params in invalid_params { - params.validate().unwrap_err(); - } - } - - #[test] - fn test_inherit_from() { - let default_params = NervousSystemParameters::with_default_values(); - - let proposed_params = NervousSystemParameters { - transaction_fee_e8s: Some(124), - max_number_of_neurons: Some(566), - max_number_of_proposals_with_ballots: Some(9801), - default_followees: Some(Default::default()), - - // Set all other fields to None. - ..Default::default() - }; - - let new_params = proposed_params.inherit_from(&default_params); - let expected_params = NervousSystemParameters { - transaction_fee_e8s: Some(124), - max_number_of_neurons: Some(566), - max_number_of_proposals_with_ballots: Some(9801), - default_followees: Some(Default::default()), - ..default_params.clone() - }; - - assert_eq!(new_params, expected_params); - - assert_eq!(new_params.maturity_modulation_disabled, Some(false)); - - let disable_maturity_modulation = NervousSystemParameters { - maturity_modulation_disabled: Some(true), - - // Set all other fields to None. - ..Default::default() - }; - - assert_eq!( - disable_maturity_modulation.inherit_from(&default_params), - NervousSystemParameters { - maturity_modulation_disabled: Some(true), - ..default_params - }, - ); - } - - lazy_static! { - static ref MANAGE_NEURON_COMMANDS: (Vec, Vec, manage_neuron::Command) = { - use manage_neuron::Command; - - #[rustfmt::skip] - let allowed_in_pre_initialization_swap = vec! [ - Command::Follow (Default::default()), - Command::MakeProposal (Default::default()), - Command::RegisterVote (Default::default()), - Command::AddNeuronPermissions (Default::default()), - Command::RemoveNeuronPermissions (Default::default()), - ]; - - #[rustfmt::skip] - let disallowed_in_pre_initialization_swap = vec! [ - Command::Configure (Default::default()), - Command::Disburse (Default::default()), - Command::Split (Default::default()), - Command::MergeMaturity (Default::default()), - Command::DisburseMaturity (Default::default()), - ]; - - // Only the swap canister is allowed to do this in PreInitializationSwap. - let claim_or_refresh = Command::ClaimOrRefresh(Default::default()); - - (allowed_in_pre_initialization_swap, disallowed_in_pre_initialization_swap, claim_or_refresh) - }; - } - - #[should_panic] - #[test] - fn test_mode_allows_manage_neuron_command_or_err_unspecified_kaboom() { - let caller_is_swap_canister = true; - let innocuous_command = &MANAGE_NEURON_COMMANDS.0[0]; - let _clippy = governance::Mode::Unspecified - .allows_manage_neuron_command_or_err(innocuous_command, caller_is_swap_canister); - } - - #[test] - fn test_mode_allows_manage_neuron_command_or_err_normal_is_generally_ok() { - let mut commands = MANAGE_NEURON_COMMANDS.0.clone(); - commands.append(&mut MANAGE_NEURON_COMMANDS.1.clone()); - commands.push(MANAGE_NEURON_COMMANDS.2.clone()); - - for command in commands { - for caller_is_swap_canister in [true, false] { - let result = governance::Mode::Normal - .allows_manage_neuron_command_or_err(&command, caller_is_swap_canister); - assert!(result.is_ok(), "{:#?}", result); - } - } - } - - #[test] - fn test_mode_allows_manage_neuron_command_or_err_pre_initialization_swap_ok() { - let allowed = &MANAGE_NEURON_COMMANDS.0; - for command in allowed { - for caller_is_swap_canister in [true, false] { - let result = PreInitializationSwap - .allows_manage_neuron_command_or_err(command, caller_is_swap_canister); - assert!(result.is_ok(), "{:#?}", result); - } - } - } - - #[test] - fn test_mode_allows_manage_neuron_command_or_err_pre_initialization_swap_verboten() { - let disallowed = &MANAGE_NEURON_COMMANDS.1; - for command in disallowed { - for caller_is_swap_canister in [true, false] { - let result = PreInitializationSwap - .allows_manage_neuron_command_or_err(command, caller_is_swap_canister); - assert!(result.is_err(), "{:#?}", result); - } - } - } - - #[test] - fn test_mode_allows_manage_neuron_command_or_err_pre_initialization_swap_claim_or_refresh() { - let claim_or_refresh = &MANAGE_NEURON_COMMANDS.2; - - let caller_is_swap_canister = false; - let result = PreInitializationSwap - .allows_manage_neuron_command_or_err(claim_or_refresh, caller_is_swap_canister); - assert!(result.is_err(), "{:#?}", result); - - let caller_is_swap_canister = true; - let result = PreInitializationSwap - .allows_manage_neuron_command_or_err(claim_or_refresh, caller_is_swap_canister); - assert!(result.is_ok(), "{:#?}", result); - } - - const ROOT_TARGETING_FUNCTION_ID: u64 = 1001; - const GOVERNANCE_TARGETING_FUNCTION_ID: u64 = 1002; - const LEDGER_TARGETING_FUNCTION_ID: u64 = 1003; - const RANDOM_CANISTER_TARGETING_FUNCTION_ID: u64 = 1004; - - #[rustfmt::skip] - lazy_static! { - static ref ROOT_CANISTER_ID: PrincipalId = [101][..].try_into().unwrap(); - static ref GOVERNANCE_CANISTER_ID: PrincipalId = [102][..].try_into().unwrap(); - static ref LEDGER_CANISTER_ID: PrincipalId = [103][..].try_into().unwrap(); - static ref RANDOM_CANISTER_ID: PrincipalId = [0xDE, 0xAD, 0xBE, 0xEF][..].try_into().unwrap(); - - static ref PROPOSAL_ACTIONS: ( - Vec, // Allowed in PreInitializationSwap. - Vec, // Disallowed in PreInitializationSwap. - Vec, // ExecuteGenericNervousSystemFunction where target is root, governance, or ledger - Action, // ExecuteGenericNervousSystemFunction, but target is not one of the distinguished canisters. - ) = { - let allowed_in_pre_initialization_swap = vec! [ - Action::Motion(Default::default()), - Action::AddGenericNervousSystemFunction(Default::default()), - Action::RemoveGenericNervousSystemFunction(Default::default()), - ]; - - let disallowed_in_pre_initialization_swap = vec! [ - Action::ManageNervousSystemParameters(Default::default()), - Action::TransferSnsTreasuryFunds(Default::default()), - Action::MintSnsTokens(Default::default()), - Action::UpgradeSnsControlledCanister(Default::default()), - Action::RegisterDappCanisters(Default::default()), - Action::DeregisterDappCanisters(Default::default()), - ]; - - // Conditionally allow: No targeting SNS canisters. - fn execute(function_id: u64) -> Action { - Action::ExecuteGenericNervousSystemFunction(ExecuteGenericNervousSystemFunction { - function_id, - ..Default::default() - }) - } - - let target_sns_canister_actions = vec! [ - execute( ROOT_TARGETING_FUNCTION_ID), - execute(GOVERNANCE_TARGETING_FUNCTION_ID), - execute( LEDGER_TARGETING_FUNCTION_ID), - ]; - - let target_random_canister_action = execute(RANDOM_CANISTER_TARGETING_FUNCTION_ID); - - ( - allowed_in_pre_initialization_swap, - disallowed_in_pre_initialization_swap, - target_sns_canister_actions, - target_random_canister_action - ) - }; - - static ref ID_TO_NERVOUS_SYSTEM_FUNCTION: BTreeMap = { - fn new_fn(function_id: u64, target_canister_id: &PrincipalId) -> NervousSystemFunction { - NervousSystemFunction { - id: function_id, - name: "Amaze".to_string(), - description: Some("Best function evar.".to_string()), - function_type: Some(FunctionType::GenericNervousSystemFunction(GenericNervousSystemFunction { - target_canister_id: Some(*target_canister_id), - target_method_name: Some("Foo".to_string()), - validator_canister_id: Some(*target_canister_id), - validator_method_name: Some("Bar".to_string()), - })), - } - } - - vec![ - new_fn( ROOT_TARGETING_FUNCTION_ID, &ROOT_CANISTER_ID), - new_fn( GOVERNANCE_TARGETING_FUNCTION_ID, &GOVERNANCE_CANISTER_ID), - new_fn( LEDGER_TARGETING_FUNCTION_ID, &LEDGER_CANISTER_ID), - new_fn(RANDOM_CANISTER_TARGETING_FUNCTION_ID, &RANDOM_CANISTER_ID), - ] - .into_iter() - .map(|f| (f.id, f)) - .collect() - }; - - static ref DISALLOWED_TARGET_CANISTER_IDS: HashSet = hashset! { - CanisterId::unchecked_from_principal(*ROOT_CANISTER_ID), - CanisterId::unchecked_from_principal(*GOVERNANCE_CANISTER_ID), - CanisterId::unchecked_from_principal(*LEDGER_CANISTER_ID), - }; - } - - #[should_panic] - #[test] - fn test_mode_allows_proposal_action_or_err_unspecified_kaboom() { - let innocuous_action = &PROPOSAL_ACTIONS.0[0]; - let _clippy = governance::Mode::Unspecified.allows_proposal_action_or_err( - innocuous_action, - &DISALLOWED_TARGET_CANISTER_IDS, - &ID_TO_NERVOUS_SYSTEM_FUNCTION, - ); - } - - #[test] - fn test_mode_allows_proposal_action_or_err_normal_is_always_ok() { - // Flatten PROPOSAL_ACTIONS into one big Vec. - let mut actions = PROPOSAL_ACTIONS.0.clone(); - actions.append(&mut PROPOSAL_ACTIONS.1.clone()); - actions.append(&mut PROPOSAL_ACTIONS.2.clone()); - actions.push(PROPOSAL_ACTIONS.3.clone()); - - for action in actions { - let result = governance::Mode::Normal.allows_proposal_action_or_err( - &action, - &DISALLOWED_TARGET_CANISTER_IDS, - &ID_TO_NERVOUS_SYSTEM_FUNCTION, - ); - assert!(result.is_ok(), "{:#?} {:#?}", result, action); - } - } - - #[test] - fn test_mode_allows_proposal_action_or_err_pre_initialization_swap_happy() { - for action in &PROPOSAL_ACTIONS.0 { - let result = PreInitializationSwap.allows_proposal_action_or_err( - action, - &DISALLOWED_TARGET_CANISTER_IDS, - &ID_TO_NERVOUS_SYSTEM_FUNCTION, - ); - assert!(result.is_ok(), "{:#?} {:#?}", result, action); - } - } - - #[test] - fn test_mode_allows_proposal_action_or_err_pre_initialization_swap_sad() { - for action in &PROPOSAL_ACTIONS.1 { - let result = PreInitializationSwap.allows_proposal_action_or_err( - action, - &DISALLOWED_TARGET_CANISTER_IDS, - &ID_TO_NERVOUS_SYSTEM_FUNCTION, - ); - assert!(result.is_err(), "{:#?}", action); - } - } - - #[test] - fn test_mode_allows_proposal_action_or_err_pre_initialization_swap_disallows_targeting_an_sns_canister( - ) { - for action in &PROPOSAL_ACTIONS.2 { - let result = PreInitializationSwap.allows_proposal_action_or_err( - action, - &DISALLOWED_TARGET_CANISTER_IDS, - &ID_TO_NERVOUS_SYSTEM_FUNCTION, - ); - assert!(result.is_err(), "{:#?}", action); - } - } - - #[test] - fn test_mode_allows_proposal_action_or_err_pre_initialization_swap_allows_targeting_a_random_canister( - ) { - let action = &PROPOSAL_ACTIONS.3; - let result = PreInitializationSwap.allows_proposal_action_or_err( - action, - &DISALLOWED_TARGET_CANISTER_IDS, - &ID_TO_NERVOUS_SYSTEM_FUNCTION, - ); - assert!(result.is_ok(), "{:#?} {:#?}", result, action); - } - - #[test] - fn test_mode_allows_proposal_action_or_err_function_not_found() { - let execute = - Action::ExecuteGenericNervousSystemFunction(ExecuteGenericNervousSystemFunction { - function_id: 0xDEADBEF, - ..Default::default() - }); - - let result = governance::Mode::PreInitializationSwap.allows_proposal_action_or_err( - &execute, - &DISALLOWED_TARGET_CANISTER_IDS, - &ID_TO_NERVOUS_SYSTEM_FUNCTION, - ); - - let err = match result { - Err(err) => err, - Ok(_) => panic!( - "Make proposal is supposed to result in NotFound when \ - it specifies an unknown function ID." - ), - }; - assert_eq!(err.error_type, ErrorType::NotFound as i32, "{:#?}", err); - } - - #[should_panic] - #[test] - fn test_mode_allows_proposal_action_or_err_panic_when_function_has_no_type() { - let function_id = 42; - - let execute = - Action::ExecuteGenericNervousSystemFunction(ExecuteGenericNervousSystemFunction { - function_id, - ..Default::default() - }); - - let mut functions = ID_TO_NERVOUS_SYSTEM_FUNCTION.clone(); - functions.insert( - function_id, - NervousSystemFunction { - id: function_id, - function_type: None, // This is evil. - name: "Toxic".to_string(), - description: None, - }, - ); - - let _unused = governance::Mode::PreInitializationSwap.allows_proposal_action_or_err( - &execute, - &DISALLOWED_TARGET_CANISTER_IDS, - &functions, - ); - } - - #[should_panic] - #[test] - fn test_mode_allows_proposal_action_or_err_panic_when_function_has_no_target_canister_id() { - let function_id = 42; - - let execute = - Action::ExecuteGenericNervousSystemFunction(ExecuteGenericNervousSystemFunction { - function_id, - ..Default::default() - }); - - let mut functions = ID_TO_NERVOUS_SYSTEM_FUNCTION.clone(); - functions.insert( - function_id, - NervousSystemFunction { - id: function_id, - name: "Toxic".to_string(), - description: None, - function_type: Some(FunctionType::GenericNervousSystemFunction( - GenericNervousSystemFunction { - target_canister_id: None, // This is evil. - ..Default::default() - }, - )), - }, - ); - - let _unused = governance::Mode::PreInitializationSwap.allows_proposal_action_or_err( - &execute, - &DISALLOWED_TARGET_CANISTER_IDS, - &functions, - ); - } - - #[test] - fn test_sns_metadata_validate() { - let default = SnsMetadata { - logo: Some("data:image/png;base64,aGVsbG8gZnJvbSBkZmluaXR5IQ==".to_string()), - url: Some("https://forum.dfinity.org".to_string()), - name: Some("X".repeat(SnsMetadata::MIN_NAME_LENGTH)), - description: Some("X".repeat(SnsMetadata::MIN_DESCRIPTION_LENGTH)), - }; - - let valid_sns_metadata = vec![ - default.clone(), - SnsMetadata { - url: Some("https://forum.dfinity.org/foo/bar/?".to_string()), - ..default.clone() - }, - SnsMetadata { - url: Some("https://forum.dfinity.org/foo/bar/?".to_string()), - ..default.clone() - }, - SnsMetadata { - url: Some("https://any-url.com/foo/bar/?".to_string()), - ..default.clone() - }, - ]; - - let invalid_sns_metadata = vec![ - SnsMetadata { - name: None, - ..default.clone() - }, - SnsMetadata { - name: Some("X".repeat(SnsMetadata::MAX_NAME_LENGTH + 1)), - ..default.clone() - }, - SnsMetadata { - name: Some("X".repeat(SnsMetadata::MIN_NAME_LENGTH - 1)), - ..default.clone() - }, - SnsMetadata { - description: None, - ..default.clone() - }, - SnsMetadata { - description: Some("X".repeat(SnsMetadata::MAX_DESCRIPTION_LENGTH + 1)), - ..default.clone() - }, - SnsMetadata { - description: Some("X".repeat(SnsMetadata::MIN_DESCRIPTION_LENGTH - 1)), - ..default.clone() - }, - SnsMetadata { - logo: Some("X".repeat(MAX_LOGO_LENGTH + 1)), - ..default.clone() - }, - SnsMetadata { - url: None, - ..default.clone() - }, - SnsMetadata { - url: Some("X".repeat(SnsMetadata::MAX_URL_LENGTH + 1)), - ..default.clone() - }, - SnsMetadata { - url: Some("X".to_string()), - ..default.clone() - }, - SnsMetadata { - url: Some("X".repeat(SnsMetadata::MIN_URL_LENGTH - 1)), - ..default.clone() - }, - SnsMetadata { - url: Some("file://forum.dfinity.org".to_string()), - ..default.clone() - }, - SnsMetadata { - url: Some("https://".to_string()), - ..default.clone() - }, - SnsMetadata { - url: Some("https://forum.dfinity.org/https://forum.dfinity.org".to_string()), - ..default.clone() - }, - SnsMetadata { - url: Some("https://example@forum.dfinity.org".to_string()), - ..default.clone() - }, - SnsMetadata { - url: Some("http://internetcomputer".to_string()), - ..default.clone() - }, - SnsMetadata { - url: Some("mailto:example@internetcomputer.org".to_string()), - ..default.clone() - }, - SnsMetadata { - url: Some("internetcomputer".to_string()), - ..default - }, - ]; - - for sns_metadata in invalid_sns_metadata { - if sns_metadata.validate().is_ok() { - panic!("Invalid metadata passed validation: {:?}", sns_metadata); - } - } - - for sns_metadata in valid_sns_metadata { - if sns_metadata.validate().is_err() { - panic!("Valid metadata failed validation: {:?}", sns_metadata); - } - } - } - - impl NeuronRecipe { - fn validate_default_direct_participant() -> Self { - Self { - controller: Some(*TEST_USER1_PRINCIPAL), - neuron_id: Some(NeuronId::new_test_neuron_id(0)), - stake_e8s: Some(E8S_PER_TOKEN), - dissolve_delay_seconds: Some(3 * ONE_MONTH_SECONDS), - followees: Some(NeuronIds::from(vec![NeuronId::new_test_neuron_id(1)])), - participant: Some(Participant::Direct(neuron_recipe::Direct {})), - } - } - - fn validate_default_neurons_fund() -> Self { - Self { - controller: Some(PrincipalId::from(ic_nns_constants::GOVERNANCE_CANISTER_ID)), - neuron_id: Some(NeuronId::new_test_neuron_id(0)), - stake_e8s: Some(E8S_PER_TOKEN), - dissolve_delay_seconds: Some(3 * ONE_MONTH_SECONDS), - followees: Some(NeuronIds::from(vec![NeuronId::new_test_neuron_id(1)])), - participant: Some(Participant::NeuronsFund(neuron_recipe::NeuronsFund { - nns_neuron_id: Some(2), - nns_neuron_controller: Some(PrincipalId::new_user_test_id(13847)), - nns_neuron_hotkeys: Some(Principals::from(vec![ - PrincipalId::new_user_test_id(13848), - PrincipalId::new_user_test_id(13849), - ])), - })), - } - } - } - - mod neuron_recipe_validate_tests { - use super::*; - - const NEURON_MINIMUM_STAKE_E8S: u64 = E8S_PER_TOKEN; - const MAX_FOLLOWEES_PER_FUNCTION: u64 = 1; - const MAX_NUMBER_OF_PRINCIPALS_PER_NEURON: u64 = 5; - - fn validate_recipe(recipe: &NeuronRecipe) -> Result<(), String> { - recipe.validate( - NEURON_MINIMUM_STAKE_E8S, - MAX_FOLLOWEES_PER_FUNCTION, - MAX_NUMBER_OF_PRINCIPALS_PER_NEURON, - ) - } - - #[test] - fn test_default_direct_participant_is_valid() { - validate_recipe(&NeuronRecipe::validate_default_direct_participant()).unwrap(); - } - - #[test] - fn test_default_neurons_fund_is_valid() { - validate_recipe(&NeuronRecipe::validate_default_neurons_fund()).unwrap(); - } - - #[test] - fn test_invalid_missing_controller() { - let recipe = NeuronRecipe { - controller: None, - ..NeuronRecipe::validate_default_direct_participant() - }; - validate_recipe(&recipe).unwrap_err(); - } - - #[test] - fn test_invalid_missing_neuron_id() { - let recipe = NeuronRecipe { - neuron_id: None, - ..NeuronRecipe::validate_default_direct_participant() - }; - validate_recipe(&recipe).unwrap_err(); - } - - #[test] - fn test_invalid_missing_stake() { - let recipe = NeuronRecipe { - stake_e8s: None, - ..NeuronRecipe::validate_default_direct_participant() - }; - validate_recipe(&recipe).unwrap_err(); - } - - #[test] - fn test_invalid_low_stake() { - let recipe = NeuronRecipe { - stake_e8s: Some(NEURON_MINIMUM_STAKE_E8S - 1), - ..NeuronRecipe::validate_default_direct_participant() - }; - validate_recipe(&recipe).unwrap_err(); - } - - #[test] - fn test_invalid_missing_dissolve_delay() { - let recipe = NeuronRecipe { - dissolve_delay_seconds: None, - ..NeuronRecipe::validate_default_direct_participant() - }; - validate_recipe(&recipe).unwrap_err(); - } - - #[test] - fn test_invalid_missing_followees() { - let recipe = NeuronRecipe { - followees: None, - ..NeuronRecipe::validate_default_direct_participant() - }; - validate_recipe(&recipe).unwrap_err(); - } - - #[test] - fn test_invalid_too_many_followees() { - let recipe = NeuronRecipe { - followees: Some(NeuronIds::from(vec![ - NeuronId::new_test_neuron_id(1), - NeuronId::new_test_neuron_id(2), - ])), - ..NeuronRecipe::validate_default_direct_participant() - }; - validate_recipe(&recipe).unwrap_err(); - } - - #[test] - fn test_invalid_missing_participant() { - let recipe = NeuronRecipe { - participant: None, - ..NeuronRecipe::validate_default_direct_participant() - }; - validate_recipe(&recipe).unwrap_err(); - } - - #[test] - fn test_invalid_neurons_fund_missing_nns_neuron_id() { - let recipe = NeuronRecipe { - participant: Some(Participant::NeuronsFund(neuron_recipe::NeuronsFund { - nns_neuron_id: None, - nns_neuron_controller: Some(PrincipalId::new_user_test_id(13847)), - nns_neuron_hotkeys: Some(Principals::from(vec![ - PrincipalId::new_user_test_id(13848), - ])), - })), - ..NeuronRecipe::validate_default_neurons_fund() - }; - validate_recipe(&recipe).unwrap_err(); - } - - #[test] - fn test_invalid_neurons_fund_missing_controller() { - let recipe = NeuronRecipe { - participant: Some(Participant::NeuronsFund(neuron_recipe::NeuronsFund { - nns_neuron_id: Some(2), - nns_neuron_controller: None, - nns_neuron_hotkeys: Some(Principals::from(vec![ - PrincipalId::new_user_test_id(13848), - ])), - })), - ..NeuronRecipe::validate_default_neurons_fund() - }; - validate_recipe(&recipe).unwrap_err(); - } - - #[test] - fn test_invalid_neurons_fund_missing_hotkeys() { - let recipe = NeuronRecipe { - participant: Some(Participant::NeuronsFund(neuron_recipe::NeuronsFund { - nns_neuron_id: Some(2), - nns_neuron_controller: Some(PrincipalId::new_user_test_id(13847)), - nns_neuron_hotkeys: None, - })), - ..NeuronRecipe::validate_default_neurons_fund() - }; - validate_recipe(&recipe).unwrap_err(); - } - - #[test] - fn test_invalid_neurons_fund_too_many_hotkeys() { - let recipe = NeuronRecipe { - participant: Some(Participant::NeuronsFund(neuron_recipe::NeuronsFund { - nns_neuron_id: Some(2), - nns_neuron_controller: Some(PrincipalId::new_user_test_id(13847)), - nns_neuron_hotkeys: Some(Principals::from(vec![ - PrincipalId::new_user_test_id(13848), - PrincipalId::new_user_test_id(13849), - PrincipalId::new_user_test_id(13810), - PrincipalId::new_user_test_id(13811), - PrincipalId::new_user_test_id(13812), - ])), - })), - ..NeuronRecipe::validate_default_neurons_fund() - }; - validate_recipe(&recipe).unwrap_err(); - } - - #[test] - fn test_valid_zero_dissolve_delay() { - let recipe = NeuronRecipe { - dissolve_delay_seconds: Some(0), - ..NeuronRecipe::validate_default_direct_participant() - }; - validate_recipe(&recipe).unwrap(); - } - - #[test] - fn test_valid_empty_followees() { - let recipe = NeuronRecipe { - followees: Some(NeuronIds::from(vec![])), - ..NeuronRecipe::validate_default_direct_participant() - }; - validate_recipe(&recipe).unwrap(); - } - - #[test] - fn test_valid_minimum_stake() { - let recipe = NeuronRecipe { - stake_e8s: Some(NEURON_MINIMUM_STAKE_E8S), - ..NeuronRecipe::validate_default_neurons_fund() - }; - validate_recipe(&recipe).unwrap(); - } - } - - #[test] - fn test_voting_rewards_parameters_set_to_zero_by_default() { - let parameters = NervousSystemParameters::with_default_values(); - parameters.validate().unwrap(); - let voting_rewards_parameters = parameters.voting_rewards_parameters.unwrap(); - assert_eq!( - voting_rewards_parameters - .initial_reward_rate_basis_points - .unwrap(), - 0 - ); - assert_eq!( - voting_rewards_parameters - .final_reward_rate_basis_points - .unwrap(), - 0 - ); - } - - #[test] - #[should_panic] - fn test_nervous_system_parameters_wont_validate_without_voting_rewards_parameters() { - let mut parameters = NervousSystemParameters::with_default_values(); - parameters.voting_rewards_parameters = None; - // This is where we expect to panic. - parameters.validate().unwrap(); - } - - #[test] - fn test_nervous_system_parameters_wont_validate_without_the_required_claimer_permissions() { - for permission_to_omit in NervousSystemParameters::REQUIRED_NEURON_CLAIMER_PERMISSIONS { - let mut parameters = NervousSystemParameters::with_default_values(); - parameters.neuron_claimer_permissions = Some( - NervousSystemParameters::REQUIRED_NEURON_CLAIMER_PERMISSIONS - .iter() - .filter(|p| *p != permission_to_omit) - .cloned() - .collect::>() - .into(), - ); - parameters.validate().unwrap_err(); - } - } - - #[test] - fn test_validate_logo_lets_base64_through() { - SnsMetadata::validate_logo("data:image/png;base64,aGVsbG8gZnJvbSBkZmluaXR5IQ==").unwrap(); - } - - #[test] - fn test_validate_logo_doesnt_let_non_base64_through() { - // `_` is not in the base64 character set we're using - // so we should panic here. - SnsMetadata::validate_logo("data:image/png;base64,aGVsbG8gZnJvbSBkZmluaXR5IQ==_") - .unwrap_err(); - } - - #[test] - fn test_neuron_permission_list_display_impl() { - let neuron_permission_list = NeuronPermissionList::all(); - assert_eq!( - format!("permissions: {neuron_permission_list}"), - format!("permissions: [Unspecified, ConfigureDissolveState, ManagePrincipals, SubmitProposal, Vote, Disburse, Split, MergeMaturity, DisburseMaturity, StakeMaturity, ManageVotingPermission]") - ); - } - - #[test] - fn test_neuron_permission_list_display_impl_doesnt_panic_unknown_permission() { - let invalid_permission = 10000; - let neuron_permission_list = { - let mut neuron_permission_list = NeuronPermissionList::all(); - neuron_permission_list.permissions.push(invalid_permission); // Add an unknown permission to the list - neuron_permission_list - }; - assert_eq!( - format!("permissions: {neuron_permission_list}"), - format!("permissions: [Unspecified, ConfigureDissolveState, ManagePrincipals, SubmitProposal, Vote, Disburse, Split, MergeMaturity, DisburseMaturity, StakeMaturity, ManageVotingPermission, ]") - ); - } - - mod neuron_recipe_construct_followees_tests { - use super::*; - - #[test] - fn test_direct_participant_empty_followees() { - let [b0] = NeuronId::test_neuron_ids(); - let recipe = NeuronRecipe { - followees: Some(NeuronIds::from(vec![])), - neuron_id: Some(b0.clone()), - ..NeuronRecipe::validate_default_direct_participant() - }; - assert_eq!(recipe.construct_followees(), btreemap! {}); - } - - #[test] - fn test_direct_participant_single_followee() { - let [b0, b1] = NeuronId::test_neuron_ids(); - let w = u64::from(&Action::Unspecified(Empty {})); - let recipe = NeuronRecipe { - followees: Some(NeuronIds::from(vec![b0.clone()])), - neuron_id: Some(b1.clone()), - ..NeuronRecipe::validate_default_direct_participant() - }; - assert_eq!( - recipe.construct_followees(), - btreemap! { w => Followees { followees: vec![b0.clone()] } } - ); - } - - #[test] - fn test_direct_participant_multiple_followees() { - let [b0, b1, b2] = NeuronId::test_neuron_ids(); - let w = u64::from(&Action::Unspecified(Empty {})); - let recipe = NeuronRecipe { - followees: Some(NeuronIds::from(vec![b0.clone(), b1.clone()])), - neuron_id: Some(b2.clone()), - ..NeuronRecipe::validate_default_direct_participant() - }; - assert_eq!( - recipe.construct_followees(), - btreemap! { w => Followees { followees: vec![b0.clone(), b1.clone()] } } - ); - } - - #[test] - fn test_neurons_fund_empty_followees() { - let [b1] = NeuronId::test_neuron_ids(); - let recipe = NeuronRecipe { - followees: Some(NeuronIds::from(vec![])), - neuron_id: Some(b1.clone()), - ..NeuronRecipe::validate_default_neurons_fund() - }; - assert_eq!(recipe.construct_followees(), btreemap! {}); - } - - #[test] - fn test_neurons_fund_single_followee() { - let [b0, b1] = NeuronId::test_neuron_ids(); - let w = u64::from(&Action::Unspecified(Empty {})); - let recipe = NeuronRecipe { - followees: Some(NeuronIds::from(vec![b0.clone()])), - neuron_id: Some(b1.clone()), - ..NeuronRecipe::validate_default_neurons_fund() - }; - assert_eq!( - recipe.construct_followees(), - btreemap! { w => Followees { followees: vec![b0.clone()] } } - ); - } - - #[test] - fn test_neurons_fund_multiple_followees() { - let [b0, b1, b2, b3] = NeuronId::test_neuron_ids(); - let w = u64::from(&Action::Unspecified(Empty {})); - let recipe = NeuronRecipe { - followees: Some(NeuronIds::from(vec![b0.clone(), b1.clone(), b2.clone()])), - neuron_id: Some(b3.clone()), - ..NeuronRecipe::validate_default_neurons_fund() - }; - assert_eq!( - recipe.construct_followees(), - btreemap! { w => Followees { followees: vec![b0.clone(), b1.clone(), b2.clone()] } } - ); - } - } - - #[test] - fn test_summarize_blob_field() { - for len in 0..=64 { - let direct_copy_input = (0..len).collect::>(); - - assert_eq!(summarize_blob_field(&direct_copy_input), direct_copy_input); - } - - let too_long = (0..65).collect::>(); - let result = summarize_blob_field(&too_long); - assert_ne!(result, too_long); - assert!(result.len() > 64, "{:X?}", result); - - let result = String::from_utf8(summarize_blob_field(&too_long)).unwrap(); - assert!( - result.contains("⚠️ NOT THE ORIGINAL CONTENTS OF THIS FIELD ⚠"), - "{:X?}", - result, - ); - assert!(result.contains("00 01 02 03"), "{:X?}", result); - assert!(result.contains("3D 3E 3F 40"), "{:X?}", result); - assert!(result.contains("Length: 65"), "{:X?}", result); - assert!( - // SHA256 - result.contains( - // Independently calculating using Python. - "4B FD 2C 8B 6F 1E EC 7A \ - 2A FE B4 8B 93 4E E4 B2 \ - 69 41 82 02 7E 6D 0F C0 \ - 75 07 4F 2F AB B3 17 81", - ), - "{:X?}", - result, - ); - } - - #[test] - fn test_limited_for_get_proposal() { - let motion_proposal = ProposalData { - proposal: Some(Proposal { - action: Some(Action::Motion(Motion { - motion_text: "Hello, world!".to_string(), - })), - ..Default::default() - }), - ..Default::default() - }; - - assert_eq!(motion_proposal.limited_for_get_proposal(), motion_proposal,); - - let upgrade_sns_controlled_canister_proposal = ProposalData { - proposal: Some(Proposal { - action: Some(Action::UpgradeSnsControlledCanister( - UpgradeSnsControlledCanister { - new_canister_wasm: (0..=255).collect(), - ..Default::default() - }, - )), - ..Default::default() - }), - ..Default::default() - }; - - assert_ne!( - upgrade_sns_controlled_canister_proposal.limited_for_get_proposal(), - upgrade_sns_controlled_canister_proposal, - ); - - let execute_generic_nervous_system_function_proposal = ProposalData { - proposal: Some(Proposal { - action: Some(Action::ExecuteGenericNervousSystemFunction( - ExecuteGenericNervousSystemFunction { - payload: (0..=255).collect(), - ..Default::default() - }, - )), - ..Default::default() - }), - ..Default::default() - }; - - assert_ne!( - execute_generic_nervous_system_function_proposal.limited_for_get_proposal(), - execute_generic_nervous_system_function_proposal, - ); - } -} +mod tests; diff --git a/rs/sns/governance/src/types/tests.rs b/rs/sns/governance/src/types/tests.rs new file mode 100644 index 00000000000..68d731cc512 --- /dev/null +++ b/rs/sns/governance/src/types/tests.rs @@ -0,0 +1,1494 @@ +use super::*; +use crate::pb::v1::{ + claim_swap_neurons_request::neuron_recipe, + governance::Mode::PreInitializationSwap, + nervous_system_function::{FunctionType, GenericNervousSystemFunction}, + neuron::Followees, + ExecuteGenericNervousSystemFunction, Proposal, ProposalData, VotingRewardsParameters, +}; +use futures::FutureExt; +use ic_base_types::PrincipalId; +use ic_management_canister_types::ChunkHash; +use ic_nervous_system_common_test_keys::TEST_USER1_PRINCIPAL; +use ic_nervous_system_proto::pb::v1::Principals; +use lazy_static::lazy_static; +use maplit::{btreemap, hashset}; +use std::convert::TryInto; +use test_helpers::NativeEnvironment; + +#[test] +fn test_voting_period_parameters() { + let non_critical_action = Action::Motion(Default::default()); + let critical_action = Action::TransferSnsTreasuryFunds(Default::default()); + + let normal_nervous_system_parameters = NervousSystemParameters { + initial_voting_period_seconds: Some(4 * ONE_DAY_SECONDS), + wait_for_quiet_deadline_increase_seconds: Some(2 * ONE_DAY_SECONDS), + ..Default::default() + }; + assert_eq!( + non_critical_action.voting_duration_parameters(&normal_nervous_system_parameters), + VotingDurationParameters { + initial_voting_period: PbDuration { + seconds: Some(4 * ONE_DAY_SECONDS), + }, + wait_for_quiet_deadline_increase: PbDuration { + seconds: Some(2 * ONE_DAY_SECONDS), + } + }, + ); + assert_eq!( + critical_action.voting_duration_parameters(&normal_nervous_system_parameters), + VotingDurationParameters { + initial_voting_period: PbDuration { + seconds: Some(5 * ONE_DAY_SECONDS), + }, + wait_for_quiet_deadline_increase: PbDuration { + seconds: Some(2 * ONE_DAY_SECONDS + ONE_DAY_SECONDS / 2), + } + }, + ); + + // This is even slower than the hard-coded values (5 days initial and 2.5 days wait for + // quiet) for critical proposals. Therefore, these values are used for both normal and + // critical proposals. + let slow_nervous_system_parameters = NervousSystemParameters { + initial_voting_period_seconds: Some(7 * ONE_DAY_SECONDS), + wait_for_quiet_deadline_increase_seconds: Some(4 * ONE_DAY_SECONDS), + ..Default::default() + }; + assert_eq!( + non_critical_action.voting_duration_parameters(&slow_nervous_system_parameters), + VotingDurationParameters { + initial_voting_period: PbDuration { + seconds: Some(7 * ONE_DAY_SECONDS), + }, + wait_for_quiet_deadline_increase: PbDuration { + seconds: Some(4 * ONE_DAY_SECONDS), + } + }, + ); + assert_eq!( + critical_action.voting_duration_parameters(&slow_nervous_system_parameters), + VotingDurationParameters { + initial_voting_period: PbDuration { + seconds: Some(7 * ONE_DAY_SECONDS), + }, + wait_for_quiet_deadline_increase: PbDuration { + seconds: Some(4 * ONE_DAY_SECONDS), + } + }, + ); +} + +#[test] +fn test_nervous_system_parameters_validate() { + NervousSystemParameters::with_default_values() + .validate() + .unwrap(); + + let invalid_params = vec![ + NervousSystemParameters { + neuron_minimum_stake_e8s: None, + ..NervousSystemParameters::with_default_values() + }, + NervousSystemParameters { + transaction_fee_e8s: Some(100), + neuron_minimum_stake_e8s: Some(10), + ..NervousSystemParameters::with_default_values() + }, + NervousSystemParameters { + transaction_fee_e8s: None, + ..NervousSystemParameters::with_default_values() + }, + NervousSystemParameters { + max_proposals_to_keep_per_action: None, + ..NervousSystemParameters::with_default_values() + }, + NervousSystemParameters { + max_proposals_to_keep_per_action: Some(0), + ..NervousSystemParameters::with_default_values() + }, + NervousSystemParameters { + max_proposals_to_keep_per_action: Some( + NervousSystemParameters::MAX_PROPOSALS_TO_KEEP_PER_ACTION_CEILING + 1, + ), + ..NervousSystemParameters::with_default_values() + }, + NervousSystemParameters { + initial_voting_period_seconds: None, + ..NervousSystemParameters::with_default_values() + }, + NervousSystemParameters { + initial_voting_period_seconds: Some( + NervousSystemParameters::INITIAL_VOTING_PERIOD_SECONDS_FLOOR - 1, + ), + ..NervousSystemParameters::with_default_values() + }, + NervousSystemParameters { + initial_voting_period_seconds: Some( + NervousSystemParameters::INITIAL_VOTING_PERIOD_SECONDS_CEILING + 1, + ), + ..NervousSystemParameters::with_default_values() + }, + NervousSystemParameters { + default_followees: None, + ..NervousSystemParameters::with_default_values() + }, + NervousSystemParameters { + max_number_of_neurons: None, + ..NervousSystemParameters::with_default_values() + }, + NervousSystemParameters { + max_number_of_neurons: Some(0), + ..NervousSystemParameters::with_default_values() + }, + NervousSystemParameters { + max_number_of_neurons: Some(NervousSystemParameters::MAX_NUMBER_OF_NEURONS_CEILING + 1), + ..NervousSystemParameters::with_default_values() + }, + NervousSystemParameters { + neuron_minimum_dissolve_delay_to_vote_seconds: None, + ..NervousSystemParameters::with_default_values() + }, + NervousSystemParameters { + max_dissolve_delay_seconds: Some(10), + neuron_minimum_dissolve_delay_to_vote_seconds: Some(20), + ..NervousSystemParameters::with_default_values() + }, + NervousSystemParameters { + max_followees_per_function: None, + ..NervousSystemParameters::with_default_values() + }, + NervousSystemParameters { + max_followees_per_function: Some( + NervousSystemParameters::MAX_FOLLOWEES_PER_FUNCTION_CEILING + 1, + ), + ..NervousSystemParameters::with_default_values() + }, + NervousSystemParameters { + max_dissolve_delay_seconds: None, + ..NervousSystemParameters::with_default_values() + }, + NervousSystemParameters { + max_neuron_age_for_age_bonus: None, + ..NervousSystemParameters::with_default_values() + }, + NervousSystemParameters { + max_number_of_proposals_with_ballots: None, + ..NervousSystemParameters::with_default_values() + }, + NervousSystemParameters { + max_number_of_proposals_with_ballots: Some(0), + ..NervousSystemParameters::with_default_values() + }, + NervousSystemParameters { + max_number_of_proposals_with_ballots: Some( + NervousSystemParameters::MAX_NUMBER_OF_PROPOSALS_WITH_BALLOTS_CEILING + 1, + ), + ..NervousSystemParameters::with_default_values() + }, + NervousSystemParameters { + neuron_claimer_permissions: Some(NeuronPermissionList { + permissions: vec![NeuronPermissionType::Vote as i32], + }), + ..NervousSystemParameters::with_default_values() + }, + NervousSystemParameters { + neuron_claimer_permissions: None, + ..NervousSystemParameters::with_default_values() + }, + NervousSystemParameters { + neuron_grantable_permissions: None, + ..NervousSystemParameters::with_default_values() + }, + NervousSystemParameters { + max_number_of_principals_per_neuron: Some(0), + ..NervousSystemParameters::with_default_values() + }, + NervousSystemParameters { + max_number_of_principals_per_neuron: Some(1000), + ..NervousSystemParameters::with_default_values() + }, + NervousSystemParameters { + voting_rewards_parameters: Some(VotingRewardsParameters { + round_duration_seconds: None, + ..Default::default() + }), + ..NervousSystemParameters::with_default_values() + }, + NervousSystemParameters { + max_number_of_principals_per_neuron: Some(4), + ..NervousSystemParameters::with_default_values() + }, + ]; + + for params in invalid_params { + params.validate().unwrap_err(); + } +} + +#[test] +fn test_inherit_from() { + let default_params = NervousSystemParameters::with_default_values(); + + let proposed_params = NervousSystemParameters { + transaction_fee_e8s: Some(124), + max_number_of_neurons: Some(566), + max_number_of_proposals_with_ballots: Some(9801), + default_followees: Some(Default::default()), + + // Set all other fields to None. + ..Default::default() + }; + + let new_params = proposed_params.inherit_from(&default_params); + let expected_params = NervousSystemParameters { + transaction_fee_e8s: Some(124), + max_number_of_neurons: Some(566), + max_number_of_proposals_with_ballots: Some(9801), + default_followees: Some(Default::default()), + ..default_params.clone() + }; + + assert_eq!(new_params, expected_params); + + assert_eq!(new_params.maturity_modulation_disabled, Some(false)); + + let disable_maturity_modulation = NervousSystemParameters { + maturity_modulation_disabled: Some(true), + + // Set all other fields to None. + ..Default::default() + }; + + assert_eq!( + disable_maturity_modulation.inherit_from(&default_params), + NervousSystemParameters { + maturity_modulation_disabled: Some(true), + ..default_params + }, + ); +} + +lazy_static! { + static ref MANAGE_NEURON_COMMANDS: (Vec, Vec, manage_neuron::Command) = { + use manage_neuron::Command; + + #[rustfmt::skip] + let allowed_in_pre_initialization_swap = vec! [ + Command::Follow (Default::default()), + Command::MakeProposal (Default::default()), + Command::RegisterVote (Default::default()), + Command::AddNeuronPermissions (Default::default()), + Command::RemoveNeuronPermissions (Default::default()), + ]; + + #[rustfmt::skip] + let disallowed_in_pre_initialization_swap = vec! [ + Command::Configure (Default::default()), + Command::Disburse (Default::default()), + Command::Split (Default::default()), + Command::MergeMaturity (Default::default()), + Command::DisburseMaturity (Default::default()), + ]; + + // Only the swap canister is allowed to do this in PreInitializationSwap. + let claim_or_refresh = Command::ClaimOrRefresh(Default::default()); + + (allowed_in_pre_initialization_swap, disallowed_in_pre_initialization_swap, claim_or_refresh) + }; +} + +#[should_panic] +#[test] +fn test_mode_allows_manage_neuron_command_or_err_unspecified_kaboom() { + let caller_is_swap_canister = true; + let innocuous_command = &MANAGE_NEURON_COMMANDS.0[0]; + let _clippy = governance::Mode::Unspecified + .allows_manage_neuron_command_or_err(innocuous_command, caller_is_swap_canister); +} + +#[test] +fn test_mode_allows_manage_neuron_command_or_err_normal_is_generally_ok() { + let mut commands = MANAGE_NEURON_COMMANDS.0.clone(); + commands.append(&mut MANAGE_NEURON_COMMANDS.1.clone()); + commands.push(MANAGE_NEURON_COMMANDS.2.clone()); + + for command in commands { + for caller_is_swap_canister in [true, false] { + let result = governance::Mode::Normal + .allows_manage_neuron_command_or_err(&command, caller_is_swap_canister); + assert!(result.is_ok(), "{:#?}", result); + } + } +} + +#[test] +fn test_mode_allows_manage_neuron_command_or_err_pre_initialization_swap_ok() { + let allowed = &MANAGE_NEURON_COMMANDS.0; + for command in allowed { + for caller_is_swap_canister in [true, false] { + let result = PreInitializationSwap + .allows_manage_neuron_command_or_err(command, caller_is_swap_canister); + assert!(result.is_ok(), "{:#?}", result); + } + } +} + +#[test] +fn test_mode_allows_manage_neuron_command_or_err_pre_initialization_swap_verboten() { + let disallowed = &MANAGE_NEURON_COMMANDS.1; + for command in disallowed { + for caller_is_swap_canister in [true, false] { + let result = PreInitializationSwap + .allows_manage_neuron_command_or_err(command, caller_is_swap_canister); + assert!(result.is_err(), "{:#?}", result); + } + } +} + +#[test] +fn test_mode_allows_manage_neuron_command_or_err_pre_initialization_swap_claim_or_refresh() { + let claim_or_refresh = &MANAGE_NEURON_COMMANDS.2; + + let caller_is_swap_canister = false; + let result = PreInitializationSwap + .allows_manage_neuron_command_or_err(claim_or_refresh, caller_is_swap_canister); + assert!(result.is_err(), "{:#?}", result); + + let caller_is_swap_canister = true; + let result = PreInitializationSwap + .allows_manage_neuron_command_or_err(claim_or_refresh, caller_is_swap_canister); + assert!(result.is_ok(), "{:#?}", result); +} + +const ROOT_TARGETING_FUNCTION_ID: u64 = 1001; +const GOVERNANCE_TARGETING_FUNCTION_ID: u64 = 1002; +const LEDGER_TARGETING_FUNCTION_ID: u64 = 1003; +const RANDOM_CANISTER_TARGETING_FUNCTION_ID: u64 = 1004; + +#[rustfmt::skip] +lazy_static! { + static ref ROOT_CANISTER_ID: PrincipalId = [101][..].try_into().unwrap(); + static ref GOVERNANCE_CANISTER_ID: PrincipalId = [102][..].try_into().unwrap(); + static ref LEDGER_CANISTER_ID: PrincipalId = [103][..].try_into().unwrap(); + static ref RANDOM_CANISTER_ID: PrincipalId = [0xDE, 0xAD, 0xBE, 0xEF][..].try_into().unwrap(); + + static ref PROPOSAL_ACTIONS: ( + Vec, // Allowed in PreInitializationSwap. + Vec, // Disallowed in PreInitializationSwap. + Vec, // ExecuteGenericNervousSystemFunction where target is root, governance, or ledger + Action, // ExecuteGenericNervousSystemFunction, but target is not one of the distinguished canisters. + ) = { + let allowed_in_pre_initialization_swap = vec! [ + Action::Motion(Default::default()), + Action::AddGenericNervousSystemFunction(Default::default()), + Action::RemoveGenericNervousSystemFunction(Default::default()), + ]; + + let disallowed_in_pre_initialization_swap = vec! [ + Action::ManageNervousSystemParameters(Default::default()), + Action::TransferSnsTreasuryFunds(Default::default()), + Action::MintSnsTokens(Default::default()), + Action::UpgradeSnsControlledCanister(Default::default()), + Action::RegisterDappCanisters(Default::default()), + Action::DeregisterDappCanisters(Default::default()), + ]; + + // Conditionally allow: No targeting SNS canisters. + fn execute(function_id: u64) -> Action { + Action::ExecuteGenericNervousSystemFunction(ExecuteGenericNervousSystemFunction { + function_id, + ..Default::default() + }) + } + + let target_sns_canister_actions = vec! [ + execute( ROOT_TARGETING_FUNCTION_ID), + execute(GOVERNANCE_TARGETING_FUNCTION_ID), + execute( LEDGER_TARGETING_FUNCTION_ID), + ]; + + let target_random_canister_action = execute(RANDOM_CANISTER_TARGETING_FUNCTION_ID); + + ( + allowed_in_pre_initialization_swap, + disallowed_in_pre_initialization_swap, + target_sns_canister_actions, + target_random_canister_action + ) + }; + + static ref ID_TO_NERVOUS_SYSTEM_FUNCTION: BTreeMap = { + fn new_fn(function_id: u64, target_canister_id: &PrincipalId) -> NervousSystemFunction { + NervousSystemFunction { + id: function_id, + name: "Amaze".to_string(), + description: Some("Best function evar.".to_string()), + function_type: Some(FunctionType::GenericNervousSystemFunction(GenericNervousSystemFunction { + target_canister_id: Some(*target_canister_id), + target_method_name: Some("Foo".to_string()), + validator_canister_id: Some(*target_canister_id), + validator_method_name: Some("Bar".to_string()), + })), + } + } + + vec![ + new_fn( ROOT_TARGETING_FUNCTION_ID, &ROOT_CANISTER_ID), + new_fn( GOVERNANCE_TARGETING_FUNCTION_ID, &GOVERNANCE_CANISTER_ID), + new_fn( LEDGER_TARGETING_FUNCTION_ID, &LEDGER_CANISTER_ID), + new_fn(RANDOM_CANISTER_TARGETING_FUNCTION_ID, &RANDOM_CANISTER_ID), + ] + .into_iter() + .map(|f| (f.id, f)) + .collect() + }; + + static ref DISALLOWED_TARGET_CANISTER_IDS: HashSet = hashset! { + CanisterId::unchecked_from_principal(*ROOT_CANISTER_ID), + CanisterId::unchecked_from_principal(*GOVERNANCE_CANISTER_ID), + CanisterId::unchecked_from_principal(*LEDGER_CANISTER_ID), + }; +} + +#[should_panic] +#[test] +fn test_mode_allows_proposal_action_or_err_unspecified_kaboom() { + let innocuous_action = &PROPOSAL_ACTIONS.0[0]; + let _clippy = governance::Mode::Unspecified.allows_proposal_action_or_err( + innocuous_action, + &DISALLOWED_TARGET_CANISTER_IDS, + &ID_TO_NERVOUS_SYSTEM_FUNCTION, + ); +} + +#[test] +fn test_mode_allows_proposal_action_or_err_normal_is_always_ok() { + // Flatten PROPOSAL_ACTIONS into one big Vec. + let mut actions = PROPOSAL_ACTIONS.0.clone(); + actions.append(&mut PROPOSAL_ACTIONS.1.clone()); + actions.append(&mut PROPOSAL_ACTIONS.2.clone()); + actions.push(PROPOSAL_ACTIONS.3.clone()); + + for action in actions { + let result = governance::Mode::Normal.allows_proposal_action_or_err( + &action, + &DISALLOWED_TARGET_CANISTER_IDS, + &ID_TO_NERVOUS_SYSTEM_FUNCTION, + ); + assert!(result.is_ok(), "{:#?} {:#?}", result, action); + } +} + +#[test] +fn test_mode_allows_proposal_action_or_err_pre_initialization_swap_happy() { + for action in &PROPOSAL_ACTIONS.0 { + let result = PreInitializationSwap.allows_proposal_action_or_err( + action, + &DISALLOWED_TARGET_CANISTER_IDS, + &ID_TO_NERVOUS_SYSTEM_FUNCTION, + ); + assert!(result.is_ok(), "{:#?} {:#?}", result, action); + } +} + +#[test] +fn test_mode_allows_proposal_action_or_err_pre_initialization_swap_sad() { + for action in &PROPOSAL_ACTIONS.1 { + let result = PreInitializationSwap.allows_proposal_action_or_err( + action, + &DISALLOWED_TARGET_CANISTER_IDS, + &ID_TO_NERVOUS_SYSTEM_FUNCTION, + ); + assert!(result.is_err(), "{:#?}", action); + } +} + +#[test] +fn test_mode_allows_proposal_action_or_err_pre_initialization_swap_disallows_targeting_an_sns_canister( +) { + for action in &PROPOSAL_ACTIONS.2 { + let result = PreInitializationSwap.allows_proposal_action_or_err( + action, + &DISALLOWED_TARGET_CANISTER_IDS, + &ID_TO_NERVOUS_SYSTEM_FUNCTION, + ); + assert!(result.is_err(), "{:#?}", action); + } +} + +#[test] +fn test_mode_allows_proposal_action_or_err_pre_initialization_swap_allows_targeting_a_random_canister( +) { + let action = &PROPOSAL_ACTIONS.3; + let result = PreInitializationSwap.allows_proposal_action_or_err( + action, + &DISALLOWED_TARGET_CANISTER_IDS, + &ID_TO_NERVOUS_SYSTEM_FUNCTION, + ); + assert!(result.is_ok(), "{:#?} {:#?}", result, action); +} + +#[test] +fn test_mode_allows_proposal_action_or_err_function_not_found() { + let execute = + Action::ExecuteGenericNervousSystemFunction(ExecuteGenericNervousSystemFunction { + function_id: 0xDEADBEF, + ..Default::default() + }); + + let result = governance::Mode::PreInitializationSwap.allows_proposal_action_or_err( + &execute, + &DISALLOWED_TARGET_CANISTER_IDS, + &ID_TO_NERVOUS_SYSTEM_FUNCTION, + ); + + let err = match result { + Err(err) => err, + Ok(_) => panic!( + "Make proposal is supposed to result in NotFound when \ + it specifies an unknown function ID." + ), + }; + assert_eq!(err.error_type, ErrorType::NotFound as i32, "{:#?}", err); +} + +#[should_panic] +#[test] +fn test_mode_allows_proposal_action_or_err_panic_when_function_has_no_type() { + let function_id = 42; + + let execute = + Action::ExecuteGenericNervousSystemFunction(ExecuteGenericNervousSystemFunction { + function_id, + ..Default::default() + }); + + let mut functions = ID_TO_NERVOUS_SYSTEM_FUNCTION.clone(); + functions.insert( + function_id, + NervousSystemFunction { + id: function_id, + function_type: None, // This is evil. + name: "Toxic".to_string(), + description: None, + }, + ); + + let _unused = governance::Mode::PreInitializationSwap.allows_proposal_action_or_err( + &execute, + &DISALLOWED_TARGET_CANISTER_IDS, + &functions, + ); +} + +#[should_panic] +#[test] +fn test_mode_allows_proposal_action_or_err_panic_when_function_has_no_target_canister_id() { + let function_id = 42; + + let execute = + Action::ExecuteGenericNervousSystemFunction(ExecuteGenericNervousSystemFunction { + function_id, + ..Default::default() + }); + + let mut functions = ID_TO_NERVOUS_SYSTEM_FUNCTION.clone(); + functions.insert( + function_id, + NervousSystemFunction { + id: function_id, + name: "Toxic".to_string(), + description: None, + function_type: Some(FunctionType::GenericNervousSystemFunction( + GenericNervousSystemFunction { + target_canister_id: None, // This is evil. + ..Default::default() + }, + )), + }, + ); + + let _unused = governance::Mode::PreInitializationSwap.allows_proposal_action_or_err( + &execute, + &DISALLOWED_TARGET_CANISTER_IDS, + &functions, + ); +} + +#[test] +fn test_sns_metadata_validate() { + let default = SnsMetadata { + logo: Some("data:image/png;base64,aGVsbG8gZnJvbSBkZmluaXR5IQ==".to_string()), + url: Some("https://forum.dfinity.org".to_string()), + name: Some("X".repeat(SnsMetadata::MIN_NAME_LENGTH)), + description: Some("X".repeat(SnsMetadata::MIN_DESCRIPTION_LENGTH)), + }; + + let valid_sns_metadata = vec![ + default.clone(), + SnsMetadata { + url: Some("https://forum.dfinity.org/foo/bar/?".to_string()), + ..default.clone() + }, + SnsMetadata { + url: Some("https://forum.dfinity.org/foo/bar/?".to_string()), + ..default.clone() + }, + SnsMetadata { + url: Some("https://any-url.com/foo/bar/?".to_string()), + ..default.clone() + }, + ]; + + let invalid_sns_metadata = vec![ + SnsMetadata { + name: None, + ..default.clone() + }, + SnsMetadata { + name: Some("X".repeat(SnsMetadata::MAX_NAME_LENGTH + 1)), + ..default.clone() + }, + SnsMetadata { + name: Some("X".repeat(SnsMetadata::MIN_NAME_LENGTH - 1)), + ..default.clone() + }, + SnsMetadata { + description: None, + ..default.clone() + }, + SnsMetadata { + description: Some("X".repeat(SnsMetadata::MAX_DESCRIPTION_LENGTH + 1)), + ..default.clone() + }, + SnsMetadata { + description: Some("X".repeat(SnsMetadata::MIN_DESCRIPTION_LENGTH - 1)), + ..default.clone() + }, + SnsMetadata { + logo: Some("X".repeat(MAX_LOGO_LENGTH + 1)), + ..default.clone() + }, + SnsMetadata { + url: None, + ..default.clone() + }, + SnsMetadata { + url: Some("X".repeat(SnsMetadata::MAX_URL_LENGTH + 1)), + ..default.clone() + }, + SnsMetadata { + url: Some("X".to_string()), + ..default.clone() + }, + SnsMetadata { + url: Some("X".repeat(SnsMetadata::MIN_URL_LENGTH - 1)), + ..default.clone() + }, + SnsMetadata { + url: Some("file://forum.dfinity.org".to_string()), + ..default.clone() + }, + SnsMetadata { + url: Some("https://".to_string()), + ..default.clone() + }, + SnsMetadata { + url: Some("https://forum.dfinity.org/https://forum.dfinity.org".to_string()), + ..default.clone() + }, + SnsMetadata { + url: Some("https://example@forum.dfinity.org".to_string()), + ..default.clone() + }, + SnsMetadata { + url: Some("http://internetcomputer".to_string()), + ..default.clone() + }, + SnsMetadata { + url: Some("mailto:example@internetcomputer.org".to_string()), + ..default.clone() + }, + SnsMetadata { + url: Some("internetcomputer".to_string()), + ..default + }, + ]; + + for sns_metadata in invalid_sns_metadata { + if sns_metadata.validate().is_ok() { + panic!("Invalid metadata passed validation: {:?}", sns_metadata); + } + } + + for sns_metadata in valid_sns_metadata { + if sns_metadata.validate().is_err() { + panic!("Valid metadata failed validation: {:?}", sns_metadata); + } + } +} + +impl NeuronRecipe { + fn validate_default_direct_participant() -> Self { + Self { + controller: Some(*TEST_USER1_PRINCIPAL), + neuron_id: Some(NeuronId::new_test_neuron_id(0)), + stake_e8s: Some(E8S_PER_TOKEN), + dissolve_delay_seconds: Some(3 * ONE_MONTH_SECONDS), + followees: Some(NeuronIds::from(vec![NeuronId::new_test_neuron_id(1)])), + participant: Some(Participant::Direct(neuron_recipe::Direct {})), + } + } + + fn validate_default_neurons_fund() -> Self { + Self { + controller: Some(PrincipalId::from(ic_nns_constants::GOVERNANCE_CANISTER_ID)), + neuron_id: Some(NeuronId::new_test_neuron_id(0)), + stake_e8s: Some(E8S_PER_TOKEN), + dissolve_delay_seconds: Some(3 * ONE_MONTH_SECONDS), + followees: Some(NeuronIds::from(vec![NeuronId::new_test_neuron_id(1)])), + participant: Some(Participant::NeuronsFund(neuron_recipe::NeuronsFund { + nns_neuron_id: Some(2), + nns_neuron_controller: Some(PrincipalId::new_user_test_id(13847)), + nns_neuron_hotkeys: Some(Principals::from(vec![ + PrincipalId::new_user_test_id(13848), + PrincipalId::new_user_test_id(13849), + ])), + })), + } + } +} + +mod neuron_recipe_validate_tests { + use super::*; + + const NEURON_MINIMUM_STAKE_E8S: u64 = E8S_PER_TOKEN; + const MAX_FOLLOWEES_PER_FUNCTION: u64 = 1; + const MAX_NUMBER_OF_PRINCIPALS_PER_NEURON: u64 = 5; + + fn validate_recipe(recipe: &NeuronRecipe) -> Result<(), String> { + recipe.validate( + NEURON_MINIMUM_STAKE_E8S, + MAX_FOLLOWEES_PER_FUNCTION, + MAX_NUMBER_OF_PRINCIPALS_PER_NEURON, + ) + } + + #[test] + fn test_default_direct_participant_is_valid() { + validate_recipe(&NeuronRecipe::validate_default_direct_participant()).unwrap(); + } + + #[test] + fn test_default_neurons_fund_is_valid() { + validate_recipe(&NeuronRecipe::validate_default_neurons_fund()).unwrap(); + } + + #[test] + fn test_invalid_missing_controller() { + let recipe = NeuronRecipe { + controller: None, + ..NeuronRecipe::validate_default_direct_participant() + }; + validate_recipe(&recipe).unwrap_err(); + } + + #[test] + fn test_invalid_missing_neuron_id() { + let recipe = NeuronRecipe { + neuron_id: None, + ..NeuronRecipe::validate_default_direct_participant() + }; + validate_recipe(&recipe).unwrap_err(); + } + + #[test] + fn test_invalid_missing_stake() { + let recipe = NeuronRecipe { + stake_e8s: None, + ..NeuronRecipe::validate_default_direct_participant() + }; + validate_recipe(&recipe).unwrap_err(); + } + + #[test] + fn test_invalid_low_stake() { + let recipe = NeuronRecipe { + stake_e8s: Some(NEURON_MINIMUM_STAKE_E8S - 1), + ..NeuronRecipe::validate_default_direct_participant() + }; + validate_recipe(&recipe).unwrap_err(); + } + + #[test] + fn test_invalid_missing_dissolve_delay() { + let recipe = NeuronRecipe { + dissolve_delay_seconds: None, + ..NeuronRecipe::validate_default_direct_participant() + }; + validate_recipe(&recipe).unwrap_err(); + } + + #[test] + fn test_invalid_missing_followees() { + let recipe = NeuronRecipe { + followees: None, + ..NeuronRecipe::validate_default_direct_participant() + }; + validate_recipe(&recipe).unwrap_err(); + } + + #[test] + fn test_invalid_too_many_followees() { + let recipe = NeuronRecipe { + followees: Some(NeuronIds::from(vec![ + NeuronId::new_test_neuron_id(1), + NeuronId::new_test_neuron_id(2), + ])), + ..NeuronRecipe::validate_default_direct_participant() + }; + validate_recipe(&recipe).unwrap_err(); + } + + #[test] + fn test_invalid_missing_participant() { + let recipe = NeuronRecipe { + participant: None, + ..NeuronRecipe::validate_default_direct_participant() + }; + validate_recipe(&recipe).unwrap_err(); + } + + #[test] + fn test_invalid_neurons_fund_missing_nns_neuron_id() { + let recipe = NeuronRecipe { + participant: Some(Participant::NeuronsFund(neuron_recipe::NeuronsFund { + nns_neuron_id: None, + nns_neuron_controller: Some(PrincipalId::new_user_test_id(13847)), + nns_neuron_hotkeys: Some(Principals::from(vec![PrincipalId::new_user_test_id( + 13848, + )])), + })), + ..NeuronRecipe::validate_default_neurons_fund() + }; + validate_recipe(&recipe).unwrap_err(); + } + + #[test] + fn test_invalid_neurons_fund_missing_controller() { + let recipe = NeuronRecipe { + participant: Some(Participant::NeuronsFund(neuron_recipe::NeuronsFund { + nns_neuron_id: Some(2), + nns_neuron_controller: None, + nns_neuron_hotkeys: Some(Principals::from(vec![PrincipalId::new_user_test_id( + 13848, + )])), + })), + ..NeuronRecipe::validate_default_neurons_fund() + }; + validate_recipe(&recipe).unwrap_err(); + } + + #[test] + fn test_invalid_neurons_fund_missing_hotkeys() { + let recipe = NeuronRecipe { + participant: Some(Participant::NeuronsFund(neuron_recipe::NeuronsFund { + nns_neuron_id: Some(2), + nns_neuron_controller: Some(PrincipalId::new_user_test_id(13847)), + nns_neuron_hotkeys: None, + })), + ..NeuronRecipe::validate_default_neurons_fund() + }; + validate_recipe(&recipe).unwrap_err(); + } + + #[test] + fn test_invalid_neurons_fund_too_many_hotkeys() { + let recipe = NeuronRecipe { + participant: Some(Participant::NeuronsFund(neuron_recipe::NeuronsFund { + nns_neuron_id: Some(2), + nns_neuron_controller: Some(PrincipalId::new_user_test_id(13847)), + nns_neuron_hotkeys: Some(Principals::from(vec![ + PrincipalId::new_user_test_id(13848), + PrincipalId::new_user_test_id(13849), + PrincipalId::new_user_test_id(13810), + PrincipalId::new_user_test_id(13811), + PrincipalId::new_user_test_id(13812), + ])), + })), + ..NeuronRecipe::validate_default_neurons_fund() + }; + validate_recipe(&recipe).unwrap_err(); + } + + #[test] + fn test_valid_zero_dissolve_delay() { + let recipe = NeuronRecipe { + dissolve_delay_seconds: Some(0), + ..NeuronRecipe::validate_default_direct_participant() + }; + validate_recipe(&recipe).unwrap(); + } + + #[test] + fn test_valid_empty_followees() { + let recipe = NeuronRecipe { + followees: Some(NeuronIds::from(vec![])), + ..NeuronRecipe::validate_default_direct_participant() + }; + validate_recipe(&recipe).unwrap(); + } + + #[test] + fn test_valid_minimum_stake() { + let recipe = NeuronRecipe { + stake_e8s: Some(NEURON_MINIMUM_STAKE_E8S), + ..NeuronRecipe::validate_default_neurons_fund() + }; + validate_recipe(&recipe).unwrap(); + } +} + +#[test] +fn test_voting_rewards_parameters_set_to_zero_by_default() { + let parameters = NervousSystemParameters::with_default_values(); + parameters.validate().unwrap(); + let voting_rewards_parameters = parameters.voting_rewards_parameters.unwrap(); + assert_eq!( + voting_rewards_parameters + .initial_reward_rate_basis_points + .unwrap(), + 0 + ); + assert_eq!( + voting_rewards_parameters + .final_reward_rate_basis_points + .unwrap(), + 0 + ); +} + +#[test] +#[should_panic] +fn test_nervous_system_parameters_wont_validate_without_voting_rewards_parameters() { + let mut parameters = NervousSystemParameters::with_default_values(); + parameters.voting_rewards_parameters = None; + // This is where we expect to panic. + parameters.validate().unwrap(); +} + +#[test] +fn test_nervous_system_parameters_wont_validate_without_the_required_claimer_permissions() { + for permission_to_omit in NervousSystemParameters::REQUIRED_NEURON_CLAIMER_PERMISSIONS { + let mut parameters = NervousSystemParameters::with_default_values(); + parameters.neuron_claimer_permissions = Some( + NervousSystemParameters::REQUIRED_NEURON_CLAIMER_PERMISSIONS + .iter() + .filter(|p| *p != permission_to_omit) + .cloned() + .collect::>() + .into(), + ); + parameters.validate().unwrap_err(); + } +} + +#[test] +fn test_validate_logo_lets_base64_through() { + SnsMetadata::validate_logo("data:image/png;base64,aGVsbG8gZnJvbSBkZmluaXR5IQ==").unwrap(); +} + +#[test] +fn test_validate_logo_doesnt_let_non_base64_through() { + // `_` is not in the base64 character set we're using + // so we should panic here. + SnsMetadata::validate_logo("data:image/png;base64,aGVsbG8gZnJvbSBkZmluaXR5IQ==_").unwrap_err(); +} + +#[test] +fn test_neuron_permission_list_display_impl() { + let neuron_permission_list = NeuronPermissionList::all(); + assert_eq!( + format!("permissions: {neuron_permission_list}"), + format!("permissions: [Unspecified, ConfigureDissolveState, ManagePrincipals, SubmitProposal, Vote, Disburse, Split, MergeMaturity, DisburseMaturity, StakeMaturity, ManageVotingPermission]") + ); +} + +#[test] +fn test_neuron_permission_list_display_impl_doesnt_panic_unknown_permission() { + let invalid_permission = 10000; + let neuron_permission_list = { + let mut neuron_permission_list = NeuronPermissionList::all(); + neuron_permission_list.permissions.push(invalid_permission); // Add an unknown permission to the list + neuron_permission_list + }; + assert_eq!( + format!("permissions: {neuron_permission_list}"), + format!("permissions: [Unspecified, ConfigureDissolveState, ManagePrincipals, SubmitProposal, Vote, Disburse, Split, MergeMaturity, DisburseMaturity, StakeMaturity, ManageVotingPermission, ]") + ); +} + +mod neuron_recipe_construct_followees_tests { + use super::*; + + #[test] + fn test_direct_participant_empty_followees() { + let [b0] = NeuronId::test_neuron_ids(); + let recipe = NeuronRecipe { + followees: Some(NeuronIds::from(vec![])), + neuron_id: Some(b0.clone()), + ..NeuronRecipe::validate_default_direct_participant() + }; + assert_eq!(recipe.construct_followees(), btreemap! {}); + } + + #[test] + fn test_direct_participant_single_followee() { + let [b0, b1] = NeuronId::test_neuron_ids(); + let w = u64::from(&Action::Unspecified(Empty {})); + let recipe = NeuronRecipe { + followees: Some(NeuronIds::from(vec![b0.clone()])), + neuron_id: Some(b1.clone()), + ..NeuronRecipe::validate_default_direct_participant() + }; + assert_eq!( + recipe.construct_followees(), + btreemap! { w => Followees { followees: vec![b0.clone()] } } + ); + } + + #[test] + fn test_direct_participant_multiple_followees() { + let [b0, b1, b2] = NeuronId::test_neuron_ids(); + let w = u64::from(&Action::Unspecified(Empty {})); + let recipe = NeuronRecipe { + followees: Some(NeuronIds::from(vec![b0.clone(), b1.clone()])), + neuron_id: Some(b2.clone()), + ..NeuronRecipe::validate_default_direct_participant() + }; + assert_eq!( + recipe.construct_followees(), + btreemap! { w => Followees { followees: vec![b0.clone(), b1.clone()] } } + ); + } + + #[test] + fn test_neurons_fund_empty_followees() { + let [b1] = NeuronId::test_neuron_ids(); + let recipe = NeuronRecipe { + followees: Some(NeuronIds::from(vec![])), + neuron_id: Some(b1.clone()), + ..NeuronRecipe::validate_default_neurons_fund() + }; + assert_eq!(recipe.construct_followees(), btreemap! {}); + } + + #[test] + fn test_neurons_fund_single_followee() { + let [b0, b1] = NeuronId::test_neuron_ids(); + let w = u64::from(&Action::Unspecified(Empty {})); + let recipe = NeuronRecipe { + followees: Some(NeuronIds::from(vec![b0.clone()])), + neuron_id: Some(b1.clone()), + ..NeuronRecipe::validate_default_neurons_fund() + }; + assert_eq!( + recipe.construct_followees(), + btreemap! { w => Followees { followees: vec![b0.clone()] } } + ); + } + + #[test] + fn test_neurons_fund_multiple_followees() { + let [b0, b1, b2, b3] = NeuronId::test_neuron_ids(); + let w = u64::from(&Action::Unspecified(Empty {})); + let recipe = NeuronRecipe { + followees: Some(NeuronIds::from(vec![b0.clone(), b1.clone(), b2.clone()])), + neuron_id: Some(b3.clone()), + ..NeuronRecipe::validate_default_neurons_fund() + }; + assert_eq!( + recipe.construct_followees(), + btreemap! { w => Followees { followees: vec![b0.clone(), b1.clone(), b2.clone()] } } + ); + } +} + +#[test] +fn test_summarize_blob_field() { + for len in 0..=64 { + let direct_copy_input = (0..len).collect::>(); + + assert_eq!(summarize_blob_field(&direct_copy_input), direct_copy_input); + } + + let too_long = (0..65).collect::>(); + let result = summarize_blob_field(&too_long); + assert_ne!(result, too_long); + assert!(result.len() > 64, "{:X?}", result); + + let result = String::from_utf8(summarize_blob_field(&too_long)).unwrap(); + assert!( + result.contains("⚠️ NOT THE ORIGINAL CONTENTS OF THIS FIELD ⚠"), + "{:X?}", + result, + ); + assert!(result.contains("00 01 02 03"), "{:X?}", result); + assert!(result.contains("3D 3E 3F 40"), "{:X?}", result); + assert!(result.contains("Length: 65"), "{:X?}", result); + assert!( + // SHA256 + result.contains( + // Independently calculating using Python. + "4B FD 2C 8B 6F 1E EC 7A \ + 2A FE B4 8B 93 4E E4 B2 \ + 69 41 82 02 7E 6D 0F C0 \ + 75 07 4F 2F AB B3 17 81", + ), + "{:X?}", + result, + ); +} + +#[test] +fn test_limited_for_get_proposal() { + let motion_proposal = ProposalData { + proposal: Some(Proposal { + action: Some(Action::Motion(Motion { + motion_text: "Hello, world!".to_string(), + })), + ..Default::default() + }), + ..Default::default() + }; + + assert_eq!(motion_proposal.limited_for_get_proposal(), motion_proposal,); + + let upgrade_sns_controlled_canister_proposal = ProposalData { + proposal: Some(Proposal { + action: Some(Action::UpgradeSnsControlledCanister( + UpgradeSnsControlledCanister { + new_canister_wasm: (0..=255).collect(), + ..Default::default() + }, + )), + ..Default::default() + }), + ..Default::default() + }; + + assert_ne!( + upgrade_sns_controlled_canister_proposal.limited_for_get_proposal(), + upgrade_sns_controlled_canister_proposal, + ); + + let execute_generic_nervous_system_function_proposal = ProposalData { + proposal: Some(Proposal { + action: Some(Action::ExecuteGenericNervousSystemFunction( + ExecuteGenericNervousSystemFunction { + payload: (0..=255).collect(), + ..Default::default() + }, + )), + ..Default::default() + }), + ..Default::default() + }; + + assert_ne!( + execute_generic_nervous_system_function_proposal.limited_for_get_proposal(), + execute_generic_nervous_system_function_proposal, + ); +} + +#[test] +fn test_validate_chunked_wasm_happy() { + let store_canister_id = CanisterId::unchecked_from_principal(PrincipalId::new_user_test_id(42)); + + let mut env = NativeEnvironment::new(None); + let arg = Encode!(&CanisterIdRecord::from(store_canister_id)).unwrap(); + let response = Ok(Encode!(&StoredChunksReply(vec![ + ChunkHash { + hash: vec![4, 4, 4] + }, + ChunkHash { + hash: vec![2, 2, 2] + }, + ChunkHash { + hash: vec![3, 3, 3] + }, + ChunkHash { + hash: vec![1, 1, 1] + }, + ])) + .unwrap()); + env.set_call_canister_response(CanisterId::ic_00(), "stored_chunks", arg, response); + + let wasm_module_hash = vec![1, 2, 3]; + + let chunk_hashes_list = vec![vec![1, 1, 1], vec![2, 2, 2], vec![3, 3, 3]]; + + assert_eq!( + validate_chunked_wasm( + &env, + &wasm_module_hash, + store_canister_id, + &chunk_hashes_list + ) + .now_or_never() + .unwrap(), + Ok(()), + ); +} + +// TODO[NNS1-3550]: Enable stored chunks validation on mainnet. +#[cfg(feature = "test")] +#[test] +fn test_validate_chunked_wasm_not_uploaded_some_chunks() { + let store_canister_id = CanisterId::unchecked_from_principal(PrincipalId::new_user_test_id(42)); + + let mut env = NativeEnvironment::new(None); + let arg = Encode!(&CanisterIdRecord::from(store_canister_id)).unwrap(); + let response = Ok(Encode!(&StoredChunksReply(vec![ + ChunkHash { + hash: vec![4, 4, 4] + }, + ChunkHash { + hash: vec![2, 2, 2] + }, + ChunkHash { + hash: vec![3, 3, 3] + }, + ChunkHash { + hash: vec![1, 1, 1] + }, + ])) + .unwrap()); + env.set_call_canister_response(CanisterId::ic_00(), "stored_chunks", arg, response); + + let wasm_module_hash = vec![1, 2, 3]; + + let chunk_hashes_list = vec![ + vec![1, 1, 1], + // The problem is here. + vec![3, 2, 1], + vec![3, 3, 3], + ]; + + assert_eq!( + validate_chunked_wasm( + &env, + &wasm_module_hash, + store_canister_id, + &chunk_hashes_list + ) + .now_or_never() + .unwrap(), + Err(vec![ + "1 out of 3 expected WASM chunks were not uploaded to the store canister: 030201" + .to_string() + ]), + ); +} + +#[test] +fn test_validate_chunked_wasm_one_chunk_happy() { + let store_canister_id = CanisterId::unchecked_from_principal(PrincipalId::new_user_test_id(42)); + + let mut env = NativeEnvironment::new(None); + let arg = Encode!(&CanisterIdRecord::from(store_canister_id)).unwrap(); + let response = Ok(Encode!(&StoredChunksReply(vec![ + ChunkHash { + hash: vec![4, 4, 4] + }, + ChunkHash { + hash: vec![1, 2, 3] + }, + ChunkHash { + hash: vec![3, 3, 3] + }, + ChunkHash { + hash: vec![1, 1, 1] + }, + ])) + .unwrap()); + env.set_call_canister_response(CanisterId::ic_00(), "stored_chunks", arg, response); + + let wasm_module_hash = vec![1, 2, 3]; + + let chunk_hashes_list = vec![vec![1, 2, 3]]; + + assert_eq!( + validate_chunked_wasm( + &env, + &wasm_module_hash, + store_canister_id, + &chunk_hashes_list + ) + .now_or_never() + .unwrap(), + Ok(()), + ); +} + +#[test] +fn test_validate_chunked_wasm_one_chunk_hash_mismatch() { + let store_canister_id = CanisterId::unchecked_from_principal(PrincipalId::new_user_test_id(42)); + + let mut env = NativeEnvironment::new(None); + let arg = Encode!(&CanisterIdRecord::from(store_canister_id)).unwrap(); + let response = Ok(Encode!(&StoredChunksReply(vec![ + ChunkHash { + hash: vec![4, 4, 4] + }, + ChunkHash { + hash: vec![2, 2, 2] + }, + ChunkHash { + hash: vec![3, 3, 3] + }, + ChunkHash { + hash: vec![1, 1, 1] + }, + ])) + .unwrap()); + env.set_call_canister_response(CanisterId::ic_00(), "stored_chunks", arg, response); + + // The issue is here. + let wasm_module_hash = vec![1, 2, 3]; + let chunk_hashes_list = vec![vec![2, 2, 2]]; + + assert_eq!( + validate_chunked_wasm( + &env, + &wasm_module_hash, + store_canister_id, + &chunk_hashes_list + ) + .now_or_never() + .unwrap(), + Err(vec![ + "chunked_canister_wasm.chunk_hashes_list specifies only one hash (020202), but it \ + differs from chunked_canister_wasm.wasm_module_hash (010203)." + .to_string() + ]), + ); +} + +#[test] +fn test_validate_chunked_wasm_chunk_hashes_list_empty() { + let store_canister_id = CanisterId::unchecked_from_principal(PrincipalId::new_user_test_id(42)); + + let mut env = NativeEnvironment::new(None); + let arg = Encode!(&CanisterIdRecord::from(store_canister_id)).unwrap(); + let response = Ok(Encode!(&StoredChunksReply(vec![ + ChunkHash { + hash: vec![4, 4, 4] + }, + ChunkHash { + hash: vec![1, 2, 3] + }, + ChunkHash { + hash: vec![3, 3, 3] + }, + ChunkHash { + hash: vec![1, 1, 1] + }, + ])) + .unwrap()); + env.set_call_canister_response(CanisterId::ic_00(), "stored_chunks", arg, response); + + let wasm_module_hash = vec![1, 2, 3]; + + // The issue is here. + let chunk_hashes_list = vec![]; + + assert_eq!( + validate_chunked_wasm( + &env, + &wasm_module_hash, + store_canister_id, + &chunk_hashes_list + ) + .now_or_never() + .unwrap(), + Err(vec![ + "chunked_canister_wasm.chunk_hashes_list cannot be empty.".to_string() + ]), + ); +} + +// TODO[NNS1-3550]: Enable stored chunks validation on mainnet. +#[cfg(feature = "test")] +#[test] +fn test_validate_chunked_wasm_management_canister_call_fails() { + let store_canister_id = CanisterId::unchecked_from_principal(PrincipalId::new_user_test_id(42)); + + let mut env = NativeEnvironment::new(None); + let arg = Encode!(&CanisterIdRecord::from(store_canister_id)).unwrap(); + + // This is the problem. + let response = Err((Some(404), "No such canister".to_string())); + env.set_call_canister_response(CanisterId::ic_00(), "stored_chunks", arg, response); + + let wasm_module_hash = vec![1, 2, 3]; + + // The issue is here. + let chunk_hashes_list = vec![vec![1, 2, 3]]; + + assert_eq!( + validate_chunked_wasm( + &env, + &wasm_module_hash, + store_canister_id, + &chunk_hashes_list + ) + .now_or_never() + .unwrap(), + Err(vec![format!( + "Cannot call stored_chunks for {}: (Some(404), \"No such canister\")", + store_canister_id + )]), + ); +} + +// TODO[NNS1-3550]: Enable stored chunks validation on mainnet. +#[cfg(feature = "test")] +#[test] +fn test_validate_chunked_wasm_management_canister_call_returns_junk() { + let store_canister_id = CanisterId::unchecked_from_principal(PrincipalId::new_user_test_id(42)); + + let mut env = NativeEnvironment::new(None); + + // This is causing the problem (incorrect response type `PrincipalId` / `CanisterIdRecord`). + let arg = Encode!(&PrincipalId::new_user_test_id(888)).unwrap(); + + let response = Ok(Encode!(&StoredChunksReply(vec![ChunkHash { + hash: vec![1, 2, 3] + },])) + .unwrap()); + env.set_call_canister_response(CanisterId::ic_00(), "stored_chunks", arg, response); + + let wasm_module_hash = vec![1, 2, 3]; + + // The issue is here. + let chunk_hashes_list = vec![vec![1, 2, 3]]; + + assert_eq!( + validate_chunked_wasm( + &env, + &wasm_module_hash, + store_canister_id, + &chunk_hashes_list + ) + .now_or_never() + .unwrap(), + Err(vec![format!( + "Cannot decode response from calling stored_chunks for {}: Cannot parse header ", + store_canister_id + )]), + ); +} diff --git a/rs/sns/governance/unreleased_changelog.md b/rs/sns/governance/unreleased_changelog.md index 94126a0ff42..374eaa23868 100644 --- a/rs/sns/governance/unreleased_changelog.md +++ b/rs/sns/governance/unreleased_changelog.md @@ -9,6 +9,14 @@ on the process that this file is part of, see ## Added +Enable upgrading SNS-controlled canisters using chunked WASMs. This is implemented as an extension +of the existing `UpgradeSnsControllerCanister` proposal type with new field `chunked_canister_wasm`. +This field can be used for specifying an upgrade of an SNS-controlled *target* canister using +a potentially large WASM module (over 2 MiB) uploaded to some *store* canister, which: +* must be installed on the same subnet as target. +* must have SNS Root as one of its controllers. +* must have enough cycles for performing the upgrade. + ## Changed ## Deprecated diff --git a/rs/sns/integration_tests/src/upgrade_canister.rs b/rs/sns/integration_tests/src/upgrade_canister.rs index 7fd01998880..2eb886dc4a6 100644 --- a/rs/sns/integration_tests/src/upgrade_canister.rs +++ b/rs/sns/integration_tests/src/upgrade_canister.rs @@ -135,6 +135,7 @@ fn test_upgrade_canister_proposal_is_successful() { canister_upgrade_arg: Some(wasm().set_global_data(&[42]).build()), // mode: None corresponds to CanisterInstallModeProto::Upgrade mode: None, + chunked_canister_wasm: None, }, )), ..Default::default() @@ -261,6 +262,7 @@ fn test_upgrade_canister_proposal_reinstall() { new_canister_wasm: new_dapp_wasm, canister_upgrade_arg: Some(wasm().build()), mode: Some(CanisterInstallModeProto::Reinstall.into()), + chunked_canister_wasm: None, }, )), ..Default::default() @@ -418,6 +420,7 @@ fn test_upgrade_canister_proposal_execution_fail() { new_canister_wasm: new_dapp_wasm, canister_upgrade_arg: None, mode: Some(CanisterInstallModeProto::Upgrade.into()), + chunked_canister_wasm: None, }, )), ..Default::default() @@ -520,6 +523,7 @@ fn test_upgrade_canister_proposal_too_large() { canister_upgrade_arg: Some(wasm().set_global_data(&[42; 2_000_000]).build()), // mode: None corresponds to CanisterInstallModeProto::Upgrade mode: None, + chunked_canister_wasm: None, }, )), ..Default::default() @@ -641,6 +645,7 @@ fn test_upgrade_after_state_shrink() { new_canister_wasm: governance_wasm, canister_upgrade_arg: None, mode: Some(CanisterInstallModeProto::Upgrade.into()), + chunked_canister_wasm: None, }, )), ..Default::default()