diff --git a/rs/nns/governance/src/governance.rs b/rs/nns/governance/src/governance.rs index 038ef01aa4e..573127ea634 100644 --- a/rs/nns/governance/src/governance.rs +++ b/rs/nns/governance/src/governance.rs @@ -1040,18 +1040,66 @@ impl ProposalData { return self.open_sns_token_swap_can_be_purged(); } + if let Some(Action::CreateServiceNervousSystem(_)) = + self.proposal.as_ref().and_then(|p| p.action.as_ref()) + { + return self.create_service_nervous_system_can_be_purged(); + } + true } // Precondition: action must be OpenSnsTokenSwap (behavior is undefined otherwise). // - // The idea here is that we must wait until Community Fund participation has + // The idea here is that we must wait until Neurons' Fund participation has // been settled (part of swap finalization), because in that case, we are - // holding CF participation in escrow. + // holding NF participation in escrow. // - // We can tell whether CF participation settlement has been taken care of by + // We can tell whether NF participation settlement has been taken care of by // looking at the sns_token_swap_lifecycle field. fn open_sns_token_swap_can_be_purged(&self) -> bool { + match self.status() { + ProposalStatus::Rejected => { + // Because nothing has been taken from the neurons' fund yet (and never + // will). We handle this specially, because in this case, + // sns_token_swap_lifecycle will be None, which is later treated as not + // terminal. + true + } + + ProposalStatus::Failed => { + // Because because maturity is refunded to the Neurons' Fund before setting + // execution status to failed. + true + } + + ProposalStatus::Executed => { + // Need to wait for settle_community_fund_participation. + self.sns_token_swap_lifecycle + .and_then(Lifecycle::from_i32) + .unwrap_or(Lifecycle::Unspecified) + .is_terminal() + } + + status => { + println!( + "{}WARNING: Proposal status unexpectedly {:?}. self={:#?}", + LOG_PREFIX, status, self, + ); + false + } + } + } + + // Precondition: action must be CreateServiceNervousSystem (behavior is undefined otherwise). + // + // The idea here is that we must wait until Neurons' Fund participation has + // been settled (part of swap finalization), because in that case, we are + // holding NF participation in escrow. + // + // We can tell whether NF participation settlement has been taken care of by + // looking at the sns_token_swap_lifecycle field. + fn create_service_nervous_system_can_be_purged(&self) -> bool { match self.status() { ProposalStatus::Rejected => { // Because nothing has been taken from the community fund yet (and never @@ -1070,8 +1118,8 @@ impl ProposalData { ProposalStatus::Executed => { // Need to wait for settle_community_fund_participation. self.sns_token_swap_lifecycle - .and_then(sns_swap_pb::Lifecycle::from_i32) - .unwrap_or(sns_swap_pb::Lifecycle::Unspecified) + .and_then(Lifecycle::from_i32) + .unwrap_or(Lifecycle::Unspecified) .is_terminal() } @@ -4628,6 +4676,27 @@ impl Governance { }, ); + // Record the maturity deductions that we just made. + match self.proto.proposals.get_mut(&proposal_id) { + Some(proposal_data) => { + proposal_data.cf_participants = neurons_fund_participants.clone(); + } + None => { + let failed_refunds = refund_community_fund_maturity( + &mut self.proto.neurons, + &neurons_fund_participants, + ); + return Err(GovernanceError::new_with_message( + ErrorType::NotFound, + format!( + "CreateServiceNervousSystem proposal {} not found while trying to execute it. \ + CreateServiceNervousSystem = {:#?}. failed_refunds = {:#?}", + proposal_id, create_service_nervous_system, failed_refunds, + ), + )); + } + } + let executed_create_service_nervous_system_proposal = ExecutedCreateServiceNervousSystemProposal { current_timestamp_seconds, @@ -4680,7 +4749,8 @@ impl Governance { // Step 1: Convert proposal into main request object. let sns_init_payload = - match SnsInitPayload::try_from(executed_create_service_nervous_system_proposal) { + match SnsInitPayload::try_from(executed_create_service_nervous_system_proposal.clone()) + { Ok(ok) => ok, Err(err) => { return Err(GovernanceError::new_with_message( @@ -4756,14 +4826,27 @@ impl Governance { }; if deploy_new_sns_response.error.is_some() { + let failed_refunds = refund_community_fund_maturity( + &mut self.proto.neurons, + &executed_create_service_nervous_system_proposal.neurons_fund_participants, + ); + return Err(GovernanceError::new_with_message( ErrorType::External, format!( - "deploy_new_sns response contained an error: {:#?}", - deploy_new_sns_response, + "deploy_new_sns response contained an error: {:#?}. failed_refunds = {:#?}", + deploy_new_sns_response, failed_refunds, ), )); } + //Creation of an SNS was a success. Record this fact for latter settlement. + if let Some(proposal_data) = self + .proto + .proposals + .get_mut(&executed_create_service_nervous_system_proposal.proposal_id) + { + Self::set_sns_token_swap_lifecycle_to_open(proposal_data); + } // subnet_id and canisters fields in deploy_new_sns_response are not // used. Would probably make sense to stick them on the @@ -6947,9 +7030,9 @@ impl Governance { /// If the request is Committed, mint ICP and deposit it in the SNS /// governance canister's account. If the request is Aborted, refund - /// Community Fund neurons that participated. + /// Neurons' Fund neurons that participated. /// - /// Caller must be the swap canister, as recorded in the proposal. + /// Caller must be a Swap Canister Id. /// /// On success, sets the proposal's sns_token_swap_lifecycle accord to /// Committed or Aborted @@ -6960,11 +7043,12 @@ impl Governance { ) -> Result<(), GovernanceError> { validate_settle_community_fund_participation(request)?; + // TODO NNS1-2454: Migrate open_sns_token_swap_proposal_id to generic field name // Look up proposal. let proposal_id = request .open_sns_token_swap_proposal_id .expect("The open_sns_token_swap_proposal_id field is not populated."); - let proposal_data = match self.proto.proposals.get_mut(&proposal_id) { + let proposal_data = match self.proto.proposals.get(&proposal_id) { Some(pd) => pd, None => { return Err(GovernanceError::new_with_message( @@ -6977,36 +7061,28 @@ impl Governance { } }; - // Unpack proposal. - let open_sns_token_swap = match proposal_data - .proposal - .as_ref() - .and_then(|p| p.action.as_ref()) - { - Some(Action::OpenSnsTokenSwap(open_sns_token_swap)) => open_sns_token_swap, - _ => { + // Check authorization + is_caller_authorized_to_settle_neurons_fund_participation( + &mut *self.env, + caller, + proposal_data, + ) + .await?; + + // Re-acquire the proposal_data mutably after the await + let proposal_data = match self.proto.proposals.get_mut(&proposal_id) { + Some(pd) => pd, + None => { return Err(GovernanceError::new_with_message( ErrorType::NotFound, format!( - "Proposal {} is not of type OpenSnsTokenSwap. request = {:#?}", - proposal_id, request, + "Proposal {} not found. request = {:#?}", + proposal_id, request ), )) } }; - // Check authorization. - if Some(caller) != open_sns_token_swap.target_swap_canister_id { - return Err(GovernanceError::new_with_message( - ErrorType::NotAuthorized, - format!( - "Caller was {}, but needs to be {:?}, the \ - target_swap_canister_id in the original proposal.", - caller, open_sns_token_swap.target_swap_canister_id, - ), - )); - } - // It's possible that settle_community_fund_participation is called twice for a single Sale, // as such NNS Governance must treat this method as idempotent. If the proposal's // sns_token_swap_lifecycle is already set to Aborted or Committed (only done in a previous @@ -7545,47 +7621,14 @@ async fn validate_open_sns_token_swap( // Is target_swap_canister_id known to sns_wasm ? if let Some(some_target_swap_canister_id) = target_swap_canister_id { - let result = env - .call_canister_method( - SNS_WASM_CANISTER_ID, - "list_deployed_snses", - Encode!(&ListDeployedSnsesRequest {}).expect(""), - ) - .await; - - let target_swap_canister_id_is_ok = match &result { - Err(err) => { - defects.push(format!( - "Failed to call the list_deployed_snses method on sns_wasm ({}): {:?}", - SNS_WASM_CANISTER_ID, err, - )); - false - } - - Ok(reply_bytes) => match Decode!(reply_bytes, ListDeployedSnsesResponse) { - Err(err) => { - defects.push(format!( - "Unable to decode response as ListDeployedSnsesResponse: {}. reply_bytes = {:#?}", - err, reply_bytes, - )); + let target_swap_canister_id_is_ok = + match is_canister_id_valid_swap_canister_id(some_target_swap_canister_id, env).await { + Ok(_) => true, + Err(error_msg) => { + defects.push(error_msg); false } - - Ok(response) => { - let is_swap = response.instances.iter().any(|sns| { - sns.swap_canister_id == Some(some_target_swap_canister_id.into()) - }); - if !is_swap { - defects.push(format!( - "target_swap_canister_id is not the ID of any swap canister \ - known to sns_wasm: {}", - some_target_swap_canister_id - )); - } - is_swap - } - }, - }; + }; if !target_swap_canister_id_is_ok { target_swap_canister_id = None; @@ -7631,6 +7674,48 @@ async fn validate_open_sns_token_swap( Ok(()) } +/// Given a target_canister_id, is it a CanisterId of a deployed SNS recorded by +/// the SNS-W canister. +async fn is_canister_id_valid_swap_canister_id( + target_canister_id: CanisterId, + env: &mut dyn Environment, +) -> Result<(), String> { + let list_deployed_snses_response = env + .call_canister_method( + SNS_WASM_CANISTER_ID, + "list_deployed_snses", + Encode!(&ListDeployedSnsesRequest {}).expect(""), + ) + .await + .map_err(|err| { + format!( + "Failed to call the list_deployed_snses method on sns_wasm ({}): {:?}", + SNS_WASM_CANISTER_ID, err, + ) + })?; + + let list_deployed_snses_response = + Decode!(&list_deployed_snses_response, ListDeployedSnsesResponse).map_err(|err| { + format!( + "Unable to decode response as ListDeployedSnsesResponse: {}. reply_bytes = {:#?}", + err, list_deployed_snses_response, + ) + })?; + + let is_swap = list_deployed_snses_response + .instances + .iter() + .any(|sns| sns.swap_canister_id == Some(target_canister_id.into())); + if !is_swap { + return Err(format!( + "target_swap_canister_id is not the ID of any swap canister known to sns_wasm: {}", + target_canister_id + )); + } + + Ok(()) +} + async fn validate_swap_params( env: &mut dyn Environment, target_swap_canister_id: CanisterId, @@ -7685,6 +7770,82 @@ async fn validate_swap_params( params.validate(&init) } +pub async fn is_caller_authorized_to_settle_neurons_fund_participation( + env: &mut dyn Environment, + caller: PrincipalId, + proposal_data: &ProposalData, +) -> Result<(), GovernanceError> { + let action = proposal_data + .proposal + .as_ref() + .and_then(|p| p.action.as_ref()) + .ok_or_else(|| { + GovernanceError::new_with_message( + ErrorType::PreconditionFailed, + format!( + "Proposal {:?} is missing its action and cannot authorize {} to \ + settle Neurons' Fund participation.", + proposal_data.id, caller + ), + ) + })?; + + match action { + Action::OpenSnsTokenSwap(open_sns_token_swap) => { + if Some(caller) != open_sns_token_swap.target_swap_canister_id { + return Err(GovernanceError::new_with_message( + ErrorType::NotAuthorized, + format!( + "Caller was {}, but needs to be {:?}, the \ + target_swap_canister_id in the original proposal.", + caller, open_sns_token_swap.target_swap_canister_id, + ), + )); + } + } + Action::CreateServiceNervousSystem(_) => { + let target_canister_id = match CanisterId::try_from(caller) { + Ok(canister_id) => canister_id, + Err(err) => { + return Err(GovernanceError::new_with_message( + ErrorType::NotAuthorized, + format!( + "Caller {} is not a valid canisterId and is not authorized to \ + settle Neuron's Fund participation in a decentralization swap. Err: {:?}", + caller, err, + ), + )); + } + }; + match is_canister_id_valid_swap_canister_id(target_canister_id, env).await { + Ok(_) => {} + Err(err_msg) => { + return Err(GovernanceError::new_with_message( + ErrorType::NotAuthorized, + format!( + "Caller {} is not authorized to settle Neuron's Fund \ + participation in a decentralization swap. Err: {:?}", + caller, err_msg, + ), + )); + } + } + } + + _ => { + return Err(GovernanceError::new_with_message( + ErrorType::PreconditionFailed, + format!( + "Proposal {:?} is not of type OpenSnsTokenSwap or CreateServiceNervousSystem.", + proposal_data.id + ), + )) + } + }; + + Ok(()) +} + /// A helper for the Registry's get_node_providers_monthly_xdr_rewards method async fn get_node_providers_monthly_xdr_rewards( ) -> Result { diff --git a/rs/nns/governance/src/governance/test_data.rs b/rs/nns/governance/src/governance/test_data.rs index a3598452680..de39f337b2a 100644 --- a/rs/nns/governance/src/governance/test_data.rs +++ b/rs/nns/governance/src/governance/test_data.rs @@ -80,7 +80,7 @@ lazy_static! { // Neuron Parameters // ----------------- - neuron_minimum_stake: Some(pb::Tokens { e8s: Some(61800) }), + neuron_minimum_stake: Some(pb::Tokens { e8s: Some(250_000) }), neuron_minimum_dissolve_delay_to_vote: Some(pb::Duration { seconds: Some(482538), @@ -126,16 +126,16 @@ lazy_static! { e8s: Some(12_300_000_000), }), maximum_icp: Some(pb::Tokens { - e8s: Some(6500000000000), + e8s: Some(25_000_000_000), }), minimum_participant_icp: Some(pb::Tokens { - e8s: Some(6500000000) + e8s: Some(100_000_000) }), maximum_participant_icp: Some(pb::Tokens { - e8s: Some(65_000_000_000) + e8s: Some(10_000_000_000) }), neuron_basket_construction_parameters: Some(src::NeuronBasketConstructionParameters { - count: Some(5), + count: Some(2), dissolve_delay_interval: Some(pb::Duration { seconds: Some(10_001), }) @@ -148,7 +148,7 @@ lazy_static! { }), neurons_fund_investment: Some(pb::Tokens { - e8s: Some(1_000_000), + e8s: Some(6_100_000_000), }), }) }; diff --git a/rs/nns/governance/src/proposals/create_service_nervous_system.rs b/rs/nns/governance/src/proposals/create_service_nervous_system.rs index ab4e452e4c6..c75d2f2d583 100644 --- a/rs/nns/governance/src/proposals/create_service_nervous_system.rs +++ b/rs/nns/governance/src/proposals/create_service_nervous_system.rs @@ -21,6 +21,7 @@ pub(crate) fn create_service_nervous_system_proposals_is_enabled() -> bool { false } +#[derive(Clone, Debug)] pub struct ExecutedCreateServiceNervousSystemProposal { pub current_timestamp_seconds: u64, pub create_service_nervous_system: CreateServiceNervousSystem, diff --git a/rs/nns/governance/tests/governance.rs b/rs/nns/governance/tests/governance.rs index 9c7d348e673..69b1740ff06 100644 --- a/rs/nns/governance/tests/governance.rs +++ b/rs/nns/governance/tests/governance.rs @@ -43,12 +43,13 @@ use ic_nns_governance::pb::v1::{ }; use ic_nns_governance::{ governance::{ - subaccount_from_slice, validate_proposal_title, Environment, Governance, - HeapGrowthPotential, EXECUTE_NNS_FUNCTION_PAYLOAD_LISTING_BYTES_MAX, - MAX_DISSOLVE_DELAY_SECONDS, MAX_NEURON_AGE_FOR_AGE_BONUS, - MAX_NUMBER_OF_PROPOSALS_WITH_BALLOTS, MIN_DISSOLVE_DELAY_FOR_VOTE_ELIGIBILITY_SECONDS, - ONE_DAY_SECONDS, ONE_MONTH_SECONDS, ONE_YEAR_SECONDS, PROPOSAL_MOTION_TEXT_BYTES_MAX, - REWARD_DISTRIBUTION_PERIOD_SECONDS, WAIT_FOR_QUIET_DEADLINE_INCREASE_SECONDS, + subaccount_from_slice, test_data::CREATE_SERVICE_NERVOUS_SYSTEM, validate_proposal_title, + Environment, Governance, HeapGrowthPotential, + EXECUTE_NNS_FUNCTION_PAYLOAD_LISTING_BYTES_MAX, MAX_DISSOLVE_DELAY_SECONDS, + MAX_NEURON_AGE_FOR_AGE_BONUS, MAX_NUMBER_OF_PROPOSALS_WITH_BALLOTS, + MIN_DISSOLVE_DELAY_FOR_VOTE_ELIGIBILITY_SECONDS, ONE_DAY_SECONDS, ONE_MONTH_SECONDS, + ONE_YEAR_SECONDS, PROPOSAL_MOTION_TEXT_BYTES_MAX, REWARD_DISTRIBUTION_PERIOD_SECONDS, + WAIT_FOR_QUIET_DEADLINE_INCREASE_SECONDS, }, init::GovernanceCanisterInitPayloadBuilder, pb::v1::{ @@ -85,10 +86,17 @@ use ic_nns_governance::{ SettleCommunityFundParticipation, SwapBackgroundInformation, Tally, Topic, UpdateNodeProvider, Vote, WaitForQuietState, }, + proposals::create_service_nervous_system::ExecutedCreateServiceNervousSystemProposal, }; +use ic_sns_init::pb::v1::SnsInitPayload; use ic_sns_root::{GetSnsCanistersSummaryRequest, GetSnsCanistersSummaryResponse}; -use ic_sns_swap::pb::v1::{self as sns_swap_pb, NeuronBasketConstructionParameters, Params}; -use ic_sns_wasm::pb::v1::{DeployedSns, ListDeployedSnsesRequest, ListDeployedSnsesResponse}; +use ic_sns_swap::pb::v1::{ + self as sns_swap_pb, CfNeuron, NeuronBasketConstructionParameters, Params, +}; +use ic_sns_wasm::pb::v1::{ + DeployNewSnsRequest, DeployNewSnsResponse, DeployedSns, ListDeployedSnsesRequest, + ListDeployedSnsesResponse, SnsWasmError, +}; use icp_ledger::{AccountIdentifier, Memo, Subaccount, Tokens}; use lazy_static::lazy_static; use maplit::{btreemap, hashmap}; @@ -120,6 +128,8 @@ pub mod common; const DEFAULT_TEST_START_TIMESTAMP_SECONDS: u64 = 999_111_000_u64; +const RANDOM_U64: u64 = 0_u64; + const USUAL_REWARD_POT_E8S: u64 = 100; fn check_proposal_status_after_voting_and_after_expiration_new( @@ -10608,9 +10618,21 @@ impl Environment for MockEnvironment<'_> { request: request.clone(), }, expected_arguments, - "Decoded request:\n{}", + "Decoded actual request:\n{}, Decoded expected request:\n {}", match method_name { "open" => format!("{:#?}", Decode!(&request, sns_swap_pb::OpenRequest)), + "deploy_new_sns" => format!("{:#?}", Decode!(&request, DeployNewSnsRequest)), + _ => "???".to_string(), + }, + match method_name { + "open" => format!( + "{:#?}", + Decode!(&expected_arguments.request, sns_swap_pb::OpenRequest) + ), + "deploy_new_sns" => format!( + "{:#?}", + Decode!(&expected_arguments.request, DeployNewSnsRequest) + ), _ => "???".to_string(), }, ); @@ -10626,7 +10648,7 @@ impl Environment for MockEnvironment<'_> { } fn random_u64(&mut self) -> u64 { - panic!("Unexpected call to Environment::random_u64"); + RANDOM_U64 } fn random_byte_array(&mut self) -> [u8; 32] { @@ -10916,6 +10938,37 @@ lazy_static! { })), ..Default::default() }; + + static ref CREATE_SERVICE_NERVOUS_SYSTEM_PROPOSAL: Proposal = Proposal { + title: Some("Create a Service Nervous System".to_string()), + summary: "".to_string(), + action: Some(proposal::Action::CreateServiceNervousSystem(CREATE_SERVICE_NERVOUS_SYSTEM.clone())), + ..Default::default() + }; + + static ref SNS_INIT_PAYLOAD: SnsInitPayload = SnsInitPayload::try_from(ExecutedCreateServiceNervousSystemProposal { + current_timestamp_seconds: DEFAULT_TEST_START_TIMESTAMP_SECONDS, + create_service_nervous_system: CREATE_SERVICE_NERVOUS_SYSTEM.clone(), + proposal_id: 1, + neurons_fund_participants: CF_PARTICIPANTS.clone(), + random_swap_start_time: GlobalTimeOfDay { + seconds_after_utc_midnight: Some(RANDOM_U64) + } + }).unwrap(); + + static ref EXPECTED_DEPLOY_NEW_SNS_CALL: (ExpectedCallCanisterMethodCallArguments<'static>, CanisterCallResult) = ( + ExpectedCallCanisterMethodCallArguments { + target: SNS_WASM_CANISTER_ID, + method_name: "deploy_new_sns", + request: Encode!(&DeployNewSnsRequest { + sns_init_payload: Some(SNS_INIT_PAYLOAD.clone()) + }).unwrap(), + }, + Ok(Encode!(&DeployNewSnsResponse { + error: None, + ..Default::default() + }).unwrap()) + ); } const COMMUNITY_FUND_INVESTMENT_E8S: u64 = 61 * E8; @@ -11448,6 +11501,546 @@ async fn test_settle_community_fund_participation_restores_lifecycle_on_failure( ); } +fn assert_neurons_fund_decremented( + gov: &Governance, + nf_participants: Vec, + original_state: HashMap, +) { + for participant in nf_participants { + for nf_neuron in participant.cf_neurons { + let CfNeuron { + nns_neuron_id, + amount_icp_e8s, + } = nf_neuron; + + let current_neuron_maturity = gov + .get_neuron(&NeuronId { id: nns_neuron_id }) + .unwrap() + .maturity_e8s_equivalent; + let original_neuron_maturity = original_state + .get(&nns_neuron_id) + .unwrap() + .maturity_e8s_equivalent; + + assert_eq!( + current_neuron_maturity + amount_icp_e8s, + original_neuron_maturity + ); + } + } +} + +fn assert_neurons_fund_unchanged(gov: &Governance, original_state: HashMap) { + for (id, original_neuron) in original_state { + let current_neuron = gov.get_neuron(&NeuronId { id }).unwrap(); + assert_eq!( + current_neuron.maturity_e8s_equivalent, + original_neuron.maturity_e8s_equivalent + ); + } +} + +#[tokio::test] +async fn test_create_service_nervous_system_settles_neurons_fund_commit() { + // Step 1: Prepare the world. + let governance_proto = GovernanceProto { + economics: Some(NetworkEconomics::with_default_values()), + neurons: SWAP_ID_TO_NEURON.clone(), + ..Default::default() + }; + + let expected_call_canister_method_calls: Arc>> = Arc::new(Mutex::new( + [ + // Called during proposal execution + EXPECTED_DEPLOY_NEW_SNS_CALL.clone(), + // Called during settlement + EXPECTED_LIST_DEPLOYED_SNSES_CALL.clone(), + ] + .into(), + )); + + let driver = fake::FakeDriver::default().with_ledger_accounts(vec![]); // Initialize the minting account + let mut gov = Governance::new( + governance_proto, + Box::new(MockEnvironment { + expected_call_canister_method_calls: Arc::clone(&expected_call_canister_method_calls), + }), + driver.get_fake_ledger(), + driver.get_fake_cmc(), + ); + + // Step 2: Run code under test. This is done indirectly via proposal. The + // proposal is executed right away, because of the "passage of time", as + // experienced via the MockEnvironment in gov. + gov.make_proposal( + &NeuronId { id: 1 }, + &principal(1), + &CREATE_SERVICE_NERVOUS_SYSTEM_PROPOSAL, + ) + .await + .unwrap(); + + // Step 3: Inspect results. + + // Step 3.1: Inspect the proposal. In particular, look at its execution status. + assert_eq!(gov.proto.proposals.len(), 1, "{:#?}", gov.proto.proposals); + let mut proposals: Vec<(_, _)> = gov.proto.proposals.iter().collect(); + let (_id, proposal) = proposals.pop().unwrap(); + assert_eq!( + proposal.proposal.as_ref().unwrap().title.as_ref().unwrap(), + "Create a Service Nervous System", + "{:#?}", + proposal.proposal.as_ref().unwrap() + ); + assert_eq!( + proposal.executed_timestamp_seconds, DEFAULT_TEST_START_TIMESTAMP_SECONDS, + "{:#?}", + proposal + ); + assert_eq!(proposal.cf_participants, *CF_PARTICIPANTS); + assert_eq!( + proposal.sns_token_swap_lifecycle, + Some(sns_swap_pb::Lifecycle::Open as i32) + ); + assert_eq!(proposal.failed_timestamp_seconds, 0, "{:#?}", proposal); + assert_eq!(proposal.failure_reason, None, "{:#?}", proposal); + assert_eq!(proposal.derived_proposal_information, None); + + // Assert all of the maturity has been decremented and is held in escrow + assert_neurons_fund_decremented(&gov, CF_PARTICIPANTS.clone(), SWAP_ID_TO_NEURON.clone()); + + // Cannot be purged yet, because Community Fund participation has not been settled yet. + let proposal = ProposalData { + reward_event_round: 1, // Pretend that proposal is now settled. + ..proposal.clone() + }; + let now = DEFAULT_TEST_START_TIMESTAMP_SECONDS + 999 * SECONDS_PER_DAY; + let voting_period_seconds = gov.voting_period_seconds()(proposal.topic()); + assert_eq!( + proposal.reward_status(now, voting_period_seconds), + ProposalRewardStatus::Settled, + ); + assert!(!proposal.can_be_purged(now, voting_period_seconds)); + + // Settle CF participation (Commit). + { + use settle_community_fund_participation::{Committed, Result}; + gov.settle_community_fund_participation( + *TARGET_SWAP_CANISTER_ID, + &SettleCommunityFundParticipation { + open_sns_token_swap_proposal_id: Some(proposal.id.unwrap().id), + result: Some(Result::Committed(Committed { + sns_governance_canister_id: Some(*SNS_GOVERNANCE_CANISTER_ID), + })), + }, + ) + .await + .unwrap(); + + // Re-inspect the proposal. + let mut proposals: Vec<(_, _)> = gov.proto.proposals.iter().collect(); + assert_eq!(proposals.len(), 1); + let (_id, proposal) = proposals.pop().unwrap(); + + // Force proposal to be seen as Settled (from a voting rewards point of view). + let proposal = ProposalData { + reward_event_round: 1, + ..proposal.clone() + }; + assert_eq!( + proposal.reward_status(now, voting_period_seconds), + ProposalRewardStatus::Settled, + ); + + // Unlike a short while ago (right before this block), we are now settled + assert_eq!( + proposal.sns_token_swap_lifecycle, + Some(sns_swap_pb::Lifecycle::Committed as i32), + ); + assert!(proposal.can_be_purged(now, voting_period_seconds)); + } + + // Step 3.2: Make sure expected canister call(s) take place. + assert!( + expected_call_canister_method_calls + .lock() + .unwrap() + .is_empty(), + "Calls that should have been made, but were not: {:#?}", + expected_call_canister_method_calls, + ); +} + +#[tokio::test] +async fn test_create_service_nervous_system_settles_neurons_fund_abort() { + // Step 1: Prepare the world. + let governance_proto = GovernanceProto { + economics: Some(NetworkEconomics::with_default_values()), + neurons: SWAP_ID_TO_NEURON.clone(), + ..Default::default() + }; + + let expected_call_canister_method_calls: Arc>> = Arc::new(Mutex::new( + [ + // Called during proposal execution + EXPECTED_DEPLOY_NEW_SNS_CALL.clone(), + // Called during settlement + EXPECTED_LIST_DEPLOYED_SNSES_CALL.clone(), + ] + .into(), + )); + + let driver = fake::FakeDriver::default().with_ledger_accounts(vec![]); // Initialize the minting account + let mut gov = Governance::new( + governance_proto, + Box::new(MockEnvironment { + expected_call_canister_method_calls: Arc::clone(&expected_call_canister_method_calls), + }), + driver.get_fake_ledger(), + driver.get_fake_cmc(), + ); + + // Step 2: Run code under test. This is done indirectly via proposal. The + // proposal is executed right away, because of the "passage of time", as + // experienced via the MockEnvironment in gov. + gov.make_proposal( + &NeuronId { id: 1 }, + &principal(1), + &CREATE_SERVICE_NERVOUS_SYSTEM_PROPOSAL, + ) + .await + .unwrap(); + + // Step 3: Inspect results. + + // Step 3.1: Inspect the proposal. In particular, look at its execution status. + assert_eq!(gov.proto.proposals.len(), 1, "{:#?}", gov.proto.proposals); + let mut proposals: Vec<(_, _)> = gov.proto.proposals.iter().collect(); + let (_id, proposal) = proposals.pop().unwrap(); + assert_eq!( + proposal.proposal.as_ref().unwrap().title.as_ref().unwrap(), + "Create a Service Nervous System", + "{:#?}", + proposal.proposal.as_ref().unwrap() + ); + assert_eq!( + proposal.executed_timestamp_seconds, DEFAULT_TEST_START_TIMESTAMP_SECONDS, + "{:#?}", + proposal + ); + assert_eq!(proposal.cf_participants, *CF_PARTICIPANTS); + assert_eq!( + proposal.sns_token_swap_lifecycle, + Some(sns_swap_pb::Lifecycle::Open as i32) + ); + assert_eq!(proposal.failed_timestamp_seconds, 0, "{:#?}", proposal); + assert_eq!(proposal.failure_reason, None, "{:#?}", proposal); + assert_eq!(proposal.derived_proposal_information, None); + + // Assert all of the maturity has been decremented and is held in escrow + assert_neurons_fund_decremented(&gov, CF_PARTICIPANTS.clone(), SWAP_ID_TO_NEURON.clone()); + + // Cannot be purged yet, because Community Fund participation has not been settled yet. + let proposal = ProposalData { + reward_event_round: 1, // Pretend that proposal is now settled. + ..proposal.clone() + }; + let now = DEFAULT_TEST_START_TIMESTAMP_SECONDS + 999 * SECONDS_PER_DAY; + let voting_period_seconds = gov.voting_period_seconds()(proposal.topic()); + assert_eq!( + proposal.reward_status(now, voting_period_seconds), + ProposalRewardStatus::Settled, + ); + assert!(!proposal.can_be_purged(now, voting_period_seconds)); + + // Settle CF participation (Abort). + { + use settle_community_fund_participation::{Aborted, Result}; + gov.settle_community_fund_participation( + *TARGET_SWAP_CANISTER_ID, + &SettleCommunityFundParticipation { + open_sns_token_swap_proposal_id: Some(proposal.id.unwrap().id), + result: Some(Result::Aborted(Aborted::default())), + }, + ) + .await + .unwrap(); + + // Re-inspect the proposal. + let mut proposals: Vec<(_, _)> = gov.proto.proposals.iter().collect(); + assert_eq!(proposals.len(), 1); + let (_id, proposal) = proposals.pop().unwrap(); + + // Force proposal to be seen as Settled (from a voting rewards point of view). + let proposal = ProposalData { + reward_event_round: 1, + ..proposal.clone() + }; + assert_eq!( + proposal.reward_status(now, voting_period_seconds), + ProposalRewardStatus::Settled, + ); + + // Unlike a short while ago (right before this block), we are now settled + assert_eq!( + proposal.sns_token_swap_lifecycle, + Some(sns_swap_pb::Lifecycle::Aborted as i32), + ); + assert!(proposal.can_be_purged(now, voting_period_seconds)); + + assert_neurons_fund_unchanged(&gov, SWAP_ID_TO_NEURON.clone()); + } + + // Step 3.2: Make sure expected canister call(s) take place. + assert!( + expected_call_canister_method_calls + .lock() + .unwrap() + .is_empty(), + "Calls that should have been made, but were not: {:#?}", + expected_call_canister_method_calls, + ); +} + +#[tokio::test] +async fn test_create_service_nervous_system_proposal_execution_fails() { + // Step 1: Prepare the world. + let governance_proto = GovernanceProto { + economics: Some(NetworkEconomics::with_default_values()), + neurons: SWAP_ID_TO_NEURON.clone(), + ..Default::default() + }; + + let expected_call_canister_method_calls: Arc>> = Arc::new(Mutex::new( + [ + // Called during proposal execution + ( + EXPECTED_DEPLOY_NEW_SNS_CALL.0.clone(), + // Error from SNS-W + Ok(Encode!(&DeployNewSnsResponse { + error: Some(SnsWasmError { + message: "Error encountered".to_string() + }), + ..Default::default() + }) + .unwrap()), + ), + ] + .into(), + )); + + let driver = fake::FakeDriver::default().with_ledger_accounts(vec![]); // Initialize the minting account + let mut gov = Governance::new( + governance_proto, + // This is where the main expectation is set. To wit, we expect that + // execution of the proposal will cause governance to call out to the + // swap canister. + Box::new(MockEnvironment { + expected_call_canister_method_calls: Arc::clone(&expected_call_canister_method_calls), + }), + driver.get_fake_ledger(), + driver.get_fake_cmc(), + ); + + // Step 2: Run code under test. This is done indirectly via proposal. The + // proposal is executed right away, because of the "passage of time", as + // experienced via the MockEnvironment in gov. + gov.make_proposal( + &NeuronId { id: 1 }, + &principal(1), + &CREATE_SERVICE_NERVOUS_SYSTEM_PROPOSAL, + ) + .await + .unwrap(); + + // Step 3: Inspect results. + + // Step 3.1: Inspect the proposal. In particular, look at its execution status. + assert_eq!(gov.proto.proposals.len(), 1, "{:#?}", gov.proto.proposals); + let mut proposals: Vec<(_, _)> = gov.proto.proposals.iter().collect(); + let (_id, proposal) = proposals.pop().unwrap(); + assert_eq!( + proposal.proposal.as_ref().unwrap().title.as_ref().unwrap(), + "Create a Service Nervous System", + "{:#?}", + proposal.proposal.as_ref().unwrap() + ); + assert_eq!(proposal.executed_timestamp_seconds, 0, "{:#?}", proposal); + assert_eq!(proposal.cf_participants, *CF_PARTICIPANTS); + assert_eq!(proposal.sns_token_swap_lifecycle, None); + assert_ne!(proposal.failed_timestamp_seconds, 0, "{:#?}", proposal); + let failure_reason = proposal.failure_reason.clone().unwrap(); + assert_eq!( + failure_reason.error_type, + ErrorType::External as i32, + "{:#?}", + proposal, + ); + assert!( + failure_reason.error_message.contains("Error encountered"), + "proposal = {:#?}.", + proposal, + ); + assert_eq!(proposal.derived_proposal_information, None); + + assert_neurons_fund_unchanged(&gov, SWAP_ID_TO_NEURON.clone()); + + // Step 3.2: Make sure expected canister call(s) take place. + assert!( + expected_call_canister_method_calls + .lock() + .unwrap() + .is_empty(), + "Calls that should have been made, but were not: {:#?}", + expected_call_canister_method_calls, + ); +} + +#[tokio::test] +async fn test_settle_community_fund_is_idempotent_for_create_service_nervous_system() { + use settle_community_fund_participation::Result; + + // Step 1: Prepare the world. + let governance_proto = GovernanceProto { + economics: Some(NetworkEconomics::with_default_values()), + neurons: SWAP_ID_TO_NEURON.clone(), + ..Default::default() + }; + + let expected_call_canister_method_calls: Arc>> = Arc::new(Mutex::new( + [ + // Called during proposal execution + EXPECTED_DEPLOY_NEW_SNS_CALL.clone(), + // Called during first settlement call + EXPECTED_LIST_DEPLOYED_SNSES_CALL.clone(), + // Called during second settlement call + EXPECTED_LIST_DEPLOYED_SNSES_CALL.clone(), + ] + .into(), + )); + + let driver = fake::FakeDriver::default().with_ledger_accounts(vec![]); // Initialize the minting account + let mut gov = Governance::new( + governance_proto, + Box::new(MockEnvironment { + expected_call_canister_method_calls: Arc::clone(&expected_call_canister_method_calls), + }), + driver.get_fake_ledger(), + driver.get_fake_cmc(), + ); + + // Step 2: Run code under test. + + // Create an CreateServiceNervousSystem Proposal that will decrement NF neuron's stake a measurable amount + let proposal_id = gov + .make_proposal( + &NeuronId { id: 1 }, + &principal(1), + &CREATE_SERVICE_NERVOUS_SYSTEM_PROPOSAL, + ) + .await + .unwrap(); + + let proposal = gov.get_proposal_data(proposal_id).unwrap(); + // Assert that the proposal is executed and the lifecycle has been set + assert!(proposal.executed_timestamp_seconds > 0); + assert_eq!( + proposal.sns_token_swap_lifecycle, + Some(sns_swap_pb::Lifecycle::Open as i32) + ); + + // Calculate the AccountIdentifier of SNS Governance for balance lookups + let sns_governance_icp_account = + AccountIdentifier::new(*SNS_GOVERNANCE_CANISTER_ID, /* Subaccount*/ None); + + // Get the treasury accounts balance + let sns_governance_treasury_balance_before_commitment = driver + .get_fake_ledger() + .account_balance(sns_governance_icp_account) + .await + .unwrap(); + + // The value should be zero since the maturity has not been minted + assert_eq!( + sns_governance_treasury_balance_before_commitment.get_e8s(), + 0 + ); + + // Settle the CommunityFund participation for the first time. + let response = gov + .settle_community_fund_participation( + *TARGET_SWAP_CANISTER_ID, + &SettleCommunityFundParticipation { + open_sns_token_swap_proposal_id: Some(proposal.id.unwrap().id), + result: Some(Result::Committed(Committed { + sns_governance_canister_id: Some(*SNS_GOVERNANCE_CANISTER_ID), + })), + }, + ) + .await; + + // Assert that the settling process succeeded + assert!(response.is_ok(), "{:?}", response); + + // Get the treasury account's balance again + let sns_governance_treasury_balance_after_commitment = driver + .get_fake_ledger() + .account_balance(sns_governance_icp_account) + .await + .unwrap(); + + // The balance should now not be zero. + assert!(sns_governance_treasury_balance_after_commitment.get_e8s() > 0); + assert!( + sns_governance_treasury_balance_after_commitment + > sns_governance_treasury_balance_before_commitment + ); + + // Make sure the ProposalData's sns_token_swap_lifecycle was also set, as this is how + // idempotency is achieved + let proposal = gov.get_proposal_data(proposal_id).unwrap(); + assert_eq!( + proposal.sns_token_swap_lifecycle, + Some(sns_swap_pb::Lifecycle::Committed as i32) + ); + + // Try to settle the CommunityFund participation for the second time. + let response = gov + .settle_community_fund_participation( + *TARGET_SWAP_CANISTER_ID, + &SettleCommunityFundParticipation { + open_sns_token_swap_proposal_id: Some(proposal.id.unwrap().id), + result: Some(Result::Committed(Committed { + sns_governance_canister_id: Some(*SNS_GOVERNANCE_CANISTER_ID), + })), + }, + ) + .await; + + // Assert that the call did not fail. + assert!(response.is_ok()); + + // Get the treasury account's balance again + let sns_governance_treasury_balance_after_second_settle_call = driver + .get_fake_ledger() + .account_balance(sns_governance_icp_account) + .await + .unwrap(); + + // Assert that no work has been done, the balance should not have changed + assert_eq!( + sns_governance_treasury_balance_after_commitment, + sns_governance_treasury_balance_after_second_settle_call + ); + + // Assert that the ProposalData's sns_token_swap_lifecycle hasn't changed + let proposal = gov.get_proposal_data(proposal_id).unwrap(); + assert_eq!( + proposal.sns_token_swap_lifecycle, + Some(sns_swap_pb::Lifecycle::Committed as i32) + ); +} + #[tokio::test] async fn distribute_rewards_load_test() { // Step 1: Prepare the world.