diff --git a/Cargo.lock b/Cargo.lock index c3e3aa5b0bb..bbf398aad9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11466,10 +11466,13 @@ dependencies = [ name = "ic-sns-audit" version = "0.9.0" dependencies = [ + "anyhow", "candid", "colored", "csv", "ic-agent", + "ic-base-types", + "ic-nervous-system-agent", "ic-nervous-system-common-test-keys", "ic-neurons-fund", "ic-nns-common", @@ -11481,6 +11484,7 @@ dependencies = [ "serde", "serde_json", "textplots", + "thiserror", "tokio", ] diff --git a/rs/nns/governance/api/src/pb.rs b/rs/nns/governance/api/src/pb.rs index b07081fce94..42da3f161c4 100644 --- a/rs/nns/governance/api/src/pb.rs +++ b/rs/nns/governance/api/src/pb.rs @@ -48,6 +48,8 @@ impl fmt::Display for GovernanceError { } } +impl std::error::Error for GovernanceError {} + impl NeuronsFundEconomics { /// The default values for network economics (until we initialize it). /// Can't implement Default since it conflicts with Prost's. diff --git a/rs/sns/audit/BUILD.bazel b/rs/sns/audit/BUILD.bazel index cd5423644b5..315d6d6b79c 100644 --- a/rs/sns/audit/BUILD.bazel +++ b/rs/sns/audit/BUILD.bazel @@ -4,11 +4,13 @@ package(default_visibility = ["//visibility:public"]) DEPENDENCIES = [ # Keep sorted. + "//rs/nervous_system/agent", "//rs/nervous_system/neurons_fund", "//rs/nns/common", "//rs/nns/governance/api", "//rs/sns/governance", "//rs/sns/swap", + "//rs/types/base_types", "@crate_index//:candid", "@crate_index//:colored", "@crate_index//:csv", @@ -18,6 +20,7 @@ DEPENDENCIES = [ "@crate_index//:serde", "@crate_index//:serde_json", "@crate_index//:textplots", + "@crate_index//:thiserror", "@crate_index//:tokio", ] @@ -30,5 +33,8 @@ rust_library( rust_binary( name = "sns-audit", srcs = ["src/main.rs"], - deps = DEPENDENCIES + [":ic-sns-audit"], + deps = DEPENDENCIES + [ + ":ic-sns-audit", + "@crate_index//:anyhow", + ], ) diff --git a/rs/sns/audit/Cargo.toml b/rs/sns/audit/Cargo.toml index 737c37ff614..ad2b13de43b 100644 --- a/rs/sns/audit/Cargo.toml +++ b/rs/sns/audit/Cargo.toml @@ -15,10 +15,13 @@ name = "ic_sns_audit" path = "src/lib.rs" [dependencies] +anyhow = { workspace = true } candid = { workspace = true } colored = "2.0.0" csv = "1.1" ic-agent = { workspace = true } +ic-base-types = { path = "../../types/base_types" } +ic-nervous-system-agent = { path = "../../nervous_system/agent" } ic-nervous-system-common-test-keys = { path = "../../nervous_system/common/test_keys" } ic-neurons-fund = { path = "../../nervous_system/neurons_fund" } ic-nns-common = { path = "../../nns/common" } @@ -30,4 +33,5 @@ rust_decimal = { version = "1.25" } serde = { workspace = true } serde_json = { workspace = true } textplots = { version = "0.8" } +thiserror = { workspace = true } tokio = { workspace = true } diff --git a/rs/sns/audit/src/lib.rs b/rs/sns/audit/src/lib.rs index 98f8d31b91a..7b7b7038d3a 100644 --- a/rs/sns/audit/src/lib.rs +++ b/rs/sns/audit/src/lib.rs @@ -1,21 +1,49 @@ use std::collections::BTreeMap; -use candid::{Decode, Encode, Principal}; +use candid::Principal; use colored::{ColoredString, Colorize}; -use ic_agent::Agent; -use ic_neurons_fund::u64_to_dec; +use ic_nervous_system_agent::{ + nns, + sns::{governance::GovernanceCanister, swap::SwapCanister}, + CallCanisters, +}; use ic_nns_common::pb::v1::ProposalId; use ic_nns_governance_api::pb::v1::{ - get_neurons_fund_audit_info_response, GetNeuronsFundAuditInfoRequest, - GetNeuronsFundAuditInfoResponse, NeuronsFundAuditInfo, -}; -use ic_sns_governance::pb::v1::{GetMetadataRequest, GetMetadataResponse}; -use ic_sns_swap::pb::v1::{ - sns_neuron_recipe::Investor, GetDerivedStateRequest, GetDerivedStateResponse, GetInitRequest, - GetInitResponse, ListSnsNeuronRecipesRequest, ListSnsNeuronRecipesResponse, SnsNeuronRecipe, + get_neurons_fund_audit_info_response, GovernanceError, NeuronsFundAuditInfo, }; +use ic_sns_swap::pb::v1::sns_neuron_recipe::Investor; use rgb::RGB8; use rust_decimal::{prelude::FromPrimitive, Decimal}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AuditError { + #[error(transparent)] + CanisterCallError(#[from] CallCanistersError), + + #[error("sns was created before 1-proposal and cannot be audited using this tool; please audit this swap manually.")] + CreatedBeforeOneProposal, + + #[error("sns was created before matched funding and cannot be audited using this tool; please audit this swap manually.")] + CreatedBeforeMatchedFunding, + + #[error( + "sns was created after matched funding, but audit info is not available in NNS Governance" + )] + AuditInfoNotAvailable(#[source] GovernanceError), + + // Note: This error is not possible because `Decimal::from_u64` cannot fail. + // However, we keep the error message to protect against future changes to the `Decimal` implementation. + #[error("cannot convert value {0} to Decimal: {1}")] + DecimalConversionError(u64, String), + + #[error("swap is not in the final state yet, so `initial_neurons_fund_participation` is not specified.")] + SwapNotInFinalState, +} + +fn u64_to_dec(x: u64) -> Result> { + ic_neurons_fund::u64_to_dec(x).map_err(|s| AuditError::DecimalConversionError(x, s)) +} const GREEN: RGB8 = RGB8::new(0, 200, 30); const RED: RGB8 = RGB8::new(200, 0, 30); @@ -46,105 +74,44 @@ fn audit_check(text: &str, condition: bool) { } } -async fn list_sns_neuron_recipes( - agent: &Agent, - swap_canister_id: &Principal, -) -> Result, String> { - let mut sns_neuron_recipes: Vec = vec![]; - let batch_size = 10_000_u64; - let num_calls = 100_u64; - for i in 0..num_calls { - let response = agent - .query(swap_canister_id, "list_sns_neuron_recipes") - .with_arg( - Encode!(&ListSnsNeuronRecipesRequest { - limit: Some(batch_size as u32), - offset: Some(batch_size * i), - }) - .map_err(|e| e.to_string())?, - ) - .call() - .await - .map_err(|e| e.to_string())?; - let new_sns_neuron_recipes = Decode!(response.as_slice(), ListSnsNeuronRecipesResponse) - .map_err(|e| e.to_string())?; - if new_sns_neuron_recipes.sns_neuron_recipes.is_empty() { - return Ok(sns_neuron_recipes); - } else { - sns_neuron_recipes.extend(new_sns_neuron_recipes.sns_neuron_recipes.into_iter()) - } - } - Err(format!( - "There seem to be too many neuron recipes ({}).", - batch_size * num_calls - )) -} - -async fn validate_neurons_fund_sns_swap_participation( - agent: &Agent, - swap_canister_id: Principal, -) -> Result<(), String> { - let swap_derived_state = { - let response = agent - .query(&swap_canister_id, "get_derived_state") - .with_arg(Encode!(&GetDerivedStateRequest {}).map_err(|e| e.to_string())?) - .call() - .await - .map_err(|e| e.to_string())?; - Decode!(response.as_slice(), GetDerivedStateResponse).map_err(|e| e.to_string())? - }; - - let swap_init = { - let response = agent - .query(&swap_canister_id, "get_init") - .with_arg(Encode!(&GetInitRequest {}).map_err(|e| e.to_string())?) - .call() - .await - .map_err(|e| e.to_string())?; - let response: GetInitResponse = - Decode!(response.as_slice(), GetInitResponse).map_err(|e| e.to_string())?; - response.init.unwrap() - }; +/// Validate that the NNS (identified by `nns_url`) and an SNS instance (identified by +/// `swap_canister_id`) agree on how the SNS neurons of a successful swap have been allocated. +/// +/// This function performs a best-effort audit, e.g., there is no completeness guarantee for +/// the checks. +/// +/// Currently, the following SNS-global aspects are checked: +/// 1. Number of Neurons' Fund neurons whose maturity was initially reserved >= number of Neurons' Fund neurons who actually participated in the swap. +/// 2. Number of Neurons' Fund neurons whose maturity was initially reserved >= number of Neurons' Fund neurons who have been refunded. +/// +/// And the following neuron-local aspects are checked (only for Neurons' Fund neurons): +/// 1. initial_amount_icp_e8s == final_amount_icp_e8s + refunded_amount_icp_e8s +pub async fn validate_sns_swap( + agent: &C, + swap: SwapCanister, +) -> Result<(), AuditError> { + let swap_derived_state = swap.get_derived_state(agent).await?; + + let swap_init = swap.get_init(agent).await?.init.unwrap(); + let governance = GovernanceCanister::new( + Principal::from_text(swap_init.sns_governance_canister_id.clone()).unwrap(), + ); - let sns_governance_canister_id = swap_init.sns_governance_canister_id.clone(); - let sns_governance_canister_id = Principal::from_text(sns_governance_canister_id).unwrap(); - - let metadata = { - let response = agent - .query(&sns_governance_canister_id, "get_metadata") - .with_arg(Encode!(&GetMetadataRequest {}).map_err(|e| e.to_string())?) - .call() - .await - .map_err(|e| e.to_string())?; - Decode!(response.as_slice(), GetMetadataResponse).map_err(|e| e.to_string())? - }; + let metadata = governance.metadata(agent).await?; let sns_name = metadata.name.unwrap(); + println!("sns_name = {}", sns_name); - let nns_governance_canister_id = swap_init.nns_governance_canister_id.clone(); - let nns_governance_canister_id = Principal::from_text(nns_governance_canister_id).unwrap(); let Some(nns_proposal_id) = swap_init.nns_proposal_id.as_ref() else { - return Err(format!( - "{} swap has been created before 1-proposal and cannot be audited using this tool; please \ - audit this swap manually.", - sns_name, - )); - }; - let audit_info = { - let response = agent - .query(&nns_governance_canister_id, "get_neurons_fund_audit_info") - .with_arg( - Encode!(&GetNeuronsFundAuditInfoRequest { - nns_proposal_id: Some(ProposalId { - id: *nns_proposal_id - }), - }) - .map_err(|e| e.to_string())?, - ) - .call() - .await - .map_err(|e| e.to_string())?; - Decode!(response.as_slice(), GetNeuronsFundAuditInfoResponse).map_err(|e| e.to_string())? + return Err(AuditError::CreatedBeforeOneProposal); }; + let audit_info = nns::governance::get_neurons_fund_audit_info( + agent, + ProposalId { + id: *nns_proposal_id, + }, + ) + .await?; + let audit_info = match audit_info.result.clone().unwrap() { get_neurons_fund_audit_info_response::Result::Ok( get_neurons_fund_audit_info_response::Ok { @@ -154,16 +121,9 @@ async fn validate_neurons_fund_sns_swap_participation( get_neurons_fund_audit_info_response::Result::Err(err) => { if err.error_message.starts_with("Neurons Fund data not found") { - return Err(format!( - "{} swap has been created before Matched Funding and cannot be audited using this \ - tool; please audit this swap manually.", - sns_name, - )); + return Err(AuditError::CreatedBeforeMatchedFunding); } else { - return Err(format!( - "Expected GetNeuronsFundAuditInfoResponse for {} to be Ok, got {:?}", - sns_name, audit_info, - )); + return Err(AuditError::AuditInfoNotAvailable(err)); } } }; @@ -187,10 +147,12 @@ async fn validate_neurons_fund_sns_swap_participation( swap_init.neuron_basket_construction_parameters.unwrap(); let buyer_total_icp_e8s = swap_derived_state.buyer_total_icp_e8s.unwrap(); let sns_token_e8s = swap_init.sns_token_e8s.unwrap(); - let sns_tokens_per_icp = u64_to_dec(sns_token_e8s)? / u64_to_dec(buyer_total_icp_e8s)?; + let sns_tokens_per_icp = + u64_to_dec::(sns_token_e8s)? / u64_to_dec::(buyer_total_icp_e8s)?; println!("sns_tokens_per_icp = {:?}", sns_tokens_per_icp); - let sns_neuron_recipes: Vec<_> = list_sns_neuron_recipes(agent, &swap_canister_id) + let sns_neuron_recipes: Vec<_> = swap + .list_all_sns_neuron_recipes(agent) .await .unwrap() .into_iter() @@ -205,9 +167,9 @@ async fn validate_neurons_fund_sns_swap_participation( }) .collect(); - let neurons_fund_refunds = audit_info.neurons_fund_refunds.ok_or_else(|| { - format!("SNS swap {} is not in the final state yet, so `neurons_fund_refunds` is not specified.", sns_name) - })?; + let neurons_fund_refunds = audit_info + .neurons_fund_refunds + .ok_or(AuditError::SwapNotInFinalState)?; let refunded_neuron_portions = neurons_fund_refunds.neurons_fund_neuron_portions; let mut refunded_amounts_per_controller = BTreeMap::new(); for refunded_neuron_portion in refunded_neuron_portions.iter() { @@ -219,9 +181,9 @@ async fn validate_neurons_fund_sns_swap_participation( .or_insert(new_amount_icp_e8s); } - let initial_neurons_fund_participation = audit_info.initial_neurons_fund_participation.ok_or_else(|| { - format!("SNS swap {} is not in the final state yet, so `initial_neurons_fund_participation` is not specified.", sns_name) - })?; + let initial_neurons_fund_participation = audit_info + .initial_neurons_fund_participation + .ok_or(AuditError::SwapNotInFinalState)?; let initial_neuron_portions = initial_neurons_fund_participation .neurons_fund_reserves .unwrap() @@ -236,9 +198,9 @@ async fn validate_neurons_fund_sns_swap_participation( .or_insert(new_amount_icp_e8s); } - let final_neurons_fund_participation = audit_info.final_neurons_fund_participation.ok_or_else(|| { - format!("SNS swap {} is not in the final state yet, so `final_neurons_fund_participation` is not specified.", sns_name) - })?; + let final_neurons_fund_participation = audit_info + .final_neurons_fund_participation + .ok_or(AuditError::SwapNotInFinalState)?; let final_neuron_portions = final_neurons_fund_participation .neurons_fund_reserves .unwrap() @@ -325,12 +287,12 @@ async fn validate_neurons_fund_sns_swap_participation( for (controller, nns_neurons) in investment_per_controller_icp_e8s.iter() { let amount_icp_e8s = nns_neurons.iter().sum::(); - let amount_icp_e8s = u64_to_dec(amount_icp_e8s)?; + let amount_icp_e8s = u64_to_dec::(amount_icp_e8s)?; let sns_neurons = sns_neuron_recipes_per_controller .get(controller) .expect("All Neuron's Fund participants should have SNS neuron recipes."); let amount_sns_e8s = sns_neurons.iter().sum::(); - let amount_sns_e8s = u64_to_dec(amount_sns_e8s)?; + let amount_sns_e8s = u64_to_dec::(amount_sns_e8s)?; let absolute_error_sns_e8s = (amount_icp_e8s * sns_tokens_per_icp - amount_sns_e8s).abs(); let error_per_cent = (Decimal::new(100, 0) * absolute_error_sns_e8s) / amount_sns_e8s; let nns_neurons_str = nns_neurons @@ -354,27 +316,3 @@ async fn validate_neurons_fund_sns_swap_participation( Ok(()) } - -/// Validate that the NNS (identified by `nns_url`) and an SNS instance (identified by -/// `swap_canister_id`) agree on how the SNS neurons of a successful swap have been allocated. -/// -/// This function performs a best-effort audit, e.g., there is no completeness guarantee for -/// the checks. -/// -/// Currently, the following SNS-global aspects are checked: -/// 1. Number of Neurons' Fund neurons whose maturity was initially reserved >= number of Neurons' Fund neurons who actually participated in the swap. -/// 2. Number of Neurons' Fund neurons whose maturity was initially reserved >= number of Neurons' Fund neurons who have been refunded. -/// -/// And the following neuron-local aspects are checked (only for Neurons' Fund neurons): -/// 1. initial_amount_icp_e8s == final_amount_icp_e8s + refunded_amount_icp_e8s -pub async fn validate_sns_swap(nns_url: &str, swap_canister_id: Principal) -> Result<(), String> { - let agent = Agent::builder() - .with_url(nns_url) - .with_verify_query_signatures(false) - .build() - .map_err(|e| e.to_string())?; - - validate_neurons_fund_sns_swap_participation(&agent, swap_canister_id).await?; - - Ok(()) -} diff --git a/rs/sns/audit/src/main.rs b/rs/sns/audit/src/main.rs index d2be859b93d..0ee4c4dc1ce 100644 --- a/rs/sns/audit/src/main.rs +++ b/rs/sns/audit/src/main.rs @@ -1,14 +1,24 @@ +use anyhow::bail; use candid::Principal; +use ic_agent::Agent; +use ic_nervous_system_agent::sns::swap::SwapCanister; use ic_sns_audit::validate_sns_swap; #[tokio::main] -async fn main() -> Result<(), String> { +async fn main() -> anyhow::Result<()> { let args: Vec<_> = std::env::args().collect(); if args.len() != 3 { - return Err("Please specify NNS_URL and SWAP_CANISTER_ID as CLI arguments.".to_string()); + bail!("Please specify NNS_URL and SWAP_CANISTER_ID as CLI arguments."); } let nns_url = &args[1]; let swap_canister_id = &args[2]; - let swap_canister_id = Principal::from_text(swap_canister_id).unwrap(); - validate_sns_swap(nns_url, swap_canister_id).await + let swap = SwapCanister::new(Principal::from_text(swap_canister_id).unwrap()); + + let agent = Agent::builder() + .with_url(nns_url) + .with_verify_query_signatures(false) + .build()?; + + validate_sns_swap(&agent, swap).await?; + Ok(()) }