diff --git a/zcash_client_sqlite/Cargo.toml b/zcash_client_sqlite/Cargo.toml index fe15f7b00..b2994f7a0 100644 --- a/zcash_client_sqlite/Cargo.toml +++ b/zcash_client_sqlite/Cargo.toml @@ -12,10 +12,15 @@ license = "MIT OR Apache-2.0" edition = "2018" [dependencies] +bech32 = "0.7" +ff = { version = "0.6", path = "../ff" } +protobuf = "2" rusqlite = { version = "0.20", features = ["bundled"] } zcash_client_backend = { version = "0.2", path = "../zcash_client_backend" } zcash_primitives = { version = "0.2", path = "../zcash_primitives" } [dev-dependencies] +pairing = { version = "0.16", path = "../pairing" } +rand_core = "0.5.1" remove_dir_all = "=0.5.2" # tempfile dependency; 0.5.3 bumped the MSRV tempfile = "3" diff --git a/zcash_client_sqlite/src/error.rs b/zcash_client_sqlite/src/error.rs index b33b5c249..fcdc1a079 100644 --- a/zcash_client_sqlite/src/error.rs +++ b/zcash_client_sqlite/src/error.rs @@ -1,12 +1,20 @@ use std::error; use std::fmt; +use zcash_primitives::{sapling::Node, transaction::TxId}; #[derive(Debug)] pub enum ErrorKind { CorruptedData(&'static str), + IncorrectHRPExtFVK, + InvalidHeight(i32, i32), + InvalidNewWitnessAnchor(usize, TxId, i32, Node), + InvalidWitnessAnchor(i64, i32), ScanRequired, TableNotEmpty, + Bech32(bech32::Error), Database(rusqlite::Error), + Io(std::io::Error), + Protobuf(protobuf::ProtobufError), } #[derive(Debug)] @@ -16,9 +24,28 @@ impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match &self.0 { ErrorKind::CorruptedData(reason) => write!(f, "Data DB is corrupted: {}", reason), + ErrorKind::IncorrectHRPExtFVK => write!(f, "Incorrect HRP for extfvk"), + ErrorKind::InvalidHeight(expected, actual) => write!( + f, + "Expected height of next CompactBlock to be {}, but was {}", + expected, actual + ), + ErrorKind::InvalidNewWitnessAnchor(output, txid, last_height, anchor) => write!( + f, + "New witness for output {} in tx {} has incorrect anchor after scanning block {}: {:?}", + output, txid, last_height, anchor, + ), + ErrorKind::InvalidWitnessAnchor(id_note, last_height) => write!( + f, + "Witness for note {} has incorrect anchor after scanning block {}", + id_note, last_height + ), ErrorKind::ScanRequired => write!(f, "Must scan blocks first"), ErrorKind::TableNotEmpty => write!(f, "Table is not empty"), + ErrorKind::Bech32(e) => write!(f, "{}", e), ErrorKind::Database(e) => write!(f, "{}", e), + ErrorKind::Io(e) => write!(f, "{}", e), + ErrorKind::Protobuf(e) => write!(f, "{}", e), } } } @@ -26,18 +53,39 @@ impl fmt::Display for Error { impl error::Error for Error { fn source(&self) -> Option<&(dyn error::Error + 'static)> { match &self.0 { + ErrorKind::Bech32(e) => Some(e), ErrorKind::Database(e) => Some(e), + ErrorKind::Io(e) => Some(e), + ErrorKind::Protobuf(e) => Some(e), _ => None, } } } +impl From for Error { + fn from(e: bech32::Error) -> Self { + Error(ErrorKind::Bech32(e)) + } +} + impl From for Error { fn from(e: rusqlite::Error) -> Self { Error(ErrorKind::Database(e)) } } +impl From for Error { + fn from(e: std::io::Error) -> Self { + Error(ErrorKind::Io(e)) + } +} + +impl From for Error { + fn from(e: protobuf::ProtobufError) -> Self { + Error(ErrorKind::Protobuf(e)) + } +} + impl Error { pub fn kind(&self) -> &ErrorKind { &self.0 diff --git a/zcash_client_sqlite/src/lib.rs b/zcash_client_sqlite/src/lib.rs index cb52144b4..922193a77 100644 --- a/zcash_client_sqlite/src/lib.rs +++ b/zcash_client_sqlite/src/lib.rs @@ -29,8 +29,10 @@ use zcash_primitives::zip32::ExtendedFullViewingKey; pub mod error; pub mod init; pub mod query; +pub mod scan; const ANCHOR_OFFSET: u32 = 10; +const SAPLING_ACTIVATION_HEIGHT: i32 = 280_000; fn address_from_extfvk(extfvk: &ExtendedFullViewingKey) -> String { let addr = extfvk.default_address().unwrap().1; @@ -63,3 +65,173 @@ fn get_target_and_anchor_heights(data: &Connection) -> Result<(u32, u32), error: }, ) } + +#[cfg(test)] +mod tests { + use ff::{Field, PrimeField}; + use pairing::bls12_381::Bls12; + use protobuf::Message; + use rand_core::{OsRng, RngCore}; + use rusqlite::{types::ToSql, Connection}; + use std::path::Path; + use zcash_client_backend::proto::compact_formats::{ + CompactBlock, CompactOutput, CompactSpend, CompactTx, + }; + use zcash_primitives::{ + block::BlockHash, + jubjub::fs::Fs, + note_encryption::{Memo, SaplingNoteEncryption}, + primitives::{Note, PaymentAddress}, + transaction::components::Amount, + zip32::ExtendedFullViewingKey, + JUBJUB, + }; + + /// Create a fake CompactBlock at the given height, containing a single output paying + /// the given address. Returns the CompactBlock and the nullifier for the new note. + pub(crate) fn fake_compact_block( + height: i32, + prev_hash: BlockHash, + extfvk: ExtendedFullViewingKey, + value: Amount, + ) -> (CompactBlock, Vec) { + let to = extfvk.default_address().unwrap().1; + + // Create a fake Note for the account + let mut rng = OsRng; + let note = Note { + g_d: to.diversifier().g_d::(&JUBJUB).unwrap(), + pk_d: to.pk_d().clone(), + value: value.into(), + r: Fs::random(&mut rng), + }; + let encryptor = SaplingNoteEncryption::new( + extfvk.fvk.ovk, + note.clone(), + to.clone(), + Memo::default(), + &mut rng, + ); + let cmu = note.cm(&JUBJUB).to_repr().as_ref().to_vec(); + let mut epk = vec![]; + encryptor.epk().write(&mut epk).unwrap(); + let enc_ciphertext = encryptor.encrypt_note_plaintext(); + + // Create a fake CompactBlock containing the note + let mut cout = CompactOutput::new(); + cout.set_cmu(cmu); + cout.set_epk(epk); + cout.set_ciphertext(enc_ciphertext[..52].to_vec()); + let mut ctx = CompactTx::new(); + let mut txid = vec![0; 32]; + rng.fill_bytes(&mut txid); + ctx.set_hash(txid); + ctx.outputs.push(cout); + let mut cb = CompactBlock::new(); + cb.set_height(height as u64); + cb.hash.resize(32, 0); + rng.fill_bytes(&mut cb.hash); + cb.prevHash.extend_from_slice(&prev_hash.0); + cb.vtx.push(ctx); + (cb, note.nf(&extfvk.fvk.vk, 0, &JUBJUB)) + } + + /// Create a fake CompactBlock at the given height, spending a single note from the + /// given address. + pub(crate) fn fake_compact_block_spending( + height: i32, + prev_hash: BlockHash, + (nf, in_value): (Vec, Amount), + extfvk: ExtendedFullViewingKey, + to: PaymentAddress, + value: Amount, + ) -> CompactBlock { + let mut rng = OsRng; + + // Create a fake CompactBlock containing the note + let mut cspend = CompactSpend::new(); + cspend.set_nf(nf); + let mut ctx = CompactTx::new(); + let mut txid = vec![0; 32]; + rng.fill_bytes(&mut txid); + ctx.set_hash(txid); + ctx.spends.push(cspend); + + // Create a fake Note for the payment + ctx.outputs.push({ + let note = Note { + g_d: to.diversifier().g_d::(&JUBJUB).unwrap(), + pk_d: to.pk_d().clone(), + value: value.into(), + r: Fs::random(&mut rng), + }; + let encryptor = SaplingNoteEncryption::new( + extfvk.fvk.ovk, + note.clone(), + to, + Memo::default(), + &mut rng, + ); + let cmu = note.cm(&JUBJUB).to_repr().as_ref().to_vec(); + let mut epk = vec![]; + encryptor.epk().write(&mut epk).unwrap(); + let enc_ciphertext = encryptor.encrypt_note_plaintext(); + + let mut cout = CompactOutput::new(); + cout.set_cmu(cmu); + cout.set_epk(epk); + cout.set_ciphertext(enc_ciphertext[..52].to_vec()); + cout + }); + + // Create a fake Note for the change + ctx.outputs.push({ + let change_addr = extfvk.default_address().unwrap().1; + let note = Note { + g_d: change_addr.diversifier().g_d::(&JUBJUB).unwrap(), + pk_d: change_addr.pk_d().clone(), + value: (in_value - value).into(), + r: Fs::random(&mut rng), + }; + let encryptor = SaplingNoteEncryption::new( + extfvk.fvk.ovk, + note.clone(), + change_addr, + Memo::default(), + &mut rng, + ); + let cmu = note.cm(&JUBJUB).to_repr().as_ref().to_vec(); + let mut epk = vec![]; + encryptor.epk().write(&mut epk).unwrap(); + let enc_ciphertext = encryptor.encrypt_note_plaintext(); + + let mut cout = CompactOutput::new(); + cout.set_cmu(cmu); + cout.set_epk(epk); + cout.set_ciphertext(enc_ciphertext[..52].to_vec()); + cout + }); + + let mut cb = CompactBlock::new(); + cb.set_height(height as u64); + cb.hash.resize(32, 0); + rng.fill_bytes(&mut cb.hash); + cb.prevHash.extend_from_slice(&prev_hash.0); + cb.vtx.push(ctx); + cb + } + + /// Insert a fake CompactBlock into the cache DB. + pub(crate) fn insert_into_cache>(db_cache: P, cb: &CompactBlock) { + let cb_bytes = cb.write_to_bytes().unwrap(); + let cache = Connection::open(&db_cache).unwrap(); + cache + .prepare("INSERT INTO compactblocks (height, data) VALUES (?, ?)") + .unwrap() + .execute(&[ + (cb.height as i32).to_sql().unwrap(), + cb_bytes.to_sql().unwrap(), + ]) + .unwrap(); + } +} diff --git a/zcash_client_sqlite/src/scan.rs b/zcash_client_sqlite/src/scan.rs new file mode 100644 index 000000000..01028d8b6 --- /dev/null +++ b/zcash_client_sqlite/src/scan.rs @@ -0,0 +1,500 @@ +//! Functions for scanning the chain and extracting relevant information. + +use ff::PrimeField; +use protobuf::parse_from_bytes; +use rusqlite::{types::ToSql, Connection, NO_PARAMS}; +use std::path::Path; +use zcash_client_backend::{ + constants::testnet::HRP_SAPLING_EXTENDED_FULL_VIEWING_KEY, + encoding::decode_extended_full_viewing_key, proto::compact_formats::CompactBlock, + welding_rig::scan_block, +}; +use zcash_primitives::{ + merkle_tree::{CommitmentTree, IncrementalWitness}, + sapling::Node, + JUBJUB, +}; + +use crate::{ + error::{Error, ErrorKind}, + SAPLING_ACTIVATION_HEIGHT, +}; + +struct CompactBlockRow { + height: i32, + data: Vec, +} + +#[derive(Clone)] +struct WitnessRow { + id_note: i64, + witness: IncrementalWitness, +} + +/// Scans new blocks added to the cache for any transactions received by the tracked +/// accounts. +/// +/// This function pays attention only to cached blocks with heights greater than the +/// highest scanned block in `db_data`. Cached blocks with lower heights are not verified +/// against previously-scanned blocks. In particular, this function **assumes** that the +/// caller is handling rollbacks. +/// +/// For brand-new light client databases, this function starts scanning from the Sapling +/// activation height. This height can be fast-forwarded to a more recent block by calling +/// [`init_blocks_table`] before this function. +/// +/// Scanned blocks are required to be height-sequential. If a block is missing from the +/// cache, an error will be returned with kind [`ErrorKind::InvalidHeight`]. +/// +/// # Examples +/// +/// ``` +/// use zcash_client_sqlite::scan::scan_cached_blocks; +/// +/// scan_cached_blocks("/path/to/cache.db", "/path/to/data.db"); +/// ``` +/// +/// [`init_blocks_table`]: crate::init::init_blocks_table +pub fn scan_cached_blocks, Q: AsRef>( + 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 mut last_height = data.query_row("SELECT MAX(height) FROM blocks", NO_PARAMS, |row| { + row.get(0).or(Ok(SAPLING_ACTIVATION_HEIGHT - 1)) + })?; + + // Fetch the CompactBlocks we need to scan + let mut stmt_blocks = cache + .prepare("SELECT height, data FROM compactblocks WHERE height > ? ORDER BY height ASC")?; + let rows = stmt_blocks.query_map(&[last_height], |row| { + Ok(CompactBlockRow { + height: row.get(0)?, + data: row.get(1)?, + }) + })?; + + // Fetch the ExtendedFullViewingKeys we are tracking + let mut stmt_fetch_accounts = + data.prepare("SELECT extfvk FROM accounts ORDER BY account ASC")?; + let extfvks = stmt_fetch_accounts.query_map(NO_PARAMS, |row| { + row.get(0).map(|extfvk: String| { + decode_extended_full_viewing_key(HRP_SAPLING_EXTENDED_FULL_VIEWING_KEY, &extfvk) + }) + })?; + // Raise SQL errors from the query, IO errors from parsing, and incorrect HRP errors. + let extfvks: Vec<_> = extfvks + .collect::, _>, _>>()?? + .ok_or(Error(ErrorKind::IncorrectHRPExtFVK))?; + + // Get the most recent CommitmentTree + let mut stmt_fetch_tree = data.prepare("SELECT sapling_tree FROM blocks WHERE height = ?")?; + let mut tree = stmt_fetch_tree + .query_row(&[last_height], |row| { + row.get(0).map(|data: Vec<_>| { + CommitmentTree::read(&data[..]).unwrap_or_else(|_| CommitmentTree::new()) + }) + }) + .unwrap_or_else(|_| CommitmentTree::new()); + + // Get most recent incremental witnesses for the notes we are tracking + let mut stmt_fetch_witnesses = + data.prepare("SELECT note, witness FROM sapling_witnesses WHERE block = ?")?; + let witnesses = stmt_fetch_witnesses.query_map(&[last_height], |row| { + let id_note = row.get(0)?; + let data: Vec<_> = row.get(1)?; + Ok(IncrementalWitness::read(&data[..]).map(|witness| WitnessRow { id_note, witness })) + })?; + let mut witnesses: Vec<_> = witnesses.collect::, _>>()??; + + // Get the nullifiers for the notes we are tracking + let mut stmt_fetch_nullifiers = + data.prepare("SELECT id_note, nf, account FROM received_notes WHERE spent IS NULL")?; + let nullifiers = stmt_fetch_nullifiers.query_map(NO_PARAMS, |row| { + let nf: Vec<_> = row.get(1)?; + let account: i64 = row.get(2)?; + Ok((nf, account as usize)) + })?; + let mut nullifiers: Vec<_> = nullifiers.collect::>()?; + + // Prepare per-block SQL statements + let mut stmt_insert_block = data.prepare( + "INSERT INTO blocks (height, hash, time, sapling_tree) + VALUES (?, ?, ?, ?)", + )?; + let mut stmt_update_tx = data.prepare( + "UPDATE transactions + SET block = ?, tx_index = ? WHERE txid = ?", + )?; + let mut stmt_insert_tx = data.prepare( + "INSERT INTO transactions (txid, block, tx_index) + VALUES (?, ?, ?)", + )?; + let mut stmt_select_tx = data.prepare("SELECT id_tx FROM transactions WHERE txid = ?")?; + let mut stmt_mark_spent_note = + data.prepare("UPDATE received_notes SET spent = ? WHERE nf = ?")?; + let mut stmt_insert_note = data.prepare( + "INSERT INTO received_notes (tx, output_index, account, diversifier, value, rcm, nf, is_change) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + )?; + let mut stmt_insert_witness = data.prepare( + "INSERT INTO sapling_witnesses (note, block, witness) + VALUES (?, ?, ?)", + )?; + let mut stmt_prune_witnesses = data.prepare("DELETE FROM sapling_witnesses WHERE block < ?")?; + let mut stmt_update_expired = data.prepare( + "UPDATE received_notes SET spent = NULL WHERE EXISTS ( + SELECT id_tx FROM transactions + WHERE id_tx = received_notes.spent AND block IS NULL AND expiry_height < ? + )", + )?; + + for row in rows { + let row = row?; + + // Start an SQL transaction for this block. + data.execute("BEGIN IMMEDIATE", NO_PARAMS)?; + + // Scanned blocks MUST be height-sequential. + if row.height != (last_height + 1) { + return Err(Error(ErrorKind::InvalidHeight(last_height + 1, row.height))); + } + last_height = row.height; + + let block: CompactBlock = parse_from_bytes(&row.data)?; + let block_hash = block.hash.clone(); + let block_time = block.time; + + let txs = { + let nf_refs: Vec<_> = nullifiers.iter().map(|(nf, acc)| (&nf[..], *acc)).collect(); + let mut witness_refs: Vec<_> = witnesses.iter_mut().map(|w| &mut w.witness).collect(); + scan_block( + block, + &extfvks[..], + &nf_refs, + &mut tree, + &mut witness_refs[..], + ) + }; + + // Enforce that all roots match. This is slow, so only include in debug builds. + #[cfg(debug_assertions)] + { + let cur_root = tree.root(); + for row in &witnesses { + if row.witness.root() != cur_root { + return Err(Error(ErrorKind::InvalidWitnessAnchor( + row.id_note, + last_height, + ))); + } + } + for tx in &txs { + for output in tx.shielded_outputs.iter() { + if output.witness.root() != cur_root { + return Err(Error(ErrorKind::InvalidNewWitnessAnchor( + output.index, + tx.txid, + last_height, + output.witness.root(), + ))); + } + } + } + } + + // Insert the block into the database. + let mut encoded_tree = Vec::new(); + tree.write(&mut encoded_tree) + .expect("Should be able to write to a Vec"); + stmt_insert_block.execute(&[ + row.height.to_sql()?, + block_hash.to_sql()?, + block_time.to_sql()?, + encoded_tree.to_sql()?, + ])?; + + for tx in txs { + // First try update an existing transaction in the database. + let txid = tx.txid.0.to_vec(); + let tx_row = if stmt_update_tx.execute(&[ + row.height.to_sql()?, + (tx.index as i64).to_sql()?, + txid.to_sql()?, + ])? == 0 + { + // It isn't there, so insert our transaction into the database. + stmt_insert_tx.execute(&[ + txid.to_sql()?, + row.height.to_sql()?, + (tx.index as i64).to_sql()?, + ])?; + data.last_insert_rowid() + } else { + // It was there, so grab its row number. + stmt_select_tx.query_row(&[txid], |row| row.get(0))? + }; + + // Mark notes as spent and remove them from the scanning cache + for spend in &tx.shielded_spends { + stmt_mark_spent_note.execute(&[tx_row.to_sql()?, spend.nf.to_sql()?])?; + } + nullifiers = nullifiers + .into_iter() + .filter(|(nf, _acc)| { + tx.shielded_spends + .iter() + .find(|spend| &spend.nf == nf) + .is_none() + }) + .collect(); + + for output in tx.shielded_outputs { + let rcm = output.note.r.to_repr(); + let nf = output.note.nf( + &extfvks[output.account].fvk.vk, + output.witness.position() as u64, + &JUBJUB, + ); + + // Insert received note into the database. + // Assumptions: + // - A transaction will not contain more than 2^63 shielded outputs. + // - A note value will never exceed 2^63 zatoshis. + stmt_insert_note.execute(&[ + tx_row.to_sql()?, + (output.index as i64).to_sql()?, + (output.account as i64).to_sql()?, + output.to.diversifier().0.to_sql()?, + (output.note.value as i64).to_sql()?, + rcm.as_ref().to_sql()?, + nf.to_sql()?, + output.is_change.to_sql()?, + ])?; + let note_row = data.last_insert_rowid(); + + // Save witness for note. + witnesses.push(WitnessRow { + id_note: note_row, + witness: output.witness, + }); + + // Cache nullifier for note (to detect subsequent spends in this scan). + nullifiers.push((nf, output.account)); + } + } + + // Insert current witnesses into the database. + let mut encoded = Vec::new(); + for witness_row in witnesses.iter() { + encoded.clear(); + witness_row + .witness + .write(&mut encoded) + .expect("Should be able to write to a Vec"); + stmt_insert_witness.execute(&[ + witness_row.id_note.to_sql()?, + last_height.to_sql()?, + encoded.to_sql()?, + ])?; + } + + // Prune the stored witnesses (we only expect rollbacks of at most 100 blocks). + stmt_prune_witnesses.execute(&[last_height - 100])?; + + // Update now-expired transactions that didn't get mined. + stmt_update_expired.execute(&[last_height])?; + + // Commit the SQL transaction, writing this block's data 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::scan_cached_blocks; + use crate::{ + init::{init_accounts_table, init_cache_database, init_data_database}, + query::get_balance, + tests::{fake_compact_block, fake_compact_block_spending, insert_into_cache}, + SAPLING_ACTIVATION_HEIGHT, + }; + + #[test] + fn scan_cached_blocks_requires_sequential_blocks() { + 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 a block with height SAPLING_ACTIVATION_HEIGHT + let value = Amount::from_u64(50000).unwrap(); + let (cb1, _) = fake_compact_block( + SAPLING_ACTIVATION_HEIGHT, + BlockHash([0; 32]), + extfvk.clone(), + value, + ); + insert_into_cache(db_cache, &cb1); + scan_cached_blocks(db_cache, db_data).unwrap(); + assert_eq!(get_balance(db_data, 0).unwrap(), value); + + // We cannot scan a block of height SAPLING_ACTIVATION_HEIGHT + 2 next + let (cb2, _) = fake_compact_block( + SAPLING_ACTIVATION_HEIGHT + 1, + cb1.hash(), + extfvk.clone(), + value, + ); + let (cb3, _) = fake_compact_block( + SAPLING_ACTIVATION_HEIGHT + 2, + cb2.hash(), + extfvk.clone(), + value, + ); + insert_into_cache(db_cache, &cb3); + match scan_cached_blocks(db_cache, db_data) { + Ok(_) => panic!("Should have failed"), + Err(e) => assert_eq!( + e.to_string(), + format!( + "Expected height of next CompactBlock to be {}, but was {}", + SAPLING_ACTIVATION_HEIGHT + 1, + SAPLING_ACTIVATION_HEIGHT + 2 + ) + ), + } + + // If we add a block of height SAPLING_ACTIVATION_HEIGHT + 1, we can now scan both + insert_into_cache(db_cache, &cb2); + scan_cached_blocks(db_cache, db_data).unwrap(); + assert_eq!( + get_balance(db_data, 0).unwrap(), + Amount::from_u64(150_000).unwrap() + ); + } + + #[test] + fn scan_cached_blocks_finds_received_notes() { + 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 a fake CompactBlock sending value to the address + let value = Amount::from_u64(5).unwrap(); + let (cb, _) = fake_compact_block( + SAPLING_ACTIVATION_HEIGHT, + BlockHash([0; 32]), + extfvk.clone(), + value, + ); + insert_into_cache(db_cache, &cb); + + // Scan the cache + scan_cached_blocks(db_cache, db_data).unwrap(); + + // Account balance should reflect the received note + assert_eq!(get_balance(db_data, 0).unwrap(), value); + + // Create a second fake CompactBlock sending more value to the address + let value2 = Amount::from_u64(7).unwrap(); + let (cb2, _) = fake_compact_block(SAPLING_ACTIVATION_HEIGHT + 1, cb.hash(), extfvk, value2); + insert_into_cache(db_cache, &cb2); + + // Scan the cache again + 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); + } + + #[test] + fn scan_cached_blocks_finds_change_notes() { + 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 a fake CompactBlock sending value to the address + let value = Amount::from_u64(5).unwrap(); + let (cb, nf) = fake_compact_block( + SAPLING_ACTIVATION_HEIGHT, + BlockHash([0; 32]), + extfvk.clone(), + value, + ); + insert_into_cache(db_cache, &cb); + + // Scan the cache + scan_cached_blocks(db_cache, db_data).unwrap(); + + // Account balance should reflect the received note + assert_eq!(get_balance(db_data, 0).unwrap(), value); + + // Create a second fake CompactBlock spending value from the address + let extsk2 = ExtendedSpendingKey::master(&[0]); + let to2 = extsk2.default_address().unwrap().1; + let value2 = Amount::from_u64(2).unwrap(); + insert_into_cache( + db_cache, + &fake_compact_block_spending( + SAPLING_ACTIVATION_HEIGHT + 1, + cb.hash(), + (nf, value), + extfvk, + to2, + value2, + ), + ); + + // Scan the cache again + scan_cached_blocks(db_cache, db_data).unwrap(); + + // Account balance should equal the change + assert_eq!(get_balance(db_data, 0).unwrap(), value - value2); + } +}