canic_host/icp/
balance.rs1use super::{error::IcpCommandError, model::IcpCli, run::run_json};
8
9use serde::Deserialize;
10use thiserror::Error as ThisError;
11
12#[derive(Debug, ThisError)]
18pub enum IcpBalanceError {
19 #[error("ICP CLI returned an invalid {unit} balance: {value}")]
20 InvalidAmount { unit: &'static str, value: String },
21
22 #[error("ICP balance does not fit e8s: {value}")]
23 IcpAmountOverflow { value: String },
24
25 #[error(transparent)]
26 Icp(#[from] IcpCommandError),
27}
28
29#[derive(Deserialize)]
30#[serde(deny_unknown_fields)]
31struct BalanceOutput {
32 balance: String,
33}
34
35impl IcpCli {
36 pub fn identity_cycles_balance(&self) -> Result<u128, IcpBalanceError> {
38 let mut command = self.command();
39 command.args(["cycles", "balance", "--json"]);
40 self.add_target_args(&mut command);
41 let output = run_json::<BalanceOutput>(&mut command)?;
42 parse_cycles(&output.balance)
43 }
44
45 pub fn identity_icp_balance_e8s(&self) -> Result<u64, IcpBalanceError> {
47 let mut command = self.command();
48 command.args(["token", "balance", "--json"]);
49 self.add_target_args(&mut command);
50 let output = run_json::<BalanceOutput>(&mut command)?;
51 parse_icp_e8s(&output.balance)
52 }
53}
54
55fn parse_cycles(value: &str) -> Result<u128, IcpBalanceError> {
56 let amount = strip_unit(value, "cycles")?;
57 amount
58 .replace('_', "")
59 .parse()
60 .map_err(|_| invalid_amount("cycles", value))
61}
62
63fn parse_icp_e8s(value: &str) -> Result<u64, IcpBalanceError> {
64 const E8S_PER_ICP: u64 = 100_000_000;
65
66 let amount = strip_unit(value, "ICP")?.replace('_', "");
67 let mut parts = amount.split('.');
68 let whole = parts
69 .next()
70 .filter(|whole| !whole.is_empty())
71 .and_then(|whole| whole.parse::<u64>().ok())
72 .ok_or_else(|| invalid_amount("ICP", value))?;
73 let fraction = parts.next().unwrap_or_default();
74 if parts.next().is_some()
75 || fraction.len() > 8
76 || !fraction.bytes().all(|byte| byte.is_ascii_digit())
77 {
78 return Err(invalid_amount("ICP", value));
79 }
80 let fractional_e8s = if fraction.is_empty() {
81 0
82 } else {
83 let padding =
84 u32::try_from(8_usize - fraction.len()).map_err(|_| invalid_amount("ICP", value))?;
85 fraction
86 .parse::<u64>()
87 .map_err(|_| invalid_amount("ICP", value))?
88 .checked_mul(10_u64.pow(padding))
89 .ok_or_else(|| IcpBalanceError::IcpAmountOverflow {
90 value: value.to_string(),
91 })?
92 };
93 whole
94 .checked_mul(E8S_PER_ICP)
95 .and_then(|whole_e8s| whole_e8s.checked_add(fractional_e8s))
96 .ok_or_else(|| IcpBalanceError::IcpAmountOverflow {
97 value: value.to_string(),
98 })
99}
100
101fn strip_unit<'a>(value: &'a str, unit: &'static str) -> Result<&'a str, IcpBalanceError> {
102 value
103 .trim()
104 .strip_suffix(unit)
105 .map(str::trim)
106 .filter(|amount| !amount.is_empty())
107 .ok_or_else(|| invalid_amount(unit, value))
108}
109
110fn invalid_amount(unit: &'static str, value: &str) -> IcpBalanceError {
111 IcpBalanceError::InvalidAmount {
112 unit,
113 value: value.to_string(),
114 }
115}
116
117#[cfg(test)]
121mod tests {
122 use super::*;
123
124 const ICP_CLI_1_3_CYCLES_BALANCE_JSON: &str = r#"{"balance":"3_519_900_000_000 cycles"}"#;
125 const ICP_CLI_1_3_TOKEN_BALANCE_JSON: &str = r#"{"balance":"1.23456780 ICP"}"#;
126
127 #[test]
128 fn decodes_icp_cli_one_three_balance_json_goldens() {
129 let cycles: BalanceOutput = serde_json::from_str(ICP_CLI_1_3_CYCLES_BALANCE_JSON)
130 .expect("ICP CLI 1.3 cycles balance JSON");
131 let token: BalanceOutput = serde_json::from_str(ICP_CLI_1_3_TOKEN_BALANCE_JSON)
132 .expect("ICP CLI 1.3 token balance JSON");
133
134 assert_eq!(parse_cycles(&cycles.balance).unwrap(), 3_519_900_000_000);
135 assert_eq!(parse_icp_e8s(&token.balance).unwrap(), 123_456_780);
136 }
137
138 #[test]
139 fn parses_exact_cycles_ledger_amounts() {
140 assert_eq!(
141 parse_cycles("3_519_900_000_000 cycles").unwrap(),
142 3_519_900_000_000
143 );
144 assert_eq!(parse_cycles("0 cycles").unwrap(), 0);
145 assert!(matches!(
146 parse_cycles("1.5 cycles"),
147 Err(IcpBalanceError::InvalidAmount { unit: "cycles", .. })
148 ));
149 }
150
151 #[test]
152 fn parses_exact_icp_amounts_into_e8s() {
153 assert_eq!(parse_icp_e8s("1.23456780 ICP").unwrap(), 123_456_780);
154 assert_eq!(parse_icp_e8s("1 ICP").unwrap(), 100_000_000);
155 assert_eq!(parse_icp_e8s("0.1 ICP").unwrap(), 10_000_000);
156 assert!(matches!(
157 parse_icp_e8s("0.000000001 ICP"),
158 Err(IcpBalanceError::InvalidAmount { unit: "ICP", .. })
159 ));
160 }
161}