Skip to main content

canic_host/icp/
balance.rs

1//! Module: icp::balance
2//!
3//! Responsibility: observe the selected identity's default ICP and cycles accounts.
4//! Does not own: funding sufficiency policy, Fleet authority, or deployment planning.
5//! Boundary: parses the exact machine-readable balance output owned by ICP CLI.
6
7use super::{error::IcpCommandError, model::IcpCli, run::run_json};
8
9use serde::Deserialize;
10use thiserror::Error as ThisError;
11
12///
13/// IcpBalanceError
14///
15/// Typed failure while observing an identity ledger balance through ICP CLI.
16///
17#[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    /// Observe the selected identity's default Cycles Ledger account.
37    pub fn identity_cycles_balance(&self) -> Result<u128, IcpBalanceError> {
38        let mut command = self.request_command();
39        command.args(["cycles", "balance", "--json"]);
40        self.add_target_args(&mut command);
41        self.record_remote_call();
42        let output = run_json::<BalanceOutput>(&mut command, self)?;
43        parse_cycles(&output.balance)
44    }
45
46    /// Observe the selected identity's default ICP Ledger account in e8s.
47    pub fn identity_icp_balance_e8s(&self) -> Result<u64, IcpBalanceError> {
48        let mut command = self.request_command();
49        command.args(["token", "balance", "--json"]);
50        self.add_target_args(&mut command);
51        self.record_remote_call();
52        let output = run_json::<BalanceOutput>(&mut command, self)?;
53        parse_icp_e8s(&output.balance)
54    }
55}
56
57fn parse_cycles(value: &str) -> Result<u128, IcpBalanceError> {
58    let amount = strip_unit(value, "cycles")?;
59    amount
60        .replace('_', "")
61        .parse()
62        .map_err(|_| invalid_amount("cycles", value))
63}
64
65fn parse_icp_e8s(value: &str) -> Result<u64, IcpBalanceError> {
66    const E8S_PER_ICP: u64 = 100_000_000;
67
68    let amount = strip_unit(value, "ICP")?.replace('_', "");
69    let mut parts = amount.split('.');
70    let whole = parts
71        .next()
72        .filter(|whole| !whole.is_empty())
73        .and_then(|whole| whole.parse::<u64>().ok())
74        .ok_or_else(|| invalid_amount("ICP", value))?;
75    let fraction = parts.next().unwrap_or_default();
76    if parts.next().is_some()
77        || fraction.len() > 8
78        || !fraction.bytes().all(|byte| byte.is_ascii_digit())
79    {
80        return Err(invalid_amount("ICP", value));
81    }
82    let fractional_e8s = if fraction.is_empty() {
83        0
84    } else {
85        let padding =
86            u32::try_from(8_usize - fraction.len()).map_err(|_| invalid_amount("ICP", value))?;
87        fraction
88            .parse::<u64>()
89            .map_err(|_| invalid_amount("ICP", value))?
90            .checked_mul(10_u64.pow(padding))
91            .ok_or_else(|| IcpBalanceError::IcpAmountOverflow {
92                value: value.to_string(),
93            })?
94    };
95    whole
96        .checked_mul(E8S_PER_ICP)
97        .and_then(|whole_e8s| whole_e8s.checked_add(fractional_e8s))
98        .ok_or_else(|| IcpBalanceError::IcpAmountOverflow {
99            value: value.to_string(),
100        })
101}
102
103fn strip_unit<'a>(value: &'a str, unit: &'static str) -> Result<&'a str, IcpBalanceError> {
104    value
105        .trim()
106        .strip_suffix(unit)
107        .map(str::trim)
108        .filter(|amount| !amount.is_empty())
109        .ok_or_else(|| invalid_amount(unit, value))
110}
111
112fn invalid_amount(unit: &'static str, value: &str) -> IcpBalanceError {
113    IcpBalanceError::InvalidAmount {
114        unit,
115        value: value.to_string(),
116    }
117}
118
119// -----------------------------------------------------------------------------
120// Tests
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    const ICP_CLI_1_5_CYCLES_BALANCE_JSON: &str = r#"{"balance":"3_519_900_000_000 cycles"}"#;
127    const ICP_CLI_1_5_TOKEN_BALANCE_JSON: &str = r#"{"balance":"1.23456780 ICP"}"#;
128
129    #[test]
130    fn decodes_icp_cli_one_five_balance_json_goldens() {
131        let cycles: BalanceOutput = serde_json::from_str(ICP_CLI_1_5_CYCLES_BALANCE_JSON)
132            .expect("ICP CLI 1.5 cycles balance JSON");
133        let token: BalanceOutput = serde_json::from_str(ICP_CLI_1_5_TOKEN_BALANCE_JSON)
134            .expect("ICP CLI 1.5 token balance JSON");
135
136        assert_eq!(parse_cycles(&cycles.balance).unwrap(), 3_519_900_000_000);
137        assert_eq!(parse_icp_e8s(&token.balance).unwrap(), 123_456_780);
138    }
139
140    #[test]
141    fn parses_exact_cycles_ledger_amounts() {
142        assert_eq!(
143            parse_cycles("3_519_900_000_000 cycles").unwrap(),
144            3_519_900_000_000
145        );
146        assert_eq!(parse_cycles("0 cycles").unwrap(), 0);
147        assert!(matches!(
148            parse_cycles("1.5 cycles"),
149            Err(IcpBalanceError::InvalidAmount { unit: "cycles", .. })
150        ));
151    }
152
153    #[test]
154    fn parses_exact_icp_amounts_into_e8s() {
155        assert_eq!(parse_icp_e8s("1.23456780 ICP").unwrap(), 123_456_780);
156        assert_eq!(parse_icp_e8s("1 ICP").unwrap(), 100_000_000);
157        assert_eq!(parse_icp_e8s("0.1 ICP").unwrap(), 10_000_000);
158        assert!(matches!(
159            parse_icp_e8s("0.000000001 ICP"),
160            Err(IcpBalanceError::InvalidAmount { unit: "ICP", .. })
161        ));
162    }
163}