diff --git a/CHANGELOG.md b/CHANGELOG.md index c02b392..d7b5e50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased - Overhauled PEM auth. PEM files are now password-protected by default, and must be used instead of seed files. Passwords can be provided interactively or with `--password-file`. Keys can be generated unencrypted with `quill generate --storage-mode plaintext`, and encrypted keys can be converted to plaintext with `quill decrypt-pem`. -- Overhauled output format. All commands besides `quill sns` should have human-readable output instead of candid IDL. Candid IDL format can be forced with `--raw`. +- Overhauled output format. All commands should have human-readable output instead of candid IDL. Candid IDL format can be forced with `--raw`. - Added support for setting the install mode for UpgradeSnsControlledCanister proposals. diff --git a/Cargo.lock b/Cargo.lock index fa80bf8..33a548b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -373,9 +373,9 @@ checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" [[package]] name = "bigdecimal" -version = "0.4.3" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9324c8014cd04590682b34f1e9448d38f0674d0f7b2dc553331016ef0e4e9ebc" +checksum = "51d712318a27c7150326677b321a5fa91b55f6d9034ffd67f20319e147d40cee" dependencies = [ "autocfg", "libm", diff --git a/src/lib/format/mod.rs b/src/lib/format/mod.rs index 3302963..aeb5c03 100644 --- a/src/lib/format/mod.rs +++ b/src/lib/format/mod.rs @@ -1,5 +1,11 @@ +use bigdecimal::BigDecimal; +use candid::{Nat, Principal}; use chrono::{DateTime, TimeZone, Utc}; +use icrc_ledger_types::icrc1::account::Account; use itertools::Itertools; +use num_bigint::ToBigInt; + +use super::ParsedAccount; pub mod ckbtc; pub mod gtc; @@ -7,6 +13,10 @@ pub mod icp_ledger; pub mod icrc1; pub mod nns_governance; pub mod registry; +pub mod sns_governance; +pub mod sns_root; +pub mod sns_swap; +pub mod sns_wasm; pub fn format_datetime(datetime: DateTime) -> String { format!("{} UTC", datetime.format("%b %d %Y %X")) @@ -52,8 +62,53 @@ pub fn format_duration_seconds(mut seconds: u64) -> String { .to_string() } +pub fn icrc1_account(owner: Principal, subaccount: Option<[u8; 32]>) -> ParsedAccount { + ParsedAccount(Account { owner, subaccount }) +} + +pub fn format_t_cycles(cycles: Nat) -> String { + let t_cycles = BigDecimal::new(cycles.0.into(), 12); + let e10 = t_cycles.digits(); + if e10 < 14 { + format!("{:.1}T", t_cycles) + } else { + format!("{:.0}T", t_cycles) + } +} + +pub fn format_n_cycles(cycles: Nat) -> String { + let e10 = BigDecimal::from(cycles.0.to_bigint().unwrap()).digits(); + if e10 < 4 { + return cycles.to_string(); + } + let unit = (e10 - 1) / 3; + let letter = b"KMBTQ"[unit as usize - 1] as char; + let scale = unit * 3; + let printable = BigDecimal::new(cycles.0.into(), scale as i64); + if e10 - scale == 1 { + format!("{printable:.1}{letter}") + } else { + format!("{printable:.0}{letter}") + } +} + #[test] fn magic_durations() { assert_eq!(format_duration_seconds(15_778_800), "6 months"); assert_eq!(format_duration_seconds(252_460_800), "8 years"); } + +#[test] +fn cycle_units() { + assert_eq!(format_t_cycles(100_000_000_000_u64.into()), "0.1T"); + assert_eq!(format_t_cycles(1_100_000_000_000_u64.into()), "1.1T"); + assert_eq!(format_t_cycles(10_100_000_000_000_u64.into()), "10T"); + assert_eq!(format_t_cycles(1_000_000_000_000_000_u64.into()), "1000T"); + assert_eq!(format_n_cycles(1_000_000_000_000_000_u64.into()), "1.0Q"); + assert_eq!(format_n_cycles(100_000_000_000_u64.into()), "100B"); + assert_eq!(format_n_cycles(10_100_000_000_u64.into()), "10B"); + assert_eq!(format_n_cycles(1_100_000_000_u64.into()), "1.1B"); + assert_eq!(format_n_cycles(1_000_u64.into()), "1.0K"); + assert_eq!(format_n_cycles(1_100_u64.into()), "1.1K"); + assert_eq!(format_n_cycles(100_u64.into()), "100"); +} diff --git a/src/lib/format/sns_governance.rs b/src/lib/format/sns_governance.rs new file mode 100644 index 0000000..0f86b6a --- /dev/null +++ b/src/lib/format/sns_governance.rs @@ -0,0 +1,64 @@ +use anyhow::Context; +use candid::Decode; +use ic_sns_governance::pb::v1::{ + manage_neuron_response::Command, GovernanceError, ManageNeuronResponse, +}; + +use crate::lib::{e8s_to_tokens, AnyhowResult}; + +pub fn display_manage_neuron(blob: &[u8]) -> AnyhowResult { + let response = Decode!(blob, ManageNeuronResponse)?; + let command = response.command.context("command was null")?; + let fmt = match command { + Command::Error(error) => display_governance_error(error), + Command::Configure(_) => "Neuron successfully configured".to_string(), + Command::RegisterVote(_) => "Successfully voted".to_string(), + Command::Follow(_) => "Successfully set following relationship".to_string(), + Command::AddNeuronPermission(_) => "Successfully added neuron permissions".to_string(), + Command::RemoveNeuronPermission(_) => "Successfully removed neuron permissions".to_string(), + Command::Disburse(c) => format!( + "Successfully disbursed ICP at block index {}", + c.transfer_block_height + ), + Command::ClaimOrRefresh(c) => { + if let Some(id) = c.refreshed_neuron_id { + format!("Successfully updated the stake of neuron {id}") + } else { + "Successfully updated the stake of unknown neuron".to_string() + } + } + Command::DisburseMaturity(c) => format!( + "Successfully disbursed {} maturity", + c.amount_deducted_e8s() + ), + Command::MakeProposal(c) => { + if let Some(id) = c.proposal_id { + format!("Successfully created new proposal with ID {id}", id = id.id) + } else { + "Successfully created new proposal with unknown ID".to_string() + } + } + Command::MergeMaturity(c) => format!( + "Successfully merged {merged} maturity (total stake now {total})", + merged = e8s_to_tokens(c.merged_maturity_e8s.into()), + total = e8s_to_tokens(c.new_stake_e8s.into()) + ), + Command::Split(c) => { + if let Some(id) = c.created_neuron_id { + format!("Neuron successfully split off to new neuron {id}") + } else { + "Neuron successfully split off to unknown new neuron".to_string() + } + } + Command::StakeMaturity(c) => format!( + "Successfully staked maturity ({staked} staked maturity total, {remaining} unstaked)", + staked = e8s_to_tokens(c.staked_maturity_e8s.into()), + remaining = e8s_to_tokens(c.maturity_e8s.into()) + ), + }; + Ok(fmt) +} + +pub fn display_governance_error(err: GovernanceError) -> String { + format!("SNS governance error: {}", err.error_message) +} diff --git a/src/lib/format/sns_root.rs b/src/lib/format/sns_root.rs new file mode 100644 index 0000000..8c383b7 --- /dev/null +++ b/src/lib/format/sns_root.rs @@ -0,0 +1,114 @@ +use candid::{Decode, Principal}; +use ic_sns_root::{CanisterSummary, GetSnsCanistersSummaryResponse}; +use indicatif::HumanBytes; +use itertools::Itertools; +use std::fmt::Write; + +use crate::lib::{format::format_n_cycles, AnyhowResult}; + +use super::format_t_cycles; + +pub fn display_canisters_summary(blob: &[u8]) -> AnyhowResult { + let response = Decode!(blob, GetSnsCanistersSummaryResponse)?; + let root = response.root_canister_summary().canister_id().0; + let governance = response.governance_canister_summary().canister_id().0; + let mut fmt = format!( + "\ +System canisters: + +Root: +{root} + +Governance: +{governance} + +Ledger: +{ledger} + +Index: +{index} + +Swap: +{swap}", + root = display_canister_summary(response.root_canister_summary(), root, governance)?, + governance = + display_canister_summary(response.governance_canister_summary(), root, governance)?, + ledger = display_canister_summary(response.ledger_canister_summary(), root, governance)?, + index = display_canister_summary(response.index_canister_summary(), root, governance)?, + swap = display_canister_summary(response.swap_canister_summary(), root, governance)?, + ); + if !response.dapps.is_empty() { + fmt.push_str("\n\nDapp canisters:"); + for dapp in &response.dapps { + write!( + fmt, + "\n\n{}", + display_canister_summary(dapp, root, governance)? + )?; + } + } + if !response.archives.is_empty() { + fmt.push_str("\n\nArchive canisters:"); + for archive in &response.archives { + write!( + fmt, + "\n\n{}", + display_canister_summary(archive, root, governance)? + )?; + } + } + Ok(fmt) +} + +fn display_canister_summary( + summary: &CanisterSummary, + root: Principal, + governance: Principal, +) -> AnyhowResult { + const NNS_ROOT: Principal = + Principal::from_slice(&[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x01, 0x01]); + let status = summary.status(); + let canister_id = summary.canister_id(); + let mut fmt = format!( + "\ +Canister ID: {canister_id}, status: {status:?} +Cycles: {cycles}, memory usage: {memory}", + status = status.status(), + cycles = format_t_cycles(status.cycles.clone()), + memory = HumanBytes(status.memory_size().get()) + ); + if let Some(hash) = &status.module_hash { + write!(fmt, "\nInstalled module: hash {}", hex::encode(hash))?; + } + let freezing = &status.settings.freezing_threshold; + let idle = &status.idle_cycles_burned_per_day; + let freezing_time = freezing.clone() / idle.clone(); + write!( + fmt, + " +Freezing threshold: {freezing} cycles ({freezing_time} days at current idle usage of {idle}/day) +Memory allocation: {memory}%, compute allocation: {compute}% +Controllers: {controllers}", + freezing = format_t_cycles(freezing.clone()), + idle = format_n_cycles(idle.clone()), + memory = status.settings.memory_allocation, + compute = status.settings.compute_allocation, + controllers = status + .settings + .controllers + .iter() + .format_with(", ", |c, f| if c.0 == NNS_ROOT { + f(&"NNS root") + } else if c.0 == governance { + f(&"SNS governance") + } else if c.0 == root { + f(&"SNS root") + } else if *c == canister_id { + f(&"self") + } else { + f(c) + }) + )?; + + Ok(fmt) +} diff --git a/src/lib/format/sns_swap.rs b/src/lib/format/sns_swap.rs new file mode 100644 index 0000000..d9472c3 --- /dev/null +++ b/src/lib/format/sns_swap.rs @@ -0,0 +1,150 @@ +use anyhow::Context; +use candid::Decode; +use ic_sns_swap::pb::v1::{ + error_refund_icp_response::Result as RefundResult, + new_sale_ticket_response::{err::Type, Result as TicketResult}, + ErrorRefundIcpResponse, GetBuyerStateResponse, NewSaleTicketResponse, + RefreshBuyerTokensResponse, +}; +use std::fmt::Write; + +use crate::lib::{e8s_to_tokens, AnyhowResult}; + +use super::{format_timestamp_seconds, icrc1_account}; + +pub fn display_get_buyer_state(blob: &[u8]) -> AnyhowResult { + let response = Decode!(blob, GetBuyerStateResponse)?; + let state = response.buyer_state.context("buyer state was null")?; + let fmt = format!( + "Total participation: {icp} ICP", + icp = e8s_to_tokens(state.amount_icp_e8s().into()) + ); + Ok(fmt) +} + +pub fn display_refund(blob: &[u8]) -> AnyhowResult { + let response = Decode!(blob, ErrorRefundIcpResponse)?; + let result = response.result.context("result was null")?; + let fmt = match result { + RefundResult::Ok(transfer) => { + if let Some(index) = transfer.block_height { + format!("Refunded ICP at block index {index}") + } else { + "Refunded ICP (unknown block index)".to_string() + } + } + RefundResult::Err(err) => format!("Refund error: {}", err.description()), + }; + Ok(fmt) +} + +pub fn display_new_sale_ticket(blob: &[u8]) -> AnyhowResult { + let response = Decode!(blob, NewSaleTicketResponse)?; + let result = response.result.context("result was null")?; + let fmt = match result { + TicketResult::Ok(ticket) => { + if let Some(ticket) = ticket.ticket { + let mut fmt = format!( + "\ +Successfully created ticket with ID {id} +Creation time: {time} +Ticket amount: {amount} ICP", + id = ticket.ticket_id, + time = format_timestamp_seconds(ticket.creation_time), + amount = e8s_to_tokens(ticket.amount_icp_e8s.into()), + ); + if let Some(account) = ticket.account { + if let Some(owner) = account.owner { + write!( + fmt, + "\nTicket owner: {}", + icrc1_account( + owner.into(), + Some( + account + .subaccount() + .try_into() + .context("subaccount had wrong length")? + ) + ) + )?; + } + } + fmt + } else { + "Ticket successfully created (no data available)".to_string() + } + } + TicketResult::Err(err) => match err.error_type() { + Type::InvalidPrincipal => { + "Ticket creation error: This principal is forbidden from creating tickets" + .to_string() + } + Type::InvalidSubaccount => { + "Ticket creation error: Invalid subaccount, not 32 bytes (64 hex digits)" + .to_string() + } + Type::InvalidUserAmount => { + if let Some(user_amount) = err.invalid_user_amount { + format!("Ticket creation error: Invalid amount, must be between {min} and {max} ICP", min = e8s_to_tokens(user_amount.min_amount_icp_e8s_included.into()), max = e8s_to_tokens(user_amount.max_amount_icp_e8s_included.into())) + } else { + "Ticket creation error: Invalid amount, not within the required range" + .to_string() + } + } + Type::SaleClosed => { + "Ticket creation error: Token sale has already been closed".to_string() + } + Type::SaleNotOpen => "Ticket creation error: Token sale has not yet opened".to_string(), + Type::TicketExists => { + if let Some(ticket) = err.existing_ticket { + let mut fmt = format!( + "\ +Ticket creation error: An open ticket from this account already exists. +Ticket ID: {id} +Creation time: {time} +Ticket amount: {amount} ICP", + id = ticket.ticket_id, + time = format_timestamp_seconds(ticket.creation_time), + amount = e8s_to_tokens(ticket.amount_icp_e8s.into()) + ); + if let Some(account) = ticket.account { + if let Some(owner) = account.owner { + write!( + fmt, + "\nTicket owner: {}", + icrc1_account( + owner.into(), + Some( + account + .subaccount() + .try_into() + .context("subaccount had wrong length")? + ) + ) + )?; + } + } + fmt + } else { + "Ticket creation error: An open ticket from this account already exists." + .to_string() + } + } + Type::Unspecified => "Ticket creation error: unknown".to_string(), + }, + }; + Ok(fmt) +} + +pub fn display_refresh_buyer_tokens(blob: &[u8]) -> AnyhowResult { + let response = Decode!(blob, RefreshBuyerTokensResponse)?; + let fmt = format!( + "\ +Ticket balance: {ticket} ICP +Total transferred ICP: {total} ICP", + ticket = e8s_to_tokens(response.icp_accepted_participation_e8s.into()), + total = e8s_to_tokens(response.icp_ledger_account_balance_e8s.into()), + ); + Ok(fmt) +} diff --git a/src/lib/format/sns_wasm.rs b/src/lib/format/sns_wasm.rs new file mode 100644 index 0000000..eb41115 --- /dev/null +++ b/src/lib/format/sns_wasm.rs @@ -0,0 +1,33 @@ +use std::fmt::Write; + +use anyhow::Context; +use candid::Decode; +use ic_sns_wasm::pb::v1::ListDeployedSnsesResponse; + +use crate::lib::AnyhowResult; + +pub fn display_list_snses(blob: &[u8]) -> AnyhowResult { + let response = Decode!(blob, ListDeployedSnsesResponse)?; + let mut fmt = String::new(); + for sns in response.instances { + let root = sns.root_canister_id.context("root canister was null")?; + writeln!( + fmt, + "https://dashboard.internetcomputer.org/sns/{root}\nRoot canister: {root}" + )?; + if let Some(ledger) = sns.ledger_canister_id { + writeln!(fmt, "Ledger canister: {ledger}")?; + } + if let Some(governance) = sns.governance_canister_id { + writeln!(fmt, "Governance canister: {governance}")?; + } + if let Some(swap) = sns.swap_canister_id { + writeln!(fmt, "Swap canister: {swap}")?; + } + if let Some(index) = sns.index_canister_id { + writeln!(fmt, "Index canister: {index}")?; + } + fmt.push('\n'); + } + Ok(fmt) +} diff --git a/src/lib/mod.rs b/src/lib/mod.rs index e6fe4dc..52bbfdb 100644 --- a/src/lib/mod.rs +++ b/src/lib/mod.rs @@ -263,6 +263,10 @@ pub fn display_response( } _ => get_idl_string(blob, canister_id, role, method_name, part), }, + ROLE_SNS_GOVERNANCE => match method_name { + "manage_neuron" => format::sns_governance::display_manage_neuron(blob), + _ => get_idl_string(blob, canister_id, role, method_name, part), + }, ROLE_NNS_LEDGER => match method_name { "transfer" => format::icp_ledger::display_transfer(blob), "send_dfx" => format::icp_ledger::display_send_dfx(blob), @@ -276,6 +280,13 @@ pub fn display_response( "icrc1_balance_of" => format::icrc1::display_balance(blob), _ => get_idl_string(blob, canister_id, role, method_name, part), }, + ROLE_SNS_SWAP => match method_name { + "get_buyer_state" => format::sns_swap::display_get_buyer_state(blob), + "error_refund_icp" => format::sns_swap::display_refund(blob), + "new_sale_ticket" => format::sns_swap::display_new_sale_ticket(blob), + "refresh_buyer_tokens" => format::sns_swap::display_refresh_buyer_tokens(blob), + _ => get_idl_string(blob, canister_id, role, method_name, part), + }, ROLE_CKBTC_MINTER => match method_name { "update_balance" => format::ckbtc::display_update_balance(blob), "retrieve_btc" => format::ckbtc::display_retrieve_btc(blob), @@ -287,12 +298,20 @@ pub fn display_response( "claim_neurons" => format::gtc::format_claim_neurons(blob), _ => get_idl_string(blob, canister_id, role, method_name, part), }, + ROLE_SNS_ROOT => match method_name { + "get_sns_canisters_summary" => format::sns_root::display_canisters_summary(blob), + _ => get_idl_string(blob, canister_id, role, method_name, part), + }, ROLE_NNS_REGISTRY => match method_name { "update_node_operator_config_directly" => { format::registry::display_update_node_operator_config_directly(blob) } _ => get_idl_string(blob, canister_id, role, method_name, part), }, + ROLE_SNS_WASM => match method_name { + "list_deployed_snses" => format::sns_wasm::display_list_snses(blob), + _ => get_idl_string(blob, canister_id, role, method_name, part), + }, _ => get_idl_string(blob, canister_id, role, method_name, part), } }