diff --git a/Cargo.lock b/Cargo.lock index d8e4a5d..6adbb76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2747,8 +2747,10 @@ dependencies = [ "gumdrop", "prost", "rayon", + "rusqlite", "schemer", "secrecy", + "time", "tokio", "tonic", "tracing", diff --git a/Cargo.toml b/Cargo.toml index c97dcd9..3f2cd57 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,8 +12,10 @@ futures-util = "0.3" gumdrop = "0.8" prost = "0.11" rayon = "1.7" +rusqlite = { version = "0.25", features = ["time"] } schemer = "0.2" secrecy = "0.8" +time = "0.2" tokio = { version = "1.21.0", features = ["fs", "macros", "rt-multi-thread"] } tonic = { version = "0.9", features = ["gzip", "tls-webpki-roots"] } tracing = "0.1" diff --git a/src/commands.rs b/src/commands.rs index fb94419..255d337 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,5 +1,7 @@ pub(crate) mod balance; pub(crate) mod init; +pub(crate) mod list_tx; +pub(crate) mod list_unspent; pub(crate) mod send; pub(crate) mod sync; pub(crate) mod upgrade; diff --git a/src/commands/list_tx.rs b/src/commands/list_tx.rs new file mode 100644 index 0000000..9fedcce --- /dev/null +++ b/src/commands/list_tx.rs @@ -0,0 +1,141 @@ +use anyhow::anyhow; +use gumdrop::Options; + +use rusqlite::{named_params, Connection}; +use zcash_primitives::{ + consensus::BlockHeight, + transaction::{ + components::{amount::NonNegativeAmount, Amount}, + TxId, + }, +}; + +use crate::data::get_db_paths; + +// Options accepted for the `list` command +#[derive(Debug, Options)] +pub(crate) struct Command {} + +impl Command { + pub(crate) fn run(self, wallet_dir: Option) -> anyhow::Result<()> { + let (_, db_data) = get_db_paths(wallet_dir); + + let conn = Connection::open(db_data)?; + rusqlite::vtab::array::load_module(&conn)?; + + let mut stmt_txs = conn.prepare( + "SELECT mined_height, + txid, + expiry_height, + fee_paid, + sent_note_count, + received_note_count, + memo_count, + block_time, + expired_unmined + FROM v_transactions + WHERE account_id = :account_id", + )?; + + println!("Transactions:"); + for row in stmt_txs.query_and_then( + named_params! {":account_id": 0}, + |row| -> anyhow::Result<_> { + Transaction::from_parts( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + row.get(7)?, + row.get(8)?, + ) + }, + )? { + let tx = row?; + println!(""); + tx.print(); + } + + Ok(()) + } +} + +struct Transaction { + mined_height: Option, + txid: TxId, + expiry_height: Option, + fee_paid: Option, + sent_note_count: usize, + received_note_count: usize, + memo_count: usize, + block_time: Option, + expired_unmined: bool, +} + +impl Transaction { + fn from_parts( + mined_height: Option, + txid: Vec, + expiry_height: Option, + fee_paid: Option, + sent_note_count: usize, + received_note_count: usize, + memo_count: usize, + block_time: Option, + expired_unmined: bool, + ) -> anyhow::Result { + Ok(Transaction { + mined_height: mined_height.map(BlockHeight::from_u32), + txid: TxId::from_bytes(txid.try_into().map_err(|_| anyhow!("Invalid TxId"))?), + expiry_height: expiry_height.map(BlockHeight::from_u32), + fee_paid: fee_paid + .map(|v| NonNegativeAmount::from_u64(v).map_err(|()| anyhow!("Fee out of range"))) + .transpose()?, + sent_note_count, + received_note_count, + memo_count, + block_time, + expired_unmined, + }) + } + + fn print(&self) { + let height_to_str = |height: Option, def: &str| { + height.map(|h| h.to_string()).unwrap_or(def.to_owned()) + }; + + println!("{}", self.txid); + if let Some((height, block_time)) = self.mined_height.zip(self.block_time) { + println!( + " Mined: {} ({})", + height, + time::OffsetDateTime::from_unix_timestamp(block_time), + ); + } else { + println!( + " {} (expiry height: {})", + if self.expired_unmined { + " Expired" + } else { + " Unmined" + }, + height_to_str(self.expiry_height, "Unknown"), + ); + } + println!( + " Fee paid: {}", + self.fee_paid + .map(|v| format!("{} zatoshis", u64::from(Amount::from(v)))) + .as_ref() + .map(|s| s.as_str()) + .unwrap_or("Unknown"), + ); + println!( + " Sent {} notes, received {} notes, {} memos", + self.sent_note_count, self.received_note_count, self.memo_count, + ); + } +} diff --git a/src/commands/list_unspent.rs b/src/commands/list_unspent.rs new file mode 100644 index 0000000..a2674c0 --- /dev/null +++ b/src/commands/list_unspent.rs @@ -0,0 +1,37 @@ +use anyhow::anyhow; +use gumdrop::Options; + +use zcash_client_backend::data_api::WalletRead; +use zcash_client_sqlite::WalletDb; +use zcash_primitives::{consensus::Parameters, zip32::AccountId}; + +use crate::{data::get_db_paths, error, MIN_CONFIRMATIONS}; + +// Options accepted for the `balance` command +#[derive(Debug, Options)] +pub(crate) struct Command {} + +impl Command { + pub(crate) fn run( + self, + params: impl Parameters + Copy + 'static, + wallet_dir: Option, + ) -> Result<(), anyhow::Error> { + let account = AccountId::from(0); + let (_, db_data) = get_db_paths(wallet_dir); + let db_data = WalletDb::for_path(db_data, params)?; + + let (target_height, _) = db_data + .get_target_and_anchor_heights(MIN_CONFIRMATIONS)? + .ok_or(error::WalletErrorT::ScanRequired) + .map_err(|e| anyhow!("{:?}", e))?; + + let notes = db_data.get_spendable_sapling_notes(account, target_height, &[])?; + + for note in notes { + println!("{}: {} zatoshis", note.note_id, u64::from(note.note_value)); + } + + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs index 1c19468..25dc009 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,6 +43,12 @@ enum Command { #[options(help = "get the balance in the wallet")] Balance(commands::balance::Command), + #[options(help = "list the transactions in the wallet")] + ListTx(commands::list_tx::Command), + + #[options(help = "list the unspent notes in the wallet")] + ListUnspent(commands::list_unspent::Command), + #[options(help = "send funds to the given address")] Send(commands::send::Command), } @@ -75,6 +81,8 @@ fn main() -> Result<(), anyhow::Error> { Some(Command::Upgrade(command)) => command.run(params, opts.wallet_dir), Some(Command::Sync(command)) => command.run(params, opts.wallet_dir).await, Some(Command::Balance(command)) => command.run(params, opts.wallet_dir), + Some(Command::ListTx(command)) => command.run(opts.wallet_dir), + Some(Command::ListUnspent(command)) => command.run(params, opts.wallet_dir), Some(Command::Send(command)) => command.run(params, opts.wallet_dir).await, _ => Ok(()), }