From bf9d396e4807f441f2d10115e28aea634eef2efc Mon Sep 17 00:00:00 2001 From: Daira-Emma Hopwood Date: Sat, 18 May 2024 01:47:08 +0100 Subject: [PATCH] Work-in-progress implementation of ZIP 320. Co-authored-by: Kris Nuttycombe Co-authored-by: Jack Grigg Signed-off-by: Daira-Emma Hopwood --- zcash_client_backend/proto/proposal.proto | 2 + zcash_client_backend/src/data_api.rs | 156 +++++++- zcash_client_backend/src/data_api/error.rs | 9 + zcash_client_backend/src/data_api/wallet.rs | 333 ++++++++++-------- .../src/data_api/wallet/input_selection.rs | 167 ++++++++- zcash_client_backend/src/proto/proposal.rs | 2 + zcash_client_sqlite/src/error.rs | 11 + zcash_client_sqlite/src/lib.rs | 87 ++++- zcash_client_sqlite/src/testing/pool.rs | 4 +- zcash_client_sqlite/src/wallet.rs | 36 +- .../src/wallet/address_tracker.rs | 65 ++++ zcash_client_sqlite/src/wallet/init.rs | 8 + .../src/wallet/init/migrations.rs | 10 +- .../init/migrations/address_tracking.rs | 57 +++ 14 files changed, 737 insertions(+), 210 deletions(-) create mode 100644 zcash_client_sqlite/src/wallet/address_tracker.rs create mode 100644 zcash_client_sqlite/src/wallet/init/migrations/address_tracking.rs diff --git a/zcash_client_backend/proto/proposal.proto b/zcash_client_backend/proto/proposal.proto index 950bb3406c..ef1c0732bc 100644 --- a/zcash_client_backend/proto/proposal.proto +++ b/zcash_client_backend/proto/proposal.proto @@ -113,6 +113,8 @@ enum FeeRule { // The proposed change outputs and fee value. message TransactionBalance { // A list of change output values. + // Any `ChangeValue`s for the transparent value pool represent ephemeral + // outputs that will each be given a unique t-address. repeated ChangeValue proposedChange = 1; // The fee to be paid by the proposed transaction, in zatoshis. uint64 feeRequired = 2; diff --git a/zcash_client_backend/src/data_api.rs b/zcash_client_backend/src/data_api.rs index 17ff7bb05d..4403f9a06f 100644 --- a/zcash_client_backend/src/data_api.rs +++ b/zcash_client_backend/src/data_api.rs @@ -30,18 +30,19 @@ //! //! ## Core Traits //! -//! The utility functions described above depend upon four important traits defined in this +//! The utility functions described above depend upon five important traits defined in this //! module, which between them encompass the data storage requirements of a light wallet. -//! The relevant traits are [`InputSource`], [`WalletRead`], [`WalletWrite`], and -//! [`WalletCommitmentTrees`]. A complete implementation of the data storage layer for a wallet -//! will include an implementation of all four of these traits. See the [`zcash_client_sqlite`] -//! crate for a complete example of the implementation of these traits. +//! The relevant traits are [`InputSource`], [`WalletRead`], [`WalletWrite`], +//! [`WalletCommitmentTrees`], and [`WalletAddressTracking`]. A complete implementation of the +//! data storage layer for a wallet will include an implementation of all five of these traits. +//! See the [`zcash_client_sqlite`] crate for a complete example of the implementation of these +//! traits. //! //! ## Accounts //! -//! The operation of the [`InputSource`], [`WalletRead`] and [`WalletWrite`] traits is built around -//! the concept of a wallet having one or more accounts, with a unique `AccountId` for each -//! account. +//! The operation of the [`InputSource`], [`WalletRead`], [`WalletWrite`], and +//! [`WalletAddressTracking`] traits is built around the concept of a wallet having one or more +//! accounts, with a unique `AccountId` for each account. //! //! An account identifier corresponds to at most a single [`UnifiedSpendingKey`]'s worth of spend //! authority, with the received and spent notes of that account tracked via the corresponding @@ -57,7 +58,7 @@ use std::{ collections::HashMap, - fmt::Debug, + fmt::{self, Debug, Display}, hash::Hash, io, num::{NonZeroU32, TryFromIntError}, @@ -86,6 +87,7 @@ use crate::{ use zcash_primitives::{ block::BlockHash, consensus::BlockHeight, + legacy::TransparentAddress, memo::{Memo, MemoBytes}, transaction::{ components::amount::{BalanceError, NonNegativeAmount}, @@ -95,8 +97,7 @@ use zcash_primitives::{ #[cfg(feature = "transparent-inputs")] use { - crate::wallet::TransparentAddressMetadata, - zcash_primitives::{legacy::TransparentAddress, transaction::components::OutPoint}, + crate::wallet::TransparentAddressMetadata, zcash_primitives::transaction::components::OutPoint, }; #[cfg(feature = "test-dependencies")] @@ -1514,8 +1515,11 @@ pub trait WalletWrite: WalletRead { received_tx: DecryptedTransaction, ) -> Result<(), Self::Error>; - /// Saves information about a transaction that was constructed and sent by the wallet to the - /// persistent wallet store. + /// Saves information about a transaction constructed by the wallet to the persistent + /// wallet store. + /// + /// The name `store_sent_tx` is somewhat misleading; this must be called *before* the + /// transaction is sent to the network. fn store_sent_tx( &mut self, sent_tx: &SentTransaction, @@ -1608,17 +1612,104 @@ pub trait WalletCommitmentTrees { ) -> Result<(), ShardTreeError>; } +/// An error related to tracking of ephemeral transparent addresses. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AddressTrackingError { + /// Only ZIP 32 accounts are supported for ZIP 320 (TEX Addresses). + UnsupportedAccountType, + + /// The proposal cannot be constructed until transactions with previously reserved + /// ephemeral address outputs have been mined. + ReachedGapLimit, + + /// Internal error. + Internal(String), +} + +impl Display for AddressTrackingError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AddressTrackingError::UnsupportedAccountType => write!( + f, + "Only ZIP 32 accounts are supported for ZIP 320 (TEX Addresses)." + ), + AddressTrackingError::ReachedGapLimit => write!( + f, + "The proposal cannot be constructed until transactions with previously reserved ephemeral address outputs have been mined." + ), + AddressTrackingError::Internal(e) => write!( + f, + "Internal address tracking error: {}", e + ), + } + } +} + +/// Wallet operations required for tracking of ephemeral transparent addresses. +/// +/// This trait serves to allow the corresponding wallet functions to be abstracted +/// away from any particular data storage substrate. +pub trait WalletAddressTracking { + /// Reserves the next available address. + /// + /// To ensure that sufficient information is stored on-chain to allow recovering + /// funds sent back to any of the used addresses, a "gap limit" of 20 addresses + /// should be observed as described in [BIP 44]. An implementation should record + /// the index of the first unmined address, and update it for addresses that have + /// been observed as outputs in mined transactions when `addresses_are_mined` is + /// called. + /// + /// Returns an error if all addresses within the gap limit have already been + /// reserved. + /// + /// [BIP 44]: https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki#user-content-Address_gap_limit + fn reserve_next_address( + &self, + uivk: &UnifiedIncomingViewingKey, + ) -> Result; + + /// Frees previously reserved ephemeral transparent addresses. + /// + /// This should only be used in the case that an error occurs in transaction + /// construction after the address was reserved. It is sufficient for an + /// implementation to only be able to unreserve the last reserved address from + /// the given account. + /// + /// Returns an error if the account identifier does not correspond to a known + /// account. + fn unreserve_addresses( + &self, + address: &[TransparentAddress], + ) -> Result<(), AddressTrackingError>; + + /// Mark addresses as having been used. + fn mark_addresses_as_used( + &self, + address: &[TransparentAddress], + ) -> Result<(), AddressTrackingError>; + + /// Checks the set of ephemeral transparent addresses within the gap limit for the + /// given mined t-addresses, in order to update the first unmined ephemeral t-address + /// index if necessary. + fn mark_addresses_as_mined( + &self, + addresses: &[TransparentAddress], + ) -> Result<(), AddressTrackingError>; +} + #[cfg(feature = "test-dependencies")] pub mod testing { use incrementalmerkletree::Address; use secrecy::{ExposeSecret, SecretVec}; use shardtree::{error::ShardTreeError, store::memory::MemoryShardStore, ShardTree}; use std::{collections::HashMap, convert::Infallible, num::NonZeroU32}; + use zcash_keys::keys::UnifiedIncomingViewingKey; use zip32::fingerprint::SeedFingerprint; use zcash_primitives::{ block::BlockHash, consensus::{BlockHeight, Network}, + legacy::TransparentAddress, memo::Memo, transaction::{components::amount::NonNegativeAmount, Transaction, TxId}, }; @@ -1633,13 +1724,14 @@ pub mod testing { use super::{ chain::{ChainState, CommitmentTreeRoot}, scanning::ScanRange, - AccountBirthday, BlockMetadata, DecryptedTransaction, InputSource, NullifierQuery, - ScannedBlock, SeedRelevance, SentTransaction, SpendableNotes, WalletCommitmentTrees, - WalletRead, WalletSummary, WalletWrite, SAPLING_SHARD_HEIGHT, + AccountBirthday, AddressTrackingError, BlockMetadata, DecryptedTransaction, InputSource, + NullifierQuery, ScannedBlock, SeedRelevance, SentTransaction, SpendableNotes, + WalletAddressTracking, WalletCommitmentTrees, WalletRead, WalletSummary, WalletWrite, + SAPLING_SHARD_HEIGHT, }; #[cfg(feature = "transparent-inputs")] - use {crate::wallet::TransparentAddressMetadata, zcash_primitives::legacy::TransparentAddress}; + use crate::wallet::TransparentAddressMetadata; #[cfg(feature = "orchard")] use super::ORCHARD_SHARD_HEIGHT; @@ -1926,6 +2018,36 @@ pub mod testing { } } + impl WalletAddressTracking for MockWalletDb { + fn reserve_next_address( + &self, + _uivk: &UnifiedIncomingViewingKey, + ) -> Result { + Err(AddressTrackingError::ReachedGapLimit) + } + + fn unreserve_addresses( + &self, + _addresses: &[TransparentAddress], + ) -> Result<(), AddressTrackingError> { + Ok(()) + } + + fn mark_addresses_as_used( + &self, + _addresses: &[TransparentAddress], + ) -> Result<(), AddressTrackingError> { + Ok(()) + } + + fn mark_addresses_as_mined( + &self, + _addresses: &[TransparentAddress], + ) -> Result<(), AddressTrackingError> { + Ok(()) + } + } + impl WalletCommitmentTrees for MockWalletDb { type Error = Infallible; type SaplingShardStore<'a> = MemoryShardStore; diff --git a/zcash_client_backend/src/data_api/error.rs b/zcash_client_backend/src/data_api/error.rs index 0641571bb8..0fd6bbc942 100644 --- a/zcash_client_backend/src/data_api/error.rs +++ b/zcash_client_backend/src/data_api/error.rs @@ -20,6 +20,8 @@ use zcash_primitives::legacy::TransparentAddress; use crate::wallet::NoteId; +use super::AddressTrackingError; + /// Errors that can occur as a consequence of wallet operations. #[derive(Debug)] pub enum Error { @@ -83,6 +85,9 @@ pub enum Error { #[cfg(feature = "transparent-inputs")] AddressNotRecognized(TransparentAddress), + + /// An error related to tracking of ephemeral transparent addresses. + AddressTracking(AddressTrackingError), } impl fmt::Display for Error @@ -149,6 +154,9 @@ where Error::AddressNotRecognized(_) => { write!(f, "The specified transparent address was not recognized as belonging to the wallet.") } + Error::AddressTracking(e) => { + write!(f, "Error related to tracking of ephemeral transparent addresses: {}", e) + } } } } @@ -198,6 +206,7 @@ impl From> for Error required, }, InputSelectorError::SyncRequired => Error::ScanRequired, + InputSelectorError::AddressTracking(e) => Error::AddressTracking(e), } } } diff --git a/zcash_client_backend/src/data_api/wallet.rs b/zcash_client_backend/src/data_api/wallet.rs index a28e12b2d6..31b7fdee3c 100644 --- a/zcash_client_backend/src/data_api/wallet.rs +++ b/zcash_client_backend/src/data_api/wallet.rs @@ -41,7 +41,7 @@ use sapling::{ }; use std::num::NonZeroU32; -use super::InputSource; +use super::{InputSource, WalletAddressTracking}; use crate::{ address::Address, data_api::{ @@ -257,7 +257,7 @@ where Error = ::Error, AccountId = ::AccountId, >, - DbT: WalletCommitmentTrees, + DbT: WalletCommitmentTrees + WalletAddressTracking, { let account = wallet_db .get_account_for_ufvk(&usk.to_unified_full_viewing_key()) @@ -368,7 +368,7 @@ where Error = ::Error, AccountId = ::AccountId, >, - DbT: WalletCommitmentTrees, + DbT: WalletCommitmentTrees + WalletAddressTracking, ParamsT: consensus::Parameters + Clone, InputsT: InputSelector, { @@ -601,7 +601,7 @@ pub fn create_proposed_transactions( >, > where - DbT: WalletWrite + WalletCommitmentTrees, + DbT: WalletWrite + WalletCommitmentTrees + WalletAddressTracking, ParamsT: consensus::Parameters + Clone, FeeRuleT: FeeRule, { @@ -654,7 +654,8 @@ fn create_proposed_transaction( >, > where - DbT: WalletWrite + WalletCommitmentTrees, + DbT: WalletWrite + WalletCommitmentTrees + WalletAddressTracking, + ParamsT: consensus::Parameters + Clone, FeeRuleT: FeeRule, { @@ -694,8 +695,8 @@ where let account = wallet_db .get_account_for_ufvk(&usk.to_unified_full_viewing_key()) .map_err(Error::DataSource)? - .ok_or(Error::KeyNotRecognized)? - .id(); + .ok_or(Error::KeyNotRecognized)?; + let account_id = account.id(); let (sapling_anchor, sapling_inputs) = if proposal_step.involves(PoolType::Shielded(ShieldedProtocol::Sapling)) { @@ -800,7 +801,7 @@ where #[cfg(feature = "transparent-inputs")] let utxos_spent = { let known_addrs = wallet_db - .get_transparent_receivers(account) + .get_transparent_receivers(account_id) .map_err(Error::DataSource)?; let mut utxos_spent: Vec = vec![]; @@ -857,6 +858,9 @@ where { Address::Transparent(t) => Some(t), Address::Unified(uaddr) => uaddr.transparent(), + Address::TransparentSourceOnly(_) => { + unreachable!("Transparent-source-only addresses should not occur here") + } _ => None, } .ok_or(Error::ProposalNotSupported)?; @@ -1031,52 +1035,33 @@ where } else { builder.add_transparent_output(to, payment.amount)?; } - transparent_output_meta.push((to, payment.amount)); + transparent_output_meta.push((*to, payment.amount)); } Address::TransparentSourceOnly(_) => { - panic!("Transparent-source-only addresses should not occur at this stage"); + unreachable!("Transparent-source-only addresses should not occur here"); } } } - for change_value in proposal_step.balance().proposed_change() { - let memo = change_value - .memo() - .map_or_else(MemoBytes::empty, |m| m.clone()); - let output_pool = change_value.output_pool(); - match output_pool { - PoolType::Shielded(ShieldedProtocol::Sapling) => { - builder.add_sapling_output( - sapling_internal_ovk(), - sapling_dfvk.change_address().1, - change_value.value(), - memo.clone(), - )?; - sapling_output_meta.push(( - Recipient::InternalAccount { - receiving_account: account, - external_address: None, - note: output_pool, - }, - change_value.value(), - Some(memo), - )) - } - PoolType::Shielded(ShieldedProtocol::Orchard) => { - #[cfg(not(feature = "orchard"))] - return Err(Error::UnsupportedChangeType(output_pool)); - - #[cfg(feature = "orchard")] - { - builder.add_orchard_output( - orchard_internal_ovk(), - orchard_fvk.address_at(0u32, orchard::keys::Scope::Internal), - change_value.value().into(), + let mut ephemeral_addrs = vec![]; + + let finish = || { + for change_value in proposal_step.balance().proposed_change() { + let memo = change_value + .memo() + .map_or_else(MemoBytes::empty, |m| m.clone()); + let output_pool = change_value.output_pool(); + match output_pool { + PoolType::Shielded(ShieldedProtocol::Sapling) => { + builder.add_sapling_output( + sapling_internal_ovk(), + sapling_dfvk.change_address().1, + change_value.value(), memo.clone(), )?; - orchard_output_meta.push(( + sapling_output_meta.push(( Recipient::InternalAccount { - receiving_account: account, + receiving_account: account_id, external_address: None, note: output_pool, }, @@ -1084,116 +1069,165 @@ where Some(memo), )) } - } - PoolType::Transparent => { - return Err(Error::UnsupportedChangeType(output_pool)); + PoolType::Shielded(ShieldedProtocol::Orchard) => { + #[cfg(not(feature = "orchard"))] + return Err(Error::UnsupportedChangeType(output_pool)); + + #[cfg(feature = "orchard")] + { + builder.add_orchard_output( + orchard_internal_ovk(), + orchard_fvk.address_at(0u32, orchard::keys::Scope::Internal), + change_value.value().into(), + memo.clone(), + )?; + orchard_output_meta.push(( + Recipient::InternalAccount { + receiving_account: account_id, + external_address: None, + note: output_pool, + }, + change_value.value(), + Some(memo), + )) + } + } + PoolType::Transparent => { + // TODO: make sure that this is supposed to be an ephemeral output (by checking + // for backlinks) rather than a non-ephemeral transparent change output. + let ephemeral_addr = wallet_db + .reserve_next_address(&account.uivk()) + .map_err(InputSelectorError::from)?; + ephemeral_addrs.push(ephemeral_addr); + builder.add_transparent_output(&ephemeral_addr, change_value.value())?; + transparent_output_meta.push((ephemeral_addr, change_value.value())) + } } } - } - // Build the transaction with the specified fee rule - let build_result = builder.build(OsRng, spend_prover, output_prover, fee_rule)?; + // Build the transaction with the specified fee rule + let build_result = builder.build(OsRng, spend_prover, output_prover, fee_rule)?; + + #[cfg(feature = "orchard")] + let orchard_internal_ivk = orchard_fvk.to_ivk(orchard::keys::Scope::Internal); + #[cfg(feature = "orchard")] + let orchard_outputs = + orchard_output_meta + .into_iter() + .enumerate() + .map(|(i, (recipient, value, memo))| { + let output_index = build_result.orchard_meta().output_action_index(i).expect( + "An action should exist in the transaction for each Orchard output.", + ); - #[cfg(feature = "orchard")] - let orchard_internal_ivk = orchard_fvk.to_ivk(orchard::keys::Scope::Internal); - #[cfg(feature = "orchard")] - let orchard_outputs = - orchard_output_meta - .into_iter() - .enumerate() - .map(|(i, (recipient, value, memo))| { - let output_index = build_result - .orchard_meta() - .output_action_index(i) - .expect("An action should exist in the transaction for each Orchard output."); - - let recipient = recipient - .map_internal_account_note(|pool| { - assert!(pool == PoolType::Shielded(ShieldedProtocol::Orchard)); - build_result - .transaction() - .orchard_bundle() - .and_then(|bundle| { - bundle - .decrypt_output_with_key(output_index, &orchard_internal_ivk) - .map(|(note, _, _)| Note::Orchard(note)) - }) - }) - .internal_account_note_transpose_option() - .expect("Wallet-internal outputs must be decryptable with the wallet's IVK"); - - SentTransactionOutput::from_parts(output_index, recipient, value, memo) - }); - - let sapling_internal_ivk = - PreparedIncomingViewingKey::new(&sapling_dfvk.to_ivk(Scope::Internal)); - let sapling_outputs = - sapling_output_meta - .into_iter() - .enumerate() - .map(|(i, (recipient, value, memo))| { - let output_index = build_result - .sapling_meta() - .output_index(i) - .expect("An output should exist in the transaction for each Sapling payment."); - - let recipient = recipient - .map_internal_account_note(|pool| { - assert!(pool == PoolType::Shielded(ShieldedProtocol::Sapling)); - build_result - .transaction() - .sapling_bundle() - .and_then(|bundle| { - try_sapling_note_decryption( - &sapling_internal_ivk, - &bundle.shielded_outputs()[output_index], - zip212_enforcement(params, min_target_height), - ) - .map(|(note, _, _)| Note::Sapling(note)) - }) - }) - .internal_account_note_transpose_option() - .expect("Wallet-internal outputs must be decryptable with the wallet's IVK"); - - SentTransactionOutput::from_parts(output_index, recipient, value, memo) - }); - - let transparent_outputs = transparent_output_meta.into_iter().map(|(addr, value)| { - let script = addr.script(); - let output_index = build_result - .transaction() - .transparent_bundle() - .and_then(|b| { - b.vout - .iter() - .enumerate() - .find(|(_, tx_out)| tx_out.script_pubkey == script) + let recipient = recipient + .map_internal_account_note(|pool| { + assert!(pool == PoolType::Shielded(ShieldedProtocol::Orchard)); + build_result + .transaction() + .orchard_bundle() + .and_then(|bundle| { + bundle + .decrypt_output_with_key( + output_index, + &orchard_internal_ivk, + ) + .map(|(note, _, _)| Note::Orchard(note)) + }) + }) + .internal_account_note_transpose_option() + .expect( + "Wallet-internal outputs must be decryptable with the wallet's IVK", + ); + + SentTransactionOutput::from_parts(output_index, recipient, value, memo) + }); + + let sapling_internal_ivk = + PreparedIncomingViewingKey::new(&sapling_dfvk.to_ivk(Scope::Internal)); + let sapling_outputs = + sapling_output_meta + .into_iter() + .enumerate() + .map(|(i, (recipient, value, memo))| { + let output_index = build_result.sapling_meta().output_index(i).expect( + "An output should exist in the transaction for each Sapling payment.", + ); + + let recipient = recipient + .map_internal_account_note(|pool| { + assert!(pool == PoolType::Shielded(ShieldedProtocol::Sapling)); + build_result + .transaction() + .sapling_bundle() + .and_then(|bundle| { + try_sapling_note_decryption( + &sapling_internal_ivk, + &bundle.shielded_outputs()[output_index], + zip212_enforcement(params, min_target_height), + ) + .map(|(note, _, _)| Note::Sapling(note)) + }) + }) + .internal_account_note_transpose_option() + .expect( + "Wallet-internal outputs must be decryptable with the wallet's IVK", + ); + + SentTransactionOutput::from_parts(output_index, recipient, value, memo) + }); + + let transparent_outputs = transparent_output_meta.into_iter().map(|(addr, value)| { + let script = addr.script(); + let output_index = build_result + .transaction() + .transparent_bundle() + .and_then(|b| { + b.vout + .iter() + .enumerate() + .find(|(_, tx_out)| tx_out.script_pubkey == script) + }) + .map(|(index, _)| index) + .expect("An output should exist in the transaction for each transparent payment."); + + SentTransactionOutput::from_parts( + output_index, + Recipient::Transparent(addr), + value, + None, + ) + }); + + let mut outputs: Vec::AccountId>> = vec![]; + #[cfg(feature = "orchard")] + outputs.extend(orchard_outputs); + outputs.extend(sapling_outputs); + outputs.extend(transparent_outputs); + + wallet_db + .store_sent_tx(&SentTransaction { + tx: build_result.transaction(), + created: time::OffsetDateTime::now_utc(), + account: account_id, + outputs, + fee_amount: proposal_step.balance().fee_required(), + #[cfg(feature = "transparent-inputs")] + utxos_spent, }) - .map(|(index, _)| index) - .expect("An output should exist in the transaction for each transparent payment."); + .map_err(Error::DataSource)?; - SentTransactionOutput::from_parts(output_index, Recipient::Transparent(*addr), value, None) - }); + Ok(build_result) + }; + let res = finish(); - let mut outputs = vec![]; - #[cfg(feature = "orchard")] - outputs.extend(orchard_outputs); - outputs.extend(sapling_outputs); - outputs.extend(transparent_outputs); - - wallet_db - .store_sent_tx(&SentTransaction { - tx: build_result.transaction(), - created: time::OffsetDateTime::now_utc(), - account, - outputs, - fee_amount: proposal_step.balance().fee_required(), - #[cfg(feature = "transparent-inputs")] - utxos_spent, - }) - .map_err(Error::DataSource)?; + match res { + Ok(_) => wallet_db.mark_addresses_as_used(&ephemeral_addrs), + Err(_) => wallet_db.unreserve_addresses(&ephemeral_addrs), + } + .map_err(InputSelectorError::from)?; - Ok(build_result) + res } /// Constructs a transaction that consumes available transparent UTXOs belonging to the specified @@ -1253,7 +1287,10 @@ pub fn shield_transparent_funds( > where ParamsT: consensus::Parameters, - DbT: WalletWrite + WalletCommitmentTrees + InputSource::Error>, + DbT: WalletWrite + + WalletCommitmentTrees + + WalletAddressTracking + + InputSource::Error>, InputsT: ShieldingSelector, { let proposal = propose_shielding( diff --git a/zcash_client_backend/src/data_api/wallet/input_selection.rs b/zcash_client_backend/src/data_api/wallet/input_selection.rs index 792e0bd196..505276c2ed 100644 --- a/zcash_client_backend/src/data_api/wallet/input_selection.rs +++ b/zcash_client_backend/src/data_api/wallet/input_selection.rs @@ -21,7 +21,7 @@ use zcash_primitives::{ use crate::{ address::{Address, UnifiedAddress}, - data_api::{InputSource, SimpleNoteRetention, SpendableNotes}, + data_api::{AddressTrackingError, InputSource, SimpleNoteRetention, SpendableNotes}, fees::{sapling, ChangeError, ChangeStrategy, DustOutputPolicy}, proposal::{Proposal, ProposalError, ShieldedInputs}, wallet::WalletTransparentOutput, @@ -31,9 +31,16 @@ use crate::{ #[cfg(feature = "transparent-inputs")] use { - std::collections::BTreeSet, std::convert::Infallible, - zcash_primitives::legacy::TransparentAddress, - zcash_primitives::transaction::components::OutPoint, + crate::{ + fees::{ChangeValue, TransactionBalance}, + proposal::{Step, StepOutput, StepOutputIndex}, + zip321::Payment, + }, + std::collections::BTreeSet, + std::convert::Infallible, + zcash_primitives::{ + legacy::TransparentAddress, transaction::components::OutPoint, transaction::fees::zip317, + }, }; #[cfg(feature = "orchard")] @@ -57,6 +64,8 @@ pub enum InputSelectorError { /// The data source does not have enough information to choose an expiry height /// for the transaction. SyncRequired, + /// An error related to tracking of ephemeral transparent addresses. + AddressTracking(AddressTrackingError), } impl fmt::Display for InputSelectorError { @@ -91,10 +100,23 @@ impl fmt::Display for InputSelectorError { write!(f, "Insufficient chain data is available, sync required.") } + InputSelectorError::AddressTracking(e) => { + write!( + f, + "Error related to tracking of ephemeral transparent addresses: {}", + e + ) + } } } } +impl From for InputSelectorError { + fn from(e: AddressTrackingError) -> Self { + InputSelectorError::AddressTracking(e) + } +} + impl error::Error for InputSelectorError where DE: Debug + Display + error::Error + 'static, @@ -348,6 +370,16 @@ where #[cfg(feature = "orchard")] let mut orchard_outputs = vec![]; let mut payment_pools = BTreeMap::new(); + + #[cfg(feature = "transparent-inputs")] + let mut tr2_transparent_outputs = vec![]; + #[cfg(feature = "transparent-inputs")] + let mut tr2_payments = vec![]; + #[cfg(feature = "transparent-inputs")] + let mut tr2_payment_pools = BTreeMap::new(); + #[cfg(feature = "transparent-inputs")] + let mut total_ephemeral_plus_fee = Some(NonNegativeAmount::ZERO); // None means overflow + for (idx, payment) in transaction_request.payments() { match &payment.recipient_address { Address::Transparent(addr) => { @@ -357,6 +389,26 @@ where script_pubkey: addr.script(), }); } + #[cfg(feature = "transparent-inputs")] + Address::TransparentSourceOnly(data) => { + let p2pkh_addr = TransparentAddress::PublicKeyHash(*data); + tr2_payment_pools.insert(*idx, PoolType::Transparent); + tr2_transparent_outputs.push(TxOut { + value: payment.amount, + script_pubkey: p2pkh_addr.script(), + }); + tr2_payments.push(Payment { + recipient_address: Address::Transparent(p2pkh_addr), + amount: payment.amount, + memo: None, + label: payment.label.clone(), + message: payment.message.clone(), + other_params: payment.other_params.clone(), + }); + total_ephemeral_plus_fee = + total_ephemeral_plus_fee + payment.amount + zip317::MARGINAL_FEE; + } + #[cfg(not(feature = "transparent-inputs"))] Address::TransparentSourceOnly(_) => { return Err(InputSelectorError::Selection( GreedyInputSelectorError::UnsupportedTransparentSourceOnlyAddress, @@ -396,10 +448,30 @@ where } } + #[cfg(feature = "transparent-inputs")] + let total_ephemeral_plus_fee = + total_ephemeral_plus_fee.ok_or(InputSelectorError::Selection( + GreedyInputSelectorError::Balance(BalanceError::Overflow), + ))?; + + #[cfg(feature = "transparent-inputs")] + if !tr2_transparent_outputs.is_empty() { + // Push a fake output of total_ephemeral_plus_fee to an arbitrary t-address. + // This is *only* used to compute the balance and will not appear in any + // `TransactionRequest`, so it is fine to use a burn address. This assumes + // that the fake output will have the same effect on the fee for the first + // transaction as the real ephemeral output. + transparent_outputs.push(TxOut { + value: total_ephemeral_plus_fee, + script_pubkey: TransparentAddress::PublicKeyHash([0u8; 20]).script(), + }); + } + let mut shielded_inputs = SpendableNotes::empty(); let mut prior_available = NonNegativeAmount::ZERO; let mut amount_required = NonNegativeAmount::ZERO; let mut exclude: Vec = vec![]; + // This loop is guaranteed to terminate because on each iteration we check that the amount // of funds selected is strictly increasing. The loop will either return a successful // result or the wallet will eventually run out of funds to select. @@ -466,18 +538,93 @@ where match balance { Ok(balance) => { - return Proposal::single_step( - transaction_request, - payment_pools, - vec![], + let fee_rule = (*self.change_strategy.fee_rule()).clone(); + let shielded_inputs = NonEmpty::from_vec(shielded_inputs.into_vec(&SimpleNoteRetention { sapling: use_sapling, #[cfg(feature = "orchard")] orchard: use_orchard, })) - .map(|notes| ShieldedInputs::from_parts(anchor_height, notes)), + .map(|notes| ShieldedInputs::from_parts(anchor_height, notes)); + + #[cfg(feature = "transparent-inputs")] + if !tr2_transparent_outputs.is_empty() { + // Construct two new `TransactionRequest`s: + // * `tr1` excludes the TEX outputs, and in their place includes + // a single additional "change" output to the transparent pool. + // * `tr2` spends from that change output to each of the P2PKH + // addresses that were wrapped as TEX addresses. + + let tr1 = TransactionRequest::from_indexed( + transaction_request + .payments() + .iter() + .filter_map(|(idx, payment)| { + if tr2_payment_pools.contains_key(idx) { + None + } else { + Some((*idx, payment.clone())) + } + }) + .collect(), + ) + .expect( + "removing payments from a TransactionRequest preserves correctness", + ); + + // Create a TransactionBalance for `tr1` that adds the ephemeral output + // as an extra change output. + let mut tr1_change: Vec<_> = balance.proposed_change().into(); + let ephemeral_output = + StepOutput::new(0, StepOutputIndex::Change(tr1_change.len())); + tr1_change.push(ChangeValue::transparent(total_ephemeral_plus_fee)); + let tr1_balance = + TransactionBalance::new(tr1_change, balance.fee_required()).map_err( + |_| InputSelectorError::Proposal(ProposalError::Overflow), + )?; + + let tr2 = + TransactionRequest::new(tr2_payments).expect("correct by construction"); + + let step1 = Step::from_parts( + &[], + tr1, + payment_pools, + vec![], + shielded_inputs, + vec![], + tr1_balance, + false, + ) + .map_err(InputSelectorError::Proposal)?; + + let step2 = Step::from_parts( + &[step1.clone()], + tr2, + tr2_payment_pools, + vec![], + None, + vec![ephemeral_output], + balance, + false, + ) + .map_err(InputSelectorError::Proposal)?; + + return Proposal::multi_step( + fee_rule, + target_height, + NonEmpty::from((step1, vec![step2])), + ) + .map_err(InputSelectorError::Proposal); + } + + return Proposal::single_step( + transaction_request, + payment_pools, + vec![], + shielded_inputs, balance, - (*self.change_strategy.fee_rule()).clone(), + fee_rule, target_height, false, ) diff --git a/zcash_client_backend/src/proto/proposal.rs b/zcash_client_backend/src/proto/proposal.rs index a17b83bf8b..c94aada37e 100644 --- a/zcash_client_backend/src/proto/proposal.rs +++ b/zcash_client_backend/src/proto/proposal.rs @@ -116,6 +116,8 @@ pub mod proposed_input { #[derive(Clone, PartialEq, ::prost::Message)] pub struct TransactionBalance { /// A list of change output values. + /// Any `ChangeValue`s for the transparent value pool represent ephemeral + /// outputs that will each be given a unique t-address. #[prost(message, repeated, tag = "1")] pub proposed_change: ::prost::alloc::vec::Vec, /// The fee to be paid by the proposed transaction, in zatoshis. diff --git a/zcash_client_sqlite/src/error.rs b/zcash_client_sqlite/src/error.rs index 6796c41421..0073128772 100644 --- a/zcash_client_sqlite/src/error.rs +++ b/zcash_client_sqlite/src/error.rs @@ -4,6 +4,7 @@ use std::error; use std::fmt; use shardtree::error::ShardTreeError; +use zcash_client_backend::data_api::AddressTrackingError; use zcash_client_backend::{ encoding::{Bech32DecodeError, TransparentCodecError}, PoolType, @@ -110,6 +111,9 @@ pub enum SqliteClientError { /// An error occurred in computing wallet balance BalanceError(BalanceError), + + /// Address tracking error + AddressTracking(AddressTrackingError), } impl error::Error for SqliteClientError { @@ -160,6 +164,7 @@ impl fmt::Display for SqliteClientError { SqliteClientError::ChainHeightUnknown => write!(f, "Chain height unknown; please call `update_chain_tip`"), SqliteClientError::UnsupportedPoolType(t) => write!(f, "Pool type is not currently supported: {}", t), SqliteClientError::BalanceError(e) => write!(f, "Balance error: {}", e), + SqliteClientError::AddressTracking(e) => write!(f, "Error related to tracking of ephemeral transparent addresses: {}", e), } } } @@ -224,3 +229,9 @@ impl From for SqliteClientError { SqliteClientError::AddressGeneration(e) } } + +impl From for SqliteClientError { + fn from(e: AddressTrackingError) -> Self { + SqliteClientError::AddressTracking(e) + } +} diff --git a/zcash_client_sqlite/src/lib.rs b/zcash_client_sqlite/src/lib.rs index ca4245330a..fe910164b4 100644 --- a/zcash_client_sqlite/src/lib.rs +++ b/zcash_client_sqlite/src/lib.rs @@ -43,7 +43,7 @@ use secrecy::{ExposeSecret, SecretVec}; use shardtree::{error::ShardTreeError, ShardTree}; use std::{ borrow::Borrow, collections::HashMap, convert::AsRef, fmt, num::NonZeroU32, ops::Range, - path::Path, + path::Path, rc::Rc, sync::Mutex, }; use subtle::ConditionallySelectable; use tracing::{debug, trace, warn}; @@ -54,9 +54,10 @@ use zcash_client_backend::{ self, chain::{BlockSource, ChainState, CommitmentTreeRoot}, scanning::{ScanPriority, ScanRange}, - Account, AccountBirthday, AccountSource, BlockMetadata, DecryptedTransaction, InputSource, - NullifierQuery, ScannedBlock, SeedRelevance, SentTransaction, SpendableNotes, - WalletCommitmentTrees, WalletRead, WalletSummary, WalletWrite, SAPLING_SHARD_HEIGHT, + Account, AccountBirthday, AccountSource, AddressTrackingError, BlockMetadata, + DecryptedTransaction, InputSource, NullifierQuery, ScannedBlock, SeedRelevance, + SentTransaction, SpendableNotes, WalletAddressTracking, WalletCommitmentTrees, WalletRead, + WalletSummary, WalletWrite, SAPLING_SHARD_HEIGHT, }, keys::{ AddressGenerationError, UnifiedAddressRequest, UnifiedFullViewingKey, UnifiedSpendingKey, @@ -65,10 +66,11 @@ use zcash_client_backend::{ wallet::{Note, NoteId, ReceivedNote, Recipient, WalletTransparentOutput}, DecryptedOutput, PoolType, ShieldedProtocol, TransferType, }; -use zcash_keys::address::Address; +use zcash_keys::{address::Address, keys::UnifiedIncomingViewingKey}; use zcash_primitives::{ block::BlockHash, consensus::{self, BlockHeight}, + legacy::TransparentAddress, memo::{Memo, MemoBytes}, transaction::{components::amount::NonNegativeAmount, Transaction, TxId}, zip32::{self, DiversifierIndex, Scope}, @@ -88,7 +90,7 @@ use { #[cfg(feature = "transparent-inputs")] use { zcash_client_backend::wallet::TransparentAddressMetadata, - zcash_primitives::{legacy::TransparentAddress, transaction::components::OutPoint}, + zcash_primitives::transaction::components::OutPoint, }; #[cfg(feature = "unstable")] @@ -102,6 +104,7 @@ pub mod chain; pub mod error; pub mod wallet; use wallet::{ + address_tracker::AddressTracker, commitment_tree::{self, put_shard_roots}, SubtreeScanProgress, }; @@ -168,6 +171,7 @@ pub struct UtxoId(pub i64); pub struct WalletDb { conn: C, params: P, + address_tracker: Rc>, } /// A wrapper for a SQLite transaction affecting the wallet database. @@ -184,7 +188,12 @@ impl WalletDb { pub fn for_path>(path: F, params: P) -> Result { Connection::open(path).and_then(move |conn| { rusqlite::vtab::array::load_module(&conn)?; - Ok(WalletDb { conn, params }) + let tracker = AddressTracker::new(&conn, ¶ms); + Ok(WalletDb { + conn, + params, + address_tracker: Rc::new(Mutex::new(tracker)), + }) }) } @@ -196,6 +205,7 @@ impl WalletDb { let mut wdb = WalletDb { conn: SqlTransaction(&tx), params: self.params.clone(), + address_tracker: self.address_tracker.clone(), }; let result = f(&mut wdb)?; tx.commit()?; @@ -785,7 +795,7 @@ impl WalletWrite for WalletDb } // Prune the nullifier map of entries we no longer need. - if let Some(meta) = wdb.block_fully_scanned()? { + if let Some(meta) = wallet::block_fully_scanned(wdb.conn.0, &wdb.params)? { wallet::prune_nullifier_map( wdb.conn.0, meta.block_height().saturating_sub(PRUNING_DEPTH), @@ -1232,6 +1242,7 @@ impl WalletWrite for WalletDb ) } + let mut output_addrs = vec![]; for (output_index, txout) in d_tx .tx() .transparent_bundle() @@ -1240,6 +1251,11 @@ impl WalletWrite for WalletDb .enumerate() { if let Some(address) = txout.recipient_address() { + // Mark this output address as mined. + // TODO: we really want to only mark outputs when a transaction has been + // *reliably* mined, e.g. it has 10 confirmations. + output_addrs.push(address); + wallet::put_sent_output( wdb.conn.0, &wdb.params, @@ -1252,6 +1268,7 @@ impl WalletWrite for WalletDb )?; } } + wdb.mark_addresses_as_mined(&output_addrs).map_err(SqliteClientError::from)?; } } @@ -1364,9 +1381,7 @@ impl WalletWrite for WalletDb } fn truncate_to_height(&mut self, block_height: BlockHeight) -> Result<(), Self::Error> { - self.transactionally(|wdb| { - wallet::truncate_to_height(wdb.conn.0, &wdb.params, block_height) - }) + self.transactionally(|wdb| wallet::truncate_to_height(wdb, block_height)) } } @@ -1562,6 +1577,56 @@ impl<'conn, P: consensus::Parameters> WalletCommitmentTrees for WalletDb WalletAddressTracking for WalletDb +where + C: Borrow, + P: consensus::Parameters, +{ + fn reserve_next_address( + &self, + uivk: &UnifiedIncomingViewingKey, + ) -> Result { + let mut tracker = self + .address_tracker + .lock() + .map_err(|e| AddressTrackingError::Internal(format!("{:?}", e)))?; + tracker.reserve_next_address(self, uivk) + } + + fn unreserve_addresses( + &self, + addresses: &[TransparentAddress], + ) -> Result<(), AddressTrackingError> { + let mut tracker = self + .address_tracker + .lock() + .map_err(|e| AddressTrackingError::Internal(format!("{:?}", e)))?; + tracker.unreserve_addresses(self, addresses) + } + + fn mark_addresses_as_used( + &self, + addresses: &[TransparentAddress], + ) -> Result<(), AddressTrackingError> { + let mut tracker = self + .address_tracker + .lock() + .map_err(|e| AddressTrackingError::Internal(format!("{:?}", e)))?; + tracker.mark_addresses_as_used(self, addresses) + } + + fn mark_addresses_as_mined( + &self, + addresses: &[TransparentAddress], + ) -> Result<(), AddressTrackingError> { + let mut tracker = self + .address_tracker + .lock() + .map_err(|e| AddressTrackingError::Internal(format!("{:?}", e)))?; + tracker.mark_addresses_as_mined(self, addresses) + } +} + /// A handle for the SQLite block source. pub struct BlockDb(Connection); diff --git a/zcash_client_sqlite/src/testing/pool.rs b/zcash_client_sqlite/src/testing/pool.rs index e63fe057c9..696c1ae036 100644 --- a/zcash_client_sqlite/src/testing/pool.rs +++ b/zcash_client_sqlite/src/testing/pool.rs @@ -2060,7 +2060,7 @@ pub(crate) fn data_db_truncation() { // "Rewind" to height of last scanned block (this is a no-op) st.wallet_mut() - .transactionally(|wdb| truncate_to_height(wdb.conn.0, &wdb.params, h + 1)) + .transactionally(|wdb| truncate_to_height(wdb, h + 1)) .unwrap(); // Spendable balance should be unaltered @@ -2071,7 +2071,7 @@ pub(crate) fn data_db_truncation() { // Rewind so that one block is dropped st.wallet_mut() - .transactionally(|wdb| truncate_to_height(wdb.conn.0, &wdb.params, h)) + .transactionally(|wdb| truncate_to_height(wdb, h)) .unwrap(); // Spendable balance should only contain the first received note; diff --git a/zcash_client_sqlite/src/wallet.rs b/zcash_client_sqlite/src/wallet.rs index 82a802a89c..abeb896f76 100644 --- a/zcash_client_sqlite/src/wallet.rs +++ b/zcash_client_sqlite/src/wallet.rs @@ -132,6 +132,7 @@ use { }, }; +pub(crate) mod address_tracker; pub mod commitment_tree; pub(crate) mod common; pub mod init; @@ -1981,22 +1982,25 @@ pub(crate) fn get_min_unspent_height( /// /// This should only be executed inside a transactional context. pub(crate) fn truncate_to_height( - conn: &rusqlite::Transaction, - params: &P, + wdb: &mut WalletDb, P>, block_height: BlockHeight, ) -> Result<(), SqliteClientError> { - let sapling_activation_height = params + let sapling_activation_height = wdb + .params .activation_height(NetworkUpgrade::Sapling) .expect("Sapling activation height must be available."); // Recall where we synced up to previously. - let last_scanned_height = conn.query_row("SELECT MAX(height) FROM blocks", [], |row| { - row.get::<_, Option>(0) - .map(|opt| opt.map_or_else(|| sapling_activation_height - 1, BlockHeight::from)) - })?; + let last_scanned_height = + wdb.conn + .0 + .query_row("SELECT MAX(height) FROM blocks", [], |row| { + row.get::<_, Option>(0) + .map(|opt| opt.map_or_else(|| sapling_activation_height - 1, BlockHeight::from)) + })?; if block_height < last_scanned_height - PRUNING_DEPTH { - if let Some(h) = get_min_unspent_height(conn)? { + if let Some(h) = get_min_unspent_height(wdb.conn.0)? { if block_height > h { return Err(SqliteClientError::RequestedRewindInvalid(h, block_height)); } @@ -2007,12 +2011,12 @@ pub(crate) fn truncate_to_height( // truncation height, and then truncate any remaining range by setting the end // equal to the truncation height + 1. This sets our view of the chain tip back // to the retained height. - conn.execute( + wdb.conn.0.execute( "DELETE FROM scan_queue WHERE block_range_start >= :new_end_height", named_params![":new_end_height": u32::from(block_height + 1)], )?; - conn.execute( + wdb.conn.0.execute( "UPDATE scan_queue SET block_range_end = :new_end_height WHERE block_range_end > :new_end_height", @@ -2024,10 +2028,6 @@ pub(crate) fn truncate_to_height( // database. if block_height < last_scanned_height { // Truncate the note commitment trees - let mut wdb = WalletDb { - conn: SqlTransaction(conn), - params: params.clone(), - }; wdb.with_sapling_tree_mut(|tree| { tree.truncate_removing_checkpoint(&block_height).map(|_| ()) })?; @@ -2045,27 +2045,27 @@ pub(crate) fn truncate_to_height( // Rewind utxos. It is currently necessary to delete these because we do // not have the full transaction data for the received output. - conn.execute( + wdb.conn.0.execute( "DELETE FROM utxos WHERE height > ?", [u32::from(block_height)], )?; // Un-mine transactions. - conn.execute( + wdb.conn.0.execute( "UPDATE transactions SET block = NULL, tx_index = NULL WHERE block IS NOT NULL AND block > ?", [u32::from(block_height)], )?; // Now that they aren't depended on, delete un-mined blocks. - conn.execute( + wdb.conn.0.execute( "DELETE FROM blocks WHERE height > ?", [u32::from(block_height)], )?; // Delete from the nullifier map any entries with a locator referencing a block // height greater than the truncation height. - conn.execute( + wdb.conn.0.execute( "DELETE FROM tx_locator_map WHERE block_height > :block_height", named_params![":block_height": u32::from(block_height)], diff --git a/zcash_client_sqlite/src/wallet/address_tracker.rs b/zcash_client_sqlite/src/wallet/address_tracker.rs new file mode 100644 index 0000000000..97d2e2e93e --- /dev/null +++ b/zcash_client_sqlite/src/wallet/address_tracker.rs @@ -0,0 +1,65 @@ +//! Allocator for ephemeral transparent addresses. + +use std::borrow::Borrow; + +use rusqlite::Connection; + +use zcash_client_backend::data_api::AddressTrackingError; +use zcash_primitives::{consensus::Parameters, legacy::TransparentAddress}; + +use super::UnifiedIncomingViewingKey; +use crate::WalletDb; + +pub(crate) struct AddressTracker { + gap_set: Vec, + first_unused_index: u32, + first_unmined_index: u32, +} + +impl AddressTracker { + pub(crate) fn new, P: Parameters>(_conn: C, _params: &P) -> Self { + AddressTracker { + gap_set: vec![], + first_unused_index: 0, + first_unmined_index: 0, + } + } + + pub(crate) fn reserve_next_address, P: Parameters>( + &mut self, + _wallet: &WalletDb, + _uivk: &UnifiedIncomingViewingKey, + ) -> Result { + Err(AddressTrackingError::ReachedGapLimit) + } + + pub(crate) fn unreserve_addresses, P: Parameters>( + &mut self, + _wallet: &WalletDb, + addresses: &[TransparentAddress], + ) -> Result<(), AddressTrackingError> { + for _addr in addresses.iter().rev() { + todo!() + } + Ok(()) + } + + pub(crate) fn mark_addresses_as_used, P: Parameters>( + &mut self, + _wallet: &WalletDb, + _addresses: &[TransparentAddress], + ) -> Result<(), AddressTrackingError> { + Ok(()) + } + + /// Checks the set of ephemeral transparent addresses within the gap limit for the + /// given mined t-addresses, in order to update the first unmined ephemeral t-address + /// index if necessary. + pub(crate) fn mark_addresses_as_mined, P: Parameters>( + &mut self, + _wallet: &WalletDb, + _addresses: &[TransparentAddress], + ) -> Result<(), AddressTrackingError> { + Ok(()) + } +} diff --git a/zcash_client_sqlite/src/wallet/init.rs b/zcash_client_sqlite/src/wallet/init.rs index 622a6a0e0d..3326f81e02 100644 --- a/zcash_client_sqlite/src/wallet/init.rs +++ b/zcash_client_sqlite/src/wallet/init.rs @@ -175,6 +175,9 @@ fn sqlite_client_error_to_wallet_migration_error(e: SqliteClientError) -> Wallet SqliteClientError::ChainHeightUnknown => { unreachable!("we don't call methods that require a known chain height") } + SqliteClientError::AddressTracking(_) => { + unreachable!("we don't call address tracking methods") + } } } @@ -380,6 +383,11 @@ mod tests { birthday_sapling_tree_size INTEGER, birthday_orchard_tree_size INTEGER, recover_until_height INTEGER, + first_unmined_ephemeral_taddr_index INTEGER NOT NULL DEFAULT 0, + first_unused_ephemeral_taddr_index INTEGER NOT NULL DEFAULT 0 + CONSTRAINT unused_gte_unmined CHECK ( + first_unused_ephemeral_taddr_index >= first_unmined_ephemeral_taddr_index + ), CHECK ( ( account_kind = 0 diff --git a/zcash_client_sqlite/src/wallet/init/migrations.rs b/zcash_client_sqlite/src/wallet/init/migrations.rs index 0cfba40e93..1d47a63792 100644 --- a/zcash_client_sqlite/src/wallet/init/migrations.rs +++ b/zcash_client_sqlite/src/wallet/init/migrations.rs @@ -1,6 +1,7 @@ mod add_account_birthdays; mod add_transaction_views; mod add_utxo_account; +mod address_tracking; mod addresses_table; mod ensure_orchard_ua_receiver; mod full_account_ids; @@ -61,10 +62,10 @@ pub(super) fn all_migrations( // \ \ | v_transactions_note_uniqueness // \ \ | / // -------------------- full_account_ids - // | - // orchard_received_notes - // | - // ensure_orchard_ua_receiver + // / \ + // orchard_received_notes address_tracking + // | + // ensure_orchard_ua_receiver vec![ Box::new(initial_setup::Migration {}), Box::new(utxos_table::Migration {}), @@ -114,5 +115,6 @@ pub(super) fn all_migrations( Box::new(ensure_orchard_ua_receiver::Migration { params: params.clone(), }), + Box::new(address_tracking::Migration), ] } diff --git a/zcash_client_sqlite/src/wallet/init/migrations/address_tracking.rs b/zcash_client_sqlite/src/wallet/init/migrations/address_tracking.rs new file mode 100644 index 0000000000..fb2368ee68 --- /dev/null +++ b/zcash_client_sqlite/src/wallet/init/migrations/address_tracking.rs @@ -0,0 +1,57 @@ +//! The migration that records ephemeral addresses used beyond the last known mined address, for each account. +use std::collections::HashSet; + +use rusqlite; +use schemer; +use schemer_rusqlite::RusqliteMigration; +use uuid::Uuid; + +use crate::wallet::init::WalletMigrationError; + +use super::full_account_ids; + +pub(super) const MIGRATION_ID: Uuid = Uuid::from_u128(0x0e1d4274_1f8e_44e2_909d_689a4bc2967b); + +pub(super) struct Migration; + +impl schemer::Migration for Migration { + fn id(&self) -> Uuid { + MIGRATION_ID + } + + fn dependencies(&self) -> HashSet { + [full_account_ids::MIGRATION_ID].into_iter().collect() + } + + fn description(&self) -> &'static str { + "Record indices of ephemeral addresses that have been used beyond the last known mined address, for each account." + } +} + +impl RusqliteMigration for Migration { + type Error = WalletMigrationError; + + fn up(&self, transaction: &rusqlite::Transaction) -> Result<(), WalletMigrationError> { + transaction.execute_batch( + r#" + ALTER TABLE accounts ADD first_unmined_ephemeral_taddr_index INTEGER NOT NULL DEFAULT 0; + ALTER TABLE accounts ADD first_unused_ephemeral_taddr_index INTEGER NOT NULL DEFAULT 0 + CONSTRAINT unused_gte_unmined CHECK ( + first_unused_ephemeral_taddr_index >= first_unmined_ephemeral_taddr_index + ); + "#, + )?; + Ok(()) + } + + fn down(&self, transaction: &rusqlite::Transaction) -> Result<(), WalletMigrationError> { + // Dropping first_unused_ephemeral_taddr_index also drops its constraint. + transaction.execute_batch( + r#" + ALTER TABLE accounts DROP COLUMN first_unused_ephemeral_index; + ALTER TABLE accounts DROP COLUMN first_unmined_ephemeral_index; + "#, + )?; + Ok(()) + } +}