diff --git a/.github/helpers/check-dep-graph.py b/.github/helpers/check-dep-graph.py index d3aec508a6..b5f0d74454 100644 --- a/.github/helpers/check-dep-graph.py +++ b/.github/helpers/check-dep-graph.py @@ -32,6 +32,14 @@ 'zip32', ]) +# Maps cargo package names to the logical name used in README.md's mermaid graph. +# The workspace imports `orchard` via the `valar-orchard` crates.io package using +# cargo's `package = "valar-orchard"` rename, so `cargo tree` reports the real +# package name `valar-orchard` while the README uses the alias `orchard`. +PACKAGE_NAME_REMAP = { + 'valar-orchard': 'orchard', +} + def main(): script_dir = os.path.dirname(os.path.realpath(__file__)) base_dir = os.path.dirname(os.path.dirname(script_dir)) @@ -79,6 +87,7 @@ def main(): continue (depth, crate, _) = line.strip().split(' ', 2) depth = int(depth) + crate = PACKAGE_NAME_REMAP.get(crate, crate) if depth == 0: crate_stack = [crate] diff --git a/Cargo.lock b/Cargo.lock index d676b1b8a6..46d227d7b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2964,42 +2964,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "orchard" -version = "0.12.0" -source = "git+https://github.com/zcash/orchard.git?rev=b0bf2670e248958c6ce7c1deed466032e0dbd4d9#b0bf2670e248958c6ce7c1deed466032e0dbd4d9" -dependencies = [ - "aes", - "bitvec", - "blake2b_simd", - "corez", - "ff", - "fpe", - "getset", - "group", - "halo2_gadgets", - "halo2_poseidon", - "halo2_proofs", - "hex", - "incrementalmerkletree", - "lazy_static", - "memuse", - "nonempty", - "pasta_curves", - "proptest", - "rand 0.8.5", - "rand_core 0.6.4", - "reddsa", - "serde", - "sinsemilla", - "subtle", - "tracing", - "visibility", - "zcash_note_encryption", - "zcash_spec", - "zip32", -] - [[package]] name = "ordered-float" version = "2.10.1" @@ -3154,7 +3118,6 @@ dependencies = [ "incrementalmerkletree", "jubjub", "nonempty", - "orchard", "pasta_curves", "postcard", "rand_core 0.6.4", @@ -3166,6 +3129,7 @@ dependencies = [ "serde_with", "sha2 0.10.8", "shardtree", + "valar-orchard", "zcash_note_encryption", "zcash_primitives", "zcash_proofs", @@ -6312,6 +6276,43 @@ dependencies = [ "serde", ] +[[package]] +name = "valar-orchard" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0415fc8aba029019c974bb0b0cc47e0bca53a27fdc2da263a2e6c99c9b3727f8" +dependencies = [ + "aes", + "bitvec", + "blake2b_simd", + "corez", + "ff", + "fpe", + "getset", + "group", + "halo2_gadgets", + "halo2_poseidon", + "halo2_proofs", + "hex", + "incrementalmerkletree", + "lazy_static", + "memuse", + "nonempty", + "pasta_curves", + "proptest", + "rand 0.8.5", + "rand_core 0.6.4", + "reddsa", + "serde", + "sinsemilla", + "subtle", + "tracing", + "visibility", + "zcash_note_encryption", + "zcash_spec", + "zip32", +] + [[package]] name = "valuable" version = "0.1.0" @@ -7065,7 +7066,6 @@ dependencies = [ "jubjub", "memuse", "nonempty", - "orchard", "pasta_curves", "pczt", "percent-encoding", @@ -7096,6 +7096,7 @@ dependencies = [ "tower", "tracing", "trait-variant", + "valar-orchard", "webpki-roots 1.0.3", "which", "zcash_address", @@ -7125,7 +7126,6 @@ dependencies = [ "incrementalmerkletree", "jubjub", "nonempty", - "orchard", "postcard", "proptest", "prost", @@ -7143,6 +7143,7 @@ dependencies = [ "time", "tokio", "tracing", + "valar-orchard", "wasm_sync", "which", "zcash_address", @@ -7175,7 +7176,6 @@ dependencies = [ "jubjub", "maybe-rayon", "nonempty", - "orchard", "pasta_curves", "proptest", "prost", @@ -7198,6 +7198,7 @@ dependencies = [ "time", "tracing", "uuid", + "valar-orchard", "zcash_address", "zcash_client_backend", "zcash_encoding", @@ -7229,9 +7230,9 @@ dependencies = [ "blake2b_simd", "ff", "jubjub", - "orchard", "rand_core 0.6.4", "sapling-crypto", + "valar-orchard", "zcash_address", "zcash_primitives", "zcash_proofs", @@ -7269,7 +7270,6 @@ dependencies = [ "jubjub", "memuse", "nonempty", - "orchard", "proptest", "rand 0.8.5", "rand_chacha 0.3.1", @@ -7280,6 +7280,7 @@ dependencies = [ "secrecy", "subtle", "tracing", + "valar-orchard", "zcash_address", "zcash_encoding", "zcash_protocol", @@ -7321,7 +7322,6 @@ dependencies = [ "jubjub", "memuse", "nonempty", - "orchard", "pprof", "proptest", "rand_core 0.6.4", @@ -7330,6 +7330,7 @@ dependencies = [ "sapling-crypto", "secp256k1", "sha2 0.10.8", + "valar-orchard", "zcash_encoding", "zcash_note_encryption", "zcash_protocol", diff --git a/Cargo.toml b/Cargo.toml index 6dff6ec5b0..aeefb4f536 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,7 +68,14 @@ redjubjub = { version = "0.8", default-features = false } sapling = { package = "sapling-crypto", version = "0.6.2", default-features = false } # - Orchard -orchard = { version = "0.12", default-features = false } +# +# Points at the Valar Group `valar-orchard` fork on crates.io, aliased locally to +# `orchard` so every member still writes `use orchard::…`. The fork is +# upstream-0.12.0 + post-release fixes (up to zcash/orchard 6b12c77) + governance- +# visibility additions needed by the shielded-voting circuits in +# valargroup/voting-circuits. Drop back to `orchard = "0.12"` once those +# visibility changes land in zcash/orchard upstream. +orchard = { version = "0.12.0", package = "valar-orchard", default-features = false } pasta_curves = "0.5" # - Transparent @@ -231,4 +238,6 @@ unexpected_cfgs = { level = "warn", check-cfg = [ [patch.crates-io] sapling = { package = "sapling-crypto", git = "https://github.com/zcash/sapling-crypto.git", rev = "b8a81c22f034d68f9bbd6cba728aab807b9ba2ea" } -orchard = { package = "orchard", git = "https://github.com/zcash/orchard.git", rev = "b0bf2670e248958c6ce7c1deed466032e0dbd4d9" } +# No orchard patch: the workspace resolves `orchard` via its `package = "valar-orchard"` +# alias on crates.io, which already carries the post-0.12.0 upstream fixes plus the +# governance-visibility additions. diff --git a/pczt/CHANGELOG.md b/pczt/CHANGELOG.md index 81875c8457..55d2f55353 100644 --- a/pczt/CHANGELOG.md +++ b/pczt/CHANGELOG.md @@ -14,6 +14,7 @@ workspace. - `pczt::ExtractError` - `pczt::EffectsOnly` - `pczt::orchard::Spend::spend_auth_sig` getter (via `getset`). +- `pczt::roles::signer::Signer::shielded_sighash` getter. - `pczt::roles::signer`: - `Signer::sighash` - `Signer::append_transparent_signature` diff --git a/pczt/src/roles/signer/mod.rs b/pczt/src/roles/signer/mod.rs index 4235a3b473..82820232f0 100644 --- a/pczt/src/roles/signer/mod.rs +++ b/pczt/src/roles/signer/mod.rs @@ -80,7 +80,7 @@ impl Signer { /// Calculates the signature digest that must be signed to authorize shielded spends. /// /// This can be used to produce a signature externally suitable for passing to e.g. - /// [`Self::apply_orchard_signature`].} + /// [`Self::apply_orchard_signature`]. pub fn shielded_sighash(&self) -> [u8; 32] { self.shielded_sighash } @@ -89,7 +89,7 @@ impl Signer { /// spend at the given index. /// /// This can be used to produce a signature externally suitable for passing to e.g. - /// [`Self::append_transparent_signature`].} + /// [`Self::append_transparent_signature`]. /// /// Returns an error if `index` is invalid for this PCZT. pub fn transparent_sighash(&self, index: usize) -> Result<[u8; 32], Error> { diff --git a/supply-chain/config.toml b/supply-chain/config.toml index 64757fc85b..8983c511b4 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -844,10 +844,6 @@ criteria = "safe-to-deploy" version = "11.1.4" criteria = "safe-to-deploy" -[[exemptions.orchard]] -version = "0.12.0@git:b0bf2670e248958c6ce7c1deed466032e0dbd4d9" -criteria = "safe-to-deploy" - [[exemptions.ordered-float]] version = "2.10.1" criteria = "safe-to-deploy" @@ -1588,6 +1584,10 @@ criteria = "safe-to-deploy" version = "1.8.0" criteria = "safe-to-deploy" +[[exemptions.valar-orchard]] +version = "0.12.0" +criteria = "safe-to-deploy" + [[exemptions.wait-timeout]] version = "0.2.0" criteria = "safe-to-deploy" diff --git a/zcash_client_sqlite/CHANGELOG.md b/zcash_client_sqlite/CHANGELOG.md index 554ae9d159..e229d0131b 100644 --- a/zcash_client_sqlite/CHANGELOG.md +++ b/zcash_client_sqlite/CHANGELOG.md @@ -30,6 +30,22 @@ workspace. - `impl<'conn, P, CL, R> WalletWrite for WalletDb, P, CL, R>` to enable calling `WalletWrite` methods inside `WalletDb::transactionally` (amortizing the database transaction overhead). +- `WalletDb::get_unspent_orchard_notes_at_historical_height` returns all Orchard + notes that existed and were unspent at a given height. +- `WalletDb::generate_orchard_witnesses_at_historical_height` generates Merkle + witnesses at a historical height using an ephemeral in-memory + `shardtree::store::memory::MemoryShardStore`. +- Two new `orchard`-gated variants have been added to + `zcash_client_sqlite::error::SqliteClientError` to surface the failure modes + of `WalletDb::generate_orchard_witnesses_at_historical_height`: + - `HistoricalFrontierInvalid(shardtree::error::InsertionError)` — + the caller-supplied frontier is inconsistent with the shard data + reconstructed from the wallet at the requested height. + - `HistoricalWitnessUnavailable { position, height }` — no witness can be + produced for the specified position at the specified height (the wallet + most likely has not synced through that height). + Shard-read failures continue to surface via the existing + `SqliteClientError::CommitmentTree` variant. ### Changed - The `accounts` table now stores IVK item caches instead of FVK item caches for diff --git a/zcash_client_sqlite/src/error.rs b/zcash_client_sqlite/src/error.rs index db35dd18d4..6a53dddfbe 100644 --- a/zcash_client_sqlite/src/error.rs +++ b/zcash_client_sqlite/src/error.rs @@ -3,7 +3,11 @@ use std::error; use std::fmt; +#[cfg(feature = "orchard")] +use incrementalmerkletree::Position; use nonempty::NonEmpty; +#[cfg(feature = "orchard")] +use shardtree::error::InsertionError; use shardtree::error::ShardTreeError; #[cfg(feature = "transparent-key-import")] @@ -116,6 +120,35 @@ pub enum SqliteClientError { /// commitment trees. CommitmentTree(ShardTreeError), + /// The caller-supplied frontier passed to + /// [`WalletDb::generate_orchard_witnesses_at_historical_height`] is + /// inconsistent with the shard data reconstructed from the wallet at the + /// requested height. + /// + /// [`WalletDb::generate_orchard_witnesses_at_historical_height`]: + /// crate::WalletDb::generate_orchard_witnesses_at_historical_height + #[cfg(feature = "orchard")] + HistoricalFrontierInvalid(InsertionError), + + /// A witness could not be generated for the specified position at the + /// specified historical height in a call to + /// [`WalletDb::generate_orchard_witnesses_at_historical_height`]. + /// + /// The wallet most likely has not synced through `height`, the checkpoint + /// at `height` has been pruned, or `position` does not belong to the + /// wallet. + /// + /// [`WalletDb::generate_orchard_witnesses_at_historical_height`]: + /// crate::WalletDb::generate_orchard_witnesses_at_historical_height + #[cfg(feature = "orchard")] + HistoricalWitnessUnavailable { + /// The note commitment tree position for which a witness was + /// requested. + position: Position, + /// The historical height at which the witness was requested. + height: BlockHeight, + }, + /// The block at the specified height was not available from the block cache. CacheMiss(BlockHeight), @@ -187,6 +220,8 @@ impl error::Error for SqliteClientError { SqliteClientError::Io(e) => Some(e), SqliteClientError::BalanceError(e) => Some(e), SqliteClientError::AddressGeneration(e) => Some(e), + #[cfg(feature = "orchard")] + SqliteClientError::HistoricalFrontierInvalid(e) => Some(e), _ => None, } } @@ -272,6 +307,17 @@ impl fmt::Display for SqliteClientError { f, "An error occurred accessing or updating note commitment tree data: {err}." ), + #[cfg(feature = "orchard")] + SqliteClientError::HistoricalFrontierInvalid(err) => write!( + f, + "The frontier supplied to generate_orchard_witnesses_at_historical_height is inconsistent with the wallet's shard data: {err}" + ), + #[cfg(feature = "orchard")] + SqliteClientError::HistoricalWitnessUnavailable { position, height } => write!( + f, + "No witness is available for position {} at height {height} (the wallet may need to sync through this height).", + u64::from(*position), + ), SqliteClientError::CacheMiss(height) => write!( f, "Requested height {height} does not exist in the block cache." diff --git a/zcash_client_sqlite/src/lib.rs b/zcash_client_sqlite/src/lib.rs index 321d9a3395..3b2d87e042 100644 --- a/zcash_client_sqlite/src/lib.rs +++ b/zcash_client_sqlite/src/lib.rs @@ -2400,6 +2400,83 @@ impl<'a, C: Borrow>, P: consensus::Parameters, CL: Clo } } +#[cfg(feature = "orchard")] +impl, P: consensus::Parameters, CL, R> WalletDb { + /// Return all Orchard notes received at or before `height` + /// and unspent as of that height, for the given account. + /// + /// Unlike [`InputSource::select_unspent_notes`] (which applies confirmation, + /// dust, and expiry filters for transaction construction), this returns every + /// note that existed and was unspent at the given height. + /// + /// This function does not verify that a Merkle witness can be constructed + /// for each returned note at `height`. Witness construction is a separate + /// concern intended to be handled by the callers. As an example, a companion + /// `WalletDb::generate_orchard_witnesses_at_historical_height` returns an + /// actionable error for any position the wallet cannot witness at `height` + /// (for example, because the wallet has not synced through `height`, the checkpoint was pruned, + /// or the position does not belong to the wallet). + pub fn get_unspent_orchard_notes_at_historical_height( + &self, + account: AccountUuid, + height: BlockHeight, + ) -> Result>, SqliteClientError> { + wallet::orchard::get_unspent_orchard_notes_at_historical_height( + self.conn.borrow(), + &self.params, + account, + height, + ) + } + + /// Generates Orchard Merkle witnesses at a historical height. + /// + /// Loads the wallet's Orchard shard data into an ephemeral in-memory + /// `ShardStore`, inserts the provided frontier at `height` as a checkpoint, + /// and generates a witness for each of the given note positions. + /// + /// The caller must provide the valid frontier at the given height. The wallet DB + /// is strictly read-only; shard data is read but not modified. + /// + /// # Errors + /// + /// Returns: + /// - [`SqliteClientError::CommitmentTree`] if reading the wallet's shard + /// or cap data fails, or if the shard data reconstructed from the + /// wallet is internally inconsistent at a node the computation + /// requires. + /// - [`SqliteClientError::HistoricalFrontierInvalid`] if + /// `frontier_at_height` is inconsistent with the shard data + /// reconstructed from the wallet at `height`. + /// - [`SqliteClientError::HistoricalWitnessUnavailable`] if a witness + /// cannot be generated for one of `note_positions` at `height` (most + /// commonly because the wallet has not yet synced through that + /// height). + pub fn generate_orchard_witnesses_at_historical_height( + &self, + note_positions: &[Position], + frontier_at_height: incrementalmerkletree::frontier::NonEmptyFrontier< + orchard::tree::MerkleHashOrchard, + >, + height: BlockHeight, + ) -> Result< + Vec< + incrementalmerkletree::MerklePath< + orchard::tree::MerkleHashOrchard, + { orchard::NOTE_COMMITMENT_TREE_DEPTH as u8 }, + >, + >, + SqliteClientError, + > { + wallet::commitment_tree::generate_orchard_witnesses_at_historical_height( + self.conn.borrow(), + note_positions, + frontier_at_height, + height, + ) + } +} + /// A handle for the SQLite block source. pub struct BlockDb(rusqlite::Connection); diff --git a/zcash_client_sqlite/src/wallet/commitment_tree.rs b/zcash_client_sqlite/src/wallet/commitment_tree.rs index e1a9ad516e..c23e5b714d 100644 --- a/zcash_client_sqlite/src/wallet/commitment_tree.rs +++ b/zcash_client_sqlite/src/wallet/commitment_tree.rs @@ -29,7 +29,14 @@ use zcash_protocol::{ShieldedProtocol, consensus::BlockHeight}; use crate::{error::SqliteClientError, sapling_tree}; #[cfg(feature = "orchard")] -use crate::orchard_tree; +use incrementalmerkletree::Marking; +#[cfg(feature = "orchard")] +use shardtree::{ShardTree, store::memory::MemoryShardStore}; +#[cfg(feature = "orchard")] +use zcash_client_backend::data_api::ORCHARD_SHARD_HEIGHT; + +#[cfg(feature = "orchard")] +use crate::{ORCHARD_TABLES_PREFIX, orchard_tree}; use super::common::{TableConstants, table_constants}; @@ -1171,6 +1178,138 @@ pub(crate) fn check_witnesses( Ok(scan_ranges) } +/// Generate Orchard Merkle witnesses at a historical height. +/// +/// Loads the wallet's Orchard shard data into an ephemeral in-memory +/// [`MemoryShardStore`], inserts the provided frontier at that height as a +/// checkpoint, and generates a witness for each of the given note positions. +/// +/// It is assumed that the caller provides the valid frontier at the given height. +/// +/// How it works: +/// To construct witnesses at a historical height, we need: +/// 1. Authentication path within each note's shard — the scanner marks the +/// wallet's notes as MARKED, preventing them and their siblings within a +/// shard from being pruned. +/// 2. Cap — the upper tree above the shard level. +/// 3. Frontier — the right edge at the historical height. It lets ShardTree +/// know exactly where the tree ended at that height. +/// +/// The wallet automatically prunes the tree after PRUNING_DEPTH checkpoints. +/// These three components are sufficient to reconstruct the tree structure +/// needed for witness generation even after pruning has occurred. +/// +/// The wallet DB is strictly read-only. Shard data is read, decoded, and +/// inserted into an ephemeral in-memory [`ShardStore`] to avoid tampering with +/// the primary wallet DB. +/// +/// Example application: token holder voting. The wallet tree may have advanced past +/// the historical height, but we need witnesses anchored at that frontier. +/// +/// # Errors +/// +/// - [`SqliteClientError::CommitmentTree`] if reading the wallet's shard or +/// cap data fails, or if the shard data reconstructed from the wallet is +/// internally inconsistent at a node the computation requires. +/// - [`SqliteClientError::HistoricalFrontierInvalid`] if `frontier_at_height` +/// is inconsistent with the shard data reconstructed from the wallet. +/// - [`SqliteClientError::HistoricalWitnessUnavailable`] if a witness cannot +/// be generated for one of the requested positions at `height` (most +/// commonly because the wallet has not yet synced through that height). +#[cfg(feature = "orchard")] +pub(crate) fn generate_orchard_witnesses_at_historical_height( + conn: &rusqlite::Connection, + note_positions: &[Position], + frontier_at_height: incrementalmerkletree::frontier::NonEmptyFrontier< + orchard::tree::MerkleHashOrchard, + >, + height: BlockHeight, +) -> Result< + Vec< + incrementalmerkletree::MerklePath< + orchard::tree::MerkleHashOrchard, + { orchard::NOTE_COMMITMENT_TREE_DEPTH as u8 }, + >, + >, + SqliteClientError, +> { + // `get_shard_roots` returns addresses ordered by shard index, matching the + // ascending insertion order required by `MemoryShardStore::put_shard`. + // Storage errors flow through `From>` + // into `SqliteClientError::CommitmentTree`. + let mut store = MemoryShardStore::::empty(); + let shard_root_level = Level::new(ORCHARD_SHARD_HEIGHT); + let shard_roots = get_shard_roots(conn, ORCHARD_TABLES_PREFIX, shard_root_level) + .map_err(ShardTreeError::Storage)?; + for shard_root in shard_roots { + if let Some(shard) = + get_shard::(conn, ORCHARD_TABLES_PREFIX, shard_root) + .map_err(ShardTreeError::Storage)? + { + store.put_shard(shard).expect("put_shard is infallible"); + } + } + let cap = get_cap::(conn, ORCHARD_TABLES_PREFIX) + .map_err(ShardTreeError::Storage)?; + store.put_cap(cap).expect("put_cap is infallible"); + + // Only one checkpoint is needed (the historical frontier), but ShardTree + // requires a nonzero checkpoint limit. + let mut tree = + ShardTree::<_, { orchard::NOTE_COMMITMENT_TREE_DEPTH as u8 }, ORCHARD_SHARD_HEIGHT>::new( + store, 1, + ); + + // Insert the frontier. `Retention::Checkpoint` causes `ShardTree` to + // register a checkpoint at `height` internally. + // + // `MemoryShardStore::Error` is `Infallible`, so the only variants of + // `ShardTreeError` we can observe are `Insert` (a caller-supplied + // frontier inconsistent with the loaded shards) and `Query` (an + // inconsistency in the loaded shards themselves). + tree.insert_frontier_nodes( + frontier_at_height, + Retention::Checkpoint { + id: height, + marking: Marking::None, + }, + ) + .map_err(|e| match e { + ShardTreeError::Insert(e) => SqliteClientError::HistoricalFrontierInvalid(e), + ShardTreeError::Query(q) => SqliteClientError::CommitmentTree(ShardTreeError::Query(q)), + ShardTreeError::Storage(inf) => match inf {}, + })?; + + // Generate a witness per note position. Any `Query` failure (or a `None` + // result) at this stage means the tree reconstructed from the wallet's + // shards does not contain enough information to compute a witness at + // `height`; we surface that as `HistoricalWitnessUnavailable` so the + // caller can either sync further or stop requesting that position. + let mut witnesses = Vec::with_capacity(note_positions.len()); + for &pos in note_positions { + let merkle_path = tree + .witness_at_checkpoint_id(pos, &height) + .map_err(|e| match e { + ShardTreeError::Query(_) => SqliteClientError::HistoricalWitnessUnavailable { + position: pos, + height, + }, + ShardTreeError::Insert(i) => { + SqliteClientError::CommitmentTree(ShardTreeError::Insert(i)) + } + ShardTreeError::Storage(inf) => match inf {}, + })? + .ok_or(SqliteClientError::HistoricalWitnessUnavailable { + position: pos, + height, + })?; + + witnesses.push(merkle_path); + } + + Ok(witnesses) +} + #[cfg(test)] mod tests { use tempfile::NamedTempFile; @@ -1257,6 +1396,11 @@ mod tests { super::check_rewind_remove_mark(new_tree::); } + #[test] + fn witnesses_at_historical_height() { + super::witnesses_at_historical_height() + } + #[test] fn put_shard_roots() { super::put_shard_roots::() @@ -1373,4 +1517,91 @@ mod tests { ] ); } + + /// Test that `generate_orchard_witnesses_at_historical_height` produces valid + /// witnesses when given a frontier extracted from an earlier tree state. + #[cfg(feature = "orchard")] + fn witnesses_at_historical_height() { + 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); + + // We build two parallel trees: the wallet's ShardTree (persisted to the DB) + // and a lightweight Frontier that captures the tree state at the historical height. + let mut frontier_tree: Frontier = Frontier::empty(); + let historical_height = BlockHeight::from(100); + let note_position = Position::from(2); + 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); + + let mut leaves = Vec::new(); + for _ in 0u64..5 { + leaves.push(MerkleHashOrchard::random(&mut rng)); + } + note_leaf = leaves[u64::from(note_position) as usize]; + + for (i, &leaf) in leaves.iter().enumerate() { + let retention = if i == u64::from(note_position) as usize { + Retention::Marked + } else { + Retention::Ephemeral + }; + tree.append(leaf, retention).unwrap(); + frontier_tree.append(leaf); + } + + // Advance the tree past the historical height, simulating the + // wallet continuing to sync afterward. + tree.checkpoint(historical_height).unwrap(); + for _ in 0..5 { + tree.append(MerkleHashOrchard::random(&mut rng), Retention::Ephemeral) + .unwrap(); + } + tree.checkpoint(BlockHeight::from(200)).unwrap(); + + tx.commit().unwrap(); + } + + let expected_root = frontier_tree.root(); + let frontier = frontier_tree.take().expect("frontier is non-empty"); + + let witnesses = super::generate_orchard_witnesses_at_historical_height( + &db_data.conn, + &[note_position], + frontier, + historical_height, + ) + .expect("witness generation should succeed"); + + assert_eq!(witnesses.len(), 1); + assert_eq!(witnesses[0].root(note_leaf), expected_root); + } } diff --git a/zcash_client_sqlite/src/wallet/init.rs b/zcash_client_sqlite/src/wallet/init.rs index 1ba396cbaf..9eaa90ddc0 100644 --- a/zcash_client_sqlite/src/wallet/init.rs +++ b/zcash_client_sqlite/src/wallet/init.rs @@ -250,6 +250,11 @@ fn sqlite_client_error_to_wallet_migration_error(e: SqliteClientError) -> Wallet SqliteClientError::StandaloneImportConflict(_) => { unreachable!("we do not import standalone transparent addresses in migrations") } + #[cfg(feature = "orchard")] + SqliteClientError::HistoricalFrontierInvalid(_) + | SqliteClientError::HistoricalWitnessUnavailable { .. } => { + unreachable!("we do not generate historical witnesses in migrations") + } } } diff --git a/zcash_client_sqlite/src/wallet/orchard.rs b/zcash_client_sqlite/src/wallet/orchard.rs index 1d15380464..f00939d938 100644 --- a/zcash_client_sqlite/src/wallet/orchard.rs +++ b/zcash_client_sqlite/src/wallet/orchard.rs @@ -167,6 +167,74 @@ pub(crate) fn select_spendable_orchard_notes( ) } +/// Return all Orchard notes that were received at or before `height` +/// and unspent as of `height`, for the given account. +/// +/// Unlike `select_spendable_notes` (which applies confirmation, dust, and +/// expiry filters for transaction construction), this returns every note +/// that existed and was unspent at the given height. +/// +/// Height filtering uses `transactions.mined_height`, not `transactions.block`. +/// A transaction is considered to have occurred at its mined height as soon +/// as the wallet learns of that height (for example, from transparent UTXO +/// retrieval), even if the containing compact block has not been fully +/// scanned. In practice the two columns are equivalent for the notes this +/// query can return, because `nf IS NOT NULL` and +/// `commitment_tree_position IS NOT NULL` already require a scan of the +/// block that contains the receiving transaction. +/// +/// This function does not verify that a Merkle witness can be constructed +/// for each returned note at `height`. Witness construction is a separate +/// concern intended to be handled by the callers. As an example, a companion +/// `WalletDb::generate_orchard_witnesses_at_historical_height` returns an +/// actionable error for any position the wallet cannot witness at `height` +/// (for example, because the wallet has not synced through `height`, the checkpoint was pruned, +/// or the position does not belong to the wallet). +pub(crate) fn get_unspent_orchard_notes_at_historical_height( + conn: &Connection, + params: &P, + account: AccountUuid, + height: BlockHeight, +) -> Result>, SqliteClientError> { + let external_scope = KeyScope::EXTERNAL.encode(); + let internal_scope = KeyScope::INTERNAL.encode(); + + let mut stmt = conn.prepare_cached(&format!( + "SELECT + rn.id AS id, t.txid, rn.action_index, + rn.diversifier, rn.value, rn.rho, rn.rseed, rn.commitment_tree_position, + accounts.ufvk AS ufvk, rn.recipient_key_scope, + t.mined_height, + NULL AS max_shielding_input_height + FROM orchard_received_notes rn + INNER JOIN accounts ON accounts.id = rn.account_id + INNER JOIN transactions t ON t.id_tx = rn.transaction_id + WHERE accounts.uuid = :account_uuid + AND t.mined_height <= :height + AND rn.nf IS NOT NULL + AND rn.commitment_tree_position IS NOT NULL + AND rn.recipient_key_scope IN ({external_scope}, {internal_scope}) + AND accounts.ufvk IS NOT NULL + AND rn.id NOT IN ( + SELECT rns.orchard_received_note_id + FROM orchard_received_note_spends rns + JOIN transactions t_spend ON t_spend.id_tx = rns.transaction_id + WHERE t_spend.mined_height <= :height + ) + ORDER BY rn.commitment_tree_position", + ))?; + + let rows = stmt.query_and_then( + named_params![ + ":account_uuid": account.0, + ":height": u32::from(height), + ], + |row| to_received_note(params, row), + )?; + + rows.filter_map(|r| r.transpose()).collect() +} + pub(crate) fn ensure_address< T: ReceivedOrchardOutput, P: consensus::Parameters, @@ -639,4 +707,80 @@ pub(crate) mod tests { fn coinbase_only_filtering() { testing::pool::coinbase_only_filtering::(); } + + #[test] + #[cfg(feature = "orchard")] + fn get_unspent_orchard_notes_at_historical_height_boundary_heights() { + use zcash_client_backend::data_api::Account; + use zcash_client_backend::data_api::testing::{ + AddressType, TestBuilder, pool::ShieldedPoolTester, + }; + use zcash_primitives::block::BlockHash; + use zcash_protocol::value::Zatoshis; + + use crate::testing::{BlockCache, db::TestDbFactory}; + + let mut st = TestBuilder::new() + .with_data_store_factory(TestDbFactory::default()) + .with_block_cache(BlockCache::new()) + .with_account_from_sapling_activation(BlockHash([0; 32])) + .build(); + + let account = st.test_account().cloned().unwrap(); + let dfvk = OrchardPoolTester::test_account_fvk(&st); + + // Receive a note at h1 + let value = Zatoshis::const_from_u64(50000); + let (h1, _, nf) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value); + st.scan_cached_blocks(h1, 1); + + // Spend that note at h2 (produces change back to us) + let not_our_key = OrchardPoolTester::sk_to_fvk(&OrchardPoolTester::sk(&[0xf5; 32])); + let to = OrchardPoolTester::fvk_default_address(¬_our_key); + let spend_value = Zatoshis::const_from_u64(20000); + let (h2, _) = st.generate_next_block_spending(&dfvk, (nf, value), to, spend_value); + st.scan_cached_blocks(h2, 1); + + // Receive another note at h3 + let value3 = Zatoshis::const_from_u64(70000); + let (h3, _, _) = st.generate_next_block(&dfvk, AddressType::DefaultExternal, value3); + st.scan_cached_blocks(h3, 1); + + let db = st.wallet().db(); + + // Before any notes: nothing (h1 - 1 is before the note was mined) + let notes = db + .get_unspent_orchard_notes_at_historical_height(account.id(), h1 - 1) + .unwrap(); + assert_eq!(notes.len(), 0); + + // At h1: original note received and unspent + let notes = db + .get_unspent_orchard_notes_at_historical_height(account.id(), h1) + .unwrap(); + assert_eq!(notes.len(), 1); + assert_eq!(notes[0].note_value().unwrap(), value); + + // At h2: original spent, only change note remains + let notes = db + .get_unspent_orchard_notes_at_historical_height(account.id(), h2) + .unwrap(); + assert_eq!(notes.len(), 1); + assert_eq!( + notes[0].note_value().unwrap(), + (value - spend_value).unwrap() + ); + + // At h3: change note + new note + let notes = db + .get_unspent_orchard_notes_at_historical_height(account.id(), h3) + .unwrap(); + assert_eq!(notes.len(), 2); + let total: Zatoshis = notes + .iter() + .map(|n| n.note_value().unwrap()) + .sum::>() + .unwrap(); + assert_eq!(total, ((value - spend_value).unwrap() + value3).unwrap()); + } }