Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

zcash_client_sqlite: Track all transparent spends. #1496

Merged
merged 8 commits into from
Aug 19, 2024
2 changes: 1 addition & 1 deletion zcash_client_backend/src/data_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -566,7 +566,7 @@ pub struct SpendableNotes<NoteRef> {

/// A request for transaction data enhancement, spentness check, or discovery
/// of spends from a given transparent address within a specific block range.
#[derive(Clone, Debug, PartialEq, Eq)]
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum TransactionDataRequest {
/// Information about the chain's view of a transaction is requested.
///
Expand Down
370 changes: 14 additions & 356 deletions zcash_client_sqlite/src/lib.rs

Large diffs are not rendered by default.

376 changes: 372 additions & 4 deletions zcash_client_sqlite/src/wallet.rs

Large diffs are not rendered by default.

22 changes: 22 additions & 0 deletions zcash_client_sqlite/src/wallet/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,28 @@ CREATE TABLE "transparent_received_output_spends" (
UNIQUE (transparent_received_output_id, transaction_id)
)"#;

/// A cache of the relationship between a transaction and the prevout data of its
/// transparent inputs.
///
/// This table is used in out-of-order wallet recovery to cache the information about
/// what transaction(s) spend each transparent outpoint, so that if an output belonging
/// to the wallet is detected after the transaction that spends it has been processed,
/// the spend can also be recorded as part of the process of adding the output to
/// [`TABLE_TRANSPARENT_RECEIVED_OUTPUTS`].
pub(super) const TABLE_TRANSPARENT_SPEND_MAP: &str = r#"
CREATE TABLE transparent_spend_map (
spending_transaction_id INTEGER NOT NULL,
prevout_txid BLOB NOT NULL,
prevout_output_index INTEGER NOT NULL,
FOREIGN KEY (spending_transaction_id) REFERENCES transactions(id_tx)
-- NOTE: We can't create a unique constraint on just (prevout_txid, prevout_output_index)
-- because the same output may be attempted to be spent in multiple transactions, even
-- though only one will ever be mined.
CONSTRAINT transparent_spend_map_unique UNIQUE (
spending_transaction_id, prevout_txid, prevout_output_index
)
)"#;

/// Stores the outputs of transactions created by the wallet.
///
/// Unlike with outputs received by the wallet, we store sent outputs for all pools in
Expand Down
27 changes: 27 additions & 0 deletions zcash_client_sqlite/src/wallet/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@

/// Reverting the specified migration is not supported.
CannotRevert(Uuid),

/// Some other unexpected violation of database business rules occurred
Other(SqliteClientError),
}

impl From<rusqlite::Error> for WalletMigrationError {
Expand All @@ -79,6 +82,21 @@
}
}

impl From<SqliteClientError> for WalletMigrationError {
fn from(value: SqliteClientError) -> Self {
match value {
SqliteClientError::CorruptedData(err) => WalletMigrationError::CorruptedData(err),
SqliteClientError::DbError(err) => WalletMigrationError::DbError(err),
SqliteClientError::CommitmentTree(err) => WalletMigrationError::CommitmentTree(err),
SqliteClientError::BalanceError(err) => WalletMigrationError::BalanceError(err),
SqliteClientError::AddressGeneration(err) => {
WalletMigrationError::AddressGeneration(err)

Check warning on line 93 in zcash_client_sqlite/src/wallet/init.rs

View check run for this annotation

Codecov / codecov/patch

zcash_client_sqlite/src/wallet/init.rs#L86-L93

Added lines #L86 - L93 were not covered by tests
}
other => WalletMigrationError::Other(other),

Check warning on line 95 in zcash_client_sqlite/src/wallet/init.rs

View check run for this annotation

Codecov / codecov/patch

zcash_client_sqlite/src/wallet/init.rs#L95

Added line #L95 was not covered by tests
}
}
}

impl fmt::Display for WalletMigrationError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match &self {
Expand Down Expand Up @@ -106,6 +124,13 @@
WalletMigrationError::CannotRevert(uuid) => {
write!(f, "Reverting migration {} is not supported", uuid)
}
WalletMigrationError::Other(err) => {
write!(
f,

Check warning on line 129 in zcash_client_sqlite/src/wallet/init.rs

View check run for this annotation

Codecov / codecov/patch

zcash_client_sqlite/src/wallet/init.rs#L127-L129

Added lines #L127 - L129 were not covered by tests
"Unexpected violation of database business rules: {}",
err

Check warning on line 131 in zcash_client_sqlite/src/wallet/init.rs

View check run for this annotation

Codecov / codecov/patch

zcash_client_sqlite/src/wallet/init.rs#L131

Added line #L131 was not covered by tests
)
}
}
}
}
Expand All @@ -117,6 +142,7 @@
WalletMigrationError::BalanceError(e) => Some(e),
WalletMigrationError::CommitmentTree(e) => Some(e),
WalletMigrationError::AddressGeneration(e) => Some(e),
WalletMigrationError::Other(e) => Some(e),

Check warning on line 145 in zcash_client_sqlite/src/wallet/init.rs

View check run for this annotation

Codecov / codecov/patch

zcash_client_sqlite/src/wallet/init.rs#L145

Added line #L145 was not covered by tests
_ => None,
}
}
Expand Down Expand Up @@ -406,6 +432,7 @@
db::TABLE_TRANSACTIONS,
db::TABLE_TRANSPARENT_RECEIVED_OUTPUT_SPENDS,
db::TABLE_TRANSPARENT_RECEIVED_OUTPUTS,
db::TABLE_TRANSPARENT_SPEND_MAP,
db::TABLE_TRANSPARENT_SPEND_SEARCH_QUEUE,
db::TABLE_TX_LOCATOR_MAP,
db::TABLE_TX_RETRIEVAL_QUEUE,
Expand Down
4 changes: 3 additions & 1 deletion zcash_client_sqlite/src/wallet/init/migrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,9 @@ pub(super) fn all_migrations<P: consensus::Parameters + 'static>(
params: params.clone(),
}),
Box::new(spend_key_available::Migration),
Box::new(tx_retrieval_queue::Migration),
Box::new(tx_retrieval_queue::Migration {
params: params.clone(),
}),
]
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,21 @@
use schemer_rusqlite::RusqliteMigration;
use std::collections::HashSet;
use uuid::Uuid;
use zcash_client_backend::data_api::DecryptedTransaction;
use zcash_primitives::transaction::builder::DEFAULT_TX_EXPIRY_DELTA;
use zcash_protocol::consensus::{self, BlockHeight, BranchId};

use crate::wallet::init::WalletMigrationError;
use crate::wallet::{self, init::WalletMigrationError};

use super::utxos_to_txos;

pub(super) const MIGRATION_ID: Uuid = Uuid::from_u128(0xfec02b61_3988_4b4f_9699_98977fac9e7f);

pub(crate) struct Migration;
pub(super) struct Migration<P> {
pub(super) params: P,
}

impl schemer::Migration for Migration {
impl<P> schemer::Migration for Migration<P> {
fn id(&self) -> Uuid {
MIGRATION_ID
}
Expand All @@ -28,7 +32,7 @@
}
}

impl RusqliteMigration for Migration {
impl<P: consensus::Parameters> RusqliteMigration for Migration<P> {
type Error = WalletMigrationError;

fn up(&self, transaction: &Transaction) -> Result<(), WalletMigrationError> {
Expand All @@ -48,6 +52,19 @@
output_index INTEGER NOT NULL,
FOREIGN KEY (transaction_id) REFERENCES transactions(id_tx),
CONSTRAINT value_received_height UNIQUE (transaction_id, output_index)
);

CREATE TABLE transparent_spend_map (
spending_transaction_id INTEGER NOT NULL,
prevout_txid BLOB NOT NULL,
prevout_output_index INTEGER NOT NULL,
FOREIGN KEY (spending_transaction_id) REFERENCES transactions(id_tx)
-- NOTE: We can't create a unique constraint on just (prevout_txid, prevout_output_index)
-- because the same output may be attempted to be spent in multiple transactions, even
-- though only one will ever be mined.
CONSTRAINT transparent_spend_map_unique UNIQUE (
spending_transaction_id, prevout_txid, prevout_output_index
)
);",
)?;

Expand All @@ -62,12 +79,50 @@
named_params![":default_expiry_delta": DEFAULT_TX_EXPIRY_DELTA],
)?;

// Call `decrypt_and_store_transaction` for each transaction known to the wallet to
// populate the enhancement queues with any transparent history information that we don't
// already have.
let mut stmt_transactions =
transaction.prepare("SELECT raw, mined_height FROM transactions")?;
let mut rows = stmt_transactions.query([])?;
while let Some(row) = rows.next()? {
let tx_data = row.get::<_, Option<Vec<u8>>>(0)?;
let mined_height = row.get::<_, Option<u32>>(1)?.map(BlockHeight::from);

if let Some(tx_data) = tx_data {
let tx = zcash_primitives::transaction::Transaction::read(
&tx_data[..],
// We assume unmined transactions are created with the current consensus branch ID.
mined_height
.map_or(BranchId::Sapling, |h| BranchId::for_height(&self.params, h)),
)
.map_err(|_| {
WalletMigrationError::CorruptedData(
"Could not read serialized transaction data.".to_owned(),

Check warning on line 101 in zcash_client_sqlite/src/wallet/init/migrations/tx_retrieval_queue.rs

View check run for this annotation

Codecov / codecov/patch

zcash_client_sqlite/src/wallet/init/migrations/tx_retrieval_queue.rs#L100-L101

Added lines #L100 - L101 were not covered by tests
)
})?;

wallet::store_decrypted_tx(
transaction,
&self.params,
DecryptedTransaction::new(
mined_height,
&tx,
vec![],
#[cfg(feature = "orchard")]
vec![],
),
)?;
}
}

Ok(())
}

fn down(&self, transaction: &Transaction) -> Result<(), WalletMigrationError> {
transaction.execute_batch(
"DROP TABLE transparent_spend_search_queue;
"DROP TABLE transparent_spend_map;
DROP TABLE transparent_spend_search_queue;
ALTER TABLE transactions DROP COLUMN target_height;
DROP TABLE tx_retrieval_queue;",
)?;
Expand Down
76 changes: 66 additions & 10 deletions zcash_client_sqlite/src/wallet/transparent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -427,25 +427,31 @@
}

/// Marks the given UTXO as having been spent.
///
/// Returns `true` if the UTXO was known to the wallet.
pub(crate) fn mark_transparent_utxo_spent(
conn: &rusqlite::Connection,
tx_ref: TxRef,
spent_in_tx: TxRef,
outpoint: &OutPoint,
) -> Result<(), SqliteClientError> {
) -> Result<bool, SqliteClientError> {
let spend_params = named_params![
":spent_in_tx": spent_in_tx.0,
":prevout_txid": outpoint.hash().as_ref(),
":prevout_idx": outpoint.n(),
];
let mut stmt_mark_transparent_utxo_spent = conn.prepare_cached(
"INSERT INTO transparent_received_output_spends (transparent_received_output_id, transaction_id)
SELECT txo.id, :spent_in_tx
FROM transparent_received_outputs txo
JOIN transactions t ON t.id_tx = txo.transaction_id
WHERE t.txid = :prevout_txid
AND txo.output_index = :prevout_idx
ON CONFLICT (transparent_received_output_id, transaction_id) DO NOTHING",
ON CONFLICT (transparent_received_output_id, transaction_id)
-- The following UPDATE is effectively a no-op, but we perform it anyway so that the
-- number of affected rows can be used to determine whether a record existed.
DO UPDATE SET transaction_id = :spent_in_tx",
)?;
stmt_mark_transparent_utxo_spent.execute(named_params![
":spent_in_tx": tx_ref.0,
":prevout_txid": outpoint.hash().as_ref(),
":prevout_idx": outpoint.n(),
])?;
let affected_rows = stmt_mark_transparent_utxo_spent.execute(spend_params)?;

// Since we know that the output is spent, we no longer need to search for
// it to find out if it has been spent.
Expand All @@ -461,7 +467,24 @@
":prevout_idx": outpoint.n(),
])?;

Ok(())
// If no rows were affected, we know that we don't actually have the output in
// `transparent_received_outputs` yet, so we have to record the output as spent
// so that when we eventually detect the output, we can create the spend record.
if affected_rows == 0 {
conn.execute(

Check warning on line 474 in zcash_client_sqlite/src/wallet/transparent.rs

View check run for this annotation

Codecov / codecov/patch

zcash_client_sqlite/src/wallet/transparent.rs#L474

Added line #L474 was not covered by tests
"INSERT INTO transparent_spend_map (
spending_transaction_id,
prevout_txid,
prevout_output_index
)
VALUES (:spent_in_tx, :prevout_txid, :prevout_idx)
ON CONFLICT (spending_transaction_id, prevout_txid, prevout_output_index)
DO NOTHING",
spend_params,

Check warning on line 483 in zcash_client_sqlite/src/wallet/transparent.rs

View check run for this annotation

Codecov / codecov/patch

zcash_client_sqlite/src/wallet/transparent.rs#L483

Added line #L483 was not covered by tests
)?;
}

Ok(affected_rows > 0)
}

/// Adds the given received UTXO to the datastore.
Expand Down Expand Up @@ -494,6 +517,11 @@
conn: &rusqlite::Connection,
params: &P,
) -> Result<Vec<TransactionDataRequest>, SqliteClientError> {
// `lightwalletd` will return an error for `GetTaddressTxids` requests having an end height
// greater than the current chain tip height, so we take the chain tip height into account
// here in order to make this pothole easier for clients of the API to avoid.
let chain_tip_height = super::chain_tip_height(conn)?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incidentally, why does the doc for wallet::chain_tip_height say

/// Returns the minimum and maximum heights of blocks in the chain which may be scanned.

when it actually only returns (optionally) a single height?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filed #1498.


// We cannot construct address-based transaction data requests for the case where we cannot
// determine the height at which to begin, so we require that either the target height or mined
// height be set.
Expand All @@ -509,11 +537,16 @@
.query_and_then([], |row| {
let address = TransparentAddress::decode(params, &row.get::<_, String>(0)?)?;
let block_range_start = BlockHeight::from(row.get::<_, u32>(1)?);
let max_end_height = block_range_start + DEFAULT_TX_EXPIRY_DELTA + 1;

Check warning on line 540 in zcash_client_sqlite/src/wallet/transparent.rs

View check run for this annotation

Codecov / codecov/patch

zcash_client_sqlite/src/wallet/transparent.rs#L540

Added line #L540 was not covered by tests

Ok::<TransactionDataRequest, SqliteClientError>(
TransactionDataRequest::SpendsFromAddress {
address,
block_range_start,
block_range_end: Some(block_range_start + DEFAULT_TX_EXPIRY_DELTA + 1),
block_range_end: Some(
chain_tip_height
.map_or(max_end_height, |h| std::cmp::min(h + 1, max_end_height)),

Check warning on line 548 in zcash_client_sqlite/src/wallet/transparent.rs

View check run for this annotation

Codecov / codecov/patch

zcash_client_sqlite/src/wallet/transparent.rs#L546-L548

Added lines #L546 - L548 were not covered by tests
),
},
)
})?
Expand Down Expand Up @@ -728,6 +761,29 @@

let utxo_id = stmt_upsert_transparent_output
.query_row(sql_args, |row| row.get::<_, i64>(0).map(UtxoId))?;

// If we have a record of the output already having been spent, then mark it as spent using the
// stored reference to the spending transaction.
let spending_tx_ref = conn
.query_row(
"SELECT ts.spending_transaction_id
FROM transparent_spend_map ts
JOIN transactions t ON t.id_tx = ts.spending_transaction_id
WHERE ts.prevout_txid = :prevout_txid
AND ts.prevout_output_index = :prevout_idx
ORDER BY t.block NULLS LAST LIMIT 1",
named_params![
":prevout_txid": outpoint.txid().as_ref(),
":prevout_idx": outpoint.n()
],
|row| row.get::<_, i64>(0).map(TxRef),
)
.optional()?;

if let Some(spending_transaction_id) = spending_tx_ref {
mark_transparent_utxo_spent(conn, spending_transaction_id, outpoint)?;

Check warning on line 784 in zcash_client_sqlite/src/wallet/transparent.rs

View check run for this annotation

Codecov / codecov/patch

zcash_client_sqlite/src/wallet/transparent.rs#L784

Added line #L784 was not covered by tests
}

Ok(utxo_id)
}

Expand Down
Loading
Loading