librustzcash/zcash_client_sqlite/src/wallet/init/migrations/add_transaction_views.rs

496 lines
22 KiB
Rust

//! Migration that adds transaction summary views & add fee information to transactions.
use std::collections::HashSet;
use rusqlite::{self, types::ToSql, OptionalExtension};
use schemer_rusqlite::RusqliteMigration;
use uuid::Uuid;
use zcash_primitives::{
consensus::BranchId,
transaction::{
components::amount::{Amount, BalanceError},
Transaction,
},
};
use super::{add_utxo_account, sent_notes_to_internal};
use crate::wallet::init::WalletMigrationError;
pub(super) const MIGRATION_ID: Uuid = Uuid::from_u128(0x282fad2e_8372_4ca0_8bed_71821320909f);
pub(crate) struct Migration;
impl schemer::Migration for Migration {
fn id(&self) -> Uuid {
MIGRATION_ID
}
fn dependencies(&self) -> HashSet<Uuid> {
[
add_utxo_account::MIGRATION_ID,
sent_notes_to_internal::MIGRATION_ID,
]
.into_iter()
.collect()
}
fn description(&self) -> &'static str {
"Add transaction summary views & add fee information to transactions."
}
}
impl RusqliteMigration for Migration {
type Error = WalletMigrationError;
fn up(&self, transaction: &rusqlite::Transaction) -> Result<(), WalletMigrationError> {
enum FeeError {
Db(rusqlite::Error),
UtxoNotFound,
Balance(BalanceError),
CorruptedData(String),
}
impl From<BalanceError> for FeeError {
fn from(e: BalanceError) -> Self {
FeeError::Balance(e)
}
}
impl From<rusqlite::Error> for FeeError {
fn from(e: rusqlite::Error) -> Self {
FeeError::Db(e)
}
}
transaction.execute_batch("ALTER TABLE transactions ADD COLUMN fee INTEGER;")?;
let mut stmt_list_txs = transaction.prepare("SELECT id_tx, raw FROM transactions")?;
let mut stmt_set_fee =
transaction.prepare("UPDATE transactions SET fee = ? WHERE id_tx = ?")?;
let mut stmt_find_utxo_value = transaction
.prepare("SELECT value_zat FROM utxos WHERE prevout_txid = ? AND prevout_idx = ?")?;
let mut tx_rows = stmt_list_txs.query([])?;
while let Some(row) = tx_rows.next()? {
let id_tx: i64 = row.get(0)?;
let tx_bytes: Option<Vec<u8>> = row.get(1)?;
// If only transaction metadata has been stored, and not transaction data, the fee
// information will eventually be set when the full transaction data is inserted.
if let Some(tx_bytes) = tx_bytes {
let tx = Transaction::read(
&tx_bytes[..],
// The consensus branch ID is unused in determining the fee paid, so
// just pass Nu5 as a dummy value since we know that parsing both v4
// and v5 transactions is supported during the Nu5 epoch.
BranchId::Nu5,
)
.map_err(|e| {
WalletMigrationError::CorruptedData(format!(
"Parsing failed for transaction {:?}: {:?}",
id_tx, e
))
})?;
let fee_paid = tx.fee_paid(|op| {
let op_amount = stmt_find_utxo_value
.query_row([op.hash().to_sql()?, op.n().to_sql()?], |row| {
row.get::<_, i64>(0)
})
.optional()
.map_err(FeeError::Db)?;
op_amount.map_or_else(
|| Err(FeeError::UtxoNotFound),
|i| {
Amount::from_i64(i).map_err(|_| {
FeeError::CorruptedData(format!(
"UTXO amount out of range in outpoint {:?}",
op
))
})
},
)
});
match fee_paid {
Ok(fee_paid) => {
stmt_set_fee.execute([i64::from(fee_paid), id_tx])?;
}
Err(FeeError::UtxoNotFound) => {
// The fee and net value will end up being null in the transactions view.
}
Err(FeeError::Db(e)) => {
return Err(WalletMigrationError::from(e));
}
Err(FeeError::Balance(e)) => {
return Err(WalletMigrationError::from(e));
}
Err(FeeError::CorruptedData(s)) => {
return Err(WalletMigrationError::CorruptedData(s));
}
}
}
}
transaction.execute_batch(
"UPDATE sent_notes SET memo = NULL
WHERE memo = X'F600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000';
UPDATE received_notes SET memo = NULL
WHERE memo = X'F600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000';")?;
transaction.execute_batch(
"CREATE VIEW v_tx_sent AS
SELECT transactions.id_tx AS id_tx,
transactions.block AS mined_height,
transactions.tx_index AS tx_index,
transactions.txid AS txid,
transactions.expiry_height AS expiry_height,
transactions.raw AS raw,
MAX(sent_notes.from_account) AS sent_from_account,
SUM(sent_notes.value) AS sent_total,
COUNT(sent_notes.id_note) AS sent_note_count,
SUM(
CASE
WHEN sent_notes.memo IS NULL THEN 0
ELSE 1
END
) AS memo_count,
blocks.time AS block_time
FROM transactions
JOIN sent_notes
ON transactions.id_tx = sent_notes.tx
LEFT JOIN blocks
ON transactions.block = blocks.height
GROUP BY sent_notes.tx, sent_notes.from_account;",
)?;
transaction.execute_batch(
"CREATE VIEW v_tx_received AS
SELECT transactions.id_tx AS id_tx,
transactions.block AS mined_height,
transactions.tx_index AS tx_index,
transactions.txid AS txid,
transactions.expiry_height AS expiry_height,
transactions.raw AS raw,
MAX(received_notes.account) AS received_by_account,
SUM(received_notes.value) AS received_total,
COUNT(received_notes.id_note) AS received_note_count,
SUM(
CASE
WHEN received_notes.memo IS NULL THEN 0
ELSE 1
END
) AS memo_count,
blocks.time AS block_time
FROM transactions
JOIN received_notes
ON transactions.id_tx = received_notes.tx
LEFT JOIN blocks
ON transactions.block = blocks.height
GROUP BY received_notes.tx, received_notes.account;",
)?;
transaction.execute_batch(
"CREATE VIEW v_transactions AS
SELECT notes.id_tx,
notes.mined_height,
notes.tx_index,
notes.txid,
notes.expiry_height,
notes.raw,
SUM(notes.value) + MAX(notes.fee) AS net_value,
MAX(notes.fee) AS fee_paid,
SUM(notes.sent_count) == 0 AS is_wallet_internal,
SUM(notes.is_change) > 0 AS has_change,
SUM(notes.sent_count) AS sent_note_count,
SUM(notes.received_count) AS received_note_count,
SUM(notes.memo_present) AS memo_count,
blocks.time AS block_time
FROM (
SELECT transactions.id_tx AS id_tx,
transactions.block AS mined_height,
transactions.tx_index AS tx_index,
transactions.txid AS txid,
transactions.expiry_height AS expiry_height,
transactions.raw AS raw,
0 AS fee,
CASE
WHEN received_notes.is_change THEN 0
ELSE value
END AS value,
0 AS sent_count,
CASE
WHEN received_notes.is_change THEN 1
ELSE 0
END AS is_change,
CASE
WHEN received_notes.is_change THEN 0
ELSE 1
END AS received_count,
CASE
WHEN received_notes.memo IS NULL THEN 0
ELSE 1
END AS memo_present
FROM transactions
JOIN received_notes ON transactions.id_tx = received_notes.tx
UNION
SELECT transactions.id_tx AS id_tx,
transactions.block AS mined_height,
transactions.tx_index AS tx_index,
transactions.txid AS txid,
transactions.expiry_height AS expiry_height,
transactions.raw AS raw,
transactions.fee AS fee,
-sent_notes.value AS value,
CASE
WHEN sent_notes.from_account = sent_notes.to_account THEN 0
ELSE 1
END AS sent_count,
0 AS is_change,
0 AS received_count,
CASE
WHEN sent_notes.memo IS NULL THEN 0
ELSE 1
END AS memo_present
FROM transactions
JOIN sent_notes ON transactions.id_tx = sent_notes.tx
) AS notes
LEFT JOIN blocks ON notes.mined_height = blocks.height
GROUP BY notes.id_tx;",
)?;
Ok(())
}
fn down(&self, _transaction: &rusqlite::Transaction) -> Result<(), WalletMigrationError> {
Err(WalletMigrationError::CannotRevert(MIGRATION_ID))
}
}
#[cfg(test)]
mod tests {
use rusqlite::{self, params};
use tempfile::NamedTempFile;
use zcash_client_backend::keys::UnifiedSpendingKey;
use zcash_primitives::{consensus::Network, zip32::AccountId};
use crate::{
wallet::init::{init_wallet_db_internal, migrations::addresses_table},
WalletDb,
};
#[cfg(feature = "transparent-inputs")]
use {
crate::wallet::init::migrations::{ufvk_support, utxos_table},
zcash_client_backend::encoding::AddressCodec,
zcash_primitives::{
consensus::{BlockHeight, BranchId},
legacy::{keys::IncomingViewingKey, Script},
transaction::{
components::{
transparent::{self, Authorized, OutPoint},
Amount, TxIn, TxOut,
},
TransactionData, TxVersion,
},
},
};
#[test]
fn transaction_views() {
let network = Network::TestNetwork;
let data_file = NamedTempFile::new().unwrap();
let mut db_data = WalletDb::for_path(data_file.path(), network).unwrap();
init_wallet_db_internal(&mut db_data, None, &[addresses_table::MIGRATION_ID], false)
.unwrap();
let usk = UnifiedSpendingKey::from_seed(&network, &[0u8; 32][..], AccountId::ZERO).unwrap();
let ufvk = usk.to_unified_full_viewing_key();
db_data
.conn
.execute(
"INSERT INTO accounts (account, ufvk) VALUES (0, ?)",
params![ufvk.encode(&network)],
)
.unwrap();
db_data.conn.execute_batch(
"INSERT INTO blocks (height, hash, time, sapling_tree) VALUES (0, 0, 0, x'00');
INSERT INTO transactions (block, id_tx, txid) VALUES (0, 0, '');
INSERT INTO sent_notes (tx, output_pool, output_index, from_account, address, value)
VALUES (0, 2, 0, 0, '', 2);
INSERT INTO sent_notes (tx, output_pool, output_index, from_account, address, value, memo)
VALUES (0, 2, 1, 0, '', 3, X'61');
INSERT INTO sent_notes (tx, output_pool, output_index, from_account, address, value, memo)
VALUES (0, 2, 2, 0, '', 0, X'F600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000');
INSERT INTO received_notes (tx, output_index, account, diversifier, value, rcm, nf, is_change)
VALUES (0, 0, 0, '', 2, '', 'a', false);
INSERT INTO received_notes (tx, output_index, account, diversifier, value, rcm, nf, is_change, memo)
VALUES (0, 3, 0, '', 5, '', 'b', false, X'62');
INSERT INTO received_notes (tx, output_index, account, diversifier, value, rcm, nf, is_change, memo)
VALUES (0, 4, 0, '', 7, '', 'c', true, X'63');",
).unwrap();
init_wallet_db_internal(&mut db_data, None, &[super::MIGRATION_ID], false).unwrap();
let mut q = db_data
.conn
.prepare("SELECT received_total, received_note_count, memo_count FROM v_tx_received")
.unwrap();
let mut rows = q.query([]).unwrap();
let mut row_count = 0;
while let Some(row) = rows.next().unwrap() {
row_count += 1;
let total: i64 = row.get(0).unwrap();
let count: i64 = row.get(1).unwrap();
let memo_count: i64 = row.get(2).unwrap();
assert_eq!(total, 14);
assert_eq!(count, 3);
assert_eq!(memo_count, 2);
}
assert_eq!(row_count, 1);
let mut q = db_data
.conn
.prepare("SELECT sent_total, sent_note_count, memo_count FROM v_tx_sent")
.unwrap();
let mut rows = q.query([]).unwrap();
let mut row_count = 0;
while let Some(row) = rows.next().unwrap() {
row_count += 1;
let total: i64 = row.get(0).unwrap();
let count: i64 = row.get(1).unwrap();
let memo_count: i64 = row.get(2).unwrap();
assert_eq!(total, 5);
assert_eq!(count, 3);
assert_eq!(memo_count, 1);
}
assert_eq!(row_count, 1);
let mut q = db_data
.conn
.prepare("SELECT net_value, has_change, memo_count FROM v_transactions")
.unwrap();
let mut rows = q.query([]).unwrap();
let mut row_count = 0;
while let Some(row) = rows.next().unwrap() {
row_count += 1;
let net_value: i64 = row.get(0).unwrap();
let has_change: bool = row.get(1).unwrap();
let memo_count: i64 = row.get(2).unwrap();
assert_eq!(net_value, 2);
assert!(has_change);
assert_eq!(memo_count, 3);
}
assert_eq!(row_count, 1);
}
#[test]
#[cfg(feature = "transparent-inputs")]
fn migrate_from_wm2() {
use zcash_client_backend::keys::UnifiedAddressRequest;
use zcash_primitives::{
legacy::keys::NonHardenedChildIndex, transaction::components::amount::NonNegativeAmount,
};
use crate::UA_TRANSPARENT;
let network = Network::TestNetwork;
let data_file = NamedTempFile::new().unwrap();
let mut db_data = WalletDb::for_path(data_file.path(), network).unwrap();
init_wallet_db_internal(
&mut db_data,
None,
&[utxos_table::MIGRATION_ID, ufvk_support::MIGRATION_ID],
false,
)
.unwrap();
// create a UTXO to spend
let tx = TransactionData::from_parts(
TxVersion::Sapling,
BranchId::Canopy,
0,
BlockHeight::from(3),
Some(transparent::Bundle {
vin: vec![TxIn {
prevout: OutPoint::new([1u8; 32], 1),
script_sig: Script(vec![]),
sequence: 0,
}],
vout: vec![TxOut {
value: NonNegativeAmount::const_from_u64(1100000000),
script_pubkey: Script(vec![]),
}],
authorization: Authorized,
}),
None,
None,
None,
)
.freeze()
.unwrap();
let mut tx_bytes = vec![];
tx.write(&mut tx_bytes).unwrap();
let usk = UnifiedSpendingKey::from_seed(&network, &[0u8; 32][..], AccountId::ZERO).unwrap();
let ufvk = usk.to_unified_full_viewing_key();
let (ua, _) = ufvk
.default_address(UnifiedAddressRequest::unsafe_new_without_expiry(
false,
true,
UA_TRANSPARENT,
))
.unwrap();
let taddr = ufvk
.transparent()
.and_then(|k| {
k.derive_external_ivk()
.ok()
.map(|k| k.derive_address(NonHardenedChildIndex::ZERO).unwrap())
})
.map(|a| a.encode(&network));
db_data.conn.execute(
"INSERT INTO accounts (account, ufvk, address, transparent_address) VALUES (0, ?, ?, ?)",
params![ufvk.encode(&network), ua.encode(&network), &taddr]
).unwrap();
db_data
.conn
.execute_batch(
"INSERT INTO blocks (height, hash, time, sapling_tree) VALUES (0, 0, 0, x'00');",
)
.unwrap();
db_data.conn.execute(
"INSERT INTO utxos (address, prevout_txid, prevout_idx, script, value_zat, height)
VALUES (?, X'0101010101010101010101010101010101010101010101010101010101010101', 1, X'', 1400000000, 1)",
[taddr]
).unwrap();
db_data
.conn
.execute(
"INSERT INTO transactions (block, id_tx, txid, raw) VALUES (0, 0, '', ?)",
params![tx_bytes],
)
.unwrap();
init_wallet_db_internal(&mut db_data, None, &[super::MIGRATION_ID], false).unwrap();
let fee = db_data
.conn
.query_row("SELECT fee FROM transactions WHERE id_tx = 0", [], |row| {
Ok(Amount::from_i64(row.get(0)?).unwrap())
})
.unwrap();
assert_eq!(fee, Amount::from_i64(300000000).unwrap());
}
}