Track inputs sent to wallet-internal recipients.

Ensure that we're attempting trial-decryption with the internal IVK
and correctly track internal vs. external recipients in the wallet
database.
This commit is contained in:
Kris Nuttycombe 2022-09-26 10:59:50 -06:00
parent 1dc3cfe724
commit 06e43a572a
10 changed files with 290 additions and 169 deletions

View File

@ -248,13 +248,19 @@ pub struct SentTransaction<'a> {
pub tx: &'a Transaction,
pub created: time::OffsetDateTime,
pub account: AccountId,
pub outputs: Vec<SentTransactionOutput<'a>>,
pub outputs: Vec<SentTransactionOutput>,
pub fee_amount: Amount,
#[cfg(feature = "transparent-inputs")]
pub utxos_spent: Vec<OutPoint>,
}
pub struct SentTransactionOutput<'a> {
#[derive(Debug, Clone)]
pub enum Recipient {
Address(RecipientAddress),
InternalAccount(AccountId),
}
pub struct SentTransactionOutput {
/// The index within the transaction that contains the recipient output.
///
/// - If `recipient_address` is a Sapling address, this is an index into the Sapling
@ -262,7 +268,7 @@ pub struct SentTransactionOutput<'a> {
/// - If `recipient_address` is a transparent address, this is an index into the
/// transparent outputs of the transaction.
pub output_index: usize,
pub recipient_address: &'a RecipientAddress,
pub recipient: Recipient,
pub value: Amount,
pub memo: Option<MemoBytes>,
}

View File

@ -23,7 +23,8 @@ use {
use crate::{
address::RecipientAddress,
data_api::{
error::Error, DecryptedTransaction, SentTransaction, SentTransactionOutput, WalletWrite,
error::Error, DecryptedTransaction, Recipient, SentTransaction, SentTransactionOutput,
WalletWrite,
},
decrypt_transaction,
wallet::OvkPolicy,
@ -394,7 +395,7 @@ where
SentTransactionOutput {
output_index: idx,
recipient_address: &payment.recipient_address,
recipient: Recipient::Address(payment.recipient_address.clone()),
value: payment.amount,
memo: payment.memo.clone()
}
@ -512,12 +513,7 @@ where
// add the sapling output to shield the funds
builder
.add_sapling_output(
Some(ovk),
shielding_address.clone(),
amount_to_shield,
memo.clone(),
)
.add_sapling_output(Some(ovk), shielding_address, amount_to_shield, memo.clone())
.map_err(Error::Builder)?;
let (tx, tx_metadata) = builder.build(&prover).map_err(Error::Builder)?;
@ -531,8 +527,8 @@ where
account,
outputs: vec![SentTransactionOutput {
output_index,
recipient_address: &RecipientAddress::Shielded(shielding_address),
value: amount_to_shield,
recipient: Recipient::InternalAccount(account),
memo: Some(memo.clone()),
}],
fee_amount: fee,

View File

@ -10,11 +10,22 @@ use zcash_primitives::{
Note, PaymentAddress,
},
transaction::Transaction,
zip32::AccountId,
zip32::{AccountId, Scope},
};
use crate::keys::UnifiedFullViewingKey;
/// An enumeration of the possible relationships a TXO can have to the wallet.
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum TransferType {
/// The transfer was received on one of the wallet's external addresses.
Incoming,
/// The transfer was received on one of the wallet's internal-only addresses.
WalletInternal,
/// The transfer was decrypted using one of the wallet's outgoing viewing keys.
Outgoing,
}
/// A decrypted shielded output.
pub struct DecryptedOutput {
/// The index of the output within [`shielded_outputs`].
@ -33,7 +44,7 @@ pub struct DecryptedOutput {
/// this is a logical output of the transaction.
///
/// [`OutgoingViewingKey`]: zcash_primitives::keys::OutgoingViewingKey
pub outgoing: bool,
pub transfer_type: TransferType,
}
/// Scans a [`Transaction`] for any information that can be decrypted by the set of
@ -49,19 +60,29 @@ pub fn decrypt_transaction<P: consensus::Parameters>(
if let Some(bundle) = tx.sapling_bundle() {
for (account, ufvk) in ufvks.iter() {
if let Some(dfvk) = ufvk.sapling() {
let ivk = PreparedIncomingViewingKey::new(&dfvk.fvk().vk.ivk());
let ivk_external = PreparedIncomingViewingKey::new(&dfvk.to_ivk(Scope::External));
let ivk_internal = PreparedIncomingViewingKey::new(&dfvk.to_ivk(Scope::Internal));
let ovk = dfvk.fvk().ovk;
for (index, output) in bundle.shielded_outputs.iter().enumerate() {
let ((note, to, memo), outgoing) =
match try_sapling_note_decryption(params, height, &ivk, output) {
Some(ret) => (ret, false),
None => match try_sapling_output_recovery(params, height, &ovk, output)
{
Some(ret) => (ret, true),
None => continue,
},
};
let decryption_result =
try_sapling_note_decryption(params, height, &ivk_external, output)
.map(|ret| (ret, TransferType::Incoming))
.or_else(|| {
try_sapling_note_decryption(params, height, &ivk_internal, output)
.map(|ret| (ret, TransferType::WalletInternal))
})
.or_else(|| {
try_sapling_output_recovery(params, height, &ovk, output)
.map(|ret| (ret, TransferType::Outgoing))
});
let ((note, to, memo), transfer_type) = match decryption_result {
Some(result) => result,
None => {
continue;
}
};
decrypted.push(DecryptedOutput {
index,
@ -69,7 +90,7 @@ pub fn decrypt_transaction<P: consensus::Parameters>(
account: *account,
to,
memo,
outgoing,
transfer_type,
})
}
}

View File

@ -19,4 +19,4 @@ pub mod wallet;
pub mod welding_rig;
pub mod zip321;
pub use decrypt::{decrypt_transaction, DecryptedOutput};
pub use decrypt::{decrypt_transaction, DecryptedOutput, TransferType};

View File

@ -55,11 +55,13 @@ use zcash_primitives::{
use zcash_client_backend::{
address::{RecipientAddress, UnifiedAddress},
data_api::{
BlockSource, DecryptedTransaction, PrunedBlock, SentTransaction, WalletRead, WalletWrite,
BlockSource, DecryptedTransaction, PrunedBlock, Recipient, SentTransaction, WalletRead,
WalletWrite,
},
keys::{UnifiedFullViewingKey, UnifiedSpendingKey},
proto::compact_formats::CompactBlock,
wallet::SpendableNote,
TransferType,
};
use crate::error::SqliteClientError;
@ -529,29 +531,38 @@ impl<'a, P: consensus::Parameters> WalletWrite for DataConnStmtCache<'a, P> {
let mut spending_account_id: Option<AccountId> = None;
for output in d_tx.sapling_outputs {
if output.outgoing {
wallet::put_sent_note(
up,
tx_ref,
output.index,
output.account,
&output.to,
Amount::from_u64(output.note.value)
.map_err(|_| SqliteClientError::CorruptedData("Note value invalid.".to_string()))?,
Some(&output.memo),
)?;
} else {
match spending_account_id {
Some(id) =>
if id != output.account {
panic!("Unable to determine a unique account identifier for z->t spend.");
}
None => {
spending_account_id = Some(output.account);
}
}
match output.transfer_type {
TransferType::Outgoing | TransferType::WalletInternal => {
let recipient = if output.transfer_type == TransferType::Outgoing {
Recipient::Address(RecipientAddress::Shielded(output.to.clone()))
} else {
Recipient::InternalAccount(output.account)
};
wallet::put_received_note(up, output, tx_ref)?;
wallet::put_sent_note(
up,
tx_ref,
output.index,
output.account,
&recipient,
Amount::from_u64(output.note.value)
.map_err(|_| SqliteClientError::CorruptedData("Note value invalid.".to_string()))?,
Some(&output.memo),
)?;
}
TransferType::Incoming => {
match spending_account_id {
Some(id) =>
if id != output.account {
panic!("Unable to determine a unique account identifier for z->t spend.");
}
None => {
spending_account_id = Some(output.account);
}
}
wallet::put_received_note(up, output, tx_ref)?;
}
}
}
@ -611,35 +622,26 @@ impl<'a, P: consensus::Parameters> WalletWrite for DataConnStmtCache<'a, P> {
}
for output in &sent_tx.outputs {
match output.recipient_address {
// TODO: Store the entire UA, not just the Sapling component.
// This will require more info about the output index.
RecipientAddress::Unified(ua) => wallet::insert_sent_note(
match &output.recipient {
Recipient::Address(RecipientAddress::Transparent(addr)) => {
wallet::insert_sent_utxo(
up,
tx_ref,
output.output_index,
sent_tx.account,
addr,
output.value,
)?
}
shielded_recipient => wallet::insert_sent_note(
up,
tx_ref,
output.output_index,
sent_tx.account,
ua.sapling().expect("TODO: Add Orchard support"),
shielded_recipient,
output.value,
output.memo.as_ref(),
)?,
RecipientAddress::Shielded(addr) => wallet::insert_sent_note(
up,
tx_ref,
output.output_index,
sent_tx.account,
addr,
output.value,
output.memo.as_ref(),
)?,
RecipientAddress::Transparent(addr) => wallet::insert_sent_utxo(
up,
tx_ref,
output.output_index,
sent_tx.account,
addr,
output.value,
)?,
}
}

View File

@ -7,7 +7,7 @@
//! - Build the statement in [`DataConnStmtCache::new`].
//! - Add a crate-private helper method to `DataConnStmtCache` for running the statement.
use rusqlite::{params, Statement, ToSql};
use rusqlite::{named_params, params, Statement, ToSql};
use zcash_primitives::{
block::BlockHash,
consensus::{self, BlockHeight},
@ -18,14 +18,14 @@ use zcash_primitives::{
zip32::{AccountId, DiversifierIndex},
};
use zcash_client_backend::address::UnifiedAddress;
use zcash_client_backend::{address::UnifiedAddress, data_api::Recipient};
use crate::{error::SqliteClientError, wallet::PoolType, NoteId, WalletDb};
#[cfg(feature = "transparent-inputs")]
use {
crate::UtxoId,
rusqlite::{named_params, OptionalExtension},
rusqlite::OptionalExtension,
zcash_client_backend::{encoding::AddressCodec, wallet::WalletTransparentOutput},
zcash_primitives::transaction::components::transparent::OutPoint,
};
@ -155,7 +155,8 @@ impl<'a, P> DataConnStmtCache<'a, P> {
stmt_update_sent_note: wallet_db.conn.prepare(
"UPDATE sent_notes
SET from_account = :account,
address = :address,
to_address = :to_address,
to_account = :to_account,
value = :value,
memo = IFNULL(:memo, memo)
WHERE tx = :tx
@ -163,8 +164,12 @@ impl<'a, P> DataConnStmtCache<'a, P> {
AND output_index = :output_index",
)?,
stmt_insert_sent_note: wallet_db.conn.prepare(
"INSERT INTO sent_notes (tx, output_pool, output_index, from_account, address, value, memo)
VALUES (:tx, :output_pool, :output_index, :from_account, :address, :value, :memo)"
"INSERT INTO sent_notes (
tx, output_pool, output_index, from_account,
to_address, to_account, value, memo)
VALUES (
:tx, :output_pool, :output_index, :from_account,
:to_address, :to_account, :value, :memo)"
)?,
stmt_insert_witness: wallet_db.conn.prepare(
"INSERT INTO sapling_witnesses (note, block, witness)
@ -346,6 +351,80 @@ impl<'a, P> DataConnStmtCache<'a, P> {
}
impl<'a, P: consensus::Parameters> DataConnStmtCache<'a, P> {
/// Inserts a sent note into the wallet database.
///
/// `output_index` is the index within the transaction that contains the recipient output:
///
/// - If `to` is a Sapling address, this is an index into the Sapling outputs of the
/// transaction.
/// - If `to` is a transparent address, this is an index into the transparent outputs of
/// the transaction.
/// - If `to` is an internal account, this is an index into the Sapling outputs of the
/// transaction.
#[allow(clippy::too_many_arguments)]
pub(crate) fn stmt_insert_sent_note(
&mut self,
tx_ref: i64,
pool_type: PoolType,
output_index: usize,
from_account: AccountId,
to: &Recipient,
value: Amount,
memo: Option<&MemoBytes>,
) -> Result<(), SqliteClientError> {
let (to_address, to_account) = match to {
Recipient::Address(addr) => (Some(addr.encode(&self.wallet_db.params)), None),
Recipient::InternalAccount(id) => (None, Some(u32::from(*id))),
};
self.stmt_insert_sent_note.execute(named_params![
":tx": &tx_ref,
":output_pool": &pool_type.typecode(),
":output_index": &i64::try_from(output_index).unwrap(),
":from_account": &u32::from(from_account),
":to_address": &to_address,
":to_account": &to_account,
":value": &i64::from(value),
":memo": &memo.filter(|m| *m != &MemoBytes::empty()).map(|m| m.as_slice()),
])?;
Ok(())
}
/// Updates the data for the given sent note.
///
/// Returns `false` if the transaction doesn't exist in the wallet.
#[allow(clippy::too_many_arguments)]
pub(crate) fn stmt_update_sent_note(
&mut self,
account: AccountId,
to: &Recipient,
value: Amount,
memo: Option<&MemoBytes>,
tx_ref: i64,
pool_type: PoolType,
output_index: usize,
) -> Result<bool, SqliteClientError> {
let (to_address, to_account) = match to {
Recipient::Address(addr) => (Some(addr.encode(&self.wallet_db.params)), None),
Recipient::InternalAccount(id) => (None, Some(u32::from(*id))),
};
match self.stmt_update_sent_note.execute(named_params![
":account": &u32::from(account),
":to_address": &to_address,
":to_account": &to_account,
":value": &i64::from(value),
":memo": &memo.filter(|m| *m != &MemoBytes::empty()).map(|m| m.as_slice()),
":tx": &tx_ref,
":output_pool": &pool_type.typecode(),
":output_index": &i64::try_from(output_index).unwrap(),
])? {
0 => Ok(false),
1 => Ok(true),
_ => unreachable!("tx_output constraint is marked as UNIQUE"),
}
}
/// Adds the given received UTXO to the datastore.
///
/// Returns the database row for the newly-inserted UTXO, or an error if the UTXO
@ -528,78 +607,6 @@ impl<'a, P> DataConnStmtCache<'a, P> {
.map_err(SqliteClientError::from)
}
/// Inserts a sent note into the wallet database.
///
/// `output_index` is the index within the transaction that contains the recipient output:
///
/// - If `to` is a Sapling address, this is an index into the Sapling outputs of the
/// transaction.
/// - If `to` is a transparent address, this is an index into the transparent outputs of
/// the transaction.
#[allow(clippy::too_many_arguments)]
pub(crate) fn stmt_insert_sent_note(
&mut self,
tx_ref: i64,
pool_type: PoolType,
output_index: usize,
account: AccountId,
to_str: &str,
value: Amount,
memo: Option<&MemoBytes>,
) -> Result<(), SqliteClientError> {
let sql_args: &[(&str, &dyn ToSql)] = &[
(":tx", &tx_ref),
(":output_pool", &pool_type.typecode()),
(":output_index", &i64::try_from(output_index).unwrap()),
(":from_account", &u32::from(account)),
(":address", &to_str),
(":value", &i64::from(value)),
(
":memo",
&memo
.filter(|m| *m != &MemoBytes::empty())
.map(|m| m.as_slice()),
),
];
self.stmt_insert_sent_note.execute(sql_args)?;
Ok(())
}
/// Updates the data for the given sent note.
///
/// Returns `false` if the transaction doesn't exist in the wallet.
#[allow(clippy::too_many_arguments)]
pub(crate) fn stmt_update_sent_note(
&mut self,
account: AccountId,
to_str: &str,
value: Amount,
memo: Option<&MemoBytes>,
tx_ref: i64,
pool_type: PoolType,
output_index: usize,
) -> Result<bool, SqliteClientError> {
let sql_args: &[(&str, &dyn ToSql)] = &[
(":account", &u32::from(account)),
(":address", &to_str),
(":value", &i64::from(value)),
(
":memo",
&memo
.filter(|m| *m != &MemoBytes::empty())
.map(|m| m.as_slice()),
),
(":tx", &tx_ref),
(":output_pool", &pool_type.typecode()),
(":output_index", &i64::try_from(output_index).unwrap()),
];
match self.stmt_update_sent_note.execute(sql_args)? {
0 => Ok(false),
1 => Ok(true),
_ => unreachable!("tx_output constraint is marked as UNIQUE"),
}
}
/// Records the incremental witness for the specified note, as of the given block
/// height.
///

View File

@ -27,8 +27,7 @@ use zcash_primitives::{
use zcash_client_backend::{
address::{RecipientAddress, UnifiedAddress},
data_api::error::Error,
encoding::{encode_payment_address_p, encode_transparent_address_p},
data_api::{error::Error, Recipient},
keys::UnifiedFullViewingKey,
wallet::{WalletShieldedOutput, WalletTx},
DecryptedOutput,
@ -1163,14 +1162,14 @@ pub fn put_sent_note<'a, P: consensus::Parameters>(
tx_ref: i64,
output_index: usize,
account: AccountId,
to: &PaymentAddress,
to: &Recipient,
value: Amount,
memo: Option<&MemoBytes>,
) -> Result<(), SqliteClientError> {
// Try updating an existing sent note.
if !stmts.stmt_update_sent_note(
account,
&encode_payment_address_p(&stmts.wallet_db.params, to),
to,
value,
memo,
tx_ref,
@ -1203,7 +1202,7 @@ pub fn put_sent_utxo<'a, P: consensus::Parameters>(
// Try updating an existing sent UTXO.
if !stmts.stmt_update_sent_note(
account,
&encode_transparent_address_p(&stmts.wallet_db.params, to),
&Recipient::Address(RecipientAddress::Transparent(*to)),
value,
None,
tx_ref,
@ -1225,26 +1224,21 @@ pub fn put_sent_utxo<'a, P: consensus::Parameters>(
/// transaction.
/// - If `to` is a transparent address, this is an index into the transparent outputs of
/// the transaction.
#[deprecated(
note = "This method will be removed in a future update. Use zcash_client_backend::data_api::WalletWrite::store_sent_tx instead."
)]
pub fn insert_sent_note<'a, P: consensus::Parameters>(
pub(crate) fn insert_sent_note<'a, P: consensus::Parameters>(
stmts: &mut DataConnStmtCache<'a, P>,
tx_ref: i64,
output_index: usize,
account: AccountId,
to: &PaymentAddress,
from_account: AccountId,
to: &Recipient,
value: Amount,
memo: Option<&MemoBytes>,
) -> Result<(), SqliteClientError> {
let to_str = encode_payment_address_p(&stmts.wallet_db.params, to);
stmts.stmt_insert_sent_note(
tx_ref,
PoolType::Sapling,
output_index,
account,
&to_str,
from_account,
to,
value,
memo,
)
@ -1264,14 +1258,12 @@ pub fn insert_sent_utxo<'a, P: consensus::Parameters>(
to: &TransparentAddress,
value: Amount,
) -> Result<(), SqliteClientError> {
let to_str = encode_transparent_address_p(&stmts.wallet_db.params, to);
stmts.stmt_insert_sent_note(
tx_ref,
PoolType::Transparent,
output_index,
account,
&to_str,
&Recipient::Address(RecipientAddress::Transparent(*to)),
value,
None,
)

View File

@ -122,8 +122,13 @@ fn init_wallet_db_internal<P: consensus::Parameters + 'static>(
seed: Option<SecretVec<u8>>,
target_migration: Option<Uuid>,
) -> Result<(), MigratorError<WalletMigrationError>> {
// Turn off foreign keys, and ensure that table replacement/modification
// does not break views
wdb.conn
.execute("PRAGMA foreign_keys = OFF", [])
.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.");
@ -375,11 +380,13 @@ mod tests {
output_pool INTEGER NOT NULL ,
output_index INTEGER NOT NULL,
from_account INTEGER NOT NULL,
address TEXT NOT NULL,
to_address TEXT,
to_account INTEGER,
value INTEGER NOT NULL,
memo BLOB,
FOREIGN KEY (tx) REFERENCES transactions(id_tx),
FOREIGN KEY (from_account) REFERENCES accounts(account),
FOREIGN KEY (to_account) REFERENCES accounts(account),
CONSTRAINT tx_output UNIQUE (tx, output_pool, output_index)
)",
"CREATE TABLE transactions (

View File

@ -2,6 +2,7 @@ mod add_transaction_views;
mod add_utxo_account;
mod addresses_table;
mod initial_setup;
mod sent_notes_to_internal;
mod ufvk_support;
mod utxos_table;
@ -31,5 +32,6 @@ pub(super) fn all_migrations<P: consensus::Parameters + 'static>(
Box::new(add_utxo_account::Migration {
_params: params.clone(),
}),
Box::new(sent_notes_to_internal::Migration {}),
]
}

View File

@ -0,0 +1,88 @@
//! A migration that adds an identifier for the account that received a sent note
//! on an internal address to the sent_notes table.
use std::collections::HashSet;
use rusqlite;
use schemer;
use schemer_rusqlite::RusqliteMigration;
use uuid::Uuid;
use super::{addresses_table, utxos_table};
use crate::wallet::init::WalletMigrationError;
/// This migration adds the `to_account` field to the `sent_notes` table.
///
/// 0ddbe561-8259-4212-9ab7-66fdc4a74e1d
pub(super) const MIGRATION_ID: Uuid = Uuid::from_fields(
0x0ddbe561,
0x8259,
0x4212,
b"\x9a\xb7\x66\xfd\xc4\xa7\x4e\x1d",
);
pub(super) struct Migration;
impl schemer::Migration for Migration {
fn id(&self) -> Uuid {
MIGRATION_ID
}
fn dependencies(&self) -> HashSet<Uuid> {
[utxos_table::MIGRATION_ID, addresses_table::MIGRATION_ID]
.into_iter()
.collect()
}
fn description(&self) -> &'static str {
"Adds an identifier for the account that received an internal note to the sent_notes table"
}
}
impl RusqliteMigration for Migration {
type Error = WalletMigrationError;
fn up(&self, transaction: &rusqlite::Transaction) -> Result<(), WalletMigrationError> {
transaction.execute_batch("ALTER TABLE sent_notes ADD COLUMN to_account INTEGER;")?;
// `to_account` should be null for all migrated rows, since internal addresses
// have not been used for change or shielding prior to this migration.
transaction.execute_batch(
"CREATE TABLE sent_notes_new (
id_note INTEGER PRIMARY KEY,
tx INTEGER NOT NULL,
output_pool INTEGER NOT NULL ,
output_index INTEGER NOT NULL,
from_account INTEGER NOT NULL,
to_address TEXT,
to_account INTEGER,
value INTEGER NOT NULL,
memo BLOB,
FOREIGN KEY (tx) REFERENCES transactions(id_tx),
FOREIGN KEY (from_account) REFERENCES accounts(account),
FOREIGN KEY (to_account) REFERENCES accounts(account),
CONSTRAINT tx_output UNIQUE (tx, output_pool, output_index)
);
INSERT INTO sent_notes_new (
id_note, tx, output_pool, output_index,
from_account, to_address,
value, memo)
SELECT
id_note, tx, output_pool, output_index,
from_account, address,
value, memo
FROM sent_notes;",
)?;
transaction.execute_batch(
"DROP TABLE sent_notes;
ALTER TABLE sent_notes_new RENAME TO sent_notes;",
)?;
Ok(())
}
fn down(&self, _transaction: &rusqlite::Transaction) -> Result<(), WalletMigrationError> {
// TODO: something better than just panic?
panic!("Cannot revert this migration.");
}
}