diff --git a/automap/Cargo.lock b/automap/Cargo.lock index 7fd8ef8bf..82258e87b 100644 --- a/automap/Cargo.lock +++ b/automap/Cargo.lock @@ -137,7 +137,7 @@ checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" [[package]] name = "automap" -version = "0.8.2" +version = "0.9.0" dependencies = [ "crossbeam-channel 0.5.8", "flexi_logger", @@ -1116,7 +1116,7 @@ dependencies = [ [[package]] name = "masq_lib" -version = "0.8.2" +version = "0.9.0" dependencies = [ "actix", "clap", diff --git a/automap/Cargo.toml b/automap/Cargo.toml index 21c1acf91..b379c8a55 100644 --- a/automap/Cargo.toml +++ b/automap/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "automap" -version = "0.8.2" +version = "0.9.0" authors = ["Dan Wiebe ", "MASQ"] license = "GPL-3.0-only" description = "Library full of code to make routers map ports through firewalls" diff --git a/dns_utility/Cargo.lock b/dns_utility/Cargo.lock index 872334417..32431ab07 100644 --- a/dns_utility/Cargo.lock +++ b/dns_utility/Cargo.lock @@ -457,7 +457,7 @@ dependencies = [ [[package]] name = "dns_utility" -version = "0.8.2" +version = "0.9.0" dependencies = [ "core-foundation", "ipconfig 0.2.2", @@ -919,7 +919,7 @@ dependencies = [ [[package]] name = "masq_lib" -version = "0.8.2" +version = "0.9.0" dependencies = [ "actix", "clap", diff --git a/dns_utility/Cargo.toml b/dns_utility/Cargo.toml index 50f358db8..c21f8a9ca 100644 --- a/dns_utility/Cargo.toml +++ b/dns_utility/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dns_utility" -version = "0.8.2" +version = "0.9.0" license = "GPL-3.0-only" authors = ["Dan Wiebe ", "MASQ"] copyright = "Copyright (c) 2019, MASQ (https://masq.ai) and/or its affiliates. All rights reserved." diff --git a/masq/Cargo.toml b/masq/Cargo.toml index 0a6484895..0bef7d9ca 100644 --- a/masq/Cargo.toml +++ b/masq/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masq" -version = "0.8.2" +version = "0.9.0" authors = ["Dan Wiebe ", "MASQ"] license = "GPL-3.0-only" description = "Reference implementation of user interface for MASQ Node" diff --git a/masq_lib/Cargo.toml b/masq_lib/Cargo.toml index 01c3957a6..ca2ce815b 100644 --- a/masq_lib/Cargo.toml +++ b/masq_lib/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masq_lib" -version = "0.8.2" +version = "0.9.0" authors = ["Dan Wiebe ", "MASQ"] license = "GPL-3.0-only" description = "Code common to Node and masq; also, temporarily, to dns_utility" diff --git a/multinode_integration_tests/Cargo.toml b/multinode_integration_tests/Cargo.toml index 9720859f7..6a9d22533 100644 --- a/multinode_integration_tests/Cargo.toml +++ b/multinode_integration_tests/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "multinode_integration_tests" -version = "0.8.2" +version = "0.9.0" authors = ["Dan Wiebe ", "MASQ"] license = "GPL-3.0-only" description = "" diff --git a/node/Cargo.lock b/node/Cargo.lock index dec334a81..c825fda4b 100644 --- a/node/Cargo.lock +++ b/node/Cargo.lock @@ -182,7 +182,7 @@ checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" [[package]] name = "automap" -version = "0.8.2" +version = "0.9.0" dependencies = [ "crossbeam-channel 0.5.1", "flexi_logger 0.17.1", @@ -1868,7 +1868,7 @@ dependencies = [ [[package]] name = "masq" -version = "0.8.2" +version = "0.9.0" dependencies = [ "atty", "clap", @@ -1889,7 +1889,7 @@ dependencies = [ [[package]] name = "masq_lib" -version = "0.8.2" +version = "0.9.0" dependencies = [ "actix", "clap", @@ -2082,7 +2082,7 @@ dependencies = [ [[package]] name = "multinode_integration_tests" -version = "0.8.2" +version = "0.9.0" dependencies = [ "base64 0.13.0", "crossbeam-channel 0.5.1", @@ -2176,7 +2176,7 @@ dependencies = [ [[package]] name = "node" -version = "0.8.2" +version = "0.9.0" dependencies = [ "actix", "automap", diff --git a/node/Cargo.toml b/node/Cargo.toml index 3d8c8ea36..80d75b5ef 100644 --- a/node/Cargo.toml +++ b/node/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "node" -version = "0.8.2" +version = "0.9.0" license = "GPL-3.0-only" authors = ["Dan Wiebe ", "MASQ"] description = "MASQ Node is the foundation of MASQ Network, an open-source network that allows anyone to allocate spare computing resources to make the internet a free and fair place for the entire world." diff --git a/node/src/accountant/db_access_objects/failed_payable_dao.rs b/node/src/accountant/db_access_objects/failed_payable_dao.rs new file mode 100644 index 000000000..ce93a1f17 --- /dev/null +++ b/node/src/accountant/db_access_objects/failed_payable_dao.rs @@ -0,0 +1,790 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. +use crate::accountant::db_access_objects::failed_payable_dao::FailureRetrieveCondition::UncheckedPendingTooLong; +use crate::accountant::db_access_objects::utils::{TxHash, TxIdentifiers, VigilantRusqliteFlatten}; +use crate::accountant::db_big_integer::big_int_divider::BigIntDivider; +use crate::accountant::{checked_conversion, comma_joined_stringifiable}; +use crate::database::rusqlite_wrappers::ConnectionWrapper; +use masq_lib::utils::ExpectValue; +use std::collections::HashSet; +use std::fmt::{Display, Formatter}; +use std::str::FromStr; +use web3::types::Address; + +#[derive(Debug, PartialEq, Eq)] +pub enum FailedPayableDaoError { + EmptyInput, + NoChange, + InvalidInput(String), + PartialExecution(String), + SqlExecutionFailed(String), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FailureReason { + PendingTooLong, + NonceIssue, +} + +impl FromStr for FailureReason { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "PendingTooLong" => Ok(FailureReason::PendingTooLong), + "NonceIssue" => Ok(FailureReason::NonceIssue), + _ => Err(format!("Invalid FailureReason: {}", s)), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FailedTx { + pub hash: TxHash, + pub receiver_address: Address, + pub amount: u128, + pub timestamp: i64, + pub gas_price_wei: u128, + pub nonce: u64, + pub reason: FailureReason, + pub rechecked: bool, +} + +pub enum FailureRetrieveCondition { + UncheckedPendingTooLong, +} + +impl Display for FailureRetrieveCondition { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + FailureRetrieveCondition::UncheckedPendingTooLong => { + write!(f, "WHERE reason = 'PendingTooLong' AND rechecked = 0",) + } + } + } +} + +pub trait FailedPayableDao { + fn get_tx_identifiers(&self, hashes: &HashSet) -> TxIdentifiers; + fn insert_new_records(&self, txs: &[FailedTx]) -> Result<(), FailedPayableDaoError>; + fn retrieve_txs(&self, condition: Option) -> Vec; + fn mark_as_rechecked(&self) -> Result<(), FailedPayableDaoError>; + fn delete_records(&self, hashes: &HashSet) -> Result<(), FailedPayableDaoError>; +} + +#[derive(Debug)] +pub struct FailedPayableDaoReal<'a> { + conn: Box, +} + +impl<'a> FailedPayableDaoReal<'a> { + pub fn new(conn: Box) -> Self { + Self { conn } + } +} + +impl FailedPayableDao for FailedPayableDaoReal<'_> { + fn get_tx_identifiers(&self, hashes: &HashSet) -> TxIdentifiers { + let hashes_vec: Vec = hashes.iter().copied().collect(); + let sql = format!( + "SELECT tx_hash, rowid FROM failed_payable WHERE tx_hash IN ({})", + comma_joined_stringifiable(&hashes_vec, |hash| format!("'{:?}'", hash)) + ); + + let mut stmt = self + .conn + .prepare(&sql) + .unwrap_or_else(|_| panic!("Failed to prepare SQL statement")); + + stmt.query_map([], |row| { + let tx_hash_str: String = row.get(0).expectv("tx_hash"); + let tx_hash = TxHash::from_str(&tx_hash_str[2..]).expect("Failed to parse TxHash"); + let row_id: u64 = row.get(1).expectv("row_id"); + + Ok((tx_hash, row_id)) + }) + .unwrap_or_else(|_| panic!("Failed to execute query")) + .vigilant_flatten() + .collect() + } + + fn insert_new_records(&self, txs: &[FailedTx]) -> Result<(), FailedPayableDaoError> { + if txs.is_empty() { + return Err(FailedPayableDaoError::EmptyInput); + } + + let unique_hashes: HashSet = txs.iter().map(|tx| tx.hash).collect(); + if unique_hashes.len() != txs.len() { + return Err(FailedPayableDaoError::InvalidInput(format!( + "Duplicate hashes found in the input. Input Transactions: {:?}", + txs + ))); + } + + let duplicates = self.get_tx_identifiers(&unique_hashes); + if !duplicates.is_empty() { + return Err(FailedPayableDaoError::InvalidInput(format!( + "Duplicates detected in the database: {:?}", + duplicates, + ))); + } + + if let Some(_rechecked_tx) = txs.iter().find(|tx| tx.rechecked) { + return Err(FailedPayableDaoError::InvalidInput(format!( + "Already rechecked transaction(s) provided: {:?}", + txs + ))); + } + + let sql = format!( + "INSERT INTO failed_payable (\ + tx_hash, \ + receiver_address, \ + amount_high_b, \ + amount_low_b, \ + timestamp, \ + gas_price_wei_high_b, \ + gas_price_wei_low_b, \ + nonce, \ + reason, \ + rechecked + ) VALUES {}", + comma_joined_stringifiable(txs, |tx| { + let amount_checked = checked_conversion::(tx.amount); + let gas_price_wei_checked = checked_conversion::(tx.gas_price_wei); + let (amount_high_b, amount_low_b) = BigIntDivider::deconstruct(amount_checked); + let (gas_price_wei_high_b, gas_price_wei_low_b) = + BigIntDivider::deconstruct(gas_price_wei_checked); + format!( + "('{:?}', '{:?}', {}, {}, {}, {}, {}, {}, '{:?}', {})", + tx.hash, + tx.receiver_address, + amount_high_b, + amount_low_b, + tx.timestamp, + gas_price_wei_high_b, + gas_price_wei_low_b, + tx.nonce, + tx.reason, + tx.rechecked + ) + }) + ); + + match self.conn.prepare(&sql).expect("Internal error").execute([]) { + Ok(inserted_rows) => { + if inserted_rows == txs.len() { + Ok(()) + } else { + Err(FailedPayableDaoError::PartialExecution(format!( + "Only {} out of {} records inserted", + inserted_rows, + txs.len() + ))) + } + } + Err(e) => Err(FailedPayableDaoError::SqlExecutionFailed(e.to_string())), + } + } + + fn retrieve_txs(&self, condition: Option) -> Vec { + let raw_sql = "SELECT tx_hash, \ + receiver_address, \ + amount_high_b, \ + amount_low_b, \ + timestamp, \ + gas_price_wei_high_b, \ + gas_price_wei_low_b, \ + nonce, \ + reason, \ + rechecked \ + FROM failed_payable" + .to_string(); + let sql = match condition { + None => raw_sql, + Some(condition) => format!("{} {}", raw_sql, condition), + }; + + let mut stmt = self + .conn + .prepare(&sql) + .expect("Failed to prepare SQL statement"); + + stmt.query_map([], |row| { + let tx_hash_str: String = row.get(0).expectv("tx_hash"); + let hash = TxHash::from_str(&tx_hash_str[2..]).expect("Failed to parse TxHash"); + let receiver_address_str: String = row.get(1).expectv("receiver_address"); + let receiver_address = + Address::from_str(&receiver_address_str[2..]).expect("Failed to parse Address"); + let amount_high_b = row.get(2).expectv("amount_high_b"); + let amount_low_b = row.get(3).expectv("amount_low_b"); + let amount = BigIntDivider::reconstitute(amount_high_b, amount_low_b) as u128; + let timestamp = row.get(4).expectv("timestamp"); + let gas_price_wei_high_b = row.get(5).expectv("gas_price_wei_high_b"); + let gas_price_wei_low_b = row.get(6).expectv("gas_price_wei_low_b"); + let gas_price_wei = + BigIntDivider::reconstitute(gas_price_wei_high_b, gas_price_wei_low_b) as u128; + let nonce = row.get(7).expectv("nonce"); + let reason_str: String = row.get(8).expectv("reason"); + let reason = + FailureReason::from_str(&reason_str).expect("Failed to parse FailureReason"); + let rechecked_as_integer: u8 = row.get(9).expectv("rechecked"); + let rechecked = rechecked_as_integer == 1; + + Ok(FailedTx { + hash, + receiver_address, + amount, + timestamp, + gas_price_wei, + nonce, + reason, + rechecked, + }) + }) + .expect("Failed to execute query") + .vigilant_flatten() + .collect() + } + + fn mark_as_rechecked(&self) -> Result<(), FailedPayableDaoError> { + let txs = self.retrieve_txs(Some(UncheckedPendingTooLong)); + let hashes_vec: Vec = txs.iter().map(|tx| tx.hash).collect(); + let hashes_string = comma_joined_stringifiable(&hashes_vec, |hash| format!("'{:?}'", hash)); + + let sql = format!( + "UPDATE failed_payable SET rechecked = 1 WHERE tx_hash IN ({})", + hashes_string + ); + + match self.conn.prepare(&sql).expect("Internal error").execute([]) { + Ok(rows_changed) => { + if rows_changed == txs.len() { + Ok(()) + } else { + // This should never occur because we retrieve transaction hashes + // under the condition that all retrieved transactions are unchecked. + Err(FailedPayableDaoError::PartialExecution(format!( + "Only {} of {} records has been marked as rechecked.", + rows_changed, + txs.len(), + ))) + } + } + Err(e) => Err(FailedPayableDaoError::SqlExecutionFailed(e.to_string())), + } + } + + fn delete_records(&self, hashes: &HashSet) -> Result<(), FailedPayableDaoError> { + if hashes.is_empty() { + return Err(FailedPayableDaoError::EmptyInput); + } + + let hashes_vec: Vec = hashes.iter().cloned().collect(); + let sql = format!( + "DELETE FROM failed_payable WHERE tx_hash IN ({})", + comma_joined_stringifiable(&hashes_vec, |hash| { format!("'{:?}'", hash) }) + ); + + match self.conn.prepare(&sql).expect("Internal error").execute([]) { + Ok(deleted_rows) => { + if deleted_rows == hashes.len() { + Ok(()) + } else if deleted_rows == 0 { + Err(FailedPayableDaoError::NoChange) + } else { + Err(FailedPayableDaoError::PartialExecution(format!( + "Only {} of {} hashes has been deleted.", + deleted_rows, + hashes.len(), + ))) + } + } + Err(e) => Err(FailedPayableDaoError::SqlExecutionFailed(e.to_string())), + } + } +} + +#[cfg(test)] +mod tests { + use crate::accountant::db_access_objects::failed_payable_dao::FailureReason::{ + NonceIssue, PendingTooLong, + }; + use crate::accountant::db_access_objects::failed_payable_dao::{ + FailedPayableDao, FailedPayableDaoError, FailedPayableDaoReal, FailureReason, + FailureRetrieveCondition, + }; + use crate::accountant::db_access_objects::test_utils::{ + make_read_only_db_connection, FailedTxBuilder, + }; + use crate::accountant::db_access_objects::utils::current_unix_timestamp; + use crate::blockchain::test_utils::make_tx_hash; + use crate::database::db_initializer::{ + DbInitializationConfig, DbInitializer, DbInitializerReal, + }; + use crate::database::test_utils::ConnectionWrapperMock; + use masq_lib::test_utils::utils::ensure_node_home_directory_exists; + use rusqlite::Connection; + use std::collections::HashSet; + use std::str::FromStr; + + #[test] + fn insert_new_records_works() { + let home_dir = + ensure_node_home_directory_exists("failed_payable_dao", "insert_new_records_works"); + let wrapped_conn = DbInitializerReal::default() + .initialize(&home_dir, DbInitializationConfig::test_default()) + .unwrap(); + let tx1 = FailedTxBuilder::default() + .hash(make_tx_hash(1)) + .reason(NonceIssue) + .build(); + let tx2 = FailedTxBuilder::default() + .hash(make_tx_hash(2)) + .reason(PendingTooLong) + .build(); + let subject = FailedPayableDaoReal::new(wrapped_conn); + let txs = vec![tx1, tx2]; + + let result = subject.insert_new_records(&txs); + + let retrieved_txs = subject.retrieve_txs(None); + assert_eq!(result, Ok(())); + assert_eq!(retrieved_txs, txs); + } + + #[test] + fn insert_new_records_throws_err_for_empty_input() { + let home_dir = ensure_node_home_directory_exists( + "failed_payable_dao", + "insert_new_records_throws_err_for_empty_input", + ); + let wrapped_conn = DbInitializerReal::default() + .initialize(&home_dir, DbInitializationConfig::test_default()) + .unwrap(); + let subject = FailedPayableDaoReal::new(wrapped_conn); + let empty_input = vec![]; + + let result = subject.insert_new_records(&empty_input); + + assert_eq!(result, Err(FailedPayableDaoError::EmptyInput)); + } + + #[test] + fn insert_new_records_throws_error_when_two_txs_with_same_hash_are_present_in_the_input() { + let home_dir = ensure_node_home_directory_exists( + "failed_payable_dao", + "insert_new_records_throws_error_when_two_txs_with_same_hash_are_present_in_the_input", + ); + let wrapped_conn = DbInitializerReal::default() + .initialize(&home_dir, DbInitializationConfig::test_default()) + .unwrap(); + let hash = make_tx_hash(123); + let tx1 = FailedTxBuilder::default().hash(hash).build(); + let tx2 = FailedTxBuilder::default() + .hash(hash) + .rechecked(true) + .build(); + let subject = FailedPayableDaoReal::new(wrapped_conn); + + let result = subject.insert_new_records(&vec![tx1, tx2]); + + assert_eq!( + result, + Err(FailedPayableDaoError::InvalidInput( + "Duplicate hashes found in the input. Input Transactions: \ + [FailedTx { \ + hash: 0x000000000000000000000000000000000000000000000000000000000000007b, \ + receiver_address: 0x0000000000000000000000000000000000000000, \ + amount: 0, timestamp: 0, gas_price_wei: 0, \ + nonce: 0, reason: PendingTooLong, rechecked: false }, \ + FailedTx { \ + hash: 0x000000000000000000000000000000000000000000000000000000000000007b, \ + receiver_address: 0x0000000000000000000000000000000000000000, \ + amount: 0, timestamp: 0, gas_price_wei: 0, \ + nonce: 0, reason: PendingTooLong, rechecked: true }]" + .to_string() + )) + ); + } + + #[test] + fn insert_new_records_throws_error_when_input_tx_hash_is_already_present_in_the_db() { + let home_dir = ensure_node_home_directory_exists( + "failed_payable_dao", + "insert_new_records_throws_error_when_input_tx_hash_is_already_present_in_the_db", + ); + let wrapped_conn = DbInitializerReal::default() + .initialize(&home_dir, DbInitializationConfig::test_default()) + .unwrap(); + let hash = make_tx_hash(123); + let tx1 = FailedTxBuilder::default().hash(hash).build(); + let tx2 = FailedTxBuilder::default() + .hash(hash) + .rechecked(true) + .build(); + let subject = FailedPayableDaoReal::new(wrapped_conn); + let initial_insertion_result = subject.insert_new_records(&vec![tx1]); + + let result = subject.insert_new_records(&vec![tx2]); + + assert_eq!(initial_insertion_result, Ok(())); + assert_eq!( + result, + Err(FailedPayableDaoError::InvalidInput( + "Duplicates detected in the database: \ + {0x000000000000000000000000000000000000000000000000000000000000007b: 1}" + .to_string() + )) + ); + } + + #[test] + fn insert_new_records_throws_err_if_an_already_rechecked_tx_is_supplied() { + let home_dir = ensure_node_home_directory_exists( + "failed_payable_dao", + "insert_new_records_throws_err_if_an_already_rechecked_tx_is_supplied", + ); + let wrapped_conn = DbInitializerReal::default() + .initialize(&home_dir, DbInitializationConfig::test_default()) + .unwrap(); + let subject = FailedPayableDaoReal::new(wrapped_conn); + let tx1 = FailedTxBuilder::default() + .hash(make_tx_hash(1)) + .rechecked(true) + .build(); + let tx2 = FailedTxBuilder::default() + .hash(make_tx_hash(2)) + .rechecked(false) + .build(); + let input = vec![tx1, tx2]; + + let result = subject.insert_new_records(&input); + + assert_eq!( + result, + Err(FailedPayableDaoError::InvalidInput(format!( + "Already rechecked transaction(s) provided: {:?}", + input + ))) + ); + } + + #[test] + fn insert_new_records_returns_err_if_partially_executed() { + let setup_conn = Connection::open_in_memory().unwrap(); + setup_conn + .execute("CREATE TABLE example (id integer)", []) + .unwrap(); + let get_tx_identifiers_stmt = setup_conn.prepare("SELECT id FROM example").unwrap(); + let faulty_insert_stmt = { setup_conn.prepare("SELECT id FROM example").unwrap() }; + let wrapped_conn = ConnectionWrapperMock::default() + .prepare_result(Ok(get_tx_identifiers_stmt)) + .prepare_result(Ok(faulty_insert_stmt)); + let tx = FailedTxBuilder::default().build(); + let subject = FailedPayableDaoReal::new(Box::new(wrapped_conn)); + + let result = subject.insert_new_records(&vec![tx]); + + assert_eq!( + result, + Err(FailedPayableDaoError::PartialExecution( + "Only 0 out of 1 records inserted".to_string() + )) + ); + } + + #[test] + fn insert_new_records_can_throw_error() { + let home_dir = ensure_node_home_directory_exists( + "failed_payable_dao", + "insert_new_records_can_throw_error", + ); + let wrapped_conn = make_read_only_db_connection(home_dir); + let tx = FailedTxBuilder::default().build(); + let subject = FailedPayableDaoReal::new(Box::new(wrapped_conn)); + + let result = subject.insert_new_records(&vec![tx]); + + assert_eq!( + result, + Err(FailedPayableDaoError::SqlExecutionFailed( + "attempt to write a readonly database".to_string() + )) + ) + } + + #[test] + fn get_tx_identifiers_works() { + let home_dir = + ensure_node_home_directory_exists("failed_payable_dao", "get_tx_identifiers_works"); + let wrapped_conn = DbInitializerReal::default() + .initialize(&home_dir, DbInitializationConfig::test_default()) + .unwrap(); + let subject = FailedPayableDaoReal::new(wrapped_conn); + let present_hash = make_tx_hash(1); + let absent_hash = make_tx_hash(2); + let another_present_hash = make_tx_hash(3); + let hashset = HashSet::from([present_hash, absent_hash, another_present_hash]); + let present_tx = FailedTxBuilder::default().hash(present_hash).build(); + let another_present_tx = FailedTxBuilder::default() + .hash(another_present_hash) + .build(); + subject + .insert_new_records(&vec![present_tx, another_present_tx]) + .unwrap(); + + let result = subject.get_tx_identifiers(&hashset); + + assert_eq!(result.get(&present_hash), Some(&1u64)); + assert_eq!(result.get(&absent_hash), None); + assert_eq!(result.get(&another_present_hash), Some(&2u64)); + } + + #[test] + fn failure_reason_from_str_works() { + assert_eq!( + FailureReason::from_str("PendingTooLong"), + Ok(PendingTooLong) + ); + assert_eq!(FailureReason::from_str("NonceIssue"), Ok(NonceIssue)); + assert_eq!( + FailureReason::from_str("InvalidReason"), + Err("Invalid FailureReason: InvalidReason".to_string()) + ); + } + + #[test] + fn retrieve_condition_display_works() { + let expected_condition = "WHERE reason = 'PendingTooLong' AND rechecked = 0"; + assert_eq!( + FailureRetrieveCondition::UncheckedPendingTooLong.to_string(), + expected_condition + ); + } + + #[test] + fn can_retrieve_all_txs() { + let home_dir = + ensure_node_home_directory_exists("failed_payable_dao", "can_retrieve_all_txs"); + let wrapped_conn = DbInitializerReal::default() + .initialize(&home_dir, DbInitializationConfig::test_default()) + .unwrap(); + let subject = FailedPayableDaoReal::new(wrapped_conn); + let tx1 = FailedTxBuilder::default().hash(make_tx_hash(1)).build(); + let tx2 = FailedTxBuilder::default() + .hash(make_tx_hash(2)) + .nonce(1) + .build(); + let tx3 = FailedTxBuilder::default().hash(make_tx_hash(3)).build(); + subject + .insert_new_records(&vec![tx1.clone(), tx2.clone()]) + .unwrap(); + subject.insert_new_records(&vec![tx3.clone()]).unwrap(); + + let result = subject.retrieve_txs(None); + + assert_eq!(result, vec![tx1, tx2, tx3]); + } + + #[test] + fn can_retrieve_unchecked_pending_too_long_txs() { + let home_dir = ensure_node_home_directory_exists( + "failed_payable_dao", + "can_retrieve_unchecked_pending_too_long_txs", + ); + let wrapped_conn = DbInitializerReal::default() + .initialize(&home_dir, DbInitializationConfig::test_default()) + .unwrap(); + let subject = FailedPayableDaoReal::new(wrapped_conn); + let now = current_unix_timestamp(); + let tx1 = FailedTxBuilder::default() + .hash(make_tx_hash(1)) + .reason(PendingTooLong) + .timestamp(now - 3600) + .rechecked(false) + .build(); + let tx2 = FailedTxBuilder::default() + .hash(make_tx_hash(2)) + .reason(NonceIssue) + .rechecked(false) + .build(); + let tx3 = FailedTxBuilder::default() + .hash(make_tx_hash(3)) + .reason(PendingTooLong) + .rechecked(false) + .timestamp(now - 3000) + .build(); + subject + .insert_new_records(&vec![tx1.clone(), tx2, tx3.clone()]) + .unwrap(); + + let result = subject.retrieve_txs(Some(FailureRetrieveCondition::UncheckedPendingTooLong)); + + assert_eq!(result, vec![tx1, tx3]); + } + + #[test] + fn mark_as_rechecked_works() { + let home_dir = + ensure_node_home_directory_exists("failed_payable_dao", "mark_as_rechecked_works"); + let wrapped_conn = DbInitializerReal::default() + .initialize(&home_dir, DbInitializationConfig::test_default()) + .unwrap(); + let subject = FailedPayableDaoReal::new(wrapped_conn); + let tx1 = FailedTxBuilder::default() + .hash(make_tx_hash(1)) + .reason(NonceIssue) + .rechecked(false) + .build(); + let tx2 = FailedTxBuilder::default() + .hash(make_tx_hash(2)) + .reason(PendingTooLong) + .rechecked(false) + .build(); + let tx3 = FailedTxBuilder::default() + .hash(make_tx_hash(3)) + .reason(PendingTooLong) + .rechecked(false) + .build(); + let tx1_pre_checked_state = tx1.rechecked; + let tx2_pre_checked_state = tx2.rechecked; + let tx3_pre_checked_state = tx3.rechecked; + subject + .insert_new_records(&vec![tx1, tx2.clone(), tx3.clone()]) + .unwrap(); + + let result = subject.mark_as_rechecked(); + + let updated_txs = subject.retrieve_txs(None); + assert_eq!(result, Ok(())); + assert_eq!(tx1_pre_checked_state, false); + assert_eq!(tx2_pre_checked_state, false); + assert_eq!(tx3_pre_checked_state, false); + assert_eq!(updated_txs[0].rechecked, false); + assert_eq!(updated_txs[1].rechecked, true); + assert_eq!(updated_txs[2].rechecked, true); + } + + #[test] + fn mark_as_rechecked_handles_sql_error() { + let home_dir = ensure_node_home_directory_exists( + "failed_payable_dao", + "mark_as_rechecked_handles_sql_error", + ); + let wrapped_conn = make_read_only_db_connection(home_dir); + let subject = FailedPayableDaoReal::new(Box::new(wrapped_conn)); + + let result = subject.mark_as_rechecked(); + + assert_eq!( + result, + Err(FailedPayableDaoError::SqlExecutionFailed( + "attempt to write a readonly database".to_string() + )) + ); + } + + #[test] + fn txs_can_be_deleted() { + let home_dir = + ensure_node_home_directory_exists("failed_payable_dao", "txs_can_be_deleted"); + let wrapped_conn = DbInitializerReal::default() + .initialize(&home_dir, DbInitializationConfig::test_default()) + .unwrap(); + let subject = FailedPayableDaoReal::new(wrapped_conn); + let tx1 = FailedTxBuilder::default().hash(make_tx_hash(1)).build(); + let tx2 = FailedTxBuilder::default().hash(make_tx_hash(2)).build(); + let tx3 = FailedTxBuilder::default().hash(make_tx_hash(3)).build(); + let tx4 = FailedTxBuilder::default().hash(make_tx_hash(4)).build(); + subject + .insert_new_records(&vec![tx1.clone(), tx2.clone(), tx3.clone(), tx4.clone()]) + .unwrap(); + let hashset = HashSet::from([tx1.hash, tx3.hash]); + + let result = subject.delete_records(&hashset); + + let remaining_records = subject.retrieve_txs(None); + assert_eq!(result, Ok(())); + assert_eq!(remaining_records, vec![tx2, tx4]); + } + + #[test] + fn delete_records_returns_error_when_input_is_empty() { + let home_dir = ensure_node_home_directory_exists( + "failed_payable_dao", + "delete_records_returns_error_when_input_is_empty", + ); + let wrapped_conn = DbInitializerReal::default() + .initialize(&home_dir, DbInitializationConfig::test_default()) + .unwrap(); + let subject = FailedPayableDaoReal::new(wrapped_conn); + + let result = subject.delete_records(&HashSet::new()); + + assert_eq!(result, Err(FailedPayableDaoError::EmptyInput)); + } + + #[test] + fn delete_records_returns_error_when_no_records_are_deleted() { + let home_dir = ensure_node_home_directory_exists( + "failed_payable_dao", + "delete_records_returns_error_when_no_records_are_deleted", + ); + let wrapped_conn = DbInitializerReal::default() + .initialize(&home_dir, DbInitializationConfig::test_default()) + .unwrap(); + let subject = FailedPayableDaoReal::new(wrapped_conn); + let non_existent_hash = make_tx_hash(999); + let hashset = HashSet::from([non_existent_hash]); + + let result = subject.delete_records(&hashset); + + assert_eq!(result, Err(FailedPayableDaoError::NoChange)); + } + + #[test] + fn delete_records_returns_error_when_not_all_input_records_were_deleted() { + let home_dir = ensure_node_home_directory_exists( + "failed_payable_dao", + "delete_records_returns_error_when_not_all_input_records_were_deleted", + ); + let wrapped_conn = DbInitializerReal::default() + .initialize(&home_dir, DbInitializationConfig::test_default()) + .unwrap(); + let subject = FailedPayableDaoReal::new(wrapped_conn); + let present_hash = make_tx_hash(1); + let absent_hash = make_tx_hash(2); + let tx = FailedTxBuilder::default().hash(present_hash).build(); + subject.insert_new_records(&vec![tx]).unwrap(); + let hashset = HashSet::from([present_hash, absent_hash]); + + let result = subject.delete_records(&hashset); + + assert_eq!( + result, + Err(FailedPayableDaoError::PartialExecution( + "Only 1 of 2 hashes has been deleted.".to_string() + )) + ); + } + + #[test] + fn delete_records_returns_a_general_error_from_sql() { + let home_dir = ensure_node_home_directory_exists( + "failed_payable_dao", + "delete_records_returns_a_general_error_from_sql", + ); + let wrapped_conn = make_read_only_db_connection(home_dir); + let subject = FailedPayableDaoReal::new(Box::new(wrapped_conn)); + let hashes = HashSet::from([make_tx_hash(1)]); + + let result = subject.delete_records(&hashes); + + assert_eq!( + result, + Err(FailedPayableDaoError::SqlExecutionFailed( + "attempt to write a readonly database".to_string() + )) + ) + } +} diff --git a/node/src/accountant/db_access_objects/mod.rs b/node/src/accountant/db_access_objects/mod.rs index cf1ca4611..ae165909a 100644 --- a/node/src/accountant/db_access_objects/mod.rs +++ b/node/src/accountant/db_access_objects/mod.rs @@ -1,6 +1,7 @@ // Copyright (c) 2019, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. pub mod banned_dao; +pub mod failed_payable_dao; pub mod payable_dao; pub mod pending_payable_dao; pub mod receivable_dao; diff --git a/node/src/accountant/db_access_objects/sent_payable_dao.rs b/node/src/accountant/db_access_objects/sent_payable_dao.rs index 1ef307224..5cdc59047 100644 --- a/node/src/accountant/db_access_objects/sent_payable_dao.rs +++ b/node/src/accountant/db_access_objects/sent_payable_dao.rs @@ -3,13 +3,15 @@ use std::collections::{HashMap, HashSet}; use std::fmt::{Display, Formatter}; use std::str::FromStr; -use ethereum_types::H256; +use ethereum_types::{H256, U64}; use web3::types::Address; use masq_lib::utils::ExpectValue; use crate::accountant::{checked_conversion, comma_joined_stringifiable}; +use crate::accountant::db_access_objects::utils::{TxHash, TxIdentifiers}; use crate::accountant::db_big_integer::big_int_divider::BigIntDivider; -use crate::blockchain::blockchain_interface::blockchain_interface_web3::lower_level_interface_web3::TxStatus; +use crate::blockchain::blockchain_interface::blockchain_interface_web3::lower_level_interface_web3::{TransactionBlock}; use crate::database::rusqlite_wrappers::ConnectionWrapper; +use itertools::Itertools; #[derive(Debug, PartialEq, Eq)] pub enum SentPayableDaoError { @@ -20,26 +22,19 @@ pub enum SentPayableDaoError { SqlExecutionFailed(String), } -type TxHash = H256; -type RowId = u64; - -type TxIdentifiers = HashMap; -type TxUpdates = HashMap; - #[derive(Clone, Debug, PartialEq, Eq)] pub struct Tx { pub hash: TxHash, pub receiver_address: Address, pub amount: u128, pub timestamp: i64, - pub gas_price_wei: u64, - pub nonce: u32, - pub status: TxStatus, + pub gas_price_wei: u128, + pub nonce: u64, + pub block_opt: Option, } pub enum RetrieveCondition { IsPending, - ToRetry, ByHash(Vec), } @@ -47,10 +42,7 @@ impl Display for RetrieveCondition { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { RetrieveCondition::IsPending => { - write!(f, "WHERE status = 'Pending'") - } - RetrieveCondition::ToRetry => { - write!(f, "WHERE status = 'Failed'") + write!(f, "WHERE block_hash IS NULL") } RetrieveCondition::ByHash(tx_hashes) => { write!( @@ -67,7 +59,11 @@ pub trait SentPayableDao { fn get_tx_identifiers(&self, hashes: &HashSet) -> TxIdentifiers; fn insert_new_records(&self, txs: &[Tx]) -> Result<(), SentPayableDaoError>; fn retrieve_txs(&self, condition: Option) -> Vec; - fn change_statuses(&self, hash_map: &TxUpdates) -> Result<(), SentPayableDaoError>; + fn update_tx_blocks( + &self, + hash_map: &HashMap, + ) -> Result<(), SentPayableDaoError>; + fn replace_records(&self, new_txs: &[Tx]) -> Result<(), SentPayableDaoError>; fn delete_records(&self, hashes: &HashSet) -> Result<(), SentPayableDaoError>; } @@ -114,35 +110,54 @@ impl SentPayableDao for SentPayableDaoReal<'_> { let unique_hashes: HashSet = txs.iter().map(|tx| tx.hash).collect(); if unique_hashes.len() != txs.len() { - return Err(SentPayableDaoError::InvalidInput( - "Duplicate hashes found in the input".to_string(), - )); + return Err(SentPayableDaoError::InvalidInput(format!( + "Duplicate hashes found in the input. Input Transactions: {:?}", + txs + ))); } - if !self.get_tx_identifiers(&unique_hashes).is_empty() { - return Err(SentPayableDaoError::InvalidInput( - "Input hash is already present in the database".to_string(), - )); + let duplicates = self.get_tx_identifiers(&unique_hashes); + if !duplicates.is_empty() { + return Err(SentPayableDaoError::InvalidInput(format!( + "Duplicates detected in the database: {:?}", + duplicates, + ))); } let sql = format!( "INSERT INTO sent_payable (\ - tx_hash, receiver_address, amount_high_b, amount_low_b, \ - timestamp, gas_price_wei, nonce, status + tx_hash, \ + receiver_address, \ + amount_high_b, \ + amount_low_b, \ + timestamp, \ + gas_price_wei_high_b, \ + gas_price_wei_low_b, \ + nonce, \ + block_hash, \ + block_number ) VALUES {}", comma_joined_stringifiable(txs, |tx| { let amount_checked = checked_conversion::(tx.amount); - let (high_bytes, low_bytes) = BigIntDivider::deconstruct(amount_checked); + let gas_price_wei_checked = checked_conversion::(tx.gas_price_wei); + let (amount_high_b, amount_low_b) = BigIntDivider::deconstruct(amount_checked); + let (gas_price_wei_high_b, gas_price_wei_low_b) = + BigIntDivider::deconstruct(gas_price_wei_checked); + let block_details = match &tx.block_opt { + Some(block) => format!("'{:?}', {}", block.block_hash, block.block_number), + None => "null, null".to_string(), + }; format!( - "('{:?}', '{:?}', {}, {}, {}, {}, {}, '{}')", + "('{:?}', '{:?}', {}, {}, {}, {}, {}, {}, {})", tx.hash, tx.receiver_address, - high_bytes, - low_bytes, + amount_high_b, + amount_low_b, tx.timestamp, - tx.gas_price_wei, + gas_price_wei_high_b, + gas_price_wei_low_b, tx.nonce, - tx.status + block_details ) }) ); @@ -165,7 +180,7 @@ impl SentPayableDao for SentPayableDaoReal<'_> { fn retrieve_txs(&self, condition_opt: Option) -> Vec { let raw_sql = "SELECT tx_hash, receiver_address, amount_high_b, amount_low_b, \ - timestamp, gas_price_wei, nonce, status FROM sent_payable" + timestamp, gas_price_wei_high_b, gas_price_wei_low_b, nonce, block_hash, block_number FROM sent_payable" .to_string(); let sql = match condition_opt { None => raw_sql, @@ -187,10 +202,29 @@ impl SentPayableDao for SentPayableDaoReal<'_> { let amount_low_b = row.get(3).expectv("amount_low_b"); let amount = BigIntDivider::reconstitute(amount_high_b, amount_low_b) as u128; let timestamp = row.get(4).expectv("timestamp"); - let gas_price_wei = row.get(5).expectv("gas_price_wei"); - let nonce = row.get(6).expectv("nonce"); - let status_str: String = row.get(7).expectv("status"); - let status = TxStatus::from_str(&status_str).expect("Failed to parse TxStatus"); + let gas_price_wei_high_b = row.get(5).expectv("gas_price_wei_high_b"); + let gas_price_wei_low_b = row.get(6).expectv("gas_price_wei_low_b"); + let gas_price_wei = + BigIntDivider::reconstitute(gas_price_wei_high_b, gas_price_wei_low_b) as u128; + let nonce = row.get(7).expectv("nonce"); + let block_hash_opt: Option = { + let block_hash_str_opt: Option = row.get(8).expectv("block_hash"); + block_hash_str_opt + .map(|string| H256::from_str(&string[2..]).expect("Failed to parse H256")) + }; + let block_number_opt: Option = { + let block_number_i64_opt: Option = row.get(9).expectv("block_number"); + block_number_i64_opt.map(|v| u64::try_from(v).expect("Failed to parse u64")) + }; + + let block_opt = match (block_hash_opt, block_number_opt) { + (Some(block_hash), Some(block_number)) => Some(TransactionBlock { + block_hash, + block_number: U64::from(block_number), + }), + (None, None) => None, + _ => panic!("Invalid block details"), + }; Ok(Tx { hash, @@ -199,7 +233,7 @@ impl SentPayableDao for SentPayableDaoReal<'_> { timestamp, gas_price_wei, nonce, - status, + block_opt, }) }) .expect("Failed to execute query") @@ -207,15 +241,18 @@ impl SentPayableDao for SentPayableDaoReal<'_> { .collect() } - fn change_statuses(&self, hash_map: &TxUpdates) -> Result<(), SentPayableDaoError> { + fn update_tx_blocks( + &self, + hash_map: &HashMap, + ) -> Result<(), SentPayableDaoError> { if hash_map.is_empty() { return Err(SentPayableDaoError::EmptyInput); } - for (hash, status) in hash_map { + for (hash, transaction_block) in hash_map { let sql = format!( - "UPDATE sent_payable SET status = '{}' WHERE tx_hash = '{:?}'", - status, hash + "UPDATE sent_payable SET block_hash = '{:?}', block_number = {} WHERE tx_hash = '{:?}'", + transaction_block.block_hash, transaction_block.block_number, hash ); match self.conn.prepare(&sql).expect("Internal error").execute([]) { @@ -238,6 +275,99 @@ impl SentPayableDao for SentPayableDaoReal<'_> { Ok(()) } + fn replace_records(&self, new_txs: &[Tx]) -> Result<(), SentPayableDaoError> { + if new_txs.is_empty() { + return Err(SentPayableDaoError::EmptyInput); + } + + let build_case = |value_fn: fn(&Tx) -> String| { + new_txs + .iter() + .map(|tx| format!("WHEN nonce = {} THEN {}", tx.nonce, value_fn(tx))) + .join(" ") + }; + + let tx_hash_cases = build_case(|tx| format!("'{:?}'", tx.hash)); + let receiver_address_cases = build_case(|tx| format!("'{:?}'", tx.receiver_address)); + let amount_high_b_cases = build_case(|tx| { + let amount_checked = checked_conversion::(tx.amount); + let (high, _) = BigIntDivider::deconstruct(amount_checked); + high.to_string() + }); + let amount_low_b_cases = build_case(|tx| { + let amount_checked = checked_conversion::(tx.amount); + let (_, low) = BigIntDivider::deconstruct(amount_checked); + low.to_string() + }); + let timestamp_cases = build_case(|tx| tx.timestamp.to_string()); + let gas_price_wei_high_b_cases = build_case(|tx| { + let gas_price_wei_checked = checked_conversion::(tx.gas_price_wei); + let (high, _) = BigIntDivider::deconstruct(gas_price_wei_checked); + high.to_string() + }); + let gas_price_wei_low_b_cases = build_case(|tx| { + let gas_price_wei_checked = checked_conversion::(tx.gas_price_wei); + let (_, low) = BigIntDivider::deconstruct(gas_price_wei_checked); + low.to_string() + }); + let block_hash_cases = build_case(|tx| match &tx.block_opt { + Some(block) => format!("'{:?}'", block.block_hash), + None => "NULL".to_string(), + }); + let block_number_cases = build_case(|tx| match &tx.block_opt { + Some(block) => block.block_number.as_u64().to_string(), + None => "NULL".to_string(), + }); + + let nonces = comma_joined_stringifiable(new_txs, |tx| tx.nonce.to_string()); + + let sql = format!( + "UPDATE sent_payable \ + SET \ + tx_hash = CASE \ + {tx_hash_cases} \ + END, \ + receiver_address = CASE \ + {receiver_address_cases} \ + END, \ + amount_high_b = CASE \ + {amount_high_b_cases} \ + END, \ + amount_low_b = CASE \ + {amount_low_b_cases} \ + END, \ + timestamp = CASE \ + {timestamp_cases} \ + END, \ + gas_price_wei_high_b = CASE \ + {gas_price_wei_high_b_cases} \ + END, \ + gas_price_wei_low_b = CASE \ + {gas_price_wei_low_b_cases} \ + END, \ + block_hash = CASE \ + {block_hash_cases} \ + END, \ + block_number = CASE \ + {block_number_cases} \ + END \ + WHERE nonce IN ({nonces})", + ); + + match self.conn.prepare(&sql).expect("Internal error").execute([]) { + Ok(updated_rows) => match updated_rows { + 0 => Err(SentPayableDaoError::NoChange), + count if count == new_txs.len() => Ok(()), + _ => Err(SentPayableDaoError::PartialExecution(format!( + "Only {} out of {} records updated", + updated_rows, + new_txs.len() + ))), + }, + Err(e) => Err(SentPayableDaoError::SqlExecutionFailed(e.to_string())), + } + } + fn delete_records(&self, hashes: &HashSet) -> Result<(), SentPayableDaoError> { if hashes.is_empty() { return Err(SentPayableDaoError::EmptyInput); @@ -271,19 +401,20 @@ impl SentPayableDao for SentPayableDaoReal<'_> { #[cfg(test)] mod tests { use std::collections::{HashMap, HashSet}; + use std::sync::{Arc, Mutex}; use crate::accountant::db_access_objects::sent_payable_dao::{RetrieveCondition, SentPayableDao, SentPayableDaoError, SentPayableDaoReal}; - use crate::accountant::db_access_objects::utils::current_unix_timestamp; use crate::database::db_initializer::{ - DbInitializationConfig, DbInitializer, DbInitializerReal, DATABASE_FILE, + DbInitializationConfig, DbInitializer, DbInitializerReal, }; - use crate::database::rusqlite_wrappers::ConnectionWrapperReal; use crate::database::test_utils::ConnectionWrapperMock; use ethereum_types::{ H256, U64}; use masq_lib::test_utils::utils::ensure_node_home_directory_exists; - use rusqlite::{Connection, OpenFlags}; - use crate::accountant::db_access_objects::sent_payable_dao::RetrieveCondition::{ByHash, IsPending, ToRetry}; - use crate::accountant::db_access_objects::test_utils::TxBuilder; - use crate::blockchain::blockchain_interface::blockchain_interface_web3::lower_level_interface_web3::{TransactionBlock, TxStatus}; + use rusqlite::{Connection}; + use crate::accountant::db_access_objects::sent_payable_dao::RetrieveCondition::{ByHash, IsPending}; + use crate::accountant::db_access_objects::sent_payable_dao::SentPayableDaoError::{EmptyInput, PartialExecution}; + use crate::accountant::db_access_objects::test_utils::{make_read_only_db_connection, TxBuilder}; + use crate::blockchain::blockchain_interface::blockchain_interface_web3::lower_level_interface_web3::{TransactionBlock}; + use crate::blockchain::test_utils::{make_block_hash, make_tx_hash}; #[test] fn insert_new_records_works() { @@ -292,29 +423,19 @@ mod tests { let wrapped_conn = DbInitializerReal::default() .initialize(&home_dir, DbInitializationConfig::test_default()) .unwrap(); - let tx1 = TxBuilder::default() - .hash(H256::from_low_u64_le(1)) - .status(TxStatus::Pending) - .build(); + let tx1 = TxBuilder::default().hash(make_tx_hash(1)).build(); let tx2 = TxBuilder::default() - .hash(H256::from_low_u64_le(2)) - .status(TxStatus::Failed) - .build(); - let tx3 = TxBuilder::default() - .hash(H256::from_low_u64_le(3)) - .status(TxStatus::Succeeded(TransactionBlock { - block_hash: Default::default(), - block_number: Default::default(), - })) + .hash(make_tx_hash(2)) + .block(Default::default()) .build(); let subject = SentPayableDaoReal::new(wrapped_conn); - let txs = vec![tx1, tx2, tx3]; + let txs = vec![tx1, tx2]; let result = subject.insert_new_records(&txs); let retrieved_txs = subject.retrieve_txs(None); assert_eq!(result, Ok(())); - assert_eq!(retrieved_txs.len(), 3); + assert_eq!(retrieved_txs.len(), 2); assert_eq!(retrieved_txs, txs); } @@ -344,14 +465,15 @@ mod tests { let wrapped_conn = DbInitializerReal::default() .initialize(&home_dir, DbInitializationConfig::test_default()) .unwrap(); - let hash = H256::from_low_u64_be(1234567890); + let hash = make_tx_hash(1234); let tx1 = TxBuilder::default() .hash(hash) - .status(TxStatus::Pending) + .timestamp(1749204017) .build(); let tx2 = TxBuilder::default() .hash(hash) - .status(TxStatus::Failed) + .timestamp(1749204020) + .block(Default::default()) .build(); let subject = SentPayableDaoReal::new(wrapped_conn); @@ -360,7 +482,20 @@ mod tests { assert_eq!( result, Err(SentPayableDaoError::InvalidInput( - "Duplicate hashes found in the input".to_string() + "Duplicate hashes found in the input. Input Transactions: \ + [Tx { \ + hash: 0x00000000000000000000000000000000000000000000000000000000000004d2, \ + receiver_address: 0x0000000000000000000000000000000000000000, \ + amount: 0, timestamp: 1749204017, gas_price_wei: 0, \ + nonce: 0, block_opt: None }, \ + Tx { \ + hash: 0x00000000000000000000000000000000000000000000000000000000000004d2, \ + receiver_address: 0x0000000000000000000000000000000000000000, \ + amount: 0, timestamp: 1749204020, gas_price_wei: 0, \ + nonce: 0, block_opt: Some(TransactionBlock { \ + block_hash: 0x0000000000000000000000000000000000000000000000000000000000000000, \ + block_number: 0 }) }]" + .to_string() )) ); } @@ -374,14 +509,11 @@ mod tests { let wrapped_conn = DbInitializerReal::default() .initialize(&home_dir, DbInitializationConfig::test_default()) .unwrap(); - let hash = H256::from_low_u64_be(1234567890); - let tx1 = TxBuilder::default() - .hash(hash) - .status(TxStatus::Pending) - .build(); + let hash = make_tx_hash(1234); + let tx1 = TxBuilder::default().hash(hash).build(); let tx2 = TxBuilder::default() .hash(hash) - .status(TxStatus::Failed) + .block(Default::default()) .build(); let subject = SentPayableDaoReal::new(wrapped_conn); let initial_insertion_result = subject.insert_new_records(&vec![tx1]); @@ -392,7 +524,9 @@ mod tests { assert_eq!( result, Err(SentPayableDaoError::InvalidInput( - "Input hash is already present in the database".to_string() + "Duplicates detected in the database: \ + {0x00000000000000000000000000000000000000000000000000000000000004d2: 1}" + .to_string() )) ); } @@ -427,18 +561,8 @@ mod tests { "sent_payable_dao", "insert_new_records_can_throw_error", ); - { - DbInitializerReal::default() - .initialize(&home_dir, DbInitializationConfig::test_default()) - .unwrap(); - } - let read_only_conn = Connection::open_with_flags( - home_dir.join(DATABASE_FILE), - OpenFlags::SQLITE_OPEN_READ_ONLY, - ) - .unwrap(); - let wrapped_conn = ConnectionWrapperReal::new(read_only_conn); let tx = TxBuilder::default().build(); + let wrapped_conn = make_read_only_db_connection(home_dir); let subject = SentPayableDaoReal::new(Box::new(wrapped_conn)); let result = subject.insert_new_records(&vec![tx]); @@ -459,9 +583,9 @@ mod tests { .initialize(&home_dir, DbInitializationConfig::test_default()) .unwrap(); let subject = SentPayableDaoReal::new(wrapped_conn); - let present_hash = H256::from_low_u64_le(1); - let absent_hash = H256::from_low_u64_le(2); - let another_present_hash = H256::from_low_u64_le(3); + let present_hash = make_tx_hash(1); + let absent_hash = make_tx_hash(2); + let another_present_hash = make_tx_hash(3); let hashset = HashSet::from([present_hash, absent_hash, another_present_hash]); let present_tx = TxBuilder::default().hash(present_hash).build(); let another_present_tx = TxBuilder::default().hash(another_present_hash).build(); @@ -478,8 +602,7 @@ mod tests { #[test] fn retrieve_condition_display_works() { - assert_eq!(IsPending.to_string(), "WHERE status = 'Pending'"); - assert_eq!(ToRetry.to_string(), "WHERE status = 'Failed'"); + assert_eq!(IsPending.to_string(), "WHERE block_hash IS NULL"); assert_eq!( ByHash(vec![ H256::from_low_u64_be(0x123456789), @@ -502,39 +625,20 @@ mod tests { .initialize(&home_dir, DbInitializationConfig::test_default()) .unwrap(); let subject = SentPayableDaoReal::new(wrapped_conn); - let tx1 = TxBuilder::default() - .hash(H256::from_low_u64_le(1)) - .status(TxStatus::Pending) - .build(); + let tx1 = TxBuilder::default().hash(make_tx_hash(1)).build(); let tx2 = TxBuilder::default() - .hash(H256::from_low_u64_le(2)) - .status(TxStatus::Failed) + .hash(make_tx_hash(2)) + .block(Default::default()) .build(); - let tx3 = TxBuilder::default() - .hash(H256::from_low_u64_le(3)) - .status(TxStatus::Succeeded(TransactionBlock { - block_hash: Default::default(), - block_number: Default::default(), - })) - .build(); - let tx4 = TxBuilder::default() - .hash(H256::from_low_u64_le(4)) - .status(TxStatus::Pending) - .build(); - let tx5 = TxBuilder::default() - .hash(H256::from_low_u64_le(5)) - .status(TxStatus::Failed) - .build(); - subject - .insert_new_records(&vec![tx1.clone(), tx2.clone(), tx3.clone()]) - .unwrap(); + let tx3 = TxBuilder::default().hash(make_tx_hash(3)).build(); subject - .insert_new_records(&vec![tx4.clone(), tx5.clone()]) + .insert_new_records(&vec![tx1.clone(), tx2.clone()]) .unwrap(); + subject.insert_new_records(&vec![tx3.clone()]).unwrap(); let result = subject.retrieve_txs(None); - assert_eq!(result, vec![tx1, tx2, tx3, tx4, tx5]); + assert_eq!(result, vec![tx1, tx2, tx3]); } #[test] @@ -545,27 +649,14 @@ mod tests { .initialize(&home_dir, DbInitializationConfig::test_default()) .unwrap(); let subject = SentPayableDaoReal::new(wrapped_conn); - let tx1 = TxBuilder::default() - .hash(H256::from_low_u64_le(1)) - .status(TxStatus::Pending) - .build(); - let tx2 = TxBuilder::default() - .hash(H256::from_low_u64_le(2)) - .status(TxStatus::Pending) - .build(); + let tx1 = TxBuilder::default().hash(make_tx_hash(1)).build(); + let tx2 = TxBuilder::default().hash(make_tx_hash(2)).build(); let tx3 = TxBuilder::default() - .hash(H256::from_low_u64_le(3)) - .status(TxStatus::Failed) - .build(); - let tx4 = TxBuilder::default() - .hash(H256::from_low_u64_le(4)) - .status(TxStatus::Succeeded(TransactionBlock { - block_hash: Default::default(), - block_number: Default::default(), - })) + .hash(make_tx_hash(3)) + .block(Default::default()) .build(); subject - .insert_new_records(&vec![tx1.clone(), tx2.clone(), tx3, tx4]) + .insert_new_records(&vec![tx1.clone(), tx2.clone(), tx3]) .unwrap(); let result = subject.retrieve_txs(Some(RetrieveCondition::IsPending)); @@ -574,166 +665,161 @@ mod tests { } #[test] - fn can_retrieve_txs_to_retry() { + fn tx_can_be_retrieved_by_hash() { let home_dir = - ensure_node_home_directory_exists("sent_payable_dao", "can_retrieve_txs_to_retry"); + ensure_node_home_directory_exists("sent_payable_dao", "tx_can_be_retrieved_by_hash"); let wrapped_conn = DbInitializerReal::default() .initialize(&home_dir, DbInitializationConfig::test_default()) .unwrap(); let subject = SentPayableDaoReal::new(wrapped_conn); - let old_timestamp = current_unix_timestamp() - 60; // 1 minute old - let tx1 = TxBuilder::default() - .hash(H256::from_low_u64_le(3)) - .timestamp(old_timestamp) - .status(TxStatus::Pending) - .build(); - let tx2 = TxBuilder::default() - .hash(H256::from_low_u64_le(4)) - .timestamp(old_timestamp) - .status(TxStatus::Succeeded(TransactionBlock { - block_hash: Default::default(), - block_number: Default::default(), - })) - .build(); - // TODO: GH-631: Instead of fetching it from SentPayables, fetch it from the FailedPayables table - let tx3 = TxBuilder::default() // this should be picked for retry - .hash(H256::from_low_u64_le(5)) - .timestamp(old_timestamp) - .status(TxStatus::Failed) - .build(); - let tx4 = TxBuilder::default() // this should be picked for retry - .hash(H256::from_low_u64_le(6)) - .status(TxStatus::Failed) - .build(); - let tx5 = TxBuilder::default() - .hash(H256::from_low_u64_le(7)) - .timestamp(old_timestamp) - .status(TxStatus::Pending) - .build(); + let tx1 = TxBuilder::default().hash(make_tx_hash(1)).build(); + let tx2 = TxBuilder::default().hash(make_tx_hash(2)).build(); + let tx3 = TxBuilder::default().hash(make_tx_hash(3)).build(); subject - .insert_new_records(&vec![tx1, tx2, tx3.clone(), tx4.clone(), tx5]) + .insert_new_records(&vec![tx1.clone(), tx2, tx3.clone()]) .unwrap(); - let result = subject.retrieve_txs(Some(RetrieveCondition::ToRetry)); + let result = subject.retrieve_txs(Some(ByHash(vec![tx1.hash, tx3.hash]))); - assert_eq!(result, vec![tx3, tx4]); + assert_eq!(result, vec![tx1, tx3]); } #[test] - fn tx_can_be_retrieved_by_hash() { - let home_dir = - ensure_node_home_directory_exists("sent_payable_dao", "tx_can_be_retrieved_by_hash"); + #[should_panic(expected = "Invalid block details")] + fn retrieve_txs_enforces_complete_block_details() { + let home_dir = ensure_node_home_directory_exists( + "sent_payable_dao", + "retrieve_txs_enforces_complete_block_details", + ); let wrapped_conn = DbInitializerReal::default() .initialize(&home_dir, DbInitializationConfig::test_default()) .unwrap(); - let subject = SentPayableDaoReal::new(wrapped_conn); - let tx1 = TxBuilder::default() - .hash(H256::from_low_u64_le(1)) - .status(TxStatus::Pending) - .build(); - let tx2 = TxBuilder::default() - .hash(H256::from_low_u64_le(2)) - .status(TxStatus::Failed) - .build(); - subject - .insert_new_records(&vec![tx1.clone(), tx2.clone()]) + // Insert a record with block_hash but no block_number + { + let sql = "INSERT INTO sent_payable (\ + tx_hash, \ + receiver_address, \ + amount_high_b, \ + amount_low_b, \ + timestamp, \ + gas_price_wei_high_b, \ + gas_price_wei_low_b, \ + nonce, \ + block_hash, \ + block_number\ + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)"; + let mut stmt = wrapped_conn.prepare(sql).unwrap(); + stmt.execute(rusqlite::params![ + "0x1234567890123456789012345678901234567890123456789012345678901234", + "0x1234567890123456789012345678901234567890", + 0, + 100, + 1234567890, + 0, + 1000000000, + 1, + "0x2345678901234567890123456789012345678901234567890123456789012345", + rusqlite::types::Null, + ]) .unwrap(); + } + let subject = SentPayableDaoReal::new(wrapped_conn); - let result = subject.retrieve_txs(Some(ByHash(vec![tx1.hash]))); - - assert_eq!(result, vec![tx1]); + // This should panic due to invalid block details + let _ = subject.retrieve_txs(None); } #[test] - fn change_statuses_works() { + fn update_tx_blocks_works() { let home_dir = - ensure_node_home_directory_exists("sent_payable_dao", "change_statuses_works"); + ensure_node_home_directory_exists("sent_payable_dao", "update_tx_blocks_works"); let wrapped_conn = DbInitializerReal::default() .initialize(&home_dir, DbInitializationConfig::test_default()) .unwrap(); let subject = SentPayableDaoReal::new(wrapped_conn); - let tx1 = TxBuilder::default() - .hash(H256::from_low_u64_le(1)) - .status(TxStatus::Pending) - .build(); - let tx2 = TxBuilder::default() - .hash(H256::from_low_u64_le(2)) - .status(TxStatus::Pending) - .build(); + let tx1 = TxBuilder::default().hash(make_tx_hash(1)).build(); + let tx2 = TxBuilder::default().hash(make_tx_hash(2)).build(); + let pre_assert_is_block_details_present_tx1 = tx1.block_opt.is_some(); + let pre_assert_is_block_details_present_tx2 = tx2.block_opt.is_some(); subject .insert_new_records(&vec![tx1.clone(), tx2.clone()]) .unwrap(); + let tx_block_1 = TransactionBlock { + block_hash: make_block_hash(3), + block_number: U64::from(1), + }; + let tx_block_2 = TransactionBlock { + block_hash: make_block_hash(4), + block_number: U64::from(2), + }; let hash_map = HashMap::from([ - (tx1.hash, TxStatus::Failed), - ( - tx2.hash, - TxStatus::Succeeded(TransactionBlock { - block_hash: H256::from_low_u64_le(3), - block_number: U64::from(1), - }), - ), + (tx1.hash, tx_block_1.clone()), + (tx2.hash, tx_block_2.clone()), ]); - let result = subject.change_statuses(&hash_map); + let result = subject.update_tx_blocks(&hash_map); let updated_txs = subject.retrieve_txs(Some(ByHash(vec![tx1.hash, tx2.hash]))); assert_eq!(result, Ok(())); - assert_eq!(updated_txs[0].status, TxStatus::Failed); - assert_eq!( - updated_txs[1].status, - TxStatus::Succeeded(TransactionBlock { - block_hash: H256::from_low_u64_le(3), - block_number: U64::from(1), - }) - ) + assert_eq!(pre_assert_is_block_details_present_tx1, false); + assert_eq!(updated_txs[0].block_opt, Some(tx_block_1)); + assert_eq!(pre_assert_is_block_details_present_tx2, false); + assert_eq!(updated_txs[1].block_opt, Some(tx_block_2)); } #[test] - fn change_statuses_returns_error_when_input_is_empty() { + fn update_tx_blocks_returns_error_when_input_is_empty() { let home_dir = ensure_node_home_directory_exists( "sent_payable_dao", - "change_statuses_returns_error_when_input_is_empty", + "update_tx_blocks_returns_error_when_input_is_empty", ); let wrapped_conn = DbInitializerReal::default() .initialize(&home_dir, DbInitializationConfig::test_default()) .unwrap(); let subject = SentPayableDaoReal::new(wrapped_conn); - let existent_hash = H256::from_low_u64_le(1); - let tx = TxBuilder::default() - .hash(existent_hash) - .status(TxStatus::Pending) - .build(); + let existent_hash = make_tx_hash(1); + let tx = TxBuilder::default().hash(existent_hash).build(); subject.insert_new_records(&vec![tx]).unwrap(); let hash_map = HashMap::new(); - let result = subject.change_statuses(&hash_map); + let result = subject.update_tx_blocks(&hash_map); assert_eq!(result, Err(SentPayableDaoError::EmptyInput)); } #[test] - fn change_statuses_returns_error_during_partial_execution() { + fn update_tx_blocks_returns_error_during_partial_execution() { let home_dir = ensure_node_home_directory_exists( "sent_payable_dao", - "change_statuses_returns_error_during_partial_execution", + "update_tx_blocks_returns_error_during_partial_execution", ); let wrapped_conn = DbInitializerReal::default() .initialize(&home_dir, DbInitializationConfig::test_default()) .unwrap(); let subject = SentPayableDaoReal::new(wrapped_conn); - let existent_hash = H256::from_low_u64_le(1); - let non_existent_hash = H256::from_low_u64_le(999); - let tx = TxBuilder::default() - .hash(existent_hash) - .status(TxStatus::Pending) - .build(); + let existent_hash = make_tx_hash(1); + let non_existent_hash = make_tx_hash(999); + let tx = TxBuilder::default().hash(existent_hash).build(); subject.insert_new_records(&vec![tx]).unwrap(); let hash_map = HashMap::from([ - (existent_hash, TxStatus::Failed), - (non_existent_hash, TxStatus::Failed), + ( + existent_hash, + TransactionBlock { + block_hash: make_block_hash(1), + block_number: U64::from(1), + }, + ), + ( + non_existent_hash, + TransactionBlock { + block_hash: make_block_hash(2), + block_number: U64::from(2), + }, + ), ]); - let result = subject.change_statuses(&hash_map); + let result = subject.update_tx_blocks(&hash_map); assert_eq!( result, @@ -745,27 +831,23 @@ mod tests { } #[test] - fn change_statuses_returns_error_when_an_error_occurs_while_executing_sql() { + fn update_tx_blocks_returns_error_when_an_error_occurs_while_executing_sql() { let home_dir = ensure_node_home_directory_exists( "sent_payable_dao", - "change_statuses_returns_error_when_an_error_occurs_while_executing_sql", + "update_tx_blocks_returns_error_when_an_error_occurs_while_executing_sql", ); - { - DbInitializerReal::default() - .initialize(&home_dir, DbInitializationConfig::test_default()) - .unwrap(); - } - let read_only_conn = Connection::open_with_flags( - home_dir.join(DATABASE_FILE), - OpenFlags::SQLITE_OPEN_READ_ONLY, - ) - .unwrap(); - let wrapped_conn = ConnectionWrapperReal::new(read_only_conn); + let wrapped_conn = make_read_only_db_connection(home_dir); let subject = SentPayableDaoReal::new(Box::new(wrapped_conn)); - let hash = H256::from_low_u64_le(1); - let hash_map = HashMap::from([(hash, TxStatus::Failed)]); + let hash = make_tx_hash(1); + let hash_map = HashMap::from([( + hash, + TransactionBlock { + block_hash: make_block_hash(1), + block_number: U64::default(), + }, + )]); - let result = subject.change_statuses(&hash_map); + let result = subject.update_tx_blocks(&hash_map); assert_eq!( result, @@ -782,24 +864,12 @@ mod tests { .initialize(&home_dir, DbInitializationConfig::test_default()) .unwrap(); let subject = SentPayableDaoReal::new(wrapped_conn); - let tx1 = TxBuilder::default() - .hash(H256::from_low_u64_le(1)) - .status(TxStatus::Pending) - .build(); - let tx2 = TxBuilder::default() - .hash(H256::from_low_u64_le(2)) - .status(TxStatus::Pending) - .build(); - let tx3 = TxBuilder::default() - .hash(H256::from_low_u64_le(3)) - .status(TxStatus::Failed) - .build(); + let tx1 = TxBuilder::default().hash(make_tx_hash(1)).build(); + let tx2 = TxBuilder::default().hash(make_tx_hash(2)).build(); + let tx3 = TxBuilder::default().hash(make_tx_hash(3)).build(); let tx4 = TxBuilder::default() - .hash(H256::from_low_u64_le(4)) - .status(TxStatus::Succeeded(TransactionBlock { - block_hash: Default::default(), - block_number: Default::default(), - })) + .hash(make_tx_hash(4)) + .block(Default::default()) .build(); subject .insert_new_records(&vec![tx1.clone(), tx2.clone(), tx3.clone(), tx4.clone()]) @@ -839,7 +909,7 @@ mod tests { .initialize(&home_dir, DbInitializationConfig::test_default()) .unwrap(); let subject = SentPayableDaoReal::new(wrapped_conn); - let non_existent_hash = H256::from_low_u64_le(999); + let non_existent_hash = make_tx_hash(999); let hashset = HashSet::from([non_existent_hash]); let result = subject.delete_records(&hashset); @@ -857,12 +927,9 @@ mod tests { .initialize(&home_dir, DbInitializationConfig::test_default()) .unwrap(); let subject = SentPayableDaoReal::new(wrapped_conn); - let present_hash = H256::from_low_u64_le(1); - let absent_hash = H256::from_low_u64_le(2); - let tx = TxBuilder::default() - .hash(present_hash) - .status(TxStatus::Failed) - .build(); + let present_hash = make_tx_hash(1); + let absent_hash = make_tx_hash(2); + let tx = TxBuilder::default().hash(present_hash).build(); subject.insert_new_records(&vec![tx]).unwrap(); let hashset = HashSet::from([present_hash, absent_hash]); @@ -882,19 +949,9 @@ mod tests { "sent_payable_dao", "delete_records_returns_a_general_error_from_sql", ); - { - DbInitializerReal::default() - .initialize(&home_dir, DbInitializationConfig::test_default()) - .unwrap(); - } - let read_only_conn = Connection::open_with_flags( - home_dir.join(DATABASE_FILE), - OpenFlags::SQLITE_OPEN_READ_ONLY, - ) - .unwrap(); - let wrapped_conn = ConnectionWrapperReal::new(read_only_conn); + let wrapped_conn = make_read_only_db_connection(home_dir); let subject = SentPayableDaoReal::new(Box::new(wrapped_conn)); - let hashes = HashSet::from([H256::from_low_u64_le(1)]); + let hashes = HashSet::from([make_tx_hash(1)]); let result = subject.delete_records(&hashes); @@ -905,4 +962,179 @@ mod tests { )) ) } + + #[test] + fn replace_records_works_as_expected() { + let home_dir = ensure_node_home_directory_exists( + "sent_payable_dao", + "replace_records_works_as_expected", + ); + let wrapped_conn = DbInitializerReal::default() + .initialize(&home_dir, DbInitializationConfig::test_default()) + .unwrap(); + let subject = SentPayableDaoReal::new(wrapped_conn); + let tx1 = TxBuilder::default().hash(make_tx_hash(1)).nonce(1).build(); + let tx2 = TxBuilder::default().hash(make_tx_hash(2)).nonce(2).build(); + let tx3 = TxBuilder::default().hash(make_tx_hash(3)).nonce(3).build(); + subject + .insert_new_records(&vec![tx1.clone(), tx2, tx3]) + .unwrap(); + let new_tx2 = TxBuilder::default() + .hash(make_tx_hash(22)) + .block(TransactionBlock { + block_hash: make_block_hash(1), + block_number: U64::from(1), + }) + .nonce(2) + .build(); + let new_tx3 = TxBuilder::default() + .hash(make_tx_hash(33)) + .block(TransactionBlock { + block_hash: make_block_hash(1), + block_number: U64::from(1), + }) + .nonce(3) + .build(); + + let result = subject.replace_records(&[new_tx2.clone(), new_tx3.clone()]); + + let retrieved_txs = subject.retrieve_txs(None); + assert_eq!(result, Ok(())); + assert_eq!(retrieved_txs, vec![tx1, new_tx2, new_tx3]); + } + + #[test] + fn replace_records_uses_single_sql_statement() { + let prepare_params = Arc::new(Mutex::new(vec![])); + let setup_conn = Connection::open_in_memory().unwrap(); + setup_conn + .execute("CREATE TABLE example (id integer)", []) + .unwrap(); + let stmt = setup_conn.prepare("SELECT id FROM example").unwrap(); + let wrapped_conn = ConnectionWrapperMock::default() + .prepare_params(&prepare_params) + .prepare_result(Ok(stmt)); + let subject = SentPayableDaoReal::new(Box::new(wrapped_conn)); + let tx1 = TxBuilder::default().hash(make_tx_hash(1)).nonce(1).build(); + let tx2 = TxBuilder::default().hash(make_tx_hash(2)).nonce(2).build(); + let tx3 = TxBuilder::default().hash(make_tx_hash(3)).nonce(3).build(); + + let _ = subject.replace_records(&[tx1, tx2, tx3]); + + let captured_params = prepare_params.lock().unwrap(); + let sql = &captured_params[0]; + assert!(sql.starts_with("UPDATE sent_payable SET")); + assert!(sql.contains("tx_hash = CASE")); + assert!(sql.contains("receiver_address = CASE")); + assert!(sql.contains("amount_high_b = CASE")); + assert!(sql.contains("amount_low_b = CASE")); + assert!(sql.contains("timestamp = CASE")); + assert!(sql.contains("gas_price_wei_high_b = CASE")); + assert!(sql.contains("gas_price_wei_low_b = CASE")); + assert!(sql.contains("block_hash = CASE")); + assert!(sql.contains("block_number = CASE")); + assert!(sql.contains("WHERE nonce IN (1, 2, 3)")); + assert!(sql.contains("WHEN nonce = 1 THEN '0x0000000000000000000000000000000000000000000000000000000000000001'")); + assert!(sql.contains("WHEN nonce = 2 THEN '0x0000000000000000000000000000000000000000000000000000000000000002'")); + assert!(sql.contains("WHEN nonce = 3 THEN '0x0000000000000000000000000000000000000000000000000000000000000003'")); + assert_eq!(captured_params.len(), 1); + } + + #[test] + fn replace_records_throws_error_for_empty_input() { + let home_dir = ensure_node_home_directory_exists( + "sent_payable_dao", + "replace_records_throws_error_for_empty_input", + ); + let wrapped_conn = DbInitializerReal::default() + .initialize(&home_dir, DbInitializationConfig::test_default()) + .unwrap(); + let subject = SentPayableDaoReal::new(wrapped_conn); + let tx1 = TxBuilder::default().hash(make_tx_hash(1)).nonce(1).build(); + let tx2 = TxBuilder::default().hash(make_tx_hash(2)).nonce(2).build(); + subject.insert_new_records(&vec![tx1, tx2]).unwrap(); + + let result = subject.replace_records(&[]); + + assert_eq!(result, Err(EmptyInput)); + } + + #[test] + fn replace_records_throws_partial_execution_error() { + let home_dir = ensure_node_home_directory_exists( + "sent_payable_dao", + "replace_records_throws_partial_execution_error", + ); + let wrapped_conn = DbInitializerReal::default() + .initialize(&home_dir, DbInitializationConfig::test_default()) + .unwrap(); + let subject = SentPayableDaoReal::new(wrapped_conn); + let tx1 = TxBuilder::default().hash(make_tx_hash(1)).nonce(1).build(); + let tx2 = TxBuilder::default().hash(make_tx_hash(2)).nonce(2).build(); + subject + .insert_new_records(&vec![tx1.clone(), tx2.clone()]) + .unwrap(); + let new_tx2 = TxBuilder::default() + .hash(make_tx_hash(22)) + .block(TransactionBlock { + block_hash: make_block_hash(1), + block_number: U64::from(1), + }) + .nonce(2) + .build(); + let new_tx3 = TxBuilder::default() + .hash(make_tx_hash(33)) + .block(TransactionBlock { + block_hash: make_block_hash(1), + block_number: U64::from(1), + }) + .nonce(3) + .build(); + + let result = subject.replace_records(&[new_tx2, new_tx3]); + + assert_eq!( + result, + Err(PartialExecution( + "Only 1 out of 2 records updated".to_string() + )) + ); + } + + #[test] + fn replace_records_returns_no_change_error_when_no_rows_updated() { + let home_dir = ensure_node_home_directory_exists( + "sent_payable_dao", + "replace_records_returns_no_change_error_when_no_rows_updated", + ); + let wrapped_conn = DbInitializerReal::default() + .initialize(&home_dir, DbInitializationConfig::test_default()) + .unwrap(); + let subject = SentPayableDaoReal::new(wrapped_conn); + let tx = TxBuilder::default().hash(make_tx_hash(1)).nonce(42).build(); + + let result = subject.replace_records(&[tx]); + + assert_eq!(result, Err(SentPayableDaoError::NoChange)); + } + + #[test] + fn replace_records_returns_a_general_error_from_sql() { + let home_dir = ensure_node_home_directory_exists( + "sent_payable_dao", + "replace_records_returns_a_general_error_from_sql", + ); + let wrapped_conn = make_read_only_db_connection(home_dir); + let subject = SentPayableDaoReal::new(Box::new(wrapped_conn)); + let tx = TxBuilder::default().hash(make_tx_hash(1)).nonce(1).build(); + + let result = subject.replace_records(&[tx]); + + assert_eq!( + result, + Err(SentPayableDaoError::SqlExecutionFailed( + "attempt to write a readonly database".to_string() + )) + ) + } } diff --git a/node/src/accountant/db_access_objects/test_utils.rs b/node/src/accountant/db_access_objects/test_utils.rs index 3a571ff6a..598a4121d 100644 --- a/node/src/accountant/db_access_objects/test_utils.rs +++ b/node/src/accountant/db_access_objects/test_utils.rs @@ -1,20 +1,25 @@ // Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. #![cfg(test)] -use web3::types::{Address, H256}; -use crate::accountant::db_access_objects::sent_payable_dao::Tx; -use crate::accountant::db_access_objects::utils::current_unix_timestamp; -use crate::blockchain::blockchain_interface::blockchain_interface_web3::lower_level_interface_web3::TxStatus; +use std::path::PathBuf; +use rusqlite::{Connection, OpenFlags}; +use crate::accountant::db_access_objects::sent_payable_dao::{ Tx}; +use crate::accountant::db_access_objects::utils::{current_unix_timestamp, TxHash}; +use web3::types::{Address}; +use crate::accountant::db_access_objects::failed_payable_dao::{FailedTx, FailureReason}; +use crate::blockchain::blockchain_interface::blockchain_interface_web3::lower_level_interface_web3::TransactionBlock; +use crate::database::db_initializer::{DbInitializationConfig, DbInitializer, DbInitializerReal, DATABASE_FILE}; +use crate::database::rusqlite_wrappers::ConnectionWrapperReal; #[derive(Default)] pub struct TxBuilder { - hash_opt: Option, + hash_opt: Option, receiver_address_opt: Option
, amount_opt: Option, timestamp_opt: Option, - gas_price_wei_opt: Option, - nonce_opt: Option, - status_opt: Option, + gas_price_wei_opt: Option, + nonce_opt: Option, + block_opt: Option, } impl TxBuilder { @@ -22,7 +27,7 @@ impl TxBuilder { Default::default() } - pub fn hash(mut self, hash: H256) -> Self { + pub fn hash(mut self, hash: TxHash) -> Self { self.hash_opt = Some(hash); self } @@ -32,8 +37,13 @@ impl TxBuilder { self } - pub fn status(mut self, status: TxStatus) -> Self { - self.status_opt = Some(status); + pub fn nonce(mut self, nonce: u64) -> Self { + self.nonce_opt = Some(nonce); + self + } + + pub fn block(mut self, block: TransactionBlock) -> Self { + self.block_opt = Some(block); self } @@ -45,7 +55,80 @@ impl TxBuilder { timestamp: self.timestamp_opt.unwrap_or_else(current_unix_timestamp), gas_price_wei: self.gas_price_wei_opt.unwrap_or_default(), nonce: self.nonce_opt.unwrap_or_default(), - status: self.status_opt.unwrap_or(TxStatus::Pending), + block_opt: self.block_opt, + } + } +} + +#[derive(Default)] +pub struct FailedTxBuilder { + hash_opt: Option, + receiver_address_opt: Option
, + amount_opt: Option, + timestamp_opt: Option, + gas_price_wei_opt: Option, + nonce_opt: Option, + reason_opt: Option, + rechecked_opt: Option, +} + +impl FailedTxBuilder { + pub fn default() -> Self { + Default::default() + } + + pub fn hash(mut self, hash: TxHash) -> Self { + self.hash_opt = Some(hash); + self + } + + pub fn timestamp(mut self, timestamp: i64) -> Self { + self.timestamp_opt = Some(timestamp); + self + } + + pub fn nonce(mut self, nonce: u64) -> Self { + self.nonce_opt = Some(nonce); + self + } + + pub fn reason(mut self, reason: FailureReason) -> Self { + self.reason_opt = Some(reason); + self + } + + pub fn rechecked(mut self, rechecked: bool) -> Self { + self.rechecked_opt = Some(rechecked); + self + } + + pub fn build(self) -> FailedTx { + FailedTx { + hash: self.hash_opt.unwrap_or_default(), + receiver_address: self.receiver_address_opt.unwrap_or_default(), + amount: self.amount_opt.unwrap_or_default(), + timestamp: self.timestamp_opt.unwrap_or_default(), + gas_price_wei: self.gas_price_wei_opt.unwrap_or_default(), + nonce: self.nonce_opt.unwrap_or_default(), + reason: self + .reason_opt + .unwrap_or_else(|| FailureReason::PendingTooLong), + rechecked: self.rechecked_opt.unwrap_or_else(|| false), } } } + +pub fn make_read_only_db_connection(home_dir: PathBuf) -> ConnectionWrapperReal { + { + DbInitializerReal::default() + .initialize(&home_dir, DbInitializationConfig::test_default()) + .unwrap(); + } + let read_only_conn = Connection::open_with_flags( + home_dir.join(DATABASE_FILE), + OpenFlags::SQLITE_OPEN_READ_ONLY, + ) + .unwrap(); + + ConnectionWrapperReal::new(read_only_conn) +} diff --git a/node/src/accountant/db_access_objects/utils.rs b/node/src/accountant/db_access_objects/utils.rs index fe84712d7..8fbc875c2 100644 --- a/node/src/accountant/db_access_objects/utils.rs +++ b/node/src/accountant/db_access_objects/utils.rs @@ -9,11 +9,13 @@ use crate::database::db_initializer::{ }; use crate::database::rusqlite_wrappers::ConnectionWrapper; use crate::sub_lib::accountant::PaymentThresholds; +use ethereum_types::H256; use masq_lib::constants::WEIS_IN_GWEI; use masq_lib::messages::{ RangeQuery, TopRecordsConfig, TopRecordsOrdering, UiPayableAccount, UiReceivableAccount, }; use rusqlite::{Row, Statement, ToSql}; +use std::collections::HashMap; use std::fmt::{Debug, Display}; use std::iter::FlatMap; use std::path::{Path, PathBuf}; @@ -21,6 +23,10 @@ use std::string::ToString; use std::time::Duration; use std::time::SystemTime; +pub type TxHash = H256; +pub type RowId = u64; +pub type TxIdentifiers = HashMap; + pub fn to_unix_timestamp(system_time: SystemTime) -> i64 { match system_time.duration_since(SystemTime::UNIX_EPOCH) { Ok(d) => sign_conversion::(d.as_secs()).expect("MASQNode has expired"), diff --git a/node/src/accountant/mod.rs b/node/src/accountant/mod.rs index 79f7c52f5..24dbdcc68 100644 --- a/node/src/accountant/mod.rs +++ b/node/src/accountant/mod.rs @@ -1041,7 +1041,7 @@ impl Accountant { e.log_error(&self.logger, scanner.into(), is_externally_triggered); - response_skeleton_opt.map(|skeleton| { + if let Some(skeleton) = response_skeleton_opt { self.ui_message_sub_opt .as_ref() .expect("UiGateway is unbound") @@ -1050,7 +1050,7 @@ impl Accountant { body: UiScanResponse {}.tmb(skeleton.context_id), }) .expect("UiGateway is dead"); - }); + }; self.scan_schedulers .reschedule_on_error_resolver @@ -1084,7 +1084,7 @@ impl Accountant { response_skeleton_opt.is_some(), ); - response_skeleton_opt.map(|skeleton| { + if let Some(skeleton) = response_skeleton_opt { self.ui_message_sub_opt .as_ref() .expect("UiGateway is unbound") @@ -1093,7 +1093,7 @@ impl Accountant { body: UiScanResponse {}.tmb(skeleton.context_id), }) .expect("UiGateway is dead"); - }); + }; } } } @@ -3010,8 +3010,8 @@ mod tests { scan_for_pending_payables_notify_later_params_arc .lock() .unwrap(); - // PendingPayableScanner can only start after NewPayableScanner finishes and makes at least - // one transaction. The test stops before running NewPayableScanner, missing both + // PendingPayableScanner can only start after NewPayableScanner finishes and makes at least + // one transaction. The test stops before running NewPayableScanner, missing both // the second PendingPayableScanner run and its scheduling event. assert!( scan_for_pending_payables_notify_later_params.is_empty(), diff --git a/node/src/accountant/scanners/mod.rs b/node/src/accountant/scanners/mod.rs index ca1810290..349ffe3df 100644 --- a/node/src/accountant/scanners/mod.rs +++ b/node/src/accountant/scanners/mod.rs @@ -920,7 +920,7 @@ impl Scanner for PendingPay let requires_payment_retry = self.process_transactions_by_reported_state(scan_report, logger); - self.mark_as_ended(&logger); + self.mark_as_ended(logger); requires_payment_retry } diff --git a/node/src/accountant/scanners/scan_schedulers.rs b/node/src/accountant/scanners/scan_schedulers.rs index 77ac6646e..74e102aff 100644 --- a/node/src/accountant/scanners/scan_schedulers.rs +++ b/node/src/accountant/scanners/scan_schedulers.rs @@ -38,18 +38,18 @@ impl ScanSchedulers { } } -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Eq)] pub enum PayableScanSchedulerError { ScanForNewPayableAlreadyScheduled, } -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Eq)] pub enum ScanRescheduleAfterEarlyStop { Schedule(ScanType), DoNotSchedule, } -#[derive(Debug, PartialEq, Clone, Copy)] +#[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum PayableSequenceScanner { NewPayables, RetryPayables, @@ -123,7 +123,7 @@ impl PayableScanScheduler { } else { debug!(logger, "Scheduling a new-payable scan asap"); - let _ = self.new_payable_notify.notify( + self.new_payable_notify.notify( ScanForNewPayables { response_skeleton_opt: None, }, diff --git a/node/src/accountant/scanners/scanners_utils.rs b/node/src/accountant/scanners/scanners_utils.rs index 4d2bf16e1..b50a1388b 100644 --- a/node/src/accountant/scanners/scanners_utils.rs +++ b/node/src/accountant/scanners/scanners_utils.rs @@ -33,7 +33,7 @@ pub mod payable_scanner_utils { pub result: OperationOutcome, } - #[derive(Debug, PartialEq)] + #[derive(Debug, PartialEq, Eq)] pub enum OperationOutcome { NewPendingPayable, Failure, @@ -341,7 +341,7 @@ pub mod pending_payable_scanner_utils { } } - #[derive(Debug, PartialEq)] + #[derive(Debug, PartialEq, Eq)] pub enum PendingPayableScanResult { NoPendingPayablesLeft(Option), PaymentRetryRequired, diff --git a/node/src/blockchain/blockchain_interface/blockchain_interface_web3/lower_level_interface_web3.rs b/node/src/blockchain/blockchain_interface/blockchain_interface_web3/lower_level_interface_web3.rs index 25a747907..b7353b7c2 100644 --- a/node/src/blockchain/blockchain_interface/blockchain_interface_web3/lower_level_interface_web3.rs +++ b/node/src/blockchain/blockchain_interface/blockchain_interface_web3/lower_level_interface_web3.rs @@ -77,7 +77,7 @@ pub struct TxReceipt { pub status: TxStatus, } -#[derive(Debug, PartialEq, Eq, Clone)] +#[derive(Debug, Default, PartialEq, Eq, Clone)] pub struct TransactionBlock { pub block_hash: H256, pub block_number: U64, diff --git a/node/src/blockchain/test_utils.rs b/node/src/blockchain/test_utils.rs index 4124e283a..6259e8739 100644 --- a/node/src/blockchain/test_utils.rs +++ b/node/src/blockchain/test_utils.rs @@ -185,10 +185,18 @@ pub fn make_default_signed_transaction() -> SignedTransaction { } } -pub fn make_tx_hash(base: u32) -> H256 { +pub fn make_hash(base: u32) -> Hash { H256::from_uint(&U256::from(base)) } +pub fn make_tx_hash(base: u32) -> H256 { + make_hash(base) +} + +pub fn make_block_hash(base: u32) -> H256 { + make_hash(base + 1000000000) +} + pub fn all_chains() -> [Chain; 4] { [ Chain::EthMainnet, diff --git a/node/src/database/db_initializer.rs b/node/src/database/db_initializer.rs index 6e0090f0d..86e82aed1 100644 --- a/node/src/database/db_initializer.rs +++ b/node/src/database/db_initializer.rs @@ -136,6 +136,7 @@ impl DbInitializerReal { Self::initialize_config(conn, external_params); Self::create_payable_table(conn); Self::create_sent_payable_table(conn); + Self::create_failed_payable_table(conn); Self::create_pending_payable_table(conn); Self::create_receivable_table(conn); Self::create_banned_table(conn); @@ -268,9 +269,11 @@ impl DbInitializerReal { amount_high_b integer not null, amount_low_b integer not null, timestamp integer not null, - gas_price_wei integer not null, + gas_price_wei_high_b integer not null, + gas_price_wei_low_b integer not null, nonce integer not null, - status text not null + block_hash text null, + block_number integer null )", [], ) @@ -283,6 +286,32 @@ impl DbInitializerReal { .expect("Can't create transaction hash index in sent payments"); } + pub fn create_failed_payable_table(conn: &Connection) { + conn.execute( + "create table if not exists failed_payable ( + rowid integer primary key, + tx_hash text not null, + receiver_address text not null, + amount_high_b integer not null, + amount_low_b integer not null, + timestamp integer not null, + gas_price_wei_high_b integer not null, + gas_price_wei_low_b integer not null, + nonce integer not null, + reason text not null, + rechecked integer not null + )", + [], + ) + .expect("Can't create failed_payable table"); + + conn.execute( + "CREATE UNIQUE INDEX failed_payable_tx_hash_idx ON sent_payable (tx_hash)", + [], + ) + .expect("Can't create transaction hash index in failed payments"); + } + pub fn create_pending_payable_table(conn: &Connection) { conn.execute( "create table if not exists pending_payable ( @@ -646,7 +675,9 @@ impl Debug for DbInitializationConfig { mod tests { use super::*; use crate::database::db_initializer::InitializationError::SqliteError; - use crate::database::test_utils::SQL_ATTRIBUTES_FOR_CREATING_SENT_PAYABLE; + use crate::database::test_utils::{ + SQL_ATTRIBUTES_FOR_CREATING_FAILED_PAYABLE, SQL_ATTRIBUTES_FOR_CREATING_SENT_PAYABLE, + }; use crate::db_config::config_dao::{ConfigDao, ConfigDaoReal}; use crate::test_utils::database_utils::{ assert_create_table_stm_contains_all_parts, @@ -696,7 +727,7 @@ mod tests { let mut stmt = conn .prepare("select name, value, encrypted from config") .unwrap(); - let _ = stmt.query_map([], |_| Ok(42)).unwrap(); + let _ = stmt.execute([]); let expected_key_words: &[&[&str]] = &[ &["name", "text", "primary", "key"], &["value", "text"], @@ -718,9 +749,20 @@ mod tests { .initialize(&home_dir, DbInitializationConfig::test_default()) .unwrap(); - let mut stmt = conn.prepare("select rowid, transaction_hash, amount_high_b, amount_low_b, payable_timestamp, attempt, process_error from pending_payable").unwrap(); - let mut payable_contents = stmt.query_map([], |_| Ok(42)).unwrap(); - assert!(payable_contents.next().is_none()); + let mut stmt = conn + .prepare( + "SELECT rowid, + transaction_hash, + amount_high_b, + amount_low_b, + payable_timestamp, + attempt, + process_error + FROM pending_payable", + ) + .unwrap(); + let result = stmt.execute([]).unwrap(); + assert_eq!(result, 1); let expected_key_words: &[&[&str]] = &[ &["rowid", "integer", "primary", "key"], &["transaction_hash", "text", "not", "null"], @@ -750,10 +792,24 @@ mod tests { let conn = subject .initialize(&home_dir, DbInitializationConfig::test_default()) .unwrap(); - - let mut stmt = conn.prepare("select rowid, tx_hash, receiver_address, amount_high_b, amount_low_b, timestamp, gas_price_wei, nonce, status from sent_payable").unwrap(); - let mut sent_payable_contents = stmt.query_map([], |_| Ok(42)).unwrap(); - assert!(sent_payable_contents.next().is_none()); + let mut stmt = conn + .prepare( + "SELECT rowid, + tx_hash, + receiver_address, + amount_high_b, + amount_low_b, + timestamp, + gas_price_wei_high_b, + gas_price_wei_low_b, + nonce, + block_hash, + block_number + FROM sent_payable", + ) + .unwrap(); + let result = stmt.execute([]).unwrap(); + assert_eq!(result, 1); assert_create_table_stm_contains_all_parts( &*conn, "sent_payable", @@ -767,6 +823,48 @@ mod tests { ) } + #[test] + fn db_initialize_creates_failed_payable_table() { + let home_dir = ensure_node_home_directory_does_not_exist( + "db_initializer", + "db_initialize_creates_failed_payable_table", + ); + let subject = DbInitializerReal::default(); + + let conn = subject + .initialize(&home_dir, DbInitializationConfig::test_default()) + .unwrap(); + let mut stmt = conn + .prepare( + "SELECT rowid, + tx_hash, + receiver_address, + amount_high_b, + amount_low_b, + timestamp, + gas_price_wei_high_b, + gas_price_wei_low_b, + nonce, + reason, + rechecked + FROM failed_payable", + ) + .unwrap(); + let result = stmt.execute([]).unwrap(); + assert_eq!(result, 1); + assert_create_table_stm_contains_all_parts( + &*conn, + "failed_payable", + SQL_ATTRIBUTES_FOR_CREATING_FAILED_PAYABLE, + ); + let expected_key_words: &[&[&str]] = &[&["tx_hash"]]; + assert_index_stm_is_coupled_with_right_parameter( + conn.as_ref(), + "failed_payable_tx_hash_idx", + expected_key_words, + ) + } + #[test] fn db_initialize_creates_payable_table() { let home_dir = ensure_node_home_directory_does_not_exist( @@ -779,9 +877,18 @@ mod tests { .initialize(&home_dir, DbInitializationConfig::test_default()) .unwrap(); - let mut stmt = conn.prepare ("select wallet_address, balance_high_b, balance_low_b, last_paid_timestamp, pending_payable_rowid from payable").unwrap (); - let mut payable_contents = stmt.query_map([], |_| Ok(42)).unwrap(); - assert!(payable_contents.next().is_none()); + let mut stmt = conn + .prepare( + "SELECT wallet_address, + balance_high_b, + balance_low_b, + last_paid_timestamp, + pending_payable_rowid + FROM payable", + ) + .unwrap(); + let result = stmt.execute([]).unwrap(); + assert_eq!(result, 1); assert_table_created_as_strict(&*conn, "payable"); let expected_key_words: &[&[&str]] = &[ &["wallet_address", "text", "primary", "key"], @@ -807,10 +914,16 @@ mod tests { .unwrap(); let mut stmt = conn - .prepare("select wallet_address, balance_high_b, balance_low_b, last_received_timestamp from receivable") + .prepare( + "SELECT wallet_address, + balance_high_b, + balance_low_b, + last_received_timestamp + FROM receivable", + ) .unwrap(); - let mut receivable_contents = stmt.query_map([], |_| Ok(())).unwrap(); - assert!(receivable_contents.next().is_none()); + let result = stmt.execute([]).unwrap(); + assert_eq!(result, 1); assert_table_created_as_strict(&*conn, "receivable"); let expected_key_words: &[&[&str]] = &[ &["wallet_address", "text", "primary", "key"], @@ -836,8 +949,8 @@ mod tests { .unwrap(); let mut stmt = conn.prepare("select wallet_address from banned").unwrap(); - let mut banned_contents = stmt.query_map([], |_| Ok(42)).unwrap(); - assert!(banned_contents.next().is_none()); + let result = stmt.execute([]).unwrap(); + assert_eq!(result, 1); let expected_key_words: &[&[&str]] = &[&["wallet_address", "text", "primary", "key"]]; assert_create_table_stm_contains_all_parts(conn.as_ref(), "banned", expected_key_words); assert_no_index_exists_for_table(conn.as_ref(), "banned") diff --git a/node/src/database/db_migrations/migrations/migration_10_to_11.rs b/node/src/database/db_migrations/migrations/migration_10_to_11.rs index 8b7984673..4dbfd5b5e 100644 --- a/node/src/database/db_migrations/migrations/migration_10_to_11.rs +++ b/node/src/database/db_migrations/migrations/migration_10_to_11.rs @@ -9,19 +9,38 @@ impl DatabaseMigration for Migrate_10_to_11 { &self, declaration_utils: Box, ) -> rusqlite::Result<()> { - let sql_statement = "create table if not exists sent_payable ( + let sql_statement_for_sent_payable = "create table if not exists sent_payable ( rowid integer primary key, tx_hash text not null, receiver_address text not null, amount_high_b integer not null, amount_low_b integer not null, timestamp integer not null, - gas_price_wei integer not null, + gas_price_wei_high_b integer not null, + gas_price_wei_low_b integer not null, nonce integer not null, - status text not null + block_hash text null, + block_number integer null )"; - declaration_utils.execute_upon_transaction(&[&sql_statement]) + let sql_statement_for_failed_payable = "create table if not exists failed_payable ( + rowid integer primary key, + tx_hash text not null, + receiver_address text not null, + amount_high_b integer not null, + amount_low_b integer not null, + timestamp integer not null, + gas_price_wei_high_b integer not null, + gas_price_wei_low_b integer not null, + nonce integer not null, + reason text not null, + rechecked integer not null + )"; + + declaration_utils.execute_upon_transaction(&[ + &sql_statement_for_sent_payable, + &sql_statement_for_failed_payable, + ]) } fn old_version(&self) -> usize { @@ -34,7 +53,9 @@ mod tests { use crate::database::db_initializer::{ DbInitializationConfig, DbInitializer, DbInitializerReal, DATABASE_FILE, }; - use crate::database::test_utils::SQL_ATTRIBUTES_FOR_CREATING_SENT_PAYABLE; + use crate::database::test_utils::{ + SQL_ATTRIBUTES_FOR_CREATING_FAILED_PAYABLE, SQL_ATTRIBUTES_FOR_CREATING_SENT_PAYABLE, + }; use crate::test_utils::database_utils::{ assert_create_table_stm_contains_all_parts, assert_table_exists, bring_db_0_back_to_life_and_return_connection, make_external_data, @@ -72,11 +93,17 @@ mod tests { .unwrap(); assert_table_exists(connection.as_ref(), "sent_payable"); + assert_table_exists(connection.as_ref(), "failed_payable"); assert_create_table_stm_contains_all_parts( &*connection, "sent_payable", SQL_ATTRIBUTES_FOR_CREATING_SENT_PAYABLE, ); + assert_create_table_stm_contains_all_parts( + &*connection, + "failed_payable", + SQL_ATTRIBUTES_FOR_CREATING_FAILED_PAYABLE, + ); TestLogHandler::new().assert_logs_contain_in_order(vec![ "DbMigrator: Database successfully migrated from version 10 to 11", ]); diff --git a/node/src/database/test_utils/mod.rs b/node/src/database/test_utils/mod.rs index 4251d1588..6e88e1292 100644 --- a/node/src/database/test_utils/mod.rs +++ b/node/src/database/test_utils/mod.rs @@ -19,9 +19,25 @@ pub const SQL_ATTRIBUTES_FOR_CREATING_SENT_PAYABLE: &[&[&str]] = &[ &["amount_high_b", "integer", "not", "null"], &["amount_low_b", "integer", "not", "null"], &["timestamp", "integer", "not", "null"], - &["gas_price_wei", "integer", "not", "null"], + &["gas_price_wei_high_b", "integer", "not", "null"], + &["gas_price_wei_low_b", "integer", "not", "null"], &["nonce", "integer", "not", "null"], - &["status", "text", "not", "null"], + &["block_hash", "text", "null"], + &["block_number", "integer", "null"], +]; + +pub const SQL_ATTRIBUTES_FOR_CREATING_FAILED_PAYABLE: &[&[&str]] = &[ + &["rowid", "integer", "primary", "key"], + &["tx_hash", "text", "not", "null"], + &["receiver_address", "text", "not", "null"], + &["amount_high_b", "integer", "not", "null"], + &["amount_low_b", "integer", "not", "null"], + &["timestamp", "integer", "not", "null"], + &["gas_price_wei_high_b", "integer", "not", "null"], + &["gas_price_wei_low_b", "integer", "not", "null"], + &["nonce", "integer", "not", "null"], + &["reason", "text", "not", "null"], + &["rechecked", "integer", "not", "null"], ]; #[derive(Debug, Default)] @@ -126,26 +142,3 @@ impl DbInitializerMock { self } } - -#[cfg(test)] -mod tests { - use crate::database::test_utils::SQL_ATTRIBUTES_FOR_CREATING_SENT_PAYABLE; - - #[test] - fn constants_have_correct_values() { - assert_eq!( - SQL_ATTRIBUTES_FOR_CREATING_SENT_PAYABLE, - &[ - &["rowid", "integer", "primary", "key"], - &["tx_hash", "text", "not", "null"], - &["receiver_address", "text", "not", "null"], - &["amount_high_b", "integer", "not", "null"], - &["amount_low_b", "integer", "not", "null"], - &["timestamp", "integer", "not", "null"], - &["gas_price_wei", "integer", "not", "null"], - &["nonce", "integer", "not", "null"], - &["status", "text", "not", "null"], - ] - ); - } -} diff --git a/node/src/test_utils/recorder_counter_msgs.rs b/node/src/test_utils/recorder_counter_msgs.rs index 9aa856fc2..ee56936f6 100644 --- a/node/src/test_utils/recorder_counter_msgs.rs +++ b/node/src/test_utils/recorder_counter_msgs.rs @@ -13,8 +13,8 @@ use std::collections::HashMap; // a system. They enable sending either a single message or multiple messages in response to // a specific trigger, which is just another Actor message arriving at the Recorder. // By trigger, we mean the moment when an incoming message is tested sequentially against collected -// identification methods and matches. Each counter-message must have its ID method attached when -// it is being prepared for storage in the Recorder. This bundle is called a setup. Each setup has +// identification methods and matches. Each counter-message must have its ID method attached when +// it is being prepared for storage in the Recorder. This bundle is called a setup. Each setup has // one ID method but can contain multiple counter-messages that are all sent when triggered. // Counter-messages can be independently customized and targeted at different actors by @@ -25,9 +25,9 @@ use std::collections::HashMap; // addresses are known. The setup for counter-messages must be registered with the appropriate // Recorder using a specially designated Actor message SetUpCounterMsgs. -// If a trigger message matches multiple counter-message setups, the triggered setup depends -// on the order in which setups are provided. Consider using MsgIdentification::ByMatch -// or MsgIdentification::ByPredicate instead of MsgIdentification::ByTypeId to avoid confusion +// If a trigger message matches multiple counter-message setups, the triggered setup depends +// on the order in which setups are provided. Consider using MsgIdentification::ByMatch +// or MsgIdentification::ByPredicate instead of MsgIdentification::ByTypeId to avoid confusion // about setup ordering. pub trait CounterMsgGear: Send { diff --git a/port_exposer/Cargo.lock b/port_exposer/Cargo.lock index 210c0de54..1a65f5fc1 100644 --- a/port_exposer/Cargo.lock +++ b/port_exposer/Cargo.lock @@ -20,7 +20,7 @@ checksum = "6a987beff54b60ffa6d51982e1aa1146bc42f19bd26be28b0586f252fccf5317" [[package]] name = "port_exposer" -version = "0.8.2" +version = "0.9.0" dependencies = [ "default-net", ] diff --git a/port_exposer/Cargo.toml b/port_exposer/Cargo.toml index a5eab68f0..703fa9813 100644 --- a/port_exposer/Cargo.toml +++ b/port_exposer/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "port_exposer" -version = "0.8.2" +version = "0.9.0" authors = ["Dan Wiebe ", "MASQ"] license = "GPL-3.0-only" copyright = "Copyright (c) 2019, MASQ (https://masq.ai) and/or its affiliates. All rights reserved."