librustzcash/zcash_client_sqlite/src/wallet/init.rs

1562 lines
66 KiB
Rust

//! Functions for initializing the various databases.
use std::fmt;
use std::rc::Rc;
use schemer::{Migrator, MigratorError};
use schemer_rusqlite::RusqliteAdapter;
use secrecy::SecretVec;
use shardtree::error::ShardTreeError;
use uuid::Uuid;
use zcash_client_backend::{
data_api::{SeedRelevance, WalletRead},
keys::AddressGenerationError,
};
use zcash_primitives::{consensus, transaction::components::amount::BalanceError};
use super::commitment_tree;
use crate::{error::SqliteClientError, WalletDb};
mod migrations;
#[derive(Debug)]
pub enum WalletMigrationError {
/// The seed is required for the migration.
SeedRequired,
/// A seed was provided that is not relevant to any of the accounts within the wallet.
///
/// Specifically, it is not relevant to any account for which [`Account::source`] is
/// [`AccountSource::Derived`]. We do not check whether the seed is relevant to any
/// imported account, because that would require brute-forcing the ZIP 32 account
/// index space.
///
/// [`Account::source`]: zcash_client_backend::data_api::Account::source
/// [`AccountSource::Derived`]: zcash_client_backend::data_api::AccountSource::Derived
SeedNotRelevant,
/// Decoding of an existing value from its serialized form has failed.
CorruptedData(String),
/// An error occurred in migrating a Zcash address or key.
AddressGeneration(AddressGenerationError),
/// Wrapper for rusqlite errors.
DbError(rusqlite::Error),
/// Wrapper for amount balance violations
BalanceError(BalanceError),
/// Wrapper for commitment tree invariant violations
CommitmentTree(ShardTreeError<commitment_tree::Error>),
/// Reverting the specified migration is not supported.
CannotRevert(Uuid),
}
impl From<rusqlite::Error> for WalletMigrationError {
fn from(e: rusqlite::Error) -> Self {
WalletMigrationError::DbError(e)
}
}
impl From<BalanceError> for WalletMigrationError {
fn from(e: BalanceError) -> Self {
WalletMigrationError::BalanceError(e)
}
}
impl From<ShardTreeError<commitment_tree::Error>> for WalletMigrationError {
fn from(e: ShardTreeError<commitment_tree::Error>) -> Self {
WalletMigrationError::CommitmentTree(e)
}
}
impl From<AddressGenerationError> for WalletMigrationError {
fn from(e: AddressGenerationError) -> Self {
WalletMigrationError::AddressGeneration(e)
}
}
impl fmt::Display for WalletMigrationError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match &self {
WalletMigrationError::SeedRequired => {
write!(
f,
"The wallet seed is required in order to update the database."
)
}
WalletMigrationError::SeedNotRelevant => {
write!(
f,
"The provided seed is not relevant to any derived accounts in the database."
)
}
WalletMigrationError::CorruptedData(reason) => {
write!(f, "Wallet database is corrupted: {}", reason)
}
WalletMigrationError::DbError(e) => write!(f, "{}", e),
WalletMigrationError::BalanceError(e) => write!(f, "Balance error: {:?}", e),
WalletMigrationError::CommitmentTree(e) => write!(f, "Commitment tree error: {:?}", e),
WalletMigrationError::AddressGeneration(e) => {
write!(f, "Address generation error: {:?}", e)
}
WalletMigrationError::CannotRevert(uuid) => {
write!(f, "Reverting migration {} is not supported", uuid)
}
}
}
}
impl std::error::Error for WalletMigrationError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match &self {
WalletMigrationError::DbError(e) => Some(e),
WalletMigrationError::BalanceError(e) => Some(e),
WalletMigrationError::CommitmentTree(e) => Some(e),
WalletMigrationError::AddressGeneration(e) => Some(e),
_ => None,
}
}
}
/// Helper to enable calling regular `WalletDb` methods inside the migration code.
///
/// In this context we can know the full set of errors that are generated by any call we
/// make, so we mark errors as unreachable instead of adding new `WalletMigrationError`
/// variants.
fn sqlite_client_error_to_wallet_migration_error(e: SqliteClientError) -> WalletMigrationError {
match e {
SqliteClientError::CorruptedData(e) => WalletMigrationError::CorruptedData(e),
SqliteClientError::Protobuf(e) => WalletMigrationError::CorruptedData(e.to_string()),
SqliteClientError::InvalidNote => {
WalletMigrationError::CorruptedData("invalid note".into())
}
SqliteClientError::DecodingError(e) => WalletMigrationError::CorruptedData(e.to_string()),
#[cfg(feature = "transparent-inputs")]
SqliteClientError::HdwalletError(e) => WalletMigrationError::CorruptedData(e.to_string()),
#[cfg(feature = "transparent-inputs")]
SqliteClientError::TransparentAddress(e) => {
WalletMigrationError::CorruptedData(e.to_string())
}
SqliteClientError::DbError(e) => WalletMigrationError::DbError(e),
SqliteClientError::Io(e) => WalletMigrationError::CorruptedData(e.to_string()),
SqliteClientError::InvalidMemo(e) => WalletMigrationError::CorruptedData(e.to_string()),
SqliteClientError::AddressGeneration(e) => WalletMigrationError::AddressGeneration(e),
SqliteClientError::BadAccountData(e) => WalletMigrationError::CorruptedData(e),
SqliteClientError::CommitmentTree(e) => WalletMigrationError::CommitmentTree(e),
SqliteClientError::UnsupportedPoolType(pool) => WalletMigrationError::CorruptedData(
format!("Wallet DB contains unsupported pool type {}", pool),
),
SqliteClientError::BalanceError(e) => WalletMigrationError::BalanceError(e),
SqliteClientError::TableNotEmpty => unreachable!("wallet already initialized"),
SqliteClientError::BlockConflict(_)
| SqliteClientError::NonSequentialBlocks
| SqliteClientError::RequestedRewindInvalid(_, _)
| SqliteClientError::KeyDerivationError(_)
| SqliteClientError::AccountIdDiscontinuity
| SqliteClientError::AccountIdOutOfRange
| SqliteClientError::CacheMiss(_) => {
unreachable!("we only call WalletRead methods; mutations can't occur")
}
#[cfg(feature = "transparent-inputs")]
SqliteClientError::AddressNotRecognized(_) => {
unreachable!("we only call WalletRead methods; mutations can't occur")
}
SqliteClientError::AccountUnknown => {
unreachable!("all accounts are known in migration context")
}
SqliteClientError::UnknownZip32Derivation => {
unreachable!("we don't call methods that require operating on imported accounts")
}
SqliteClientError::ChainHeightUnknown => {
unreachable!("we don't call methods that require a known chain height")
}
}
}
/// Sets up the internal structure of the data database.
///
/// This procedure will automatically perform migration operations to update the wallet database to
/// the database structure required by the current version of this library, and should be invoked
/// at least once any time a client program upgrades to a new version of this library. The
/// operation of this procedure is idempotent, so it is safe (though not required) to invoke this
/// operation every time the wallet is opened.
///
/// In order to correctly apply migrations to accounts derived from a seed, sometimes the
/// optional `seed` argument is required. This function should first be invoked with
/// `seed` set to `None`; if a pending migration requires the seed, the function returns
/// `Err(schemer::MigratorError::Migration { error: WalletMigrationError::SeedRequired, .. })`.
/// The caller can then re-call this function with the necessary seed.
///
/// > Note that currently only one seed can be provided; as such, wallets containing
/// > accounts derived from several different seeds are unsupported, and will result in an
/// > error. Support for multi-seed wallets is being tracked in [zcash/librustzcash#1284].
///
/// When the `seed` argument is provided, the seed is checked against the database for
/// _relevance_: if any account in the wallet for which [`Account::source`] is
/// [`AccountSource::Derived`] can be derived from the given seed, the seed is relevant to
/// the wallet. If the given seed is not relevant, the function returns
/// `Err(schemer::MigratorError::Migration { error: WalletMigrationError::SeedNotRelevant, .. })`
/// or `Err(schemer::MigratorError::Adapter(WalletMigrationError::SeedNotRelevant))`.
///
/// We do not check whether the seed is relevant to any imported account, because that
/// would require brute-forcing the ZIP 32 account index space. Consequentially, imported
/// accounts are not migrated.
///
/// It is safe to use a wallet database previously 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 already controlled by the wallet.
///
/// [zcash/librustzcash#1284]: https://github.com/zcash/librustzcash/issues/1284
/// [`Account::source`]: zcash_client_backend::data_api::Account::source
/// [`AccountSource::Derived`]: zcash_client_backend::data_api::AccountSource::Derived
///
/// # Examples
///
/// ```
/// # use std::error::Error;
/// # use secrecy::SecretVec;
/// # use tempfile::NamedTempFile;
/// use zcash_primitives::consensus::Network;
/// use zcash_client_sqlite::{
/// WalletDb,
/// wallet::init::{WalletMigrationError, init_wallet_db},
/// };
///
/// # fn main() -> Result<(), Box<dyn Error>> {
/// # let data_file = NamedTempFile::new().unwrap();
/// # let get_data_db_path = || data_file.path();
/// # let load_seed = || -> Result<_, String> { Ok(SecretVec::new(vec![])) };
/// let mut db = WalletDb::for_path(get_data_db_path(), Network::TestNetwork)?;
/// match init_wallet_db(&mut db, None) {
/// Err(e)
/// if matches!(
/// e.source().and_then(|e| e.downcast_ref()),
/// Some(&WalletMigrationError::SeedRequired)
/// ) =>
/// {
/// let seed = load_seed()?;
/// init_wallet_db(&mut db, Some(seed))
/// }
/// res => res,
/// }?;
/// # Ok(())
/// # }
/// ```
// TODO: It would be possible to make the transition from providing transparent support to no
// longer providing transparent support safe, by including a migration that verifies that no
// unspent transparent outputs exist in the wallet at the time of upgrading to a version of
// the library that does not support transparent use. It might be a good idea to add an explicit
// check for unspent transparent outputs whenever running initialization with a version of the
// library *not* compiled with the `transparent-inputs` feature flag, and fail if any are present.
pub fn init_wallet_db<P: consensus::Parameters + 'static>(
wdb: &mut WalletDb<rusqlite::Connection, P>,
seed: Option<SecretVec<u8>>,
) -> Result<(), MigratorError<WalletMigrationError>> {
init_wallet_db_internal(wdb, seed, &[], true)
}
fn init_wallet_db_internal<P: consensus::Parameters + 'static>(
wdb: &mut WalletDb<rusqlite::Connection, P>,
seed: Option<SecretVec<u8>>,
target_migrations: &[Uuid],
verify_seed_relevance: bool,
) -> Result<(), MigratorError<WalletMigrationError>> {
let seed = seed.map(Rc::new);
// Turn off foreign keys, and ensure that table replacement/modification
// does not break views
wdb.conn
.execute_batch(
"PRAGMA foreign_keys = OFF;
PRAGMA legacy_alter_table = TRUE;",
)
.map_err(|e| MigratorError::Adapter(WalletMigrationError::from(e)))?;
let adapter = RusqliteAdapter::new(&mut wdb.conn, Some("schemer_migrations".to_string()));
adapter.init().expect("Migrations table setup succeeds.");
let mut migrator = Migrator::new(adapter);
migrator
.register_multiple(migrations::all_migrations(&wdb.params, seed.clone()))
.expect("Wallet migration registration should have been successful.");
if target_migrations.is_empty() {
migrator.up(None)?;
} else {
for target_migration in target_migrations {
migrator.up(Some(*target_migration))?;
}
}
wdb.conn
.execute("PRAGMA foreign_keys = ON", [])
.map_err(|e| MigratorError::Adapter(WalletMigrationError::from(e)))?;
// Now that the migration succeeded, check whether the seed is relevant to the wallet.
// We can only check this if we have migrated as far as `full_account_ids::MIGRATION_ID`,
// but unfortunately `schemer` does not currently expose its DAG of migrations. As a
// consequence, the caller has to choose whether or not this check should be performed
// based upon which migrations they're asking to apply.
if verify_seed_relevance {
if let Some(seed) = seed {
match wdb
.seed_relevance_to_derived_accounts(&seed)
.map_err(sqlite_client_error_to_wallet_migration_error)?
{
SeedRelevance::Relevant { .. } => (),
// Every seed is relevant to a wallet with no accounts; this is most likely a
// new wallet database being initialized for the first time.
SeedRelevance::NoAccounts => (),
// No seed is relevant to a wallet that only has imported accounts.
SeedRelevance::NotRelevant | SeedRelevance::NoDerivedAccounts => {
return Err(WalletMigrationError::SeedNotRelevant.into())
}
}
}
}
Ok(())
}
#[cfg(test)]
#[allow(deprecated)]
mod tests {
use rusqlite::{self, named_params, ToSql};
use secrecy::Secret;
use tempfile::NamedTempFile;
use zcash_client_backend::{
address::Address,
data_api::scanning::ScanPriority,
encoding::{encode_extended_full_viewing_key, encode_payment_address},
keys::{sapling, UnifiedAddressRequest, UnifiedFullViewingKey, UnifiedSpendingKey},
};
use ::sapling::zip32::ExtendedFullViewingKey;
use zcash_primitives::{
consensus::{
self, BlockHeight, BranchId, Network, NetworkConstants, NetworkUpgrade, Parameters,
},
transaction::{TransactionData, TxVersion},
zip32::AccountId,
};
use crate::{testing::TestBuilder, wallet::scanning::priority_code, WalletDb, UA_TRANSPARENT};
use super::init_wallet_db;
#[cfg(feature = "transparent-inputs")]
use {
super::WalletMigrationError,
crate::wallet::{self, pool_code, PoolType},
zcash_address::test_vectors,
zcash_client_backend::data_api::WalletWrite,
zcash_primitives::zip32::DiversifierIndex,
};
#[test]
fn verify_schema() {
let st = TestBuilder::new().build();
use regex::Regex;
let re = Regex::new(r"\s+").unwrap();
let expected_tables = vec![
r#"CREATE TABLE "accounts" (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
account_kind INTEGER NOT NULL DEFAULT 0,
hd_seed_fingerprint BLOB,
hd_account_index INTEGER,
ufvk TEXT,
uivk TEXT NOT NULL,
orchard_fvk_item_cache BLOB,
sapling_fvk_item_cache BLOB,
p2pkh_fvk_item_cache BLOB,
birthday_height INTEGER NOT NULL,
birthday_sapling_tree_size INTEGER,
birthday_orchard_tree_size INTEGER,
recover_until_height INTEGER,
CHECK (
(
account_kind = 0
AND hd_seed_fingerprint IS NOT NULL
AND hd_account_index IS NOT NULL
AND ufvk IS NOT NULL
)
OR
(
account_kind = 1
AND hd_seed_fingerprint IS NULL
AND hd_account_index IS NULL
)
)
)"#,
r#"CREATE TABLE "addresses" (
account_id INTEGER NOT NULL,
diversifier_index_be BLOB NOT NULL,
address TEXT NOT NULL,
cached_transparent_receiver_address TEXT,
FOREIGN KEY (account_id) REFERENCES accounts(id),
CONSTRAINT diversification UNIQUE (account_id, diversifier_index_be)
)"#,
"CREATE TABLE blocks (
height INTEGER PRIMARY KEY,
hash BLOB NOT NULL,
time INTEGER NOT NULL,
sapling_tree BLOB NOT NULL ,
sapling_commitment_tree_size INTEGER,
orchard_commitment_tree_size INTEGER,
sapling_output_count INTEGER,
orchard_action_count INTEGER)",
"CREATE TABLE nullifier_map (
spend_pool INTEGER NOT NULL,
nf BLOB NOT NULL,
block_height INTEGER NOT NULL,
tx_index INTEGER NOT NULL,
CONSTRAINT tx_locator
FOREIGN KEY (block_height, tx_index)
REFERENCES tx_locator_map(block_height, tx_index)
ON DELETE CASCADE
ON UPDATE RESTRICT,
CONSTRAINT nf_uniq UNIQUE (spend_pool, nf)
)",
"CREATE TABLE orchard_received_note_spends (
orchard_received_note_id INTEGER NOT NULL,
transaction_id INTEGER NOT NULL,
FOREIGN KEY (orchard_received_note_id)
REFERENCES orchard_received_notes(id)
ON DELETE CASCADE,
FOREIGN KEY (transaction_id)
-- We do not delete transactions, so this does not cascade
REFERENCES transactions(id_tx),
UNIQUE (orchard_received_note_id, transaction_id)
)",
"CREATE TABLE orchard_received_notes (
id INTEGER PRIMARY KEY,
tx INTEGER NOT NULL,
action_index INTEGER NOT NULL,
account_id INTEGER NOT NULL,
diversifier BLOB NOT NULL,
value INTEGER NOT NULL,
rho BLOB NOT NULL,
rseed BLOB NOT NULL,
nf BLOB UNIQUE,
is_change INTEGER NOT NULL,
memo BLOB,
commitment_tree_position INTEGER,
recipient_key_scope INTEGER,
FOREIGN KEY (tx) REFERENCES transactions(id_tx),
FOREIGN KEY (account_id) REFERENCES accounts(id),
CONSTRAINT tx_output UNIQUE (tx, action_index)
)",
"CREATE TABLE orchard_tree_cap (
-- cap_id exists only to be able to take advantage of `ON CONFLICT`
-- upsert functionality; the table will only ever contain one row
cap_id INTEGER PRIMARY KEY,
cap_data BLOB NOT NULL
)",
"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)
)",
"CREATE TABLE orchard_tree_checkpoints (
checkpoint_id INTEGER PRIMARY KEY,
position INTEGER
)",
"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 sapling_received_note_spends (
sapling_received_note_id INTEGER NOT NULL,
transaction_id INTEGER NOT NULL,
FOREIGN KEY (sapling_received_note_id)
REFERENCES sapling_received_notes(id)
ON DELETE CASCADE,
FOREIGN KEY (transaction_id)
-- We do not delete transactions, so this does not cascade
REFERENCES transactions(id_tx),
UNIQUE (sapling_received_note_id, transaction_id)
)",
r#"CREATE TABLE "sapling_received_notes" (
id INTEGER PRIMARY KEY,
tx INTEGER NOT NULL,
output_index INTEGER NOT NULL,
account_id INTEGER NOT NULL,
diversifier BLOB NOT NULL,
value INTEGER NOT NULL,
rcm BLOB NOT NULL,
nf BLOB UNIQUE,
is_change INTEGER NOT NULL,
memo BLOB,
commitment_tree_position INTEGER,
recipient_key_scope INTEGER,
FOREIGN KEY (tx) REFERENCES transactions(id_tx),
FOREIGN KEY (account_id) REFERENCES accounts(id),
CONSTRAINT tx_output UNIQUE (tx, output_index)
)"#,
"CREATE TABLE sapling_tree_cap (
-- cap_id exists only to be able to take advantage of `ON CONFLICT`
-- upsert functionality; the table will only ever contain one row
cap_id INTEGER PRIMARY KEY,
cap_data BLOB NOT NULL
)",
"CREATE TABLE sapling_tree_checkpoint_marks_removed (
checkpoint_id INTEGER NOT NULL,
mark_removed_position INTEGER NOT NULL,
FOREIGN KEY (checkpoint_id) REFERENCES sapling_tree_checkpoints(checkpoint_id)
ON DELETE CASCADE,
CONSTRAINT spend_position_unique UNIQUE (checkpoint_id, mark_removed_position)
)",
"CREATE TABLE sapling_tree_checkpoints (
checkpoint_id INTEGER PRIMARY KEY,
position INTEGER
)",
"CREATE TABLE sapling_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 scan_queue (
block_range_start INTEGER NOT NULL,
block_range_end INTEGER NOT NULL,
priority INTEGER NOT NULL,
CONSTRAINT range_start_uniq UNIQUE (block_range_start),
CONSTRAINT range_end_uniq UNIQUE (block_range_end),
CONSTRAINT range_bounds_order CHECK (
block_range_start < block_range_end
)
)",
"CREATE TABLE schemer_migrations (
id blob PRIMARY KEY
)",
r#"CREATE TABLE "sent_notes" (
id INTEGER PRIMARY KEY,
tx INTEGER NOT NULL,
output_pool INTEGER NOT NULL,
output_index INTEGER NOT NULL,
from_account_id INTEGER NOT NULL,
to_address TEXT,
to_account_id INTEGER,
value INTEGER NOT NULL,
memo BLOB,
FOREIGN KEY (tx) REFERENCES transactions(id_tx),
FOREIGN KEY (from_account_id) REFERENCES accounts(id),
FOREIGN KEY (to_account_id) REFERENCES accounts(id),
CONSTRAINT tx_output UNIQUE (tx, output_pool, output_index),
CONSTRAINT note_recipient CHECK (
(to_address IS NOT NULL) OR (to_account_id IS NOT NULL)
)
)"#,
// Internal table created by SQLite when we started using `AUTOINCREMENT`.
"CREATE TABLE sqlite_sequence(name,seq)",
"CREATE TABLE transactions (
id_tx INTEGER PRIMARY KEY,
txid BLOB NOT NULL UNIQUE,
created TEXT,
block INTEGER,
tx_index INTEGER,
expiry_height INTEGER,
raw BLOB,
fee INTEGER,
FOREIGN KEY (block) REFERENCES blocks(height)
)",
"CREATE TABLE transparent_received_output_spends (
transparent_received_output_id INTEGER NOT NULL,
transaction_id INTEGER NOT NULL,
FOREIGN KEY (transparent_received_output_id)
REFERENCES utxos(id)
ON DELETE CASCADE,
FOREIGN KEY (transaction_id)
-- We do not delete transactions, so this does not cascade
REFERENCES transactions(id_tx),
UNIQUE (transparent_received_output_id, transaction_id)
)",
"CREATE TABLE tx_locator_map (
block_height INTEGER NOT NULL,
tx_index INTEGER NOT NULL,
txid BLOB NOT NULL UNIQUE,
PRIMARY KEY (block_height, tx_index)
)",
r#"CREATE TABLE "utxos" (
id INTEGER PRIMARY KEY,
received_by_account_id INTEGER NOT NULL,
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,
FOREIGN KEY (received_by_account_id) REFERENCES accounts(id),
CONSTRAINT tx_outpoint UNIQUE (prevout_txid, prevout_idx)
)"#,
];
let mut tables_query = st
.wallet()
.conn
.prepare("SELECT sql FROM sqlite_schema WHERE type = 'table' ORDER BY tbl_name")
.unwrap();
let mut rows = tables_query.query([]).unwrap();
let mut expected_idx = 0;
while let Some(row) = rows.next().unwrap() {
let sql: String = row.get(0).unwrap();
assert_eq!(
re.replace_all(&sql, " "),
re.replace_all(expected_tables[expected_idx], " ")
);
expected_idx += 1;
}
let expected_indices = vec![
r#"CREATE UNIQUE INDEX accounts_ufvk ON "accounts" (ufvk)"#,
r#"CREATE UNIQUE INDEX accounts_uivk ON "accounts" (uivk)"#,
r#"CREATE UNIQUE INDEX hd_account ON "accounts" (hd_seed_fingerprint, hd_account_index)"#,
r#"CREATE INDEX "addresses_accounts" ON "addresses" (
"account_id" ASC
)"#,
r#"CREATE INDEX nf_map_locator_idx ON nullifier_map(block_height, tx_index)"#,
r#"CREATE INDEX orchard_received_notes_account ON orchard_received_notes (
account_id ASC
)"#,
r#"CREATE INDEX orchard_received_notes_tx ON orchard_received_notes (
tx ASC
)"#,
r#"CREATE INDEX "sapling_received_notes_account" ON "sapling_received_notes" (
"account_id" ASC
)"#,
r#"CREATE INDEX "sapling_received_notes_tx" ON "sapling_received_notes" (
"tx" ASC
)"#,
r#"CREATE INDEX sent_notes_from_account ON "sent_notes" (from_account_id)"#,
r#"CREATE INDEX sent_notes_to_account ON "sent_notes" (to_account_id)"#,
r#"CREATE INDEX sent_notes_tx ON "sent_notes" (tx)"#,
r#"CREATE INDEX utxos_received_by_account ON "utxos" (received_by_account_id)"#,
];
let mut indices_query = st
.wallet()
.conn
.prepare("SELECT sql FROM sqlite_master WHERE type = 'index' AND sql != '' ORDER BY tbl_name, name")
.unwrap();
let mut rows = indices_query.query([]).unwrap();
let mut expected_idx = 0;
while let Some(row) = rows.next().unwrap() {
let sql: String = row.get(0).unwrap();
assert_eq!(
re.replace_all(&sql, " "),
re.replace_all(expected_indices[expected_idx], " ")
);
expected_idx += 1;
}
let expected_views = vec![
// v_orchard_shard_scan_ranges
format!(
"CREATE VIEW v_orchard_shard_scan_ranges AS
SELECT
shard.shard_index,
shard.shard_index << 16 AS start_position,
(shard.shard_index + 1) << 16 AS end_position_exclusive,
IFNULL(prev_shard.subtree_end_height, {}) AS subtree_start_height,
shard.subtree_end_height,
shard.contains_marked,
scan_queue.block_range_start,
scan_queue.block_range_end,
scan_queue.priority
FROM orchard_tree_shards shard
LEFT OUTER JOIN orchard_tree_shards prev_shard
ON shard.shard_index = prev_shard.shard_index + 1
-- Join with scan ranges that overlap with the subtree's involved blocks.
INNER JOIN scan_queue ON (
subtree_start_height < scan_queue.block_range_end AND
(
scan_queue.block_range_start <= shard.subtree_end_height OR
shard.subtree_end_height IS NULL
)
)",
u32::from(st.network().activation_height(NetworkUpgrade::Nu5).unwrap()),
),
//v_orchard_shard_unscanned_ranges
format!(
"CREATE VIEW v_orchard_shard_unscanned_ranges AS
WITH wallet_birthday AS (SELECT MIN(birthday_height) AS height FROM accounts)
SELECT
shard_index,
start_position,
end_position_exclusive,
subtree_start_height,
subtree_end_height,
contains_marked,
block_range_start,
block_range_end,
priority
FROM v_orchard_shard_scan_ranges
INNER JOIN wallet_birthday
WHERE priority > {}
AND block_range_end > wallet_birthday.height",
priority_code(&ScanPriority::Scanned),
),
// v_orchard_shards_scan_state
"CREATE VIEW v_orchard_shards_scan_state AS
SELECT
shard_index,
start_position,
end_position_exclusive,
subtree_start_height,
subtree_end_height,
contains_marked,
MAX(priority) AS max_priority
FROM v_orchard_shard_scan_ranges
GROUP BY
shard_index,
start_position,
end_position_exclusive,
subtree_start_height,
subtree_end_height,
contains_marked".to_owned(),
// v_received_note_spends
"CREATE VIEW v_received_note_spends AS
SELECT
2 AS pool,
sapling_received_note_id AS received_note_id,
transaction_id
FROM sapling_received_note_spends
UNION
SELECT
3 AS pool,
orchard_received_note_id AS received_note_id,
transaction_id
FROM orchard_received_note_spends".to_owned(),
// v_received_notes
"CREATE VIEW v_received_notes AS
SELECT
sapling_received_notes.id AS id_within_pool_table,
sapling_received_notes.tx,
2 AS pool,
sapling_received_notes.output_index AS output_index,
account_id,
sapling_received_notes.value,
is_change,
sapling_received_notes.memo,
sent_notes.id AS sent_note_id
FROM sapling_received_notes
LEFT JOIN sent_notes
ON (sent_notes.tx, sent_notes.output_pool, sent_notes.output_index) =
(sapling_received_notes.tx, 2, sapling_received_notes.output_index)
UNION
SELECT
orchard_received_notes.id AS id_within_pool_table,
orchard_received_notes.tx,
3 AS pool,
orchard_received_notes.action_index AS output_index,
account_id,
orchard_received_notes.value,
is_change,
orchard_received_notes.memo,
sent_notes.id AS sent_note_id
FROM orchard_received_notes
LEFT JOIN sent_notes
ON (sent_notes.tx, sent_notes.output_pool, sent_notes.output_index) =
(orchard_received_notes.tx, 3, orchard_received_notes.action_index)".to_owned(),
// v_sapling_shard_scan_ranges
format!(
"CREATE VIEW v_sapling_shard_scan_ranges AS
SELECT
shard.shard_index,
shard.shard_index << 16 AS start_position,
(shard.shard_index + 1) << 16 AS end_position_exclusive,
IFNULL(prev_shard.subtree_end_height, {}) AS subtree_start_height,
shard.subtree_end_height,
shard.contains_marked,
scan_queue.block_range_start,
scan_queue.block_range_end,
scan_queue.priority
FROM sapling_tree_shards shard
LEFT OUTER JOIN sapling_tree_shards prev_shard
ON shard.shard_index = prev_shard.shard_index + 1
-- Join with scan ranges that overlap with the subtree's involved blocks.
INNER JOIN scan_queue ON (
subtree_start_height < scan_queue.block_range_end AND
(
scan_queue.block_range_start <= shard.subtree_end_height OR
shard.subtree_end_height IS NULL
)
)",
u32::from(st.network().activation_height(NetworkUpgrade::Sapling).unwrap()),
),
// v_sapling_shard_unscanned_ranges
format!(
"CREATE VIEW v_sapling_shard_unscanned_ranges AS
WITH wallet_birthday AS (SELECT MIN(birthday_height) AS height FROM accounts)
SELECT
shard_index,
start_position,
end_position_exclusive,
subtree_start_height,
subtree_end_height,
contains_marked,
block_range_start,
block_range_end,
priority
FROM v_sapling_shard_scan_ranges
INNER JOIN wallet_birthday
WHERE priority > {}
AND block_range_end > wallet_birthday.height",
priority_code(&ScanPriority::Scanned)
),
// v_sapling_shards_scan_state
"CREATE VIEW v_sapling_shards_scan_state AS
SELECT
shard_index,
start_position,
end_position_exclusive,
subtree_start_height,
subtree_end_height,
contains_marked,
MAX(priority) AS max_priority
FROM v_sapling_shard_scan_ranges
GROUP BY
shard_index,
start_position,
end_position_exclusive,
subtree_start_height,
subtree_end_height,
contains_marked".to_owned(),
// v_transactions
"CREATE VIEW v_transactions AS
WITH
notes AS (
-- Shielded notes received in this transaction
SELECT v_received_notes.account_id AS account_id,
transactions.block AS block,
transactions.txid AS txid,
v_received_notes.pool AS pool,
id_within_pool_table,
v_received_notes.value AS value,
CASE
WHEN v_received_notes.is_change THEN 1
ELSE 0
END AS is_change,
CASE
WHEN v_received_notes.is_change THEN 0
ELSE 1
END AS received_count,
CASE
WHEN (v_received_notes.memo IS NULL OR v_received_notes.memo = X'F6')
THEN 0
ELSE 1
END AS memo_present
FROM v_received_notes
JOIN transactions
ON transactions.id_tx = v_received_notes.tx
UNION
-- Transparent TXOs received in this transaction
SELECT utxos.received_by_account_id AS account_id,
utxos.height AS block,
utxos.prevout_txid AS txid,
0 AS pool,
utxos.id AS id_within_pool_table,
utxos.value_zat AS value,
0 AS is_change,
1 AS received_count,
0 AS memo_present
FROM utxos
UNION
-- Shielded notes spent in this transaction
SELECT v_received_notes.account_id AS account_id,
transactions.block AS block,
transactions.txid AS txid,
v_received_notes.pool AS pool,
id_within_pool_table,
-v_received_notes.value AS value,
0 AS is_change,
0 AS received_count,
0 AS memo_present
FROM v_received_notes
JOIN v_received_note_spends rns
ON rns.pool = v_received_notes.pool
AND rns.received_note_id = v_received_notes.id_within_pool_table
JOIN transactions
ON transactions.id_tx = rns.transaction_id
UNION
-- Transparent TXOs spent in this transaction
SELECT utxos.received_by_account_id AS account_id,
transactions.block AS block,
transactions.txid AS txid,
0 AS pool,
utxos.id AS id_within_pool_table,
-utxos.value_zat AS value,
0 AS is_change,
0 AS received_count,
0 AS memo_present
FROM utxos
JOIN transparent_received_output_spends tros
ON tros.transparent_received_output_id = utxos.id
JOIN transactions
ON transactions.id_tx = tros.transaction_id
),
-- Obtain a count of the notes that the wallet created in each transaction,
-- not counting change notes.
sent_note_counts AS (
SELECT sent_notes.from_account_id AS account_id,
transactions.txid AS txid,
COUNT(DISTINCT sent_notes.id) as sent_notes,
SUM(
CASE
WHEN (sent_notes.memo IS NULL OR sent_notes.memo = X'F6' OR v_received_notes.tx IS NOT NULL)
THEN 0
ELSE 1
END
) AS memo_count
FROM sent_notes
JOIN transactions
ON transactions.id_tx = sent_notes.tx
LEFT JOIN v_received_notes
ON sent_notes.id = v_received_notes.sent_note_id
WHERE COALESCE(v_received_notes.is_change, 0) = 0
GROUP BY account_id, txid
),
blocks_max_height AS (
SELECT MAX(blocks.height) as max_height FROM blocks
)
SELECT notes.account_id AS account_id,
notes.block AS mined_height,
notes.txid AS txid,
transactions.tx_index AS tx_index,
transactions.expiry_height AS expiry_height,
transactions.raw AS raw,
SUM(notes.value) AS account_balance_delta,
transactions.fee AS fee_paid,
SUM(notes.is_change) > 0 AS has_change,
MAX(COALESCE(sent_note_counts.sent_notes, 0)) AS sent_note_count,
SUM(notes.received_count) AS received_note_count,
SUM(notes.memo_present) + MAX(COALESCE(sent_note_counts.memo_count, 0)) AS memo_count,
blocks.time AS block_time,
(
blocks.height IS NULL
AND transactions.expiry_height BETWEEN 1 AND blocks_max_height.max_height
) AS expired_unmined
FROM notes
LEFT JOIN transactions
ON notes.txid = transactions.txid
JOIN blocks_max_height
LEFT JOIN blocks ON blocks.height = notes.block
LEFT JOIN sent_note_counts
ON sent_note_counts.account_id = notes.account_id
AND sent_note_counts.txid = notes.txid
GROUP BY notes.account_id, notes.txid".to_owned(),
// v_tx_outputs
"CREATE VIEW v_tx_outputs AS
SELECT transactions.txid AS txid,
v_received_notes.pool AS output_pool,
v_received_notes.output_index AS output_index,
sent_notes.from_account_id AS from_account_id,
v_received_notes.account_id AS to_account_id,
NULL AS to_address,
v_received_notes.value AS value,
v_received_notes.is_change AS is_change,
v_received_notes.memo AS memo
FROM v_received_notes
JOIN transactions
ON transactions.id_tx = v_received_notes.tx
LEFT JOIN sent_notes
ON sent_notes.id = v_received_notes.sent_note_id
UNION
SELECT utxos.prevout_txid AS txid,
0 AS output_pool,
utxos.prevout_idx AS output_index,
NULL AS from_account_id,
utxos.received_by_account_id AS to_account_id,
utxos.address AS to_address,
utxos.value_zat AS value,
0 AS is_change,
NULL AS memo
FROM utxos
UNION
SELECT transactions.txid AS txid,
sent_notes.output_pool AS output_pool,
sent_notes.output_index AS output_index,
sent_notes.from_account_id AS from_account_id,
v_received_notes.account_id AS to_account_id,
sent_notes.to_address AS to_address,
sent_notes.value AS value,
0 AS is_change,
sent_notes.memo AS memo
FROM sent_notes
JOIN transactions
ON transactions.id_tx = sent_notes.tx
LEFT JOIN v_received_notes
ON sent_notes.id = v_received_notes.sent_note_id
WHERE COALESCE(v_received_notes.is_change, 0) = 0".to_owned(),
];
let mut views_query = st
.wallet()
.conn
.prepare("SELECT sql FROM sqlite_schema WHERE type = 'view' ORDER BY tbl_name")
.unwrap();
let mut rows = views_query.query([]).unwrap();
let mut expected_idx = 0;
while let Some(row) = rows.next().unwrap() {
let sql: String = row.get(0).unwrap();
assert_eq!(
re.replace_all(&sql, " "),
re.replace_all(&expected_views[expected_idx], " ")
);
expected_idx += 1;
}
}
#[test]
fn init_migrate_from_0_3_0() {
fn init_0_3_0<P: consensus::Parameters>(
wdb: &mut WalletDb<rusqlite::Connection, P>,
extfvk: &ExtendedFullViewingKey,
account: AccountId,
) -> Result<(), rusqlite::Error> {
wdb.conn.execute(
"CREATE TABLE accounts (
account INTEGER PRIMARY KEY,
extfvk TEXT NOT NULL,
address TEXT NOT NULL
)",
[],
)?;
wdb.conn.execute(
"CREATE TABLE blocks (
height INTEGER PRIMARY KEY,
hash BLOB NOT NULL,
time INTEGER NOT NULL,
sapling_tree BLOB NOT NULL
)",
[],
)?;
wdb.conn.execute(
"CREATE TABLE 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)
)",
[],
)?;
wdb.conn.execute(
"CREATE TABLE 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)
)",
[],
)?;
wdb.conn.execute(
"CREATE TABLE 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)
)",
[],
)?;
wdb.conn.execute(
"CREATE TABLE sent_notes (
id_note INTEGER PRIMARY KEY,
tx 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_index)
)",
[],
)?;
let address = encode_payment_address(
wdb.params.hrp_sapling_payment_address(),
&extfvk.default_address().1,
);
let extfvk = encode_extended_full_viewing_key(
wdb.params.hrp_sapling_extended_full_viewing_key(),
extfvk,
);
wdb.conn.execute(
"INSERT INTO accounts (account, extfvk, address)
VALUES (?, ?, ?)",
[
u32::from(account).to_sql()?,
extfvk.to_sql()?,
address.to_sql()?,
],
)?;
Ok(())
}
let data_file = NamedTempFile::new().unwrap();
let mut db_data = WalletDb::for_path(data_file.path(), Network::TestNetwork).unwrap();
let seed = [0xab; 32];
let account = AccountId::ZERO;
let secret_key = sapling::spending_key(&seed, db_data.params.coin_type(), account);
let extfvk = secret_key.to_extended_full_viewing_key();
init_0_3_0(&mut db_data, &extfvk, account).unwrap();
assert_matches!(
init_wallet_db(&mut db_data, Some(Secret::new(seed.to_vec()))),
Ok(_)
);
}
#[test]
fn init_migrate_from_autoshielding_poc() {
fn init_autoshielding<P: consensus::Parameters>(
wdb: &mut WalletDb<rusqlite::Connection, P>,
extfvk: &ExtendedFullViewingKey,
account: AccountId,
) -> Result<(), rusqlite::Error> {
wdb.conn.execute(
"CREATE TABLE accounts (
account INTEGER PRIMARY KEY,
extfvk TEXT NOT NULL,
address TEXT NOT NULL,
transparent_address TEXT NOT NULL
)",
[],
)?;
wdb.conn.execute(
"CREATE TABLE blocks (
height INTEGER PRIMARY KEY,
hash BLOB NOT NULL,
time INTEGER NOT NULL,
sapling_tree BLOB NOT NULL
)",
[],
)?;
wdb.conn.execute(
"CREATE TABLE 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)
)",
[],
)?;
wdb.conn.execute(
"CREATE TABLE 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)
)",
[],
)?;
wdb.conn.execute(
"CREATE TABLE 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)
)",
[],
)?;
wdb.conn.execute(
"CREATE TABLE sent_notes (
id_note INTEGER PRIMARY KEY,
tx 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_index)
)",
[],
)?;
wdb.conn.execute(
"CREATE TABLE 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)
)",
[],
)?;
let address = encode_payment_address(
wdb.params.hrp_sapling_payment_address(),
&extfvk.default_address().1,
);
let extfvk = encode_extended_full_viewing_key(
wdb.params.hrp_sapling_extended_full_viewing_key(),
extfvk,
);
wdb.conn.execute(
"INSERT INTO accounts (account, extfvk, address, transparent_address)
VALUES (?, ?, ?, '')",
[
u32::from(account).to_sql()?,
extfvk.to_sql()?,
address.to_sql()?,
],
)?;
// add a sapling sent note
wdb.conn.execute(
"INSERT INTO blocks (height, hash, time, sapling_tree) VALUES (0, 0, 0, x'000000')",
[],
)?;
let tx = TransactionData::from_parts(
TxVersion::Sapling,
BranchId::Canopy,
0,
BlockHeight::from(0),
None,
None,
None,
None,
)
.freeze()
.unwrap();
let mut tx_bytes = vec![];
tx.write(&mut tx_bytes).unwrap();
wdb.conn.execute(
"INSERT INTO transactions (block, id_tx, txid, raw) VALUES (0, 0, :txid, :tx_bytes)",
named_params![
":txid": tx.txid().as_ref(),
":tx_bytes": &tx_bytes[..]
],
)?;
wdb.conn.execute(
"INSERT INTO sent_notes (tx, output_index, from_account, address, value)
VALUES (0, 0, ?, ?, 0)",
[u32::from(account).to_sql()?, address.to_sql()?],
)?;
Ok(())
}
let data_file = NamedTempFile::new().unwrap();
let mut db_data = WalletDb::for_path(data_file.path(), Network::TestNetwork).unwrap();
let seed = [0xab; 32];
let account = AccountId::ZERO;
let secret_key = sapling::spending_key(&seed, db_data.params.coin_type(), account);
let extfvk = secret_key.to_extended_full_viewing_key();
init_autoshielding(&mut db_data, &extfvk, account).unwrap();
assert_matches!(
init_wallet_db(&mut db_data, Some(Secret::new(seed.to_vec()))),
Ok(_)
);
}
#[test]
fn init_migrate_from_main_pre_migrations() {
fn init_main<P: consensus::Parameters>(
wdb: &mut WalletDb<rusqlite::Connection, P>,
ufvk: &UnifiedFullViewingKey,
account: AccountId,
) -> Result<(), rusqlite::Error> {
wdb.conn.execute(
"CREATE TABLE accounts (
account INTEGER PRIMARY KEY,
ufvk TEXT,
address TEXT,
transparent_address TEXT
)",
[],
)?;
wdb.conn.execute(
"CREATE TABLE blocks (
height INTEGER PRIMARY KEY,
hash BLOB NOT NULL,
time INTEGER NOT NULL,
sapling_tree BLOB NOT NULL
)",
[],
)?;
wdb.conn.execute(
"CREATE TABLE 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)
)",
[],
)?;
wdb.conn.execute(
"CREATE TABLE 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)
)",
[],
)?;
wdb.conn.execute(
"CREATE TABLE 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)
)",
[],
)?;
wdb.conn.execute(
"CREATE TABLE 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)
)",
[],
)?;
wdb.conn.execute(
"CREATE TABLE 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)
)",
[],
)?;
let ufvk_str = ufvk.encode(&wdb.params);
// Unified addresses at the time of the addition of migrations did not contain an
// Orchard component.
let ua_request = UnifiedAddressRequest::unsafe_new(false, true, UA_TRANSPARENT);
let address_str = Address::Unified(
ufvk.default_address(ua_request)
.expect("A valid default address exists for the UFVK")
.0,
)
.encode(&wdb.params);
wdb.conn.execute(
"INSERT INTO accounts (account, ufvk, address, transparent_address)
VALUES (?, ?, ?, '')",
[
u32::from(account).to_sql()?,
ufvk_str.to_sql()?,
address_str.to_sql()?,
],
)?;
// add a transparent "sent note"
#[cfg(feature = "transparent-inputs")]
{
let taddr = Address::Transparent(
*ufvk
.default_address(ua_request)
.expect("A valid default address exists for the UFVK")
.0
.transparent()
.unwrap(),
)
.encode(&wdb.params);
wdb.conn.execute(
"INSERT INTO blocks (height, hash, time, sapling_tree) VALUES (0, 0, 0, x'000000')",
[],
)?;
wdb.conn.execute(
"INSERT INTO transactions (block, id_tx, txid) VALUES (0, 0, '')",
[],
)?;
wdb.conn.execute(
"INSERT INTO sent_notes (tx, output_pool, output_index, from_account, address, value)
VALUES (0, ?, 0, ?, ?, 0)",
[pool_code(PoolType::Transparent).to_sql()?, u32::from(account).to_sql()?, taddr.to_sql()?])?;
}
Ok(())
}
let data_file = NamedTempFile::new().unwrap();
let mut db_data = WalletDb::for_path(data_file.path(), Network::TestNetwork).unwrap();
let seed = [0xab; 32];
let account = AccountId::ZERO;
let secret_key = UnifiedSpendingKey::from_seed(&db_data.params, &seed, account).unwrap();
init_main(
&mut db_data,
&secret_key.to_unified_full_viewing_key(),
account,
)
.unwrap();
assert_matches!(
init_wallet_db(&mut db_data, Some(Secret::new(seed.to_vec()))),
Ok(_)
);
}
#[test]
#[cfg(feature = "transparent-inputs")]
fn account_produces_expected_ua_sequence() {
use zcash_client_backend::data_api::{AccountBirthday, AccountSource, WalletRead};
use zcash_primitives::block::BlockHash;
let network = Network::MainNetwork;
let data_file = NamedTempFile::new().unwrap();
let mut db_data = WalletDb::for_path(data_file.path(), network).unwrap();
assert_matches!(init_wallet_db(&mut db_data, None), Ok(_));
// Prior to adding any accounts, every seed phrase is relevant to the wallet.
let seed = test_vectors::UNIFIED[0].root_seed;
let other_seed = [7; 32];
assert_matches!(
init_wallet_db(&mut db_data, Some(Secret::new(seed.to_vec()))),
Ok(())
);
assert_matches!(
init_wallet_db(&mut db_data, Some(Secret::new(other_seed.to_vec()))),
Ok(())
);
let birthday = AccountBirthday::from_sapling_activation(&network, BlockHash([0; 32]));
let (account_id, _usk) = db_data
.create_account(&Secret::new(seed.to_vec()), &birthday)
.unwrap();
assert_matches!(
db_data.get_account(account_id),
Ok(Some(account)) if matches!(
account.kind,
AccountSource::Derived{account_index, ..} if account_index == zip32::AccountId::ZERO,
)
);
// After adding an account, only the real seed phrase is relevant to the wallet.
assert_matches!(
init_wallet_db(&mut db_data, Some(Secret::new(seed.to_vec()))),
Ok(())
);
assert_matches!(
init_wallet_db(&mut db_data, Some(Secret::new(other_seed.to_vec()))),
Err(schemer::MigratorError::Adapter(
WalletMigrationError::SeedNotRelevant
))
);
for tv in &test_vectors::UNIFIED[..3] {
if let Some(Address::Unified(tvua)) =
Address::decode(&Network::MainNetwork, tv.unified_addr)
{
let (ua, di) =
wallet::get_current_address(&db_data.conn, &db_data.params, account_id)
.unwrap()
.expect("create_account generated the first address");
assert_eq!(DiversifierIndex::from(tv.diversifier_index), di);
assert_eq!(tvua.transparent(), ua.transparent());
assert_eq!(tvua.sapling(), ua.sapling());
#[cfg(not(feature = "orchard"))]
assert_eq!(tv.unified_addr, ua.encode(&Network::MainNetwork));
// hardcoded with knowledge of what's coming next
let ua_request = UnifiedAddressRequest::unsafe_new(false, true, true);
db_data
.get_next_available_address(account_id, ua_request)
.unwrap()
.expect("get_next_available_address generated an address");
} else {
panic!(
"{} did not decode to a valid unified address",
tv.unified_addr
);
}
}
}
}