Skip to main content

soroban_cli/commands/token/
balance.rs

1use std::ffi::OsString;
2
3use clap::Parser;
4
5use crate::{
6    commands::{
7        contract::invoke,
8        global,
9        token::args::{self, OutputFormat},
10    },
11    config::{
12        self, locator, network, sign_with,
13        token::{ResolvedToken, UnresolvedToken},
14        UnresolvedContract, UnresolvedScAddress,
15    },
16    fixed_point::FixedPoint,
17    output::Output,
18};
19
20#[derive(Debug, Parser, Clone)]
21#[group(skip)]
22pub struct Cmd {
23    /// The token to query: a contract id or alias, `native`, or a classic asset
24    /// as `CODE:ISSUER`.
25    #[arg(long = "id")]
26    pub id: UnresolvedToken,
27
28    /// Account or contract whose balance to read.
29    #[arg(long)]
30    pub account: UnresolvedScAddress,
31
32    /// Format the balance as a decimal using the token's `decimals`, instead of
33    /// the raw smallest unit (stroops for a Stellar Asset Contract).
34    #[arg(long)]
35    pub decimal: bool,
36
37    /// Format of the output.
38    #[arg(long, default_value = "text")]
39    pub output: OutputFormat,
40
41    #[command(flatten)]
42    pub network: network::Args,
43
44    #[command(flatten)]
45    pub locator: locator::Args,
46}
47
48#[derive(thiserror::Error, Debug)]
49pub enum Error {
50    #[error(transparent)]
51    Config(#[from] config::Error),
52    #[error(transparent)]
53    Network(#[from] network::Error),
54    #[error(transparent)]
55    Args(#[from] args::Error),
56    #[error(transparent)]
57    Token(#[from] config::token::Error),
58    #[error(transparent)]
59    ScAddress(#[from] config::sc_address::Error),
60    #[error(transparent)]
61    Invoke(#[from] invoke::Error),
62    #[error(transparent)]
63    Serde(#[from] serde_json::Error),
64
65    #[error("could not parse {what} from the contract: {value:?}")]
66    ParseResult { what: &'static str, value: String },
67}
68
69impl Error {
70    /// Machine-readable discriminator for the JSON error envelope's `type` field.
71    #[must_use]
72    pub fn error_type(&self) -> &'static str {
73        match self {
74            Error::Config(_) => "config",
75            Error::Network(_) => "network",
76            Error::Args(e) => e.error_type(),
77            Error::Token(e) => e.error_type(),
78            Error::ScAddress(_) => "invalid_address",
79            Error::Invoke(_) => "invoke",
80            Error::Serde(_) | Error::ParseResult { .. } => "internal",
81        }
82    }
83}
84
85/// The machine-readable result of a balance query.
86#[derive(Debug, serde::Serialize)]
87struct BalanceResult {
88    /// The balance, in the requested representation: raw smallest units by
89    /// default, or a decimal string when `--decimal` is set.
90    balance: String,
91    /// The token's `decimals`, present only when `--decimal` was requested.
92    #[serde(skip_serializing_if = "Option::is_none")]
93    decimals: Option<u32>,
94}
95
96impl Cmd {
97    /// A read-only config: balance is resolved by simulation, so no source
98    /// account, signing options, or fees are needed.
99    fn config(&self) -> config::Args {
100        config::Args {
101            network: self.network.clone(),
102            source_account: config::UnresolvedMuxedAccount::default(),
103            locator: self.locator.clone(),
104            sign_with: sign_with::Args::default(),
105            fee: None,
106            inclusion_fee: None,
107        }
108    }
109
110    pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> {
111        let output = Output::new(self.output.into(), global_args.quiet);
112        // Read-only calls still log through the invoke pipeline's Print; keep it
113        // quiet in JSON mode so stdout stays pure JSON.
114        let quiet = global_args.quiet || output.is_json();
115        let config = self.config();
116        let network = config.get_network()?;
117
118        let token = self
119            .id
120            .resolve(&config.locator, &network.network_passphrase)?;
121        let account = self
122            .account
123            .clone()
124            .resolve(&config.locator, &network.network_passphrase, None)?
125            .to_string();
126
127        let raw: i128 = self
128            .read_parsed(
129                &config,
130                quiet,
131                global_args.no_cache,
132                &token,
133                vec![
134                    OsString::from("balance"),
135                    OsString::from("--id"),
136                    OsString::from(&account),
137                ],
138                "balance",
139            )
140            .await?;
141
142        let (balance, decimals) = if self.decimal {
143            // Deliberately a second, separate simulation: `decimals` isn't
144            // returned by `balance`, so `--decimal` costs one extra read-only
145            // RPC round-trip on top of the balance query.
146            let decimals: u32 = self
147                .read_parsed(
148                    &config,
149                    quiet,
150                    global_args.no_cache,
151                    &token,
152                    vec![OsString::from("decimals")],
153                    "decimals",
154                )
155                .await?;
156            (FixedPoint::new(raw, decimals).to_string(), Some(decimals))
157        } else {
158            (raw.to_string(), None)
159        };
160
161        output.readable(|_| println!("{balance}"));
162        output.json_value(&BalanceResult { balance, decimals })?;
163
164        Ok(())
165    }
166
167    /// Invoke a read-only token function and return its decoded output string.
168    async fn read(
169        &self,
170        config: &config::Args,
171        quiet: bool,
172        no_cache: bool,
173        token: &ResolvedToken,
174        slop: Vec<OsString>,
175    ) -> Result<String, Error> {
176        let invoke_cmd = invoke::Cmd {
177            contract_id: UnresolvedContract::Resolved(token.contract_id),
178            slop,
179            config: config.clone(),
180            send: invoke::Send::No,
181            ..Default::default()
182        };
183
184        let receipt = invoke_cmd
185            .execute_with_receipt(config, quiet, no_cache)
186            .await
187            .map_err(|e| args::not_deployed_error(token, &e).map_or(Error::Invoke(e), Error::Args))?
188            .into_result();
189
190        Ok(receipt.map(|r| r.output).unwrap_or_default())
191    }
192
193    /// Invoke a read-only token function, then parse its decoded output as `T`.
194    /// `what` labels the value in a `ParseResult` error if parsing fails.
195    async fn read_parsed<T: std::str::FromStr>(
196        &self,
197        config: &config::Args,
198        quiet: bool,
199        no_cache: bool,
200        token: &ResolvedToken,
201        slop: Vec<OsString>,
202        what: &'static str,
203    ) -> Result<T, Error> {
204        let out = self.read(config, quiet, no_cache, token, slop).await?;
205        // A 128-bit balance comes back JSON-encoded as a quoted string (it can't
206        // fit a JSON number), while `decimals` (u32) comes back bare; strip any
207        // surrounding quotes so both parse straight into `T`.
208        out.trim()
209            .trim_matches('"')
210            .parse()
211            .map_err(|_| Error::ParseResult { what, value: out })
212    }
213}