diff --git a/Cargo.lock b/Cargo.lock index 05deb13..8a24edc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2098,7 +2098,7 @@ dependencies = [ [[package]] name = "equihash" version = "0.2.2" -source = "git+https://github.com/zcash/librustzcash.git?rev=2448b6d730c8e07d829c85429b756b5f84073624#2448b6d730c8e07d829c85429b756b5f84073624" +source = "git+https://github.com/zcash/librustzcash.git?rev=77c422f1bd56400c9647f089c87f3776e16fd212#77c422f1bd56400c9647f089c87f3776e16fd212" dependencies = [ "blake2b_simd", "core2 0.3.3", @@ -2150,7 +2150,7 @@ dependencies = [ [[package]] name = "f4jumble" version = "0.1.1" -source = "git+https://github.com/zcash/librustzcash.git?rev=2448b6d730c8e07d829c85429b756b5f84073624#2448b6d730c8e07d829c85429b756b5f84073624" +source = "git+https://github.com/zcash/librustzcash.git?rev=77c422f1bd56400c9647f089c87f3776e16fd212#77c422f1bd56400c9647f089c87f3776e16fd212" dependencies = [ "blake2b_simd", ] @@ -4195,7 +4195,7 @@ dependencies = [ [[package]] name = "pczt" version = "0.5.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=2448b6d730c8e07d829c85429b756b5f84073624#2448b6d730c8e07d829c85429b756b5f84073624" +source = "git+https://github.com/zcash/librustzcash.git?rev=77c422f1bd56400c9647f089c87f3776e16fd212#77c422f1bd56400c9647f089c87f3776e16fd212" dependencies = [ "blake2b_simd", "bls12_381", @@ -8508,7 +8508,7 @@ dependencies = [ [[package]] name = "zcash_address" version = "0.10.1" -source = "git+https://github.com/zcash/librustzcash.git?rev=2448b6d730c8e07d829c85429b756b5f84073624#2448b6d730c8e07d829c85429b756b5f84073624" +source = "git+https://github.com/zcash/librustzcash.git?rev=77c422f1bd56400c9647f089c87f3776e16fd212#77c422f1bd56400c9647f089c87f3776e16fd212" dependencies = [ "bech32 0.11.0", "bs58", @@ -8521,7 +8521,7 @@ dependencies = [ [[package]] name = "zcash_client_backend" version = "0.21.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=2448b6d730c8e07d829c85429b756b5f84073624#2448b6d730c8e07d829c85429b756b5f84073624" +source = "git+https://github.com/zcash/librustzcash.git?rev=77c422f1bd56400c9647f089c87f3776e16fd212#77c422f1bd56400c9647f089c87f3776e16fd212" dependencies = [ "arti-client", "base64 0.22.1", @@ -8589,7 +8589,7 @@ dependencies = [ [[package]] name = "zcash_client_sqlite" version = "0.19.1" -source = "git+https://github.com/zcash/librustzcash.git?rev=2448b6d730c8e07d829c85429b756b5f84073624#2448b6d730c8e07d829c85429b756b5f84073624" +source = "git+https://github.com/zcash/librustzcash.git?rev=77c422f1bd56400c9647f089c87f3776e16fd212#77c422f1bd56400c9647f089c87f3776e16fd212" dependencies = [ "bip32", "bitflags 2.10.0", @@ -8635,7 +8635,7 @@ dependencies = [ [[package]] name = "zcash_encoding" version = "0.3.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=2448b6d730c8e07d829c85429b756b5f84073624#2448b6d730c8e07d829c85429b756b5f84073624" +source = "git+https://github.com/zcash/librustzcash.git?rev=77c422f1bd56400c9647f089c87f3776e16fd212#77c422f1bd56400c9647f089c87f3776e16fd212" dependencies = [ "core2 0.3.3", "hex", @@ -8645,7 +8645,7 @@ dependencies = [ [[package]] name = "zcash_keys" version = "0.12.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=2448b6d730c8e07d829c85429b756b5f84073624#2448b6d730c8e07d829c85429b756b5f84073624" +source = "git+https://github.com/zcash/librustzcash.git?rev=77c422f1bd56400c9647f089c87f3776e16fd212#77c422f1bd56400c9647f089c87f3776e16fd212" dependencies = [ "bech32 0.11.0", "bip32", @@ -8687,7 +8687,7 @@ dependencies = [ [[package]] name = "zcash_primitives" version = "0.26.4" -source = "git+https://github.com/zcash/librustzcash.git?rev=2448b6d730c8e07d829c85429b756b5f84073624#2448b6d730c8e07d829c85429b756b5f84073624" +source = "git+https://github.com/zcash/librustzcash.git?rev=77c422f1bd56400c9647f089c87f3776e16fd212#77c422f1bd56400c9647f089c87f3776e16fd212" dependencies = [ "bip32", "blake2b_simd", @@ -8729,7 +8729,7 @@ dependencies = [ [[package]] name = "zcash_proofs" version = "0.26.1" -source = "git+https://github.com/zcash/librustzcash.git?rev=2448b6d730c8e07d829c85429b756b5f84073624#2448b6d730c8e07d829c85429b756b5f84073624" +source = "git+https://github.com/zcash/librustzcash.git?rev=77c422f1bd56400c9647f089c87f3776e16fd212#77c422f1bd56400c9647f089c87f3776e16fd212" dependencies = [ "bellman", "blake2b_simd", @@ -8751,7 +8751,7 @@ dependencies = [ [[package]] name = "zcash_protocol" version = "0.7.2" -source = "git+https://github.com/zcash/librustzcash.git?rev=2448b6d730c8e07d829c85429b756b5f84073624#2448b6d730c8e07d829c85429b756b5f84073624" +source = "git+https://github.com/zcash/librustzcash.git?rev=77c422f1bd56400c9647f089c87f3776e16fd212#77c422f1bd56400c9647f089c87f3776e16fd212" dependencies = [ "core2 0.3.3", "document-features", @@ -8792,7 +8792,7 @@ dependencies = [ [[package]] name = "zcash_transparent" version = "0.6.3" -source = "git+https://github.com/zcash/librustzcash.git?rev=2448b6d730c8e07d829c85429b756b5f84073624#2448b6d730c8e07d829c85429b756b5f84073624" +source = "git+https://github.com/zcash/librustzcash.git?rev=77c422f1bd56400c9647f089c87f3776e16fd212#77c422f1bd56400c9647f089c87f3776e16fd212" dependencies = [ "bip32", "blake2b_simd", @@ -8887,7 +8887,7 @@ dependencies = [ [[package]] name = "zip321" version = "0.6.0" -source = "git+https://github.com/zcash/librustzcash.git?rev=2448b6d730c8e07d829c85429b756b5f84073624#2448b6d730c8e07d829c85429b756b5f84073624" +source = "git+https://github.com/zcash/librustzcash.git?rev=77c422f1bd56400c9647f089c87f3776e16fd212#77c422f1bd56400c9647f089c87f3776e16fd212" dependencies = [ "base64 0.22.1", "nom 7.1.3", diff --git a/Cargo.toml b/Cargo.toml index 77b4388..d3285c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -111,16 +111,16 @@ tui = [ ] [patch.crates-io] -equihash = { git = "https://github.com/zcash/librustzcash.git", rev = "2448b6d730c8e07d829c85429b756b5f84073624" } -f4jumble = { git = "https://github.com/zcash/librustzcash.git", rev = "2448b6d730c8e07d829c85429b756b5f84073624" } -pczt = { git = "https://github.com/zcash/librustzcash.git", rev = "2448b6d730c8e07d829c85429b756b5f84073624" } -transparent = { package = "zcash_transparent", git = "https://github.com/zcash/librustzcash.git", rev = "2448b6d730c8e07d829c85429b756b5f84073624" } -zcash_address = { git = "https://github.com/zcash/librustzcash.git", rev = "2448b6d730c8e07d829c85429b756b5f84073624" } -zcash_client_backend = { git = "https://github.com/zcash/librustzcash.git", rev = "2448b6d730c8e07d829c85429b756b5f84073624" } -zcash_client_sqlite = { git = "https://github.com/zcash/librustzcash.git", rev = "2448b6d730c8e07d829c85429b756b5f84073624" } -zcash_encoding = { git = "https://github.com/zcash/librustzcash.git", rev = "2448b6d730c8e07d829c85429b756b5f84073624" } -zcash_keys = { git = "https://github.com/zcash/librustzcash.git", rev = "2448b6d730c8e07d829c85429b756b5f84073624" } -zcash_primitives = { git = "https://github.com/zcash/librustzcash.git", rev = "2448b6d730c8e07d829c85429b756b5f84073624" } -zcash_proofs = { git = "https://github.com/zcash/librustzcash.git", rev = "2448b6d730c8e07d829c85429b756b5f84073624" } -zcash_protocol = { git = "https://github.com/zcash/librustzcash.git", rev = "2448b6d730c8e07d829c85429b756b5f84073624" } -zip321 = { git = "https://github.com/zcash/librustzcash.git", rev = "2448b6d730c8e07d829c85429b756b5f84073624" } +equihash = { git = "https://github.com/zcash/librustzcash.git", rev = "77c422f1bd56400c9647f089c87f3776e16fd212" } +f4jumble = { git = "https://github.com/zcash/librustzcash.git", rev = "77c422f1bd56400c9647f089c87f3776e16fd212" } +pczt = { git = "https://github.com/zcash/librustzcash.git", rev = "77c422f1bd56400c9647f089c87f3776e16fd212" } +transparent = { package = "zcash_transparent", git = "https://github.com/zcash/librustzcash.git", rev = "77c422f1bd56400c9647f089c87f3776e16fd212" } +zcash_address = { git = "https://github.com/zcash/librustzcash.git", rev = "77c422f1bd56400c9647f089c87f3776e16fd212" } +zcash_client_backend = { git = "https://github.com/zcash/librustzcash.git", rev = "77c422f1bd56400c9647f089c87f3776e16fd212" } +zcash_client_sqlite = { git = "https://github.com/zcash/librustzcash.git", rev = "77c422f1bd56400c9647f089c87f3776e16fd212" } +zcash_encoding = { git = "https://github.com/zcash/librustzcash.git", rev = "77c422f1bd56400c9647f089c87f3776e16fd212" } +zcash_keys = { git = "https://github.com/zcash/librustzcash.git", rev = "77c422f1bd56400c9647f089c87f3776e16fd212" } +zcash_primitives = { git = "https://github.com/zcash/librustzcash.git", rev = "77c422f1bd56400c9647f089c87f3776e16fd212" } +zcash_proofs = { git = "https://github.com/zcash/librustzcash.git", rev = "77c422f1bd56400c9647f089c87f3776e16fd212" } +zcash_protocol = { git = "https://github.com/zcash/librustzcash.git", rev = "77c422f1bd56400c9647f089c87f3776e16fd212" } +zip321 = { git = "https://github.com/zcash/librustzcash.git", rev = "77c422f1bd56400c9647f089c87f3776e16fd212" } diff --git a/src/commands/pczt.rs b/src/commands/pczt.rs index e7a02cf..33d470a 100644 --- a/src/commands/pczt.rs +++ b/src/commands/pczt.rs @@ -4,6 +4,7 @@ pub(crate) mod combine; pub(crate) mod create; pub(crate) mod create_manual; pub(crate) mod inspect; +pub(crate) mod pay_manual; pub(crate) mod prove; pub(crate) mod redact; pub(crate) mod send; @@ -23,6 +24,9 @@ pub(crate) enum Command { Shield(shield::Command), /// Create a PCZT from manually-provided transparent inputs CreateManual(create_manual::Command), + /// Create a PCZT to satisfy a payment request by spending manually-provided transparent + /// inputs. + PayManual(pay_manual::Command), /// Inspect a PCZT Inspect(inspect::Command), /// Adds BIP 44 or ZIP 32 derivations to a PCZT diff --git a/src/commands/pczt/create_manual.rs b/src/commands/pczt/create_manual.rs index c89001f..be9443f 100644 --- a/src/commands/pczt/create_manual.rs +++ b/src/commands/pczt/create_manual.rs @@ -5,16 +5,12 @@ use anyhow::anyhow; use clap::Args; use pczt::roles::{creator::Creator, io_finalizer::IoFinalizer, updater::Updater}; use rand::rngs::OsRng; -use serde::Deserialize; use tokio::io::{stdout, AsyncWriteExt}; -use transparent::{ - address::{Script, TransparentAddress}, - bundle::{OutPoint, TxOut}, -}; +use transparent::builder::TransparentInputInfo; use zcash_address::ZcashAddress; use zcash_client_backend::proto::service::{ChainSpec, TxFilter}; -use zcash_keys::address::{Address, Receiver}; +use zcash_keys::address::Address; use zcash_primitives::transaction::{ builder::{Builder, PcztResult}, fees::zip317, @@ -25,12 +21,12 @@ use zcash_protocol::{ memo::{Memo, MemoBytes}, value::Zatoshis, }; -use zcash_script::script; use crate::{ config::WalletConfig, data::Network, error, + helpers::pczt::create_manual::{add_inputs, add_recipient, handle_recipient, parse_coins}, remote::{tor_client, Servers}, }; @@ -78,69 +74,6 @@ pub(crate) struct Command { disable_tor: bool, } -fn parse_coins(s: &str) -> anyhow::Result> { - Ok(serde_json::from_str(s)?) -} - -#[derive(Clone, Debug, Deserialize)] -struct Coin { - txid: String, - out_index: u32, - value: Option, - script_pubkey: Option, - pubkey: Option, - redeem_script: Option, -} - -impl Coin { - /// Returns a pointer to this coin in the Zcash chain. - fn outpoint(&self) -> anyhow::Result { - let hash: [u8; 32] = { - let mut bytes = hex::decode(&self.txid)?; - bytes.reverse(); - bytes - .as_slice() - .try_into() - .map_err(|e| anyhow!("Invalid coin outpoint hash: {e}"))? - }; - - Ok(OutPoint::new(hash, self.out_index)) - } - - /// Returns the coin itself, if provided. - fn coin(&self) -> anyhow::Result> { - self.value - .zip(self.script_pubkey.as_ref()) - .map(|(value, script_pubkey)| { - let value = Zatoshis::from_u64(value).map_err(|_| error::Error::InvalidAmount)?; - let script_pubkey = Script(script::Code(hex::decode(script_pubkey)?)); - Ok(TxOut::new(value, script_pubkey)) - }) - .transpose() - } - - /// Returns the information needed to spend this coin. - fn spend_info(&self) -> anyhow::Result { - match (&self.pubkey, &self.redeem_script) { - (None, None) => Err(anyhow!("Missing either `pubkey` or `redeem_script")), - (Some(_), Some(_)) => Err(anyhow!("Cannot provide both `pubkey` and `redeem_script`")), - (Some(pubkey), None) => Ok(SpendInfo::P2pkh { pubkey: *pubkey }), - (None, Some(script_hex)) => { - let script_bytes = hex::decode(script_hex)?; - let redeem_script = script::FromChain::parse(&script::Code(script_bytes)) - .map_err(|e| anyhow!("{e:?}"))?; - Ok(SpendInfo::P2sh { redeem_script }) - } - } - } -} - -#[derive(Clone)] -enum SpendInfo { - P2pkh { pubkey: secp256k1::PublicKey }, - P2sh { redeem_script: script::FromChain }, -} - impl Command { pub(crate) async fn run(self, wallet_dir: Option) -> anyhow::Result<()> { let params = if let Some(network) = self.network { @@ -218,91 +151,12 @@ impl Command { }; value_in = (value_in + coin.value).ok_or_else(|| anyhow!("Balance overflow"))?; - transparent_inputs.push((utxo, coin, spend_info)); - } - - fn handle_recipient( - recipient: Address, - ctx: C, - on_transparent: impl FnOnce(TransparentAddress, C) -> anyhow::Result, - on_sapling: impl FnOnce(sapling::PaymentAddress, C) -> anyhow::Result, - on_orchard: impl FnOnce(orchard::Address, C) -> anyhow::Result, - ) -> anyhow::Result { - match recipient { - Address::Sapling(payment_address) => on_sapling(payment_address, ctx), - Address::Transparent(transparent_address) => { - on_transparent(transparent_address, ctx) - } - Address::Unified(unified_address) => match unified_address - .as_understood_receivers() - .into_iter() - .next() - .ok_or_else(|| anyhow!("Recipient is UA with no understood receivers"))? - { - Receiver::Orchard(address) => on_orchard(address, ctx), - Receiver::Sapling(payment_address) => on_sapling(payment_address, ctx), - Receiver::Transparent(transparent_address) => { - on_transparent(transparent_address, ctx) - } - }, - // Only supported inputs are transparent, so it's fine to send directly to - // a TEX address. - Address::Tex(p2pkh_hash) => { - on_transparent(TransparentAddress::PublicKeyHash(p2pkh_hash), ctx) - } - } + let input = TransparentInputInfo::from_parts(utxo, coin, spend_info) + .map_err(|e| anyhow!("Invalid transparent input data: {}", e))?; + transparent_inputs.push(input); } - let add_inputs = |builder: &mut Builder<'_, _, _>, - transparent_inputs: Vec<(OutPoint, TxOut, SpendInfo)>| - -> anyhow::Result<()> { - for (utxo, coin, spend_info) in transparent_inputs { - match spend_info { - SpendInfo::P2pkh { pubkey } => builder - .add_transparent_input(pubkey, utxo, coin) - .map_err(|e| anyhow!("{e}"))?, - SpendInfo::P2sh { redeem_script } => builder - .add_transparent_p2sh_input(redeem_script, utxo, coin) - .map_err(|e| anyhow!("{e}"))?, - } - } - Ok(()) - }; - - let add_recipient = |builder: &mut Builder<'_, _, _>, - recipient: Address, - value: Zatoshis, - memo: Option| - -> anyhow::Result<()> { - handle_recipient( - recipient, - (builder, memo), - |to, (builder, _)| { - builder - .add_transparent_output(&to, value) - .map_err(|e| anyhow!("{e}")) - }, - |to, (builder, memo)| { - Ok(builder.add_sapling_output::( - None, - to, - value, - memo.unwrap_or(MemoBytes::empty()), - )?) - }, - |recipient, (builder, memo)| { - Ok(builder.add_orchard_output::( - None, - recipient, - value, - memo.unwrap_or(MemoBytes::empty()), - )?) - }, - )?; - Ok(()) - }; - - let prepare_builder = |transparent_inputs: Vec<(OutPoint, TxOut, SpendInfo)>, + let prepare_builder = |transparent_inputs: Vec, recipient: Address, value: Zatoshis, memo: Option| diff --git a/src/commands/pczt/pay_manual.rs b/src/commands/pczt/pay_manual.rs new file mode 100644 index 0000000..dedc6ac --- /dev/null +++ b/src/commands/pczt/pay_manual.rs @@ -0,0 +1,369 @@ +use std::{collections::BTreeMap, convert::Infallible}; + +use anyhow::anyhow; +use clap::Args; +use pczt::roles::{creator::Creator, io_finalizer::IoFinalizer, updater::Updater}; +use rand::rngs::OsRng; +use tokio::io::{stdout, AsyncWriteExt}; + +use transparent::{builder::TransparentInputInfo, bundle::TxOut}; +use zcash_client_backend::{ + fees::{ + zip317::SingleOutputChangeStrategy, ChangeError, ChangeStrategy as _, DustOutputPolicy, + }, + proto::service::{ChainSpec, TxFilter}, +}; +use zcash_keys::address::Address; +use zcash_primitives::transaction::{ + builder::{Builder, PcztResult}, + fees::zip317, + Transaction, +}; +use zcash_protocol::{ + consensus::{self, Parameters as _}, + PoolType, ShieldedProtocol, +}; +use zip321::TransactionRequest; + +use crate::{ + config::WalletConfig, + data::Network, + helpers::pczt::create_manual::{add_inputs, add_recipient, handle_recipient, parse_coins}, + remote::{tor_client, Servers}, +}; + +// Options accepted for the `pczt pay-manual` command +#[derive(Debug, Args)] +pub(crate) struct Command { + /// The transparent coins to spend in this transaction. + /// + /// This is a JSON array of objects with the following fields: + /// - `txid`: ID of the transaction in which the coin was created. + /// - `out_index`: Index of the output within the transaction's `vout`. + /// - `value` and `script_pubkey`: Fields of the output, as an integer in zatoshis and + /// a hex string respectively. If omitted, `txid` will be looked up from the chain. + /// - `pubkey` or `redeem_script`: The public key (for a P2PKH coin) or the redeem + /// script (for a P2SH coin) as a hex string. Only one of these can be set. + #[arg(long)] + coins: String, + + /// The ZIP 321 transaction request describing the desired outputs of the transaction. + #[arg(long)] + payment_request: String, + + /// The Unified, Sapling or transparent address to which change should be sent. In the case + /// that coinbase inputs are being spent, this MUST be a shielded address. + #[arg(long)] + change_address: String, + + /// The network the coins are from: \"test\" or \"main\". + /// + /// If unset, uses the network of the provided wallet. + #[arg(short, long)] + #[arg(value_parser = Network::parse)] + network: Option, + + /// The server to use for network information (default is \"ecc\") + #[arg(short, long)] + #[arg(default_value = "ecc", value_parser = Servers::parse)] + server: Servers, + + /// Disable connections via TOR + #[arg(long)] + disable_tor: bool, +} + +impl Command { + pub(crate) async fn run(self, wallet_dir: Option) -> anyhow::Result<()> { + let params = if let Some(network) = self.network { + consensus::Network::from(network) + } else { + let config = WalletConfig::read(wallet_dir.as_ref())?; + config.network() + }; + let rng = OsRng; + + let coins = parse_coins(&self.coins)?; + + 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? + }; + + let latest_block = client.get_latest_block(ChainSpec {}).await?.into_inner(); + let target_height = + consensus::BlockHeight::from_u32(u32::try_from(latest_block.height)?) + 1; + let tree_state = client.get_tree_state(latest_block).await?.into_inner(); + let sapling_anchor = Some(tree_state.sapling_tree()?.root().into()); + let orchard_anchor = Some(tree_state.orchard_tree()?.root().into()); + + let payment_request = TransactionRequest::from_uri(&self.payment_request)?; + let change_address = Address::decode(¶ms, &self.change_address) + .ok_or_else(|| anyhow!("Unable to decode change address."))?; + + // TODO: we should return an error if any of the UTXOs being spent are outputs of a + // coinbase transaction and the change address is transparent-only; however, we do not have + // the information necessary to make such a determination about the inputs here; we don't + // get that information from the RawTransaction data returned from the light client server. + //let requires_transparent_change = !change_address + // .as_understood_unified_receivers() + // .iter() + // .any(|r| matches!(r, Receiver::Orchard(_) | Receiver::Sapling(_))); + + let mut transparent_inputs = vec![]; + for input in coins { + let utxo = input.outpoint()?; + let spend_info = input.spend_info()?; + + let coin = if let Some(coin) = input.coin()? { + coin + } else { + // Look up the coin on-chain. + let request = TxFilter { + block: None, + index: 0, + hash: utxo.hash().into(), + }; + let raw_tx = client.get_transaction(request).await?.into_inner(); + let tx = Transaction::read( + raw_tx.data.as_slice(), + consensus::BranchId::for_height( + ¶ms, + // TODO: Handle mempool tx height. + consensus::BlockHeight::from_u32(u32::try_from(raw_tx.height)?), + ), + )?; + + if let Some(bundle) = tx.transparent_bundle() { + bundle + .vout + .get(usize::try_from(input.out_index)?) + .cloned() + .ok_or_else(|| anyhow!("Coin is invalid")) + } else { + Err(anyhow!("Coin is invalid")) + }? + }; + + let input = TransparentInputInfo::from_parts(utxo, coin, spend_info) + .map_err(|e| anyhow!("Invalid transparent input data: {}", e))?; + transparent_inputs.push(input); + } + + let change_strategy = SingleOutputChangeStrategy::<_, Infallible>::new( + zip317::FeeRule::standard(), + None, + ShieldedProtocol::Orchard, + DustOutputPolicy::default(), + ); + + let outputs = payment_request + .payments() + .iter() + .map(|(i, p)| { + p.recipient_address() + .clone() + .convert_if_network::
(params.network_type()) + .map_err(|e| anyhow!("Invalid address found for payment index {}: {}", i, e)) + .and_then(|addr| { + Ok(( + p.amount() + .ok_or_else(|| anyhow!("Payment amount missing at index {}", i))?, + addr, + p.memo(), + )) + }) + }) + .collect::, _>>()?; + + let transparent_outputs = outputs + .iter() + .filter_map(|(value, addr, _)| { + handle_recipient( + addr.clone(), + (), + |taddr, _| Ok(Some(TxOut::new(*value, taddr.script().into()))), + |_, _| Ok(None), + |_, _| Ok(None), + ) + .transpose() + }) + .collect::, _>>()?; + + let orchard_output_values = outputs + .iter() + .filter_map(|(value, addr, _)| { + handle_recipient( + addr.clone(), + (), + |_, _| Ok(None), + |_, _| Ok(None), + |_, _| Ok(Some(*value)), + ) + .transpose() + }) + .collect::, _>>()?; + + let sapling_output_values = outputs + .iter() + .filter_map(|(value, addr, _)| { + handle_recipient( + addr.clone(), + (), + |_, _| Ok(None), + |_, _| Ok(Some(*value)), + |_, _| Ok(None), + ) + .transpose() + }) + .collect::, _>>()?; + + let balance = change_strategy + .compute_balance::<_, Infallible>( + ¶ms, + target_height.into(), + &transparent_inputs[..], + &transparent_outputs, + &( + sapling::builder::BundleType::DEFAULT, + &[][..] as &[Infallible], + &sapling_output_values[..], + ), + &( + orchard::builder::BundleType::DEFAULT, + &[][..] as &[Infallible], + &orchard_output_values[..], + ), + None, + &(), + ) + .map_err(|e: ChangeError<_, Infallible>| { + anyhow!("Error in computing balance: {}", e) + })?; + + let mut builder = Builder::new( + params, + target_height, + zcash_primitives::transaction::builder::BuildConfig::Standard { + sapling_anchor, + orchard_anchor, + }, + ); + add_inputs(&mut builder, transparent_inputs)?; + + let mut output_counts: BTreeMap = BTreeMap::new(); + let mut output_mapping = BTreeMap::new(); + for (i, (value, addr, memo)) in outputs.iter().enumerate() { + let recipient_pool = add_recipient(&mut builder, addr.clone(), *value, memo.cloned())?; + let pool_output_index = *output_counts + .entry(recipient_pool) + .and_modify(|n| { + *n += 1; + }) + .or_default(); + output_mapping.insert(i, pool_output_index); + } + + let mut change_mapping = BTreeMap::new(); + for (j, change_output) in balance.proposed_change().iter().enumerate() { + let recipient_pool = add_recipient( + &mut builder, + change_address.clone(), + change_output.value(), + change_output.memo().cloned(), + )?; + let pool_output_index = *output_counts + .entry(recipient_pool) + .and_modify(|n| { + *n += 1; + }) + .or_default(); + change_mapping.insert(j, pool_output_index); + } + + let PcztResult { + pczt_parts, + sapling_meta, + orchard_meta, + } = builder.build_for_pczt(rng, &zip317::FeeRule::standard())?; + let created = Creator::build_from_parts(pczt_parts) + .ok_or_else(|| anyhow!("Transaction version is incompatible with PCZTs"))?; + + let io_finalized = IoFinalizer::new(created) + .finalize_io() + .map_err(|e| anyhow!("{e:?}"))?; + + let set_verification_address = + |updater: Updater, + recipient_addr: &Address, + i: usize, + mappings: &BTreeMap| { + handle_recipient( + recipient_addr.clone(), + (updater, recipient_addr.encode(¶ms)), + |_, (updater, user_address)| { + let t_index = mappings.get(&i).unwrap_or_else(|| { + panic!("Transparent output index was tracked for output {i}") + }); + updater + .update_transparent_with(|mut u| { + u.update_output_with(*t_index, |mut ou| { + ou.set_user_address(user_address); + Ok(()) + }) + }) + .map_err(|e| anyhow!("{e:?}")) + }, + |_, (updater, user_address)| { + let s_index = mappings + .get(&i) + .and_then(|i0| sapling_meta.output_index(*i0)) + .unwrap_or_else(|| { + panic!("Sapling output index was tracked for output {i}") + }); + updater + .update_sapling_with(|mut u| { + u.update_output_with(s_index, |mut ou| { + ou.set_user_address(user_address); + Ok(()) + }) + }) + .map_err(|e| anyhow!("{e:?}")) + }, + |_, (updater, user_address)| { + let o_index = mappings + .get(&i) + .and_then(|i0| orchard_meta.output_action_index(*i0)) + .unwrap_or_else(|| { + panic!("Orchard output index was tracked for output {i}") + }); + updater + .update_orchard_with(|mut u| { + u.update_action_with(o_index, |mut au| { + au.set_output_user_address(user_address); + Ok(()) + }) + }) + .map_err(|e| anyhow!("{e:?}")) + }, + ) + }; + + // Add the recipient address metadata to the generated output to permit + // verification by signers. + let mut updater = Updater::new(io_finalized); + for (i, (_, recipient_addr, _)) in outputs.iter().enumerate() { + updater = set_verification_address(updater, recipient_addr, i, &output_mapping)?; + } + for j in 0..balance.proposed_change().len() { + updater = set_verification_address(updater, &change_address, j, &change_mapping)?; + } + + let pczt = updater.finish(); + stdout().write_all(&pczt.serialize()).await?; + + Ok(()) + } +} diff --git a/src/helpers.rs b/src/helpers.rs new file mode 100644 index 0000000..b026d8b --- /dev/null +++ b/src/helpers.rs @@ -0,0 +1 @@ +pub(crate) mod pczt; diff --git a/src/helpers/pczt.rs b/src/helpers/pczt.rs new file mode 100644 index 0000000..8f3d14a --- /dev/null +++ b/src/helpers/pczt.rs @@ -0,0 +1 @@ +pub(crate) mod create_manual; diff --git a/src/helpers/pczt/create_manual.rs b/src/helpers/pczt/create_manual.rs new file mode 100644 index 0000000..c81e133 --- /dev/null +++ b/src/helpers/pczt/create_manual.rs @@ -0,0 +1,144 @@ +use anyhow::anyhow; +use serde::Deserialize; +use transparent::{ + address::{Script, TransparentAddress}, + builder::{SpendInfo, TransparentInputInfo}, + bundle::{OutPoint, TxOut}, +}; +use zcash_keys::address::{Address, Receiver}; +use zcash_primitives::transaction::{builder::Builder, fees::zip317}; +use zcash_protocol::{consensus, memo::MemoBytes, value::Zatoshis, PoolType}; +use zcash_script::script; + +use crate::error; + +pub(crate) fn parse_coins(s: &str) -> anyhow::Result> { + Ok(serde_json::from_str(s)?) +} + +#[derive(Clone, Debug, Deserialize)] +pub(crate) struct Coin { + txid: String, + pub(crate) out_index: u32, + pub(crate) value: Option, + script_pubkey: Option, + pubkey: Option, + redeem_script: Option, +} + +impl Coin { + /// Returns a pointer to this coin in the Zcash chain. + pub(crate) fn outpoint(&self) -> anyhow::Result { + let hash: [u8; 32] = { + let mut bytes = hex::decode(&self.txid)?; + bytes.reverse(); + bytes + .as_slice() + .try_into() + .map_err(|e| anyhow!("Invalid coin outpoint hash: {e}"))? + }; + + Ok(OutPoint::new(hash, self.out_index)) + } + + /// Returns the coin itself, if provided. + pub(crate) fn coin(&self) -> anyhow::Result> { + self.value + .zip(self.script_pubkey.as_ref()) + .map(|(value, script_pubkey)| { + let value = Zatoshis::from_u64(value).map_err(|_| error::Error::InvalidAmount)?; + let script_pubkey = Script(script::Code(hex::decode(script_pubkey)?)); + Ok(TxOut::new(value, script_pubkey)) + }) + .transpose() + } + + /// Returns the information needed to spend this coin. + pub(crate) fn spend_info(&self) -> anyhow::Result { + match (&self.pubkey, &self.redeem_script) { + (None, None) => Err(anyhow!("Missing either `pubkey` or `redeem_script")), + (Some(_), Some(_)) => Err(anyhow!("Cannot provide both `pubkey` and `redeem_script`")), + (Some(pubkey), None) => Ok(SpendInfo::P2pkh { pubkey: *pubkey }), + (None, Some(script_hex)) => { + let script_bytes = hex::decode(script_hex)?; + let redeem_script = script::FromChain::parse(&script::Code(script_bytes)) + .map_err(|e| anyhow!("{e:?}"))?; + Ok(SpendInfo::P2sh { redeem_script }) + } + } + } +} + +pub(crate) fn handle_recipient( + recipient: Address, + ctx: C, + on_transparent: impl FnOnce(TransparentAddress, C) -> anyhow::Result, + on_sapling: impl FnOnce(sapling::PaymentAddress, C) -> anyhow::Result, + on_orchard: impl FnOnce(orchard::Address, C) -> anyhow::Result, +) -> anyhow::Result { + match recipient { + Address::Sapling(payment_address) => on_sapling(payment_address, ctx), + Address::Transparent(transparent_address) => on_transparent(transparent_address, ctx), + Address::Unified(unified_address) => match unified_address + .as_understood_receivers() + .into_iter() + .next() + .ok_or_else(|| anyhow!("Recipient is UA with no understood receivers"))? + { + Receiver::Orchard(address) => on_orchard(address, ctx), + Receiver::Sapling(payment_address) => on_sapling(payment_address, ctx), + Receiver::Transparent(transparent_address) => on_transparent(transparent_address, ctx), + }, + // Only supported inputs are transparent, so it's fine to send directly to + // a TEX address. + Address::Tex(p2pkh_hash) => { + on_transparent(TransparentAddress::PublicKeyHash(p2pkh_hash), ctx) + } + } +} + +pub(crate) fn add_inputs( + builder: &mut Builder<'_, P, U>, + transparent_inputs: Vec, +) -> anyhow::Result<()> { + for input in transparent_inputs.into_iter() { + builder.add_transparent_input(input); + } + Ok(()) +} + +pub(crate) fn add_recipient( + builder: &mut Builder<'_, P, U>, + recipient: Address, + value: Zatoshis, + memo: Option, +) -> anyhow::Result { + handle_recipient( + recipient, + (builder, memo), + |to, (builder, _)| { + builder + .add_transparent_output(&to, value) + .map_err(|e| anyhow!("{e}"))?; + Ok(PoolType::Transparent) + }, + |to, (builder, memo)| { + builder.add_sapling_output::( + None, + to, + value, + memo.unwrap_or(MemoBytes::empty()), + )?; + Ok(PoolType::SAPLING) + }, + |recipient, (builder, memo)| { + builder.add_orchard_output::( + None, + recipient, + value, + memo.unwrap_or(MemoBytes::empty()), + )?; + Ok(PoolType::ORCHARD) + }, + ) +} diff --git a/src/main.rs b/src/main.rs index cf11aad..56b45e5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ mod commands; mod config; mod data; mod error; +mod helpers; mod remote; mod ui; @@ -188,6 +189,7 @@ fn main() -> Result<(), anyhow::Error> { commands::pczt::Command::Create(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, commands::pczt::Command::Inspect(command) => command.run(wallet_dir).await, commands::pczt::Command::UpdateWithDerivation(command) => { command.run(wallet_dir).await