diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ed2e31..a59ea6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ All notable changes to this project will be documented in this file. +## [Unreleased] + ## [0.11.1] - 2025-08-06 ### 🐛 Bug Fixes diff --git a/README.md b/README.md index 77b0f72..15560ed 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,14 @@ This will prompt you for a name, a kind (only UTxORPC supported), whether it is > If you have a [Demeter](https://demeter.run) port you would have to set the URL as `https://{host}` and on put `dmtr-api-key:YOUR_API_KEY` on the headers. +To inspect the current wallet's UTxOs in JSON format, run: + +```sh +cargo run -- wallet utxos +``` + +Use `--output-format` to override the default JSON response if you prefer a table view instead. + # Examples In the `examples` folder you can find scripts demonstrating advanced capabilities. diff --git a/docs/wallet.mdx b/docs/wallet.mdx index 81876b6..17d8ca0 100644 --- a/docs/wallet.mdx +++ b/docs/wallet.mdx @@ -61,3 +61,13 @@ And the `--help` flag can be used anywhere in Cshell. For example, after choosin ```bash cshell wallet create --help ``` + +### Inspect wallet UTxOs + +Retrieve the live UTxO set for the currently selected wallet. The response defaults to JSON, mirroring the exact schema returned by the configured UTxoRPC provider. + +```bash +cshell wallet utxos +``` + +Use `--output-format table` if you prefer a tabular summary in the terminal. diff --git a/src/main.rs b/src/main.rs index b7c83cb..48106b7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -96,21 +96,23 @@ pub struct Context { pub store: store::Store, pub output_format: output::OutputFormat, pub log_level: LogLevel, + pub output_format_overridden: bool, } impl Context { fn from_cli(cli: &Cli) -> anyhow::Result { let store = store::Store::open(cli.store_path.clone())?; - let output_format = cli - .output_format - .clone() - .unwrap_or(output::OutputFormat::Table); + let (output_format, output_format_overridden) = match cli.output_format.clone() { + Some(value) => (value, true), + None => (output::OutputFormat::Table, false), + }; let log_level = cli.log_level.clone().unwrap_or(LogLevel::Info); Ok(Context { store, output_format, log_level, + output_format_overridden, }) } diff --git a/src/provider/types.rs b/src/provider/types.rs index 912ba37..2cd285c 100644 --- a/src/provider/types.rs +++ b/src/provider/types.rs @@ -160,6 +160,47 @@ impl Provider { }) } + pub async fn get_wallet_utxos( + &self, + address: &Address, + ) -> Result> { + let mut client: CardanoQueryClient = self.client().await?; + + let predicate = utxorpc::spec::query::UtxoPredicate { + r#match: Some(utxorpc::spec::query::AnyUtxoPattern { + utxo_pattern: Some(UtxoPattern::Cardano( + utxorpc::spec::cardano::TxOutputPattern { + address: Some(utxorpc::spec::cardano::AddressPattern { + exact_address: address.to_vec().into(), + ..Default::default() + }), + ..Default::default() + }, + )), + }), + ..Default::default() + }; + + let response = client + .search_utxos(predicate, None, u32::MAX) + .await + .context("failed to query utxos")?; + + let utxos = response + .items + .into_iter() + .map(|utxo| utxorpc::spec::query::AnyUtxoData { + native_bytes: utxo.native, + txo_ref: utxo.txo_ref, + parsed_state: utxo + .parsed + .map(utxorpc::spec::query::any_utxo_data::ParsedState::Cardano), + }) + .collect(); + + Ok(utxos) + } + pub async fn get_detailed_balance(&self, address: &Address) -> Result { let mut client: CardanoQueryClient = self.client().await?; diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index c5375b3..9035dfe 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -10,6 +10,7 @@ mod info; mod list; mod restore; pub mod types; +mod utxos; #[derive(Parser)] pub struct Args { @@ -36,6 +37,8 @@ enum Commands { Delete(delete::Args), /// show wallet balance Balance(balance::Args), + /// List wallet UTxOs + Utxos(utxos::Args), } #[instrument("wallet", skip_all)] @@ -49,5 +52,6 @@ pub async fn run(args: Args, ctx: &mut crate::Context) -> anyhow::Result<()> { Commands::List => list::run(ctx).await, Commands::Delete(args) => delete::run(args, ctx).await, Commands::Balance(args) => balance::run(args, ctx).await, + Commands::Utxos(args) => utxos::run(args, ctx).await, } } diff --git a/src/wallet/utxos.rs b/src/wallet/utxos.rs new file mode 100644 index 0000000..15aa07e --- /dev/null +++ b/src/wallet/utxos.rs @@ -0,0 +1,124 @@ +use anyhow::bail; +use clap::Parser; +use comfy_table::Table; +use serde_json::json; +use utxorpc::spec::query::{any_utxo_data::ParsedState, AnyUtxoData}; + +use crate::output::{OutputFormat, OutputFormatter}; + +#[derive(Parser)] +pub struct Args { + /// Name of the wallet to show the UTxOs of. If undefined, the default wallet is used. + name: Option, + + /// Name of the provider to use. If undefined, the default provider is used. + provider: Option, +} + +pub async fn run(args: Args, ctx: &crate::Context) -> anyhow::Result<()> { + let wallet = match args.name { + Some(name) => ctx.store.find_wallet(&name), + None => ctx.store.default_wallet(), + }; + + let provider = match args.provider { + Some(name) => ctx.store.find_provider(&name), + None => ctx.store.default_provider(), + }; + + match (wallet, provider) { + (Some(wallet), Some(provider)) => { + let address = wallet.address(provider.is_testnet()); + let utxos = provider.get_wallet_utxos(&address).await?; + let output = WalletUtxoOutput::new(utxos); + + let format = if ctx.output_format_overridden { + ctx.output_format.clone() + } else { + OutputFormat::Json + }; + + output.output(&format); + + Ok(()) + } + (None, Some(_)) => bail!("Wallet not found."), + (Some(_), None) => bail!("Provider not found."), + (None, None) => bail!("Wallet and provider not found."), + } +} + +struct WalletUtxoOutput { + utxos: Vec, +} + +impl WalletUtxoOutput { + fn new(utxos: Vec) -> Self { + Self { utxos } + } +} + +impl OutputFormatter for WalletUtxoOutput { + fn to_table(&self) { + let mut table = Table::new(); + + table.set_header(vec!["Tx Hash", "Index", "Lovelace", "Assets", "Datum Hash"]); + + for utxo in &self.utxos { + let (tx_hash, index) = utxo + .txo_ref + .as_ref() + .map(|reference| (hex::encode(&reference.hash), reference.index.to_string())) + .unwrap_or_else(|| ("-".to_string(), "-".to_string())); + + let (coin, asset_count, datum_hash) = match &utxo.parsed_state { + Some(ParsedState::Cardano(output)) => { + let asset_count: usize = output + .assets + .iter() + .map(|multiasset| multiasset.assets.len()) + .sum(); + + let datum_hash = output + .datum + .as_ref() + .map(|datum| hex::encode(&datum.hash)) + .unwrap_or_else(|| "-".to_string()); + + (output.coin.to_string(), asset_count.to_string(), datum_hash) + } + None => ("-".to_string(), "0".to_string(), "-".to_string()), + }; + + table.add_row(vec![tx_hash, index, coin, asset_count, datum_hash]); + } + + println!("{table}"); + } + + fn to_json(&self) { + let payload = json!({ "utxos": self.utxos }); + println!("{}", serde_json::to_string_pretty(&payload).unwrap()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_without_arguments() { + let args = Args::parse_from(["wallet-utxos"]); + + assert!(args.name.is_none()); + assert!(args.provider.is_none()); + } + + #[test] + fn parses_with_wallet_and_provider() { + let args = Args::parse_from(["wallet-utxos", "alice", "mainnet"]); + + assert_eq!(args.name.as_deref(), Some("alice")); + assert_eq!(args.provider.as_deref(), Some("mainnet")); + } +}