zcash_client_sqlite: Bound truncation to checkpointed heights.

This commit is contained in:
Kris Nuttycombe 2024-09-24 20:48:40 -06:00
parent 5a599f15d2
commit 0b8058d732
7 changed files with 124 additions and 54 deletions

View File

@ -2038,20 +2038,27 @@ pub trait WalletWrite: WalletRead {
transactions: &[SentTransaction<Self::AccountId>], transactions: &[SentTransaction<Self::AccountId>],
) -> Result<(), Self::Error>; ) -> Result<(), Self::Error>;
/// Truncates the wallet database to the specified height. /// Truncates the wallet database to at most the specified height.
/// ///
/// This method assumes that the state of the underlying data store is /// Implementations of this method may choose a lower block height to which the data store will
/// consistent up to a particular block height. Since it is possible that /// be truncated if it is not possible to truncate exactly to the specified height. Upon
/// a chain reorg might invalidate some stored state, this method must be /// successful truncation, this method returns the height to which the data store was actually
/// implemented in order to allow users of this API to "reset" the data store /// truncated.
/// to correctly represent chainstate as of a specified block height.
/// ///
/// After calling this method, the block at the given height will be the /// This method assumes that the state of the underlying data store is consistent up to a
/// most recent block and all other operations will treat this block /// particular block height. Since it is possible that a chain reorg might invalidate some
/// as the chain tip for balance determination purposes. /// stored state, this method must be implemented in order to allow users of this API to
/// "reset" the data store to correctly represent chainstate as of at most the requested block
/// height.
/// ///
/// There may be restrictions on heights to which it is possible to truncate. /// After calling this method, the block at the returned height will be the most recent block
fn truncate_to_height(&mut self, block_height: BlockHeight) -> Result<(), Self::Error>; /// and all other operations will treat this block as the chain tip for balance determination
/// purposes.
///
/// There may be restrictions on heights to which it is possible to truncate. Specifically, it
/// will only be possible to truncate to heights at which is is possible to create a witness
/// given the current state of the wallet's note commitment tree.
fn truncate_to_height(&mut self, max_height: BlockHeight) -> Result<BlockHeight, Self::Error>;
/// Reserves the next `n` available ephemeral addresses for the given account. /// Reserves the next `n` available ephemeral addresses for the given account.
/// This cannot be undone, so as far as possible, errors associated with transaction /// This cannot be undone, so as far as possible, errors associated with transaction

View File

@ -2633,8 +2633,11 @@ impl WalletWrite for MockWalletDb {
Ok(()) Ok(())
} }
fn truncate_to_height(&mut self, _block_height: BlockHeight) -> Result<(), Self::Error> { fn truncate_to_height(
Ok(()) &mut self,
_block_height: BlockHeight,
) -> Result<BlockHeight, Self::Error> {
Err(())
} }
/// Adds a transparent UTXO received by the wallet to the data store. /// Adds a transparent UTXO received by the wallet to the data store.

View File

@ -7,12 +7,15 @@ and this library adheres to Rust's notion of
## [Unreleased] ## [Unreleased]
- `zcash_client_sqlite::error::SqliteClientError::RequestedRewindInvalid`
is now a structured variant.
## [0.11.2] - 2024-08-21 ## [0.11.2] - 2024-08-21
### Changed ### Changed
- The `v_tx_outputs` view was modified slightly to support older versions of - The `v_tx_outputs` view was modified slightly to support older versions of
`sqlite`. Queries to the exposed `v_tx_outputs` and `v_transactions` views `sqlite`. Queries to the exposed `v_tx_outputs` and `v_transactions` views
are supported for SQLite versions back to `3.19.x`. are supported for SQLite versions back to `3.19.x`.
- `zcash_client_sqlite::wallet::init::WalletMigrationError` has an additional - `zcash_client_sqlite::wallet::init::WalletMigrationError` has an additional
variant, `DatabaseNotSupported`. The `init_wallet_db` function now checks variant, `DatabaseNotSupported`. The `init_wallet_db` function now checks
that the sqlite version in use is compatible with the features required by that the sqlite version in use is compatible with the features required by

View File

@ -12,7 +12,6 @@ use zcash_primitives::{consensus::BlockHeight, transaction::components::amount::
use crate::wallet::commitment_tree; use crate::wallet::commitment_tree;
use crate::AccountId; use crate::AccountId;
use crate::PRUNING_DEPTH;
#[cfg(feature = "transparent-inputs")] #[cfg(feature = "transparent-inputs")]
use { use {
@ -64,8 +63,12 @@ pub enum SqliteClientError {
NonSequentialBlocks, NonSequentialBlocks,
/// A requested rewind would violate invariants of the storage layer. The payload returned with /// A requested rewind would violate invariants of the storage layer. The payload returned with
/// this error is (safe rewind height, requested height). /// this error is (safe rewind height, requested height). If no safe rewind height can be
RequestedRewindInvalid(BlockHeight, BlockHeight), /// determined, the safe rewind height member will be `None`.
RequestedRewindInvalid {
safe_rewind_height: Option<BlockHeight>,
requested_height: BlockHeight,
},
/// An error occurred in generating a Zcash address. /// An error occurred in generating a Zcash address.
AddressGeneration(AddressGenerationError), AddressGeneration(AddressGenerationError),
@ -152,8 +155,12 @@ impl fmt::Display for SqliteClientError {
} }
SqliteClientError::Protobuf(e) => write!(f, "Failed to parse protobuf-encoded record: {}", e), SqliteClientError::Protobuf(e) => write!(f, "Failed to parse protobuf-encoded record: {}", e),
SqliteClientError::InvalidNote => write!(f, "Invalid note"), SqliteClientError::InvalidNote => write!(f, "Invalid note"),
SqliteClientError::RequestedRewindInvalid(h, r) => SqliteClientError::RequestedRewindInvalid { safe_rewind_height, requested_height } => write!(
write!(f, "A rewind must be either of less than {} blocks, or at least back to block {} for your wallet; the requested height was {}.", PRUNING_DEPTH, h, r), f,
"A rewind for your wallet may only target height {} or greater; the requested height was {}.",
safe_rewind_height.map_or("<unavailable>".to_owned(), |h0| format!("{}", h0)),
requested_height
),
SqliteClientError::DecodingError(e) => write!(f, "{}", e), SqliteClientError::DecodingError(e) => write!(f, "{}", e),
#[cfg(feature = "transparent-inputs")] #[cfg(feature = "transparent-inputs")]
SqliteClientError::TransparentDerivation(e) => write!(f, "{:?}", e), SqliteClientError::TransparentDerivation(e) => write!(f, "{:?}", e),

View File

@ -1335,10 +1335,8 @@ impl<P: consensus::Parameters> WalletWrite for WalletDb<rusqlite::Connection, P>
}) })
} }
fn truncate_to_height(&mut self, block_height: BlockHeight) -> Result<(), Self::Error> { fn truncate_to_height(&mut self, max_height: BlockHeight) -> Result<BlockHeight, Self::Error> {
self.transactionally(|wdb| { self.transactionally(|wdb| wallet::truncate_to_height(wdb.conn.0, &wdb.params, max_height))
wallet::truncate_to_height(wdb.conn.0, &wdb.params, block_height)
})
} }
#[cfg(feature = "transparent-inputs")] #[cfg(feature = "transparent-inputs")]

View File

@ -2105,7 +2105,7 @@ pub(crate) fn get_max_height_hash(
} }
/// Gets the height to which the database must be truncated if any truncation that would remove a /// Gets the height to which the database must be truncated if any truncation that would remove a
/// number of blocks greater than the pruning height is attempted. /// number of blocks greater than the note commitment tree pruning depth is attempted.
pub(crate) fn get_min_unspent_height( pub(crate) fn get_min_unspent_height(
conn: &rusqlite::Connection, conn: &rusqlite::Connection,
) -> Result<Option<BlockHeight>, SqliteClientError> { ) -> Result<Option<BlockHeight>, SqliteClientError> {
@ -2357,34 +2357,86 @@ pub(crate) fn set_transaction_status(
Ok(()) Ok(())
} }
/// Truncates the database to the given height. /// Truncates the database to at most the given height.
/// ///
/// If the requested height is greater than or equal to the height of the last scanned /// If the requested height is greater than or equal to the height of the last scanned
/// block, this function does nothing. /// block, this function does nothing.
/// ///
/// This should only be executed inside a transactional context. /// This should only be executed inside a transactional context.
///
/// Returns the block height to which the database was truncated.
pub(crate) fn truncate_to_height<P: consensus::Parameters>( pub(crate) fn truncate_to_height<P: consensus::Parameters>(
conn: &rusqlite::Transaction, conn: &rusqlite::Transaction,
params: &P, params: &P,
block_height: BlockHeight, max_height: BlockHeight,
) -> Result<(), SqliteClientError> { ) -> Result<BlockHeight, SqliteClientError> {
let sapling_activation_height = params // Determine a checkpoint to which we can rewind, if any.
.activation_height(NetworkUpgrade::Sapling) #[cfg(not(feature = "orchard"))]
.expect("Sapling activation height must be available."); let truncation_height_query = r#"
SELECT MAX(height) FROM blocks
JOIN sapling_tree_checkpoints ON checkpoint_id = blocks.height
WHERE blocks.height <= :block_height
"#;
#[cfg(feature = "orchard")]
let truncation_height_query = r#"
SELECT MAX(height) FROM blocks
JOIN sapling_tree_checkpoints sc ON sc.checkpoint_id = blocks.height
JOIN orchard_tree_checkpoints oc ON oc.checkpoint_id = blocks.height
WHERE blocks.height <= :block_height
"#;
let truncation_height = conn
.query_row(
truncation_height_query,
named_params! {":block_height": u32::from(max_height)},
|row| row.get::<_, Option<u32>>(0),
)
.optional()?
.flatten()
.map_or_else(
|| {
// If we don't have a checkpoint at a height less than or equal to the requested
// truncation height, query for the minimum height to which it's possible for us to
// truncate so that we can report it to the caller.
#[cfg(not(feature = "orchard"))]
let min_checkpoint_height_query =
"SELECT MIN(checkpoint_id) FROM sapling_tree_checkpoints";
#[cfg(feature = "orchard")]
let min_checkpoint_height_query = "SELECT MIN(checkpoint_id)
FROM sapling_tree_checkpoints sc
JOIN orchard_tree_checkpoints oc
ON oc.checkpoint_id = sc.checkpoint_id";
let min_truncation_height = conn
.query_row(min_checkpoint_height_query, [], |row| {
row.get::<_, Option<u32>>(0)
})
.optional()?
.flatten()
.map(BlockHeight::from);
Err(SqliteClientError::RequestedRewindInvalid {
safe_rewind_height: min_truncation_height,
requested_height: max_height,
})
},
|h| Ok(BlockHeight::from(h)),
)?;
// Recall where we synced up to previously.
let last_scanned_height = conn.query_row("SELECT MAX(height) FROM blocks", [], |row| { let last_scanned_height = conn.query_row("SELECT MAX(height) FROM blocks", [], |row| {
row.get::<_, Option<u32>>(0) let h = row.get::<_, Option<u32>>(0)?;
.map(|opt| opt.map_or_else(|| sapling_activation_height - 1, BlockHeight::from))
})?;
if block_height < last_scanned_height - PRUNING_DEPTH { Ok(h.map_or_else(
if let Some(h) = get_min_unspent_height(conn)? { || {
if block_height > h { params
return Err(SqliteClientError::RequestedRewindInvalid(h, block_height)); .activation_height(NetworkUpgrade::Sapling)
} .expect("Sapling activation height must be available.")
} - 1
} },
BlockHeight::from,
))
})?;
// Delete from the scanning queue any range with a start height greater than the // Delete from the scanning queue any range with a start height greater than the
// truncation height, and then truncate any remaining range by setting the end // truncation height, and then truncate any remaining range by setting the end
@ -2393,13 +2445,13 @@ pub(crate) fn truncate_to_height<P: consensus::Parameters>(
conn.execute( conn.execute(
"DELETE FROM scan_queue "DELETE FROM scan_queue
WHERE block_range_start >= :new_end_height", WHERE block_range_start >= :new_end_height",
named_params![":new_end_height": u32::from(block_height + 1)], named_params![":new_end_height": u32::from(truncation_height + 1)],
)?; )?;
conn.execute( conn.execute(
"UPDATE scan_queue "UPDATE scan_queue
SET block_range_end = :new_end_height SET block_range_end = :new_end_height
WHERE block_range_end > :new_end_height", WHERE block_range_end > :new_end_height",
named_params![":new_end_height": u32::from(block_height + 1)], named_params![":new_end_height": u32::from(truncation_height + 1)],
)?; )?;
// Mark transparent utxos as un-mined. Since the TXO is now not mined, it would ideally be // Mark transparent utxos as un-mined. Since the TXO is now not mined, it would ideally be
@ -2413,7 +2465,7 @@ pub(crate) fn truncate_to_height<P: consensus::Parameters>(
FROM transactions tx FROM transactions tx
WHERE tx.id_tx = transaction_id WHERE tx.id_tx = transaction_id
AND max_observed_unspent_height > :height", AND max_observed_unspent_height > :height",
named_params![":height": u32::from(block_height)], named_params![":height": u32::from(truncation_height)],
)?; )?;
// Un-mine transactions. This must be done outside of the last_scanned_height check because // Un-mine transactions. This must be done outside of the last_scanned_height check because
@ -2422,32 +2474,32 @@ pub(crate) fn truncate_to_height<P: consensus::Parameters>(
"UPDATE transactions "UPDATE transactions
SET block = NULL, mined_height = NULL, tx_index = NULL SET block = NULL, mined_height = NULL, tx_index = NULL
WHERE mined_height > :height", WHERE mined_height > :height",
named_params![":height": u32::from(block_height)], named_params![":height": u32::from(truncation_height)],
)?; )?;
// If we're removing scanned blocks, we need to truncate the note commitment tree and remove // If we're removing scanned blocks, we need to truncate the note commitment tree and remove
// affected block records from the database. // affected block records from the database.
if block_height < last_scanned_height { if truncation_height < last_scanned_height {
// Truncate the note commitment trees // Truncate the note commitment trees
let mut wdb = WalletDb { let mut wdb = WalletDb {
conn: SqlTransaction(conn), conn: SqlTransaction(conn),
params: params.clone(), params: params.clone(),
}; };
wdb.with_sapling_tree_mut(|tree| { wdb.with_sapling_tree_mut(|tree| {
tree.truncate_removing_checkpoint(&block_height)?; tree.truncate_removing_checkpoint(&truncation_height)?;
// We do want a checkpoint preserved at the end of the block, but it should have no // We do want a checkpoint preserved at the end of the block, but it should have no
// other data associated with it. TODO: `truncate_removing_checkpoint` is an awkward // other data associated with it. TODO: `truncate_removing_checkpoint` is an awkward
// API to work with, and should be replaced with `truncate_to_checkpoint`. // API to work with, and should be replaced with `truncate_to_checkpoint`.
tree.checkpoint(block_height)?; tree.checkpoint(truncation_height)?;
Ok::<_, SqliteClientError>(()) Ok::<_, SqliteClientError>(())
})?; })?;
#[cfg(feature = "orchard")] #[cfg(feature = "orchard")]
wdb.with_orchard_tree_mut(|tree| { wdb.with_orchard_tree_mut(|tree| {
tree.truncate_removing_checkpoint(&block_height)?; tree.truncate_removing_checkpoint(&truncation_height)?;
// We do want a checkpoint preserved at the end of the block, but it should have no // We do want a checkpoint preserved at the end of the block, but it should have no
// other data associated with it. TODO: `truncate_removing_checkpoint` is an awkward // other data associated with it. TODO: `truncate_removing_checkpoint` is an awkward
// API to work with, and should be replaced with `truncate_to_checkpoint`. // API to work with, and should be replaced with `truncate_to_checkpoint`.
tree.checkpoint(block_height)?; tree.checkpoint(truncation_height)?;
Ok::<_, SqliteClientError>(()) Ok::<_, SqliteClientError>(())
})?; })?;
@ -2461,7 +2513,7 @@ pub(crate) fn truncate_to_height<P: consensus::Parameters>(
// Now that they aren't depended on, delete un-mined blocks. // Now that they aren't depended on, delete un-mined blocks.
conn.execute( conn.execute(
"DELETE FROM blocks WHERE height > ?", "DELETE FROM blocks WHERE height > ?",
[u32::from(block_height)], [u32::from(truncation_height)],
)?; )?;
// Delete from the nullifier map any entries with a locator referencing a block // Delete from the nullifier map any entries with a locator referencing a block
@ -2469,11 +2521,11 @@ pub(crate) fn truncate_to_height<P: consensus::Parameters>(
conn.execute( conn.execute(
"DELETE FROM tx_locator_map "DELETE FROM tx_locator_map
WHERE block_height > :block_height", WHERE block_height > :block_height",
named_params![":block_height": u32::from(block_height)], named_params![":block_height": u32::from(truncation_height)],
)?; )?;
} }
Ok(()) Ok(truncation_height)
} }
/// Returns a vector with the IDs of all accounts known to this wallet. /// Returns a vector with the IDs of all accounts known to this wallet.

View File

@ -197,7 +197,7 @@ fn sqlite_client_error_to_wallet_migration_error(e: SqliteClientError) -> Wallet
SqliteClientError::TableNotEmpty => unreachable!("wallet already initialized"), SqliteClientError::TableNotEmpty => unreachable!("wallet already initialized"),
SqliteClientError::BlockConflict(_) SqliteClientError::BlockConflict(_)
| SqliteClientError::NonSequentialBlocks | SqliteClientError::NonSequentialBlocks
| SqliteClientError::RequestedRewindInvalid(_, _) | SqliteClientError::RequestedRewindInvalid { .. }
| SqliteClientError::KeyDerivationError(_) | SqliteClientError::KeyDerivationError(_)
| SqliteClientError::AccountIdDiscontinuity | SqliteClientError::AccountIdDiscontinuity
| SqliteClientError::AccountIdOutOfRange | SqliteClientError::AccountIdOutOfRange