Skip to content
38 changes: 36 additions & 2 deletions zcash_client_backend/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ workspace.
### Added
- `zcash_client_backend::data_api`:
- `ll` module
- `wallet::ConfirmationsPolicy::confirmations_until_spendable`
- `Balance::{locked_value, add_locked_value}`
- `AccountBalance::locked_value`
- `DecryptableTransaction`
- `ReceivedTransactionOutput`
- `wallet::ConfirmationsPolicy::confirmations_until_spendable`
- in `zcash_client_backend::proto::compact_formats`:
- `CompactTx` has added fields `vin` and `vout`
- Added types `CompactTxIn`, `TxOut`
Expand All @@ -31,6 +33,11 @@ workspace.
- `Note::receiver`
- `impl From<sapling_crypto::Note> for Note`
- `impl From<orchard::Note> for Note`
- `zcash_client_backend::data_api::ll`
- `zcash_client_backend::data_api::error::LockError`
- `zcash_client_backend::wallet::OutputRef`
- `impl From<zcash_client_backend::wallet::NoteId> for zcash_client_backend::wallet::OutputRef`
- `zcash_client_backend::data_api::wallet::unlock_proposal_inputs`

### Changed
- `zcash_client_backend::data_api::wallet::create_proposed_transactions` now takes
Expand Down Expand Up @@ -72,7 +79,31 @@ workspace.
- `zcash_client_backend::data_api::WalletRead` has added method `get_received_outputs`.
- `zcash_client_backend::proposal::ProposalError` has added variants `PaymentAmountMissing`
and `IncompatibleTxVersion`
- `zcash_client_backend::data_api::WalletWrite` has added method `truncate_to_chain_state`.
- `zcash_client_backend::proposal::ProposalError::ChainDoubleSpend` now wraps an
`OutputRef` instead of `(PoolType, TxId, u32)`.
- The following `InputSource` trait methods now take an additional
`include_locked: bool` parameter that controls whether locked notes
are included in results:
- `get_spendable_note`
- `select_spendable_notes`
- `select_unspent_notes`
- `get_account_metadata`
- `get_unspent_transparent_output` (under `transparent-inputs`)
- `get_spendable_transparent_outputs` (under `transparent-inputs`)
- `WalletWrite::store_transactions_to_be_sent` implementations are now
required to unlock any locked outputs that are recorded as spent by
the stored transactions.
- `zcash_client_backend::data_api::WalletWrite` has added methods
`truncate_to_chain_state`, `lock_outputs` and `unlock_output`.
- `zcash_client_backend::data_api::WalletTest` has added method
`get_locked_outputs`.
- The following `zcash_client_backend::data_api::wallet::` proposal creation
functions now take a `lock_for_blocks: Option<u32>` parameter and require
`WalletWrite` instead of `WalletRead`:
- `propose_transfer`
- `propose_standard_transfer_to_address`
- `propose_send_max_transfer`
- `propose_shielding`
- The associated type `zcash_client_backend::fees::ChangeStrategy::MetaSource` is now
bounded on the newly added `MetaSource` type instead of
`zcash_client_backend::data_api::InputSource`.
Expand All @@ -95,6 +126,9 @@ workspace.
than or equal to the provided minimum value. This fixes an inconsistency
in the various tests related to notes having no economic value in
`zcash_client_sqlite`.
- `zcash_client_backend::data_api::Balance` has been modified to now make it
possible to represent locked value; this is value that is committed to be
spent by an in-flight proposal or PCZT.

### Removed
- `zcash_client_backend::data_api::testing::transparent::GapLimits` use
Expand Down
95 changes: 89 additions & 6 deletions zcash_client_backend/src/data_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,13 @@ use self::{
scanning::ScanRange,
};
use crate::{
data_api::wallet::{ConfirmationsPolicy, TargetHeight},
data_api::{
error::LockError,
wallet::{ConfirmationsPolicy, TargetHeight},
},
decrypt::DecryptedOutput,
proto::service::TreeState,
wallet::{Note, NoteId, ReceivedNote, Recipient, WalletTransparentOutput, WalletTx},
wallet::{Note, NoteId, OutputRef, ReceivedNote, Recipient, WalletTransparentOutput, WalletTx},
};

#[cfg(feature = "transparent-inputs")]
Expand Down Expand Up @@ -187,6 +190,7 @@ pub enum MaxSpendMode {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Balance {
spendable_value: Zatoshis,
locked_value: Zatoshis,
change_pending_confirmation: Zatoshis,
value_pending_spendability: Zatoshis,
uneconomic_value: Zatoshis,
Expand All @@ -196,13 +200,15 @@ impl Balance {
/// The [`Balance`] value having zero values for all its fields.
pub const ZERO: Self = Self {
spendable_value: Zatoshis::ZERO,
locked_value: Zatoshis::ZERO,
change_pending_confirmation: Zatoshis::ZERO,
value_pending_spendability: Zatoshis::ZERO,
uneconomic_value: Zatoshis::ZERO,
};

fn check_total_adding(&self, value: Zatoshis) -> Result<Zatoshis, BalanceError> {
(self.spendable_value
+ self.locked_value
+ self.change_pending_confirmation
+ self.value_pending_spendability
+ value)
Expand All @@ -216,13 +222,28 @@ impl Balance {
self.spendable_value
}

/// Returns the value in the account that is currently "locked".
///
/// The outputs that comprise this balance are seen by the wallet as being committed to be
/// spent by a transaction proposal or PCZT.
pub fn locked_value(&self) -> Zatoshis {
self.locked_value
}

/// Adds the specified value to the spendable total, checking for overflow.
pub fn add_spendable_value(&mut self, value: Zatoshis) -> Result<(), BalanceError> {
self.check_total_adding(value)?;
self.spendable_value = (self.spendable_value + value).unwrap();
Ok(())
}

/// Adds the specified value to the locked total, checking for overflow.
pub fn add_locked_value(&mut self, value: Zatoshis) -> Result<(), BalanceError> {
self.check_total_adding(value)?;
self.locked_value = (self.locked_value + value).unwrap();
Ok(())
}

/// Returns the value in the account of shielded change notes that do not yet have sufficient
/// confirmations to be spendable.
pub fn change_pending_confirmation(&self) -> Zatoshis {
Expand Down Expand Up @@ -264,7 +285,10 @@ impl Balance {

/// Returns the total value of funds represented by this [`Balance`].
pub fn total(&self) -> Zatoshis {
(self.spendable_value + self.change_pending_confirmation + self.value_pending_spendability)
(self.spendable_value
+ self.locked_value
+ self.change_pending_confirmation
+ self.value_pending_spendability)
.expect("Balance cannot overflow MAX_MONEY")
}
}
Expand All @@ -276,6 +300,7 @@ impl core::ops::Add<Balance> for Balance {
let result = Balance {
spendable_value: (self.spendable_value + rhs.spendable_value)
.ok_or(BalanceError::Overflow)?,
locked_value: (self.locked_value + rhs.locked_value).ok_or(BalanceError::Overflow)?,
change_pending_confirmation: (self.change_pending_confirmation
+ rhs.change_pending_confirmation)
.ok_or(BalanceError::Overflow)?,
Expand Down Expand Up @@ -398,6 +423,15 @@ impl AccountBalance {
.expect("Account balance cannot overflow MAX_MONEY")
}

/// Returns the total value of notes and UTXOs that are locked, having been committed to
/// an in-flight transaction proposal or PCZT.
pub fn locked_value(&self) -> Zatoshis {
(self.sapling_balance.locked_value()
+ self.orchard_balance.locked_value()
+ self.unshielded_balance.locked_value())
.expect("Account balance cannot overflow MAX_MONEY")
}

/// Returns the total value of change and/or shielding transaction outputs that are awaiting
/// sufficient confirmations for spendability.
pub fn change_pending_confirmation(&self) -> Zatoshis {
Expand Down Expand Up @@ -1470,18 +1504,21 @@ pub trait InputSource {
/// specified shielded protocol.
///
/// Returns `Ok(None)` if the note is not known to belong to the wallet or if the note
/// is not spendable as of the given height.
/// is not spendable as of the given height. When `include_locked` is `false`, locked
/// notes are also excluded.
fn get_spendable_note(
&self,
txid: &TxId,
protocol: ShieldedProtocol,
index: u32,
target_height: TargetHeight,
include_locked: bool,
) -> Result<Option<ReceivedNote<Self::NoteRef, Note>>, Self::Error>;

/// Returns a list of spendable notes sufficient to cover the specified target value, if
/// possible. Only spendable notes corresponding to the specified shielded protocol will
/// be included.
/// be included. When `include_locked` is `false`, locked notes are excluded.
#[allow(clippy::too_many_arguments)]
fn select_spendable_notes(
&self,
account: Self::AccountId,
Expand All @@ -1490,16 +1527,18 @@ pub trait InputSource {
target_height: TargetHeight,
confirmations_policy: ConfirmationsPolicy,
exclude: &[Self::NoteRef],
include_locked: bool,
) -> Result<ReceivedNotes<Self::NoteRef>, Self::Error>;

/// Returns the list of notes belonging to the wallet that are unspent as of the specified
/// target height.
/// target height. When `include_locked` is `false`, locked notes are excluded.
fn select_unspent_notes(
&self,
account: Self::AccountId,
sources: &[ShieldedProtocol],
target_height: TargetHeight,
exclude: &[Self::NoteRef],
include_locked: bool,
) -> Result<ReceivedNotes<Self::NoteRef>, Self::Error>;

/// Returns metadata describing the structure of the wallet for the specified account.
Expand All @@ -1508,6 +1547,7 @@ pub trait InputSource {
/// - notes that are not considered spendable as of the given `target_height`
/// - unspent notes excluded by the provided selector;
/// - unspent notes identified in the given `exclude` list.
/// - when `include_locked` is `false`, locked notes.
///
/// Implementations of this method may limit the complexity of supported queries. Such
/// limitations should be clearly documented for the implementing type.
Expand All @@ -1517,18 +1557,21 @@ pub trait InputSource {
selector: &NoteFilter,
target_height: TargetHeight,
exclude: &[Self::NoteRef],
include_locked: bool,
) -> Result<AccountMeta, Self::Error>;

/// Fetches the transparent output corresponding to the provided `outpoint` if it is considered
/// spendable as of the provided `target_height`.
///
/// Returns `Ok(None)` if the UTXO is not known to belong to the wallet or would not be
/// spendable in a transaction mined in the block at the target height.
/// When `include_locked` is `false`, locked outputs are also excluded.
#[cfg(feature = "transparent-inputs")]
fn get_unspent_transparent_output(
&self,
_outpoint: &OutPoint,
_target_height: TargetHeight,
_include_locked: bool,
) -> Result<Option<WalletUtxo>, Self::Error> {
unimplemented!(
"InputSource::get_spendable_transparent_output must be overridden for wallets to use the `transparent-inputs` feature"
Expand All @@ -1544,12 +1587,14 @@ pub trait InputSource {
///
/// Any output that is potentially spent by an unmined transaction in the mempool should be
/// excluded unless the spending transaction will be expired at `target_height`.
/// When `include_locked` is `false`, locked outputs are also excluded.
#[cfg(feature = "transparent-inputs")]
fn get_spendable_transparent_outputs(
&self,
_address: &TransparentAddress,
_target_height: TargetHeight,
_confirmations_policy: ConfirmationsPolicy,
_include_locked: bool,
) -> Result<Vec<WalletUtxo>, Self::Error> {
unimplemented!(
"InputSource::get_spendable_transparent_outputs must be overridden for wallets to use the `transparent-inputs` feature"
Expand Down Expand Up @@ -1918,6 +1963,15 @@ pub trait WalletRead {
#[cfg(any(test, feature = "test-dependencies"))]
#[cfg_attr(feature = "test-dependencies", delegatable_trait)]
pub trait WalletTest: InputSource + WalletRead {
/// Returns the set of currently locked outputs for the given account.
///
/// Locked outputs are excluded from note selection, and are tallied separately in balance
/// computations.
fn get_locked_outputs(
&self,
account: <Self as WalletRead>::AccountId,
) -> Result<Vec<OutputRef>, <Self as WalletRead>::Error>;

/// Returns a vector of transaction summaries.
///
/// Currently test-only, as production use could return a very large number of results; either
Expand Down Expand Up @@ -3073,13 +3127,42 @@ pub trait WalletWrite: WalletRead {
/// [`ConfirmationsPolicy::trusted`] confirmations even if the output is not wallet-internal.
fn set_tx_trust(&mut self, txid: TxId, trusted: bool) -> Result<(), Self::Error>;

/// Locks the specified outputs, preventing it from being selected for spending at any height
/// less than or equal to the given height.
///
/// Returns the number of outputs locked by the operation on success, or a [`LockError`] on
/// failure, wrapping either an error from the underlying storage backend or the first output
/// that could not be locked.
///
/// Implementations of this method must either succeed completely, successfully locking each
/// provided output on success, or fail completely leaving all lock state unmodified if any of
/// the outputs were already locked. Existing locks that have expired as of the chain tip
/// should be replaced with new locks.
fn lock_outputs(
&mut self,
outputs: impl Iterator<Item = OutputRef>,
lock_expiry_height: BlockHeight,
) -> Result<usize, LockError<Self::Error>>;

/// Unlocks the specified output, making it once again available for spending and balance
/// computations.
///
/// Returns `true` if the output was found and unlocked, `false` if no matching
/// output exists.
fn unlock_output(&mut self, output: &OutputRef) -> Result<bool, Self::Error>;

/// Saves information about transactions constructed by the wallet to the persistent
/// wallet store.
///
/// This must be called before the transactions are sent to the network.
///
/// Transactions that have been stored by this method should be retransmitted while it
/// is still possible that they could be mined.
///
/// Implementations must unlock any locked outputs that are recorded as spent by the
/// stored transactions. Once spend records exist, the outputs are protected from
/// double-selection by the spend tracking mechanism, so the explicit locks are no
/// longer needed.
fn store_transactions_to_be_sent(
&mut self,
transactions: &[SentTransaction<Self::AccountId>],
Expand Down
33 changes: 33 additions & 0 deletions zcash_client_backend/src/data_api/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use zcash_protocol::{
value::{BalanceError, Zatoshis},
};

use crate::wallet::OutputRef;
use crate::{
data_api::wallet::input_selection::InputSelectorError, fees::ChangeError,
proposal::ProposalError, wallet::NoteId,
Expand Down Expand Up @@ -443,3 +444,35 @@ impl<DE, TE, SE, FE, CE, N> From<pczt::roles::tx_extractor::Error>
Error::Pczt(PcztError::Extraction(e))
}
}

/// Errors that occur when attempting to lock an output.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LockError<S> {
/// Wrapper for storage errors.
Storage(S),
/// The wrapped output reference was not found, or the output it refers to was already locked.
LockFailure(OutputRef),
}

impl<S: Display> Display for LockError<S> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LockError::Storage(e) => write!(f, "Note locking failed: {e}"),
LockError::LockFailure(output) => {
write!(
f,
"Lock conflict or missing output for reference {output:?}"
)
}
}
}
}

impl<S: std::error::Error> std::error::Error for LockError<S> {
fn cause(&self) -> Option<&dyn std::error::Error> {
match self {
LockError::Storage(e) => Some(e),
LockError::LockFailure(_) => None,
}
}
}
Loading
Loading