librustzcash/zcash_client_sqlite/src/wallet/init.rs

401 lines
13 KiB
Rust

//! Functions for initializing the various databases.
use rusqlite::{params, types::ToSql, NO_PARAMS};
use std::collections::HashMap;
use zcash_primitives::{
block::BlockHash,
consensus::{self, BlockHeight},
zip32::AccountId,
};
use zcash_client_backend::keys::UnifiedFullViewingKey;
use crate::{error::SqliteClientError, WalletDb};
#[cfg(feature = "transparent-inputs")]
use {
zcash_client_backend::encoding::AddressCodec,
zcash_primitives::legacy::keys::IncomingViewingKey,
};
/// Sets up the internal structure of the data database.
///
/// It is safe to use a wallet database created without the ability to create transparent spends
/// with a build that enables transparent spends via use of the `transparent-inputs` feature flag.
/// The reverse is unsafe, as wallet balance calculations would ignore the transparent UTXOs
/// controlled by the wallet. Note that this currently applies only to wallet databases created
/// with the same _version_ of the wallet software; database migration operations currently must
/// be manually performed to update the structure of the database when changing versions.
/// Integrated migration utilities will be provided by a future version of this library.
///
/// # Examples
///
/// ```
/// use tempfile::NamedTempFile;
/// use zcash_primitives::consensus::Network;
/// use zcash_client_sqlite::{
/// WalletDb,
/// wallet::init::init_wallet_db,
/// };
///
/// let data_file = NamedTempFile::new().unwrap();
/// let db = WalletDb::for_path(data_file.path(), Network::TestNetwork).unwrap();
/// init_wallet_db(&db).unwrap();
/// ```
pub fn init_wallet_db<P>(wdb: &WalletDb<P>) -> Result<(), rusqlite::Error> {
// TODO: Add migrations (https://github.com/zcash/librustzcash/issues/489)
// - extfvk column -> ufvk column
wdb.conn.execute(
"CREATE TABLE IF NOT EXISTS accounts (
account INTEGER PRIMARY KEY,
ufvk TEXT,
address TEXT,
transparent_address TEXT
)",
NO_PARAMS,
)?;
wdb.conn.execute(
"CREATE TABLE IF NOT EXISTS blocks (
height INTEGER PRIMARY KEY,
hash BLOB NOT NULL,
time INTEGER NOT NULL,
sapling_tree BLOB NOT NULL
)",
NO_PARAMS,
)?;
wdb.conn.execute(
"CREATE TABLE IF NOT EXISTS transactions (
id_tx INTEGER PRIMARY KEY,
txid BLOB NOT NULL UNIQUE,
created TEXT,
block INTEGER,
tx_index INTEGER,
expiry_height INTEGER,
raw BLOB,
FOREIGN KEY (block) REFERENCES blocks(height)
)",
NO_PARAMS,
)?;
wdb.conn.execute(
"CREATE TABLE IF NOT EXISTS received_notes (
id_note INTEGER PRIMARY KEY,
tx INTEGER NOT NULL,
output_index INTEGER NOT NULL,
account INTEGER NOT NULL,
diversifier BLOB NOT NULL,
value INTEGER NOT NULL,
rcm BLOB NOT NULL,
nf BLOB NOT NULL UNIQUE,
is_change INTEGER NOT NULL,
memo BLOB,
spent INTEGER,
FOREIGN KEY (tx) REFERENCES transactions(id_tx),
FOREIGN KEY (account) REFERENCES accounts(account),
FOREIGN KEY (spent) REFERENCES transactions(id_tx),
CONSTRAINT tx_output UNIQUE (tx, output_index)
)",
NO_PARAMS,
)?;
wdb.conn.execute(
"CREATE TABLE IF NOT EXISTS sapling_witnesses (
id_witness INTEGER PRIMARY KEY,
note INTEGER NOT NULL,
block INTEGER NOT NULL,
witness BLOB NOT NULL,
FOREIGN KEY (note) REFERENCES received_notes(id_note),
FOREIGN KEY (block) REFERENCES blocks(height),
CONSTRAINT witness_height UNIQUE (note, block)
)",
NO_PARAMS,
)?;
wdb.conn.execute(
"CREATE TABLE IF NOT EXISTS sent_notes (
id_note INTEGER PRIMARY KEY,
tx INTEGER NOT NULL,
output_pool INTEGER NOT NULL,
output_index INTEGER NOT NULL,
from_account INTEGER NOT NULL,
address TEXT NOT NULL,
value INTEGER NOT NULL,
memo BLOB,
FOREIGN KEY (tx) REFERENCES transactions(id_tx),
FOREIGN KEY (from_account) REFERENCES accounts(account),
CONSTRAINT tx_output UNIQUE (tx, output_pool, output_index)
)",
NO_PARAMS,
)?;
wdb.conn.execute(
"CREATE TABLE IF NOT EXISTS utxos (
id_utxo INTEGER PRIMARY KEY,
address TEXT NOT NULL,
prevout_txid BLOB NOT NULL,
prevout_idx INTEGER NOT NULL,
script BLOB NOT NULL,
value_zat INTEGER NOT NULL,
height INTEGER NOT NULL,
spent_in_tx INTEGER,
FOREIGN KEY (spent_in_tx) REFERENCES transactions(id_tx),
CONSTRAINT tx_outpoint UNIQUE (prevout_txid, prevout_idx)
)",
NO_PARAMS,
)?;
Ok(())
}
/// Initialises the data database with the given [`UnifiedFullViewingKey`]s.
///
/// The [`UnifiedFullViewingKey`]s are stored internally and used by other APIs such as
/// [`get_address`], [`scan_cached_blocks`], and [`create_spend_to_address`]. `extfvks` **MUST**
/// be arranged in account-order; that is, the [`UnifiedFullViewingKey`] for ZIP 32
/// account `i` **MUST** be at `extfvks[i]`.
///
/// # Examples
///
/// ```
/// # #[cfg(feature = "transparent-inputs")]
/// # {
/// use tempfile::NamedTempFile;
/// use std::collections::HashMap;
///
/// use zcash_primitives::{
/// consensus::{Network, Parameters},
/// zip32::{AccountId, ExtendedFullViewingKey, ExtendedSpendingKey}
/// };
///
/// use zcash_client_backend::{
/// keys::{
/// sapling,
/// UnifiedFullViewingKey
/// },
/// };
///
/// use zcash_client_sqlite::{
/// WalletDb,
/// wallet::init::{init_accounts_table, init_wallet_db}
/// };
///
/// let data_file = NamedTempFile::new().unwrap();
/// let db_data = WalletDb::for_path(data_file.path(), Network::TestNetwork).unwrap();
/// init_wallet_db(&db_data).unwrap();
///
/// let seed = [0u8; 32]; // insecure; replace with a strong random seed
/// let account = AccountId::from(0);
/// let extsk = sapling::spending_key(&seed, Network::TestNetwork.coin_type(), account);
/// let dfvk = ExtendedFullViewingKey::from(&extsk).into();
/// let ufvk = UnifiedFullViewingKey::new(None, Some(dfvk), None).unwrap();
/// let ufvks = HashMap::from([(account, ufvk)]);
/// init_accounts_table(&db_data, &ufvks).unwrap();
/// # }
/// ```
///
/// [`get_address`]: crate::wallet::get_address
/// [`scan_cached_blocks`]: zcash_client_backend::data_api::chain::scan_cached_blocks
/// [`create_spend_to_address`]: zcash_client_backend::data_api::wallet::create_spend_to_address
pub fn init_accounts_table<P: consensus::Parameters>(
wdb: &WalletDb<P>,
keys: &HashMap<AccountId, UnifiedFullViewingKey>,
) -> Result<(), SqliteClientError> {
let mut empty_check = wdb.conn.prepare("SELECT * FROM accounts LIMIT 1")?;
if empty_check.exists(NO_PARAMS)? {
return Err(SqliteClientError::TableNotEmpty);
}
// Insert accounts atomically
wdb.conn.execute("BEGIN IMMEDIATE", NO_PARAMS)?;
for (account, key) in keys.iter() {
let ufvk_str: String = key.encode(&wdb.params);
let address_str: String = key.default_address().0.encode(&wdb.params);
#[cfg(feature = "transparent-inputs")]
let taddress_str: Option<String> = key.transparent().and_then(|k| {
k.derive_external_ivk()
.ok()
.map(|k| k.default_address().0.encode(&wdb.params))
});
#[cfg(not(feature = "transparent-inputs"))]
let taddress_str: Option<String> = None;
wdb.conn.execute(
"INSERT INTO accounts (account, ufvk, address, transparent_address)
VALUES (?, ?, ?, ?)",
params![<u32>::from(*account), ufvk_str, address_str, taddress_str],
)?;
}
wdb.conn.execute("COMMIT", NO_PARAMS)?;
Ok(())
}
/// Initialises the data database with the given block.
///
/// This enables a newly-created database to be immediately-usable, without needing to
/// synchronise historic blocks.
///
/// # Examples
///
/// ```
/// use tempfile::NamedTempFile;
/// use zcash_primitives::{
/// block::BlockHash,
/// consensus::{BlockHeight, Network},
/// };
/// use zcash_client_sqlite::{
/// WalletDb,
/// wallet::init::init_blocks_table,
/// };
///
/// // The block height.
/// let height = BlockHeight::from_u32(500_000);
/// // The hash of the block header.
/// let hash = BlockHash([0; 32]);
/// // The nTime field from the block header.
/// let time = 12_3456_7890;
/// // The serialized Sapling commitment tree as of this block.
/// // Pre-compute and hard-code, or obtain from a service.
/// let sapling_tree = &[];
///
/// let data_file = NamedTempFile::new().unwrap();
/// let db = WalletDb::for_path(data_file.path(), Network::TestNetwork).unwrap();
/// init_blocks_table(&db, height, hash, time, sapling_tree);
/// ```
pub fn init_blocks_table<P>(
wdb: &WalletDb<P>,
height: BlockHeight,
hash: BlockHash,
time: u32,
sapling_tree: &[u8],
) -> Result<(), SqliteClientError> {
let mut empty_check = wdb.conn.prepare("SELECT * FROM blocks LIMIT 1")?;
if empty_check.exists(NO_PARAMS)? {
return Err(SqliteClientError::TableNotEmpty);
}
wdb.conn.execute(
"INSERT INTO blocks (height, hash, time, sapling_tree)
VALUES (?, ?, ?, ?)",
&[
u32::from(height).to_sql()?,
hash.0.to_sql()?,
time.to_sql()?,
sapling_tree.to_sql()?,
],
)?;
Ok(())
}
#[cfg(test)]
#[allow(deprecated)]
mod tests {
use std::collections::HashMap;
use tempfile::NamedTempFile;
use zcash_client_backend::keys::{sapling, UnifiedFullViewingKey, UnifiedSpendingKey};
#[cfg(feature = "transparent-inputs")]
use zcash_primitives::legacy::keys as transparent;
use zcash_primitives::{
block::BlockHash,
consensus::{BlockHeight, Parameters},
sapling::keys::DiversifiableFullViewingKey,
zip32::ExtendedFullViewingKey,
};
use crate::{
tests::{self, network},
wallet::get_address,
AccountId, WalletDb,
};
use super::{init_accounts_table, init_blocks_table, init_wallet_db};
#[test]
fn init_accounts_table_only_works_once() {
let data_file = NamedTempFile::new().unwrap();
let db_data = WalletDb::for_path(data_file.path(), tests::network()).unwrap();
init_wallet_db(&db_data).unwrap();
// We can call the function as many times as we want with no data
init_accounts_table(&db_data, &HashMap::new()).unwrap();
init_accounts_table(&db_data, &HashMap::new()).unwrap();
let seed = [0u8; 32];
let account = AccountId::from(0);
// First call with data should initialise the accounts table
let extsk = sapling::spending_key(&seed, network().coin_type(), account);
let dfvk = DiversifiableFullViewingKey::from(ExtendedFullViewingKey::from(&extsk));
#[cfg(feature = "transparent-inputs")]
let ufvk = UnifiedFullViewingKey::new(
Some(
transparent::AccountPrivKey::from_seed(&network(), &seed, account)
.unwrap()
.to_account_pubkey(),
),
Some(dfvk),
None,
)
.unwrap();
#[cfg(not(feature = "transparent-inputs"))]
let ufvk = UnifiedFullViewingKey::new(Some(dfvk), None).unwrap();
let ufvks = HashMap::from([(account, ufvk)]);
init_accounts_table(&db_data, &ufvks).unwrap();
// Subsequent calls should return an error
init_accounts_table(&db_data, &HashMap::new()).unwrap_err();
init_accounts_table(&db_data, &ufvks).unwrap_err();
}
#[test]
fn init_blocks_table_only_works_once() {
let data_file = NamedTempFile::new().unwrap();
let db_data = WalletDb::for_path(data_file.path(), tests::network()).unwrap();
init_wallet_db(&db_data).unwrap();
// First call with data should initialise the blocks table
init_blocks_table(
&db_data,
BlockHeight::from(1u32),
BlockHash([1; 32]),
1,
&[],
)
.unwrap();
// Subsequent calls should return an error
init_blocks_table(
&db_data,
BlockHeight::from(2u32),
BlockHash([2; 32]),
2,
&[],
)
.unwrap_err();
}
#[test]
fn init_accounts_table_stores_correct_address() {
let data_file = NamedTempFile::new().unwrap();
let db_data = WalletDb::for_path(data_file.path(), tests::network()).unwrap();
init_wallet_db(&db_data).unwrap();
let seed = [0u8; 32];
// Add an account to the wallet
let account_id = AccountId::from(0);
let usk = UnifiedSpendingKey::from_seed(&tests::network(), &seed, account_id).unwrap();
let ufvk = usk.to_unified_full_viewing_key();
let expected_address = ufvk.sapling().unwrap().default_address().1;
let ufvks = HashMap::from([(account_id, ufvk)]);
init_accounts_table(&db_data, &ufvks).unwrap();
// The account's address should be in the data DB
let pa = get_address(&db_data, AccountId::from(0)).unwrap();
assert_eq!(pa.unwrap(), expected_address);
}
}