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