zcash_client_sqlite: Move transparent-inputs wallet methods into the `wallet::transparent` module.

This commit is contained in:
Kris Nuttycombe 2024-06-12 11:22:14 -06:00
parent 26c6b82e9b
commit d92bf27bfc
3 changed files with 750 additions and 725 deletions

View File

@ -272,7 +272,7 @@ impl<C: Borrow<rusqlite::Connection>, P: consensus::Parameters> InputSource for
&self,
outpoint: &OutPoint,
) -> Result<Option<WalletTransparentOutput>, Self::Error> {
wallet::get_unspent_transparent_output(self.conn.borrow(), outpoint)
wallet::transparent::get_unspent_transparent_output(self.conn.borrow(), outpoint)
}
#[cfg(feature = "transparent-inputs")]
@ -282,7 +282,7 @@ impl<C: Borrow<rusqlite::Connection>, P: consensus::Parameters> InputSource for
max_height: BlockHeight,
exclude: &[OutPoint],
) -> Result<Vec<WalletTransparentOutput>, Self::Error> {
wallet::get_unspent_transparent_outputs(
wallet::transparent::get_unspent_transparent_outputs(
self.conn.borrow(),
&self.params,
address,
@ -516,7 +516,7 @@ impl<C: Borrow<rusqlite::Connection>, P: consensus::Parameters> WalletRead for W
&self,
account: AccountId,
) -> Result<HashMap<TransparentAddress, Option<TransparentAddressMetadata>>, Self::Error> {
wallet::get_transparent_receivers(self.conn.borrow(), &self.params, account)
wallet::transparent::get_transparent_receivers(self.conn.borrow(), &self.params, account)
}
#[cfg(feature = "transparent-inputs")]
@ -525,7 +525,12 @@ impl<C: Borrow<rusqlite::Connection>, P: consensus::Parameters> WalletRead for W
account: AccountId,
max_height: BlockHeight,
) -> Result<HashMap<TransparentAddress, NonNegativeAmount>, Self::Error> {
wallet::get_transparent_balances(self.conn.borrow(), &self.params, account, max_height)
wallet::transparent::get_transparent_address_balances(
self.conn.borrow(),
&self.params,
account,
max_height,
)
}
}
@ -1034,7 +1039,11 @@ impl<P: consensus::Parameters> WalletWrite for WalletDb<rusqlite::Connection, P>
_output: &WalletTransparentOutput,
) -> Result<Self::UtxoRef, Self::Error> {
#[cfg(feature = "transparent-inputs")]
return wallet::put_received_transparent_utxo(&self.conn, &self.params, _output);
return wallet::transparent::put_received_transparent_utxo(
&self.conn,
&self.params,
_output,
);
#[cfg(not(feature = "transparent-inputs"))]
panic!(
@ -1228,7 +1237,7 @@ impl<P: consensus::Parameters> WalletWrite for WalletDb<rusqlite::Connection, P>
.iter()
.flat_map(|b| b.vin.iter())
{
wallet::mark_transparent_utxo_spent(wdb.conn.0, tx_ref, &txin.prevout)?;
wallet::transparent::mark_transparent_utxo_spent(wdb.conn.0, tx_ref, &txin.prevout)?;
}
// If we have some transparent outputs:
@ -1337,7 +1346,11 @@ impl<P: consensus::Parameters> WalletWrite for WalletDb<rusqlite::Connection, P>
#[cfg(feature = "transparent-inputs")]
for utxo_outpoint in sent_tx.utxos_spent() {
wallet::mark_transparent_utxo_spent(wdb.conn.0, tx_ref, utxo_outpoint)?;
wallet::transparent::mark_transparent_utxo_spent(
wdb.conn.0,
tx_ref,
utxo_outpoint,
)?;
}
for output in sent_tx.outputs() {

View File

@ -120,22 +120,6 @@ use self::scanning::{parse_priority_code, priority_code, replace_queue_entries};
#[cfg(feature = "orchard")]
use {crate::ORCHARD_TABLES_PREFIX, zcash_client_backend::data_api::ORCHARD_SHARD_HEIGHT};
#[cfg(feature = "transparent-inputs")]
use {
crate::UtxoId,
rusqlite::Row,
std::collections::BTreeSet,
zcash_address::unified::{Encoding, Ivk, Uivk},
zcash_client_backend::wallet::{TransparentAddressMetadata, WalletTransparentOutput},
zcash_primitives::{
legacy::{
keys::{IncomingViewingKey, NonHardenedChildIndex},
Script, TransparentAddress,
},
transaction::components::{OutPoint, TxOut},
},
};
pub mod commitment_tree;
pub(crate) mod common;
mod db;
@ -605,116 +589,6 @@ pub(crate) fn insert_address<P: consensus::Parameters>(
Ok(())
}
#[cfg(feature = "transparent-inputs")]
pub(crate) fn get_transparent_receivers<P: consensus::Parameters>(
conn: &rusqlite::Connection,
params: &P,
account: AccountId,
) -> Result<HashMap<TransparentAddress, Option<TransparentAddressMetadata>>, SqliteClientError> {
let mut ret: HashMap<TransparentAddress, Option<TransparentAddressMetadata>> = HashMap::new();
// Get all UAs derived
let mut ua_query = conn.prepare(
"SELECT address, diversifier_index_be FROM addresses WHERE account_id = :account",
)?;
let mut rows = ua_query.query(named_params![":account": account.0])?;
while let Some(row) = rows.next()? {
let ua_str: String = row.get(0)?;
let di_vec: Vec<u8> = row.get(1)?;
let mut di: [u8; 11] = di_vec.try_into().map_err(|_| {
SqliteClientError::CorruptedData("Diversifier index is not an 11-byte value".to_owned())
})?;
di.reverse(); // BE -> LE conversion
let ua = Address::decode(params, &ua_str)
.ok_or_else(|| {
SqliteClientError::CorruptedData("Not a valid Zcash recipient address".to_owned())
})
.and_then(|addr| match addr {
Address::Unified(ua) => Ok(ua),
_ => Err(SqliteClientError::CorruptedData(format!(
"Addresses table contains {} which is not a unified address",
ua_str,
))),
})?;
if let Some(taddr) = ua.transparent() {
let index = NonHardenedChildIndex::from_index(
DiversifierIndex::from(di).try_into().map_err(|_| {
SqliteClientError::CorruptedData(
"Unable to get diversifier for transparent address.".to_string(),
)
})?,
)
.ok_or_else(|| {
SqliteClientError::CorruptedData(
"Unexpected hardened index for transparent address.".to_string(),
)
})?;
ret.insert(
*taddr,
Some(TransparentAddressMetadata::new(
Scope::External.into(),
index,
)),
);
}
}
if let Some((taddr, address_index)) = get_legacy_transparent_address(params, conn, account)? {
ret.insert(
taddr,
Some(TransparentAddressMetadata::new(
Scope::External.into(),
address_index,
)),
);
}
Ok(ret)
}
#[cfg(feature = "transparent-inputs")]
pub(crate) fn get_legacy_transparent_address<P: consensus::Parameters>(
params: &P,
conn: &rusqlite::Connection,
account_id: AccountId,
) -> Result<Option<(TransparentAddress, NonHardenedChildIndex)>, SqliteClientError> {
use zcash_address::unified::Container;
use zcash_primitives::legacy::keys::ExternalIvk;
// Get the UIVK for the account.
let uivk_str: Option<String> = conn
.query_row(
"SELECT uivk FROM accounts WHERE id = :account",
[account_id.0],
|row| row.get(0),
)
.optional()?;
if let Some(uivk_str) = uivk_str {
let (network, uivk) = Uivk::decode(&uivk_str)
.map_err(|e| SqliteClientError::CorruptedData(format!("Unable to parse UIVK: {e}")))?;
if params.network_type() != network {
return Err(SqliteClientError::CorruptedData(
"Network type mismatch".to_owned(),
));
}
// Derive the default transparent address (if it wasn't already part of a derived UA).
for item in uivk.items() {
if let Ivk::P2pkh(tivk_bytes) = item {
let tivk = ExternalIvk::deserialize(&tivk_bytes)?;
return Ok(Some(tivk.default_address()));
}
}
}
Ok(None)
}
/// Returns the [`UnifiedFullViewingKey`]s for the wallet.
pub(crate) fn get_unified_full_viewing_keys<P: consensus::Parameters>(
conn: &rusqlite::Connection,
@ -1293,45 +1167,12 @@ pub(crate) fn get_wallet_summary<P: consensus::Parameters>(
drop(sapling_trace);
#[cfg(feature = "transparent-inputs")]
{
let transparent_trace = tracing::info_span!("stmt_transparent_balances").entered();
let zero_conf_height = (chain_tip_height + 1).saturating_sub(min_confirmations);
let stable_height = chain_tip_height.saturating_sub(PRUNING_DEPTH);
let mut stmt_transparent_balances = tx.prepare(
"SELECT u.received_by_account_id, SUM(u.value_zat)
FROM utxos u
WHERE u.height <= :max_height
-- and the received txo is unspent
AND u.id NOT IN (
SELECT transparent_received_output_id
FROM transparent_received_output_spends txo_spends
JOIN transactions tx
ON tx.id_tx = txo_spends.transaction_id
WHERE tx.block IS NOT NULL -- the spending tx is mined
OR tx.expiry_height IS NULL -- the spending tx will not expire
OR tx.expiry_height > :stable_height -- the spending tx is unexpired
)
GROUP BY u.received_by_account_id",
)?;
let mut rows = stmt_transparent_balances.query(named_params![
":max_height": u32::from(zero_conf_height),
":stable_height": u32::from(stable_height)
])?;
while let Some(row) = rows.next()? {
let account = AccountId(row.get(0)?);
let raw_value = row.get(1)?;
let value = NonNegativeAmount::from_nonnegative_i64(raw_value).map_err(|_| {
SqliteClientError::CorruptedData(format!("Negative UTXO value {:?}", raw_value))
})?;
if let Some(balances) = account_balances.get_mut(&account) {
balances.add_unshielded_value(value)?;
}
}
drop(transparent_trace);
}
transparent::add_transparent_account_balances(
tx,
chain_tip_height,
min_confirmations,
&mut account_balances,
)?;
// The approach used here for Sapling and Orchard subtree indexing was a quick hack
// that has not yet been replaced. TODO: Make less hacky.
@ -2076,172 +1917,6 @@ pub(crate) fn truncate_to_height<P: consensus::Parameters>(
Ok(())
}
#[cfg(feature = "transparent-inputs")]
fn to_unspent_transparent_output(row: &Row) -> Result<WalletTransparentOutput, SqliteClientError> {
let txid: Vec<u8> = row.get("prevout_txid")?;
let mut txid_bytes = [0u8; 32];
txid_bytes.copy_from_slice(&txid);
let index: u32 = row.get("prevout_idx")?;
let script_pubkey = Script(row.get("script")?);
let raw_value: i64 = row.get("value_zat")?;
let value = NonNegativeAmount::from_nonnegative_i64(raw_value).map_err(|_| {
SqliteClientError::CorruptedData(format!("Invalid UTXO value: {}", raw_value))
})?;
let height: u32 = row.get("height")?;
let outpoint = OutPoint::new(txid_bytes, index);
WalletTransparentOutput::from_parts(
outpoint,
TxOut {
value,
script_pubkey,
},
BlockHeight::from(height),
)
.ok_or_else(|| {
SqliteClientError::CorruptedData(
"Txout script_pubkey value did not correspond to a P2PKH or P2SH address".to_string(),
)
})
}
#[cfg(feature = "transparent-inputs")]
pub(crate) fn get_unspent_transparent_output(
conn: &rusqlite::Connection,
outpoint: &OutPoint,
) -> Result<Option<WalletTransparentOutput>, SqliteClientError> {
let mut stmt_select_utxo = conn.prepare_cached(
"SELECT u.prevout_txid, u.prevout_idx, u.script, u.value_zat, u.height
FROM utxos u
WHERE u.prevout_txid = :txid
AND u.prevout_idx = :output_index
AND u.id NOT IN (
SELECT txo_spends.transparent_received_output_id
FROM transparent_received_output_spends txo_spends
JOIN transactions tx ON tx.id_tx = txo_spends.transaction_id
WHERE tx.block IS NOT NULL -- the spending tx is mined
OR tx.expiry_height IS NULL -- the spending tx will not expire
)",
)?;
let result: Result<Option<WalletTransparentOutput>, SqliteClientError> = stmt_select_utxo
.query_and_then(
named_params![
":txid": outpoint.hash(),
":output_index": outpoint.n()
],
to_unspent_transparent_output,
)?
.next()
.transpose();
result
}
/// Returns unspent transparent outputs that have been received by this wallet at the given
/// transparent address, such that the block that included the transaction was mined at a
/// height less than or equal to the provided `max_height`.
#[cfg(feature = "transparent-inputs")]
pub(crate) fn get_unspent_transparent_outputs<P: consensus::Parameters>(
conn: &rusqlite::Connection,
params: &P,
address: &TransparentAddress,
max_height: BlockHeight,
exclude: &[OutPoint],
) -> Result<Vec<WalletTransparentOutput>, SqliteClientError> {
let chain_tip_height = scan_queue_extrema(conn)?.map(|range| *range.end());
let stable_height = chain_tip_height
.unwrap_or(max_height)
.saturating_sub(PRUNING_DEPTH);
let mut stmt_utxos = conn.prepare(
"SELECT u.prevout_txid, u.prevout_idx, u.script,
u.value_zat, u.height
FROM utxos u
WHERE u.address = :address
AND u.height <= :max_height
AND u.id NOT IN (
SELECT txo_spends.transparent_received_output_id
FROM transparent_received_output_spends txo_spends
JOIN transactions tx ON tx.id_tx = txo_spends.transaction_id
WHERE
tx.block IS NOT NULL -- the spending tx is mined
OR tx.expiry_height IS NULL -- the spending tx will not expire
OR tx.expiry_height > :stable_height -- the spending tx is unexpired
)",
)?;
let addr_str = address.encode(params);
let mut utxos = Vec::<WalletTransparentOutput>::new();
let mut rows = stmt_utxos.query(named_params![
":address": addr_str,
":max_height": u32::from(max_height),
":stable_height": u32::from(stable_height),
])?;
let excluded: BTreeSet<OutPoint> = exclude.iter().cloned().collect();
while let Some(row) = rows.next()? {
let output = to_unspent_transparent_output(row)?;
if excluded.contains(output.outpoint()) {
continue;
}
utxos.push(output);
}
Ok(utxos)
}
/// Returns the unspent balance for each transparent address associated with the specified account,
/// such that the block that included the transaction was mined at a height less than or equal to
/// the provided `max_height`.
#[cfg(feature = "transparent-inputs")]
pub(crate) fn get_transparent_balances<P: consensus::Parameters>(
conn: &rusqlite::Connection,
params: &P,
account: AccountId,
max_height: BlockHeight,
) -> Result<HashMap<TransparentAddress, NonNegativeAmount>, SqliteClientError> {
let chain_tip_height = scan_queue_extrema(conn)?.map(|range| *range.end());
let stable_height = chain_tip_height
.unwrap_or(max_height)
.saturating_sub(PRUNING_DEPTH);
let mut stmt_blocks = conn.prepare(
"SELECT u.address, SUM(u.value_zat)
FROM utxos u
WHERE u.received_by_account_id = :account_id
AND u.height <= :max_height
AND u.id NOT IN (
SELECT txo_spends.transparent_received_output_id
FROM transparent_received_output_spends txo_spends
JOIN transactions tx ON tx.id_tx = txo_spends.transaction_id
WHERE
tx.block IS NOT NULL -- the spending tx is mined
OR tx.expiry_height IS NULL -- the spending tx will not expire
OR tx.expiry_height > :stable_height -- the spending tx is unexpired
)
GROUP BY u.address",
)?;
let mut res = HashMap::new();
let mut rows = stmt_blocks.query(named_params![
":account_id": account.0,
":max_height": u32::from(max_height),
":stable_height": u32::from(stable_height),
])?;
while let Some(row) = rows.next()? {
let taddr_str: String = row.get(0)?;
let taddr = TransparentAddress::decode(params, &taddr_str)?;
let value = NonNegativeAmount::from_nonnegative_i64(row.get(1)?)?;
res.insert(taddr, value);
}
Ok(res)
}
/// Returns a vector with the IDs of all accounts known to this wallet.
pub(crate) fn get_account_ids(
conn: &rusqlite::Connection,
@ -2443,119 +2118,6 @@ pub(crate) fn put_tx_data(
.map_err(SqliteClientError::from)
}
/// Marks the given UTXO as having been spent.
#[cfg(feature = "transparent-inputs")]
pub(crate) fn mark_transparent_utxo_spent(
conn: &rusqlite::Connection,
tx_ref: i64,
outpoint: &OutPoint,
) -> Result<(), SqliteClientError> {
let mut stmt_mark_transparent_utxo_spent = conn.prepare_cached(
"INSERT INTO transparent_received_output_spends (transparent_received_output_id, transaction_id)
SELECT txo.id, :spent_in_tx
FROM utxos txo
WHERE txo.prevout_txid = :prevout_txid
AND txo.prevout_idx = :prevout_idx
ON CONFLICT (transparent_received_output_id, transaction_id) DO NOTHING",
)?;
let sql_args = named_params![
":spent_in_tx": &tx_ref,
":prevout_txid": &outpoint.hash().to_vec(),
":prevout_idx": &outpoint.n(),
];
stmt_mark_transparent_utxo_spent.execute(sql_args)?;
Ok(())
}
/// Adds the given received UTXO to the datastore.
#[cfg(feature = "transparent-inputs")]
pub(crate) fn put_received_transparent_utxo<P: consensus::Parameters>(
conn: &rusqlite::Connection,
params: &P,
output: &WalletTransparentOutput,
) -> Result<UtxoId, SqliteClientError> {
let address_str = output.recipient_address().encode(params);
let account_id = conn
.query_row(
"SELECT account_id FROM addresses WHERE cached_transparent_receiver_address = :address",
named_params![":address": &address_str],
|row| Ok(AccountId(row.get(0)?)),
)
.optional()?;
if let Some(account) = account_id {
Ok(put_legacy_transparent_utxo(conn, params, output, account)?)
} else {
// If the UTXO is received at the legacy transparent address (at BIP 44 address
// index 0 within its particular account, which we specifically ensure is returned
// from `get_transparent_receivers`), there may be no entry in the addresses table
// that can be used to tie the address to a particular account. In this case, we
// look up the legacy address for each account in the wallet, and check whether it
// matches the address for the received UTXO; if so, insert/update it directly.
get_account_ids(conn)?
.into_iter()
.find_map(
|account| match get_legacy_transparent_address(params, conn, account) {
Ok(Some((legacy_taddr, _))) if &legacy_taddr == output.recipient_address() => {
Some(
put_legacy_transparent_utxo(conn, params, output, account)
.map_err(SqliteClientError::from),
)
}
Ok(_) => None,
Err(e) => Some(Err(e)),
},
)
// The UTXO was not for any of the legacy transparent addresses.
.unwrap_or_else(|| {
Err(SqliteClientError::AddressNotRecognized(
*output.recipient_address(),
))
})
}
}
#[cfg(feature = "transparent-inputs")]
pub(crate) fn put_legacy_transparent_utxo<P: consensus::Parameters>(
conn: &rusqlite::Connection,
params: &P,
output: &WalletTransparentOutput,
received_by_account: AccountId,
) -> Result<UtxoId, rusqlite::Error> {
#[cfg(feature = "transparent-inputs")]
let mut stmt_upsert_legacy_transparent_utxo = conn.prepare_cached(
"INSERT INTO utxos (
prevout_txid, prevout_idx,
received_by_account_id, address, script,
value_zat, height)
VALUES
(:prevout_txid, :prevout_idx,
:received_by_account_id, :address, :script,
:value_zat, :height)
ON CONFLICT (prevout_txid, prevout_idx) DO UPDATE
SET received_by_account_id = :received_by_account_id,
height = :height,
address = :address,
script = :script,
value_zat = :value_zat
RETURNING id",
)?;
let sql_args = named_params![
":prevout_txid": &output.outpoint().hash().to_vec(),
":prevout_idx": &output.outpoint().n(),
":received_by_account_id": received_by_account.0,
":address": &output.recipient_address().encode(params),
":script": &output.txout().script_pubkey.0,
":value_zat": &i64::from(Amount::from(output.txout().value)),
":height": &u32::from(output.height()),
];
stmt_upsert_legacy_transparent_utxo.query_row(sql_args, |row| row.get::<_, i64>(0).map(UtxoId))
}
// A utility function for creation of parameters for use in `insert_sent_output`
// and `put_sent_output`
fn recipient_params(
@ -2841,24 +2403,6 @@ mod tests {
use super::account_birthday;
#[cfg(feature = "transparent-inputs")]
use {
crate::PRUNING_DEPTH,
zcash_client_backend::{
data_api::{wallet::input_selection::GreedyInputSelector, InputSource, WalletWrite},
encoding::AddressCodec,
fees::{fixed, DustOutputPolicy},
wallet::WalletTransparentOutput,
},
zcash_primitives::{
consensus::BlockHeight,
transaction::{
components::{OutPoint, TxOut},
fees::fixed::FeeRule as FixedFeeRule,
},
},
};
#[test]
fn empty_database_has_no_balance() {
let st = TestBuilder::new()
@ -2891,106 +2435,6 @@ mod tests {
);
}
#[test]
#[cfg(feature = "transparent-inputs")]
fn put_received_transparent_utxo() {
use crate::testing::TestBuilder;
let mut st = TestBuilder::new()
.with_account_from_sapling_activation(BlockHash([0; 32]))
.build();
let account_id = st.test_account().unwrap().account_id();
let uaddr = st
.wallet()
.get_current_address(account_id)
.unwrap()
.unwrap();
let taddr = uaddr.transparent().unwrap();
let height_1 = BlockHeight::from_u32(12345);
let bal_absent = st
.wallet()
.get_transparent_balances(account_id, height_1)
.unwrap();
assert!(bal_absent.is_empty());
// Create a fake transparent output.
let value = NonNegativeAmount::const_from_u64(100000);
let outpoint = OutPoint::fake();
let txout = TxOut {
value,
script_pubkey: taddr.script(),
};
// Pretend the output's transaction was mined at `height_1`.
let utxo =
WalletTransparentOutput::from_parts(outpoint.clone(), txout.clone(), height_1).unwrap();
let res0 = st.wallet_mut().put_received_transparent_utxo(&utxo);
assert_matches!(res0, Ok(_));
// Confirm that we see the output unspent as of `height_1`.
assert_matches!(
st.wallet().get_unspent_transparent_outputs(
taddr,
height_1,
&[]
).as_deref(),
Ok([ret]) if (ret.outpoint(), ret.txout(), ret.height()) == (utxo.outpoint(), utxo.txout(), height_1)
);
assert_matches!(
st.wallet().get_unspent_transparent_output(utxo.outpoint()),
Ok(Some(ret)) if (ret.outpoint(), ret.txout(), ret.height()) == (utxo.outpoint(), utxo.txout(), height_1)
);
// Change the mined height of the UTXO and upsert; we should get back
// the same `UtxoId`.
let height_2 = BlockHeight::from_u32(34567);
let utxo2 = WalletTransparentOutput::from_parts(outpoint, txout, height_2).unwrap();
let res1 = st.wallet_mut().put_received_transparent_utxo(&utxo2);
assert_matches!(res1, Ok(id) if id == res0.unwrap());
// Confirm that we no longer see any unspent outputs as of `height_1`.
assert_matches!(
st.wallet()
.get_unspent_transparent_outputs(taddr, height_1, &[])
.as_deref(),
Ok(&[])
);
// We can still look up the specific output, and it has the expected height.
assert_matches!(
st.wallet().get_unspent_transparent_output(utxo2.outpoint()),
Ok(Some(ret)) if (ret.outpoint(), ret.txout(), ret.height()) == (utxo2.outpoint(), utxo2.txout(), height_2)
);
// If we include `height_2` then the output is returned.
assert_matches!(
st.wallet()
.get_unspent_transparent_outputs(taddr, height_2, &[])
.as_deref(),
Ok([ret]) if (ret.outpoint(), ret.txout(), ret.height()) == (utxo.outpoint(), utxo.txout(), height_2)
);
assert_matches!(
st.wallet().get_transparent_balances(account_id, height_2),
Ok(h) if h.get(taddr) == Some(&value)
);
// Artificially delete the address from the addresses table so that
// we can ensure the update fails if the join doesn't work.
st.wallet()
.conn
.execute(
"DELETE FROM addresses WHERE cached_transparent_receiver_address = ?",
[Some(taddr.encode(&st.wallet().params))],
)
.unwrap();
let res2 = st.wallet_mut().put_received_transparent_utxo(&utxo2);
assert_matches!(res2, Err(_));
}
#[test]
fn get_default_account_index() {
use crate::testing::TestBuilder;
@ -3027,159 +2471,6 @@ mod tests {
}
}
#[test]
#[cfg(feature = "transparent-inputs")]
fn transparent_balance_across_shielding() {
use zcash_client_backend::ShieldedProtocol;
let mut st = TestBuilder::new()
.with_block_cache()
.with_account_from_sapling_activation(BlockHash([0; 32]))
.build();
let account = st.test_account().cloned().unwrap();
let uaddr = st
.wallet()
.get_current_address(account.account_id())
.unwrap()
.unwrap();
let taddr = uaddr.transparent().unwrap();
// Initialize the wallet with chain data that has no shielded notes for us.
let not_our_key = ExtendedSpendingKey::master(&[]).to_diversifiable_full_viewing_key();
let not_our_value = NonNegativeAmount::const_from_u64(10000);
let (start_height, _, _) =
st.generate_next_block(&not_our_key, AddressType::DefaultExternal, not_our_value);
for _ in 1..10 {
st.generate_next_block(&not_our_key, AddressType::DefaultExternal, not_our_value);
}
st.scan_cached_blocks(start_height, 10);
let check_balance = |st: &TestState<_>, min_confirmations: u32, expected| {
// Check the wallet summary returns the expected transparent balance.
let summary = st
.wallet()
.get_wallet_summary(min_confirmations)
.unwrap()
.unwrap();
let balance = summary
.account_balances()
.get(&account.account_id())
.unwrap();
assert_eq!(balance.unshielded(), expected);
// Check the older APIs for consistency.
let max_height = st.wallet().chain_height().unwrap().unwrap() + 1 - min_confirmations;
assert_eq!(
st.wallet()
.get_transparent_balances(account.account_id(), max_height)
.unwrap()
.get(taddr)
.cloned()
.unwrap_or(NonNegativeAmount::ZERO),
expected,
);
assert_eq!(
st.wallet()
.get_unspent_transparent_outputs(taddr, max_height, &[])
.unwrap()
.into_iter()
.map(|utxo| utxo.value())
.sum::<Option<NonNegativeAmount>>(),
Some(expected),
);
};
// The wallet starts out with zero balance.
check_balance(&st, 0, NonNegativeAmount::ZERO);
check_balance(&st, 1, NonNegativeAmount::ZERO);
// Create a fake transparent output.
let value = NonNegativeAmount::from_u64(100000).unwrap();
let txout = TxOut {
value,
script_pubkey: taddr.script(),
};
// Pretend the output was received in the chain tip.
let height = st.wallet().chain_height().unwrap().unwrap();
let utxo = WalletTransparentOutput::from_parts(OutPoint::fake(), txout, height).unwrap();
st.wallet_mut()
.put_received_transparent_utxo(&utxo)
.unwrap();
// The wallet should detect the balance as having 1 confirmation.
check_balance(&st, 0, value);
check_balance(&st, 1, value);
check_balance(&st, 2, NonNegativeAmount::ZERO);
// Shield the output.
let input_selector = GreedyInputSelector::new(
fixed::SingleOutputChangeStrategy::new(
FixedFeeRule::non_standard(NonNegativeAmount::ZERO),
None,
ShieldedProtocol::Sapling,
),
DustOutputPolicy::default(),
);
let txid = st
.shield_transparent_funds(&input_selector, value, account.usk(), &[*taddr], 1)
.unwrap()[0];
// The wallet should have zero transparent balance, because the shielding
// transaction can be mined.
check_balance(&st, 0, NonNegativeAmount::ZERO);
check_balance(&st, 1, NonNegativeAmount::ZERO);
check_balance(&st, 2, NonNegativeAmount::ZERO);
// Mine the shielding transaction.
let (mined_height, _) = st.generate_next_block_including(txid);
st.scan_cached_blocks(mined_height, 1);
// The wallet should still have zero transparent balance.
check_balance(&st, 0, NonNegativeAmount::ZERO);
check_balance(&st, 1, NonNegativeAmount::ZERO);
check_balance(&st, 2, NonNegativeAmount::ZERO);
// Unmine the shielding transaction via a reorg.
st.wallet_mut()
.truncate_to_height(mined_height - 1)
.unwrap();
assert_eq!(st.wallet().chain_height().unwrap(), Some(mined_height - 1));
// The wallet should still have zero transparent balance.
check_balance(&st, 0, NonNegativeAmount::ZERO);
check_balance(&st, 1, NonNegativeAmount::ZERO);
check_balance(&st, 2, NonNegativeAmount::ZERO);
// Expire the shielding transaction.
let expiry_height = st
.wallet()
.get_transaction(txid)
.unwrap()
.expect("Transaction exists in the wallet.")
.expiry_height();
st.wallet_mut().update_chain_tip(expiry_height).unwrap();
// TODO: Making the transparent output spendable in this situation requires
// changes to the transparent data model, so for now the wallet should still have
// zero transparent balance. https://github.com/zcash/librustzcash/issues/986
check_balance(&st, 0, NonNegativeAmount::ZERO);
check_balance(&st, 1, NonNegativeAmount::ZERO);
check_balance(&st, 2, NonNegativeAmount::ZERO);
// Roll forward the chain tip until the transaction's expiry height is in the
// stable block range (so a reorg won't make it spendable again).
st.wallet_mut()
.update_chain_tip(expiry_height + PRUNING_DEPTH)
.unwrap();
// The transparent output should be spendable again, with more confirmations.
check_balance(&st, 0, value);
check_balance(&st, 1, value);
check_balance(&st, 2, value);
}
#[test]
fn block_fully_scanned() {
let mut st = TestBuilder::new()

View File

@ -1,10 +1,29 @@
//! Functions for transparent input support in the wallet.
use rusqlite::OptionalExtension;
use rusqlite::{named_params, Connection, Row};
use std::collections::BTreeSet;
use std::collections::HashMap;
use std::collections::HashSet;
use zcash_client_backend::data_api::AccountBalance;
use zcash_keys::address::Address;
use zip32::{DiversifierIndex, Scope};
use rusqlite::{named_params, Connection};
use zcash_primitives::transaction::components::OutPoint;
use zcash_address::unified::{Encoding, Ivk, Uivk};
use zcash_client_backend::wallet::{TransparentAddressMetadata, WalletTransparentOutput};
use zcash_keys::encoding::AddressCodec;
use zcash_primitives::{
legacy::{
keys::{IncomingViewingKey, NonHardenedChildIndex},
Script, TransparentAddress,
},
transaction::components::{amount::NonNegativeAmount, Amount, OutPoint, TxOut},
};
use zcash_protocol::consensus::{self, BlockHeight};
use crate::AccountId;
use crate::{error::SqliteClientError, AccountId, UtxoId, PRUNING_DEPTH};
use super::get_account_ids;
use super::scan_queue_extrema;
pub(crate) fn detect_spending_accounts<'a>(
conn: &Connection,
@ -32,3 +51,705 @@ pub(crate) fn detect_spending_accounts<'a>(
Ok(acc)
}
pub(crate) fn get_transparent_receivers<P: consensus::Parameters>(
conn: &rusqlite::Connection,
params: &P,
account: AccountId,
) -> Result<HashMap<TransparentAddress, Option<TransparentAddressMetadata>>, SqliteClientError> {
let mut ret: HashMap<TransparentAddress, Option<TransparentAddressMetadata>> = HashMap::new();
// Get all UAs derived
let mut ua_query = conn.prepare(
"SELECT address, diversifier_index_be FROM addresses WHERE account_id = :account",
)?;
let mut rows = ua_query.query(named_params![":account": account.0])?;
while let Some(row) = rows.next()? {
let ua_str: String = row.get(0)?;
let di_vec: Vec<u8> = row.get(1)?;
let mut di: [u8; 11] = di_vec.try_into().map_err(|_| {
SqliteClientError::CorruptedData("Diversifier index is not an 11-byte value".to_owned())
})?;
di.reverse(); // BE -> LE conversion
let ua = Address::decode(params, &ua_str)
.ok_or_else(|| {
SqliteClientError::CorruptedData("Not a valid Zcash recipient address".to_owned())
})
.and_then(|addr| match addr {
Address::Unified(ua) => Ok(ua),
_ => Err(SqliteClientError::CorruptedData(format!(
"Addresses table contains {} which is not a unified address",
ua_str,
))),
})?;
if let Some(taddr) = ua.transparent() {
let index = NonHardenedChildIndex::from_index(
DiversifierIndex::from(di).try_into().map_err(|_| {
SqliteClientError::CorruptedData(
"Unable to get diversifier for transparent address.".to_string(),
)
})?,
)
.ok_or_else(|| {
SqliteClientError::CorruptedData(
"Unexpected hardened index for transparent address.".to_string(),
)
})?;
ret.insert(
*taddr,
Some(TransparentAddressMetadata::new(
Scope::External.into(),
index,
)),
);
}
}
if let Some((taddr, address_index)) = get_legacy_transparent_address(params, conn, account)? {
ret.insert(
taddr,
Some(TransparentAddressMetadata::new(
Scope::External.into(),
address_index,
)),
);
}
Ok(ret)
}
pub(crate) fn get_legacy_transparent_address<P: consensus::Parameters>(
params: &P,
conn: &rusqlite::Connection,
account_id: AccountId,
) -> Result<Option<(TransparentAddress, NonHardenedChildIndex)>, SqliteClientError> {
use zcash_address::unified::Container;
use zcash_primitives::legacy::keys::ExternalIvk;
// Get the UIVK for the account.
let uivk_str: Option<String> = conn
.query_row(
"SELECT uivk FROM accounts WHERE id = :account",
[account_id.0],
|row| row.get(0),
)
.optional()?;
if let Some(uivk_str) = uivk_str {
let (network, uivk) = Uivk::decode(&uivk_str)
.map_err(|e| SqliteClientError::CorruptedData(format!("Unable to parse UIVK: {e}")))?;
if params.network_type() != network {
return Err(SqliteClientError::CorruptedData(
"Network type mismatch".to_owned(),
));
}
// Derive the default transparent address (if it wasn't already part of a derived UA).
for item in uivk.items() {
if let Ivk::P2pkh(tivk_bytes) = item {
let tivk = ExternalIvk::deserialize(&tivk_bytes)?;
return Ok(Some(tivk.default_address()));
}
}
}
Ok(None)
}
fn to_unspent_transparent_output(row: &Row) -> Result<WalletTransparentOutput, SqliteClientError> {
let txid: Vec<u8> = row.get("prevout_txid")?;
let mut txid_bytes = [0u8; 32];
txid_bytes.copy_from_slice(&txid);
let index: u32 = row.get("prevout_idx")?;
let script_pubkey = Script(row.get("script")?);
let raw_value: i64 = row.get("value_zat")?;
let value = NonNegativeAmount::from_nonnegative_i64(raw_value).map_err(|_| {
SqliteClientError::CorruptedData(format!("Invalid UTXO value: {}", raw_value))
})?;
let height: u32 = row.get("height")?;
let outpoint = OutPoint::new(txid_bytes, index);
WalletTransparentOutput::from_parts(
outpoint,
TxOut {
value,
script_pubkey,
},
BlockHeight::from(height),
)
.ok_or_else(|| {
SqliteClientError::CorruptedData(
"Txout script_pubkey value did not correspond to a P2PKH or P2SH address".to_string(),
)
})
}
pub(crate) fn get_unspent_transparent_output(
conn: &rusqlite::Connection,
outpoint: &OutPoint,
) -> Result<Option<WalletTransparentOutput>, SqliteClientError> {
let mut stmt_select_utxo = conn.prepare_cached(
"SELECT u.prevout_txid, u.prevout_idx, u.script, u.value_zat, u.height
FROM utxos u
WHERE u.prevout_txid = :txid
AND u.prevout_idx = :output_index
AND u.id NOT IN (
SELECT txo_spends.transparent_received_output_id
FROM transparent_received_output_spends txo_spends
JOIN transactions tx ON tx.id_tx = txo_spends.transaction_id
WHERE tx.block IS NOT NULL -- the spending tx is mined
OR tx.expiry_height IS NULL -- the spending tx will not expire
)",
)?;
let result: Result<Option<WalletTransparentOutput>, SqliteClientError> = stmt_select_utxo
.query_and_then(
named_params![
":txid": outpoint.hash(),
":output_index": outpoint.n()
],
to_unspent_transparent_output,
)?
.next()
.transpose();
result
}
/// Returns unspent transparent outputs that have been received by this wallet at the given
/// transparent address, such that the block that included the transaction was mined at a
/// height less than or equal to the provided `max_height`.
pub(crate) fn get_unspent_transparent_outputs<P: consensus::Parameters>(
conn: &rusqlite::Connection,
params: &P,
address: &TransparentAddress,
max_height: BlockHeight,
exclude: &[OutPoint],
) -> Result<Vec<WalletTransparentOutput>, SqliteClientError> {
let chain_tip_height = scan_queue_extrema(conn)?.map(|range| *range.end());
let stable_height = chain_tip_height
.unwrap_or(max_height)
.saturating_sub(PRUNING_DEPTH);
let mut stmt_utxos = conn.prepare(
"SELECT u.prevout_txid, u.prevout_idx, u.script,
u.value_zat, u.height
FROM utxos u
WHERE u.address = :address
AND u.height <= :max_height
AND u.id NOT IN (
SELECT txo_spends.transparent_received_output_id
FROM transparent_received_output_spends txo_spends
JOIN transactions tx ON tx.id_tx = txo_spends.transaction_id
WHERE
tx.block IS NOT NULL -- the spending tx is mined
OR tx.expiry_height IS NULL -- the spending tx will not expire
OR tx.expiry_height > :stable_height -- the spending tx is unexpired
)",
)?;
let addr_str = address.encode(params);
let mut utxos = Vec::<WalletTransparentOutput>::new();
let mut rows = stmt_utxos.query(named_params![
":address": addr_str,
":max_height": u32::from(max_height),
":stable_height": u32::from(stable_height),
])?;
let excluded: BTreeSet<OutPoint> = exclude.iter().cloned().collect();
while let Some(row) = rows.next()? {
let output = to_unspent_transparent_output(row)?;
if excluded.contains(output.outpoint()) {
continue;
}
utxos.push(output);
}
Ok(utxos)
}
/// Returns the unspent balance for each transparent address associated with the specified account,
/// such that the block that included the transaction was mined at a height less than or equal to
/// the provided `max_height`.
pub(crate) fn get_transparent_address_balances<P: consensus::Parameters>(
conn: &rusqlite::Connection,
params: &P,
account: AccountId,
max_height: BlockHeight,
) -> Result<HashMap<TransparentAddress, NonNegativeAmount>, SqliteClientError> {
let chain_tip_height = scan_queue_extrema(conn)?.map(|range| *range.end());
let stable_height = chain_tip_height
.unwrap_or(max_height)
.saturating_sub(PRUNING_DEPTH);
let mut stmt_address_balances = conn.prepare(
"SELECT u.address, SUM(u.value_zat)
FROM utxos u
WHERE u.received_by_account_id = :account_id
AND u.height <= :max_height
AND u.id NOT IN (
SELECT txo_spends.transparent_received_output_id
FROM transparent_received_output_spends txo_spends
JOIN transactions tx ON tx.id_tx = txo_spends.transaction_id
WHERE
tx.block IS NOT NULL -- the spending tx is mined
OR tx.expiry_height IS NULL -- the spending tx will not expire
OR tx.expiry_height > :stable_height -- the spending tx is unexpired
)
GROUP BY u.address",
)?;
let mut res = HashMap::new();
let mut rows = stmt_address_balances.query(named_params![
":account_id": account.0,
":max_height": u32::from(max_height),
":stable_height": u32::from(stable_height),
])?;
while let Some(row) = rows.next()? {
let taddr_str: String = row.get(0)?;
let taddr = TransparentAddress::decode(params, &taddr_str)?;
let value = NonNegativeAmount::from_nonnegative_i64(row.get(1)?)?;
res.insert(taddr, value);
}
Ok(res)
}
pub(crate) fn add_transparent_account_balances(
conn: &rusqlite::Connection,
chain_tip_height: BlockHeight,
min_confirmations: u32,
account_balances: &mut HashMap<AccountId, AccountBalance>,
) -> Result<(), SqliteClientError> {
let transparent_trace = tracing::info_span!("stmt_transparent_balances").entered();
let zero_conf_height = (chain_tip_height + 1).saturating_sub(min_confirmations);
let stable_height = chain_tip_height.saturating_sub(PRUNING_DEPTH);
let mut stmt_transparent_balances = conn.prepare(
"SELECT u.received_by_account_id, SUM(u.value_zat)
FROM utxos u
WHERE u.height <= :max_height
-- and the received txo is unspent
AND u.id NOT IN (
SELECT transparent_received_output_id
FROM transparent_received_output_spends txo_spends
JOIN transactions tx
ON tx.id_tx = txo_spends.transaction_id
WHERE tx.block IS NOT NULL -- the spending tx is mined
OR tx.expiry_height IS NULL -- the spending tx will not expire
OR tx.expiry_height > :stable_height -- the spending tx is unexpired
)
GROUP BY u.received_by_account_id",
)?;
let mut rows = stmt_transparent_balances.query(named_params![
":max_height": u32::from(zero_conf_height),
":stable_height": u32::from(stable_height)
])?;
while let Some(row) = rows.next()? {
let account = AccountId(row.get(0)?);
let raw_value = row.get(1)?;
let value = NonNegativeAmount::from_nonnegative_i64(raw_value).map_err(|_| {
SqliteClientError::CorruptedData(format!("Negative UTXO value {:?}", raw_value))
})?;
if let Some(balances) = account_balances.get_mut(&account) {
balances.add_unshielded_value(value)?;
}
}
drop(transparent_trace);
Ok(())
}
/// Marks the given UTXO as having been spent.
pub(crate) fn mark_transparent_utxo_spent(
conn: &rusqlite::Connection,
tx_ref: i64,
outpoint: &OutPoint,
) -> Result<(), SqliteClientError> {
let mut stmt_mark_transparent_utxo_spent = conn.prepare_cached(
"INSERT INTO transparent_received_output_spends (transparent_received_output_id, transaction_id)
SELECT txo.id, :spent_in_tx
FROM utxos txo
WHERE txo.prevout_txid = :prevout_txid
AND txo.prevout_idx = :prevout_idx
ON CONFLICT (transparent_received_output_id, transaction_id) DO NOTHING",
)?;
let sql_args = named_params![
":spent_in_tx": &tx_ref,
":prevout_txid": &outpoint.hash().to_vec(),
":prevout_idx": &outpoint.n(),
];
stmt_mark_transparent_utxo_spent.execute(sql_args)?;
Ok(())
}
/// Adds the given received UTXO to the datastore.
pub(crate) fn put_received_transparent_utxo<P: consensus::Parameters>(
conn: &rusqlite::Connection,
params: &P,
output: &WalletTransparentOutput,
) -> Result<UtxoId, SqliteClientError> {
let address_str = output.recipient_address().encode(params);
let account_id = conn
.query_row(
"SELECT account_id FROM addresses WHERE cached_transparent_receiver_address = :address",
named_params![":address": &address_str],
|row| Ok(AccountId(row.get(0)?)),
)
.optional()?;
if let Some(account) = account_id {
Ok(put_legacy_transparent_utxo(conn, params, output, account)?)
} else {
// If the UTXO is received at the legacy transparent address (at BIP 44 address
// index 0 within its particular account, which we specifically ensure is returned
// from `get_transparent_receivers`), there may be no entry in the addresses table
// that can be used to tie the address to a particular account. In this case, we
// look up the legacy address for each account in the wallet, and check whether it
// matches the address for the received UTXO; if so, insert/update it directly.
get_account_ids(conn)?
.into_iter()
.find_map(
|account| match get_legacy_transparent_address(params, conn, account) {
Ok(Some((legacy_taddr, _))) if &legacy_taddr == output.recipient_address() => {
Some(
put_legacy_transparent_utxo(conn, params, output, account)
.map_err(SqliteClientError::from),
)
}
Ok(_) => None,
Err(e) => Some(Err(e)),
},
)
// The UTXO was not for any of the legacy transparent addresses.
.unwrap_or_else(|| {
Err(SqliteClientError::AddressNotRecognized(
*output.recipient_address(),
))
})
}
}
pub(crate) fn put_legacy_transparent_utxo<P: consensus::Parameters>(
conn: &rusqlite::Connection,
params: &P,
output: &WalletTransparentOutput,
received_by_account: AccountId,
) -> Result<UtxoId, rusqlite::Error> {
#[cfg(feature = "transparent-inputs")]
let mut stmt_upsert_legacy_transparent_utxo = conn.prepare_cached(
"INSERT INTO utxos (
prevout_txid, prevout_idx,
received_by_account_id, address, script,
value_zat, height)
VALUES
(:prevout_txid, :prevout_idx,
:received_by_account_id, :address, :script,
:value_zat, :height)
ON CONFLICT (prevout_txid, prevout_idx) DO UPDATE
SET received_by_account_id = :received_by_account_id,
height = :height,
address = :address,
script = :script,
value_zat = :value_zat
RETURNING id",
)?;
let sql_args = named_params![
":prevout_txid": &output.outpoint().hash().to_vec(),
":prevout_idx": &output.outpoint().n(),
":received_by_account_id": received_by_account.0,
":address": &output.recipient_address().encode(params),
":script": &output.txout().script_pubkey.0,
":value_zat": &i64::from(Amount::from(output.txout().value)),
":height": &u32::from(output.height()),
];
stmt_upsert_legacy_transparent_utxo.query_row(sql_args, |row| row.get::<_, i64>(0).map(UtxoId))
}
#[cfg(test)]
mod tests {
use crate::{
testing::{AddressType, TestBuilder, TestState},
PRUNING_DEPTH,
};
use sapling::zip32::ExtendedSpendingKey;
use zcash_client_backend::{
data_api::{
wallet::input_selection::GreedyInputSelector, InputSource, WalletRead, WalletWrite,
},
encoding::AddressCodec,
fees::{fixed, DustOutputPolicy},
wallet::WalletTransparentOutput,
};
use zcash_primitives::{
block::BlockHash,
consensus::BlockHeight,
transaction::{
components::{amount::NonNegativeAmount, OutPoint, TxOut},
fees::fixed::FeeRule as FixedFeeRule,
},
};
#[test]
fn put_received_transparent_utxo() {
use crate::testing::TestBuilder;
let mut st = TestBuilder::new()
.with_account_from_sapling_activation(BlockHash([0; 32]))
.build();
let account_id = st.test_account().unwrap().account_id();
let uaddr = st
.wallet()
.get_current_address(account_id)
.unwrap()
.unwrap();
let taddr = uaddr.transparent().unwrap();
let height_1 = BlockHeight::from_u32(12345);
let bal_absent = st
.wallet()
.get_transparent_balances(account_id, height_1)
.unwrap();
assert!(bal_absent.is_empty());
// Create a fake transparent output.
let value = NonNegativeAmount::const_from_u64(100000);
let outpoint = OutPoint::fake();
let txout = TxOut {
value,
script_pubkey: taddr.script(),
};
// Pretend the output's transaction was mined at `height_1`.
let utxo =
WalletTransparentOutput::from_parts(outpoint.clone(), txout.clone(), height_1).unwrap();
let res0 = st.wallet_mut().put_received_transparent_utxo(&utxo);
assert_matches!(res0, Ok(_));
// Confirm that we see the output unspent as of `height_1`.
assert_matches!(
st.wallet().get_unspent_transparent_outputs(
taddr,
height_1,
&[]
).as_deref(),
Ok([ret]) if (ret.outpoint(), ret.txout(), ret.height()) == (utxo.outpoint(), utxo.txout(), height_1)
);
assert_matches!(
st.wallet().get_unspent_transparent_output(utxo.outpoint()),
Ok(Some(ret)) if (ret.outpoint(), ret.txout(), ret.height()) == (utxo.outpoint(), utxo.txout(), height_1)
);
// Change the mined height of the UTXO and upsert; we should get back
// the same `UtxoId`.
let height_2 = BlockHeight::from_u32(34567);
let utxo2 = WalletTransparentOutput::from_parts(outpoint, txout, height_2).unwrap();
let res1 = st.wallet_mut().put_received_transparent_utxo(&utxo2);
assert_matches!(res1, Ok(id) if id == res0.unwrap());
// Confirm that we no longer see any unspent outputs as of `height_1`.
assert_matches!(
st.wallet()
.get_unspent_transparent_outputs(taddr, height_1, &[])
.as_deref(),
Ok(&[])
);
// We can still look up the specific output, and it has the expected height.
assert_matches!(
st.wallet().get_unspent_transparent_output(utxo2.outpoint()),
Ok(Some(ret)) if (ret.outpoint(), ret.txout(), ret.height()) == (utxo2.outpoint(), utxo2.txout(), height_2)
);
// If we include `height_2` then the output is returned.
assert_matches!(
st.wallet()
.get_unspent_transparent_outputs(taddr, height_2, &[])
.as_deref(),
Ok([ret]) if (ret.outpoint(), ret.txout(), ret.height()) == (utxo.outpoint(), utxo.txout(), height_2)
);
assert_matches!(
st.wallet().get_transparent_balances(account_id, height_2),
Ok(h) if h.get(taddr) == Some(&value)
);
// Artificially delete the address from the addresses table so that
// we can ensure the update fails if the join doesn't work.
st.wallet()
.conn
.execute(
"DELETE FROM addresses WHERE cached_transparent_receiver_address = ?",
[Some(taddr.encode(&st.wallet().params))],
)
.unwrap();
let res2 = st.wallet_mut().put_received_transparent_utxo(&utxo2);
assert_matches!(res2, Err(_));
}
#[test]
fn transparent_balance_across_shielding() {
use zcash_client_backend::ShieldedProtocol;
let mut st = TestBuilder::new()
.with_block_cache()
.with_account_from_sapling_activation(BlockHash([0; 32]))
.build();
let account = st.test_account().cloned().unwrap();
let uaddr = st
.wallet()
.get_current_address(account.account_id())
.unwrap()
.unwrap();
let taddr = uaddr.transparent().unwrap();
// Initialize the wallet with chain data that has no shielded notes for us.
let not_our_key = ExtendedSpendingKey::master(&[]).to_diversifiable_full_viewing_key();
let not_our_value = NonNegativeAmount::const_from_u64(10000);
let (start_height, _, _) =
st.generate_next_block(&not_our_key, AddressType::DefaultExternal, not_our_value);
for _ in 1..10 {
st.generate_next_block(&not_our_key, AddressType::DefaultExternal, not_our_value);
}
st.scan_cached_blocks(start_height, 10);
let check_balance = |st: &TestState<_>, min_confirmations: u32, expected| {
// Check the wallet summary returns the expected transparent balance.
let summary = st
.wallet()
.get_wallet_summary(min_confirmations)
.unwrap()
.unwrap();
let balance = summary
.account_balances()
.get(&account.account_id())
.unwrap();
assert_eq!(balance.unshielded(), expected);
// Check the older APIs for consistency.
let max_height = st.wallet().chain_height().unwrap().unwrap() + 1 - min_confirmations;
assert_eq!(
st.wallet()
.get_transparent_balances(account.account_id(), max_height)
.unwrap()
.get(taddr)
.cloned()
.unwrap_or(NonNegativeAmount::ZERO),
expected,
);
assert_eq!(
st.wallet()
.get_unspent_transparent_outputs(taddr, max_height, &[])
.unwrap()
.into_iter()
.map(|utxo| utxo.value())
.sum::<Option<NonNegativeAmount>>(),
Some(expected),
);
};
// The wallet starts out with zero balance.
check_balance(&st, 0, NonNegativeAmount::ZERO);
check_balance(&st, 1, NonNegativeAmount::ZERO);
// Create a fake transparent output.
let value = NonNegativeAmount::from_u64(100000).unwrap();
let txout = TxOut {
value,
script_pubkey: taddr.script(),
};
// Pretend the output was received in the chain tip.
let height = st.wallet().chain_height().unwrap().unwrap();
let utxo = WalletTransparentOutput::from_parts(OutPoint::fake(), txout, height).unwrap();
st.wallet_mut()
.put_received_transparent_utxo(&utxo)
.unwrap();
// The wallet should detect the balance as having 1 confirmation.
check_balance(&st, 0, value);
check_balance(&st, 1, value);
check_balance(&st, 2, NonNegativeAmount::ZERO);
// Shield the output.
let input_selector = GreedyInputSelector::new(
fixed::SingleOutputChangeStrategy::new(
FixedFeeRule::non_standard(NonNegativeAmount::ZERO),
None,
ShieldedProtocol::Sapling,
),
DustOutputPolicy::default(),
);
let txid = st
.shield_transparent_funds(&input_selector, value, account.usk(), &[*taddr], 1)
.unwrap()[0];
// The wallet should have zero transparent balance, because the shielding
// transaction can be mined.
check_balance(&st, 0, NonNegativeAmount::ZERO);
check_balance(&st, 1, NonNegativeAmount::ZERO);
check_balance(&st, 2, NonNegativeAmount::ZERO);
// Mine the shielding transaction.
let (mined_height, _) = st.generate_next_block_including(txid);
st.scan_cached_blocks(mined_height, 1);
// The wallet should still have zero transparent balance.
check_balance(&st, 0, NonNegativeAmount::ZERO);
check_balance(&st, 1, NonNegativeAmount::ZERO);
check_balance(&st, 2, NonNegativeAmount::ZERO);
// Unmine the shielding transaction via a reorg.
st.wallet_mut()
.truncate_to_height(mined_height - 1)
.unwrap();
assert_eq!(st.wallet().chain_height().unwrap(), Some(mined_height - 1));
// The wallet should still have zero transparent balance.
check_balance(&st, 0, NonNegativeAmount::ZERO);
check_balance(&st, 1, NonNegativeAmount::ZERO);
check_balance(&st, 2, NonNegativeAmount::ZERO);
// Expire the shielding transaction.
let expiry_height = st
.wallet()
.get_transaction(txid)
.unwrap()
.expect("Transaction exists in the wallet.")
.expiry_height();
st.wallet_mut().update_chain_tip(expiry_height).unwrap();
// TODO: Making the transparent output spendable in this situation requires
// changes to the transparent data model, so for now the wallet should still have
// zero transparent balance. https://github.com/zcash/librustzcash/issues/986
check_balance(&st, 0, NonNegativeAmount::ZERO);
check_balance(&st, 1, NonNegativeAmount::ZERO);
check_balance(&st, 2, NonNegativeAmount::ZERO);
// Roll forward the chain tip until the transaction's expiry height is in the
// stable block range (so a reorg won't make it spendable again).
st.wallet_mut()
.update_chain_tip(expiry_height + PRUNING_DEPTH)
.unwrap();
// The transparent output should be spendable again, with more confirmations.
check_balance(&st, 0, value);
check_balance(&st, 1, value);
check_balance(&st, 2, value);
}
}