Skip to content

Commit

Permalink
rewrite sns-audit to use ic-nervous-system-agent
Browse files Browse the repository at this point in the history
  • Loading branch information
anchpop committed Sep 30, 2024
1 parent 7937ec8 commit 2917240
Show file tree
Hide file tree
Showing 6 changed files with 119 additions and 155 deletions.
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions rs/nns/governance/api/src/pb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion rs/sns/audit/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -18,6 +20,7 @@ DEPENDENCIES = [
"@crate_index//:serde",
"@crate_index//:serde_json",
"@crate_index//:textplots",
"@crate_index//:thiserror",
"@crate_index//:tokio",
]

Expand All @@ -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",
],
)
4 changes: 4 additions & 0 deletions rs/sns/audit/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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 }
238 changes: 88 additions & 150 deletions rs/sns/audit/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<CallCanistersError: std::error::Error + 'static> {
#[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<C: CallCanisters>(x: u64) -> Result<Decimal, AuditError<C::Error>> {
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);
Expand Down Expand Up @@ -46,105 +74,44 @@ fn audit_check(text: &str, condition: bool) {
}
}

async fn list_sns_neuron_recipes(
agent: &Agent,
swap_canister_id: &Principal,
) -> Result<Vec<SnsNeuronRecipe>, String> {
let mut sns_neuron_recipes: Vec<SnsNeuronRecipe> = 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<C: CallCanisters>(
agent: &C,
swap: SwapCanister,
) -> Result<(), AuditError<C::Error>> {
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 {
Expand All @@ -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));
}
}
};
Expand All @@ -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::<C>(sns_token_e8s)? / u64_to_dec::<C>(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()
Expand All @@ -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() {
Expand All @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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::<u64>();
let amount_icp_e8s = u64_to_dec(amount_icp_e8s)?;
let amount_icp_e8s = u64_to_dec::<C>(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::<u64>();
let amount_sns_e8s = u64_to_dec(amount_sns_e8s)?;
let amount_sns_e8s = u64_to_dec::<C>(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
Expand All @@ -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(())
}
Loading

0 comments on commit 2917240

Please sign in to comment.