Skip to main content

zcash_client_sqlite/testing/
db.rs

1//! An in-memory [`WalletDb`]-backed data store and [`DataStoreFactory`] for the
2//! `zcash_client_backend` testing framework.
3
4use ambassador::Delegate;
5use rand::SeedableRng;
6use rand_chacha::ChaChaRng;
7use rusqlite::Connection;
8use std::num::NonZeroU32;
9use std::time::Duration;
10use std::{
11    collections::{HashMap, HashSet},
12    time::SystemTime,
13};
14use uuid::Uuid;
15
16use tempfile::NamedTempFile;
17
18use rusqlite::{self};
19use secrecy::SecretVec;
20use shardtree::{ShardTree, error::ShardTreeError};
21
22use zcash_client_backend::{
23    data_api::{
24        TargetValue,
25        anchor_retention::AnchorRetentionInterval,
26        chain::{ChainState, CommitmentTreeRoot},
27        error::{LockError, RewindError},
28        scanning::{ScanPriority, ScanRange},
29        testing::{DataStoreFactory, Reset, TestState},
30        wallet::{ConfirmationsPolicy, TargetHeight, input_selection::LockFilter},
31        *,
32    },
33    wallet::{LockOwner, Note, NoteId, OutputRef, ReceivedNote, WalletTransparentOutput},
34};
35use zcash_keys::{
36    address::UnifiedAddress,
37    keys::{UnifiedAddressRequest, UnifiedFullViewingKey, UnifiedSpendingKey},
38};
39use zcash_primitives::{
40    block::BlockHash,
41    transaction::{Transaction, TxId},
42};
43use zcash_protocol::{
44    ShieldedPool, consensus, consensus::BlockHeight, local_consensus::LocalNetwork, memo::Memo,
45    value::Zatoshis,
46};
47use zip32::DiversifierIndex;
48
49use crate::{
50    AccountUuid, WalletDb, error::SqliteClientError, util::testing::FixedClock,
51    wallet::init::WalletMigrator,
52};
53
54#[cfg(feature = "transparent-inputs")]
55use {
56    crate::TransparentAddressMetadata,
57    ::transparent::{address::TransparentAddress, bundle::OutPoint, keys::NonHardenedChildIndex},
58    core::ops::Range,
59    zcash_client_backend::fees::StandardFeeRule,
60    zcash_keys::keys::transparent::gap_limits::GapLimits,
61};
62
63/// Tuesday, 25 February 2025 00:00:00Z (the day the clock code was added).
64const TEST_EPOCH_SECONDS_OFFSET: Duration = Duration::from_secs(1740441600);
65
66pub(crate) fn test_clock() -> FixedClock {
67    FixedClock::new(SystemTime::UNIX_EPOCH + TEST_EPOCH_SECONDS_OFFSET)
68}
69
70pub(crate) fn test_rng() -> ChaChaRng {
71    ChaChaRng::from_seed([0u8; 32])
72}
73
74/// A [`WalletDb`] wrapped as a testing-framework data store: it delegates the wallet traits to the
75/// inner database and owns the temporary file backing it.
76#[allow(clippy::duplicated_attributes, reason = "False positive")]
77#[derive(Delegate)]
78#[delegate(InputSource, target = "wallet_db")]
79#[delegate(WalletRead, target = "wallet_db")]
80#[delegate(WalletTest, target = "wallet_db")]
81#[delegate(OutputLockStore, target = "wallet_db")]
82#[delegate(WalletWrite, target = "wallet_db")]
83#[delegate(WalletCommitmentTrees, target = "wallet_db")]
84pub struct TestDb {
85    wallet_db: WalletDb<Connection, LocalNetwork, FixedClock, ChaChaRng>,
86    data_file: Option<NamedTempFile>,
87}
88
89impl TestDb {
90    fn from_parts(
91        wallet_db: WalletDb<Connection, LocalNetwork, FixedClock, ChaChaRng>,
92        data_file: Option<NamedTempFile>,
93    ) -> Self {
94        Self {
95            wallet_db,
96            data_file,
97        }
98    }
99
100    /// The wrapped wallet database.
101    pub fn db(&self) -> &WalletDb<Connection, LocalNetwork, FixedClock, ChaChaRng> {
102        &self.wallet_db
103    }
104
105    /// The wrapped wallet database, mutably.
106    pub fn db_mut(&mut self) -> &mut WalletDb<Connection, LocalNetwork, FixedClock, ChaChaRng> {
107        &mut self.wallet_db
108    }
109
110    /// The wallet database's own SQLite connection, over which a sibling store (a
111    /// `pool_migration` store, say) is opened.
112    pub fn conn(&self) -> &Connection {
113        &self.wallet_db.conn
114    }
115
116    /// The wallet database's own SQLite connection, mutably. See [`Self::conn`].
117    pub fn conn_mut(&mut self) -> &mut Connection {
118        &mut self.wallet_db.conn
119    }
120
121    pub(crate) fn take_data_file(self) -> Option<NamedTempFile> {
122        self.data_file
123    }
124
125    #[allow(dead_code)]
126    pub(crate) fn data_file_path(&self) -> &std::path::Path {
127        self.data_file
128            .as_ref()
129            .expect("this test requires a file-backed TestDbFactory")
130            .path()
131    }
132
133    /// Dump the schema and contents of the given database table, in
134    /// sqlite3 ".dump" format. The name of the table must be a static
135    /// string. This assumes that `sqlite3` is on your path and that it
136    /// invokes a compatible version of sqlite3.
137    ///
138    /// # Panics
139    ///
140    /// Panics if `name` contains characters outside `[a-zA-Z_]`.
141    #[allow(dead_code)]
142    #[cfg(feature = "unstable")]
143    pub(crate) fn dump_table(&self, name: &'static str) {
144        assert!(name.chars().all(|c| c.is_ascii_alphabetic() || c == '_'));
145        unsafe {
146            run_sqlite3(self.data_file_path(), &format!(r#".dump "{name}""#));
147        }
148    }
149
150    /// Print the results of an arbitrary sqlite3 command (with "-safe"
151    /// and "-readonly" flags) to stderr. This is completely insecure and
152    /// should not be exposed in production. Use of the "-safe" and
153    /// "-readonly" flags is intended only to limit *accidental* misuse.
154    /// The output is unfiltered, and control codes could mess up your
155    /// terminal. This assumes that `sqlite3` is on your path and that it
156    /// invokes a compatible version of sqlite3.
157    #[allow(dead_code)]
158    #[cfg(feature = "unstable")]
159    pub(crate) unsafe fn run_sqlite3(&self, command: &str) {
160        unsafe { run_sqlite3(self.data_file_path(), command) }
161    }
162}
163
164#[cfg(feature = "unstable")]
165use std::{ffi::OsStr, process::Command};
166
167// See the doc comment for `TestState::run_sqlite3` above.
168//
169// - `db_path` is the path to the database file.
170// - `command` may contain newlines.
171#[allow(dead_code)]
172#[cfg(feature = "unstable")]
173unsafe fn run_sqlite3<S: AsRef<OsStr>>(db_path: S, command: &str) {
174    let output = Command::new("sqlite3")
175        .arg(db_path)
176        .arg("-safe")
177        .arg("-readonly")
178        .arg(command)
179        .output()
180        .expect("failed to execute sqlite3 process");
181
182    eprintln!(
183        "{}\n------\n{}",
184        command,
185        String::from_utf8_lossy(&output.stdout)
186    );
187    if !output.stderr.is_empty() {
188        eprintln!(
189            "------ stderr:\n{}",
190            String::from_utf8_lossy(&output.stderr)
191        );
192    }
193    eprintln!("------");
194}
195
196/// A [`DataStoreFactory`] that builds fresh in-memory [`TestDb`] wallets, optionally migrated only
197/// to a given set of migrations rather than all of them.
198///
199/// Tests that exercise reopening or multiple independent connections can opt into file-backed
200/// storage with [`Self::file_backed`].
201#[derive(Default)]
202pub struct TestDbFactory {
203    target_migrations: Option<Vec<Uuid>>,
204    file_backed: bool,
205}
206
207impl TestDbFactory {
208    /// Constructs a factory for tests that require a database file.
209    pub fn file_backed() -> Self {
210        Self {
211            target_migrations: None,
212            file_backed: true,
213        }
214    }
215}
216
217impl DataStoreFactory for TestDbFactory {
218    type Error = ();
219    type AccountId = AccountUuid;
220    type Account = crate::wallet::Account;
221    type DsError = SqliteClientError;
222    type DataStore = TestDb;
223
224    fn new_data_store(
225        &self,
226        network: LocalNetwork,
227        anchor_retention_interval: Option<AnchorRetentionInterval>,
228        #[cfg(feature = "transparent-inputs")] gap_limits: Option<GapLimits>,
229    ) -> Result<Self::DataStore, Self::Error> {
230        let (mut db_data, data_file) = if self.file_backed {
231            let data_file = NamedTempFile::new().unwrap();
232            let db_data =
233                WalletDb::for_path(data_file.path(), network, test_clock(), test_rng()).unwrap();
234            (db_data, Some(data_file))
235        } else {
236            let conn = Connection::open_in_memory().unwrap();
237            rusqlite::vtab::array::load_module(&conn).unwrap();
238            (
239                WalletDb::from_connection(conn, network, test_clock(), test_rng()),
240                None,
241            )
242        };
243        if let Some(interval) = anchor_retention_interval {
244            db_data = db_data.with_anchor_retention_interval(interval);
245        }
246        #[cfg(feature = "transparent-inputs")]
247        if let Some(gap_limits) = gap_limits {
248            db_data = db_data.with_gap_limits(gap_limits);
249        }
250
251        let migrator = WalletMigrator::new();
252        if let Some(migrations) = &self.target_migrations {
253            migrator
254                .init_or_migrate_to(&mut db_data, migrations)
255                .expect("wallet migration succeeds for test setup with target migrations");
256        } else {
257            migrator
258                .init_or_migrate(&mut db_data)
259                .expect("wallet migration succeeds for test setup with default migrations");
260        }
261        Ok(TestDb::from_parts(db_data, data_file))
262    }
263}
264
265impl Reset for TestDb {
266    type Handle = Option<NamedTempFile>;
267
268    fn reset<C>(st: &mut TestState<C, Self, LocalNetwork>) -> Self::Handle {
269        let network = *st.network();
270        let anchor_retention_interval = st.wallet().db().anchor_retention_interval;
271        #[cfg(feature = "transparent-inputs")]
272        let gap_limits = st.wallet().db().gap_limits;
273        let old_db = std::mem::replace(
274            st.wallet_mut(),
275            TestDbFactory::default()
276                .new_data_store(
277                    network,
278                    Some(anchor_retention_interval),
279                    #[cfg(feature = "transparent-inputs")]
280                    Some(gap_limits),
281                )
282                .unwrap(),
283        );
284        old_db.take_data_file()
285    }
286}