Merge pull request #55 from Electric-Coin-Company/pczt

Add PCZT commands
This commit is contained in:
Jack Grigg 2024-12-12 05:11:26 +13:00 committed by GitHub
commit 40a92245cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1616 additions and 48 deletions

806
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -9,9 +9,11 @@ publish = false
[dependencies]
anyhow = "1"
bip0039 = { version = "0.12", features = ["std", "all-languages"] }
bip32 = "0.5"
futures-util = "0.3"
gumdrop = "0.8"
hex = "0.4"
jubjub = "0.10"
prost = "0.13"
rayon = "1.7"
rusqlite = { version = "0.32", features = ["time"] }
@ -27,10 +29,11 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
uuid = "1"
orchard = { version = "0.10", default-features = false }
pczt = "0.0"
sapling = { package = "sapling-crypto", version = "0.3" }
zcash_address = "0.6"
zcash_client_backend = { version = "0.15", features = ["lightwalletd-tonic-tls-webpki-roots", "orchard", "tor"] }
zcash_client_sqlite = { version = "0.13", features = ["unstable", "orchard"] }
zcash_client_backend = { version = "0.15", features = ["lightwalletd-tonic-tls-webpki-roots", "orchard", "pczt", "tor"] }
zcash_client_sqlite = { version = "0.13", features = ["unstable", "orchard", "serde"] }
zcash_keys = { version = "0.5", features = ["unstable", "orchard"] }
zcash_primitives = "0.20"
zcash_proofs = "0.20"
@ -45,6 +48,12 @@ age = { version = "0.11", features = ["armor", "plugin"] }
iso_currency = { version = "0.5", features = ["with-serde"] }
rust_decimal = "1"
# PCZT QR codes
nokhwa = { version = "0.10", optional = true, features = ["input-native"] }
qrcode = { version = "0.14", optional = true, default-features = false }
rqrr = { version = "0.8", optional = true }
ur = { version = "0.4", optional = true }
# TUI
crossterm = { version = "0.28", optional = true, features = ["event-stream"] }
ratatui = { version = "0.28", optional = true }
@ -54,6 +63,7 @@ tui-logger = { version = "0.12", optional = true, features = ["tracing-support"]
[features]
default = ["transparent-inputs"]
pczt-qr = ["dep:nokhwa", "dep:qrcode", "dep:rqrr", "dep:ur"]
transparent-inputs = [
"zcash_client_sqlite/transparent-inputs",
]
@ -66,11 +76,14 @@ tui = [
]
[patch.crates-io]
zcash_address = { git = "https://github.com/zcash/librustzcash.git", rev = "e0f04e6c7749751e7f590b2c25275f1fa3421d50" }
zcash_client_backend = { git = "https://github.com/zcash/librustzcash.git", rev = "e0f04e6c7749751e7f590b2c25275f1fa3421d50" }
zcash_client_sqlite = { git = "https://github.com/zcash/librustzcash.git", rev = "e0f04e6c7749751e7f590b2c25275f1fa3421d50" }
zcash_keys = { git = "https://github.com/zcash/librustzcash.git", rev = "e0f04e6c7749751e7f590b2c25275f1fa3421d50" }
zcash_primitives = { git = "https://github.com/zcash/librustzcash.git", rev = "e0f04e6c7749751e7f590b2c25275f1fa3421d50" }
zcash_proofs = { git = "https://github.com/zcash/librustzcash.git", rev = "e0f04e6c7749751e7f590b2c25275f1fa3421d50" }
zcash_protocol = { git = "https://github.com/zcash/librustzcash.git", rev = "e0f04e6c7749751e7f590b2c25275f1fa3421d50" }
zip321 = { git = "https://github.com/zcash/librustzcash.git", rev = "e0f04e6c7749751e7f590b2c25275f1fa3421d50" }
orchard = { git = "https://github.com/zcash/orchard.git", rev = "bcd08e1d23e70c42a338f3e3f79d6f4c0c219805" }
pczt = { git = "https://github.com/zcash/librustzcash.git", rev = "1e274c892a11cd15f643f08ffa579166d60180bb" }
sapling-crypto = { git = "https://github.com/zcash/sapling-crypto.git", rev = "29cff9683cdf2f0c522ff3224081dfb4fbc80248" }
zcash_address = { git = "https://github.com/zcash/librustzcash.git", rev = "1e274c892a11cd15f643f08ffa579166d60180bb" }
zcash_client_backend = { git = "https://github.com/zcash/librustzcash.git", rev = "1e274c892a11cd15f643f08ffa579166d60180bb" }
zcash_client_sqlite = { git = "https://github.com/zcash/librustzcash.git", rev = "1e274c892a11cd15f643f08ffa579166d60180bb" }
zcash_keys = { git = "https://github.com/zcash/librustzcash.git", rev = "1e274c892a11cd15f643f08ffa579166d60180bb" }
zcash_primitives = { git = "https://github.com/zcash/librustzcash.git", rev = "1e274c892a11cd15f643f08ffa579166d60180bb" }
zcash_proofs = { git = "https://github.com/zcash/librustzcash.git", rev = "1e274c892a11cd15f643f08ffa579166d60180bb" }
zcash_protocol = { git = "https://github.com/zcash/librustzcash.git", rev = "1e274c892a11cd15f643f08ffa579166d60180bb" }
zip321 = { git = "https://github.com/zcash/librustzcash.git", rev = "1e274c892a11cd15f643f08ffa579166d60180bb" }

View File

@ -7,6 +7,7 @@ pub(crate) mod list_accounts;
pub(crate) mod list_addresses;
pub(crate) mod list_tx;
pub(crate) mod list_unspent;
pub(crate) mod pczt;
pub(crate) mod propose;
pub(crate) mod reset;
pub(crate) mod send;

View File

@ -28,7 +28,7 @@ impl Command {
println!("Account {}", self.account_id);
let (ua, _) = account
.uivk()
.default_address(UnifiedAddressRequest::all().unwrap())?;
.default_address(UnifiedAddressRequest::all())?;
println!(" Default Address: {}", ua.encode(&params));
Ok(())
}

30
src/commands/pczt.rs Normal file
View File

@ -0,0 +1,30 @@
use gumdrop::Options;
pub(crate) mod combine;
pub(crate) mod create;
pub(crate) mod prove;
pub(crate) mod send;
pub(crate) mod sign;
#[cfg(feature = "pczt-qr")]
pub(crate) mod qr;
#[derive(Debug, Options)]
pub(crate) enum Command {
#[options(help = "create a PCZT")]
Create(create::Command),
#[options(help = "create proofs for a PCZT")]
Prove(prove::Command),
#[options(help = "apply signatures to a PCZT")]
Sign(sign::Command),
#[options(help = "combine two PCZTs")]
Combine(combine::Command),
#[options(help = "extract a finished transaction and send it")]
Send(send::Command),
#[cfg(feature = "pczt-qr")]
#[options(help = "render a PCZT as an animated QR code")]
ToQr(qr::Send),
#[cfg(feature = "pczt-qr")]
#[options(help = "read a PCZT from an animated QR code via the webcam")]
FromQr(qr::Receive),
}

View File

@ -0,0 +1,40 @@
use std::path::PathBuf;
use anyhow::anyhow;
use gumdrop::Options;
use pczt::{roles::combiner::Combiner, Pczt};
use tokio::{
fs::File,
io::{stdout, AsyncReadExt, AsyncWriteExt},
};
// Options accepted for the `pczt combine` command
#[derive(Debug, Options)]
pub(crate) struct Command {
#[options(help = "a list of PCZT files to combine")]
input: Vec<PathBuf>,
}
impl Command {
pub(crate) async fn run(self) -> Result<(), anyhow::Error> {
let mut pczts = vec![];
for f in self.input {
let mut f = File::open(f).await?;
let mut buf = vec![];
f.read_to_end(&mut buf).await?;
let pczt = Pczt::parse(&buf).map_err(|e| anyhow!("Failed to read PCZT: {:?}", e))?;
pczts.push(pczt);
}
let pczt = Combiner::new(pczts)
.combine()
.map_err(|e| anyhow!("Failed to combine PCZTs: {:?}", e))?;
stdout().write_all(&pczt.serialize()).await?;
Ok(())
}
}

121
src/commands/pczt/create.rs Normal file
View File

@ -0,0 +1,121 @@
#![allow(deprecated)]
use std::{num::NonZeroUsize, str::FromStr};
use anyhow::anyhow;
use gumdrop::Options;
use tokio::io::{stdout, AsyncWriteExt};
use zcash_address::ZcashAddress;
use zcash_client_backend::{
data_api::{
wallet::{
create_pczt_from_proposal, input_selection::GreedyInputSelector, propose_transfer,
},
WalletRead,
},
fees::{standard::MultiOutputChangeStrategy, DustOutputPolicy, SplitPolicy, StandardFeeRule},
wallet::OvkPolicy,
ShieldedProtocol,
};
use zcash_client_sqlite::WalletDb;
use zcash_protocol::{
memo::{Memo, MemoBytes},
value::Zatoshis,
};
use zip321::{Payment, TransactionRequest};
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(
required,
help = "the recipient's Unified, Sapling or transparent address"
)]
address: String,
#[options(required, help = "the amount in zatoshis")]
value: u64,
#[options(help = "a memo to send to the recipient")]
memo: Option<String>,
#[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<String>) -> 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 = *db_data
.get_account_ids()?
.first()
.ok_or(anyhow!("Wallet has no accounts"))?;
// 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();
let request = TransactionRequest::new(vec![Payment::new(
ZcashAddress::from_str(&self.address).map_err(|_| error::Error::InvalidRecipient)?,
Zatoshis::from_u64(self.value).map_err(|_| error::Error::InvalidAmount)?,
self.memo
.map(|memo| Memo::from_str(&memo))
.transpose()?
.map(MemoBytes::from),
None,
None,
vec![],
)
.ok_or_else(|| anyhow!("Invalid memo"))?])
.map_err(error::Error::from)?;
let proposal = propose_transfer(
&mut db_data,
&params,
account_id,
&input_selector,
&change_strategy,
request,
MIN_CONFIRMATIONS,
)
.map_err(error::Error::from)?;
let pczt = create_pczt_from_proposal(
&mut db_data,
&params,
account_id,
OvkPolicy::Sender,
&proposal,
)
.map_err(error::Error::from)?;
stdout().write_all(&pczt.serialize()).await?;
Ok(())
}
}

198
src/commands/pczt/prove.rs Normal file
View File

@ -0,0 +1,198 @@
use anyhow::anyhow;
use gumdrop::Options;
use pczt::{
roles::{prover::Prover, updater::Updater},
Pczt,
};
use sapling::ProofGenerationKey;
use secrecy::ExposeSecret;
use tokio::io::{stdin, stdout, AsyncReadExt, AsyncWriteExt};
use zcash_keys::keys::UnifiedSpendingKey;
use zcash_proofs::prover::LocalTxProver;
use zcash_protocol::consensus::{NetworkConstants, Parameters};
use zip32::fingerprint::SeedFingerprint;
use crate::config::WalletConfig;
// Options accepted for the `pczt prove` command
#[derive(Debug, Options)]
pub(crate) struct Command {
#[options(
help = "hex encoding of the Sapling proof generation key",
parse(try_from_str = "hex::decode")
)]
sapling_proof_generation_key: Option<Vec<u8>>,
#[options(
help = "age identity file to decrypt the mnemonic phrase with for deriving the Sapling proof generation key"
)]
identity: Option<String>,
}
impl Command {
pub(crate) async fn run(self, wallet_dir: Option<String>) -> Result<(), anyhow::Error> {
let mut buf = vec![];
stdin().read_to_end(&mut buf).await?;
let pczt = Pczt::parse(&buf).map_err(|e| anyhow!("Failed to read PCZT: {:?}", e))?;
// If we have Sapling spends, we need Sapling proof generation keys.
let pczt = if !pczt.sapling().spends().is_empty() {
enum PgkSource {
Provided(ProofGenerationKey),
Wallet {
config: WalletConfig,
seed_fp: SeedFingerprint,
},
}
impl PgkSource {
fn proof_generation_key(
&self,
derivation: Option<([u8; 32], Vec<zip32::ChildIndex>)>,
) -> anyhow::Result<ProofGenerationKey> {
match self {
PgkSource::Provided(proof_generation_key) => {
Ok(proof_generation_key.clone())
}
PgkSource::Wallet { config, seed_fp } => {
if let Some((seed_fingerprint, derivation_path)) = derivation {
let params = config.network();
if seed_fingerprint == seed_fp.to_bytes()
&& derivation_path.len() == 3
&& derivation_path[0] == zip32::ChildIndex::hardened(32)
&& derivation_path[1]
== zip32::ChildIndex::hardened(
params.network_type().coin_type(),
)
{
let account_index = zip32::AccountId::try_from(
derivation_path[2].index() - (1 << 31),
)
.expect("valid");
let usk = UnifiedSpendingKey::from_seed(
&params,
config
.seed()
.ok_or(anyhow!(
"Seed must be present to enable signing"
))?
.expose_secret(),
account_index,
)?;
Ok(usk.sapling().expsk.proof_generation_key())
} else {
Err(anyhow!(
"Invalid ZIP 32 derivation path for PCZT Sapling spend"
))
}
} else {
Err(anyhow!(
"Missing ZIP 32 derivation path for PCZT Sapling spend"
))
}
}
}
}
}
let pkg_source = match (self.sapling_proof_generation_key, self.identity) {
(Some(proof_generation_key), _) => {
if proof_generation_key.len() == 64 {
Ok(PgkSource::Provided(sapling::keys::ProofGenerationKey {
ak: sapling::keys::SpendValidatingKey::temporary_zcash_from_bytes(
&proof_generation_key[..32],
)
.ok_or_else(|| anyhow!("Invalid Sapling proof generation key"))?,
nsk: jubjub::Scalar::from_bytes(
&proof_generation_key[32..].try_into().unwrap(),
)
.into_option()
.ok_or_else(|| anyhow!("Invalid Sapling proof generation key"))?,
}))
} else {
Err(anyhow!("Invalid Sapling proof generation key"))
}
}
(None, Some(identity)) => {
// Try to load it from the wallet config.
let mut config = WalletConfig::read(wallet_dir.as_ref())?;
// Decrypt the mnemonic to access the seed.
let identities = age::IdentityFile::from_file(identity)?.into_identities()?;
config.decrypt(identities.iter().map(|i| i.as_ref() as _))?;
// Cache the seed fingerprint for matching.
let seed = config
.seed()
.ok_or(anyhow!("Seed must be present to enable signing"))?
.expose_secret();
let seed_fingerprint = SeedFingerprint::from_seed(seed)
.ok_or_else(|| anyhow!("Invalid seed length"))?;
Ok(PgkSource::Wallet {
config,
seed_fp: seed_fingerprint,
})
}
(None, None) => Err(anyhow!(
"Cannot create Sapling proofs without a proof generation key"
)),
}?;
// Add Sapling proof generation key.
Updater::new(pczt)
.update_sapling_with(|mut updater| {
let non_dummy_spends = updater
.bundle()
.spends()
.iter()
.enumerate()
// Dummy spends will already have a proof generation key.
.filter(|(_, spend)| spend.proof_generation_key().is_none())
.map(|(index, spend)| {
(
index,
spend
.zip32_derivation()
.as_ref()
.map(|d| (*d.seed_fingerprint(), d.derivation_path().clone())),
)
})
.collect::<Vec<_>>();
// Assume all non-dummy spent notes are from the same account.
for (index, derivation) in non_dummy_spends {
updater.update_spend_with(index, |mut spend_updater| {
spend_updater.set_proof_generation_key(
pkg_source.proof_generation_key(derivation).unwrap(),
)
})?;
}
Ok(())
})
.map_err(|e| anyhow!("Failed to add Sapling proof generation key: {:?}", e))?
.finish()
} else {
pczt
};
let prover =
LocalTxProver::with_default_location().ok_or(anyhow!("Missing Sapling parameters"))?;
let pczt = Prover::new(pczt)
.create_orchard_proof(&orchard::circuit::ProvingKey::build())
.map_err(|e| anyhow!("Failed to create Orchard proof: {:?}", e))?
.create_sapling_proofs(&prover, &prover)
.map_err(|e| anyhow!("Failed to create Sapling proofs: {:?}", e))?
.finish();
stdout().write_all(&pczt.serialize()).await?;
Ok(())
}
}

121
src/commands/pczt/qr.rs Normal file
View File

@ -0,0 +1,121 @@
use std::time::Duration;
use anyhow::anyhow;
use gumdrop::Options;
use nokhwa::{
pixel_format::LumaFormat,
utils::{CameraIndex, RequestedFormat, RequestedFormatType},
Camera,
};
use pczt::Pczt;
use qrcode::{render::unicode, QrCode};
use tokio::io::{stdin, stdout, AsyncReadExt, AsyncWriteExt};
use crate::ShutdownListener;
const ZCASH_PCZT: &str = "zcash-pczt";
// Options accepted for the `pczt to-qr` command
#[derive(Debug, Options)]
pub(crate) struct Send {
#[options(
help = "the duration in milliseconds to wait between QR codes (default is 500)",
default = "500"
)]
interval: u64,
}
impl Send {
pub(crate) async fn run(self, mut shutdown: ShutdownListener) -> Result<(), anyhow::Error> {
let mut buf = vec![];
stdin().read_to_end(&mut buf).await?;
let pczt = Pczt::parse(&buf).map_err(|e| anyhow!("Failed to read PCZT: {:?}", e))?;
let mut encoder = ur::Encoder::new(&pczt.serialize(), 100, ZCASH_PCZT)
.map_err(|e| anyhow!("Failed to build UR encoder: {e}"))?;
let mut stdout = stdout();
let mut interval = tokio::time::interval(Duration::from_millis(self.interval));
loop {
interval.tick().await;
if shutdown.requested() {
return Ok(());
}
let ur = encoder
.next_part()
.map_err(|e| anyhow!("Failed to encode PCZT part: {e}"))?;
let code = QrCode::new(&ur.to_uppercase())?;
let string = code
.render::<unicode::Dense1x2>()
.dark_color(unicode::Dense1x2::Dark)
.light_color(unicode::Dense1x2::Light)
.quiet_zone(false)
.build();
stdout.write_all(format!("{string}\n").as_bytes()).await?;
stdout.write_all(format!("{ur}\n\n\n\n").as_bytes()).await?;
stdout.flush().await?;
}
}
}
// Options accepted for the `pczt from-qr` command
#[derive(Debug, Options)]
pub(crate) struct Receive {
#[options(
help = "the duration in milliseconds to wait between scanning for QR codes (default is 500)",
default = "500"
)]
interval: u64,
}
impl Receive {
pub(crate) async fn run(self, mut shutdown: ShutdownListener) -> Result<(), anyhow::Error> {
let mut camera = Camera::new(
CameraIndex::Index(0),
RequestedFormat::new::<LumaFormat>(RequestedFormatType::AbsoluteHighestFrameRate),
)?;
let mut decoder = ur::Decoder::default();
let mut interval = tokio::time::interval(Duration::from_millis(self.interval));
while !decoder.complete() {
interval.tick().await;
if shutdown.requested() {
return Ok(());
}
let frame = camera.frame()?;
let decoded = frame.decode_image::<LumaFormat>()?;
let mut img = rqrr::PreparedImage::prepare(decoded);
let grids = img.detect_grids();
if let Some(grid) = grids.first() {
let (_, content) = grid.decode()?;
if content.starts_with("ur:zcash-pczt") {
eprintln!("{content}");
decoder
.receive(&content)
.map_err(|e| anyhow!("Failed to parse QR code: {:?}", e))?;
} else {
eprintln!("Unexpected UR type: {content}");
}
}
}
let pczt = Pczt::parse(
&decoder
.message()
.map_err(|e| anyhow!("Failed to extract full message from QR codes: {:?}", e))?
.expect("complete"),
)
.map_err(|e| anyhow!("Failed to read PCZT from QR codes: {:?}", e))?;
stdout().write_all(&pczt.serialize()).await?;
Ok(())
}
}

90
src/commands/pczt/send.rs Normal file
View File

@ -0,0 +1,90 @@
use anyhow::anyhow;
use gumdrop::Options;
use pczt::Pczt;
use tokio::io::{stdin, AsyncReadExt};
use zcash_client_backend::{
data_api::{wallet::extract_and_store_transaction_from_pczt, WalletRead},
proto::service,
};
use zcash_client_sqlite::WalletDb;
use zcash_proofs::prover::LocalTxProver;
use crate::{
config::WalletConfig,
data::get_db_paths,
error,
remote::{tor_client, Servers},
};
// Options accepted for the `pczt send` command
#[derive(Debug, Options)]
pub(crate) struct Command {
#[options(
help = "the server to send via (default is \"ecc\")",
default = "ecc",
parse(try_from_str = "Servers::parse")
)]
server: Servers,
#[options(help = "disable connections via TOR")]
disable_tor: bool,
}
impl Command {
pub(crate) async fn run(self, wallet_dir: Option<String>) -> 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 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 mut buf = vec![];
stdin().read_to_end(&mut buf).await?;
let pczt = Pczt::parse(&buf).map_err(|e| anyhow!("Failed to read PCZT: {:?}", e))?;
let prover =
LocalTxProver::with_default_location().ok_or(anyhow!("Missing Sapling parameters"))?;
let (spend_vk, output_vk) = prover.verifying_keys();
let txid = extract_and_store_transaction_from_pczt::<_, ()>(
&mut db_data,
pczt,
&spend_vk,
&output_vk,
&orchard::circuit::VerifyingKey::build(),
)
.map_err(|e| anyhow!("Failed to extract and store transaction from PCZT: {:?}", e))?;
// 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(())
}
}
}

206
src/commands/pczt/sign.rs Normal file
View File

@ -0,0 +1,206 @@
use std::collections::BTreeMap;
use anyhow::anyhow;
use gumdrop::Options;
use pczt::{
roles::{signer::Signer, updater::Updater},
Pczt,
};
use secrecy::ExposeSecret;
use tokio::io::{stdin, stdout, AsyncReadExt, AsyncWriteExt};
use zcash_keys::keys::UnifiedSpendingKey;
use zcash_primitives::legacy::keys::{NonHardenedChildIndex, TransparentKeyScope};
use zcash_protocol::consensus::{NetworkConstants, Parameters};
use zip32::fingerprint::SeedFingerprint;
use crate::config::WalletConfig;
// Options accepted for the `pczt sign` command
#[derive(Debug, Options)]
pub(crate) struct Command {
#[options(help = "age identity file to decrypt the mnemonic phrase with")]
identity: String,
}
impl Command {
pub(crate) async fn run(self, wallet_dir: Option<String>) -> Result<(), anyhow::Error> {
let mut config = WalletConfig::read(wallet_dir.as_ref())?;
let params = config.network();
let mut buf = vec![];
stdin().read_to_end(&mut buf).await?;
let pczt = Pczt::parse(&buf).map_err(|e| anyhow!("Failed to read PCZT: {:?}", e))?;
// 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 seed = config
.seed()
.ok_or(anyhow!("Seed must be present to enable signing"))?
.expose_secret();
let seed_fp =
SeedFingerprint::from_seed(seed).ok_or_else(|| anyhow!("Invalid seed length"))?;
// Find all the spends matching our seed. For now as a hack, we use the Updater
// role to access the bundle data we need.
enum KeyRef {
Orchard {
index: usize,
},
Sapling {
index: usize,
},
Transparent {
index: usize,
scope: TransparentKeyScope,
address_index: NonHardenedChildIndex,
},
}
let mut keys = BTreeMap::<zip32::AccountId, Vec<KeyRef>>::new();
let pczt = Updater::new(pczt)
.update_orchard_with(|updater| {
for (index, action) in updater.bundle().actions().iter().enumerate() {
if let Some(derivation) = action.spend().zip32_derivation() {
if derivation.seed_fingerprint() == &seed_fp.to_bytes()
&& derivation.derivation_path().len() == 3
&& derivation.derivation_path()[0] == zip32::ChildIndex::hardened(32)
&& derivation.derivation_path()[1]
== zip32::ChildIndex::hardened(params.network_type().coin_type())
{
let account_index = zip32::AccountId::try_from(
derivation.derivation_path()[2].index() - (1 << 31),
)
.expect("valid");
keys.entry(account_index)
.or_default()
.push(KeyRef::Orchard { index });
}
}
}
Ok(())
})
.expect("no errors")
.update_sapling_with(|updater| {
for (index, spend) in updater.bundle().spends().iter().enumerate() {
if let Some(derivation) = spend.zip32_derivation() {
if derivation.seed_fingerprint() == &seed_fp.to_bytes()
&& derivation.derivation_path().len() == 3
&& derivation.derivation_path()[0] == zip32::ChildIndex::hardened(32)
&& derivation.derivation_path()[1]
== zip32::ChildIndex::hardened(params.network_type().coin_type())
{
let account_index = zip32::AccountId::try_from(
derivation.derivation_path()[2].index() - (1 << 31),
)
.expect("valid");
keys.entry(account_index)
.or_default()
.push(KeyRef::Sapling { index });
}
}
}
Ok(())
})
.expect("no errors")
.update_transparent_with(|updater| {
for (index, input) in updater.bundle().inputs().iter().enumerate() {
for derivation in input.bip32_derivation().values() {
if derivation.seed_fingerprint() == &seed_fp.to_bytes()
&& derivation.derivation_path().len() == 5
&& derivation.derivation_path()[0]
== bip32::ChildNumber::new(32, true).expect("valid")
&& derivation.derivation_path()[1]
== bip32::ChildNumber::new(params.network_type().coin_type(), true)
.expect("valid")
&& derivation.derivation_path()[2].is_hardened()
&& !derivation.derivation_path()[3].is_hardened()
&& !derivation.derivation_path()[4].is_hardened()
{
let account_index = zip32::AccountId::try_from(
derivation.derivation_path()[2].index() - (1 << 31),
)
.expect("valid");
let scope = TransparentKeyScope::custom(
derivation.derivation_path()[3].index(),
)
.expect("valid");
let address_index = NonHardenedChildIndex::from_index(
derivation.derivation_path()[4].index(),
)
.expect("valid");
keys.entry(account_index)
.or_default()
.push(KeyRef::Transparent {
index,
scope,
address_index,
});
}
}
}
Ok(())
})
.expect("no errors")
.finish();
let mut signer =
Signer::new(pczt).map_err(|e| anyhow!("Failed to initialize Signer: {:?}", e))?;
for (account_index, spends) in keys {
let usk = UnifiedSpendingKey::from_seed(&params, seed, account_index)?;
for keyref in spends {
match keyref {
KeyRef::Orchard { index } => {
signer
.sign_orchard(
index,
&orchard::keys::SpendAuthorizingKey::from(usk.orchard()),
)
.map_err(|e| {
anyhow!("Failed to sign Orchard spend {index}: {:?}", e)
})?;
}
KeyRef::Sapling { index } => {
signer
.sign_sapling(index, &usk.sapling().expsk.ask)
.map_err(|e| {
anyhow!("Failed to sign Sapling spend {index}: {:?}", e)
})?;
}
KeyRef::Transparent {
index,
scope,
address_index,
} => signer
.sign_transparent(
index,
&usk.transparent()
.derive_secret_key(scope, address_index)
.map_err(|e| {
anyhow!(
"Failed to derive transparent key at .../{:?}/{:?}: {:?}",
scope,
address_index,
e,
)
})?,
)
.map_err(|e| {
anyhow!("Failed to sign transparent input {index}: {:?}", e)
})?,
}
}
}
let pczt = signer.finish();
stdout().write_all(&pczt.serialize()).await?;
Ok(())
}
}

View File

@ -78,6 +78,9 @@ enum Command {
#[options(help = "send funds to the given address")]
Send(commands::send::Command),
#[options(help = "send funds using PCZTs")]
Pczt(commands::pczt::Command),
}
fn main() -> Result<(), anyhow::Error> {
@ -151,7 +154,18 @@ fn main() -> Result<(), anyhow::Error> {
Some(Command::ListUnspent(command)) => command.run(opts.wallet_dir),
Some(Command::Propose(command)) => command.run(opts.wallet_dir).await,
Some(Command::Send(command)) => command.run(opts.wallet_dir).await,
_ => Ok(()),
Some(Command::Pczt(command)) => match command {
commands::pczt::Command::Create(command) => command.run(opts.wallet_dir).await,
commands::pczt::Command::Prove(command) => command.run(opts.wallet_dir).await,
commands::pczt::Command::Sign(command) => command.run(opts.wallet_dir).await,
commands::pczt::Command::Combine(command) => command.run().await,
commands::pczt::Command::Send(command) => command.run(opts.wallet_dir).await,
#[cfg(feature = "pczt-qr")]
commands::pczt::Command::ToQr(command) => command.run(shutdown).await,
#[cfg(feature = "pczt-qr")]
commands::pczt::Command::FromQr(command) => command.run(shutdown).await,
},
None => Ok(()),
}
})
}