diff --git a/zcash_client_sqlite/CHANGELOG.md b/zcash_client_sqlite/CHANGELOG.md index f8a5021d1f..934a3d2db5 100644 --- a/zcash_client_sqlite/CHANGELOG.md +++ b/zcash_client_sqlite/CHANGELOG.md @@ -13,12 +13,8 @@ workspace. ### Added - `WalletDb::get_orchard_notes_at_snapshot` returns Orchard notes received and unspent as of a given height, for governance voting snapshots. -- `WalletDb::conn()` provides access to the underlying connection handle. -- `wallet::commitment_tree::create_orchard_tree_tables` creates the - Orchard commitment-tree schema in a given connection, enabling - construction of an ephemeral in-memory `SqliteShardStore`. -- `wallet::commitment_tree::SqliteShardStore::from_connection` is now - publicly accessible. +- `WalletDb::generate_orchard_witnesses_at_frontier` generates Merkle + witnesses at a historical frontier using an ephemeral in-memory tree. ## [0.19.5] - 2026-03-10 diff --git a/zcash_client_sqlite/src/lib.rs b/zcash_client_sqlite/src/lib.rs index 29ffbcf461..fd4223058b 100644 --- a/zcash_client_sqlite/src/lib.rs +++ b/zcash_client_sqlite/src/lib.rs @@ -401,11 +401,6 @@ impl Borrow for SqlTransaction<'_> { } impl WalletDb { - /// Returns a reference to the underlying connection handle. - pub fn conn(&self) -> &C { - &self.conn - } - /// Returns the network parameters that this walletdb instance is bound to. pub fn params(&self) -> &P { &self.params @@ -2318,8 +2313,8 @@ impl WalletCommitmentTrees // --- Governance-specific methods --- // // These methods support Zcash shielded voting and are not part of the -// general-purpose wallet traits. They provide a backward-looking snapshot -// query for historical note selection. +// general-purpose wallet traits. They provide backward-looking queries +// (historical snapshot) and witness generation at an external frontier. #[cfg(feature = "orchard")] impl, P: consensus::Parameters, CL, R> WalletDb { @@ -2342,6 +2337,37 @@ impl, P: consensus::Parameters, CL, R> WalletDb< snapshot_height, ) } + + /// Generate Orchard Merkle witnesses at a historical frontier. + /// + /// Copies the wallet's Orchard shard data into an ephemeral in-memory + /// database, inserts the provided frontier as a checkpoint, and generates + /// a witness for each of the given note positions. + /// + /// The wallet DB is strictly read-only — shard data is copied, not modified. + pub fn generate_orchard_witnesses_at_frontier( + &self, + note_positions: &[Position], + frontier: incrementalmerkletree::frontier::NonEmptyFrontier< + orchard::tree::MerkleHashOrchard, + >, + checkpoint_height: BlockHeight, + ) -> Result< + Vec< + incrementalmerkletree::MerklePath< + orchard::tree::MerkleHashOrchard, + { orchard::NOTE_COMMITMENT_TREE_DEPTH as u8 }, + >, + >, + SqliteClientError, + > { + wallet::commitment_tree::generate_orchard_witnesses_at_frontier( + self.conn.borrow(), + note_positions, + frontier, + checkpoint_height, + ) + } } /// A handle for the SQLite block source. diff --git a/zcash_client_sqlite/src/wallet/commitment_tree.rs b/zcash_client_sqlite/src/wallet/commitment_tree.rs index b824e20426..983a0c618d 100644 --- a/zcash_client_sqlite/src/wallet/commitment_tree.rs +++ b/zcash_client_sqlite/src/wallet/commitment_tree.rs @@ -103,7 +103,10 @@ pub struct SqliteShardStore { impl SqliteShardStore { const SHARD_ROOT_LEVEL: Level = Level::new(SHARD_HEIGHT); - pub fn from_connection(conn: C, table_prefix: &'static str) -> Result { + pub(crate) fn from_connection( + conn: C, + table_prefix: &'static str, + ) -> Result { Ok(SqliteShardStore { conn, table_prefix, @@ -1137,23 +1140,179 @@ pub(crate) fn check_witnesses( Ok(scan_ranges) } -/// Creates the Orchard commitment-tree tables in the given connection. +/// Generate Orchard Merkle witnesses at a historical frontier. /// -/// This enables constructing an ephemeral in-memory [`SqliteShardStore`] for -/// operations like witness generation at a historical frontier, without -/// requiring the full wallet schema. +/// Copies the wallet's Orchard shard data into an ephemeral in-memory database, +/// inserts the provided frontier (from lightwalletd) as a checkpoint, and +/// generates a witness for each of the given note positions. +/// +/// The wallet DB is strictly read-only — shard data is copied, not modified. +/// +/// This is used by governance voting: the wallet tree may have advanced past +/// the snapshot height, but we need witnesses anchored at the snapshot frontier. #[cfg(feature = "orchard")] -pub fn create_orchard_tree_tables(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> { - use super::db::{ - TABLE_ORCHARD_TREE_CAP, TABLE_ORCHARD_TREE_CHECKPOINT_MARKS_REMOVED, - TABLE_ORCHARD_TREE_CHECKPOINTS, TABLE_ORCHARD_TREE_SHARDS, - }; - conn.execute_batch(&format!( - "{TABLE_ORCHARD_TREE_SHARDS};\ - {TABLE_ORCHARD_TREE_CAP};\ - {TABLE_ORCHARD_TREE_CHECKPOINTS};\ - {TABLE_ORCHARD_TREE_CHECKPOINT_MARKS_REMOVED};", - )) +pub fn generate_orchard_witnesses_at_frontier( + conn: &rusqlite::Connection, + note_positions: &[Position], + frontier: incrementalmerkletree::frontier::NonEmptyFrontier, + checkpoint_height: BlockHeight, +) -> Result< + Vec< + incrementalmerkletree::MerklePath< + orchard::tree::MerkleHashOrchard, + { orchard::NOTE_COMMITMENT_TREE_DEPTH as u8 }, + >, + >, + SqliteClientError, +> { + use incrementalmerkletree::Marking; + use shardtree::ShardTree; + use shardtree::store::Checkpoint; + use zcash_client_backend::data_api::ORCHARD_SHARD_HEIGHT; + + let frontier_position = frontier.position(); + + // Create in-memory DB with the tree schema + let mem_conn = rusqlite::Connection::open_in_memory().map_err(SqliteClientError::DbError)?; + + mem_conn + .execute_batch( + "CREATE TABLE orchard_tree_shards ( + shard_index INTEGER PRIMARY KEY, + subtree_end_height INTEGER, + root_hash BLOB, + shard_data BLOB, + contains_marked INTEGER, + CONSTRAINT root_unique UNIQUE (root_hash) + ); + CREATE TABLE orchard_tree_cap ( + cap_id INTEGER PRIMARY KEY, + cap_data BLOB NOT NULL + ); + CREATE TABLE orchard_tree_checkpoints ( + checkpoint_id INTEGER PRIMARY KEY, + position INTEGER + ); + CREATE TABLE orchard_tree_checkpoint_marks_removed ( + checkpoint_id INTEGER NOT NULL, + mark_removed_position INTEGER NOT NULL, + FOREIGN KEY (checkpoint_id) REFERENCES orchard_tree_checkpoints(checkpoint_id) + ON DELETE CASCADE, + CONSTRAINT spend_position_unique UNIQUE (checkpoint_id, mark_removed_position) + );", + ) + .map_err(SqliteClientError::DbError)?; + + // Copy shard data from wallet into in-memory DB + { + use rusqlite::types::Value; + + let mut stmt = conn + .prepare( + "SELECT shard_index, subtree_end_height, root_hash, shard_data, contains_marked + FROM orchard_tree_shards", + ) + .map_err(SqliteClientError::DbError)?; + let mut rows = stmt.query([]).map_err(SqliteClientError::DbError)?; + while let Some(row) = rows.next().map_err(SqliteClientError::DbError)? { + mem_conn + .execute( + "INSERT INTO orchard_tree_shards + (shard_index, subtree_end_height, root_hash, shard_data, contains_marked) + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![ + row.get::<_, Value>(0)?, + row.get::<_, Value>(1)?, + row.get::<_, Value>(2)?, + row.get::<_, Value>(3)?, + row.get::<_, Value>(4)?, + ], + ) + .map_err(SqliteClientError::DbError)?; + } + } + + // Copy cap data + { + use rusqlite::types::Value; + + let mut stmt = conn + .prepare("SELECT cap_id, cap_data FROM orchard_tree_cap") + .map_err(SqliteClientError::DbError)?; + let mut rows = stmt.query([]).map_err(SqliteClientError::DbError)?; + while let Some(row) = rows.next().map_err(SqliteClientError::DbError)? { + mem_conn + .execute( + "INSERT INTO orchard_tree_cap (cap_id, cap_data) VALUES (?1, ?2)", + rusqlite::params![row.get::<_, Value>(0)?, row.get::<_, Value>(1)?,], + ) + .map_err(SqliteClientError::DbError)?; + } + } + + // Build ShardTree from in-memory store + let tx = mem_conn + .unchecked_transaction() + .map_err(SqliteClientError::DbError)?; + + let store = SqliteShardStore::< + _, + orchard::tree::MerkleHashOrchard, + ORCHARD_SHARD_HEIGHT, + >::from_connection(&tx, "orchard") + .map_err(SqliteClientError::DbError)?; + + let mut tree = + ShardTree::<_, { orchard::NOTE_COMMITMENT_TREE_DEPTH as u8 }, ORCHARD_SHARD_HEIGHT>::new( + store, 100, + ); + + // Insert frontier + checkpoint + tree.insert_frontier_nodes( + frontier, + Retention::Checkpoint { + id: checkpoint_height, + marking: Marking::None, + }, + ) + .map_err(|e| { + SqliteClientError::CorruptedData(format!("failed to insert frontier nodes: {}", e)) + })?; + + tree.store_mut() + .add_checkpoint( + checkpoint_height, + Checkpoint::at_position(frontier_position), + ) + .map_err(|e| { + SqliteClientError::CorruptedData(format!("failed to add checkpoint: {}", e)) + })?; + + // Generate witness per note position + let mut witnesses = Vec::with_capacity(note_positions.len()); + for &pos in note_positions { + let merkle_path = tree + .witness_at_checkpoint_id(pos, &checkpoint_height) + .map_err(|e| { + SqliteClientError::CorruptedData(format!( + "failed to generate witness for position {}: {} \ + (wallet may need to sync through snapshot height)", + u64::from(pos), + e + )) + })? + .ok_or_else(|| { + SqliteClientError::CorruptedData(format!( + "no witness available for position {} \ + (wallet missing shard data — sync through snapshot height)", + u64::from(pos) + )) + })?; + + witnesses.push(merkle_path); + } + + Ok(witnesses) } #[cfg(test)] @@ -1242,6 +1401,11 @@ mod tests { super::check_rewind_remove_mark(new_tree::); } + #[test] + fn witnesses_at_frontier() { + super::witnesses_at_frontier() + } + #[test] fn put_shard_roots() { super::put_shard_roots::() @@ -1358,4 +1522,97 @@ mod tests { ] ); } + + /// Test that `generate_orchard_witnesses_at_frontier` produces valid + /// witnesses when given a frontier extracted from an earlier tree state. + #[cfg(feature = "orchard")] + fn witnesses_at_frontier() { + use ::orchard::tree::MerkleHashOrchard; + use incrementalmerkletree::frontier::Frontier; + use rand::SeedableRng; + use rand_chacha::ChaChaRng; + use zcash_client_backend::data_api::ORCHARD_SHARD_HEIGHT; + + let data_file = NamedTempFile::new().unwrap(); + let mut db_data = WalletDb::for_path( + data_file.path(), + Network::TestNetwork, + test_clock(), + test_rng(), + ) + .unwrap(); + data_file.keep().unwrap(); + + WalletMigrator::new().init_or_migrate(&mut db_data).unwrap(); + + let mut rng = ChaChaRng::seed_from_u64(0); + + // Build a tree with some leaves, marking one as our note. + // We track the frontier separately to capture the tree state at a + // snapshot height, while the wallet DB holds the persistent shard data. + let mut frontier_tree: Frontier = Frontier::empty(); + let snapshot_height = BlockHeight::from(100); + let note_position; + let note_leaf; + + { + let tx = db_data.conn.transaction().unwrap(); + let store = + SqliteShardStore::<_, MerkleHashOrchard, ORCHARD_SHARD_HEIGHT>::from_connection( + &tx, "orchard", + ) + .unwrap(); + let mut tree = ShardTree::< + _, + { ::orchard::NOTE_COMMITMENT_TREE_DEPTH as u8 }, + ORCHARD_SHARD_HEIGHT, + >::new(store, 100); + + // Insert 5 leaves. Mark leaf 2 as our note. + let mut saved_leaf = None; + for i in 0u64..5 { + let leaf = MerkleHashOrchard::random(&mut rng); + let retention = if i == 2 { + saved_leaf = Some(leaf); + Retention::Marked + } else { + Retention::Ephemeral + }; + tree.append(leaf, retention).unwrap(); + frontier_tree.append(leaf); + } + note_position = Position::from(2); + note_leaf = saved_leaf.unwrap(); + + // Advance the tree past the snapshot height, simulating the + // wallet continuing to sync after the voting snapshot. + tree.checkpoint(snapshot_height).unwrap(); + for _ in 0..5 { + let leaf = MerkleHashOrchard::random(&mut rng); + tree.append(leaf, Retention::Ephemeral).unwrap(); + } + tree.checkpoint(BlockHeight::from(200)).unwrap(); + + tx.commit().unwrap(); + } + + // The frontier_tree captured the state after 5 leaves. + let expected_root = frontier_tree.root(); + let frontier = frontier_tree.take().expect("frontier is non-empty"); + + // Generate witness using the function under test + let witnesses = super::generate_orchard_witnesses_at_frontier( + &db_data.conn, + &[note_position], + frontier, + snapshot_height, + ) + .expect("witness generation should succeed"); + + assert_eq!(witnesses.len(), 1); + + // The witness, combined with the note's leaf hash, must recompute + // to the tree root at the snapshot height. + assert_eq!(witnesses[0].root(note_leaf), expected_root); + } }