diff --git a/src/commands.rs b/src/commands.rs index c38b1fe..bb30127 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -11,6 +11,7 @@ pub(crate) mod pczt; pub(crate) mod propose; pub(crate) mod reset; pub(crate) mod send; +pub(crate) mod shield; pub(crate) mod sync; pub(crate) mod upgrade; diff --git a/src/commands/balance.rs b/src/commands/balance.rs index 3151424..517e666 100644 --- a/src/commands/balance.rs +++ b/src/commands/balance.rs @@ -4,8 +4,9 @@ use gumdrop::Options; use iso_currency::Currency; use rust_decimal::{prelude::FromPrimitive, Decimal}; use tracing::{info, warn}; +use uuid::Uuid; use zcash_client_backend::{data_api::WalletRead, tor}; -use zcash_client_sqlite::WalletDb; +use zcash_client_sqlite::{AccountUuid, WalletDb}; use zcash_protocol::value::{Zatoshis, COIN}; use crate::{ @@ -16,6 +17,13 @@ use crate::{ // Options accepted for the `balance` command #[derive(Debug, Options)] pub(crate) struct Command { + #[options( + free, + required, + help = "the UUID of the account for which to get a balance" + )] + account_id: Uuid, + #[options(help = "Convert ZEC values into the given currency")] convert: Option, } @@ -26,10 +34,7 @@ impl Command { let (_, db_data) = get_db_paths(wallet_dir.as_ref()); let db_data = WalletDb::for_path(db_data, params)?; - let account_id = *db_data - .get_account_ids()? - .first() - .ok_or(anyhow!("Wallet has no accounts"))?; + let account_id = AccountUuid::from_uuid(self.account_id); let address = db_data .get_current_address(account_id)? diff --git a/src/commands/list_unspent.rs b/src/commands/list_unspent.rs index 979a0ff..9868e46 100644 --- a/src/commands/list_unspent.rs +++ b/src/commands/list_unspent.rs @@ -1,18 +1,26 @@ use anyhow::anyhow; use gumdrop::Options; +use uuid::Uuid; use zcash_client_backend::{ data_api::{InputSource, WalletRead}, ShieldedProtocol, }; -use zcash_client_sqlite::WalletDb; +use zcash_client_sqlite::{AccountUuid, WalletDb}; use zcash_protocol::value::{Zatoshis, MAX_MONEY}; use crate::{config::get_wallet_network, data::get_db_paths, error, ui::format_zec}; -// Options accepted for the `balance` command +// Options accepted for the `list-unspent` command #[derive(Debug, Options)] -pub(crate) struct Command {} +pub(crate) struct Command { + #[options( + free, + required, + help = "the UUID of the account for which to list unspent funds" + )] + account_id: Uuid, +} impl Command { pub(crate) fn run(self, wallet_dir: Option) -> Result<(), anyhow::Error> { @@ -20,10 +28,7 @@ impl Command { let (_, db_data) = get_db_paths(wallet_dir); let db_data = WalletDb::for_path(db_data, params)?; - let account = *db_data - .get_account_ids()? - .first() - .ok_or(anyhow!("Wallet has no accounts"))?; + let account_id = AccountUuid::from_uuid(self.account_id); // Use the height of the maximum scanned block as the anchor height, to emulate a // zero-conf transaction in order to select every note in the wallet. @@ -34,7 +39,7 @@ impl Command { .block_height(); let notes = db_data.select_spendable_notes( - account, + account_id, Zatoshis::const_from_u64(MAX_MONEY), &[ShieldedProtocol::Sapling, ShieldedProtocol::Orchard], anchor_height, diff --git a/src/commands/pczt.rs b/src/commands/pczt.rs index 29e4d5a..3c46fc9 100644 --- a/src/commands/pczt.rs +++ b/src/commands/pczt.rs @@ -5,6 +5,7 @@ pub(crate) mod create; pub(crate) mod inspect; pub(crate) mod prove; pub(crate) mod send; +pub(crate) mod shield; pub(crate) mod sign; #[cfg(feature = "pczt-qr")] @@ -14,6 +15,8 @@ pub(crate) mod qr; pub(crate) enum Command { #[options(help = "create a PCZT")] Create(create::Command), + #[options(help = "create a shielding PCZT")] + Shield(shield::Command), #[options(help = "inspect a PCZT")] Inspect(inspect::Command), #[options(help = "create proofs for a PCZT")] diff --git a/src/commands/pczt/create.rs b/src/commands/pczt/create.rs index aa5de67..cadd0f3 100644 --- a/src/commands/pczt/create.rs +++ b/src/commands/pczt/create.rs @@ -5,19 +5,17 @@ use anyhow::anyhow; use gumdrop::Options; use tokio::io::{stdout, AsyncWriteExt}; +use uuid::Uuid; use zcash_address::ZcashAddress; use zcash_client_backend::{ - data_api::{ - wallet::{ - create_pczt_from_proposal, input_selection::GreedyInputSelector, propose_transfer, - }, - WalletRead, + data_api::wallet::{ + create_pczt_from_proposal, input_selection::GreedyInputSelector, propose_transfer, }, fees::{standard::MultiOutputChangeStrategy, DustOutputPolicy, SplitPolicy, StandardFeeRule}, wallet::OvkPolicy, ShieldedProtocol, }; -use zcash_client_sqlite::WalletDb; +use zcash_client_sqlite::{AccountUuid, WalletDb}; use zcash_protocol::{ memo::{Memo, MemoBytes}, value::Zatoshis, @@ -29,6 +27,9 @@ use crate::{config::WalletConfig, data::get_db_paths, error, MIN_CONFIRMATIONS}; // Options accepted for the `pczt create` command #[derive(Debug, Options)] pub(crate) struct Command { + #[options(free, required, help = "the UUID of the account to send funds from")] + account_id: Uuid, + #[options( required, help = "the recipient's Unified, Sapling or transparent address" @@ -61,10 +62,7 @@ impl Command { let (_, db_data) = get_db_paths(wallet_dir.as_ref()); let mut db_data = WalletDb::for_path(db_data, params)?; - let account_id = *db_data - .get_account_ids()? - .first() - .ok_or(anyhow!("Wallet has no accounts"))?; + let account_id = AccountUuid::from_uuid(self.account_id); // Create the PCZT. let change_strategy = MultiOutputChangeStrategy::new( diff --git a/src/commands/pczt/shield.rs b/src/commands/pczt/shield.rs new file mode 100644 index 0000000..59b1349 --- /dev/null +++ b/src/commands/pczt/shield.rs @@ -0,0 +1,100 @@ +use std::num::NonZeroUsize; + +use anyhow::anyhow; +use gumdrop::Options; + +use tokio::io::{stdout, AsyncWriteExt}; +use uuid::Uuid; +use zcash_client_backend::{ + data_api::{ + wallet::{ + create_pczt_from_proposal, input_selection::GreedyInputSelector, propose_shielding, + }, + WalletRead, + }, + fees::{standard::MultiOutputChangeStrategy, DustOutputPolicy, SplitPolicy, StandardFeeRule}, + wallet::OvkPolicy, + ShieldedProtocol, +}; +use zcash_client_sqlite::{AccountUuid, WalletDb}; +use zcash_protocol::value::Zatoshis; + +use crate::{config::WalletConfig, data::get_db_paths, error}; + +// Options accepted for the `pczt shield` command +#[derive(Debug, Options)] +pub(crate) struct Command { + #[options(free, required, help = "the UUID of the account to shield funds in")] + account_id: Uuid, + + #[options( + help = "note management: the number of notes to maintain in the wallet", + default = "4" + )] + target_note_count: usize, + + #[options( + help = "note management: the minimum allowed value for split change amounts", + default = "10000000" + )] + min_split_output_value: u64, +} + +impl Command { + pub(crate) async fn run(self, wallet_dir: Option) -> Result<(), anyhow::Error> { + let config = WalletConfig::read(wallet_dir.as_ref())?; + let params = config.network(); + + let (_, db_data) = get_db_paths(wallet_dir.as_ref()); + let mut db_data = WalletDb::for_path(db_data, params)?; + let account_id = AccountUuid::from_uuid(self.account_id); + + // Create the PCZT. + let change_strategy = MultiOutputChangeStrategy::new( + StandardFeeRule::Zip317, + None, + ShieldedProtocol::Orchard, + DustOutputPolicy::default(), + SplitPolicy::with_min_output_value( + NonZeroUsize::new(self.target_note_count) + .ok_or(anyhow!("target note count must be nonzero"))?, + Zatoshis::from_u64(self.min_split_output_value)?, + ), + ); + let input_selector = GreedyInputSelector::new(); + + // For this dev tool, shield all funds immediately. + let max_height = match db_data.chain_height()? { + Some(max_height) => max_height, + // If we haven't scanned anything, there's nothing to do. + None => return Ok(()), + }; + let transparent_balances = db_data.get_transparent_balances(account_id, max_height)?; + let from_addrs = transparent_balances.into_keys().collect::>(); + + let proposal = propose_shielding( + &mut db_data, + ¶ms, + &input_selector, + &change_strategy, + Zatoshis::ZERO, + &from_addrs, + account_id, + 0, + ) + .map_err(error::Error::Shield)?; + + let pczt = create_pczt_from_proposal( + &mut db_data, + ¶ms, + account_id, + OvkPolicy::Sender, + &proposal, + ) + .map_err(error::Error::Shield)?; + + stdout().write_all(&pczt.serialize()).await?; + + Ok(()) + } +} diff --git a/src/commands/propose.rs b/src/commands/propose.rs index 63006b9..b0e13c7 100644 --- a/src/commands/propose.rs +++ b/src/commands/propose.rs @@ -3,16 +3,14 @@ use std::{num::NonZeroUsize, str::FromStr}; use anyhow::anyhow; use gumdrop::Options; +use uuid::Uuid; use zcash_address::ZcashAddress; use zcash_client_backend::{ - data_api::{ - wallet::{input_selection::GreedyInputSelector, propose_transfer}, - WalletRead, - }, + data_api::wallet::{input_selection::GreedyInputSelector, propose_transfer}, fees::{zip317::MultiOutputChangeStrategy, DustOutputPolicy, SplitPolicy, StandardFeeRule}, ShieldedProtocol, }; -use zcash_client_sqlite::WalletDb; +use zcash_client_sqlite::{AccountUuid, WalletDb}; use zcash_protocol::value::Zatoshis; use zip321::{Payment, TransactionRequest}; @@ -20,6 +18,9 @@ use crate::{config::get_wallet_network, data::get_db_paths, error, MIN_CONFIRMAT // Options accepted for the `propose` command #[derive(Debug, Options)] pub(crate) struct Command { + #[options(free, required, help = "the UUID of the account to send funds from")] + account_id: Uuid, + #[options( required, help = "the recipient's Unified, Sapling or transparent address" @@ -48,10 +49,7 @@ impl Command { let (_, db_data) = get_db_paths(wallet_dir.as_ref()); let mut db_data = WalletDb::for_path(db_data, params)?; - let account = *db_data - .get_account_ids()? - .first() - .ok_or_else(|| anyhow!("Wallet has no accounts."))?; + let account_id = AccountUuid::from_uuid(self.account_id); let change_strategy = MultiOutputChangeStrategy::new( StandardFeeRule::Zip317, @@ -75,7 +73,7 @@ impl Command { let proposal = propose_transfer( &mut db_data, ¶ms, - account, + account_id, &input_selector, &change_strategy, request, diff --git a/src/commands/send.rs b/src/commands/send.rs index 917a4d1..356a815 100644 --- a/src/commands/send.rs +++ b/src/commands/send.rs @@ -5,6 +5,7 @@ use anyhow::anyhow; use gumdrop::Options; use secrecy::ExposeSecret; +use uuid::Uuid; use zcash_address::ZcashAddress; use zcash_client_backend::{ data_api::{ @@ -19,7 +20,7 @@ use zcash_client_backend::{ wallet::OvkPolicy, ShieldedProtocol, }; -use zcash_client_sqlite::WalletDb; +use zcash_client_sqlite::{AccountUuid, WalletDb}; use zcash_proofs::prover::LocalTxProver; use zcash_protocol::value::Zatoshis; use zip321::{Payment, TransactionRequest}; @@ -35,6 +36,9 @@ use crate::{ // Options accepted for the `send` command #[derive(Debug, Options)] pub(crate) struct Command { + #[options(free, required, help = "the UUID of the account to send funds from")] + account_id: Uuid, + #[options( required, help = "age identity file to decrypt the mnemonic phrase with" @@ -80,10 +84,7 @@ impl Command { let (_, db_data) = get_db_paths(wallet_dir.as_ref()); let mut db_data = WalletDb::for_path(db_data, params)?; - let account_id = *db_data - .get_account_ids()? - .first() - .ok_or(anyhow!("Wallet has no accounts"))?; + let account_id = AccountUuid::from_uuid(self.account_id); let account = db_data .get_account(account_id)? .ok_or(anyhow!("Account missing: {:?}", account_id))?; diff --git a/src/commands/shield.rs b/src/commands/shield.rs new file mode 100644 index 0000000..8fd72ab --- /dev/null +++ b/src/commands/shield.rs @@ -0,0 +1,183 @@ +use std::num::NonZeroUsize; + +use anyhow::anyhow; +use gumdrop::Options; +use secrecy::ExposeSecret; + +use uuid::Uuid; +use zcash_client_backend::{ + data_api::{ + wallet::{ + create_proposed_transactions, input_selection::GreedyInputSelector, propose_shielding, + }, + Account, WalletRead, + }, + fees::{standard::MultiOutputChangeStrategy, DustOutputPolicy, SplitPolicy, StandardFeeRule}, + keys::UnifiedSpendingKey, + proto::service, + wallet::OvkPolicy, + ShieldedProtocol, +}; +use zcash_client_sqlite::{AccountUuid, WalletDb}; +use zcash_proofs::prover::LocalTxProver; +use zcash_protocol::value::Zatoshis; + +use crate::{ + config::WalletConfig, + data::get_db_paths, + error, + remote::{tor_client, Servers}, +}; + +// Options accepted for the `shield` command +#[derive(Debug, Options)] +pub(crate) struct Command { + #[options(free, required, help = "the UUID of the account to shield funds in")] + account_id: Uuid, + + #[options( + required, + help = "age identity file to decrypt the mnemonic phrase with" + )] + identity: String, + + #[options( + help = "the server to shield via (default is \"ecc\")", + default = "ecc", + parse(try_from_str = "Servers::parse") + )] + server: Servers, + + #[options(help = "disable connections via TOR")] + disable_tor: bool, + + #[options( + help = "note management: the number of notes to maintain in the wallet", + default = "4" + )] + target_note_count: usize, + + #[options( + help = "note management: the minimum allowed value for split change amounts", + default = "10000000" + )] + min_split_output_value: u64, +} + +impl Command { + pub(crate) async fn run(self, wallet_dir: Option) -> Result<(), anyhow::Error> { + let mut config = WalletConfig::read(wallet_dir.as_ref())?; + let params = config.network(); + + let (_, db_data) = get_db_paths(wallet_dir.as_ref()); + let mut db_data = WalletDb::for_path(db_data, params)?; + let account_id = AccountUuid::from_uuid(self.account_id); + let account = db_data + .get_account(account_id)? + .ok_or(anyhow!("Account missing: {:?}", account_id))?; + let derivation = account.source().key_derivation().ok_or(anyhow!( + "Cannot spend from view-only accounts; did you mean to use `pczt shield` instead?" + ))?; + + // Decrypt the mnemonic to access the seed. + let identities = age::IdentityFile::from_file(self.identity)?.into_identities()?; + config.decrypt(identities.iter().map(|i| i.as_ref() as _))?; + + let usk = UnifiedSpendingKey::from_seed( + ¶ms, + config + .seed() + .ok_or(anyhow!("Seed must be present to enable sending"))? + .expose_secret(), + derivation.account_index(), + ) + .map_err(error::Error::from)?; + + let server = self.server.pick(params)?; + let mut client = if self.disable_tor { + server.connect_direct().await? + } else { + server.connect(|| tor_client(wallet_dir.as_ref())).await? + }; + + // Create the transaction. + println!("Creating transaction..."); + let prover = + LocalTxProver::with_default_location().ok_or(error::Error::MissingParameters)?; + let change_strategy = MultiOutputChangeStrategy::new( + StandardFeeRule::Zip317, + None, + ShieldedProtocol::Orchard, + DustOutputPolicy::default(), + SplitPolicy::with_min_output_value( + NonZeroUsize::new(self.target_note_count) + .ok_or(anyhow!("target note count must be nonzero"))?, + Zatoshis::from_u64(self.min_split_output_value)?, + ), + ); + let input_selector = GreedyInputSelector::new(); + + // For this dev tool, shield all funds immediately. + let max_height = match db_data.chain_height()? { + Some(max_height) => max_height, + // If we haven't scanned anything, there's nothing to do. + None => return Ok(()), + }; + let transparent_balances = db_data.get_transparent_balances(account_id, max_height)?; + let from_addrs = transparent_balances.into_keys().collect::>(); + + let proposal = propose_shielding( + &mut db_data, + ¶ms, + &input_selector, + &change_strategy, + Zatoshis::ZERO, + &from_addrs, + account_id, + 0, + ) + .map_err(error::Error::Shield)?; + + let txids = create_proposed_transactions( + &mut db_data, + ¶ms, + &prover, + &prover, + &usk, + OvkPolicy::Sender, + &proposal, + ) + .map_err(error::Error::Shield)?; + + if txids.len() > 1 { + return Err(anyhow!( + "Multi-transaction proposals are not yet supported." + )); + } + + let txid = *txids.first(); + + // Send the transaction. + println!("Sending transaction..."); + let (txid, raw_tx) = db_data + .get_transaction(txid)? + .map(|tx| { + let mut raw_tx = service::RawTransaction::default(); + tx.write(&mut raw_tx.data).unwrap(); + (tx.txid(), raw_tx) + }) + .ok_or(anyhow!("Transaction not found for id {:?}", txid))?; + let response = client.send_transaction(raw_tx).await?.into_inner(); + + if response.error_code != 0 { + Err(error::Error::SendFailed { + code: response.error_code, + reason: response.error_message, + } + .into()) + } else { + println!("{}", txid); + Ok(()) + } + } +} diff --git a/src/error.rs b/src/error.rs index e9acc6d..5cb56cb 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,3 +1,4 @@ +use std::convert::Infallible; use std::fmt; use zcash_client_backend::{ @@ -22,6 +23,15 @@ pub(crate) type WalletErrorT = WalletError< ReceivedNoteId, >; +pub(crate) type ShieldErrorT = WalletError< + SqliteClientError, + commitment_tree::Error, + GreedyInputSelectorError, + zip317::FeeError, + zip317::FeeError, + Infallible, +>; + #[derive(Debug)] pub enum Error { Cache(FsBlockDbError), @@ -32,6 +42,7 @@ pub enum Error { InvalidTreeState, MissingParameters, SendFailed { code: i32, reason: String }, + Shield(ShieldErrorT), Wallet(WalletErrorT), Zip321(Zip321Error), } @@ -47,6 +58,7 @@ impl fmt::Display for Error { Error::InvalidTreeState => write!(f, "Invalid TreeState received from server"), Error::MissingParameters => write!(f, "Missing proving parameters"), Error::SendFailed { code, reason } => write!(f, "Send failed: ({}) {}", code, reason), + Error::Shield(e) => e.fmt(f), Error::Wallet(e) => e.fmt(f), Error::Zip321(e) => write!(f, "{:?}", e), } diff --git a/src/main.rs b/src/main.rs index 2f4589a..8ea4b82 100644 --- a/src/main.rs +++ b/src/main.rs @@ -73,6 +73,9 @@ enum Command { #[options(help = "list the unspent notes in the wallet")] ListUnspent(commands::list_unspent::Command), + #[options(help = "shield transparent funds received by the wallet")] + Shield(commands::shield::Command), + #[options(help = "propose a transfer of funds to the given address and display the proposal")] Propose(commands::propose::Command), @@ -165,10 +168,12 @@ fn main() -> Result<(), anyhow::Error> { Some(Command::ListAddresses(command)) => command.run(opts.wallet_dir), Some(Command::ListTx(command)) => command.run(opts.wallet_dir), Some(Command::ListUnspent(command)) => command.run(opts.wallet_dir), + Some(Command::Shield(command)) => command.run(opts.wallet_dir).await, Some(Command::Propose(command)) => command.run(opts.wallet_dir).await, Some(Command::Send(command)) => command.run(opts.wallet_dir).await, Some(Command::Pczt(command)) => match command { commands::pczt::Command::Create(command) => command.run(opts.wallet_dir).await, + commands::pczt::Command::Shield(command) => command.run(opts.wallet_dir).await, commands::pczt::Command::Inspect(command) => command.run().await, commands::pczt::Command::Prove(command) => command.run(opts.wallet_dir).await, commands::pczt::Command::Sign(command) => command.run(opts.wallet_dir).await,