use std::collections::HashSet;
use rusqlite::named_params;
use schemerz_rusqlite::RusqliteMigration;
use uuid::Uuid;
use super::orchard_ironwood_migration_anchor_interval;
use crate::wallet::init::WalletMigrationError;
pub const MIGRATION_ID: Uuid = Uuid::from_u128(0xd334a9fa_b9dc_46bd_9b31_1fba6aa47f55);
const DEPENDENCIES: &[Uuid] = &[orchard_ironwood_migration_anchor_interval::MIGRATION_ID];
const CREATE_SPEND_NULLIFIERS_SQL: &str = "CREATE TABLE orchard_ironwood_migration_spend_nullifiers (
migration_id INTEGER NOT NULL,
transfer_id INTEGER NOT NULL,
ordinal INTEGER NOT NULL,
nullifier BLOB NOT NULL CHECK (length(nullifier) = 32),
PRIMARY KEY (migration_id, transfer_id, ordinal),
FOREIGN KEY (migration_id, transfer_id)
REFERENCES orchard_ironwood_migration_transactions(migration_id, transfer_id) ON DELETE CASCADE
);";
pub(super) struct Migration;
impl schemerz::Migration<Uuid> for Migration {
fn id(&self) -> Uuid {
MIGRATION_ID
}
fn dependencies(&self) -> HashSet<Uuid> {
DEPENDENCIES.iter().copied().collect()
}
fn description(&self) -> &'static str {
"Renames the pool-migration transfer ordinal from tx_id to transfer_id, adds the \
unsatisfiable_at, unsatisfiable_kind, and broadcast_failure_at columns to \
orchard_ironwood_migration_transactions and the replan_threshold column to \
orchard_ironwood_migrations, and caches each transaction's real-spend nullifiers in the \
orchard_ironwood_migration_spend_nullifiers table."
}
}
#[cfg(feature = "orchard")]
fn stored_pczt_txid(bytes: &[u8]) -> Result<::zcash_protocol::TxId, &'static str> {
use ::zcash_primitives::transaction::txid::{TxIdDigester, to_txid};
let pczt = pczt::Pczt::parse(bytes).map_err(|_| "the stored bytes do not parse as a PCZT")?;
let tx_data = pczt
.into_effects()
.map_err(|_| "the PCZT's effects do not assemble into transaction data")?;
let digests = tx_data.digest(TxIdDigester);
Ok(to_txid(
tx_data.version(),
tx_data.consensus_branch_id(),
&digests,
))
}
fn real_spend_nullifiers(pczt_bytes: &[u8]) -> Result<Vec<[u8; 32]>, WalletMigrationError> {
let pczt = pczt::Pczt::parse(pczt_bytes).map_err(|e| {
WalletMigrationError::CorruptedData(format!(
"stored pool-migration PCZT does not parse: {e:?}"
))
})?;
Ok(pczt
.orchard()
.actions()
.iter()
.filter(|action| action.spend().witness().is_none())
.map(|action| *action.spend().nullifier())
.collect())
}
#[cfg(feature = "orchard")]
fn backfill_txids(conn: &rusqlite::Transaction) -> Result<(), WalletMigrationError> {
let rows: Vec<(i64, u32, Vec<u8>)> = {
let mut stmt = conn.prepare(
"SELECT migration_id, transfer_id, pczt
FROM orchard_ironwood_migration_transactions
WHERE txid IS NULL",
)?;
let mapped = stmt.query_map([], |row| {
Ok((row.get(0)?, row.get(1)?, row.get::<_, Vec<u8>>(2)?))
})?;
mapped.collect::<Result<_, _>>()?
};
for (migration_id, transfer_id, pczt_bytes) in rows {
let txid = stored_pczt_txid(&pczt_bytes).map_err(|e| {
WalletMigrationError::CorruptedData(format!(
"pool-migration transaction (migration {migration_id}, transfer {transfer_id}) \
stores a PCZT whose transaction id cannot be derived: {e}"
))
})?;
conn.execute(
"UPDATE orchard_ironwood_migration_transactions
SET txid = :txid
WHERE migration_id = :migration_id AND transfer_id = :transfer_id",
named_params! {
":txid": hex::encode(txid.as_ref()),
":migration_id": migration_id,
":transfer_id": transfer_id,
},
)?;
}
Ok(())
}
impl RusqliteMigration for Migration {
type Error = WalletMigrationError;
fn up(&self, transaction: &rusqlite::Transaction) -> Result<(), Self::Error> {
transaction.execute_batch(
"ALTER TABLE orchard_ironwood_migration_transactions
RENAME COLUMN tx_id TO transfer_id;
ALTER TABLE orchard_ironwood_migration_transaction_deps
RENAME COLUMN tx_id TO transfer_id;
ALTER TABLE orchard_ironwood_migration_transaction_deps
RENAME COLUMN depends_on_tx_id TO depends_on_transfer_id;",
)?;
transaction.execute_batch(CREATE_SPEND_NULLIFIERS_SQL)?;
transaction.execute_batch(
"ALTER TABLE orchard_ironwood_migration_transactions
ADD COLUMN unsatisfiable_at INTEGER;
ALTER TABLE orchard_ironwood_migration_transactions
ADD COLUMN unsatisfiable_kind TEXT;
ALTER TABLE orchard_ironwood_migration_transactions
ADD COLUMN broadcast_failure_at INTEGER;",
)?;
let rows: Vec<(i64, u32, Vec<u8>, String)> = {
let mut stmt = transaction.prepare(
"SELECT migration_id, transfer_id, pczt, state
FROM orchard_ironwood_migration_transactions",
)?;
let mapped = stmt.query_map([], |row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get::<_, Vec<u8>>(2)?,
row.get(3)?,
))
})?;
mapped.collect::<Result<_, _>>()?
};
for (migration_id, transfer_id, pczt_bytes, state) in rows {
let spend_nullifiers = real_spend_nullifiers(&pczt_bytes)?;
if spend_nullifiers.is_empty() && state != "mined" {
return Err(WalletMigrationError::CorruptedData(format!(
"pool-migration transaction (migration {migration_id}, transfer \
{transfer_id}, state '{state}') stores a PCZT whose real spends are no \
longer identifiable (proven bytes, or deeper corruption); this state was \
persisted before the nullifier cache existed, and the migration cannot \
be resumed: the remaining balance must be re-planned"
)));
}
for (ordinal, nullifier) in spend_nullifiers.iter().enumerate() {
transaction.execute(
"INSERT INTO orchard_ironwood_migration_spend_nullifiers (
migration_id, transfer_id, ordinal, nullifier
)
VALUES (:migration_id, :transfer_id, :ordinal, :nullifier)",
named_params! {
":migration_id": migration_id,
":transfer_id": transfer_id,
":ordinal": ordinal as u64,
":nullifier": nullifier,
},
)?;
}
}
#[cfg(feature = "orchard")]
backfill_txids(transaction)?;
transaction.execute_batch(
"ALTER TABLE orchard_ironwood_migrations
ADD COLUMN replan_threshold INTEGER NOT NULL DEFAULT 20;",
)?;
Ok(())
}
fn down(&self, _transaction: &rusqlite::Transaction) -> Result<(), Self::Error> {
Err(WalletMigrationError::CannotRevert(MIGRATION_ID))
}
}
#[cfg(test)]
mod tests {
use rusqlite::{Connection, named_params};
use super::*;
use crate::wallet::init::migrations::tests::test_migrate;
#[test]
fn migrate() {
test_migrate(&[MIGRATION_ID]);
}
fn create_released_tables(conn: &Connection) {
conn.execute_batch(super::super::orchard_ironwood_migration_tables::CREATE_TABLES_SQL)
.unwrap();
}
fn transactions_has_column(conn: &Connection, column: &str) -> bool {
conn.query_row(
"SELECT EXISTS (
SELECT 1 FROM pragma_table_info('orchard_ironwood_migration_transactions')
WHERE name = :column_name
)",
named_params![":column_name": column],
|row| row.get::<_, bool>(0),
)
.unwrap()
}
fn has_columns(conn: &Connection) -> bool {
conn.query_row(
"SELECT (
SELECT COUNT(*) FROM pragma_table_info('orchard_ironwood_migration_transactions')
WHERE name IN ('unsatisfiable_at', 'unsatisfiable_kind', 'broadcast_failure_at')
) = 3",
[],
|row| row.get::<_, bool>(0),
)
.unwrap()
}
fn cached_nullifiers(conn: &Connection, transfer_id: u32) -> Vec<Vec<u8>> {
let mut stmt = conn
.prepare(
"SELECT nullifier FROM orchard_ironwood_migration_spend_nullifiers
WHERE transfer_id = :transfer_id
ORDER BY ordinal",
)
.unwrap();
let rows = stmt
.query_map(named_params![":transfer_id": transfer_id], |row| {
row.get::<_, Vec<u8>>(0)
})
.unwrap();
rows.collect::<Result<_, _>>().unwrap()
}
fn has_replan_threshold_column(conn: &Connection) -> bool {
conn.query_row(
"SELECT EXISTS (
SELECT 1 FROM pragma_table_info('orchard_ironwood_migrations')
WHERE name = 'replan_threshold'
)",
[],
|row| row.get::<_, bool>(0),
)
.unwrap()
}
fn insert_parent_migration(conn: &Connection) {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS accounts (id INTEGER PRIMARY KEY, uuid BLOB NOT NULL);
INSERT INTO accounts (id, uuid) VALUES (1, X'5A');
INSERT INTO orchard_ironwood_migrations (
id, account_id, status, note_split_fee_buffer, note_split_prep_fees,
note_split_total_input, note_split_total_migratable
)
VALUES (1, 1, 'committed', 0, 0, 0, 0);",
)
.unwrap();
}
fn insert_transfer_row(conn: &Connection, tx_id: u32, pczt: &[u8], state: &str) {
conn.execute(
"INSERT INTO orchard_ironwood_migration_transactions (
migration_id, tx_id, kind, kind_crossing, pczt, scheduled_height, expiry_height,
state
)
VALUES (1, :tx_id, 'transfer', 0, :pczt, 200, 240, :state)",
named_params![":tx_id": tx_id, ":pczt": pczt, ":state": state],
)
.unwrap();
}
#[test]
fn renames_and_adds_the_columns_on_the_released_schema() {
let mut conn = Connection::open_in_memory().unwrap();
create_released_tables(&conn);
assert!(!has_columns(&conn));
assert!(!has_replan_threshold_column(&conn));
assert!(transactions_has_column(&conn, "tx_id"));
let tx = conn.transaction().unwrap();
RusqliteMigration::up(&Migration, &tx).unwrap();
tx.commit().unwrap();
assert!(has_columns(&conn));
assert!(has_replan_threshold_column(&conn));
assert!(transactions_has_column(&conn, "transfer_id"));
assert!(!transactions_has_column(&conn, "tx_id"));
assert!(
cached_nullifiers(&conn, 0).is_empty(),
"the cache table exists and is empty, like the table it hangs off",
);
}
#[test]
fn an_unparseable_stored_pczt_fails_the_migration() {
let mut conn = Connection::open_in_memory().unwrap();
create_released_tables(&conn);
insert_parent_migration(&conn);
insert_transfer_row(&conn, 0, &[1, 2, 3], "signed");
let tx = conn.transaction().unwrap();
let result = RusqliteMigration::up(&Migration, &tx);
assert!(matches!(
result,
Err(WalletMigrationError::CorruptedData(_))
));
}
#[test]
fn backfills_replan_threshold_to_the_default_for_existing_rows() {
let mut conn = Connection::open_in_memory().unwrap();
create_released_tables(&conn);
insert_parent_migration(&conn);
let tx = conn.transaction().unwrap();
RusqliteMigration::up(&Migration, &tx).unwrap();
tx.commit().unwrap();
let replan_threshold: u32 = conn
.query_row(
"SELECT replan_threshold FROM orchard_ironwood_migrations",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(
replan_threshold,
20,
);
}
fn create_wallet_tables(conn: &Connection) {
conn.execute_batch(
"CREATE TABLE accounts (
id INTEGER PRIMARY KEY,
uuid BLOB NOT NULL
);
CREATE TABLE transactions (
id_tx INTEGER PRIMARY KEY,
txid BLOB NOT NULL UNIQUE,
mined_height INTEGER
);
CREATE TABLE orchard_received_notes (
id INTEGER PRIMARY KEY,
nf BLOB
);
CREATE TABLE orchard_received_note_spends (
orchard_received_note_id INTEGER NOT NULL,
transaction_id INTEGER NOT NULL
);",
)
.unwrap();
}
#[cfg(feature = "orchard")]
#[test]
fn an_underivable_txid_fails_the_migration() {
let mut conn = Connection::open_in_memory().unwrap();
create_wallet_tables(&conn);
create_released_tables(&conn);
insert_parent_migration(&conn);
insert_transfer_row(&conn, 0, b"not a pczt", "signed");
let tx = conn.transaction().unwrap();
assert!(matches!(
RusqliteMigration::up(&Migration, &tx),
Err(WalletMigrationError::CorruptedData(_)),
));
}
}