From c7c73fbf3b5ebac4851bd0fb88a953918f1ce561 Mon Sep 17 00:00:00 2001 From: Jack Grigg Date: Thu, 29 Jan 2026 23:22:07 +0000 Subject: [PATCH 1/2] wallet derive-path: Expose UFVKs --- Cargo.toml | 2 +- src/commands/wallet/derive_path.rs | 39 +++++++++++++++++------------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 556b863..c2ff34e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,7 +41,7 @@ transparent = { package = "zcash_transparent", version = "0.6", features = ["tes zcash_address = "0.10" zcash_client_backend = { version = "0.21", features = ["lightwalletd-tonic-tls-webpki-roots", "orchard", "pczt", "tor"] } zcash_client_sqlite = { version = "0.19", features = ["unstable", "orchard", "serde"] } -zcash_keys = { version = "0.12", features = ["unstable", "orchard"] } +zcash_keys = { version = "0.12", features = ["orchard", "unstable", "unstable-frost"] } zcash_primitives = "0.26" zcash_proofs = { version = "0.26", features = ["bundled-prover"] } zcash_protocol = { version = "0.7", features = ["local-consensus"] } diff --git a/src/commands/wallet/derive_path.rs b/src/commands/wallet/derive_path.rs index 44568c8..32e0baa 100644 --- a/src/commands/wallet/derive_path.rs +++ b/src/commands/wallet/derive_path.rs @@ -4,7 +4,10 @@ use clap::Args; use secrecy::ExposeSecret; use transparent::address::TransparentAddress; use zcash_address::{ToAddress, ZcashAddress}; -use zcash_keys::{address::UnifiedAddress, encoding::AddressCodec}; +use zcash_keys::{ + encoding::AddressCodec, + keys::{UnifiedAddressRequest, UnifiedFullViewingKey}, +}; use zcash_protocol::{ consensus::{NetworkConstants, Parameters}, PoolType, @@ -141,7 +144,7 @@ impl Command { PoolType::SAPLING => { // Print out Sapling information. println!("Sapling derivation at {}:", self.path); - let address = match path.as_slice() { + let dfvk = match path.as_slice() { [(32, true), subpath @ ..] => { println!(" - ZIP 32 derivation path"); match subpath { @@ -174,6 +177,7 @@ impl Command { [] => { // Only encode as an address if the network // matches the wallet. + #[allow(deprecated)] network_match.then(|| { sapling::zip32::ExtendedSpendingKey::master( seed.expose_secret(), @@ -181,8 +185,7 @@ impl Command { .derive_child(zip32::ChildIndex::hardened(32)) .derive_child(zip32::ChildIndex::hardened(*coin_type)) .derive_child(zip32::ChildIndex::hardened(*account)) - .default_address() - .1 + .to_extended_full_viewing_key() }) } _ => None, @@ -195,7 +198,12 @@ impl Command { None } }; - if let Some(addr) = address { + if let Some(extfvk) = dfvk { + let addr = extfvk.default_address().1; + let ufvk = + UnifiedFullViewingKey::from_sapling_extended_full_viewing_key(extfvk) + .expect("always valid"); + println!(" - UFVK: {}", ufvk.encode(¶ms)); println!( " - Default address: {}", ZcashAddress::from_sapling(params.network_type(), addr.to_bytes()) @@ -205,7 +213,7 @@ impl Command { PoolType::ORCHARD => { // Print out Orchard information. println!("Orchard derivation at {}:", self.path); - let address = match path.as_slice() { + let fvk = match path.as_slice() { [(32, true), subpath @ ..] => { println!(" - ZIP 32 derivation path"); match subpath { @@ -249,10 +257,7 @@ impl Command { .ok() }) .flatten() - .map(|sk| { - let fvk = orchard::keys::FullViewingKey::from(&sk); - fvk.address_at(0u32, zip32::Scope::External) - }) + .map(|sk| orchard::keys::FullViewingKey::from(&sk)) } _ => None, } @@ -264,13 +269,13 @@ impl Command { None } }; - if let Some(addr) = address { - println!( - " - Default address: {}", - UnifiedAddress::from_receivers(Some(addr), None, None) - .expect("valid") - .encode(¶ms), - ); + if let Some(fvk) = fvk { + let ufvk = UnifiedFullViewingKey::from_orchard_fvk(fvk).expect("always valid"); + let (ua, _) = ufvk + .default_address(UnifiedAddressRequest::AllAvailableKeys) + .expect("always valid"); + println!(" - UFVK: {}", ufvk.encode(¶ms)); + println!(" - Default address: {}", ua.encode(¶ms)); } } } From ba8a6c40dbb37440e6c381ac93201d853ad42642 Mon Sep 17 00:00:00 2001 From: Jack Grigg Date: Thu, 29 Jan 2026 23:22:08 +0000 Subject: [PATCH 2/2] pczt: Add `create-max` command --- src/commands/pczt.rs | 3 + src/commands/pczt/create_max.rs | 98 +++++++++++++++++++++++++++++++++ src/error.rs | 12 ++++ src/main.rs | 1 + 4 files changed, 114 insertions(+) create mode 100644 src/commands/pczt/create_max.rs diff --git a/src/commands/pczt.rs b/src/commands/pczt.rs index 33d470a..1a67dbd 100644 --- a/src/commands/pczt.rs +++ b/src/commands/pczt.rs @@ -3,6 +3,7 @@ use clap::Subcommand; pub(crate) mod combine; pub(crate) mod create; pub(crate) mod create_manual; +pub(crate) mod create_max; pub(crate) mod inspect; pub(crate) mod pay_manual; pub(crate) mod prove; @@ -20,6 +21,8 @@ pub(crate) mod qr; pub(crate) enum Command { /// Create a PCZT Create(create::Command), + /// Create a PCZT that sends all funds within the account + CreateMax(create_max::Command), /// Create a shielding PCZT Shield(shield::Command), /// Create a PCZT from manually-provided transparent inputs diff --git a/src/commands/pczt/create_max.rs b/src/commands/pczt/create_max.rs new file mode 100644 index 0000000..ea850d6 --- /dev/null +++ b/src/commands/pczt/create_max.rs @@ -0,0 +1,98 @@ +use std::str::FromStr; + +use clap::Args; +use rand::rngs::OsRng; +use tokio::io::{stdout, AsyncWriteExt}; +use uuid::Uuid; + +use zcash_address::ZcashAddress; +use zcash_client_backend::{ + data_api::{ + wallet::{create_pczt_from_proposal, propose_send_max_transfer, ConfirmationsPolicy}, + Account as _, MaxSpendMode, + }, + fees::StandardFeeRule, + wallet::OvkPolicy, +}; +use zcash_client_sqlite::{util::SystemClock, WalletDb}; +use zcash_protocol::{ + memo::{Memo, MemoBytes}, + ShieldedProtocol, +}; + +use crate::{commands::select_account, config::WalletConfig, data::get_db_paths, error}; + +// Options accepted for the `pczt create-max` command +#[derive(Debug, Args)] +pub(crate) struct Command { + /// The UUID of the account to send funds from + account_id: Option, + + /// The recipient's Unified, Sapling or transparent address + #[arg(long)] + address: String, + + /// A memo to send to the recipient + #[arg(long)] + memo: Option, + + /// Spend all _currently_ spendable funds where it could be the case that the wallet + /// has received other funds that are not confirmed and therefore not spendable yet + /// and the caller evaluates that as an acceptable scenario. + /// + /// Default is to spend **all funds**, failing if there are unspendable funds in the + /// wallet or if the wallet is not yet synced. + #[arg(long)] + only_spendable: bool, +} + +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, SystemClock, OsRng)?; + let account = select_account(&db_data, self.account_id)?; + + let recipient = + ZcashAddress::from_str(&self.address).map_err(|_| error::Error::InvalidRecipient)?; + let memo = self + .memo + .map(|memo| Memo::from_str(&memo)) + .transpose()? + .map(MemoBytes::from); + let mode = if self.only_spendable { + MaxSpendMode::MaxSpendable + } else { + MaxSpendMode::Everything + }; + + // Create the PCZT. + let proposal = propose_send_max_transfer( + &mut db_data, + ¶ms, + account.id(), + &[ShieldedProtocol::Sapling, ShieldedProtocol::Orchard], + &StandardFeeRule::Zip317, + recipient, + memo, + mode, + ConfirmationsPolicy::default(), + ) + .map_err(error::Error::SendMax)?; + + let pczt = create_pczt_from_proposal( + &mut db_data, + ¶ms, + account.id(), + OvkPolicy::Sender, + &proposal, + ) + .map_err(error::Error::from)?; + + stdout().write_all(&pczt.serialize()).await?; + + Ok(()) + } +} diff --git a/src/error.rs b/src/error.rs index ac67d40..a16a8d8 100644 --- a/src/error.rs +++ b/src/error.rs @@ -9,6 +9,7 @@ use zcash_client_sqlite::{ }; use zcash_keys::keys::DerivationError; use zcash_primitives::transaction::fees::zip317; +use zcash_protocol::value::BalanceError; use zip321::Zip321Error; pub(crate) type WalletErrorT = WalletError< @@ -20,6 +21,15 @@ pub(crate) type WalletErrorT = WalletError< ReceivedNoteId, >; +pub(crate) type SendMaxErrorT = WalletError< + SqliteClientError, + commitment_tree::Error, + BalanceError, + zip317::FeeError, + zip317::FeeError, + ReceivedNoteId, +>; + pub(crate) type ShieldErrorT = WalletError< SqliteClientError, commitment_tree::Error, @@ -39,6 +49,7 @@ pub enum Error { InvalidKeysFile, InvalidTreeState, SendFailed { code: i32, reason: String }, + SendMax(SendMaxErrorT), Shield(ShieldErrorT), TransparentMemo(usize), Wallet(WalletErrorT), @@ -56,6 +67,7 @@ impl fmt::Display for Error { Error::InvalidKeysFile => write!(f, "Invalid keys file"), Error::InvalidTreeState => write!(f, "Invalid TreeState received from server"), Error::SendFailed { code, reason } => write!(f, "Send failed: ({code}) {reason}"), + Error::SendMax(e) => e.fmt(f), Error::TransparentMemo(idx) => { write!(f, "Payment {idx} invalid: can't send memo to a t-address") } diff --git a/src/main.rs b/src/main.rs index ce455ae..1b14527 100644 --- a/src/main.rs +++ b/src/main.rs @@ -188,6 +188,7 @@ fn main() -> Result<(), anyhow::Error> { command, }) => match command { commands::pczt::Command::Create(command) => command.run(wallet_dir).await, + commands::pczt::Command::CreateMax(command) => command.run(wallet_dir).await, commands::pczt::Command::Shield(command) => command.run(wallet_dir).await, commands::pczt::Command::CreateManual(command) => command.run(wallet_dir).await, commands::pczt::Command::PayManual(command) => command.run(wallet_dir).await,