diff --git a/zingoconfig/src/lib.rs b/zingoconfig/src/lib.rs index 0dd252035..b96b08840 100644 --- a/zingoconfig/src/lib.rs +++ b/zingoconfig/src/lib.rs @@ -239,7 +239,7 @@ impl ZingoConfig { pub fn wallet_path_exists(&self) -> bool { self.get_wallet_path().exists() } - #[deprecated(since = "1.3.2", note = "this function was renamed for clarity")] + #[deprecated(note = "this method was renamed 'wallet_path_exists' for clarity")] pub fn wallet_exists(&self) -> bool { self.wallet_path_exists() } diff --git a/zingolib/CHANGELOG.md b/zingolib/CHANGELOG.md index ceb3e4a21..520164afc 100644 --- a/zingolib/CHANGELOG.md +++ b/zingolib/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Deprecated +- LightClient::do_list_transactions + ### Added - lightclient pub fn get_wallet_file_location @@ -14,6 +17,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -### Deprecated - ### Removed diff --git a/zingolib/Cargo.toml b/zingolib/Cargo.toml index 0e924f35b..8b65def08 100644 --- a/zingolib/Cargo.toml +++ b/zingolib/Cargo.toml @@ -7,6 +7,8 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [features] +deprecations = ["lightclient-deprecated"] +lightclient-deprecated = [] default = ["embed_params"] embed_params = [] diff --git a/zingolib/src/commands.rs b/zingolib/src/commands.rs index 81de3ca6e..f7a511d73 100644 --- a/zingolib/src/commands.rs +++ b/zingolib/src/commands.rs @@ -1031,7 +1031,9 @@ impl Command for SeedCommand { } } +#[cfg(feature = "lightclient-deprecated")] struct TransactionsCommand {} +#[cfg(feature = "lightclient-deprecated")] impl Command for TransactionsCommand { fn help(&self) -> &'static str { indoc! {r#" @@ -1479,7 +1481,8 @@ impl Command for QuitCommand { } pub fn get_commands() -> HashMap<&'static str, Box> { - let entries: [(&'static str, Box); 37] = [ + #[allow(unused_mut)] + let mut entries: Vec<(&'static str, Box)> = vec![ (("version"), Box::new(GetVersionCommand {})), ("sync", Box::new(SyncCommand {})), ("syncstatus", Box::new(SyncStatusCommand {})), @@ -1512,7 +1515,6 @@ pub fn get_commands() -> HashMap<&'static str, Box> { ("shield", Box::new(ShieldCommand {})), ("save", Box::new(SaveCommand {})), ("quit", Box::new(QuitCommand {})), - ("list", Box::new(TransactionsCommand {})), ("notes", Box::new(NotesCommand {})), ("new", Box::new(NewAddressCommand {})), ("defaultfee", Box::new(DefaultFeeCommand {})), @@ -1521,8 +1523,11 @@ pub fn get_commands() -> HashMap<&'static str, Box> { ("wallet_kind", Box::new(WalletKindCommand {})), ("delete", Box::new(DeleteCommand {})), ]; - - HashMap::from(entries) + #[cfg(feature = "lightclient-deprecated")] + { + entries.push(("list", Box::new(TransactionsCommand {}))); + } + entries.into_iter().collect() } pub fn do_user_command(cmd: &str, args: &[&str], lightclient: &LightClient) -> String { diff --git a/zingolib/src/lightclient.rs b/zingolib/src/lightclient.rs index 085f907c5..5c5487a79 100644 --- a/zingolib/src/lightclient.rs +++ b/zingolib/src/lightclient.rs @@ -13,23 +13,19 @@ use crate::{ finsight, summaries::ValueTransfer, summaries::ValueTransferKind, OutgoingTxData, TransactionMetadata, }, - keys::{ - address_from_pubkeyhash, - unified::{ReceiverSelection, WalletCapability}, - }, + keys::{address_from_pubkeyhash, unified::ReceiverSelection}, message::Message, now, - traits::{DomainWalletExt, ReceivedNoteAndMetadata, Recipient}, + traits::ReceivedNoteAndMetadata, LightWallet, Pool, SendProgress, WalletBase, }, }; use futures::future::join_all; use json::{array, object, JsonValue}; use log::{debug, error, warn}; -use orchard::note_encryption::OrchardDomain; use serde::Serialize; use std::{ - cmp::{self, Ordering}, + cmp::{self}, collections::HashMap, fs::{remove_file, File}, io::{self, BufReader, Error, ErrorKind, Read, Write}, @@ -45,20 +41,15 @@ use tokio::{ time::sleep, }; use zcash_address::ZcashAddress; -use zcash_note_encryption::Domain; -use zcash_client_backend::{ - address::RecipientAddress, - encoding::{decode_payment_address, encode_payment_address}, -}; +use zcash_client_backend::encoding::{decode_payment_address, encode_payment_address}; use zcash_primitives::{ consensus::{BlockHeight, BranchId, Parameters}, memo::{Memo, MemoBytes}, - sapling::note_encryption::SaplingDomain, transaction::{fees::zip317::MINIMUM_FEE, Transaction, TxId}, }; use zcash_proofs::prover::LocalTxProver; -use zingoconfig::{ChainType, ZingoConfig, MAX_REORG}; +use zingoconfig::{ZingoConfig, MAX_REORG}; static LOG_INIT: std::sync::Once = std::sync::Once::new(); @@ -152,6 +143,14 @@ pub struct AccountBackupInfo { pub account_index: u32, } +/// The LightClient provides a unified interface to the separate concerns that the zingolib library manages. +/// 1. initialization of stored state +/// * from seed +/// * from keys +/// * from wallets +/// * from a fresh start with reasonable defaults +/// 2. synchronization of the client with the state of the blockchain via a gRPC server +/// * pub struct LightClient { pub(crate) config: ZingoConfig, pub wallet: LightWallet, @@ -163,6 +162,9 @@ pub struct LightClient { bsync_data: Arc>, interrupt_sync: Arc>, } + +/// This is the omnibus interface to the library, we are currently in the process of refining this types +/// overly broad and vague definition! impl LightClient { pub fn create_from_extant_wallet(wallet: LightWallet, config: ZingoConfig) -> Self { LightClient { @@ -343,109 +345,6 @@ impl LightClient { } } - fn add_nonchange_notes<'a, 'b, 'c>( - &'a self, - transaction_metadata: &'b TransactionMetadata, - unified_spend_auth: &'c WalletCapability, - ) -> impl Iterator + 'b - where - 'a: 'b, - 'c: 'b, - { - self.add_wallet_notes_in_transaction_to_list_inner::<'a, 'b, 'c, SaplingDomain>( - transaction_metadata, - unified_spend_auth, - ) - .chain( - self.add_wallet_notes_in_transaction_to_list_inner::<'a, 'b, 'c, OrchardDomain>( - transaction_metadata, - unified_spend_auth, - ), - ) - } - - fn add_wallet_notes_in_transaction_to_list_inner<'a, 'b, 'c, D>( - &'a self, - transaction_metadata: &'b TransactionMetadata, - unified_spend_auth: &'c WalletCapability, - ) -> impl Iterator + 'b - where - 'a: 'b, - 'c: 'b, - D: DomainWalletExt, - D::WalletNote: 'b, - ::Recipient: Recipient, - ::Note: PartialEq + Clone, - { - D::WalletNote::transaction_metadata_notes(transaction_metadata).iter().filter(|nd| !nd.is_change()).enumerate().map(|(i, nd)| { - let block_height: u32 = transaction_metadata.block_height.into(); - object! { - "block_height" => block_height, - "unconfirmed" => transaction_metadata.unconfirmed, - "datetime" => transaction_metadata.datetime, - "position" => i, - "txid" => format!("{}", transaction_metadata.txid), - "amount" => nd.value() as i64, - "zec_price" => transaction_metadata.price.map(|p| (p * 100.0).round() / 100.0), - "address" => LightWallet::note_address::(&self.config.chain, nd, unified_spend_auth), - "memo" => LightWallet::memo_str(nd.memo().clone()) - } - - }) - } - - /// This fn is _only_ called insde a block conditioned on "is_outgoing_transaction" - fn append_change_notes( - wallet_transaction: &TransactionMetadata, - received_utxo_value: u64, - ) -> JsonValue { - // TODO: Understand why sapling and orchard have an "is_change" filter, but transparent does not - // It seems like this already depends on an invariant where all outgoing utxos are change. - // This should never be true _AFTER SOME VERSION_ since we only send change to orchard. - // If I sent a transaction to a foreign transparent address wouldn't this "total_change" value - // be wrong? - let total_change = wallet_transaction - .sapling_notes - .iter() - .filter(|nd| nd.is_change) - .map(|nd| nd.note.value().inner()) - .sum::() - + wallet_transaction - .orchard_notes - .iter() - .filter(|nd| nd.is_change) - .map(|nd| nd.note.value().inner()) - .sum::() - + received_utxo_value; - - // Collect outgoing metadata - let outgoing_json = wallet_transaction - .outgoing_tx_data - .iter() - .map(|om| { - object! { - // Is this address ever different than the address in the containing struct - // this is the full UA. - //aha!! - "address" => om.recipient_ua.clone().unwrap_or(om.to_address.clone()), - "value" => om.value, - "memo" => LightWallet::memo_str(Some(om.memo.clone())) - } - }) - .collect::>(); - - let block_height: u32 = wallet_transaction.block_height.into(); - object! { - "block_height" => block_height, - "unconfirmed" => wallet_transaction.unconfirmed, - "datetime" => wallet_transaction.datetime, - "txid" => format!("{}", wallet_transaction.txid), - "zec_price" => wallet_transaction.price.map(|p| (p * 100.0).round() / 100.0), - "amount" => total_change as i64 - wallet_transaction.total_value_spent() as i64, - "outgoing_metadata" => outgoing_json, - } - } - pub async fn clear_state(&self) { // First, clear the state from the wallet self.wallet.clear_all().await; @@ -551,268 +450,6 @@ impl LightClient { } } - /// Return a list of all notes, spent and unspent - pub async fn do_list_notes(&self, all_notes: bool) -> JsonValue { - let mut unspent_sapling_notes: Vec = vec![]; - let mut spent_sapling_notes: Vec = vec![]; - let mut pending_sapling_notes: Vec = vec![]; - - let anchor_height = BlockHeight::from_u32(self.wallet.get_anchor_height().await); - - { - // Collect Sapling notes - self.wallet.transaction_context.transaction_metadata_set.read().await.current.iter() - .flat_map( |(transaction_id, transaction_metadata)| { - transaction_metadata.sapling_notes.iter().filter_map(move |note_metadata| - if !all_notes && note_metadata.spent.is_some() { - None - } else { - let address = LightWallet::note_address::>(&self.config.chain, note_metadata, &self.wallet.wallet_capability()); - let spendable = transaction_metadata.block_height <= anchor_height && note_metadata.spent.is_none() && note_metadata.unconfirmed_spent.is_none(); - - let created_block:u32 = transaction_metadata.block_height.into(); - Some(object!{ - "created_in_block" => created_block, - "datetime" => transaction_metadata.datetime, - "created_in_txid" => format!("{}", transaction_id.clone()), - "value" => note_metadata.note.value().inner(), - "unconfirmed" => transaction_metadata.unconfirmed, - "is_change" => note_metadata.is_change, - "address" => address, - "spendable" => spendable, - "spent" => note_metadata.spent.map(|(spent_transaction_id, _)| format!("{}", spent_transaction_id)), - "spent_at_height" => note_metadata.spent.map(|(_, h)| h), - "unconfirmed_spent" => note_metadata.unconfirmed_spent.map(|(spent_transaction_id, _)| format!("{}", spent_transaction_id)), - }) - } - ) - }) - .for_each( |note| { - if note["spent"].is_null() && note["unconfirmed_spent"].is_null() { - unspent_sapling_notes.push(note); - } else if !note["spent"].is_null() { - spent_sapling_notes.push(note); - } else { - pending_sapling_notes.push(note); - } - }); - } - - let mut unspent_orchard_notes: Vec = vec![]; - let mut spent_orchard_notes: Vec = vec![]; - let mut pending_orchard_notes: Vec = vec![]; - - { - self.wallet.transaction_context.transaction_metadata_set.read().await.current.iter() - .flat_map( |(transaction_id, transaction_metadata)| { - transaction_metadata.orchard_notes.iter().filter_map(move |orch_note_metadata| - if !all_notes && orch_note_metadata.is_spent() { - None - } else { - let address = LightWallet::note_address::(&self.config.chain, orch_note_metadata, &self.wallet.wallet_capability()); - let spendable = transaction_metadata.block_height <= anchor_height && orch_note_metadata.spent.is_none() && orch_note_metadata.unconfirmed_spent.is_none(); - - let created_block:u32 = transaction_metadata.block_height.into(); - Some(object!{ - "created_in_block" => created_block, - "datetime" => transaction_metadata.datetime, - "created_in_txid" => format!("{}", transaction_id), - "value" => orch_note_metadata.note.value().inner(), - "unconfirmed" => transaction_metadata.unconfirmed, - "is_change" => orch_note_metadata.is_change, - "address" => address, - "spendable" => spendable, - "spent" => orch_note_metadata.spent.map(|(spent_transaction_id, _)| format!("{}", spent_transaction_id)), - "spent_at_height" => orch_note_metadata.spent.map(|(_, h)| h), - "unconfirmed_spent" => orch_note_metadata.unconfirmed_spent.map(|(spent_transaction_id, _)| format!("{}", spent_transaction_id)), - }) - } - ) - }) - .for_each( |note| { - if note["spent"].is_null() && note["unconfirmed_spent"].is_null() { - unspent_orchard_notes.push(note); - } else if !note["spent"].is_null() { - spent_orchard_notes.push(note); - } else { - pending_orchard_notes.push(note); - } - }); - } - - let mut unspent_utxos: Vec = vec![]; - let mut spent_utxos: Vec = vec![]; - let mut pending_utxos: Vec = vec![]; - - { - self.wallet.transaction_context.transaction_metadata_set.read().await.current.iter() - .flat_map( |(transaction_id, wtx)| { - wtx.received_utxos.iter().filter_map(move |utxo| - if !all_notes && utxo.spent.is_some() { - None - } else { - let created_block:u32 = wtx.block_height.into(); - let recipient = RecipientAddress::decode(&self.config.chain, &utxo.address); - let taddr = match recipient { - Some(RecipientAddress::Transparent(taddr)) => taddr, - _otherwise => panic!("Read invalid taddr from wallet-local Utxo, this should be impossible"), - }; - - Some(object!{ - "created_in_block" => created_block, - "datetime" => wtx.datetime, - "created_in_txid" => format!("{}", transaction_id), - "value" => utxo.value, - "scriptkey" => hex::encode(utxo.script.clone()), - "is_change" => false, // TODO: Identify notes as change if we send change to our own taddrs - "address" => self.wallet.wallet_capability().get_ua_from_contained_transparent_receiver(&taddr).map(|ua| ua.encode(&self.config.chain)), - "spent_at_height" => utxo.spent_at_height, - "spent" => utxo.spent.map(|spent_transaction_id| format!("{}", spent_transaction_id)), - "unconfirmed_spent" => utxo.unconfirmed_spent.map(|(spent_transaction_id, _)| format!("{}", spent_transaction_id)), - }) - } - ) - }) - .for_each( |utxo| { - if utxo["spent"].is_null() && utxo["unconfirmed_spent"].is_null() { - unspent_utxos.push(utxo); - } else if !utxo["spent"].is_null() { - spent_utxos.push(utxo); - } else { - pending_utxos.push(utxo); - } - }); - } - - unspent_sapling_notes.sort_by_key(|note| note["created_in_block"].as_u64()); - spent_sapling_notes.sort_by_key(|note| note["created_in_block"].as_u64()); - pending_sapling_notes.sort_by_key(|note| note["created_in_block"].as_u64()); - unspent_orchard_notes.sort_by_key(|note| note["created_in_block"].as_u64()); - spent_orchard_notes.sort_by_key(|note| note["created_in_block"].as_u64()); - pending_orchard_notes.sort_by_key(|note| note["created_in_block"].as_u64()); - unspent_utxos.sort_by_key(|note| note["created_in_block"].as_u64()); - pending_utxos.sort_by_key(|note| note["created_in_block"].as_u64()); - spent_utxos.sort_by_key(|note| note["created_in_block"].as_u64()); - - let mut res = object! { - "unspent_sapling_notes" => unspent_sapling_notes, - "pending_sapling_notes" => pending_sapling_notes, - "unspent_orchard_notes" => unspent_orchard_notes, - "pending_orchard_notes" => pending_orchard_notes, - "utxos" => unspent_utxos, - "pending_utxos" => pending_utxos, - }; - - if all_notes { - res["spent_sapling_notes"] = JsonValue::Array(spent_sapling_notes); - res["spent_orchard_notes"] = JsonValue::Array(spent_orchard_notes); - res["spent_utxos"] = JsonValue::Array(spent_utxos); - } - - res - } - - pub async fn do_list_transactions(&self) -> JsonValue { - // Create a list of TransactionItems from wallet transactions - let mut consumer_ui_notes = self - .wallet - .transaction_context.transaction_metadata_set - .read() - .await - .current - .iter() - .flat_map(|(txid, wallet_transaction)| { - let mut consumer_notes_by_tx: Vec = vec![]; - - let total_transparent_received = wallet_transaction.received_utxos.iter().map(|u| u.value).sum::(); - if wallet_transaction.is_outgoing_transaction() { - // If money was spent, create a consumer_ui_note. For this, we'll subtract - // all the change notes + Utxos - consumer_notes_by_tx.push(Self::append_change_notes(wallet_transaction, total_transparent_received)); - } - - // For each note that is not a change, add a consumer_ui_note. - consumer_notes_by_tx.extend(self.add_nonchange_notes(wallet_transaction, &self.wallet.wallet_capability())); - - // TODO: determine if all notes are either Change-or-NotChange, if that's the case - // add a sanity check that asserts all notes are processed by this point - - // Get the total transparent value received in this transaction - // Again we see the assumption that utxos are incoming. - let net_transparent_value = total_transparent_received as i64 - wallet_transaction.total_transparent_value_spent as i64; - let address = wallet_transaction.received_utxos.iter().map(|utxo| utxo.address.clone()).collect::>().join(","); - if net_transparent_value > 0 { - if let Some(transaction) = consumer_notes_by_tx.iter_mut().find(|transaction| transaction["txid"] == txid.to_string()) { - // If this transaction is outgoing: - // Then we've already accounted for the entire balance. - - if !wallet_transaction.is_outgoing_transaction() { - // If not, we've added sapling/orchard, and need to add transparent - let old_amount = transaction.remove("amount").as_i64().unwrap(); - transaction.insert("amount", old_amount + net_transparent_value).unwrap(); - } - } else { - // Create an input transaction for the transparent value as well. - let block_height: u32 = wallet_transaction.block_height.into(); - consumer_notes_by_tx.push(object! { - "block_height" => block_height, - "unconfirmed" => wallet_transaction.unconfirmed, - "datetime" => wallet_transaction.datetime, - "txid" => format!("{}", txid), - "amount" => net_transparent_value, - "zec_price" => wallet_transaction.price.map(|p| (p * 100.0).round() / 100.0), - "address" => address, - "memo" => None:: - }) - } - } - - consumer_notes_by_tx - }) - .collect::>(); - - let match_by_txid = - |a: &JsonValue, b: &JsonValue| a["txid"].to_string().cmp(&b["txid"].to_string()); - consumer_ui_notes.sort_by(match_by_txid); - consumer_ui_notes.dedup_by(|a, b| { - if match_by_txid(a, b) == Ordering::Equal { - let val_b = b.remove("amount").as_i64().unwrap(); - b.insert( - "amount", - JsonValue::from(val_b + a.remove("amount").as_i64().unwrap()), - ) - .unwrap(); - let memo_b = b.remove("memo").to_string(); - b.insert("memo", [a.remove("memo").to_string(), memo_b].join(", ")) - .unwrap(); - for (key, a_val) in a.entries_mut() { - let b_val = b.remove(key); - if b_val == JsonValue::Null { - b.insert(key, a_val.clone()).unwrap(); - } else { - if a_val != &b_val { - log::error!("{key}: {a_val} does not match {key}: {b_val}"); - } - b.insert(key, b_val).unwrap() - } - } - - true - } else { - false - } - }); - consumer_ui_notes.sort_by(|a, b| { - if a["block_height"] == b["block_height"] { - a["txid"].as_str().cmp(&b["txid"].as_str()) - } else { - a["block_height"].as_i32().cmp(&b["block_height"].as_i32()) - } - }); - - JsonValue::Array(consumer_ui_notes) - } - pub async fn do_list_txsummaries(&self) -> Vec { let mut summaries: Vec = Vec::new(); @@ -2081,6 +1718,7 @@ impl LightClient { } } } + use serde_json::Value; enum PriceFetchError { @@ -2178,64 +1816,205 @@ async fn get_recent_median_price_from_gemini() -> Result { Ok(trades[5]) } -#[cfg(test)] -mod tests { - use tokio::runtime::Runtime; - use zingo_testutils::data::seeds::CHIMNEY_BETTER_SEED; - use zingoconfig::{ChainType, ZingoConfig}; - - use crate::{lightclient::LightClient, wallet::WalletBase}; - - #[test] - fn new_wallet_from_phrase() { - let temp_dir = tempfile::Builder::new().prefix("test").tempdir().unwrap(); - let data_dir = temp_dir - .into_path() - .canonicalize() - .expect("This path is available."); - - let wallet_name = data_dir.join("zingo-wallet.dat"); - let config = ZingoConfig::create_unconnected(ChainType::FakeMainnet, Some(data_dir)); - let lc = LightClient::create_from_wallet_base( - WalletBase::MnemonicPhrase(CHIMNEY_BETTER_SEED.to_string()), - &config, - 0, - false, +impl LightClient { + async fn list_sapling_notes( + &self, + all_notes: bool, + anchor_height: BlockHeight, + ) -> (Vec, Vec, Vec) { + let mut unspent_sapling_notes: Vec = vec![]; + let mut spent_sapling_notes: Vec = vec![]; + let mut pending_sapling_notes: Vec = vec![]; + // Collect Sapling notes + self.wallet.transaction_context.transaction_metadata_set.read().await.current.iter() + .flat_map( |(transaction_id, transaction_metadata)| { + transaction_metadata.sapling_notes.iter().filter_map(move |note_metadata| + if !all_notes && note_metadata.spent.is_some() { + None + } else { + let address = LightWallet::note_address::>(&self.config.chain, note_metadata, &self.wallet.wallet_capability()); + let spendable = transaction_metadata.block_height <= anchor_height && note_metadata.spent.is_none() && note_metadata.unconfirmed_spent.is_none(); + + let created_block:u32 = transaction_metadata.block_height.into(); + Some(object!{ + "created_in_block" => created_block, + "datetime" => transaction_metadata.datetime, + "created_in_txid" => format!("{}", transaction_id.clone()), + "value" => note_metadata.note.value().inner(), + "unconfirmed" => transaction_metadata.unconfirmed, + "is_change" => note_metadata.is_change, + "address" => address, + "spendable" => spendable, + "spent" => note_metadata.spent.map(|(spent_transaction_id, _)| format!("{}", spent_transaction_id)), + "spent_at_height" => note_metadata.spent.map(|(_, h)| h), + "unconfirmed_spent" => note_metadata.unconfirmed_spent.map(|(spent_transaction_id, _)| format!("{}", spent_transaction_id)), + }) + } + ) + }) + .for_each( |note| { + if note["spent"].is_null() && note["unconfirmed_spent"].is_null() { + unspent_sapling_notes.push(note); + } else if !note["spent"].is_null() { + spent_sapling_notes.push(note); + } else { + pending_sapling_notes.push(note); + } + }); + ( + unspent_sapling_notes, + spent_sapling_notes, + pending_sapling_notes, ) - .unwrap(); - assert_eq!( - format!( - "{:?}", - LightClient::create_from_wallet_base( - WalletBase::MnemonicPhrase(CHIMNEY_BETTER_SEED.to_string()), - &config, - 0, - false - ) - .err() - .unwrap() - ), - format!( - "{:?}", - std::io::Error::new( - std::io::ErrorKind::AlreadyExists, - format!("Cannot create a new wallet from seed, because a wallet already exists at:\n{:?}", wallet_name), - ) + } + async fn list_orchard_notes( + &self, + all_notes: bool, + anchor_height: BlockHeight, + ) -> (Vec, Vec, Vec) { + let mut unspent_orchard_notes: Vec = vec![]; + let mut spent_orchard_notes: Vec = vec![]; + let mut pending_orchard_notes: Vec = vec![]; + self.wallet.transaction_context.transaction_metadata_set.read().await.current.iter() + .flat_map( |(transaction_id, transaction_metadata)| { + transaction_metadata.orchard_notes.iter().filter_map(move |orch_note_metadata| + if !all_notes && orch_note_metadata.is_spent() { + None + } else { + let address = LightWallet::note_address::(&self.config.chain, orch_note_metadata, &self.wallet.wallet_capability()); + let spendable = transaction_metadata.block_height <= anchor_height && orch_note_metadata.spent.is_none() && orch_note_metadata.unconfirmed_spent.is_none(); + + let created_block:u32 = transaction_metadata.block_height.into(); + Some(object!{ + "created_in_block" => created_block, + "datetime" => transaction_metadata.datetime, + "created_in_txid" => format!("{}", transaction_id), + "value" => orch_note_metadata.note.value().inner(), + "unconfirmed" => transaction_metadata.unconfirmed, + "is_change" => orch_note_metadata.is_change, + "address" => address, + "spendable" => spendable, + "spent" => orch_note_metadata.spent.map(|(spent_transaction_id, _)| format!("{}", spent_transaction_id)), + "spent_at_height" => orch_note_metadata.spent.map(|(_, h)| h), + "unconfirmed_spent" => orch_note_metadata.unconfirmed_spent.map(|(spent_transaction_id, _)| format!("{}", spent_transaction_id)), + }) + } + ) + }) + .for_each( |note| { + if note["spent"].is_null() && note["unconfirmed_spent"].is_null() { + unspent_orchard_notes.push(note); + } else if !note["spent"].is_null() { + spent_orchard_notes.push(note); + } else { + pending_orchard_notes.push(note); + } + }); + ( + unspent_orchard_notes, + spent_orchard_notes, + pending_orchard_notes, ) - ); + } + async fn list_transparent_notes( + &self, + all_notes: bool, + ) -> (Vec, Vec, Vec) { + let mut unspent_transparent_notes: Vec = vec![]; + let mut spent_transparent_notes: Vec = vec![]; + let mut pending_transparent_notes: Vec = vec![]; - // The first t address and z address should be derived - Runtime::new().unwrap().block_on(async move { - let addresses = lc.do_addresses().await; - assert_eq!( - "zs1q6xk3q783t5k92kjqt2rkuuww8pdw2euzy5rk6jytw97enx8fhpazdv3th4xe7vsk6e9sfpawfg" - .to_string(), - addresses[0]["receivers"]["sapling"] - ); - assert_eq!( - "t1eQ63fwkQ4n4Eo5uCrPGaAV8FWB2tmx7ui", - addresses[0]["receivers"]["transparent"] - ); - }); + self.wallet.transaction_context.transaction_metadata_set.read().await.current.iter() + .flat_map( |(transaction_id, wtx)| { + wtx.received_utxos.iter().filter_map(move |utxo| + if !all_notes && utxo.spent.is_some() { + None + } else { + let created_block:u32 = wtx.block_height.into(); + let recipient = zcash_client_backend::address::RecipientAddress::decode(&self.config.chain, &utxo.address); + let taddr = match recipient { + Some(zcash_client_backend::address::RecipientAddress::Transparent(taddr)) => taddr, + _otherwise => panic!("Read invalid taddr from wallet-local Utxo, this should be impossible"), + }; + + Some(object!{ + "created_in_block" => created_block, + "datetime" => wtx.datetime, + "created_in_txid" => format!("{}", transaction_id), + "value" => utxo.value, + "scriptkey" => hex::encode(utxo.script.clone()), + "is_change" => false, // TODO: Identify notes as change if we send change to our own taddrs + "address" => self.wallet.wallet_capability().get_ua_from_contained_transparent_receiver(&taddr).map(|ua| ua.encode(&self.config.chain)), + "spent_at_height" => utxo.spent_at_height, + "spent" => utxo.spent.map(|spent_transaction_id| format!("{}", spent_transaction_id)), + "unconfirmed_spent" => utxo.unconfirmed_spent.map(|(spent_transaction_id, _)| format!("{}", spent_transaction_id)), + }) + } + ) + }) + .for_each( |utxo| { + if utxo["spent"].is_null() && utxo["unconfirmed_spent"].is_null() { + unspent_transparent_notes.push(utxo); + } else if !utxo["spent"].is_null() { + spent_transparent_notes.push(utxo); + } else { + pending_transparent_notes.push(utxo); + } + }); + + ( + unspent_transparent_notes, + spent_transparent_notes, + pending_transparent_notes, + ) + } + + /// Return a list of notes, if `all_notes` is false, then only return unspent notes + /// * TODO: This fn does not handle failure it must be promoted to return a Result + /// * TODO: The Err variant of the result must be a proper type + /// * TODO: remove all_notes bool + /// * TODO: This fn must (on success) return an Ok(Vec\) where Notes is a 3 variant enum.... + /// * TODO: type-associated to the variants of the enum must impl From\ for JsonValue + pub async fn do_list_notes(&self, all_notes: bool) -> JsonValue { + let anchor_height = BlockHeight::from_u32(self.wallet.get_anchor_height().await); + + let (mut unspent_sapling_notes, mut spent_sapling_notes, mut pending_sapling_notes) = + self.list_sapling_notes(all_notes, anchor_height).await; + let (mut unspent_orchard_notes, mut spent_orchard_notes, mut pending_orchard_notes) = + self.list_orchard_notes(all_notes, anchor_height).await; + let ( + mut unspent_transparent_notes, + mut spent_transparent_notes, + mut pending_transparent_notes, + ) = self.list_transparent_notes(all_notes).await; + + unspent_sapling_notes.sort_by_key(|note| note["created_in_block"].as_u64()); + spent_sapling_notes.sort_by_key(|note| note["created_in_block"].as_u64()); + pending_sapling_notes.sort_by_key(|note| note["created_in_block"].as_u64()); + unspent_orchard_notes.sort_by_key(|note| note["created_in_block"].as_u64()); + spent_orchard_notes.sort_by_key(|note| note["created_in_block"].as_u64()); + pending_orchard_notes.sort_by_key(|note| note["created_in_block"].as_u64()); + unspent_transparent_notes.sort_by_key(|note| note["created_in_block"].as_u64()); + pending_transparent_notes.sort_by_key(|note| note["created_in_block"].as_u64()); + spent_transparent_notes.sort_by_key(|note| note["created_in_block"].as_u64()); + + let mut res = object! { + "unspent_sapling_notes" => unspent_sapling_notes, + "pending_sapling_notes" => pending_sapling_notes, + "unspent_orchard_notes" => unspent_orchard_notes, + "pending_orchard_notes" => pending_orchard_notes, + "utxos" => unspent_transparent_notes, + "pending_utxos" => pending_transparent_notes, + }; + + if all_notes { + res["spent_sapling_notes"] = JsonValue::Array(spent_sapling_notes); + res["spent_orchard_notes"] = JsonValue::Array(spent_orchard_notes); + res["spent_utxos"] = JsonValue::Array(spent_transparent_notes); + } + + res } } +#[cfg(feature = "lightclient-deprecated")] +mod deprecated; diff --git a/zingolib/src/lightclient/deprecated.rs b/zingolib/src/lightclient/deprecated.rs new file mode 100644 index 000000000..7d5b3ba56 --- /dev/null +++ b/zingolib/src/lightclient/deprecated.rs @@ -0,0 +1,267 @@ +use super::*; +use zcash_note_encryption::Domain; + +impl LightClient { + fn add_nonchange_notes<'a, 'b, 'c>( + &'a self, + transaction_metadata: &'b TransactionMetadata, + unified_spend_auth: &'c crate::wallet::keys::unified::WalletCapability, + ) -> impl Iterator + 'b + where + 'a: 'b, + 'c: 'b, + { + self.add_wallet_notes_in_transaction_to_list_inner::<'a, 'b, 'c, zcash_primitives::sapling::note_encryption::SaplingDomain>( + transaction_metadata, + unified_spend_auth, + ) + .chain( + self.add_wallet_notes_in_transaction_to_list_inner::<'a, 'b, 'c, orchard::note_encryption::OrchardDomain>( + transaction_metadata, + unified_spend_auth, + ), + ) + } + + fn add_wallet_notes_in_transaction_to_list_inner<'a, 'b, 'c, D>( + &'a self, + transaction_metadata: &'b TransactionMetadata, + unified_spend_auth: &'c crate::wallet::keys::unified::WalletCapability, + ) -> impl Iterator + 'b + where + 'a: 'b, + 'c: 'b, + D: crate::wallet::traits::DomainWalletExt, + D::WalletNote: 'b, + ::Recipient: crate::wallet::traits::Recipient, + ::Note: PartialEq + Clone, + { + D::WalletNote::transaction_metadata_notes(transaction_metadata).iter().filter(|nd| !nd.is_change()).enumerate().map(|(i, nd)| { + let block_height: u32 = transaction_metadata.block_height.into(); + object! { + "block_height" => block_height, + "unconfirmed" => transaction_metadata.unconfirmed, + "datetime" => transaction_metadata.datetime, + "position" => i, + "txid" => format!("{}", transaction_metadata.txid), + "amount" => nd.value() as i64, + "zec_price" => transaction_metadata.price.map(|p| (p * 100.0).round() / 100.0), + "address" => LightWallet::note_address::(&self.config.chain, nd, unified_spend_auth), + "memo" => LightWallet::memo_str(nd.memo().clone()) + } + + }) + } + + /// This fn is _only_ called insde a block conditioned on "is_outgoing_transaction" + fn append_change_notes( + wallet_transaction: &TransactionMetadata, + received_utxo_value: u64, + ) -> JsonValue { + // TODO: Understand why sapling and orchard have an "is_change" filter, but transparent does not + // It seems like this already depends on an invariant where all outgoing utxos are change. + // This should never be true _AFTER SOME VERSION_ since we only send change to orchard. + // If I sent a transaction to a foreign transparent address wouldn't this "total_change" value + // be wrong? + let total_change = wallet_transaction + .sapling_notes + .iter() + .filter(|nd| nd.is_change) + .map(|nd| nd.note.value().inner()) + .sum::() + + wallet_transaction + .orchard_notes + .iter() + .filter(|nd| nd.is_change) + .map(|nd| nd.note.value().inner()) + .sum::() + + received_utxo_value; + + // Collect outgoing metadata + let outgoing_json = wallet_transaction + .outgoing_tx_data + .iter() + .map(|om| { + object! { + // Is this address ever different than the address in the containing struct + // this is the full UA. + "address" => om.recipient_ua.clone().unwrap_or(om.to_address.clone()), + "value" => om.value, + "memo" => LightWallet::memo_str(Some(om.memo.clone())) + } + }) + .collect::>(); + + let block_height: u32 = wallet_transaction.block_height.into(); + object! { + "block_height" => block_height, + "unconfirmed" => wallet_transaction.unconfirmed, + "datetime" => wallet_transaction.datetime, + "txid" => format!("{}", wallet_transaction.txid), + "zec_price" => wallet_transaction.price.map(|p| (p * 100.0).round() / 100.0), + "amount" => total_change as i64 - wallet_transaction.total_value_spent() as i64, + "outgoing_metadata" => outgoing_json, + } + } + pub async fn do_list_transactions(&self) -> JsonValue { + // Create a list of TransactionItems from wallet transactions + let mut consumer_ui_notes = self + .wallet + .transaction_context.transaction_metadata_set + .read() + .await + .current + .iter() + .flat_map(|(txid, wallet_transaction)| { + let mut consumer_notes_by_tx: Vec = vec![]; + + let total_transparent_received = wallet_transaction.received_utxos.iter().map(|u| u.value).sum::(); + if wallet_transaction.is_outgoing_transaction() { + // If money was spent, create a consumer_ui_note. For this, we'll subtract + // all the change notes + Utxos + consumer_notes_by_tx.push(Self::append_change_notes(wallet_transaction, total_transparent_received)); + } + + // For each note that is not a change, add a consumer_ui_note. + consumer_notes_by_tx.extend(self.add_nonchange_notes(wallet_transaction, &self.wallet.wallet_capability())); + + // TODO: determine if all notes are either Change-or-NotChange, if that's the case + // add a sanity check that asserts all notes are processed by this point + + // Get the total transparent value received in this transaction + // Again we see the assumption that utxos are incoming. + let net_transparent_value = total_transparent_received as i64 - wallet_transaction.total_transparent_value_spent as i64; + let address = wallet_transaction.received_utxos.iter().map(|utxo| utxo.address.clone()).collect::>().join(","); + if net_transparent_value > 0 { + if let Some(transaction) = consumer_notes_by_tx.iter_mut().find(|transaction| transaction["txid"] == txid.to_string()) { + // If this transaction is outgoing: + // Then we've already accounted for the entire balance. + + if !wallet_transaction.is_outgoing_transaction() { + // If not, we've added sapling/orchard, and need to add transparent + let old_amount = transaction.remove("amount").as_i64().unwrap(); + transaction.insert("amount", old_amount + net_transparent_value).unwrap(); + } + } else { + // Create an input transaction for the transparent value as well. + let block_height: u32 = wallet_transaction.block_height.into(); + consumer_notes_by_tx.push(object! { + "block_height" => block_height, + "unconfirmed" => wallet_transaction.unconfirmed, + "datetime" => wallet_transaction.datetime, + "txid" => format!("{}", txid), + "amount" => net_transparent_value, + "zec_price" => wallet_transaction.price.map(|p| (p * 100.0).round() / 100.0), + "address" => address, + "memo" => None:: + }) + } + } + + consumer_notes_by_tx + }) + .collect::>(); + + let match_by_txid = + |a: &JsonValue, b: &JsonValue| a["txid"].to_string().cmp(&b["txid"].to_string()); + consumer_ui_notes.sort_by(match_by_txid); + consumer_ui_notes.dedup_by(|a, b| { + if match_by_txid(a, b) == cmp::Ordering::Equal { + let val_b = b.remove("amount").as_i64().unwrap(); + b.insert( + "amount", + JsonValue::from(val_b + a.remove("amount").as_i64().unwrap()), + ) + .unwrap(); + let memo_b = b.remove("memo").to_string(); + b.insert("memo", [a.remove("memo").to_string(), memo_b].join(", ")) + .unwrap(); + for (key, a_val) in a.entries_mut() { + let b_val = b.remove(key); + if b_val == JsonValue::Null { + b.insert(key, a_val.clone()).unwrap(); + } else { + if a_val != &b_val { + log::error!("{key}: {a_val} does not match {key}: {b_val}"); + } + b.insert(key, b_val).unwrap() + } + } + + true + } else { + false + } + }); + consumer_ui_notes.sort_by(|a, b| { + if a["block_height"] == b["block_height"] { + a["txid"].as_str().cmp(&b["txid"].as_str()) + } else { + a["block_height"].as_i32().cmp(&b["block_height"].as_i32()) + } + }); + + JsonValue::Array(consumer_ui_notes) + } +} +#[cfg(test)] +mod tests { + use tokio::runtime::Runtime; + use zingo_testutils::data::seeds::CHIMNEY_BETTER_SEED; + use zingoconfig::{ChainType, ZingoConfig}; + + use crate::{lightclient::LightClient, wallet::WalletBase}; + + #[test] + fn new_wallet_from_phrase() { + let temp_dir = tempfile::Builder::new().prefix("test").tempdir().unwrap(); + let data_dir = temp_dir + .into_path() + .canonicalize() + .expect("This path is available."); + + let wallet_name = data_dir.join("zingo-wallet.dat"); + let config = ZingoConfig::create_unconnected(ChainType::FakeMainnet, Some(data_dir)); + let lc = LightClient::create_from_wallet_base( + WalletBase::MnemonicPhrase(CHIMNEY_BETTER_SEED.to_string()), + &config, + 0, + false, + ) + .unwrap(); + assert_eq!( + format!( + "{:?}", + LightClient::create_from_wallet_base( + WalletBase::MnemonicPhrase(CHIMNEY_BETTER_SEED.to_string()), + &config, + 0, + false + ) + .err() + .unwrap() + ), + format!( + "{:?}", + std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + format!("Cannot create a new wallet from seed, because a wallet already exists at:\n{:?}", wallet_name), + ) + ) + ); + + // The first t address and z address should be derived + Runtime::new().unwrap().block_on(async move { + let addresses = lc.do_addresses().await; + assert_eq!( + "zs1q6xk3q783t5k92kjqt2rkuuww8pdw2euzy5rk6jytw97enx8fhpazdv3th4xe7vsk6e9sfpawfg" + .to_string(), + addresses[0]["receivers"]["sapling"] + ); + assert_eq!( + "t1eQ63fwkQ4n4Eo5uCrPGaAV8FWB2tmx7ui", + addresses[0]["receivers"]["transparent"] + ); + }); + } +} diff --git a/zingolib/src/wallet/keys/unified.rs b/zingolib/src/wallet/keys/unified.rs index 6a0319bbb..700c9b871 100644 --- a/zingolib/src/wallet/keys/unified.rs +++ b/zingolib/src/wallet/keys/unified.rs @@ -141,6 +141,15 @@ fn read_write_receiver_selections() { } impl WalletCapability { + pub(crate) fn get_ua_from_contained_transparent_receiver( + &self, + receiver: &TransparentAddress, + ) -> Option { + self.addresses + .iter() + .find(|ua| ua.transparent() == Some(receiver)) + .cloned() + } pub fn addresses(&self) -> &AppendOnlyVec { &self.addresses } @@ -421,15 +430,6 @@ impl WalletCapability { Ok(wc) } - pub(crate) fn get_ua_from_contained_transparent_receiver( - &self, - receiver: &TransparentAddress, - ) -> Option { - self.addresses - .iter() - .find(|ua| ua.transparent() == Some(receiver)) - .cloned() - } pub(crate) fn get_all_taddrs(&self, config: &ZingoConfig) -> HashSet { self.addresses .iter()