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>],
) -> 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
/// consistent up to a particular block height. Since it is possible that
/// a chain reorg might invalidate some 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 a specified block height.
/// Implementations of this method may choose a lower block height to which the data store will
/// be truncated if it is not possible to truncate exactly to the specified height. Upon
/// successful truncation, this method returns the height to which the data store was actually
/// truncated.
///
/// After calling this method, the block at the given height will be the
/// most recent block and all other operations will treat this block
/// as the chain tip for balance determination purposes.
/// This method assumes that the state of the underlying data store is consistent up to a
/// particular block height. Since it is possible that a chain reorg might invalidate some
/// 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.
fn truncate_to_height(&mut self, block_height: BlockHeight) -> Result<(), Self::Error>;
/// After calling this method, the block at the returned height will be the most recent block
/// 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.
/// This cannot be undone, so as far as possible, errors associated with transaction

View File

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

View File

@ -7,6 +7,9 @@ and this library adheres to Rust's notion of
## [Unreleased]
- `zcash_client_sqlite::error::SqliteClientError::RequestedRewindInvalid`
is now a structured variant.
## [0.11.2] - 2024-08-21
### Changed

View File

@ -12,7 +12,6 @@ use zcash_primitives::{consensus::BlockHeight, transaction::components::amount::
use crate::wallet::commitment_tree;
use crate::AccountId;
use crate::PRUNING_DEPTH;
#[cfg(feature = "transparent-inputs")]
use {
@ -64,8 +63,12 @@ pub enum SqliteClientError {
NonSequentialBlocks,
/// A requested rewind would violate invariants of the storage layer. The payload returned with
/// this error is (safe rewind height, requested height).
RequestedRewindInvalid(BlockHeight, BlockHeight),
/// this error is (safe rewind height, requested height). If no safe rewind height can be
/// 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.
AddressGeneration(AddressGenerationError),
@ -152,8 +155,12 @@ impl fmt::Display for SqliteClientError {
}
SqliteClientError::Protobuf(e) => write!(f, "Failed to parse protobuf-encoded record: {}", e),
SqliteClientError::InvalidNote => write!(f, "Invalid note"),
SqliteClientError::RequestedRewindInvalid(h, r) =>
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),
SqliteClientError::RequestedRewindInvalid { safe_rewind_height, requested_height } => write!(
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),
#[cfg(feature = "transparent-inputs")]
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> {
self.transactionally(|wdb| {
wallet::truncate_to_height(wdb.conn.0, &wdb.params, block_height)
})
fn truncate_to_height(&mut self, max_height: BlockHeight) -> Result<BlockHeight, Self::Error> {
self.transactionally(|wdb| wallet::truncate_to_height(wdb.conn.0, &wdb.params, max_height))
}
#[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
/// 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(
conn: &rusqlite::Connection,
) -> Result<Option<BlockHeight>, SqliteClientError> {
@ -2357,34 +2357,86 @@ pub(crate) fn set_transaction_status(
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
/// block, this function does nothing.
///
/// 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>(
conn: &rusqlite::Transaction,
params: &P,
block_height: BlockHeight,
) -> Result<(), SqliteClientError> {
let sapling_activation_height = params
.activation_height(NetworkUpgrade::Sapling)
.expect("Sapling activation height must be available.");
max_height: BlockHeight,
) -> Result<BlockHeight, SqliteClientError> {
// Determine a checkpoint to which we can rewind, if any.
#[cfg(not(feature = "orchard"))]
let truncation_height_query = r#"
SELECT MAX(height) FROM blocks
JOIN sapling_tree_checkpoints ON checkpoint_id = blocks.height
WHERE blocks.height <= :block_height
"#;
// Recall where we synced up to previously.
let last_scanned_height = conn.query_row("SELECT MAX(height) FROM blocks", [], |row| {
#[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)
.map(|opt| opt.map_or_else(|| sapling_activation_height - 1, BlockHeight::from))
})?;
})
.optional()?
.flatten()
.map(BlockHeight::from);
if block_height < last_scanned_height - PRUNING_DEPTH {
if let Some(h) = get_min_unspent_height(conn)? {
if block_height > h {
return Err(SqliteClientError::RequestedRewindInvalid(h, block_height));
}
}
}
Err(SqliteClientError::RequestedRewindInvalid {
safe_rewind_height: min_truncation_height,
requested_height: max_height,
})
},
|h| Ok(BlockHeight::from(h)),
)?;
let last_scanned_height = conn.query_row("SELECT MAX(height) FROM blocks", [], |row| {
let h = row.get::<_, Option<u32>>(0)?;
Ok(h.map_or_else(
|| {
params
.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
// 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(
"DELETE FROM scan_queue
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(
"UPDATE scan_queue
SET 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
@ -2413,7 +2465,7 @@ pub(crate) fn truncate_to_height<P: consensus::Parameters>(
FROM transactions tx
WHERE tx.id_tx = transaction_id
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
@ -2422,32 +2474,32 @@ pub(crate) fn truncate_to_height<P: consensus::Parameters>(
"UPDATE transactions
SET block = NULL, mined_height = NULL, tx_index = NULL
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
// affected block records from the database.
if block_height < last_scanned_height {
if truncation_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)?;
tree.truncate_removing_checkpoint(&truncation_height)?;
// 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
// API to work with, and should be replaced with `truncate_to_checkpoint`.
tree.checkpoint(block_height)?;
tree.checkpoint(truncation_height)?;
Ok::<_, SqliteClientError>(())
})?;
#[cfg(feature = "orchard")]
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
// other data associated with it. TODO: `truncate_removing_checkpoint` is an awkward
// API to work with, and should be replaced with `truncate_to_checkpoint`.
tree.checkpoint(block_height)?;
tree.checkpoint(truncation_height)?;
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.
conn.execute(
"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
@ -2469,11 +2521,11 @@ pub(crate) fn truncate_to_height<P: consensus::Parameters>(
conn.execute(
"DELETE FROM tx_locator_map
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.

View File

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