Chain validity and reorg handling

This commit is contained in:
Jack Grigg 2019-05-02 11:27:43 +01:00
parent f0ce0c5530
commit 380c2f726f
3 changed files with 479 additions and 0 deletions

View File

@ -0,0 +1,474 @@
//! Functions for enforcing chain validity and handling chain reorgs.
//!
//! # Examples
//!
//! ```
//! use zcash_client_sqlite::{
//! chain::{rewind_to_height, validate_combined_chain},
//! error::ErrorKind,
//! scan::scan_cached_blocks,
//! };
//!
//! let db_cache = "/path/to/cache.db";
//! let db_data = "/path/to/data.db";
//!
//! // 1) Download new CompactBlocks into db_cache.
//!
//! // 2) Run the chain validator on the received blocks.
//! //
//! // Given that we assume the server always gives us correct-at-the-time blocks, any
//! // errors are in the blocks we have previously cached or scanned.
//! if let Err(e) = validate_combined_chain(&db_cache, &db_data) {
//! match e.kind() {
//! ErrorKind::InvalidChain(upper_bound, _) => {
//! // a) Pick a height to rewind to.
//! //
//! // This might be informed by some external chain reorg information, or
//! // heuristics such as the platform, available bandwidth, size of recent
//! // CompactBlocks, etc.
//! let rewind_height = upper_bound - 10;
//!
//! // b) Rewind scanned block information.
//! rewind_to_height(&db_data, rewind_height);
//!
//! // c) Delete cached blocks from rewind_height onwards.
//! //
//! // This does imply that assumed-valid blocks will be re-downloaded, but it
//! // is also possible that in the intervening time, a chain reorg has
//! // occurred that orphaned some of those blocks.
//!
//! // d) If there is some separate thread or service downloading
//! // CompactBlocks, tell it to go back and download from rewind_height
//! // onwards.
//! }
//! _ => {
//! // Handle other errors.
//! }
//! }
//! }
//!
//! // 3) Scan (any remaining) cached blocks.
//! //
//! // At this point, the cache and scanned data are locally consistent (though not
//! // necessarily consistent with the latest chain tip - this would be discovered the
//! // next time this codepath is executed after new blocks are received).
//! scan_cached_blocks(&db_cache, &db_data);
//! ```
use protobuf::parse_from_bytes;
use rusqlite::{Connection, NO_PARAMS};
use std::path::Path;
use zcash_client_backend::proto::compact_formats::CompactBlock;
use crate::{
error::{Error, ErrorKind},
SAPLING_ACTIVATION_HEIGHT,
};
#[derive(Debug)]
pub enum ChainInvalidCause {
PrevHashMismatch,
/// (expected_height, actual_height)
HeightMismatch(i32, i32),
}
struct CompactBlockRow {
height: i32,
data: Vec<u8>,
}
/// Checks that the scanned blocks in the data database, when combined with the recent
/// `CompactBlock`s in the cache database, form a valid chain.
///
/// This function is built on the core assumption that the information provided in the
/// cache database is more likely to be accurate than the previously-scanned information.
/// This follows from the design (and trust) assumption that the `lightwalletd` server
/// provides accurate block information as of the time it was requested.
///
/// Returns:
/// - `Ok(())` if the combined chain is valid.
/// - `Err(ErrorKind::InvalidChain(upper_bound, cause))` if the combined chain is invalid.
/// `upper_bound` is the height of the highest invalid block (on the assumption that the
/// highest block in the cache database is correct).
/// - `Err(e)` if there was an error during validation unrelated to chain validity.
///
/// This function does not mutate either of the databases.
pub fn validate_combined_chain<P: AsRef<Path>, Q: AsRef<Path>>(
db_cache: P,
db_data: Q,
) -> Result<(), Error> {
let cache = Connection::open(db_cache)?;
let data = Connection::open(db_data)?;
// Recall where we synced up to previously.
// If we have never synced, use Sapling activation height to select all cached CompactBlocks.
let (have_scanned, last_scanned_height) =
data.query_row("SELECT MAX(height) FROM blocks", NO_PARAMS, |row| {
row.get(0)
.map(|h| (true, h))
.or(Ok((false, SAPLING_ACTIVATION_HEIGHT - 1)))
})?;
// Fetch the CompactBlocks we need to validate
let mut stmt_blocks = cache
.prepare("SELECT height, data FROM compactblocks WHERE height > ? ORDER BY height DESC")?;
let mut rows = stmt_blocks.query_map(&[last_scanned_height], |row| {
Ok(CompactBlockRow {
height: row.get(0)?,
data: row.get(1)?,
})
})?;
// Take the highest cached block as accurate.
let (mut last_height, mut last_prev_hash) = {
let assumed_correct = match rows.next() {
Some(row) => row?,
None => {
// No cached blocks, and we've already validated the blocks we've scanned,
// so there's nothing to validate.
// TODO: Maybe we still want to check if there are cached blocks that are
// at heights we previously scanned? Check scanning flow again.
return Ok(());
}
};
let block: CompactBlock = parse_from_bytes(&assumed_correct.data)?;
(block.height as i32, block.prev_hash())
};
for row in rows {
let row = row?;
// Scanned blocks MUST be height-sequential.
if row.height != (last_height - 1) {
return Err(Error(ErrorKind::InvalidChain(
last_height - 1,
ChainInvalidCause::HeightMismatch(last_height - 1, row.height),
)));
}
last_height = row.height;
let block: CompactBlock = parse_from_bytes(&row.data)?;
// Cached blocks MUST be hash-chained.
if block.hash() != last_prev_hash {
return Err(Error(ErrorKind::InvalidChain(
last_height,
ChainInvalidCause::PrevHashMismatch,
)));
}
last_prev_hash = block.prev_hash();
}
if have_scanned {
// Cached blocks MUST hash-chain to the last scanned block.
let last_scanned_hash = data.query_row(
"SELECT hash FROM blocks WHERE height = ?",
&[last_scanned_height],
|row| row.get::<_, Vec<_>>(0),
)?;
if &last_scanned_hash[..] != &last_prev_hash.0[..] {
return Err(Error(ErrorKind::InvalidChain(
last_scanned_height,
ChainInvalidCause::PrevHashMismatch,
)));
}
}
// All good!
Ok(())
}
/// Rewinds the data database to the given height.
///
/// If the requested height is greater than or equal to the height of the last scanned
/// block, this function does nothing.
pub fn rewind_to_height<P: AsRef<Path>>(db_data: P, height: i32) -> Result<(), Error> {
let data = Connection::open(db_data)?;
// Recall where we synced up to previously.
// If we have never synced, use Sapling activation height.
let last_scanned_height =
data.query_row("SELECT MAX(height) FROM blocks", NO_PARAMS, |row| {
row.get(0).or(Ok(SAPLING_ACTIVATION_HEIGHT - 1))
})?;
if height >= last_scanned_height {
// Nothing to do.
return Ok(());
}
// Start an SQL transaction for rewinding.
data.execute("BEGIN IMMEDIATE", NO_PARAMS)?;
// Decrement witnesses.
data.execute("DELETE FROM sapling_witnesses WHERE block > ?", &[height])?;
// Un-mine transactions.
data.execute(
"UPDATE transactions SET block = NULL, tx_index = NULL WHERE block > ?",
&[height],
)?;
// Now that they aren't depended on, delete scanned blocks.
data.execute("DELETE FROM blocks WHERE height > ?", &[height])?;
// Commit the SQL transaction, rewinding atomically.
data.execute("COMMIT", NO_PARAMS)?;
Ok(())
}
#[cfg(test)]
mod tests {
use tempfile::NamedTempFile;
use zcash_primitives::{
block::BlockHash,
transaction::components::Amount,
zip32::{ExtendedFullViewingKey, ExtendedSpendingKey},
};
use super::{rewind_to_height, validate_combined_chain};
use crate::{
error::ErrorKind,
init::{init_accounts_table, init_cache_database, init_data_database},
query::get_balance,
scan::scan_cached_blocks,
tests::{fake_compact_block, insert_into_cache},
SAPLING_ACTIVATION_HEIGHT,
};
#[test]
fn valid_chain_states() {
let cache_file = NamedTempFile::new().unwrap();
let db_cache = cache_file.path();
init_cache_database(&db_cache).unwrap();
let data_file = NamedTempFile::new().unwrap();
let db_data = data_file.path();
init_data_database(&db_data).unwrap();
// Add an account to the wallet
let extsk = ExtendedSpendingKey::master(&[]);
let extfvk = ExtendedFullViewingKey::from(&extsk);
init_accounts_table(&db_data, &[extfvk.clone()]).unwrap();
// Empty chain should be valid
validate_combined_chain(db_cache, db_data).unwrap();
// Create a fake CompactBlock sending value to the address
let (cb, _) = fake_compact_block(
SAPLING_ACTIVATION_HEIGHT,
BlockHash([0; 32]),
extfvk.clone(),
Amount::from_u64(5).unwrap(),
);
insert_into_cache(db_cache, &cb);
// Cache-only chain should be valid
validate_combined_chain(db_cache, db_data).unwrap();
// Scan the cache
scan_cached_blocks(db_cache, db_data).unwrap();
// Data-only chain should be valid
validate_combined_chain(db_cache, db_data).unwrap();
// Create a second fake CompactBlock sending more value to the address
let (cb2, _) = fake_compact_block(
SAPLING_ACTIVATION_HEIGHT + 1,
cb.hash(),
extfvk,
Amount::from_u64(7).unwrap(),
);
insert_into_cache(db_cache, &cb2);
// Data+cache chain should be valid
validate_combined_chain(db_cache, db_data).unwrap();
// Scan the cache again
scan_cached_blocks(db_cache, db_data).unwrap();
// Data-only chain should be valid
validate_combined_chain(db_cache, db_data).unwrap();
}
#[test]
fn invalid_chain_cache_disconnected() {
let cache_file = NamedTempFile::new().unwrap();
let db_cache = cache_file.path();
init_cache_database(&db_cache).unwrap();
let data_file = NamedTempFile::new().unwrap();
let db_data = data_file.path();
init_data_database(&db_data).unwrap();
// Add an account to the wallet
let extsk = ExtendedSpendingKey::master(&[]);
let extfvk = ExtendedFullViewingKey::from(&extsk);
init_accounts_table(&db_data, &[extfvk.clone()]).unwrap();
// Create some fake CompactBlocks
let (cb, _) = fake_compact_block(
SAPLING_ACTIVATION_HEIGHT,
BlockHash([0; 32]),
extfvk.clone(),
Amount::from_u64(5).unwrap(),
);
let (cb2, _) = fake_compact_block(
SAPLING_ACTIVATION_HEIGHT + 1,
cb.hash(),
extfvk.clone(),
Amount::from_u64(7).unwrap(),
);
insert_into_cache(db_cache, &cb);
insert_into_cache(db_cache, &cb2);
// Scan the cache
scan_cached_blocks(db_cache, db_data).unwrap();
// Data-only chain should be valid
validate_combined_chain(db_cache, db_data).unwrap();
// Create more fake CompactBlocks that don't connect to the scanned ones
let (cb3, _) = fake_compact_block(
SAPLING_ACTIVATION_HEIGHT + 2,
BlockHash([1; 32]),
extfvk.clone(),
Amount::from_u64(8).unwrap(),
);
let (cb4, _) = fake_compact_block(
SAPLING_ACTIVATION_HEIGHT + 3,
cb3.hash(),
extfvk.clone(),
Amount::from_u64(3).unwrap(),
);
insert_into_cache(db_cache, &cb3);
insert_into_cache(db_cache, &cb4);
// Data+cache chain should be invalid at the data/cache boundary
match validate_combined_chain(db_cache, db_data) {
Err(e) => match e.kind() {
ErrorKind::InvalidChain(upper_bound, _) => {
assert_eq!(*upper_bound, SAPLING_ACTIVATION_HEIGHT + 1)
}
_ => panic!(),
},
_ => panic!(),
}
}
#[test]
fn invalid_chain_cache_reorg() {
let cache_file = NamedTempFile::new().unwrap();
let db_cache = cache_file.path();
init_cache_database(&db_cache).unwrap();
let data_file = NamedTempFile::new().unwrap();
let db_data = data_file.path();
init_data_database(&db_data).unwrap();
// Add an account to the wallet
let extsk = ExtendedSpendingKey::master(&[]);
let extfvk = ExtendedFullViewingKey::from(&extsk);
init_accounts_table(&db_data, &[extfvk.clone()]).unwrap();
// Create some fake CompactBlocks
let (cb, _) = fake_compact_block(
SAPLING_ACTIVATION_HEIGHT,
BlockHash([0; 32]),
extfvk.clone(),
Amount::from_u64(5).unwrap(),
);
let (cb2, _) = fake_compact_block(
SAPLING_ACTIVATION_HEIGHT + 1,
cb.hash(),
extfvk.clone(),
Amount::from_u64(7).unwrap(),
);
insert_into_cache(db_cache, &cb);
insert_into_cache(db_cache, &cb2);
// Scan the cache
scan_cached_blocks(db_cache, db_data).unwrap();
// Data-only chain should be valid
validate_combined_chain(db_cache, db_data).unwrap();
// Create more fake CompactBlocks that contain a reorg
let (cb3, _) = fake_compact_block(
SAPLING_ACTIVATION_HEIGHT + 2,
cb2.hash(),
extfvk.clone(),
Amount::from_u64(8).unwrap(),
);
let (cb4, _) = fake_compact_block(
SAPLING_ACTIVATION_HEIGHT + 3,
BlockHash([1; 32]),
extfvk.clone(),
Amount::from_u64(3).unwrap(),
);
insert_into_cache(db_cache, &cb3);
insert_into_cache(db_cache, &cb4);
// Data+cache chain should be invalid inside the cache
match validate_combined_chain(db_cache, db_data) {
Err(e) => match e.kind() {
ErrorKind::InvalidChain(upper_bound, _) => {
assert_eq!(*upper_bound, SAPLING_ACTIVATION_HEIGHT + 2)
}
_ => panic!(),
},
_ => panic!(),
}
}
#[test]
fn data_db_rewinding() {
let cache_file = NamedTempFile::new().unwrap();
let db_cache = cache_file.path();
init_cache_database(&db_cache).unwrap();
let data_file = NamedTempFile::new().unwrap();
let db_data = data_file.path();
init_data_database(&db_data).unwrap();
// Add an account to the wallet
let extsk = ExtendedSpendingKey::master(&[]);
let extfvk = ExtendedFullViewingKey::from(&extsk);
init_accounts_table(&db_data, &[extfvk.clone()]).unwrap();
// Account balance should be zero
assert_eq!(get_balance(db_data, 0).unwrap(), Amount::zero());
// Create fake CompactBlocks sending value to the address
let value = Amount::from_u64(5).unwrap();
let value2 = Amount::from_u64(7).unwrap();
let (cb, _) = fake_compact_block(
SAPLING_ACTIVATION_HEIGHT,
BlockHash([0; 32]),
extfvk.clone(),
value,
);
let (cb2, _) = fake_compact_block(SAPLING_ACTIVATION_HEIGHT + 1, cb.hash(), extfvk, value2);
insert_into_cache(db_cache, &cb);
insert_into_cache(db_cache, &cb2);
// Scan the cache
scan_cached_blocks(db_cache, db_data).unwrap();
// Account balance should reflect both received notes
assert_eq!(get_balance(db_data, 0).unwrap(), value + value2);
// "Rewind" to height of last scanned block
rewind_to_height(db_data, SAPLING_ACTIVATION_HEIGHT + 1).unwrap();
// Account balance should be unaltered
assert_eq!(get_balance(db_data, 0).unwrap(), value + value2);
// Rewind so that one block is dropped
rewind_to_height(db_data, SAPLING_ACTIVATION_HEIGHT).unwrap();
// Account balance should only contain the first received note
assert_eq!(get_balance(db_data, 0).unwrap(), value);
}
}

View File

@ -10,6 +10,7 @@ pub enum ErrorKind {
CorruptedData(&'static str),
IncorrectHRPExtFVK,
InsufficientBalance(u64, u64),
InvalidChain(i32, crate::chain::ChainInvalidCause),
InvalidExtSK(u32),
InvalidHeight(i32, i32),
InvalidMemo(std::str::Utf8Error),
@ -38,6 +39,9 @@ impl fmt::Display for Error {
"Insufficient balance (have {}, need {} including fee)",
have, need
),
ErrorKind::InvalidChain(upper_bound, cause) => {
write!(f, "Invalid chain (upper bound: {}): {:?}", upper_bound, cause)
}
ErrorKind::InvalidExtSK(account) => {
write!(f, "Incorrect ExtendedSpendingKey for account {}", account)
}

View File

@ -39,6 +39,7 @@ use zcash_client_backend::constants::testnet::{
HRP_SAPLING_EXTENDED_FULL_VIEWING_KEY, HRP_SAPLING_PAYMENT_ADDRESS,
};
pub mod chain;
pub mod error;
pub mod init;
pub mod query;