1562 lines
66 KiB
Rust
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
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|