diff --git a/USER-INTERFACE-INTERFACE.md b/USER-INTERFACE-INTERFACE.md index 443abc65a6..3a5cacffa2 100644 --- a/USER-INTERFACE-INTERFACE.md +++ b/USER-INTERFACE-INTERFACE.md @@ -358,8 +358,8 @@ Another reason the secrets might be missing is that there are not yet any secret "exitServiceRate: " }, "scanIntervals": { - "pendingPayableSec": , "payableSec": , + "pendingPayableSec": , "receivableSec": }, } @@ -453,20 +453,21 @@ database password. If you want to know whether the password you have is the corr * `scanIntervals`: These three intervals describe the length of three different scan cycles running automatically in the background since the Node has connected to a qualified neighborhood that consists of neighbors enabling a complete - 3-hop route. Each parameter can be set independently, but by default are all the same which currently is most desirable - for the consistency of service payments to and from your Node. Technically, there doesn't have to be any lower limit - for the minimum of time you can set; two scans of the same sort would never run at the same time but the next one is + 3-hop route. Each parameter can be set independently. Technically, there doesn't have to be any lower limit for +* the minimum of time you can set; two scans of the same sort would never run at the same time but the next one is always scheduled not earlier than the end of the previous one. These are ever present values, no matter if the user's set any value, because defaults are prepared. -* `pendingPayableSec`: Amount of seconds between two sequential cycles of scanning for payments that are marked as currently - pending; the payments were sent to pay our debts, the payable. The purpose of this process is to confirm the status of - the pending payment; either the payment transaction was written on blockchain as successful or failed. - -* `payableSec`: Amount of seconds between two sequential cycles of scanning aimed to find payable accounts of that meet +* `payableSec`: Amount of seconds between two sequential cycles of scanning aimed to find payable accounts that meet the criteria set by the Payment Thresholds; these accounts are tracked on behalf of our creditors. If they meet the Payment Threshold criteria, our Node will send a debt payment transaction to the creditor in question. +* `pendingPayableSec`: The time elapsed since the last payable transaction was processed. This scan operates + on an irregular schedule and is triggered after new transactions are sent or when failed transactions need + to be replaced. The scanner monitors pending transactions and verifies their blockchain status, determining whether + each payment was successfully recorded or failed. Any failed transaction is automatically resubmitted as soon + as the failure is detected. + * `receivableSec`: Amount of seconds between two sequential cycles of scanning for payments on the blockchain that have been sent by our creditors to us, which are credited against receivables recorded for services provided. diff --git a/masq_lib/src/blockchains/blockchain_records.rs b/masq_lib/src/blockchains/blockchain_records.rs index cc1198afaa..821b0d7f17 100644 --- a/masq_lib/src/blockchains/blockchain_records.rs +++ b/masq_lib/src/blockchains/blockchain_records.rs @@ -2,63 +2,82 @@ use crate::blockchains::chains::Chain; use crate::constants::{ - BASE_MAINNET_CONTRACT_CREATION_BLOCK, BASE_MAINNET_FULL_IDENTIFIER, - BASE_SEPOLIA_CONTRACT_CREATION_BLOCK, BASE_SEPOLIA_FULL_IDENTIFIER, DEV_CHAIN_FULL_IDENTIFIER, - ETH_MAINNET_CONTRACT_CREATION_BLOCK, ETH_MAINNET_FULL_IDENTIFIER, + BASE_GAS_PRICE_CEILING_WEI, BASE_MAINNET_CHAIN_ID, BASE_MAINNET_CONTRACT_CREATION_BLOCK, + BASE_MAINNET_FULL_IDENTIFIER, BASE_SEPOLIA_CHAIN_ID, BASE_SEPOLIA_CONTRACT_CREATION_BLOCK, + BASE_SEPOLIA_FULL_IDENTIFIER, DEFAULT_PENDING_PAYABLE_INTERVAL_BASE_SEC, + DEFAULT_PENDING_PAYABLE_INTERVAL_DEV_SEC, DEFAULT_PENDING_PAYABLE_INTERVAL_ETH_SEC, + DEFAULT_PENDING_PAYABLE_INTERVAL_POLYGON_SEC, DEV_CHAIN_FULL_IDENTIFIER, DEV_CHAIN_ID, + DEV_GAS_PRICE_CEILING_WEI, ETH_GAS_PRICE_CEILING_WEI, ETH_MAINNET_CHAIN_ID, + ETH_MAINNET_CONTRACT_CREATION_BLOCK, ETH_MAINNET_FULL_IDENTIFIER, ETH_ROPSTEN_CHAIN_ID, ETH_ROPSTEN_CONTRACT_CREATION_BLOCK, ETH_ROPSTEN_FULL_IDENTIFIER, - MULTINODE_TESTNET_CONTRACT_CREATION_BLOCK, POLYGON_AMOY_CONTRACT_CREATION_BLOCK, - POLYGON_AMOY_FULL_IDENTIFIER, POLYGON_MAINNET_CONTRACT_CREATION_BLOCK, - POLYGON_MAINNET_FULL_IDENTIFIER, + MULTINODE_TESTNET_CONTRACT_CREATION_BLOCK, POLYGON_AMOY_CHAIN_ID, + POLYGON_AMOY_CONTRACT_CREATION_BLOCK, POLYGON_AMOY_FULL_IDENTIFIER, + POLYGON_GAS_PRICE_CEILING_WEI, POLYGON_MAINNET_CHAIN_ID, + POLYGON_MAINNET_CONTRACT_CREATION_BLOCK, POLYGON_MAINNET_FULL_IDENTIFIER, }; use ethereum_types::{Address, H160}; -pub const CHAINS: [BlockchainRecord; 7] = [ +pub static CHAINS: [BlockchainRecord; 7] = [ BlockchainRecord { self_id: Chain::PolyMainnet, - num_chain_id: 137, + num_chain_id: POLYGON_MAINNET_CHAIN_ID, literal_identifier: POLYGON_MAINNET_FULL_IDENTIFIER, + gas_price_safe_ceiling_minor: POLYGON_GAS_PRICE_CEILING_WEI, + default_pending_payable_interval_sec: DEFAULT_PENDING_PAYABLE_INTERVAL_POLYGON_SEC, contract: POLYGON_MAINNET_CONTRACT_ADDRESS, contract_creation_block: POLYGON_MAINNET_CONTRACT_CREATION_BLOCK, }, BlockchainRecord { self_id: Chain::EthMainnet, - num_chain_id: 1, + num_chain_id: ETH_MAINNET_CHAIN_ID, literal_identifier: ETH_MAINNET_FULL_IDENTIFIER, + gas_price_safe_ceiling_minor: ETH_GAS_PRICE_CEILING_WEI, + default_pending_payable_interval_sec: DEFAULT_PENDING_PAYABLE_INTERVAL_ETH_SEC, contract: ETH_MAINNET_CONTRACT_ADDRESS, contract_creation_block: ETH_MAINNET_CONTRACT_CREATION_BLOCK, }, BlockchainRecord { self_id: Chain::BaseMainnet, - num_chain_id: 8453, + num_chain_id: BASE_MAINNET_CHAIN_ID, literal_identifier: BASE_MAINNET_FULL_IDENTIFIER, + gas_price_safe_ceiling_minor: BASE_GAS_PRICE_CEILING_WEI, + default_pending_payable_interval_sec: DEFAULT_PENDING_PAYABLE_INTERVAL_BASE_SEC, contract: BASE_MAINNET_CONTRACT_ADDRESS, contract_creation_block: BASE_MAINNET_CONTRACT_CREATION_BLOCK, }, BlockchainRecord { self_id: Chain::BaseSepolia, - num_chain_id: 84532, + num_chain_id: BASE_SEPOLIA_CHAIN_ID, literal_identifier: BASE_SEPOLIA_FULL_IDENTIFIER, + gas_price_safe_ceiling_minor: BASE_GAS_PRICE_CEILING_WEI, + default_pending_payable_interval_sec: DEFAULT_PENDING_PAYABLE_INTERVAL_BASE_SEC, contract: BASE_SEPOLIA_TESTNET_CONTRACT_ADDRESS, contract_creation_block: BASE_SEPOLIA_CONTRACT_CREATION_BLOCK, }, BlockchainRecord { self_id: Chain::PolyAmoy, - num_chain_id: 80002, + num_chain_id: POLYGON_AMOY_CHAIN_ID, literal_identifier: POLYGON_AMOY_FULL_IDENTIFIER, + gas_price_safe_ceiling_minor: POLYGON_GAS_PRICE_CEILING_WEI, + default_pending_payable_interval_sec: DEFAULT_PENDING_PAYABLE_INTERVAL_POLYGON_SEC, contract: POLYGON_AMOY_TESTNET_CONTRACT_ADDRESS, contract_creation_block: POLYGON_AMOY_CONTRACT_CREATION_BLOCK, }, BlockchainRecord { self_id: Chain::EthRopsten, - num_chain_id: 3, + num_chain_id: ETH_ROPSTEN_CHAIN_ID, literal_identifier: ETH_ROPSTEN_FULL_IDENTIFIER, + gas_price_safe_ceiling_minor: ETH_GAS_PRICE_CEILING_WEI, + default_pending_payable_interval_sec: DEFAULT_PENDING_PAYABLE_INTERVAL_ETH_SEC, contract: ETH_ROPSTEN_TESTNET_CONTRACT_ADDRESS, contract_creation_block: ETH_ROPSTEN_CONTRACT_CREATION_BLOCK, }, BlockchainRecord { self_id: Chain::Dev, - num_chain_id: 2, + num_chain_id: DEV_CHAIN_ID, literal_identifier: DEV_CHAIN_FULL_IDENTIFIER, + gas_price_safe_ceiling_minor: DEV_GAS_PRICE_CEILING_WEI, + default_pending_payable_interval_sec: DEFAULT_PENDING_PAYABLE_INTERVAL_DEV_SEC, contract: MULTINODE_TESTNET_CONTRACT_ADDRESS, contract_creation_block: MULTINODE_TESTNET_CONTRACT_CREATION_BLOCK, }, @@ -69,6 +88,8 @@ pub struct BlockchainRecord { pub self_id: Chain, pub num_chain_id: u64, pub literal_identifier: &'static str, + pub gas_price_safe_ceiling_minor: u128, + pub default_pending_payable_interval_sec: u64, pub contract: Address, pub contract_creation_block: u64, } @@ -115,7 +136,11 @@ const POLYGON_MAINNET_CONTRACT_ADDRESS: Address = H160([ mod tests { use super::*; use crate::blockchains::chains::chain_from_chain_identifier_opt; - use crate::constants::BASE_MAINNET_CONTRACT_CREATION_BLOCK; + use crate::constants::{ + BASE_MAINNET_CONTRACT_CREATION_BLOCK, DEFAULT_PENDING_PAYABLE_INTERVAL_BASE_SEC, + DEFAULT_PENDING_PAYABLE_INTERVAL_DEV_SEC, DEFAULT_PENDING_PAYABLE_INTERVAL_ETH_SEC, + DEFAULT_PENDING_PAYABLE_INTERVAL_POLYGON_SEC, WEIS_IN_GWEI, + }; use std::collections::HashSet; use std::iter::FromIterator; @@ -195,6 +220,8 @@ mod tests { num_chain_id: 1, self_id: examined_chain, literal_identifier: "eth-mainnet", + gas_price_safe_ceiling_minor: 100 * WEIS_IN_GWEI as u128, + default_pending_payable_interval_sec: DEFAULT_PENDING_PAYABLE_INTERVAL_ETH_SEC, contract: ETH_MAINNET_CONTRACT_ADDRESS, contract_creation_block: ETH_MAINNET_CONTRACT_CREATION_BLOCK, } @@ -211,6 +238,8 @@ mod tests { num_chain_id: 3, self_id: examined_chain, literal_identifier: "eth-ropsten", + gas_price_safe_ceiling_minor: 100 * WEIS_IN_GWEI as u128, + default_pending_payable_interval_sec: DEFAULT_PENDING_PAYABLE_INTERVAL_ETH_SEC, contract: ETH_ROPSTEN_TESTNET_CONTRACT_ADDRESS, contract_creation_block: ETH_ROPSTEN_CONTRACT_CREATION_BLOCK, } @@ -227,6 +256,8 @@ mod tests { num_chain_id: 137, self_id: examined_chain, literal_identifier: "polygon-mainnet", + gas_price_safe_ceiling_minor: 200 * WEIS_IN_GWEI as u128, + default_pending_payable_interval_sec: DEFAULT_PENDING_PAYABLE_INTERVAL_POLYGON_SEC, contract: POLYGON_MAINNET_CONTRACT_ADDRESS, contract_creation_block: POLYGON_MAINNET_CONTRACT_CREATION_BLOCK, } @@ -243,6 +274,8 @@ mod tests { num_chain_id: 80002, self_id: examined_chain, literal_identifier: "polygon-amoy", + gas_price_safe_ceiling_minor: 200 * WEIS_IN_GWEI as u128, + default_pending_payable_interval_sec: DEFAULT_PENDING_PAYABLE_INTERVAL_POLYGON_SEC, contract: POLYGON_AMOY_TESTNET_CONTRACT_ADDRESS, contract_creation_block: POLYGON_AMOY_CONTRACT_CREATION_BLOCK, } @@ -259,6 +292,8 @@ mod tests { num_chain_id: 8453, self_id: examined_chain, literal_identifier: "base-mainnet", + gas_price_safe_ceiling_minor: 50 * WEIS_IN_GWEI as u128, + default_pending_payable_interval_sec: DEFAULT_PENDING_PAYABLE_INTERVAL_BASE_SEC, contract: BASE_MAINNET_CONTRACT_ADDRESS, contract_creation_block: BASE_MAINNET_CONTRACT_CREATION_BLOCK, } @@ -275,6 +310,8 @@ mod tests { num_chain_id: 84532, self_id: examined_chain, literal_identifier: "base-sepolia", + gas_price_safe_ceiling_minor: 50 * WEIS_IN_GWEI as u128, + default_pending_payable_interval_sec: DEFAULT_PENDING_PAYABLE_INTERVAL_BASE_SEC, contract: BASE_SEPOLIA_TESTNET_CONTRACT_ADDRESS, contract_creation_block: BASE_SEPOLIA_CONTRACT_CREATION_BLOCK, } @@ -291,6 +328,8 @@ mod tests { num_chain_id: 2, self_id: examined_chain, literal_identifier: "dev", + gas_price_safe_ceiling_minor: 200 * WEIS_IN_GWEI as u128, + default_pending_payable_interval_sec: DEFAULT_PENDING_PAYABLE_INTERVAL_DEV_SEC, contract: MULTINODE_TESTNET_CONTRACT_ADDRESS, contract_creation_block: MULTINODE_TESTNET_CONTRACT_CREATION_BLOCK, } diff --git a/masq_lib/src/blockchains/chains.rs b/masq_lib/src/blockchains/chains.rs index c8f4a1ab70..ea7045f133 100644 --- a/masq_lib/src/blockchains/chains.rs +++ b/masq_lib/src/blockchains/chains.rs @@ -157,6 +157,8 @@ mod tests { num_chain_id: 0, self_id: Chain::PolyMainnet, literal_identifier: "", + gas_price_safe_ceiling_minor: 0, + default_pending_payable_interval_sec: 0, contract: Default::default(), contract_creation_block: 0, } diff --git a/masq_lib/src/constants.rs b/masq_lib/src/constants.rs index a67f86128c..20e332f4af 100644 --- a/masq_lib/src/constants.rs +++ b/masq_lib/src/constants.rs @@ -5,7 +5,7 @@ use crate::data_version::DataVersion; use const_format::concatcp; pub const DEFAULT_CHAIN: Chain = Chain::BaseMainnet; -pub const CURRENT_SCHEMA_VERSION: usize = 11; +pub const CURRENT_SCHEMA_VERSION: usize = 12; pub const HIGHEST_RANDOM_CLANDESTINE_PORT: u16 = 9999; pub const HTTP_PORT: u16 = 80; @@ -18,23 +18,16 @@ pub const MASQ_URL_PREFIX: &str = "masq://"; pub const CURRENT_LOGFILE_NAME: &str = "MASQNode_rCURRENT.log"; pub const MASQ_PROMPT: &str = "masq> "; -pub const DEFAULT_GAS_PRICE: u64 = 1; //TODO ?? Really - pub const WALLET_ADDRESS_LENGTH: usize = 42; -pub const MASQ_TOTAL_SUPPLY: u64 = 37_500_000; pub const WEIS_IN_GWEI: i128 = 1_000_000_000; -pub const DEFAULT_MAX_BLOCK_COUNT: u64 = 100_000; +pub const COMBINED_PARAMETERS_DELIMITER: char = '|'; pub const PAYLOAD_ZERO_SIZE: usize = 0usize; -pub const ETH_MAINNET_CONTRACT_CREATION_BLOCK: u64 = 11_170_708; -pub const ETH_ROPSTEN_CONTRACT_CREATION_BLOCK: u64 = 8_688_171; -pub const POLYGON_MAINNET_CONTRACT_CREATION_BLOCK: u64 = 14_863_650; -pub const POLYGON_AMOY_CONTRACT_CREATION_BLOCK: u64 = 5_323_366; -pub const BASE_MAINNET_CONTRACT_CREATION_BLOCK: u64 = 19_711_235; -pub const BASE_SEPOLIA_CONTRACT_CREATION_BLOCK: u64 = 14_732_730; -pub const MULTINODE_TESTNET_CONTRACT_CREATION_BLOCK: u64 = 0; +//descriptor +pub const CENTRAL_DELIMITER: char = '@'; +pub const CHAIN_IDENTIFIER_DELIMITER: char = ':'; //Migration versions //////////////////////////////////////////////////////////////////////////////////////////////////// @@ -87,13 +80,20 @@ pub const VALUE_EXCEEDS_ALLOWED_LIMIT: u64 = ACCOUNTANT_PREFIX | 3; //////////////////////////////////////////////////////////////////////////////////////////////////// -pub const COMBINED_PARAMETERS_DELIMITER: char = '|'; +pub const MASQ_TOTAL_SUPPLY: u64 = 37_500_000; -//descriptor -pub const CENTRAL_DELIMITER: char = '@'; -pub const CHAIN_IDENTIFIER_DELIMITER: char = ':'; +pub const DEFAULT_GAS_PRICE: u64 = 1; //TODO ?? Really +pub const DEFAULT_GAS_PRICE_MARGIN: u64 = 30; +pub const DEFAULT_MAX_BLOCK_COUNT: u64 = 100_000; //chains +pub const POLYGON_MAINNET_CHAIN_ID: u64 = 137; +pub const POLYGON_AMOY_CHAIN_ID: u64 = 80002; +pub const BASE_MAINNET_CHAIN_ID: u64 = 8453; +pub const BASE_SEPOLIA_CHAIN_ID: u64 = 84532; +pub const ETH_MAINNET_CHAIN_ID: u64 = 1; +pub const ETH_ROPSTEN_CHAIN_ID: u64 = 3; +pub const DEV_CHAIN_ID: u64 = 2; const POLYGON_FAMILY: &str = "polygon"; const ETH_FAMILY: &str = "eth"; const BASE_FAMILY: &str = "base"; @@ -107,6 +107,24 @@ pub const BASE_MAINNET_FULL_IDENTIFIER: &str = concatcp!(BASE_FAMILY, LINK, MAIN pub const BASE_SEPOLIA_FULL_IDENTIFIER: &str = concatcp!(BASE_FAMILY, LINK, "sepolia"); pub const DEV_CHAIN_FULL_IDENTIFIER: &str = "dev"; +pub const ETH_MAINNET_CONTRACT_CREATION_BLOCK: u64 = 11_170_708; +pub const ETH_ROPSTEN_CONTRACT_CREATION_BLOCK: u64 = 8_688_171; +pub const POLYGON_MAINNET_CONTRACT_CREATION_BLOCK: u64 = 14_863_650; +pub const POLYGON_AMOY_CONTRACT_CREATION_BLOCK: u64 = 5_323_366; +pub const BASE_MAINNET_CONTRACT_CREATION_BLOCK: u64 = 19_711_235; +pub const BASE_SEPOLIA_CONTRACT_CREATION_BLOCK: u64 = 14_732_730; +pub const MULTINODE_TESTNET_CONTRACT_CREATION_BLOCK: u64 = 0; + +pub const POLYGON_GAS_PRICE_CEILING_WEI: u128 = 200_000_000_000; +pub const ETH_GAS_PRICE_CEILING_WEI: u128 = 100_000_000_000; +pub const BASE_GAS_PRICE_CEILING_WEI: u128 = 50_000_000_000; +pub const DEV_GAS_PRICE_CEILING_WEI: u128 = 200_000_000_000; + +pub const DEFAULT_PENDING_PAYABLE_INTERVAL_ETH_SEC: u64 = 600; +pub const DEFAULT_PENDING_PAYABLE_INTERVAL_BASE_SEC: u64 = 120; +pub const DEFAULT_PENDING_PAYABLE_INTERVAL_POLYGON_SEC: u64 = 180; +pub const DEFAULT_PENDING_PAYABLE_INTERVAL_DEV_SEC: u64 = 120; + #[cfg(test)] mod tests { use super::*; @@ -124,6 +142,7 @@ mod tests { assert_eq!(CURRENT_LOGFILE_NAME, "MASQNode_rCURRENT.log"); assert_eq!(MASQ_PROMPT, "masq> "); assert_eq!(DEFAULT_GAS_PRICE, 1); + assert_eq!(DEFAULT_GAS_PRICE_MARGIN, 30); assert_eq!(WALLET_ADDRESS_LENGTH, 42); assert_eq!(MASQ_TOTAL_SUPPLY, 37_500_000); assert_eq!(WEIS_IN_GWEI, 1_000_000_000); @@ -169,6 +188,13 @@ mod tests { assert_eq!(VALUE_EXCEEDS_ALLOWED_LIMIT, ACCOUNTANT_PREFIX | 3); assert_eq!(CENTRAL_DELIMITER, '@'); assert_eq!(CHAIN_IDENTIFIER_DELIMITER, ':'); + assert_eq!(POLYGON_MAINNET_CHAIN_ID, 137); + assert_eq!(POLYGON_AMOY_CHAIN_ID, 80002); + assert_eq!(BASE_MAINNET_CHAIN_ID, 8453); + assert_eq!(BASE_SEPOLIA_CHAIN_ID, 84532); + assert_eq!(ETH_MAINNET_CHAIN_ID, 1); + assert_eq!(ETH_ROPSTEN_CHAIN_ID, 3); + assert_eq!(DEV_CHAIN_ID, 2); assert_eq!(POLYGON_FAMILY, "polygon"); assert_eq!(ETH_FAMILY, "eth"); assert_eq!(BASE_FAMILY, "base"); @@ -180,6 +206,14 @@ mod tests { assert_eq!(ETH_ROPSTEN_FULL_IDENTIFIER, "eth-ropsten"); assert_eq!(BASE_SEPOLIA_FULL_IDENTIFIER, "base-sepolia"); assert_eq!(DEV_CHAIN_FULL_IDENTIFIER, "dev"); + assert_eq!(POLYGON_GAS_PRICE_CEILING_WEI, 200_000_000_000); + assert_eq!(ETH_GAS_PRICE_CEILING_WEI, 100_000_000_000); + assert_eq!(BASE_GAS_PRICE_CEILING_WEI, 50_000_000_000); + assert_eq!(DEV_GAS_PRICE_CEILING_WEI, 200_000_000_000); + assert_eq!(DEFAULT_PENDING_PAYABLE_INTERVAL_ETH_SEC, 600); + assert_eq!(DEFAULT_PENDING_PAYABLE_INTERVAL_BASE_SEC, 120); + assert_eq!(DEFAULT_PENDING_PAYABLE_INTERVAL_POLYGON_SEC, 180); + assert_eq!(DEFAULT_PENDING_PAYABLE_INTERVAL_DEV_SEC, 120); assert_eq!( CLIENT_REQUEST_PAYLOAD_CURRENT_VERSION, DataVersion { major: 0, minor: 1 } diff --git a/masq_lib/src/lib.rs b/masq_lib/src/lib.rs index 1fc5eb68d9..c7e2b107a8 100644 --- a/masq_lib/src/lib.rs +++ b/masq_lib/src/lib.rs @@ -24,7 +24,7 @@ pub mod crash_point; pub mod data_version; pub mod exit_locations; pub mod shared_schema; +pub mod simple_clock; pub mod test_utils; -pub mod type_obfuscation; pub mod ui_gateway; pub mod ui_traffic_converter; diff --git a/masq_lib/src/messages.rs b/masq_lib/src/messages.rs index 74114b9593..29ee0e6299 100644 --- a/masq_lib/src/messages.rs +++ b/masq_lib/src/messages.rs @@ -527,10 +527,10 @@ pub struct UiRatePack { #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] pub struct UiScanIntervals { - #[serde(rename = "pendingPayableSec")] - pub pending_payable_sec: u64, #[serde(rename = "payableSec")] pub payable_sec: u64, + #[serde(rename = "pendingPayableSec")] + pub pending_payable_sec: u64, #[serde(rename = "receivableSec")] pub receivable_sec: u64, } @@ -783,8 +783,8 @@ conversation_message!(UiRecoverWalletsResponse, "recoverWallets"); #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy, Hash)] pub enum ScanType { Payables, - Receivables, PendingPayables, + Receivables, } impl FromStr for ScanType { @@ -793,8 +793,8 @@ impl FromStr for ScanType { fn from_str(s: &str) -> Result { match s { s if &s.to_lowercase() == "payables" => Ok(ScanType::Payables), - s if &s.to_lowercase() == "receivables" => Ok(ScanType::Receivables), s if &s.to_lowercase() == "pendingpayables" => Ok(ScanType::PendingPayables), + s if &s.to_lowercase() == "receivables" => Ok(ScanType::Receivables), s => Err(format!("Unrecognized ScanType: '{}'", s)), } } @@ -1237,10 +1237,10 @@ mod tests { let result: Vec = vec![ "Payables", "pAYABLES", - "Receivables", - "rECEIVABLES", "PendingPayables", "pENDINGpAYABLES", + "Receivables", + "rECEIVABLES", ] .into_iter() .map(|s| ScanType::from_str(s).unwrap()) @@ -1251,10 +1251,10 @@ mod tests { vec![ ScanType::Payables, ScanType::Payables, - ScanType::Receivables, - ScanType::Receivables, ScanType::PendingPayables, ScanType::PendingPayables, + ScanType::Receivables, + ScanType::Receivables, ] ) } diff --git a/masq_lib/src/shared_schema.rs b/masq_lib/src/shared_schema.rs index b2858f3d3c..a273205ff1 100644 --- a/masq_lib/src/shared_schema.rs +++ b/masq_lib/src/shared_schema.rs @@ -24,8 +24,8 @@ pub const CHAIN_HELP: &str = "The blockchain network MASQ Node will configure itself to use. You must ensure the \ Ethereum client specified by --blockchain-service-url communicates with the same blockchain network."; pub const CONFIG_FILE_HELP: &str = - "Optional TOML file containing configuration that doesn't often change. Should contain only \ - scalar items, string or numeric, whose names are exactly the same as the command-line parameters \ + "Optional TOML file containing configuration that seldom changes. Should contain only \ + scalar items, string, or numeric, whose names are exactly the same as the command-line parameters \ they replace (except no '--' prefix). If you specify a relative path, or no path, the Node will \ look for your config file starting in the --data-directory. If you specify an absolute path, \ --data-directory will be ignored when searching for the config file. A few parameters \ @@ -142,9 +142,9 @@ pub const REAL_USER_HELP: &str = like ::."; pub const SCANS_HELP: &str = "The Node, when running, performs various periodic scans, including scanning for payables that need to be paid, \ - for pending payables that have arrived (and are no longer pending), for incoming receivables that need to be \ - recorded, and for delinquent Nodes that need to be banned. If you don't specify this parameter, or if you give \ - it the value 'on', these scans will proceed normally. But if you give the value 'off', the scans won't be \ + for pending payables that have arrived or happened to fail (and are no longer pending), for incoming receivables \ + that need to be recorded, and for delinquent Nodes that need to be banned. If you don't specify this parameter, \ + or if you give it the value 'on', these scans will proceed normally. But if you give the value 'off', the scans won't be \ started when the Node starts, and will have to be triggered later manually and individually with the \ MASQNode-UIv2 'scan' command. (If you don't, you'll most likely be delinquency-banned by all your neighbors.) \ This parameter is most useful for testing."; @@ -187,19 +187,18 @@ pub const PAYMENT_THRESHOLDS_HELP: &str = "\ pub const SCAN_INTERVALS_HELP:&str = "\ These three intervals describe the length of three different scan cycles running automatically in the background \ since the Node has connected to a qualified neighborhood that consists of neighbors enabling a complete 3-hop \ - route. Each parameter can be set independently, but by default are all the same which currently is most desirable \ - for the consistency of service payments to and from your Node. Technically, there doesn't have to be any lower \ + route. Each parameter can be set independently. Technically, there doesn't have to be any lower \ limit for the minimum of time you can set; two scans of the same sort would never run at the same time but the \ next one is always scheduled not earlier than the end of the previous one. These are ever present values, no matter \ if the user's set any value, they have defaults. The parameters must be always supplied all together, delimited by vertical \ bars and in the right order.\n\n\ - 1. Pending Payable Scan Interval: Amount of seconds between two sequential cycles of scanning for payments that are \ - marked as currently pending; the payments were sent to pay our debts, the payable. The purpose of this process is to \ - confirm the status of the pending payment; either the payment transaction was written on blockchain as successful or \ - failed.\n\n\ - 2. Payable Scan Interval: Amount of seconds between two sequential cycles of scanning aimed to find payable accounts \ - of that meet the criteria set by the Payment Thresholds; these accounts are tracked on behalf of our creditors. If \ - they meet the Payment Threshold criteria, our Node will send a debt payment transaction to the creditor in question.\n\n\ + 1. Payable Scan Interval: Amount of seconds between two sequential cycles of scanning aimed to find payable accounts \ + that meet the criteria set by the Payment Thresholds; these accounts are tracked on behalf of our creditors. \ + If they meet the Payment Threshold criteria, our Node will send a debt payment transaction to the creditor in question.\n\n\ + 2. Pending Payable Scan Interval: The time elapsed since the last payable transaction was processed. This scan operates \ + on an irregular schedule and is triggered after new transactions are sent or when failed transactions need to be replaced. \ + The scanner monitors pending transactions and verifies their blockchain status, determining whether each payment was \ + successfully recorded or failed. Any failed transaction is automatically resubmitted as soon as the failure is detected.\n\n\ 3. Receivable Scan Interval: Amount of seconds between two sequential cycles of scanning for payments on the \ blockchain that have been sent by our creditors to us, which are credited against receivables recorded for services \ provided."; @@ -752,8 +751,8 @@ mod tests { ); assert_eq!( CONFIG_FILE_HELP, - "Optional TOML file containing configuration that doesn't often change. Should contain only \ - scalar items, string or numeric, whose names are exactly the same as the command-line parameters \ + "Optional TOML file containing configuration that seldom changes. Should contain only \ + scalar items, string, or numeric, whose names are exactly the same as the command-line parameters \ they replace (except no '--' prefix). If you specify a relative path, or no path, the Node will \ look for your config file starting in the --data-directory. If you specify an absolute path, \ --data-directory will be ignored when searching for the config file. A few parameters \ @@ -891,6 +890,16 @@ mod tests { you start the Node using pkexec or some other method that doesn't populate the SUDO_xxx variables. Use a value \ like ::." ); + assert_eq!( + SCANS_HELP, + "The Node, when running, performs various periodic scans, including scanning for payables that need to be paid, \ + for pending payables that have arrived or happened to fail (and are no longer pending), for incoming receivables \ + that need to be recorded, and for delinquent Nodes that need to be banned. If you don't specify this parameter, \ + or if you give it the value 'on', these scans will proceed normally. But if you give the value 'off', the scans won't be \ + started when the Node starts, and will have to be triggered later manually and individually with the \ + MASQNode-UIv2 'scan' command. (If you don't, you'll most likely be delinquency-banned by all your neighbors.) \ + This parameter is most useful for testing." + ); assert_eq!( DEFAULT_UI_PORT_VALUE.to_string(), @@ -967,19 +976,19 @@ mod tests { SCAN_INTERVALS_HELP, "These three intervals describe the length of three different scan cycles running automatically in the background \ since the Node has connected to a qualified neighborhood that consists of neighbors enabling a complete 3-hop \ - route. Each parameter can be set independently, but by default are all the same which currently is most desirable \ - for the consistency of service payments to and from your Node. Technically, there doesn't have to be any lower \ + route. Each parameter can be set independently. Technically, there doesn't have to be any lower \ limit for the minimum of time you can set; two scans of the same sort would never run at the same time but the \ next one is always scheduled not earlier than the end of the previous one. These are ever present values, no matter \ if the user's set any value, they have defaults. The parameters must be always supplied all together, delimited by \ vertical bars and in the right order.\n\n\ - 1. Pending Payable Scan Interval: Amount of seconds between two sequential cycles of scanning for payments that are \ - marked as currently pending; the payments were sent to pay our debts, the payable. The purpose of this process is to \ - confirm the status of the pending payment; either the payment transaction was written on blockchain as successful or \ - failed.\n\n\ - 2. Payable Scan Interval: Amount of seconds between two sequential cycles of scanning aimed to find payable accounts \ - of that meet the criteria set by the Payment Thresholds; these accounts are tracked on behalf of our creditors. If \ + 1. Payable Scan Interval: Amount of seconds between two sequential cycles of scanning aimed to find payable accounts \ + that meet the criteria set by the Payment Thresholds; these accounts are tracked on behalf of our creditors. If \ they meet the Payment Threshold criteria, our Node will send a debt payment transaction to the creditor in question.\n\n\ + 2. Pending Payable Scan Interval: The time elapsed since the last payable transaction was processed. This scan operates \ + on an irregular schedule and is triggered after new transactions are sent or when failed transactions need \ + to be replaced. The scanner monitors pending transactions and verifies their blockchain status, determining whether \ + each payment was successfully recorded or failed. Any failed transaction is automatically resubmitted as soon \ + as the failure is detected.\n\n\ 3. Receivable Scan Interval: Amount of seconds between two sequential cycles of scanning for payments on the \ blockchain that have been sent by our creditors to us, which are credited against receivables recorded for services \ provided." diff --git a/masq_lib/src/simple_clock.rs b/masq_lib/src/simple_clock.rs new file mode 100644 index 0000000000..35bc34e97e --- /dev/null +++ b/masq_lib/src/simple_clock.rs @@ -0,0 +1,16 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. + +use std::time::SystemTime; + +pub trait SimpleClock { + fn now(&self) -> SystemTime; +} + +#[derive(Default)] +pub struct SimpleClockReal {} + +impl SimpleClock for SimpleClockReal { + fn now(&self) -> SystemTime { + SystemTime::now() + } +} diff --git a/masq_lib/src/test_utils/mock_blockchain_client_server.rs b/masq_lib/src/test_utils/mock_blockchain_client_server.rs index 424a4433de..80df649bed 100644 --- a/masq_lib/src/test_utils/mock_blockchain_client_server.rs +++ b/masq_lib/src/test_utils/mock_blockchain_client_server.rs @@ -220,7 +220,7 @@ impl MockBlockchainClientServer { Err(e) if e.kind() == ErrorKind::TimedOut => (), Err(e) => panic!("MBCS accept() failed: {:?}", e), }; - thread::sleep(Duration::from_millis(100)); + thread::sleep(Duration::from_millis(50)); }; drop(listener); conn.set_nonblocking(true).unwrap(); diff --git a/masq_lib/src/test_utils/mod.rs b/masq_lib/src/test_utils/mod.rs index 2dd76c9621..293b48db05 100644 --- a/masq_lib/src/test_utils/mod.rs +++ b/masq_lib/src/test_utils/mod.rs @@ -5,5 +5,6 @@ pub mod fake_stream_holder; pub mod logging; pub mod mock_blockchain_client_server; pub mod mock_websockets_server; +pub mod simple_clock; pub mod ui_connection; pub mod utils; diff --git a/masq_lib/src/test_utils/simple_clock.rs b/masq_lib/src/test_utils/simple_clock.rs new file mode 100644 index 0000000000..d4fa5f29e0 --- /dev/null +++ b/masq_lib/src/test_utils/simple_clock.rs @@ -0,0 +1,23 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. + +use crate::simple_clock::SimpleClock; +use std::cell::RefCell; +use std::time::SystemTime; + +#[derive(Default)] +pub struct SimpleClockMock { + now_results: RefCell>, +} + +impl SimpleClock for SimpleClockMock { + fn now(&self) -> SystemTime { + self.now_results.borrow_mut().remove(0) + } +} + +impl SimpleClockMock { + pub fn now_result(self, result: SystemTime) -> Self { + self.now_results.borrow_mut().push(result); + self + } +} diff --git a/masq_lib/src/type_obfuscation.rs b/masq_lib/src/type_obfuscation.rs deleted file mode 100644 index 1f3c79258c..0000000000 --- a/masq_lib/src/type_obfuscation.rs +++ /dev/null @@ -1,83 +0,0 @@ -// Copyright (c) 2019, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. - -use std::any::TypeId; -use std::mem::transmute; - -#[derive(Debug, PartialEq, Eq, Clone)] -pub struct Obfuscated { - type_id: TypeId, - bytes: Vec, -} - -impl Obfuscated { - // Although we're asking the compiler for a cast between two types - // where one is generic and both could possibly be of a different - // size, which almost applies to an unsupported kind of operation, - // the compiler stays calm here. The use of vectors at the input as - // well as output lets us avoid the above depicted situation. - // - // If you wish to write an implementation allowing more arbitrary - // types on your own, instead of helping yourself by a library like - // 'bytemuck', consider these functions from the std library, - // 'mem::transmute_copy' or 'mem::forget()', which will renew - // the compiler's trust for you. However, the true adventure will - // begin when you are supposed to write code to realign the plain - // bytes backwards to your desired type... - - pub fn obfuscate_vector(data: Vec) -> Obfuscated { - let bytes = unsafe { transmute::, Vec>(data) }; - - Obfuscated { - type_id: TypeId::of::(), - bytes, - } - } - - pub fn expose_vector(self) -> Vec { - if self.type_id != TypeId::of::() { - panic!("Forbidden! You're trying to interpret obfuscated data as the wrong type.") - } - - unsafe { transmute::, Vec>(self.bytes) } - } - - // Proper casting from a non vec structure into a vector of bytes - // is difficult and ideally requires an involvement of a library - // like bytemuck. - // If you think we do need such cast, place other methods in here - // and don't remove the ones above because: - // a) bytemuck will force you to implement its 'Pod' trait which - // might imply an (at minimum) ugly implementation for a std - // type like a Vec because both the trait and the type have - // their definitions situated externally to our project, - // therefore you might need to solve it by introducing - // a super-trait from our code - // b) using our simple 'obfuscate_vector' function will always - // be fairly more efficient than if done with help of - // the other library -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn obfuscation_works() { - let data = vec!["I'm fearing of losing my entire identity".to_string()]; - - let obfuscated_data = Obfuscated::obfuscate_vector(data.clone()); - let fenix_like_data: Vec = obfuscated_data.expose_vector(); - - assert_eq!(data, fenix_like_data) - } - - #[test] - #[should_panic( - expected = "Forbidden! You're trying to interpret obfuscated data as the wrong type." - )] - fn obfuscation_attempt_to_reinterpret_to_wrong_type() { - let data = vec![0_u64]; - let obfuscated_data = Obfuscated::obfuscate_vector(data.clone()); - let _: Vec = obfuscated_data.expose_vector(); - } -} diff --git a/masq_lib/src/utils.rs b/masq_lib/src/utils.rs index 8d563ef374..ad4197aad4 100644 --- a/masq_lib/src/utils.rs +++ b/masq_lib/src/utils.rs @@ -463,6 +463,25 @@ macro_rules! test_only_use { } } +#[macro_export(local_inner_macros)] +macro_rules! btreemap { + () => { + ::std::collections::BTreeMap::new() + }; + ($($key:expr => $val:expr,)+) => { + btreemap!($($key => $val),+) + }; + ($($key:expr => $value:expr),+) => { + { + let mut _btm = ::std::collections::BTreeMap::new(); + $( + let _ = _btm.insert($key, $value); + )* + _btm + } + }; +} + #[macro_export(local_inner_macros)] macro_rules! hashmap { () => { @@ -482,10 +501,50 @@ macro_rules! hashmap { }; } +#[macro_export(local_inner_macros)] +macro_rules! hashset { + () => { + ::std::collections::HashSet::new() + }; + ($($val:expr,)+) => { + hashset!($($val),+) + }; + ($($value:expr),+) => { + { + let mut _hs = ::std::collections::HashSet::new(); + $( + let _ = _hs.insert($value); + )* + _hs + } + }; +} + +#[macro_export(local_inner_macros)] +macro_rules! btreeset { + () => { + ::std::collections::BTreeSet::new() + }; + ($($val:expr,)+) => { + btreeset!($($val),+) + }; + ($($value:expr),+) => { + { + let mut _bts = ::std::collections::BTreeSet::new(); + $( + let _ = _bts.insert($value); + )* + _bts + } + }; +} + #[cfg(test)] mod tests { use super::*; - use std::collections::HashMap; + use itertools::Itertools; + use std::collections::BTreeSet; + use std::collections::{BTreeMap, HashMap, HashSet}; use std::env::current_dir; use std::fmt::Write; use std::fs::{create_dir_all, File, OpenOptions}; @@ -814,7 +873,8 @@ mod tests { let hashmap_with_one_element = hashmap!(1 => 2); let hashmap_with_multiple_elements = hashmap!(1 => 2, 10 => 20, 12 => 42); let hashmap_with_trailing_comma = hashmap!(1 => 2, 10 => 20,); - let hashmap_of_string = hashmap!("key" => "val"); + let hashmap_of_string = hashmap!("key_1" => "val_a", "key_2" => "val_b"); + let hashmap_with_duplicate = hashmap!(1 => 2, 1 => 2); let expected_empty_hashmap: HashMap = HashMap::new(); let mut expected_hashmap_with_one_element = HashMap::new(); @@ -827,7 +887,10 @@ mod tests { expected_hashmap_with_trailing_comma.insert(1, 2); expected_hashmap_with_trailing_comma.insert(10, 20); let mut expected_hashmap_of_string = HashMap::new(); - expected_hashmap_of_string.insert("key", "val"); + expected_hashmap_of_string.insert("key_1", "val_a"); + expected_hashmap_of_string.insert("key_2", "val_b"); + let mut expected_hashmap_with_duplicate = HashMap::new(); + expected_hashmap_with_duplicate.insert(1, 2); assert_eq!(empty_hashmap, expected_empty_hashmap); assert_eq!(hashmap_with_one_element, expected_hashmap_with_one_element); assert_eq!( @@ -839,5 +902,119 @@ mod tests { expected_hashmap_with_trailing_comma ); assert_eq!(hashmap_of_string, expected_hashmap_of_string); + assert_eq!(hashmap_with_duplicate, expected_hashmap_with_duplicate); + } + + #[test] + fn btreemap_macro_works() { + let empty_btm: BTreeMap = btreemap!(); + let btm_with_one_element = btreemap!("ABC" => "234"); + let btm_with_multiple_elements = btreemap!("Bobble" => 2, "Hurrah" => 20, "Boom" => 42); + let btm_with_trailing_comma = btreemap!(12 => 1, 22 =>2,); + let btm_with_duplicate = btreemap!("A"=>123, "A"=>222); + + let expected_empty_btm: BTreeMap = BTreeMap::new(); + let mut expected_btm_with_one_element = BTreeMap::new(); + expected_btm_with_one_element.insert("ABC", "234"); + let mut expected_btm_with_multiple_elements = BTreeMap::new(); + expected_btm_with_multiple_elements.insert("Bobble", 2); + expected_btm_with_multiple_elements.insert("Hurrah", 20); + expected_btm_with_multiple_elements.insert("Boom", 42); + let mut expected_btm_with_trailing_comma = BTreeMap::new(); + expected_btm_with_trailing_comma.insert(12, 1); + expected_btm_with_trailing_comma.insert(22, 2); + let mut expected_btm_with_duplicate = BTreeMap::new(); + expected_btm_with_duplicate.insert("A", 222); + assert_eq!(empty_btm, expected_empty_btm); + assert_eq!(btm_with_one_element, expected_btm_with_one_element); + assert_eq!( + btm_with_multiple_elements, + expected_btm_with_multiple_elements + ); + assert_eq!( + btm_with_multiple_elements.into_iter().collect_vec(), + vec![("Bobble", 2), ("Boom", 42), ("Hurrah", 20)] + ); + assert_eq!(btm_with_trailing_comma, expected_btm_with_trailing_comma); + assert_eq!(btm_with_duplicate, expected_btm_with_duplicate); + } + + #[test] + fn hashset_macro_works() { + let empty_hashset: HashSet = hashset!(); + let hashset_with_one_element = hashset!(2); + let hashset_with_multiple_elements = hashset!(2, 20, 42); + let hashset_with_trailing_comma = hashset!(2, 20,); + let hashset_of_string = hashset!("val_a", "val_b"); + let hashset_with_duplicate = hashset!(2, 2); + + let expected_empty_hashset: HashSet = HashSet::new(); + let mut expected_hashset_with_one_element = HashSet::new(); + expected_hashset_with_one_element.insert(2); + let mut expected_hashset_with_multiple_elements = HashSet::new(); + expected_hashset_with_multiple_elements.insert(2); + expected_hashset_with_multiple_elements.insert(20); + expected_hashset_with_multiple_elements.insert(42); + let mut expected_hashset_with_trailing_comma = HashSet::new(); + expected_hashset_with_trailing_comma.insert(2); + expected_hashset_with_trailing_comma.insert(20); + let mut expected_hashset_of_string = HashSet::new(); + expected_hashset_of_string.insert("val_a"); + expected_hashset_of_string.insert("val_b"); + let mut expected_hashset_with_duplicate = HashSet::new(); + expected_hashset_with_duplicate.insert(2); + assert_eq!(empty_hashset, expected_empty_hashset); + assert_eq!(hashset_with_one_element, expected_hashset_with_one_element); + assert_eq!( + hashset_with_multiple_elements, + expected_hashset_with_multiple_elements + ); + assert_eq!( + hashset_with_trailing_comma, + expected_hashset_with_trailing_comma + ); + assert_eq!(hashset_of_string, expected_hashset_of_string); + assert_eq!(hashset_with_duplicate, expected_hashset_with_duplicate); + } + + #[test] + fn btreeset_macro_works() { + let empty_btreeset: BTreeSet = btreeset!(); + let btreeset_with_one_element = btreeset!(2); + let btreeset_with_multiple_elements = btreeset!(2, 20, 42); + let btreeset_with_trailing_comma = btreeset!(2, 20,); + let btreeset_of_string = btreeset!("val_a", "val_b"); + let btreeset_with_duplicate = btreeset!(2, 2); + + let expected_empty_btreeset: BTreeSet = BTreeSet::new(); + let mut expected_btreeset_with_one_element = BTreeSet::new(); + expected_btreeset_with_one_element.insert(2); + let mut expected_btreeset_with_multiple_elements = BTreeSet::new(); + expected_btreeset_with_multiple_elements.insert(2); + expected_btreeset_with_multiple_elements.insert(20); + expected_btreeset_with_multiple_elements.insert(42); + let mut expected_btreeset_with_trailing_comma = BTreeSet::new(); + expected_btreeset_with_trailing_comma.insert(2); + expected_btreeset_with_trailing_comma.insert(20); + let mut expected_btreeset_of_string = BTreeSet::new(); + expected_btreeset_of_string.insert("val_a"); + expected_btreeset_of_string.insert("val_b"); + let mut expected_btreeset_with_duplicate = BTreeSet::new(); + expected_btreeset_with_duplicate.insert(2); + assert_eq!(empty_btreeset, expected_empty_btreeset); + assert_eq!( + btreeset_with_one_element, + expected_btreeset_with_one_element + ); + assert_eq!( + btreeset_with_multiple_elements, + expected_btreeset_with_multiple_elements + ); + assert_eq!( + btreeset_with_trailing_comma, + expected_btreeset_with_trailing_comma + ); + assert_eq!(btreeset_of_string, expected_btreeset_of_string); + assert_eq!(btreeset_with_duplicate, expected_btreeset_with_duplicate); } } diff --git a/multinode_integration_tests/tests/bookkeeping_test.rs b/multinode_integration_tests/tests/bookkeeping_test.rs index 161487b1fe..de716a3e34 100644 --- a/multinode_integration_tests/tests/bookkeeping_test.rs +++ b/multinode_integration_tests/tests/bookkeeping_test.rs @@ -41,7 +41,7 @@ fn provided_and_consumed_services_are_recorded_in_databases() { ); // get all payables from originating node - let payables = non_pending_payables(&originating_node); + let payables = retrieve_payables(&originating_node); // Waiting until the serving nodes have finished accruing their receivables thread::sleep(Duration::from_secs(10)); @@ -80,9 +80,9 @@ fn provided_and_consumed_services_are_recorded_in_databases() { }); } -fn non_pending_payables(node: &MASQRealNode) -> Vec { +fn retrieve_payables(node: &MASQRealNode) -> Vec { let payable_dao = payable_dao(node.name()); - payable_dao.non_pending_payables() + payable_dao.retrieve_payables(None) } fn receivables(node: &MASQRealNode) -> Vec { diff --git a/multinode_integration_tests/tests/verify_bill_payment.rs b/multinode_integration_tests/tests/verify_bill_payment.rs index 5d682fea4d..e5fddc67fe 100644 --- a/multinode_integration_tests/tests/verify_bill_payment.rs +++ b/multinode_integration_tests/tests/verify_bill_payment.rs @@ -17,6 +17,9 @@ use multinode_integration_tests_lib::utils::{ }; use node_lib::accountant::db_access_objects::payable_dao::{PayableDao, PayableDaoReal}; use node_lib::accountant::db_access_objects::receivable_dao::{ReceivableDao, ReceivableDaoReal}; +use node_lib::accountant::db_access_objects::sent_payable_dao::{ + RetrieveCondition, SentPayableDao, SentPayableDaoReal, +}; use node_lib::blockchain::bip32::Bip32EncryptionKeyProvider; use node_lib::blockchain::blockchain_interface::blockchain_interface_web3::{ BlockchainInterfaceWeb3, REQUESTS_IN_PARALLEL, @@ -225,7 +228,7 @@ fn verify_bill_payment() { } let now = Instant::now(); - while !consuming_payable_dao.non_pending_payables().is_empty() + while !consuming_payable_dao.retrieve_payables(None).is_empty() && now.elapsed() < Duration::from_secs(10) { thread::sleep(Duration::from_millis(400)); @@ -234,7 +237,7 @@ fn verify_bill_payment() { assert_balances( &contract_owner_wallet, &blockchain_interface, - "99995231980000000000", + "99994287232000000000", "471999999700000000000000000", ); @@ -355,17 +358,23 @@ fn verify_pending_payables() { "{}", node_wallet.clone() ))) + // Important + .scans(false) .ui_port(ui_port) .build(); let (consuming_node_name, consuming_node_index) = cluster.prepare_real_node(&consuming_config); let consuming_node_path = node_chain_specific_data_directory(&consuming_node_name); - let consuming_node_connection = DbInitializerReal::default() - .initialize( - Path::new(&consuming_node_path), - make_init_config(cluster.chain), - ) - .unwrap(); - let consuming_payable_dao = PayableDaoReal::new(consuming_node_connection); + let connect_to_consuming_node_db = || { + DbInitializerReal::default() + .initialize( + Path::new(&consuming_node_path), + make_init_config(cluster.chain), + ) + .unwrap() + }; + let consuming_payable_dao = PayableDaoReal::new(connect_to_consuming_node_db()); + let consuming_sent_payable_dao = SentPayableDaoReal::new(connect_to_consuming_node_db()); + open_all_file_permissions(consuming_node_path.clone().into()); assert_eq!( format!("{}", &contract_owner_wallet), @@ -400,7 +409,9 @@ fn verify_pending_payables() { ); let now = Instant::now(); - while !consuming_payable_dao.non_pending_payables().is_empty() + while consuming_sent_payable_dao + .retrieve_txs(Some(RetrieveCondition::IsPending)) + .is_empty() && now.elapsed() < Duration::from_secs(10) { thread::sleep(Duration::from_millis(400)); @@ -409,7 +420,7 @@ fn verify_pending_payables() { assert_balances( &contract_owner_wallet, &blockchain_interface, - "99995231980000000000", + "99994287232000000000", "471999999700000000000000000", ); assert_balances( @@ -437,10 +448,24 @@ fn verify_pending_payables() { .tmb(0), ); - assert!(consuming_payable_dao.non_pending_payables().is_empty()); + let now = Instant::now(); + loop { + if !consuming_sent_payable_dao + .retrieve_txs(Some(RetrieveCondition::IsPending)) + .is_empty() + { + if now.elapsed() < Duration::from_secs(5) { + thread::sleep(Duration::from_millis(400)) + } else { + panic!("Pending payables still aren't resolved even after 5 seconds") + } + } else { + break; + } + } MASQNodeUtils::assert_node_wrote_log_containing( real_consuming_node.name(), - "Found 3 pending payables to process", + "Found 3 pending payables and 0 suspected failures to process", Duration::from_secs(5), ); MASQNodeUtils::assert_node_wrote_log_containing( @@ -450,17 +475,17 @@ fn verify_pending_payables() { ); MASQNodeUtils::assert_node_wrote_log_containing( real_consuming_node.name(), - "Transaction 0x75a8f185b7fb3ac0c4d1ee6b402a46940c9ae0477c0c7378a1308fb4bf539c5c has been added to the blockchain;", + "Tx 0x89acc46da0df6ef8c6f5574307ae237a812bd28af524a62131013b5e19ca3e26 confirmed", Duration::from_secs(5), ); MASQNodeUtils::assert_node_wrote_log_containing( real_consuming_node.name(), - "Transaction 0x384a3bb5bbd9718a97322be2878fa88c7cacacb2ac3416f521a621ca1946ddfc has been added to the blockchain;", + "Tx 0xae0bf6400f0b9950a1d456e488549414d118714b81a39233b811b629cf41399b confirmed", Duration::from_secs(5), ); MASQNodeUtils::assert_node_wrote_log_containing( real_consuming_node.name(), - "Transaction 0x6bc98d5db61ddd7676de1f25cb537156b3d9e066cec414fef8dbe9c695908215 has been added to the blockchain;", + "Tx 0xecab1c73ca90ebcb073526e28f1f8d4678704b74d1e0209779ddeefc8fb861f5 confirmed", Duration::from_secs(5), ); } diff --git a/node/Cargo.toml b/node/Cargo.toml index 9933071d09..80d75b5efa 100644 --- a/node/Cargo.toml +++ b/node/Cargo.toml @@ -15,7 +15,7 @@ automap = { path = "../automap"} backtrace = "0.3.57" base64 = "0.13.0" bytes = "0.4.12" -time = {version = "0.3.11", features = [ "macros" ]} +time = {version = "0.3.11", features = [ "macros", "parsing" ]} clap = "2.33.3" crossbeam-channel = "0.5.1" dirs = "4.0.0" 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 0000000000..2b83fdfab3 --- /dev/null +++ b/node/src/accountant/db_access_objects/failed_payable_dao.rs @@ -0,0 +1,1183 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. +use crate::accountant::db_access_objects::sent_payable_dao::SentTx; +use crate::accountant::db_access_objects::utils::{ + sql_values_of_failed_tx, DaoFactoryReal, TxHash, TxIdentifiers, VigilantRusqliteFlatten, +}; +use crate::accountant::db_access_objects::Transaction; +use crate::accountant::db_big_integer::big_int_divider::BigIntDivider; +use crate::accountant::{join_with_commas, join_with_separator}; +use crate::blockchain::errors::rpc_errors::{AppRpcError, AppRpcErrorKind}; +use crate::blockchain::errors::validation_status::ValidationStatus; +use crate::database::rusqlite_wrappers::ConnectionWrapper; +use masq_lib::utils::ExpectValue; +use serde_derive::{Deserialize, Serialize}; +use std::collections::{BTreeSet, HashMap}; +use std::fmt::{Display, Formatter}; +use std::str::FromStr; +use web3::types::Address; +use web3::Error as Web3Error; + +#[derive(Debug, PartialEq, Eq)] +pub enum FailedPayableDaoError { + EmptyInput, + NoChange, + InvalidInput(String), + PartialExecution(String), + SqlExecutionFailed(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum FailureReason { + Submission(AppRpcErrorKind), + Reverted, + PendingTooLong, +} + +impl Display for FailureReason { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match serde_json::to_string(self) { + Ok(json) => write!(f, "{}", json), + // Untestable + Err(_) => write!(f, ""), + } + } +} + +impl FromStr for FailureReason { + type Err = String; + + fn from_str(s: &str) -> Result { + serde_json::from_str(s).map_err(|e| format!("{} in '{}'", e, s)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)] +pub enum FailureStatus { + RetryRequired, + RecheckRequired(ValidationStatus), + Concluded, +} + +impl Display for FailureStatus { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match serde_json::to_string(self) { + Ok(json) => write!(f, "{}", json), + // Untestable + Err(_) => write!(f, ""), + } + } +} + +impl FromStr for FailureStatus { + type Err = String; + fn from_str(s: &str) -> Result { + serde_json::from_str(s).map_err(|e| format!("{} in '{}'", e, s)) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct FailedTx { + pub hash: TxHash, + pub receiver_address: Address, + pub amount_minor: u128, + pub timestamp: i64, + pub gas_price_minor: u128, + pub nonce: u64, + pub reason: FailureReason, + pub status: FailureStatus, +} + +impl Transaction for FailedTx { + fn hash(&self) -> TxHash { + self.hash + } + + fn receiver_address(&self) -> Address { + self.receiver_address + } + + fn amount(&self) -> u128 { + self.amount_minor + } + + fn timestamp(&self) -> i64 { + self.timestamp + } + + fn gas_price_wei(&self) -> u128 { + self.gas_price_minor + } + + fn nonce(&self) -> u64 { + self.nonce + } + + fn is_failed(&self) -> bool { + true + } +} + +impl From<(&SentTx, &Web3Error)> for FailedTx { + fn from((sent_tx, error): (&SentTx, &Web3Error)) -> Self { + let app_rpc_error = AppRpcError::from(error.clone()); + let error_kind = AppRpcErrorKind::from(&app_rpc_error); + Self { + hash: sent_tx.hash, + receiver_address: sent_tx.receiver_address, + amount_minor: sent_tx.amount_minor, + timestamp: sent_tx.timestamp, + gas_price_minor: sent_tx.gas_price_minor, + nonce: sent_tx.nonce, + reason: FailureReason::Submission(error_kind), + status: FailureStatus::RetryRequired, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FailureRetrieveCondition { + ByTxHash(Vec), + ByStatus(FailureStatus), + ByReceiverAddresses(BTreeSet
), + EveryRecheckRequiredRecord, +} + +impl Display for FailureRetrieveCondition { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + FailureRetrieveCondition::ByTxHash(hashes) => { + write!( + f, + "WHERE tx_hash IN ({})", + join_with_commas(hashes, |hash| format!("'{:?}'", hash)) + ) + } + FailureRetrieveCondition::ByStatus(status) => { + write!(f, "WHERE status = '{}'", status) + } + FailureRetrieveCondition::ByReceiverAddresses(addresses) => { + write!( + f, + "WHERE receiver_address IN ({})", + join_with_commas(addresses, |address| format!("'{:?}'", address)) + ) + } + FailureRetrieveCondition::EveryRecheckRequiredRecord => { + write!(f, "WHERE status LIKE 'RecheckRequired%'") + } + } + } +} + +pub trait FailedPayableDao { + fn get_tx_identifiers(&self, hashes: &BTreeSet) -> TxIdentifiers; + //TODO potentially atomically + fn insert_new_records(&self, txs: &BTreeSet) -> Result<(), FailedPayableDaoError>; + fn retrieve_txs(&self, condition: Option) -> BTreeSet; + fn update_statuses( + &self, + status_updates: &HashMap, + ) -> Result<(), FailedPayableDaoError>; + //TODO potentially atomically + fn delete_records(&self, hashes: &BTreeSet) -> 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: &BTreeSet) -> TxIdentifiers { + let sql = format!( + "SELECT tx_hash, rowid FROM failed_payable WHERE tx_hash IN ({})", + join_with_commas(hashes, |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: &BTreeSet) -> Result<(), FailedPayableDaoError> { + if txs.is_empty() { + return Err(FailedPayableDaoError::EmptyInput); + } + + let unique_hashes: BTreeSet = 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, + ))); + } + + 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, \ + status + ) VALUES {}", + join_with_commas(txs, |tx| sql_values_of_failed_tx(tx)) + ); + + 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) -> BTreeSet { + 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, \ + status \ + 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_minor = 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_minor = + 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 status_str: String = row.get(9).expectv("status"); + let status = + FailureStatus::from_str(&status_str).expect("Failed to parse FailureStatus"); + + Ok(FailedTx { + hash, + receiver_address, + amount_minor, + timestamp, + gas_price_minor, + nonce, + reason, + status, + }) + }) + .expect("Failed to execute query") + .vigilant_flatten() + .collect() + } + + fn update_statuses( + &self, + status_updates: &HashMap, + ) -> Result<(), FailedPayableDaoError> { + if status_updates.is_empty() { + return Err(FailedPayableDaoError::EmptyInput); + } + + let case_statements = join_with_separator( + status_updates, + |(hash, status)| format!("WHEN tx_hash = '{:?}' THEN '{}'", hash, status), + " ", + ); + let tx_hashes = join_with_commas(status_updates.keys(), |hash| format!("'{:?}'", hash)); + + let sql = format!( + "UPDATE failed_payable \ + SET \ + status = CASE \ + {case_statements} \ + END \ + WHERE tx_hash IN ({tx_hashes})" + ); + + match self.conn.prepare(&sql).expect("Internal error").execute([]) { + Ok(rows_changed) => { + if rows_changed == status_updates.len() { + Ok(()) + } else { + Err(FailedPayableDaoError::PartialExecution(format!( + "Only {} of {} records had their status updated.", + rows_changed, + status_updates.len(), + ))) + } + } + Err(e) => Err(FailedPayableDaoError::SqlExecutionFailed(e.to_string())), + } + } + + fn delete_records(&self, hashes: &BTreeSet) -> Result<(), FailedPayableDaoError> { + if hashes.is_empty() { + return Err(FailedPayableDaoError::EmptyInput); + } + + let sql = format!( + "DELETE FROM failed_payable WHERE tx_hash IN ({})", + join_with_commas(hashes, |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())), + } + } +} + +pub trait FailedPayableDaoFactory { + fn make(&self) -> Box; +} + +impl FailedPayableDaoFactory for DaoFactoryReal { + fn make(&self) -> Box { + Box::new(FailedPayableDaoReal::new(self.make_connection())) + } +} + +#[cfg(test)] +mod tests { + use crate::accountant::db_access_objects::failed_payable_dao::FailureReason::{ + PendingTooLong, Reverted, + }; + use crate::accountant::db_access_objects::failed_payable_dao::FailureStatus::{ + Concluded, RecheckRequired, RetryRequired, + }; + use crate::accountant::db_access_objects::failed_payable_dao::{ + FailedPayableDao, FailedPayableDaoError, FailedPayableDaoReal, FailedTx, FailureReason, + FailureRetrieveCondition, FailureStatus, + }; + 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::accountant::db_access_objects::Transaction; + use crate::blockchain::errors::rpc_errors::{AppRpcErrorKind, LocalErrorKind, RemoteErrorKind}; + use crate::blockchain::errors::validation_status::{PreviousAttempts, ValidationStatus}; + use crate::blockchain::errors::BlockchainErrorKind; + use crate::blockchain::test_utils::{make_address, make_tx_hash}; + use crate::database::db_initializer::{ + DbInitializationConfig, DbInitializer, DbInitializerReal, + }; + use crate::database::test_utils::ConnectionWrapperMock; + use masq_lib::simple_clock::SimpleClockReal; + use masq_lib::test_utils::simple_clock::SimpleClockMock; + use masq_lib::test_utils::utils::ensure_node_home_directory_exists; + use rusqlite::Connection; + use std::collections::{BTreeSet, HashMap}; + use std::ops::Add; + use std::str::FromStr; + use std::time::{Duration, SystemTime}; + + #[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(Reverted) + .nonce(1) + .build(); + let tx2 = FailedTxBuilder::default() + .hash(make_tx_hash(2)) + .nonce(2) + .reason(PendingTooLong) + .build(); + let subject = FailedPayableDaoReal::new(wrapped_conn); + let hashset = BTreeSet::from([tx1.clone(), tx2.clone()]); + + let result = subject.insert_new_records(&hashset); + + let retrieved_txs = subject.retrieve_txs(None); + assert_eq!(result, Ok(())); + assert_eq!(retrieved_txs, BTreeSet::from([tx2, tx1])); + } + + #[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 = BTreeSet::new(); + + 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) + .status(RetryRequired) + .nonce(1) + .build(); + let tx2 = FailedTxBuilder::default() + .hash(hash) + .status(RecheckRequired(ValidationStatus::Waiting)) + .nonce(2) + .build(); + let subject = FailedPayableDaoReal::new(wrapped_conn); + + let result = subject.insert_new_records(&BTreeSet::from([tx1, tx2])); + + assert_eq!( + result, + Err(FailedPayableDaoError::InvalidInput( + "Duplicate hashes found in the input. Input Transactions: \ + {FailedTx { \ + hash: 0x000000000000000000000000000000000000000000000000000000000000007b, \ + receiver_address: 0x0000000000000000000000000000000000000000, \ + amount_minor: 0, timestamp: 1719990000, gas_price_minor: 0, \ + nonce: 1, reason: PendingTooLong, status: RetryRequired }, \ + FailedTx { \ + hash: 0x000000000000000000000000000000000000000000000000000000000000007b, \ + receiver_address: 0x0000000000000000000000000000000000000000, \ + amount_minor: 0, timestamp: 1719990000, gas_price_minor: 0, \ + nonce: 2, reason: PendingTooLong, status: RecheckRequired(Waiting) }}" + .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) + .status(RetryRequired) + .build(); + let tx2 = FailedTxBuilder::default() + .hash(hash) + .status(RecheckRequired(ValidationStatus::Waiting)) + .build(); + let subject = FailedPayableDaoReal::new(wrapped_conn); + let initial_insertion_result = subject.insert_new_records(&BTreeSet::from([tx1])); + + let result = subject.insert_new_records(&BTreeSet::from([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_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(&BTreeSet::from([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(&BTreeSet::from([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 = BTreeSet::from([present_hash, absent_hash, another_present_hash]); + let present_tx = FailedTxBuilder::default() + .hash(present_hash) + .nonce(1) + .build(); + let another_present_tx = FailedTxBuilder::default() + .hash(another_present_hash) + .nonce(2) + .build(); + subject + .insert_new_records(&BTreeSet::from([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 display_for_failure_retrieve_condition_works() { + let tx_hash_1 = make_tx_hash(123); + let tx_hash_2 = make_tx_hash(456); + assert_eq!(FailureRetrieveCondition::ByTxHash(vec![tx_hash_1, tx_hash_2]).to_string(), + "WHERE tx_hash IN ('0x000000000000000000000000000000000000000000000000000000000000007b', \ + '0x00000000000000000000000000000000000000000000000000000000000001c8')" + ); + assert_eq!( + FailureRetrieveCondition::ByStatus(RetryRequired).to_string(), + "WHERE status = '\"RetryRequired\"'" + ); + assert_eq!( + FailureRetrieveCondition::ByStatus(RecheckRequired(ValidationStatus::Waiting)) + .to_string(), + "WHERE status = '{\"RecheckRequired\":\"Waiting\"}'" + ); + assert_eq!( + FailureRetrieveCondition::EveryRecheckRequiredRecord.to_string(), + "WHERE status LIKE 'RecheckRequired%'" + ); + } + + #[test] + fn failure_reason_from_str_works() { + // Submission error + assert_eq!( + FailureReason::from_str(r#"{"Submission":{"Local":"Decoder"}}"#).unwrap(), + FailureReason::Submission(AppRpcErrorKind::Local(LocalErrorKind::Decoder)) + ); + + // Reverted + assert_eq!( + FailureReason::from_str("\"Reverted\"").unwrap(), + FailureReason::Reverted + ); + + // PendingTooLong + assert_eq!( + FailureReason::from_str("\"PendingTooLong\"").unwrap(), + FailureReason::PendingTooLong + ); + + // Invalid Variant + assert_eq!( + FailureReason::from_str("\"UnknownReason\"").unwrap_err(), + "unknown variant `UnknownReason`, \ + expected one of `Submission`, `Reverted`, `PendingTooLong` \ + at line 1 column 15 in '\"UnknownReason\"'" + ); + + // Invalid Input + assert_eq!( + FailureReason::from_str("not a failure reason").unwrap_err(), + "expected value at line 1 column 1 in 'not a failure reason'" + ); + } + + #[test] + fn failure_status_from_str_works() { + let validation_failure_clock = SimpleClockMock::default().now_result( + SystemTime::UNIX_EPOCH + .add(Duration::from_secs(1755080031)) + .add(Duration::from_nanos(612180914)), + ); + assert_eq!( + FailureStatus::from_str("\"RetryRequired\"").unwrap(), + FailureStatus::RetryRequired + ); + + assert_eq!( + FailureStatus::from_str(r#"{"RecheckRequired":"Waiting"}"#).unwrap(), + FailureStatus::RecheckRequired(ValidationStatus::Waiting) + ); + + assert_eq!( + FailureStatus::from_str(r#"{"RecheckRequired":{"Reattempting":[{"error":{"AppRpc":{"Remote":"Unreachable"}},"firstSeen":{"secs_since_epoch":1755080031,"nanos_since_epoch":612180914},"attempts":1}]}}"#).unwrap(), + FailureStatus::RecheckRequired(ValidationStatus::Reattempting( PreviousAttempts::new(BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote(RemoteErrorKind::Unreachable)), &validation_failure_clock))) + ); + + assert_eq!( + FailureStatus::from_str("\"Concluded\"").unwrap(), + FailureStatus::Concluded + ); + + // Invalid Variant + assert_eq!( + FailureStatus::from_str("\"UnknownStatus\"").unwrap_err(), + "unknown variant `UnknownStatus`, expected one of `RetryRequired`, `RecheckRequired`, \ + `Concluded` at line 1 column 15 in '\"UnknownStatus\"'" + ); + + // Invalid Input + assert_eq!( + FailureStatus::from_str("not a failure status").unwrap_err(), + "expected value at line 1 column 1 in 'not a failure status'" + ); + } + + #[test] + fn retrieve_condition_display_works() { + assert_eq!( + FailureRetrieveCondition::ByStatus(RetryRequired).to_string(), + "WHERE status = '\"RetryRequired\"'" + ); + assert_eq!( + FailureRetrieveCondition::ByReceiverAddresses(BTreeSet::from([make_address(1), make_address(2)])) + .to_string(), + "WHERE receiver_address IN ('0x0000000000000000000003000000000003000000', '0x0000000000000000000006000000000006000000')" + ) + } + + #[test] + fn can_retrieve_all_txs_ordered_by_timestamp_and_nonce() { + let home_dir = ensure_node_home_directory_exists( + "failed_payable_dao", + "can_retrieve_all_txs_ordered_by_timestamp_and_nonce", + ); + 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)) + .timestamp(1000) + .nonce(1) + .build(); + let tx2 = FailedTxBuilder::default() + .hash(make_tx_hash(2)) + .timestamp(1000) + .nonce(2) + .build(); + let tx3 = FailedTxBuilder::default() + .hash(make_tx_hash(3)) + .timestamp(1001) + .nonce(1) + .build(); + let tx4 = FailedTxBuilder::default() + .hash(make_tx_hash(4)) + .timestamp(1001) + .nonce(2) + .build(); + + subject + .insert_new_records(&BTreeSet::from([tx2.clone(), tx4.clone()])) + .unwrap(); + subject + .insert_new_records(&BTreeSet::from([tx1.clone(), tx3.clone()])) + .unwrap(); + + let result = subject.retrieve_txs(None); + + assert_eq!(result, BTreeSet::from([tx4, tx3, tx2, tx1])); + } + + #[test] + fn can_retrieve_txs_to_retry() { + 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)) + .nonce(1) + .timestamp(now - 3600) + .reason(PendingTooLong) + .status(RetryRequired) + .build(); + let tx2 = FailedTxBuilder::default() + .hash(make_tx_hash(2)) + .nonce(2) + .timestamp(now - 3600) + .reason(Reverted) + .status(RetryRequired) + .build(); + let tx3 = FailedTxBuilder::default() + .hash(make_tx_hash(3)) + .nonce(3) + .timestamp(now - 3000) + .reason(PendingTooLong) + .status(RecheckRequired(ValidationStatus::Reattempting( + PreviousAttempts::new( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote( + RemoteErrorKind::Unreachable, + )), + &SimpleClockReal::default(), + ), + ))) + .build(); + let tx4 = FailedTxBuilder::default() + .hash(make_tx_hash(4)) + .nonce(4) + .reason(PendingTooLong) + .status(Concluded) + .timestamp(now - 3000) + .build(); + subject + .insert_new_records(&BTreeSet::from([tx1.clone(), tx2.clone(), tx3, tx4])) + .unwrap(); + + let result = subject.retrieve_txs(Some(FailureRetrieveCondition::ByStatus(RetryRequired))); + + assert_eq!(result, BTreeSet::from([tx2, tx1])); + } + + #[test] + fn can_retrieve_txs_by_receiver_addresses() { + let home_dir = ensure_node_home_directory_exists( + "failed_payable_dao", + "can_retrieve_txs_by_receiver_addresses", + ); + let wrapped_conn = DbInitializerReal::default() + .initialize(&home_dir, DbInitializationConfig::test_default()) + .unwrap(); + let subject = FailedPayableDaoReal::new(wrapped_conn); + let address1 = make_address(1); + let address2 = make_address(2); + let address3 = make_address(3); + let address4 = make_address(4); + let tx1 = FailedTxBuilder::default() + .hash(make_tx_hash(1)) + .receiver_address(address1) + .nonce(1) + .build(); + let tx2 = FailedTxBuilder::default() + .hash(make_tx_hash(2)) + .receiver_address(address2) + .nonce(2) + .build(); + let tx3 = FailedTxBuilder::default() + .hash(make_tx_hash(3)) + .receiver_address(address3) + .nonce(3) + .build(); + let tx4 = FailedTxBuilder::default() + .hash(make_tx_hash(4)) + .receiver_address(address4) + .nonce(4) + .build(); + subject + .insert_new_records(&BTreeSet::from([ + tx1.clone(), + tx2.clone(), + tx3.clone(), + tx4.clone(), + ])) + .unwrap(); + + let result = subject.retrieve_txs(Some(FailureRetrieveCondition::ByReceiverAddresses( + BTreeSet::from([address1, address2, address3]), + ))); + + assert_eq!(result.len(), 3); + assert!(result.contains(&tx1)); + assert!(result.contains(&tx2)); + assert!(result.contains(&tx3)); + assert!(!result.contains(&tx4)); + } + + #[test] + fn update_statuses_works() { + let home_dir = + ensure_node_home_directory_exists("failed_payable_dao", "update_statuses_works"); + let wrapped_conn = DbInitializerReal::default() + .initialize(&home_dir, DbInitializationConfig::test_default()) + .unwrap(); + let subject = FailedPayableDaoReal::new(wrapped_conn); + let hash1 = make_tx_hash(1); + let hash2 = make_tx_hash(2); + let hash3 = make_tx_hash(3); + let hash4 = make_tx_hash(4); + let tx1 = FailedTxBuilder::default() + .hash(hash1) + .reason(Reverted) + .status(RetryRequired) + .nonce(4) + .build(); + let tx2 = FailedTxBuilder::default() + .hash(hash2) + .reason(PendingTooLong) + .status(RecheckRequired(ValidationStatus::Waiting)) + .nonce(3) + .build(); + let tx3 = FailedTxBuilder::default() + .hash(hash3) + .reason(PendingTooLong) + .status(RetryRequired) + .nonce(2) + .build(); + let tx4 = FailedTxBuilder::default() + .hash(hash4) + .reason(PendingTooLong) + .status(RecheckRequired(ValidationStatus::Waiting)) + .nonce(1) + .build(); + subject + .insert_new_records(&BTreeSet::from([ + tx1.clone(), + tx2.clone(), + tx3.clone(), + tx4.clone(), + ])) + .unwrap(); + let timestamp = SystemTime::now(); + let clock = SimpleClockMock::default() + .now_result(timestamp) + .now_result(timestamp); + let hashmap = HashMap::from([ + (tx1.hash, Concluded), + ( + tx2.hash, + RecheckRequired(ValidationStatus::Reattempting(PreviousAttempts::new( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote( + RemoteErrorKind::Unreachable, + )), + &clock, + ))), + ), + (tx3.hash, Concluded), + ]); + + let result = subject.update_statuses(&hashmap); + let updated_txs = subject.retrieve_txs(None); + let find_tx = |tx_hash| updated_txs.iter().find(|tx| tx.hash == tx_hash).unwrap(); + let updated_tx1 = find_tx(hash1); + let updated_tx2 = find_tx(hash2); + let updated_tx3 = find_tx(hash3); + let updated_tx4 = find_tx(hash4); + assert_eq!(result, Ok(())); + assert_eq!(tx1.status, RetryRequired); + assert_eq!(updated_tx1.status, Concluded); + assert_eq!(tx2.status, RecheckRequired(ValidationStatus::Waiting)); + assert_eq!( + updated_tx2.status, + RecheckRequired(ValidationStatus::Reattempting(PreviousAttempts::new( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote(RemoteErrorKind::Unreachable)), + &clock + ))) + ); + assert_eq!(tx3.status, RetryRequired); + assert_eq!(updated_tx3.status, Concluded); + assert_eq!(tx4.status, RecheckRequired(ValidationStatus::Waiting)); + assert_eq!( + updated_tx4.status, + RecheckRequired(ValidationStatus::Waiting) + ); + assert_eq!(updated_txs.len(), 4); + } + + #[test] + fn update_statuses_handles_empty_input_error() { + let home_dir = ensure_node_home_directory_exists( + "failed_payable_dao", + "update_statuses_handles_empty_input_error", + ); + let wrapped_conn = DbInitializerReal::default() + .initialize(&home_dir, DbInitializationConfig::test_default()) + .unwrap(); + let subject = FailedPayableDaoReal::new(wrapped_conn); + + let result = subject.update_statuses(&HashMap::new()); + + assert_eq!(result, Err(FailedPayableDaoError::EmptyInput)); + } + + #[test] + fn update_statuses_handles_sql_error() { + let home_dir = ensure_node_home_directory_exists( + "failed_payable_dao", + "update_statuses_handles_sql_error", + ); + let wrapped_conn = make_read_only_db_connection(home_dir); + let subject = FailedPayableDaoReal::new(Box::new(wrapped_conn)); + + let result = subject.update_statuses(&HashMap::from([(make_tx_hash(1), Concluded)])); + + 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)) + .nonce(1) + .build(); + let tx2 = FailedTxBuilder::default() + .hash(make_tx_hash(2)) + .nonce(2) + .build(); + let tx3 = FailedTxBuilder::default() + .hash(make_tx_hash(3)) + .nonce(3) + .build(); + let tx4 = FailedTxBuilder::default() + .hash(make_tx_hash(4)) + .nonce(4) + .build(); + subject + .insert_new_records(&BTreeSet::from([ + tx1.clone(), + tx2.clone(), + tx3.clone(), + tx4.clone(), + ])) + .unwrap(); + let hashset = BTreeSet::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, BTreeSet::from([tx4, tx2])); + } + + #[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(&BTreeSet::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 = BTreeSet::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(&BTreeSet::from([tx])).unwrap(); + let set = BTreeSet::from([present_hash, absent_hash]); + + let result = subject.delete_records(&set); + + 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 = BTreeSet::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() + )) + ) + } + + #[test] + fn transaction_trait_methods_for_failed_tx() { + let hash = make_tx_hash(1); + let receiver_address = make_address(1); + let amount = 1000; + let timestamp = 1625247600; + let gas_price_wei = 2000; + let nonce = 42; + let reason = FailureReason::Reverted; + let status = FailureStatus::RetryRequired; + + let failed_tx = FailedTx { + hash, + receiver_address, + amount_minor: amount, + timestamp, + gas_price_minor: gas_price_wei, + nonce, + reason, + status, + }; + + assert_eq!(failed_tx.receiver_address(), receiver_address); + assert_eq!(failed_tx.hash(), hash); + assert_eq!(failed_tx.amount(), amount); + assert_eq!(failed_tx.timestamp(), timestamp); + assert_eq!(failed_tx.gas_price_wei(), gas_price_wei); + assert_eq!(failed_tx.nonce(), nonce); + assert_eq!(failed_tx.is_failed(), true); + } +} diff --git a/node/src/accountant/db_access_objects/mod.rs b/node/src/accountant/db_access_objects/mod.rs index a350148ab0..a8c8e225ee 100644 --- a/node/src/accountant/db_access_objects/mod.rs +++ b/node/src/accountant/db_access_objects/mod.rs @@ -1,7 +1,23 @@ // Copyright (c) 2019, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. +use crate::accountant::db_access_objects::utils::TxHash; +use web3::types::Address; + pub mod banned_dao; +pub mod failed_payable_dao; pub mod payable_dao; -pub mod pending_payable_dao; pub mod receivable_dao; +pub mod sent_payable_and_failed_payable_data_conversion; +pub mod sent_payable_dao; +pub mod test_utils; pub mod utils; + +pub trait Transaction { + fn hash(&self) -> TxHash; + fn receiver_address(&self) -> Address; + fn amount(&self) -> u128; + fn timestamp(&self) -> i64; + fn gas_price_wei(&self) -> u128; + fn nonce(&self) -> u64; + fn is_failed(&self) -> bool; +} diff --git a/node/src/accountant/db_access_objects/payable_dao.rs b/node/src/accountant/db_access_objects/payable_dao.rs index 88897281bf..f9a723f549 100644 --- a/node/src/accountant/db_access_objects/payable_dao.rs +++ b/node/src/accountant/db_access_objects/payable_dao.rs @@ -1,33 +1,32 @@ // Copyright (c) 2019, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. -use crate::accountant::db_big_integer::big_int_db_processor::KeyVariants::{ - PendingPayableRowid, WalletAddress, -}; -use crate::accountant::db_big_integer::big_int_db_processor::{BigIntDbProcessor, BigIntDbProcessorReal, BigIntSqlConfig, DisplayableRusqliteParamPair, ParamByUse, SQLParamsBuilder, TableNameDAO, WeiChange, WeiChangeDirection}; -use crate::accountant::db_big_integer::big_int_divider::BigIntDivider; +use crate::accountant::db_access_objects::failed_payable_dao::FailedTx; +use crate::accountant::db_access_objects::sent_payable_dao::SentTx; use crate::accountant::db_access_objects::utils; use crate::accountant::db_access_objects::utils::{ - sum_i128_values_from_table, to_time_t, AssemblerFeeder, CustomQuery, DaoFactoryReal, - RangeStmConfig, TopStmConfig, VigilantRusqliteFlatten, + from_unix_timestamp, sum_i128_values_from_table, to_unix_timestamp, AssemblerFeeder, + CustomQuery, DaoFactoryReal, RangeStmConfig, RowId, TopStmConfig, VigilantRusqliteFlatten, }; -use crate::accountant::db_access_objects::payable_dao::mark_pending_payable_associated_functions::{ - compose_case_expression, execute_command, serialize_wallets, +use crate::accountant::db_big_integer::big_int_db_processor::KeyVariants::WalletAddress; +use crate::accountant::db_big_integer::big_int_db_processor::{ + BigIntDbProcessor, BigIntDbProcessorReal, BigIntSqlConfig, DisplayableRusqliteParamPair, + ParamByUse, SQLParamsBuilder, TableNameDAO, WeiChange, WeiChangeDirection, }; -use crate::accountant::{checked_conversion, sign_conversion, PendingPayableId}; -use crate::blockchain::blockchain_bridge::PendingPayableFingerprint; +use crate::accountant::db_big_integer::big_int_divider::BigIntDivider; +use crate::accountant::{checked_conversion, join_with_commas, sign_conversion, PendingPayableId}; use crate::database::rusqlite_wrappers::ConnectionWrapper; use crate::sub_lib::wallet::Wallet; +use ethabi::Address; #[cfg(test)] -use ethereum_types::{BigEndianHash, U256}; +use ethereum_types::{BigEndianHash, H256, U256}; +use itertools::Either; use masq_lib::utils::ExpectValue; #[cfg(test)] use rusqlite::OptionalExtension; use rusqlite::{Error, Row}; -use std::fmt::Debug; -use std::str::FromStr; +use std::collections::BTreeSet; +use std::fmt::{Debug, Display, Formatter}; use std::time::SystemTime; -use itertools::Either; -use web3::types::H256; #[derive(Debug, PartialEq, Eq)] pub enum PayableDaoError { @@ -43,25 +42,53 @@ pub struct PayableAccount { pub pending_payable_opt: Option, } +impl From<&FailedTx> for PayableAccount { + fn from(failed_tx: &FailedTx) -> Self { + PayableAccount { + wallet: Wallet::from(failed_tx.receiver_address), + balance_wei: failed_tx.amount_minor, + last_paid_timestamp: from_unix_timestamp(failed_tx.timestamp), + pending_payable_opt: None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PayableRetrieveCondition { + ByAddresses(BTreeSet
), +} + +impl Display for PayableRetrieveCondition { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + PayableRetrieveCondition::ByAddresses(addresses) => write!( + f, + "AND wallet_address IN ({})", + join_with_commas(addresses, |hash| format!("'{:?}'", hash)) + ), + } + } +} + pub trait PayableDao: Debug + Send { fn more_money_payable( &self, now: SystemTime, wallet: &Wallet, - amount: u128, + amount_minor: u128, ) -> Result<(), PayableDaoError>; fn mark_pending_payables_rowids( &self, - wallets_and_rowids: &[(&Wallet, u64)], + mark_instructions: &[MarkPendingPayableID], ) -> Result<(), PayableDaoError>; - fn transactions_confirmed( - &self, - confirmed_payables: &[PendingPayableFingerprint], - ) -> Result<(), PayableDaoError>; + fn transactions_confirmed(&self, confirmed_payables: &[SentTx]) -> Result<(), PayableDaoError>; - fn non_pending_payables(&self) -> Vec; + fn retrieve_payables( + &self, + condition_opt: Option, + ) -> Vec; fn custom_query(&self, custom_query: CustomQuery) -> Option>; @@ -81,6 +108,11 @@ impl PayableDaoFactory for DaoFactoryReal { } } +pub struct MarkPendingPayableID { + pub receiver_wallet: Address, + pub rowid: RowId, +} + #[derive(Debug)] pub struct PayableDaoReal { conn: Box, @@ -92,7 +124,7 @@ impl PayableDao for PayableDaoReal { &self, timestamp: SystemTime, wallet: &Wallet, - amount: u128, + amount_minor: u128, ) -> Result<(), PayableDaoError> { let main_sql = "insert into payable (wallet_address, balance_high_b, balance_low_b, last_paid_timestamp, pending_payable_rowid) \ values (:wallet, :balance_high_b, :balance_low_b, :last_paid_timestamp, null) on conflict (wallet_address) do update set \ @@ -100,12 +132,12 @@ impl PayableDao for PayableDaoReal { let update_clause_with_compensated_overflow = "update payable set \ balance_high_b = :balance_high_b, balance_low_b = :balance_low_b where wallet_address = :wallet"; - let last_paid_timestamp = to_time_t(timestamp); + let last_paid_timestamp = to_unix_timestamp(timestamp); let params = SQLParamsBuilder::default() .key(WalletAddress(wallet)) .wei_change(WeiChange::new( "balance", - amount, + amount_minor, WeiChangeDirection::Addition, )) .other_params(vec![ParamByUse::BeforeOverflowOnly( @@ -123,46 +155,42 @@ impl PayableDao for PayableDaoReal { fn mark_pending_payables_rowids( &self, - wallets_and_rowids: &[(&Wallet, u64)], + _mark_instructions: &[MarkPendingPayableID], ) -> Result<(), PayableDaoError> { - if wallets_and_rowids.is_empty() { - panic!("broken code: empty input is not permit to enter this method") - } - - let case_expr = compose_case_expression(wallets_and_rowids); - let wallets = serialize_wallets(wallets_and_rowids, Some('\'')); - //the Wallet type is secure against SQL injections - let sql = format!( - "update payable set \ - pending_payable_rowid = {} \ - where - pending_payable_rowid is null and wallet_address in ({}) - returning - pending_payable_rowid", - case_expr, wallets, - ); - execute_command(&*self.conn, wallets_and_rowids, &sql) + todo!("Will be an object of removal in GH-662") + // if wallets_and_rowids.is_empty() { + // panic!("broken code: empty input is not permit to enter this method") + // } + // + // let case_expr = compose_case_expression(wallets_and_rowids); + // let wallets = serialize_wallets(wallets_and_rowids, Some('\'')); + // //the Wallet type is secure against SQL injections + // let sql = format!( + // "update payable set \ + // pending_payable_rowid = {} \ + // where + // pending_payable_rowid is null and wallet_address in ({}) + // returning + // pending_payable_rowid", + // case_expr, wallets, + // ); + // execute_command(&*self.conn, wallets_and_rowids, &sql) } - fn transactions_confirmed( - &self, - confirmed_payables: &[PendingPayableFingerprint], - ) -> Result<(), PayableDaoError> { - confirmed_payables.iter().try_for_each(|pending_payable_fingerprint| { - + fn transactions_confirmed(&self, confirmed_payables: &[SentTx]) -> Result<(), PayableDaoError> { + confirmed_payables.iter().try_for_each(|confirmed_payable| { let main_sql = "update payable set \ balance_high_b = balance_high_b + :balance_high_b, balance_low_b = balance_low_b + :balance_low_b, \ - last_paid_timestamp = :last_paid, pending_payable_rowid = null where pending_payable_rowid = :rowid"; + last_paid_timestamp = :last_paid, pending_payable_rowid = null where wallet_address = :wallet"; let update_clause_with_compensated_overflow = "update payable set \ balance_high_b = :balance_high_b, balance_low_b = :balance_low_b, last_paid_timestamp = :last_paid, \ - pending_payable_rowid = null where pending_payable_rowid = :rowid"; + pending_payable_rowid = null where wallet_address = :wallet"; - let i64_rowid = checked_conversion::(pending_payable_fingerprint.rowid); - let last_paid = to_time_t(pending_payable_fingerprint.timestamp); + let wallet = format!("{:?}", confirmed_payable.receiver_address); let params = SQLParamsBuilder::default() - .key( PendingPayableRowid(&i64_rowid)) - .wei_change(WeiChange::new( "balance", pending_payable_fingerprint.amount, WeiChangeDirection::Subtraction)) - .other_params(vec![ParamByUse::BeforeAndAfterOverflow(DisplayableRusqliteParamPair::new(":last_paid", &last_paid))]) + .key( WalletAddress(&wallet)) + .wei_change(WeiChange::new("balance", confirmed_payable.amount_minor, WeiChangeDirection::Subtraction)) + .other_params(vec![ParamByUse::BeforeAndAfterOverflow(DisplayableRusqliteParamPair::new(":last_paid", &confirmed_payable.timestamp))]) .build(); self.big_int_db_processor.execute(Either::Left(self.conn.as_ref()), BigIntSqlConfig::new( @@ -174,11 +202,19 @@ impl PayableDao for PayableDaoReal { }) } - fn non_pending_payables(&self) -> Vec { - let sql = "\ + fn retrieve_payables( + &self, + condition_opt: Option, + ) -> Vec { + let raw_sql = "\ select wallet_address, balance_high_b, balance_low_b, last_paid_timestamp from \ - payable where pending_payable_rowid is null"; - let mut stmt = self.conn.prepare(sql).expect("Internal error"); + payable where pending_payable_rowid is null" + .to_string(); + let sql = match condition_opt { + None => raw_sql, + Some(condition) => format!("{} {}", raw_sql, condition), + }; + let mut stmt = self.conn.prepare(&sql).expect("Internal error"); stmt.query_map([], |row| { let wallet_result: Result = row.get(0); let high_b_result: Result = row.get(1); @@ -196,7 +232,7 @@ impl PayableDao for PayableDaoReal { balance_wei: checked_conversion::(BigIntDivider::reconstitute( high_b, low_b, )), - last_paid_timestamp: utils::from_time_t(last_paid_timestamp), + last_paid_timestamp: utils::from_unix_timestamp(last_paid_timestamp), pending_payable_opt: None, }) } @@ -282,7 +318,7 @@ impl PayableDao for PayableDaoReal { balance_wei: checked_conversion::(BigIntDivider::reconstitute( high_bytes, low_bytes, )), - last_paid_timestamp: utils::from_time_t(last_paid_timestamp), + last_paid_timestamp: utils::from_unix_timestamp(last_paid_timestamp), pending_payable_opt: match rowid { Some(rowid) => Some(PendingPayableId::new( u64::try_from(rowid).unwrap(), @@ -316,39 +352,22 @@ impl PayableDaoReal { let balance_high_bytes_result = row.get(1); let balance_low_bytes_result = row.get(2); let last_paid_timestamp_result = row.get(3); - let pending_payable_rowid_result: Result, Error> = row.get(4); - let pending_payable_hash_result: Result, Error> = row.get(5); match ( wallet_result, balance_high_bytes_result, balance_low_bytes_result, last_paid_timestamp_result, - pending_payable_rowid_result, - pending_payable_hash_result, ) { - ( - Ok(wallet), - Ok(high_bytes), - Ok(low_bytes), - Ok(last_paid_timestamp), - Ok(rowid_opt), - Ok(hash_opt), - ) => Ok(PayableAccount { - wallet, - balance_wei: checked_conversion::(BigIntDivider::reconstitute( - high_bytes, low_bytes, - )), - last_paid_timestamp: utils::from_time_t(last_paid_timestamp), - pending_payable_opt: rowid_opt.map(|rowid| { - let hash_str = - hash_opt.expect("database corrupt; missing hash but existing rowid"); - PendingPayableId::new( - u64::try_from(rowid).unwrap(), - H256::from_str(&hash_str[2..]) - .unwrap_or_else(|_| panic!("wrong form of tx hash {}", hash_str)), - ) - }), - }), + (Ok(wallet), Ok(high_bytes), Ok(low_bytes), Ok(last_paid_timestamp)) => { + Ok(PayableAccount { + wallet, + balance_wei: checked_conversion::(BigIntDivider::reconstitute( + high_bytes, low_bytes, + )), + last_paid_timestamp: utils::from_unix_timestamp(last_paid_timestamp), + pending_payable_opt: None, + }) + } e => panic!( "Database is corrupt: PAYABLE table columns and/or types: {:?}", e @@ -362,13 +381,9 @@ impl PayableDaoReal { wallet_address, balance_high_b, balance_low_b, - last_paid_timestamp, - pending_payable_rowid, - pending_payable.transaction_hash + last_paid_timestamp from payable - left join pending_payable on - pending_payable.rowid = payable.pending_payable_rowid {} {} order by {}, @@ -389,175 +404,183 @@ impl TableNameDAO for PayableDaoReal { } } -mod mark_pending_payable_associated_functions { - use crate::accountant::comma_joined_stringifiable; - use crate::accountant::db_access_objects::payable_dao::PayableDaoError; - use crate::accountant::db_access_objects::utils::{ - update_rows_and_return_valid_count, VigilantRusqliteFlatten, - }; - use crate::database::rusqlite_wrappers::ConnectionWrapper; - use crate::sub_lib::wallet::Wallet; - use itertools::Itertools; - use rusqlite::Row; - use std::fmt::Display; - - pub fn execute_command( - conn: &dyn ConnectionWrapper, - wallets_and_rowids: &[(&Wallet, u64)], - sql: &str, - ) -> Result<(), PayableDaoError> { - let mut stm = conn.prepare(sql).expect("Internal Error"); - let validator = validate_row_updated; - let rows_affected_res = update_rows_and_return_valid_count(&mut stm, validator); - - match rows_affected_res { - Ok(rows_affected) => match rows_affected { - num if num == wallets_and_rowids.len() => Ok(()), - num => mismatched_row_count_panic(conn, wallets_and_rowids, num), - }, - Err(errs) => { - let err_msg = format!( - "Multi-row update to mark pending payable hit these errors: {:?}", - errs - ); - Err(PayableDaoError::RusqliteError(err_msg)) - } - } - } - - pub fn compose_case_expression(wallets_and_rowids: &[(&Wallet, u64)]) -> String { - //the Wallet type is secure against SQL injections - fn when_clause((wallet, rowid): &(&Wallet, u64)) -> String { - format!("when wallet_address = '{wallet}' then {rowid}") - } - - format!( - "case {} end", - wallets_and_rowids.iter().map(when_clause).join("\n") - ) - } - - pub fn serialize_wallets( - wallets_and_rowids: &[(&Wallet, u64)], - quotes_opt: Option, - ) -> String { - wallets_and_rowids - .iter() - .map(|(wallet, _)| match quotes_opt { - Some(char) => format!("{}{}{}", char, wallet, char), - None => wallet.to_string(), - }) - .join(", ") - } - - fn validate_row_updated(row: &Row) -> Result { - row.get::>(0).map(|opt| opt.is_some()) - } - - fn mismatched_row_count_panic( - conn: &dyn ConnectionWrapper, - wallets_and_rowids: &[(&Wallet, u64)], - actual_count: usize, - ) -> ! { - let serialized_wallets = serialize_wallets(wallets_and_rowids, None); - let expected_count = wallets_and_rowids.len(); - let extension = explanatory_extension(conn, wallets_and_rowids); - panic!( - "Marking pending payable rowid for wallets {serialized_wallets} affected \ - {actual_count} rows but expected {expected_count}. {extension}" - ) - } - - pub(super) fn explanatory_extension( - conn: &dyn ConnectionWrapper, - wallets_and_rowids: &[(&Wallet, u64)], - ) -> String { - let resulting_pairs_collection = - query_resulting_pairs_of_wallets_and_rowids(conn, wallets_and_rowids); - let resulting_pairs_summary = if resulting_pairs_collection.is_empty() { - "".to_string() - } else { - pairs_in_pretty_string(&resulting_pairs_collection, |rowid_opt: &Option| { - match rowid_opt { - Some(rowid) => Box::new(*rowid), - None => Box::new("N/A"), - } - }) - }; - let wallets_and_non_optional_rowids = - pairs_in_pretty_string(wallets_and_rowids, |rowid: &u64| Box::new(*rowid)); - format!( - "\ - The demanded data according to {} looks different from the resulting state {}!. Operation failed.\n\ - Notes:\n\ - a) if row ids have stayed non-populated it points out that writing failed but without the double payment threat,\n\ - b) if some accounts on the resulting side are missing, other kind of serious issues should be suspected but see other\n\ - points to figure out if you were put in danger of double payment,\n\ - c) seeing ids different from those demanded might be a sign of some payments having been doubled.\n\ - The operation which is supposed to clear out the ids of the payments previously requested for this account\n\ - probably had not managed to complete successfully before another payment was requested: preventive measures failed.\n", - wallets_and_non_optional_rowids, resulting_pairs_summary) - } - - fn query_resulting_pairs_of_wallets_and_rowids( - conn: &dyn ConnectionWrapper, - wallets_and_rowids: &[(&Wallet, u64)], - ) -> Vec<(Wallet, Option)> { - let select_dealt_accounts = - format!( - "select wallet_address, pending_payable_rowid from payable where wallet_address in ({})", - serialize_wallets(wallets_and_rowids, Some('\'')) - ); - let row_processor = |row: &Row| { - Ok(( - row.get::(0) - .expect("database corrupt: wallet addresses found in bad format"), - row.get::>(1) - .expect("database_corrupt: rowid found in bad format"), - )) - }; - conn.prepare(&select_dealt_accounts) - .expect("select failed") - .query_map([], row_processor) - .expect("no args yet binding failed") - .vigilant_flatten() - .collect() - } - - fn pairs_in_pretty_string( - pairs: &[(W, R)], - rowid_pretty_writer: fn(&R) -> Box, - ) -> String { - comma_joined_stringifiable(pairs, |(wallet, rowid)| { - format!( - "( Wallet: {}, Rowid: {} )", - wallet, - rowid_pretty_writer(rowid) - ) - }) - } -} +// TODO Will be an object of removal in GH-662 +// mod mark_pending_payable_associated_functions { +// use crate::accountant::join_with_commas; +// use crate::accountant::db_access_objects::payable_dao::{MarkPendingPayableID, PayableDaoError}; +// use crate::accountant::db_access_objects::utils::{ +// update_rows_and_return_valid_count, VigilantRusqliteFlatten, +// }; +// use crate::database::rusqlite_wrappers::ConnectionWrapper; +// use crate::sub_lib::wallet::Wallet; +// use itertools::Itertools; +// use rusqlite::Row; +// use std::fmt::Display; +// +// pub fn execute_command( +// conn: &dyn ConnectionWrapper, +// wallets_and_rowids: &[(&Wallet, u64)], +// sql: &str, +// ) -> Result<(), PayableDaoError> { +// let mut stm = conn.prepare(sql).expect("Internal Error"); +// let validator = validate_row_updated; +// let rows_affected_res = update_rows_and_return_valid_count(&mut stm, validator); +// +// match rows_affected_res { +// Ok(rows_affected) => match rows_affected { +// num if num == wallets_and_rowids.len() => Ok(()), +// num => mismatched_row_count_panic(conn, wallets_and_rowids, num), +// }, +// Err(errs) => { +// let err_msg = format!( +// "Multi-row update to mark pending payable hit these errors: {:?}", +// errs +// ); +// Err(PayableDaoError::RusqliteError(err_msg)) +// } +// } +// } +// +// pub fn compose_case_expression(wallets_and_rowids: &[(&Wallet, u64)]) -> String { +// //the Wallet type is secure against SQL injections +// fn when_clause((wallet, rowid): &(&Wallet, u64)) -> String { +// format!("when wallet_address = '{wallet}' then {rowid}") +// } +// +// format!( +// "case {} end", +// wallets_and_rowids.iter().map(when_clause).join("\n") +// ) +// } +// +// pub fn serialize_wallets( +// wallets_and_rowids: &[MarkPendingPayableID], +// quotes_opt: Option, +// ) -> String { +// wallets_and_rowids +// .iter() +// .map(|(wallet, _)| match quotes_opt { +// Some(char) => format!("{}{}{}", char, wallet, char), +// None => wallet.to_string(), +// }) +// .join(", ") +// } +// +// fn validate_row_updated(row: &Row) -> Result { +// row.get::>(0).map(|opt| opt.is_some()) +// } +// +// fn mismatched_row_count_panic( +// conn: &dyn ConnectionWrapper, +// wallets_and_rowids: &[(&Wallet, u64)], +// actual_count: usize, +// ) -> ! { +// let serialized_wallets = serialize_wallets(wallets_and_rowids, None); +// let expected_count = wallets_and_rowids.len(); +// let extension = explanatory_extension(conn, wallets_and_rowids); +// panic!( +// "Marking pending payable rowid for wallets {serialized_wallets} affected \ +// {actual_count} rows but expected {expected_count}. {extension}" +// ) +// } +// +// pub(super) fn explanatory_extension( +// conn: &dyn ConnectionWrapper, +// wallets_and_rowids: &[(&Wallet, u64)], +// ) -> String { +// let resulting_pairs_collection = +// query_resulting_pairs_of_wallets_and_rowids(conn, wallets_and_rowids); +// let resulting_pairs_summary = if resulting_pairs_collection.is_empty() { +// "".to_string() +// } else { +// pairs_in_pretty_string(&resulting_pairs_collection, |rowid_opt: &Option| { +// match rowid_opt { +// Some(rowid) => Box::new(*rowid), +// None => Box::new("N/A"), +// } +// }) +// }; +// let wallets_and_non_optional_rowids = +// pairs_in_pretty_string(wallets_and_rowids, |rowid: &u64| Box::new(*rowid)); +// format!( +// "\ +// The demanded data according to {} looks different from the resulting state {}!. Operation failed.\n\ +// Notes:\n\ +// a) if row ids have stayed non-populated it points out that writing failed but without the double payment threat,\n\ +// b) if some accounts on the resulting side are missing, other kind of serious issues should be suspected but see other\n\ +// points to figure out if you were put in danger of double payment,\n\ +// c) seeing ids different from those demanded might be a sign of some payments having been doubled.\n\ +// The operation which is supposed to clear out the ids of the payments previously requested for this account\n\ +// probably had not managed to complete successfully before another payment was requested: preventive measures failed.\n", +// wallets_and_non_optional_rowids, resulting_pairs_summary) +// } +// +// fn query_resulting_pairs_of_wallets_and_rowids( +// conn: &dyn ConnectionWrapper, +// wallets_and_rowids: &[(&Wallet, u64)], +// ) -> Vec<(Wallet, Option)> { +// let select_dealt_accounts = +// format!( +// "select wallet_address, pending_payable_rowid from payable where wallet_address in ({})", +// serialize_wallets(wallets_and_rowids, Some('\'')) +// ); +// let row_processor = |row: &Row| { +// Ok(( +// row.get::(0) +// .expect("database corrupt: wallet addresses found in bad format"), +// row.get::>(1) +// .expect("database_corrupt: rowid found in bad format"), +// )) +// }; +// conn.prepare(&select_dealt_accounts) +// .expect("select failed") +// .query_map([], row_processor) +// .expect("no args yet binding failed") +// .vigilant_flatten() +// .collect() +// } +// +// fn pairs_in_pretty_string( +// pairs: &[(W, R)], +// rowid_pretty_writer: fn(&R) -> Box, +// ) -> String { +// join_with_commas(pairs, |(wallet, rowid)| { +// format!( +// "( Wallet: {}, Rowid: {} )", +// wallet, +// rowid_pretty_writer(rowid) +// ) +// }) +// } +// } #[cfg(test)] mod tests { use super::*; - use crate::accountant::db_access_objects::utils::{from_time_t, now_time_t, to_time_t}; + use crate::accountant::db_access_objects::payable_dao::PayableRetrieveCondition::ByAddresses; + use crate::accountant::db_access_objects::sent_payable_dao::SentTx; + use crate::accountant::db_access_objects::test_utils::make_sent_tx; + use crate::accountant::db_access_objects::utils::{ + current_unix_timestamp, from_unix_timestamp, to_unix_timestamp, TxHash, + }; use crate::accountant::gwei_to_wei; - use crate::accountant::db_access_objects::payable_dao::mark_pending_payable_associated_functions::explanatory_extension; - use crate::accountant::test_utils::{assert_account_creation_fn_fails_on_finding_wrong_columns_and_value_types, make_pending_payable_fingerprint, trick_rusqlite_with_read_only_conn}; + use crate::accountant::test_utils::{ + assert_account_creation_fn_fails_on_finding_wrong_columns_and_value_types, + trick_rusqlite_with_read_only_conn, + }; use crate::blockchain::test_utils::make_tx_hash; - use crate::database::rusqlite_wrappers::ConnectionWrapperReal; use crate::database::db_initializer::{ DbInitializationConfig, DbInitializer, DbInitializerReal, DATABASE_FILE, }; + use crate::database::rusqlite_wrappers::ConnectionWrapperReal; use crate::test_utils::make_wallet; + use itertools::Itertools; use masq_lib::messages::TopRecordsOrdering::{Age, Balance}; use masq_lib::test_utils::utils::ensure_node_home_directory_exists; + use rusqlite::ToSql; use rusqlite::{Connection, OpenFlags}; - use rusqlite::{ToSql}; use std::path::Path; - use std::str::FromStr; - use crate::database::test_utils::ConnectionWrapperMock; + use std::time::Duration; #[test] fn more_money_payable_works_for_new_address() { @@ -577,7 +600,10 @@ mod tests { let status = subject.account_status(&wallet).unwrap(); assert_eq!(status.wallet, wallet); assert_eq!(status.balance_wei, 1234); - assert_eq!(to_time_t(status.last_paid_timestamp), to_time_t(now)); + assert_eq!( + to_unix_timestamp(status.last_paid_timestamp), + to_unix_timestamp(now) + ); } #[test] @@ -616,8 +642,8 @@ mod tests { assert_eq!(status.wallet, wallet); assert_eq!(status.balance_wei, expected_balance); assert_eq!( - to_time_t(status.last_paid_timestamp), - to_time_t(SystemTime::UNIX_EPOCH) + to_unix_timestamp(status.last_paid_timestamp), + to_unix_timestamp(SystemTime::UNIX_EPOCH) ); }; assert_account(wallet, initial_value + balance_change); @@ -653,8 +679,8 @@ mod tests { assert_eq!(status.wallet, wallet); assert_eq!(status.balance_wei, initial_value + balance_change); assert_eq!( - to_time_t(status.last_paid_timestamp), - to_time_t(SystemTime::UNIX_EPOCH) + to_unix_timestamp(status.last_paid_timestamp), + to_unix_timestamp(SystemTime::UNIX_EPOCH) ); } @@ -701,260 +727,271 @@ mod tests { fn mark_pending_payables_marks_pending_transactions_for_new_addresses() { //the extra unchanged record checks the safety of right count of changed rows; //experienced serious troubles in the past - let home_dir = ensure_node_home_directory_exists( - "payable_dao", - "mark_pending_payables_marks_pending_transactions_for_new_addresses", - ); - let wallet_0 = make_wallet("wallet"); - let wallet_1 = make_wallet("booga"); - let pending_payable_rowid_1 = 656; - let wallet_2 = make_wallet("bagaboo"); - let pending_payable_rowid_2 = 657; - let boxed_conn = DbInitializerReal::default() - .initialize(&home_dir, DbInitializationConfig::test_default()) - .unwrap(); - { - let insert = "insert into payable (wallet_address, balance_high_b, balance_low_b, \ - last_paid_timestamp) values (?, ?, ?, ?), (?, ?, ?, ?), (?, ?, ?, ?)"; - let mut stm = boxed_conn.prepare(insert).unwrap(); - let params = [ - [&wallet_0 as &dyn ToSql, &12345, &1, &45678], - [&wallet_1, &0, &i64::MAX, &150_000_000], - [&wallet_2, &3, &0, &151_000_000], - ] - .into_iter() - .flatten() - .collect::>(); - stm.execute(params.as_slice()).unwrap(); - } - let subject = PayableDaoReal::new(boxed_conn); - - subject - .mark_pending_payables_rowids(&[ - (&wallet_1, pending_payable_rowid_1), - (&wallet_2, pending_payable_rowid_2), - ]) - .unwrap(); - - let account_statuses = [&wallet_0, &wallet_1, &wallet_2] - .iter() - .map(|wallet| subject.account_status(wallet).unwrap()) - .collect::>(); - assert_eq!( - account_statuses, - vec![ - PayableAccount { - wallet: wallet_0, - balance_wei: u128::try_from(BigIntDivider::reconstitute(12345, 1)).unwrap(), - last_paid_timestamp: from_time_t(45678), - pending_payable_opt: None, - }, - PayableAccount { - wallet: wallet_1, - balance_wei: u128::try_from(BigIntDivider::reconstitute(0, i64::MAX)).unwrap(), - last_paid_timestamp: from_time_t(150_000_000), - pending_payable_opt: Some(PendingPayableId::new( - pending_payable_rowid_1, - make_tx_hash(0) - )), - }, - //notice the hashes are garbage generated by a test method not knowing doing better - PayableAccount { - wallet: wallet_2, - balance_wei: u128::try_from(BigIntDivider::reconstitute(3, 0)).unwrap(), - last_paid_timestamp: from_time_t(151_000_000), - pending_payable_opt: Some(PendingPayableId::new( - pending_payable_rowid_2, - make_tx_hash(0) - )) - } - ] - ) + // TODO Will be an object of removal in GH-662 + // let home_dir = ensure_node_home_directory_exists( + // "payable_dao", + // "mark_pending_payables_marks_pending_transactions_for_new_addresses", + // ); + // let wallet_0 = make_wallet("wallet"); + // let wallet_1 = make_wallet("booga"); + // let pending_payable_rowid_1 = 656; + // let wallet_2 = make_wallet("bagaboo"); + // let pending_payable_rowid_2 = 657; + // let boxed_conn = DbInitializerReal::default() + // .initialize(&home_dir, DbInitializationConfig::test_default()) + // .unwrap(); + // { + // let insert = "insert into payable (wallet_address, balance_high_b, balance_low_b, \ + // last_paid_timestamp) values (?, ?, ?, ?), (?, ?, ?, ?), (?, ?, ?, ?)"; + // let mut stm = boxed_conn.prepare(insert).unwrap(); + // let params = [ + // [&wallet_0 as &dyn ToSql, &12345, &1, &45678], + // [&wallet_1, &0, &i64::MAX, &150_000_000], + // [&wallet_2, &3, &0, &151_000_000], + // ] + // .into_iter() + // .flatten() + // .collect::>(); + // stm.execute(params.as_slice()).unwrap(); + // } + // let subject = PayableDaoReal::new(boxed_conn); + // + // subject + // .mark_pending_payables_rowids(&[ + // (&wallet_1, pending_payable_rowid_1), + // (&wallet_2, pending_payable_rowid_2), + // ]) + // .unwrap(); + // + // let account_statuses = [&wallet_0, &wallet_1, &wallet_2] + // .iter() + // .map(|wallet| subject.account_status(wallet).unwrap()) + // .collect::>(); + // assert_eq!( + // account_statuses, + // vec![ + // PayableAccount { + // wallet: wallet_0, + // balance_wei: u128::try_from(BigIntDivider::reconstitute(12345, 1)).unwrap(), + // last_paid_timestamp: from_unix_timestamp(45678), + // pending_payable_opt: None, + // }, + // PayableAccount { + // wallet: wallet_1, + // balance_wei: u128::try_from(BigIntDivider::reconstitute(0, i64::MAX)).unwrap(), + // last_paid_timestamp: from_unix_timestamp(150_000_000), + // pending_payable_opt: Some(PendingPayableId::new( + // pending_payable_rowid_1, + // make_tx_hash(0) + // )), + // }, + // //notice the hashes are garbage generated by a test method not knowing doing better + // PayableAccount { + // wallet: wallet_2, + // balance_wei: u128::try_from(BigIntDivider::reconstitute(3, 0)).unwrap(), + // last_paid_timestamp: from_unix_timestamp(151_000_000), + // pending_payable_opt: Some(PendingPayableId::new( + // pending_payable_rowid_2, + // make_tx_hash(0) + // )) + // } + // ] + // ) } #[test] - #[should_panic(expected = "\ - Marking pending payable rowid for wallets 0x000000000000000000000000000000626f6f6761, \ - 0x0000000000000000000000000000007961686f6f affected 0 rows but expected 2. \ - The demanded data according to ( Wallet: 0x000000000000000000000000000000626f6f6761, Rowid: 456 ), \ - ( Wallet: 0x0000000000000000000000000000007961686f6f, Rowid: 789 ) looks different from \ - the resulting state ( Wallet: 0x000000000000000000000000000000626f6f6761, Rowid: 456 )!. Operation failed.\n\ - Notes:\n\ - a) if row ids have stayed non-populated it points out that writing failed but without the double payment threat,\n\ - b) if some accounts on the resulting side are missing, other kind of serious issues should be suspected but see other\n\ - points to figure out if you were put in danger of double payment,\n\ - c) seeing ids different from those demanded might be a sign of some payments having been doubled.\n\ - The operation which is supposed to clear out the ids of the payments previously requested for this account\n\ - probably had not managed to complete successfully before another payment was requested: preventive measures failed.")] + // #[should_panic(expected = "\ + // Marking pending payable rowid for wallets 0x000000000000000000000000000000626f6f6761, \ + // 0x0000000000000000000000000000007961686f6f affected 0 rows but expected 2. \ + // The demanded data according to ( Wallet: 0x000000000000000000000000000000626f6f6761, Rowid: 456 ), \ + // ( Wallet: 0x0000000000000000000000000000007961686f6f, Rowid: 789 ) looks different from \ + // the resulting state ( Wallet: 0x000000000000000000000000000000626f6f6761, Rowid: 456 )!. Operation failed.\n\ + // Notes:\n\ + // a) if row ids have stayed non-populated it points out that writing failed but without the double payment threat,\n\ + // b) if some accounts on the resulting side are missing, other kind of serious issues should be suspected but see other\n\ + // points to figure out if you were put in danger of double payment,\n\ + // c) seeing ids different from those demanded might be a sign of some payments having been doubled.\n\ + // The operation which is supposed to clear out the ids of the payments previously requested for this account\n\ + // probably had not managed to complete successfully before another payment was requested: preventive measures failed.")] fn mark_pending_payables_rowids_returned_different_row_count_than_expected_with_one_account_missing_and_one_unmodified( ) { - let home_dir = ensure_node_home_directory_exists( - "payable_dao", - "mark_pending_payables_rowids_returned_different_row_count_than_expected_with_one_account_missing_and_one_unmodified", - ); - let conn = DbInitializerReal::default() - .initialize(&home_dir, DbInitializationConfig::test_default()) - .unwrap(); - let first_wallet = make_wallet("booga"); - let first_rowid = 456; - insert_payable_record_fn( - &*conn, - &first_wallet.to_string(), - 123456, - 789789, - Some(first_rowid), - ); - let subject = PayableDaoReal::new(conn); - - let _ = subject.mark_pending_payables_rowids(&[ - (&first_wallet, first_rowid as u64), - (&make_wallet("yahoo"), 789), - ]); + // TODO Will be an object of removal in GH-662 + // let home_dir = ensure_node_home_directory_exists( + // "payable_dao", + // "mark_pending_payables_rowids_returned_different_row_count_than_expected_with_one_account_missing_and_one_unmodified", + // ); + // let conn = DbInitializerReal::default() + // .initialize(&home_dir, DbInitializationConfig::test_default()) + // .unwrap(); + // let first_wallet = make_wallet("booga"); + // let first_rowid = 456; + // insert_payable_record_fn( + // &*conn, + // &first_wallet.to_string(), + // 123456, + // 789789, + // Some(first_rowid), + // ); + // let subject = PayableDaoReal::new(conn); + // + // let _ = subject.mark_pending_payables_rowids(&[ + // (&first_wallet, first_rowid as u64), + // (&make_wallet("yahoo"), 789), + // ]); } #[test] fn explanatory_extension_shows_resulting_account_with_unpopulated_rowid() { - let home_dir = ensure_node_home_directory_exists( - "payable_dao", - "explanatory_extension_shows_resulting_account_with_unpopulated_rowid", - ); - let wallet_1 = make_wallet("hooga"); - let rowid_1 = 550; - let wallet_2 = make_wallet("booga"); - let rowid_2 = 555; - let conn = DbInitializerReal::default() - .initialize(&home_dir, DbInitializationConfig::test_default()) - .unwrap(); - let record_seeds = [ - (&wallet_1.to_string(), 12345, 1_000_000_000, None), - (&wallet_2.to_string(), 23456, 1_000_000_111, Some(540)), - ]; - record_seeds - .into_iter() - .for_each(|(wallet, balance, timestamp, rowid_opt)| { - insert_payable_record_fn(&*conn, wallet, balance, timestamp, rowid_opt) - }); - - let result = explanatory_extension(&*conn, &[(&wallet_1, rowid_1), (&wallet_2, rowid_2)]); - - assert_eq!(result, "\ - The demanded data according to ( Wallet: 0x000000000000000000000000000000686f6f6761, Rowid: 550 ), \ - ( Wallet: 0x000000000000000000000000000000626f6f6761, Rowid: 555 ) looks different from \ - the resulting state ( Wallet: 0x000000000000000000000000000000626f6f6761, Rowid: 540 ), \ - ( Wallet: 0x000000000000000000000000000000686f6f6761, Rowid: N/A )!. \ - Operation failed.\n\ - Notes:\n\ - a) if row ids have stayed non-populated it points out that writing failed but without the double \ - payment threat,\n\ - b) if some accounts on the resulting side are missing, other kind of serious issues should be \ - suspected but see other\npoints to figure out if you were put in danger of double payment,\n\ - c) seeing ids different from those demanded might be a sign of some payments having been doubled.\n\ - The operation which is supposed to clear out the ids of the payments previously requested for \ - this account\nprobably had not managed to complete successfully before another payment was \ - requested: preventive measures failed.\n".to_string()) + // TODO Will be an object of removal in GH-662 + // let home_dir = ensure_node_home_directory_exists( + // "payable_dao", + // "explanatory_extension_shows_resulting_account_with_unpopulated_rowid", + // ); + // let wallet_1 = make_wallet("hooga"); + // let rowid_1 = 550; + // let wallet_2 = make_wallet("booga"); + // let rowid_2 = 555; + // let conn = DbInitializerReal::default() + // .initialize(&home_dir, DbInitializationConfig::test_default()) + // .unwrap(); + // let record_seeds = [ + // (&wallet_1.to_string(), 12345, 1_000_000_000, None), + // (&wallet_2.to_string(), 23456, 1_000_000_111, Some(540)), + // ]; + // record_seeds + // .into_iter() + // .for_each(|(wallet, balance, timestamp, rowid_opt)| { + // insert_payable_record_fn(&*conn, wallet, balance, timestamp, rowid_opt) + // }); + // + // let result = explanatory_extension(&*conn, &[(&wallet_1, rowid_1), (&wallet_2, rowid_2)]); + // + // assert_eq!(result, "\ + // The demanded data according to ( Wallet: 0x000000000000000000000000000000686f6f6761, Rowid: 550 ), \ + // ( Wallet: 0x000000000000000000000000000000626f6f6761, Rowid: 555 ) looks different from \ + // the resulting state ( Wallet: 0x000000000000000000000000000000626f6f6761, Rowid: 540 ), \ + // ( Wallet: 0x000000000000000000000000000000686f6f6761, Rowid: N/A )!. \ + // Operation failed.\n\ + // Notes:\n\ + // a) if row ids have stayed non-populated it points out that writing failed but without the double \ + // payment threat,\n\ + // b) if some accounts on the resulting side are missing, other kind of serious issues should be \ + // suspected but see other\npoints to figure out if you were put in danger of double payment,\n\ + // c) seeing ids different from those demanded might be a sign of some payments having been doubled.\n\ + // The operation which is supposed to clear out the ids of the payments previously requested for \ + // this account\nprobably had not managed to complete successfully before another payment was \ + // requested: preventive measures failed.\n".to_string()) } #[test] fn mark_pending_payables_rowids_handles_general_sql_error() { - let home_dir = ensure_node_home_directory_exists( - "payable_dao", - "mark_pending_payables_rowids_handles_general_sql_error", - ); - let wallet = make_wallet("booga"); - let rowid = 656; - let conn = payable_read_only_conn(&home_dir); - let conn_wrapped = ConnectionWrapperReal::new(conn); - let subject = PayableDaoReal::new(Box::new(conn_wrapped)); - - let result = subject.mark_pending_payables_rowids(&[(&wallet, rowid)]); - - assert_eq!( - result, - Err(PayableDaoError::RusqliteError( - "Multi-row update to mark pending payable hit these errors: [SqliteFailure(\ - Error { code: ReadOnly, extended_code: 8 }, Some(\"attempt to write a readonly \ - database\"))]" - .to_string() - )) - ) + // TODO Will be an object of removal in GH-662 + // let home_dir = ensure_node_home_directory_exists( + // "payable_dao", + // "mark_pending_payables_rowids_handles_general_sql_error", + // ); + // let wallet = make_wallet("booga"); + // let rowid = 656; + // let single_mark_instruction = MarkPendingPayableID::new(wallet.address(), rowid); + // let conn = payable_read_only_conn(&home_dir); + // let conn_wrapped = ConnectionWrapperReal::new(conn); + // let subject = PayableDaoReal::new(Box::new(conn_wrapped)); + // + // let result = subject.mark_pending_payables_rowids(&[single_mark_instruction]); + // + // assert_eq!( + // result, + // Err(PayableDaoError::RusqliteError( + // "Multi-row update to mark pending payable hit these errors: [SqliteFailure(\ + // Error { code: ReadOnly, extended_code: 8 }, Some(\"attempt to write a readonly \ + // database\"))]" + // .to_string() + // )) + // ) } #[test] - #[should_panic(expected = "broken code: empty input is not permit to enter this method")] + //#[should_panic(expected = "broken code: empty input is not permit to enter this method")] fn mark_pending_payables_rowids_is_strict_about_empty_input() { - let wrapped_conn = ConnectionWrapperMock::default(); - let subject = PayableDaoReal::new(Box::new(wrapped_conn)); - - let _ = subject.mark_pending_payables_rowids(&[]); + // TODO Will be an object of removal in GH-662 + // let wrapped_conn = ConnectionWrapperMock::default(); + // let subject = PayableDaoReal::new(Box::new(wrapped_conn)); + // + // let _ = subject.mark_pending_payables_rowids(&[]); } struct TestSetupValuesHolder { - fingerprint_1: PendingPayableFingerprint, - fingerprint_2: PendingPayableFingerprint, - wallet_1: Wallet, - wallet_2: Wallet, - previous_timestamp_1: SystemTime, - previous_timestamp_2: SystemTime, + account_1: TxWalletAndTimestamp, + account_2: TxWalletAndTimestamp, + } + + struct TxWalletAndTimestamp { + pending_payable: SentTx, + previous_timestamp: SystemTime, } - fn make_fingerprint_pair_and_insert_initial_payable_records( + struct TestInputs { + hash: TxHash, + previous_timestamp: SystemTime, + new_payable_timestamp: SystemTime, + receiver_wallet: Address, + initial_amount_wei: u128, + balance_change: u128, + } + + fn insert_initial_payable_records_and_return_sent_txs( conn: &dyn ConnectionWrapper, - initial_amount_1: u128, - initial_amount_2: u128, - balance_change_1: u128, - balance_change_2: u128, + (initial_amount_1, balance_change_1): (u128, u128), + (initial_amount_2, balance_change_2): (u128, u128), ) -> TestSetupValuesHolder { - let hash_1 = make_tx_hash(12345); - let rowid_1 = 789; - let previous_timestamp_1_s = 190_000_000; - let new_payable_timestamp_1 = from_time_t(199_000_000); - let wallet_1 = make_wallet("bobble"); - let hash_2 = make_tx_hash(54321); - let rowid_2 = 792; - let previous_timestamp_2_s = 187_100_000; - let new_payable_timestamp_2 = from_time_t(191_333_000); - let wallet_2 = make_wallet("booble bobble"); - { + let now = SystemTime::now(); + let (account_1, account_2) = [ + TestInputs { + hash: make_tx_hash(12345), + previous_timestamp: now.checked_sub(Duration::from_secs(45_000)).unwrap(), + new_payable_timestamp: now.checked_sub(Duration::from_secs(2)).unwrap(), + receiver_wallet: make_wallet("bobbles").address(), + initial_amount_wei: initial_amount_1, + balance_change: balance_change_1, + }, + TestInputs { + hash: make_tx_hash(54321), + previous_timestamp: now.checked_sub(Duration::from_secs(22_000)).unwrap(), + new_payable_timestamp: now.checked_sub(Duration::from_secs(2)).unwrap(), + receiver_wallet: make_wallet("yet more bobbles").address(), + initial_amount_wei: initial_amount_2, + balance_change: balance_change_2, + }, + ] + .into_iter() + .enumerate() + .map(|(idx, test_inputs)| { insert_payable_record_fn( conn, - &wallet_1.to_string(), - i128::try_from(initial_amount_1).unwrap(), - previous_timestamp_1_s, - Some(rowid_1 as i64), + &format!("{:?}", test_inputs.receiver_wallet), + i128::try_from(test_inputs.initial_amount_wei).unwrap(), + to_unix_timestamp(test_inputs.previous_timestamp), + // TODO argument will be eliminated in GH-662 + None, ); - insert_payable_record_fn( - conn, - &wallet_2.to_string(), - i128::try_from(initial_amount_2).unwrap(), - previous_timestamp_2_s, - Some(rowid_2 as i64), - ) - } - let fingerprint_1 = PendingPayableFingerprint { - rowid: rowid_1, - timestamp: new_payable_timestamp_1, - hash: hash_1, - attempt: 1, - amount: balance_change_1, - process_error: None, - }; - let fingerprint_2 = PendingPayableFingerprint { - rowid: rowid_2, - timestamp: new_payable_timestamp_2, - hash: hash_2, - attempt: 1, - amount: balance_change_2, - process_error: None, - }; - let previous_timestamp_1 = from_time_t(previous_timestamp_1_s); - let previous_timestamp_2 = from_time_t(previous_timestamp_2_s); + let mut sent_tx = make_sent_tx((idx as u32 + 1) * 1234); + sent_tx.hash = test_inputs.hash; + sent_tx.amount_minor = test_inputs.balance_change; + sent_tx.receiver_address = test_inputs.receiver_wallet; + sent_tx.timestamp = to_unix_timestamp(test_inputs.new_payable_timestamp); + sent_tx.amount_minor = test_inputs.balance_change; + + TxWalletAndTimestamp { + pending_payable: sent_tx, + previous_timestamp: test_inputs.previous_timestamp, + } + }) + .collect_tuple() + .unwrap(); + TestSetupValuesHolder { - fingerprint_1, - fingerprint_2, - wallet_1, - wallet_2, - previous_timestamp_1, - previous_timestamp_2, + account_1, + account_2, } } @@ -965,7 +1002,7 @@ mod tests { //initial (1, 9999) let initial_changing_end_resulting_values = (initial, 11111, initial as u128 - 11111); //change (-1, abs(i64::MIN) - 11111) - transaction_confirmed_works( + test_transaction_confirmed_works( "transaction_confirmed_works_without_overflow", initial_changing_end_resulting_values, ) @@ -978,77 +1015,80 @@ mod tests { //initial (0, 10000) //change (-1, abs(i64::MIN) - 111) //10000 + (abs(i64::MIN) - 111) > i64::MAX -> overflow - transaction_confirmed_works( + test_transaction_confirmed_works( "transaction_confirmed_works_hitting_overflow", initial_changing_end_resulting_values, ) } - fn transaction_confirmed_works( + fn test_transaction_confirmed_works( test_name: &str, (initial_amount_1, balance_change_1, expected_balance_after_1): (u128, u128, u128), ) { let home_dir = ensure_node_home_directory_exists("payable_dao", test_name); - //a hardcoded set that just makes a complement to the crucial, supplied one; this points to the ability of - //handling multiple transactions together + // A hardcoded set that just makes a complement to the crucial, supplied first one; this + // shows the ability to handle multiple transactions together let initial_amount_2 = 5_678_901; let balance_change_2 = 678_902; let expected_balance_after_2 = 4_999_999; let boxed_conn = DbInitializerReal::default() .initialize(&home_dir, DbInitializationConfig::test_default()) .unwrap(); - let setup_holder = make_fingerprint_pair_and_insert_initial_payable_records( + let setup_holder = insert_initial_payable_records_and_return_sent_txs( boxed_conn.as_ref(), - initial_amount_1, - initial_amount_2, - balance_change_1, - balance_change_2, + (initial_amount_1, balance_change_1), + (initial_amount_2, balance_change_2), ); let subject = PayableDaoReal::new(boxed_conn); - let status_1_before_opt = subject.account_status(&setup_holder.wallet_1); - let status_2_before_opt = subject.account_status(&setup_holder.wallet_2); + let wallet_1 = Wallet::from(setup_holder.account_1.pending_payable.receiver_address); + let wallet_2 = Wallet::from(setup_holder.account_2.pending_payable.receiver_address); + let status_1_before_opt = subject.account_status(&wallet_1); + let status_2_before_opt = subject.account_status(&wallet_2); let result = subject.transactions_confirmed(&[ - setup_holder.fingerprint_1.clone(), - setup_holder.fingerprint_2.clone(), + setup_holder.account_1.pending_payable.clone(), + setup_holder.account_2.pending_payable.clone(), ]); assert_eq!(result, Ok(())); + let expected_last_paid_timestamp_1 = + from_unix_timestamp(to_unix_timestamp(setup_holder.account_1.previous_timestamp)); + let expected_last_paid_timestamp_2 = + from_unix_timestamp(to_unix_timestamp(setup_holder.account_2.previous_timestamp)); + // TODO yes these pending_payable_opt values are unsensible now but it will eventually be all cleaned up with GH-662 let expected_status_before_1 = PayableAccount { - wallet: setup_holder.wallet_1.clone(), + wallet: wallet_1.clone(), balance_wei: initial_amount_1, - last_paid_timestamp: setup_holder.previous_timestamp_1, - pending_payable_opt: Some(PendingPayableId::new( - setup_holder.fingerprint_1.rowid, - H256::from_uint(&U256::from(0)), - )), //hash is just garbage + last_paid_timestamp: expected_last_paid_timestamp_1, + pending_payable_opt: None, }; let expected_status_before_2 = PayableAccount { - wallet: setup_holder.wallet_2.clone(), + wallet: wallet_2.clone(), balance_wei: initial_amount_2, - last_paid_timestamp: setup_holder.previous_timestamp_2, - pending_payable_opt: Some(PendingPayableId::new( - setup_holder.fingerprint_2.rowid, - H256::from_uint(&U256::from(0)), - )), //hash is just garbage + last_paid_timestamp: expected_last_paid_timestamp_2, + pending_payable_opt: None, }; let expected_resulting_status_1 = PayableAccount { - wallet: setup_holder.wallet_1.clone(), + wallet: wallet_1.clone(), balance_wei: expected_balance_after_1, - last_paid_timestamp: setup_holder.fingerprint_1.timestamp, + last_paid_timestamp: from_unix_timestamp( + setup_holder.account_1.pending_payable.timestamp, + ), pending_payable_opt: None, }; let expected_resulting_status_2 = PayableAccount { - wallet: setup_holder.wallet_2.clone(), + wallet: wallet_2.clone(), balance_wei: expected_balance_after_2, - last_paid_timestamp: setup_holder.fingerprint_2.timestamp, + last_paid_timestamp: from_unix_timestamp( + setup_holder.account_2.pending_payable.timestamp, + ), pending_payable_opt: None, }; assert_eq!(status_1_before_opt, Some(expected_status_before_1)); assert_eq!(status_2_before_opt, Some(expected_status_before_2)); - let resulting_account_1_opt = subject.account_status(&setup_holder.wallet_1); + let resulting_account_1_opt = subject.account_status(&wallet_1); assert_eq!(resulting_account_1_opt, Some(expected_resulting_status_1)); - let resulting_account_2_opt = subject.account_status(&setup_holder.wallet_2); + let resulting_account_2_opt = subject.account_status(&wallet_2); assert_eq!(resulting_account_2_opt, Some(expected_resulting_status_2)) } @@ -1060,22 +1100,20 @@ mod tests { ); let conn = payable_read_only_conn(&home_dir); let conn_wrapped = Box::new(ConnectionWrapperReal::new(conn)); - let mut pending_payable_fingerprint = make_pending_payable_fingerprint(); - let hash = make_tx_hash(12345); - let rowid = 789; - pending_payable_fingerprint.hash = hash; - pending_payable_fingerprint.rowid = rowid; + let mut confirmed_transaction = make_sent_tx(5); + confirmed_transaction.amount_minor = 12345; + let wallet_address = confirmed_transaction.receiver_address; let subject = PayableDaoReal::new(conn_wrapped); - let result = subject.transactions_confirmed(&[pending_payable_fingerprint]); + let result = subject.transactions_confirmed(&[confirmed_transaction]); assert_eq!( result, - Err(PayableDaoError::RusqliteError( + Err(PayableDaoError::RusqliteError(format!( "Error from invalid update command for payable table and change of -12345 wei to \ - 'pending_payable_rowid = 789' with error 'attempt to write a readonly database'" - .to_string() - )) + 'wallet_address = {:?}' with error 'attempt to write a readonly database'", + wallet_address + ))) ) } @@ -1083,26 +1121,21 @@ mod tests { #[should_panic( expected = "Overflow detected with 340282366920938463463374607431768211455: cannot be converted from u128 to i128" )] - fn transaction_confirmed_works_for_overflow_from_amount_stored_in_pending_payable_fingerprint() - { + fn transaction_confirmed_works_for_overflow_from_sent_tx_record() { let home_dir = ensure_node_home_directory_exists( "payable_dao", - "transaction_confirmed_works_for_overflow_from_amount_stored_in_pending_payable_fingerprint", + "transaction_confirmed_works_for_overflow_from_sent_tx_record", ); let subject = PayableDaoReal::new( DbInitializerReal::default() .initialize(&home_dir, DbInitializationConfig::test_default()) .unwrap(), ); - let mut pending_payable_fingerprint = make_pending_payable_fingerprint(); - let hash = make_tx_hash(12345); - let rowid = 789; - pending_payable_fingerprint.hash = hash; - pending_payable_fingerprint.rowid = rowid; - pending_payable_fingerprint.amount = u128::MAX; + let mut sent_tx = make_sent_tx(456); + sent_tx.amount_minor = u128::MAX; //The overflow occurs before we start modifying the payable account so we can have the database empty - let _ = subject.transactions_confirmed(&[pending_payable_fingerprint]); + let _ = subject.transactions_confirmed(&[sent_tx]); } #[test] @@ -1114,46 +1147,45 @@ mod tests { let conn = DbInitializerReal::default() .initialize(&home_dir, DbInitializationConfig::test_default()) .unwrap(); - let setup_holder = make_fingerprint_pair_and_insert_initial_payable_records( + let setup_holder = insert_initial_payable_records_and_return_sent_txs( conn.as_ref(), - 1_111_111, - 2_222_222, - 111_111, - 222_222, + (1_111_111, 111_111), + (2_222_222, 222_222), ); + let wallet_1 = Wallet::from(setup_holder.account_1.pending_payable.receiver_address); + let wallet_2 = Wallet::from(setup_holder.account_2.pending_payable.receiver_address); conn.prepare("delete from payable where wallet_address = ?") .unwrap() - .execute(&[&setup_holder.wallet_2]) + .execute(&[&wallet_2.to_string()]) .unwrap(); let subject = PayableDaoReal::new(conn); - let expected_account = PayableAccount { - wallet: setup_holder.wallet_1.clone(), - balance_wei: 1_111_111 - setup_holder.fingerprint_1.amount, - last_paid_timestamp: setup_holder.fingerprint_1.timestamp, - pending_payable_opt: None, - }; - let result = subject - .transactions_confirmed(&[setup_holder.fingerprint_1, setup_holder.fingerprint_2]); + let result = subject.transactions_confirmed(&[ + setup_holder.account_1.pending_payable, + setup_holder.account_2.pending_payable, + ]); + let expected_err_msg = format!( + "Expected 1 row to be changed for the unique key \ + {} but got this count: 0", + wallet_2 + ); assert_eq!( result, - Err(PayableDaoError::RusqliteError( - "Expected 1 row to be changed for the unique key 792 but got this count: 0" - .to_string() - )) + Err(PayableDaoError::RusqliteError(expected_err_msg)) ); - let account_1_opt = subject.account_status(&setup_holder.wallet_1); - assert_eq!(account_1_opt, Some(expected_account)); - let account_2_opt = subject.account_status(&setup_holder.wallet_2); + let expected_resulting_balance_1 = 1_111_111 - 111_111; + let account_1 = subject.account_status(&wallet_1).unwrap(); + assert_eq!(account_1.balance_wei, expected_resulting_balance_1); + let account_2_opt = subject.account_status(&wallet_2); assert_eq!(account_2_opt, None); } #[test] - fn non_pending_payables_should_return_an_empty_vec_when_the_database_is_empty() { + fn retrieve_payables_should_return_an_empty_vec_when_the_database_is_empty() { let home_dir = ensure_node_home_directory_exists( "payable_dao", - "non_pending_payables_should_return_an_empty_vec_when_the_database_is_empty", + "retrieve_payables_should_return_an_empty_vec_when_the_database_is_empty", ); let subject = PayableDaoReal::new( DbInitializerReal::default() @@ -1161,16 +1193,16 @@ mod tests { .unwrap(), ); - let result = subject.non_pending_payables(); + let result = subject.retrieve_payables(None); assert_eq!(result, vec![]); } #[test] - fn non_pending_payables_should_return_payables_with_no_pending_transaction() { + fn retrieve_payables_should_return_payables_with_no_pending_transaction() { let home_dir = ensure_node_home_directory_exists( "payable_dao", - "non_pending_payables_should_return_payables_with_no_pending_transaction", + "retrieve_payables_should_return_payables_with_no_pending_transaction", ); let subject = PayableDaoReal::new( DbInitializerReal::default() @@ -1195,7 +1227,7 @@ mod tests { insert("0x0000000000000000000000000000000000626172", Some(16)); insert(&make_wallet("barfoo").to_string(), None); - let result = subject.non_pending_payables(); + let result = subject.retrieve_payables(None); assert_eq!( result, @@ -1203,13 +1235,66 @@ mod tests { PayableAccount { wallet: make_wallet("foobar"), balance_wei: 1234567890123456 as u128, - last_paid_timestamp: from_time_t(111_111_111), + last_paid_timestamp: from_unix_timestamp(111_111_111), pending_payable_opt: None }, PayableAccount { wallet: make_wallet("barfoo"), balance_wei: 1234567890123456 as u128, - last_paid_timestamp: from_time_t(111_111_111), + last_paid_timestamp: from_unix_timestamp(111_111_111), + pending_payable_opt: None + }, + ] + ); + } + + #[test] + fn retrieve_payables_should_return_payables_by_addresses() { + let home_dir = ensure_node_home_directory_exists( + "payable_dao", + "retrieve_payables_should_return_payables_by_addresses", + ); + let subject = PayableDaoReal::new( + DbInitializerReal::default() + .initialize(&home_dir, DbInitializationConfig::test_default()) + .unwrap(), + ); + let mut flags = OpenFlags::empty(); + flags.insert(OpenFlags::SQLITE_OPEN_READ_WRITE); + let conn = Connection::open_with_flags(&home_dir.join(DATABASE_FILE), flags).unwrap(); + let conn = ConnectionWrapperReal::new(conn); + let insert = |wallet: &str, pending_payable_rowid: Option| { + insert_payable_record_fn( + &conn, + wallet, + 1234567890123456, + 111_111_111, + pending_payable_rowid, + ); + }; + let wallet1 = make_wallet("foobar"); + let wallet2 = make_wallet("barfoo"); + insert("0x0000000000000000000000000000000000666f6f", Some(15)); + insert(&wallet1.to_string(), None); + insert("0x0000000000000000000000000000000000626172", None); + insert(&wallet2.to_string(), None); + let set = BTreeSet::from([wallet1.address(), wallet2.address()]); + + let result = subject.retrieve_payables(Some(ByAddresses(set))); + + assert_eq!( + result, + vec![ + PayableAccount { + wallet: wallet2, + balance_wei: 1234567890123456 as u128, + last_paid_timestamp: from_unix_timestamp(111_111_111), + pending_payable_opt: None + }, + PayableAccount { + wallet: wallet1, + balance_wei: 1234567890123456 as u128, + last_paid_timestamp: from_unix_timestamp(111_111_111), pending_payable_opt: None }, ] @@ -1301,10 +1386,10 @@ mod tests { #[test] fn custom_query_in_top_records_mode_with_default_ordering() { - //Accounts of balances smaller than one gwei don't qualify. - //Two accounts differ only in debt's age but not balance which allows to check doubled ordering, - //here by balance and then by age. - let now = now_time_t(); + // Accounts of balances smaller than one gwei don't qualify. + // Two accounts differ only in the debt age but not the balance which allows to check double + // ordering, primarily by balance and then age. + let now = current_unix_timestamp(); let main_test_setup = accounts_for_tests_of_top_records(now); let subject = custom_query_test_body_for_payable( "custom_query_in_top_records_mode_with_default_ordering", @@ -1324,25 +1409,19 @@ mod tests { PayableAccount { wallet: Wallet::new("0x2222222222222222222222222222222222222222"), balance_wei: 7_562_000_300_000, - last_paid_timestamp: from_time_t(now - 86_001), + last_paid_timestamp: from_unix_timestamp(now - 86_001), pending_payable_opt: None }, PayableAccount { wallet: Wallet::new("0x5555555555555555555555555555555555555555"), balance_wei: 10_000_000_100, - last_paid_timestamp: from_time_t(now - 86_401), - pending_payable_opt: Some(PendingPayableId::new( - 1, - H256::from_str( - "abc4546cce78230a2312e12f3acb78747340456fe5237896666100143abcd223" - ) - .unwrap() - )) + last_paid_timestamp: from_unix_timestamp(now - 86_401), + pending_payable_opt: None }, PayableAccount { wallet: Wallet::new("0x4444444444444444444444444444444444444444"), balance_wei: 10_000_000_100, - last_paid_timestamp: from_time_t(now - 86_300), + last_paid_timestamp: from_unix_timestamp(now - 86_300), pending_payable_opt: None }, ] @@ -1351,10 +1430,10 @@ mod tests { #[test] fn custom_query_in_top_records_mode_ordered_by_age() { - //Accounts of balances smaller than one gwei don't qualify. - //Two accounts differ only in balance but not in the debt's age which allows to check doubled ordering, - //here by age and then by balance. - let now = now_time_t(); + // Accounts of balances smaller than one gwei don't qualify. + // Two accounts differ only in the debt age but not the balance which allows to check double + // ordering, primarily by balance and then age. + let now = current_unix_timestamp(); let main_test_setup = accounts_for_tests_of_top_records(now); let subject = custom_query_test_body_for_payable( "custom_query_in_top_records_mode_ordered_by_age", @@ -1374,25 +1453,19 @@ mod tests { PayableAccount { wallet: Wallet::new("0x5555555555555555555555555555555555555555"), balance_wei: 10_000_000_100, - last_paid_timestamp: from_time_t(now - 86_401), - pending_payable_opt: Some(PendingPayableId::new( - 1, - H256::from_str( - "abc4546cce78230a2312e12f3acb78747340456fe5237896666100143abcd223" - ) - .unwrap() - )) + last_paid_timestamp: from_unix_timestamp(now - 86_401), + pending_payable_opt: None }, PayableAccount { wallet: Wallet::new("0x1111111111111111111111111111111111111111"), balance_wei: 1_000_000_002, - last_paid_timestamp: from_time_t(now - 86_401), + last_paid_timestamp: from_unix_timestamp(now - 86_401), pending_payable_opt: None }, PayableAccount { wallet: Wallet::new("0x4444444444444444444444444444444444444444"), balance_wei: 10_000_000_100, - last_paid_timestamp: from_time_t(now - 86_300), + last_paid_timestamp: from_unix_timestamp(now - 86_300), pending_payable_opt: None }, ] @@ -1420,9 +1493,9 @@ mod tests { #[test] fn custom_query_in_range_mode() { - //Two accounts differ only in debt's age but not balance which allows to check doubled ordering, - //by balance and then by age. - let now = now_time_t(); + // Two accounts differ only in the debt age but not the balance which allows to check double + // ordering, primarily by balance and then age. + let now = current_unix_timestamp(); let main_setup = |conn: &dyn ConnectionWrapper, insert: InsertPayableHelperFn| { insert( conn, @@ -1482,7 +1555,7 @@ mod tests { max_age_s: 200000, min_amount_gwei: 500_000_000, max_amount_gwei: 35_000_000_000, - timestamp: from_time_t(now), + timestamp: from_unix_timestamp(now), }) .unwrap(); @@ -1492,26 +1565,20 @@ mod tests { PayableAccount { wallet: Wallet::new("0x7777777777777777777777777777777777777777"), balance_wei: gwei_to_wei(2_500_647_000_u32), - last_paid_timestamp: from_time_t(now - 80_333), + last_paid_timestamp: from_unix_timestamp(now - 80_333), pending_payable_opt: None }, PayableAccount { wallet: Wallet::new("0x6666666666666666666666666666666666666666"), balance_wei: gwei_to_wei(1_800_456_000_u32), - last_paid_timestamp: from_time_t(now - 100_401), + last_paid_timestamp: from_unix_timestamp(now - 100_401), pending_payable_opt: None }, PayableAccount { wallet: Wallet::new("0x2222222222222222222222222222222222222222"), balance_wei: gwei_to_wei(1_800_456_000_u32), - last_paid_timestamp: from_time_t(now - 55_120), - pending_payable_opt: Some(PendingPayableId::new( - 1, - H256::from_str( - "abc4546cce78230a2312e12f3acb78747340456fe5237896666100143abcd223" - ) - .unwrap() - )) + last_paid_timestamp: from_unix_timestamp(now - 55_120), + pending_payable_opt: None } ] ); @@ -1519,7 +1586,7 @@ mod tests { #[test] fn range_query_does_not_display_values_from_below_1_gwei() { - let now = now_time_t(); + let now = current_unix_timestamp(); let timestamp_1 = now - 11_001; let timestamp_2 = now - 5000; let main_setup = |conn: &dyn ConnectionWrapper, insert: InsertPayableHelperFn| { @@ -1558,7 +1625,7 @@ mod tests { vec![PayableAccount { wallet: Wallet::new("0x2222222222222222222222222222222222222222"), balance_wei: 30_000_300_000, - last_paid_timestamp: from_time_t(timestamp_2), + last_paid_timestamp: from_unix_timestamp(timestamp_2), pending_payable_opt: None },] ) @@ -1570,7 +1637,7 @@ mod tests { let conn = DbInitializerReal::default() .initialize(&home_dir, DbInitializationConfig::test_default()) .unwrap(); - let timestamp = utils::now_time_t(); + let timestamp = utils::current_unix_timestamp(); insert_payable_record_fn( &*conn, "0x1111111111111111111111111111111111111111", @@ -1677,19 +1744,17 @@ mod tests { .initialize(&home_dir, DbInitializationConfig::test_default()) .unwrap(); main_setup_fn(conn.as_ref(), &insert_payable_record_fn); - - let pending_payable_account: &[&dyn ToSql] = &[ - &String::from("0xabc4546cce78230a2312e12f3acb78747340456fe5237896666100143abcd223"), - &40, - &478945, - &177777777, - &1, - ]; - conn - .prepare("insert into pending_payable (transaction_hash, amount_high_b, amount_low_b, payable_timestamp, attempt) values (?,?,?,?,?)") - .unwrap() - .execute(pending_payable_account) - .unwrap(); PayableDaoReal::new(conn) } + + #[test] + fn payable_retrieve_condition_to_str_works() { + let address_1 = make_wallet("first").address(); + let address_2 = make_wallet("second").address(); + assert_eq!( + PayableRetrieveCondition::ByAddresses(BTreeSet::from([address_1, address_2])) + .to_string(), + "AND wallet_address IN ('0x0000000000000000000000000000006669727374', '0x00000000000000000000000000007365636f6e64')" + ); + } } diff --git a/node/src/accountant/db_access_objects/pending_payable_dao.rs b/node/src/accountant/db_access_objects/pending_payable_dao.rs deleted file mode 100644 index 67c779ce08..0000000000 --- a/node/src/accountant/db_access_objects/pending_payable_dao.rs +++ /dev/null @@ -1,950 +0,0 @@ -// Copyright (c) 2019, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. - -use crate::accountant::db_access_objects::utils::{ - from_time_t, to_time_t, DaoFactoryReal, VigilantRusqliteFlatten, -}; -use crate::accountant::db_big_integer::big_int_divider::BigIntDivider; -use crate::accountant::{checked_conversion, comma_joined_stringifiable}; -use crate::blockchain::blockchain_bridge::PendingPayableFingerprint; -use crate::blockchain::blockchain_interface::blockchain_interface_web3::HashAndAmount; -use crate::database::rusqlite_wrappers::ConnectionWrapper; -use crate::sub_lib::wallet::Wallet; -use masq_lib::utils::ExpectValue; -use rusqlite::Row; -use std::collections::HashSet; -use std::fmt::Debug; -use std::str::FromStr; -use std::time::SystemTime; -use web3::types::H256; - -#[derive(Debug, PartialEq, Eq)] -pub enum PendingPayableDaoError { - InsertionFailed(String), - UpdateFailed(String), - SignConversionError(u64), - RecordCannotBeRead, - RecordDeletion(String), - ErrorMarkFailed(String), -} - -#[derive(Debug)] -pub struct TransactionHashes { - pub rowid_results: Vec<(u64, H256)>, - pub no_rowid_results: Vec, -} - -pub trait PendingPayableDao { - // Note that the order of the returned results is not guaranteed - fn fingerprints_rowids(&self, hashes: &[H256]) -> TransactionHashes; - fn return_all_errorless_fingerprints(&self) -> Vec; - fn insert_new_fingerprints( - &self, - hashes_and_amounts: &[HashAndAmount], - batch_wide_timestamp: SystemTime, - ) -> Result<(), PendingPayableDaoError>; - fn delete_fingerprints(&self, ids: &[u64]) -> Result<(), PendingPayableDaoError>; - fn increment_scan_attempts(&self, ids: &[u64]) -> Result<(), PendingPayableDaoError>; - fn mark_failures(&self, ids: &[u64]) -> Result<(), PendingPayableDaoError>; -} - -impl PendingPayableDao for PendingPayableDaoReal<'_> { - fn fingerprints_rowids(&self, hashes: &[H256]) -> TransactionHashes { - //Vec<(Option, H256)> { - fn hash_and_rowid_in_single_row(row: &Row) -> rusqlite::Result<(u64, H256)> { - let hash_str: String = row.get(0).expectv("hash"); - let hash = H256::from_str(&hash_str[2..]).expect("hash inserted right turned wrong"); - let sqlite_signed_rowid: i64 = row.get(1).expectv("rowid"); - let rowid = u64::try_from(sqlite_signed_rowid).expect("SQlite goes from 1 to i64:MAX"); - Ok((rowid, hash)) - } - - let sql = format!( - "select transaction_hash, rowid from pending_payable where transaction_hash in ({})", - comma_joined_stringifiable(hashes, |hash| format!("'{:?}'", hash)) - ); - - let all_found_records = self - .conn - .prepare(&sql) - .expect("Internal error") - .query_map([], hash_and_rowid_in_single_row) - .expect("map query failed") - .vigilant_flatten() - .collect::>(); - let hashes_of_found_records = all_found_records - .iter() - .map(|(_, hash)| *hash) - .collect::>(); - let hashes_of_missing_rowids = hashes - .iter() - .filter(|hash| !hashes_of_found_records.contains(hash)) - .cloned() - .collect(); - - TransactionHashes { - rowid_results: all_found_records, - no_rowid_results: hashes_of_missing_rowids, - } - } - - fn return_all_errorless_fingerprints(&self) -> Vec { - let mut stm = self - .conn - .prepare( - "select rowid, transaction_hash, amount_high_b, amount_low_b, \ - payable_timestamp, attempt from pending_payable where process_error is null", - ) - .expect("Internal error"); - stm.query_map([], |row| { - let rowid: u64 = Self::get_with_expect(row, 0); - let transaction_hash: String = Self::get_with_expect(row, 1); - let amount_high_bytes: i64 = Self::get_with_expect(row, 2); - let amount_low_bytes: i64 = Self::get_with_expect(row, 3); - let timestamp: i64 = Self::get_with_expect(row, 4); - let attempt: u16 = Self::get_with_expect(row, 5); - Ok(PendingPayableFingerprint { - rowid, - timestamp: from_time_t(timestamp), - hash: H256::from_str(&transaction_hash[2..]).unwrap_or_else(|e| { - panic!( - "Invalid hash format (\"{}\": {:?}) - database corrupt", - transaction_hash, e - ) - }), - attempt, - amount: checked_conversion::(BigIntDivider::reconstitute( - amount_high_bytes, - amount_low_bytes, - )), - process_error: None, - }) - }) - .expect("rusqlite failure") - .vigilant_flatten() - .collect() - } - - fn insert_new_fingerprints( - &self, - hashes_and_amounts: &[HashAndAmount], - batch_wide_timestamp: SystemTime, - ) -> Result<(), PendingPayableDaoError> { - fn values_clause_for_fingerprints_to_insert( - hashes_and_amounts: &[HashAndAmount], - batch_wide_timestamp: SystemTime, - ) -> String { - let time_t = to_time_t(batch_wide_timestamp); - comma_joined_stringifiable(hashes_and_amounts, |hash_and_amount| { - let amount_checked = checked_conversion::(hash_and_amount.amount); - let (high_bytes, low_bytes) = BigIntDivider::deconstruct(amount_checked); - format!( - "('{:?}', {}, {}, {}, 1, null)", - hash_and_amount.hash, high_bytes, low_bytes, time_t - ) - }) - } - - let insert_sql = format!( - "insert into pending_payable (\ - transaction_hash, amount_high_b, amount_low_b, payable_timestamp, attempt, process_error\ - ) values {}", - values_clause_for_fingerprints_to_insert(hashes_and_amounts, batch_wide_timestamp) - ); - match self - .conn - .prepare(&insert_sql) - .expect("Internal error") - .execute([]) - { - Ok(x) if x == hashes_and_amounts.len() => Ok(()), - Ok(x) => panic!( - "expected {} changed rows but got {}", - hashes_and_amounts.len(), - x - ), - Err(e) => Err(PendingPayableDaoError::InsertionFailed(e.to_string())), - } - } - - fn delete_fingerprints(&self, ids: &[u64]) -> Result<(), PendingPayableDaoError> { - let sql = format!( - "delete from pending_payable where rowid in ({})", - Self::serialize_ids(ids) - ); - match self - .conn - .prepare(&sql) - .expect("delete command wrong") - .execute([]) - { - Ok(x) if x == ids.len() => Ok(()), - Ok(num) => panic!( - "deleting fingerprint, expected {} rows to be changed, but the actual number is {}", - ids.len(), - num - ), - Err(e) => Err(PendingPayableDaoError::RecordDeletion(e.to_string())), - } - } - - fn increment_scan_attempts(&self, ids: &[u64]) -> Result<(), PendingPayableDaoError> { - let sql = format!( - "update pending_payable set attempt = attempt + 1 where rowid in ({})", - Self::serialize_ids(ids) - ); - match self.conn.prepare(&sql).expect("Internal error").execute([]) { - Ok(num) if num == ids.len() => Ok(()), - Ok(num) => panic!( - "Database corrupt: updating fingerprints: expected to update {} rows but did {}", - ids.len(), - num - ), - Err(e) => Err(PendingPayableDaoError::UpdateFailed(e.to_string())), - } - } - - fn mark_failures(&self, ids: &[u64]) -> Result<(), PendingPayableDaoError> { - let sql = format!( - "update pending_payable set process_error = 'ERROR' where rowid in ({})", - Self::serialize_ids(ids) - ); - match self - .conn - .prepare(&sql) - .expect("Internal error") - .execute([]) { - Ok(num) if num == ids.len() => Ok(()), - Ok(num) => - panic!( - "Database corrupt: marking failure at fingerprints: expected to change {} rows but did {}", - ids.len(), num - ) - , - Err(e) => Err(PendingPayableDaoError::ErrorMarkFailed(e.to_string())), - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PendingPayable { - pub recipient_wallet: Wallet, - pub hash: H256, -} - -impl PendingPayable { - pub fn new(recipient_wallet: Wallet, hash: H256) -> Self { - Self { - recipient_wallet, - hash, - } - } -} - -#[derive(Debug)] -pub struct PendingPayableDaoReal<'a> { - conn: Box, -} - -impl<'a> PendingPayableDaoReal<'a> { - pub fn new(conn: Box) -> Self { - Self { conn } - } - - fn get_with_expect(row: &Row, index: usize) -> T { - row.get(index).expect("database is corrupt") - } - - fn serialize_ids(ids: &[u64]) -> String { - comma_joined_stringifiable(ids, |id| id.to_string()) - } -} - -pub trait PendingPayableDaoFactory { - fn make(&self) -> Box; -} - -impl PendingPayableDaoFactory for DaoFactoryReal { - fn make(&self) -> Box { - Box::new(PendingPayableDaoReal::new(self.make_connection())) - } -} - -#[cfg(test)] -mod tests { - use crate::accountant::checked_conversion; - use crate::accountant::db_access_objects::pending_payable_dao::{ - PendingPayableDao, PendingPayableDaoError, PendingPayableDaoReal, - }; - use crate::accountant::db_access_objects::utils::from_time_t; - use crate::accountant::db_big_integer::big_int_divider::BigIntDivider; - use crate::blockchain::blockchain_bridge::PendingPayableFingerprint; - use crate::blockchain::blockchain_interface::blockchain_interface_web3::HashAndAmount; - use crate::blockchain::test_utils::make_tx_hash; - use crate::database::db_initializer::{ - DbInitializationConfig, DbInitializer, DbInitializerReal, DATABASE_FILE, - }; - use crate::database::rusqlite_wrappers::ConnectionWrapperReal; - use crate::database::test_utils::ConnectionWrapperMock; - use masq_lib::test_utils::utils::ensure_node_home_directory_exists; - use rusqlite::{Connection, OpenFlags}; - use std::str::FromStr; - use std::time::SystemTime; - use web3::types::H256; - - #[test] - fn insert_new_fingerprints_happy_path() { - let home_dir = ensure_node_home_directory_exists( - "pending_payable_dao", - "insert_new_fingerprints_happy_path", - ); - let wrapped_conn = DbInitializerReal::default() - .initialize(&home_dir, DbInitializationConfig::test_default()) - .unwrap(); - let hash_1 = make_tx_hash(4546); - let amount_1 = 55556; - let hash_2 = make_tx_hash(6789); - let amount_2 = 44445; - let batch_wide_timestamp = from_time_t(200_000_000); - let subject = PendingPayableDaoReal::new(wrapped_conn); - let hash_and_amount_1 = HashAndAmount { - hash: hash_1, - amount: amount_1, - }; - let hash_and_amount_2 = HashAndAmount { - hash: hash_2, - amount: amount_2, - }; - - let _ = subject - .insert_new_fingerprints( - &[hash_and_amount_1, hash_and_amount_2], - batch_wide_timestamp, - ) - .unwrap(); - - let records = subject.return_all_errorless_fingerprints(); - assert_eq!( - records, - vec![ - PendingPayableFingerprint { - rowid: 1, - timestamp: batch_wide_timestamp, - hash: hash_and_amount_1.hash, - attempt: 1, - amount: hash_and_amount_1.amount, - process_error: None - }, - PendingPayableFingerprint { - rowid: 2, - timestamp: batch_wide_timestamp, - hash: hash_and_amount_2.hash, - attempt: 1, - amount: hash_and_amount_2.amount, - process_error: None - } - ] - ) - } - - #[test] - fn insert_new_fingerprints_sad_path() { - let home_dir = ensure_node_home_directory_exists( - "pending_payable_dao", - "insert_new_fingerprints_sad_path", - ); - { - DbInitializerReal::default() - .initialize(&home_dir, DbInitializationConfig::test_default()) - .unwrap(); - } - let conn_read_only = Connection::open_with_flags( - home_dir.join(DATABASE_FILE), - OpenFlags::SQLITE_OPEN_READ_ONLY, - ) - .unwrap(); - let wrapped_conn = ConnectionWrapperReal::new(conn_read_only); - let hash = make_tx_hash(45466); - let amount = 55556; - let timestamp = from_time_t(200_000_000); - let subject = PendingPayableDaoReal::new(Box::new(wrapped_conn)); - let hash_and_amount = HashAndAmount { hash, amount }; - - let result = subject.insert_new_fingerprints(&[hash_and_amount], timestamp); - - assert_eq!( - result, - Err(PendingPayableDaoError::InsertionFailed( - "attempt to write a readonly database".to_string() - )) - ) - } - - #[test] - #[should_panic(expected = "expected 1 changed rows but got 0")] - fn insert_new_fingerprints_number_of_returned_rows_different_than_expected() { - let setup_conn = Connection::open_in_memory().unwrap(); - // injecting a by-plan failing statement into the mocked connection in order to provoke - // a reaction that would've been untestable directly on the table the act is closely coupled with - let statement = { - setup_conn - .execute("create table example (id integer)", []) - .unwrap(); - setup_conn.prepare("select id from example").unwrap() - }; - let wrapped_conn = ConnectionWrapperMock::default().prepare_result(Ok(statement)); - let hash_1 = make_tx_hash(4546); - let amount_1 = 55556; - let batch_wide_timestamp = from_time_t(200_000_000); - let subject = PendingPayableDaoReal::new(Box::new(wrapped_conn)); - let hash_and_amount = HashAndAmount { - hash: hash_1, - amount: amount_1, - }; - - let _ = subject.insert_new_fingerprints(&[hash_and_amount], batch_wide_timestamp); - } - - #[test] - fn fingerprints_rowids_when_records_reachable() { - let home_dir = ensure_node_home_directory_exists( - "pending_payable_dao", - "fingerprints_rowids_when_records_reachable", - ); - let wrapped_conn = DbInitializerReal::default() - .initialize(&home_dir, DbInitializationConfig::test_default()) - .unwrap(); - let subject = PendingPayableDaoReal::new(wrapped_conn); - let timestamp = from_time_t(195_000_000); - // use full range tx hashes because SqLite has tendencies to see the value as a hex and convert it to an integer, - // then complain about its excessive size if supplied in unquoted strings - let hash_1 = - H256::from_str("b4bc263278d3a82a652a8d73a6bfd8ec0ba1a63923bbb4f38147fb8a943da26a") - .unwrap(); - let hash_2 = - H256::from_str("5a2909e7bb71943c82a94d9beb04e230351541fc14619ee8bb9b7372ea88ba39") - .unwrap(); - let hash_and_amount_1 = HashAndAmount { - hash: hash_1, - amount: 4567, - }; - let hash_and_amount_2 = HashAndAmount { - hash: hash_2, - amount: 6789, - }; - let fingerprints_init_input = vec![hash_and_amount_1, hash_and_amount_2]; - { - subject - .insert_new_fingerprints(&fingerprints_init_input, timestamp) - .unwrap(); - } - - let result = subject.fingerprints_rowids(&[hash_1, hash_2]); - - let first_expected_pair = &(1, hash_1); - assert!( - result.rowid_results.contains(first_expected_pair), - "Returned rowid pairs should have contained {:?} but all it did is {:?}", - first_expected_pair, - result.rowid_results - ); - let second_expected_pair = &(2, hash_2); - assert!( - result.rowid_results.contains(second_expected_pair), - "Returned rowid pairs should have contained {:?} but all it did is {:?}", - second_expected_pair, - result.rowid_results - ); - assert_eq!(result.rowid_results.len(), 2); - } - - #[test] - fn fingerprints_rowids_when_nonexistent_records() { - let home_dir = ensure_node_home_directory_exists( - "pending_payable_dao", - "fingerprints_rowids_when_nonexistent_records", - ); - let wrapped_conn = DbInitializerReal::default() - .initialize(&home_dir, DbInitializationConfig::test_default()) - .unwrap(); - let subject = PendingPayableDaoReal::new(wrapped_conn); - let hash_1 = make_tx_hash(11119); - let hash_2 = make_tx_hash(22229); - let hash_3 = make_tx_hash(33339); - let hash_4 = make_tx_hash(44449); - // For more illustrative results, I use the official tooling but also generate one extra record before the chief one for - // this test, and in the end, I delete the first one. It leaves a single record still in but with the rowid 2 instead of - // just an ambiguous 1 - subject - .insert_new_fingerprints( - &[HashAndAmount { - hash: hash_2, - amount: 8901234, - }], - SystemTime::now(), - ) - .unwrap(); - subject - .insert_new_fingerprints( - &[HashAndAmount { - hash: hash_3, - amount: 1234567, - }], - SystemTime::now(), - ) - .unwrap(); - subject.delete_fingerprints(&[1]).unwrap(); - - let result = subject.fingerprints_rowids(&[hash_1, hash_2, hash_3, hash_4]); - - assert_eq!(result.rowid_results, vec![(2, hash_3),]); - assert_eq!(result.no_rowid_results, vec![hash_1, hash_2, hash_4]); - } - - #[test] - fn return_all_errorless_fingerprints_works_when_no_records_with_error_marks() { - let home_dir = ensure_node_home_directory_exists( - "pending_payable_dao", - "return_all_errorless_fingerprints_works_when_no_records_with_error_marks", - ); - let wrapped_conn = DbInitializerReal::default() - .initialize(&home_dir, DbInitializationConfig::test_default()) - .unwrap(); - let subject = PendingPayableDaoReal::new(wrapped_conn); - let batch_wide_timestamp = from_time_t(195_000_000); - let hash_1 = make_tx_hash(11119); - let amount_1 = 787; - let hash_2 = make_tx_hash(10000); - let amount_2 = 333; - let hash_and_amount_1 = HashAndAmount { - hash: hash_1, - amount: amount_1, - }; - let hash_and_amount_2 = HashAndAmount { - hash: hash_2, - amount: amount_2, - }; - - { - subject - .insert_new_fingerprints( - &[hash_and_amount_1, hash_and_amount_2], - batch_wide_timestamp, - ) - .unwrap(); - } - - let result = subject.return_all_errorless_fingerprints(); - - assert_eq!( - result, - vec![ - PendingPayableFingerprint { - rowid: 1, - timestamp: batch_wide_timestamp, - hash: hash_1, - attempt: 1, - amount: amount_1, - process_error: None - }, - PendingPayableFingerprint { - rowid: 2, - timestamp: batch_wide_timestamp, - hash: hash_2, - attempt: 1, - amount: amount_2, - process_error: None - } - ] - ) - } - - #[test] - fn return_all_errorless_fingerprints_works_when_some_records_with_error_marks() { - let home_dir = ensure_node_home_directory_exists( - "pending_payable_dao", - "return_all_errorless_fingerprints_works_when_some_records_with_error_marks", - ); - let wrapped_conn = DbInitializerReal::default() - .initialize(&home_dir, DbInitializationConfig::test_default()) - .unwrap(); - let subject = PendingPayableDaoReal::new(wrapped_conn); - let timestamp = from_time_t(198_000_000); - let hash = make_tx_hash(10000); - let amount = 333; - let hash_and_amount_1 = HashAndAmount { - hash: make_tx_hash(11119), - amount: 2000, - }; - let hash_and_amount_2 = HashAndAmount { hash, amount }; - { - subject - .insert_new_fingerprints(&[hash_and_amount_1, hash_and_amount_2], timestamp) - .unwrap(); - subject.mark_failures(&[1]).unwrap(); - } - - let result = subject.return_all_errorless_fingerprints(); - - assert_eq!( - result, - vec![PendingPayableFingerprint { - rowid: 2, - timestamp, - hash, - attempt: 1, - amount, - process_error: None - }] - ) - } - - #[test] - #[should_panic( - expected = "Invalid hash format (\"silly_hash\": Invalid character 'l' at position 0) - database corrupt" - )] - fn return_all_errorless_fingerprints_panics_on_malformed_hash() { - let home_dir = ensure_node_home_directory_exists( - "pending_payable_dao", - "return_all_errorless_fingerprints_panics_on_malformed_hash", - ); - let wrapped_conn = DbInitializerReal::default() - .initialize(&home_dir, DbInitializationConfig::test_default()) - .unwrap(); - { - wrapped_conn - .prepare("insert into pending_payable \ - (rowid, transaction_hash, amount_high_b, amount_low_b, payable_timestamp, attempt, process_error) \ - values (1, 'silly_hash', 4, 111, 10000000000, 1, null)") - .unwrap() - .execute([]) - .unwrap(); - } - let subject = PendingPayableDaoReal::new(wrapped_conn); - - let _ = subject.return_all_errorless_fingerprints(); - } - - #[test] - fn delete_fingerprints_happy_path() { - let home_dir = ensure_node_home_directory_exists( - "pending_payable_dao", - "delete_fingerprints_happy_path", - ); - let conn = DbInitializerReal::default() - .initialize(&home_dir, DbInitializationConfig::test_default()) - .unwrap(); - let subject = PendingPayableDaoReal::new(conn); - { - subject - .insert_new_fingerprints( - &[ - HashAndAmount { - hash: make_tx_hash(1234), - amount: 1111, - }, - HashAndAmount { - hash: make_tx_hash(2345), - amount: 5555, - }, - HashAndAmount { - hash: make_tx_hash(3456), - amount: 2222, - }, - ], - SystemTime::now(), - ) - .unwrap(); - } - - let result = subject.delete_fingerprints(&[2, 3]); - - assert_eq!(result, Ok(())); - let records_in_the_db = subject.return_all_errorless_fingerprints(); - let record_left_in = &records_in_the_db[0]; - assert_eq!(record_left_in.hash, make_tx_hash(1234)); - assert_eq!(record_left_in.rowid, 1); - assert_eq!(records_in_the_db.len(), 1); - } - - #[test] - fn delete_fingerprints_sad_path() { - let home_dir = ensure_node_home_directory_exists( - "pending_payable_dao", - "delete_fingerprints_sad_path", - ); - { - DbInitializerReal::default() - .initialize(&home_dir, DbInitializationConfig::test_default()) - .unwrap(); - } - let conn_read_only = Connection::open_with_flags( - home_dir.join(DATABASE_FILE), - OpenFlags::SQLITE_OPEN_READ_ONLY, - ) - .unwrap(); - let wrapped_conn = ConnectionWrapperReal::new(conn_read_only); - let rowid = 45; - let subject = PendingPayableDaoReal::new(Box::new(wrapped_conn)); - - let result = subject.delete_fingerprints(&[rowid]); - - assert_eq!( - result, - Err(PendingPayableDaoError::RecordDeletion( - "attempt to write a readonly database".to_string() - )) - ) - } - - #[test] - #[should_panic( - expected = "deleting fingerprint, expected 2 rows to be changed, but the actual number is 1" - )] - fn delete_fingerprints_changed_different_number_of_rows_than_expected() { - let home_dir = ensure_node_home_directory_exists( - "pending_payable_dao", - "delete_fingerprints_changed_different_number_of_rows_than_expected", - ); - let conn = DbInitializerReal::default() - .initialize(&home_dir, DbInitializationConfig::test_default()) - .unwrap(); - let rowid_1 = 1; - let rowid_2 = 2; - let subject = PendingPayableDaoReal::new(conn); - { - subject - .insert_new_fingerprints( - &[HashAndAmount { - hash: make_tx_hash(666666), - amount: 5555, - }], - SystemTime::now(), - ) - .unwrap(); - } - - let _ = subject.delete_fingerprints(&[rowid_1, rowid_2]); - } - - #[test] - fn increment_scan_attempts_works() { - let home_dir = ensure_node_home_directory_exists( - "pending_payable_dao", - "increment_scan_attempts_works", - ); - let conn = DbInitializerReal::default() - .initialize(&home_dir, DbInitializationConfig::test_default()) - .unwrap(); - let hash_1 = make_tx_hash(345); - let hash_2 = make_tx_hash(456); - let hash_3 = make_tx_hash(567); - let hash_and_amount_1 = HashAndAmount { - hash: hash_1, - amount: 1122, - }; - let hash_and_amount_2 = HashAndAmount { - hash: hash_2, - amount: 2233, - }; - let hash_and_amount_3 = HashAndAmount { - hash: hash_3, - amount: 3344, - }; - let timestamp = from_time_t(190_000_000); - let subject = PendingPayableDaoReal::new(conn); - { - subject - .insert_new_fingerprints( - &[hash_and_amount_1, hash_and_amount_2, hash_and_amount_3], - timestamp, - ) - .unwrap(); - } - - let result = subject.increment_scan_attempts(&[2, 3]); - - assert_eq!(result, Ok(())); - let mut all_records = subject.return_all_errorless_fingerprints(); - assert_eq!(all_records.len(), 3); - let record_1 = all_records.remove(0); - assert_eq!(record_1.hash, hash_1); - assert_eq!(record_1.attempt, 1); - let record_2 = all_records.remove(0); - assert_eq!(record_2.hash, hash_2); - assert_eq!(record_2.attempt, 2); - let record_3 = all_records.remove(0); - assert_eq!(record_3.hash, hash_3); - assert_eq!(record_3.attempt, 2); - } - - #[test] - fn increment_scan_attempts_works_sad_path() { - let home_dir = ensure_node_home_directory_exists( - "pending_payable_dao", - "increment_scan_attempts_works_sad_path", - ); - { - DbInitializerReal::default() - .initialize(&home_dir, DbInitializationConfig::test_default()) - .unwrap(); - } - let conn_read_only = Connection::open_with_flags( - home_dir.join(DATABASE_FILE), - OpenFlags::SQLITE_OPEN_READ_ONLY, - ) - .unwrap(); - let wrapped_conn = ConnectionWrapperReal::new(conn_read_only); - let subject = PendingPayableDaoReal::new(Box::new(wrapped_conn)); - - let result = subject.increment_scan_attempts(&[1]); - - assert_eq!( - result, - Err(PendingPayableDaoError::UpdateFailed( - "attempt to write a readonly database".to_string() - )) - ) - } - - #[test] - #[should_panic( - expected = "Database corrupt: updating fingerprints: expected to update 2 rows but did 0" - )] - fn increment_scan_attempts_panics_on_unexpected_row_change_count() { - let home_dir = ensure_node_home_directory_exists( - "pending_payable_dao", - "increment_scan_attempts_panics_on_unexpected_row_change_count", - ); - let conn = DbInitializerReal::default() - .initialize(&home_dir, DbInitializationConfig::test_default()) - .unwrap(); - let subject = PendingPayableDaoReal::new(conn); - - let _ = subject.increment_scan_attempts(&[1, 2]); - } - - #[test] - fn mark_failures_works() { - let home_dir = - ensure_node_home_directory_exists("pending_payable_dao", "mark_failures_works"); - let conn = DbInitializerReal::default() - .initialize(&home_dir, DbInitializationConfig::test_default()) - .unwrap(); - let hash_1 = make_tx_hash(555); - let amount_1 = 1234; - let hash_2 = make_tx_hash(666); - let amount_2 = 2345; - let hash_and_amount_1 = HashAndAmount { - hash: hash_1, - amount: amount_1, - }; - let hash_and_amount_2 = HashAndAmount { - hash: hash_2, - amount: amount_2, - }; - let timestamp = from_time_t(190_000_000); - let subject = PendingPayableDaoReal::new(conn); - { - subject - .insert_new_fingerprints(&[hash_and_amount_1, hash_and_amount_2], timestamp) - .unwrap(); - } - - let result = subject.mark_failures(&[2]); - - assert_eq!(result, Ok(())); - let assert_conn = Connection::open(home_dir.join(DATABASE_FILE)).unwrap(); - let mut assert_stm = assert_conn - .prepare("select rowid, transaction_hash, amount_high_b, amount_low_b, payable_timestamp, attempt, process_error from pending_payable") - .unwrap(); - let found_fingerprints = assert_stm - .query_map([], |row| { - let rowid: u64 = row.get(0).unwrap(); - let transaction_hash: String = row.get(1).unwrap(); - let amount_high_b: i64 = row.get(2).unwrap(); - let amount_low_b: i64 = row.get(3).unwrap(); - let timestamp: i64 = row.get(4).unwrap(); - let attempt: u16 = row.get(5).unwrap(); - let process_error: Option = row.get(6).unwrap(); - Ok(PendingPayableFingerprint { - rowid, - timestamp: from_time_t(timestamp), - hash: H256::from_str(&transaction_hash[2..]).unwrap(), - attempt, - amount: checked_conversion::(BigIntDivider::reconstitute( - amount_high_b, - amount_low_b, - )), - process_error, - }) - }) - .unwrap() - .flatten() - .collect::>(); - assert_eq!( - *found_fingerprints, - vec![ - PendingPayableFingerprint { - rowid: 1, - timestamp, - hash: hash_1, - attempt: 1, - amount: amount_1, - process_error: None - }, - PendingPayableFingerprint { - rowid: 2, - timestamp, - hash: hash_2, - attempt: 1, - amount: amount_2, - process_error: Some("ERROR".to_string()) - } - ] - ) - } - - #[test] - fn mark_failures_sad_path() { - let home_dir = - ensure_node_home_directory_exists("pending_payable_dao", "mark_failures_sad_path"); - { - DbInitializerReal::default() - .initialize(&home_dir, DbInitializationConfig::test_default()) - .unwrap(); - } - let conn_read_only = Connection::open_with_flags( - home_dir.join(DATABASE_FILE), - OpenFlags::SQLITE_OPEN_READ_ONLY, - ) - .unwrap(); - let wrapped_conn = ConnectionWrapperReal::new(conn_read_only); - let subject = PendingPayableDaoReal::new(Box::new(wrapped_conn)); - - let result = subject.mark_failures(&[1]); - - assert_eq!( - result, - Err(PendingPayableDaoError::ErrorMarkFailed( - "attempt to write a readonly database".to_string() - )) - ) - } - - #[test] - #[should_panic( - expected = "Database corrupt: marking failure at fingerprints: expected to change 2 rows but did 0" - )] - fn mark_failures_panics_on_wrong_row_change_count() { - let home_dir = ensure_node_home_directory_exists( - "pending_payable_dao", - "mark_failures_panics_on_wrong_row_change_count", - ); - let conn = DbInitializerReal::default() - .initialize(&home_dir, DbInitializationConfig::test_default()) - .unwrap(); - let subject = PendingPayableDaoReal::new(conn); - - let _ = subject.mark_failures(&[10, 20]); - } -} diff --git a/node/src/accountant/db_access_objects/receivable_dao.rs b/node/src/accountant/db_access_objects/receivable_dao.rs index 284fc2113e..b80908603d 100644 --- a/node/src/accountant/db_access_objects/receivable_dao.rs +++ b/node/src/accountant/db_access_objects/receivable_dao.rs @@ -4,7 +4,7 @@ use crate::accountant::checked_conversion; use crate::accountant::db_access_objects::receivable_dao::ReceivableDaoError::RusqliteError; use crate::accountant::db_access_objects::utils; use crate::accountant::db_access_objects::utils::{ - sum_i128_values_from_table, to_time_t, AssemblerFeeder, CustomQuery, DaoFactoryReal, + sum_i128_values_from_table, to_unix_timestamp, AssemblerFeeder, CustomQuery, DaoFactoryReal, RangeStmConfig, ThresholdUtils, TopStmConfig, VigilantRusqliteFlatten, }; use crate::accountant::db_big_integer::big_int_db_processor::KeyVariants::WalletAddress; @@ -55,7 +55,7 @@ pub trait ReceivableDao { &self, now: SystemTime, wallet: &Wallet, - amount: u128, + amount_minor: u128, ) -> Result<(), ReceivableDaoError>; fn more_money_received( @@ -112,7 +112,7 @@ impl ReceivableDao for ReceivableDaoReal { &self, timestamp: SystemTime, wallet: &Wallet, - amount: u128, + amount_minor: u128, ) -> Result<(), ReceivableDaoError> { let main_sql = "insert into receivable (wallet_address, balance_high_b, balance_low_b, last_received_timestamp) values \ (:wallet, :balance_high_b, :balance_low_b, :last_received_timestamp) on conflict (wallet_address) do update set \ @@ -120,12 +120,12 @@ impl ReceivableDao for ReceivableDaoReal { let update_clause_with_compensated_overflow = "update receivable set balance_high_b = :balance_high_b, balance_low_b = :balance_low_b \ where wallet_address = :wallet"; - let last_received_timestamp = to_time_t(timestamp); + let last_received_timestamp = to_unix_timestamp(timestamp); let params = SQLParamsBuilder::default() .key(WalletAddress(wallet)) .wei_change(WeiChange::new( "balance", - amount, + amount_minor, WeiChangeDirection::Addition, )) .other_params(vec![ParamByUse::BeforeOverflowOnly( @@ -216,7 +216,7 @@ impl ReceivableDao for ReceivableDaoReal { named_params! { ":debt_threshold": checked_conversion::(payment_thresholds.debt_threshold_gwei), ":slope": slope, - ":sugg_and_grace": payment_thresholds.sugg_and_grace(to_time_t(now)), + ":sugg_and_grace": payment_thresholds.sugg_and_grace(to_unix_timestamp(now)), ":permanent_debt_allowed_high_b": permanent_debt_allowed_high_b, ":permanent_debt_allowed_low_b": permanent_debt_allowed_low_b }, @@ -338,7 +338,7 @@ impl ReceivableDaoReal { where wallet_address = :wallet"; match received_payments.iter().try_for_each(|received_payment| { - let last_received_timestamp = to_time_t(timestamp); + let last_received_timestamp = to_unix_timestamp(timestamp); let params = SQLParamsBuilder::default() .key(WalletAddress(&received_payment.from)) .wei_change(WeiChange::new( @@ -415,7 +415,7 @@ impl ReceivableDaoReal { Ok(ReceivableAccount { wallet, balance_wei: BigIntDivider::reconstitute(high_bytes, low_bytes), - last_received_timestamp: utils::from_time_t(last_received_timestamp), + last_received_timestamp: utils::from_unix_timestamp(last_received_timestamp), }) } e => panic!( @@ -494,7 +494,7 @@ impl TableNameDAO for ReceivableDaoReal { mod tests { use super::*; use crate::accountant::db_access_objects::utils::{ - from_time_t, now_time_t, to_time_t, CustomQuery, + current_unix_timestamp, from_unix_timestamp, to_unix_timestamp, CustomQuery, }; use crate::accountant::gwei_to_wei; use crate::accountant::test_utils::{ @@ -610,8 +610,8 @@ mod tests { "receivable_dao", "more_money_receivable_works_for_new_address", ); - let payment_time_t = to_time_t(SystemTime::now()) - 1111; - let payment_time = from_time_t(payment_time_t); + let payment_time_t = to_unix_timestamp(SystemTime::now()) - 1111; + let payment_time = from_unix_timestamp(payment_time_t); let wallet = make_wallet("booga"); let subject = ReceivableDaoReal::new( DbInitializerReal::default() @@ -626,7 +626,10 @@ mod tests { let status = subject.account_status(&wallet).unwrap(); assert_eq!(status.wallet, wallet); assert_eq!(status.balance_wei, 1234); - assert_eq!(to_time_t(status.last_received_timestamp), payment_time_t); + assert_eq!( + to_unix_timestamp(status.last_received_timestamp), + payment_time_t + ); } #[test] @@ -662,8 +665,8 @@ mod tests { assert_eq!(status.wallet, wallet); assert_eq!(status.balance_wei, expected_balance); assert_eq!( - to_time_t(status.last_received_timestamp), - to_time_t(SystemTime::UNIX_EPOCH) + to_unix_timestamp(status.last_received_timestamp), + to_unix_timestamp(SystemTime::UNIX_EPOCH) ); }; assert_account(wallet, 1234 + 2345); @@ -696,8 +699,8 @@ mod tests { assert_eq!(status.wallet, wallet); assert_eq!(status.balance_wei, 1234 + i64::MAX as i128); assert_eq!( - to_time_t(status.last_received_timestamp), - to_time_t(SystemTime::UNIX_EPOCH) + to_unix_timestamp(status.last_received_timestamp), + to_unix_timestamp(SystemTime::UNIX_EPOCH) ); } @@ -813,15 +816,15 @@ mod tests { assert_eq!(status1.wallet, debtor1); assert_eq!(status1.balance_wei, first_expected_result); assert_eq!( - to_time_t(status1.last_received_timestamp), - to_time_t(payment_time) + to_unix_timestamp(status1.last_received_timestamp), + to_unix_timestamp(payment_time) ); let status2 = subject.account_status(&debtor2).unwrap(); assert_eq!(status2.wallet, debtor2); assert_eq!(status2.balance_wei, second_expected_result); assert_eq!( - to_time_t(status2.last_received_timestamp), - to_time_t(payment_time) + to_unix_timestamp(status2.last_received_timestamp), + to_unix_timestamp(payment_time) ); } @@ -888,8 +891,8 @@ mod tests { first_initial_balance as i128 - 1111 ); assert_eq!( - to_time_t(actual_record_1.last_received_timestamp), - to_time_t(time_of_change) + to_unix_timestamp(actual_record_1.last_received_timestamp), + to_unix_timestamp(time_of_change) ); let actual_record_2 = subject.account_status(&unknown_wallet); assert!(actual_record_2.is_none()); @@ -900,8 +903,8 @@ mod tests { second_initial_balance as i128 - 9999 ); assert_eq!( - to_time_t(actual_record_3.last_received_timestamp), - to_time_t(time_of_change) + to_unix_timestamp(actual_record_3.last_received_timestamp), + to_unix_timestamp(time_of_change) ); let log_handler = TestLogHandler::new(); log_handler.exists_log_containing(&format!( @@ -1203,37 +1206,37 @@ mod tests { threshold_interval_sec: 100, unban_below_gwei: 0, }; - let now = now_time_t(); + let now = current_unix_timestamp(); let mut not_delinquent_inside_grace_period = make_receivable_account(1234, false); not_delinquent_inside_grace_period.balance_wei = gwei_to_wei(payment_thresholds.debt_threshold_gwei + 1); not_delinquent_inside_grace_period.last_received_timestamp = - from_time_t(payment_thresholds.sugg_and_grace(now) + 2); + from_unix_timestamp(payment_thresholds.sugg_and_grace(now) + 2); let mut not_delinquent_after_grace_below_slope = make_receivable_account(2345, false); not_delinquent_after_grace_below_slope.balance_wei = gwei_to_wei(payment_thresholds.debt_threshold_gwei - 2); not_delinquent_after_grace_below_slope.last_received_timestamp = - from_time_t(payment_thresholds.sugg_and_grace(now) - 1); + from_unix_timestamp(payment_thresholds.sugg_and_grace(now) - 1); let mut delinquent_above_slope_after_grace = make_receivable_account(3456, true); delinquent_above_slope_after_grace.balance_wei = gwei_to_wei(payment_thresholds.debt_threshold_gwei - 1); delinquent_above_slope_after_grace.last_received_timestamp = - from_time_t(payment_thresholds.sugg_and_grace(now) - 2); + from_unix_timestamp(payment_thresholds.sugg_and_grace(now) - 2); let mut not_delinquent_below_slope_before_stop = make_receivable_account(4567, false); not_delinquent_below_slope_before_stop.balance_wei = gwei_to_wei(payment_thresholds.permanent_debt_allowed_gwei + 1); not_delinquent_below_slope_before_stop.last_received_timestamp = - from_time_t(payment_thresholds.sugg_thru_decreasing(now) + 2); + from_unix_timestamp(payment_thresholds.sugg_thru_decreasing(now) + 2); let mut delinquent_above_slope_before_stop = make_receivable_account(5678, true); delinquent_above_slope_before_stop.balance_wei = gwei_to_wei(payment_thresholds.permanent_debt_allowed_gwei + 2); delinquent_above_slope_before_stop.last_received_timestamp = - from_time_t(payment_thresholds.sugg_thru_decreasing(now) + 1); + from_unix_timestamp(payment_thresholds.sugg_thru_decreasing(now) + 1); let mut not_delinquent_above_slope_after_stop = make_receivable_account(6789, false); not_delinquent_above_slope_after_stop.balance_wei = gwei_to_wei(payment_thresholds.permanent_debt_allowed_gwei - 1); not_delinquent_above_slope_after_stop.last_received_timestamp = - from_time_t(payment_thresholds.sugg_thru_decreasing(now) - 2); + from_unix_timestamp(payment_thresholds.sugg_thru_decreasing(now) - 2); let home_dir = ensure_node_home_directory_exists("accountant", "new_delinquencies"); let conn = make_connection_with_our_defined_sqlite_functions(&home_dir); add_receivable_account(&conn, ¬_delinquent_inside_grace_period); @@ -1244,7 +1247,7 @@ mod tests { add_receivable_account(&conn, ¬_delinquent_above_slope_after_stop); let subject = ReceivableDaoReal::new(conn); - let result = subject.new_delinquencies(from_time_t(now), &payment_thresholds); + let result = subject.new_delinquencies(from_unix_timestamp(now), &payment_thresholds); assert_contains(&result, &delinquent_above_slope_after_grace); assert_contains(&result, &delinquent_above_slope_before_stop); @@ -1261,15 +1264,15 @@ mod tests { threshold_interval_sec: 100, unban_below_gwei: 0, }; - let now = now_time_t(); + let now = current_unix_timestamp(); let mut not_delinquent = make_receivable_account(1234, false); not_delinquent.balance_wei = gwei_to_wei(105); not_delinquent.last_received_timestamp = - from_time_t(payment_thresholds.sugg_and_grace(now) - 25); + from_unix_timestamp(payment_thresholds.sugg_and_grace(now) - 25); let mut delinquent = make_receivable_account(2345, true); delinquent.balance_wei = gwei_to_wei(105); delinquent.last_received_timestamp = - from_time_t(payment_thresholds.sugg_and_grace(now) - 75); + from_unix_timestamp(payment_thresholds.sugg_and_grace(now) - 75); let home_dir = ensure_node_home_directory_exists("accountant", "new_delinquencies_shallow_slope"); let conn = make_connection_with_our_defined_sqlite_functions(&home_dir); @@ -1277,7 +1280,7 @@ mod tests { add_receivable_account(&conn, &delinquent); let subject = ReceivableDaoReal::new(conn); - let result = subject.new_delinquencies(from_time_t(now), &payment_thresholds); + let result = subject.new_delinquencies(from_unix_timestamp(now), &payment_thresholds); assert_contains(&result, &delinquent); assert_eq!(result.len(), 1); @@ -1293,15 +1296,15 @@ mod tests { threshold_interval_sec: 100, unban_below_gwei: 0, }; - let now = now_time_t(); + let now = current_unix_timestamp(); let mut not_delinquent = make_receivable_account(1234, false); not_delinquent.balance_wei = gwei_to_wei(600); not_delinquent.last_received_timestamp = - from_time_t(payment_thresholds.sugg_and_grace(now) - 25); + from_unix_timestamp(payment_thresholds.sugg_and_grace(now) - 25); let mut delinquent = make_receivable_account(2345, true); delinquent.balance_wei = gwei_to_wei(600); delinquent.last_received_timestamp = - from_time_t(payment_thresholds.sugg_and_grace(now) - 75); + from_unix_timestamp(payment_thresholds.sugg_and_grace(now) - 75); let home_dir = ensure_node_home_directory_exists("accountant", "new_delinquencies_steep_slope"); let conn = make_connection_with_our_defined_sqlite_functions(&home_dir); @@ -1309,7 +1312,7 @@ mod tests { add_receivable_account(&conn, &delinquent); let subject = ReceivableDaoReal::new(conn); - let result = subject.new_delinquencies(from_time_t(now), &payment_thresholds); + let result = subject.new_delinquencies(from_unix_timestamp(now), &payment_thresholds); assert_contains(&result, &delinquent); assert_eq!(result.len(), 1); @@ -1325,15 +1328,15 @@ mod tests { threshold_interval_sec: 100, unban_below_gwei: 0, }; - let now = now_time_t(); + let now = current_unix_timestamp(); let mut existing_delinquency = make_receivable_account(1234, true); existing_delinquency.balance_wei = gwei_to_wei(250); existing_delinquency.last_received_timestamp = - from_time_t(payment_thresholds.sugg_and_grace(now) - 1); + from_unix_timestamp(payment_thresholds.sugg_and_grace(now) - 1); let mut new_delinquency = make_receivable_account(2345, true); new_delinquency.balance_wei = gwei_to_wei(250); new_delinquency.last_received_timestamp = - from_time_t(payment_thresholds.sugg_and_grace(now) - 1); + from_unix_timestamp(payment_thresholds.sugg_and_grace(now) - 1); let home_dir = ensure_node_home_directory_exists( "receivable_dao", "new_delinquencies_does_not_find_existing_delinquencies", @@ -1344,7 +1347,7 @@ mod tests { add_banned_account(&conn, &existing_delinquency); let subject = ReceivableDaoReal::new(conn); - let result = subject.new_delinquencies(from_time_t(now), &payment_thresholds); + let result = subject.new_delinquencies(from_unix_timestamp(now), &payment_thresholds); assert_contains(&result, &new_delinquency); assert_eq!(result.len(), 1); @@ -1360,7 +1363,7 @@ mod tests { threshold_interval_sec: 100, unban_below_gwei: 0, }; - let now = now_time_t(); + let now = current_unix_timestamp(); let home_dir = ensure_node_home_directory_exists( "receivable_dao", "new_delinquencies_work_for_still_empty_tables", @@ -1368,7 +1371,7 @@ mod tests { let conn = make_connection_with_our_defined_sqlite_functions(&home_dir); let subject = ReceivableDaoReal::new(conn); - let result = subject.new_delinquencies(from_time_t(now), &payment_thresholds); + let result = subject.new_delinquencies(from_unix_timestamp(now), &payment_thresholds); assert!(result.is_empty()) } @@ -1388,24 +1391,24 @@ mod tests { threshold_interval_sec: 100, unban_below_gwei: 0, }; - let now = to_time_t(SystemTime::now()); + let now = to_unix_timestamp(SystemTime::now()); let sugg_and_grace = payment_thresholds.sugg_and_grace(now); let too_young_new_delinquency = ReceivableAccount { wallet: make_wallet("abc123"), balance_wei: 123_456_789_101_112, - last_received_timestamp: from_time_t(sugg_and_grace + 1), + last_received_timestamp: from_unix_timestamp(sugg_and_grace + 1), }; let ok_new_delinquency = ReceivableAccount { wallet: make_wallet("aaa999"), balance_wei: 123_456_789_101_112, - last_received_timestamp: from_time_t(sugg_and_grace - 1), + last_received_timestamp: from_unix_timestamp(sugg_and_grace - 1), }; let conn = make_connection_with_our_defined_sqlite_functions(&home_dir); add_receivable_account(&conn, &too_young_new_delinquency); add_receivable_account(&conn, &ok_new_delinquency.clone()); let subject = ReceivableDaoReal::new(conn); - let result = subject.new_delinquencies(from_time_t(now), &payment_thresholds); + let result = subject.new_delinquencies(from_unix_timestamp(now), &payment_thresholds); assert_eq!(result, vec![ok_new_delinquency]) } @@ -1536,7 +1539,7 @@ mod tests { #[test] fn custom_query_in_top_records_mode_default_ordering() { - let now = now_time_t(); + let now = current_unix_timestamp(); let main_test_setup = common_setup_of_accounts_for_tests_of_top_records(now); let subject = custom_query_test_body_for_receivable( "custom_query_in_top_records_mode_default_ordering", @@ -1556,17 +1559,17 @@ mod tests { ReceivableAccount { wallet: Wallet::new("0x5555555555555555555555555555555555555555"), balance_wei: 32_000_000_200, - last_received_timestamp: from_time_t(now - 86_480), + last_received_timestamp: from_unix_timestamp(now - 86_480), }, ReceivableAccount { wallet: Wallet::new("0x2222222222222222222222222222222222222222"), balance_wei: 1_000_000_001, - last_received_timestamp: from_time_t(now - 222_000), + last_received_timestamp: from_unix_timestamp(now - 222_000), }, ReceivableAccount { wallet: Wallet::new("0x1111111111111111111111111111111111111111"), balance_wei: 1_000_000_001, - last_received_timestamp: from_time_t(now - 86_480), + last_received_timestamp: from_unix_timestamp(now - 86_480), }, ] ); @@ -1574,7 +1577,7 @@ mod tests { #[test] fn custom_query_in_top_records_mode_ordered_by_age() { - let now = now_time_t(); + let now = current_unix_timestamp(); let main_test_setup = common_setup_of_accounts_for_tests_of_top_records(now); let subject = custom_query_test_body_for_receivable( "custom_query_in_top_records_mode_ordered_by_age", @@ -1594,17 +1597,17 @@ mod tests { ReceivableAccount { wallet: Wallet::new("0x2222222222222222222222222222222222222222"), balance_wei: 1_000_000_001, - last_received_timestamp: from_time_t(now - 222_000), + last_received_timestamp: from_unix_timestamp(now - 222_000), }, ReceivableAccount { wallet: Wallet::new("0x5555555555555555555555555555555555555555"), balance_wei: 32_000_000_200, - last_received_timestamp: from_time_t(now - 86_480), + last_received_timestamp: from_unix_timestamp(now - 86_480), }, ReceivableAccount { wallet: Wallet::new("0x1111111111111111111111111111111111111111"), balance_wei: 1_000_000_001, - last_received_timestamp: from_time_t(now - 86_480), + last_received_timestamp: from_unix_timestamp(now - 86_480), }, ] ); @@ -1633,7 +1636,7 @@ mod tests { fn custom_query_in_range_mode() { //Two accounts differ only in debt's age but not balance which allows to check doubled ordering, //by balance and then by age. - let now = now_time_t(); + let now = current_unix_timestamp(); let main_test_setup = |conn: &dyn ConnectionWrapper, insert: InsertReceivableHelperFn| { insert( conn, @@ -1693,7 +1696,7 @@ mod tests { max_age_s: 99000, min_amount_gwei: -560000, max_amount_gwei: 1_100_000_000, - timestamp: from_time_t(now), + timestamp: from_unix_timestamp(now), }) .unwrap(); @@ -1703,22 +1706,22 @@ mod tests { ReceivableAccount { wallet: Wallet::new("0x6666666666666666666666666666666666666666"), balance_wei: gwei_to_wei(1_050_444_230), - last_received_timestamp: from_time_t(now - 66_244), + last_received_timestamp: from_unix_timestamp(now - 66_244), }, ReceivableAccount { wallet: Wallet::new("0x5555555555555555555555555555555555555555"), balance_wei: gwei_to_wei(1_000_000_230), - last_received_timestamp: from_time_t(now - 86_000), + last_received_timestamp: from_unix_timestamp(now - 86_000), }, ReceivableAccount { wallet: Wallet::new("0x3333333333333333333333333333333333333333"), balance_wei: gwei_to_wei(1_000_000_230), - last_received_timestamp: from_time_t(now - 70_000), + last_received_timestamp: from_unix_timestamp(now - 70_000), }, ReceivableAccount { wallet: Wallet::new("0x8888888888888888888888888888888888888888"), balance_wei: gwei_to_wei(-90), - last_received_timestamp: from_time_t(now - 66_000), + last_received_timestamp: from_unix_timestamp(now - 66_000), } ] ); @@ -1726,20 +1729,20 @@ mod tests { #[test] fn range_query_does_not_display_values_from_below_1_gwei() { - let timestamp1 = now_time_t() - 5000; - let timestamp2 = now_time_t() - 3232; + let timestamp1 = current_unix_timestamp() - 5000; + let timestamp2 = current_unix_timestamp() - 3232; let main_setup = |conn: &dyn ConnectionWrapper, insert: InsertReceivableHelperFn| { insert( conn, "0x1111111111111111111111111111111111111111", 999_999_999, //smaller than 1 gwei - now_time_t() - 11_001, + current_unix_timestamp() - 11_001, ); insert( conn, "0x2222222222222222222222222222222222222222", -999_999_999, //smaller than -1 gwei - now_time_t() - 5_606, + current_unix_timestamp() - 5_606, ); insert( conn, @@ -1775,12 +1778,12 @@ mod tests { ReceivableAccount { wallet: Wallet::new("0x3333333333333333333333333333333333333333"), balance_wei: 30_000_300_000, - last_received_timestamp: from_time_t(timestamp1), + last_received_timestamp: from_unix_timestamp(timestamp1), }, ReceivableAccount { wallet: Wallet::new("0x4444444444444444444444444444444444444444"), balance_wei: -2_000_300_000, - last_received_timestamp: from_time_t(timestamp2), + last_received_timestamp: from_unix_timestamp(timestamp2), } ] ) @@ -1794,7 +1797,7 @@ mod tests { .unwrap(); let insert = insert_account_by_separate_values; - let timestamp = utils::now_time_t(); + let timestamp = utils::current_unix_timestamp(); insert( &*conn, "0x1111111111111111111111111111111111111111", @@ -1856,7 +1859,7 @@ mod tests { &account.wallet, &high_bytes, &low_bytes, - &to_time_t(account.last_received_timestamp), + &to_unix_timestamp(account.last_received_timestamp), ]; stmt.execute(params).unwrap(); } diff --git a/node/src/accountant/db_access_objects/sent_payable_and_failed_payable_data_conversion.rs b/node/src/accountant/db_access_objects/sent_payable_and_failed_payable_data_conversion.rs new file mode 100644 index 0000000000..26c7dd5fed --- /dev/null +++ b/node/src/accountant/db_access_objects/sent_payable_and_failed_payable_data_conversion.rs @@ -0,0 +1,137 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. + +use crate::accountant::db_access_objects::failed_payable_dao::{ + FailedTx, FailureReason, FailureStatus, +}; +use crate::accountant::db_access_objects::sent_payable_dao::{Detection, SentTx, TxStatus}; +use crate::blockchain::blockchain_interface::data_structures::TxBlock; + +impl From<(FailedTx, TxBlock)> for SentTx { + fn from((failed_tx, confirmation_block): (FailedTx, TxBlock)) -> Self { + SentTx { + hash: failed_tx.hash, + receiver_address: failed_tx.receiver_address, + amount_minor: failed_tx.amount_minor, + timestamp: failed_tx.timestamp, + gas_price_minor: failed_tx.gas_price_minor, + nonce: failed_tx.nonce, + status: TxStatus::Confirmed { + block_hash: format!("{:?}", confirmation_block.block_hash), + block_number: confirmation_block.block_number.as_u64(), + detection: Detection::Reclaim, + }, + } + } +} + +impl From<(SentTx, FailureReason)> for FailedTx { + fn from((sent_tx, failure_reason): (SentTx, FailureReason)) -> Self { + FailedTx { + hash: sent_tx.hash, + receiver_address: sent_tx.receiver_address, + amount_minor: sent_tx.amount_minor, + timestamp: sent_tx.timestamp, + gas_price_minor: sent_tx.gas_price_minor, + nonce: sent_tx.nonce, + reason: failure_reason, + status: FailureStatus::RetryRequired, + } + } +} + +#[cfg(test)] +mod tests { + use crate::accountant::db_access_objects::failed_payable_dao::{ + FailedTx, FailureReason, FailureStatus, + }; + use crate::accountant::db_access_objects::sent_payable_dao::{Detection, SentTx, TxStatus}; + use crate::accountant::db_access_objects::utils::to_unix_timestamp; + use crate::accountant::gwei_to_wei; + use crate::accountant::test_utils::make_transaction_block; + use crate::blockchain::errors::rpc_errors::{AppRpcErrorKind, LocalErrorKind}; + use crate::blockchain::errors::validation_status::ValidationStatus; + use crate::blockchain::test_utils::make_tx_hash; + use crate::test_utils::make_wallet; + use std::time::{Duration, SystemTime}; + + #[test] + fn sent_tx_record_can_be_converted_from_failed_tx_record() { + let failed_tx = FailedTx { + hash: make_tx_hash(456), + receiver_address: make_wallet("abc").address(), + amount_minor: 456789012, + timestamp: 345678974, + gas_price_minor: 123456789, + nonce: 11, + reason: FailureReason::PendingTooLong, + status: FailureStatus::RetryRequired, + }; + let tx_block = make_transaction_block(789); + + let result = SentTx::from((failed_tx.clone(), tx_block)); + + assert_eq!( + result, + SentTx { + hash: make_tx_hash(456), + receiver_address: make_wallet("abc").address(), + amount_minor: 456789012, + timestamp: 345678974, + gas_price_minor: 123456789, + nonce: 11, + status: TxStatus::Confirmed { + block_hash: + "0x000000000000000000000000000000000000000000000000000000003b9acd15" + .to_string(), + block_number: 491169069, + detection: Detection::Reclaim, + }, + } + ); + } + + #[test] + fn conversion_from_sent_tx_and_failure_reason_to_failed_tx_works() { + let sent_tx = SentTx { + hash: make_tx_hash(789), + receiver_address: make_wallet("receiver").address(), + amount_minor: 123_456_789, + timestamp: to_unix_timestamp( + SystemTime::now() + .checked_sub(Duration::from_secs(10_000)) + .unwrap(), + ), + gas_price_minor: gwei_to_wei(424_u64), + nonce: 456_u64.into(), + status: TxStatus::Pending(ValidationStatus::Waiting), + }; + + let result_1 = FailedTx::from((sent_tx.clone(), FailureReason::Reverted)); + let result_2 = FailedTx::from(( + sent_tx.clone(), + FailureReason::Submission(AppRpcErrorKind::Local(LocalErrorKind::Internal)), + )); + + assert_conversion_into_failed_tx(result_1, sent_tx.clone(), FailureReason::Reverted); + assert_conversion_into_failed_tx( + result_2, + sent_tx, + FailureReason::Submission(AppRpcErrorKind::Local(LocalErrorKind::Internal)), + ); + } + + fn assert_conversion_into_failed_tx( + result: FailedTx, + original_sent_tx: SentTx, + expected_failure_reason: FailureReason, + ) { + assert_eq!(result.hash, original_sent_tx.hash); + assert_eq!(result.receiver_address, original_sent_tx.receiver_address); + assert_eq!(result.amount_minor, original_sent_tx.amount_minor); + assert_eq!(result.timestamp, original_sent_tx.timestamp); + assert_eq!(result.gas_price_minor, original_sent_tx.gas_price_minor); + assert_eq!(result.nonce, original_sent_tx.nonce); + assert_eq!(result.status, FailureStatus::RetryRequired); + assert_eq!(result.reason, expected_failure_reason); + } +} diff --git a/node/src/accountant/db_access_objects/sent_payable_dao.rs b/node/src/accountant/db_access_objects/sent_payable_dao.rs new file mode 100644 index 0000000000..471e352aa9 --- /dev/null +++ b/node/src/accountant/db_access_objects/sent_payable_dao.rs @@ -0,0 +1,1666 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. + +use crate::accountant::db_access_objects::utils::{ + sql_values_of_sent_tx, DaoFactoryReal, TxHash, TxIdentifiers, +}; +use crate::accountant::db_access_objects::Transaction; +use crate::accountant::db_big_integer::big_int_divider::BigIntDivider; +use crate::accountant::{checked_conversion, join_with_commas, join_with_separator}; +use crate::blockchain::blockchain_interface::data_structures::TxBlock; +use crate::blockchain::errors::validation_status::ValidationStatus; +use crate::database::rusqlite_wrappers::ConnectionWrapper; +use ethereum_types::H256; +use itertools::Itertools; +use masq_lib::utils::ExpectValue; +use serde_derive::{Deserialize, Serialize}; +use std::cmp::Ordering; +use std::collections::{BTreeSet, HashMap}; +use std::fmt::{Display, Formatter}; +use std::str::FromStr; +use web3::types::Address; + +#[derive(Debug, PartialEq, Eq)] +pub enum SentPayableDaoError { + EmptyInput, + NoChange, + InvalidInput(String), + PartialExecution(String), + SqlExecutionFailed(String), +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct SentTx { + pub hash: TxHash, + pub receiver_address: Address, + pub amount_minor: u128, + pub timestamp: i64, + pub gas_price_minor: u128, + pub nonce: u64, + pub status: TxStatus, +} + +impl Transaction for SentTx { + fn hash(&self) -> TxHash { + self.hash + } + + fn receiver_address(&self) -> Address { + self.receiver_address + } + + fn amount(&self) -> u128 { + self.amount_minor + } + + fn timestamp(&self) -> i64 { + self.timestamp + } + + fn gas_price_wei(&self) -> u128 { + self.gas_price_minor + } + + fn nonce(&self) -> u64 { + self.nonce + } + + fn is_failed(&self) -> bool { + false + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum TxStatus { + Pending(ValidationStatus), + Confirmed { + block_hash: String, + block_number: u64, + detection: Detection, + }, +} + +impl PartialOrd for TxStatus { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +// Manual impl of Ord for enums makes sense because the derive macro determines the ordering +// by the order of the enum variants in its declaration, not only alphabetically. Swiping +// the position of the variants makes a difference, which is counter-intuitive. Structs are not +// implemented the same way and are safe to be used with derive. +impl Ord for TxStatus { + fn cmp(&self, other: &Self) -> Ordering { + match (self, other) { + (TxStatus::Pending(status1), TxStatus::Pending(status2)) => status1.cmp(status2), + (TxStatus::Pending(_), TxStatus::Confirmed { .. }) => Ordering::Greater, + (TxStatus::Confirmed { .. }, TxStatus::Pending(_)) => Ordering::Less, + ( + TxStatus::Confirmed { + block_hash: block_hash1, + block_number: block_num1, + detection: detection1, + }, + TxStatus::Confirmed { + block_hash: block_hash2, + block_number: block_num2, + detection: detection2, + }, + ) => block_hash1 + .cmp(block_hash2) + .then_with(|| block_num1.cmp(block_num2)) + .then_with(|| detection1.cmp(detection2)), + } + } +} + +impl FromStr for TxStatus { + type Err = String; + + fn from_str(s: &str) -> Result { + serde_json::from_str(s).map_err(|e| format!("{} in '{}'", e, s)) + } +} + +impl Display for TxStatus { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match serde_json::to_string(self) { + Ok(json) => write!(f, "{}", json), + // Untestable + Err(_) => write!(f, ""), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)] +pub enum Detection { + Normal, + Reclaim, +} + +impl From for TxStatus { + fn from(tx_block: TxBlock) -> Self { + TxStatus::Confirmed { + block_hash: format!("{:?}", tx_block.block_hash), + block_number: u64::try_from(tx_block.block_number).expect("block number too big"), + detection: Detection::Normal, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RetrieveCondition { + IsPending, + ByHash(BTreeSet), + ByNonce(Vec), +} + +impl Display for RetrieveCondition { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + RetrieveCondition::IsPending => { + write!(f, r#"WHERE status LIKE '%"Pending":%'"#) + } + RetrieveCondition::ByHash(tx_hashes) => { + write!( + f, + "WHERE tx_hash IN ({})", + join_with_commas(tx_hashes, |hash| format!("'{:?}'", hash)) + ) + } + RetrieveCondition::ByNonce(nonces) => { + write!( + f, + "WHERE nonce IN ({})", + join_with_commas(nonces, |nonce| nonce.to_string()) + ) + } + } + } +} + +pub trait SentPayableDao { + fn get_tx_identifiers(&self, hashes: &BTreeSet) -> TxIdentifiers; + fn insert_new_records(&self, txs: &BTreeSet) -> Result<(), SentPayableDaoError>; + fn retrieve_txs(&self, condition: Option) -> BTreeSet; + //TODO potentially atomically + fn confirm_txs(&self, hash_map: &HashMap) -> Result<(), SentPayableDaoError>; + fn replace_records(&self, new_txs: &BTreeSet) -> Result<(), SentPayableDaoError>; + fn update_statuses( + &self, + hash_map: &HashMap, + ) -> Result<(), SentPayableDaoError>; + //TODO potentially atomically + fn delete_records(&self, hashes: &BTreeSet) -> Result<(), SentPayableDaoError>; +} + +#[derive(Debug)] +pub struct SentPayableDaoReal<'a> { + conn: Box, +} + +impl<'a> SentPayableDaoReal<'a> { + pub fn new(conn: Box) -> Self { + Self { conn } + } +} + +impl SentPayableDao for SentPayableDaoReal<'_> { + fn get_tx_identifiers(&self, hashes: &BTreeSet) -> TxIdentifiers { + let sql = format!( + "SELECT tx_hash, rowid FROM sent_payable WHERE tx_hash IN ({})", + join_with_commas(hashes, |hash| format!("'{:?}'", hash)) + ); + + 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 tx_hash = H256::from_str(&tx_hash_str[2..]).expect("Failed to parse H256"); + let row_id: u64 = row.get(1).expectv("rowid"); + + Ok((tx_hash, row_id)) + }) + .expect("Failed to execute query") + .filter_map(Result::ok) + .collect() + } + + fn insert_new_records(&self, txs: &BTreeSet) -> Result<(), SentPayableDaoError> { + if txs.is_empty() { + return Err(SentPayableDaoError::EmptyInput); + } + + let unique_hashes: BTreeSet = txs.iter().map(|tx| tx.hash).collect(); + if unique_hashes.len() != txs.len() { + return Err(SentPayableDaoError::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(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_high_b, \ + gas_price_wei_low_b, \ + nonce, \ + status \ + ) VALUES {}", + join_with_commas(txs, |tx| sql_values_of_sent_tx(tx)) + ); + + match self.conn.prepare(&sql).expect("Internal error").execute([]) { + Ok(inserted_rows) => { + if inserted_rows == txs.len() { + Ok(()) + } else { + Err(SentPayableDaoError::PartialExecution(format!( + "Only {} out of {} records inserted", + inserted_rows, + txs.len() + ))) + } + } + Err(e) => Err(SentPayableDaoError::SqlExecutionFailed(e.to_string())), + } + } + + fn retrieve_txs(&self, condition_opt: Option) -> BTreeSet { + 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, status FROM sent_payable" + .to_string(); + let sql = match condition_opt { + 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 = H256::from_str(&tx_hash_str[2..]).expect("Failed to parse H256"); + let receiver_address_str: String = row.get(1).expectv("receivable_address"); + let receiver_address = + Address::from_str(&receiver_address_str[2..]).expect("Failed to parse H160"); + let amount_high_b = row.get(2).expectv("amount_high_b"); + let amount_low_b = row.get(3).expectv("amount_low_b"); + let amount_minor = 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_minor = + BigIntDivider::reconstitute(gas_price_wei_high_b, gas_price_wei_low_b) as u128; + let nonce = row.get(7).expectv("nonce"); + let status_str: String = row.get(8).expectv("status"); + let status = TxStatus::from_str(&status_str).expect("Failed to parse TxStatus"); + + Ok(SentTx { + hash, + receiver_address, + amount_minor, + timestamp, + gas_price_minor, + nonce, + status, + }) + }) + .expect("Failed to execute query") + .filter_map(Result::ok) + .collect() + } + + fn confirm_txs(&self, hash_map: &HashMap) -> Result<(), SentPayableDaoError> { + if hash_map.is_empty() { + return Err(SentPayableDaoError::EmptyInput); + } + + for (hash, tx_block) in hash_map { + let sql = format!( + "UPDATE sent_payable SET status = '{}' WHERE tx_hash = '{:?}'", + TxStatus::from(*tx_block), + hash + ); + + match self.conn.prepare(&sql).expect("Internal error").execute([]) { + Ok(updated_rows) => { + if updated_rows == 1 { + continue; + } else { + return Err(SentPayableDaoError::PartialExecution(format!( + "Failed to update status for hash {:?}", + hash + ))); + } + } + Err(e) => { + return Err(SentPayableDaoError::SqlExecutionFailed(e.to_string())); + } + } + } + + Ok(()) + } + + fn replace_records(&self, new_txs: &BTreeSet) -> Result<(), SentPayableDaoError> { + if new_txs.is_empty() { + return Err(SentPayableDaoError::EmptyInput); + } + + let build_case = |value_fn: fn(&SentTx) -> String| { + join_with_separator( + new_txs, + |tx| format!("WHEN nonce = {} THEN {}", tx.nonce, value_fn(tx)), + " ", + ) + }; + + 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_minor); + let (high, _) = BigIntDivider::deconstruct(amount_checked); + high.to_string() + }); + let amount_low_b_cases = build_case(|tx| { + let amount_checked = checked_conversion::(tx.amount_minor); + 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_minor); + 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_minor); + let (_, low) = BigIntDivider::deconstruct(gas_price_wei_checked); + low.to_string() + }); + let status_cases = build_case(|tx| format!("'{}'", tx.status)); + + let nonces = join_with_commas(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, \ + status = CASE \ + {status_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 update_statuses( + &self, + status_updates: &HashMap, + ) -> Result<(), SentPayableDaoError> { + if status_updates.is_empty() { + return Err(SentPayableDaoError::EmptyInput); + } + + let case_statements = status_updates + .iter() + .map(|(hash, status)| format!("WHEN tx_hash = '{:?}' THEN '{}'", hash, status)) + .join(" "); + let tx_hashes = join_with_commas(&status_updates.keys().collect_vec(), |hash| { + format!("'{:?}'", hash) + }); + + let sql = format!( + "UPDATE sent_payable \ + SET \ + status = CASE \ + {case_statements} \ + END \ + WHERE tx_hash IN ({tx_hashes})" + ); + + match self.conn.prepare(&sql).expect("Internal error").execute([]) { + Ok(rows_changed) => { + if rows_changed == status_updates.len() { + Ok(()) + } else { + Err(SentPayableDaoError::PartialExecution(format!( + "Only {} of {} records had their status updated.", + rows_changed, + status_updates.len(), + ))) + } + } + Err(e) => Err(SentPayableDaoError::SqlExecutionFailed(e.to_string())), + } + } + + fn delete_records(&self, hashes: &BTreeSet) -> Result<(), SentPayableDaoError> { + if hashes.is_empty() { + return Err(SentPayableDaoError::EmptyInput); + } + + let sql = format!( + "DELETE FROM sent_payable WHERE tx_hash IN ({})", + join_with_commas(hashes, |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(SentPayableDaoError::NoChange) + } else { + Err(SentPayableDaoError::PartialExecution(format!( + "Only {} of the {} hashes has been deleted.", + deleted_rows, + hashes.len(), + ))) + } + } + Err(e) => Err(SentPayableDaoError::SqlExecutionFailed(e.to_string())), + } + } +} + +pub trait SentPayableDaoFactory { + fn make(&self) -> Box; +} + +impl SentPayableDaoFactory for DaoFactoryReal { + fn make(&self) -> Box { + Box::new(SentPayableDaoReal::new(self.make_connection())) + } +} + +#[cfg(test)] +mod tests { + use crate::accountant::db_access_objects::sent_payable_dao::RetrieveCondition::{ + ByHash, ByNonce, IsPending, + }; + use crate::accountant::db_access_objects::sent_payable_dao::SentPayableDaoError::{ + EmptyInput, PartialExecution, + }; + use crate::accountant::db_access_objects::sent_payable_dao::{ + Detection, RetrieveCondition, SentPayableDao, SentPayableDaoError, SentPayableDaoReal, + SentTx, TxStatus, + }; + use crate::accountant::db_access_objects::test_utils::{ + make_read_only_db_connection, make_sent_tx, TxBuilder, + }; + use crate::accountant::db_access_objects::Transaction; + use crate::blockchain::blockchain_interface::data_structures::TxBlock; + use crate::blockchain::errors::internal_errors::InternalErrorKind; + use crate::blockchain::errors::rpc_errors::{AppRpcErrorKind, LocalErrorKind, RemoteErrorKind}; + use crate::blockchain::errors::validation_status::{PreviousAttempts, ValidationStatus}; + use crate::blockchain::errors::BlockchainErrorKind; + use crate::blockchain::test_utils::{make_address, make_block_hash, make_tx_hash}; + use crate::database::db_initializer::{ + DbInitializationConfig, DbInitializer, DbInitializerReal, + }; + use crate::database::test_utils::ConnectionWrapperMock; + use ethereum_types::{H256, U64}; + use masq_lib::simple_clock::SimpleClockReal; + use masq_lib::test_utils::simple_clock::SimpleClockMock; + use masq_lib::test_utils::utils::ensure_node_home_directory_exists; + use rusqlite::Connection; + use std::cmp::Ordering; + use std::collections::{BTreeSet, HashMap}; + use std::ops::{Add, Sub}; + use std::str::FromStr; + use std::sync::{Arc, Mutex}; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + #[test] + fn insert_new_records_works() { + let home_dir = + ensure_node_home_directory_exists("sent_payable_dao", "insert_new_records_works"); + let wrapped_conn = DbInitializerReal::default() + .initialize(&home_dir, DbInitializationConfig::test_default()) + .unwrap(); + let tx1 = TxBuilder::default().hash(make_tx_hash(1)).build(); + let tx2 = TxBuilder::default() + .hash(make_tx_hash(2)) + .status(TxStatus::Pending(ValidationStatus::Reattempting( + PreviousAttempts::new( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote( + RemoteErrorKind::Unreachable, + )), + &SimpleClockReal::default(), + ) + .add_attempt( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote( + RemoteErrorKind::Unreachable, + )), + &SimpleClockReal::default(), + ), + ))) + .build(); + let subject = SentPayableDaoReal::new(wrapped_conn); + let txs = BTreeSet::from([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( + "sent_payable_dao", + "insert_new_records_throws_err_for_empty_input", + ); + let wrapped_conn = DbInitializerReal::default() + .initialize(&home_dir, DbInitializationConfig::test_default()) + .unwrap(); + let subject = SentPayableDaoReal::new(wrapped_conn); + let empty_input = BTreeSet::new(); + + let result = subject.insert_new_records(&empty_input); + + assert_eq!(result, Err(SentPayableDaoError::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( + "sent_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(1234); + let tx1 = TxBuilder::default() + .hash(hash) + .timestamp(1749204017) + .status(TxStatus::Pending(ValidationStatus::Waiting)) + .build(); + let tx2 = TxBuilder::default() + .hash(hash) + .timestamp(1749204020) + .status(TxStatus::Confirmed { + block_hash: format!("{:?}", make_block_hash(456)), + block_number: 7890123, + detection: Detection::Reclaim, + }) + .build(); + let subject = SentPayableDaoReal::new(wrapped_conn); + + let result = subject.insert_new_records(&BTreeSet::from([tx1, tx2])); + + assert_eq!( + result, + Err(SentPayableDaoError::InvalidInput( + "Duplicate hashes found in the input. Input Transactions: \ + {\ + SentTx { hash: 0x00000000000000000000000000000000000000000000000000000000000004d2, \ + receiver_address: 0x0000000000000000000000000000000000000000, \ + amount_minor: 0, timestamp: 1749204017, gas_price_minor: 0, \ + nonce: 0, status: Pending(Waiting) }, \ + SentTx { \ + hash: 0x00000000000000000000000000000000000000000000000000000000000004d2, \ + receiver_address: 0x0000000000000000000000000000000000000000, \ + amount_minor: 0, timestamp: 1749204020, gas_price_minor: 0, \ + nonce: 0, status: Confirmed { block_hash: \ + \"0x000000000000000000000000000000000000000000000000000000003b9acbc8\", \ + block_number: 7890123, detection: Reclaim } }\ + }" + .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( + "sent_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(1234); + let tx1 = TxBuilder::default().hash(hash).build(); + let tx2 = TxBuilder::default().hash(hash).build(); + let subject = SentPayableDaoReal::new(wrapped_conn); + let initial_insertion_result = subject.insert_new_records(&BTreeSet::from([tx1])); + + let result = subject.insert_new_records(&BTreeSet::from([tx2])); + + assert_eq!(initial_insertion_result, Ok(())); + assert_eq!( + result, + Err(SentPayableDaoError::InvalidInput( + "Duplicates detected in the database: \ + {0x00000000000000000000000000000000000000000000000000000000000004d2: 1}" + .to_string() + )) + ); + } + + #[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 = TxBuilder::default().build(); + let subject = SentPayableDaoReal::new(Box::new(wrapped_conn)); + + let result = subject.insert_new_records(&BTreeSet::from([tx])); + + assert_eq!( + result, + Err(SentPayableDaoError::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( + "sent_payable_dao", + "insert_new_records_can_throw_error", + ); + 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(&BTreeSet::from([tx])); + + assert_eq!( + result, + Err(SentPayableDaoError::SqlExecutionFailed( + "attempt to write a readonly database".to_string() + )) + ) + } + + #[test] + fn get_tx_identifiers_works() { + let home_dir = + ensure_node_home_directory_exists("sent_payable_dao", "get_tx_identifiers_works"); + let wrapped_conn = DbInitializerReal::default() + .initialize(&home_dir, DbInitializationConfig::test_default()) + .unwrap(); + let subject = SentPayableDaoReal::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 = BTreeSet::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(); + subject + .insert_new_records(&BTreeSet::from([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 retrieve_condition_display_works() { + assert_eq!(IsPending.to_string(), "WHERE status LIKE '%\"Pending\":%'"); + // 0x0000000000000000000000000000000000000000000000000000000123456789 + assert_eq!( + ByHash(BTreeSet::from([ + H256::from_low_u64_be(0x123456789), + H256::from_low_u64_be(0x987654321), + ])) + .to_string(), + "WHERE tx_hash IN (\ + '0x0000000000000000000000000000000000000000000000000000000123456789', \ + '0x0000000000000000000000000000000000000000000000000000000987654321'\ + )" + ); + assert_eq!(ByNonce(vec![45, 47]).to_string(), "WHERE nonce IN (45, 47)") + } + + #[test] + fn can_retrieve_all_txs() { + let home_dir = + ensure_node_home_directory_exists("sent_payable_dao", "can_retrieve_all_txs"); + 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)).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(&BTreeSet::from([tx1.clone(), tx2.clone()])) + .unwrap(); + subject + .insert_new_records(&BTreeSet::from([tx3.clone()])) + .unwrap(); + + let result = subject.retrieve_txs(None); + + assert_eq!(result, BTreeSet::from([tx1, tx2, tx3])); + } + + #[test] + fn can_retrieve_pending_txs() { + let home_dir = + ensure_node_home_directory_exists("sent_payable_dao", "can_retrieve_pending_txs"); + 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)) + .status(TxStatus::Pending(ValidationStatus::Waiting)) + .build(); + let tx2 = TxBuilder::default() + .hash(make_tx_hash(2)) + .status(TxStatus::Pending(ValidationStatus::Reattempting( + PreviousAttempts::new( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote( + RemoteErrorKind::Unreachable, + )), + &SimpleClockReal::default(), + ), + ))) + .build(); + let tx3 = TxBuilder::default() + .hash(make_tx_hash(3)) + .status(TxStatus::Confirmed { + block_hash: format!("{:?}", make_block_hash(456)), + block_number: 456789, + detection: Detection::Normal, + }) + .build(); + subject + .insert_new_records(&BTreeSet::from([tx1.clone(), tx2.clone(), tx3])) + .unwrap(); + + let result = subject.retrieve_txs(Some(RetrieveCondition::IsPending)); + + assert_eq!(result, BTreeSet::from([tx1, tx2])); + } + + #[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"); + 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)).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(&BTreeSet::from([tx1.clone(), tx2, tx3.clone()])) + .unwrap(); + + let result = subject.retrieve_txs(Some(ByHash(BTreeSet::from([tx1.hash, tx3.hash])))); + + assert_eq!(result, BTreeSet::from([tx1, tx3])); + } + + #[test] + fn retrieve_txs_by_hash_returns_only_existing_transactions() { + let home_dir = ensure_node_home_directory_exists( + "sent_payable_dao", + "retrieve_txs_by_hash_returns_only_existing_transactions", + ); + 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(&BTreeSet::from([tx1.clone(), tx2.clone(), tx3.clone()])) + .unwrap(); + let mut query_hashes = BTreeSet::new(); + query_hashes.insert(make_tx_hash(1)); // Exists + query_hashes.insert(make_tx_hash(2)); // Exists + query_hashes.insert(make_tx_hash(4)); // Does not exist + query_hashes.insert(make_tx_hash(5)); // Does not exist + + let result = subject.retrieve_txs(Some(RetrieveCondition::ByHash(query_hashes))); + + assert_eq!(result.len(), 2, "Should only return 2 transactions"); + assert!(result.contains(&tx1), "Should contain tx1"); + assert!(result.contains(&tx2), "Should contain tx2"); + assert!(!result.contains(&tx3), "Should not contain tx3"); + assert!( + result.iter().all(|tx| tx.hash != make_tx_hash(4)), + "Should not contain hash 4" + ); + assert!( + result.iter().all(|tx| tx.hash != make_tx_hash(5)), + "Should not contain hash 5" + ); + } + + #[test] + fn tx_can_be_retrieved_by_nonce() { + let home_dir = + ensure_node_home_directory_exists("sent_payable_dao", "tx_can_be_retrieved_by_nonce"); + 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(123)) + .nonce(33) + .build(); + let tx2 = TxBuilder::default() + .hash(make_tx_hash(456)) + .nonce(34) + .build(); + let tx3 = TxBuilder::default() + .hash(make_tx_hash(789)) + .nonce(35) + .build(); + subject + .insert_new_records(&BTreeSet::from([tx1.clone(), tx2, tx3.clone()])) + .unwrap(); + + let result = subject.retrieve_txs(Some(ByNonce(vec![33, 35]))); + + assert_eq!(result, BTreeSet::from([tx1, tx3])); + } + + #[test] + fn confirm_tx_works() { + let home_dir = ensure_node_home_directory_exists("sent_payable_dao", "confirm_tx_works"); + let wrapped_conn = DbInitializerReal::default() + .initialize(&home_dir, DbInitializationConfig::test_default()) + .unwrap(); + let subject = SentPayableDaoReal::new(wrapped_conn); + let hash1 = make_tx_hash(1); + let hash2 = make_tx_hash(2); + let tx1 = TxBuilder::default().hash(hash1).build(); + let tx2 = TxBuilder::default().hash(hash2).build(); + subject + .insert_new_records(&BTreeSet::from([tx1.clone(), tx2.clone()])) + .unwrap(); + let updated_pre_assert_txs = + subject.retrieve_txs(Some(ByHash(BTreeSet::from([hash1, hash2])))); + let pre_assert_status_tx1 = updated_pre_assert_txs.get(&tx1).unwrap().status.clone(); + let pre_assert_status_tx2 = updated_pre_assert_txs.get(&tx2).unwrap().status.clone(); + let confirmed_tx_block_1 = TxBlock { + block_hash: make_block_hash(3), + block_number: U64::from(1), + }; + let confirmed_tx_block_2 = TxBlock { + block_hash: make_block_hash(4), + block_number: U64::from(2), + }; + let hash_map = HashMap::from([ + (tx1.hash, confirmed_tx_block_1.clone()), + (tx2.hash, confirmed_tx_block_2.clone()), + ]); + + let result = subject.confirm_txs(&hash_map); + + let updated_txs = subject.retrieve_txs(Some(ByHash(BTreeSet::from([tx1.hash, tx2.hash])))); + let updated_tx1 = updated_txs.iter().find(|tx| tx.hash == hash1).unwrap(); + let updated_tx2 = updated_txs.iter().find(|tx| tx.hash == hash2).unwrap(); + assert_eq!(result, Ok(())); + assert_eq!( + pre_assert_status_tx1, + TxStatus::Pending(ValidationStatus::Waiting) + ); + assert_eq!( + updated_tx1.status, + TxStatus::Confirmed { + block_hash: format!("{:?}", confirmed_tx_block_1.block_hash), + block_number: confirmed_tx_block_1.block_number.as_u64(), + detection: Detection::Normal + } + ); + assert_eq!( + pre_assert_status_tx2, + TxStatus::Pending(ValidationStatus::Waiting) + ); + assert_eq!( + updated_tx2.status, + TxStatus::Confirmed { + block_hash: format!("{:?}", confirmed_tx_block_2.block_hash), + block_number: confirmed_tx_block_2.block_number.as_u64(), + detection: Detection::Normal + } + ); + } + + #[test] + fn confirm_tx_returns_error_when_input_is_empty() { + let home_dir = ensure_node_home_directory_exists( + "sent_payable_dao", + "confirm_tx_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 = make_tx_hash(1); + let tx = TxBuilder::default().hash(existent_hash).build(); + subject.insert_new_records(&BTreeSet::from([tx])).unwrap(); + let hash_map = HashMap::new(); + + let result = subject.confirm_txs(&hash_map); + + assert_eq!(result, Err(SentPayableDaoError::EmptyInput)); + } + + #[test] + fn confirm_tx_returns_error_during_partial_execution() { + let home_dir = ensure_node_home_directory_exists( + "sent_payable_dao", + "confirm_tx_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 = make_tx_hash(1); + let non_existent_hash = make_tx_hash(999); + let tx = TxBuilder::default().hash(existent_hash).build(); + subject.insert_new_records(&BTreeSet::from([tx])).unwrap(); + let hash_map = HashMap::from([ + ( + existent_hash, + TxBlock { + block_hash: make_block_hash(1), + block_number: U64::from(1), + }, + ), + ( + non_existent_hash, + TxBlock { + block_hash: make_block_hash(2), + block_number: U64::from(2), + }, + ), + ]); + + let result = subject.confirm_txs(&hash_map); + + assert_eq!( + result, + Err(SentPayableDaoError::PartialExecution(format!( + "Failed to update status for hash {:?}", + non_existent_hash + ))) + ); + } + + #[test] + fn confirm_tx_returns_error_when_an_error_occurs_while_executing_sql() { + let home_dir = ensure_node_home_directory_exists( + "sent_payable_dao", + "confirm_tx_returns_error_when_an_error_occurs_while_executing_sql", + ); + let wrapped_conn = make_read_only_db_connection(home_dir); + let subject = SentPayableDaoReal::new(Box::new(wrapped_conn)); + let hash = make_tx_hash(1); + let hash_map = HashMap::from([( + hash, + TxBlock { + block_hash: make_block_hash(1), + block_number: U64::default(), + }, + )]); + + let result = subject.confirm_txs(&hash_map); + + assert_eq!( + result, + Err(SentPayableDaoError::SqlExecutionFailed( + "attempt to write a readonly database".to_string() + )) + ) + } + + #[test] + fn txs_can_be_deleted() { + let home_dir = ensure_node_home_directory_exists("sent_payable_dao", "txs_can_be_deleted"); + 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)).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(make_tx_hash(4)).build(); + subject + .insert_new_records(&BTreeSet::from([ + tx1.clone(), + tx2.clone(), + tx3.clone(), + tx4.clone(), + ])) + .unwrap(); + let hashset = BTreeSet::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, BTreeSet::from([tx2, tx4])); + } + + #[test] + fn delete_records_returns_error_when_input_is_empty() { + let home_dir = ensure_node_home_directory_exists( + "sent_payable_dao", + "delete_records_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 result = subject.delete_records(&BTreeSet::new()); + + assert_eq!(result, Err(SentPayableDaoError::EmptyInput)); + } + + #[test] + fn delete_records_returns_error_when_no_records_are_deleted() { + let home_dir = ensure_node_home_directory_exists( + "sent_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 = SentPayableDaoReal::new(wrapped_conn); + let non_existent_hash = make_tx_hash(999); + let hashset = BTreeSet::from([non_existent_hash]); + + let result = subject.delete_records(&hashset); + + assert_eq!(result, Err(SentPayableDaoError::NoChange)); + } + + #[test] + fn delete_records_returns_error_when_not_all_input_records_were_deleted() { + let home_dir = ensure_node_home_directory_exists( + "sent_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 = SentPayableDaoReal::new(wrapped_conn); + 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(&BTreeSet::from([tx])).unwrap(); + let hashset = BTreeSet::from([present_hash, absent_hash]); + + let result = subject.delete_records(&hashset); + + assert_eq!( + result, + Err(SentPayableDaoError::PartialExecution( + "Only 1 of the 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( + "sent_payable_dao", + "delete_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 hashes = BTreeSet::from([make_tx_hash(1)]); + + let result = subject.delete_records(&hashes); + + assert_eq!( + result, + Err(SentPayableDaoError::SqlExecutionFailed( + "attempt to write a readonly database".to_string() + )) + ) + } + + #[test] + fn update_statuses_works() { + let home_dir = + ensure_node_home_directory_exists("sent_payable_dao", "update_statuses_works"); + let wrapped_conn = DbInitializerReal::default() + .initialize(&home_dir, DbInitializationConfig::test_default()) + .unwrap(); + let timestamp_a = SystemTime::now().sub(Duration::from_millis(11)); + let timestamp_b = SystemTime::now().sub(Duration::from_millis(1234)); + let subject = SentPayableDaoReal::new(wrapped_conn); + let mut tx1 = make_sent_tx(456); + tx1.status = TxStatus::Pending(ValidationStatus::Waiting); + let mut tx2 = make_sent_tx(789); + tx2.status = TxStatus::Pending(ValidationStatus::Reattempting(PreviousAttempts::new( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote(RemoteErrorKind::Unreachable)), + &SimpleClockMock::default().now_result(timestamp_b), + ))); + let mut tx3 = make_sent_tx(123); + tx3.status = TxStatus::Pending(ValidationStatus::Waiting); + subject + .insert_new_records(&BTreeSet::from([tx1.clone(), tx2.clone(), tx3.clone()])) + .unwrap(); + let hashmap = HashMap::from([ + ( + tx1.hash, + TxStatus::Pending(ValidationStatus::Reattempting(PreviousAttempts::new( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Internal)), + &SimpleClockMock::default().now_result(timestamp_a), + ))), + ), + ( + tx2.hash, + TxStatus::Pending(ValidationStatus::Reattempting( + PreviousAttempts::new( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote( + RemoteErrorKind::Unreachable, + )), + &SimpleClockMock::default().now_result(timestamp_b), + ) + .add_attempt( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote( + RemoteErrorKind::Unreachable, + )), + &SimpleClockReal::default(), + ), + )), + ), + ( + tx3.hash, + TxStatus::Confirmed { + block_hash: + "0x0000000000000000000000000000000000000000000000000000000000000002" + .to_string(), + block_number: 123, + detection: Detection::Normal, + }, + ), + ]); + + let result = subject.update_statuses(&hashmap); + + let updated_txs: Vec<_> = subject.retrieve_txs(None).into_iter().collect(); + assert_eq!(result, Ok(())); + assert_eq!( + updated_txs[0].status, + TxStatus::Confirmed { + block_hash: "0x0000000000000000000000000000000000000000000000000000000000000002" + .to_string(), + block_number: 123, + detection: Detection::Normal, + } + ); + assert_eq!( + updated_txs[1].status, + TxStatus::Pending(ValidationStatus::Reattempting(PreviousAttempts::new( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Internal)), + &SimpleClockMock::default().now_result(timestamp_a) + ))) + ); + assert_eq!( + updated_txs[2].status, + TxStatus::Pending(ValidationStatus::Reattempting( + PreviousAttempts::new( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote( + RemoteErrorKind::Unreachable + )), + &SimpleClockMock::default().now_result(timestamp_b) + ) + .add_attempt( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote( + RemoteErrorKind::Unreachable + )), + &SimpleClockReal::default() + ) + )) + ); + assert_eq!(updated_txs.len(), 3) + } + + #[test] + fn update_statuses_handles_empty_input_error() { + let home_dir = ensure_node_home_directory_exists( + "sent_payable_dao", + "update_statuses_handles_empty_input_error", + ); + let wrapped_conn = DbInitializerReal::default() + .initialize(&home_dir, DbInitializationConfig::test_default()) + .unwrap(); + let subject = SentPayableDaoReal::new(wrapped_conn); + + let result = subject.update_statuses(&HashMap::new()); + + assert_eq!(result, Err(SentPayableDaoError::EmptyInput)); + } + + #[test] + fn update_statuses_handles_sql_error() { + let home_dir = ensure_node_home_directory_exists( + "sent_payable_dao", + "update_statuses_handles_sql_error", + ); + let wrapped_conn = make_read_only_db_connection(home_dir); + let subject = SentPayableDaoReal::new(Box::new(wrapped_conn)); + + let result = subject.update_statuses(&HashMap::from([( + make_tx_hash(1), + TxStatus::Pending(ValidationStatus::Reattempting(PreviousAttempts::new( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote(RemoteErrorKind::Unreachable)), + &SimpleClockReal::default(), + ))), + )])); + + assert_eq!( + result, + Err(SentPayableDaoError::SqlExecutionFailed( + "attempt to write a readonly database".to_string() + )) + ); + } + + #[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(&BTreeSet::from([tx1.clone(), tx2, tx3])) + .unwrap(); + let new_tx2 = TxBuilder::default() + .hash(make_tx_hash(22)) + .status(TxStatus::Confirmed { + block_hash: format!("{:?}", make_block_hash(123)), + block_number: 45454545, + detection: Detection::Normal, + }) + .nonce(2) + .build(); + let new_tx3 = TxBuilder::default() + .hash(make_tx_hash(33)) + .status(TxStatus::Confirmed { + block_hash: format!("{:?}", make_block_hash(789)), + block_number: 45454566, + detection: Detection::Reclaim, + }) + .nonce(3) + .build(); + + let result = subject.replace_records(&BTreeSet::from([new_tx2.clone(), new_tx3.clone()])); + + let retrieved_txs = subject.retrieve_txs(None); + assert_eq!(result, Ok(())); + assert_eq!(retrieved_txs, BTreeSet::from([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(&BTreeSet::from([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("status = 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(&BTreeSet::from([tx1, tx2])) + .unwrap(); + + let result = subject.replace_records(&BTreeSet::new()); + + 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(&BTreeSet::from([tx1.clone(), tx2.clone()])) + .unwrap(); + let new_tx2 = TxBuilder::default() + .hash(make_tx_hash(22)) + .status(TxStatus::Confirmed { + block_hash: format!("{:?}", make_block_hash(77777)), + block_number: 357913, + detection: Detection::Normal, + }) + .nonce(2) + .build(); + let new_tx3 = TxBuilder::default() + .hash(make_tx_hash(33)) + .status(TxStatus::Confirmed { + block_hash: format!("{:?}", make_block_hash(66666)), + block_number: 353535, + detection: Detection::Reclaim, + }) + .nonce(3) + .build(); + + let result = subject.replace_records(&BTreeSet::from([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(&BTreeSet::from([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(&BTreeSet::from([tx])); + + assert_eq!( + result, + Err(SentPayableDaoError::SqlExecutionFailed( + "attempt to write a readonly database".to_string() + )) + ) + } + + #[test] + fn tx_status_from_str_works() { + let validation_failure_clock = + SimpleClockMock::default().now_result(UNIX_EPOCH.add(Duration::from_secs(12456))); + + assert_eq!( + TxStatus::from_str(r#"{"Pending":"Waiting"}"#).unwrap(), + TxStatus::Pending(ValidationStatus::Waiting) + ); + + assert_eq!( + TxStatus::from_str(r#"{"Pending":{"Reattempting":[{"error":{"AppRpc":{"Remote":"InvalidResponse"}},"firstSeen":{"secs_since_epoch":12456,"nanos_since_epoch":0},"attempts":1}]}}"#).unwrap(), + TxStatus::Pending(ValidationStatus::Reattempting(PreviousAttempts::new(BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote(RemoteErrorKind::InvalidResponse)), &validation_failure_clock))) + ); + + assert_eq!( + TxStatus::from_str(r#"{"Confirmed":{"block_hash":"0xb4bc263299d3a82a652a8d73a6bfd8ec0ba1a63923bbb4f38147fb8a943da26a","block_number":456789,"detection":"Normal"}}"#).unwrap(), + TxStatus::Confirmed{ + block_hash: "0xb4bc263299d3a82a652a8d73a6bfd8ec0ba1a63923bbb4f38147fb8a943da26a".to_string(), + block_number: 456789, + detection: Detection::Normal, + } + ); + + assert_eq!( + TxStatus::from_str(r#"{"Confirmed":{"block_hash":"0x6d0abc11e617442c26104c2bc63d1bc05e1e002e555aec4ab62a46e826b18f18","block_number":567890,"detection":"Reclaim"}}"#).unwrap(), + TxStatus::Confirmed{ + block_hash: "0x6d0abc11e617442c26104c2bc63d1bc05e1e002e555aec4ab62a46e826b18f18".to_string(), + block_number: 567890, + detection: Detection::Reclaim, + } + ); + + // Invalid Variant + assert_eq!( + TxStatus::from_str("\"UnknownStatus\"").unwrap_err(), + "unknown variant `UnknownStatus`, \ + expected `Pending` or `Confirmed` at line 1 column 15 in '\"UnknownStatus\"'" + ); + + // Invalid Input + assert_eq!( + TxStatus::from_str("not a failure status").unwrap_err(), + "expected value at line 1 column 1 in 'not a failure status'" + ); + } + + #[test] + fn tx_status_can_be_made_from_transaction_block() { + let tx_block = TxBlock { + block_hash: make_block_hash(6), + block_number: 456789_u64.into(), + }; + + assert_eq!( + TxStatus::from(tx_block), + TxStatus::Confirmed { + block_hash: format!("{:?}", tx_block.block_hash), + block_number: u64::try_from(tx_block.block_number).unwrap(), + detection: Detection::Normal, + } + ) + } + + #[test] + fn tx_status_ordering_works() { + let tx_status_1 = TxStatus::Pending(ValidationStatus::Waiting); + let tx_status_2 = TxStatus::Pending(ValidationStatus::Reattempting(PreviousAttempts::new( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote(RemoteErrorKind::InvalidResponse)), + &SimpleClockReal::default(), + ))); + let tx_status_3 = TxStatus::Pending(ValidationStatus::Reattempting(PreviousAttempts::new( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Decoder)), + &SimpleClockReal::default(), + ))); + let tx_status_4 = TxStatus::Pending(ValidationStatus::Reattempting(PreviousAttempts::new( + BlockchainErrorKind::Internal(InternalErrorKind::PendingTooLongNotReplaced), + &SimpleClockReal::default(), + ))); + let tx_status_5 = TxStatus::Confirmed { + block_hash: format!("{:?}", make_tx_hash(1)), + block_number: 123456, + detection: Detection::Normal, + }; + let tx_status_6 = TxStatus::Confirmed { + block_hash: format!("{:?}", make_tx_hash(2)), + block_number: 6543, + detection: Detection::Normal, + }; + let tx_status_7 = TxStatus::Confirmed { + block_hash: format!("{:?}", make_tx_hash(1)), + block_number: 123456, + detection: Detection::Reclaim, + }; + let tx_status_1_identical = tx_status_1.clone(); + let tx_status_6_identical = tx_status_6.clone(); + + let mut set = BTreeSet::new(); + vec![ + tx_status_1.clone(), + tx_status_2.clone(), + tx_status_3.clone(), + tx_status_4.clone(), + tx_status_5.clone(), + tx_status_6.clone(), + tx_status_7.clone(), + ] + .into_iter() + .for_each(|tx| { + set.insert(tx); + }); + + let expected_order = vec![ + tx_status_5, + tx_status_7, + tx_status_6.clone(), + tx_status_3, + tx_status_2, + tx_status_4, + tx_status_1.clone(), + ]; + assert_eq!(set.into_iter().collect::>(), expected_order); + assert_eq!(tx_status_1.cmp(&tx_status_1_identical), Ordering::Equal); + assert_eq!(tx_status_6.cmp(&tx_status_6_identical), Ordering::Equal); + } + + #[test] + fn transaction_trait_methods_for_tx() { + let hash = make_tx_hash(1); + let receiver_address = make_address(1); + let amount_minor = 1000; + let timestamp = 1625247600; + let gas_price_minor = 2000; + let nonce = 42; + let status = TxStatus::Pending(ValidationStatus::Waiting); + + let tx = SentTx { + hash, + receiver_address, + amount_minor, + timestamp, + gas_price_minor, + nonce, + status, + }; + + assert_eq!(tx.receiver_address(), receiver_address); + assert_eq!(tx.hash(), hash); + assert_eq!(tx.amount(), amount_minor); + assert_eq!(tx.timestamp(), timestamp); + assert_eq!(tx.gas_price_wei(), gas_price_minor); + assert_eq!(tx.nonce(), nonce); + assert_eq!(tx.is_failed(), false); + } +} diff --git a/node/src/accountant/db_access_objects/test_utils.rs b/node/src/accountant/db_access_objects/test_utils.rs new file mode 100644 index 0000000000..fca96ed7f3 --- /dev/null +++ b/node/src/accountant/db_access_objects/test_utils.rs @@ -0,0 +1,235 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. +#![cfg(test)] + +use crate::accountant::db_access_objects::failed_payable_dao::{ + FailedTx, FailureReason, FailureStatus, +}; +use crate::accountant::db_access_objects::sent_payable_dao::{SentTx, TxStatus}; +use crate::accountant::db_access_objects::utils::{current_unix_timestamp, TxHash}; +use crate::accountant::scanners::payable_scanner::tx_templates::signable::SignableTxTemplate; +use crate::blockchain::errors::validation_status::ValidationStatus; +use crate::blockchain::test_utils::{make_address, make_tx_hash}; +use crate::database::db_initializer::{ + DbInitializationConfig, DbInitializer, DbInitializerReal, DATABASE_FILE, +}; +use crate::database::rusqlite_wrappers::ConnectionWrapperReal; +use rusqlite::{Connection, OpenFlags}; +use std::path::PathBuf; +use web3::types::Address; + +#[derive(Default)] +pub struct TxBuilder { + hash_opt: Option, + receiver_address_opt: Option
, + amount_opt: Option, + timestamp_opt: Option, + gas_price_wei_opt: Option, + nonce_opt: Option, + status_opt: Option, +} + +impl TxBuilder { + pub fn default() -> Self { + Default::default() + } + + pub fn hash(mut self, hash: TxHash) -> Self { + self.hash_opt = Some(hash); + self + } + + pub fn receiver_address(mut self, receiver_address: Address) -> Self { + self.receiver_address_opt = Some(receiver_address); + 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 template(mut self, signable_tx_template: SignableTxTemplate) -> Self { + self.receiver_address_opt = Some(signable_tx_template.receiver_address); + self.amount_opt = Some(signable_tx_template.amount_in_wei); + self.gas_price_wei_opt = Some(signable_tx_template.gas_price_wei); + self.nonce_opt = Some(signable_tx_template.nonce); + self + } + + pub fn status(mut self, status: TxStatus) -> Self { + self.status_opt = Some(status); + self + } + + pub fn build(self) -> SentTx { + SentTx { + hash: self.hash_opt.unwrap_or_default(), + receiver_address: self.receiver_address_opt.unwrap_or_default(), + amount_minor: self.amount_opt.unwrap_or_default(), + timestamp: self.timestamp_opt.unwrap_or_else(current_unix_timestamp), + gas_price_minor: self.gas_price_wei_opt.unwrap_or_default(), + nonce: self.nonce_opt.unwrap_or_default(), + status: self + .status_opt + .unwrap_or(TxStatus::Pending(ValidationStatus::Waiting)), + } + } +} + +#[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, + status_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 receiver_address(mut self, receiver_address: Address) -> Self { + self.receiver_address_opt = Some(receiver_address); + self + } + + pub fn amount(mut self, amount: u128) -> Self { + self.amount_opt = Some(amount); + self + } + + pub fn timestamp(mut self, timestamp: i64) -> Self { + self.timestamp_opt = Some(timestamp); + self + } + + pub fn gas_price_wei(mut self, gas_price_wei: u128) -> Self { + self.gas_price_wei_opt = Some(gas_price_wei); + 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 template(mut self, signable_tx_template: SignableTxTemplate) -> Self { + self.receiver_address_opt = Some(signable_tx_template.receiver_address); + self.amount_opt = Some(signable_tx_template.amount_in_wei); + self.gas_price_wei_opt = Some(signable_tx_template.gas_price_wei); + self.nonce_opt = Some(signable_tx_template.nonce); + self + } + + pub fn status(mut self, failure_status: FailureStatus) -> Self { + self.status_opt = Some(failure_status); + self + } + + pub fn build(self) -> FailedTx { + FailedTx { + hash: self.hash_opt.unwrap_or_default(), + receiver_address: self.receiver_address_opt.unwrap_or_default(), + amount_minor: self.amount_opt.unwrap_or_default(), + timestamp: self.timestamp_opt.unwrap_or_else(|| 1719990000), + gas_price_minor: self.gas_price_wei_opt.unwrap_or_default(), + nonce: self.nonce_opt.unwrap_or_default(), + reason: self + .reason_opt + .unwrap_or_else(|| FailureReason::PendingTooLong), + status: self + .status_opt + .unwrap_or_else(|| FailureStatus::RetryRequired), + } + } +} + +pub fn make_failed_tx(n: u32) -> FailedTx { + let n = n % 0xfff; + FailedTxBuilder::default() + .hash(make_tx_hash(n)) + .timestamp(((n * 12) as i64).pow(2)) + .receiver_address(make_address(n.pow(2))) + .gas_price_wei((n as u128).pow(3)) + .amount((n as u128).pow(4)) + .nonce(n as u64) + .build() +} + +pub fn make_sent_tx(n: u32) -> SentTx { + let n = n % 0xfff; + TxBuilder::default() + .hash(make_tx_hash(n)) + .timestamp(((n * 12) as i64).pow(2)) + .template(SignableTxTemplate { + receiver_address: make_address(n), + amount_in_wei: (n as u128).pow(4), + gas_price_wei: (n as u128).pow(3), + nonce: n as u64, + }) + .build() +} + +pub fn assert_on_sent_txs(actual: Vec, expected: Vec) { + assert_eq!(actual.len(), expected.len()); + + actual.iter().zip(expected).for_each(|(st1, st2)| { + assert_eq!(st1.hash, st2.hash); + assert_eq!(st1.receiver_address, st2.receiver_address); + assert_eq!(st1.amount_minor, st2.amount_minor); + assert_eq!(st1.gas_price_minor, st2.gas_price_minor); + assert_eq!(st1.nonce, st2.nonce); + assert_eq!(st1.status, st2.status); + assert!((st1.timestamp - st2.timestamp).abs() < 10); + }) +} + +pub fn assert_on_failed_txs(actual: Vec, expected: Vec) { + assert_eq!(actual.len(), expected.len()); + + actual.iter().zip(expected).for_each(|(f1, f2)| { + assert_eq!(f1.hash, f2.hash); + assert_eq!(f1.receiver_address, f2.receiver_address); + assert_eq!(f1.amount_minor, f2.amount_minor); + assert_eq!(f1.gas_price_minor, f2.gas_price_minor); + assert_eq!(f1.nonce, f2.nonce); + assert_eq!(f1.reason, f2.reason); + assert_eq!(f1.status, f2.status); + assert!((f1.timestamp - f2.timestamp).abs() < 10); + }) +} + +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 8b78bb5f46..98c14ac3e0 100644 --- a/node/src/accountant/db_access_objects/utils.rs +++ b/node/src/accountant/db_access_objects/utils.rs @@ -1,7 +1,9 @@ // Copyright (c) 2019, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. +use crate::accountant::db_access_objects::failed_payable_dao::FailedTx; use crate::accountant::db_access_objects::payable_dao::PayableAccount; use crate::accountant::db_access_objects::receivable_dao::ReceivableAccount; +use crate::accountant::db_access_objects::sent_payable_dao::SentTx; use crate::accountant::db_big_integer::big_int_divider::BigIntDivider; use crate::accountant::{checked_conversion, gwei_to_wei, sign_conversion}; use crate::database::db_initializer::{ @@ -9,11 +11,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,7 +25,11 @@ use std::string::ToString; use std::time::Duration; use std::time::SystemTime; -pub fn to_time_t(system_time: SystemTime) -> i64 { +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"), Err(e) => panic!( @@ -31,15 +39,56 @@ pub fn to_time_t(system_time: SystemTime) -> i64 { } } -pub fn now_time_t() -> i64 { - to_time_t(SystemTime::now()) +pub fn current_unix_timestamp() -> i64 { + to_unix_timestamp(SystemTime::now()) } -pub fn from_time_t(time_t: i64) -> SystemTime { - let interval = Duration::from_secs(time_t as u64); +pub fn from_unix_timestamp(unix_timestamp: i64) -> SystemTime { + let interval = Duration::from_secs(unix_timestamp as u64); SystemTime::UNIX_EPOCH + interval } +pub fn sql_values_of_failed_tx(failed_tx: &FailedTx) -> String { + let amount_checked = checked_conversion::(failed_tx.amount_minor); + let gas_price_wei_checked = checked_conversion::(failed_tx.gas_price_minor); + 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!( + "('{:?}', '{:?}', {}, {}, {}, {}, {}, {}, '{}', '{}')", + failed_tx.hash, + failed_tx.receiver_address, + amount_high_b, + amount_low_b, + failed_tx.timestamp, + gas_price_wei_high_b, + gas_price_wei_low_b, + failed_tx.nonce, + failed_tx.reason, + failed_tx.status + ) +} + +pub fn sql_values_of_sent_tx(sent_tx: &SentTx) -> String { + let amount_checked = checked_conversion::(sent_tx.amount_minor); + let gas_price_wei_checked = checked_conversion::(sent_tx.gas_price_minor); + 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!( + "('{:?}', '{:?}', {}, {}, {}, {}, {}, {}, '{}')", + sent_tx.hash, + sent_tx.receiver_address, + amount_high_b, + amount_low_b, + sent_tx.timestamp, + gas_price_wei_high_b, + gas_price_wei_low_b, + sent_tx.nonce, + sent_tx.status + ) +} + pub struct DaoFactoryReal { pub data_directory: PathBuf, pub init_config: DbInitializationConfig, @@ -193,11 +242,11 @@ impl CustomQuery { max_age: u64, timestamp: SystemTime, ) -> RusqliteParamsWithOwnedToSql { - let now = to_time_t(timestamp); - let age_to_time_t = |age_limit| now - checked_conversion::(age_limit); + let now = to_unix_timestamp(timestamp); + let age_to_unix_timestamp = |age_limit| now - checked_conversion::(age_limit); vec![ - (":min_timestamp", Box::new(age_to_time_t(max_age))), - (":max_timestamp", Box::new(age_to_time_t(min_age))), + (":min_timestamp", Box::new(age_to_unix_timestamp(max_age))), + (":max_timestamp", Box::new(age_to_unix_timestamp(min_age))), ] } @@ -299,7 +348,7 @@ pub fn remap_receivable_accounts(accounts: Vec) -> Vec u64 { - (to_time_t(SystemTime::now()) - to_time_t(timestamp)) as u64 + (to_unix_timestamp(SystemTime::now()) - to_unix_timestamp(timestamp)) as u64 } #[allow(clippy::type_complexity)] @@ -466,8 +515,8 @@ mod tests { }; let assigned_value_1 = get_assigned_value(param_pair_1.1.to_sql().unwrap()); let assigned_value_2 = get_assigned_value(param_pair_2.1.to_sql().unwrap()); - assert_eq!(assigned_value_1, to_time_t(now) - 10000); - assert_eq!(assigned_value_2, to_time_t(now) - 5555) + assert_eq!(assigned_value_1, to_unix_timestamp(now) - 10000); + assert_eq!(assigned_value_2, to_unix_timestamp(now) - 5555) } #[test] @@ -608,10 +657,10 @@ mod tests { #[test] #[should_panic(expected = "Must be wrong, moment way far in the past")] - fn to_time_t_does_not_like_time_traveling() { + fn to_unix_timestamp_does_not_like_time_traveling() { let far_far_before = UNIX_EPOCH.checked_sub(Duration::from_secs(1)).unwrap(); - let _ = to_time_t(far_far_before); + let _ = to_unix_timestamp(far_far_before); } #[test] diff --git a/node/src/accountant/db_big_integer/big_int_db_processor.rs b/node/src/accountant/db_big_integer/big_int_db_processor.rs index 3ef15278d4..c362e3740f 100644 --- a/node/src/accountant/db_big_integer/big_int_db_processor.rs +++ b/node/src/accountant/db_big_integer/big_int_db_processor.rs @@ -322,6 +322,7 @@ pub trait DisplayableParamValue: ToSql + Display {} impl DisplayableParamValue for i64 {} impl DisplayableParamValue for &str {} +impl DisplayableParamValue for String {} impl DisplayableParamValue for Wallet {} #[derive(Default)] diff --git a/node/src/accountant/mod.rs b/node/src/accountant/mod.rs index 0a74d076c1..31f3033b58 100644 --- a/node/src/accountant/mod.rs +++ b/node/src/accountant/mod.rs @@ -14,32 +14,40 @@ use masq_lib::constants::{SCAN_ERROR, WEIS_IN_GWEI}; use std::cell::{Ref, RefCell}; use crate::accountant::db_access_objects::payable_dao::{PayableDao, PayableDaoError}; -use crate::accountant::db_access_objects::pending_payable_dao::PendingPayableDao; use crate::accountant::db_access_objects::receivable_dao::{ReceivableDao, ReceivableDaoError}; +use crate::accountant::db_access_objects::sent_payable_dao::{SentPayableDao, SentTx}; use crate::accountant::db_access_objects::utils::{ - remap_payable_accounts, remap_receivable_accounts, CustomQuery, DaoFactoryReal, + remap_payable_accounts, remap_receivable_accounts, CustomQuery, DaoFactoryReal, TxHash, }; use crate::accountant::financials::visibility_restricted_module::{ check_query_is_within_tech_limits, financials_entry_check, }; -use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::msgs::{ - BlockchainAgentWithContextMessage, QualifiedPayablesMessage, +use crate::accountant::scanners::payable_scanner::msgs::{ + InitialTemplatesMessage, PricedTemplatesMessage, +}; +use crate::accountant::scanners::payable_scanner::utils::NextScanToRun; +use crate::accountant::scanners::pending_payable_scanner::utils::{ + PendingPayableScanResult, TxHashByTable, +}; +use crate::accountant::scanners::scan_schedulers::{ + PayableSequenceScanner, ScanReschedulingAfterEarlyStop, ScanSchedulers, +}; +use crate::accountant::scanners::{Scanners, StartScanError}; +use crate::blockchain::blockchain_bridge::{ + BlockMarker, RegisterNewPendingPayables, RetrieveTransactions, }; -use crate::accountant::scanners::{BeginScanError, ScanSchedulers, Scanners}; -use crate::blockchain::blockchain_bridge::{BlockMarker, PendingPayableFingerprint, PendingPayableFingerprintSeeds, RetrieveTransactions}; -use crate::blockchain::blockchain_interface::blockchain_interface_web3::HashAndAmount; -use crate::blockchain::blockchain_interface::data_structures::errors::PayableTransactionError; use crate::blockchain::blockchain_interface::data_structures::{ - BlockchainTransaction, ProcessedPayableFallible, + BatchResults, BlockchainTransaction, StatusReadFromReceiptCheck, }; +use crate::blockchain::errors::rpc_errors::AppRpcError; use crate::bootstrapper::BootstrapperConfig; use crate::database::db_initializer::DbInitializationConfig; -use crate::sub_lib::accountant::AccountantSubs; use crate::sub_lib::accountant::DaoFactories; use crate::sub_lib::accountant::FinancialStatistics; use crate::sub_lib::accountant::ReportExitServiceProvidedMessage; use crate::sub_lib::accountant::ReportRoutingServiceProvidedMessage; use crate::sub_lib::accountant::ReportServicesConsumedMessage; +use crate::sub_lib::accountant::{AccountantSubs, DetailedScanType}; use crate::sub_lib::accountant::{MessageIdGenerator, MessageIdGeneratorReal}; use crate::sub_lib::blockchain_bridge::OutboundPaymentsInstructions; use crate::sub_lib::neighborhood::{ConfigChange, ConfigChangeMsg}; @@ -57,17 +65,17 @@ use itertools::Either; use itertools::Itertools; use masq_lib::crash_point::CrashPoint; use masq_lib::logger::Logger; -use masq_lib::messages::UiFinancialsResponse; use masq_lib::messages::{FromMessageBody, ToMessageBody, UiFinancialsRequest}; use masq_lib::messages::{ - QueryResults, ScanType, UiFinancialStatistics, UiPayableAccount, UiReceivableAccount, - UiScanRequest, + QueryResults, UiFinancialStatistics, UiPayableAccount, UiReceivableAccount, UiScanRequest, }; +use masq_lib::messages::{ScanType, UiFinancialsResponse, UiScanResponse}; use masq_lib::ui_gateway::MessageTarget::ClientId; -use masq_lib::ui_gateway::{MessageBody, MessagePath}; +use masq_lib::ui_gateway::{MessageBody, MessagePath, MessageTarget}; use masq_lib::ui_gateway::{NodeFromUiMessage, NodeToUiMessage}; use masq_lib::utils::ExpectValue; use std::any::type_name; +use std::collections::{BTreeMap, BTreeSet}; #[cfg(test)] use std::default::Default; use std::fmt::Display; @@ -75,27 +83,24 @@ use std::ops::{Div, Mul}; use std::path::Path; use std::rc::Rc; use std::time::SystemTime; -use web3::types::H256; -use crate::blockchain::blockchain_interface::blockchain_interface_web3::lower_level_interface_web3::TransactionReceiptResult; pub const CRASH_KEY: &str = "ACCOUNTANT"; pub const DEFAULT_PENDING_TOO_LONG_SEC: u64 = 21_600; //6 hours pub struct Accountant { - suppress_initial_scans: bool, consuming_wallet_opt: Option, earning_wallet: Wallet, payable_dao: Box, receivable_dao: Box, - pending_payable_dao: Box, + sent_payable_dao: Box, crashable: bool, scanners: Scanners, scan_schedulers: ScanSchedulers, financial_statistics: Rc>, outbound_payments_instructions_sub_opt: Option>, - qualified_payables_sub_opt: Option>, + qualified_payables_sub_opt: Option>, retrieve_transactions_sub_opt: Option>, - request_transaction_receipts_subs_opt: Option>, + request_transaction_receipts_sub_opt: Option>, report_inbound_payments_sub_opt: Option>, report_sent_payables_sub_opt: Option>, ui_message_sub_opt: Option>, @@ -134,30 +139,50 @@ pub struct ReceivedPayments { pub response_skeleton_opt: Option, } -#[derive(Debug, Message, PartialEq)] +pub type TxReceiptResult = Result; + +#[derive(Debug, PartialEq, Eq, Message, Clone)] +pub struct TxReceiptsMessage { + pub results: BTreeMap, + pub response_skeleton_opt: Option, +} + +#[derive(Debug, PartialEq, Eq, Copy, Clone)] +pub enum PayableScanType { + New, + Retry, +} + +#[derive(Debug, Message, PartialEq, Eq, Clone)] pub struct SentPayables { - pub payment_procedure_result: Result, PayableTransactionError>, + pub payment_procedure_result: Result, + pub payable_scan_type: PayableScanType, pub response_skeleton_opt: Option, } #[derive(Debug, Message, Default, PartialEq, Eq, Clone, Copy)] -pub struct ScanForPayables { +pub struct ScanForPendingPayables { pub response_skeleton_opt: Option, } #[derive(Debug, Message, Default, PartialEq, Eq, Clone, Copy)] -pub struct ScanForReceivables { +pub struct ScanForNewPayables { pub response_skeleton_opt: Option, } #[derive(Debug, Message, Default, PartialEq, Eq, Clone, Copy)] -pub struct ScanForPendingPayables { +pub struct ScanForRetryPayables { + pub response_skeleton_opt: Option, +} + +#[derive(Debug, Message, Default, PartialEq, Eq, Clone, Copy)] +pub struct ScanForReceivables { pub response_skeleton_opt: Option, } #[derive(Debug, Clone, Message, PartialEq, Eq)] pub struct ScanError { - pub scan_type: ScanType, + pub scan_type: DetailedScanType, pub response_skeleton_opt: Option, pub msg: String, } @@ -183,134 +208,265 @@ impl Handler for Accountant { type Result = (); fn handle(&mut self, _msg: StartMessage, ctx: &mut Self::Context) -> Self::Result { - if self.suppress_initial_scans { - info!( - &self.logger, - "Started with --scans off; declining to begin database and blockchain scans" - ); - } else { + if self.scan_schedulers.automatic_scans_enabled { debug!( &self.logger, "Started with --scans on; starting database and blockchain scans" ); - ctx.notify(ScanForPendingPayables { response_skeleton_opt: None, }); - ctx.notify(ScanForPayables { - response_skeleton_opt: None, - }); ctx.notify(ScanForReceivables { response_skeleton_opt: None, }); + } else { + info!( + &self.logger, + "Started with --scans off; declining to begin database and blockchain scans" + ); } } } -impl Handler for Accountant { +impl Handler for Accountant { type Result = (); - fn handle(&mut self, msg: ReceivedPayments, _ctx: &mut Self::Context) -> Self::Result { - if let Some(node_to_ui_msg) = self.scanners.receivable.finish_scan(msg, &self.logger) { - self.ui_message_sub_opt - .as_ref() - .expect("UIGateway is not bound") - .try_send(node_to_ui_msg) - .expect("UIGateway is dead"); + fn handle(&mut self, msg: ScanForPendingPayables, ctx: &mut Self::Context) -> Self::Result { + // By now we know this is an automatic scan process. The scan may be or may not be + // rescheduled. It depends on the findings. Any failed transaction will lead to the launch + // of the RetryPayableScanner, which finishes, and the PendingPayablesScanner is scheduled + // to run again. However, not from here. + let response_skeleton_opt = msg.response_skeleton_opt; + + let scheduling_hint = + self.handle_request_of_scan_for_pending_payable(response_skeleton_opt); + + match scheduling_hint { + ScanReschedulingAfterEarlyStop::Schedule(ScanType::Payables) => self + .scan_schedulers + .payable + .schedule_new_payable_scan(ctx, &self.logger), + ScanReschedulingAfterEarlyStop::Schedule(ScanType::PendingPayables) => self + .scan_schedulers + .pending_payable + .schedule(ctx, &self.logger), + ScanReschedulingAfterEarlyStop::Schedule(scan_type) => unreachable!( + "Early stopped pending payable scan was suggested to be followed up \ + by the scan for {:?}, which is not supported though", + scan_type + ), + ScanReschedulingAfterEarlyStop::DoNotSchedule => { + trace!( + self.logger, + "No early rescheduling, as the pending payable scan did find results" + ); + } } } } -impl Handler for Accountant { +impl Handler for Accountant { type Result = (); - fn handle( - &mut self, - msg: BlockchainAgentWithContextMessage, - _ctx: &mut Self::Context, - ) -> Self::Result { - self.handle_payable_payment_setup(msg) + fn handle(&mut self, msg: ScanForNewPayables, ctx: &mut Self::Context) -> Self::Result { + // We know this must be a scheduled scan, but are yet clueless where it's going to be + // rescheduled. If no payable qualifies for a payment, we do it here right away. If some + // transactions made it out, the next scheduling of this scanner is going to be decided by + // the PendingPayableScanner whose job is to evaluate if it has seen every pending payable + // complete. That's the moment when another run of the NewPayableScanner makes sense again. + let response_skeleton = msg.response_skeleton_opt; + + let scheduling_hint = self.handle_request_of_scan_for_new_payable(response_skeleton); + + match scheduling_hint { + ScanReschedulingAfterEarlyStop::Schedule(ScanType::Payables) => self + .scan_schedulers + .payable + .schedule_new_payable_scan(ctx, &self.logger), + ScanReschedulingAfterEarlyStop::Schedule(other_scan_type) => unreachable!( + "Early stopped new payable scan was suggested to be followed up by the scan \ + for {:?}, which is not supported though", + other_scan_type + ), + ScanReschedulingAfterEarlyStop::DoNotSchedule => { + trace!( + self.logger, + "No early rescheduling, as the new payable scan did find results" + ) + } + } } } -impl Handler for Accountant { +impl Handler for Accountant { type Result = (); - fn handle(&mut self, msg: SentPayables, _ctx: &mut Self::Context) -> Self::Result { - if let Some(node_to_ui_msg) = self.scanners.payable.finish_scan(msg, &self.logger) { - self.ui_message_sub_opt - .as_ref() - .expect("UIGateway is not bound") - .try_send(node_to_ui_msg) - .expect("UIGateway is dead"); - } + fn handle(&mut self, msg: ScanForRetryPayables, _ctx: &mut Self::Context) -> Self::Result { + // RetryPayableScanner is scheduled only when the PendingPayableScanner finishes discovering + // that there have been some failed payables. No place for that here. + let response_skeleton = msg.response_skeleton_opt; + self.handle_request_of_scan_for_retry_payable(response_skeleton); + } +} + +impl Handler for Accountant { + type Result = (); + + fn handle(&mut self, msg: ScanForReceivables, ctx: &mut Self::Context) -> Self::Result { + // By now we know it is an automatic scan. The ReceivableScanner is independent of other + // scanners and rescheduled regularly, just here. + self.handle_request_of_scan_for_receivable(msg.response_skeleton_opt); + self.scan_schedulers.receivable.schedule(ctx, &self.logger); } } -impl Handler for Accountant { +impl Handler for Accountant { type Result = (); - fn handle(&mut self, msg: ScanForPayables, ctx: &mut Self::Context) -> Self::Result { - self.handle_request_of_scan_for_payable(msg.response_skeleton_opt); - self.schedule_next_scan(ScanType::Payables, ctx); + fn handle(&mut self, msg: TxReceiptsMessage, ctx: &mut Self::Context) -> Self::Result { + match self.scanners.finish_pending_payable_scan(msg, &self.logger) { + PendingPayableScanResult::NoPendingPayablesLeft(ui_msg_opt) => { + if let Some(node_to_ui_msg) = ui_msg_opt { + self.ui_message_sub_opt + .as_ref() + .expect("UIGateway is not bound") + .try_send(node_to_ui_msg) + .expect("UIGateway is dead"); + // Non-automatic scan for pending payables is not permitted to spark a payable + // scan bringing over new payables with fresh nonces. The job's done here. + } else { + self.scan_schedulers + .payable + .schedule_new_payable_scan(ctx, &self.logger) + } + } + PendingPayableScanResult::PaymentRetryRequired(response_skeleton_opt) => self + .scan_schedulers + .payable + .schedule_retry_payable_scan(ctx, response_skeleton_opt, &self.logger), + PendingPayableScanResult::ProcedureShouldBeRepeated(ui_msg_opt) => { + if let Some(node_to_ui_msg) = ui_msg_opt { + info!( + self.logger, + "Re-running the pending payable scan is recommended, as some parts \ + did not finish last time." + ); + self.ui_message_sub_opt + .as_ref() + .expect("UIGateway is not bound") + .try_send(node_to_ui_msg) + .expect("UIGateway is dead"); + // The repetition must be triggered by an external impulse + } else { + self.scan_schedulers + .pending_payable + .schedule(ctx, &self.logger) + } + } + }; } } -impl Handler for Accountant { +impl Handler for Accountant { type Result = (); - fn handle(&mut self, msg: ScanForPendingPayables, ctx: &mut Self::Context) -> Self::Result { - self.handle_request_of_scan_for_pending_payable(msg.response_skeleton_opt); - self.schedule_next_scan(ScanType::PendingPayables, ctx); + fn handle(&mut self, msg: PricedTemplatesMessage, _ctx: &mut Self::Context) -> Self::Result { + self.handle_payable_payment_setup(msg) } } -impl Handler for Accountant { +impl Handler for Accountant { type Result = (); - fn handle(&mut self, msg: ScanForReceivables, ctx: &mut Self::Context) -> Self::Result { - self.handle_request_of_scan_for_receivable(msg.response_skeleton_opt); - self.schedule_next_scan(ScanType::Receivables, ctx); + fn handle(&mut self, msg: SentPayables, ctx: &mut Self::Context) -> Self::Result { + let scan_result = self.scanners.finish_payable_scan(msg, &self.logger); + + match scan_result.ui_response_opt { + None => self.schedule_next_automatic_scan(scan_result.result, ctx), + Some(node_to_ui_msg) => { + self.ui_message_sub_opt + .as_ref() + .expect("UIGateway is not bound") + .try_send(node_to_ui_msg) + .expect("UIGateway is dead"); + + // Externally triggered scans are not allowed to provoke an unwinding scan sequence + // with intervals. The only exception is the PendingPayableScanner that is always + // followed by the retry-payable scanner in a tight tandem. + } + } + } +} + +impl Handler for Accountant { + type Result = (); + + fn handle(&mut self, msg: ReceivedPayments, _ctx: &mut Self::Context) -> Self::Result { + if let Some(node_to_ui_msg) = self.scanners.finish_receivable_scan(msg, &self.logger) { + self.ui_message_sub_opt + .as_ref() + .expect("UIGateway is not bound") + .try_send(node_to_ui_msg) + .expect("UIGateway is dead"); + } } } impl Handler for Accountant { type Result = (); - fn handle(&mut self, scan_error: ScanError, _ctx: &mut Self::Context) -> Self::Result { + fn handle(&mut self, scan_error: ScanError, ctx: &mut Self::Context) -> Self::Result { error!(self.logger, "Received ScanError: {:?}", scan_error); - match scan_error.scan_type { - ScanType::Payables => { - self.scanners.payable.mark_as_ended(&self.logger); - } - ScanType::PendingPayables => { - self.scanners.pending_payable.mark_as_ended(&self.logger); + + self.scanners + .acknowledge_scan_error(&scan_error, &self.logger); + + match scan_error.response_skeleton_opt { + None => { + debug!( + self.logger, + "Trying to restore the scan train after a crash" + ); + match scan_error.scan_type { + DetailedScanType::NewPayables => self + .scan_schedulers + .payable + .schedule_new_payable_scan(ctx, &self.logger), + DetailedScanType::RetryPayables => self + .scan_schedulers + .payable + .schedule_retry_payable_scan(ctx, None, &self.logger), + DetailedScanType::PendingPayables => self + .scan_schedulers + .pending_payable + .schedule(ctx, &self.logger), + DetailedScanType::Receivables => { + self.scan_schedulers.receivable.schedule(ctx, &self.logger) + } + } } - ScanType::Receivables => { - self.scanners.receivable.mark_as_ended(&self.logger); + Some(response_skeleton) => { + let error_msg = NodeToUiMessage { + target: ClientId(response_skeleton.client_id), + body: MessageBody { + opcode: "scan".to_string(), + path: MessagePath::Conversation(response_skeleton.context_id), + payload: Err(( + SCAN_ERROR, + format!( + "{:?} scan failed: '{}'", + scan_error.scan_type, scan_error.msg + ), + )), + }, + }; + error!(self.logger, "Sending UiScanResponse: {:?}", error_msg); + self.ui_message_sub_opt + .as_ref() + .expect("UIGateway not bound") + .try_send(error_msg) + .expect("UiGateway is dead"); } - }; - if let Some(response_skeleton) = scan_error.response_skeleton_opt { - let error_msg = NodeToUiMessage { - target: ClientId(response_skeleton.client_id), - body: MessageBody { - opcode: "scan".to_string(), - path: MessagePath::Conversation(response_skeleton.context_id), - payload: Err(( - SCAN_ERROR, - format!( - "{:?} scan failed: '{}'", - scan_error.scan_type, scan_error.msg - ), - )), - }, - }; - error!(self.logger, "Sending UiScanResponse: {:?}", error_msg); - self.ui_message_sub_opt - .as_ref() - .expect("UIGateway not bound") - .try_send(error_msg) - .expect("UiGateway is dead"); } } } @@ -357,7 +513,7 @@ pub trait SkeletonOptHolder { #[derive(Debug, PartialEq, Eq, Message, Clone)] pub struct RequestTransactionReceipts { - pub pending_payable: Vec, + pub tx_hashes: Vec, pub response_skeleton_opt: Option, } @@ -367,34 +523,14 @@ impl SkeletonOptHolder for RequestTransactionReceipts { } } -#[derive(Debug, PartialEq, Eq, Message, Clone)] -pub struct ReportTransactionReceipts { - pub fingerprints_with_receipts: Vec<(TransactionReceiptResult, PendingPayableFingerprint)>, - pub response_skeleton_opt: Option, -} - -impl Handler for Accountant { - type Result = (); - - fn handle(&mut self, msg: ReportTransactionReceipts, _ctx: &mut Self::Context) -> Self::Result { - if let Some(node_to_ui_msg) = self.scanners.pending_payable.finish_scan(msg, &self.logger) { - self.ui_message_sub_opt - .as_ref() - .expect("UIGateway is not bound") - .try_send(node_to_ui_msg) - .expect("UIGateway is dead"); - } - } -} - -impl Handler for Accountant { +impl Handler for Accountant { type Result = (); fn handle( &mut self, - msg: PendingPayableFingerprintSeeds, + msg: RegisterNewPendingPayables, _ctx: &mut Self::Context, ) -> Self::Result { - self.handle_new_pending_payable_fingerprints(msg) + self.register_new_pending_sent_tx(msg) } } @@ -427,32 +563,31 @@ impl Accountant { let earning_wallet = config.earning_wallet.clone(); let financial_statistics = Rc::new(RefCell::new(FinancialStatistics::default())); let payable_dao = dao_factories.payable_dao_factory.make(); - let pending_payable_dao = dao_factories.pending_payable_dao_factory.make(); + let sent_payable_dao = dao_factories.sent_payable_dao_factory.make(); let receivable_dao = dao_factories.receivable_dao_factory.make(); + let scan_schedulers = ScanSchedulers::new(scan_intervals, config.automatic_scans_enabled); let scanners = Scanners::new( dao_factories, Rc::new(payment_thresholds), - config.when_pending_too_long_sec, Rc::clone(&financial_statistics), ); Accountant { - suppress_initial_scans: config.suppress_initial_scans, consuming_wallet_opt: config.consuming_wallet_opt.clone(), earning_wallet, payable_dao, receivable_dao, - pending_payable_dao, + sent_payable_dao, scanners, crashable: config.crash_point == CrashPoint::Message, - scan_schedulers: ScanSchedulers::new(scan_intervals), + scan_schedulers, financial_statistics: Rc::clone(&financial_statistics), outbound_payments_instructions_sub_opt: None, qualified_payables_sub_opt: None, report_sent_payables_sub_opt: None, retrieve_transactions_sub_opt: None, report_inbound_payments_sub_opt: None, - request_transaction_receipts_subs_opt: None, + request_transaction_receipts_sub_opt: None, ui_message_sub_opt: None, message_id_generator: Box::new(MessageIdGeneratorReal::default()), logger: Logger::new("Accountant"), @@ -467,10 +602,10 @@ impl Accountant { report_routing_service_provided: recipient!(addr, ReportRoutingServiceProvidedMessage), report_exit_service_provided: recipient!(addr, ReportExitServiceProvidedMessage), report_services_consumed: recipient!(addr, ReportServicesConsumedMessage), - report_payable_payments_setup: recipient!(addr, BlockchainAgentWithContextMessage), + report_payable_payments_setup: recipient!(addr, PricedTemplatesMessage), report_inbound_payments: recipient!(addr, ReceivedPayments), - init_pending_payable_fingerprints: recipient!(addr, PendingPayableFingerprintSeeds), - report_transaction_receipts: recipient!(addr, ReportTransactionReceipts), + register_new_pending_payables: recipient!(addr, RegisterNewPendingPayables), + report_transaction_status: recipient!(addr, TxReceiptsMessage), report_sent_payments: recipient!(addr, SentPayables), scan_errors: recipient!(addr, ScanError), ui_message_sub: recipient!(addr, NodeFromUiMessage), @@ -504,12 +639,12 @@ impl Accountant { byte_rate, payload_size ), - Err(e) => panic!("Recording services provided for {} but has hit fatal database error: {:?}", wallet, e) + Err(e) => panic!("Was recording services provided for {} but hit a fatal database error: {:?}", wallet, e) }; } else { warning!( self.logger, - "Declining to record a receivable against our wallet {} for service we provided", + "Declining to record a receivable against our wallet {} for services we provided", wallet ); } @@ -571,7 +706,7 @@ impl Accountant { Some(msg.peer_actors.blockchain_bridge.qualified_payables); self.report_sent_payables_sub_opt = Some(msg.peer_actors.accountant.report_sent_payments); self.ui_message_sub_opt = Some(msg.peer_actors.ui_gateway.node_to_ui_message_sub); - self.request_transaction_receipts_subs_opt = Some( + self.request_transaction_receipts_sub_opt = Some( msg.peer_actors .blockchain_bridge .request_transaction_receipts, @@ -600,21 +735,15 @@ impl Accountant { } } - fn schedule_next_scan(&self, scan_type: ScanType, ctx: &mut Context) { - self.scan_schedulers - .schedulers - .get(&scan_type) - .unwrap_or_else(|| panic!("Scan Scheduler {:?} not properly prepared", scan_type)) - .schedule(ctx) - } - fn handle_report_routing_service_provided_message( &mut self, msg: ReportRoutingServiceProvidedMessage, ) { - debug!( + trace!( self.logger, - "Charging routing of {} bytes to wallet {}", msg.payload_size, msg.paying_wallet + "Charging routing of {} bytes to wallet {}", + msg.payload_size, + msg.paying_wallet ); self.record_service_provided( msg.service_rate, @@ -629,7 +758,7 @@ impl Accountant { &mut self, msg: ReportExitServiceProvidedMessage, ) { - debug!( + trace!( self.logger, "Charging exit service for {} bytes to wallet {} at {} per service and {} per byte", msg.payload_size, @@ -656,7 +785,7 @@ impl Accountant { fn handle_report_services_consumed_message(&mut self, msg: ReportServicesConsumedMessage) { let msg_id = self.msg_id(); - debug!( + trace!( self.logger, "MsgId {}: Accruing debt to {} for consuming {} exited bytes", msg_id, @@ -671,7 +800,7 @@ impl Accountant { &msg.exit.earning_wallet, ); msg.routing.iter().for_each(|routing_service| { - debug!( + trace!( self.logger, "MsgId {}: Accruing debt to {} for consuming {} routed bytes", msg_id, @@ -688,18 +817,16 @@ impl Accountant { }) } - fn handle_payable_payment_setup(&mut self, msg: BlockchainAgentWithContextMessage) { + fn handle_payable_payment_setup(&mut self, msg: PricedTemplatesMessage) { let blockchain_bridge_instructions = match self .scanners - .payable - .try_skipping_payment_adjustment(msg, &self.logger) + .try_skipping_payable_adjustment(msg, &self.logger) { Ok(Either::Left(finalized_msg)) => finalized_msg, Ok(Either::Right(unaccepted_msg)) => { //TODO we will eventually query info from Neighborhood before the adjustment, according to GH-699 self.scanners - .payable - .perform_payment_adjustment(unaccepted_msg, &self.logger) + .perform_payable_adjustment(unaccepted_msg, &self.logger) } Err(_e) => todo!("be completed by GH-711"), }; @@ -839,19 +966,55 @@ impl Accountant { } } - fn handle_request_of_scan_for_payable( + fn handle_request_of_scan_for_new_payable( &mut self, response_skeleton_opt: Option, - ) { - let result = match self.consuming_wallet_opt.clone() { - Some(consuming_wallet) => self.scanners.payable.begin_scan( - consuming_wallet, - SystemTime::now(), + ) -> ScanReschedulingAfterEarlyStop { + let result: Result = + match self.consuming_wallet_opt.as_ref() { + Some(consuming_wallet) => self.scanners.start_new_payable_scan_guarded( + consuming_wallet, + SystemTime::now(), + response_skeleton_opt, + &self.logger, + self.scan_schedulers.automatic_scans_enabled, + ), + None => Err(StartScanError::NoConsumingWalletFound), + }; + + self.scan_schedulers.payable.reset_scan_timer(&self.logger); + + match result { + Ok(scan_message) => { + self.qualified_payables_sub_opt + .as_ref() + .expect("BlockchainBridge is unbound") + .try_send(scan_message) + .expect("BlockchainBridge is dead"); + ScanReschedulingAfterEarlyStop::DoNotSchedule + } + Err(e) => self.handle_start_scan_error_and_prevent_scan_stall_point( + PayableSequenceScanner::NewPayables, + e, response_skeleton_opt, - &self.logger, ), - None => Err(BeginScanError::NoConsumingWalletFound), - }; + } + } + + fn handle_request_of_scan_for_retry_payable( + &mut self, + response_skeleton_opt: Option, + ) { + let result: Result = + match self.consuming_wallet_opt.as_ref() { + Some(consuming_wallet) => self.scanners.start_retry_payable_scan_guarded( + consuming_wallet, + SystemTime::now(), + response_skeleton_opt, + &self.logger, + ), + None => Err(StartScanError::NoConsumingWalletFound), + }; match result { Ok(scan_message) => { @@ -861,65 +1024,127 @@ impl Accountant { .try_send(scan_message) .expect("BlockchainBridge is dead"); } - Err(e) => e.handle_error( - &self.logger, - ScanType::Payables, - response_skeleton_opt.is_some(), - ), + Err(e) => { + // It is thrown away and there is no rescheduling downstream because every error + // happening here on the start resolves into a panic by the current design + let _ = self.handle_start_scan_error_and_prevent_scan_stall_point( + PayableSequenceScanner::RetryPayables, + e, + response_skeleton_opt, + ); + } } } fn handle_request_of_scan_for_pending_payable( &mut self, response_skeleton_opt: Option, - ) { - let result = match self.consuming_wallet_opt.clone() { - Some(consuming_wallet) => self.scanners.pending_payable.begin_scan( - consuming_wallet, // This argument is not used and is therefore irrelevant - SystemTime::now(), - response_skeleton_opt, - &self.logger, - ), - None => Err(BeginScanError::NoConsumingWalletFound), + ) -> ScanReschedulingAfterEarlyStop { + let result: Result = + match self.consuming_wallet_opt.as_ref() { + Some(consuming_wallet) => self.scanners.start_pending_payable_scan_guarded( + consuming_wallet, // This argument is not used and is therefore irrelevant + SystemTime::now(), + response_skeleton_opt, + &self.logger, + self.scan_schedulers.automatic_scans_enabled, + ), + None => Err(StartScanError::NoConsumingWalletFound), + }; + + let hint: ScanReschedulingAfterEarlyStop = match result { + Ok(scan_message) => { + self.request_transaction_receipts_sub_opt + .as_ref() + .expect("BlockchainBridge is unbound") + .try_send(scan_message) + .expect("BlockchainBridge is dead"); + ScanReschedulingAfterEarlyStop::DoNotSchedule + } + Err(e) => { + let initial_pending_payable_scan = self.scanners.initial_pending_payable_scan(); + self.handle_start_scan_error_and_prevent_scan_stall_point( + PayableSequenceScanner::PendingPayables { + initial_pending_payable_scan, + }, + e, + response_skeleton_opt, + ) + } }; - match result { - Ok(scan_message) => self - .request_transaction_receipts_subs_opt - .as_ref() - .expect("BlockchainBridge is unbound") - .try_send(scan_message) - .expect("BlockchainBridge is dead"), - Err(e) => e.handle_error( - &self.logger, - ScanType::PendingPayables, - response_skeleton_opt.is_some(), - ), + if self.scanners.initial_pending_payable_scan() { + self.scanners.unset_initial_pending_payable_scan() } + + hint + } + + fn handle_start_scan_error_and_prevent_scan_stall_point( + &self, + scanner: PayableSequenceScanner, + e: StartScanError, + response_skeleton_opt: Option, + ) -> ScanReschedulingAfterEarlyStop { + let is_externally_triggered = response_skeleton_opt.is_some(); + + e.log_error(&self.logger, scanner.into(), is_externally_triggered); + + if let Some(skeleton) = response_skeleton_opt { + self.ui_message_sub_opt + .as_ref() + .expect("UiGateway is unbound") + .try_send(NodeToUiMessage { + target: MessageTarget::ClientId(skeleton.client_id), + body: UiScanResponse {}.tmb(skeleton.context_id), + }) + .expect("UiGateway is dead"); + }; + + self.scan_schedulers + .reschedule_on_error_resolver + .resolve_rescheduling_on_error(scanner, &e, is_externally_triggered, &self.logger) } fn handle_request_of_scan_for_receivable( &mut self, response_skeleton_opt: Option, ) { - match self.scanners.receivable.begin_scan( - self.earning_wallet.clone(), - SystemTime::now(), - response_skeleton_opt, - &self.logger, - ) { + let result: Result = + self.scanners.start_receivable_scan_guarded( + &self.earning_wallet, + SystemTime::now(), + response_skeleton_opt, + &self.logger, + self.scan_schedulers.automatic_scans_enabled, + ); + + match result { Ok(scan_message) => self .retrieve_transactions_sub_opt .as_ref() .expect("BlockchainBridge is unbound") .try_send(scan_message) .expect("BlockchainBridge is dead"), - Err(e) => e.handle_error( - &self.logger, - ScanType::Receivables, - response_skeleton_opt.is_some(), - ), - }; + Err(e) => { + e.log_error( + &self.logger, + ScanType::Receivables, + response_skeleton_opt.is_some(), + ); + + if let Some(skeleton) = response_skeleton_opt { + self.ui_message_sub_opt + .as_ref() + .expect("UiGateway is unbound") + .try_send(NodeToUiMessage { + target: MessageTarget::ClientId(skeleton.client_id), + body: UiScanResponse {}.tmb(skeleton.context_id), + }) + .expect("UiGateway is dead"); + }; + } + } } fn handle_externally_triggered_scan( @@ -928,38 +1153,61 @@ impl Accountant { scan_type: ScanType, response_skeleton: ResponseSkeleton, ) { + // Each of these scans runs only once per request, they do not go on into a sequence under + // any circumstances match scan_type { - ScanType::Payables => self.handle_request_of_scan_for_payable(Some(response_skeleton)), + ScanType::Payables => { + self.handle_request_of_scan_for_new_payable(Some(response_skeleton)); + } ScanType::PendingPayables => { self.handle_request_of_scan_for_pending_payable(Some(response_skeleton)); } ScanType::Receivables => { - self.handle_request_of_scan_for_receivable(Some(response_skeleton)) + self.handle_request_of_scan_for_receivable(Some(response_skeleton)); } } } - fn handle_new_pending_payable_fingerprints(&self, msg: PendingPayableFingerprintSeeds) { - fn serialize_hashes(fingerprints_data: &[HashAndAmount]) -> String { - comma_joined_stringifiable(fingerprints_data, |hash_and_amount| { - format!("{:?}", hash_and_amount.hash) - }) + fn schedule_next_automatic_scan( + &self, + next_scan_to_run: NextScanToRun, + ctx: &mut Context, + ) { + match next_scan_to_run { + NextScanToRun::PendingPayableScan => self + .scan_schedulers + .pending_payable + .schedule(ctx, &self.logger), + NextScanToRun::NewPayableScan => self + .scan_schedulers + .payable + .schedule_new_payable_scan(ctx, &self.logger), + NextScanToRun::RetryPayableScan => self + .scan_schedulers + .payable + .schedule_retry_payable_scan(ctx, None, &self.logger), } - match self - .pending_payable_dao - .insert_new_fingerprints(&msg.hashes_and_balances, msg.batch_wide_timestamp) - { + } + + fn register_new_pending_sent_tx(&self, msg: RegisterNewPendingPayables) { + fn serialize_hashes(tx_hashes: &[SentTx]) -> String { + join_with_commas(tx_hashes, |sent_tx| format!("{:?}", sent_tx.hash)) + } + + let sent_txs: BTreeSet = msg.new_sent_txs.iter().cloned().collect(); + + match self.sent_payable_dao.insert_new_records(&sent_txs) { Ok(_) => debug!( self.logger, - "Saved new pending payable fingerprints for: {}", - serialize_hashes(&msg.hashes_and_balances) + "Registered new pending payables for: {}", + serialize_hashes(&msg.new_sent_txs) ), Err(e) => error!( self.logger, - "Failed to process new pending payable fingerprints due to '{:?}', \ - disabling the automated confirmation for all these transactions: {}", - e, - serialize_hashes(&msg.hashes_and_balances) + "Failed to save new pending payable records for {} due to '{:?}' which is integral \ + to the function of the automated tx confirmation", + serialize_hashes(&msg.new_sent_txs), + e ), } } @@ -969,40 +1217,50 @@ impl Accountant { } } -#[derive(Debug, PartialEq, Eq, Clone, Copy)] -pub struct PendingPayableId { - pub rowid: u64, - pub hash: H256, +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PendingPayable { + pub recipient_wallet: Wallet, + pub hash: TxHash, } -impl PendingPayableId { - pub fn new(rowid: u64, hash: H256) -> Self { - Self { rowid, hash } +impl PendingPayable { + pub fn new(recipient_wallet: Wallet, hash: TxHash) -> Self { + Self { + recipient_wallet, + hash, + } } +} - fn rowids(ids: &[Self]) -> Vec { - ids.iter().map(|id| id.rowid).collect() - } +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub struct PendingPayableId { + pub rowid: u64, + pub hash: TxHash, +} - fn serialize_hashes_to_string(ids: &[Self]) -> String { - comma_joined_stringifiable(ids, |id| format!("{:?}", id.hash)) +impl PendingPayableId { + pub fn new(rowid: u64, hash: TxHash) -> Self { + Self { rowid, hash } } } -impl From for PendingPayableId { - fn from(pending_payable_fingerprint: PendingPayableFingerprint) -> Self { - Self { - hash: pending_payable_fingerprint.hash, - rowid: pending_payable_fingerprint.rowid, - } - } +pub fn join_with_separator(collection: I, stringify: F, separator: &str) -> String +where + F: Fn(&T) -> String, + I: IntoIterator, +{ + collection + .into_iter() + .map(|item| stringify(&item)) + .join(separator) } -pub fn comma_joined_stringifiable(collection: &[T], stringify: F) -> String +pub fn join_with_commas(collection: I, stringify: F) -> String where - F: FnMut(&T) -> String, + F: Fn(&T) -> String, + I: IntoIterator, { - collection.iter().map(stringify).join(", ") + join_with_separator(collection, stringify, ", ") } pub fn sign_conversion>(num: T) -> Result { @@ -1036,40 +1294,61 @@ pub fn wei_to_gwei, S: Display + Copy + Div + From> for Accountant { type Result = (); @@ -1139,9 +1419,10 @@ mod tests { #[test] fn new_calls_factories_properly() { - let config = make_bc_with_defaults(); + let config = make_bc_with_defaults(DEFAULT_CHAIN); let payable_dao_factory_params_arc = Arc::new(Mutex::new(vec![])); - let pending_payable_dao_factory_params_arc = Arc::new(Mutex::new(vec![])); + let failed_payable_dao_factory_params_arc = Arc::new(Mutex::new(vec![])); + let sent_payable_dao_factory_params_arc = Arc::new(Mutex::new(vec![])); let receivable_dao_factory_params_arc = Arc::new(Mutex::new(vec![])); let banned_dao_factory_params_arc = Arc::new(Mutex::new(vec![])); let config_dao_factory_params_arc = Arc::new(Mutex::new(vec![])); @@ -1150,11 +1431,15 @@ mod tests { .make_result(PayableDaoMock::new()) // For Accountant .make_result(PayableDaoMock::new()) // For Payable Scanner .make_result(PayableDaoMock::new()); // For PendingPayable Scanner - let pending_payable_dao_factory = PendingPayableDaoFactoryMock::new() - .make_params(&pending_payable_dao_factory_params_arc) - .make_result(PendingPayableDaoMock::new()) // For Accountant - .make_result(PendingPayableDaoMock::new()) // For Payable Scanner - .make_result(PendingPayableDaoMock::new()); // For PendingPayable Scanner + let sent_payable_dao_factory = SentPayableDaoFactoryMock::new() + .make_params(&sent_payable_dao_factory_params_arc) + .make_result(SentPayableDaoMock::new()) // For Accountant + .make_result(SentPayableDaoMock::new()) // For Payable Scanner + .make_result(SentPayableDaoMock::new()); // For PendingPayable Scanner + let failed_payable_dao_factory = FailedPayableDaoFactoryMock::new() + .make_params(&failed_payable_dao_factory_params_arc) + .make_result(FailedPayableDaoMock::new()) // For Payable Scanner + .make_result(FailedPayableDaoMock::new().retrieve_txs_result(BTreeSet::new())); // For PendingPayableScanner; let receivable_dao_factory = ReceivableDaoFactoryMock::new() .make_params(&receivable_dao_factory_params_arc) .make_result(ReceivableDaoMock::new()) // For Accountant @@ -1170,7 +1455,8 @@ mod tests { config, DaoFactories { payable_dao_factory: Box::new(payable_dao_factory), - pending_payable_dao_factory: Box::new(pending_payable_dao_factory), + sent_payable_dao_factory: Box::new(sent_payable_dao_factory), + failed_payable_dao_factory: Box::new(failed_payable_dao_factory), receivable_dao_factory: Box::new(receivable_dao_factory), banned_dao_factory: Box::new(banned_dao_factory), config_dao_factory: Box::new(config_dao_factory), @@ -1182,9 +1468,13 @@ mod tests { vec![(), (), ()] ); assert_eq!( - *pending_payable_dao_factory_params_arc.lock().unwrap(), + *sent_payable_dao_factory_params_arc.lock().unwrap(), vec![(), (), ()] ); + assert_eq!( + *failed_payable_dao_factory_params_arc.lock().unwrap(), + vec![(), ()] + ); assert_eq!( *receivable_dao_factory_params_arc.lock().unwrap(), vec![(), ()] @@ -1195,19 +1485,25 @@ mod tests { #[test] fn accountant_have_proper_defaulted_values() { - let bootstrapper_config = make_bc_with_defaults(); + let chain = TEST_DEFAULT_CHAIN; + let bootstrapper_config = make_bc_with_defaults(chain); let payable_dao_factory = Box::new( PayableDaoFactoryMock::new() .make_result(PayableDaoMock::new()) // For Accountant .make_result(PayableDaoMock::new()) // For Payable Scanner .make_result(PayableDaoMock::new()), // For PendingPayable Scanner ); - let pending_payable_dao_factory = Box::new( - PendingPayableDaoFactoryMock::new() - .make_result(PendingPayableDaoMock::new()) // For Accountant - .make_result(PendingPayableDaoMock::new()) // For Payable Scanner - .make_result(PendingPayableDaoMock::new()), // For PendingPayable Scanner - ); + let failed_payable_dao_factory = Box::new( + FailedPayableDaoFactoryMock::new() + .make_result(FailedPayableDaoMock::new()) // For Payable Scanner + .make_result(FailedPayableDaoMock::new()), + ); // For PendingPayable Scanner + let sent_payable_dao_factory = Box::new( + SentPayableDaoFactoryMock::new() + .make_result(SentPayableDaoMock::new()) // For Accountant + .make_result(SentPayableDaoMock::new()) // For Payable Scanner + .make_result(SentPayableDaoMock::new()), + ); // For PendingPayable Scanner let receivable_dao_factory = Box::new( ReceivableDaoFactoryMock::new() .make_result(ReceivableDaoMock::new()) // For Accountant @@ -1222,7 +1518,8 @@ mod tests { bootstrapper_config, DaoFactories { payable_dao_factory, - pending_payable_dao_factory, + sent_payable_dao_factory, + failed_payable_dao_factory, receivable_dao_factory, banned_dao_factory, config_dao_factory, @@ -1230,33 +1527,29 @@ mod tests { ); let financial_statistics = result.financial_statistics().clone(); - let assert_scan_scheduler = |scan_type: ScanType, expected_scan_interval: Duration| { - assert_eq!( - result - .scan_schedulers - .schedulers - .get(&scan_type) - .unwrap() - .interval(), - expected_scan_interval - ) - }; - let default_scan_intervals = ScanIntervals::default(); - assert_scan_scheduler( - ScanType::Payables, - default_scan_intervals.payable_scan_interval, - ); - assert_scan_scheduler( - ScanType::PendingPayables, + let default_scan_intervals = ScanIntervals::compute_default(chain); + result + .scan_schedulers + .payable + .interval_computer + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!( + result.scan_schedulers.pending_payable.interval, default_scan_intervals.pending_payable_scan_interval, ); - assert_scan_scheduler( - ScanType::Receivables, + assert_eq!( + result.scan_schedulers.receivable.interval, default_scan_intervals.receivable_scan_interval, ); + assert_eq!(result.scan_schedulers.automatic_scans_enabled, true); + assert_eq!( + result.scanners.aware_of_unresolved_pending_payables(), + false + ); assert_eq!(result.consuming_wallet_opt, None); assert_eq!(result.earning_wallet, *DEFAULT_EARNING_WALLET); - assert_eq!(result.suppress_initial_scans, false); result .message_id_generator .as_any() @@ -1320,7 +1613,7 @@ mod tests { { init_test_logging(); let mut subject = AccountantBuilder::default() - .bootstrapper_config(make_bc_with_defaults()) + .bootstrapper_config(make_bc_with_defaults(TEST_DEFAULT_CHAIN)) .build(); subject.logger = Logger::new("ConfigChange"); @@ -1330,100 +1623,7 @@ mod tests { } #[test] - fn scan_receivables_request() { - let mut config = bc_from_earning_wallet(make_wallet("earning_wallet")); - config.scan_intervals_opt = Some(ScanIntervals { - payable_scan_interval: Duration::from_millis(10_000), - receivable_scan_interval: Duration::from_millis(10_000), - pending_payable_scan_interval: Duration::from_secs(100), - }); - let receivable_dao = ReceivableDaoMock::new() - .new_delinquencies_result(vec![]) - .paid_delinquencies_result(vec![]); - let subject = AccountantBuilder::default() - .bootstrapper_config(config) - .receivable_daos(vec![ForReceivableScanner(receivable_dao)]) - .build(); - let (blockchain_bridge, _, blockchain_bridge_recording_arc) = make_recorder(); - let subject_addr = subject.start(); - let system = System::new("test"); - let peer_actors = peer_actors_builder() - .blockchain_bridge(blockchain_bridge) - .build(); - subject_addr.try_send(BindMessage { peer_actors }).unwrap(); - let ui_message = NodeFromUiMessage { - client_id: 1234, - body: UiScanRequest { - scan_type: ScanType::Receivables, - } - .tmb(4321), - }; - - subject_addr.try_send(ui_message).unwrap(); - - System::current().stop(); - system.run(); - let blockchain_bridge_recording = blockchain_bridge_recording_arc.lock().unwrap(); - assert_eq!( - blockchain_bridge_recording.get_record::(0), - &RetrieveTransactions { - recipient: make_wallet("earning_wallet"), - response_skeleton_opt: Some(ResponseSkeleton { - client_id: 1234, - context_id: 4321, - }), - } - ); - } - - #[test] - fn received_payments_with_response_skeleton_sends_response_to_ui_gateway() { - let mut config = bc_from_earning_wallet(make_wallet("earning_wallet")); - config.scan_intervals_opt = Some(ScanIntervals { - payable_scan_interval: Duration::from_millis(10_000), - receivable_scan_interval: Duration::from_millis(10_000), - pending_payable_scan_interval: Duration::from_secs(100), - }); - config.suppress_initial_scans = true; - let subject = AccountantBuilder::default() - .bootstrapper_config(config) - .config_dao( - ConfigDaoMock::new() - .get_result(Ok(ConfigDaoRecord::new("start_block", None, false))) - .set_result(Ok(())), - ) - .build(); - let (ui_gateway, _, ui_gateway_recording_arc) = make_recorder(); - let subject_addr = subject.start(); - let system = System::new("test"); - let peer_actors = peer_actors_builder().ui_gateway(ui_gateway).build(); - subject_addr.try_send(BindMessage { peer_actors }).unwrap(); - let received_payments = ReceivedPayments { - timestamp: SystemTime::now(), - new_start_block: BlockMarker::Value(0), - response_skeleton_opt: Some(ResponseSkeleton { - client_id: 1234, - context_id: 4321, - }), - transactions: vec![], - }; - - subject_addr.try_send(received_payments).unwrap(); - - System::current().stop(); - system.run(); - let ui_gateway_recording = ui_gateway_recording_arc.lock().unwrap(); - assert_eq!( - ui_gateway_recording.get_record::(0), - &NodeToUiMessage { - target: ClientId(1234), - body: UiScanResponse {}.tmb(4321), - } - ); - } - - #[test] - fn scan_payables_request() { + fn externally_triggered_scan_payables_request() { let config = bc_from_earning_wallet(make_wallet("some_wallet_address")); let consuming_wallet = make_paying_wallet(b"consuming"); let payable_account = PayableAccount { @@ -1435,19 +1635,26 @@ mod tests { pending_payable_opt: None, }; let payable_dao = - PayableDaoMock::new().non_pending_payables_result(vec![payable_account.clone()]); - let subject = AccountantBuilder::default() + PayableDaoMock::new().retrieve_payables_result(vec![payable_account.clone()]); + let mut subject = AccountantBuilder::default() + .consuming_wallet(make_paying_wallet(b"consuming")) .bootstrapper_config(config) - .consuming_wallet(consuming_wallet.clone()) .payable_daos(vec![ForPayableScanner(payable_dao)]) .build(); let (blockchain_bridge, _, blockchain_bridge_recording_arc) = make_recorder(); + let blockchain_bridge = blockchain_bridge + .system_stop_conditions(match_lazily_every_type_id!(InitialTemplatesMessage)); + let blockchain_bridge_addr = blockchain_bridge.start(); + // Important + subject.scan_schedulers.automatic_scans_enabled = false; + subject.qualified_payables_sub_opt = Some(blockchain_bridge_addr.recipient()); + // Making sure we would get a panic if another scan was scheduled + subject.scan_schedulers.payable.new_payable_notify_later = + Box::new(NotifyLaterHandleMock::default().panic_on_schedule_attempt()); + subject.scan_schedulers.payable.new_payable_notify = + Box::new(NotifyHandleMock::default().panic_on_schedule_attempt()); let subject_addr = subject.start(); let system = System::new("test"); - let peer_actors = peer_actors_builder() - .blockchain_bridge(blockchain_bridge) - .build(); - subject_addr.try_send(BindMessage { peer_actors }).unwrap(); let ui_message = NodeFromUiMessage { client_id: 1234, body: UiScanRequest { @@ -1458,13 +1665,13 @@ mod tests { subject_addr.try_send(ui_message).unwrap(); - System::current().stop(); system.run(); let blockchain_bridge_recording = blockchain_bridge_recording_arc.lock().unwrap(); + let expected_new_tx_templates = NewTxTemplates::from(&vec![payable_account]); assert_eq!( - blockchain_bridge_recording.get_record::(0), - &QualifiedPayablesMessage { - protected_qualified_payables: protect_payables_in_test(vec![payable_account]), + blockchain_bridge_recording.get_record::(0), + &InitialTemplatesMessage { + initial_templates: Either::Left(expected_new_tx_templates), consuming_wallet, response_skeleton_opt: Some(ResponseSkeleton { client_id: 1234, @@ -1475,37 +1682,35 @@ mod tests { } #[test] - fn sent_payable_with_response_skeleton_sends_scan_response_to_ui_gateway() { + fn sent_payables_with_response_skeleton_results_in_scan_response_to_ui_gateway() { let config = bc_from_earning_wallet(make_wallet("earning_wallet")); - let pending_payable_dao = - PendingPayableDaoMock::default().fingerprints_rowids_result(TransactionHashes { - rowid_results: vec![(1, make_tx_hash(123))], - no_rowid_results: vec![], - }); let payable_dao = PayableDaoMock::default().mark_pending_payables_rowids_result(Ok(())); + let sent_payable_dao = SentPayableDaoMock::default().insert_new_records_result(Ok(())); let subject = AccountantBuilder::default() - .pending_payable_daos(vec![ForPayableScanner(pending_payable_dao)]) .payable_daos(vec![ForPayableScanner(payable_dao)]) + .sent_payable_daos(vec![DaoWithDestination::ForPayableScanner( + sent_payable_dao, + )]) .bootstrapper_config(config) .build(); let (ui_gateway, _, ui_gateway_recording_arc) = make_recorder(); let subject_addr = subject.start(); let system = System::new("test"); let peer_actors = peer_actors_builder().ui_gateway(ui_gateway).build(); - subject_addr.try_send(BindMessage { peer_actors }).unwrap(); - - let sent_payable = SentPayables { - payment_procedure_result: Ok(vec![ProcessedPayableFallible::Correct(PendingPayable { - recipient_wallet: make_wallet("blah"), - hash: make_tx_hash(123), - })]), + let sent_payables = SentPayables { + payment_procedure_result: Ok(BatchResults { + sent_txs: vec![make_sent_tx(1)], + failed_txs: vec![], + }), + payable_scan_type: PayableScanType::New, response_skeleton_opt: Some(ResponseSkeleton { client_id: 1234, context_id: 4321, }), }; + subject_addr.try_send(BindMessage { peer_actors }).unwrap(); - subject_addr.try_send(sent_payable).unwrap(); + subject_addr.try_send(sent_payables).unwrap(); System::current().stop(); system.run(); @@ -1520,17 +1725,16 @@ mod tests { } #[test] - fn received_balances_and_qualified_payables_under_our_money_limit_thus_all_forwarded_to_blockchain_bridge( - ) { - // the numbers for balances don't do real math, they need not to match either the condition for + fn qualified_payables_under_our_money_limit_are_forwarded_to_blockchain_bridge_right_away() { + // The numbers in balances don't do real math, they don't need to match either the condition for // the payment adjustment or the actual values that come from the payable size reducing algorithm; // all that is mocked in this test init_test_logging(); - let test_name = "received_balances_and_qualified_payables_under_our_money_limit_thus_all_forwarded_to_blockchain_bridge"; + let test_name = "qualified_payables_under_our_money_limit_are_forwarded_to_blockchain_bridge_right_away"; let is_adjustment_required_params_arc = Arc::new(Mutex::new(vec![])); let (blockchain_bridge, _, blockchain_bridge_recording_arc) = make_recorder(); let instructions_recipient = blockchain_bridge - .system_stop_conditions(match_every_type_id!(OutboundPaymentsInstructions)) + .system_stop_conditions(match_lazily_every_type_id!(OutboundPaymentsInstructions)) .start() .recipient(); let mut subject = AccountantBuilder::default().build(); @@ -1540,7 +1744,11 @@ mod tests { let payable_scanner = PayableScannerBuilder::new() .payment_adjuster(payment_adjuster) .build(); - subject.scanners.payable = Box::new(payable_scanner); + subject + .scanners + .replace_scanner(ScannerReplacement::Payable(ReplacementType::Real( + payable_scanner, + ))); subject.outbound_payments_instructions_sub_opt = Some(instructions_recipient); subject.logger = Logger::new(test_name); let subject_addr = subject.start(); @@ -1549,9 +1757,12 @@ mod tests { let system = System::new("test"); let agent_id_stamp = ArbitraryIdStamp::new(); let agent = BlockchainAgentMock::default().set_arbitrary_id_stamp(agent_id_stamp); - let accounts = vec![account_1, account_2]; - let msg = BlockchainAgentWithContextMessage { - protected_qualified_payables: protect_payables_in_test(accounts.clone()), + let priced_new_templates = make_priced_new_tx_templates(vec![ + (account_1, 1_000_000_001), + (account_2, 1_000_000_002), + ]); + let msg = PricedTemplatesMessage { + priced_templates: Either::Left(priced_new_templates.clone()), agent: Box::new(agent), response_skeleton_opt: Some(ResponseSkeleton { client_id: 1234, @@ -1566,8 +1777,8 @@ mod tests { let (blockchain_agent_with_context_msg_actual, logger_clone) = is_adjustment_required_params.remove(0); assert_eq!( - blockchain_agent_with_context_msg_actual.protected_qualified_payables, - protect_payables_in_test(accounts.clone()) + blockchain_agent_with_context_msg_actual.priced_templates, + Either::Left(priced_new_templates.clone()) ); assert_eq!( blockchain_agent_with_context_msg_actual.response_skeleton_opt, @@ -1586,7 +1797,10 @@ mod tests { let blockchain_bridge_recording = blockchain_bridge_recording_arc.lock().unwrap(); let payments_instructions = blockchain_bridge_recording.get_record::(0); - assert_eq!(payments_instructions.affordable_accounts, accounts); + assert_eq!( + payments_instructions.priced_templates, + Either::Left(priced_new_templates.clone()) + ); assert_eq!( payments_instructions.response_skeleton_opt, Some(ResponseSkeleton { @@ -1599,31 +1813,37 @@ mod tests { agent_id_stamp ); assert_eq!(blockchain_bridge_recording.len(), 1); - test_use_of_the_same_logger(&logger_clone, test_name) - // adjust_payments() did not need a prepared result which means it wasn't reached - // because otherwise this test would've panicked + assert_using_the_same_logger(&logger_clone, test_name, None) + // The adjust_payments() function doesn't require prepared results, indicating it shouldn't + // have been reached during the test, or it would have caused a panic. } - fn test_use_of_the_same_logger(logger_clone: &Logger, test_name: &str) { - let experiment_msg = format!("DEBUG: {test_name}: hello world"); + fn assert_using_the_same_logger( + logger_clone: &Logger, + test_name: &str, + differentiation_opt: Option<&str>, + ) { let log_handler = TestLogHandler::default(); + let experiment_msg = format!("DEBUG: {test_name}: hello world: {:?}", differentiation_opt); log_handler.exists_no_log_containing(&experiment_msg); - debug!(logger_clone, "hello world"); + + debug!(logger_clone, "hello world: {:?}", differentiation_opt); + log_handler.exists_log_containing(&experiment_msg); } #[test] - fn received_qualified_payables_exceeding_our_masq_balance_are_adjusted_before_forwarded_to_blockchain_bridge( - ) { - // the numbers for balances don't do real math, they need not to match either the condition for + fn qualified_payables_over_masq_balance_are_adjusted_before_sending_to_blockchain_bridge() { + // The numbers in balances don't do real math, they don't need to match either the condition for // the payment adjustment or the actual values that come from the payable size reducing algorithm; // all that is mocked in this test init_test_logging(); - let test_name = "received_qualified_payables_exceeding_our_masq_balance_are_adjusted_before_forwarded_to_blockchain_bridge"; + let test_name = + "qualified_payables_over_masq_balance_are_adjusted_before_sending_to_blockchain_bridge"; let adjust_payments_params_arc = Arc::new(Mutex::new(vec![])); let (blockchain_bridge, _, blockchain_bridge_recording_arc) = make_recorder(); let report_recipient = blockchain_bridge - .system_stop_conditions(match_every_type_id!(OutboundPaymentsInstructions)) + .system_stop_conditions(match_lazily_every_type_id!(OutboundPaymentsInstructions)) .start() .recipient(); let mut subject = AccountantBuilder::default().build(); @@ -1644,12 +1864,12 @@ mod tests { let agent_id_stamp_first_phase = ArbitraryIdStamp::new(); let agent = BlockchainAgentMock::default().set_arbitrary_id_stamp(agent_id_stamp_first_phase); - let initial_unadjusted_accounts = protect_payables_in_test(vec![ - unadjusted_account_1.clone(), - unadjusted_account_2.clone(), + let initial_unadjusted_accounts = make_priced_new_tx_templates(vec![ + (unadjusted_account_1.clone(), 111_222_333), + (unadjusted_account_2.clone(), 222_333_444), ]); - let msg = BlockchainAgentWithContextMessage { - protected_qualified_payables: initial_unadjusted_accounts.clone(), + let msg = PricedTemplatesMessage { + priced_templates: Either::Left(initial_unadjusted_accounts.clone()), agent: Box::new(agent), response_skeleton_opt: Some(response_skeleton), }; @@ -1658,9 +1878,12 @@ mod tests { let agent_id_stamp_second_phase = ArbitraryIdStamp::new(); let agent = BlockchainAgentMock::default().set_arbitrary_id_stamp(agent_id_stamp_second_phase); - let affordable_accounts = vec![adjusted_account_1.clone(), adjusted_account_2.clone()]; + let affordable_accounts = make_priced_new_tx_templates(vec![ + (adjusted_account_1.clone(), 111_222_333), + (adjusted_account_2.clone(), 222_333_444), + ]); let payments_instructions = OutboundPaymentsInstructions { - affordable_accounts: affordable_accounts.clone(), + priced_templates: Either::Left(affordable_accounts.clone()), agent: Box::new(agent), response_skeleton_opt: Some(response_skeleton), }; @@ -1671,7 +1894,11 @@ mod tests { let payable_scanner = PayableScannerBuilder::new() .payment_adjuster(payment_adjuster) .build(); - subject.scanners.payable = Box::new(payable_scanner); + subject + .scanners + .replace_scanner(ScannerReplacement::Payable(ReplacementType::Real( + payable_scanner, + ))); subject.outbound_payments_instructions_sub_opt = Some(report_recipient); subject.logger = Logger::new(test_name); let subject_addr = subject.start(); @@ -1689,8 +1916,8 @@ mod tests { assert_eq!( actual_prepared_adjustment .original_setup_msg - .protected_qualified_payables, - initial_unadjusted_accounts + .priced_templates, + Either::Left(initial_unadjusted_accounts) ); assert_eq!( actual_prepared_adjustment @@ -1701,7 +1928,7 @@ mod tests { ); assert!( before <= captured_now && captured_now <= after, - "captured timestamp should have been between {:?} and {:?} but was {:?}", + "timestamp should be between {:?} and {:?} but was {:?}", before, after, captured_now @@ -1715,48 +1942,51 @@ mod tests { agent_id_stamp_second_phase ); assert_eq!( - payments_instructions.affordable_accounts, - affordable_accounts + payments_instructions.priced_templates, + Either::Left(affordable_accounts) ); assert_eq!( payments_instructions.response_skeleton_opt, Some(response_skeleton) ); assert_eq!(blockchain_bridge_recording.len(), 1); - test_use_of_the_same_logger(&logger_clone, test_name) + assert_using_the_same_logger(&logger_clone, test_name, None) } #[test] - fn scan_pending_payables_request() { + fn externally_triggered_scan_pending_payables_request() { let mut config = bc_from_earning_wallet(make_wallet("some_wallet_address")); - config.suppress_initial_scans = true; config.scan_intervals_opt = Some(ScanIntervals { payable_scan_interval: Duration::from_millis(10_000), receivable_scan_interval: Duration::from_millis(10_000), pending_payable_scan_interval: Duration::from_secs(100), }); - let fingerprint = PendingPayableFingerprint { - rowid: 1234, - timestamp: SystemTime::now(), - hash: Default::default(), - attempt: 1, - amount: 1_000_000, - process_error: None, - }; - let pending_payable_dao = PendingPayableDaoMock::default() - .return_all_errorless_fingerprints_result(vec![fingerprint.clone()]); - let subject = AccountantBuilder::default() + let sent_tx = make_sent_tx(555); + let tx_hash = sent_tx.hash; + let sent_payable_dao = + SentPayableDaoMock::default().retrieve_txs_result(BTreeSet::from([sent_tx])); + let failed_payable_dao = + FailedPayableDaoMock::default().retrieve_txs_result(BTreeSet::new()); + let mut subject = AccountantBuilder::default() .consuming_wallet(make_paying_wallet(b"consuming")) .bootstrapper_config(config) - .pending_payable_daos(vec![ForPendingPayableScanner(pending_payable_dao)]) + .sent_payable_daos(vec![ForPendingPayableScanner(sent_payable_dao)]) + .failed_payable_daos(vec![ForPendingPayableScanner(failed_payable_dao)]) .build(); let (blockchain_bridge, _, blockchain_bridge_recording_arc) = make_recorder(); - let subject_addr = subject.start(); + let blockchain_bridge = blockchain_bridge + .system_stop_conditions(match_lazily_every_type_id!(RequestTransactionReceipts)); + let blockchain_bridge_addr = blockchain_bridge.start(); let system = System::new("test"); - let peer_actors = peer_actors_builder() - .blockchain_bridge(blockchain_bridge) - .build(); - subject_addr.try_send(BindMessage { peer_actors }).unwrap(); + // Important + subject.scan_schedulers.automatic_scans_enabled = false; + subject.request_transaction_receipts_sub_opt = Some(blockchain_bridge_addr.recipient()); + // Making sure we would get a panic if another scan was scheduled + subject.scan_schedulers.payable.new_payable_notify_later = + Box::new(NotifyLaterHandleMock::default().panic_on_schedule_attempt()); + subject.scan_schedulers.payable.new_payable_notify = + Box::new(NotifyHandleMock::default().panic_on_schedule_attempt()); + let subject_addr = subject.start(); let ui_message = NodeFromUiMessage { client_id: 1234, body: UiScanRequest { @@ -1767,13 +1997,12 @@ mod tests { subject_addr.try_send(ui_message).unwrap(); - System::current().stop(); system.run(); let blockchain_bridge_recording = blockchain_bridge_recording_arc.lock().unwrap(); assert_eq!( blockchain_bridge_recording.get_record::(0), &RequestTransactionReceipts { - pending_payable: vec![fingerprint], + tx_hashes: vec![TxHashByTable::SentPayable(tx_hash)], response_skeleton_opt: Some(ResponseSkeleton { client_id: 1234, context_id: 4321, @@ -1783,46 +2012,116 @@ mod tests { } #[test] - fn scan_request_from_ui_is_handled_in_case_the_scan_is_already_running() { - init_test_logging(); - let test_name = "scan_request_from_ui_is_handled_in_case_the_scan_is_already_running"; - let mut config = bc_from_earning_wallet(make_wallet("some_wallet_address")); - config.suppress_initial_scans = true; - config.scan_intervals_opt = Some(ScanIntervals { - payable_scan_interval: Duration::from_millis(10_000), - receivable_scan_interval: Duration::from_millis(10_000), - pending_payable_scan_interval: Duration::from_secs(100), + fn externally_triggered_scan_identifies_all_pending_payables_as_complete() { + let transaction_confirmed_params_arc = Arc::new(Mutex::new(vec![])); + let response_skeleton_opt = Some(ResponseSkeleton { + client_id: 565, + context_id: 112233, }); - let fingerprint = PendingPayableFingerprint { - rowid: 1234, - timestamp: SystemTime::now(), - hash: Default::default(), - attempt: 1, - amount: 1_000_000, - process_error: None, + let payable_dao = PayableDaoMock::default() + .transactions_confirmed_params(&transaction_confirmed_params_arc) + .transactions_confirmed_result(Ok(())); + let sent_payable_dao = SentPayableDaoMock::default().confirm_tx_result(Ok(())); + let mut subject = AccountantBuilder::default().build(); + let mut sent_tx = make_sent_tx(123); + sent_tx.status = TxStatus::Pending(ValidationStatus::Waiting); + let sent_payable_cache = + PendingPayableCacheMock::default().get_record_by_hash_result(Some(sent_tx.clone())); + let pending_payable_scanner = PendingPayableScannerBuilder::new() + .payable_dao(payable_dao) + .sent_payable_dao(sent_payable_dao) + .sent_payable_cache(Box::new(sent_payable_cache)) + .build(); + subject + .scanners + .replace_scanner(ScannerReplacement::PendingPayable(ReplacementType::Real( + pending_payable_scanner, + ))); + let (ui_gateway, _, ui_gateway_recording_arc) = make_recorder(); + let ui_gateway = + ui_gateway.system_stop_conditions(match_lazily_every_type_id!(NodeToUiMessage)); + let ui_gateway_addr = ui_gateway.start(); + let system = System::new("test"); + subject.scan_schedulers.automatic_scans_enabled = false; + // Making sure we would kill the test if any sort of scan was scheduled + subject.scan_schedulers.payable.retry_payable_notify_later = + Box::new(NotifyLaterHandleMock::default().panic_on_schedule_attempt()); + subject.scan_schedulers.payable.new_payable_notify_later = + Box::new(NotifyLaterHandleMock::default().panic_on_schedule_attempt()); + subject.scan_schedulers.payable.new_payable_notify = + Box::new(NotifyHandleMock::default().panic_on_schedule_attempt()); + subject.ui_message_sub_opt = Some(ui_gateway_addr.recipient()); + let subject_addr = subject.start(); + let tx_block = TxBlock { + block_hash: make_tx_hash(456), + block_number: 78901234.into(), + }; + let tx_receipts_msg = TxReceiptsMessage { + results: btreemap![TxHashByTable::SentPayable(sent_tx.hash) => Ok( + StatusReadFromReceiptCheck::Succeeded(tx_block), + )], + response_skeleton_opt, + }; + + subject_addr.try_send(tx_receipts_msg).unwrap(); + + system.run(); + let transaction_confirmed_params = transaction_confirmed_params_arc.lock().unwrap(); + sent_tx.status = TxStatus::Confirmed { + block_hash: format!("{:?}", tx_block.block_hash), + block_number: tx_block.block_number.as_u64(), + detection: Detection::Normal, }; - let pending_payable_dao = PendingPayableDaoMock::default() - .return_all_errorless_fingerprints_result(vec![fingerprint]); + assert_eq!(*transaction_confirmed_params, vec![vec![sent_tx]]); + let ui_gateway_recording = ui_gateway_recording_arc.lock().unwrap(); + assert_eq!( + ui_gateway_recording.get_record::(0), + &NodeToUiMessage { + target: MessageTarget::ClientId(response_skeleton_opt.unwrap().client_id), + body: UiScanResponse {}.tmb(response_skeleton_opt.unwrap().context_id), + } + ); + assert_eq!(ui_gateway_recording.len(), 1); + } + + #[test] + fn externally_triggered_scan_is_not_handled_in_case_the_scan_is_already_running() { + init_test_logging(); + let test_name = + "externally_triggered_scan_is_not_handled_in_case_the_scan_is_already_running"; + let mut config = bc_from_earning_wallet(make_wallet("some_wallet_address")); + config.automatic_scans_enabled = false; + let now_unix = to_unix_timestamp(SystemTime::now()); + let payment_thresholds = PaymentThresholds::default(); + let past_timestamp_unix = now_unix + - (payment_thresholds.maturity_threshold_sec + + payment_thresholds.threshold_interval_sec) as i64; + let mut payable_account = make_payable_account(123); + payable_account.balance_wei = gwei_to_wei(payment_thresholds.debt_threshold_gwei); + payable_account.last_paid_timestamp = from_unix_timestamp(past_timestamp_unix); + let payable_dao = PayableDaoMock::default().retrieve_payables_result(vec![payable_account]); let subject = AccountantBuilder::default() .bootstrapper_config(config) .consuming_wallet(make_paying_wallet(b"consuming")) .logger(Logger::new(test_name)) - .pending_payable_daos(vec![ForPendingPayableScanner(pending_payable_dao)]) + .payable_daos(vec![ForPayableScanner(payable_dao)]) .build(); let (blockchain_bridge, _, blockchain_bridge_recording_arc) = make_recorder(); + let (ui_gateway, _, ui_gateway_recording_arc) = make_recorder(); let subject_addr = subject.start(); - let system = System::new("test"); + let system = System::new(test_name); + let peer_actors = peer_actors_builder() + .blockchain_bridge(blockchain_bridge) + .ui_gateway(ui_gateway) + .build(); let first_message = NodeFromUiMessage { client_id: 1234, body: UiScanRequest { - scan_type: ScanType::PendingPayables, + scan_type: ScanType::Payables, } .tmb(4321), }; let second_message = first_message.clone(); - let peer_actors = peer_actors_builder() - .blockchain_bridge(blockchain_bridge) - .build(); subject_addr.try_send(BindMessage { peer_actors }).unwrap(); subject_addr.try_send(first_message).unwrap(); @@ -1832,117 +2131,424 @@ mod tests { system.run(); let blockchain_bridge_recording = blockchain_bridge_recording_arc.lock().unwrap(); TestLogHandler::new().exists_log_containing(&format!( - "INFO: {}: PendingPayables scan was already initiated", + "INFO: {}: Payables scan was already initiated", test_name )); assert_eq!(blockchain_bridge_recording.len(), 1); + let ui_gateway_recording = ui_gateway_recording_arc.lock().unwrap(); + let msg = ui_gateway_recording.get_record::(0); + assert_eq!(msg.body, UiScanResponse {}.tmb(4321)); } - #[test] - fn report_transaction_receipts_with_response_skeleton_sends_scan_response_to_ui_gateway() { - let mut config = bc_from_earning_wallet(make_wallet("earning_wallet")); - config.scan_intervals_opt = Some(ScanIntervals { - payable_scan_interval: Duration::from_millis(10_000), - receivable_scan_interval: Duration::from_millis(10_000), - pending_payable_scan_interval: Duration::from_secs(100), - }); - let subject = AccountantBuilder::default() - .bootstrapper_config(config) - .build(); - let (ui_gateway, _, ui_gateway_recording_arc) = make_recorder(); - let subject_addr = subject.start(); - let system = System::new("test"); - let peer_actors = peer_actors_builder().ui_gateway(ui_gateway).build(); - subject_addr.try_send(BindMessage { peer_actors }).unwrap(); - let report_transaction_receipts = ReportTransactionReceipts { - fingerprints_with_receipts: vec![], - response_skeleton_opt: Some(ResponseSkeleton { - client_id: 1234, - context_id: 4321, - }), - }; + fn test_externally_triggered_scan_is_prevented_if_automatic_scans_are_enabled( + test_name: &str, + scan_type: ScanType, + ) { + let expected_log_msg = format!( + "WARN: {test_name}: User requested {:?} scan was denied. Automatic mode \ + prevents manual triggers.", + scan_type + ); - subject_addr.try_send(report_transaction_receipts).unwrap(); + test_externally_triggered_scan_is_prevented_if( + true, + true, + test_name, + scan_type, + &expected_log_msg, + ) + } - System::current().stop(); - system.run(); + fn test_externally_triggered_scan_is_prevented_if( + automatic_scans_enabled: bool, + aware_of_unresolved_pending_payables: bool, + test_name: &str, + scan_type: ScanType, + expected_log_message: &str, + ) { + init_test_logging(); + let (blockchain_bridge, _, blockchain_bridge_recorder_arc) = make_recorder(); + let (ui_gateway, _, ui_gateway_recording_arc) = make_recorder(); + let ui_gateway = + ui_gateway.system_stop_conditions(match_lazily_every_type_id!(NodeToUiMessage)); + let mut subject = AccountantBuilder::default() + .logger(Logger::new(test_name)) + .consuming_wallet(make_wallet("abc")) + .build(); + subject.scan_schedulers.automatic_scans_enabled = automatic_scans_enabled; + subject + .scanners + .set_aware_of_unresolved_pending_payables(aware_of_unresolved_pending_payables); + subject.scanners.unset_initial_pending_payable_scan(); + let subject_addr = subject.start(); + let system = System::new(test_name); + let peer_actors = PeerActorsBuilder::default() + .ui_gateway(ui_gateway) + .blockchain_bridge(blockchain_bridge) + .build(); + let ui_message = NodeFromUiMessage { + client_id: 1234, + body: UiScanRequest { scan_type }.tmb(6789), + }; + subject_addr.try_send(BindMessage { peer_actors }).unwrap(); + + subject_addr.try_send(ui_message).unwrap(); + + assert_eq!(system.run(), 0); + let ui_gateway_recording = ui_gateway_recording_arc.lock().unwrap(); + let msg = ui_gateway_recording.get_record::(0); + assert_eq!(msg.body, UiScanResponse {}.tmb(6789)); + assert_eq!(ui_gateway_recording.len(), 1); + let blockchain_bridge_recorder = blockchain_bridge_recorder_arc.lock().unwrap(); + assert_eq!(blockchain_bridge_recorder.len(), 0); + TestLogHandler::new().exists_log_containing(expected_log_message); + } + + #[test] + fn externally_triggered_scan_for_new_payables_is_prevented_if_automatic_scans_are_enabled() { + test_externally_triggered_scan_is_prevented_if_automatic_scans_are_enabled("externally_triggered_scan_for_new_payables_is_prevented_if_automatic_scans_are_enabled", ScanType::Payables) + } + + #[test] + fn externally_triggered_scan_for_pending_payables_is_prevented_if_automatic_scans_are_enabled() + { + test_externally_triggered_scan_is_prevented_if_automatic_scans_are_enabled("externally_triggered_scan_for_pending_payables_is_prevented_if_automatic_scans_are_enabled", ScanType::PendingPayables) + } + + #[test] + fn externally_triggered_scan_for_receivables_is_prevented_if_automatic_scans_are_enabled() { + test_externally_triggered_scan_is_prevented_if_automatic_scans_are_enabled( + "externally_triggered_scan_for_receivables_is_prevented_if_automatic_scans_are_enabled", + ScanType::Receivables, + ) + } + + #[test] + fn externally_triggered_scan_for_pending_payables_is_prevented_if_all_payments_already_complete( + ) { + let test_name = "externally_triggered_scan_for_pending_payables_is_prevented_if_all_payments_already_complete"; + let expected_log_msg = format!( + "INFO: {test_name}: User requested PendingPayables scan was denied expecting zero \ + findings. Run the Payable scanner first." + ); + + test_externally_triggered_scan_is_prevented_if( + false, + false, + test_name, + ScanType::PendingPayables, + &expected_log_msg, + ) + } + + #[test] + fn pending_payable_scan_response_is_sent_to_ui_gateway_when_both_participating_scanners_have_completed( + ) { + let insert_new_records_params_arc = Arc::new(Mutex::new(vec![])); + let delete_records_params_arc = Arc::new(Mutex::new(vec![])); + let payable_dao_for_payable_scanner = + PayableDaoMock::default().retrieve_payables_result(vec![]); + let payable_dao_for_pending_payable_scanner = + PayableDaoMock::default().transactions_confirmed_result(Ok(())); + let sent_tx = make_sent_tx(123); + let tx_hash = sent_tx.hash; + let sent_payable_dao_for_payable_scanner = SentPayableDaoMock::default() + // TODO should be removed with GH-701 + .insert_new_records_result(Ok(())); + let sent_payable_dao_for_pending_payable_scanner = SentPayableDaoMock::default() + .retrieve_txs_result(BTreeSet::from([sent_tx.clone()])) + .delete_records_params(&delete_records_params_arc) + .delete_records_result(Ok(())); + let failed_tx = make_failed_tx(123); + let failed_payable_dao_for_payable_scanner = + FailedPayableDaoMock::default().retrieve_txs_result(btreeset!(failed_tx)); + let failed_payable_dao_for_pending_payable_scanner = FailedPayableDaoMock::default() + .insert_new_records_params(&insert_new_records_params_arc) + .insert_new_records_result(Ok(())); + let mut subject = AccountantBuilder::default() + .consuming_wallet(make_wallet("consuming")) + .payable_daos(vec![ + ForPayableScanner(payable_dao_for_payable_scanner), + ForPendingPayableScanner(payable_dao_for_pending_payable_scanner), + ]) + .sent_payable_daos(vec![ + ForPayableScanner(sent_payable_dao_for_payable_scanner), + ForPendingPayableScanner(sent_payable_dao_for_pending_payable_scanner), + ]) + .failed_payable_daos(vec![ + ForPayableScanner(failed_payable_dao_for_payable_scanner), + ForPendingPayableScanner(failed_payable_dao_for_pending_payable_scanner), + ]) + .build(); + subject.scan_schedulers.automatic_scans_enabled = false; + subject.scan_schedulers.payable.retry_payable_scan_interval = Duration::from_millis(1); + let (blockchain_bridge, _, blockchain_bridge_recording_arc) = make_recorder(); + let (ui_gateway, _, ui_gateway_recording_arc) = make_recorder(); + let ui_gateway = + ui_gateway.system_stop_conditions(match_lazily_every_type_id!(NodeToUiMessage)); + let (peer_actors, peer_addresses) = peer_actors_builder() + .blockchain_bridge(blockchain_bridge) + .ui_gateway(ui_gateway) + .build_and_provide_addresses(); + let subject_addr = subject.start(); + let system = System::new("test"); + let response_skeleton_opt = Some(ResponseSkeleton { + client_id: 4555, + context_id: 5566, + }); + let first_counter_msg_setup = setup_for_counter_msg_triggered_via_type_id!( + RequestTransactionReceipts, + TxReceiptsMessage { + results: btreemap![TxHashByTable::SentPayable(sent_tx.hash) => Ok( + StatusReadFromReceiptCheck::Reverted + ),], + response_skeleton_opt + }, + &subject_addr + ); + let sent_payables = SentPayables { + payment_procedure_result: Ok(BatchResults { + sent_txs: vec![make_sent_tx(1)], + failed_txs: vec![], + }), + payable_scan_type: PayableScanType::New, + response_skeleton_opt, + }; + let second_counter_msg_setup = setup_for_counter_msg_triggered_via_type_id!( + InitialTemplatesMessage, + sent_payables, + &subject_addr + ); + peer_addresses + .blockchain_bridge_addr + .try_send(SetUpCounterMsgs::new(vec![ + first_counter_msg_setup, + second_counter_msg_setup, + ])) + .unwrap(); + subject_addr.try_send(BindMessage { peer_actors }).unwrap(); + let pending_payable_request = ScanForPendingPayables { + response_skeleton_opt, + }; + + subject_addr.try_send(pending_payable_request).unwrap(); + + system.run(); + let insert_new_records_params = insert_new_records_params_arc.lock().unwrap(); + let expected_failed_tx = FailedTx::from((sent_tx, FailureReason::Reverted)); + assert_eq!( + *insert_new_records_params, + vec![BTreeSet::from([expected_failed_tx])] + ); + let delete_records_params = delete_records_params_arc.lock().unwrap(); + assert_eq!(*delete_records_params, vec![BTreeSet::from([tx_hash])]); let ui_gateway_recording = ui_gateway_recording_arc.lock().unwrap(); assert_eq!( ui_gateway_recording.get_record::(0), &NodeToUiMessage { - target: ClientId(1234), - body: UiScanResponse {}.tmb(4321), + target: ClientId(4555), + body: UiScanResponse {}.tmb(5566), } ); + let blockchain_bridge_recording = blockchain_bridge_recording_arc.lock().unwrap(); + assert_eq!(blockchain_bridge_recording.len(), 2); } #[test] - fn accountant_calls_payable_dao_to_mark_pending_payable() { - let fingerprints_rowids_params_arc = Arc::new(Mutex::new(vec![])); - let mark_pending_payables_rowids_params_arc = Arc::new(Mutex::new(vec![])); - let expected_wallet = make_wallet("paying_you"); - let expected_hash = H256::from("transaction_hash".keccak256()); - let expected_rowid = 45623; - let pending_payable_dao = PendingPayableDaoMock::default() - .fingerprints_rowids_params(&fingerprints_rowids_params_arc) - .fingerprints_rowids_result(TransactionHashes { - rowid_results: vec![(expected_rowid, expected_hash)], - no_rowid_results: vec![], - }); - let payable_dao = PayableDaoMock::new() - .mark_pending_payables_rowids_params(&mark_pending_payables_rowids_params_arc) - .mark_pending_payables_rowids_result(Ok(())); - let system = System::new("accountant_calls_payable_dao_to_mark_pending_payable"); - let accountant = AccountantBuilder::default() + fn accountant_sends_qualified_payable_msg_for_new_payable_scan_when_qualified_payable_found() { + let new_payable_templates = NewTxTemplates::from(&vec![make_payable_account(123)]); + accountant_sends_qualified_payable_msg_when_qualified_payable_found( + ScanForNewPayables { + response_skeleton_opt: None, + }, + Either::Left(new_payable_templates), + vec![()], + ) + } + + #[test] + fn accountant_sends_qualified_payable_msg_for_retry_payable_scan_when_qualified_payable_found() + { + let retry_payable_templates = RetryTxTemplates(vec![make_retry_tx_template(123)]); + accountant_sends_qualified_payable_msg_when_qualified_payable_found( + ScanForRetryPayables { + response_skeleton_opt: None, + }, + Either::Right(retry_payable_templates), + vec![], + ) + } + + fn accountant_sends_qualified_payable_msg_when_qualified_payable_found( + act_msg: ActorMessage, + initial_templates: Either, + reset_last_scan_timestamp_params_expected: Vec<()>, + ) where + ActorMessage: Message + Send + 'static, + ActorMessage::Result: Send, + Accountant: Handler, + { + let reset_last_scan_timestamp_params_arc = Arc::new(Mutex::new(vec![])); + let (blockchain_bridge, _, blockchain_bridge_recording_arc) = make_recorder(); + let system = + System::new("accountant_sends_qualified_payable_msg_when_qualified_payable_found"); + let consuming_wallet = make_paying_wallet(b"consuming"); + let mut subject = AccountantBuilder::default() .bootstrapper_config(bc_from_earning_wallet(make_wallet("some_wallet_address"))) - .payable_daos(vec![ForPayableScanner(payable_dao)]) - .pending_payable_daos(vec![ForPayableScanner(pending_payable_dao)]) + .consuming_wallet(consuming_wallet.clone()) .build(); - let expected_payable = PendingPayable::new(expected_wallet.clone(), expected_hash.clone()); - let sent_payable = SentPayables { - payment_procedure_result: Ok(vec![ProcessedPayableFallible::Correct( - expected_payable.clone(), - )]), + let initial_template_msg = InitialTemplatesMessage { + initial_templates, + consuming_wallet, response_skeleton_opt: None, }; - let subject = accountant.start(); - + let payable_scanner = ScannerMock::default() + .scan_started_at_result(None) + .start_scan_result(Ok(initial_template_msg.clone())); subject - .try_send(sent_payable) - .expect("unexpected actix error"); + .scanners + .replace_scanner(ScannerReplacement::Payable(ReplacementType::Mock( + payable_scanner, + ))); + subject + .scanners + .replace_scanner(ScannerReplacement::PendingPayable(ReplacementType::Null)); + subject + .scanners + .replace_scanner(ScannerReplacement::Receivable(ReplacementType::Null)); + subject.scan_schedulers.payable.interval_computer = Box::new( + NewPayableScanIntervalComputerMock::default() + .reset_last_scan_timestamp_params(&reset_last_scan_timestamp_params_arc), + ); + let accountant_addr = subject.start(); + let accountant_subs = Accountant::make_subs_from(&accountant_addr); + let peer_actors = peer_actors_builder() + .blockchain_bridge(blockchain_bridge) + .build(); + send_bind_message!(accountant_subs, peer_actors); + + accountant_addr.try_send(act_msg).unwrap(); System::current().stop(); system.run(); - let fingerprints_rowids_params = fingerprints_rowids_params_arc.lock().unwrap(); - assert_eq!(*fingerprints_rowids_params, vec![vec![expected_hash]]); - let mark_pending_payables_rowids_params = - mark_pending_payables_rowids_params_arc.lock().unwrap(); + let blockchain_bridge_recorder = blockchain_bridge_recording_arc.lock().unwrap(); + assert_eq!(blockchain_bridge_recorder.len(), 1); + let message = blockchain_bridge_recorder.get_record::(0); + assert_eq!(message, &initial_template_msg); + let reset_last_scan_timestamp_params = reset_last_scan_timestamp_params_arc.lock().unwrap(); assert_eq!( - *mark_pending_payables_rowids_params, - vec![vec![(expected_wallet, expected_rowid)]] + *reset_last_scan_timestamp_params, + reset_last_scan_timestamp_params_expected + ) + } + + #[test] + fn automatic_scan_for_new_payables_schedules_another_one_immediately_if_no_qualified_payables_found( + ) { + let notify_later_params_arc = Arc::new(Mutex::new(vec![])); + let system = + System::new("automatic_scan_for_new_payables_schedules_another_one_immediately_if_no_qualified_payables_found"); + let consuming_wallet = make_paying_wallet(b"consuming"); + let mut subject = AccountantBuilder::default() + .bootstrapper_config(make_bc_with_defaults(TEST_DEFAULT_CHAIN)) + .consuming_wallet(consuming_wallet) + .build(); + subject.scan_schedulers.payable.new_payable_notify_later = Box::new( + NotifyLaterHandleMock::default().notify_later_params(¬ify_later_params_arc), ); + subject.scan_schedulers.payable.new_payable_notify = + Box::new(NotifyHandleMock::default().panic_on_schedule_attempt()); + let payable_scanner = ScannerMock::default() + .scan_started_at_result(None) + .scan_started_at_result(None) + .start_scan_result(Err(StartScanError::NothingToProcess)); + subject + .scanners + .replace_scanner(ScannerReplacement::Payable(ReplacementType::Mock( + payable_scanner, + ))); + subject + .scanners + .replace_scanner(ScannerReplacement::PendingPayable(ReplacementType::Null)); + subject + .scanners + .replace_scanner(ScannerReplacement::Receivable(ReplacementType::Null)); + let accountant_addr = subject.start(); + + accountant_addr + .try_send(ScanForNewPayables { + response_skeleton_opt: None, + }) + .unwrap(); + + System::current().stop(); + assert_eq!(system.run(), 0); + let mut notify_later_params = notify_later_params_arc.lock().unwrap(); + // As obvious, the next scan is scheduled for the future and should not run immediately. + let (msg, actual_interval) = notify_later_params.remove(0); + assert_eq!( + msg, + ScanForNewPayables { + response_skeleton_opt: None + } + ); + // The initial last_new_payable_scan_timestamp is UNIX_EPOCH by this design. Such a value + // would've driven an immediate scan without an interval. Therefore, the performed interval + // implies that the last_new_payable_scan_timestamp must have been updated to the current + // time. (As the result of running into StartScanError::NothingToProcess) + let default_interval = + ScanIntervals::compute_default(TEST_DEFAULT_CHAIN).payable_scan_interval; + let tolerance = Duration::from_secs(5); + let min_interval = default_interval.checked_sub(tolerance).unwrap(); + let max_interval = default_interval.checked_add(tolerance).unwrap(); + // The divergence should be only a few milliseconds, definitely not seconds; the tested + // interval should be safe for slower machines too. + assert!( + min_interval <= actual_interval && actual_interval <= max_interval, + "Expected interval between {:?} and {:?}, got {:?}", + min_interval, + max_interval, + actual_interval + ); + assert_eq!(notify_later_params.len(), 0); + // Accountant is unbound; therefore, it is guaranteed that sending a message to + // the BlockchainBridge wasn't attempted. It would've panicked otherwise. } #[test] - fn accountant_sends_initial_payable_payments_msg_when_qualified_payable_found() { + fn accountant_handles_scan_for_retry_payables() { + init_test_logging(); + let test_name = "accountant_handles_scan_for_retry_payables"; + let start_scan_params_arc = Arc::new(Mutex::new(vec![])); let (blockchain_bridge, _, blockchain_bridge_recording_arc) = make_recorder(); - let now = SystemTime::now(); - let payment_thresholds = PaymentThresholds::default(); - let (qualified_payables, _, all_non_pending_payables) = - make_payables(now, &payment_thresholds); - let payable_dao = - PayableDaoMock::new().non_pending_payables_result(all_non_pending_payables); - let system = System::new( - "accountant_sends_initial_payable_payments_msg_when_qualified_payable_found", - ); - let consuming_wallet = make_paying_wallet(b"consuming"); + let system = System::new(test_name); let mut subject = AccountantBuilder::default() - .bootstrapper_config(bc_from_earning_wallet(make_wallet("some_wallet_address"))) - .consuming_wallet(consuming_wallet.clone()) - .payable_daos(vec![ForPayableScanner(payable_dao)]) + .logger(Logger::new(test_name)) .build(); - subject.scanners.pending_payable = Box::new(NullScanner::new()); - subject.scanners.receivable = Box::new(NullScanner::new()); + let consuming_wallet = make_wallet("abc"); + subject.consuming_wallet_opt = Some(consuming_wallet.clone()); + let retry_tx_templates = + RetryTxTemplates(vec![make_retry_tx_template(1), make_retry_tx_template(2)]); + let qualified_payables_msg = InitialTemplatesMessage { + initial_templates: Either::Right(retry_tx_templates), + consuming_wallet: consuming_wallet.clone(), + response_skeleton_opt: None, + }; + let payable_scanner_mock = ScannerMock::new() + .scan_started_at_result(None) + .start_scan_params(&start_scan_params_arc) + .start_scan_result(Ok(qualified_payables_msg.clone())); + subject + .scanners + .replace_scanner(ScannerReplacement::Payable(ReplacementType::Mock( + payable_scanner_mock, + ))); + subject + .scanners + .replace_scanner(ScannerReplacement::PendingPayable(ReplacementType::Null)); + subject + .scanners + .replace_scanner(ScannerReplacement::Receivable(ReplacementType::Null)); let accountant_addr = subject.start(); let accountant_subs = Accountant::make_subs_from(&accountant_addr); let peer_actors = peer_actors_builder() @@ -1950,27 +2556,86 @@ mod tests { .build(); send_bind_message!(accountant_subs, peer_actors); - send_start_message!(accountant_subs); + accountant_addr + .try_send(ScanForRetryPayables { + response_skeleton_opt: None, + }) + .unwrap(); System::current().stop(); + let before = SystemTime::now(); system.run(); + let after = SystemTime::now(); + let mut start_scan_params = start_scan_params_arc.lock().unwrap(); + let (actual_wallet, actual_now, actual_response_skeleton_opt, actual_logger, _) = + start_scan_params.remove(0); + assert_eq!(actual_wallet, consuming_wallet); + assert_eq!(actual_response_skeleton_opt, None); + assert!(before <= actual_now && actual_now <= after); + assert!( + start_scan_params.is_empty(), + "should be empty but was {:?}", + start_scan_params + ); let blockchain_bridge_recorder = blockchain_bridge_recording_arc.lock().unwrap(); + let message = blockchain_bridge_recorder.get_record::(0); + assert_eq!(message, &qualified_payables_msg); assert_eq!(blockchain_bridge_recorder.len(), 1); - let message = blockchain_bridge_recorder.get_record::(0); + assert_using_the_same_logger(&actual_logger, test_name, None) + } + + #[test] + fn scan_for_retry_payables_if_consuming_wallet_is_not_present() { + init_test_logging(); + let test_name = "scan_for_retry_payables_if_consuming_wallet_is_not_present"; + let system = System::new(test_name); + let (ui_gateway, _, ui_gateway_recording_arc) = make_recorder(); + let ui_gateway = + ui_gateway.system_stop_conditions(match_lazily_every_type_id!(NodeToUiMessage)); + let ui_gateway_addr = ui_gateway.start(); + let mut subject = AccountantBuilder::default() + .logger(Logger::new(test_name)) + .build(); + let payable_scanner_mock = ScannerMock::new(); + subject + .scanners + .replace_scanner(ScannerReplacement::Payable(ReplacementType::Mock( + payable_scanner_mock, + ))); + subject.ui_message_sub_opt = Some(ui_gateway_addr.recipient()); + // It must be populated because no errors are tolerated at the RetryPayableScanner + // if automatic scans are on + let response_skeleton_opt = Some(ResponseSkeleton { + client_id: 789, + context_id: 111, + }); + let accountant_addr = subject.start(); + + accountant_addr + .try_send(ScanForRetryPayables { + response_skeleton_opt, + }) + .unwrap(); + + system.run(); + let ui_gateway_recording = ui_gateway_recording_arc.lock().unwrap(); + let message = ui_gateway_recording.get_record::(0); assert_eq!( message, - &QualifiedPayablesMessage { - protected_qualified_payables: protect_payables_in_test(qualified_payables), - consuming_wallet, - response_skeleton_opt: None, + &NodeToUiMessage { + target: MessageTarget::ClientId(response_skeleton_opt.unwrap().client_id), + body: UiScanResponse {}.tmb(response_skeleton_opt.unwrap().context_id) } ); + TestLogHandler::new().exists_log_containing(&format!("WARN: {test_name}: Cannot initiate Payables scan because no consuming wallet was found")); } #[test] fn accountant_requests_blockchain_bridge_to_scan_for_received_payments() { init_test_logging(); let (blockchain_bridge, _, blockchain_bridge_recording_arc) = make_recorder(); + let blockchain_bridge = blockchain_bridge + .system_stop_conditions(match_lazily_every_type_id!(RetrieveTransactions)); let earning_wallet = make_wallet("someearningwallet"); let system = System::new("accountant_requests_blockchain_bridge_to_scan_for_received_payments"); @@ -1981,8 +2646,12 @@ mod tests { .bootstrapper_config(bc_from_earning_wallet(earning_wallet.clone())) .receivable_daos(vec![ForReceivableScanner(receivable_dao)]) .build(); - subject.scanners.pending_payable = Box::new(NullScanner::new()); - subject.scanners.payable = Box::new(NullScanner::new()); + // Important. Preventing the possibly endless sequence of + // PendingPayableScanner -> NewPayableScanner -> NewPayableScanner... + subject.scan_schedulers.payable.new_payable_notify = Box::new(NotifyHandleMock::default()); + subject + .scanners + .replace_scanner(ScannerReplacement::PendingPayable(ReplacementType::Null)); let accountant_addr = subject.start(); let accountant_subs = Accountant::make_subs_from(&accountant_addr); let peer_actors = peer_actors_builder() @@ -1990,19 +2659,112 @@ mod tests { .build(); send_bind_message!(accountant_subs, peer_actors); - send_start_message!(accountant_subs); + send_start_message!(accountant_subs); + + system.run(); + let blockchain_bridge_recorder = blockchain_bridge_recording_arc.lock().unwrap(); + let retrieve_transactions_msg = + blockchain_bridge_recorder.get_record::(0); + assert_eq!( + retrieve_transactions_msg, + &RetrieveTransactions { + recipient: earning_wallet.clone(), + response_skeleton_opt: None, + } + ); + assert_eq!(blockchain_bridge_recorder.len(), 1); + } + + #[test] + fn externally_triggered_scan_receivables_request() { + let mut config = bc_from_earning_wallet(make_wallet("earning_wallet")); + config.scan_intervals_opt = Some(ScanIntervals { + payable_scan_interval: Duration::from_millis(10_000), + pending_payable_scan_interval: Duration::from_millis(2_000), + receivable_scan_interval: Duration::from_millis(10_000), + }); + let receivable_dao = ReceivableDaoMock::new() + .new_delinquencies_result(vec![]) + .paid_delinquencies_result(vec![]); + let mut subject = AccountantBuilder::default() + .bootstrapper_config(config) + .receivable_daos(vec![ForReceivableScanner(receivable_dao)]) + .build(); + let (blockchain_bridge, _, blockchain_bridge_recording_arc) = make_recorder(); + let blockchain_bridge = blockchain_bridge + .system_stop_conditions(match_lazily_every_type_id!(RetrieveTransactions)); + let blockchain_bridge_addr = blockchain_bridge.start(); + // Important + subject.scan_schedulers.automatic_scans_enabled = false; + subject.retrieve_transactions_sub_opt = Some(blockchain_bridge_addr.recipient()); + let subject_addr = subject.start(); + let system = System::new("test"); + let ui_message = NodeFromUiMessage { + client_id: 1234, + body: UiScanRequest { + scan_type: ScanType::Receivables, + } + .tmb(4321), + }; + + subject_addr.try_send(ui_message).unwrap(); + + system.run(); + let blockchain_bridge_recording = blockchain_bridge_recording_arc.lock().unwrap(); + assert_eq!( + blockchain_bridge_recording.get_record::(0), + &RetrieveTransactions { + recipient: make_wallet("earning_wallet"), + response_skeleton_opt: Some(ResponseSkeleton { + client_id: 1234, + context_id: 4321, + }), + } + ); + } + + #[test] + fn received_payments_with_response_skeleton_sends_response_to_ui_gateway() { + let mut config = bc_from_earning_wallet(make_wallet("earning_wallet")); + config.scan_intervals_opt = Some(ScanIntervals { + payable_scan_interval: Duration::from_millis(10_000), + pending_payable_scan_interval: Duration::from_millis(2_000), + receivable_scan_interval: Duration::from_millis(10_000), + }); + config.automatic_scans_enabled = false; + let subject = AccountantBuilder::default() + .bootstrapper_config(config) + .config_dao( + ConfigDaoMock::new() + .get_result(Ok(ConfigDaoRecord::new("start_block", None, false))) + .set_result(Ok(())), + ) + .build(); + let (ui_gateway, _, ui_gateway_recording_arc) = make_recorder(); + let subject_addr = subject.start(); + let system = System::new("test"); + let peer_actors = peer_actors_builder().ui_gateway(ui_gateway).build(); + subject_addr.try_send(BindMessage { peer_actors }).unwrap(); + let received_payments = ReceivedPayments { + timestamp: SystemTime::now(), + new_start_block: BlockMarker::Value(0), + response_skeleton_opt: Some(ResponseSkeleton { + client_id: 1234, + context_id: 4321, + }), + transactions: vec![], + }; + + subject_addr.try_send(received_payments).unwrap(); System::current().stop(); system.run(); - let blockchain_bridge_recorder = blockchain_bridge_recording_arc.lock().unwrap(); - assert_eq!(blockchain_bridge_recorder.len(), 1); - let retrieve_transactions_msg = - blockchain_bridge_recorder.get_record::(0); + let ui_gateway_recording = ui_gateway_recording_arc.lock().unwrap(); assert_eq!( - retrieve_transactions_msg, - &RetrieveTransactions { - recipient: earning_wallet.clone(), - response_skeleton_opt: None, + ui_gateway_recording.get_record::(0), + &NodeToUiMessage { + target: ClientId(1234), + body: UiScanResponse {}.tmb(4321), } ); } @@ -2077,82 +2839,811 @@ mod tests { } #[test] - fn accountant_scans_after_startup() { + fn accountant_scans_after_startup_and_does_not_detect_any_pending_payables() { + // We will want to prove that the PendingPayableScanner runs before the NewPayableScanner. + // Their relationship towards the ReceivableScanner isn't important. init_test_logging(); - let pending_payable_params_arc = Arc::new(Mutex::new(vec![])); - let payable_params_arc = Arc::new(Mutex::new(vec![])); - let new_delinquencies_params_arc = Arc::new(Mutex::new(vec![])); - let paid_delinquencies_params_arc = Arc::new(Mutex::new(vec![])); - let (blockchain_bridge, _, _) = make_recorder(); + let test_name = "accountant_scans_after_startup_and_does_not_detect_any_pending_payables"; + let scan_params = ScanParams::default(); + let notify_and_notify_later_params = NotifyAndNotifyLaterParams::default(); + let time_until_next_scan_params_arc = Arc::new(Mutex::new(vec![])); let earning_wallet = make_wallet("earning"); - let system = System::new("accountant_scans_after_startup"); - let config = bc_from_wallets(make_wallet("buy"), earning_wallet.clone()); - let payable_dao = PayableDaoMock::new() - .non_pending_payables_params(&payable_params_arc) - .non_pending_payables_result(vec![]); - let pending_payable_dao = PendingPayableDaoMock::default() - .return_all_errorless_fingerprints_params(&pending_payable_params_arc) - .return_all_errorless_fingerprints_result(vec![]); - let receivable_dao = ReceivableDaoMock::new() - .new_delinquencies_parameters(&new_delinquencies_params_arc) - .new_delinquencies_result(vec![]) - .paid_delinquencies_parameters(&paid_delinquencies_params_arc) - .paid_delinquencies_result(vec![]); - let subject = AccountantBuilder::default() + let consuming_wallet = make_wallet("consuming"); + let system = System::new(test_name); + let _ = SystemKillerActor::new(Duration::from_secs(10)).start(); + let config = bc_from_wallets(consuming_wallet.clone(), earning_wallet.clone()); + let payable_scanner = ScannerMock::new() + .scan_started_at_result(None) + .start_scan_params(&scan_params.payable_start_scan) + .start_scan_result(Err(StartScanError::NothingToProcess)); + let pending_payable_scanner = ScannerMock::new() + .scan_started_at_result(None) + .start_scan_params(&scan_params.pending_payable_start_scan) + .start_scan_result(Err(StartScanError::NothingToProcess)); + let receivable_scanner = ScannerMock::new() + .scan_started_at_result(None) + .start_scan_params(&scan_params.receivable_start_scan) + .start_scan_result(Err(StartScanError::NothingToProcess)); + let (subject, new_payable_expected_computed_interval, receivable_scan_interval) = + configure_accountant_for_startup_with_preexisting_pending_payables( + test_name, + ¬ify_and_notify_later_params, + &time_until_next_scan_params_arc, + config, + pending_payable_scanner, + receivable_scanner, + payable_scanner, + ); + let peer_actors = peer_actors_builder().build(); + let subject_addr: Addr = subject.start(); + let subject_subs = Accountant::make_subs_from(&subject_addr); + send_bind_message!(subject_subs, peer_actors); + + send_start_message!(subject_subs); + + // The system is stopped by the NotifyLaterHandleMock for the Receivable scanner + let before = SystemTime::now(); + system.run(); + let after = SystemTime::now(); + assert_pending_payable_scanner_for_no_pending_payable_found( + test_name, + consuming_wallet, + &scan_params.pending_payable_start_scan, + ¬ify_and_notify_later_params.pending_payables_notify_later, + before, + after, + ); + assert_payable_scanner_for_no_pending_payable_found( + &scan_params.payable_start_scan, + ¬ify_and_notify_later_params, + time_until_next_scan_params_arc, + new_payable_expected_computed_interval, + ); + assert_receivable_scanner( + test_name, + earning_wallet, + &scan_params.receivable_start_scan, + ¬ify_and_notify_later_params.receivables_notify_later, + receivable_scan_interval, + ); + // The test lays down evidences that the NewPayableScanner couldn't run before + // the PendingPayableScanner, which is an intention. + // To interpret the evidence, we have to notice that the PendingPayableScanner ran + // certainly, while it wasn't attempted to schedule in the whole test. That points out that + // the scanning sequence started spontaneously, not requiring any prior scheduling. Most + // importantly, regarding the payable scanner, it ran not even once. We know, though, + // that its scheduling did take place, specifically an urgent call of the new payable mode. + // That totally corresponds with the expected behavior where the PendingPayableScanner + // should first search for any stray pending payables; if no findings, the NewPayableScanner + // is supposed to go next, and it shouldn't have to undertake the standard new-payable + // interval, but here, at the beginning, it comes immediately. + } + + #[test] + fn accountant_scans_after_startup_and_detects_pending_payable_from_before() { + // We do ensure the PendingPayableScanner runs before the NewPayableScanner. Not interested + // in an exact placing of the ReceivableScanner so much. + init_test_logging(); + let test_name = "accountant_scans_after_startup_and_detects_pending_payable_from_before"; + let scan_params = ScanParams::default(); + let notify_and_notify_later_params = NotifyAndNotifyLaterParams::default(); + let earning_wallet = make_wallet("earning"); + let consuming_wallet = make_wallet("consuming"); + let system = System::new(test_name); + let _ = SystemKillerActor::new(Duration::from_secs(10)).start(); + let config = bc_from_wallets(consuming_wallet.clone(), earning_wallet.clone()); + let tx_hash = make_tx_hash(456); + let retry_tx_templates = RetryTxTemplates(vec![make_retry_tx_template(1)]); + let payable_scanner = ScannerMock::new() + .scan_started_at_result(None) + .scan_started_at_result(None) + // These values belong to the RetryPayableScanner + .start_scan_params(&scan_params.payable_start_scan) + .start_scan_result(Ok(InitialTemplatesMessage { + initial_templates: Either::Right(retry_tx_templates), + consuming_wallet: consuming_wallet.clone(), + response_skeleton_opt: None, + })) + .finish_scan_params(&scan_params.payable_finish_scan) + // Important + .finish_scan_result(PayableScanResult { + ui_response_opt: None, + result: NextScanToRun::PendingPayableScan, + }); + let pending_payable_scanner = ScannerMock::new() + .scan_started_at_result(None) + .start_scan_params(&scan_params.pending_payable_start_scan) + .start_scan_result(Ok(RequestTransactionReceipts { + tx_hashes: vec![TxHashByTable::SentPayable(tx_hash)], + response_skeleton_opt: None, + })) + .finish_scan_params(&scan_params.pending_payable_finish_scan) + .finish_scan_result(PendingPayableScanResult::PaymentRetryRequired(None)); + let receivable_scanner = ScannerMock::new() + .scan_started_at_result(None) + .start_scan_params(&scan_params.receivable_start_scan) + .start_scan_result(Err(StartScanError::NothingToProcess)); + let (subject, expected_pending_payable_notify_later_interval, receivable_scan_interval) = + configure_accountant_for_startup_with_no_preexisting_pending_payables( + test_name, + ¬ify_and_notify_later_params, + config, + payable_scanner, + pending_payable_scanner, + receivable_scanner, + ); + let (peer_actors, addresses) = peer_actors_builder().build_and_provide_addresses(); + let subject_addr: Addr = subject.start(); + let subject_subs = Accountant::make_subs_from(&subject_addr); + let expected_tx_receipts_msg = TxReceiptsMessage { + results: btreemap![TxHashByTable::SentPayable(tx_hash) => Ok( + StatusReadFromReceiptCheck::Reverted, + )], + response_skeleton_opt: None, + }; + let sent_tx = TxBuilder::default() + .hash(make_tx_hash(890)) + .receiver_address(make_wallet("bcd").address()) + .build(); + let expected_sent_payables = SentPayables { + payment_procedure_result: Ok(BatchResults { + sent_txs: vec![sent_tx], + failed_txs: vec![], + }), + payable_scan_type: PayableScanType::New, + response_skeleton_opt: None, + }; + let blockchain_bridge_counter_msg_setup_for_pending_payable_scanner = setup_for_counter_msg_triggered_via_type_id!( + RequestTransactionReceipts, + expected_tx_receipts_msg.clone(), + &subject_addr + ); + let blockchain_bridge_counter_msg_setup_for_payable_scanner = setup_for_counter_msg_triggered_via_type_id!( + InitialTemplatesMessage, + expected_sent_payables.clone(), + &subject_addr + ); + send_bind_message!(subject_subs, peer_actors); + addresses + .blockchain_bridge_addr + .try_send(SetUpCounterMsgs::new(vec![ + blockchain_bridge_counter_msg_setup_for_pending_payable_scanner, + blockchain_bridge_counter_msg_setup_for_payable_scanner, + ])) + .unwrap(); + + send_start_message!(subject_subs); + + // The system is stopped by the NotifyHandleLaterMock for the PendingPayable scanner + let before = SystemTime::now(); + system.run(); + let after = SystemTime::now(); + assert_pending_payable_scanner_for_some_pending_payable_found( + test_name, + consuming_wallet.clone(), + &scan_params, + ¬ify_and_notify_later_params.pending_payables_notify_later, + expected_pending_payable_notify_later_interval, + expected_tx_receipts_msg, + before, + after, + ); + assert_payable_scanner_for_some_pending_payable_found( + test_name, + consuming_wallet, + &scan_params, + ¬ify_and_notify_later_params, + expected_sent_payables, + ); + assert_receivable_scanner( + test_name, + earning_wallet, + &scan_params.receivable_start_scan, + ¬ify_and_notify_later_params.receivables_notify_later, + receivable_scan_interval, + ); + // Since the assertions proved that the pending payable scanner had run multiple times + // before the new payable scanner started or was scheduled, the front position definitely + // belonged to the one first mentioned. + } + + #[derive(Default)] + struct ScanParams { + payable_start_scan: + Arc, Logger, String)>>>, + payable_finish_scan: Arc>>, + pending_payable_start_scan: + Arc, Logger, String)>>>, + pending_payable_finish_scan: Arc>>, + receivable_start_scan: + Arc, Logger, String)>>>, + } + + #[derive(Default)] + struct NotifyAndNotifyLaterParams { + new_payables_notify_later: Arc>>, + new_payables_notify: Arc>>, + retry_payables_notify_later: Arc>>, + pending_payables_notify_later: Arc>>, + receivables_notify_later: Arc>>, + } + + fn configure_accountant_for_startup_with_preexisting_pending_payables( + test_name: &str, + notify_and_notify_later_params: &NotifyAndNotifyLaterParams, + time_until_next_scan_params_arc: &Arc>>, + config: BootstrapperConfig, + pending_payable_scanner: ScannerMock< + RequestTransactionReceipts, + TxReceiptsMessage, + PendingPayableScanResult, + >, + receivable_scanner: ScannerMock< + RetrieveTransactions, + ReceivedPayments, + Option, + >, + payable_scanner: ScannerMock, + ) -> (Accountant, Duration, Duration) { + let mut subject = make_subject_and_inject_scanners( + test_name, + config, + pending_payable_scanner, + receivable_scanner, + payable_scanner, + ); + let new_payable_expected_computed_interval = Duration::from_secs(3600); + // Important that this is made short because the test relies on it with the system stop. + let receivable_scan_interval = Duration::from_millis(50); + subject.scan_schedulers.pending_payable.handle = Box::new( + NotifyLaterHandleMock::default() + .notify_later_params(¬ify_and_notify_later_params.pending_payables_notify_later), + ); + subject.scan_schedulers.payable.new_payable_notify_later = Box::new( + NotifyLaterHandleMock::default() + .notify_later_params(¬ify_and_notify_later_params.new_payables_notify_later), + ); + subject.scan_schedulers.payable.retry_payable_notify_later = Box::new( + NotifyLaterHandleMock::default() + .notify_later_params(¬ify_and_notify_later_params.retry_payables_notify_later), + ); + subject.scan_schedulers.payable.new_payable_notify = Box::new( + NotifyHandleMock::default() + .notify_params(¬ify_and_notify_later_params.new_payables_notify), + ); + let receivable_notify_later_handle_mock = NotifyLaterHandleMock::default() + .notify_later_params(¬ify_and_notify_later_params.receivables_notify_later) + .stop_system_on_count_received(1); + subject.scan_schedulers.receivable.handle = Box::new(receivable_notify_later_handle_mock); + subject.scan_schedulers.receivable.interval = receivable_scan_interval; + let interval_computer = NewPayableScanIntervalComputerMock::default() + .time_until_next_scan_params(&time_until_next_scan_params_arc) + .time_until_next_scan_result(ScanTiming::WaitFor( + new_payable_expected_computed_interval, + )); + subject.scan_schedulers.payable.interval_computer = Box::new(interval_computer); + ( + subject, + new_payable_expected_computed_interval, + receivable_scan_interval, + ) + } + + fn configure_accountant_for_startup_with_no_preexisting_pending_payables( + test_name: &str, + notify_and_notify_later_params: &NotifyAndNotifyLaterParams, + config: BootstrapperConfig, + payable_scanner: ScannerMock, + pending_payable_scanner: ScannerMock< + RequestTransactionReceipts, + TxReceiptsMessage, + PendingPayableScanResult, + >, + receivable_scanner: ScannerMock< + RetrieveTransactions, + ReceivedPayments, + Option, + >, + ) -> (Accountant, Duration, Duration) { + let mut subject = make_subject_and_inject_scanners( + test_name, + config, + pending_payable_scanner, + receivable_scanner, + payable_scanner, + ); + let retry_payble_scan_interval = Duration::from_millis(1); + let pending_payable_scan_interval = Duration::from_secs(3600); + let receivable_scan_interval = Duration::from_secs(3600); + let pending_payable_notify_later_handle_mock = NotifyLaterHandleMock::default() + .notify_later_params(¬ify_and_notify_later_params.pending_payables_notify_later) + // This should stop the system + .stop_system_on_count_received(1); + subject.scan_schedulers.pending_payable.handle = + Box::new(pending_payable_notify_later_handle_mock); + subject.scan_schedulers.payable.retry_payable_scan_interval = retry_payble_scan_interval; + subject.scan_schedulers.pending_payable.interval = pending_payable_scan_interval; + subject.scan_schedulers.payable.new_payable_notify_later = Box::new( + NotifyLaterHandleMock::default() + .notify_later_params(¬ify_and_notify_later_params.new_payables_notify_later), + ); + subject.scan_schedulers.payable.retry_payable_notify_later = Box::new( + NotifyLaterHandleMock::default() + .notify_later_params(¬ify_and_notify_later_params.retry_payables_notify_later) + .capture_msg_and_let_it_fly_on(), + ); + subject.scan_schedulers.payable.new_payable_notify = Box::new( + NotifyHandleMock::default() + .notify_params(¬ify_and_notify_later_params.new_payables_notify), + ); + let receivable_notify_later_handle_mock = NotifyLaterHandleMock::default() + .notify_later_params(¬ify_and_notify_later_params.receivables_notify_later); + subject.scan_schedulers.receivable.interval = receivable_scan_interval; + subject.scan_schedulers.receivable.handle = Box::new(receivable_notify_later_handle_mock); + ( + subject, + pending_payable_scan_interval, + receivable_scan_interval, + ) + } + + fn make_subject_and_inject_scanners( + test_name: &str, + config: BootstrapperConfig, + pending_payable_scanner: ScannerMock< + RequestTransactionReceipts, + TxReceiptsMessage, + PendingPayableScanResult, + >, + receivable_scanner: ScannerMock< + RetrieveTransactions, + ReceivedPayments, + Option, + >, + payable_scanner: ScannerMock, + ) -> Accountant { + let mut subject = AccountantBuilder::default() + .logger(Logger::new(test_name)) .bootstrapper_config(config) - .payable_daos(vec![ForPayableScanner(payable_dao)]) - .pending_payable_daos(vec![ForPendingPayableScanner(pending_payable_dao)]) - .receivable_daos(vec![ForReceivableScanner(receivable_dao)]) .build(); - let peer_actors = peer_actors_builder() - .blockchain_bridge(blockchain_bridge) + subject + .scanners + .replace_scanner(ScannerReplacement::PendingPayable(ReplacementType::Mock( + pending_payable_scanner, + ))); + subject + .scanners + .replace_scanner(ScannerReplacement::Receivable(ReplacementType::Mock( + receivable_scanner, + ))); + subject + .scanners + .replace_scanner(ScannerReplacement::Payable(ReplacementType::Mock( + payable_scanner, + ))); + subject + } + + fn assert_pending_payable_scanner_for_no_pending_payable_found( + test_name: &str, + consuming_wallet: Wallet, + pending_payable_start_scan_params_arc: &Arc< + Mutex, Logger, String)>>, + >, + scan_for_pending_payables_notify_later_params_arc: &Arc< + Mutex>, + >, + act_started_at: SystemTime, + act_finished_at: SystemTime, + ) { + let pp_logger = assert_pending_payable_scanner_ran( + consuming_wallet, + pending_payable_start_scan_params_arc, + act_started_at, + act_finished_at, + ); + let scan_for_pending_payables_notify_later_params = + 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 + // the second PendingPayableScanner run and its scheduling event. + assert!( + scan_for_pending_payables_notify_later_params.is_empty(), + "We did not expect to see another schedule for pending payables, but it happened {:?}", + scan_for_pending_payables_notify_later_params + ); + assert_using_the_same_logger(&pp_logger, test_name, Some("pp")); + } + + fn assert_pending_payable_scanner_for_some_pending_payable_found( + test_name: &str, + consuming_wallet: Wallet, + scan_params: &ScanParams, + scan_for_pending_payables_notify_later_params_arc: &Arc< + Mutex>, + >, + pending_payable_expected_notify_later_interval: Duration, + expected_tx_receipts_msg: TxReceiptsMessage, + act_started_at: SystemTime, + act_finished_at: SystemTime, + ) { + let pp_start_scan_logger = assert_pending_payable_scanner_ran( + consuming_wallet, + &scan_params.pending_payable_start_scan, + act_started_at, + act_finished_at, + ); + assert_using_the_same_logger(&pp_start_scan_logger, test_name, Some("pp start scan")); + let mut pending_payable_finish_scan_params = + scan_params.pending_payable_finish_scan.lock().unwrap(); + let (actual_tx_receipts_msg, pp_finish_scan_logger) = + pending_payable_finish_scan_params.remove(0); + assert_eq!(actual_tx_receipts_msg, expected_tx_receipts_msg); + assert_using_the_same_logger(&pp_finish_scan_logger, test_name, Some("pp finish scan")); + let scan_for_pending_payables_notify_later_params = + scan_for_pending_payables_notify_later_params_arc + .lock() + .unwrap(); + // This is the moment when the test ends. It says that we went the way of the pending payable + // sequence, instead of calling the NewPayableScan just after the initial pending payable + // scan. + assert_eq!( + *scan_for_pending_payables_notify_later_params, + vec![( + ScanForPendingPayables { + response_skeleton_opt: None + }, + pending_payable_expected_notify_later_interval + )], + ); + } + + fn assert_pending_payable_scanner_ran( + consuming_wallet: Wallet, + pending_payable_start_scan_params_arc: &Arc< + Mutex, Logger, String)>>, + >, + act_started_at: SystemTime, + act_finished_at: SystemTime, + ) -> Logger { + let mut pending_payable_params = pending_payable_start_scan_params_arc.lock().unwrap(); + let ( + pp_wallet, + pp_scan_started_at, + pp_response_skeleton_opt, + pp_logger, + pp_trigger_msg_type_str, + ) = pending_payable_params.remove(0); + assert_eq!(pp_wallet, consuming_wallet); + assert_eq!(pp_response_skeleton_opt, None); + assert!( + pp_trigger_msg_type_str.contains("PendingPayable"), + "Should contain PendingPayable but {}", + pp_trigger_msg_type_str + ); + assert!( + pending_payable_params.is_empty(), + "Should be empty but was {:?}", + pending_payable_params + ); + assert!( + act_started_at <= pp_scan_started_at && pp_scan_started_at <= act_finished_at, + "The scanner was supposed to run between {:?} and {:?} but it was {:?}", + act_started_at, + act_finished_at, + pp_scan_started_at + ); + pp_logger + } + + fn assert_payable_scanner_for_no_pending_payable_found( + payable_scanner_start_scan_arc: &Arc< + Mutex, Logger, String)>>, + >, + notify_and_notify_later_params: &NotifyAndNotifyLaterParams, + time_until_next_new_payable_scan_params_arc: Arc>>, + new_payable_expected_computed_interval: Duration, + ) { + // Note that there is no functionality from the payable scanner actually running. + // We only witness it to be scheduled. + let scan_for_new_payables_notify_later_params = notify_and_notify_later_params + .new_payables_notify_later + .lock() + .unwrap(); + assert_eq!( + *scan_for_new_payables_notify_later_params, + vec![( + ScanForNewPayables { + response_skeleton_opt: None + }, + new_payable_expected_computed_interval + )] + ); + let time_until_next_new_payable_scan_params = + time_until_next_new_payable_scan_params_arc.lock().unwrap(); + assert_eq!(*time_until_next_new_payable_scan_params, vec![()]); + let payable_scanner_start_scan = payable_scanner_start_scan_arc.lock().unwrap(); + assert!( + payable_scanner_start_scan.is_empty(), + "We expected the payable scanner not to run in this test, but it did" + ); + let scan_for_new_payables_notify_params = notify_and_notify_later_params + .new_payables_notify + .lock() + .unwrap(); + assert!( + scan_for_new_payables_notify_params.is_empty(), + "We did not expect any immediate scheduling of new payables, but it happened {:?}", + scan_for_new_payables_notify_params + ); + let scan_for_retry_payables_notify_params = notify_and_notify_later_params + .retry_payables_notify_later + .lock() + .unwrap(); + assert!( + scan_for_retry_payables_notify_params.is_empty(), + "We did not expect any scheduling of retry payables, but it happened {:?}", + scan_for_retry_payables_notify_params + ); + } + + fn assert_payable_scanner_for_some_pending_payable_found( + test_name: &str, + consuming_wallet: Wallet, + scan_params: &ScanParams, + notify_and_notify_later_params: &NotifyAndNotifyLaterParams, + expected_sent_payables: SentPayables, + ) { + assert_payable_scanner_ran_for_some_pending_payable_found( + test_name, + consuming_wallet, + scan_params, + expected_sent_payables, + ); + assert_scan_scheduling_for_some_pending_payable_found(notify_and_notify_later_params); + } + + fn assert_payable_scanner_ran_for_some_pending_payable_found( + test_name: &str, + consuming_wallet: Wallet, + scan_params: &ScanParams, + expected_sent_payables: SentPayables, + ) { + let mut payable_start_scan_params = scan_params.payable_start_scan.lock().unwrap(); + let (p_wallet, _, p_response_skeleton_opt, p_start_scan_logger, p_trigger_msg_type_str) = + payable_start_scan_params.remove(0); + assert_eq!(p_wallet, consuming_wallet); + assert_eq!(p_response_skeleton_opt, None); + // Important: it's the proof that we're dealing with the RetryPayableScanner not NewPayableScanner + assert!( + p_trigger_msg_type_str.contains("RetryPayable"), + "Should contain RetryPayable but {}", + p_trigger_msg_type_str + ); + assert!( + payable_start_scan_params.is_empty(), + "Should be empty but was {:?}", + payable_start_scan_params + ); + assert_using_the_same_logger(&p_start_scan_logger, test_name, Some("retry payable start")); + let mut payable_finish_scan_params = scan_params.payable_finish_scan.lock().unwrap(); + let (actual_sent_payable, p_finish_scan_logger) = payable_finish_scan_params.remove(0); + assert_eq!(actual_sent_payable, expected_sent_payables,); + assert!( + payable_finish_scan_params.is_empty(), + "Should be empty but was {:?}", + payable_finish_scan_params + ); + assert_using_the_same_logger( + &p_finish_scan_logger, + test_name, + Some("retry payable finish"), + ); + } + + fn assert_scan_scheduling_for_some_pending_payable_found( + notify_and_notify_later_params: &NotifyAndNotifyLaterParams, + ) { + let scan_for_new_payables_notify_later_params = notify_and_notify_later_params + .new_payables_notify_later + .lock() + .unwrap(); + assert!( + scan_for_new_payables_notify_later_params.is_empty(), + "We did not expect any later scheduling of new payables, but it happened {:?}", + scan_for_new_payables_notify_later_params + ); + let scan_for_new_payables_notify_params = notify_and_notify_later_params + .new_payables_notify + .lock() + .unwrap(); + assert!( + scan_for_new_payables_notify_params.is_empty(), + "We did not expect any immediate scheduling of new payables, but it happened {:?}", + scan_for_new_payables_notify_params + ); + let scan_for_retry_payables_notify_params = notify_and_notify_later_params + .retry_payables_notify_later + .lock() + .unwrap(); + assert_eq!( + *scan_for_retry_payables_notify_params, + vec![( + ScanForRetryPayables { + response_skeleton_opt: None + }, + Duration::from_millis(1) + )], + ); + } + + fn assert_receivable_scanner( + test_name: &str, + earning_wallet: Wallet, + receivable_start_scan_params_arc: &Arc< + Mutex, Logger, String)>>, + >, + scan_for_receivables_notify_later_params_arc: &Arc< + Mutex>, + >, + receivable_scan_interval: Duration, + ) { + assert_receivable_scan_ran(test_name, receivable_start_scan_params_arc, earning_wallet); + assert_another_receivable_scan_scheduled( + scan_for_receivables_notify_later_params_arc, + receivable_scan_interval, + ) + } + + fn assert_receivable_scan_ran( + test_name: &str, + receivable_start_scan_params_arc: &Arc< + Mutex, Logger, String)>>, + >, + earning_wallet: Wallet, + ) { + let mut receivable_start_scan_params = receivable_start_scan_params_arc.lock().unwrap(); + let (r_wallet, _r_started_at, r_response_skeleton_opt, r_logger, r_trigger_msg_name_str) = + receivable_start_scan_params.remove(0); + assert_eq!(r_wallet, earning_wallet); + assert_eq!(r_response_skeleton_opt, None); + assert!( + r_trigger_msg_name_str.contains("Receivable"), + "Should contain 'Receivable' but {}", + r_trigger_msg_name_str + ); + assert!( + receivable_start_scan_params.is_empty(), + "Should be empty by now but was {:?}", + receivable_start_scan_params + ); + assert_using_the_same_logger(&r_logger, test_name, Some("r")); + } + + fn assert_another_receivable_scan_scheduled( + scan_for_receivables_notify_later_params_arc: &Arc< + Mutex>, + >, + receivable_scan_interval: Duration, + ) { + let scan_for_receivables_notify_later_params = + scan_for_receivables_notify_later_params_arc.lock().unwrap(); + assert_eq!( + *scan_for_receivables_notify_later_params, + vec![( + ScanForReceivables { + response_skeleton_opt: None + }, + receivable_scan_interval + )] + ); + } + + #[test] + fn initial_pending_payable_scan_if_some_payables_found() { + let sent_payable_dao = + SentPayableDaoMock::default().retrieve_txs_result(BTreeSet::from([make_sent_tx(789)])); + let failed_payable_dao = + FailedPayableDaoMock::default().retrieve_txs_result(BTreeSet::new()); + let mut subject = AccountantBuilder::default() + .consuming_wallet(make_wallet("consuming")) + .sent_payable_daos(vec![ForPendingPayableScanner(sent_payable_dao)]) + .failed_payable_daos(vec![ForPendingPayableScanner(failed_payable_dao)]) + .build(); + let system = System::new("test"); + let (blockchain_bridge, _, blockchain_bridge_recording_arc) = make_recorder(); + let blockchain_bridge_addr = blockchain_bridge.start(); + subject.request_transaction_receipts_sub_opt = Some(blockchain_bridge_addr.recipient()); + let flag_before = subject.scanners.initial_pending_payable_scan(); + + let hint = subject.handle_request_of_scan_for_pending_payable(None); + + System::current().stop(); + system.run(); + let flag_after = subject.scanners.initial_pending_payable_scan(); + assert_eq!(hint, ScanReschedulingAfterEarlyStop::DoNotSchedule); + assert_eq!(flag_before, true); + assert_eq!(flag_after, false); + let blockchain_bridge_recording = blockchain_bridge_recording_arc.lock().unwrap(); + let _ = blockchain_bridge_recording.get_record::(0); + } + + #[test] + fn initial_pending_payable_scan_if_no_payables_found() { + let sent_payable_dao = SentPayableDaoMock::default().retrieve_txs_result(BTreeSet::new()); + let failed_payable_dao = + FailedPayableDaoMock::default().retrieve_txs_result(BTreeSet::new()); + let mut subject = AccountantBuilder::default() + .consuming_wallet(make_wallet("consuming")) + .sent_payable_daos(vec![ForPendingPayableScanner(sent_payable_dao)]) + .failed_payable_daos(vec![ForPendingPayableScanner(failed_payable_dao)]) .build(); - let subject_addr: Addr = subject.start(); - let subject_subs = Accountant::make_subs_from(&subject_addr); - send_bind_message!(subject_subs, peer_actors); + let flag_before = subject.scanners.initial_pending_payable_scan(); - send_start_message!(subject_subs); + let hint = subject.handle_request_of_scan_for_pending_payable(None); - System::current().stop(); - system.run(); - let payable_params = payable_params_arc.lock().unwrap(); - let pending_payable_params = pending_payable_params_arc.lock().unwrap(); - //proof of calling pieces of scan_for_delinquencies() - let mut new_delinquencies_params = new_delinquencies_params_arc.lock().unwrap(); - let (captured_timestamp, captured_curves) = new_delinquencies_params.remove(0); - let paid_delinquencies_params = paid_delinquencies_params_arc.lock().unwrap(); - assert_eq!(*payable_params, vec![()]); - assert_eq!(*pending_payable_params, vec![()]); - assert!(new_delinquencies_params.is_empty()); - assert!( - captured_timestamp < SystemTime::now() - && captured_timestamp >= from_time_t(to_time_t(SystemTime::now()) - 5) + let flag_after = subject.scanners.initial_pending_payable_scan(); + assert_eq!( + hint, + ScanReschedulingAfterEarlyStop::Schedule(ScanType::Payables) ); - assert_eq!(captured_curves, PaymentThresholds::default()); - assert_eq!(paid_delinquencies_params.len(), 1); - assert_eq!(paid_delinquencies_params[0], PaymentThresholds::default()); - let tlh = TestLogHandler::new(); - tlh.exists_log_containing("INFO: Accountant: Scanning for payables"); - tlh.exists_log_containing("INFO: Accountant: Scanning for pending payable"); - tlh.exists_log_containing(&format!( - "INFO: Accountant: Scanning for receivables to {}", - earning_wallet - )); - tlh.exists_log_containing("INFO: Accountant: Scanning for delinquencies"); + assert_eq!(flag_before, true); + assert_eq!(flag_after, false); + } + + #[test] + #[cfg(windows)] + #[should_panic( + expected = "internal error: entered unreachable code: ScanAlreadyRunning { \ + cross_scan_cause_opt: None, started_at: SystemTime { intervals: 116444736000000000 } } \ + should be impossible with PendingPayableScanner in automatic mode" + )] + fn initial_pending_payable_scan_hits_unexpected_error() { + test_initial_pending_payable_scan_hits_unexpected_error() + } + + #[test] + #[cfg(not(windows))] + #[should_panic( + expected = "internal error: entered unreachable code: ScanAlreadyRunning { \ + cross_scan_cause_opt: None, started_at: SystemTime { tv_sec: 0, tv_nsec: 0 } } \ + should be impossible with PendingPayableScanner in automatic mode" + )] + fn initial_pending_payable_scan_hits_unexpected_error() { + test_initial_pending_payable_scan_hits_unexpected_error() + } + + fn test_initial_pending_payable_scan_hits_unexpected_error() { + let mut subject = AccountantBuilder::default() + .consuming_wallet(make_wallet("abc")) + .build(); + let pending_payable_scanner = + ScannerMock::default().scan_started_at_result(Some(UNIX_EPOCH)); + subject + .scanners + .replace_scanner(ScannerReplacement::PendingPayable(ReplacementType::Mock( + pending_payable_scanner, + ))); + + let _ = subject.handle_request_of_scan_for_pending_payable(None); } #[test] fn periodical_scanning_for_receivables_and_delinquencies_works() { init_test_logging(); let test_name = "periodical_scanning_for_receivables_and_delinquencies_works"; - let begin_scan_params_arc = Arc::new(Mutex::new(vec![])); + let start_scan_params_arc = Arc::new(Mutex::new(vec![])); let notify_later_receivable_params_arc = Arc::new(Mutex::new(vec![])); let system = System::new(test_name); SystemKillerActor::new(Duration::from_secs(10)).start(); // a safety net for GitHub Actions let receivable_scanner = ScannerMock::new() - .begin_scan_params(&begin_scan_params_arc) - .begin_scan_result(Err(BeginScanError::NothingToProcess)) - .begin_scan_result(Ok(RetrieveTransactions { + .scan_started_at_result(None) + .scan_started_at_result(None) + .start_scan_params(&start_scan_params_arc) + .start_scan_result(Err(StartScanError::NothingToProcess)) + .start_scan_result(Ok(RetrieveTransactions { recipient: make_wallet("some_recipient"), response_skeleton_opt: None, })) @@ -2161,64 +3652,70 @@ mod tests { let mut config = bc_from_earning_wallet(earning_wallet.clone()); config.scan_intervals_opt = Some(ScanIntervals { payable_scan_interval: Duration::from_secs(100), + pending_payable_scan_interval: Duration::from_secs(10), receivable_scan_interval: Duration::from_millis(99), - pending_payable_scan_interval: Duration::from_secs(100), }); let mut subject = AccountantBuilder::default() .bootstrapper_config(config) .logger(Logger::new(test_name)) .build(); - subject.scanners.payable = Box::new(NullScanner::new()); // Skipping - subject.scanners.pending_payable = Box::new(NullScanner::new()); // Skipping - subject.scanners.receivable = Box::new(receivable_scanner); - subject.scan_schedulers.update_scheduler( - ScanType::Receivables, - Some(Box::new( - NotifyLaterHandleMock::default() - .notify_later_params(¬ify_later_receivable_params_arc) - .capture_msg_and_let_it_fly_on(), - )), - None, + subject + .scanners + .replace_scanner(ScannerReplacement::Receivable(ReplacementType::Mock( + receivable_scanner, + ))); + subject.scan_schedulers.receivable.handle = Box::new( + NotifyLaterHandleMock::default() + .notify_later_params(¬ify_later_receivable_params_arc) + .capture_msg_and_let_it_fly_on(), ); let subject_addr = subject.start(); let subject_subs = Accountant::make_subs_from(&subject_addr); let peer_actors = peer_actors_builder().build(); send_bind_message!(subject_subs, peer_actors); - send_start_message!(subject_subs); + subject_addr + .try_send(ScanForReceivables { + response_skeleton_opt: None, + }) + .unwrap(); let time_before = SystemTime::now(); system.run(); let time_after = SystemTime::now(); let notify_later_receivable_params = notify_later_receivable_params_arc.lock().unwrap(); - TestLogHandler::new().exists_log_containing(&format!( - "DEBUG: {test_name}: There was nothing to process during Receivables scan." - )); - let mut begin_scan_params = begin_scan_params_arc.lock().unwrap(); + let tlh = TestLogHandler::new(); + let mut start_scan_params = start_scan_params_arc.lock().unwrap(); let ( first_attempt_wallet, first_attempt_timestamp, first_attempt_response_skeleton_opt, first_attempt_logger, - ) = begin_scan_params.remove(0); + _, + ) = start_scan_params.remove(0); let ( second_attempt_wallet, second_attempt_timestamp, second_attempt_response_skeleton_opt, second_attempt_logger, - ) = begin_scan_params.remove(0); - assert_eq!(first_attempt_wallet, second_attempt_wallet); + _, + ) = start_scan_params.remove(0); + assert_eq!(first_attempt_wallet, earning_wallet); assert_eq!(second_attempt_wallet, earning_wallet); assert!(time_before <= first_attempt_timestamp); assert!(first_attempt_timestamp <= second_attempt_timestamp); assert!(second_attempt_timestamp <= time_after); assert_eq!(first_attempt_response_skeleton_opt, None); assert_eq!(second_attempt_response_skeleton_opt, None); - debug!(first_attempt_logger, "first attempt"); - debug!(second_attempt_logger, "second attempt"); - let tlh = TestLogHandler::new(); - tlh.exists_log_containing(&format!("DEBUG: {test_name}: first attempt")); - tlh.exists_log_containing(&format!("DEBUG: {test_name}: second attempt")); + assert!(start_scan_params.is_empty()); + debug!( + first_attempt_logger, + "first attempt verifying receivable scanner" + ); + debug!( + second_attempt_logger, + "second attempt verifying receivable scanner" + ); assert_eq!( *notify_later_receivable_params, vec![ @@ -2235,208 +3732,243 @@ mod tests { Duration::from_millis(99) ), ] - ) + ); + tlh.exists_log_containing(&format!( + "DEBUG: {test_name}: There was nothing to process during Receivables scan." + )); + tlh.exists_log_containing(&format!( + "DEBUG: {test_name}: first attempt verifying receivable scanner", + )); + tlh.exists_log_containing(&format!( + "DEBUG: {test_name}: second attempt verifying receivable scanner", + )); } + // This test begins with the new payable scan, continues over the retry payable scan and ends + // with another attempt for new payables which proves one complete cycle. #[test] - fn periodical_scanning_for_pending_payable_works() { + fn periodical_scanning_for_payables_works() { init_test_logging(); - let test_name = "periodical_scanning_for_pending_payable_works"; - let begin_scan_params_arc = Arc::new(Mutex::new(vec![])); - let notify_later_pending_payable_params_arc = Arc::new(Mutex::new(vec![])); + let test_name = "periodical_scanning_for_payables_works"; + let start_scan_pending_payable_params_arc = Arc::new(Mutex::new(vec![])); + let start_scan_payable_params_arc = Arc::new(Mutex::new(vec![])); + let notify_later_pending_payables_params_arc = Arc::new(Mutex::new(vec![])); + let notify_payable_params_arc = Arc::new(Mutex::new(vec![])); let system = System::new(test_name); - SystemKillerActor::new(Duration::from_secs(10)).start(); // a safety net for GitHub Actions + let (blockchain_bridge, _, blockchain_bridge_recording_arc) = make_recorder(); + let blockchain_bridge_addr = blockchain_bridge.start(); + let payable_account = make_payable_account(123); + let new_tx_templates = NewTxTemplates::from(&vec![payable_account.clone()]); + let priced_new_tx_templates = + make_priced_new_tx_templates(vec![(payable_account, 123_456_789)]); let consuming_wallet = make_paying_wallet(b"consuming"); - let pending_payable_scanner = ScannerMock::new() - .begin_scan_params(&begin_scan_params_arc) - .begin_scan_result(Err(BeginScanError::NothingToProcess)) - .begin_scan_result(Ok(RequestTransactionReceipts { - pending_payable: vec![], - response_skeleton_opt: None, - })) - .stop_the_system_after_last_msg(); - let mut config = make_bc_with_defaults(); - config.scan_intervals_opt = Some(ScanIntervals { - payable_scan_interval: Duration::from_secs(100), - receivable_scan_interval: Duration::from_secs(100), - pending_payable_scan_interval: Duration::from_millis(98), - }); - let mut subject = AccountantBuilder::default() - .consuming_wallet(consuming_wallet.clone()) - .bootstrapper_config(config) - .logger(Logger::new(test_name)) + let counter_msg_1 = PricedTemplatesMessage { + priced_templates: Either::Left(priced_new_tx_templates.clone()), + agent: Box::new(BlockchainAgentMock::default()), + response_skeleton_opt: None, + }; + let transaction_hash = make_tx_hash(789); + let tx_hash = make_tx_hash(456); + let creditor_wallet = make_wallet("blah"); + let sent_tx = TxBuilder::default() + .hash(transaction_hash) + .receiver_address(creditor_wallet.address()) .build(); - subject.scanners.payable = Box::new(NullScanner::new()); //skipping - subject.scanners.pending_payable = Box::new(pending_payable_scanner); - subject.scanners.receivable = Box::new(NullScanner::new()); //skipping - subject.scan_schedulers.update_scheduler( - ScanType::PendingPayables, - Some(Box::new( - NotifyLaterHandleMock::default() - .notify_later_params(¬ify_later_pending_payable_params_arc) - .capture_msg_and_let_it_fly_on(), - )), - None, + let counter_msg_2 = SentPayables { + payment_procedure_result: Ok(BatchResults { + sent_txs: vec![sent_tx], + failed_txs: vec![], + }), + payable_scan_type: PayableScanType::New, + response_skeleton_opt: None, + }; + let tx_status = StatusReadFromReceiptCheck::Succeeded(TxBlock { + block_hash: make_tx_hash(369369), + block_number: 4444444444u64.into(), + }); + let counter_msg_3 = TxReceiptsMessage { + results: btreemap![TxHashByTable::SentPayable(tx_hash) => Ok(tx_status)], + response_skeleton_opt: None, + }; + let request_transaction_receipts_msg = RequestTransactionReceipts { + tx_hashes: vec![TxHashByTable::SentPayable(tx_hash)], + response_skeleton_opt: None, + }; + let qualified_payables_msg = InitialTemplatesMessage { + initial_templates: Either::Left(new_tx_templates), + consuming_wallet: consuming_wallet.clone(), + response_skeleton_opt: None, + }; + let subject = set_up_subject_to_prove_periodical_payable_scan( + test_name, + &blockchain_bridge_addr, + &consuming_wallet, + &qualified_payables_msg, + &request_transaction_receipts_msg, + &start_scan_pending_payable_params_arc, + &start_scan_payable_params_arc, + ¬ify_later_pending_payables_params_arc, + ¬ify_payable_params_arc, ); - let subject_addr: Addr = subject.start(); - let subject_subs = Accountant::make_subs_from(&subject_addr); - let peer_actors = peer_actors_builder().build(); - send_bind_message!(subject_subs, peer_actors); + let subject_addr = subject.start(); + let set_up_counter_msgs = SetUpCounterMsgs::new(vec![ + setup_for_counter_msg_triggered_via_type_id!( + InitialTemplatesMessage, + counter_msg_1, + &subject_addr + ), + setup_for_counter_msg_triggered_via_type_id!( + OutboundPaymentsInstructions, + counter_msg_2, + &subject_addr + ), + setup_for_counter_msg_triggered_via_type_id!( + RequestTransactionReceipts, + counter_msg_3, + &subject_addr + ), + ]); + blockchain_bridge_addr + .try_send(set_up_counter_msgs) + .unwrap(); - send_start_message!(subject_subs); + subject_addr + .try_send(ScanForNewPayables { + response_skeleton_opt: None, + }) + .unwrap(); let time_before = SystemTime::now(); system.run(); let time_after = SystemTime::now(); - let notify_later_pending_payable_params = - notify_later_pending_payable_params_arc.lock().unwrap(); - TestLogHandler::new().exists_log_containing(&format!( - "DEBUG: {test_name}: There was nothing to process during PendingPayables scan." - )); - let mut begin_scan_params = begin_scan_params_arc.lock().unwrap(); - let ( - first_attempt_wallet, - first_attempt_timestamp, - first_attempt_response_skeleton_opt, - first_attempt_logger, - ) = begin_scan_params.remove(0); - let ( - second_attempt_wallet, - second_attempt_timestamp, - second_attempt_response_skeleton_opt, - second_attempt_logger, - ) = begin_scan_params.remove(0); - assert_eq!(first_attempt_wallet, second_attempt_wallet); - assert_eq!(second_attempt_wallet, consuming_wallet); - assert!(time_before <= first_attempt_timestamp); - assert!(first_attempt_timestamp <= second_attempt_timestamp); - assert!(second_attempt_timestamp <= time_after); - assert_eq!(first_attempt_response_skeleton_opt, None); - assert_eq!(second_attempt_response_skeleton_opt, None); - debug!(first_attempt_logger, "first attempt"); - debug!(second_attempt_logger, "second attempt"); - let tlh = TestLogHandler::new(); - tlh.exists_log_containing(&format!("DEBUG: {test_name}: first attempt")); - tlh.exists_log_containing(&format!("DEBUG: {test_name}: second attempt")); + let mut start_scan_payable_params = start_scan_payable_params_arc.lock().unwrap(); + let (wallet, timestamp, response_skeleton_opt, logger, _) = + start_scan_payable_params.remove(0); + assert_eq!(wallet, consuming_wallet); + assert!(time_before <= timestamp && timestamp <= time_after); + assert_eq!(response_skeleton_opt, None); + assert!(start_scan_payable_params.is_empty()); + assert_using_the_same_logger(&logger, test_name, Some("start scan payable")); + let mut start_scan_pending_payable_params = + start_scan_pending_payable_params_arc.lock().unwrap(); + let (wallet, timestamp, response_skeleton_opt, logger, _) = + start_scan_pending_payable_params.remove(0); + assert_eq!(wallet, consuming_wallet); + assert!(time_before <= timestamp && timestamp <= time_after); + assert_eq!(response_skeleton_opt, None); + assert!(start_scan_pending_payable_params.is_empty()); + assert_using_the_same_logger(&logger, test_name, Some("start scan pending payable")); + let blockchain_bridge_recording = blockchain_bridge_recording_arc.lock().unwrap(); + let actual_qualified_payables_msg = + blockchain_bridge_recording.get_record::(0); + assert_eq!(actual_qualified_payables_msg, &qualified_payables_msg); + let actual_outbound_payment_instructions_msg = + blockchain_bridge_recording.get_record::(1); assert_eq!( - *notify_later_pending_payable_params, - vec![ - ( - ScanForPendingPayables { - response_skeleton_opt: None - }, - Duration::from_millis(98) - ), - ( - ScanForPendingPayables { - response_skeleton_opt: None - }, - Duration::from_millis(98) - ), - ] - ) + actual_outbound_payment_instructions_msg.priced_templates, + Either::Left(priced_new_tx_templates) + ); + let actual_requested_receipts_1 = + blockchain_bridge_recording.get_record::(2); + assert_eq!( + actual_requested_receipts_1, + &request_transaction_receipts_msg + ); + let notify_later_pending_payables_params = + notify_later_pending_payables_params_arc.lock().unwrap(); + assert_eq!( + *notify_later_pending_payables_params, + vec![( + ScanForPendingPayables { + response_skeleton_opt: None + }, + Duration::from_millis(50) + ),] + ); + let notify_payables_params = notify_payable_params_arc.lock().unwrap(); + assert_eq!( + *notify_payables_params, + vec![ScanForNewPayables { + response_skeleton_opt: None + },] + ); } - #[test] - fn periodical_scanning_for_payable_works() { - init_test_logging(); - let test_name = "periodical_scanning_for_payable_works"; - let begin_scan_params_arc = Arc::new(Mutex::new(vec![])); - let notify_later_payables_params_arc = Arc::new(Mutex::new(vec![])); - let system = System::new(test_name); - SystemKillerActor::new(Duration::from_secs(10)).start(); // a safety net for GitHub Actions - let consuming_wallet = make_paying_wallet(b"consuming"); + fn set_up_subject_to_prove_periodical_payable_scan( + test_name: &str, + blockchain_bridge_addr: &Addr, + consuming_wallet: &Wallet, + qualified_payables_msg: &InitialTemplatesMessage, + request_transaction_receipts: &RequestTransactionReceipts, + start_scan_pending_payable_params_arc: &Arc< + Mutex, Logger, String)>>, + >, + start_scan_payable_params_arc: &Arc< + Mutex, Logger, String)>>, + >, + notify_later_pending_payables_params_arc: &Arc< + Mutex>, + >, + notify_payable_params_arc: &Arc>>, + ) -> Accountant { + let pending_payable_scanner = ScannerMock::new() + .scan_started_at_result(None) + .start_scan_params(&start_scan_pending_payable_params_arc) + .start_scan_result(Ok(request_transaction_receipts.clone())) + .finish_scan_result(PendingPayableScanResult::NoPendingPayablesLeft(None)); let payable_scanner = ScannerMock::new() - .begin_scan_params(&begin_scan_params_arc) - .begin_scan_result(Err(BeginScanError::NothingToProcess)) - .begin_scan_result(Ok(QualifiedPayablesMessage { - protected_qualified_payables: protect_payables_in_test(vec![make_payable_account( - 123, - )]), - consuming_wallet: consuming_wallet.clone(), - response_skeleton_opt: None, - })) - .stop_the_system_after_last_msg(); + .scan_started_at_result(None) + // Always checking also on the payable scanner when handling ScanForPendingPayable + .scan_started_at_result(None) + .start_scan_params(&start_scan_payable_params_arc) + .start_scan_result(Ok(qualified_payables_msg.clone())) + .finish_scan_result(PayableScanResult { + ui_response_opt: None, + result: NextScanToRun::PendingPayableScan, + }); let mut config = bc_from_earning_wallet(make_wallet("hi")); config.scan_intervals_opt = Some(ScanIntervals { - payable_scan_interval: Duration::from_millis(97), + // This simply means that we're gonna surplus this value (it abides by how many pending + // payable cycles have to go in between before the lastly submitted txs are confirmed), + payable_scan_interval: Duration::from_millis(10), + pending_payable_scan_interval: Duration::from_millis(50), receivable_scan_interval: Duration::from_secs(100), // We'll never run this scanner - pending_payable_scan_interval: Duration::from_secs(100), // We'll never run this scanner }); let mut subject = AccountantBuilder::default() .bootstrapper_config(config) .consuming_wallet(consuming_wallet.clone()) .logger(Logger::new(test_name)) .build(); - subject.scanners.payable = Box::new(payable_scanner); - subject.scanners.pending_payable = Box::new(NullScanner::new()); //skipping - subject.scanners.receivable = Box::new(NullScanner::new()); //skipping - subject.scan_schedulers.update_scheduler( - ScanType::Payables, - Some(Box::new( - NotifyLaterHandleMock::default() - .notify_later_params(¬ify_later_payables_params_arc) - .capture_msg_and_let_it_fly_on(), - )), - None, + subject + .scanners + .replace_scanner(ScannerReplacement::PendingPayable(ReplacementType::Mock( + pending_payable_scanner, + ))); + subject + .scanners + .replace_scanner(ScannerReplacement::Payable(ReplacementType::Mock( + payable_scanner, + ))); + subject + .scanners + .replace_scanner(ScannerReplacement::Receivable(ReplacementType::Null)); //skipping + subject.scan_schedulers.pending_payable.handle = Box::new( + NotifyLaterHandleMock::::default() + .notify_later_params(¬ify_later_pending_payables_params_arc) + .capture_msg_and_let_it_fly_on(), ); - let subject_addr = subject.start(); - let subject_subs = Accountant::make_subs_from(&subject_addr); - let peer_actors = peer_actors_builder().build(); - send_bind_message!(subject_subs, peer_actors); - - send_start_message!(subject_subs); - - let time_before = SystemTime::now(); - system.run(); - let time_after = SystemTime::now(); - //the second attempt is the one where the queue is empty and System::current.stop() ends the cycle - let notify_later_payables_params = notify_later_payables_params_arc.lock().unwrap(); - TestLogHandler::new().exists_log_containing(&format!( - "DEBUG: {test_name}: There was nothing to process during Payables scan." - )); - let mut begin_scan_params = begin_scan_params_arc.lock().unwrap(); - let ( - first_attempt_wallet, - first_attempt_timestamp, - first_attempt_response_skeleton_opt, - first_attempt_logger, - ) = begin_scan_params.remove(0); - let ( - second_attempt_wallet, - second_attempt_timestamp, - second_attempt_response_skeleton_opt, - second_attempt_logger, - ) = begin_scan_params.remove(0); - assert_eq!(first_attempt_wallet, second_attempt_wallet); - assert_eq!(second_attempt_wallet, consuming_wallet); - assert!(time_before <= first_attempt_timestamp); - assert!(first_attempt_timestamp <= second_attempt_timestamp); - assert!(second_attempt_timestamp <= time_after); - assert_eq!(first_attempt_response_skeleton_opt, None); - assert_eq!(second_attempt_response_skeleton_opt, None); - debug!(first_attempt_logger, "first attempt"); - debug!(second_attempt_logger, "second attempt"); - let tlh = TestLogHandler::new(); - tlh.exists_log_containing(&format!("DEBUG: {test_name}: first attempt")); - tlh.exists_log_containing(&format!("DEBUG: {test_name}: second attempt")); - assert_eq!( - *notify_later_payables_params, - vec![ - ( - ScanForPayables { - response_skeleton_opt: None - }, - Duration::from_millis(97) - ), - ( - ScanForPayables { - response_skeleton_opt: None - }, - Duration::from_millis(97) - ), - ] - ) + subject.scan_schedulers.payable.new_payable_notify = Box::new( + NotifyHandleMock::::default() + .notify_params(¬ify_payable_params_arc) + // This should stop the system. If anything goes wrong, the SystemKillerActor will. + .stop_system_on_count_received(1), + ); + subject.qualified_payables_sub_opt = Some(blockchain_bridge_addr.clone().recipient()); + subject.outbound_payments_instructions_sub_opt = + Some(blockchain_bridge_addr.clone().recipient()); + subject.request_transaction_receipts_sub_opt = + Some(blockchain_bridge_addr.clone().recipient()); + subject } #[test] @@ -2447,12 +3979,15 @@ mod tests { subject.consuming_wallet_opt = None; subject.logger = Logger::new(test_name); - subject.handle_request_of_scan_for_payable(None); + subject.handle_request_of_scan_for_new_payable(None); - let has_scan_started = subject.scanners.payable.scan_started_at().is_some(); + let has_scan_started = subject + .scanners + .scan_started_at(ScanType::Payables) + .is_some(); assert_eq!(has_scan_started, false); TestLogHandler::new().exists_log_containing(&format!( - "DEBUG: {test_name}: Cannot initiate Payables scan because no consuming wallet was found." + "WARN: {test_name}: Cannot initiate Payables scan because no consuming wallet was found." )); } @@ -2466,10 +4001,13 @@ mod tests { subject.handle_request_of_scan_for_pending_payable(None); - let has_scan_started = subject.scanners.pending_payable.scan_started_at().is_some(); + let has_scan_started = subject + .scanners + .scan_started_at(ScanType::PendingPayables) + .is_some(); assert_eq!(has_scan_started, false); TestLogHandler::new().exists_log_containing(&format!( - "DEBUG: {test_name}: Cannot initiate PendingPayables scan because no consuming wallet was found." + "WARN: {test_name}: Cannot initiate PendingPayables scan because no consuming wallet was found." )); } @@ -2481,10 +4019,10 @@ mod tests { let mut config = bc_from_earning_wallet(make_wallet("hi")); config.scan_intervals_opt = Some(ScanIntervals { payable_scan_interval: Duration::from_millis(100), + pending_payable_scan_interval: Duration::from_millis(50), receivable_scan_interval: Duration::from_millis(100), - pending_payable_scan_interval: Duration::from_millis(100), }); - config.suppress_initial_scans = true; + config.automatic_scans_enabled = false; let peer_actors = peer_actors_builder().build(); let subject = AccountantBuilder::default() .bootstrapper_config(config) @@ -2498,14 +4036,14 @@ mod tests { System::current().stop(); assert_eq!(system.run(), 0); - // no panics because of recalcitrant DAOs; therefore DAOs were not called; therefore test passes + // No panics because of recalcitrant DAOs; therefore DAOs were not called; therefore test passes TestLogHandler::new().exists_log_containing( &format!("{test_name}: Started with --scans off; declining to begin database and blockchain scans"), ); } #[test] - fn scan_for_payables_message_does_not_trigger_payment_for_balances_below_the_curve() { + fn scan_for_new_payables_does_not_trigger_payment_for_balances_below_the_curve() { init_test_logging(); let consuming_wallet = make_paying_wallet(b"consuming wallet"); let payment_thresholds = PaymentThresholds { @@ -2517,15 +4055,16 @@ mod tests { unban_below_gwei: 10_000_000, }; let config = bc_from_earning_wallet(make_wallet("mine")); - let now = to_time_t(SystemTime::now()); + let now = to_unix_timestamp(SystemTime::now()); let accounts = vec![ // below minimum balance, to the right of time intersection (inside buffer zone) PayableAccount { wallet: make_wallet("wallet0"), balance_wei: gwei_to_wei(payment_thresholds.permanent_debt_allowed_gwei - 1), - last_paid_timestamp: from_time_t( + last_paid_timestamp: from_unix_timestamp( now - checked_conversion::( - payment_thresholds.threshold_interval_sec + 10, + payment_thresholds.maturity_threshold_sec + + payment_thresholds.threshold_interval_sec, ), ), pending_payable_opt: None, @@ -2534,73 +4073,86 @@ mod tests { PayableAccount { wallet: make_wallet("wallet1"), balance_wei: gwei_to_wei(payment_thresholds.debt_threshold_gwei + 1), - last_paid_timestamp: from_time_t( - now - checked_conversion::( - payment_thresholds.maturity_threshold_sec - 10, - ), + last_paid_timestamp: from_unix_timestamp( + now - checked_conversion::(payment_thresholds.maturity_threshold_sec) + + 1, ), pending_payable_opt: None, }, // above minimum balance, to the right of minimum time (not in buffer zone, below the curve) PayableAccount { wallet: make_wallet("wallet2"), - balance_wei: gwei_to_wei(payment_thresholds.permanent_debt_allowed_gwei + 55), - last_paid_timestamp: from_time_t( - now - checked_conversion::( - payment_thresholds.maturity_threshold_sec + 15, - ), + balance_wei: gwei_to_wei::( + payment_thresholds.permanent_debt_allowed_gwei, + ) + 1, + last_paid_timestamp: from_unix_timestamp( + now - checked_conversion::(payment_thresholds.threshold_interval_sec) + + 1, ), pending_payable_opt: None, }, ]; let payable_dao = PayableDaoMock::new() - .non_pending_payables_result(accounts.clone()) - .non_pending_payables_result(vec![]); + .retrieve_payables_result(accounts.clone()) + .retrieve_payables_result(vec![]); let (blockchain_bridge, _, blockchain_bridge_recordings_arc) = make_recorder(); let system = System::new( - "scan_for_payable_message_does_not_trigger_payment_for_balances_below_the_curve", + "scan_for_new_payables_does_not_trigger_payment_for_balances_below_the_curve", ); let blockchain_bridge_addr: Addr = blockchain_bridge.start(); - let outbound_payments_instructions_sub = - blockchain_bridge_addr.recipient::(); let mut subject = AccountantBuilder::default() .bootstrapper_config(config) - .payable_daos(vec![ForPayableScanner(payable_dao)]) + .consuming_wallet(consuming_wallet.clone()) + .build(); + let payable_scanner = PayableScannerBuilder::new() + .payment_thresholds(payment_thresholds) + .payable_dao(payable_dao) .build(); - subject.outbound_payments_instructions_sub_opt = Some(outbound_payments_instructions_sub); + subject + .scanners + .replace_scanner(ScannerReplacement::Payable(ReplacementType::Real( + payable_scanner, + ))); + subject + .scanners + .replace_scanner(ScannerReplacement::PendingPayable(ReplacementType::Null)); + subject + .scanners + .replace_scanner(ScannerReplacement::Receivable(ReplacementType::Null)); + subject.qualified_payables_sub_opt = Some(blockchain_bridge_addr.recipient()); + bind_ui_gateway_unasserted(&mut subject); - let _result = subject.scanners.payable.begin_scan( - consuming_wallet, - SystemTime::now(), - None, - &subject.logger, - ); + let result = subject.handle_request_of_scan_for_new_payable(None); System::current().stop(); system.run(); + assert_eq!( + result, + ScanReschedulingAfterEarlyStop::Schedule(ScanType::Payables) + ); let blockchain_bridge_recordings = blockchain_bridge_recordings_arc.lock().unwrap(); assert_eq!(blockchain_bridge_recordings.len(), 0); } #[test] - fn scan_for_payable_message_triggers_payment_for_balances_over_the_curve() { + fn scan_for_new_payables_triggers_payment_for_balances_over_the_curve() { init_test_logging(); let mut config = bc_from_earning_wallet(make_wallet("mine")); let consuming_wallet = make_paying_wallet(b"consuming"); config.scan_intervals_opt = Some(ScanIntervals { - pending_payable_scan_interval: Duration::from_secs(50_000), payable_scan_interval: Duration::from_secs(50_000), + pending_payable_scan_interval: Duration::from_secs(10_000), receivable_scan_interval: Duration::from_secs(50_000), }); - let now = to_time_t(SystemTime::now()); + let now = to_unix_timestamp(SystemTime::now()); let qualified_payables = vec![ - // slightly above minimum balance, to the right of the curve (time intersection) + // Slightly above the minimum balance, to the right of the curve (time intersection) PayableAccount { wallet: make_wallet("wallet0"), balance_wei: gwei_to_wei( DEFAULT_PAYMENT_THRESHOLDS.permanent_debt_allowed_gwei + 1, ), - last_paid_timestamp: from_time_t( + last_paid_timestamp: from_unix_timestamp( now - checked_conversion::( DEFAULT_PAYMENT_THRESHOLDS.threshold_interval_sec + DEFAULT_PAYMENT_THRESHOLDS.maturity_threshold_sec @@ -2609,11 +4161,11 @@ mod tests { ), pending_payable_opt: None, }, - // slightly above the curve (balance intersection), to the right of minimum time + // Slightly above the curve (balance intersection), to the right of minimum time PayableAccount { wallet: make_wallet("wallet1"), balance_wei: gwei_to_wei(DEFAULT_PAYMENT_THRESHOLDS.debt_threshold_gwei + 1), - last_paid_timestamp: from_time_t( + last_paid_timestamp: from_unix_timestamp( now - checked_conversion::( DEFAULT_PAYMENT_THRESHOLDS.maturity_threshold_sec + 10, ), @@ -2622,67 +4174,90 @@ mod tests { }, ]; let payable_dao = - PayableDaoMock::default().non_pending_payables_result(qualified_payables.clone()); + PayableDaoMock::default().retrieve_payables_result(qualified_payables.clone()); let (blockchain_bridge, _, blockchain_bridge_recordings_arc) = make_recorder(); - let blockchain_bridge = blockchain_bridge - .system_stop_conditions(match_every_type_id!(QualifiedPayablesMessage)); + let blockchain_bridge_addr = blockchain_bridge.start(); let system = System::new("scan_for_payable_message_triggers_payment_for_balances_over_the_curve"); - let peer_actors = peer_actors_builder() - .blockchain_bridge(blockchain_bridge) - .build(); let mut subject = AccountantBuilder::default() .bootstrapper_config(config) .consuming_wallet(consuming_wallet.clone()) .payable_daos(vec![ForPayableScanner(payable_dao)]) .build(); - subject.scanners.pending_payable = Box::new(NullScanner::new()); - subject.scanners.receivable = Box::new(NullScanner::new()); - let subject_addr = subject.start(); - let accountant_subs = Accountant::make_subs_from(&subject_addr); - send_bind_message!(accountant_subs, peer_actors); + subject + .scanners + .replace_scanner(ScannerReplacement::PendingPayable(ReplacementType::Null)); + subject + .scanners + .replace_scanner(ScannerReplacement::Receivable(ReplacementType::Null)); + subject.qualified_payables_sub_opt = Some(blockchain_bridge_addr.recipient()); + bind_ui_gateway_unasserted(&mut subject); - send_start_message!(accountant_subs); + subject.handle_request_of_scan_for_new_payable(None); + System::current().stop(); system.run(); let blockchain_bridge_recordings = blockchain_bridge_recordings_arc.lock().unwrap(); - let message = blockchain_bridge_recordings.get_record::(0); + let message = blockchain_bridge_recordings.get_record::(0); + let new_tx_templates = NewTxTemplates::from(&qualified_payables); assert_eq!( message, - &QualifiedPayablesMessage { - protected_qualified_payables: protect_payables_in_test(qualified_payables), + &InitialTemplatesMessage { + initial_templates: Either::Left(new_tx_templates), consuming_wallet, response_skeleton_opt: None, } ); } + #[test] + #[should_panic( + expected = "internal error: entered unreachable code: Early stopped new payable scan \ + was suggested to be followed up by the scan for Receivables, which is not supported though" + )] + fn start_scan_error_in_new_payables_and_unexpected_reaction_by_receivable_scan_scheduling() { + let mut subject = AccountantBuilder::default().build(); + let reschedule_on_error_resolver = RescheduleScanOnErrorResolverMock::default() + .resolve_rescheduling_on_error_result(ScanReschedulingAfterEarlyStop::Schedule( + ScanType::Receivables, + )); + subject.scan_schedulers.reschedule_on_error_resolver = + Box::new(reschedule_on_error_resolver); + let system = System::new("test"); + let subject_addr = subject.start(); + + subject_addr + .try_send(ScanForNewPayables { + response_skeleton_opt: None, + }) + .unwrap(); + + system.run(); + } + #[test] fn accountant_does_not_initiate_another_scan_if_one_is_already_running() { init_test_logging(); let test_name = "accountant_does_not_initiate_another_scan_if_one_is_already_running"; - let payable_dao = PayableDaoMock::default(); + let now = SystemTime::now(); + let payment_thresholds = PaymentThresholds::default(); let (blockchain_bridge, _, blockchain_bridge_recording) = make_recorder(); let blockchain_bridge_addr = blockchain_bridge - .system_stop_conditions(match_every_type_id!( - QualifiedPayablesMessage, - QualifiedPayablesMessage + .system_stop_conditions(match_lazily_every_type_id!( + InitialTemplatesMessage, + InitialTemplatesMessage )) .start(); - let pps_for_blockchain_bridge_sub = blockchain_bridge_addr.clone().recipient(); - let last_paid_timestamp = to_time_t(SystemTime::now()) - - DEFAULT_PAYMENT_THRESHOLDS.maturity_threshold_sec as i64 - - 1; - let payable_account = PayableAccount { - wallet: make_wallet("scan_for_payables"), - balance_wei: gwei_to_wei(DEFAULT_PAYMENT_THRESHOLDS.debt_threshold_gwei + 1), - last_paid_timestamp: from_time_t(last_paid_timestamp), - pending_payable_opt: None, - }; - let payable_dao = payable_dao - .non_pending_payables_result(vec![payable_account.clone()]) - .non_pending_payables_result(vec![payable_account]); - let config = bc_from_earning_wallet(make_wallet("mine")); + let qualified_payables_sub = blockchain_bridge_addr.clone().recipient(); + let (mut qualified_payables, _, _) = + make_qualified_and_unqualified_payables(now, &payment_thresholds); + let payable_1 = qualified_payables.remove(0); + let payable_2 = qualified_payables.remove(0); + let payable_dao = PayableDaoMock::new() + .retrieve_payables_result(vec![payable_1.clone()]) + .retrieve_payables_result(vec![payable_2.clone()]); + let mut config = bc_from_earning_wallet(make_wallet("mine")); + config.payment_thresholds_opt = Some(payment_thresholds); let system = System::new(test_name); let mut subject = AccountantBuilder::default() .consuming_wallet(make_paying_wallet(b"consuming")) @@ -2690,97 +4265,104 @@ mod tests { .payable_daos(vec![ForPayableScanner(payable_dao)]) .bootstrapper_config(config) .build(); - let message_before = ScanForPayables { + let message_before = ScanForNewPayables { response_skeleton_opt: Some(ResponseSkeleton { client_id: 111, context_id: 222, }), }; - let message_after = ScanForPayables { + let message_simultaneous = ScanForNewPayables { + response_skeleton_opt: Some(ResponseSkeleton { + client_id: 999, + context_id: 888, + }), + }; + let message_after = ScanForNewPayables { response_skeleton_opt: Some(ResponseSkeleton { client_id: 333, context_id: 444, }), }; - subject.qualified_payables_sub_opt = Some(pps_for_blockchain_bridge_sub); + subject.qualified_payables_sub_opt = Some(qualified_payables_sub); + bind_ui_gateway_unasserted(&mut subject); + // important + subject.scan_schedulers.automatic_scans_enabled = false; let addr = subject.start(); addr.try_send(message_before.clone()).unwrap(); - addr.try_send(ScanForPayables { - response_skeleton_opt: None, - }) - .unwrap(); + addr.try_send(message_simultaneous).unwrap(); - // We ignored the second ScanForPayables message because the first message meant a scan - // was already in progress; now let's make it look like that scan has ended so that we - // can prove the next message will start another one. - addr.try_send(AssertionsMessage { - assertions: Box::new(|accountant: &mut Accountant| { - accountant - .scanners - .payable - .mark_as_ended(&Logger::new("irrelevant")) + // We ignored the second ScanForNewPayables message as there was already in progress from + // the first message. Now we reset the state by ending the first scan by a failure and see + // that the third scan request is going to be accepted willingly again. + addr.try_send(SentPayables { + payment_procedure_result: Err("blah".to_string()), + payable_scan_type: PayableScanType::New, + response_skeleton_opt: Some(ResponseSkeleton { + client_id: 1122, + context_id: 7788, }), }) .unwrap(); addr.try_send(message_after.clone()).unwrap(); system.run(); - let recording = blockchain_bridge_recording.lock().unwrap(); - let messages_received = recording.len(); - assert_eq!(messages_received, 2); - let first_message: &QualifiedPayablesMessage = recording.get_record(0); + let blockchain_bridge_recording = blockchain_bridge_recording.lock().unwrap(); + let first_message_actual: &InitialTemplatesMessage = + blockchain_bridge_recording.get_record(0); assert_eq!( - first_message.response_skeleton_opt, + first_message_actual.response_skeleton_opt, message_before.response_skeleton_opt ); - let second_message: &QualifiedPayablesMessage = recording.get_record(1); + let second_message_actual: &InitialTemplatesMessage = + blockchain_bridge_recording.get_record(1); assert_eq!( - second_message.response_skeleton_opt, + second_message_actual.response_skeleton_opt, message_after.response_skeleton_opt ); + let messages_received = blockchain_bridge_recording.len(); + assert_eq!(messages_received, 2); TestLogHandler::new().exists_log_containing(&format!( - "DEBUG: {}: Payables scan was already initiated", + "INFO: {}: Payables scan was already initiated", test_name )); } #[test] - fn scan_for_pending_payables_finds_still_pending_payables() { + fn scan_for_pending_payables_finds_various_payables() { init_test_logging(); + let test_name = "scan_for_pending_payables_finds_various_payables"; + let start_scan_params_arc = Arc::new(Mutex::new(vec![])); let (blockchain_bridge, _, blockchain_bridge_recording_arc) = make_recorder(); let blockchain_bridge_addr = blockchain_bridge - .system_stop_conditions(match_every_type_id!(RequestTransactionReceipts)) + .system_stop_conditions(match_lazily_every_type_id!(RequestTransactionReceipts)) .start(); - let payable_fingerprint_1 = PendingPayableFingerprint { - rowid: 555, - timestamp: from_time_t(210_000_000), - hash: make_tx_hash(45678), - attempt: 1, - amount: 4444, - process_error: None, - }; - let payable_fingerprint_2 = PendingPayableFingerprint { - rowid: 550, - timestamp: from_time_t(210_000_100), - hash: make_tx_hash(112233), - attempt: 2, - amount: 7999, - process_error: None, + let tx_hash_1 = make_tx_hash(456); + let tx_hash_2 = make_tx_hash(789); + let tx_hash_3 = make_tx_hash(123); + let expected_composed_msg_for_blockchain_bridge = RequestTransactionReceipts { + tx_hashes: vec![ + TxHashByTable::SentPayable(tx_hash_1), + TxHashByTable::FailedPayable(tx_hash_2), + TxHashByTable::FailedPayable(tx_hash_3), + ], + response_skeleton_opt: None, }; - let pending_payable_dao = PendingPayableDaoMock::default() - .return_all_errorless_fingerprints_result(vec![ - payable_fingerprint_1.clone(), - payable_fingerprint_2.clone(), - ]); - let config = bc_from_earning_wallet(make_wallet("mine")); + let pending_payable_scanner = ScannerMock::new() + .scan_started_at_result(None) + .start_scan_params(&start_scan_params_arc) + .start_scan_result(Ok(expected_composed_msg_for_blockchain_bridge.clone())); + let consuming_wallet = make_wallet("consuming"); let system = System::new("pending payable scan"); let mut subject = AccountantBuilder::default() - .consuming_wallet(make_paying_wallet(b"consuming")) - .pending_payable_daos(vec![ForPendingPayableScanner(pending_payable_dao)]) - .bootstrapper_config(config) + .consuming_wallet(consuming_wallet.clone()) + .logger(Logger::new(test_name)) .build(); - - subject.request_transaction_receipts_subs_opt = Some(blockchain_bridge_addr.recipient()); + subject + .scanners + .replace_scanner(ScannerReplacement::PendingPayable(ReplacementType::Mock( + pending_payable_scanner, + ))); + subject.request_transaction_receipts_sub_opt = Some(blockchain_bridge_addr.recipient()); let account_addr = subject.start(); let _ = account_addr @@ -2789,19 +4371,94 @@ mod tests { }) .unwrap(); + let before = SystemTime::now(); system.run(); + let after = SystemTime::now(); + let mut start_scan_params = start_scan_params_arc.lock().unwrap(); + let (wallet, timestamp, response_skeleton_opt, logger, _) = start_scan_params.remove(0); + assert_eq!(wallet, consuming_wallet); + assert!(before <= timestamp && timestamp <= after); + assert_eq!(response_skeleton_opt, None); + assert!( + start_scan_params.is_empty(), + "Should be empty but {:?}", + start_scan_params + ); + assert_using_the_same_logger(&logger, test_name, Some("start scan payable")); let blockchain_bridge_recording = blockchain_bridge_recording_arc.lock().unwrap(); - assert_eq!(blockchain_bridge_recording.len(), 1); let received_msg = blockchain_bridge_recording.get_record::(0); - assert_eq!( - received_msg, - &RequestTransactionReceipts { - pending_payable: vec![payable_fingerprint_1, payable_fingerprint_2], + assert_eq!(received_msg, &expected_composed_msg_for_blockchain_bridge); + assert_eq!(blockchain_bridge_recording.len(), 1); + } + + #[test] + fn start_scan_error_in_pending_payables_if_initial_scan_is_true_and_no_consuming_wallet_found() + { + let pending_payables_notify_later_params_arc = Arc::new(Mutex::new(vec![])); + let new_payables_notify_params_arc = Arc::new(Mutex::new(vec![])); + let mut subject = AccountantBuilder::default().build(); + subject.consuming_wallet_opt = None; + subject.scan_schedulers.pending_payable.handle = Box::new( + NotifyLaterHandleMock::default() + .notify_later_params(&pending_payables_notify_later_params_arc) + .stop_system_on_count_received(1), + ); + subject.scan_schedulers.pending_payable.interval = Duration::from_secs(60); + subject.scan_schedulers.payable.new_payable_notify = + Box::new(NotifyHandleMock::default().notify_params(&new_payables_notify_params_arc)); + let system = System::new("test"); + let subject_addr = subject.start(); + + subject_addr + .try_send(ScanForPendingPayables { response_skeleton_opt: None, - } + }) + .unwrap(); + + system.run(); + let pending_payables_notify_later_params = + pending_payables_notify_later_params_arc.lock().unwrap(); + assert_eq!( + *pending_payables_notify_later_params, + vec![( + ScanForPendingPayables { + response_skeleton_opt: None + }, + Duration::from_secs(60) + )] + ); + let new_payables_notify_params = new_payables_notify_params_arc.lock().unwrap(); + assert_eq!( + new_payables_notify_params.len(), + 0, + "Did not expect the new payables request" ); - let log_handler = TestLogHandler::new(); - log_handler.exists_log_containing("DEBUG: Accountant: Found 2 pending payables to process"); + } + + #[test] + #[should_panic( + expected = "internal error: entered unreachable code: Early stopped pending payable scan \ + was suggested to be followed up by the scan for Receivables, which is not supported though" + )] + fn start_scan_error_in_pending_payables_and_unexpected_reaction_by_receivable_scan_scheduling() + { + let mut subject = AccountantBuilder::default().build(); + let reschedule_on_error_resolver = RescheduleScanOnErrorResolverMock::default() + .resolve_rescheduling_on_error_result(ScanReschedulingAfterEarlyStop::Schedule( + ScanType::Receivables, + )); + subject.scan_schedulers.reschedule_on_error_resolver = + Box::new(reschedule_on_error_resolver); + let system = System::new("test"); + let subject_addr = subject.start(); + + subject_addr + .try_send(ScanForPendingPayables { + response_skeleton_opt: None, + }) + .unwrap(); + + system.run(); } #[test] @@ -2810,7 +4467,7 @@ mod tests { let now = SystemTime::now(); let bootstrapper_config = bc_from_earning_wallet(make_wallet("hi")); let more_money_receivable_parameters_arc = Arc::new(Mutex::new(vec![])); - let payable_dao_mock = PayableDaoMock::new().non_pending_payables_result(vec![]); + let payable_dao_mock = PayableDaoMock::new().retrieve_payables_result(vec![]); let receivable_dao_mock = ReceivableDaoMock::new() .more_money_receivable_parameters(&more_money_receivable_parameters_arc) .more_money_receivable_result(Ok(())); @@ -2845,10 +4502,6 @@ mod tests { more_money_receivable_parameters[0], (now, make_wallet("booga"), (1 * 42) + (1234 * 24)) ); - TestLogHandler::new().exists_log_containing(&format!( - "DEBUG: Accountant: Charging routing of 1234 bytes to wallet {}", - paying_wallet - )); } #[test] @@ -2857,7 +4510,7 @@ mod tests { let consuming_wallet = make_wallet("our consuming wallet"); let config = bc_from_wallets(consuming_wallet.clone(), make_wallet("our earning wallet")); let more_money_receivable_parameters_arc = Arc::new(Mutex::new(vec![])); - let payable_dao_mock = PayableDaoMock::new().non_pending_payables_result(vec![]); + let payable_dao_mock = PayableDaoMock::new().retrieve_payables_result(vec![]); let receivable_dao_mock = ReceivableDaoMock::new() .more_money_receivable_parameters(&more_money_receivable_parameters_arc); let subject = AccountantBuilder::default() @@ -2891,7 +4544,7 @@ mod tests { .is_empty()); TestLogHandler::new().exists_log_containing(&format!( - "WARN: Accountant: Declining to record a receivable against our wallet {} for service we provided", + "WARN: Accountant: Declining to record a receivable against our wallet {} for services we provided", consuming_wallet, )); } @@ -2902,7 +4555,7 @@ mod tests { let earning_wallet = make_wallet("our earning wallet"); let config = bc_from_earning_wallet(earning_wallet.clone()); let more_money_receivable_parameters_arc = Arc::new(Mutex::new(vec![])); - let payable_dao_mock = PayableDaoMock::new().non_pending_payables_result(vec![]); + let payable_dao_mock = PayableDaoMock::new().retrieve_payables_result(vec![]); let receivable_dao_mock = ReceivableDaoMock::new() .more_money_receivable_parameters(&more_money_receivable_parameters_arc); let subject = AccountantBuilder::default() @@ -2936,7 +4589,7 @@ mod tests { .is_empty()); TestLogHandler::new().exists_log_containing(&format!( - "WARN: Accountant: Declining to record a receivable against our wallet {} for service we provided", + "WARN: Accountant: Declining to record a receivable against our wallet {} for services we provided", earning_wallet, )); } @@ -2947,7 +4600,7 @@ mod tests { let now = SystemTime::now(); let config = bc_from_earning_wallet(make_wallet("hi")); let more_money_receivable_parameters_arc = Arc::new(Mutex::new(vec![])); - let payable_dao_mock = PayableDaoMock::new().non_pending_payables_result(vec![]); + let payable_dao_mock = PayableDaoMock::new().retrieve_payables_result(vec![]); let receivable_dao_mock = ReceivableDaoMock::new() .more_money_receivable_parameters(&more_money_receivable_parameters_arc) .more_money_receivable_result(Ok(())); @@ -2982,10 +4635,6 @@ mod tests { more_money_receivable_parameters[0], (now, make_wallet("booga"), (1 * 42) + (1234 * 24)) ); - TestLogHandler::new().exists_log_containing(&format!( - "DEBUG: Accountant: Charging exit service for 1234 bytes to wallet {}", - paying_wallet - )); } #[test] @@ -2994,7 +4643,7 @@ mod tests { let consuming_wallet = make_wallet("my consuming wallet"); let config = bc_from_wallets(consuming_wallet.clone(), make_wallet("my earning wallet")); let more_money_receivable_parameters_arc = Arc::new(Mutex::new(vec![])); - let payable_dao_mock = PayableDaoMock::new().non_pending_payables_result(vec![]); + let payable_dao_mock = PayableDaoMock::new().retrieve_payables_result(vec![]); let receivable_dao_mock = ReceivableDaoMock::new() .more_money_receivable_parameters(&more_money_receivable_parameters_arc); let subject = AccountantBuilder::default() @@ -3028,7 +4677,7 @@ mod tests { .is_empty()); TestLogHandler::new().exists_log_containing(&format!( - "WARN: Accountant: Declining to record a receivable against our wallet {} for service we provided", + "WARN: Accountant: Declining to record a receivable against our wallet {} for services we provided", consuming_wallet )); } @@ -3039,7 +4688,7 @@ mod tests { let earning_wallet = make_wallet("my earning wallet"); let config = bc_from_earning_wallet(earning_wallet.clone()); let more_money_receivable_parameters_arc = Arc::new(Mutex::new(vec![])); - let payable_dao_mock = PayableDaoMock::new().non_pending_payables_result(vec![]); + let payable_dao_mock = PayableDaoMock::new().retrieve_payables_result(vec![]); let receivable_dao_mock = ReceivableDaoMock::new() .more_money_receivable_parameters(&more_money_receivable_parameters_arc); let subject = AccountantBuilder::default() @@ -3073,7 +4722,7 @@ mod tests { .is_empty()); TestLogHandler::new().exists_log_containing(&format!( - "WARN: Accountant: Declining to record a receivable against our wallet {} for service we provided", + "WARN: Accountant: Declining to record a receivable against our wallet {} for services we provided", earning_wallet, )); } @@ -3081,7 +4730,7 @@ mod tests { #[test] fn report_services_consumed_message_is_received() { init_test_logging(); - let config = make_bc_with_defaults(); + let config = make_bc_with_defaults(TEST_DEFAULT_CHAIN); let more_money_payable_params_arc = Arc::new(Mutex::new(vec![])); let payable_dao_mock = PayableDaoMock::new() .more_money_payable_params(more_money_payable_params_arc.clone()) @@ -3152,20 +4801,6 @@ mod tests { ) ] ); - let test_log_handler = TestLogHandler::new(); - - test_log_handler.exists_log_containing(&format!( - "DEBUG: Accountant: MsgId 123: Accruing debt to {} for consuming 1200 exited bytes", - earning_wallet_exit - )); - test_log_handler.exists_log_containing(&format!( - "DEBUG: Accountant: MsgId 123: Accruing debt to {} for consuming 3456 routed bytes", - earning_wallet_routing_1 - )); - test_log_handler.exists_log_containing(&format!( - "DEBUG: Accountant: MsgId 123: Accruing debt to {} for consuming 3456 routed bytes", - earning_wallet_routing_2 - )); } fn assert_that_we_do_not_charge_our_own_wallet_for_consumed_services( @@ -3174,7 +4809,7 @@ mod tests { ) -> Arc>> { let more_money_payable_parameters_arc = Arc::new(Mutex::new(vec![])); let payable_dao_mock = PayableDaoMock::new() - .non_pending_payables_result(vec![]) + .retrieve_payables_result(vec![]) .more_money_payable_result(Ok(())) .more_money_payable_params(more_money_payable_parameters_arc.clone()); let subject = AccountantBuilder::default() @@ -3342,8 +4977,8 @@ mod tests { #[test] #[should_panic( - expected = "Recording services provided for 0x000000000000000000000000000000626f6f6761 \ - but has hit fatal database error: RusqliteError(\"we cannot help ourselves; this is baaad\")" + expected = "Was recording services provided for 0x000000000000000000000000000000626f6f6761 \ + but hit a fatal database error: RusqliteError(\"we cannot help ourselves; this is baaad\")" )] fn record_service_provided_panics_on_fatal_errors() { init_test_logging(); @@ -3423,522 +5058,806 @@ mod tests { expected = "panic message (processed with: node_lib::sub_lib::utils::crash_request_analyzer)" )] fn accountant_can_be_crashed_properly_but_not_improperly() { - let mut config = make_bc_with_defaults(); + let mut config = make_bc_with_defaults(TEST_DEFAULT_CHAIN); config.crash_point = CrashPoint::Message; let accountant = AccountantBuilder::default() .bootstrapper_config(config) .build(); - prove_that_crash_request_handler_is_hooked_up(accountant, CRASH_KEY); + prove_that_crash_request_handler_is_hooked_up(accountant, CRASH_KEY); + } + + #[test] + fn accountant_processes_sent_payables_and_schedules_pending_payable_scanner() { + // let get_tx_identifiers_params_arc = Arc::new(Mutex::new(vec![])); + let pending_payable_notify_later_params_arc = Arc::new(Mutex::new(vec![])); + let inserted_new_records_params_arc = Arc::new(Mutex::new(vec![])); + let expected_hash = H256::from("transaction_hash".keccak256()); + let payable_dao = PayableDaoMock::new(); + let sent_payable_dao = SentPayableDaoMock::new() + .insert_new_records_params(&inserted_new_records_params_arc) + .insert_new_records_result(Ok(())); + // let expected_rowid = 45623; + // let sent_payable_dao = SentPayableDaoMock::default() + // .get_tx_identifiers_params(&get_tx_identifiers_params_arc) + // .get_tx_identifiers_result(hashmap! (expected_hash => expected_rowid)); + let system = + System::new("accountant_processes_sent_payables_and_schedules_pending_payable_scanner"); + let mut subject = AccountantBuilder::default() + .bootstrapper_config(bc_from_earning_wallet(make_wallet("some_wallet_address"))) + .payable_daos(vec![ForPayableScanner(payable_dao)]) + .sent_payable_daos(vec![ForPayableScanner(sent_payable_dao)]) + .build(); + let pending_payable_interval = Duration::from_millis(55); + subject.scan_schedulers.pending_payable.interval = pending_payable_interval; + subject.scan_schedulers.pending_payable.handle = Box::new( + NotifyLaterHandleMock::default() + .notify_later_params(&pending_payable_notify_later_params_arc), + ); + subject.scan_schedulers.payable.new_payable_notify = + Box::new(NotifyHandleMock::default().panic_on_schedule_attempt()); + subject.scan_schedulers.payable.new_payable_notify_later = + Box::new(NotifyLaterHandleMock::default().panic_on_schedule_attempt()); + subject.scan_schedulers.payable.retry_payable_notify_later = + Box::new(NotifyLaterHandleMock::default().panic_on_schedule_attempt()); + let expected_tx = TxBuilder::default().hash(expected_hash.clone()).build(); + let sent_payable = SentPayables { + payment_procedure_result: Ok(BatchResults { + sent_txs: vec![expected_tx.clone()], + failed_txs: vec![], + }), + payable_scan_type: PayableScanType::New, + response_skeleton_opt: None, + }; + let addr = subject.start(); + + addr.try_send(sent_payable).expect("unexpected actix error"); + + System::current().stop(); + system.run(); + let inserted_new_records_params = inserted_new_records_params_arc.lock().unwrap(); + assert_eq!( + inserted_new_records_params[0], + BTreeSet::from([expected_tx]) + ); + let pending_payable_notify_later_params = + pending_payable_notify_later_params_arc.lock().unwrap(); + assert_eq!( + *pending_payable_notify_later_params, + vec![(ScanForPendingPayables::default(), pending_payable_interval)] + ); + } + + #[test] + fn accountant_finishes_processing_of_retry_payables_and_schedules_pending_payable_scanner() { + let pending_payable_notify_later_params_arc = Arc::new(Mutex::new(vec![])); + let inserted_new_records_params_arc = Arc::new(Mutex::new(vec![])); + let expected_hash = H256::from("transaction_hash".keccak256()); + let payable_dao = PayableDaoMock::new(); + let sent_payable_dao = SentPayableDaoMock::new() + .insert_new_records_params(&inserted_new_records_params_arc) + .insert_new_records_result(Ok(())); + let failed_payble_dao = FailedPayableDaoMock::new().retrieve_txs_result(BTreeSet::new()); + let system = System::new( + "accountant_finishes_processing_of_retry_payables_and_schedules_pending_payable_scanner", + ); + let mut subject = AccountantBuilder::default() + .bootstrapper_config(bc_from_earning_wallet(make_wallet("some_wallet_address"))) + .payable_daos(vec![ForPayableScanner(payable_dao)]) + .failed_payable_daos(vec![ForPayableScanner(failed_payble_dao)]) + .sent_payable_daos(vec![ForPayableScanner(sent_payable_dao)]) + .build(); + let pending_payable_interval = Duration::from_millis(55); + subject.scan_schedulers.pending_payable.interval = pending_payable_interval; + subject.scan_schedulers.pending_payable.handle = Box::new( + NotifyLaterHandleMock::default() + .notify_later_params(&pending_payable_notify_later_params_arc), + ); + subject.scan_schedulers.payable.new_payable_notify = + Box::new(NotifyHandleMock::default().panic_on_schedule_attempt()); + subject.scan_schedulers.payable.new_payable_notify_later = + Box::new(NotifyLaterHandleMock::default().panic_on_schedule_attempt()); + subject.scan_schedulers.payable.retry_payable_notify_later = + Box::new(NotifyLaterHandleMock::default().panic_on_schedule_attempt()); + let expected_tx = TxBuilder::default().hash(expected_hash.clone()).build(); + let sent_payable = SentPayables { + payment_procedure_result: Ok(BatchResults { + sent_txs: vec![expected_tx.clone()], + failed_txs: vec![], + }), + payable_scan_type: PayableScanType::Retry, + response_skeleton_opt: None, + }; + let addr = subject.start(); + + addr.try_send(sent_payable).expect("unexpected actix error"); + + System::current().stop(); + system.run(); + let inserted_new_records_params = inserted_new_records_params_arc.lock().unwrap(); + assert_eq!( + inserted_new_records_params[0], + BTreeSet::from([expected_tx]) + ); + let pending_payable_notify_later_params = + pending_payable_notify_later_params_arc.lock().unwrap(); + assert_eq!( + *pending_payable_notify_later_params, + vec![(ScanForPendingPayables::default(), pending_payable_interval)] + ); + } + + #[test] + fn retry_payable_scan_is_requested_to_be_repeated() { + init_test_logging(); + let test_name = "retry_payable_scan_is_requested_to_be_repeated"; + let finish_scan_params_arc = Arc::new(Mutex::new(vec![])); + let retry_payable_notify_later_params_arc = Arc::new(Mutex::new(vec![])); + let system = System::new(test_name); + let consuming_wallet = make_paying_wallet(b"paying wallet"); + let mut subject = AccountantBuilder::default() + .consuming_wallet(consuming_wallet.clone()) + .logger(Logger::new(test_name)) + .build(); + subject + .scanners + .replace_scanner(ScannerReplacement::Payable(ReplacementType::Mock( + ScannerMock::default() + .finish_scan_params(&finish_scan_params_arc) + .finish_scan_result(PayableScanResult { + ui_response_opt: None, + result: NextScanToRun::RetryPayableScan, + }), + ))); + subject.scan_schedulers.payable.retry_payable_notify_later = Box::new( + NotifyLaterHandleMock::default() + .notify_later_params(&retry_payable_notify_later_params_arc), + ); + subject.scan_schedulers.payable.new_payable_notify = + Box::new(NotifyHandleMock::default().panic_on_schedule_attempt()); + subject.scan_schedulers.payable.new_payable_notify_later = + Box::new(NotifyLaterHandleMock::default().panic_on_schedule_attempt()); + subject.scan_schedulers.pending_payable.handle = + Box::new(NotifyLaterHandleMock::default().panic_on_schedule_attempt()); + let sent_payable = SentPayables { + payment_procedure_result: Ok(BatchResults { + sent_txs: vec![], + failed_txs: vec![make_failed_tx(1), make_failed_tx(2)], + }), + payable_scan_type: PayableScanType::New, + response_skeleton_opt: None, + }; + let addr = subject.start(); + + addr.try_send(sent_payable.clone()) + .expect("unexpected actix error"); + + System::current().stop(); + assert_eq!(system.run(), 0); + let mut finish_scan_params = finish_scan_params_arc.lock().unwrap(); + let (actual_sent_payable, logger) = finish_scan_params.remove(0); + assert_eq!(actual_sent_payable, sent_payable,); + assert_using_the_same_logger(&logger, test_name, None); + let mut payable_notify_params = retry_payable_notify_later_params_arc.lock().unwrap(); + let (scheduled_msg, duration) = payable_notify_params.remove(0); + assert_eq!(scheduled_msg, ScanForRetryPayables::default()); + assert_eq!(duration, Duration::from_secs(5 * 60)); + assert!( + payable_notify_params.is_empty(), + "Should be empty but {:?}", + payable_notify_params + ); + } + + #[test] + fn accountant_in_automatic_mode_schedules_tx_retry_as_some_pending_payables_have_not_completed() + { + init_test_logging(); + let test_name = + "accountant_in_automatic_mode_schedules_tx_retry_as_some_pending_payables_have_not_completed"; + let finish_scan_params_arc = Arc::new(Mutex::new(vec![])); + let retry_payable_notify_later_params_arc = Arc::new(Mutex::new(vec![])); + let mut subject = AccountantBuilder::default() + .logger(Logger::new(test_name)) + .build(); + let pending_payable_scanner = ScannerMock::new() + .finish_scan_params(&finish_scan_params_arc) + .finish_scan_result(PendingPayableScanResult::PaymentRetryRequired(None)); + subject + .scanners + .replace_scanner(ScannerReplacement::PendingPayable(ReplacementType::Mock( + pending_payable_scanner, + ))); + subject.scan_schedulers.payable.new_payable_notify = + Box::new(NotifyHandleMock::default().panic_on_schedule_attempt()); + subject.scan_schedulers.payable.new_payable_notify_later = + Box::new(NotifyLaterHandleMock::default().panic_on_schedule_attempt()); + subject.scan_schedulers.pending_payable.handle = + Box::new(NotifyLaterHandleMock::default().panic_on_schedule_attempt()); + subject.scan_schedulers.payable.retry_payable_notify_later = Box::new( + NotifyLaterHandleMock::default() + .notify_later_params(&retry_payable_notify_later_params_arc), + ); + let system = System::new(test_name); + let (mut msg, _) = make_tx_receipts_msg(vec![ + SeedsToMakeUpPayableWithStatus { + tx_hash: TxHashByTable::SentPayable(make_tx_hash(123)), + status: StatusReadFromReceiptCheck::Pending, + }, + SeedsToMakeUpPayableWithStatus { + tx_hash: TxHashByTable::FailedPayable(make_tx_hash(456)), + status: StatusReadFromReceiptCheck::Reverted, + }, + ]); + msg.response_skeleton_opt = None; + let subject_addr = subject.start(); + + subject_addr.try_send(msg.clone()).unwrap(); + + System::current().stop(); + system.run(); + let mut finish_scan_params = finish_scan_params_arc.lock().unwrap(); + let (msg_actual, logger) = finish_scan_params.remove(0); + assert_eq!(msg_actual, msg); + let retry_payable_notify_params = retry_payable_notify_later_params_arc.lock().unwrap(); + assert_eq!( + *retry_payable_notify_params, + vec![( + ScanForRetryPayables { + response_skeleton_opt: None + }, + Duration::from_secs(5 * 60) + )] + ); + assert_using_the_same_logger(&logger, test_name, None) + } + + #[test] + fn accountant_reschedules_pending_p_scanner_in_automatic_mode_after_receipt_fetching_failed() { + init_test_logging(); + let test_name = + "accountant_reschedules_pending_p_scanner_in_automatic_mode_after_receipt_fetching_failed"; + let finish_scan_params_arc = Arc::new(Mutex::new(vec![])); + let pending_payable_notify_later_params_arc = Arc::new(Mutex::new(vec![])); + let mut subject = AccountantBuilder::default() + .logger(Logger::new(test_name)) + .build(); + let pending_payable_scanner = ScannerMock::new() + .finish_scan_params(&finish_scan_params_arc) + .finish_scan_result(PendingPayableScanResult::ProcedureShouldBeRepeated(None)); + subject + .scanners + .replace_scanner(ScannerReplacement::PendingPayable(ReplacementType::Mock( + pending_payable_scanner, + ))); + subject.scan_schedulers.payable.retry_payable_notify_later = + Box::new(NotifyLaterHandleMock::default().panic_on_schedule_attempt()); + subject.scan_schedulers.payable.new_payable_notify = + Box::new(NotifyHandleMock::default().panic_on_schedule_attempt()); + subject.scan_schedulers.payable.new_payable_notify_later = + Box::new(NotifyLaterHandleMock::default().panic_on_schedule_attempt()); + let interval = Duration::from_secs(20); + subject.scan_schedulers.pending_payable.interval = interval; + subject.scan_schedulers.pending_payable.handle = Box::new( + NotifyLaterHandleMock::default() + .notify_later_params(&pending_payable_notify_later_params_arc), + ); + let system = System::new(test_name); + let msg = TxReceiptsMessage { + results: btreemap!(TxHashByTable::SentPayable(make_tx_hash(123)) => Err(AppRpcError::Remote(RemoteError::Unreachable))), + response_skeleton_opt: None, + }; + let subject_addr = subject.start(); + + subject_addr.try_send(msg.clone()).unwrap(); + + System::current().stop(); + system.run(); + let mut finish_scan_params = finish_scan_params_arc.lock().unwrap(); + let (msg_actual, logger) = finish_scan_params.remove(0); + assert_eq!(msg_actual, msg); + let pending_payable_notify_later_params = + pending_payable_notify_later_params_arc.lock().unwrap(); + assert_eq!( + *pending_payable_notify_later_params, + vec![( + ScanForPendingPayables { + response_skeleton_opt: None + }, + interval + )] + ); + assert_using_the_same_logger(&logger, test_name, None) + } + + #[test] + fn accountant_reschedules_pending_p_scanner_in_manual_mode_after_receipt_fetching_failed() { + init_test_logging(); + let test_name = + "accountant_reschedules_pending_p_scanner_in_manual_mode_after_receipt_fetching_failed"; + let finish_scan_params_arc = Arc::new(Mutex::new(vec![])); + let (ui_gateway, _, ui_gateway_recording_arc) = make_recorder(); + let ui_gateway = + ui_gateway.system_stop_conditions(match_lazily_every_type_id!(NodeToUiMessage)); + let expected_node_to_ui_msg = NodeToUiMessage { + target: MessageTarget::ClientId(1234), + body: UiScanResponse {}.tmb(54), + }; + let mut subject = AccountantBuilder::default() + .logger(Logger::new(test_name)) + .build(); + let pending_payable_scanner = ScannerMock::new() + .finish_scan_params(&finish_scan_params_arc) + .finish_scan_result(PendingPayableScanResult::ProcedureShouldBeRepeated(Some( + expected_node_to_ui_msg.clone(), + ))); + subject + .scanners + .replace_scanner(ScannerReplacement::PendingPayable(ReplacementType::Mock( + pending_payable_scanner, + ))); + subject.scan_schedulers.payable.retry_payable_notify_later = + Box::new(NotifyLaterHandleMock::default().panic_on_schedule_attempt()); + subject.scan_schedulers.payable.new_payable_notify = + Box::new(NotifyHandleMock::default().panic_on_schedule_attempt()); + subject.scan_schedulers.payable.new_payable_notify_later = + Box::new(NotifyLaterHandleMock::default().panic_on_schedule_attempt()); + let interval = Duration::from_secs(20); + subject.scan_schedulers.pending_payable.interval = interval; + subject.scan_schedulers.pending_payable.handle = + Box::new(NotifyLaterHandleMock::default().panic_on_schedule_attempt()); + subject.ui_message_sub_opt = Some(ui_gateway.start().recipient()); + let system = System::new(test_name); + let response_skeleton = ResponseSkeleton { + client_id: 1234, + context_id: 54, + }; + let msg = TxReceiptsMessage { + results: btreemap!(TxHashByTable::SentPayable(make_tx_hash(123)) => Err(AppRpcError::Remote(RemoteError::Unreachable))), + response_skeleton_opt: Some(response_skeleton), + }; + let subject_addr = subject.start(); + + subject_addr.try_send(msg.clone()).unwrap(); + + system.run(); + let mut finish_scan_params = finish_scan_params_arc.lock().unwrap(); + let (msg_actual, logger) = finish_scan_params.remove(0); + assert_eq!(msg_actual, msg); + let ui_gateway_recording = ui_gateway_recording_arc.lock().unwrap(); + let node_to_ui_msg = ui_gateway_recording.get_record::(0); + assert_eq!(node_to_ui_msg, &expected_node_to_ui_msg); + assert_eq!(ui_gateway_recording.len(), 1); + assert_using_the_same_logger(&logger, test_name, None); + TestLogHandler::new().exists_log_containing(&format!( + "INFO: {test_name}: Re-running the pending payable scan is recommended, as some parts \ + did not finish last time." + )); + } + + #[test] + fn accountant_in_manual_mode_schedules_tx_retry_as_some_pending_payables_have_not_completed() { + init_test_logging(); + let test_name = + "accountant_in_manual_mode_schedules_tx_retry_as_some_pending_payables_have_not_completed"; + let retry_payable_notify_params_arc = Arc::new(Mutex::new(vec![])); + let finish_scan_params_arc = Arc::new(Mutex::new(vec![])); + let mut subject = AccountantBuilder::default() + .logger(Logger::new(test_name)) + .build(); + let response_skeleton = ResponseSkeleton { + client_id: 123, + context_id: 333, + }; + let pending_payable_scanner = ScannerMock::new() + .finish_scan_params(&finish_scan_params_arc) + .finish_scan_result(PendingPayableScanResult::PaymentRetryRequired(Some( + response_skeleton, + ))); + subject + .scanners + .replace_scanner(ScannerReplacement::PendingPayable(ReplacementType::Mock( + pending_payable_scanner, + ))); + subject.scan_schedulers.payable.retry_payable_notify_later = Box::new( + NotifyLaterHandleMock::default().notify_later_params(&retry_payable_notify_params_arc), + ); + subject.scan_schedulers.payable.new_payable_notify = + Box::new(NotifyHandleMock::default().panic_on_schedule_attempt()); + subject.scan_schedulers.payable.new_payable_notify_later = + Box::new(NotifyLaterHandleMock::default().panic_on_schedule_attempt()); + subject.scan_schedulers.pending_payable.handle = + Box::new(NotifyLaterHandleMock::default().panic_on_schedule_attempt()); + let system = System::new(test_name); + let msg = TxReceiptsMessage { + results: btreemap!(TxHashByTable::SentPayable(make_tx_hash(123)) => Err(AppRpcError::Remote(RemoteError::Unreachable))), + response_skeleton_opt: Some(response_skeleton), + }; + let subject_addr = subject.start(); + + subject_addr.try_send(msg.clone()).unwrap(); + + System::current().stop(); + system.run(); + let mut finish_scan_params = finish_scan_params_arc.lock().unwrap(); + let (msg_actual, logger) = finish_scan_params.remove(0); + assert_eq!(msg_actual, msg); + let retry_payable_notify_params = retry_payable_notify_params_arc.lock().unwrap(); + assert_eq!( + *retry_payable_notify_params, + vec![( + ScanForRetryPayables { + response_skeleton_opt: Some(response_skeleton) + }, + Duration::from_secs(5 * 60) + )] + ); + assert_using_the_same_logger(&logger, test_name, None) } #[test] - fn pending_transaction_is_registered_and_monitored_until_it_gets_confirmed_or_canceled() { + fn accountant_confirms_all_pending_txs_and_schedules_new_payable_scanner_timely() { init_test_logging(); - let port = find_free_port(); - let pending_tx_hash_1 = - H256::from_str("e66814b2812a80d619813f51aa999c0df84eb79d10f4923b2b7667b30d6b33d3") - .unwrap(); - let pending_tx_hash_2 = - H256::from_str("0288ef000581b3bca8a2017eac9aea696366f8f1b7437f18d1aad57bccb7032c") - .unwrap(); - let _blockchain_client_server = MBCSBuilder::new(port) - // Blockchain Agent Gas Price - .ok_response("0x3B9ACA00".to_string(), 0) // 1000000000 - // Blockchain Agent transaction fee balance - .ok_response("0xFFF0".to_string(), 0) // 65520 - // Blockchain Agent masq balance - .ok_response( - "0x000000000000000000000000000000000000000000000000000000000000FFFF".to_string(), - 0, - ) - // Submit payments to blockchain - .ok_response("0xFFF0".to_string(), 1) - .begin_batch() - .raw_response( - ReceiptResponseBuilder::default() - .transaction_hash(pending_tx_hash_1) - .build(), - ) - .raw_response( - ReceiptResponseBuilder::default() - .transaction_hash(pending_tx_hash_2) - .build(), - ) - .end_batch() - // Round 1 - handle_request_transaction_receipts - .begin_batch() - .raw_response(r#"{ "jsonrpc": "2.0", "id": 1, "result": null }"#.to_string()) // Null response - .raw_response( - ReceiptResponseBuilder::default() - .transaction_hash(pending_tx_hash_2) - .build(), - ) - .end_batch() - // Round 2 - handle_request_transaction_receipts - .begin_batch() - .raw_response( - ReceiptResponseBuilder::default() - .transaction_hash(pending_tx_hash_1) - .build(), - ) - .raw_response( - ReceiptResponseBuilder::default() - .transaction_hash(pending_tx_hash_2) - .build(), - ) - .end_batch() - // Round 3 - handle_request_transaction_receipts - .begin_batch() - .raw_response( - ReceiptResponseBuilder::default() - .transaction_hash(pending_tx_hash_1) - .status(U64::from(0)) - .build(), - ) - .raw_response( - ReceiptResponseBuilder::default() - .transaction_hash(pending_tx_hash_2) - .build(), - ) - .end_batch() - // Round 4 - handle_request_transaction_receipts - .begin_batch() - .raw_response( - ReceiptResponseBuilder::default() - .transaction_hash(pending_tx_hash_2) - .status(U64::from(1)) - .block_number(U64::from(1234)) - .block_hash(Default::default()) - .build(), - ) - .end_batch() - .start(); - let non_pending_payables_params_arc = Arc::new(Mutex::new(vec![])); - let mark_pending_payable_params_arc = Arc::new(Mutex::new(vec![])); - let return_all_errorless_fingerprints_params_arc = Arc::new(Mutex::new(vec![])); - let update_fingerprint_params_arc = Arc::new(Mutex::new(vec![])); - let mark_failure_params_arc = Arc::new(Mutex::new(vec![])); - let transactions_confirmed_params_arc = Arc::new(Mutex::new(vec![])); - let delete_record_params_arc = Arc::new(Mutex::new(vec![])); - let notify_later_scan_for_pending_payable_params_arc = Arc::new(Mutex::new(vec![])); - let notify_later_scan_for_pending_payable_arc_cloned = - notify_later_scan_for_pending_payable_params_arc.clone(); // because it moves into a closure - let rowid_for_account_1 = 3; - let rowid_for_account_2 = 5; - let now = SystemTime::now(); - let past_payable_timestamp_1 = now.sub(Duration::from_secs( - (DEFAULT_PAYMENT_THRESHOLDS.maturity_threshold_sec + 555) as u64, - )); - let past_payable_timestamp_2 = now.sub(Duration::from_secs( - (DEFAULT_PAYMENT_THRESHOLDS.maturity_threshold_sec + 50) as u64, - )); - let this_payable_timestamp_1 = now; - let this_payable_timestamp_2 = now.add(Duration::from_millis(50)); - let payable_account_balance_1 = - gwei_to_wei(DEFAULT_PAYMENT_THRESHOLDS.debt_threshold_gwei + 10); - let payable_account_balance_2 = - gwei_to_wei(DEFAULT_PAYMENT_THRESHOLDS.debt_threshold_gwei + 666); - let wallet_account_1 = make_wallet("creditor1"); - let wallet_account_2 = make_wallet("creditor2"); - let blockchain_interface = make_blockchain_interface_web3(port); - let consuming_wallet = make_paying_wallet(b"wallet"); - let system = System::new("pending_transaction"); - let persistent_config_id_stamp = ArbitraryIdStamp::new(); - let persistent_config = PersistentConfigurationMock::default() - .set_arbitrary_id_stamp(persistent_config_id_stamp); - let blockchain_bridge = BlockchainBridge::new( - Box::new(blockchain_interface), - Arc::new(Mutex::new(persistent_config)), - false, + let test_name = + "accountant_confirms_all_pending_txs_and_schedules_new_payable_scanner_timely"; + let finish_scan_params_arc = Arc::new(Mutex::new(vec![])); + let time_until_next_scan_params_arc = Arc::new(Mutex::new(vec![])); + let new_payable_notify_later_arc = Arc::new(Mutex::new(vec![])); + let new_payable_notify_arc = Arc::new(Mutex::new(vec![])); + let system = System::new("new_payable_scanner_timely"); + let mut subject = AccountantBuilder::default() + .logger(Logger::new(test_name)) + .build(); + let pending_payable_scanner = ScannerMock::new() + .finish_scan_params(&finish_scan_params_arc) + .finish_scan_result(PendingPayableScanResult::NoPendingPayablesLeft(None)); + subject + .scanners + .replace_scanner(ScannerReplacement::PendingPayable(ReplacementType::Mock( + pending_payable_scanner, + ))); + let expected_computed_interval = Duration::from_secs(3); + let interval_computer = NewPayableScanIntervalComputerMock::default() + .time_until_next_scan_params(&time_until_next_scan_params_arc) + // This determines the test + .time_until_next_scan_result(ScanTiming::WaitFor(expected_computed_interval)); + subject.scan_schedulers.payable.interval_computer = Box::new(interval_computer); + subject.scan_schedulers.payable.new_payable_notify_later = Box::new( + NotifyLaterHandleMock::default().notify_later_params(&new_payable_notify_later_arc), ); - let account_1 = PayableAccount { - wallet: wallet_account_1.clone(), - balance_wei: payable_account_balance_1, - last_paid_timestamp: past_payable_timestamp_1, - pending_payable_opt: None, - }; - let account_2 = PayableAccount { - wallet: wallet_account_2.clone(), - balance_wei: payable_account_balance_2, - last_paid_timestamp: past_payable_timestamp_2, - pending_payable_opt: None, - }; - let pending_payable_scan_interval = 1000; // should be slightly less than 1/5 of the time until shutting the system - let payable_dao_for_payable_scanner = PayableDaoMock::new() - .non_pending_payables_params(&non_pending_payables_params_arc) - .non_pending_payables_result(vec![account_1, account_2]) - .mark_pending_payables_rowids_params(&mark_pending_payable_params_arc) - .mark_pending_payables_rowids_result(Ok(())); - let payable_dao_for_pending_payable_scanner = PayableDaoMock::new() - .transactions_confirmed_params(&transactions_confirmed_params_arc) - .transactions_confirmed_result(Ok(())); - let mut bootstrapper_config = bc_from_earning_wallet(make_wallet("some_wallet_address")); - bootstrapper_config.scan_intervals_opt = Some(ScanIntervals { - payable_scan_interval: Duration::from_secs(1_000_000), // we don't care about this scan - receivable_scan_interval: Duration::from_secs(1_000_000), // we don't care about this scan - pending_payable_scan_interval: Duration::from_millis(pending_payable_scan_interval), - }); - let fingerprint_1_first_round = PendingPayableFingerprint { - rowid: rowid_for_account_1, - timestamp: this_payable_timestamp_1, - hash: pending_tx_hash_1, - attempt: 1, - amount: payable_account_balance_1, - process_error: None, - }; - let fingerprint_2_first_round = PendingPayableFingerprint { - rowid: rowid_for_account_2, - timestamp: this_payable_timestamp_2, - hash: pending_tx_hash_2, - attempt: 1, - amount: payable_account_balance_2, - process_error: None, - }; - let fingerprint_1_second_round = PendingPayableFingerprint { - attempt: 2, - ..fingerprint_1_first_round.clone() - }; - let fingerprint_2_second_round = PendingPayableFingerprint { - attempt: 2, - ..fingerprint_2_first_round.clone() - }; - let fingerprint_1_third_round = PendingPayableFingerprint { - attempt: 3, - ..fingerprint_1_first_round.clone() - }; - let fingerprint_2_third_round = PendingPayableFingerprint { - attempt: 3, - ..fingerprint_2_first_round.clone() - }; - let fingerprint_2_fourth_round = PendingPayableFingerprint { - attempt: 4, - ..fingerprint_2_first_round.clone() - }; - let pending_payable_dao_for_payable_scanner = PendingPayableDaoMock::default() - .fingerprints_rowids_result(TransactionHashes { - rowid_results: vec![ - (rowid_for_account_1, pending_tx_hash_1), - (rowid_for_account_2, pending_tx_hash_2), - ], - no_rowid_results: vec![], - }) - .fingerprints_rowids_result(TransactionHashes { - rowid_results: vec![ - (rowid_for_account_1, pending_tx_hash_1), - (rowid_for_account_2, pending_tx_hash_2), - ], - no_rowid_results: vec![], - }); - let mut pending_payable_dao_for_pending_payable_scanner = PendingPayableDaoMock::new() - .return_all_errorless_fingerprints_params(&return_all_errorless_fingerprints_params_arc) - .return_all_errorless_fingerprints_result(vec![]) - .return_all_errorless_fingerprints_result(vec![ - fingerprint_1_first_round, - fingerprint_2_first_round, - ]) - .return_all_errorless_fingerprints_result(vec![ - fingerprint_1_second_round, - fingerprint_2_second_round, - ]) - .return_all_errorless_fingerprints_result(vec![ - fingerprint_1_third_round, - fingerprint_2_third_round, - ]) - .return_all_errorless_fingerprints_result(vec![fingerprint_2_fourth_round.clone()]) - .fingerprints_rowids_result(TransactionHashes { - rowid_results: vec![ - (rowid_for_account_1, pending_tx_hash_1), - (rowid_for_account_2, pending_tx_hash_2), - ], - no_rowid_results: vec![], - }) - .increment_scan_attempts_params(&update_fingerprint_params_arc) - .increment_scan_attempts_result(Ok(())) - .increment_scan_attempts_result(Ok(())) - .increment_scan_attempts_result(Ok(())) - .mark_failures_params(&mark_failure_params_arc) - // we don't have a better solution yet, so we mark this down - .mark_failures_result(Ok(())) - .delete_fingerprints_params(&delete_record_params_arc) - // this is used during confirmation of the successful one - .delete_fingerprints_result(Ok(())); - pending_payable_dao_for_pending_payable_scanner - .have_return_all_errorless_fingerprints_shut_down_the_system = true; - let pending_payable_dao_for_accountant = - PendingPayableDaoMock::new().insert_fingerprints_result(Ok(())); - let accountant_addr = Arbiter::builder() - .stop_system_on_panic(true) - .start(move |_| { - let mut subject = AccountantBuilder::default() - .consuming_wallet(consuming_wallet) - .bootstrapper_config(bootstrapper_config) - .payable_daos(vec![ - ForPayableScanner(payable_dao_for_payable_scanner), - ForPendingPayableScanner(payable_dao_for_pending_payable_scanner), - ]) - .pending_payable_daos(vec![ - ForAccountantBody(pending_payable_dao_for_accountant), - ForPayableScanner(pending_payable_dao_for_payable_scanner), - ForPendingPayableScanner(pending_payable_dao_for_pending_payable_scanner), - ]) - .build(); - subject.scanners.receivable = Box::new(NullScanner::new()); - let notify_later_half_mock = NotifyLaterHandleMock::default() - .notify_later_params(¬ify_later_scan_for_pending_payable_arc_cloned) - .capture_msg_and_let_it_fly_on(); - subject.scan_schedulers.update_scheduler( - ScanType::PendingPayables, - Some(Box::new(notify_later_half_mock)), - None, - ); - subject - }); - let mut peer_actors = peer_actors_builder().build(); - let accountant_subs = Accountant::make_subs_from(&accountant_addr); - peer_actors.accountant = accountant_subs.clone(); - let blockchain_bridge_addr = blockchain_bridge.start(); - let blockchain_bridge_subs = BlockchainBridge::make_subs_from(&blockchain_bridge_addr); - peer_actors.blockchain_bridge = blockchain_bridge_subs.clone(); - send_bind_message!(accountant_subs, peer_actors); - send_bind_message!(blockchain_bridge_subs, peer_actors); + subject.scan_schedulers.payable.new_payable_notify = + Box::new(NotifyHandleMock::default().notify_params(&new_payable_notify_arc)); + let subject_addr = subject.start(); + let (msg, _) = make_tx_receipts_msg(vec![ + SeedsToMakeUpPayableWithStatus { + tx_hash: TxHashByTable::SentPayable(make_tx_hash(123)), + status: StatusReadFromReceiptCheck::Succeeded(TxBlock { + block_hash: make_tx_hash(123), + block_number: U64::from(100), + }), + }, + SeedsToMakeUpPayableWithStatus { + tx_hash: TxHashByTable::FailedPayable(make_tx_hash(555)), + status: StatusReadFromReceiptCheck::Succeeded(TxBlock { + block_hash: make_tx_hash(234), + block_number: U64::from(200), + }), + }, + ]); - send_start_message!(accountant_subs); + subject_addr.try_send(msg.clone()).unwrap(); - assert_eq!(system.run(), 0); - let mut mark_pending_payable_params = mark_pending_payable_params_arc.lock().unwrap(); - let mut one_set_of_mark_pending_payable_params = mark_pending_payable_params.remove(0); - assert!(mark_pending_payable_params.is_empty()); - let first_payable = one_set_of_mark_pending_payable_params.remove(0); - assert_eq!(first_payable.0, wallet_account_1); - assert_eq!(first_payable.1, rowid_for_account_1); - let second_payable = one_set_of_mark_pending_payable_params.remove(0); + System::current().stop(); + system.run(); + let mut finish_scan_params = finish_scan_params_arc.lock().unwrap(); + let (captured_msg, logger) = finish_scan_params.remove(0); + assert_eq!(captured_msg, msg); + assert_using_the_same_logger(&logger, test_name, None); assert!( - one_set_of_mark_pending_payable_params.is_empty(), - "{:?}", - one_set_of_mark_pending_payable_params - ); - assert_eq!(second_payable.0, wallet_account_2); - assert_eq!(second_payable.1, rowid_for_account_2); - let return_all_errorless_fingerprints_params = - return_all_errorless_fingerprints_params_arc.lock().unwrap(); - // it varies with machines and sometimes we manage more cycles than necessary - assert!(return_all_errorless_fingerprints_params.len() >= 5); - let non_pending_payables_params = non_pending_payables_params_arc.lock().unwrap(); - assert_eq!(*non_pending_payables_params, vec![()]); // because we disabled further scanning for payables - let update_fingerprints_params = update_fingerprint_params_arc.lock().unwrap(); - assert_eq!( - *update_fingerprints_params, - vec![ - vec![rowid_for_account_1, rowid_for_account_2], - vec![rowid_for_account_1, rowid_for_account_2], - vec![rowid_for_account_2], - ] + finish_scan_params.is_empty(), + "Should be empty but {:?}", + finish_scan_params ); - let mark_failure_params = mark_failure_params_arc.lock().unwrap(); - assert_eq!(*mark_failure_params, vec![vec![rowid_for_account_1]]); - let delete_record_params = delete_record_params_arc.lock().unwrap(); - assert_eq!(*delete_record_params, vec![vec![rowid_for_account_2]]); - let transaction_confirmed_params = transactions_confirmed_params_arc.lock().unwrap(); + // Here, we see that the next payable scan is scheduled for the future, in the expected interval. + let new_payable_notify_later = new_payable_notify_later_arc.lock().unwrap(); assert_eq!( - *transaction_confirmed_params, - vec![vec![fingerprint_2_fourth_round.clone()]] + *new_payable_notify_later, + vec![(ScanForNewPayables::default(), expected_computed_interval)] ); - let expected_scan_pending_payable_msg_and_interval = ( - ScanForPendingPayables { - response_skeleton_opt: None, - }, - Duration::from_millis(pending_payable_scan_interval), + let new_payable_notify = new_payable_notify_arc.lock().unwrap(); + assert!( + new_payable_notify.is_empty(), + "should be empty but was: {:?}", + new_payable_notify + ) + } + + #[test] + fn accountant_confirms_payable_txs_and_schedules_the_delayed_new_payable_scanner_asap() { + init_test_logging(); + let test_name = + "accountant_confirms_payable_txs_and_schedules_the_delayed_new_payable_scanner_asap"; + let finish_scan_params_arc = Arc::new(Mutex::new(vec![])); + let time_until_next_scan_params_arc = Arc::new(Mutex::new(vec![])); + let new_payable_notify_later_arc = Arc::new(Mutex::new(vec![])); + let new_payable_notify_arc = Arc::new(Mutex::new(vec![])); + let mut subject = AccountantBuilder::default() + .logger(Logger::new(test_name)) + .build(); + let pending_payable_scanner = ScannerMock::new() + .finish_scan_params(&finish_scan_params_arc) + .finish_scan_result(PendingPayableScanResult::NoPendingPayablesLeft(None)); + subject + .scanners + .replace_scanner(ScannerReplacement::PendingPayable(ReplacementType::Mock( + pending_payable_scanner, + ))); + let interval_computer = NewPayableScanIntervalComputerMock::default() + .time_until_next_scan_params(&time_until_next_scan_params_arc) + // This determines the test + .time_until_next_scan_result(ScanTiming::ReadyNow); + subject.scan_schedulers.payable.interval_computer = Box::new(interval_computer); + subject.scan_schedulers.payable.new_payable_notify_later = Box::new( + NotifyLaterHandleMock::default().notify_later_params(&new_payable_notify_later_arc), ); - let mut notify_later_check_for_confirmation = - notify_later_scan_for_pending_payable_params_arc - .lock() - .unwrap(); - // it varies with machines and sometimes we manage more cycles than necessary - let vector_of_first_five_cycles = notify_later_check_for_confirmation - .drain(0..=4) - .collect_vec(); - assert_eq!( - vector_of_first_five_cycles, - vec![ - expected_scan_pending_payable_msg_and_interval.clone(), - expected_scan_pending_payable_msg_and_interval.clone(), - expected_scan_pending_payable_msg_and_interval.clone(), - expected_scan_pending_payable_msg_and_interval.clone(), - expected_scan_pending_payable_msg_and_interval, - ] + subject.scan_schedulers.payable.new_payable_notify = + Box::new(NotifyHandleMock::default().notify_params(&new_payable_notify_arc)); + let tx_block_1 = make_transaction_block(4567); + let tx_block_2 = make_transaction_block(1234); + let subject_addr = subject.start(); + let (msg, _) = make_tx_receipts_msg(vec![ + SeedsToMakeUpPayableWithStatus { + tx_hash: TxHashByTable::SentPayable(make_tx_hash(123)), + status: StatusReadFromReceiptCheck::Succeeded(tx_block_1), + }, + SeedsToMakeUpPayableWithStatus { + tx_hash: TxHashByTable::FailedPayable(make_tx_hash(456)), + status: StatusReadFromReceiptCheck::Succeeded(tx_block_2), + }, + ]); + + subject_addr.try_send(msg.clone()).unwrap(); + + let system = System::new(test_name); + System::current().stop(); + system.run(); + let mut finish_scan_params = finish_scan_params_arc.lock().unwrap(); + let (captured_msg, logger) = finish_scan_params.remove(0); + assert_eq!(captured_msg, msg); + assert_using_the_same_logger(&logger, test_name, None); + assert!( + finish_scan_params.is_empty(), + "Should be empty but {:?}", + finish_scan_params ); - let log_handler = TestLogHandler::new(); - log_handler.exists_log_containing( - "WARN: Accountant: Broken transactions 0xe66814b2812a80d619813f51aa999c0df84eb79d10f\ - 4923b2b7667b30d6b33d3 marked as an error. You should take over the care of those to make sure \ - your debts are going to be settled properly. At the moment, there is no automated process \ - fixing that without your assistance"); - log_handler.exists_log_matching("INFO: Accountant: Transaction 0x0288ef000581b3bca8a2017eac9\ - aea696366f8f1b7437f18d1aad57bccb7032c has been added to the blockchain; detected locally at \ - attempt 4 at \\d{2,}ms after its sending"); - log_handler.exists_log_containing( - "INFO: Accountant: Transactions 0x0288ef000581b3bca8a2017eac9aea696366f8f1b7437f18d1aad5\ - 7bccb7032c completed their confirmation process succeeding", + let time_until_next_scan_params = time_until_next_scan_params_arc.lock().unwrap(); + assert_eq!(*time_until_next_scan_params, vec![()]); + let new_payable_notify_later = new_payable_notify_later_arc.lock().unwrap(); + assert!( + new_payable_notify_later.is_empty(), + "should be empty but was: {:?}", + new_payable_notify_later ); + // As a proof, the handle for an immediate launch of the new payable scanner was used + let new_payable_notify = new_payable_notify_arc.lock().unwrap(); + assert_eq!(*new_payable_notify, vec![ScanForNewPayables::default()]); } #[test] - fn accountant_receives_reported_transaction_receipts_and_processes_them_all() { - let transactions_confirmed_params_arc = Arc::new(Mutex::new(vec![])); - let payable_dao = PayableDaoMock::default() - .transactions_confirmed_params(&transactions_confirmed_params_arc) - .transactions_confirmed_result(Ok(())); - let pending_payable_dao = - PendingPayableDaoMock::default().delete_fingerprints_result(Ok(())); - let subject = AccountantBuilder::default() - .payable_daos(vec![ForPendingPayableScanner(payable_dao)]) - .pending_payable_daos(vec![ForPendingPayableScanner(pending_payable_dao)]) + fn scheduler_for_new_payables_operates_with_proper_now_timestamp() { + let new_payable_notify_later_arc = Arc::new(Mutex::new(vec![])); + let test_name = "scheduler_for_new_payables_operates_with_proper_now_timestamp"; + let mut subject = AccountantBuilder::default() + .bootstrapper_config(make_bc_with_defaults(TEST_DEFAULT_CHAIN)) + .logger(Logger::new(test_name)) .build(); + let pending_payable_scanner = ScannerMock::new() + .finish_scan_result(PendingPayableScanResult::NoPendingPayablesLeft(None)); + subject + .scanners + .replace_scanner(ScannerReplacement::PendingPayable(ReplacementType::Mock( + pending_payable_scanner, + ))); + subject.scan_schedulers.payable.new_payable_notify_later = Box::new( + NotifyLaterHandleMock::default().notify_later_params(&new_payable_notify_later_arc), + ); + let default_scan_intervals = ScanIntervals::compute_default(TEST_DEFAULT_CHAIN); + let mut assertion_interval_computer = + NewPayableScanIntervalComputerReal::new(default_scan_intervals.payable_scan_interval); + { + subject + .scan_schedulers + .payable + .interval_computer + .reset_last_scan_timestamp(); + assertion_interval_computer.reset_last_scan_timestamp(); + } + let system = System::new(test_name); let subject_addr = subject.start(); - let transaction_hash_1 = make_tx_hash(4545); - let transaction_receipt_1 = TxReceipt { - transaction_hash: transaction_hash_1, - status: TxStatus::Succeeded(TransactionBlock { - block_hash: Default::default(), + let (msg, _) = make_tx_receipts_msg(vec![SeedsToMakeUpPayableWithStatus { + tx_hash: TxHashByTable::SentPayable(make_tx_hash(123)), + status: StatusReadFromReceiptCheck::Succeeded(TxBlock { + block_hash: make_tx_hash(123), block_number: U64::from(100), }), - }; - let fingerprint_1 = PendingPayableFingerprint { - rowid: 5, - timestamp: from_time_t(200_000_000), - hash: transaction_hash_1, - attempt: 2, - amount: 444, - process_error: None, - }; - let transaction_hash_2 = make_tx_hash(3333333); - let transaction_receipt_2 = TxReceipt { - transaction_hash: transaction_hash_2, - status: TxStatus::Succeeded(TransactionBlock { - block_hash: Default::default(), - block_number: U64::from(200), - }), - }; - let fingerprint_2 = PendingPayableFingerprint { - rowid: 10, - timestamp: from_time_t(199_780_000), - hash: Default::default(), - attempt: 15, - amount: 1212, - process_error: None, - }; - let msg = ReportTransactionReceipts { - fingerprints_with_receipts: vec![ - ( - TransactionReceiptResult::RpcResponse(transaction_receipt_1), - fingerprint_1.clone(), - ), - ( - TransactionReceiptResult::RpcResponse(transaction_receipt_2), - fingerprint_2.clone(), - ), - ], - response_skeleton_opt: None, + }]); + let left_side_bound = if let ScanTiming::WaitFor(interval) = + assertion_interval_computer.time_until_next_scan() + { + interval + } else { + panic!("expected an interval") }; subject_addr.try_send(msg).unwrap(); - let system = System::new("processing reported receipts"); System::current().stop(); system.run(); - let transactions_confirmed_params = transactions_confirmed_params_arc.lock().unwrap(); - assert_eq!( - *transactions_confirmed_params, - vec![vec![fingerprint_1, fingerprint_2]] + let new_payable_notify_later = new_payable_notify_later_arc.lock().unwrap(); + let (_, actual_interval) = new_payable_notify_later[0]; + let right_side_bound = if let ScanTiming::WaitFor(interval) = + assertion_interval_computer.time_until_next_scan() + { + interval + } else { + panic!("expected an interval") + }; + assert!( + left_side_bound >= actual_interval && actual_interval >= right_side_bound, + "expected actual {:?} to be between {:?} and {:?}", + actual_interval, + left_side_bound, + right_side_bound ); } + pub struct SeedsToMakeUpPayableWithStatus { + tx_hash: TxHashByTable, + status: StatusReadFromReceiptCheck, + } + + fn make_tx_receipts_msg( + seeds: Vec, + ) -> (TxReceiptsMessage, Vec) { + let (tx_receipt_results, tx_record_vec) = seeds.into_iter().enumerate().fold( + (btreemap![], vec![]), + |(mut tx_receipt_results, mut record_by_table_vec), (idx, seed_params)| { + let tx_hash = seed_params.tx_hash; + let status = seed_params.status; + let (key, value, record) = + make_receipt_check_result_and_record(tx_hash, status, idx as u64); + tx_receipt_results.insert(key, value); + record_by_table_vec.push(record); + (tx_receipt_results, record_by_table_vec) + }, + ); + + let msg = TxReceiptsMessage { + results: tx_receipt_results, + response_skeleton_opt: None, + }; + + (msg, tx_record_vec) + } + + fn make_receipt_check_result_and_record( + tx_hash: TxHashByTable, + status: StatusReadFromReceiptCheck, + idx: u64, + ) -> (TxHashByTable, TxReceiptResult, TxByTable) { + match tx_hash { + TxHashByTable::SentPayable(hash) => { + let mut sent_tx = make_sent_tx((1 + idx) as u32); + sent_tx.hash = hash; + + if let StatusReadFromReceiptCheck::Succeeded(block) = &status { + sent_tx.status = TxStatus::Confirmed { + block_hash: format!("{:?}", block.block_hash), + block_number: block.block_number.as_u64(), + detection: Detection::Normal, + } + } + + let result = Ok(status); + let record_by_table = TxByTable::SentPayable(sent_tx); + (tx_hash, result, record_by_table) + } + TxHashByTable::FailedPayable(hash) => { + let mut failed_tx = make_failed_tx(1 + idx as u32); + failed_tx.hash = hash; + + let result = Ok(status); + let record_by_table = TxByTable::FailedPayable(failed_tx); + (tx_hash, result, record_by_table) + } + } + } + #[test] - fn accountant_handles_inserting_new_fingerprints() { + fn accountant_handles_registering_new_pending_payables() { init_test_logging(); - let insert_fingerprint_params_arc = Arc::new(Mutex::new(vec![])); - let pending_payable_dao = PendingPayableDaoMock::default() - .insert_fingerprints_params(&insert_fingerprint_params_arc) - .insert_fingerprints_result(Ok(())); + let test_name = "accountant_handles_registering_new_pending_payables"; + let insert_new_records_params_arc = Arc::new(Mutex::new(vec![])); + let sent_payable_dao = SentPayableDaoMock::default() + .insert_new_records_params(&insert_new_records_params_arc) + .insert_new_records_result(Ok(())); let subject = AccountantBuilder::default() - .pending_payable_daos(vec![ForAccountantBody(pending_payable_dao)]) + .sent_payable_daos(vec![ForAccountantBody(sent_payable_dao)]) + .logger(Logger::new(test_name)) .build(); let accountant_addr = subject.start(); let accountant_subs = Accountant::make_subs_from(&accountant_addr); - let timestamp = SystemTime::now(); + let mut sent_tx_1 = make_sent_tx(456); let hash_1 = make_tx_hash(0x6c81c); - let amount_1 = 12345; + sent_tx_1.hash = hash_1; + let mut sent_tx_2 = make_sent_tx(789); let hash_2 = make_tx_hash(0x1b207); - let amount_2 = 87654; - let hash_and_amount_1 = HashAndAmount { - hash: hash_1, - amount: amount_1, - }; - let hash_and_amount_2 = HashAndAmount { - hash: hash_2, - amount: amount_2, - }; - let init_params = vec![hash_and_amount_1, hash_and_amount_2]; - let init_fingerprints_msg = PendingPayableFingerprintSeeds { - batch_wide_timestamp: timestamp, - hashes_and_balances: init_params.clone(), - }; + sent_tx_2.hash = hash_2; + let new_sent_txs = vec![sent_tx_1.clone(), sent_tx_2.clone()]; + let msg = RegisterNewPendingPayables { new_sent_txs }; let _ = accountant_subs - .init_pending_payable_fingerprints - .try_send(init_fingerprints_msg) + .register_new_pending_payables + .try_send(msg) .unwrap(); - let system = System::new("ordering payment fingerprint test"); + let system = System::new("ordering payment sent tx record test"); System::current().stop(); assert_eq!(system.run(), 0); - let insert_fingerprint_params = insert_fingerprint_params_arc.lock().unwrap(); + let insert_new_records_params = insert_new_records_params_arc.lock().unwrap(); assert_eq!( - *insert_fingerprint_params, - vec![(vec![hash_and_amount_1, hash_and_amount_2], timestamp)] - ); - TestLogHandler::new().exists_log_containing( - "DEBUG: Accountant: Saved new pending payable fingerprints for: \ - 0x000000000000000000000000000000000000000000000000000000000006c81c, 0x000000000000000000000000000000000000000000000000000000000001b207", + *insert_new_records_params, + vec![BTreeSet::from([sent_tx_1, sent_tx_2])] ); + TestLogHandler::new().exists_log_containing(&format!( + "DEBUG: {test_name}: Registered new pending payables for: \ + 0x000000000000000000000000000000000000000000000000000000000006c81c, \ + 0x000000000000000000000000000000000000000000000000000000000001b207", + )); } #[test] - fn payable_fingerprint_insertion_clearly_failed_and_we_log_it_at_least() { - //despite it doesn't end so here this event would be a cause of a later panic + fn sent_payable_insertion_clearly_failed_and_we_log_at_least() { + // Even though it's factually a filed db operation, which is treated by an instant panic + // due to the broken db reliance, this is an exception. We give out some time to complete + // the actual paying and panic soon after when we figure out, from a different place + // that some sent tx records are missing. This should eventually be eliminated by GH-655 init_test_logging(); - let insert_fingerprint_params_arc = Arc::new(Mutex::new(vec![])); - let pending_payable_dao = PendingPayableDaoMock::default() - .insert_fingerprints_params(&insert_fingerprint_params_arc) - .insert_fingerprints_result(Err(PendingPayableDaoError::InsertionFailed( + let test_name = "sent_payable_insertion_clearly_failed_and_we_log_at_least"; + let insert_new_records_params_arc = Arc::new(Mutex::new(vec![])); + let sent_payable_dao = SentPayableDaoMock::default() + .insert_new_records_params(&insert_new_records_params_arc) + .insert_new_records_result(Err(SentPayableDaoError::SqlExecutionFailed( "Crashed".to_string(), ))); - let amount = 2345; - let transaction_hash = make_tx_hash(0x1c8); - let hash_and_amount = HashAndAmount { - hash: transaction_hash, - amount, - }; + let tx_hash_1 = make_tx_hash(0x1c8); + let mut sent_tx_1 = make_sent_tx(456); + sent_tx_1.hash = tx_hash_1; + let tx_hash_2 = make_tx_hash(0x1b2); + let mut sent_tx_2 = make_sent_tx(789); + sent_tx_2.hash = tx_hash_2; let subject = AccountantBuilder::default() - .pending_payable_daos(vec![ForAccountantBody(pending_payable_dao)]) + .sent_payable_daos(vec![ForAccountantBody(sent_payable_dao)]) + .logger(Logger::new(test_name)) .build(); - let timestamp = SystemTime::now(); - let report_new_fingerprints = PendingPayableFingerprintSeeds { - batch_wide_timestamp: timestamp, - hashes_and_balances: vec![hash_and_amount], + let msg = RegisterNewPendingPayables { + new_sent_txs: vec![sent_tx_1.clone(), sent_tx_2.clone()], }; - let _ = subject.handle_new_pending_payable_fingerprints(report_new_fingerprints); + let _ = subject.register_new_pending_sent_tx(msg); - let insert_fingerprint_params = insert_fingerprint_params_arc.lock().unwrap(); + let insert_new_records_params = insert_new_records_params_arc.lock().unwrap(); assert_eq!( - *insert_fingerprint_params, - vec![(vec![hash_and_amount], timestamp)] + *insert_new_records_params, + vec![BTreeSet::from([sent_tx_1, sent_tx_2])] ); - TestLogHandler::new().exists_log_containing("ERROR: Accountant: Failed to process \ - new pending payable fingerprints due to 'InsertionFailed(\"Crashed\")', disabling the automated \ - confirmation for all these transactions: 0x00000000000000000000000000000000000000000000000000000000000001c8"); + TestLogHandler::new().exists_log_containing(&format!( + "ERROR: {test_name}: Failed to save new pending payable records for \ + 0x00000000000000000000000000000000000000000000000000000000000001c8, \ + 0x00000000000000000000000000000000000000000000000000000000000001b2 \ + due to 'SqlExecutionFailed(\"Crashed\")' which is integral to the function \ + of the automated tx confirmation" + )); } const EXAMPLE_RESPONSE_SKELETON: ResponseSkeleton = ResponseSkeleton { @@ -3948,75 +5867,268 @@ mod tests { const EXAMPLE_ERROR_MSG: &str = "My tummy hurts"; + fn do_setup_and_prepare_assertions_for_new_payables( + ) -> Box RunSchedulersAssertions> { + Box::new( + |_scanners: &mut Scanners, scan_schedulers: &mut ScanSchedulers| { + // Setup + let notify_later_params_arc = Arc::new(Mutex::new(vec![])); + scan_schedulers.payable.interval_computer = Box::new( + NewPayableScanIntervalComputerMock::default() + .time_until_next_scan_result(ScanTiming::WaitFor(Duration::from_secs(152))), + ); + scan_schedulers.payable.new_payable_notify_later = Box::new( + NotifyLaterHandleMock::default().notify_later_params(¬ify_later_params_arc), + ); + + // Assertions + Box::new(move |response_skeleton_opt| { + let notify_later_params = notify_later_params_arc.lock().unwrap(); + match response_skeleton_opt { + None => assert_eq!( + *notify_later_params, + vec![(ScanForNewPayables::default(), Duration::from_secs(152))] + ), + Some(_) => { + assert!( + notify_later_params.is_empty(), + "Should be empty but contained {:?}", + notify_later_params + ) + } + } + }) + }, + ) + } + + fn do_setup_and_prepare_assertions_for_retry_payables( + ) -> Box RunSchedulersAssertions> { + Box::new( + |_scanners: &mut Scanners, scan_schedulers: &mut ScanSchedulers| { + // Setup + let notify_later_params_arc = Arc::new(Mutex::new(vec![])); + scan_schedulers.payable.retry_payable_notify_later = Box::new( + NotifyLaterHandleMock::default().notify_later_params(¬ify_later_params_arc), + ); + + // Assertions + Box::new(move |response_skeleton_opt| { + let notify_later_params = notify_later_params_arc.lock().unwrap(); + match response_skeleton_opt { + None => { + // Response skeleton must be None + assert_eq!( + *notify_later_params, + vec![( + ScanForRetryPayables { + response_skeleton_opt: None + }, + Duration::from_secs(5 * 60) + )] + ) + } + Some(_) => { + assert!( + notify_later_params.is_empty(), + "Should be empty but contained {:?}", + notify_later_params + ) + } + } + }) + }, + ) + } + + fn do_setup_and_prepare_assertions_for_pending_payables( + ) -> Box RunSchedulersAssertions> { + Box::new( + |scanners: &mut Scanners, scan_schedulers: &mut ScanSchedulers| { + // Setup + let notify_later_params_arc = Arc::new(Mutex::new(vec![])); + let ensure_empty_cache_sent_tx_params_arc = Arc::new(Mutex::new(vec![])); + let ensure_empty_cache_failed_tx_params_arc = Arc::new(Mutex::new(vec![])); + scan_schedulers.pending_payable.interval = Duration::from_secs(600); + scan_schedulers.pending_payable.handle = Box::new( + NotifyLaterHandleMock::default().notify_later_params(¬ify_later_params_arc), + ); + let sent_payable_cache = PendingPayableCacheMock::default() + .ensure_empty_cache_params(&ensure_empty_cache_sent_tx_params_arc); + let failed_payable_cache = PendingPayableCacheMock::default() + .ensure_empty_cache_params(&ensure_empty_cache_failed_tx_params_arc); + let scanner = PendingPayableScannerBuilder::new() + .sent_payable_cache(Box::new(sent_payable_cache)) + .failed_payable_cache(Box::new(failed_payable_cache)) + .build(); + scanners.replace_scanner(ScannerReplacement::PendingPayable( + ReplacementType::Real(scanner), + )); + + // Assertions + Box::new(move |response_skeleton_opt| { + let notify_later_params = notify_later_params_arc.lock().unwrap(); + match response_skeleton_opt { + None => { + assert_eq!( + *notify_later_params, + vec![(ScanForPendingPayables::default(), Duration::from_secs(600))] + ) + } + Some(_) => { + assert!( + notify_later_params.is_empty(), + "Should be empty but contained {:?}", + notify_later_params + ) + } + } + let ensure_empty_cache_sent_tx_params = + ensure_empty_cache_sent_tx_params_arc.lock().unwrap(); + assert_eq!(*ensure_empty_cache_sent_tx_params, vec![()]); + let ensure_empty_cache_failed_tx_params = + ensure_empty_cache_failed_tx_params_arc.lock().unwrap(); + assert_eq!(*ensure_empty_cache_failed_tx_params, vec![()]); + }) + }, + ) + } + + fn do_setup_and_prepare_assertions_for_receivables( + ) -> Box RunSchedulersAssertions> { + Box::new( + |_scanners: &mut Scanners, scan_schedulers: &mut ScanSchedulers| { + // Setup + let notify_later_params_arc = Arc::new(Mutex::new(vec![])); + scan_schedulers.receivable.interval = Duration::from_secs(600); + scan_schedulers.receivable.handle = Box::new( + NotifyLaterHandleMock::default().notify_later_params(¬ify_later_params_arc), + ); + + // Assertions + Box::new(move |response_skeleton_opt| { + let notify_later_params = notify_later_params_arc.lock().unwrap(); + match response_skeleton_opt { + None => { + assert_eq!( + *notify_later_params, + vec![(ScanForReceivables::default(), Duration::from_secs(600))] + ) + } + Some(_) => { + assert!( + notify_later_params.is_empty(), + "Should be empty but contained {:?}", + notify_later_params + ) + } + } + }) + }, + ) + } + #[test] - fn handling_scan_error_for_externally_triggered_payables() { - assert_scan_error_is_handled_properly( - "handling_scan_error_for_externally_triggered_payables", + fn handling_scan_error_for_externally_triggered_new_payables() { + test_scan_error_is_handled_properly( + "handling_scan_error_for_externally_triggered_new_payables", ScanError { - scan_type: ScanType::Payables, + scan_type: DetailedScanType::NewPayables, response_skeleton_opt: Some(EXAMPLE_RESPONSE_SKELETON), msg: EXAMPLE_ERROR_MSG.to_string(), }, + do_setup_and_prepare_assertions_for_new_payables(), ); } + #[test] + fn handling_scan_error_for_externally_triggered_retry_payables() { + test_scan_error_is_handled_properly( + "handling_scan_error_for_externally_triggered_retry_payables", + ScanError { + scan_type: DetailedScanType::RetryPayables, + response_skeleton_opt: Some(EXAMPLE_RESPONSE_SKELETON), + msg: EXAMPLE_ERROR_MSG.to_string(), + }, + do_setup_and_prepare_assertions_for_retry_payables(), + ) + } + #[test] fn handling_scan_error_for_externally_triggered_pending_payables() { - assert_scan_error_is_handled_properly( + test_scan_error_is_handled_properly( "handling_scan_error_for_externally_triggered_pending_payables", ScanError { - scan_type: ScanType::PendingPayables, + scan_type: DetailedScanType::PendingPayables, response_skeleton_opt: Some(EXAMPLE_RESPONSE_SKELETON), msg: EXAMPLE_ERROR_MSG.to_string(), }, + do_setup_and_prepare_assertions_for_pending_payables(), ); } #[test] fn handling_scan_error_for_externally_triggered_receivables() { - assert_scan_error_is_handled_properly( + test_scan_error_is_handled_properly( "handling_scan_error_for_externally_triggered_receivables", ScanError { - scan_type: ScanType::Receivables, + scan_type: DetailedScanType::Receivables, response_skeleton_opt: Some(EXAMPLE_RESPONSE_SKELETON), msg: EXAMPLE_ERROR_MSG.to_string(), }, + do_setup_and_prepare_assertions_for_receivables(), ); } #[test] - fn handling_scan_error_for_internally_triggered_payables() { - assert_scan_error_is_handled_properly( - "handling_scan_error_for_internally_triggered_payables", + fn handling_scan_error_for_internally_triggered_new_payables() { + test_scan_error_is_handled_properly( + "handling_scan_error_for_internally_triggered_new_payables", ScanError { - scan_type: ScanType::Payables, + scan_type: DetailedScanType::NewPayables, + response_skeleton_opt: None, + msg: EXAMPLE_ERROR_MSG.to_string(), + }, + do_setup_and_prepare_assertions_for_new_payables(), + ); + } + + #[test] + fn handling_scan_error_for_internally_triggered_retry_payables() { + test_scan_error_is_handled_properly( + "handling_scan_error_for_internally_triggered_retry_payables", + ScanError { + scan_type: DetailedScanType::RetryPayables, response_skeleton_opt: None, msg: EXAMPLE_ERROR_MSG.to_string(), }, + do_setup_and_prepare_assertions_for_retry_payables(), ); } #[test] fn handling_scan_error_for_internally_triggered_pending_payables() { - assert_scan_error_is_handled_properly( + test_scan_error_is_handled_properly( "handling_scan_error_for_internally_triggered_pending_payables", ScanError { - scan_type: ScanType::PendingPayables, + scan_type: DetailedScanType::PendingPayables, response_skeleton_opt: None, msg: EXAMPLE_ERROR_MSG.to_string(), }, + do_setup_and_prepare_assertions_for_pending_payables(), ); } #[test] fn handling_scan_error_for_internally_triggered_receivables() { - assert_scan_error_is_handled_properly( + test_scan_error_is_handled_properly( "handling_scan_error_for_internally_triggered_receivables", ScanError { - scan_type: ScanType::Receivables, + scan_type: DetailedScanType::Receivables, response_skeleton_opt: None, msg: EXAMPLE_ERROR_MSG.to_string(), }, + do_setup_and_prepare_assertions_for_receivables(), ); } @@ -4106,7 +6218,7 @@ mod tests { let receivable_dao = ReceivableDaoMock::new().total_result(987_654_328_996); let system = System::new("test"); let subject = AccountantBuilder::default() - .bootstrapper_config(make_bc_with_defaults()) + .bootstrapper_config(make_bc_with_defaults(TEST_DEFAULT_CHAIN)) .payable_daos(vec![ForAccountantBody(payable_dao)]) .receivable_daos(vec![ForAccountantBody(receivable_dao)]) .build(); @@ -4765,23 +6877,29 @@ mod tests { let _: u64 = wei_to_gwei(u128::MAX); } - fn assert_scan_error_is_handled_properly(test_name: &str, message: ScanError) { + type RunSchedulersAssertions = Box)>; + + fn test_scan_error_is_handled_properly( + test_name: &str, + message: ScanError, + set_up_schedulers_and_prepare_assertions: Box< + dyn FnOnce(&mut Scanners, &mut ScanSchedulers) -> RunSchedulersAssertions, + >, + ) { init_test_logging(); let (ui_gateway, _, ui_gateway_recording_arc) = make_recorder(); let mut subject = AccountantBuilder::default() + .consuming_wallet(make_wallet("blah")) .logger(Logger::new(test_name)) .build(); - match message.scan_type { - ScanType::Payables => subject.scanners.payable.mark_as_started(SystemTime::now()), - ScanType::PendingPayables => subject - .scanners - .pending_payable - .mark_as_started(SystemTime::now()), - ScanType::Receivables => subject - .scanners - .receivable - .mark_as_started(SystemTime::now()), - } + subject.scanners.reset_scan_started( + message.scan_type.into(), + MarkScanner::Started(SystemTime::now()), + ); + let run_schedulers_assertions = set_up_schedulers_and_prepare_assertions( + &mut subject.scanners, + &mut subject.scan_schedulers, + ); let subject_addr = subject.start(); let system = System::new("test"); let peer_actors = peer_actors_builder().ui_gateway(ui_gateway).build(); @@ -4792,19 +6910,15 @@ mod tests { subject_addr .try_send(AssertionsMessage { assertions: Box::new(move |actor: &mut Accountant| { - let scan_started_at_opt = match message.scan_type { - ScanType::Payables => actor.scanners.payable.scan_started_at(), - ScanType::PendingPayables => { - actor.scanners.pending_payable.scan_started_at() - } - ScanType::Receivables => actor.scanners.receivable.scan_started_at(), - }; + let scan_started_at_opt = + actor.scanners.scan_started_at(message.scan_type.into()); assert_eq!(scan_started_at_opt, None); }), }) .unwrap(); System::current().stop(); - system.run(); + assert_eq!(system.run(), 0); + run_schedulers_assertions(message.response_skeleton_opt); let ui_gateway_recording = ui_gateway_recording_arc.lock().unwrap(); match message.response_skeleton_opt { Some(response_skeleton) => { @@ -4860,6 +6974,10 @@ mod tests { assert_on_initialization_with_panic_on_migration(&data_dir, &act); } + + fn bind_ui_gateway_unasserted(accountant: &mut Accountant) { + accountant.ui_message_sub_opt = Some(make_recorder().0.start().recipient()); + } } #[cfg(test)] @@ -4880,6 +6998,7 @@ pub mod exportable_test_parts { check_if_source_code_is_attached, ensure_node_home_directory_exists, ShouldWeRunTheTest, }; use regex::Regex; + use std::collections::BTreeSet; use std::env::current_dir; use std::fs::File; use std::io::{BufRead, BufReader}; @@ -5056,4 +7175,27 @@ pub mod exportable_test_parts { // We didn't blow up, it recognized the functions. // This is an example of the error: "no such function: slope_drop_high_bytes" } + + #[test] + fn join_with_separator_works() { + // With a Vec + let vec = vec![1, 2, 3]; + let result_vec = join_with_separator(vec, |&num| num.to_string(), ", "); + assert_eq!(result_vec, "1, 2, 3".to_string()); + + // With a HashSet + let set = BTreeSet::from([1, 2, 3]); + let result_set = join_with_separator(set, |&num| num.to_string(), ", "); + assert_eq!(result_set, "1, 2, 3".to_string()); + + // With a slice + let slice = &[1, 2, 3]; + let result_slice = join_with_separator(slice.to_vec(), |&num| num.to_string(), ", "); + assert_eq!(result_slice, "1, 2, 3".to_string()); + + // With an array + let array = [1, 2, 3]; + let result_array = join_with_separator(array.to_vec(), |&num| num.to_string(), ", "); + assert_eq!(result_array, "1, 2, 3".to_string()); + } } diff --git a/node/src/accountant/payment_adjuster.rs b/node/src/accountant/payment_adjuster.rs index 88ee13e746..b8318895d0 100644 --- a/node/src/accountant/payment_adjuster.rs +++ b/node/src/accountant/payment_adjuster.rs @@ -1,7 +1,7 @@ // Copyright (c) 2019, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. -use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::msgs::BlockchainAgentWithContextMessage; -use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::PreparedAdjustment; +use crate::accountant::scanners::payable_scanner::msgs::PricedTemplatesMessage; +use crate::accountant::scanners::payable_scanner::payment_adjuster_integration::PreparedAdjustment; use crate::sub_lib::blockchain_bridge::OutboundPaymentsInstructions; use masq_lib::logger::Logger; use std::time::SystemTime; @@ -9,7 +9,7 @@ use std::time::SystemTime; pub trait PaymentAdjuster { fn search_for_indispensable_adjustment( &self, - msg: &BlockchainAgentWithContextMessage, + msg: &PricedTemplatesMessage, logger: &Logger, ) -> Result, AnalysisError>; @@ -28,7 +28,7 @@ pub struct PaymentAdjusterReal {} impl PaymentAdjuster for PaymentAdjusterReal { fn search_for_indispensable_adjustment( &self, - _msg: &BlockchainAgentWithContextMessage, + _msg: &PricedTemplatesMessage, _logger: &Logger, ) -> Result, AnalysisError> { Ok(None) @@ -71,22 +71,23 @@ pub enum AnalysisError {} #[cfg(test)] mod tests { use crate::accountant::payment_adjuster::{PaymentAdjuster, PaymentAdjusterReal}; - use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::msgs::BlockchainAgentWithContextMessage; - use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::test_utils::BlockchainAgentMock; - use crate::accountant::scanners::test_utils::protect_payables_in_test; + use crate::accountant::scanners::payable_scanner::msgs::PricedTemplatesMessage; + use crate::accountant::scanners::payable_scanner::tx_templates::test_utils::make_priced_new_tx_templates; use crate::accountant::test_utils::make_payable_account; + use crate::blockchain::blockchain_agent::test_utils::BlockchainAgentMock; + use itertools::Either; use masq_lib::logger::Logger; use masq_lib::test_utils::logging::{init_test_logging, TestLogHandler}; #[test] fn search_for_indispensable_adjustment_always_returns_none() { init_test_logging(); - let test_name = "is_adjustment_required_always_returns_none"; - let mut payable = make_payable_account(111); - payable.balance_wei = 100_000_000; + let test_name = "search_for_indispensable_adjustment_always_returns_none"; + let payable = make_payable_account(123); let agent = BlockchainAgentMock::default(); - let setup_msg = BlockchainAgentWithContextMessage { - protected_qualified_payables: protect_payables_in_test(vec![payable]), + let priced_new_tx_templates = make_priced_new_tx_templates(vec![(payable, 111_111_111)]); + let setup_msg = PricedTemplatesMessage { + priced_templates: Either::Left(priced_new_tx_templates), agent: Box::new(agent), response_skeleton_opt: None, }; diff --git a/node/src/accountant/scanners/mid_scan_msg_handling/mod.rs b/node/src/accountant/scanners/mid_scan_msg_handling/mod.rs deleted file mode 100644 index 16331e4bf6..0000000000 --- a/node/src/accountant/scanners/mid_scan_msg_handling/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2019, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. - -pub mod payable_scanner; diff --git a/node/src/accountant/scanners/mid_scan_msg_handling/payable_scanner/agent_null.rs b/node/src/accountant/scanners/mid_scan_msg_handling/payable_scanner/agent_null.rs deleted file mode 100644 index e95673002c..0000000000 --- a/node/src/accountant/scanners/mid_scan_msg_handling/payable_scanner/agent_null.rs +++ /dev/null @@ -1,195 +0,0 @@ -// Copyright (c) 2019, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. - -use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::blockchain_agent::BlockchainAgent; - -use crate::sub_lib::blockchain_bridge::ConsumingWalletBalances; -use crate::sub_lib::wallet::Wallet; -use ethereum_types::U256; -use masq_lib::blockchains::chains::Chain; -use masq_lib::logger::Logger; -use masq_lib::test_utils::utils::TEST_DEFAULT_CHAIN; - -#[derive(Clone)] -pub struct BlockchainAgentNull { - wallet: Wallet, - logger: Logger, -} - -impl BlockchainAgent for BlockchainAgentNull { - fn estimated_transaction_fee_total(&self, _number_of_transactions: usize) -> u128 { - self.log_function_call("estimated_transaction_fee_total()"); - 0 - } - - fn consuming_wallet_balances(&self) -> ConsumingWalletBalances { - self.log_function_call("consuming_wallet_balances()"); - ConsumingWalletBalances { - transaction_fee_balance_in_minor_units: U256::zero(), - masq_token_balance_in_minor_units: U256::zero(), - } - } - - fn agreed_fee_per_computation_unit(&self) -> u128 { - self.log_function_call("agreed_fee_per_computation_unit()"); - 0 - } - - fn consuming_wallet(&self) -> &Wallet { - self.log_function_call("consuming_wallet()"); - &self.wallet - } - - fn get_chain(&self) -> Chain { - self.log_function_call("get_chain()"); - TEST_DEFAULT_CHAIN - } - - #[cfg(test)] - fn dup(&self) -> Box { - intentionally_blank!() - } - - #[cfg(test)] - as_any_ref_in_trait_impl!(); -} - -impl BlockchainAgentNull { - pub fn new() -> Self { - Self { - wallet: Wallet::null(), - logger: Logger::new("BlockchainAgentNull"), - } - } - - fn log_function_call(&self, function_call: &str) { - error!( - self.logger, - "calling null version of {function_call} for BlockchainAgentNull will be without effect", - ); - } -} - -impl Default for BlockchainAgentNull { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::agent_null::BlockchainAgentNull; - use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::blockchain_agent::BlockchainAgent; - - use crate::sub_lib::blockchain_bridge::ConsumingWalletBalances; - use crate::sub_lib::wallet::Wallet; - - use masq_lib::logger::Logger; - use masq_lib::test_utils::logging::{init_test_logging, TestLogHandler}; - use masq_lib::test_utils::utils::TEST_DEFAULT_CHAIN; - use web3::types::U256; - - fn blockchain_agent_null_constructor_works(constructor: C) - where - C: Fn() -> BlockchainAgentNull, - { - init_test_logging(); - - let result = constructor(); - - assert_eq!(result.wallet, Wallet::null()); - warning!(result.logger, "blockchain_agent_null_constructor_works"); - TestLogHandler::default().exists_log_containing( - "WARN: BlockchainAgentNull: \ - blockchain_agent_null_constructor_works", - ); - } - - #[test] - fn blockchain_agent_null_constructor_works_for_new() { - blockchain_agent_null_constructor_works(BlockchainAgentNull::new) - } - - #[test] - fn blockchain_agent_null_constructor_works_for_default() { - blockchain_agent_null_constructor_works(BlockchainAgentNull::default) - } - - fn assert_error_log(test_name: &str, expected_operation: &str) { - TestLogHandler::default().exists_log_containing(&format!( - "ERROR: {test_name}: calling \ - null version of {expected_operation}() for BlockchainAgentNull \ - will be without effect" - )); - } - - #[test] - fn null_agent_estimated_transaction_fee_total() { - init_test_logging(); - let test_name = "null_agent_estimated_transaction_fee_total"; - let mut subject = BlockchainAgentNull::new(); - subject.logger = Logger::new(test_name); - - let result = subject.estimated_transaction_fee_total(4); - - assert_eq!(result, 0); - assert_error_log(test_name, "estimated_transaction_fee_total"); - } - - #[test] - fn null_agent_consuming_wallet_balances() { - init_test_logging(); - let test_name = "null_agent_consuming_wallet_balances"; - let mut subject = BlockchainAgentNull::new(); - subject.logger = Logger::new(test_name); - - let result = subject.consuming_wallet_balances(); - - assert_eq!( - result, - ConsumingWalletBalances { - transaction_fee_balance_in_minor_units: U256::zero(), - masq_token_balance_in_minor_units: U256::zero() - } - ); - assert_error_log(test_name, "consuming_wallet_balances") - } - - #[test] - fn null_agent_agreed_fee_per_computation_unit() { - init_test_logging(); - let test_name = "null_agent_agreed_fee_per_computation_unit"; - let mut subject = BlockchainAgentNull::new(); - subject.logger = Logger::new(test_name); - - let result = subject.agreed_fee_per_computation_unit(); - - assert_eq!(result, 0); - assert_error_log(test_name, "agreed_fee_per_computation_unit") - } - - #[test] - fn null_agent_consuming_wallet() { - init_test_logging(); - let test_name = "null_agent_consuming_wallet"; - let mut subject = BlockchainAgentNull::new(); - subject.logger = Logger::new(test_name); - - let result = subject.consuming_wallet(); - - assert_eq!(result, &Wallet::null()); - assert_error_log(test_name, "consuming_wallet") - } - - #[test] - fn null_agent_get_chain() { - init_test_logging(); - let test_name = "null_agent_get_chain"; - let mut subject = BlockchainAgentNull::new(); - subject.logger = Logger::new(test_name); - - let result = subject.get_chain(); - - assert_eq!(result, TEST_DEFAULT_CHAIN); - assert_error_log(test_name, "get_chain") - } -} diff --git a/node/src/accountant/scanners/mid_scan_msg_handling/payable_scanner/agent_web3.rs b/node/src/accountant/scanners/mid_scan_msg_handling/payable_scanner/agent_web3.rs deleted file mode 100644 index 725e14f008..0000000000 --- a/node/src/accountant/scanners/mid_scan_msg_handling/payable_scanner/agent_web3.rs +++ /dev/null @@ -1,135 +0,0 @@ -// Copyright (c) 2019, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. - -use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::blockchain_agent::BlockchainAgent; -use crate::sub_lib::blockchain_bridge::ConsumingWalletBalances; -use crate::sub_lib::wallet::Wallet; -use masq_lib::blockchains::chains::Chain; - -#[derive(Debug, Clone)] -pub struct BlockchainAgentWeb3 { - gas_price_wei: u128, - gas_limit_const_part: u128, - maximum_added_gas_margin: u128, - consuming_wallet: Wallet, - consuming_wallet_balances: ConsumingWalletBalances, - chain: Chain, -} - -impl BlockchainAgent for BlockchainAgentWeb3 { - fn estimated_transaction_fee_total(&self, number_of_transactions: usize) -> u128 { - let gas_price = self.gas_price_wei; - let max_gas_limit = self.maximum_added_gas_margin + self.gas_limit_const_part; - number_of_transactions as u128 * gas_price * max_gas_limit - } - - fn consuming_wallet_balances(&self) -> ConsumingWalletBalances { - self.consuming_wallet_balances - } - - fn agreed_fee_per_computation_unit(&self) -> u128 { - self.gas_price_wei - } - - fn consuming_wallet(&self) -> &Wallet { - &self.consuming_wallet - } - - fn get_chain(&self) -> Chain { - self.chain - } -} - -// 64 * (64 - 12) ... std transaction has data of 64 bytes and 12 bytes are never used with us; -// each non-zero byte costs 64 units of gas -pub const WEB3_MAXIMAL_GAS_LIMIT_MARGIN: u128 = 3328; - -impl BlockchainAgentWeb3 { - pub fn new( - gas_price_wei: u128, - gas_limit_const_part: u128, - consuming_wallet: Wallet, - consuming_wallet_balances: ConsumingWalletBalances, - chain: Chain, - ) -> Self { - Self { - gas_price_wei, - gas_limit_const_part, - consuming_wallet, - maximum_added_gas_margin: WEB3_MAXIMAL_GAS_LIMIT_MARGIN, - consuming_wallet_balances, - chain, - } - } -} - -#[cfg(test)] -mod tests { - use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::agent_web3::{ - BlockchainAgentWeb3, WEB3_MAXIMAL_GAS_LIMIT_MARGIN, - }; - use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::blockchain_agent::BlockchainAgent; - use crate::sub_lib::blockchain_bridge::ConsumingWalletBalances; - use crate::test_utils::make_wallet; - use masq_lib::test_utils::utils::TEST_DEFAULT_CHAIN; - use web3::types::U256; - - #[test] - fn constants_are_correct() { - assert_eq!(WEB3_MAXIMAL_GAS_LIMIT_MARGIN, 3_328) - } - - #[test] - fn blockchain_agent_can_return_non_computed_input_values() { - let gas_price_gwei = 123; - let gas_limit_const_part = 44_000; - let consuming_wallet = make_wallet("abcde"); - let consuming_wallet_balances = ConsumingWalletBalances { - transaction_fee_balance_in_minor_units: U256::from(456_789), - masq_token_balance_in_minor_units: U256::from(123_000_000), - }; - - let subject = BlockchainAgentWeb3::new( - gas_price_gwei, - gas_limit_const_part, - consuming_wallet.clone(), - consuming_wallet_balances, - TEST_DEFAULT_CHAIN, - ); - - assert_eq!(subject.agreed_fee_per_computation_unit(), gas_price_gwei); - assert_eq!(subject.consuming_wallet(), &consuming_wallet); - assert_eq!( - subject.consuming_wallet_balances(), - consuming_wallet_balances - ); - assert_eq!(subject.get_chain(), TEST_DEFAULT_CHAIN); - } - - #[test] - fn estimated_transaction_fee_works() { - let consuming_wallet = make_wallet("efg"); - let consuming_wallet_balances = ConsumingWalletBalances { - transaction_fee_balance_in_minor_units: Default::default(), - masq_token_balance_in_minor_units: Default::default(), - }; - let agent = BlockchainAgentWeb3::new( - 444, - 77_777, - consuming_wallet, - consuming_wallet_balances, - TEST_DEFAULT_CHAIN, - ); - - let result = agent.estimated_transaction_fee_total(3); - - assert_eq!(agent.gas_limit_const_part, 77_777); - assert_eq!( - agent.maximum_added_gas_margin, - WEB3_MAXIMAL_GAS_LIMIT_MARGIN - ); - assert_eq!( - result, - (3 * (77_777 + WEB3_MAXIMAL_GAS_LIMIT_MARGIN)) as u128 * 444 - ); - } -} diff --git a/node/src/accountant/scanners/mid_scan_msg_handling/payable_scanner/mod.rs b/node/src/accountant/scanners/mid_scan_msg_handling/payable_scanner/mod.rs deleted file mode 100644 index 257c88fdea..0000000000 --- a/node/src/accountant/scanners/mid_scan_msg_handling/payable_scanner/mod.rs +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (c) 2019, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. - -pub mod agent_null; -pub mod agent_web3; -pub mod blockchain_agent; -pub mod msgs; -pub mod test_utils; - -use crate::accountant::payment_adjuster::Adjustment; -use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::msgs::BlockchainAgentWithContextMessage; -use crate::accountant::scanners::Scanner; -use crate::sub_lib::blockchain_bridge::OutboundPaymentsInstructions; -use actix::Message; -use itertools::Either; -use masq_lib::logger::Logger; - -pub trait MultistagePayableScanner: - Scanner + SolvencySensitivePaymentInstructor -where - BeginMessage: Message, - EndMessage: Message, -{ -} - -pub trait SolvencySensitivePaymentInstructor { - fn try_skipping_payment_adjustment( - &self, - msg: BlockchainAgentWithContextMessage, - logger: &Logger, - ) -> Result, String>; - - fn perform_payment_adjustment( - &self, - setup: PreparedAdjustment, - logger: &Logger, - ) -> OutboundPaymentsInstructions; -} - -pub struct PreparedAdjustment { - pub original_setup_msg: BlockchainAgentWithContextMessage, - pub adjustment: Adjustment, -} - -impl PreparedAdjustment { - pub fn new( - original_setup_msg: BlockchainAgentWithContextMessage, - adjustment: Adjustment, - ) -> Self { - Self { - original_setup_msg, - adjustment, - } - } -} - -#[cfg(test)] -mod tests { - use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::PreparedAdjustment; - - impl Clone for PreparedAdjustment { - fn clone(&self) -> Self { - Self { - original_setup_msg: self.original_setup_msg.clone(), - adjustment: self.adjustment.clone(), - } - } - } -} diff --git a/node/src/accountant/scanners/mid_scan_msg_handling/payable_scanner/msgs.rs b/node/src/accountant/scanners/mid_scan_msg_handling/payable_scanner/msgs.rs deleted file mode 100644 index 41a1b39408..0000000000 --- a/node/src/accountant/scanners/mid_scan_msg_handling/payable_scanner/msgs.rs +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (c) 2019, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. - -use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::blockchain_agent::BlockchainAgent; -use crate::accountant::{ResponseSkeleton, SkeletonOptHolder}; -use crate::sub_lib::wallet::Wallet; -use actix::Message; -use masq_lib::type_obfuscation::Obfuscated; -use std::fmt::Debug; - -#[derive(Debug, Message, PartialEq, Eq, Clone)] -pub struct QualifiedPayablesMessage { - pub protected_qualified_payables: Obfuscated, - pub consuming_wallet: Wallet, - pub response_skeleton_opt: Option, -} - -impl QualifiedPayablesMessage { - pub(in crate::accountant) fn new( - protected_qualified_payables: Obfuscated, - consuming_wallet: Wallet, - response_skeleton_opt: Option, - ) -> Self { - Self { - protected_qualified_payables, - consuming_wallet, - response_skeleton_opt, - } - } -} - -impl SkeletonOptHolder for QualifiedPayablesMessage { - fn skeleton_opt(&self) -> Option { - self.response_skeleton_opt - } -} - -#[derive(Message)] -pub struct BlockchainAgentWithContextMessage { - pub protected_qualified_payables: Obfuscated, - pub agent: Box, - pub response_skeleton_opt: Option, -} - -impl BlockchainAgentWithContextMessage { - pub fn new( - qualified_payables: Obfuscated, - blockchain_agent: Box, - response_skeleton_opt: Option, - ) -> Self { - Self { - protected_qualified_payables: qualified_payables, - agent: blockchain_agent, - response_skeleton_opt, - } - } -} - -#[cfg(test)] -mod tests { - - use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::msgs::BlockchainAgentWithContextMessage; - use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::test_utils::BlockchainAgentMock; - - impl Clone for BlockchainAgentWithContextMessage { - fn clone(&self) -> Self { - let original_agent_id = self.agent.arbitrary_id_stamp(); - let cloned_agent = - BlockchainAgentMock::default().set_arbitrary_id_stamp(original_agent_id); - Self { - protected_qualified_payables: self.protected_qualified_payables.clone(), - agent: Box::new(cloned_agent), - response_skeleton_opt: self.response_skeleton_opt, - } - } - } -} diff --git a/node/src/accountant/scanners/mod.rs b/node/src/accountant/scanners/mod.rs index 1307cb006b..cbc337a8dc 100644 --- a/node/src/accountant/scanners/mod.rs +++ b/node/src/accountant/scanners/mod.rs @@ -1,91 +1,89 @@ // Copyright (c) 2019, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. -pub mod mid_scan_msg_handling; -pub mod scanners_utils; +pub mod payable_scanner; +pub mod pending_payable_scanner; +pub mod receivable_scanner; +pub mod scan_schedulers; pub mod test_utils; -use crate::accountant::db_access_objects::payable_dao::{PayableAccount, PayableDao}; -use crate::accountant::db_access_objects::pending_payable_dao::{PendingPayable, PendingPayableDao}; -use crate::accountant::db_access_objects::receivable_dao::ReceivableDao; -use crate::accountant::payment_adjuster::{PaymentAdjuster, PaymentAdjusterReal}; -use crate::accountant::scanners::scanners_utils::payable_scanner_utils::PayableTransactingErrorEnum::{ - LocallyCausedError, RemotelyCausedErrors, +use crate::accountant::payment_adjuster::PaymentAdjusterReal; +use crate::accountant::scanners::payable_scanner::msgs::{ + InitialTemplatesMessage, PricedTemplatesMessage, }; -use crate::accountant::scanners::scanners_utils::payable_scanner_utils::{ - debugging_summary_after_error_separation, err_msg_for_failure_with_expected_but_missing_fingerprints, - investigate_debt_extremes, mark_pending_payable_fatal_error, payables_debug_summary, - separate_errors, separate_rowids_and_hashes, PayableThresholdsGauge, - PayableThresholdsGaugeReal, PayableTransactingErrorEnum, PendingPayableMetadata, +use crate::accountant::scanners::payable_scanner::payment_adjuster_integration::PreparedAdjustment; +use crate::accountant::scanners::payable_scanner::utils::{NextScanToRun, PayableScanResult}; +use crate::accountant::scanners::payable_scanner::{MultistageDualPayableScanner, PayableScanner}; +use crate::accountant::scanners::pending_payable_scanner::utils::PendingPayableScanResult; +use crate::accountant::scanners::pending_payable_scanner::{ + ExtendedPendingPayablePrivateScanner, PendingPayableScanner, }; -use crate::accountant::scanners::scanners_utils::pending_payable_scanner_utils::{handle_none_receipt, handle_status_with_failure, handle_status_with_success, PendingPayableScanReport}; -use crate::accountant::scanners::scanners_utils::receivable_scanner_utils::balance_and_age; -use crate::accountant::PendingPayableId; +use crate::accountant::scanners::receivable_scanner::ReceivableScanner; use crate::accountant::{ - comma_joined_stringifiable, gwei_to_wei, Accountant, ReceivedPayments, - ReportTransactionReceipts, RequestTransactionReceipts, ResponseSkeleton, ScanForPayables, - ScanForPendingPayables, ScanForReceivables, SentPayables, + ReceivedPayments, RequestTransactionReceipts, ResponseSkeleton, ScanError, ScanForNewPayables, + ScanForReceivables, ScanForRetryPayables, SentPayables, TxReceiptsMessage, }; -use crate::accountant::db_access_objects::banned_dao::BannedDao; -use crate::blockchain::blockchain_bridge::{BlockMarker, PendingPayableFingerprint, RetrieveTransactions}; +use crate::blockchain::blockchain_bridge::RetrieveTransactions; +use crate::db_config::persistent_configuration::PersistentConfigurationReal; use crate::sub_lib::accountant::{ - DaoFactories, FinancialStatistics, PaymentThresholds, ScanIntervals, + DaoFactories, DetailedScanType, FinancialStatistics, PaymentThresholds, }; -use crate::sub_lib::blockchain_bridge::{ - OutboundPaymentsInstructions, -}; -use crate::sub_lib::utils::{NotifyLaterHandle, NotifyLaterHandleReal}; +use crate::sub_lib::blockchain_bridge::OutboundPaymentsInstructions; use crate::sub_lib::wallet::Wallet; -use actix::{Context, Message}; -use itertools::{Either, Itertools}; +use actix::Message; +use itertools::Either; use masq_lib::logger::Logger; use masq_lib::logger::TIME_FORMATTING_STRING; -use masq_lib::messages::{ScanType, ToMessageBody, UiScanResponse}; -use masq_lib::ui_gateway::{MessageTarget, NodeToUiMessage}; -use masq_lib::utils::ExpectValue; +use masq_lib::messages::ScanType; +use masq_lib::ui_gateway::NodeToUiMessage; use std::cell::RefCell; -use std::collections::{HashMap, HashSet}; +use std::fmt::Debug; use std::rc::Rc; -use std::time::{Duration, SystemTime}; +use std::time::SystemTime; use time::format_description::parse; use time::OffsetDateTime; -use web3::types::H256; -use masq_lib::type_obfuscation::Obfuscated; -use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::{PreparedAdjustment, MultistagePayableScanner, SolvencySensitivePaymentInstructor}; -use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::msgs::{BlockchainAgentWithContextMessage, QualifiedPayablesMessage}; -use crate::blockchain::blockchain_interface::blockchain_interface_web3::lower_level_interface_web3::{TransactionReceiptResult, TxStatus}; -use crate::blockchain::blockchain_interface::data_structures::errors::PayableTransactionError; -use crate::db_config::persistent_configuration::{PersistentConfiguration, PersistentConfigurationReal}; +use variant_count::VariantCount; +// Leave the individual scanner objects private! pub struct Scanners { - pub payable: Box>, - pub pending_payable: Box>, - pub receivable: Box>, + payable: Box, + aware_of_unresolved_pending_payable: bool, + initial_pending_payable_scan: bool, + pending_payable: Box, + receivable: Box< + dyn PrivateScanner< + ScanForReceivables, + RetrieveTransactions, + ReceivedPayments, + Option, + >, + >, } impl Scanners { pub fn new( dao_factories: DaoFactories, payment_thresholds: Rc, - when_pending_too_long_sec: u64, financial_statistics: Rc>, ) -> Self { let payable = Box::new(PayableScanner::new( dao_factories.payable_dao_factory.make(), - dao_factories.pending_payable_dao_factory.make(), + dao_factories.sent_payable_dao_factory.make(), + dao_factories.failed_payable_dao_factory.make(), Rc::clone(&payment_thresholds), Box::new(PaymentAdjusterReal::new()), )); let pending_payable = Box::new(PendingPayableScanner::new( dao_factories.payable_dao_factory.make(), - dao_factories.pending_payable_dao_factory.make(), + dao_factories.sent_payable_dao_factory.make(), + dao_factories.failed_payable_dao_factory.make(), Rc::clone(&payment_thresholds), - when_pending_too_long_sec, Rc::clone(&financial_statistics), )); let persistent_configuration = PersistentConfigurationReal::from(dao_factories.config_dao_factory.make()); + let receivable = Box::new(ReceivableScanner::new( dao_factories.receivable_dao_factory.make(), dao_factories.banned_dao_factory.make(), @@ -96,36 +94,306 @@ impl Scanners { Scanners { payable, + aware_of_unresolved_pending_payable: false, + initial_pending_payable_scan: true, pending_payable, receivable, } } + + pub fn start_new_payable_scan_guarded( + &mut self, + wallet: &Wallet, + timestamp: SystemTime, + response_skeleton_opt: Option, + logger: &Logger, + automatic_scans_enabled: bool, + ) -> Result { + let triggered_manually = response_skeleton_opt.is_some(); + if triggered_manually && automatic_scans_enabled { + return Err(StartScanError::ManualTriggerError( + ManulTriggerError::AutomaticScanConflict, + )); + } + if let Some(started_at) = self.payable.scan_started_at() { + return Err(StartScanError::ScanAlreadyRunning { + cross_scan_cause_opt: None, + started_at, + }); + } + + Self::start_correct_payable_scanner::( + &mut *self.payable, + wallet, + timestamp, + response_skeleton_opt, + logger, + ) + } + + // Note: This scanner cannot be started on its own. It always runs after the pending payable + // scan, but only if it is clear that a retry is needed. + pub fn start_retry_payable_scan_guarded( + &mut self, + wallet: &Wallet, + timestamp: SystemTime, + response_skeleton_opt: Option, + logger: &Logger, + ) -> Result { + if let Some(started_at) = self.payable.scan_started_at() { + unreachable!( + "Guards should ensure that no payable scanner can run if the pending payable \ + repetitive sequence is still ongoing. However, some other payable scan intruded \ + at {} and is still running at {}", + StartScanError::timestamp_as_string(started_at), + StartScanError::timestamp_as_string(SystemTime::now()) + ) + } + + Self::start_correct_payable_scanner::( + &mut *self.payable, + wallet, + timestamp, + response_skeleton_opt, + logger, + ) + } + + pub fn start_pending_payable_scan_guarded( + &mut self, + wallet: &Wallet, + timestamp: SystemTime, + response_skeleton_opt: Option, + logger: &Logger, + automatic_scans_enabled: bool, + ) -> Result { + let triggered_manually = response_skeleton_opt.is_some(); + self.check_general_conditions_for_pending_payable_scan( + triggered_manually, + automatic_scans_enabled, + )?; + match ( + self.pending_payable.scan_started_at(), + self.payable.scan_started_at(), + ) { + (Some(pp_timestamp), Some(p_timestamp)) => + // If you're wondering, then yes, this condition should be the sacred truth between + // PendingPayableScanner and NewPayableScanner. + { + unreachable!( + "Any payable-related scanners should never be allowed to run in parallel. \ + Scan for pending payables started at: {}, scan for payables started at: {}", + StartScanError::timestamp_as_string(pp_timestamp), + StartScanError::timestamp_as_string(p_timestamp) + ) + } + (Some(started_at), None) => { + return Err(StartScanError::ScanAlreadyRunning { + cross_scan_cause_opt: None, + started_at, + }) + } + (None, Some(started_at)) => { + return Err(StartScanError::ScanAlreadyRunning { + cross_scan_cause_opt: Some(ScanType::Payables), + started_at, + }) + } + (None, None) => (), + } + + self.pending_payable + .start_scan(wallet, timestamp, response_skeleton_opt, logger) + } + + pub fn start_receivable_scan_guarded( + &mut self, + wallet: &Wallet, + timestamp: SystemTime, + response_skeleton_opt: Option, + logger: &Logger, + automatic_scans_enabled: bool, + ) -> Result { + let triggered_manually = response_skeleton_opt.is_some(); + if triggered_manually && automatic_scans_enabled { + return Err(StartScanError::ManualTriggerError( + ManulTriggerError::AutomaticScanConflict, + )); + } + if let Some(started_at) = self.receivable.scan_started_at() { + return Err(StartScanError::ScanAlreadyRunning { + cross_scan_cause_opt: None, + started_at, + }); + } + + self.receivable + .start_scan(wallet, timestamp, response_skeleton_opt, logger) + } + + pub fn finish_payable_scan(&mut self, msg: SentPayables, logger: &Logger) -> PayableScanResult { + let scan_result = self.payable.finish_scan(msg, logger); + if scan_result.result == NextScanToRun::PendingPayableScan { + self.aware_of_unresolved_pending_payable = true + } + scan_result + } + + pub fn finish_pending_payable_scan( + &mut self, + msg: TxReceiptsMessage, + logger: &Logger, + ) -> PendingPayableScanResult { + self.pending_payable.finish_scan(msg, logger) + } + + pub fn finish_receivable_scan( + &mut self, + msg: ReceivedPayments, + logger: &Logger, + ) -> Option { + self.receivable.finish_scan(msg, logger) + } + + pub fn acknowledge_scan_error(&mut self, error: &ScanError, logger: &Logger) { + debug!(logger, "Acknowledging a scan that couldn't finish"); + match error.scan_type { + DetailedScanType::NewPayables | DetailedScanType::RetryPayables => { + self.payable.mark_as_ended(logger) + } + DetailedScanType::PendingPayables => { + self.empty_caches(logger); + self.pending_payable.mark_as_ended(logger); + } + DetailedScanType::Receivables => { + self.receivable.mark_as_ended(logger); + } + }; + } + + fn empty_caches(&mut self, logger: &Logger) { + self.pending_payable.empty_caches(logger) + } + + pub fn try_skipping_payable_adjustment( + &self, + msg: PricedTemplatesMessage, + logger: &Logger, + ) -> Result, String> { + self.payable.try_skipping_payment_adjustment(msg, logger) + } + + pub fn perform_payable_adjustment( + &self, + setup: PreparedAdjustment, + logger: &Logger, + ) -> OutboundPaymentsInstructions { + self.payable.perform_payment_adjustment(setup, logger) + } + + pub fn initial_pending_payable_scan(&self) -> bool { + self.initial_pending_payable_scan + } + + pub fn unset_initial_pending_payable_scan(&mut self) { + self.initial_pending_payable_scan = false + } + + // This is a helper function reducing a boilerplate of complex trait resolving where + // the compiler requires to specify which trigger message distinguishes the scan to run. + // The payable scanner offers two modes through doubled implementations of StartableScanner + // which uses the trigger message type as the only distinction between them. + fn start_correct_payable_scanner<'a, TriggerMessage>( + scanner: &'a mut (dyn MultistageDualPayableScanner + 'a), + wallet: &Wallet, + timestamp: SystemTime, + response_skeleton_opt: Option, + logger: &Logger, + ) -> Result + where + TriggerMessage: Message, + (dyn MultistageDualPayableScanner + 'a): + StartableScanner, + { + <(dyn MultistageDualPayableScanner + 'a) as StartableScanner< + TriggerMessage, + InitialTemplatesMessage, + >>::start_scan(scanner, wallet, timestamp, response_skeleton_opt, logger) + } + + fn check_general_conditions_for_pending_payable_scan( + &mut self, + triggered_manually: bool, + automatic_scans_enabled: bool, + ) -> Result<(), StartScanError> { + if triggered_manually && automatic_scans_enabled { + return Err(StartScanError::ManualTriggerError( + ManulTriggerError::AutomaticScanConflict, + )); + } + if self.initial_pending_payable_scan { + return Ok(()); + } + if triggered_manually && !self.aware_of_unresolved_pending_payable { + return Err(StartScanError::ManualTriggerError( + ManulTriggerError::UnnecessaryRequest { + hint_opt: Some("Run the Payable scanner first.".to_string()), + }, + )); + } + if !self.aware_of_unresolved_pending_payable { + unreachable!( + "Automatic pending payable scan should never start if there are no pending \ + payables to process." + ) + } + + Ok(()) + } } -pub trait Scanner -where - BeginMessage: Message, +pub(in crate::accountant::scanners) trait PrivateScanner< + TriggerMessage, + StartMessage, + EndMessage, + ScanResult, +>: + StartableScanner + Scanner where + TriggerMessage: Message, + StartMessage: Message, EndMessage: Message, { - fn begin_scan( +} + +trait StartableScanner +where + TriggerMessage: Message, + StartMessage: Message, +{ + fn start_scan( &mut self, - wallet: Wallet, + wallet: &Wallet, timestamp: SystemTime, response_skeleton_opt: Option, logger: &Logger, - ) -> Result; - fn finish_scan(&mut self, message: EndMessage, logger: &Logger) -> Option; + ) -> Result; +} + +trait Scanner +where + EndMessage: Message, +{ + fn finish_scan(&mut self, message: EndMessage, logger: &Logger) -> ScanResult; fn scan_started_at(&self) -> Option; fn mark_as_started(&mut self, timestamp: SystemTime); fn mark_as_ended(&mut self, logger: &Logger); - as_any_ref_in_trait!(); as_any_mut_in_trait!(); } pub struct ScannerCommon { initiated_at_opt: Option, - pub payment_thresholds: Rc, + payment_thresholds: Rc, } impl ScannerCommon { @@ -151,7 +419,7 @@ impl ScannerCommon { None => { error!( logger, - "Called scan_finished() for {:?} scanner but timestamp was not found", + "Called scan_finished() for {:?} scanner but could not find any timestamp", scan_type ); } @@ -159,6 +427,7 @@ impl ScannerCommon { } } +#[macro_export] macro_rules! time_marking_methods { ($scan_type_variant: ident) => { fn scan_started_at(&self) -> Option { @@ -179,2722 +448,1114 @@ macro_rules! time_marking_methods { }; } -pub struct PayableScanner { - pub common: ScannerCommon, - pub payable_dao: Box, - pub pending_payable_dao: Box, - pub payable_threshold_gauge: Box, - pub payment_adjuster: Box, +#[derive(Debug, PartialEq, Eq, Clone, VariantCount)] +pub enum StartScanError { + NothingToProcess, + NoConsumingWalletFound, + ScanAlreadyRunning { + cross_scan_cause_opt: Option, + started_at: SystemTime, + }, + CalledFromNullScanner, // Exclusive for tests + ManualTriggerError(ManulTriggerError), } -impl Scanner for PayableScanner { - fn begin_scan( - &mut self, - consuming_wallet: Wallet, - timestamp: SystemTime, - response_skeleton_opt: Option, - logger: &Logger, - ) -> Result { - if let Some(timestamp) = self.scan_started_at() { - return Err(BeginScanError::ScanAlreadyRunning(timestamp)); +impl StartScanError { + pub fn log_error(&self, logger: &Logger, scan_type: ScanType, is_externally_triggered: bool) { + enum ErrorType { + Temporary(String), + Permanent(String), } - self.mark_as_started(timestamp); - info!(logger, "Scanning for payables"); - let all_non_pending_payables = self.payable_dao.non_pending_payables(); - - debug!( - logger, - "{}", - investigate_debt_extremes(timestamp, &all_non_pending_payables) - ); - let qualified_payables = - self.sniff_out_alarming_payables_and_maybe_log_them(all_non_pending_payables, logger); + let log_message = match self { + StartScanError::NothingToProcess => ErrorType::Temporary(format!( + "There was nothing to process during {:?} scan.", + scan_type + )), + StartScanError::ScanAlreadyRunning { + cross_scan_cause_opt, + started_at, + } => ErrorType::Temporary(Self::scan_already_running_msg( + scan_type, + *cross_scan_cause_opt, + *started_at, + )), + StartScanError::NoConsumingWalletFound => ErrorType::Permanent(format!( + "Cannot initiate {:?} scan because no consuming wallet was found.", + scan_type + )), + StartScanError::CalledFromNullScanner => match cfg!(test) { + true => ErrorType::Permanent(format!( + "Called from NullScanner, not the {:?} scanner.", + scan_type + )), + false => panic!("Null Scanner shouldn't be running inside production code."), + }, + StartScanError::ManualTriggerError(e) => match e { + ManulTriggerError::AutomaticScanConflict => ErrorType::Permanent(format!( + "User requested {:?} scan was denied. Automatic mode prevents manual triggers.", + scan_type + )), + ManulTriggerError::UnnecessaryRequest { hint_opt } => { + ErrorType::Temporary(format!( + "User requested {:?} scan was denied expecting zero findings.{}", + scan_type, + match hint_opt { + Some(hint) => format!(" {}", hint), + None => "".to_string(), + } + )) + } + }, + }; - match qualified_payables.is_empty() { - true => { - self.mark_as_ended(logger); - Err(BeginScanError::NothingToProcess) - } - false => { - info!( - logger, - "Chose {} qualified debts to pay", - qualified_payables.len() - ); - let protected_payables = self.protect_payables(qualified_payables); - let outgoing_msg = QualifiedPayablesMessage::new( - protected_payables, - consuming_wallet, - response_skeleton_opt, - ); - Ok(outgoing_msg) - } + match log_message { + ErrorType::Temporary(msg) => match is_externally_triggered { + true => info!(logger, "{}", msg), + false => debug!(logger, "{}", msg), + }, + ErrorType::Permanent(msg) => warning!(logger, "{}", msg), } } - fn finish_scan(&mut self, message: SentPayables, logger: &Logger) -> Option { - let (sent_payables, err_opt) = separate_errors(&message, logger); - debug!( - logger, - "{}", - debugging_summary_after_error_separation(&sent_payables, &err_opt) - ); - - if !sent_payables.is_empty() { - self.mark_pending_payable(&sent_payables, logger); - } - self.handle_sent_payable_errors(err_opt, logger); - - self.mark_as_ended(logger); - message - .response_skeleton_opt - .map(|response_skeleton| NodeToUiMessage { - target: MessageTarget::ClientId(response_skeleton.client_id), - body: UiScanResponse {}.tmb(response_skeleton.context_id), - }) + fn timestamp_as_string(timestamp: SystemTime) -> String { + let offset_date_time = OffsetDateTime::from(timestamp); + offset_date_time + .format( + &parse(TIME_FORMATTING_STRING) + .expect("Error while parsing the time formatting string."), + ) + .expect("Error while formatting timestamp as string.") } - time_marking_methods!(Payables); - - as_any_ref_in_trait_impl!(); -} - -impl SolvencySensitivePaymentInstructor for PayableScanner { - fn try_skipping_payment_adjustment( - &self, - msg: BlockchainAgentWithContextMessage, - logger: &Logger, - ) -> Result, String> { - match self - .payment_adjuster - .search_for_indispensable_adjustment(&msg, logger) + fn scan_already_running_msg( + request_of: ScanType, + cross_scan_cause_opt: Option, + scan_started: SystemTime, + ) -> String { + let (blocking_scanner, request_spec) = if let Some(cross_scan_cause) = cross_scan_cause_opt { - Ok(None) => { - let protected = msg.protected_qualified_payables; - let unprotected = self.expose_payables(protected); - Ok(Either::Left(OutboundPaymentsInstructions::new( - unprotected, - msg.agent, - msg.response_skeleton_opt, - ))) - } - Ok(Some(adjustment)) => Ok(Either::Right(PreparedAdjustment::new(msg, adjustment))), - Err(_e) => todo!("be implemented with GH-711"), - } - } + (cross_scan_cause, format!("the {:?}", request_of)) + } else { + (request_of, "this".to_string()) + }; - fn perform_payment_adjustment( - &self, - setup: PreparedAdjustment, - logger: &Logger, - ) -> OutboundPaymentsInstructions { - let now = SystemTime::now(); - self.payment_adjuster.adjust_payments(setup, now, logger) + format!( + "{:?} scan was already initiated at {}. Hence, {} scan request will be ignored.", + blocking_scanner, + StartScanError::timestamp_as_string(scan_started), + request_spec + ) } } -impl MultistagePayableScanner for PayableScanner {} +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum ManulTriggerError { + AutomaticScanConflict, + UnnecessaryRequest { hint_opt: Option }, +} -impl PayableScanner { - pub fn new( - payable_dao: Box, - pending_payable_dao: Box, - payment_thresholds: Rc, - payment_adjuster: Box, - ) -> Self { - Self { - common: ScannerCommon::new(payment_thresholds), - payable_dao, - pending_payable_dao, - payable_threshold_gauge: Box::new(PayableThresholdsGaugeReal::default()), - payment_adjuster, - } +pub trait RealScannerMarker {} + +macro_rules! impl_real_scanner_marker { + ($($t:ty),*) => { + $(impl RealScannerMarker for $t {})* } +} - fn sniff_out_alarming_payables_and_maybe_log_them( - &self, - non_pending_payables: Vec, - logger: &Logger, - ) -> Vec { - fn pass_payables_and_drop_points( - qp_tp: impl Iterator, - ) -> Vec { - let (payables, _) = qp_tp.unzip::<_, _, Vec, Vec<_>>(); - payables - } +impl_real_scanner_marker!(PayableScanner, PendingPayableScanner, ReceivableScanner); - let qualified_payables_and_points_uncollected = - non_pending_payables.into_iter().flat_map(|account| { - self.payable_exceeded_threshold(&account, SystemTime::now()) - .map(|threshold_point| (account, threshold_point)) - }); - match logger.debug_enabled() { - false => pass_payables_and_drop_points(qualified_payables_and_points_uncollected), - true => { - let qualified_and_points_collected = - qualified_payables_and_points_uncollected.collect_vec(); - payables_debug_summary(&qualified_and_points_collected, logger); - pass_payables_and_drop_points(qualified_and_points_collected.into_iter()) +#[cfg(test)] +mod tests { + use crate::accountant::db_access_objects::failed_payable_dao::{ + FailedTx, FailureReason, FailureStatus, + }; + use crate::accountant::db_access_objects::sent_payable_dao::{Detection, SentTx, TxStatus}; + use crate::accountant::db_access_objects::test_utils::{make_failed_tx, make_sent_tx}; + use crate::accountant::db_access_objects::utils::from_unix_timestamp; + use crate::accountant::scanners::payable_scanner::msgs::InitialTemplatesMessage; + use crate::accountant::scanners::payable_scanner::test_utils::PayableScannerBuilder; + use crate::accountant::scanners::payable_scanner::tx_templates::initial::new::NewTxTemplates; + use crate::accountant::scanners::payable_scanner::tx_templates::initial::retry::{ + RetryTxTemplate, RetryTxTemplates, + }; + use crate::accountant::scanners::payable_scanner::utils::PayableScanResult; + use crate::accountant::scanners::payable_scanner::PayableScanner; + use crate::accountant::scanners::pending_payable_scanner::utils::{ + CurrentPendingPayables, PendingPayableScanResult, RecheckRequiringFailures, TxHashByTable, + }; + use crate::accountant::scanners::pending_payable_scanner::PendingPayableScanner; + use crate::accountant::scanners::receivable_scanner::ReceivableScanner; + use crate::accountant::scanners::test_utils::{ + assert_timestamps_from_str, parse_system_time_from_str, + trim_expected_timestamp_to_three_digits_nanos, MarkScanner, NullScanner, + PendingPayableCacheMock, ReplacementType, ScannerReplacement, + }; + use crate::accountant::scanners::{ + ManulTriggerError, Scanner, ScannerCommon, Scanners, StartScanError, StartableScanner, + }; + use crate::accountant::test_utils::{ + make_custom_payment_thresholds, make_qualified_and_unqualified_payables, + make_receivable_account, BannedDaoFactoryMock, BannedDaoMock, ConfigDaoFactoryMock, + FailedPayableDaoFactoryMock, FailedPayableDaoMock, PayableDaoFactoryMock, PayableDaoMock, + PendingPayableScannerBuilder, ReceivableDaoFactoryMock, ReceivableDaoMock, + ReceivableScannerBuilder, SentPayableDaoFactoryMock, SentPayableDaoMock, + }; + use crate::accountant::{ + PayableScanType, ReceivedPayments, RequestTransactionReceipts, ResponseSkeleton, ScanError, + SentPayables, TxReceiptsMessage, + }; + use crate::blockchain::blockchain_bridge::{BlockMarker, RetrieveTransactions}; + use crate::blockchain::blockchain_interface::data_structures::{ + BatchResults, BlockchainTransaction, StatusReadFromReceiptCheck, TxBlock, + }; + use crate::blockchain::errors::rpc_errors::{ + AppRpcError, AppRpcErrorKind, RemoteError, RemoteErrorKind, + }; + use crate::blockchain::errors::validation_status::{PreviousAttempts, ValidationStatus}; + use crate::blockchain::errors::BlockchainErrorKind; + use crate::blockchain::test_utils::{make_block_hash, make_tx_hash}; + use crate::database::rusqlite_wrappers::TransactionSafeWrapper; + use crate::database::test_utils::transaction_wrapper_mock::TransactionInnerWrapperMockBuilder; + use crate::db_config::mocks::ConfigDaoMock; + use crate::db_config::persistent_configuration::PersistentConfigError; + use crate::sub_lib::accountant::{ + DaoFactories, DetailedScanType, FinancialStatistics, PaymentThresholds, + }; + use crate::test_utils::persistent_configuration_mock::PersistentConfigurationMock; + use crate::test_utils::unshared_test_utils::arbitrary_id_stamp::ArbitraryIdStamp; + use crate::test_utils::{make_paying_wallet, make_wallet}; + use actix::Message; + use ethereum_types::U64; + use itertools::Either; + use masq_lib::logger::Logger; + use masq_lib::messages::ScanType; + use masq_lib::test_utils::logging::{init_test_logging, TestLogHandler}; + use masq_lib::test_utils::simple_clock::SimpleClockMock; + use masq_lib::ui_gateway::NodeToUiMessage; + use regex::Regex; + use rusqlite::{ffi, ErrorCode}; + use std::cell::RefCell; + use std::collections::BTreeSet; + use std::ops::Sub; + use std::panic::{catch_unwind, AssertUnwindSafe}; + use std::rc::Rc; + use std::sync::{Arc, Mutex}; + use std::time::{Duration, SystemTime}; + + impl Scanners { + pub fn replace_scanner(&mut self, replacement: ScannerReplacement) { + match replacement { + ScannerReplacement::Payable(ReplacementType::Real(scanner)) => { + self.payable = Box::new(scanner) + } + ScannerReplacement::Payable(ReplacementType::Mock(scanner)) => { + self.payable = Box::new(scanner) + } + ScannerReplacement::Payable(ReplacementType::Null) => { + self.payable = Box::new(NullScanner::default()) + } + ScannerReplacement::PendingPayable(ReplacementType::Real(scanner)) => { + self.pending_payable = Box::new(scanner) + } + ScannerReplacement::PendingPayable(ReplacementType::Mock(scanner)) => { + self.pending_payable = Box::new(scanner) + } + ScannerReplacement::PendingPayable(ReplacementType::Null) => { + self.pending_payable = Box::new(NullScanner::default()) + } + ScannerReplacement::Receivable(ReplacementType::Real(scanner)) => { + self.receivable = Box::new(scanner) + } + ScannerReplacement::Receivable(ReplacementType::Mock(scanner)) => { + self.receivable = Box::new(scanner) + } + ScannerReplacement::Receivable(ReplacementType::Null) => { + self.receivable = Box::new(NullScanner::default()) + } } } - } - fn payable_exceeded_threshold( - &self, - payable: &PayableAccount, - now: SystemTime, - ) -> Option { - let debt_age = now - .duration_since(payable.last_paid_timestamp) - .expect("Internal error") - .as_secs(); - - if self.payable_threshold_gauge.is_innocent_age( - debt_age, - self.common.payment_thresholds.maturity_threshold_sec, - ) { - return None; + pub fn reset_scan_started(&mut self, scan_type: ScanType, value: MarkScanner) { + match scan_type { + ScanType::Payables => { + Self::simple_scanner_timestamp_treatment(&mut *self.payable, value) + } + ScanType::PendingPayables => { + Self::simple_scanner_timestamp_treatment(&mut *self.pending_payable, value) + } + ScanType::Receivables => { + Self::simple_scanner_timestamp_treatment(&mut *self.receivable, value) + } + } } - if self.payable_threshold_gauge.is_innocent_balance( - payable.balance_wei, - gwei_to_wei(self.common.payment_thresholds.permanent_debt_allowed_gwei), - ) { - return None; + pub fn aware_of_unresolved_pending_payables(&self) -> bool { + self.aware_of_unresolved_pending_payable } - let threshold = self - .payable_threshold_gauge - .calculate_payout_threshold_in_gwei(&self.common.payment_thresholds, debt_age); - if payable.balance_wei > threshold { - Some(threshold) - } else { - None + pub fn set_aware_of_unresolved_pending_payables(&mut self, value: bool) { + self.aware_of_unresolved_pending_payable = value } - } - fn separate_existent_and_nonexistent_fingerprints<'a>( - &'a self, - sent_payables: &[&'a PendingPayable], - ) -> (Vec, Vec) { - let hashes = sent_payables - .iter() - .map(|pending_payable| pending_payable.hash) - .collect::>(); - let mut sent_payables_hashmap = sent_payables - .iter() - .map(|payable| (payable.hash, &payable.recipient_wallet)) - .collect::>(); - - let transaction_hashes = self.pending_payable_dao.fingerprints_rowids(&hashes); - let mut hashes_from_db = transaction_hashes - .rowid_results - .iter() - .map(|(_rowid, hash)| *hash) - .collect::>(); - for hash in &transaction_hashes.no_rowid_results { - hashes_from_db.insert(*hash); + fn simple_scanner_timestamp_treatment( + scanner: &mut Scanner, + value: MarkScanner, + ) where + Scanner: self::Scanner + ?Sized, + EndMessage: actix::Message, + { + match value { + MarkScanner::Ended(logger) => scanner.mark_as_ended(logger), + MarkScanner::Started(timestamp) => scanner.mark_as_started(timestamp), + } } - let sent_payables_hashes = hashes.iter().copied().collect::>(); - if !PayableScanner::is_symmetrical(sent_payables_hashes, hashes_from_db) { - panic!( - "Inconsistency in two maps, they cannot be matched by hashes. Data set directly \ - sent from BlockchainBridge: {:?}, set derived from the DB: {:?}", - sent_payables, transaction_hashes - ) + pub fn scan_started_at(&self, scan_type: ScanType) -> Option { + match scan_type { + ScanType::Payables => self.payable.scan_started_at(), + ScanType::PendingPayables => self.pending_payable.scan_started_at(), + ScanType::Receivables => self.receivable.scan_started_at(), + } } - - let pending_payables_with_rowid = transaction_hashes - .rowid_results - .into_iter() - .map(|(rowid, hash)| { - let wallet = sent_payables_hashmap - .remove(&hash) - .expect("expect transaction hash, but it disappear"); - PendingPayableMetadata::new(wallet, hash, Some(rowid)) - }) - .collect_vec(); - let pending_payables_without_rowid = transaction_hashes - .no_rowid_results - .into_iter() - .map(|hash| { - let wallet = sent_payables_hashmap - .remove(&hash) - .expect("expect transaction hash, but it disappear"); - PendingPayableMetadata::new(wallet, hash, None) - }) - .collect_vec(); - - (pending_payables_with_rowid, pending_payables_without_rowid) - } - - fn is_symmetrical( - sent_payables_hashes: HashSet, - fingerptint_hashes: HashSet, - ) -> bool { - sent_payables_hashes == fingerptint_hashes - } - - fn mark_pending_payable(&self, sent_payments: &[&PendingPayable], logger: &Logger) { - fn missing_fingerprints_msg(nonexistent: &[PendingPayableMetadata]) -> String { - format!( - "Expected pending payable fingerprints for {} were not found; system unreliable", - comma_joined_stringifiable(nonexistent, |pp_triple| format!( - "(tx: {:?}, to wallet: {})", - pp_triple.hash, pp_triple.recipient - )) - ) - } - fn ready_data_for_supply<'a>( - existent: &'a [PendingPayableMetadata], - ) -> Vec<(&'a Wallet, u64)> { - existent - .iter() - .map(|pp_triple| (pp_triple.recipient, pp_triple.rowid_opt.expectv("rowid"))) - .collect() - } - - let (existent, nonexistent) = - self.separate_existent_and_nonexistent_fingerprints(sent_payments); - let mark_pp_input_data = ready_data_for_supply(&existent); - if !mark_pp_input_data.is_empty() { - if let Err(e) = self - .payable_dao - .as_ref() - .mark_pending_payables_rowids(&mark_pp_input_data) - { - mark_pending_payable_fatal_error( - sent_payments, - &nonexistent, - e, - missing_fingerprints_msg, - logger, - ) - } - debug!( - logger, - "Payables {} marked as pending in the payable table", - comma_joined_stringifiable(sent_payments, |pending_p| format!( - "{:?}", - pending_p.hash - )) - ) - } - if !nonexistent.is_empty() { - panic!("{}", missing_fingerprints_msg(&nonexistent)) - } - } - - fn handle_sent_payable_errors( - &self, - err_opt: Option, - logger: &Logger, - ) { - if let Some(err) = err_opt { - match err { - LocallyCausedError(PayableTransactionError::Sending { hashes, .. }) - | RemotelyCausedErrors(hashes) => { - self.discard_failed_transactions_with_possible_fingerprints(hashes, logger) - } - non_fatal => - debug!( - logger, - "Ignoring a non-fatal error on our end from before the transactions are hashed: {:?}", - non_fatal - ) - } - } - } - - fn discard_failed_transactions_with_possible_fingerprints( - &self, - hashes_of_failed: Vec, - logger: &Logger, - ) { - fn serialize_hashes(hashes: &[H256]) -> String { - comma_joined_stringifiable(hashes, |hash| format!("{:?}", hash)) - } - let existent_and_nonexistent = self - .pending_payable_dao - .fingerprints_rowids(&hashes_of_failed); - let missing_fgp_err_msg_opt = err_msg_for_failure_with_expected_but_missing_fingerprints( - existent_and_nonexistent.no_rowid_results, - serialize_hashes, - ); - if !existent_and_nonexistent.rowid_results.is_empty() { - let (ids, hashes) = separate_rowids_and_hashes(existent_and_nonexistent.rowid_results); - warning!( - logger, - "Deleting fingerprints for failed transactions {}", - serialize_hashes(&hashes) - ); - if let Err(e) = self.pending_payable_dao.delete_fingerprints(&ids) { - if let Some(msg) = missing_fgp_err_msg_opt { - error!(logger, "{}", msg) - }; - panic!( - "Database corrupt: payable fingerprint deletion for transactions {} \ - failed due to {:?}", - serialize_hashes(&hashes), - e - ) - } - } - if let Some(msg) = missing_fgp_err_msg_opt { - panic!("{}", msg) - }; - } - - fn protect_payables(&self, payables: Vec) -> Obfuscated { - Obfuscated::obfuscate_vector(payables) - } - - fn expose_payables(&self, obfuscated: Obfuscated) -> Vec { - obfuscated.expose_vector() - } -} - -pub struct PendingPayableScanner { - pub common: ScannerCommon, - pub payable_dao: Box, - pub pending_payable_dao: Box, - pub when_pending_too_long_sec: u64, - pub financial_statistics: Rc>, -} - -impl Scanner for PendingPayableScanner { - fn begin_scan( - &mut self, - _irrelevant_wallet: Wallet, - timestamp: SystemTime, - response_skeleton_opt: Option, - logger: &Logger, - ) -> Result { - if let Some(timestamp) = self.scan_started_at() { - return Err(BeginScanError::ScanAlreadyRunning(timestamp)); - } - self.mark_as_started(timestamp); - info!(logger, "Scanning for pending payable"); - let filtered_pending_payable = self.pending_payable_dao.return_all_errorless_fingerprints(); - match filtered_pending_payable.is_empty() { - true => { - self.mark_as_ended(logger); - Err(BeginScanError::NothingToProcess) - } - false => { - debug!( - logger, - "Found {} pending payables to process", - filtered_pending_payable.len() - ); - Ok(RequestTransactionReceipts { - pending_payable: filtered_pending_payable, - response_skeleton_opt, - }) - } - } - } - - fn finish_scan( - &mut self, - message: ReportTransactionReceipts, - logger: &Logger, - ) -> Option { - let response_skeleton_opt = message.response_skeleton_opt; - - match message.fingerprints_with_receipts.is_empty() { - true => debug!(logger, "No transaction receipts found."), - false => { - debug!( - logger, - "Processing receipts for {} transactions", - message.fingerprints_with_receipts.len() - ); - let scan_report = self.handle_receipts_for_pending_transactions(message, logger); - self.process_transactions_by_reported_state(scan_report, logger); - } - } - - self.mark_as_ended(logger); - response_skeleton_opt.map(|response_skeleton| NodeToUiMessage { - target: MessageTarget::ClientId(response_skeleton.client_id), - body: UiScanResponse {}.tmb(response_skeleton.context_id), - }) - } - - time_marking_methods!(PendingPayables); - - as_any_ref_in_trait_impl!(); -} - -impl PendingPayableScanner { - pub fn new( - payable_dao: Box, - pending_payable_dao: Box, - payment_thresholds: Rc, - when_pending_too_long_sec: u64, - financial_statistics: Rc>, - ) -> Self { - Self { - common: ScannerCommon::new(payment_thresholds), - payable_dao, - pending_payable_dao, - when_pending_too_long_sec, - financial_statistics, - } - } - - fn handle_receipts_for_pending_transactions( - &self, - msg: ReportTransactionReceipts, - logger: &Logger, - ) -> PendingPayableScanReport { - let scan_report = PendingPayableScanReport::default(); - msg.fingerprints_with_receipts.into_iter().fold( - scan_report, - |scan_report_so_far, (receipt_result, fingerprint)| match receipt_result { - TransactionReceiptResult::RpcResponse(tx_receipt) => match tx_receipt.status { - TxStatus::Pending => handle_none_receipt( - scan_report_so_far, - fingerprint, - "none was given", - logger, - ), - TxStatus::Failed => { - handle_status_with_failure(scan_report_so_far, fingerprint, logger) - } - TxStatus::Succeeded(_) => { - handle_status_with_success(scan_report_so_far, fingerprint, logger) - } - }, - TransactionReceiptResult::LocalError(e) => handle_none_receipt( - scan_report_so_far, - fingerprint, - &format!("failed due to {}", e), - logger, - ), - }, - ) - } - - fn process_transactions_by_reported_state( - &mut self, - scan_report: PendingPayableScanReport, - logger: &Logger, - ) { - self.confirm_transactions(scan_report.confirmed, logger); - self.cancel_failed_transactions(scan_report.failures, logger); - self.update_remaining_fingerprints(scan_report.still_pending, logger) - } - - fn update_remaining_fingerprints(&self, ids: Vec, logger: &Logger) { - if !ids.is_empty() { - let rowids = PendingPayableId::rowids(&ids); - match self.pending_payable_dao.increment_scan_attempts(&rowids) { - Ok(_) => trace!( - logger, - "Updated records for rowids: {} ", - comma_joined_stringifiable(&rowids, |id| id.to_string()) - ), - Err(e) => panic!( - "Failure on incrementing scan attempts for fingerprints of {} due to {:?}", - PendingPayableId::serialize_hashes_to_string(&ids), - e - ), - } - } - } - - fn cancel_failed_transactions(&self, ids: Vec, logger: &Logger) { - if !ids.is_empty() { - //TODO this function is imperfect. It waits for GH-663 - let rowids = PendingPayableId::rowids(&ids); - match self.pending_payable_dao.mark_failures(&rowids) { - Ok(_) => warning!( - logger, - "Broken transactions {} marked as an error. You should take over the care \ - of those to make sure your debts are going to be settled properly. At the moment, \ - there is no automated process fixing that without your assistance", - PendingPayableId::serialize_hashes_to_string(&ids) - ), - Err(e) => panic!( - "Unsuccessful attempt for transactions {} \ - to mark fatal error at payable fingerprint due to {:?}; database unreliable", - PendingPayableId::serialize_hashes_to_string(&ids), - e - ), - } - } - } - - fn confirm_transactions( - &mut self, - fingerprints: Vec, - logger: &Logger, - ) { - fn serialize_hashes(fingerprints: &[PendingPayableFingerprint]) -> String { - comma_joined_stringifiable(fingerprints, |fgp| format!("{:?}", fgp.hash)) - } - - if !fingerprints.is_empty() { - if let Err(e) = self.payable_dao.transactions_confirmed(&fingerprints) { - panic!( - "Unable to cast confirmed pending payables {} into adjustment in the corresponding payable \ - records due to {:?}", serialize_hashes(&fingerprints), e - ) - } else { - self.add_to_the_total_of_paid_payable(&fingerprints, serialize_hashes, logger); - let rowids = fingerprints - .iter() - .map(|fingerprint| fingerprint.rowid) - .collect::>(); - if let Err(e) = self.pending_payable_dao.delete_fingerprints(&rowids) { - panic!("Unable to delete payable fingerprints {} of verified transactions due to {:?}", - serialize_hashes(&fingerprints), e) - } else { - info!( - logger, - "Transactions {} completed their confirmation process succeeding", - serialize_hashes(&fingerprints) - ) - } - } - } - } - - fn add_to_the_total_of_paid_payable( - &mut self, - fingerprints: &[PendingPayableFingerprint], - serialize_hashes: fn(&[PendingPayableFingerprint]) -> String, - logger: &Logger, - ) { - fingerprints.iter().for_each(|fingerprint| { - self.financial_statistics - .borrow_mut() - .total_paid_payable_wei += fingerprint.amount - }); - debug!( - logger, - "Confirmation of transactions {}; record for total paid payable was modified", - serialize_hashes(fingerprints) - ); - } -} - -pub struct ReceivableScanner { - pub common: ScannerCommon, - pub receivable_dao: Box, - pub banned_dao: Box, - pub persistent_configuration: Box, - pub financial_statistics: Rc>, -} - -impl Scanner for ReceivableScanner { - fn begin_scan( - &mut self, - earning_wallet: Wallet, - timestamp: SystemTime, - response_skeleton_opt: Option, - logger: &Logger, - ) -> Result { - if let Some(timestamp) = self.scan_started_at() { - return Err(BeginScanError::ScanAlreadyRunning(timestamp)); - } - self.mark_as_started(timestamp); - info!(logger, "Scanning for receivables to {}", earning_wallet); - self.scan_for_delinquencies(timestamp, logger); - - Ok(RetrieveTransactions { - recipient: earning_wallet, - response_skeleton_opt, - }) - } - - fn finish_scan(&mut self, msg: ReceivedPayments, logger: &Logger) -> Option { - self.handle_new_received_payments(&msg, logger); - self.mark_as_ended(logger); - msg.response_skeleton_opt - .map(|response_skeleton| NodeToUiMessage { - target: MessageTarget::ClientId(response_skeleton.client_id), - body: UiScanResponse {}.tmb(response_skeleton.context_id), - }) - } - - time_marking_methods!(Receivables); - - as_any_ref_in_trait_impl!(); - as_any_mut_in_trait_impl!(); -} - -impl ReceivableScanner { - pub fn new( - receivable_dao: Box, - banned_dao: Box, - persistent_configuration: Box, - payment_thresholds: Rc, - financial_statistics: Rc>, - ) -> Self { - Self { - common: ScannerCommon::new(payment_thresholds), - receivable_dao, - banned_dao, - persistent_configuration, - financial_statistics, - } - } - - fn handle_new_received_payments( - &mut self, - received_payments_msg: &ReceivedPayments, - logger: &Logger, - ) { - if received_payments_msg.transactions.is_empty() { - info!( - logger, - "No newly received payments were detected during the scanning process." - ); - let new_start_block = received_payments_msg.new_start_block; - if let BlockMarker::Value(start_block_number) = new_start_block { - match self - .persistent_configuration - .set_start_block(Some(start_block_number)) - { - Ok(()) => debug!(logger, "Start block updated to {}", start_block_number), - Err(e) => panic!( - "Attempt to set new start block to {} failed due to: {:?}", - start_block_number, e - ), - } - } - } else { - let mut txn = self.receivable_dao.as_mut().more_money_received( - received_payments_msg.timestamp, - &received_payments_msg.transactions, - ); - let new_start_block = received_payments_msg.new_start_block; - if let BlockMarker::Value(start_block_number) = new_start_block { - match self - .persistent_configuration - .set_start_block_from_txn(Some(start_block_number), &mut txn) - { - Ok(()) => debug!(logger, "Start block updated to {}", start_block_number), - Err(e) => panic!( - "Attempt to set new start block to {} failed due to: {:?}", - start_block_number, e - ), - } - } else { - unreachable!("Failed to get start_block while transactions were present"); - } - match txn.commit() { - Ok(_) => { - debug!(logger, "Received payments have been commited to database"); - } - Err(e) => panic!("Commit of received transactions failed: {:?}", e), - } - let total_newly_paid_receivable = received_payments_msg - .transactions - .iter() - .fold(0, |so_far, now| so_far + now.wei_amount); - - self.financial_statistics - .borrow_mut() - .total_paid_receivable_wei += total_newly_paid_receivable; - } - } - - pub fn scan_for_delinquencies(&self, timestamp: SystemTime, logger: &Logger) { - info!(logger, "Scanning for delinquencies"); - self.find_and_ban_delinquents(timestamp, logger); - self.find_and_unban_reformed_nodes(timestamp, logger); - } - - fn find_and_ban_delinquents(&self, timestamp: SystemTime, logger: &Logger) { - self.receivable_dao - .new_delinquencies(timestamp, self.common.payment_thresholds.as_ref()) - .into_iter() - .for_each(|account| { - self.banned_dao.ban(&account.wallet); - let (balance_str_wei, age) = balance_and_age(timestamp, &account); - info!( - logger, - "Wallet {} (balance: {} gwei, age: {} sec) banned for delinquency", - account.wallet, - balance_str_wei, - age.as_secs() - ) - }); - } - - fn find_and_unban_reformed_nodes(&self, timestamp: SystemTime, logger: &Logger) { - self.receivable_dao - .paid_delinquencies(self.common.payment_thresholds.as_ref()) - .into_iter() - .for_each(|account| { - self.banned_dao.unban(&account.wallet); - let (balance_str_wei, age) = balance_and_age(timestamp, &account); - info!( - logger, - "Wallet {} (balance: {} gwei, age: {} sec) is no longer delinquent: unbanned", - account.wallet, - balance_str_wei, - age.as_secs() - ) - }); - } -} - -#[derive(Debug, PartialEq, Eq)] -pub enum BeginScanError { - NothingToProcess, - NoConsumingWalletFound, - ScanAlreadyRunning(SystemTime), - CalledFromNullScanner, // Exclusive for tests -} - -impl BeginScanError { - pub fn handle_error( - &self, - logger: &Logger, - scan_type: ScanType, - is_externally_triggered: bool, - ) { - let log_message_opt = match self { - BeginScanError::NothingToProcess => Some(format!( - "There was nothing to process during {:?} scan.", - scan_type - )), - BeginScanError::ScanAlreadyRunning(timestamp) => Some(format!( - "{:?} scan was already initiated at {}. \ - Hence, this scan request will be ignored.", - scan_type, - BeginScanError::timestamp_as_string(timestamp) - )), - BeginScanError::NoConsumingWalletFound => Some(format!( - "Cannot initiate {:?} scan because no consuming wallet was found.", - scan_type - )), - BeginScanError::CalledFromNullScanner => match cfg!(test) { - true => None, - false => panic!("Null Scanner shouldn't be running inside production code."), - }, - }; - - if let Some(log_message) = log_message_opt { - match is_externally_triggered { - true => info!(logger, "{}", log_message), - false => debug!(logger, "{}", log_message), - } - } - } - - fn timestamp_as_string(timestamp: &SystemTime) -> String { - let offset_date_time = OffsetDateTime::from(*timestamp); - offset_date_time - .format( - &parse(TIME_FORMATTING_STRING) - .expect("Error while parsing the time formatting string."), - ) - .expect("Error while formatting timestamp as string.") - } -} - -pub struct ScanSchedulers { - pub schedulers: HashMap>, -} - -impl ScanSchedulers { - pub fn new(scan_intervals: ScanIntervals) -> Self { - let schedulers = HashMap::from_iter([ - ( - ScanType::Payables, - Box::new(PeriodicalScanScheduler:: { - handle: Box::new(NotifyLaterHandleReal::default()), - interval: scan_intervals.payable_scan_interval, - }) as Box, - ), - ( - ScanType::PendingPayables, - Box::new(PeriodicalScanScheduler:: { - handle: Box::new(NotifyLaterHandleReal::default()), - interval: scan_intervals.pending_payable_scan_interval, - }), - ), - ( - ScanType::Receivables, - Box::new(PeriodicalScanScheduler:: { - handle: Box::new(NotifyLaterHandleReal::default()), - interval: scan_intervals.receivable_scan_interval, - }), - ), - ]); - ScanSchedulers { schedulers } - } -} - -pub struct PeriodicalScanScheduler { - pub handle: Box>, - pub interval: Duration, -} - -pub trait ScanScheduler { - fn schedule(&self, ctx: &mut Context); - fn interval(&self) -> Duration { - intentionally_blank!() - } - - as_any_ref_in_trait!(); - as_any_mut_in_trait!(); -} - -impl ScanScheduler for PeriodicalScanScheduler { - fn schedule(&self, ctx: &mut Context) { - // the default of the message implies response_skeleton_opt to be None - // because scheduled scans don't respond - let _ = self.handle.notify_later(T::default(), self.interval, ctx); - } - fn interval(&self) -> Duration { - self.interval - } - - as_any_ref_in_trait_impl!(); - as_any_mut_in_trait_impl!(); -} -#[cfg(test)] -mod tests { - use crate::accountant::db_access_objects::payable_dao::{PayableAccount, PayableDaoError}; - use crate::accountant::db_access_objects::pending_payable_dao::{ - PendingPayable, PendingPayableDaoError, TransactionHashes, - }; - use crate::accountant::db_access_objects::utils::{from_time_t, to_time_t}; - use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::msgs::QualifiedPayablesMessage; - use crate::accountant::scanners::scanners_utils::payable_scanner_utils::PendingPayableMetadata; - use crate::accountant::scanners::scanners_utils::pending_payable_scanner_utils::{handle_none_status, handle_status_with_failure, PendingPayableScanReport}; - use crate::accountant::scanners::test_utils::protect_payables_in_test; - use crate::accountant::scanners::{ - BeginScanError, PayableScanner, PendingPayableScanner, ReceivableScanner, ScanSchedulers, - Scanner, ScannerCommon, Scanners, - }; - use crate::accountant::test_utils::{ - make_custom_payment_thresholds, make_payable_account, make_payables, - make_pending_payable_fingerprint, make_receivable_account, BannedDaoFactoryMock, - BannedDaoMock, ConfigDaoFactoryMock, PayableDaoFactoryMock, PayableDaoMock, - PayableScannerBuilder, PayableThresholdsGaugeMock, PendingPayableDaoFactoryMock, - PendingPayableDaoMock, PendingPayableScannerBuilder, ReceivableDaoFactoryMock, - ReceivableDaoMock, ReceivableScannerBuilder, - }; - use crate::accountant::{gwei_to_wei, PendingPayableId, ReceivedPayments, ReportTransactionReceipts, RequestTransactionReceipts, SentPayables, DEFAULT_PENDING_TOO_LONG_SEC}; - use crate::blockchain::blockchain_bridge::{BlockMarker, PendingPayableFingerprint, RetrieveTransactions}; - use crate::blockchain::blockchain_interface::data_structures::errors::PayableTransactionError; - use crate::blockchain::blockchain_interface::data_structures::{ - BlockchainTransaction, ProcessedPayableFallible, RpcPayableFailure, - }; - use crate::blockchain::test_utils::make_tx_hash; - use crate::database::rusqlite_wrappers::TransactionSafeWrapper; - use crate::database::test_utils::transaction_wrapper_mock::TransactionInnerWrapperMockBuilder; - use crate::db_config::mocks::ConfigDaoMock; - use crate::db_config::persistent_configuration::{PersistentConfigError}; - use crate::sub_lib::accountant::{ - DaoFactories, FinancialStatistics, PaymentThresholds, ScanIntervals, - DEFAULT_PAYMENT_THRESHOLDS, - }; - use crate::test_utils::persistent_configuration_mock::PersistentConfigurationMock; - use crate::test_utils::unshared_test_utils::arbitrary_id_stamp::ArbitraryIdStamp; - use crate::test_utils::{make_paying_wallet, make_wallet}; - use actix::{Message, System}; - use ethereum_types::U64; - use masq_lib::logger::Logger; - use masq_lib::messages::ScanType; - use masq_lib::test_utils::logging::{init_test_logging, TestLogHandler}; - use regex::Regex; - use rusqlite::{ffi, ErrorCode}; - use std::cell::RefCell; - use std::collections::HashSet; - use std::ops::Sub; - use std::panic::{catch_unwind, AssertUnwindSafe}; - use std::rc::Rc; - use std::sync::{Arc, Mutex}; - use std::time::{Duration, SystemTime}; - use web3::types::{TransactionReceipt, H256}; - use web3::Error; - use crate::blockchain::blockchain_interface::blockchain_interface_web3::lower_level_interface_web3::{TransactionBlock, TransactionReceiptResult, TxReceipt, TxStatus}; - - #[test] - fn scanners_struct_can_be_constructed_with_the_respective_scanners() { - let payable_dao_factory = PayableDaoFactoryMock::new() - .make_result(PayableDaoMock::new()) - .make_result(PayableDaoMock::new()); - let pending_payable_dao_factory = PendingPayableDaoFactoryMock::new() - .make_result(PendingPayableDaoMock::new()) - .make_result(PendingPayableDaoMock::new()); - let receivable_dao = ReceivableDaoMock::new(); - let receivable_dao_factory = ReceivableDaoFactoryMock::new().make_result(receivable_dao); - let banned_dao_factory = BannedDaoFactoryMock::new().make_result(BannedDaoMock::new()); - let set_params_arc = Arc::new(Mutex::new(vec![])); - let config_dao_mock = ConfigDaoMock::new() - .set_params(&set_params_arc) - .set_result(Ok(())); - let config_dao_factory = ConfigDaoFactoryMock::new().make_result(config_dao_mock); - let when_pending_too_long_sec = 1234; - let financial_statistics = FinancialStatistics { - total_paid_payable_wei: 1, - total_paid_receivable_wei: 2, - }; - let payment_thresholds = make_custom_payment_thresholds(); - let payment_thresholds_rc = Rc::new(payment_thresholds); - let initial_rc_count = Rc::strong_count(&payment_thresholds_rc); - - let mut scanners = Scanners::new( - DaoFactories { - payable_dao_factory: Box::new(payable_dao_factory), - pending_payable_dao_factory: Box::new(pending_payable_dao_factory), - receivable_dao_factory: Box::new(receivable_dao_factory), - banned_dao_factory: Box::new(banned_dao_factory), - config_dao_factory: Box::new(config_dao_factory), - }, - Rc::clone(&payment_thresholds_rc), - when_pending_too_long_sec, - Rc::new(RefCell::new(financial_statistics.clone())), - ); - - let payable_scanner = scanners - .payable - .as_any() - .downcast_ref::() - .unwrap(); - let pending_payable_scanner = scanners - .pending_payable - .as_any() - .downcast_ref::() - .unwrap(); - let receivable_scanner = scanners - .receivable - .as_any_mut() - .downcast_mut::() - .unwrap(); - assert_eq!( - payable_scanner.common.payment_thresholds.as_ref(), - &payment_thresholds - ); - assert_eq!(payable_scanner.common.initiated_at_opt.is_some(), false); - assert_eq!( - pending_payable_scanner.when_pending_too_long_sec, - when_pending_too_long_sec - ); - assert_eq!( - *pending_payable_scanner.financial_statistics.borrow(), - financial_statistics - ); - assert_eq!( - pending_payable_scanner.common.payment_thresholds.as_ref(), - &payment_thresholds - ); - assert_eq!( - pending_payable_scanner.common.initiated_at_opt.is_some(), - false - ); - assert_eq!( - *receivable_scanner.financial_statistics.borrow(), - financial_statistics - ); - assert_eq!( - receivable_scanner.common.payment_thresholds.as_ref(), - &payment_thresholds - ); - assert_eq!(receivable_scanner.common.initiated_at_opt.is_some(), false); - receivable_scanner - .persistent_configuration - .set_start_block(Some(136890)) - .unwrap(); - let set_params = set_params_arc.lock().unwrap(); - assert_eq!( - *set_params, - vec![("start_block".to_string(), Some("136890".to_string()))] - ); - assert_eq!( - Rc::strong_count(&payment_thresholds_rc), - initial_rc_count + 3 - ); - } - - #[test] - fn protected_payables_can_be_cast_from_and_back_to_vec_of_payable_accounts_by_payable_scanner() - { - let initial_unprotected = vec![make_payable_account(123), make_payable_account(456)]; - let subject = PayableScannerBuilder::new().build(); - - let protected = subject.protect_payables(initial_unprotected.clone()); - let again_unprotected: Vec = subject.expose_payables(protected); - - assert_eq!(initial_unprotected, again_unprotected) - } - - #[test] - fn payable_scanner_can_initiate_a_scan() { - init_test_logging(); - let test_name = "payable_scanner_can_initiate_a_scan"; - let consuming_wallet = make_paying_wallet(b"consuming wallet"); - let now = SystemTime::now(); - let (qualified_payable_accounts, _, all_non_pending_payables) = - make_payables(now, &PaymentThresholds::default()); - let payable_dao = - PayableDaoMock::new().non_pending_payables_result(all_non_pending_payables); - let mut subject = PayableScannerBuilder::new() - .payable_dao(payable_dao) - .build(); - - let result = - subject.begin_scan(consuming_wallet.clone(), now, None, &Logger::new(test_name)); - - let timestamp = subject.scan_started_at(); - assert_eq!(timestamp, Some(now)); - assert_eq!( - result, - Ok(QualifiedPayablesMessage { - protected_qualified_payables: protect_payables_in_test( - qualified_payable_accounts.clone() - ), - consuming_wallet, - response_skeleton_opt: None, - }) - ); - TestLogHandler::new().assert_logs_match_in_order(vec![ - &format!("INFO: {test_name}: Scanning for payables"), - &format!( - "INFO: {test_name}: Chose {} qualified debts to pay", - qualified_payable_accounts.len() - ), - ]) - } - - #[test] - fn payable_scanner_throws_error_when_a_scan_is_already_running() { - let consuming_wallet = make_paying_wallet(b"consuming wallet"); - let now = SystemTime::now(); - let (_, _, all_non_pending_payables) = make_payables(now, &PaymentThresholds::default()); - let payable_dao = - PayableDaoMock::new().non_pending_payables_result(all_non_pending_payables); - let mut subject = PayableScannerBuilder::new() - .payable_dao(payable_dao) - .build(); - let _result = subject.begin_scan(consuming_wallet.clone(), now, None, &Logger::new("test")); - - let run_again_result = subject.begin_scan( - consuming_wallet, - SystemTime::now(), - None, - &Logger::new("test"), - ); - - let is_scan_running = subject.scan_started_at().is_some(); - assert_eq!(is_scan_running, true); - assert_eq!( - run_again_result, - Err(BeginScanError::ScanAlreadyRunning(now)) - ); - } - - #[test] - fn payable_scanner_throws_error_in_case_no_qualified_payable_is_found() { - let consuming_wallet = make_paying_wallet(b"consuming wallet"); - let now = SystemTime::now(); - let (_, unqualified_payable_accounts, _) = - make_payables(now, &PaymentThresholds::default()); - let payable_dao = - PayableDaoMock::new().non_pending_payables_result(unqualified_payable_accounts); - let mut subject = PayableScannerBuilder::new() - .payable_dao(payable_dao) - .build(); - - let result = subject.begin_scan(consuming_wallet, now, None, &Logger::new("test")); - - let is_scan_running = subject.scan_started_at().is_some(); - assert_eq!(is_scan_running, false); - assert_eq!(result, Err(BeginScanError::NothingToProcess)); - } - - #[test] - fn payable_scanner_handles_sent_payable_message() { - init_test_logging(); - let test_name = "payable_scanner_handles_sent_payable_message"; - let fingerprints_rowids_params_arc = Arc::new(Mutex::new(vec![])); - let mark_pending_payables_params_arc = Arc::new(Mutex::new(vec![])); - let delete_fingerprints_params_arc = Arc::new(Mutex::new(vec![])); - let correct_payable_hash_1 = make_tx_hash(0x6f); - let correct_payable_rowid_1 = 125; - let correct_payable_wallet_1 = make_wallet("tralala"); - let correct_pending_payable_1 = - PendingPayable::new(correct_payable_wallet_1.clone(), correct_payable_hash_1); - let failure_payable_hash_2 = make_tx_hash(0xde); - let failure_payable_rowid_2 = 126; - let failure_payable_wallet_2 = make_wallet("hihihi"); - let failure_payable_2 = RpcPayableFailure { - rpc_error: Error::InvalidResponse( - "Learn how to write before you send your garbage!".to_string(), - ), - recipient_wallet: failure_payable_wallet_2, - hash: failure_payable_hash_2, - }; - let correct_payable_hash_3 = make_tx_hash(0x14d); - let correct_payable_rowid_3 = 127; - let correct_payable_wallet_3 = make_wallet("booga"); - let correct_pending_payable_3 = - PendingPayable::new(correct_payable_wallet_3.clone(), correct_payable_hash_3); - let pending_payable_dao = PendingPayableDaoMock::default() - .fingerprints_rowids_params(&fingerprints_rowids_params_arc) - .fingerprints_rowids_result(TransactionHashes { - rowid_results: vec![ - (correct_payable_rowid_3, correct_payable_hash_3), - (correct_payable_rowid_1, correct_payable_hash_1), - ], - no_rowid_results: vec![], - }) - .fingerprints_rowids_result(TransactionHashes { - rowid_results: vec![(failure_payable_rowid_2, failure_payable_hash_2)], - no_rowid_results: vec![], - }) - .delete_fingerprints_params(&delete_fingerprints_params_arc) - .delete_fingerprints_result(Ok(())); - let payable_dao = PayableDaoMock::new() - .mark_pending_payables_rowids_params(&mark_pending_payables_params_arc) - .mark_pending_payables_rowids_result(Ok(())) - .mark_pending_payables_rowids_result(Ok(())); - let mut subject = PayableScannerBuilder::new() - .payable_dao(payable_dao) - .pending_payable_dao(pending_payable_dao) - .build(); - let logger = Logger::new(test_name); - let sent_payable = SentPayables { - payment_procedure_result: Ok(vec![ - ProcessedPayableFallible::Correct(correct_pending_payable_1), - ProcessedPayableFallible::Failed(failure_payable_2), - ProcessedPayableFallible::Correct(correct_pending_payable_3), - ]), - response_skeleton_opt: None, - }; - subject.mark_as_started(SystemTime::now()); - - let message_opt = subject.finish_scan(sent_payable, &logger); - - let is_scan_running = subject.scan_started_at().is_some(); - assert_eq!(message_opt, None); - assert_eq!(is_scan_running, false); - let fingerprints_rowids_params = fingerprints_rowids_params_arc.lock().unwrap(); - assert_eq!( - *fingerprints_rowids_params, - vec![ - vec![correct_payable_hash_1, correct_payable_hash_3], - vec![failure_payable_hash_2] - ] - ); - let mark_pending_payables_params = mark_pending_payables_params_arc.lock().unwrap(); - assert_eq!( - *mark_pending_payables_params, - vec![vec![ - (correct_payable_wallet_3, correct_payable_rowid_3), - (correct_payable_wallet_1, correct_payable_rowid_1), - ]] - ); - let delete_fingerprints_params = delete_fingerprints_params_arc.lock().unwrap(); - assert_eq!( - *delete_fingerprints_params, - vec![vec![failure_payable_rowid_2]] - ); - let log_handler = TestLogHandler::new(); - log_handler.assert_logs_contain_in_order(vec![ - &format!( - "WARN: {test_name}: Remote transaction failure: 'Got invalid response: Learn how to write before you send your garbage!' \ - for payment to 0x0000000000000000000000000000686968696869 and transaction hash \ - 0x00000000000000000000000000000000000000000000000000000000000000de. Please check your blockchain service URL configuration" - ), - &format!("DEBUG: {test_name}: Got 2 properly sent payables of 3 attempts"), - &format!( - "DEBUG: {test_name}: Payables 0x000000000000000000000000000000000000000000000000000000000000006f, \ - 0x000000000000000000000000000000000000000000000000000000000000014d marked as pending in the payable table" - ), - &format!( - "WARN: {test_name}: Deleting fingerprints for failed transactions \ - 0x00000000000000000000000000000000000000000000000000000000000000de" - ), - ]); - log_handler.exists_log_matching(&format!( - "INFO: {test_name}: The Payables scan ended in \\d+ms." - )); - } - - #[test] - fn entries_must_be_kept_consistent_and_aligned() { - let wallet_1 = make_wallet("abc"); - let hash_1 = make_tx_hash(123); - let wallet_2 = make_wallet("def"); - let hash_2 = make_tx_hash(345); - let wallet_3 = make_wallet("ghi"); - let hash_3 = make_tx_hash(546); - let wallet_4 = make_wallet("jkl"); - let hash_4 = make_tx_hash(678); - let pending_payables_owned = vec![ - PendingPayable::new(wallet_1.clone(), hash_1), - PendingPayable::new(wallet_2.clone(), hash_2), - PendingPayable::new(wallet_3.clone(), hash_3), - PendingPayable::new(wallet_4.clone(), hash_4), - ]; - let pending_payables_ref = pending_payables_owned - .iter() - .collect::>(); - let pending_payable_dao = - PendingPayableDaoMock::new().fingerprints_rowids_result(TransactionHashes { - rowid_results: vec![(4, hash_4), (1, hash_1), (3, hash_3), (2, hash_2)], - no_rowid_results: vec![], - }); - let subject = PayableScannerBuilder::new() - .pending_payable_dao(pending_payable_dao) - .build(); - - let (existent, nonexistent) = - subject.separate_existent_and_nonexistent_fingerprints(&pending_payables_ref); - - assert_eq!( - existent, - vec![ - PendingPayableMetadata::new(&wallet_4, hash_4, Some(4)), - PendingPayableMetadata::new(&wallet_1, hash_1, Some(1)), - PendingPayableMetadata::new(&wallet_3, hash_3, Some(3)), - PendingPayableMetadata::new(&wallet_2, hash_2, Some(2)), - ] - ); - assert!(nonexistent.is_empty()) - } - - struct TestingMismatchedDataAboutPendingPayables { - pending_payables: Vec, - common_hash_1: H256, - common_hash_3: H256, - intruder_for_hash_2: H256, - } - - fn prepare_values_for_mismatched_setting() -> TestingMismatchedDataAboutPendingPayables { - let hash_1 = make_tx_hash(123); - let hash_2 = make_tx_hash(456); - let hash_3 = make_tx_hash(789); - let intruder = make_tx_hash(567); - let pending_payables = vec![ - PendingPayable::new(make_wallet("abc"), hash_1), - PendingPayable::new(make_wallet("def"), hash_2), - PendingPayable::new(make_wallet("ghi"), hash_3), - ]; - TestingMismatchedDataAboutPendingPayables { - pending_payables, - common_hash_1: hash_1, - common_hash_3: hash_3, - intruder_for_hash_2: intruder, - } - } - - #[test] - #[should_panic( - expected = "Inconsistency in two maps, they cannot be matched by hashes. \ - Data set directly sent from BlockchainBridge: \ - [PendingPayable { recipient_wallet: Wallet { kind: Address(0x0000000000000000000000000000000000616263) }, \ - hash: 0x000000000000000000000000000000000000000000000000000000000000007b }, \ - PendingPayable { recipient_wallet: Wallet { kind: Address(0x0000000000000000000000000000000000646566) }, \ - hash: 0x00000000000000000000000000000000000000000000000000000000000001c8 }, \ - PendingPayable { recipient_wallet: Wallet { kind: Address(0x0000000000000000000000000000000000676869) }, \ - hash: 0x0000000000000000000000000000000000000000000000000000000000000315 }], \ - set derived from the DB: \ - TransactionHashes { rowid_results: \ - [(4, 0x000000000000000000000000000000000000000000000000000000000000007b), \ - (1, 0x0000000000000000000000000000000000000000000000000000000000000237), \ - (3, 0x0000000000000000000000000000000000000000000000000000000000000315)], \ - no_rowid_results: [] }" - )] - fn two_sourced_information_of_new_pending_payables_and_their_fingerprints_is_not_symmetrical() { - let vals = prepare_values_for_mismatched_setting(); - let pending_payables_ref = vals - .pending_payables - .iter() - .collect::>(); - let pending_payable_dao = - PendingPayableDaoMock::new().fingerprints_rowids_result(TransactionHashes { - rowid_results: vec![ - (4, vals.common_hash_1), - (1, vals.intruder_for_hash_2), - (3, vals.common_hash_3), - ], - no_rowid_results: vec![], - }); - let subject = PayableScannerBuilder::new() - .pending_payable_dao(pending_payable_dao) - .build(); - - subject.separate_existent_and_nonexistent_fingerprints(&pending_payables_ref); - } - - #[test] - fn symmetry_check_happy_path() { - let hash_1 = make_tx_hash(123); - let hash_2 = make_tx_hash(456); - let hash_3 = make_tx_hash(789); - let pending_payables_sent_from_blockchain_bridge = vec![ - PendingPayable::new(make_wallet("abc"), hash_1), - PendingPayable::new(make_wallet("def"), hash_2), - PendingPayable::new(make_wallet("ghi"), hash_3), - ]; - let pending_payables_ref = pending_payables_sent_from_blockchain_bridge - .iter() - .map(|ppayable| ppayable.hash) - .collect::>(); - let hashes_from_fingerprints = vec![(hash_1, 3), (hash_2, 5), (hash_3, 6)] - .iter() - .map(|(hash, _id)| *hash) - .collect::>(); - - let result = PayableScanner::is_symmetrical(pending_payables_ref, hashes_from_fingerprints); - - assert_eq!(result, true) - } - - #[test] - fn symmetry_check_sad_path_for_intruder() { - let vals = prepare_values_for_mismatched_setting(); - let pending_payables_ref_from_blockchain_bridge = vals - .pending_payables - .iter() - .map(|ppayable| ppayable.hash) - .collect::>(); - let rowids_and_hashes_from_fingerprints = vec![ - (vals.common_hash_1, 3), - (vals.intruder_for_hash_2, 5), - (vals.common_hash_3, 6), - ] - .iter() - .map(|(hash, _rowid)| *hash) - .collect::>(); - - let result = PayableScanner::is_symmetrical( - pending_payables_ref_from_blockchain_bridge, - rowids_and_hashes_from_fingerprints, - ); - - assert_eq!(result, false) - } - - #[test] - fn symmetry_check_indifferent_to_wrong_order_on_the_input() { - let hash_1 = make_tx_hash(123); - let hash_2 = make_tx_hash(456); - let hash_3 = make_tx_hash(789); - let pending_payables_sent_from_blockchain_bridge = vec![ - PendingPayable::new(make_wallet("abc"), hash_1), - PendingPayable::new(make_wallet("def"), hash_2), - PendingPayable::new(make_wallet("ghi"), hash_3), - ]; - let bb_returned_p_payables_ref = pending_payables_sent_from_blockchain_bridge - .iter() - .map(|ppayable| ppayable.hash) - .collect::>(); - // Not in ascending order - let rowids_and_hashes_from_fingerprints = vec![(hash_1, 3), (hash_3, 5), (hash_2, 6)] - .iter() - .map(|(hash, _id)| *hash) - .collect::>(); - - let result = PayableScanner::is_symmetrical( - bb_returned_p_payables_ref, - rowids_and_hashes_from_fingerprints, - ); - - assert_eq!(result, true) - } - - #[test] - #[should_panic( - expected = "Expected pending payable fingerprints for (tx: 0x0000000000000000000000000000000000000000000000000000000000000315, \ - to wallet: 0x000000000000000000000000000000626f6f6761), (tx: 0x000000000000000000000000000000000000000000000000000000000000007b, \ - to wallet: 0x00000000000000000000000000000061676f6f62) were not found; system unreliable" - )] - fn payable_scanner_panics_when_fingerprints_for_correct_payments_not_found() { - let hash_1 = make_tx_hash(0x315); - let payment_1 = PendingPayable::new(make_wallet("booga"), hash_1); - let hash_2 = make_tx_hash(0x7b); - let payment_2 = PendingPayable::new(make_wallet("agoob"), hash_2); - let pending_payable_dao = - PendingPayableDaoMock::default().fingerprints_rowids_result(TransactionHashes { - rowid_results: vec![], - no_rowid_results: vec![hash_1, hash_2], - }); - let payable_dao = PayableDaoMock::new(); - let mut subject = PayableScannerBuilder::new() - .payable_dao(payable_dao) - .pending_payable_dao(pending_payable_dao) - .build(); - let sent_payable = SentPayables { - payment_procedure_result: Ok(vec![ - ProcessedPayableFallible::Correct(payment_1), - ProcessedPayableFallible::Correct(payment_2), - ]), - response_skeleton_opt: None, - }; - - let _ = subject.finish_scan(sent_payable, &Logger::new("test")); - } - - fn assert_panic_from_failing_to_mark_pending_payable_rowid( - test_name: &str, - pending_payable_dao: PendingPayableDaoMock, - hash_1: H256, - hash_2: H256, - ) { - let payable_1 = PendingPayable::new(make_wallet("blah111"), hash_1); - let payable_2 = PendingPayable::new(make_wallet("blah222"), hash_2); - let payable_dao = PayableDaoMock::new().mark_pending_payables_rowids_result(Err( - PayableDaoError::SignConversion(9999999999999), - )); - let mut subject = PayableScannerBuilder::new() - .payable_dao(payable_dao) - .pending_payable_dao(pending_payable_dao) - .build(); - let sent_payables = SentPayables { - payment_procedure_result: Ok(vec![ - ProcessedPayableFallible::Correct(payable_1), - ProcessedPayableFallible::Correct(payable_2), - ]), - response_skeleton_opt: None, - }; - - let caught_panic_in_err = catch_unwind(AssertUnwindSafe(|| { - subject.finish_scan(sent_payables, &Logger::new(test_name)) - })); - - let caught_panic = caught_panic_in_err.unwrap_err(); - let panic_msg = caught_panic.downcast_ref::().unwrap(); - assert_eq!( - panic_msg, - "Unable to create a mark in the payable table for wallets 0x00000000000\ - 000000000000000626c6168313131, 0x00000000000000000000000000626c6168323232 due to \ - SignConversion(9999999999999)" - ); - } - - #[test] - fn payable_scanner_mark_pending_payable_only_panics_all_fingerprints_found() { - init_test_logging(); - let test_name = "payable_scanner_mark_pending_payable_only_panics_all_fingerprints_found"; - let hash_1 = make_tx_hash(248); - let hash_2 = make_tx_hash(139); - let pending_payable_dao = - PendingPayableDaoMock::default().fingerprints_rowids_result(TransactionHashes { - rowid_results: vec![(7879, hash_1), (7881, hash_2)], - no_rowid_results: vec![], - }); - - assert_panic_from_failing_to_mark_pending_payable_rowid( - test_name, - pending_payable_dao, - hash_1, - hash_2, - ); - - // Missing fingerprints, being an additional issue, would provoke an error log, but not here. - TestLogHandler::new().exists_no_log_containing(&format!("ERROR: {test_name}:")); - } - - #[test] - fn payable_scanner_mark_pending_payable_panics_nonexistent_fingerprints_also_found() { - init_test_logging(); - let test_name = - "payable_scanner_mark_pending_payable_panics_nonexistent_fingerprints_also_found"; - let hash_1 = make_tx_hash(0xff); - let hash_2 = make_tx_hash(0xf8); - let pending_payable_dao = - PendingPayableDaoMock::default().fingerprints_rowids_result(TransactionHashes { - rowid_results: vec![(7881, hash_1)], - no_rowid_results: vec![hash_2], - }); - - assert_panic_from_failing_to_mark_pending_payable_rowid( - test_name, - pending_payable_dao, - hash_1, - hash_2, - ); - - TestLogHandler::new().exists_log_containing(&format!("ERROR: {test_name}: Expected pending payable \ - fingerprints for (tx: 0x00000000000000000000000000000000000000000000000000000000000000f8, to wallet: \ - 0x00000000000000000000000000626c6168323232) were not found; system unreliable")); - } - - #[test] - fn payable_scanner_is_facing_failed_transactions_and_their_fingerprints_exist() { - init_test_logging(); - let test_name = - "payable_scanner_is_facing_failed_transactions_and_their_fingerprints_exist"; - let fingerprints_rowids_params_arc = Arc::new(Mutex::new(vec![])); - let delete_fingerprints_params_arc = Arc::new(Mutex::new(vec![])); - let hash_tx_1 = make_tx_hash(0x15b3); - let hash_tx_2 = make_tx_hash(0x3039); - let first_fingerprint_rowid = 3; - let second_fingerprint_rowid = 5; - let system = System::new(test_name); - let pending_payable_dao = PendingPayableDaoMock::default() - .fingerprints_rowids_params(&fingerprints_rowids_params_arc) - .fingerprints_rowids_result(TransactionHashes { - rowid_results: vec![ - (first_fingerprint_rowid, hash_tx_1), - (second_fingerprint_rowid, hash_tx_2), - ], - no_rowid_results: vec![], - }) - .delete_fingerprints_params(&delete_fingerprints_params_arc) - .delete_fingerprints_result(Ok(())); - let mut subject = PayableScannerBuilder::new() - .pending_payable_dao(pending_payable_dao) - .build(); - let logger = Logger::new(test_name); - let sent_payable = SentPayables { - payment_procedure_result: Err(PayableTransactionError::Sending { - msg: "Attempt failed".to_string(), - hashes: vec![hash_tx_1, hash_tx_2], - }), - response_skeleton_opt: None, - }; - - let result = subject.finish_scan(sent_payable, &logger); - - System::current().stop(); - system.run(); - assert_eq!(result, None); - let fingerprints_rowids_params = fingerprints_rowids_params_arc.lock().unwrap(); - assert_eq!( - *fingerprints_rowids_params, - vec![vec![hash_tx_1, hash_tx_2]] - ); - let delete_fingerprints_params = delete_fingerprints_params_arc.lock().unwrap(); - assert_eq!( - *delete_fingerprints_params, - vec![vec![first_fingerprint_rowid, second_fingerprint_rowid]] - ); - let log_handler = TestLogHandler::new(); - log_handler.exists_log_containing(&format!("WARN: {test_name}: \ - Any persisted data from failed process will be deleted. Caused by: Sending phase: \"Attempt failed\". \ - Signed and hashed transactions: 0x000000000000000000000000000000000000000000000000000\ - 00000000015b3, 0x0000000000000000000000000000000000000000000000000000000000003039")); - log_handler.exists_log_containing( - &format!("WARN: {test_name}: \ - Deleting fingerprints for failed transactions 0x00000000000000000000000000000000000000000000000000000000000015b3, \ - 0x0000000000000000000000000000000000000000000000000000000000003039", - )); - // we haven't supplied any result for mark_pending_payable() and so it's proved uncalled - } - - #[test] - fn payable_scanner_handles_error_born_too_early_to_see_transaction_hash() { - init_test_logging(); - let test_name = "payable_scanner_handles_error_born_too_early_to_see_transaction_hash"; - let sent_payable = SentPayables { - payment_procedure_result: Err(PayableTransactionError::Signing( - "Some error".to_string(), - )), - response_skeleton_opt: None, - }; - let mut subject = PayableScannerBuilder::new().build(); - - subject.finish_scan(sent_payable, &Logger::new(test_name)); - - let log_handler = TestLogHandler::new(); - log_handler.exists_log_containing(&format!( - "DEBUG: {test_name}: Got 0 properly sent payables of an unknown number of attempts" - )); - log_handler.exists_log_containing(&format!( - "DEBUG: {test_name}: Ignoring a non-fatal error on our end from before \ - the transactions are hashed: LocallyCausedError(Signing(\"Some error\"))" - )); - } - - #[test] - fn payable_scanner_finds_fingerprints_for_failed_payments_but_panics_at_their_deletion() { - let test_name = - "payable_scanner_finds_fingerprints_for_failed_payments_but_panics_at_their_deletion"; - let rowid_1 = 4; - let hash_1 = make_tx_hash(0x7b); - let rowid_2 = 6; - let hash_2 = make_tx_hash(0x315); - let sent_payable = SentPayables { - payment_procedure_result: Err(PayableTransactionError::Sending { - msg: "blah".to_string(), - hashes: vec![hash_1, hash_2], - }), - response_skeleton_opt: None, - }; - let pending_payable_dao = PendingPayableDaoMock::default() - .fingerprints_rowids_result(TransactionHashes { - rowid_results: vec![(rowid_1, hash_1), (rowid_2, hash_2)], - no_rowid_results: vec![], - }) - .delete_fingerprints_result(Err(PendingPayableDaoError::RecordDeletion( - "Gosh, I overslept without an alarm set".to_string(), - ))); - let mut subject = PayableScannerBuilder::new() - .pending_payable_dao(pending_payable_dao) - .build(); - - let caught_panic_in_err = catch_unwind(AssertUnwindSafe(|| { - subject.finish_scan(sent_payable, &Logger::new(test_name)) - })); - - let caught_panic = caught_panic_in_err.unwrap_err(); - let panic_msg = caught_panic.downcast_ref::().unwrap(); - assert_eq!( - panic_msg, - "Database corrupt: payable fingerprint deletion for transactions \ - 0x000000000000000000000000000000000000000000000000000000000000007b, 0x00000000000000000000\ - 00000000000000000000000000000000000000000315 failed due to RecordDeletion(\"Gosh, I overslept \ - without an alarm set\")"); - let log_handler = TestLogHandler::new(); - // There is a possible situation when we stumble over missing fingerprints, so we log it. - // Here we don't and so any ERROR log shouldn't turn up - log_handler.exists_no_log_containing(&format!("ERROR: {}", test_name)) - } - - #[test] - fn payable_scanner_panics_for_missing_fingerprints_but_deletion_of_some_works() { - init_test_logging(); - let test_name = - "payable_scanner_panics_for_missing_fingerprints_but_deletion_of_some_works"; - let hash_1 = make_tx_hash(0x1b669); - let hash_2 = make_tx_hash(0x3039); - let hash_3 = make_tx_hash(0x223d); - let pending_payable_dao = PendingPayableDaoMock::default() - .fingerprints_rowids_result(TransactionHashes { - rowid_results: vec![(333, hash_1)], - no_rowid_results: vec![hash_2, hash_3], - }) - .delete_fingerprints_result(Ok(())); - let mut subject = PayableScannerBuilder::new() - .pending_payable_dao(pending_payable_dao) - .build(); - let sent_payable = SentPayables { - payment_procedure_result: Err(PayableTransactionError::Sending { - msg: "SQLite migraine".to_string(), - hashes: vec![hash_1, hash_2, hash_3], - }), - response_skeleton_opt: None, - }; - - let caught_panic_in_err = catch_unwind(AssertUnwindSafe(|| { - subject.finish_scan(sent_payable, &Logger::new(test_name)) - })); - - let caught_panic = caught_panic_in_err.unwrap_err(); - let panic_msg = caught_panic.downcast_ref::().unwrap(); - assert_eq!(panic_msg, "Ran into failed transactions 0x0000000000000000000000000000000000\ - 000000000000000000000000003039, 0x000000000000000000000000000000000000000000000000000000000000223d \ - with missing fingerprints. System no longer reliable"); - let log_handler = TestLogHandler::new(); - log_handler.exists_log_containing( - &format!("WARN: {test_name}: Any persisted data from failed process will be deleted. Caused by: \ - Sending phase: \"SQLite migraine\". Signed and hashed transactions: \ - 0x000000000000000000000000000000000000000000000000000000000001b669, \ - 0x0000000000000000000000000000000000000000000000000000000000003039, \ - 0x000000000000000000000000000000000000000000000000000000000000223d")); - log_handler.exists_log_containing(&format!( - "WARN: {test_name}: Deleting fingerprints for failed transactions {:?}", - hash_1 - )); - } - - #[test] - fn payable_scanner_for_failed_rpcs_one_fingerprint_missing_and_deletion_of_the_other_one_fails() - { - // Two fatal failures at once, missing fingerprints and fingerprint deletion error are both - // legitimate reasons for panic - init_test_logging(); - let test_name = "payable_scanner_for_failed_rpcs_one_fingerprint_missing_and_deletion_of_the_other_one_fails"; - let existent_record_hash = make_tx_hash(0xb26e); - let nonexistent_record_hash = make_tx_hash(0x4d2); - let pending_payable_dao = PendingPayableDaoMock::default() - .fingerprints_rowids_result(TransactionHashes { - rowid_results: vec![(45, existent_record_hash)], - no_rowid_results: vec![nonexistent_record_hash], - }) - .delete_fingerprints_result(Err(PendingPayableDaoError::RecordDeletion( - "Another failure. Really???".to_string(), - ))); - let mut subject = PayableScannerBuilder::new() - .pending_payable_dao(pending_payable_dao) - .build(); - let failed_payment_1 = RpcPayableFailure { - rpc_error: Error::Unreachable, - recipient_wallet: make_wallet("abc"), - hash: existent_record_hash, - }; - let failed_payment_2 = RpcPayableFailure { - rpc_error: Error::Internal, - recipient_wallet: make_wallet("def"), - hash: nonexistent_record_hash, - }; - let sent_payable = SentPayables { - payment_procedure_result: Ok(vec![ - ProcessedPayableFallible::Failed(failed_payment_1), - ProcessedPayableFallible::Failed(failed_payment_2), - ]), - response_skeleton_opt: None, - }; - - let caught_panic_in_err = catch_unwind(AssertUnwindSafe(|| { - subject.finish_scan(sent_payable, &Logger::new(test_name)) - })); - - let caught_panic = caught_panic_in_err.unwrap_err(); - let panic_msg = caught_panic.downcast_ref::().unwrap(); - assert_eq!( - panic_msg, - "Database corrupt: payable fingerprint deletion for transactions 0x00000000000000000000000\ - 0000000000000000000000000000000000000b26e failed due to RecordDeletion(\"Another failure. Really???\")"); - let log_handler = TestLogHandler::new(); - log_handler.exists_log_containing(&format!("WARN: {test_name}: Remote transaction failure: 'Server is unreachable' \ - for payment to 0x0000000000000000000000000000000000616263 and transaction hash 0x00000000000000000000000\ - 0000000000000000000000000000000000000b26e. Please check your blockchain service URL configuration.")); - log_handler.exists_log_containing(&format!("WARN: {test_name}: Remote transaction failure: 'Internal Web3 error' \ - for payment to 0x0000000000000000000000000000000000646566 and transaction hash 0x000000000000000000000000\ - 00000000000000000000000000000000000004d2. Please check your blockchain service URL configuration.")); - log_handler.exists_log_containing(&format!( - "DEBUG: {test_name}: Got 0 properly sent payables of 2 attempts" - )); - log_handler.exists_log_containing(&format!("ERROR: {test_name}: Ran into failed transactions 0x0000000000000000\ - 0000000000000000000000000000000000000000000004d2 with missing fingerprints. System no longer reliable")); - } - - #[test] - fn payable_is_found_innocent_by_age_and_returns() { - let is_innocent_age_params_arc = Arc::new(Mutex::new(vec![])); - let payable_thresholds_gauge = PayableThresholdsGaugeMock::default() - .is_innocent_age_params(&is_innocent_age_params_arc) - .is_innocent_age_result(true); - let mut subject = PayableScannerBuilder::new().build(); - subject.payable_threshold_gauge = Box::new(payable_thresholds_gauge); - let now = SystemTime::now(); - let debt_age_s = 111_222; - let last_paid_timestamp = now.checked_sub(Duration::from_secs(debt_age_s)).unwrap(); - let mut payable = make_payable_account(111); - payable.last_paid_timestamp = last_paid_timestamp; - - let result = subject.payable_exceeded_threshold(&payable, now); - - assert_eq!(result, None); - let mut is_innocent_age_params = is_innocent_age_params_arc.lock().unwrap(); - let (debt_age_returned, threshold_value) = is_innocent_age_params.remove(0); - assert!(is_innocent_age_params.is_empty()); - assert_eq!(debt_age_returned, debt_age_s); - assert_eq!( - threshold_value, - DEFAULT_PAYMENT_THRESHOLDS.maturity_threshold_sec - ) - // No panic and so no other method was called, which means an early return - } - - #[test] - fn payable_is_found_innocent_by_balance_and_returns() { - let is_innocent_age_params_arc = Arc::new(Mutex::new(vec![])); - let is_innocent_balance_params_arc = Arc::new(Mutex::new(vec![])); - let payable_thresholds_gauge = PayableThresholdsGaugeMock::default() - .is_innocent_age_params(&is_innocent_age_params_arc) - .is_innocent_age_result(false) - .is_innocent_balance_params(&is_innocent_balance_params_arc) - .is_innocent_balance_result(true); - let mut subject = PayableScannerBuilder::new().build(); - subject.payable_threshold_gauge = Box::new(payable_thresholds_gauge); - let now = SystemTime::now(); - let debt_age_s = 3_456; - let last_paid_timestamp = now.checked_sub(Duration::from_secs(debt_age_s)).unwrap(); - let mut payable = make_payable_account(222); - payable.last_paid_timestamp = last_paid_timestamp; - payable.balance_wei = 123456; - - let result = subject.payable_exceeded_threshold(&payable, now); - - assert_eq!(result, None); - let mut is_innocent_age_params = is_innocent_age_params_arc.lock().unwrap(); - let (debt_age_returned, _) = is_innocent_age_params.remove(0); - assert!(is_innocent_age_params.is_empty()); - assert_eq!(debt_age_returned, debt_age_s); - let is_innocent_balance_params = is_innocent_balance_params_arc.lock().unwrap(); - assert_eq!( - *is_innocent_balance_params, - vec![( - 123456_u128, - gwei_to_wei(DEFAULT_PAYMENT_THRESHOLDS.permanent_debt_allowed_gwei) - )] - ) - //no other method was called (absence of panic) and that means we returned early } #[test] - fn threshold_calculation_depends_on_user_defined_payment_thresholds() { - let is_innocent_age_params_arc = Arc::new(Mutex::new(vec![])); - let is_innocent_balance_params_arc = Arc::new(Mutex::new(vec![])); - let calculate_payable_threshold_params_arc = Arc::new(Mutex::new(vec![])); - let balance = gwei_to_wei(5555_u64); - let now = SystemTime::now(); - let debt_age_s = 1111 + 1; - let last_paid_timestamp = now.checked_sub(Duration::from_secs(debt_age_s)).unwrap(); - let payable_account = PayableAccount { - wallet: make_wallet("hi"), - balance_wei: balance, - last_paid_timestamp, - pending_payable_opt: None, - }; - let custom_payment_thresholds = PaymentThresholds { - maturity_threshold_sec: 1111, - payment_grace_period_sec: 2222, - permanent_debt_allowed_gwei: 3333, - debt_threshold_gwei: 4444, - threshold_interval_sec: 5555, - unban_below_gwei: 5555, + fn scanners_struct_can_be_constructed_with_the_respective_scanners() { + let payable_dao_factory = PayableDaoFactoryMock::new() + .make_result(PayableDaoMock::new()) + .make_result(PayableDaoMock::new()); + let sent_payable_dao_factory = SentPayableDaoFactoryMock::new() + .make_result(SentPayableDaoMock::new()) + .make_result(SentPayableDaoMock::new()); + let failed_payable_dao_factory = FailedPayableDaoFactoryMock::new() + .make_result(FailedPayableDaoMock::new()) + .make_result(FailedPayableDaoMock::new()); + let receivable_dao = ReceivableDaoMock::new(); + let receivable_dao_factory = ReceivableDaoFactoryMock::new().make_result(receivable_dao); + let banned_dao_factory = BannedDaoFactoryMock::new().make_result(BannedDaoMock::new()); + let set_params_arc = Arc::new(Mutex::new(vec![])); + let config_dao_mock = ConfigDaoMock::new() + .set_params(&set_params_arc) + .set_result(Ok(())); + let config_dao_factory = ConfigDaoFactoryMock::new().make_result(config_dao_mock); + let financial_statistics = FinancialStatistics { + total_paid_payable_wei: 1, + total_paid_receivable_wei: 2, }; - let payable_thresholds_gauge = PayableThresholdsGaugeMock::default() - .is_innocent_age_params(&is_innocent_age_params_arc) - .is_innocent_age_result( - debt_age_s <= custom_payment_thresholds.maturity_threshold_sec as u64, - ) - .is_innocent_balance_params(&is_innocent_balance_params_arc) - .is_innocent_balance_result( - balance <= gwei_to_wei(custom_payment_thresholds.permanent_debt_allowed_gwei), - ) - .calculate_payout_threshold_in_gwei_params(&calculate_payable_threshold_params_arc) - .calculate_payout_threshold_in_gwei_result(4567898); //made up value - let mut subject = PayableScannerBuilder::new() - .payment_thresholds(custom_payment_thresholds) - .build(); - subject.payable_threshold_gauge = Box::new(payable_thresholds_gauge); + let payment_thresholds = make_custom_payment_thresholds(); + let payment_thresholds_rc = Rc::new(payment_thresholds); + let initial_rc_count = Rc::strong_count(&payment_thresholds_rc); - let result = subject.payable_exceeded_threshold(&payable_account, now); + let mut scanners = Scanners::new( + DaoFactories { + payable_dao_factory: Box::new(payable_dao_factory), + sent_payable_dao_factory: Box::new(sent_payable_dao_factory), + failed_payable_dao_factory: Box::new(failed_payable_dao_factory), + receivable_dao_factory: Box::new(receivable_dao_factory), + banned_dao_factory: Box::new(banned_dao_factory), + config_dao_factory: Box::new(config_dao_factory), + }, + Rc::clone(&payment_thresholds_rc), + Rc::new(RefCell::new(financial_statistics.clone())), + ); - assert_eq!(result, Some(4567898)); - let mut is_innocent_age_params = is_innocent_age_params_arc.lock().unwrap(); - let (debt_age_returned_innocent, curve_derived_time) = is_innocent_age_params.remove(0); - assert_eq!(*is_innocent_age_params, vec![]); - assert_eq!(debt_age_returned_innocent, debt_age_s); + let payable_scanner = scanners + .payable + .as_any() + .downcast_ref::() + .unwrap(); + let pending_payable_scanner = scanners + .pending_payable + .as_any_mut() + .downcast_mut::() + .unwrap(); + let receivable_scanner = scanners + .receivable + .as_any_mut() + .downcast_mut::() + .unwrap(); assert_eq!( - curve_derived_time, - custom_payment_thresholds.maturity_threshold_sec as u64 + payable_scanner.common.payment_thresholds.as_ref(), + &payment_thresholds ); - let is_innocent_balance_params = is_innocent_balance_params_arc.lock().unwrap(); + assert_eq!(payable_scanner.common.initiated_at_opt.is_some(), false); + assert_eq!(scanners.aware_of_unresolved_pending_payable, false); + assert_eq!(scanners.initial_pending_payable_scan, true); assert_eq!( - *is_innocent_balance_params, - vec![( - payable_account.balance_wei, - gwei_to_wei(custom_payment_thresholds.permanent_debt_allowed_gwei) - )] + *pending_payable_scanner.financial_statistics.borrow(), + financial_statistics ); - let mut calculate_payable_curves_params = - calculate_payable_threshold_params_arc.lock().unwrap(); - let (payment_thresholds, debt_age_returned_curves) = - calculate_payable_curves_params.remove(0); - assert_eq!(*calculate_payable_curves_params, vec![]); - assert_eq!(debt_age_returned_curves, debt_age_s); - assert_eq!(payment_thresholds, custom_payment_thresholds) - } - - #[test] - fn payable_with_debt_under_the_slope_is_marked_unqualified() { - init_test_logging(); - let now = SystemTime::now(); - let payment_thresholds = PaymentThresholds::default(); - let debt = gwei_to_wei(payment_thresholds.permanent_debt_allowed_gwei + 1); - let time = to_time_t(now) - payment_thresholds.maturity_threshold_sec as i64 - 1; - let unqualified_payable_account = vec![PayableAccount { - wallet: make_wallet("wallet0"), - balance_wei: debt, - last_paid_timestamp: from_time_t(time), - pending_payable_opt: None, - }]; - let subject = PayableScannerBuilder::new() - .payment_thresholds(payment_thresholds) - .build(); - let test_name = - "payable_with_debt_above_the_slope_is_qualified_and_the_threshold_value_is_returned"; - let logger = Logger::new(test_name); - - let result = subject - .sniff_out_alarming_payables_and_maybe_log_them(unqualified_payable_account, &logger); - - assert_eq!(result, vec![]); - TestLogHandler::new() - .exists_no_log_containing(&format!("DEBUG: {}: Paying qualified debts", test_name)); - } - - #[test] - fn payable_with_debt_above_the_slope_is_qualified() { - init_test_logging(); - let payment_thresholds = PaymentThresholds::default(); - let debt = gwei_to_wei(payment_thresholds.debt_threshold_gwei - 1); - let time = (payment_thresholds.maturity_threshold_sec - + payment_thresholds.threshold_interval_sec - - 1) as i64; - let qualified_payable = PayableAccount { - wallet: make_wallet("wallet0"), - balance_wei: debt, - last_paid_timestamp: from_time_t(time), - pending_payable_opt: None, - }; - let subject = PayableScannerBuilder::new() - .payment_thresholds(payment_thresholds) - .build(); - let test_name = "payable_with_debt_above_the_slope_is_qualified"; - let logger = Logger::new(test_name); - - let result = subject.sniff_out_alarming_payables_and_maybe_log_them( - vec![qualified_payable.clone()], - &logger, + assert_eq!( + pending_payable_scanner.common.payment_thresholds.as_ref(), + &payment_thresholds + ); + assert_eq!( + pending_payable_scanner.common.initiated_at_opt.is_some(), + false + ); + let dumped_records = pending_payable_scanner + .suspected_failed_payables + .dump_cache(); + assert!( + dumped_records.is_empty(), + "There should be no suspected failures but found {:?}.", + dumped_records + ); + assert_eq!( + receivable_scanner.common.payment_thresholds.as_ref(), + &payment_thresholds + ); + assert_eq!(receivable_scanner.common.initiated_at_opt.is_some(), false); + assert_eq!( + *receivable_scanner.financial_statistics.borrow(), + financial_statistics + ); + assert_eq!( + receivable_scanner.common.payment_thresholds.as_ref(), + &payment_thresholds + ); + assert_eq!(receivable_scanner.common.initiated_at_opt.is_some(), false); + receivable_scanner + .persistent_configuration + .set_start_block(Some(136890)) + .unwrap(); + let set_params = set_params_arc.lock().unwrap(); + assert_eq!( + *set_params, + vec![("start_block".to_string(), Some("136890".to_string()))] + ); + assert_eq!( + Rc::strong_count(&payment_thresholds_rc), + initial_rc_count + 3 ); - - assert_eq!(result, vec![qualified_payable]); - TestLogHandler::new().exists_log_matching(&format!( - "DEBUG: {}: Paying qualified debts:\n999,999,999,000,000,\ - 000 wei owed for \\d+ sec exceeds threshold: 500,000,000,000,000,000 wei; creditor: \ - 0x0000000000000000000000000077616c6c657430", - test_name - )); - } - - #[test] - fn non_pending_payables_turn_into_an_empty_vector_if_all_unqualified() { - init_test_logging(); - let test_name = "non_pending_payables_turn_into_an_empty_vector_if_all_unqualified"; - let now = SystemTime::now(); - let payment_thresholds = PaymentThresholds::default(); - let unqualified_payable_account = vec![PayableAccount { - wallet: make_wallet("wallet1"), - balance_wei: gwei_to_wei(payment_thresholds.permanent_debt_allowed_gwei + 1), - last_paid_timestamp: from_time_t( - to_time_t(now) - payment_thresholds.maturity_threshold_sec as i64 + 1, - ), - pending_payable_opt: None, - }]; - let subject = PayableScannerBuilder::new() - .payment_thresholds(payment_thresholds) - .build(); - let logger = Logger::new(test_name); - - let result = subject - .sniff_out_alarming_payables_and_maybe_log_them(unqualified_payable_account, &logger); - - assert_eq!(result, vec![]); - TestLogHandler::new() - .exists_no_log_containing(&format!("DEBUG: {test_name}: Paying qualified debts")); } #[test] - fn pending_payable_scanner_can_initiate_a_scan() { + fn new_payable_scanner_can_initiate_a_scan() { init_test_logging(); - let test_name = "pending_payable_scanner_can_initiate_a_scan"; + let test_name = "new_payable_scanner_can_initiate_a_scan"; let consuming_wallet = make_paying_wallet(b"consuming wallet"); let now = SystemTime::now(); - let payable_fingerprint_1 = PendingPayableFingerprint { - rowid: 555, - timestamp: from_time_t(210_000_000), - hash: make_tx_hash(45678), - attempt: 1, - amount: 4444, - process_error: None, - }; - let payable_fingerprint_2 = PendingPayableFingerprint { - rowid: 550, - timestamp: from_time_t(210_000_100), - hash: make_tx_hash(112233), - attempt: 1, - amount: 7999, - process_error: None, - }; - let fingerprints = vec![payable_fingerprint_1, payable_fingerprint_2]; - let pending_payable_dao = PendingPayableDaoMock::new() - .return_all_errorless_fingerprints_result(fingerprints.clone()); - let mut pending_payable_scanner = PendingPayableScannerBuilder::new() - .pending_payable_dao(pending_payable_dao) + let (qualified_payable_accounts, _, retrieved_payables) = + make_qualified_and_unqualified_payables(now, &PaymentThresholds::default()); + let payable_dao = PayableDaoMock::new().retrieve_payables_result(retrieved_payables); + let mut subject = make_dull_subject(); + let payable_scanner = PayableScannerBuilder::new() + .payable_dao(payable_dao) .build(); + subject.payable = Box::new(payable_scanner); - let result = pending_payable_scanner.begin_scan( - consuming_wallet, + let result = subject.start_new_payable_scan_guarded( + &consuming_wallet, now, None, &Logger::new(test_name), + true, ); - let no_of_pending_payables = fingerprints.len(); - let is_scan_running = pending_payable_scanner.scan_started_at().is_some(); - assert_eq!(is_scan_running, true); + let timestamp = subject.payable.scan_started_at(); + assert_eq!(timestamp, Some(now)); + let qualified_payables_count = qualified_payable_accounts.len(); + let expected_tx_templates = NewTxTemplates::from(&qualified_payable_accounts); assert_eq!( result, - Ok(RequestTransactionReceipts { - pending_payable: fingerprints, - response_skeleton_opt: None + Ok(InitialTemplatesMessage { + initial_templates: Either::Left(expected_tx_templates), + consuming_wallet, + response_skeleton_opt: None, }) ); TestLogHandler::new().assert_logs_match_in_order(vec![ - &format!("INFO: {test_name}: Scanning for pending payable"), + &format!("INFO: {test_name}: Scanning for new payables"), &format!( - "DEBUG: {test_name}: Found {no_of_pending_payables} pending payables to process" + "INFO: {test_name}: Chose {} qualified debts to pay", + qualified_payables_count ), ]) } #[test] - fn pending_payable_scanner_throws_error_in_case_scan_is_already_running() { - let now = SystemTime::now(); - let consuming_wallet = make_paying_wallet(b"consuming"); - let pending_payable_dao = PendingPayableDaoMock::new() - .return_all_errorless_fingerprints_result(vec![PendingPayableFingerprint { - rowid: 1234, - timestamp: SystemTime::now(), - hash: make_tx_hash(1), - attempt: 1, - amount: 1_000_000, - process_error: None, - }]); - let mut subject = PendingPayableScannerBuilder::new() - .pending_payable_dao(pending_payable_dao) + fn new_payable_scanner_cannot_be_initiated_if_it_is_already_running() { + let consuming_wallet = make_paying_wallet(b"consuming wallet"); + let (_, _, retrieved_payables) = make_qualified_and_unqualified_payables( + SystemTime::now(), + &PaymentThresholds::default(), + ); + let payable_dao = PayableDaoMock::new().retrieve_payables_result(retrieved_payables); + let mut subject = make_dull_subject(); + let payable_scanner = PayableScannerBuilder::new() + .payable_dao(payable_dao) .build(); - let logger = Logger::new("test"); - let _ = subject.begin_scan(consuming_wallet.clone(), now, None, &logger); + subject.payable = Box::new(payable_scanner); + let previous_scan_started_at = SystemTime::now(); + let _ = subject.start_new_payable_scan_guarded( + &consuming_wallet, + previous_scan_started_at, + None, + &Logger::new("test"), + true, + ); - let result = subject.begin_scan(consuming_wallet, SystemTime::now(), None, &logger); + let result = subject.start_new_payable_scan_guarded( + &consuming_wallet, + SystemTime::now(), + None, + &Logger::new("test"), + true, + ); - let is_scan_running = subject.scan_started_at().is_some(); + let is_scan_running = subject.payable.scan_started_at().is_some(); assert_eq!(is_scan_running, true); - assert_eq!(result, Err(BeginScanError::ScanAlreadyRunning(now))); + assert_eq!( + result, + Err(StartScanError::ScanAlreadyRunning { + cross_scan_cause_opt: None, + started_at: previous_scan_started_at + }) + ); } #[test] - fn pending_payable_scanner_throws_an_error_when_no_fingerprint_is_found() { + fn new_payable_scanner_throws_error_in_case_no_qualified_payable_is_found() { + let consuming_wallet = make_paying_wallet(b"consuming wallet"); let now = SystemTime::now(); - let consuming_wallet = make_paying_wallet(b"consuming_wallet"); - let pending_payable_dao = - PendingPayableDaoMock::new().return_all_errorless_fingerprints_result(vec![]); - let mut pending_payable_scanner = PendingPayableScannerBuilder::new() - .pending_payable_dao(pending_payable_dao) - .build(); + let (_, unqualified_payable_accounts, _) = + make_qualified_and_unqualified_payables(now, &PaymentThresholds::default()); + let payable_dao = + PayableDaoMock::new().retrieve_payables_result(unqualified_payable_accounts); + let mut subject = make_dull_subject(); + subject.payable = Box::new( + PayableScannerBuilder::new() + .payable_dao(payable_dao) + .build(), + ); - let result = - pending_payable_scanner.begin_scan(consuming_wallet, now, None, &Logger::new("test")); + let result = subject.start_new_payable_scan_guarded( + &consuming_wallet, + SystemTime::now(), + None, + &Logger::new("test"), + true, + ); - let is_scan_running = pending_payable_scanner.scan_started_at().is_some(); - assert_eq!(result, Err(BeginScanError::NothingToProcess)); + let is_scan_running = subject.scan_started_at(ScanType::Payables).is_some(); assert_eq!(is_scan_running, false); + assert_eq!(result, Err(StartScanError::NothingToProcess)); } - fn assert_interpreting_none_status_for_pending_payable( - test_name: &str, - when_pending_too_long_sec: u64, - pending_payable_age_sec: u64, - rowid: u64, - hash: H256, - ) -> PendingPayableScanReport { + #[test] + fn retry_payable_scanner_can_initiate_a_scan() { init_test_logging(); - let when_sent = SystemTime::now().sub(Duration::from_secs(pending_payable_age_sec)); - let fingerprint = PendingPayableFingerprint { - rowid, - timestamp: when_sent, - hash, - attempt: 1, - amount: 123, - process_error: None, + let test_name = "retry_payable_scanner_can_initiate_a_scan"; + let consuming_wallet = make_paying_wallet(b"consuming wallet"); + let now = SystemTime::now(); + let response_skeleton = ResponseSkeleton { + client_id: 24, + context_id: 42, }; - let logger = Logger::new(test_name); - let scan_report = PendingPayableScanReport::default(); - - handle_none_status(scan_report, fingerprint, when_pending_too_long_sec, &logger) - } - - fn assert_log_msg_and_elapsed_time_in_log_makes_sense( - expected_msg: &str, - elapsed_after: u64, - capture_regex: &str, - ) { - let log_handler = TestLogHandler::default(); - let log_idx = log_handler.exists_log_matching(expected_msg); - let log = log_handler.get_log_at(log_idx); - let capture = captures_for_regex_time_in_sec(&log, capture_regex); - assert!(capture <= elapsed_after) - } - - fn captures_for_regex_time_in_sec(stack: &str, capture_regex: &str) -> u64 { - let capture_regex = Regex::new(capture_regex).unwrap(); - let time_str = capture_regex - .captures(stack) - .unwrap() - .get(1) - .unwrap() - .as_str(); - time_str.parse().unwrap() - } - - fn elapsed_since_secs_back(sec: u64) -> u64 { - SystemTime::now() - .sub(Duration::from_secs(sec)) - .elapsed() - .unwrap() - .as_secs() - } - - #[test] - fn interpret_transaction_receipt_when_transaction_status_is_none_and_outside_waiting_interval() - { - let test_name = "interpret_transaction_receipt_when_transaction_status_is_none_and_outside_waiting_interval"; - let hash = make_tx_hash(0x237); - let rowid = 466; + let (_, _, retrieved_payables) = + make_qualified_and_unqualified_payables(now, &PaymentThresholds::default()); + let failed_tx = make_failed_tx(1); + let payable_dao = PayableDaoMock::new().retrieve_payables_result(retrieved_payables); + let failed_payable_dao = + FailedPayableDaoMock::new().retrieve_txs_result(BTreeSet::from([failed_tx.clone()])); + let mut subject = make_dull_subject(); + let payable_scanner = PayableScannerBuilder::new() + .payable_dao(payable_dao) + .failed_payable_dao(failed_payable_dao) + .build(); + subject.payable = Box::new(payable_scanner); - let result = assert_interpreting_none_status_for_pending_payable( - test_name, - DEFAULT_PENDING_TOO_LONG_SEC, - DEFAULT_PENDING_TOO_LONG_SEC + 1, - rowid, - hash, + let result = subject.start_retry_payable_scan_guarded( + &consuming_wallet, + now, + Some(response_skeleton), + &Logger::new(test_name), ); - let elapsed_after = elapsed_since_secs_back(DEFAULT_PENDING_TOO_LONG_SEC + 1); + let timestamp = subject.payable.scan_started_at(); + let expected_template = RetryTxTemplate::from(&failed_tx); + assert_eq!(timestamp, Some(now)); assert_eq!( result, - PendingPayableScanReport { - still_pending: vec![], - failures: vec![PendingPayableId::new(rowid, hash)], - confirmed: vec![] - } + Ok(InitialTemplatesMessage { + initial_templates: Either::Right(RetryTxTemplates(vec![expected_template])), + consuming_wallet, + response_skeleton_opt: Some(response_skeleton), + }) ); - let capture_regex = "(\\d+){2}sec"; - assert_log_msg_and_elapsed_time_in_log_makes_sense(&format!( - "ERROR: {}: Pending transaction 0x00000000000000000000000000000000000000\ - 00000000000000000000000237 has exceeded the maximum pending time \\({}sec\\) with the age \ - \\d+sec and the confirmation process is going to be aborted now at the final attempt 1; manual \ - resolution is required from the user to complete the transaction" - , test_name, DEFAULT_PENDING_TOO_LONG_SEC, ), elapsed_after, capture_regex) + let tlh = TestLogHandler::new(); + tlh.exists_log_containing(&format!("INFO: {test_name}: Scanning for retry payables")); } #[test] - fn interpret_transaction_receipt_when_transaction_status_is_none_and_within_waiting_interval() { - let test_name = "interpret_transaction_receipt_when_transaction_status_is_none_and_within_waiting_interval"; - let hash = make_tx_hash(0x7b); - let rowid = 333; - let pending_payable_age = DEFAULT_PENDING_TOO_LONG_SEC - 1; - - let result = assert_interpreting_none_status_for_pending_payable( - test_name, - DEFAULT_PENDING_TOO_LONG_SEC, - pending_payable_age, - rowid, - hash, + fn retry_payable_scanner_panics_in_case_scan_is_already_running() { + let consuming_wallet = make_paying_wallet(b"consuming wallet"); + let (_, _, retrieved_payables) = make_qualified_and_unqualified_payables( + SystemTime::now(), + &PaymentThresholds::default(), ); - - let elapsed_after_ms = elapsed_since_secs_back(pending_payable_age) * 1000; - assert_eq!( - result, - PendingPayableScanReport { - still_pending: vec![PendingPayableId::new(rowid, hash)], - failures: vec![], - confirmed: vec![] - } + let payable_dao = PayableDaoMock::new().retrieve_payables_result(retrieved_payables); + let failed_payable_dao = + FailedPayableDaoMock::default().retrieve_txs_result(BTreeSet::new()); + let mut subject = make_dull_subject(); + let payable_scanner = PayableScannerBuilder::new() + .payable_dao(payable_dao) + .failed_payable_dao(failed_payable_dao) + .build(); + subject.payable = Box::new(payable_scanner); + let before = SystemTime::now(); + let _ = subject.start_retry_payable_scan_guarded( + &consuming_wallet, + SystemTime::now(), + None, + &Logger::new("test"), ); - let capture_regex = r#"\s(\d+)ms"#; - assert_log_msg_and_elapsed_time_in_log_makes_sense(&format!( - "INFO: {test_name}: Pending transaction 0x0000000000000000000000000000000000000000000000000\ - 00000000000007b couldn't be confirmed at attempt 1 at \\d+ms after its sending"), elapsed_after_ms, capture_regex); - } - #[test] - fn interpret_transaction_receipt_when_transaction_status_is_none_and_time_equals_the_limit() { - let test_name = "interpret_transaction_receipt_when_transaction_status_is_none_and_time_equals_the_limit"; - let hash = make_tx_hash(0x237); - let rowid = 466; - let pending_payable_age = DEFAULT_PENDING_TOO_LONG_SEC; + let caught_panic = catch_unwind(AssertUnwindSafe(|| { + let _: Result = subject + .start_retry_payable_scan_guarded( + &consuming_wallet, + SystemTime::now(), + None, + &Logger::new("test"), + ); + })) + .unwrap_err(); - let result = assert_interpreting_none_status_for_pending_payable( - test_name, - DEFAULT_PENDING_TOO_LONG_SEC, - pending_payable_age, - rowid, - hash, + let after = SystemTime::now(); + let panic_msg = caught_panic.downcast_ref::().unwrap(); + let expected_needle_1 = "internal error: entered unreachable code: \ + Guards should ensure that no payable scanner can run if the pending payable \ + repetitive sequence is still ongoing. However, some other payable scan intruded at"; + assert!( + panic_msg.contains(expected_needle_1), + "We looked for {} but the actual string doesn't contain it: {}", + expected_needle_1, + panic_msg ); - - let elapsed_after_ms = elapsed_since_secs_back(pending_payable_age) * 1000; - assert_eq!( - result, - PendingPayableScanReport { - still_pending: vec![PendingPayableId::new(rowid, hash)], - failures: vec![], - confirmed: vec![] - } + let expected_needle_2 = "and is still running at "; + assert!( + panic_msg.contains(expected_needle_2), + "We looked for {} but the actual string doesn't contain it: {}", + expected_needle_2, + panic_msg ); - let capture_regex = r#"\s(\d+)ms"#; - assert_log_msg_and_elapsed_time_in_log_makes_sense(&format!( - "INFO: {test_name}: Pending transaction 0x0000000000000000000000000000000000000000000000000\ - 000000000000237 couldn't be confirmed at attempt 1 at \\d+ms after its sending", - ), elapsed_after_ms, capture_regex); + check_timestamps_in_panic_for_already_running_retry_payable_scanner( + &panic_msg, before, after, + ) } - #[test] - fn interpret_transaction_receipt_when_transaction_status_is_a_failure() { - init_test_logging(); - let test_name = "interpret_transaction_receipt_when_transaction_status_is_a_failure"; - let mut tx_receipt = TransactionReceipt::default(); - tx_receipt.status = Some(U64::from(0)); //failure - let hash = make_tx_hash(0xd7); - let fingerprint = PendingPayableFingerprint { - rowid: 777777, - timestamp: SystemTime::now().sub(Duration::from_millis(150000)), - hash, - attempt: 5, - amount: 2222, - process_error: None, - }; - let logger = Logger::new(test_name); - let scan_report = PendingPayableScanReport::default(); - - let result = handle_status_with_failure(scan_report, fingerprint, &logger); + fn check_timestamps_in_panic_for_already_running_retry_payable_scanner( + panic_msg: &str, + before: SystemTime, + after: SystemTime, + ) { + let system_times = parse_system_time_from_str(panic_msg); + let before = trim_expected_timestamp_to_three_digits_nanos(before); + let first_actual = system_times[0]; + let second_actual = system_times[1]; + let after = trim_expected_timestamp_to_three_digits_nanos(after); - assert_eq!( - result, - PendingPayableScanReport { - still_pending: vec![], - failures: vec![PendingPayableId::new(777777, hash,)], - confirmed: vec![] - } + assert!( + before <= first_actual + && first_actual <= second_actual + && second_actual <= after, + "We expected this relationship before({:?}) <= first_actual({:?}) <= second_actual({:?}) \ + <= after({:?}), but it does not hold true", + before, + first_actual, + second_actual, + after ); - TestLogHandler::new().exists_log_matching(&format!( - "ERROR: {test_name}: Pending transaction 0x0000000000000000000000000000000000000000\ - 0000000000000000000000d7 announced as a failure, interpreting attempt 5 after \ - 1500\\d\\dms from the sending" - )); } #[test] - fn handle_pending_txs_with_receipts_handles_none_for_receipt() { - init_test_logging(); - let test_name = "handle_pending_txs_with_receipts_handles_none_for_receipt"; - let subject = PendingPayableScannerBuilder::new().build(); - let rowid = 455; - let hash = make_tx_hash(0x913); - let fingerprint = PendingPayableFingerprint { - rowid, - timestamp: SystemTime::now().sub(Duration::from_millis(10000)), - hash, - attempt: 3, - amount: 111, - process_error: None, - }; - let msg = ReportTransactionReceipts { - fingerprints_with_receipts: vec![( - TransactionReceiptResult::RpcResponse(TxReceipt { - transaction_hash: hash, - status: TxStatus::Pending, - }), - fingerprint.clone(), - )], - response_skeleton_opt: None, - }; - - let result = subject.handle_receipts_for_pending_transactions(msg, &Logger::new(test_name)); - - assert_eq!( - result, - PendingPayableScanReport { - still_pending: vec![PendingPayableId::new(rowid, hash)], - failures: vec![], - confirmed: vec![] - } + fn finish_payable_scan_keeps_the_aware_of_unresolved_pending_payable_flag_as_false_in_case_of_err( + ) { + test_finish_payable_scan_keeps_aware_flag_false_on_error(PayableScanType::New, "new_scan"); + test_finish_payable_scan_keeps_aware_flag_false_on_error( + PayableScanType::Retry, + "retry_scan", ); - TestLogHandler::new().exists_log_matching(&format!( - "DEBUG: {test_name}: Interpreting a receipt for transaction \ - 0x0000000000000000000000000000000000000000000000000000000000000913 \ - but none was given; attempt 3, 100\\d\\dms since sending" - )); } - #[test] - fn increment_scan_attempts_happy_path() { - let update_remaining_fingerprints_params_arc = Arc::new(Mutex::new(vec![])); - let hash_1 = make_tx_hash(444888); - let rowid_1 = 3456; - let hash_2 = make_tx_hash(444888); - let rowid_2 = 3456; - let pending_payable_dao = PendingPayableDaoMock::default() - .increment_scan_attempts_params(&update_remaining_fingerprints_params_arc) - .increment_scan_attempts_result(Ok(())); - let subject = PendingPayableScannerBuilder::new() - .pending_payable_dao(pending_payable_dao) - .build(); - let transaction_id_1 = PendingPayableId::new(rowid_1, hash_1); - let transaction_id_2 = PendingPayableId::new(rowid_2, hash_2); - - let _ = subject.update_remaining_fingerprints( - vec![transaction_id_1, transaction_id_2], - &Logger::new("test"), + fn test_finish_payable_scan_keeps_aware_flag_false_on_error( + payable_scan_type: PayableScanType, + test_name_str: &str, + ) { + init_test_logging(); + let test_name = format!( + "finish_payable_scan_keeps_the_aware_of_unresolved_\ + pending_payable_flag_as_false_in_case_of_err_for_\ + {test_name_str}" ); - - let update_remaining_fingerprints_params = - update_remaining_fingerprints_params_arc.lock().unwrap(); - assert_eq!( - *update_remaining_fingerprints_params, - vec![vec![rowid_1, rowid_2]] - ) - } - - #[test] - #[should_panic( - expected = "Failure on incrementing scan attempts for fingerprints of \ - 0x000000000000000000000000000000000000000000000000000000000006c9d8 \ - due to UpdateFailed(\"yeah, bad\")" - )] - fn increment_scan_attempts_sad_path() { - let hash = make_tx_hash(0x6c9d8); - let rowid = 3456; - let pending_payable_dao = - PendingPayableDaoMock::default().increment_scan_attempts_result(Err( - PendingPayableDaoError::UpdateFailed("yeah, bad".to_string()), - )); - let subject = PendingPayableScannerBuilder::new() - .pending_payable_dao(pending_payable_dao) - .build(); - let logger = Logger::new("test"); - let transaction_id = PendingPayableId::new(rowid, hash); - - let _ = subject.update_remaining_fingerprints(vec![transaction_id], &logger); - } - - #[test] - fn update_remaining_fingerprints_does_nothing_if_no_still_pending_transactions_remain() { - let subject = PendingPayableScannerBuilder::new().build(); - - subject.update_remaining_fingerprints(vec![], &Logger::new("test")) - - //mocked pending payable DAO didn't panic which means we skipped the actual process + let sent_payable = SentPayables { + payment_procedure_result: Err("Some error".to_string()), + payable_scan_type, + response_skeleton_opt: None, + }; + let logger = Logger::new(&test_name); + let payable_scanner = PayableScannerBuilder::new().build(); + let mut subject = make_dull_subject(); + subject.payable = Box::new(payable_scanner); + let aware_of_unresolved_pending_payable_before = + subject.aware_of_unresolved_pending_payable; + + subject.finish_payable_scan(sent_payable, &logger); + + let aware_of_unresolved_pending_payable_after = subject.aware_of_unresolved_pending_payable; + assert_eq!(aware_of_unresolved_pending_payable_before, false); + assert_eq!(aware_of_unresolved_pending_payable_after, false); + let log_handler = TestLogHandler::new(); + log_handler.exists_log_containing(&format!( + "WARN: {test_name}: Local error occurred before transaction signing. Error: Some error" + )); } #[test] - fn cancel_failed_transactions_works() { + fn finish_payable_scan_changes_the_aware_of_unresolved_pending_payable_flag_as_true_when_pending_txs_found_in_retry_mode( + ) { init_test_logging(); - let test_name = "cancel_failed_transactions_works"; - let mark_failures_params_arc = Arc::new(Mutex::new(vec![])); - let pending_payable_dao = PendingPayableDaoMock::default() - .mark_failures_params(&mark_failures_params_arc) - .mark_failures_result(Ok(())); - let subject = PendingPayableScannerBuilder::new() - .pending_payable_dao(pending_payable_dao) + let test_name = "finish_payable_scan_changes_the_aware_of_unresolved_pending_payable_flag_as_true_when_pending_txs_found_in_retry_mode"; + let sent_payable_dao = SentPayableDaoMock::default().insert_new_records_result(Ok(())); + let failed_payable_dao = + FailedPayableDaoMock::default().retrieve_txs_result(BTreeSet::new()); + let payable_scanner = PayableScannerBuilder::new() + .sent_payable_dao(sent_payable_dao) + .failed_payable_dao(failed_payable_dao) .build(); - let id_1 = PendingPayableId::new(2, make_tx_hash(0x7b)); - let id_2 = PendingPayableId::new(3, make_tx_hash(0x1c8)); + let logger = Logger::new(test_name); + let mut subject = make_dull_subject(); + subject.payable = Box::new(payable_scanner); + let sent_payables = SentPayables { + payment_procedure_result: Ok(BatchResults { + sent_txs: vec![make_sent_tx(1)], + failed_txs: vec![], + }), + payable_scan_type: PayableScanType::Retry, + response_skeleton_opt: None, + }; + let aware_of_unresolved_pending_payable_before = + subject.aware_of_unresolved_pending_payable; - subject.cancel_failed_transactions(vec![id_1, id_2], &Logger::new(test_name)); + subject.finish_payable_scan(sent_payables, &logger); - let mark_failures_params = mark_failures_params_arc.lock().unwrap(); - assert_eq!(*mark_failures_params, vec![vec![2, 3]]); - TestLogHandler::new().exists_log_containing(&format!( - "WARN: {test_name}: Broken transactions 0x000000000000000000000000000000000000000000000000000000000000007b, \ - 0x00000000000000000000000000000000000000000000000000000000000001c8 marked as an error. You should take over \ - the care of those to make sure your debts are going to be settled properly. At the moment, there is no automated \ - process fixing that without your assistance", + let aware_of_unresolved_pending_payable_after = subject.aware_of_unresolved_pending_payable; + assert_eq!(aware_of_unresolved_pending_payable_before, false); + assert_eq!(aware_of_unresolved_pending_payable_after, true); + let log_handler = TestLogHandler::new(); + log_handler.exists_log_containing(&format!( + "DEBUG: {test_name}: Processed retried txs while sending to RPC: \ + Total: 1, Sent to RPC: 1, Failed to send: 0." )); } #[test] - #[should_panic( - expected = "Unsuccessful attempt for transactions 0x00000000000000000000000000000000000\ - 0000000000000000000000000014d, 0x000000000000000000000000000000000000000000000000000000\ - 00000001bc to mark fatal error at payable fingerprint due to UpdateFailed(\"no no no\"); \ - database unreliable" - )] - fn cancel_failed_transactions_panics_when_it_fails_to_mark_failure() { - let pending_payable_dao = PendingPayableDaoMock::default().mark_failures_result(Err( - PendingPayableDaoError::UpdateFailed("no no no".to_string()), - )); - let subject = PendingPayableScannerBuilder::new() - .pending_payable_dao(pending_payable_dao) + fn pending_payable_scanner_can_initiate_a_scan() { + init_test_logging(); + let test_name = "pending_payable_scanner_can_initiate_a_scan"; + let consuming_wallet = make_paying_wallet(b"consuming wallet"); + let now = SystemTime::now(); + let sent_tx = make_sent_tx(456); + let sent_tx_hash = sent_tx.hash; + let failed_tx = make_failed_tx(789); + let sent_payable_dao = + SentPayableDaoMock::new().retrieve_txs_result(btreeset![sent_tx.clone()]); + let failed_payable_dao = + FailedPayableDaoMock::new().retrieve_txs_result(BTreeSet::from([failed_tx.clone()])); + let mut subject = make_dull_subject(); + let pending_payable_scanner = PendingPayableScannerBuilder::new() + .sent_payable_dao(sent_payable_dao) + .failed_payable_dao(failed_payable_dao) + .sent_payable_cache(Box::new(CurrentPendingPayables::default())) + .failed_payable_cache(Box::new(RecheckRequiringFailures::default())) .build(); - let transaction_id_1 = PendingPayableId::new(2, make_tx_hash(333)); - let transaction_id_2 = PendingPayableId::new(3, make_tx_hash(444)); - let transaction_ids = vec![transaction_id_1, transaction_id_2]; + // Important + subject.aware_of_unresolved_pending_payable = true; + subject.pending_payable = Box::new(pending_payable_scanner); + let payable_scanner = PayableScannerBuilder::new().build(); + subject.payable = Box::new(payable_scanner); + + let result = subject.start_pending_payable_scan_guarded( + &consuming_wallet, + now, + None, + &Logger::new(test_name), + true, + ); - subject.cancel_failed_transactions(transaction_ids, &Logger::new("test")); + let is_scan_running = subject.pending_payable.scan_started_at().is_some(); + assert_eq!(is_scan_running, true); + assert_eq!( + result, + Ok(RequestTransactionReceipts { + tx_hashes: vec![ + TxHashByTable::SentPayable(sent_tx_hash), + TxHashByTable::FailedPayable(failed_tx.hash) + ], + response_skeleton_opt: None + }) + ); + TestLogHandler::new().assert_logs_match_in_order(vec![ + &format!("INFO: {test_name}: Scanning for pending payable"), + &format!( + "DEBUG: {test_name}: Found 1 pending payables and 1 suspected failures to process" + ), + ]) } #[test] - fn cancel_failed_transactions_does_nothing_if_no_tx_failures_detected() { - let subject = PendingPayableScannerBuilder::new().build(); - - subject.cancel_failed_transactions(vec![], &Logger::new("test")) + fn pending_payable_scanner_cannot_be_initiated_if_it_itself_is_already_running() { + let now = SystemTime::now(); + let consuming_wallet = make_paying_wallet(b"consuming"); + let mut subject = make_dull_subject(); + let sent_payable_dao = + SentPayableDaoMock::new().retrieve_txs_result(btreeset![make_sent_tx(123)]); + let failed_payable_dao = + FailedPayableDaoMock::new().retrieve_txs_result(BTreeSet::from([make_failed_tx(456)])); + let pending_payable_scanner = PendingPayableScannerBuilder::new() + .sent_payable_dao(sent_payable_dao) + .failed_payable_dao(failed_payable_dao) + .sent_payable_cache(Box::new(CurrentPendingPayables::default())) + .failed_payable_cache(Box::new(RecheckRequiringFailures::default())) + .build(); + // Important + subject.aware_of_unresolved_pending_payable = true; + subject.pending_payable = Box::new(pending_payable_scanner); + let payable_scanner = PayableScannerBuilder::new().build(); + subject.payable = Box::new(payable_scanner); + let logger = Logger::new("test"); + let _ = + subject.start_pending_payable_scan_guarded(&consuming_wallet, now, None, &logger, true); - //mocked pending payable DAO didn't panic which means we skipped the actual process - } + let result = subject.start_pending_payable_scan_guarded( + &consuming_wallet, + SystemTime::now(), + None, + &logger, + true, + ); - #[test] - #[should_panic( - expected = "Unable to delete payable fingerprints 0x000000000000000000000000000000000\ - 0000000000000000000000000000315, 0x00000000000000000000000000000000000000000000000000\ - 0000000000021a of verified transactions due to RecordDeletion(\"the database \ - is fooling around with us\")" - )] - fn confirm_transactions_panics_while_deleting_pending_payable_fingerprint() { - let payable_dao = PayableDaoMock::new().transactions_confirmed_result(Ok(())); - let pending_payable_dao = PendingPayableDaoMock::default().delete_fingerprints_result(Err( - PendingPayableDaoError::RecordDeletion( - "the database is fooling around with us".to_string(), - ), - )); - let mut subject = PendingPayableScannerBuilder::new() - .payable_dao(payable_dao) - .pending_payable_dao(pending_payable_dao) - .build(); - let mut fingerprint_1 = make_pending_payable_fingerprint(); - fingerprint_1.rowid = 1; - fingerprint_1.hash = make_tx_hash(0x315); - let mut fingerprint_2 = make_pending_payable_fingerprint(); - fingerprint_2.rowid = 1; - fingerprint_2.hash = make_tx_hash(0x21a); - - subject.confirm_transactions(vec![fingerprint_1, fingerprint_2], &Logger::new("test")); + let is_scan_running = subject.pending_payable.scan_started_at().is_some(); + assert_eq!(is_scan_running, true); + assert_eq!( + result, + Err(StartScanError::ScanAlreadyRunning { + cross_scan_cause_opt: None, + started_at: now + }) + ); } #[test] - fn confirm_transactions_does_nothing_if_none_found_on_the_blockchain() { - let mut subject = PendingPayableScannerBuilder::new().build(); + fn pending_payable_scanner_cannot_be_initiated_if_payable_scanner_is_still_running() { + let consuming_wallet = make_paying_wallet(b"consuming"); + let mut subject = make_dull_subject(); + let pending_payable_scanner = PendingPayableScannerBuilder::new().build(); + let payable_scanner = PayableScannerBuilder::new().build(); + // Important + subject.aware_of_unresolved_pending_payable = true; + subject.pending_payable = Box::new(pending_payable_scanner); + subject.payable = Box::new(payable_scanner); + let logger = Logger::new("test"); + let previous_scan_started_at = SystemTime::now(); + subject.payable.mark_as_started(previous_scan_started_at); - subject.confirm_transactions(vec![], &Logger::new("test")) + let result = subject.start_pending_payable_scan_guarded( + &consuming_wallet, + SystemTime::now(), + None, + &logger, + true, + ); - //mocked payable DAO didn't panic which means we skipped the actual process + let is_scan_running = subject.pending_payable.scan_started_at().is_some(); + assert_eq!(is_scan_running, false); + assert_eq!( + result, + Err(StartScanError::ScanAlreadyRunning { + cross_scan_cause_opt: Some(ScanType::Payables), + started_at: previous_scan_started_at + }) + ); } #[test] - fn confirm_transactions_works() { - init_test_logging(); - let transactions_confirmed_params_arc = Arc::new(Mutex::new(vec![])); - let delete_fingerprints_params_arc = Arc::new(Mutex::new(vec![])); - let payable_dao = PayableDaoMock::default() - .transactions_confirmed_params(&transactions_confirmed_params_arc) - .transactions_confirmed_result(Ok(())); - let pending_payable_dao = PendingPayableDaoMock::default() - .delete_fingerprints_params(&delete_fingerprints_params_arc) - .delete_fingerprints_result(Ok(())); - let mut subject = PendingPayableScannerBuilder::new() - .payable_dao(payable_dao) - .pending_payable_dao(pending_payable_dao) - .build(); - let rowid_1 = 2; - let rowid_2 = 5; - let pending_payable_fingerprint_1 = PendingPayableFingerprint { - rowid: rowid_1, - timestamp: from_time_t(199_000_000), - hash: make_tx_hash(0x123), - attempt: 1, - amount: 4567, - process_error: None, - }; - let pending_payable_fingerprint_2 = PendingPayableFingerprint { - rowid: rowid_2, - timestamp: from_time_t(200_000_000), - hash: make_tx_hash(0x567), - attempt: 1, - amount: 5555, - process_error: None, - }; + fn both_payable_scanners_cannot_be_detected_in_progress_at_the_same_time() { + let consuming_wallet = make_paying_wallet(b"consuming"); + let mut subject = make_dull_subject(); + let pending_payable_scanner = PendingPayableScannerBuilder::new().build(); + let payable_scanner = PayableScannerBuilder::new().build(); + subject.pending_payable = Box::new(pending_payable_scanner); + subject.payable = Box::new(payable_scanner); + let timestamp_pending_payable_start = SystemTime::now() + .checked_sub(Duration::from_millis(12)) + .unwrap(); + let timestamp_payable_scanner_start = SystemTime::now(); + subject.aware_of_unresolved_pending_payable = true; + subject + .pending_payable + .mark_as_started(timestamp_pending_payable_start); + subject + .payable + .mark_as_started(timestamp_payable_scanner_start); - subject.confirm_transactions( - vec![ - pending_payable_fingerprint_1.clone(), - pending_payable_fingerprint_2.clone(), - ], - &Logger::new("confirm_transactions_works"), - ); + let caught_panic = catch_unwind(AssertUnwindSafe(|| { + let _ = subject.start_pending_payable_scan_guarded( + &consuming_wallet, + SystemTime::now(), + None, + &Logger::new("test"), + true, + ); + })) + .unwrap_err(); - let confirm_transactions_params = transactions_confirmed_params_arc.lock().unwrap(); - assert_eq!( - *confirm_transactions_params, - vec![vec![ - pending_payable_fingerprint_1, - pending_payable_fingerprint_2 - ]] - ); - let delete_fingerprints_params = delete_fingerprints_params_arc.lock().unwrap(); - assert_eq!(*delete_fingerprints_params, vec![vec![rowid_1, rowid_2]]); - let log_handler = TestLogHandler::new(); - log_handler.exists_log_containing( - "DEBUG: confirm_transactions_works: \ - Confirmation of transactions \ - 0x0000000000000000000000000000000000000000000000000000000000000123, \ - 0x0000000000000000000000000000000000000000000000000000000000000567; \ - record for total paid payable was modified", + let panic_msg = caught_panic.downcast_ref::().unwrap(); + let expected_msg_fragment_1 = "internal error: entered unreachable code: Any payable-\ + related scanners should never be allowed to run in parallel. Scan for pending payables \ + started at: "; + assert!( + panic_msg.contains(expected_msg_fragment_1), + "This fragment '{}' wasn't found in \ + '{}'", + expected_msg_fragment_1, + panic_msg ); - log_handler.exists_log_containing( - "INFO: confirm_transactions_works: \ - Transactions \ - 0x0000000000000000000000000000000000000000000000000000000000000123, \ - 0x0000000000000000000000000000000000000000000000000000000000000567 \ - completed their confirmation process succeeding", + let expected_msg_fragment_2 = ", scan for payables started at: "; + assert!( + panic_msg.contains(expected_msg_fragment_2), + "This fragment '{}' wasn't found in \ + '{}'", + expected_msg_fragment_2, + panic_msg ); + assert_timestamps_from_str( + panic_msg, + vec![ + timestamp_pending_payable_start, + timestamp_payable_scanner_start, + ], + ) } #[test] #[should_panic( - expected = "Unable to cast confirmed pending payables 0x0000000000000000000000000000000000000000000\ - 000000000000000000315 into adjustment in the corresponding payable records due to RusqliteError\ - (\"record change not successful\")" + expected = "internal error: entered unreachable code: Automatic pending payable \ + scan should never start if there are no pending payables to process." )] - fn confirm_transactions_panics_on_unchecking_payable_table() { - let hash = make_tx_hash(0x315); - let rowid = 3; - let payable_dao = PayableDaoMock::new().transactions_confirmed_result(Err( - PayableDaoError::RusqliteError("record change not successful".to_string()), - )); - let mut subject = PendingPayableScannerBuilder::new() - .payable_dao(payable_dao) - .build(); - let mut fingerprint = make_pending_payable_fingerprint(); - fingerprint.rowid = rowid; - fingerprint.hash = hash; + fn pending_payable_scanner_bumps_into_zero_pending_payable_awareness_in_the_automatic_mode() { + let consuming_wallet = make_paying_wallet(b"consuming"); + let mut subject = make_dull_subject(); + let pending_payable_scanner = PendingPayableScannerBuilder::new().build(); + subject.pending_payable = Box::new(pending_payable_scanner); + subject.aware_of_unresolved_pending_payable = false; - subject.confirm_transactions(vec![fingerprint], &Logger::new("test")); + let _ = subject.start_pending_payable_scan_guarded( + &consuming_wallet, + SystemTime::now(), + None, + &Logger::new("test"), + true, + ); } #[test] - fn total_paid_payable_rises_with_each_bill_paid() { - let test_name = "total_paid_payable_rises_with_each_bill_paid"; - let fingerprint_1 = PendingPayableFingerprint { - rowid: 5, - timestamp: from_time_t(189_999_888), - hash: make_tx_hash(56789), - attempt: 1, - amount: 5478, - process_error: None, - }; - let fingerprint_2 = PendingPayableFingerprint { - rowid: 6, - timestamp: from_time_t(200_000_011), - hash: make_tx_hash(33333), - attempt: 1, - amount: 6543, - process_error: None, - }; - let payable_dao = PayableDaoMock::default().transactions_confirmed_result(Ok(())); - let pending_payable_dao = - PendingPayableDaoMock::default().delete_fingerprints_result(Ok(())); - let mut subject = PendingPayableScannerBuilder::new() - .payable_dao(payable_dao) - .pending_payable_dao(pending_payable_dao) - .build(); - let mut financial_statistics = subject.financial_statistics.borrow().clone(); - financial_statistics.total_paid_payable_wei += 1111; - subject.financial_statistics.replace(financial_statistics); + fn check_general_conditions_for_pending_payable_scan_if_it_is_initial_pending_payable_scan() { + let mut subject = make_dull_subject(); + subject.initial_pending_payable_scan = true; - subject.confirm_transactions( - vec![fingerprint_1.clone(), fingerprint_2.clone()], - &Logger::new(test_name), - ); + let result = subject.check_general_conditions_for_pending_payable_scan(false, true); - let total_paid_payable = subject.financial_statistics.borrow().total_paid_payable_wei; - assert_eq!(total_paid_payable, 1111 + 5478 + 6543); + assert_eq!(result, Ok(())); + assert_eq!(subject.initial_pending_payable_scan, true); } #[test] - fn pending_payable_scanner_handles_report_transaction_receipts_message() { + fn pending_payable_scanner_handles_tx_receipts_message() { + // Note: the choice of those hashes isn't random; I tried to make sure I will know the order, + // in which these records will be processed, because they are in an ordered map. + // It is important because otherwise preparation of results with the mocks would become + // chaotic, as long as you care about the exact receiver of the mock call among these records init_test_logging(); - let test_name = "pending_payable_scanner_handles_report_transaction_receipts_message"; + let test_name = "pending_payable_scanner_handles_tx_receipts_message"; + // Normal confirmation let transactions_confirmed_params_arc = Arc::new(Mutex::new(vec![])); + let confirm_tx_params_arc = Arc::new(Mutex::new(vec![])); + // FailedTx reclaim + let replace_records_params_arc = Arc::new(Mutex::new(vec![])); + // New tx failure + let insert_new_records_params_arc = Arc::new(Mutex::new(vec![])); + // Validation failures + let update_statuses_pending_payable_params_arc = Arc::new(Mutex::new(vec![])); + let update_statuses_failed_payable_params_arc = Arc::new(Mutex::new(vec![])); + let timestamp_a = SystemTime::now(); + let timestamp_b = SystemTime::now().sub(Duration::from_millis(12)); + let timestamp_c = SystemTime::now().sub(Duration::from_millis(1234)); let payable_dao = PayableDaoMock::new() .transactions_confirmed_params(&transactions_confirmed_params_arc) .transactions_confirmed_result(Ok(())); - let pending_payable_dao = PendingPayableDaoMock::new().delete_fingerprints_result(Ok(())); - let mut subject = PendingPayableScannerBuilder::new() - .payable_dao(payable_dao) - .pending_payable_dao(pending_payable_dao) - .build(); - let transaction_hash_1 = make_tx_hash(4545); - let transaction_receipt_1 = TxReceipt { - transaction_hash: transaction_hash_1, - status: TxStatus::Succeeded(TransactionBlock { - block_hash: Default::default(), - block_number: U64::from(1234), - }), - }; - let fingerprint_1 = PendingPayableFingerprint { - rowid: 5, - timestamp: from_time_t(200_000_000), - hash: transaction_hash_1, - attempt: 2, - amount: 444, - process_error: None, + let sent_payable_dao = SentPayableDaoMock::new() + .confirm_tx_params(&confirm_tx_params_arc) + .confirm_tx_result(Ok(())) + .update_statuses_params(&update_statuses_pending_payable_params_arc) + .update_statuses_result(Ok(())) + .replace_records_result(Ok(())) + .delete_records_result(Ok(())) + .replace_records_params(&replace_records_params_arc) + .replace_records_result(Ok(())); + let failed_payable_dao = FailedPayableDaoMock::new() + .insert_new_records_params(&insert_new_records_params_arc) + .insert_new_records_result(Ok(())) + .update_statuses_params(&update_statuses_failed_payable_params_arc) + .update_statuses_result(Ok(())) + .delete_records_result(Ok(())); + let tx_hash_1 = make_tx_hash(0x111); + let mut sent_tx_1 = make_sent_tx(123); + sent_tx_1.hash = tx_hash_1; + let tx_block_1 = TxBlock { + block_hash: make_block_hash(333), + block_number: U64::from(1234), }; - let transaction_hash_2 = make_tx_hash(1234); - let transaction_receipt_2 = TxReceipt { - transaction_hash: transaction_hash_2, - status: TxStatus::Succeeded(TransactionBlock { - block_hash: Default::default(), - block_number: U64::from(2345), - }), - }; - let fingerprint_2 = PendingPayableFingerprint { - rowid: 10, - timestamp: from_time_t(199_780_000), - hash: transaction_hash_2, - attempt: 15, - amount: 1212, - process_error: None, + let tx_status_1 = StatusReadFromReceiptCheck::Succeeded(tx_block_1); + let tx_hash_2 = make_tx_hash(0x222); + let mut failed_tx_2 = make_failed_tx(789); + failed_tx_2.hash = tx_hash_2; + let tx_block_2 = TxBlock { + block_hash: make_block_hash(222), + block_number: U64::from(2345), }; - let msg = ReportTransactionReceipts { - fingerprints_with_receipts: vec![ - ( - TransactionReceiptResult::RpcResponse(transaction_receipt_1), - fingerprint_1.clone(), - ), - ( - TransactionReceiptResult::RpcResponse(transaction_receipt_2), - fingerprint_2.clone(), - ), + let tx_status_2 = StatusReadFromReceiptCheck::Succeeded(tx_block_2); + let tx_hash_3 = make_tx_hash(0x333); + let mut sent_tx_3 = make_sent_tx(456); + sent_tx_3.hash = tx_hash_3; + let tx_status_3 = StatusReadFromReceiptCheck::Pending; + let tx_hash_4 = make_tx_hash(0x444); + let mut sent_tx_4 = make_sent_tx(4567); + sent_tx_4.hash = tx_hash_4; + sent_tx_4.status = TxStatus::Pending(ValidationStatus::Waiting); + let tx_receipt_rpc_error_4 = AppRpcError::Remote(RemoteError::Unreachable); + let tx_hash_5 = make_tx_hash(0x555); + let mut failed_tx_5 = make_failed_tx(888); + failed_tx_5.hash = tx_hash_5; + failed_tx_5.status = + FailureStatus::RecheckRequired(ValidationStatus::Reattempting(PreviousAttempts::new( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote(RemoteErrorKind::Unreachable)), + &SimpleClockMock::default().now_result(timestamp_c), + ))); + let tx_receipt_rpc_error_5 = + AppRpcError::Remote(RemoteError::InvalidResponse("game over".to_string())); + let tx_hash_6 = make_tx_hash(0x666); + let mut sent_tx_6 = make_sent_tx(789); + sent_tx_6.hash = tx_hash_6; + let tx_status_6 = StatusReadFromReceiptCheck::Reverted; + let sent_payable_cache = PendingPayableCacheMock::default() + .get_record_by_hash_result(Some(sent_tx_1.clone())) + .get_record_by_hash_result(Some(sent_tx_3.clone())) + .get_record_by_hash_result(Some(sent_tx_4)) + .get_record_by_hash_result(Some(sent_tx_6.clone())); + let failed_payable_cache = PendingPayableCacheMock::default() + .get_record_by_hash_result(Some(failed_tx_2.clone())) + .get_record_by_hash_result(Some(failed_tx_5)); + let validation_failure_clock = SimpleClockMock::default() + .now_result(timestamp_a) + .now_result(timestamp_b); + let mut pending_payable_scanner = PendingPayableScannerBuilder::new() + .payable_dao(payable_dao) + .sent_payable_dao(sent_payable_dao) + .failed_payable_dao(failed_payable_dao) + .sent_payable_cache(Box::new(sent_payable_cache)) + .failed_payable_cache(Box::new(failed_payable_cache)) + .validation_failure_clock(Box::new(validation_failure_clock)) + .build(); + let msg = TxReceiptsMessage { + results: btreemap![ + TxHashByTable::SentPayable(tx_hash_1) => Ok(tx_status_1), + TxHashByTable::FailedPayable(tx_hash_2) => Ok(tx_status_2), + TxHashByTable::SentPayable(tx_hash_3) => Ok(tx_status_3), + TxHashByTable::SentPayable(tx_hash_4) => Err(tx_receipt_rpc_error_4), + TxHashByTable::FailedPayable(tx_hash_5) => Err(tx_receipt_rpc_error_5), + TxHashByTable::SentPayable(tx_hash_6) => Ok(tx_status_6), ], response_skeleton_opt: None, }; - subject.mark_as_started(SystemTime::now()); + pending_payable_scanner.mark_as_started(SystemTime::now()); + let mut subject = make_dull_subject(); + subject.pending_payable = Box::new(pending_payable_scanner); - let message_opt = subject.finish_scan(msg, &Logger::new(test_name)); + let result = subject.finish_pending_payable_scan(msg, &Logger::new(test_name)); + assert_eq!(result, PendingPayableScanResult::PaymentRetryRequired(None)); let transactions_confirmed_params = transactions_confirmed_params_arc.lock().unwrap(); - assert_eq!(message_opt, None); + sent_tx_1.status = TxStatus::Confirmed { + block_hash: format!("{:?}", tx_block_1.block_hash), + block_number: tx_block_1.block_number.as_u64(), + detection: Detection::Normal, + }; + assert_eq!(*transactions_confirmed_params, vec![vec![sent_tx_1]]); + let confirm_tx_params = confirm_tx_params_arc.lock().unwrap(); + assert_eq!(*confirm_tx_params, vec![hashmap![tx_hash_1 => tx_block_1]]); + let sent_tx_2 = SentTx::from((failed_tx_2, tx_block_2)); + let replace_records_params = replace_records_params_arc.lock().unwrap(); + assert_eq!(*replace_records_params, vec![btreeset![sent_tx_2]]); + let insert_new_records_params = insert_new_records_params_arc.lock().unwrap(); + let expected_failure_for_tx_3 = FailedTx::from((sent_tx_3, FailureReason::PendingTooLong)); + let expected_failure_for_tx_6 = FailedTx::from((sent_tx_6, FailureReason::Reverted)); assert_eq!( - *transactions_confirmed_params, - vec![vec![fingerprint_1, fingerprint_2]] + *insert_new_records_params, + vec![btreeset![ + expected_failure_for_tx_3, + expected_failure_for_tx_6 + ]] ); - assert_eq!(subject.scan_started_at(), None); - TestLogHandler::new().assert_logs_match_in_order(vec![ - &format!( - "INFO: {}: Transactions {:?}, {:?} completed their confirmation process succeeding", - test_name, transaction_hash_1, transaction_hash_2 - ), - &format!("INFO: {test_name}: The PendingPayables scan ended in \\d+ms."), - ]); + let update_statuses_pending_payable_params = + update_statuses_pending_payable_params_arc.lock().unwrap(); + assert_eq!( + *update_statuses_pending_payable_params, + vec![ + hashmap!(tx_hash_4 => TxStatus::Pending(ValidationStatus::Reattempting(PreviousAttempts::new(BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote(RemoteErrorKind::Unreachable)), &SimpleClockMock::default().now_result(timestamp_a))))) + ] + ); + let update_statuses_failed_payable_params = + update_statuses_failed_payable_params_arc.lock().unwrap(); + assert_eq!( + *update_statuses_failed_payable_params, + vec![ + hashmap!(tx_hash_5 => FailureStatus::RecheckRequired(ValidationStatus::Reattempting(PreviousAttempts::new(BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote(RemoteErrorKind::Unreachable)), &SimpleClockMock::default().now_result(timestamp_c)).add_attempt(BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote(RemoteErrorKind::InvalidResponse)), &SimpleClockMock::default().now_result(timestamp_b))))) + ] + ); + assert_eq!(subject.scan_started_at(ScanType::PendingPayables), None); + let test_log_handler = TestLogHandler::new(); + test_log_handler.exists_log_containing(&format!( + "DEBUG: {test_name}: Processing receipts for 6 txs" + )); + test_log_handler.exists_log_containing(&format!("WARN: {test_name}: Failed to retrieve tx receipt for SentPayable(0x0000000000000000000000000000000000000000000000000000000000000444): Remote(Unreachable). Will retry receipt retrieval next cycle")); + test_log_handler.exists_log_containing(&format!("WARN: {test_name}: Failed to retrieve tx receipt for FailedPayable(0x0000000000000000000000000000000000000000000000000000000000000555): Remote(InvalidResponse(\"game over\")). Will retry receipt retrieval next cycle")); + test_log_handler.exists_log_containing(&format!("INFO: {test_name}: Reclaimed txs 0x0000000000000000000000000000000000000000000000000000000000000222 (block 2345) as confirmed on-chain")); + test_log_handler.exists_log_containing(&format!( + "INFO: {test_name}: Tx 0x0000000000000000000000000000000000000000000000000000000000000111 (block 1234) recorded in local ledger", + )); + test_log_handler.exists_log_containing(&format!("INFO: {test_name}: Failed txs 0x0000000000000000000000000000000000000000000000000000000000000333, 0x0000000000000000000000000000000000000000000000000000000000000666 were processed in the db")); } #[test] + #[should_panic( + expected = "We should never receive an empty list of results. Even receipts that could not \ + be retrieved can be interpreted" + )] fn pending_payable_scanner_handles_empty_report_transaction_receipts_message() { - init_test_logging(); - let test_name = - "pending_payable_scanner_handles_report_transaction_receipts_message_with_empty_vector"; - let mut subject = PendingPayableScannerBuilder::new().build(); - let msg = ReportTransactionReceipts { - fingerprints_with_receipts: vec![], + let mut pending_payable_scanner = PendingPayableScannerBuilder::new().build(); + let msg = TxReceiptsMessage { + results: btreemap![], response_skeleton_opt: None, }; - subject.mark_as_started(SystemTime::now()); + pending_payable_scanner.mark_as_started(SystemTime::now()); + let mut subject = make_dull_subject(); + subject.pending_payable = Box::new(pending_payable_scanner); - let message_opt = subject.finish_scan(msg, &Logger::new(test_name)); - - let is_scan_running = subject.scan_started_at().is_some(); - assert_eq!(message_opt, None); - assert_eq!(is_scan_running, false); - let tlh = TestLogHandler::new(); - tlh.exists_log_containing(&format!( - "DEBUG: {test_name}: No transaction receipts found." - )); - tlh.exists_log_matching(&format!( - "INFO: {test_name}: The PendingPayables scan ended in \\d+ms." - )); + let _ = subject.finish_pending_payable_scan(msg, &Logger::new("test")); } #[test] @@ -2906,18 +1567,21 @@ mod tests { .new_delinquencies_result(vec![]) .paid_delinquencies_result(vec![]); let earning_wallet = make_wallet("earning"); - let mut receivable_scanner = ReceivableScannerBuilder::new() + let mut subject = make_dull_subject(); + let receivable_scanner = ReceivableScannerBuilder::new() .receivable_dao(receivable_dao) .build(); + subject.receivable = Box::new(receivable_scanner); - let result = receivable_scanner.begin_scan( - earning_wallet.clone(), + let result = subject.start_receivable_scan_guarded( + &earning_wallet, now, None, &Logger::new(test_name), + true, ); - let is_scan_running = receivable_scanner.scan_started_at().is_some(); + let is_scan_running = subject.receivable.scan_started_at().is_some(); assert_eq!(is_scan_running, true); assert_eq!( result, @@ -2938,22 +1602,36 @@ mod tests { .new_delinquencies_result(vec![]) .paid_delinquencies_result(vec![]); let earning_wallet = make_wallet("earning"); - let mut receivable_scanner = ReceivableScannerBuilder::new() + let mut subject = make_dull_subject(); + let receivable_scanner = ReceivableScannerBuilder::new() .receivable_dao(receivable_dao) .build(); - let _ = - receivable_scanner.begin_scan(earning_wallet.clone(), now, None, &Logger::new("test")); + subject.receivable = Box::new(receivable_scanner); + let _ = subject.start_receivable_scan_guarded( + &earning_wallet, + now, + None, + &Logger::new("test"), + true, + ); - let result = receivable_scanner.begin_scan( - earning_wallet, + let result = subject.start_receivable_scan_guarded( + &earning_wallet, SystemTime::now(), None, &Logger::new("test"), + true, ); - let is_scan_running = receivable_scanner.scan_started_at().is_some(); + let is_scan_running = subject.receivable.scan_started_at().is_some(); assert_eq!(is_scan_running, true); - assert_eq!(result, Err(BeginScanError::ScanAlreadyRunning(now))); + assert_eq!( + result, + Err(StartScanError::ScanAlreadyRunning { + cross_scan_cause_opt: None, + started_at: now + }) + ); } #[test] @@ -2986,7 +1664,7 @@ mod tests { let logger = Logger::new("DELINQUENCY_TEST"); let now = SystemTime::now(); - let result = receivable_scanner.begin_scan(earning_wallet.clone(), now, None, &logger); + let result = receivable_scanner.start_scan(&earning_wallet, now, None, &logger); assert_eq!( result, @@ -3040,7 +1718,7 @@ mod tests { .start_block_result(Ok(None)) .set_start_block_params(&set_start_block_params_arc) .set_start_block_result(Ok(())); - let mut subject = ReceivableScannerBuilder::new() + let receivable_scanner = ReceivableScannerBuilder::new() .persistent_configuration(persistent_config) .build(); let msg = ReceivedPayments { @@ -3049,10 +1727,12 @@ mod tests { response_skeleton_opt: None, transactions: vec![], }; + let mut subject = make_dull_subject(); + subject.receivable = Box::new(receivable_scanner); - let message_opt = subject.finish_scan(msg, &Logger::new(test_name)); + let ui_msg_opt = subject.finish_receivable_scan(msg, &Logger::new(test_name)); - assert_eq!(message_opt, None); + assert_eq!(ui_msg_opt, None); let set_start_block_params = set_start_block_params_arc.lock().unwrap(); assert_eq!(*set_start_block_params, vec![Some(4321)]); TestLogHandler::new().exists_log_containing(&format!( @@ -3061,8 +1741,10 @@ mod tests { } #[test] - #[should_panic(expected = "Attempt to set new start block to 6709 failed due to: \ - UninterpretableValue(\"Illiterate database manager\")")] + #[should_panic( + expected = "Attempt to advance the start block to 6709 failed due to: \ + UninterpretableValue(\"Illiterate database manager\")" + )] fn no_transactions_received_but_start_block_setting_fails() { init_test_logging(); let test_name = "no_transactions_received_but_start_block_setting_fails"; @@ -3084,7 +1766,6 @@ mod tests { response_skeleton_opt: None, transactions: vec![], }; - // Not necessary, rather for preciseness subject.mark_as_started(SystemTime::now()); @@ -3112,13 +1793,15 @@ mod tests { let receivable_dao = ReceivableDaoMock::new() .more_money_received_params(&more_money_received_params_arc) .more_money_received_result(transaction); - let mut subject = ReceivableScannerBuilder::new() + let mut receivable_scanner = ReceivableScannerBuilder::new() .receivable_dao(receivable_dao) .persistent_configuration(persistent_config) .build(); - let mut financial_statistics = subject.financial_statistics.borrow().clone(); + let mut financial_statistics = receivable_scanner.financial_statistics.borrow().clone(); financial_statistics.total_paid_receivable_wei += 2_222_123_123; - subject.financial_statistics.replace(financial_statistics); + receivable_scanner + .financial_statistics + .replace(financial_statistics); let receivables = vec![ BlockchainTransaction { block_number: 4578910, @@ -3137,16 +1820,23 @@ mod tests { response_skeleton_opt: None, transactions: receivables.clone(), }; - subject.mark_as_started(SystemTime::now()); + receivable_scanner.mark_as_started(SystemTime::now()); + let mut subject = make_dull_subject(); + subject.receivable = Box::new(receivable_scanner); - let message_opt = subject.finish_scan(msg, &Logger::new(test_name)); + let ui_msg_opt = subject.finish_receivable_scan(msg, &Logger::new(test_name)); - let total_paid_receivable = subject + let scanner_after = subject + .receivable + .as_any() + .downcast_ref::() + .unwrap(); + let total_paid_receivable = scanner_after .financial_statistics .borrow() .total_paid_receivable_wei; - assert_eq!(message_opt, None); - assert_eq!(subject.scan_started_at(), None); + assert_eq!(ui_msg_opt, None); + assert_eq!(scanner_after.scan_started_at(), None); assert_eq!(total_paid_receivable, 2_222_123_123 + 45_780 + 3_333_345); let more_money_received_params = more_money_received_params_arc.lock().unwrap(); assert_eq!(*more_money_received_params, vec![(now, receivables)]); @@ -3277,7 +1967,7 @@ mod tests { let test_name = "signal_scanner_completion_and_log_if_timestamp_is_correct"; let logger = Logger::new(test_name); let mut subject = ScannerCommon::new(Rc::new(make_custom_payment_thresholds())); - let start = from_time_t(1_000_000_000); + let start = from_unix_timestamp(1_000_000_000); let end = start.checked_add(Duration::from_millis(145)).unwrap(); subject.initiated_at_opt = Some(start); @@ -3299,12 +1989,12 @@ mod tests { subject.signal_scanner_completion(ScanType::Receivables, SystemTime::now(), &logger); TestLogHandler::new().exists_log_containing(&format!( - "ERROR: {test_name}: Called scan_finished() for Receivables scanner but timestamp was not found" + "ERROR: {test_name}: Called scan_finished() for Receivables scanner but could not find any timestamp" )); } - fn assert_elapsed_time_in_mark_as_ended( - subject: &mut dyn Scanner, + fn assert_elapsed_time_in_mark_as_ended( + subject: &mut dyn Scanner, scanner_name: &str, test_name: &str, logger: &Logger, @@ -3343,21 +2033,21 @@ mod tests { let logger = Logger::new(test_name); let log_handler = TestLogHandler::new(); - assert_elapsed_time_in_mark_as_ended::( + assert_elapsed_time_in_mark_as_ended::( &mut PayableScannerBuilder::new().build(), "Payables", test_name, &logger, &log_handler, ); - assert_elapsed_time_in_mark_as_ended::( + assert_elapsed_time_in_mark_as_ended::( &mut PendingPayableScannerBuilder::new().build(), "PendingPayables", test_name, &logger, &log_handler, ); - assert_elapsed_time_in_mark_as_ended::( + assert_elapsed_time_in_mark_as_ended::>( &mut ReceivableScannerBuilder::new().build(), "Receivables", test_name, @@ -3367,38 +2057,234 @@ mod tests { } #[test] - fn scan_schedulers_can_be_properly_initialized() { - let scan_intervals = ScanIntervals { - payable_scan_interval: Duration::from_secs(240), - pending_payable_scan_interval: Duration::from_secs(300), - receivable_scan_interval: Duration::from_secs(360), - }; + fn scan_already_running_msg_displays_correctly_if_blocked_by_requested_scan() { + test_scan_already_running_msg( + ScanType::PendingPayables, + None, + "PendingPayables scan was already initiated at", + ". Hence, this scan request will be ignored.", + ) + } + + #[test] + fn scan_already_running_msg_displays_correctly_if_blocked_by_other_scan_than_directly_requested( + ) { + test_scan_already_running_msg( + ScanType::PendingPayables, + Some(ScanType::Payables), + "Payables scan was already initiated at", + ". Hence, the PendingPayables scan request will be ignored.", + ) + } - let result = ScanSchedulers::new(scan_intervals); + fn test_scan_already_running_msg( + requested_scan: ScanType, + cross_scan_blocking_cause_opt: Option, + expected_leading_msg_fragment: &str, + expected_trailing_msg_fragment: &str, + ) { + let some_time = SystemTime::now(); - assert_eq!( - result - .schedulers - .get(&ScanType::Payables) - .unwrap() - .interval(), - scan_intervals.payable_scan_interval + let result = StartScanError::scan_already_running_msg( + requested_scan, + cross_scan_blocking_cause_opt, + some_time, ); - assert_eq!( + + assert!( + result.contains(expected_leading_msg_fragment), + "We expected {} but the msg is: {}", + expected_leading_msg_fragment, result - .schedulers - .get(&ScanType::PendingPayables) - .unwrap() - .interval(), - scan_intervals.pending_payable_scan_interval ); - assert_eq!( + assert!( + result.contains(expected_trailing_msg_fragment), + "We expected {} but the msg is: {}", + expected_trailing_msg_fragment, result - .schedulers - .get(&ScanType::Receivables) - .unwrap() - .interval(), - scan_intervals.receivable_scan_interval ); + assert_timestamps_from_str(&result, vec![some_time]); + } + + #[test] + fn acknowledge_scan_error_works() { + fn scan_error(scan_type: DetailedScanType) -> ScanError { + ScanError { + scan_type, + response_skeleton_opt: None, + msg: "blah".to_string(), + } + } + + init_test_logging(); + let test_name = "acknowledge_scan_error_works"; + let inputs: Vec<( + DetailedScanType, + Box, + Box Option>, + )> = vec![ + ( + DetailedScanType::NewPayables, + Box::new(|subject| subject.payable.mark_as_started(SystemTime::now())), + Box::new(|subject| subject.payable.scan_started_at()), + ), + ( + DetailedScanType::RetryPayables, + Box::new(|subject| subject.payable.mark_as_started(SystemTime::now())), + Box::new(|subject| subject.payable.scan_started_at()), + ), + ( + DetailedScanType::PendingPayables, + Box::new(|subject| subject.pending_payable.mark_as_started(SystemTime::now())), + Box::new(|subject| subject.pending_payable.scan_started_at()), + ), + ( + DetailedScanType::Receivables, + Box::new(|subject| subject.receivable.mark_as_started(SystemTime::now())), + Box::new(|subject| subject.receivable.scan_started_at()), + ), + ]; + let mut subject = make_dull_subject(); + subject.payable = Box::new(PayableScannerBuilder::new().build()); + subject.pending_payable = Box::new(PendingPayableScannerBuilder::new().build()); + subject.receivable = Box::new(ReceivableScannerBuilder::new().build()); + let logger = Logger::new(test_name); + let test_log_handler = TestLogHandler::new(); + + inputs + .into_iter() + .for_each(|(scan_type, set_started, get_started_at)| { + set_started(&mut subject); + let started_at_before = get_started_at(&subject); + + subject.acknowledge_scan_error(&scan_error(scan_type), &logger); + + let started_at_after = get_started_at(&subject); + assert!( + started_at_before.is_some(), + "Should've been started for {:?}", + scan_type + ); + assert_eq!( + started_at_after, None, + "Should've been unset for {:?}", + scan_type + ); + test_log_handler.exists_log_containing(&format!( + "INFO: {test_name}: The {:?} scan ended in", + ScanType::from(scan_type) + )); + }) + } + + #[test] + fn log_error_works_fine() { + init_test_logging(); + let test_name = "log_error_works_fine"; + let now = SystemTime::now(); + let input: Vec<(StartScanError, Box String>, &str, &str)> = vec![ + ( + StartScanError::ScanAlreadyRunning { + cross_scan_cause_opt: None, + started_at: now, + }, + Box::new(|sev| { + format!( + "{sev}: {test_name}: Payables scan was already initiated at {}", + StartScanError::timestamp_as_string(now) + ) + }), + "INFO", + "DEBUG", + ), + ( + StartScanError::ManualTriggerError(ManulTriggerError::AutomaticScanConflict), + Box::new(|sev| { + format!("{sev}: {test_name}: User requested Payables scan was denied. Automatic mode prevents manual triggers.") + }), + "WARN", + "WARN", + ), + ( + StartScanError::ManualTriggerError(ManulTriggerError::UnnecessaryRequest { + hint_opt: Some("Wise words".to_string()), + }), + Box::new(|sev| { + format!("{sev}: {test_name}: User requested Payables scan was denied expecting zero findings. Wise words") + }), + "INFO", + "DEBUG", + ), + ( + StartScanError::ManualTriggerError(ManulTriggerError::UnnecessaryRequest { + hint_opt: None, + }), + Box::new(|sev| { + format!("{sev}: {test_name}: User requested Payables scan was denied expecting zero findings.") + }), + "INFO", + "DEBUG", + ), + ( + StartScanError::CalledFromNullScanner, + Box::new(|sev| { + format!( + "{sev}: {test_name}: Called from NullScanner, not the Payables scanner." + ) + }), + "WARN", + "WARN", + ), + ( + StartScanError::NoConsumingWalletFound, + Box::new(|sev| { + format!("{sev}: {test_name}: Cannot initiate Payables scan because no consuming wallet was found.") + }), + "WARN", + "WARN", + ), + ( + StartScanError::NothingToProcess, + Box::new(|sev| { + format!( + "{sev}: {test_name}: There was nothing to process during Payables scan." + ) + }), + "INFO", + "DEBUG", + ), + ]; + let logger = Logger::new(test_name); + let test_log_handler = TestLogHandler::new(); + + input.into_iter().for_each( + |( + err, + form_expected_log_msg, + log_severity_for_externally_triggered_scans, + log_severity_for_automatic_scans, + )| { + let test_log_error_by_mode = + |is_externally_triggered: bool, expected_severity: &str| { + err.log_error(&logger, ScanType::Payables, is_externally_triggered); + let expected_log_msg = form_expected_log_msg(expected_severity); + test_log_handler.exists_log_containing(&expected_log_msg); + }; + + test_log_error_by_mode(true, log_severity_for_externally_triggered_scans); + + test_log_error_by_mode(false, log_severity_for_automatic_scans); + }, + ); + } + + fn make_dull_subject() -> Scanners { + Scanners { + payable: Box::new(NullScanner::new()), + aware_of_unresolved_pending_payable: false, + initial_pending_payable_scan: false, + pending_payable: Box::new(NullScanner::new()), + receivable: Box::new(NullScanner::new()), + } } } diff --git a/node/src/accountant/scanners/payable_scanner/finish_scan.rs b/node/src/accountant/scanners/payable_scanner/finish_scan.rs new file mode 100644 index 0000000000..900bf9b561 --- /dev/null +++ b/node/src/accountant/scanners/payable_scanner/finish_scan.rs @@ -0,0 +1,258 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. +use crate::accountant::scanners::payable_scanner::utils::PayableScanResult; +use crate::accountant::scanners::payable_scanner::PayableScanner; +use crate::accountant::scanners::Scanner; +use crate::accountant::SentPayables; +use crate::time_marking_methods; +use masq_lib::logger::Logger; +use masq_lib::messages::ScanType; +use std::time::SystemTime; + +impl Scanner for PayableScanner { + fn finish_scan(&mut self, msg: SentPayables, logger: &Logger) -> PayableScanResult { + // TODO as for GH-701, here there should be this check, but later on, when it comes to + // GH-655, the need for this check passes and it will go away. Until then it should be + // present, though. + // if !sent_payables.is_empty() { + // self.check_on_missing_sent_tx_records(&sent_payables); + // } + + self.process_message(&msg, logger); + + self.mark_as_ended(logger); + + PayableScanResult { + ui_response_opt: Self::generate_ui_response(msg.response_skeleton_opt), + result: Self::determine_next_scan_to_run(&msg), + } + } + + time_marking_methods!(Payables); + + as_any_ref_in_trait_impl!(); +} + +#[cfg(test)] +mod tests { + use crate::accountant::db_access_objects::failed_payable_dao::{FailedTx, FailureStatus}; + use crate::accountant::db_access_objects::test_utils::{ + make_failed_tx, make_sent_tx, FailedTxBuilder, + }; + use crate::accountant::scanners::payable_scanner::test_utils::PayableScannerBuilder; + use crate::accountant::scanners::payable_scanner::utils::{NextScanToRun, PayableScanResult}; + use crate::accountant::scanners::Scanner; + use crate::accountant::test_utils::{FailedPayableDaoMock, SentPayableDaoMock}; + use crate::accountant::{join_with_separator, PayableScanType, ResponseSkeleton, SentPayables}; + use crate::blockchain::blockchain_interface::data_structures::BatchResults; + use crate::blockchain::errors::validation_status::ValidationStatus::Waiting; + use crate::blockchain::test_utils::make_tx_hash; + use masq_lib::logger::Logger; + use masq_lib::messages::{ToMessageBody, UiScanResponse}; + use masq_lib::test_utils::logging::{init_test_logging, TestLogHandler}; + use masq_lib::ui_gateway::{MessageTarget, NodeToUiMessage}; + use std::collections::BTreeSet; + use std::sync::{Arc, Mutex}; + use std::time::SystemTime; + + #[test] + fn new_payable_scan_finishes_as_expected() { + init_test_logging(); + let test_name = "new_payable_scan_finishes_as_expected"; + let sent_payable_insert_new_records_params_arc = Arc::new(Mutex::new(vec![])); + let failed_payable_insert_new_records_params_arc = Arc::new(Mutex::new(vec![])); + let failed_tx_1 = make_failed_tx(1); + let failed_tx_2 = make_failed_tx(2); + let sent_tx_1 = make_sent_tx(1); + let sent_tx_2 = make_sent_tx(2); + let batch_results = BatchResults { + sent_txs: vec![sent_tx_1.clone(), sent_tx_2.clone()], + failed_txs: vec![failed_tx_1.clone(), failed_tx_2.clone()], + }; + let response_skeleton = ResponseSkeleton { + client_id: 1234, + context_id: 5678, + }; + let sent_payable_dao = SentPayableDaoMock::default() + .insert_new_records_params(&sent_payable_insert_new_records_params_arc) + .insert_new_records_result(Ok(())); + let failed_payable_dao = FailedPayableDaoMock::default() + .insert_new_records_params(&failed_payable_insert_new_records_params_arc) + .insert_new_records_result(Ok(())); + let mut subject = PayableScannerBuilder::new() + .sent_payable_dao(sent_payable_dao) + .failed_payable_dao(failed_payable_dao) + .build(); + subject.mark_as_started(SystemTime::now()); + let sent_payables = SentPayables { + payment_procedure_result: Ok(batch_results), + payable_scan_type: PayableScanType::New, + response_skeleton_opt: Some(response_skeleton), + }; + let logger = Logger::new(test_name); + + let result = subject.finish_scan(sent_payables, &logger); + + let sent_payable_insert_new_records_params = + sent_payable_insert_new_records_params_arc.lock().unwrap(); + let failed_payable_insert_new_records_params = + failed_payable_insert_new_records_params_arc.lock().unwrap(); + assert_eq!(sent_payable_insert_new_records_params.len(), 1); + assert_eq!( + sent_payable_insert_new_records_params[0], + BTreeSet::from([sent_tx_1, sent_tx_2]) + ); + assert_eq!(failed_payable_insert_new_records_params.len(), 1); + assert!(failed_payable_insert_new_records_params[0].contains(&failed_tx_1)); + assert!(failed_payable_insert_new_records_params[0].contains(&failed_tx_2)); + assert_eq!( + result, + PayableScanResult { + ui_response_opt: Some(NodeToUiMessage { + target: MessageTarget::ClientId(response_skeleton.client_id), + body: UiScanResponse {}.tmb(response_skeleton.context_id), + }), + result: NextScanToRun::PendingPayableScan, + } + ); + TestLogHandler::new().exists_log_matching(&format!( + "INFO: {test_name}: The Payables scan ended in \\d+ms." + )); + } + + #[test] + fn retry_payable_scan_finishes_as_expected() { + init_test_logging(); + let test_name = "retry_payable_scan_finishes_as_expected"; + let sent_payable_insert_new_records_params_arc = Arc::new(Mutex::new(vec![])); + let failed_payable_update_statuses_params_arc = Arc::new(Mutex::new(vec![])); + let sent_payable_dao = SentPayableDaoMock::default() + .insert_new_records_params(&sent_payable_insert_new_records_params_arc) + .insert_new_records_result(Ok(())); + let sent_txs = vec![make_sent_tx(1), make_sent_tx(2)]; + let failed_txs = vec![make_failed_tx(1), make_failed_tx(2)]; + let prev_failed_txs: BTreeSet = sent_txs + .iter() + .enumerate() + .map(|(i, tx)| { + let i = (i + 1) * 10; + FailedTxBuilder::default() + .hash(make_tx_hash(i as u32)) + .nonce(i as u64) + .receiver_address(tx.receiver_address) + .build() + }) + .collect(); + let failed_paybale_dao = FailedPayableDaoMock::default() + .update_statuses_params(&failed_payable_update_statuses_params_arc) + .retrieve_txs_result(prev_failed_txs) + .update_statuses_result(Ok(())); + let mut subject = PayableScannerBuilder::new() + .sent_payable_dao(sent_payable_dao) + .failed_payable_dao(failed_paybale_dao) + .build(); + subject.mark_as_started(SystemTime::now()); + let response_skeleton = ResponseSkeleton { + client_id: 1234, + context_id: 5678, + }; + let sent_payables = SentPayables { + payment_procedure_result: Ok(BatchResults { + sent_txs: sent_txs.clone(), + failed_txs: failed_txs.clone(), + }), + payable_scan_type: PayableScanType::Retry, + response_skeleton_opt: Some(response_skeleton), + }; + let logger = Logger::new(test_name); + + let result = subject.finish_scan(sent_payables, &logger); + + let sent_payable_insert_new_records_params = + sent_payable_insert_new_records_params_arc.lock().unwrap(); + let failed_payable_update_statuses_params = + failed_payable_update_statuses_params_arc.lock().unwrap(); + assert_eq!(sent_payable_insert_new_records_params.len(), 1); + assert_eq!( + sent_payable_insert_new_records_params[0], + sent_txs.iter().cloned().collect::>() + ); + assert_eq!(failed_payable_update_statuses_params.len(), 1); + let updated_statuses = failed_payable_update_statuses_params[0].clone(); + assert_eq!(updated_statuses.len(), 2); + assert_eq!( + updated_statuses.get(&make_tx_hash(10)).unwrap(), + &FailureStatus::RecheckRequired(Waiting) + ); + assert_eq!( + updated_statuses.get(&make_tx_hash(20)).unwrap(), + &FailureStatus::RecheckRequired(Waiting) + ); + assert_eq!( + result, + PayableScanResult { + ui_response_opt: Some(NodeToUiMessage { + target: MessageTarget::ClientId(response_skeleton.client_id), + body: UiScanResponse {}.tmb(response_skeleton.context_id), + }), + result: NextScanToRun::PendingPayableScan, + } + ); + let tlh = TestLogHandler::new(); + tlh.exists_log_containing(&format!( + "WARN: {test_name}: While retrying, 2 transactions with hashes: {} have failed.", + join_with_separator(failed_txs, |failed_tx| format!("{:?}", failed_tx.hash), ",") + )); + tlh.exists_log_matching(&format!( + "INFO: {test_name}: The Payables scan ended in \\d+ms." + )); + } + + #[test] + fn payable_scanner_with_error_works_as_expected() { + test_execute_payable_scanner_finish_scan_with_an_error(PayableScanType::New, "new"); + test_execute_payable_scanner_finish_scan_with_an_error(PayableScanType::Retry, "retry"); + } + + fn test_execute_payable_scanner_finish_scan_with_an_error( + payable_scan_type: PayableScanType, + suffix: &str, + ) { + init_test_logging(); + let test_name = &format!("test_execute_payable_scanner_finish_scan_with_an_error_{suffix}"); + let response_skeleton = ResponseSkeleton { + client_id: 1234, + context_id: 5678, + }; + let mut subject = PayableScannerBuilder::new().build(); + subject.mark_as_started(SystemTime::now()); + let sent_payables = SentPayables { + payment_procedure_result: Err("Any error".to_string()), + payable_scan_type, + response_skeleton_opt: Some(response_skeleton), + }; + let logger = Logger::new(test_name); + + let result = subject.finish_scan(sent_payables, &logger); + + assert_eq!( + result, + PayableScanResult { + ui_response_opt: Some(NodeToUiMessage { + target: MessageTarget::ClientId(response_skeleton.client_id), + body: UiScanResponse {}.tmb(response_skeleton.context_id), + }), + result: match payable_scan_type { + PayableScanType::New => NextScanToRun::NewPayableScan, + PayableScanType::Retry => NextScanToRun::RetryPayableScan, + }, + } + ); + let tlh = TestLogHandler::new(); + tlh.exists_log_containing(&format!( + "WARN: {test_name}: Local error occurred before transaction signing. Error: Any error" + )); + tlh.exists_log_matching(&format!( + "INFO: {test_name}: The Payables scan ended in \\d+ms." + )); + } +} diff --git a/node/src/accountant/scanners/payable_scanner/mod.rs b/node/src/accountant/scanners/payable_scanner/mod.rs new file mode 100644 index 0000000000..4c9ac98045 --- /dev/null +++ b/node/src/accountant/scanners/payable_scanner/mod.rs @@ -0,0 +1,1069 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. +mod finish_scan; +pub mod msgs; +mod start_scan; +pub mod test_utils; +pub mod tx_templates; + +pub mod payment_adjuster_integration; +pub mod utils; + +use crate::accountant::db_access_objects::failed_payable_dao::FailureRetrieveCondition::ByStatus; +use crate::accountant::db_access_objects::failed_payable_dao::FailureStatus::RetryRequired; +use crate::accountant::db_access_objects::failed_payable_dao::{ + FailedPayableDao, FailedTx, FailureReason, FailureRetrieveCondition, FailureStatus, +}; +use crate::accountant::db_access_objects::payable_dao::PayableRetrieveCondition::ByAddresses; +use crate::accountant::db_access_objects::payable_dao::{PayableAccount, PayableDao}; +use crate::accountant::db_access_objects::sent_payable_dao::{SentPayableDao, SentTx}; +use crate::accountant::db_access_objects::utils::TxHash; +use crate::accountant::payment_adjuster::PaymentAdjuster; +use crate::accountant::scanners::payable_scanner::msgs::InitialTemplatesMessage; +use crate::accountant::scanners::payable_scanner::payment_adjuster_integration::SolvencySensitivePaymentInstructor; +use crate::accountant::scanners::payable_scanner::utils::{ + batch_stats, calculate_occurences, filter_receiver_addresses_from_txs, generate_status_updates, + payables_debug_summary, NextScanToRun, PayableScanResult, PayableThresholdsGauge, + PayableThresholdsGaugeReal, PendingPayableMissingInDb, +}; +use crate::accountant::scanners::{Scanner, ScannerCommon, StartableScanner}; +use crate::accountant::{ + gwei_to_wei, join_with_commas, join_with_separator, PayableScanType, PendingPayable, + ResponseSkeleton, ScanForNewPayables, ScanForRetryPayables, SentPayables, +}; +use crate::blockchain::blockchain_interface::data_structures::BatchResults; +use crate::blockchain::errors::validation_status::ValidationStatus; +use crate::sub_lib::accountant::PaymentThresholds; +use crate::sub_lib::wallet::Wallet; +use itertools::Itertools; +use masq_lib::logger::Logger; +use masq_lib::messages::{ToMessageBody, UiScanResponse}; +use masq_lib::ui_gateway::{MessageTarget, NodeToUiMessage}; +use masq_lib::utils::ExpectValue; +use std::collections::{BTreeSet, HashMap}; +use std::rc::Rc; +use std::time::SystemTime; +use web3::types::Address; + +pub(in crate::accountant::scanners) trait MultistageDualPayableScanner: + StartableScanner + + StartableScanner + + SolvencySensitivePaymentInstructor + + Scanner +{ +} + +pub struct PayableScanner { + pub payable_threshold_gauge: Box, + pub common: ScannerCommon, + pub payable_dao: Box, + pub sent_payable_dao: Box, + pub failed_payable_dao: Box, + pub payment_adjuster: Box, +} + +impl MultistageDualPayableScanner for PayableScanner {} + +impl PayableScanner { + pub fn new( + payable_dao: Box, + sent_payable_dao: Box, + failed_payable_dao: Box, + payment_thresholds: Rc, + payment_adjuster: Box, + ) -> Self { + Self { + common: ScannerCommon::new(payment_thresholds), + payable_dao, + sent_payable_dao, + failed_payable_dao, + payable_threshold_gauge: Box::new(PayableThresholdsGaugeReal::default()), + payment_adjuster, + } + } + + pub fn sniff_out_alarming_payables_and_maybe_log_them( + &self, + retrieve_payables: Vec, + logger: &Logger, + ) -> Vec { + fn pass_payables_and_drop_points( + qp_tp: impl Iterator, + ) -> Vec { + let (payables, _) = qp_tp.unzip::<_, _, Vec, Vec<_>>(); + payables + } + + let qualified_payables_and_points_uncollected = + retrieve_payables.into_iter().flat_map(|account| { + self.payable_exceeded_threshold(&account, SystemTime::now()) + .map(|threshold_point| (account, threshold_point)) + }); + match logger.debug_enabled() { + false => pass_payables_and_drop_points(qualified_payables_and_points_uncollected), + true => { + let qualified_and_points_collected = + qualified_payables_and_points_uncollected.collect_vec(); + payables_debug_summary(&qualified_and_points_collected, logger); + pass_payables_and_drop_points(qualified_and_points_collected.into_iter()) + } + } + } + + fn payable_exceeded_threshold( + &self, + payable: &PayableAccount, + now: SystemTime, + ) -> Option { + let debt_age = now + .duration_since(payable.last_paid_timestamp) + .expect("Internal error") + .as_secs(); + + if self.payable_threshold_gauge.is_innocent_age( + debt_age, + self.common.payment_thresholds.maturity_threshold_sec, + ) { + return None; + } + + if self.payable_threshold_gauge.is_innocent_balance( + payable.balance_wei, + gwei_to_wei(self.common.payment_thresholds.permanent_debt_allowed_gwei), + ) { + return None; + } + + let threshold = self + .payable_threshold_gauge + .calculate_payout_threshold_in_gwei(&self.common.payment_thresholds, debt_age); + if payable.balance_wei > threshold { + Some(threshold) + } else { + None + } + } + + fn check_for_missing_records( + &self, + just_baked_sent_payables: &[&PendingPayable], + ) -> Vec { + let actual_sent_payables_len = just_baked_sent_payables.len(); + let hashset_with_hashes_to_eliminate_duplicates = just_baked_sent_payables + .iter() + .map(|pending_payable| pending_payable.hash) + .collect::>(); + + if hashset_with_hashes_to_eliminate_duplicates.len() != actual_sent_payables_len { + panic!( + "Found duplicates in the recent sent txs: {:?}", + just_baked_sent_payables + ); + } + + let transaction_hashes_and_rowids_from_db = self + .sent_payable_dao + .get_tx_identifiers(&hashset_with_hashes_to_eliminate_duplicates); + let hashes_from_db = transaction_hashes_and_rowids_from_db + .keys() + .copied() + .collect::>(); + + let missing_sent_payables_hashes = hashset_with_hashes_to_eliminate_duplicates + .difference(&hashes_from_db) + .copied(); + + let mut sent_payables_hashmap = just_baked_sent_payables + .iter() + .map(|payable| (payable.hash, &payable.recipient_wallet)) + .collect::>(); + missing_sent_payables_hashes + .map(|hash| { + let wallet_address = sent_payables_hashmap + .remove(&hash) + .expectv("wallet") + .address(); + PendingPayableMissingInDb::new(wallet_address, hash) + }) + .collect() + } + + // TODO this should be used when Utkarsh picks the card GH-701 where he postponed the fix of saving the SentTxs + #[allow(dead_code)] + fn check_on_missing_sent_tx_records(&self, sent_payments: &[&PendingPayable]) { + fn missing_record_msg(nonexistent: &[PendingPayableMissingInDb]) -> String { + format!( + "Expected sent-payable records for {} were not found. The system has become unreliable", + join_with_commas(nonexistent, |missing_sent_tx_ids| format!( + "(tx: {:?}, to wallet: {:?})", + missing_sent_tx_ids.hash, missing_sent_tx_ids.recipient + )) + ) + } + + let missing_sent_tx_records = self.check_for_missing_records(sent_payments); + if !missing_sent_tx_records.is_empty() { + panic!("{}", missing_record_msg(&missing_sent_tx_records)) + } + } + + fn determine_next_scan_to_run(msg: &SentPayables) -> NextScanToRun { + match &msg.payment_procedure_result { + Ok(batch_results) => { + if batch_results.sent_txs.is_empty() { + if batch_results.failed_txs.is_empty() { + return NextScanToRun::NewPayableScan; + } else { + return NextScanToRun::RetryPayableScan; + } + } + + NextScanToRun::PendingPayableScan + } + Err(_e) => match msg.payable_scan_type { + PayableScanType::New => NextScanToRun::NewPayableScan, + PayableScanType::Retry => NextScanToRun::RetryPayableScan, + }, + } + } + + fn process_message(&self, msg: &SentPayables, logger: &Logger) { + match &msg.payment_procedure_result { + Ok(batch_results) => match msg.payable_scan_type { + PayableScanType::New => { + self.handle_batch_results_for_new_scan(batch_results, logger) + } + PayableScanType::Retry => { + self.handle_batch_results_for_retry_scan(batch_results, logger) + } + }, + Err(local_error) => Self::log_local_error(local_error, logger), + } + } + + fn handle_batch_results_for_new_scan(&self, batch_results: &BatchResults, logger: &Logger) { + let (sent, failed) = calculate_occurences(batch_results); + debug!( + logger, + "Processed new txs while sending to RPC: {}", + batch_stats(sent, failed), + ); + if sent > 0 { + self.insert_records_in_sent_payables(&batch_results.sent_txs); + } + if failed > 0 { + debug!( + logger, + "Recording failed txs: {:?}", batch_results.failed_txs + ); + self.insert_records_in_failed_payables(&batch_results.failed_txs); + } + } + + fn handle_batch_results_for_retry_scan(&self, batch_results: &BatchResults, logger: &Logger) { + let (sent, failed) = calculate_occurences(batch_results); + debug!( + logger, + "Processed retried txs while sending to RPC: {}", + batch_stats(sent, failed), + ); + + if sent > 0 { + self.insert_records_in_sent_payables(&batch_results.sent_txs); + self.update_statuses_of_prev_txs(&batch_results.sent_txs); + } + if failed > 0 { + // TODO: Would it be a good ides to update Retry attempt of previous tx? + Self::log_failed_txs_during_retry(&batch_results.failed_txs, logger); + } + } + + fn update_statuses_of_prev_txs(&self, sent_txs: &[SentTx]) { + // TODO: We can do better here, possibly by creating a relationship between failed and sent txs + // Also, consider the fact that some txs will be with PendingTooLong status, what should we do with them? + let retrieved_txs = self.retrieve_failed_txs_by_receiver_addresses(sent_txs); + let (pending_too_long, other_reasons): (BTreeSet<_>, BTreeSet<_>) = retrieved_txs + .into_iter() + .partition(|tx| matches!(tx.reason, FailureReason::PendingTooLong)); + if !pending_too_long.is_empty() { + self.update_failed_txs( + &pending_too_long, + FailureStatus::RecheckRequired(ValidationStatus::Waiting), + ); + } + if !other_reasons.is_empty() { + self.update_failed_txs(&other_reasons, FailureStatus::Concluded); + } + } + + fn retrieve_failed_txs_by_receiver_addresses(&self, sent_txs: &[SentTx]) -> BTreeSet { + let receiver_addresses = filter_receiver_addresses_from_txs(sent_txs.iter()); + self.failed_payable_dao + .retrieve_txs(Some(FailureRetrieveCondition::ByReceiverAddresses( + receiver_addresses, + ))) + } + + fn update_failed_txs(&self, failed_txs: &BTreeSet, status: FailureStatus) { + let status_updates = generate_status_updates(failed_txs, status); + self.failed_payable_dao + .update_statuses(&status_updates) + .unwrap_or_else(|e| panic!("Failed to conclude txs in database: {:?}", e)); + } + + fn log_failed_txs_during_retry(failed_txs: &[FailedTx], logger: &Logger) { + warning!( + logger, + "While retrying, {} transactions with hashes: {} have failed.", + failed_txs.len(), + join_with_separator(failed_txs, |failed_tx| format!("{:?}", failed_tx.hash), ",") + ) + } + + fn log_local_error(local_error: &str, logger: &Logger) { + warning!( + logger, + "Local error occurred before transaction signing. Error: {}", + local_error + ) + } + + fn insert_records_in_sent_payables(&self, sent_txs: &[SentTx]) { + self.sent_payable_dao + .insert_new_records(&sent_txs.iter().cloned().collect()) + .unwrap_or_else(|e| { + panic!( + "Failed to insert transactions into the SentPayable table. Error: {:?}", + e + ) + }); + } + + fn insert_records_in_failed_payables(&self, failed_txs: &[FailedTx]) { + self.failed_payable_dao + .insert_new_records(&failed_txs.iter().cloned().collect()) + .unwrap_or_else(|e| { + panic!( + "Failed to insert transactions into the FailedPayable table. Error: {:?}", + e + ) + }); + } + + fn generate_ui_response( + response_skeleton_opt: Option, + ) -> Option { + response_skeleton_opt.map(|response_skeleton| NodeToUiMessage { + target: MessageTarget::ClientId(response_skeleton.client_id), + body: UiScanResponse {}.tmb(response_skeleton.context_id), + }) + } + + fn get_txs_to_retry(&self) -> BTreeSet { + self.failed_payable_dao + .retrieve_txs(Some(ByStatus(RetryRequired))) + } + + fn find_amount_from_payables( + &self, + txs_to_retry: &BTreeSet, + ) -> HashMap { + let addresses = filter_receiver_addresses_from_txs(txs_to_retry.iter()); + self.payable_dao + .retrieve_payables(Some(ByAddresses(addresses))) + .into_iter() + .map(|payable| (payable.wallet.address(), payable.balance_wei)) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::accountant::db_access_objects::failed_payable_dao::FailedPayableDaoError; + use crate::accountant::db_access_objects::sent_payable_dao::SentPayableDaoError; + use crate::accountant::db_access_objects::test_utils::{ + make_failed_tx, make_sent_tx, FailedTxBuilder, TxBuilder, + }; + use crate::accountant::db_access_objects::utils::{from_unix_timestamp, to_unix_timestamp}; + use crate::accountant::scanners::payable_scanner::test_utils::PayableScannerBuilder; + use crate::accountant::test_utils::{ + make_payable_account, FailedPayableDaoMock, PayableThresholdsGaugeMock, SentPayableDaoMock, + }; + use crate::blockchain::test_utils::make_tx_hash; + use crate::sub_lib::accountant::DEFAULT_PAYMENT_THRESHOLDS; + use crate::test_utils::make_wallet; + use masq_lib::test_utils::logging::{init_test_logging, TestLogHandler}; + use std::panic::{catch_unwind, AssertUnwindSafe}; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + #[test] + fn generate_ui_response_works_correctly() { + assert_eq!(PayableScanner::generate_ui_response(None), None); + assert_eq!( + PayableScanner::generate_ui_response(Some(ResponseSkeleton { + client_id: 1234, + context_id: 5678 + })), + Some(NodeToUiMessage { + target: MessageTarget::ClientId(1234), + body: UiScanResponse {}.tmb(5678), + }) + ); + } + + #[test] + fn determine_next_scan_to_run_works() { + // Error + assert_eq!( + PayableScanner::determine_next_scan_to_run(&SentPayables { + payment_procedure_result: Err("Any error".to_string()), + payable_scan_type: PayableScanType::New, + response_skeleton_opt: None, + }), + NextScanToRun::NewPayableScan + ); + assert_eq!( + PayableScanner::determine_next_scan_to_run(&SentPayables { + payment_procedure_result: Err("Any error".to_string()), + payable_scan_type: PayableScanType::Retry, + response_skeleton_opt: None, + }), + NextScanToRun::RetryPayableScan + ); + + // BatchResults is empty + assert_eq!( + PayableScanner::determine_next_scan_to_run(&SentPayables { + payment_procedure_result: Ok(BatchResults { + sent_txs: vec![], + failed_txs: vec![], + }), + payable_scan_type: PayableScanType::New, + response_skeleton_opt: None, + }), + NextScanToRun::NewPayableScan + ); + assert_eq!( + PayableScanner::determine_next_scan_to_run(&SentPayables { + payment_procedure_result: Ok(BatchResults { + sent_txs: vec![], + failed_txs: vec![], + }), + payable_scan_type: PayableScanType::Retry, + response_skeleton_opt: None, + }), + NextScanToRun::NewPayableScan + ); + + // Only FailedTxs + assert_eq!( + PayableScanner::determine_next_scan_to_run(&SentPayables { + payment_procedure_result: Ok(BatchResults { + sent_txs: vec![], + failed_txs: vec![make_failed_tx(1), make_failed_tx(2)], + }), + payable_scan_type: PayableScanType::New, + response_skeleton_opt: None, + }), + NextScanToRun::RetryPayableScan + ); + assert_eq!( + PayableScanner::determine_next_scan_to_run(&SentPayables { + payment_procedure_result: Ok(BatchResults { + sent_txs: vec![], + failed_txs: vec![make_failed_tx(1), make_failed_tx(2)], + }), + payable_scan_type: PayableScanType::Retry, + response_skeleton_opt: None, + }), + NextScanToRun::RetryPayableScan + ); + + // Only SentTxs + assert_eq!( + PayableScanner::determine_next_scan_to_run(&SentPayables { + payment_procedure_result: Ok(BatchResults { + sent_txs: vec![make_sent_tx(1), make_sent_tx(2)], + failed_txs: vec![], + }), + payable_scan_type: PayableScanType::New, + response_skeleton_opt: None, + }), + NextScanToRun::PendingPayableScan + ); + assert_eq!( + PayableScanner::determine_next_scan_to_run(&SentPayables { + payment_procedure_result: Ok(BatchResults { + sent_txs: vec![make_sent_tx(1), make_sent_tx(2)], + failed_txs: vec![], + }), + payable_scan_type: PayableScanType::Retry, + response_skeleton_opt: None, + }), + NextScanToRun::PendingPayableScan + ); + + // Both SentTxs and FailedTxs are present + assert_eq!( + PayableScanner::determine_next_scan_to_run(&SentPayables { + payment_procedure_result: Ok(BatchResults { + sent_txs: vec![make_sent_tx(1), make_sent_tx(2)], + failed_txs: vec![make_failed_tx(1), make_failed_tx(2)], + }), + payable_scan_type: PayableScanType::New, + response_skeleton_opt: None, + }), + NextScanToRun::PendingPayableScan + ); + assert_eq!( + PayableScanner::determine_next_scan_to_run(&SentPayables { + payment_procedure_result: Ok(BatchResults { + sent_txs: vec![make_sent_tx(1), make_sent_tx(2)], + failed_txs: vec![make_failed_tx(1), make_failed_tx(2)], + }), + payable_scan_type: PayableScanType::Retry, + response_skeleton_opt: None, + }), + NextScanToRun::PendingPayableScan + ); + } + + #[test] + fn update_statuses_of_prev_txs_updates_statuses_correctly() { + let retrieve_txs_params = Arc::new(Mutex::new(vec![])); + let update_statuses_params = Arc::new(Mutex::new(vec![])); + let tx_hash_1 = make_tx_hash(1); + let tx_hash_2 = make_tx_hash(2); + let failed_payable_dao = FailedPayableDaoMock::default() + .retrieve_txs_params(&retrieve_txs_params) + .retrieve_txs_result(BTreeSet::from([ + FailedTxBuilder::default() + .hash(tx_hash_1) + .reason(FailureReason::PendingTooLong) + .build(), + FailedTxBuilder::default() + .hash(tx_hash_2) + .reason(FailureReason::Reverted) + .build(), + ])) + .update_statuses_params(&update_statuses_params) + .update_statuses_result(Ok(())) + .update_statuses_result(Ok(())); + let subject = PayableScannerBuilder::new() + .failed_payable_dao(failed_payable_dao) + .build(); + let sent_txs = vec![make_sent_tx(1), make_sent_tx(2)]; + + subject.update_statuses_of_prev_txs(&sent_txs); + + let update_params = update_statuses_params.lock().unwrap(); + assert_eq!(update_params.len(), 2); + assert_eq!( + update_params[0], + hashmap!(tx_hash_1 => FailureStatus::RecheckRequired(ValidationStatus::Waiting)) + ); + assert_eq!( + update_params[1], + hashmap!(tx_hash_2 => FailureStatus::Concluded) + ); + } + + #[test] + fn no_missing_records() { + let wallet_1 = make_wallet("abc"); + let hash_1 = make_tx_hash(123); + let wallet_2 = make_wallet("def"); + let hash_2 = make_tx_hash(345); + let wallet_3 = make_wallet("ghi"); + let hash_3 = make_tx_hash(546); + let wallet_4 = make_wallet("jkl"); + let hash_4 = make_tx_hash(678); + let pending_payables_owned = vec![ + PendingPayable::new(wallet_1.clone(), hash_1), + PendingPayable::new(wallet_2.clone(), hash_2), + PendingPayable::new(wallet_3.clone(), hash_3), + PendingPayable::new(wallet_4.clone(), hash_4), + ]; + let pending_payables_ref = pending_payables_owned + .iter() + .collect::>(); + let sent_payable_dao = SentPayableDaoMock::new().get_tx_identifiers_result( + hashmap!(hash_4 => 4, hash_1 => 1, hash_3 => 3, hash_2 => 2), + ); + let subject = PayableScannerBuilder::new() + .sent_payable_dao(sent_payable_dao) + .build(); + + let missing_records = subject.check_for_missing_records(&pending_payables_ref); + + assert!( + missing_records.is_empty(), + "We thought the vec would be empty but contained: {:?}", + missing_records + ); + } + + #[test] + #[should_panic( + expected = "Found duplicates in the recent sent txs: [PendingPayable { recipient_wallet: \ + Wallet { kind: Address(0x0000000000000000000000000000000000616263) }, hash: \ + 0x000000000000000000000000000000000000000000000000000000000000007b }, PendingPayable { \ + recipient_wallet: Wallet { kind: Address(0x0000000000000000000000000000000000646566) }, \ + hash: 0x00000000000000000000000000000000000000000000000000000000000001c8 }, \ + PendingPayable { recipient_wallet: Wallet { kind: \ + Address(0x0000000000000000000000000000000000676869) }, hash: \ + 0x00000000000000000000000000000000000000000000000000000000000001c8 }, PendingPayable { \ + recipient_wallet: Wallet { kind: Address(0x00000000000000000000000000000000006a6b6c) }, \ + hash: 0x0000000000000000000000000000000000000000000000000000000000000315 }]" + )] + fn just_baked_pending_payables_contain_duplicates() { + let hash_1 = make_tx_hash(123); + let hash_2 = make_tx_hash(456); + let hash_3 = make_tx_hash(789); + let pending_payables = vec![ + PendingPayable::new(make_wallet("abc"), hash_1), + PendingPayable::new(make_wallet("def"), hash_2), + PendingPayable::new(make_wallet("ghi"), hash_2), + PendingPayable::new(make_wallet("jkl"), hash_3), + ]; + let pending_payables_ref = pending_payables.iter().collect::>(); + let sent_payable_dao = SentPayableDaoMock::new() + .get_tx_identifiers_result(hashmap!(hash_1 => 1, hash_2 => 3, hash_3 => 5)); + let subject = PayableScannerBuilder::new() + .sent_payable_dao(sent_payable_dao) + .build(); + + subject.check_for_missing_records(&pending_payables_ref); + } + + #[test] + fn payable_is_found_innocent_by_age_and_returns() { + let is_innocent_age_params_arc = Arc::new(Mutex::new(vec![])); + let payable_thresholds_gauge = PayableThresholdsGaugeMock::default() + .is_innocent_age_params(&is_innocent_age_params_arc) + .is_innocent_age_result(true); + let mut subject = PayableScannerBuilder::new().build(); + subject.payable_threshold_gauge = Box::new(payable_thresholds_gauge); + let now = SystemTime::now(); + let debt_age_s = 111_222; + let last_paid_timestamp = now.checked_sub(Duration::from_secs(debt_age_s)).unwrap(); + let mut payable = make_payable_account(111); + payable.last_paid_timestamp = last_paid_timestamp; + + let result = subject.payable_exceeded_threshold(&payable, now); + + assert_eq!(result, None); + let mut is_innocent_age_params = is_innocent_age_params_arc.lock().unwrap(); + let (debt_age_returned, threshold_value) = is_innocent_age_params.remove(0); + assert!(is_innocent_age_params.is_empty()); + assert_eq!(debt_age_returned, debt_age_s); + assert_eq!( + threshold_value, + DEFAULT_PAYMENT_THRESHOLDS.maturity_threshold_sec + ) + // No panic and so no other method was called, which means an early return + } + + #[test] + fn payable_is_found_innocent_by_balance_and_returns() { + let is_innocent_age_params_arc = Arc::new(Mutex::new(vec![])); + let is_innocent_balance_params_arc = Arc::new(Mutex::new(vec![])); + let payable_thresholds_gauge = PayableThresholdsGaugeMock::default() + .is_innocent_age_params(&is_innocent_age_params_arc) + .is_innocent_age_result(false) + .is_innocent_balance_params(&is_innocent_balance_params_arc) + .is_innocent_balance_result(true); + let mut subject = PayableScannerBuilder::new().build(); + subject.payable_threshold_gauge = Box::new(payable_thresholds_gauge); + let now = SystemTime::now(); + let debt_age_s = 3_456; + let last_paid_timestamp = now.checked_sub(Duration::from_secs(debt_age_s)).unwrap(); + let mut payable = make_payable_account(222); + payable.last_paid_timestamp = last_paid_timestamp; + payable.balance_wei = 123456; + + let result = subject.payable_exceeded_threshold(&payable, now); + + assert_eq!(result, None); + let mut is_innocent_age_params = is_innocent_age_params_arc.lock().unwrap(); + let (debt_age_returned, _) = is_innocent_age_params.remove(0); + assert!(is_innocent_age_params.is_empty()); + assert_eq!(debt_age_returned, debt_age_s); + let is_innocent_balance_params = is_innocent_balance_params_arc.lock().unwrap(); + assert_eq!( + *is_innocent_balance_params, + vec![( + 123456_u128, + gwei_to_wei(DEFAULT_PAYMENT_THRESHOLDS.permanent_debt_allowed_gwei) + )] + ) + //no other method was called (absence of panic), and that means we returned early + } + + #[test] + fn threshold_calculation_depends_on_user_defined_payment_thresholds() { + let is_innocent_age_params_arc = Arc::new(Mutex::new(vec![])); + let is_innocent_balance_params_arc = Arc::new(Mutex::new(vec![])); + let calculate_payable_threshold_params_arc = Arc::new(Mutex::new(vec![])); + let balance = gwei_to_wei(5555_u64); + let now = SystemTime::now(); + let debt_age_s = 1111 + 1; + let last_paid_timestamp = now.checked_sub(Duration::from_secs(debt_age_s)).unwrap(); + let payable_account = PayableAccount { + wallet: make_wallet("hi"), + balance_wei: balance, + last_paid_timestamp, + pending_payable_opt: None, + }; + let custom_payment_thresholds = PaymentThresholds { + maturity_threshold_sec: 1111, + payment_grace_period_sec: 2222, + permanent_debt_allowed_gwei: 3333, + debt_threshold_gwei: 4444, + threshold_interval_sec: 5555, + unban_below_gwei: 5555, + }; + let payable_thresholds_gauge = PayableThresholdsGaugeMock::default() + .is_innocent_age_params(&is_innocent_age_params_arc) + .is_innocent_age_result( + debt_age_s <= custom_payment_thresholds.maturity_threshold_sec as u64, + ) + .is_innocent_balance_params(&is_innocent_balance_params_arc) + .is_innocent_balance_result( + balance <= gwei_to_wei(custom_payment_thresholds.permanent_debt_allowed_gwei), + ) + .calculate_payout_threshold_in_gwei_params(&calculate_payable_threshold_params_arc) + .calculate_payout_threshold_in_gwei_result(4567898); //made up value + let mut subject = PayableScannerBuilder::new() + .payment_thresholds(custom_payment_thresholds) + .build(); + subject.payable_threshold_gauge = Box::new(payable_thresholds_gauge); + + let result = subject.payable_exceeded_threshold(&payable_account, now); + + assert_eq!(result, Some(4567898)); + let mut is_innocent_age_params = is_innocent_age_params_arc.lock().unwrap(); + let (debt_age_returned_innocent, curve_derived_time) = is_innocent_age_params.remove(0); + assert_eq!(*is_innocent_age_params, vec![]); + assert_eq!(debt_age_returned_innocent, debt_age_s); + assert_eq!( + curve_derived_time, + custom_payment_thresholds.maturity_threshold_sec as u64 + ); + let is_innocent_balance_params = is_innocent_balance_params_arc.lock().unwrap(); + assert_eq!( + *is_innocent_balance_params, + vec![( + payable_account.balance_wei, + gwei_to_wei(custom_payment_thresholds.permanent_debt_allowed_gwei) + )] + ); + let mut calculate_payable_curves_params = + calculate_payable_threshold_params_arc.lock().unwrap(); + let (payment_thresholds, debt_age_returned_curves) = + calculate_payable_curves_params.remove(0); + assert_eq!(*calculate_payable_curves_params, vec![]); + assert_eq!(debt_age_returned_curves, debt_age_s); + assert_eq!(payment_thresholds, custom_payment_thresholds) + } + + #[test] + fn payable_with_debt_under_the_slope_is_marked_unqualified() { + init_test_logging(); + let now = SystemTime::now(); + let payment_thresholds = PaymentThresholds::default(); + let debt = gwei_to_wei(payment_thresholds.permanent_debt_allowed_gwei + 1); + let time = to_unix_timestamp(now) - payment_thresholds.maturity_threshold_sec as i64 - 1; + let unqualified_payable_account = vec![PayableAccount { + wallet: make_wallet("wallet0"), + balance_wei: debt, + last_paid_timestamp: from_unix_timestamp(time), + pending_payable_opt: None, + }]; + let subject = PayableScannerBuilder::new() + .payment_thresholds(payment_thresholds) + .build(); + let test_name = + "payable_with_debt_above_the_slope_is_qualified_and_the_threshold_value_is_returned"; + let logger = Logger::new(test_name); + + let result = subject + .sniff_out_alarming_payables_and_maybe_log_them(unqualified_payable_account, &logger); + + assert_eq!(result, vec![]); + TestLogHandler::new() + .exists_no_log_containing(&format!("DEBUG: {}: Paying qualified debts", test_name)); + } + + #[test] + fn payable_with_debt_above_the_slope_is_qualified() { + init_test_logging(); + let payment_thresholds = PaymentThresholds::default(); + let debt = gwei_to_wei(payment_thresholds.debt_threshold_gwei - 1); + let time = (payment_thresholds.maturity_threshold_sec + + payment_thresholds.threshold_interval_sec + - 1) as i64; + let qualified_payable = PayableAccount { + wallet: make_wallet("wallet0"), + balance_wei: debt, + last_paid_timestamp: from_unix_timestamp(time), + pending_payable_opt: None, + }; + let subject = PayableScannerBuilder::new() + .payment_thresholds(payment_thresholds) + .build(); + let test_name = "payable_with_debt_above_the_slope_is_qualified"; + let logger = Logger::new(test_name); + + let result = subject.sniff_out_alarming_payables_and_maybe_log_them( + vec![qualified_payable.clone()], + &logger, + ); + + assert_eq!(result, vec![qualified_payable]); + TestLogHandler::new().exists_log_matching(&format!( + "DEBUG: {}: Paying qualified debts:\n\ + 999,999,999,000,000,000 wei owed for \\d+ sec exceeds the threshold \ + 500,000,000,000,000,000 wei for creditor 0x0000000000000000000000000077616c6c657430", + test_name + )); + } + + #[test] + fn retrieved_payables_turn_into_an_empty_vector_if_all_unqualified() { + init_test_logging(); + let test_name = "retrieved_payables_turn_into_an_empty_vector_if_all_unqualified"; + let now = SystemTime::now(); + let payment_thresholds = PaymentThresholds::default(); + let unqualified_payable_account = vec![PayableAccount { + wallet: make_wallet("wallet1"), + balance_wei: gwei_to_wei(payment_thresholds.permanent_debt_allowed_gwei + 1), + last_paid_timestamp: from_unix_timestamp( + to_unix_timestamp(now) - payment_thresholds.maturity_threshold_sec as i64 + 1, + ), + pending_payable_opt: None, + }]; + let subject = PayableScannerBuilder::new() + .payment_thresholds(payment_thresholds) + .build(); + let logger = Logger::new(test_name); + + let result = subject + .sniff_out_alarming_payables_and_maybe_log_them(unqualified_payable_account, &logger); + + assert_eq!(result, vec![]); + TestLogHandler::new() + .exists_no_log_containing(&format!("DEBUG: {test_name}: Paying qualified debts")); + } + + #[test] + fn insert_records_in_sent_payables_inserts_records_successfully() { + let insert_new_records_params = Arc::new(Mutex::new(vec![])); + let sent_payable_dao = SentPayableDaoMock::default() + .insert_new_records_params(&insert_new_records_params) + .insert_new_records_result(Ok(())); + let subject = PayableScannerBuilder::new() + .sent_payable_dao(sent_payable_dao) + .build(); + let tx1 = TxBuilder::default().hash(make_tx_hash(1)).build(); + let tx2 = TxBuilder::default().hash(make_tx_hash(2)).build(); + let sent_txs = vec![tx1.clone(), tx2.clone()]; + + subject.insert_records_in_sent_payables(&sent_txs); + + let params = insert_new_records_params.lock().unwrap(); + assert_eq!(params.len(), 1); + assert_eq!(params[0], sent_txs.into_iter().collect()); + } + + #[test] + fn insert_records_in_sent_payables_panics_on_error() { + let sent_payable_dao = SentPayableDaoMock::default().insert_new_records_result(Err( + SentPayableDaoError::PartialExecution("Test error".to_string()), + )); + let subject = PayableScannerBuilder::new() + .sent_payable_dao(sent_payable_dao) + .build(); + let tx = TxBuilder::default().hash(make_tx_hash(1)).build(); + let sent_txs = vec![tx]; + + let result = catch_unwind(AssertUnwindSafe(|| { + subject.insert_records_in_sent_payables(&sent_txs); + })) + .unwrap_err(); + + let panic_msg = result.downcast_ref::().unwrap(); + assert!(panic_msg.contains("Failed to insert transactions into the SentPayable table")); + assert!(panic_msg.contains("Test error")); + } + + #[test] + fn insert_records_in_failed_payables_inserts_records_successfully() { + let insert_new_records_params = Arc::new(Mutex::new(vec![])); + let failed_payable_dao = FailedPayableDaoMock::default() + .insert_new_records_params(&insert_new_records_params) + .insert_new_records_result(Ok(())); + let subject = PayableScannerBuilder::new() + .failed_payable_dao(failed_payable_dao) + .build(); + let failed_tx1 = FailedTxBuilder::default().hash(make_tx_hash(1)).build(); + let failed_tx2 = FailedTxBuilder::default().hash(make_tx_hash(2)).build(); + let failed_txs = vec![failed_tx1.clone(), failed_tx2.clone()]; + + subject.insert_records_in_failed_payables(&failed_txs); + + let params = insert_new_records_params.lock().unwrap(); + assert_eq!(params.len(), 1); + assert_eq!(params[0], BTreeSet::from([failed_tx1, failed_tx2])); + } + + #[test] + fn insert_records_in_failed_payables_panics_on_error() { + let failed_payable_dao = FailedPayableDaoMock::default().insert_new_records_result(Err( + FailedPayableDaoError::PartialExecution("Test error".to_string()), + )); + let subject = PayableScannerBuilder::new() + .failed_payable_dao(failed_payable_dao) + .build(); + let failed_tx = FailedTxBuilder::default().hash(make_tx_hash(1)).build(); + let failed_txs = vec![failed_tx]; + + let result = catch_unwind(AssertUnwindSafe(|| { + subject.insert_records_in_failed_payables(&failed_txs); + })) + .unwrap_err(); + + let panic_msg = result.downcast_ref::().unwrap(); + assert!(panic_msg.contains("Failed to insert transactions into the FailedPayable table")); + assert!(panic_msg.contains("Test error")); + } + + #[test] + fn handle_batch_results_for_new_scan_does_not_perform_any_operation_when_sent_txs_is_empty() { + let insert_new_records_sent_tx_params_arc = Arc::new(Mutex::new(vec![])); + let insert_new_records_failed_tx_params_arc = Arc::new(Mutex::new(vec![])); + let sent_payable_dao = SentPayableDaoMock::default() + .insert_new_records_params(&insert_new_records_sent_tx_params_arc); + let failed_payable_dao = FailedPayableDaoMock::default() + .insert_new_records_params(&insert_new_records_failed_tx_params_arc) + .insert_new_records_result(Ok(())); + let subject = PayableScannerBuilder::new() + .sent_payable_dao(sent_payable_dao) + .failed_payable_dao(failed_payable_dao) + .build(); + let batch_results = BatchResults { + sent_txs: vec![], + failed_txs: vec![make_failed_tx(1)], + }; + + subject.handle_batch_results_for_new_scan(&batch_results, &Logger::new("test")); + + assert_eq!( + insert_new_records_failed_tx_params_arc + .lock() + .unwrap() + .len(), + 1 + ); + assert!(insert_new_records_sent_tx_params_arc + .lock() + .unwrap() + .is_empty()); + } + + #[test] + fn handle_batch_results_for_new_scan_does_not_perform_any_operation_when_failed_txs_is_empty() { + let insert_new_records_params_failed = Arc::new(Mutex::new(vec![])); + let sent_payable_dao = SentPayableDaoMock::default().insert_new_records_result(Ok(())); + let failed_payable_dao = FailedPayableDaoMock::default() + .insert_new_records_params(&insert_new_records_params_failed); + let subject = PayableScannerBuilder::new() + .sent_payable_dao(sent_payable_dao) + .failed_payable_dao(failed_payable_dao) + .build(); + let batch_results = BatchResults { + sent_txs: vec![make_sent_tx(1)], + failed_txs: vec![], + }; + + subject.handle_batch_results_for_new_scan(&batch_results, &Logger::new("test")); + + assert!(insert_new_records_params_failed.lock().unwrap().is_empty()); + } + + #[test] + fn handle_batch_results_for_retry_scan_does_not_perform_any_operation_when_sent_txs_is_empty() { + let insert_new_records_sent_tx_params_arc = Arc::new(Mutex::new(vec![])); + let retrieve_txs_params = Arc::new(Mutex::new(vec![])); + let update_statuses_params = Arc::new(Mutex::new(vec![])); + let sent_payable_dao = SentPayableDaoMock::default() + .insert_new_records_params(&insert_new_records_sent_tx_params_arc); + let failed_payable_dao = FailedPayableDaoMock::default() + .retrieve_txs_params(&retrieve_txs_params) + .update_statuses_params(&update_statuses_params); + let subject = PayableScannerBuilder::new() + .sent_payable_dao(sent_payable_dao) + .failed_payable_dao(failed_payable_dao) + .build(); + let batch_results = BatchResults { + sent_txs: vec![], + failed_txs: vec![make_failed_tx(1)], + }; + + subject.handle_batch_results_for_retry_scan(&batch_results, &Logger::new("test")); + + assert!(insert_new_records_sent_tx_params_arc + .lock() + .unwrap() + .is_empty()); + assert!(retrieve_txs_params.lock().unwrap().is_empty()); + assert!(update_statuses_params.lock().unwrap().is_empty()); + } + + #[test] + fn handle_retry_logs_no_warn_when_failed_txs_exist() { + init_test_logging(); + let test_name = "handle_retry_logs_no_warn_when_failed_txs_exist"; + let sent_payable_dao = SentPayableDaoMock::default().insert_new_records_result(Ok(())); + let failed_payable_dao = FailedPayableDaoMock::default() + .retrieve_txs_result(BTreeSet::from([make_failed_tx(1)])) + .update_statuses_result(Ok(())) + .update_statuses_result(Ok(())); + let subject = PayableScannerBuilder::new() + .sent_payable_dao(sent_payable_dao) + .failed_payable_dao(failed_payable_dao) + .build(); + let batch_results = BatchResults { + sent_txs: vec![make_sent_tx(1)], + failed_txs: vec![], + }; + + subject.handle_batch_results_for_retry_scan(&batch_results, &Logger::new(test_name)); + + let tlh = TestLogHandler::new(); + tlh.exists_no_log_containing(&format!("WARN: {test_name}")); + } + + #[test] + fn update_failed_txs_panics_on_error() { + let failed_payable_dao = FailedPayableDaoMock::default().update_statuses_result(Err( + FailedPayableDaoError::SqlExecutionFailed("I slept too much".to_string()), + )); + let subject = PayableScannerBuilder::new() + .failed_payable_dao(failed_payable_dao) + .build(); + let failed_tx = FailedTxBuilder::default().hash(make_tx_hash(1)).build(); + let failed_txs = BTreeSet::from([failed_tx]); + + let result = catch_unwind(AssertUnwindSafe(|| { + subject.update_failed_txs(&failed_txs, FailureStatus::Concluded); + })) + .unwrap_err(); + + let panic_msg = result.downcast_ref::().unwrap(); + assert!(panic_msg.contains( + "Failed to conclude txs in database: SqlExecutionFailed(\"I slept too much\")" + )); + } +} diff --git a/node/src/accountant/scanners/payable_scanner/msgs.rs b/node/src/accountant/scanners/payable_scanner/msgs.rs new file mode 100644 index 0000000000..5379d26f5b --- /dev/null +++ b/node/src/accountant/scanners/payable_scanner/msgs.rs @@ -0,0 +1,69 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. +use crate::accountant::scanners::payable_scanner::tx_templates::initial::new::NewTxTemplates; +use crate::accountant::scanners::payable_scanner::tx_templates::initial::retry::RetryTxTemplates; +use crate::accountant::scanners::payable_scanner::tx_templates::priced::new::PricedNewTxTemplates; +use crate::accountant::scanners::payable_scanner::tx_templates::priced::retry::PricedRetryTxTemplates; +use crate::accountant::{ResponseSkeleton, SkeletonOptHolder}; +use crate::blockchain::blockchain_agent::BlockchainAgent; +use crate::blockchain::blockchain_bridge::MsgInterpretableAsDetailedScanType; +use crate::sub_lib::accountant::DetailedScanType; +use crate::sub_lib::wallet::Wallet; +use actix::Message; +use itertools::Either; + +#[derive(Debug, Message, PartialEq, Eq, Clone)] +pub struct InitialTemplatesMessage { + pub initial_templates: Either, + pub consuming_wallet: Wallet, + pub response_skeleton_opt: Option, +} + +impl MsgInterpretableAsDetailedScanType for InitialTemplatesMessage { + fn detailed_scan_type(&self) -> DetailedScanType { + match self.initial_templates { + Either::Left(_) => DetailedScanType::NewPayables, + Either::Right(_) => DetailedScanType::RetryPayables, + } + } +} + +#[derive(Message)] +pub struct PricedTemplatesMessage { + pub priced_templates: Either, + pub agent: Box, + pub response_skeleton_opt: Option, +} + +impl SkeletonOptHolder for InitialTemplatesMessage { + fn skeleton_opt(&self) -> Option { + self.response_skeleton_opt + } +} + +#[cfg(test)] +mod tests { + use crate::accountant::scanners::payable_scanner::msgs::InitialTemplatesMessage; + use crate::accountant::scanners::payable_scanner::tx_templates::initial::new::NewTxTemplates; + use crate::accountant::scanners::payable_scanner::tx_templates::initial::retry::RetryTxTemplates; + use crate::blockchain::blockchain_bridge::MsgInterpretableAsDetailedScanType; + use crate::sub_lib::accountant::DetailedScanType; + use crate::test_utils::make_wallet; + use itertools::Either; + + #[test] + fn detailed_scan_type_is_implemented_for_initial_templates_message() { + let msg_a = InitialTemplatesMessage { + initial_templates: Either::Left(NewTxTemplates(vec![])), + consuming_wallet: make_wallet("abc"), + response_skeleton_opt: None, + }; + let msg_b = InitialTemplatesMessage { + initial_templates: Either::Right(RetryTxTemplates(vec![])), + consuming_wallet: make_wallet("abc"), + response_skeleton_opt: None, + }; + + assert_eq!(msg_a.detailed_scan_type(), DetailedScanType::NewPayables); + assert_eq!(msg_b.detailed_scan_type(), DetailedScanType::RetryPayables); + } +} diff --git a/node/src/accountant/scanners/payable_scanner/payment_adjuster_integration.rs b/node/src/accountant/scanners/payable_scanner/payment_adjuster_integration.rs new file mode 100644 index 0000000000..d3d38e3a99 --- /dev/null +++ b/node/src/accountant/scanners/payable_scanner/payment_adjuster_integration.rs @@ -0,0 +1,60 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. +use crate::accountant::payment_adjuster::Adjustment; +use crate::accountant::scanners::payable_scanner::msgs::PricedTemplatesMessage; +use crate::accountant::scanners::payable_scanner::PayableScanner; +use crate::sub_lib::blockchain_bridge::OutboundPaymentsInstructions; +use itertools::Either; +use masq_lib::logger::Logger; +use std::time::SystemTime; + +pub struct PreparedAdjustment { + pub original_setup_msg: PricedTemplatesMessage, + pub adjustment: Adjustment, +} + +pub trait SolvencySensitivePaymentInstructor { + fn try_skipping_payment_adjustment( + &self, + msg: PricedTemplatesMessage, + logger: &Logger, + ) -> Result, String>; + + fn perform_payment_adjustment( + &self, + setup: PreparedAdjustment, + logger: &Logger, + ) -> OutboundPaymentsInstructions; +} + +impl SolvencySensitivePaymentInstructor for PayableScanner { + fn try_skipping_payment_adjustment( + &self, + msg: PricedTemplatesMessage, + logger: &Logger, + ) -> Result, String> { + match self + .payment_adjuster + .search_for_indispensable_adjustment(&msg, logger) + { + Ok(None) => Ok(Either::Left(OutboundPaymentsInstructions::new( + msg.priced_templates, + msg.agent, + msg.response_skeleton_opt, + ))), + Ok(Some(adjustment)) => Ok(Either::Right(PreparedAdjustment { + original_setup_msg: msg, + adjustment, + })), + Err(_e) => todo!("be implemented with GH-711"), + } + } + + fn perform_payment_adjustment( + &self, + setup: PreparedAdjustment, + logger: &Logger, + ) -> OutboundPaymentsInstructions { + let now = SystemTime::now(); + self.payment_adjuster.adjust_payments(setup, now, logger) + } +} diff --git a/node/src/accountant/scanners/payable_scanner/start_scan.rs b/node/src/accountant/scanners/payable_scanner/start_scan.rs new file mode 100644 index 0000000000..457ab73eee --- /dev/null +++ b/node/src/accountant/scanners/payable_scanner/start_scan.rs @@ -0,0 +1,186 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. +use crate::accountant::scanners::payable_scanner::msgs::InitialTemplatesMessage; +use crate::accountant::scanners::payable_scanner::tx_templates::initial::new::NewTxTemplates; +use crate::accountant::scanners::payable_scanner::tx_templates::initial::retry::RetryTxTemplates; +use crate::accountant::scanners::payable_scanner::utils::investigate_debt_extremes; +use crate::accountant::scanners::payable_scanner::PayableScanner; +use crate::accountant::scanners::{Scanner, StartScanError, StartableScanner}; +use crate::accountant::{ResponseSkeleton, ScanForNewPayables, ScanForRetryPayables}; +use crate::sub_lib::wallet::Wallet; +use itertools::Either; +use masq_lib::logger::Logger; +use std::time::SystemTime; + +impl StartableScanner for PayableScanner { + fn start_scan( + &mut self, + consuming_wallet: &Wallet, + timestamp: SystemTime, + response_skeleton_opt: Option, + logger: &Logger, + ) -> Result { + self.mark_as_started(timestamp); + info!(logger, "Scanning for new payables"); + let retrieved_payables = self.payable_dao.retrieve_payables(None); + + debug!( + logger, + "{}", + investigate_debt_extremes(timestamp, &retrieved_payables) + ); + + let qualified_payables = + self.sniff_out_alarming_payables_and_maybe_log_them(retrieved_payables, logger); + + match qualified_payables.is_empty() { + true => { + self.mark_as_ended(logger); + Err(StartScanError::NothingToProcess) + } + false => { + info!( + logger, + "Chose {} qualified debts to pay", + qualified_payables.len() + ); + let new_tx_templates = NewTxTemplates::from(&qualified_payables); + Ok(InitialTemplatesMessage { + initial_templates: Either::Left(new_tx_templates), + consuming_wallet: consuming_wallet.clone(), + response_skeleton_opt, + }) + } + } + } +} + +impl StartableScanner for PayableScanner { + fn start_scan( + &mut self, + consuming_wallet: &Wallet, + timestamp: SystemTime, + response_skeleton_opt: Option, + logger: &Logger, + ) -> Result { + self.mark_as_started(timestamp); + info!(logger, "Scanning for retry payables"); + let failed_txs = self.get_txs_to_retry(); + let amount_from_payables = self.find_amount_from_payables(&failed_txs); + let retry_tx_templates = RetryTxTemplates::new(&failed_txs, &amount_from_payables, logger); + + Ok(InitialTemplatesMessage { + initial_templates: Either::Right(retry_tx_templates), + consuming_wallet: consuming_wallet.clone(), + response_skeleton_opt, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::accountant::db_access_objects::failed_payable_dao::FailureReason::PendingTooLong; + use crate::accountant::db_access_objects::failed_payable_dao::FailureRetrieveCondition::ByStatus; + use crate::accountant::db_access_objects::failed_payable_dao::FailureStatus; + use crate::accountant::db_access_objects::failed_payable_dao::FailureStatus::RetryRequired; + use crate::accountant::db_access_objects::payable_dao::PayableRetrieveCondition; + use crate::accountant::db_access_objects::test_utils::FailedTxBuilder; + use crate::accountant::scanners::payable_scanner::test_utils::PayableScannerBuilder; + use crate::accountant::scanners::payable_scanner::tx_templates::initial::retry::{ + RetryTxTemplate, RetryTxTemplates, + }; + use crate::accountant::scanners::Scanners; + use crate::accountant::test_utils::{ + make_payable_account, FailedPayableDaoMock, PayableDaoMock, + }; + use crate::blockchain::test_utils::make_tx_hash; + use crate::test_utils::{make_paying_wallet, make_wallet}; + use masq_lib::logger::Logger; + use masq_lib::test_utils::logging::{init_test_logging, TestLogHandler}; + use std::collections::BTreeSet; + use std::sync::{Arc, Mutex}; + use std::time::SystemTime; + + #[test] + fn start_scan_for_retry_works() { + init_test_logging(); + let test_name = "start_scan_for_retry_works"; + let logger = Logger::new(test_name); + let retrieve_txs_params_arc = Arc::new(Mutex::new(vec![])); + let retrieve_payables_params_arc = Arc::new(Mutex::new(vec![])); + let timestamp = SystemTime::now(); + let consuming_wallet = make_paying_wallet(b"consuming"); + let response_skeleton = ResponseSkeleton { + client_id: 1234, + context_id: 4321, + }; + let payable_account_1 = make_payable_account(42); + let receiver_address_1 = payable_account_1.wallet.address(); + let receiever_wallet_2 = make_wallet("absent in payable dao"); + let receiver_address_2 = receiever_wallet_2.address(); + let failed_tx_1 = FailedTxBuilder::default() + .nonce(1) + .hash(make_tx_hash(1)) + .receiver_address(receiver_address_1) + .reason(PendingTooLong) + .status(RetryRequired) + .build(); + let failed_tx_2 = FailedTxBuilder::default() + .nonce(2) + .hash(make_tx_hash(2)) + .receiver_address(receiver_address_2) + .reason(PendingTooLong) + .status(RetryRequired) + .build(); + let expected_addresses = BTreeSet::from([receiver_address_1, receiver_address_2]); + let failed_payable_dao = FailedPayableDaoMock::new() + .retrieve_txs_params(&retrieve_txs_params_arc) + .retrieve_txs_result(BTreeSet::from([failed_tx_1.clone(), failed_tx_2.clone()])); + let payable_dao = PayableDaoMock::new() + .retrieve_payables_params(&retrieve_payables_params_arc) + .retrieve_payables_result(vec![payable_account_1.clone()]); // the second record is absent + let mut subject = PayableScannerBuilder::new() + .failed_payable_dao(failed_payable_dao) + .payable_dao(payable_dao) + .build(); + + let result = Scanners::start_correct_payable_scanner::( + &mut subject, + &consuming_wallet, + timestamp, + Some(response_skeleton), + &logger, + ); + + let scan_started_at = subject.scan_started_at(); + let failed_payables_retrieve_txs_params = retrieve_txs_params_arc.lock().unwrap(); + let retrieve_payables_params = retrieve_payables_params_arc.lock().unwrap(); + let expected_tx_templates = { + let mut tx_template_1 = RetryTxTemplate::from(&failed_tx_1); + tx_template_1.base.amount_in_wei = payable_account_1.balance_wei; + let tx_template_2 = RetryTxTemplate::from(&failed_tx_2); + RetryTxTemplates(vec![tx_template_1, tx_template_2]) + }; + assert_eq!( + result, + Ok(InitialTemplatesMessage { + initial_templates: Either::Right(expected_tx_templates), + consuming_wallet: consuming_wallet.clone(), + response_skeleton_opt: Some(response_skeleton), + }) + ); + assert_eq!(scan_started_at, Some(timestamp)); + assert_eq!( + failed_payables_retrieve_txs_params[0], + Some(ByStatus(FailureStatus::RetryRequired)) + ); + assert_eq!(failed_payables_retrieve_txs_params.len(), 1); + assert_eq!( + retrieve_payables_params[0], + Some(PayableRetrieveCondition::ByAddresses(expected_addresses)) + ); + assert_eq!(retrieve_payables_params.len(), 1); + TestLogHandler::new() + .exists_log_containing(&format!("INFO: {test_name}: Scanning for retry payables")); + } +} diff --git a/node/src/accountant/scanners/payable_scanner/test_utils.rs b/node/src/accountant/scanners/payable_scanner/test_utils.rs new file mode 100644 index 0000000000..4dcc8d67de --- /dev/null +++ b/node/src/accountant/scanners/payable_scanner/test_utils.rs @@ -0,0 +1,97 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. +#![cfg(test)] + +use crate::accountant::scanners::payable_scanner::msgs::PricedTemplatesMessage; +use crate::accountant::scanners::payable_scanner::payment_adjuster_integration::PreparedAdjustment; +use crate::accountant::scanners::payable_scanner::PayableScanner; +use crate::accountant::test_utils::{ + FailedPayableDaoMock, PayableDaoMock, PaymentAdjusterMock, SentPayableDaoMock, +}; +use crate::blockchain::blockchain_agent::test_utils::BlockchainAgentMock; +use crate::sub_lib::accountant::PaymentThresholds; +use std::rc::Rc; + +pub struct PayableScannerBuilder { + payable_dao: PayableDaoMock, + sent_payable_dao: SentPayableDaoMock, + failed_payable_dao: FailedPayableDaoMock, + payment_thresholds: PaymentThresholds, + payment_adjuster: PaymentAdjusterMock, +} + +impl PayableScannerBuilder { + pub fn new() -> Self { + Self { + payable_dao: PayableDaoMock::new(), + sent_payable_dao: SentPayableDaoMock::new(), + failed_payable_dao: FailedPayableDaoMock::new(), + payment_thresholds: PaymentThresholds::default(), + payment_adjuster: PaymentAdjusterMock::default(), + } + } + + pub fn payable_dao(mut self, payable_dao: PayableDaoMock) -> PayableScannerBuilder { + self.payable_dao = payable_dao; + self + } + + pub fn sent_payable_dao( + mut self, + sent_payable_dao: SentPayableDaoMock, + ) -> PayableScannerBuilder { + self.sent_payable_dao = sent_payable_dao; + self + } + + pub fn failed_payable_dao( + mut self, + failed_payable_dao: FailedPayableDaoMock, + ) -> PayableScannerBuilder { + self.failed_payable_dao = failed_payable_dao; + self + } + + pub fn payment_adjuster( + mut self, + payment_adjuster: PaymentAdjusterMock, + ) -> PayableScannerBuilder { + self.payment_adjuster = payment_adjuster; + self + } + + pub fn payment_thresholds(mut self, payment_thresholds: PaymentThresholds) -> Self { + self.payment_thresholds = payment_thresholds; + self + } + + pub fn build(self) -> PayableScanner { + PayableScanner::new( + Box::new(self.payable_dao), + Box::new(self.sent_payable_dao), + Box::new(self.failed_payable_dao), + Rc::new(self.payment_thresholds), + Box::new(self.payment_adjuster), + ) + } +} + +impl Clone for PricedTemplatesMessage { + fn clone(&self) -> Self { + let original_agent_id = self.agent.arbitrary_id_stamp(); + let cloned_agent = BlockchainAgentMock::default().set_arbitrary_id_stamp(original_agent_id); + Self { + priced_templates: self.priced_templates.clone(), + agent: Box::new(cloned_agent), + response_skeleton_opt: self.response_skeleton_opt, + } + } +} + +impl Clone for PreparedAdjustment { + fn clone(&self) -> Self { + Self { + original_setup_msg: self.original_setup_msg.clone(), + adjustment: self.adjustment.clone(), + } + } +} diff --git a/node/src/accountant/scanners/payable_scanner/tx_templates/initial/mod.rs b/node/src/accountant/scanners/payable_scanner/tx_templates/initial/mod.rs new file mode 100644 index 0000000000..84adbe5e33 --- /dev/null +++ b/node/src/accountant/scanners/payable_scanner/tx_templates/initial/mod.rs @@ -0,0 +1,3 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. +pub mod new; +pub mod retry; diff --git a/node/src/accountant/scanners/payable_scanner/tx_templates/initial/new.rs b/node/src/accountant/scanners/payable_scanner/tx_templates/initial/new.rs new file mode 100644 index 0000000000..aceb532b05 --- /dev/null +++ b/node/src/accountant/scanners/payable_scanner/tx_templates/initial/new.rs @@ -0,0 +1,217 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. +use crate::accountant::db_access_objects::payable_dao::PayableAccount; +use crate::accountant::scanners::payable_scanner::tx_templates::BaseTxTemplate; +use std::ops::{Deref, DerefMut}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NewTxTemplate { + pub base: BaseTxTemplate, +} + +impl From<&PayableAccount> for NewTxTemplate { + fn from(payable_account: &PayableAccount) -> Self { + Self { + base: BaseTxTemplate::from(payable_account), + } + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct NewTxTemplates(pub Vec); + +impl From> for NewTxTemplates { + fn from(new_tx_template_vec: Vec) -> Self { + Self(new_tx_template_vec) + } +} + +impl Deref for NewTxTemplates { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for NewTxTemplates { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl IntoIterator for NewTxTemplates { + type Item = NewTxTemplate; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +impl FromIterator for NewTxTemplates { + fn from_iter>(iter: I) -> Self { + NewTxTemplates(iter.into_iter().collect()) + } +} + +impl From<&Vec> for NewTxTemplates { + fn from(payable_accounts: &Vec) -> Self { + Self(payable_accounts.iter().map(NewTxTemplate::from).collect()) + } +} + +#[cfg(test)] +mod tests { + use crate::accountant::db_access_objects::payable_dao::PayableAccount; + use crate::accountant::scanners::payable_scanner::tx_templates::initial::new::{ + NewTxTemplate, NewTxTemplates, + }; + use crate::accountant::scanners::payable_scanner::tx_templates::BaseTxTemplate; + use crate::blockchain::test_utils::make_address; + use crate::test_utils::make_wallet; + use std::time::SystemTime; + + #[test] + fn new_tx_template_can_be_created_from_payable_account() { + let wallet = make_wallet("some wallet"); + let balance_wei = 1_000_000; + let payable_account = PayableAccount { + wallet: wallet.clone(), + balance_wei, + last_paid_timestamp: SystemTime::now(), + pending_payable_opt: None, + }; + + let new_tx_template = NewTxTemplate::from(&payable_account); + + assert_eq!(new_tx_template.base.receiver_address, wallet.address()); + assert_eq!(new_tx_template.base.amount_in_wei, balance_wei); + } + + #[test] + fn new_tx_templates_can_be_created_from_vec_using_into() { + let template1 = NewTxTemplate { + base: BaseTxTemplate { + receiver_address: make_address(1), + amount_in_wei: 1000, + }, + }; + let template2 = NewTxTemplate { + base: BaseTxTemplate { + receiver_address: make_address(2), + amount_in_wei: 2000, + }, + }; + let templates_vec = vec![template1.clone(), template2.clone()]; + + let templates: NewTxTemplates = templates_vec.into(); + + assert_eq!(templates.len(), 2); + assert_eq!(templates[0], template1); + assert_eq!(templates[1], template2); + } + + #[test] + fn new_tx_templates_deref_provides_access_to_inner_vector() { + let template1 = NewTxTemplate { + base: BaseTxTemplate { + receiver_address: make_address(1), + amount_in_wei: 1000, + }, + }; + let template2 = NewTxTemplate { + base: BaseTxTemplate { + receiver_address: make_address(2), + amount_in_wei: 2000, + }, + }; + + let templates = NewTxTemplates(vec![template1.clone(), template2.clone()]); + + assert_eq!(templates.len(), 2); + assert_eq!(templates[0], template1); + assert_eq!(templates[1], template2); + assert!(!templates.is_empty()); + assert!(templates.contains(&template1)); + assert_eq!( + templates + .iter() + .map(|template| template.base.amount_in_wei) + .sum::(), + 3000 + ); + } + + #[test] + fn new_tx_templates_into_iter_consumes_and_iterates() { + let template1 = NewTxTemplate { + base: BaseTxTemplate { + receiver_address: make_address(1), + amount_in_wei: 1000, + }, + }; + let template2 = NewTxTemplate { + base: BaseTxTemplate { + receiver_address: make_address(2), + amount_in_wei: 2000, + }, + }; + let templates = NewTxTemplates(vec![template1.clone(), template2.clone()]); + + let collected: Vec = templates.into_iter().collect(); + + assert_eq!(collected.len(), 2); + assert_eq!(collected[0], template1); + assert_eq!(collected[1], template2); + } + + #[test] + fn new_tx_templates_can_be_created_from_payable_accounts() { + let wallet1 = make_wallet("wallet1"); + let wallet2 = make_wallet("wallet2"); + let payable_accounts = vec![ + PayableAccount { + wallet: wallet1.clone(), + balance_wei: 1000, + last_paid_timestamp: SystemTime::now(), + pending_payable_opt: None, + }, + PayableAccount { + wallet: wallet2.clone(), + balance_wei: 2000, + last_paid_timestamp: SystemTime::now(), + pending_payable_opt: None, + }, + ]; + + let new_tx_templates = NewTxTemplates::from(&payable_accounts); + + assert_eq!(new_tx_templates.len(), 2); + assert_eq!(new_tx_templates[0].base.receiver_address, wallet1.address()); + assert_eq!(new_tx_templates[0].base.amount_in_wei, 1000); + assert_eq!(new_tx_templates[1].base.receiver_address, wallet2.address()); + assert_eq!(new_tx_templates[1].base.amount_in_wei, 2000); + } + + #[test] + fn new_tx_templates_can_be_created_from_iterator() { + let template1 = NewTxTemplate { + base: BaseTxTemplate { + receiver_address: make_address(1), + amount_in_wei: 1000, + }, + }; + let template2 = NewTxTemplate { + base: BaseTxTemplate { + receiver_address: make_address(2), + amount_in_wei: 2000, + }, + }; + + let templates = NewTxTemplates::from_iter(vec![template1.clone(), template2.clone()]); + + assert_eq!(templates.len(), 2); + assert_eq!(templates[0], template1); + assert_eq!(templates[1], template2); + } +} diff --git a/node/src/accountant/scanners/payable_scanner/tx_templates/initial/retry.rs b/node/src/accountant/scanners/payable_scanner/tx_templates/initial/retry.rs new file mode 100644 index 0000000000..8157a373b7 --- /dev/null +++ b/node/src/accountant/scanners/payable_scanner/tx_templates/initial/retry.rs @@ -0,0 +1,275 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. +use crate::accountant::db_access_objects::failed_payable_dao::FailedTx; +use crate::accountant::scanners::payable_scanner::tx_templates::BaseTxTemplate; +use masq_lib::logger::Logger; +use std::collections::{BTreeSet, HashMap}; +use std::ops::{Deref, DerefMut}; +use web3::types::Address; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RetryTxTemplate { + pub base: BaseTxTemplate, + pub prev_gas_price_wei: u128, + pub prev_nonce: u64, +} + +impl RetryTxTemplate { + pub fn new( + failed_tx: &FailedTx, + updated_payable_balance_opt: Option, + logger: &Logger, + ) -> Self { + let mut retry_template = RetryTxTemplate::from(failed_tx); + + debug!(logger, "Tx to retry {:?}", failed_tx); + + if let Some(updated_payable_balance) = updated_payable_balance_opt { + debug!( + logger, + "Updating the pay for {:?} from former {} to latest accounted balance {} of minor", + failed_tx.receiver_address, + failed_tx.amount_minor, + updated_payable_balance + ); + + retry_template.base.amount_in_wei = updated_payable_balance; + } + + retry_template + } +} + +impl From<&FailedTx> for RetryTxTemplate { + fn from(failed_tx: &FailedTx) -> Self { + RetryTxTemplate { + base: BaseTxTemplate { + receiver_address: failed_tx.receiver_address, + amount_in_wei: failed_tx.amount_minor, + }, + prev_gas_price_wei: failed_tx.gas_price_minor, + prev_nonce: failed_tx.nonce, + } + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct RetryTxTemplates(pub Vec); + +impl RetryTxTemplates { + pub fn new( + txs_to_retry: &BTreeSet, + amounts_from_payables: &HashMap, + logger: &Logger, + ) -> Self { + Self( + txs_to_retry + .iter() + .map(|tx_to_retry| { + let payable_scan_amount_opt = amounts_from_payables + .get(&tx_to_retry.receiver_address) + .copied(); + RetryTxTemplate::new(tx_to_retry, payable_scan_amount_opt, logger) + }) + .collect(), + ) + } +} + +impl From> for RetryTxTemplates { + fn from(retry_tx_templates: Vec) -> Self { + Self(retry_tx_templates) + } +} + +impl Deref for RetryTxTemplates { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for RetryTxTemplates { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl IntoIterator for RetryTxTemplates { + type Item = RetryTxTemplate; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +#[cfg(test)] +mod tests { + use crate::accountant::db_access_objects::failed_payable_dao::{ + FailedTx, FailureReason, FailureStatus, + }; + use crate::accountant::scanners::payable_scanner::tx_templates::initial::retry::{ + RetryTxTemplate, RetryTxTemplates, + }; + use crate::accountant::scanners::payable_scanner::tx_templates::BaseTxTemplate; + use crate::blockchain::test_utils::{make_address, make_tx_hash}; + use masq_lib::logger::Logger; + + #[test] + fn retry_tx_template_constructor_works() { + let receiver_address = make_address(42); + let amount_in_wei = 1_000_000; + let gas_price = 20_000_000_000; + let nonce = 123; + let tx_hash = make_tx_hash(789); + let failed_tx = FailedTx { + hash: tx_hash, + receiver_address, + amount_minor: amount_in_wei, + gas_price_minor: gas_price, + nonce, + timestamp: 1234567, + reason: FailureReason::PendingTooLong, + status: FailureStatus::RetryRequired, + }; + let logger = Logger::new("test"); + let fetched_balance_from_payable_table_opt_1 = None; + let fetched_balance_from_payable_table_opt_2 = Some(1_234_567); + + let result_1 = RetryTxTemplate::new( + &failed_tx, + fetched_balance_from_payable_table_opt_1, + &logger, + ); + let result_2 = RetryTxTemplate::new( + &failed_tx, + fetched_balance_from_payable_table_opt_2, + &logger, + ); + + let assert = |result: RetryTxTemplate, expected_amount_in_wei: u128| { + assert_eq!(result.base.receiver_address, receiver_address); + assert_eq!(result.base.amount_in_wei, expected_amount_in_wei); + assert_eq!(result.prev_gas_price_wei, gas_price); + assert_eq!(result.prev_nonce, nonce); + }; + assert(result_1, amount_in_wei); + assert(result_2, fetched_balance_from_payable_table_opt_2.unwrap()); + } + + #[test] + fn retry_tx_template_can_be_created_from_failed_tx() { + let receiver_address = make_address(42); + let amount_in_wei = 1_000_000; + let gas_price = 20_000_000_000; + let nonce = 123; + let tx_hash = make_tx_hash(789); + let failed_tx = FailedTx { + hash: tx_hash, + receiver_address, + amount_minor: amount_in_wei, + gas_price_minor: gas_price, + nonce, + timestamp: 1234567, + reason: FailureReason::PendingTooLong, + status: FailureStatus::RetryRequired, + }; + + let retry_tx_template = RetryTxTemplate::from(&failed_tx); + + assert_eq!(retry_tx_template.base.receiver_address, receiver_address); + assert_eq!(retry_tx_template.base.amount_in_wei, amount_in_wei); + assert_eq!(retry_tx_template.prev_gas_price_wei, gas_price); + assert_eq!(retry_tx_template.prev_nonce, nonce); + } + + #[test] + fn retry_tx_templates_can_be_created_from_vec_using_into() { + let template1 = RetryTxTemplate { + base: BaseTxTemplate { + receiver_address: make_address(1), + amount_in_wei: 1000, + }, + prev_gas_price_wei: 20_000_000_000, + prev_nonce: 5, + }; + let template2 = RetryTxTemplate { + base: BaseTxTemplate { + receiver_address: make_address(2), + amount_in_wei: 2000, + }, + prev_gas_price_wei: 25_000_000_000, + prev_nonce: 6, + }; + let templates_vec = vec![template1.clone(), template2.clone()]; + + let templates: RetryTxTemplates = templates_vec.into(); + + assert_eq!(templates.len(), 2); + assert_eq!(templates[0], template1); + assert_eq!(templates[1], template2); + } + + #[test] + fn retry_tx_templates_deref_provides_access_to_inner_vector() { + let template1 = RetryTxTemplate { + base: BaseTxTemplate { + receiver_address: make_address(1), + amount_in_wei: 1000, + }, + prev_gas_price_wei: 20_000_000_000, + prev_nonce: 5, + }; + let template2 = RetryTxTemplate { + base: BaseTxTemplate { + receiver_address: make_address(2), + amount_in_wei: 2000, + }, + prev_gas_price_wei: 25_000_000_000, + prev_nonce: 6, + }; + + let templates = RetryTxTemplates(vec![template1.clone(), template2.clone()]); + + assert_eq!(templates.len(), 2); + assert_eq!(templates[0], template1); + assert_eq!(templates[1], template2); + assert!(!templates.is_empty()); + assert!(templates.contains(&template1)); + assert_eq!( + templates + .iter() + .map(|template| template.base.amount_in_wei) + .sum::(), + 3000 + ); + } + + #[test] + fn retry_tx_templates_into_iter_consumes_and_iterates() { + let template1 = RetryTxTemplate { + base: BaseTxTemplate { + receiver_address: make_address(1), + amount_in_wei: 1000, + }, + prev_gas_price_wei: 20_000_000_000, + prev_nonce: 5, + }; + let template2 = RetryTxTemplate { + base: BaseTxTemplate { + receiver_address: make_address(2), + amount_in_wei: 2000, + }, + prev_gas_price_wei: 25_000_000_000, + prev_nonce: 6, + }; + let templates = RetryTxTemplates(vec![template1.clone(), template2.clone()]); + + let collected: Vec = templates.into_iter().collect(); + + assert_eq!(collected.len(), 2); + assert_eq!(collected[0], template1); + assert_eq!(collected[1], template2); + } +} diff --git a/node/src/accountant/scanners/payable_scanner/tx_templates/mod.rs b/node/src/accountant/scanners/payable_scanner/tx_templates/mod.rs new file mode 100644 index 0000000000..ca8dfa8701 --- /dev/null +++ b/node/src/accountant/scanners/payable_scanner/tx_templates/mod.rs @@ -0,0 +1,48 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. +use crate::accountant::db_access_objects::payable_dao::PayableAccount; +use web3::types::Address; + +pub mod initial; +pub mod priced; +pub mod signable; +pub mod test_utils; + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct BaseTxTemplate { + pub receiver_address: Address, + pub amount_in_wei: u128, +} + +impl From<&PayableAccount> for BaseTxTemplate { + fn from(payable_account: &PayableAccount) -> Self { + Self { + receiver_address: payable_account.wallet.address(), + amount_in_wei: payable_account.balance_wei, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::accountant::db_access_objects::payable_dao::PayableAccount; + use crate::test_utils::make_wallet; + use std::time::SystemTime; + + #[test] + fn base_tx_template_can_be_created_from_payable_account() { + let wallet = make_wallet("some wallet"); + let balance_wei = 1_000_000; + let payable_account = PayableAccount { + wallet: wallet.clone(), + balance_wei, + last_paid_timestamp: SystemTime::now(), + pending_payable_opt: None, + }; + + let base_tx_template = BaseTxTemplate::from(&payable_account); + + assert_eq!(base_tx_template.receiver_address, wallet.address()); + assert_eq!(base_tx_template.amount_in_wei, balance_wei); + } +} diff --git a/node/src/accountant/scanners/payable_scanner/tx_templates/priced/mod.rs b/node/src/accountant/scanners/payable_scanner/tx_templates/priced/mod.rs new file mode 100644 index 0000000000..84adbe5e33 --- /dev/null +++ b/node/src/accountant/scanners/payable_scanner/tx_templates/priced/mod.rs @@ -0,0 +1,3 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. +pub mod new; +pub mod retry; diff --git a/node/src/accountant/scanners/payable_scanner/tx_templates/priced/new.rs b/node/src/accountant/scanners/payable_scanner/tx_templates/priced/new.rs new file mode 100644 index 0000000000..6de54e4c97 --- /dev/null +++ b/node/src/accountant/scanners/payable_scanner/tx_templates/priced/new.rs @@ -0,0 +1,107 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. +use crate::accountant::join_with_separator; +use crate::accountant::scanners::payable_scanner::tx_templates::initial::new::{ + NewTxTemplate, NewTxTemplates, +}; +use crate::accountant::scanners::payable_scanner::tx_templates::BaseTxTemplate; +use crate::blockchain::blockchain_bridge::increase_gas_price_by_margin; +use masq_lib::logger::Logger; +use std::ops::Deref; +use thousands::Separable; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PricedNewTxTemplate { + pub base: BaseTxTemplate, + pub computed_gas_price_wei: u128, +} + +impl PricedNewTxTemplate { + pub fn new(unpriced_tx_template: NewTxTemplate, computed_gas_price_wei: u128) -> Self { + Self { + base: unpriced_tx_template.base, + computed_gas_price_wei, + } + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct PricedNewTxTemplates(pub Vec); + +// TODO: GH-703: Consider design changes here +impl Deref for PricedNewTxTemplates { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl FromIterator for PricedNewTxTemplates { + fn from_iter>(iter: I) -> Self { + PricedNewTxTemplates(iter.into_iter().collect()) + } +} + +impl PricedNewTxTemplates { + pub fn new( + unpriced_new_tx_templates: NewTxTemplates, + computed_gas_price_wei: u128, + ) -> PricedNewTxTemplates { + let updated_tx_templates = unpriced_new_tx_templates + .into_iter() + .map(|new_tx_template| { + PricedNewTxTemplate::new(new_tx_template, computed_gas_price_wei) + }) + .collect(); + + PricedNewTxTemplates(updated_tx_templates) + } + + pub fn from_initial_with_logging( + initial_templates: NewTxTemplates, + latest_gas_price_wei: u128, + ceil: u128, + logger: &Logger, + ) -> Self { + let computed_gas_price_wei = increase_gas_price_by_margin(latest_gas_price_wei); + + let safe_gas_price_wei = if computed_gas_price_wei > ceil { + warning!( + logger, + "{}", + Self::log_ceiling_crossed(&initial_templates, computed_gas_price_wei, ceil) + ); + + ceil + } else { + computed_gas_price_wei + }; + + Self::new(initial_templates, safe_gas_price_wei) + } + + fn log_ceiling_crossed( + templates: &NewTxTemplates, + computed_gas_price_wei: u128, + ceil: u128, + ) -> String { + format!( + "The computed gas price {} wei is above the ceil value of {} wei computed by this Node.\n\ + Transaction(s) to following receivers are affected:\n\ + {}", + computed_gas_price_wei.separate_with_commas(), + ceil.separate_with_commas(), + join_with_separator( + templates.iter(), + |tx_template| format!("{:?}", tx_template.base.receiver_address), + "\n" + ) + ) + } + + pub fn total_gas_price(&self) -> u128 { + self.iter() + .map(|new_tx_template| new_tx_template.computed_gas_price_wei) + .sum() + } +} diff --git a/node/src/accountant/scanners/payable_scanner/tx_templates/priced/retry.rs b/node/src/accountant/scanners/payable_scanner/tx_templates/priced/retry.rs new file mode 100644 index 0000000000..48e41f4b9c --- /dev/null +++ b/node/src/accountant/scanners/payable_scanner/tx_templates/priced/retry.rs @@ -0,0 +1,169 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. +use crate::accountant::join_with_separator; +use crate::accountant::scanners::payable_scanner::tx_templates::initial::retry::{ + RetryTxTemplate, RetryTxTemplates, +}; +use crate::accountant::scanners::payable_scanner::tx_templates::BaseTxTemplate; +use crate::blockchain::blockchain_bridge::increase_gas_price_by_margin; +use masq_lib::logger::Logger; +use std::ops::{Deref, DerefMut}; +use thousands::Separable; +use web3::types::Address; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PricedRetryTxTemplate { + pub base: BaseTxTemplate, + pub prev_nonce: u64, + pub computed_gas_price_wei: u128, +} + +impl PricedRetryTxTemplate { + pub fn new(initial: RetryTxTemplate, computed_gas_price_wei: u128) -> Self { + Self { + base: initial.base, + prev_nonce: initial.prev_nonce, + computed_gas_price_wei, + } + } + + fn create_and_update_log_data( + retry_tx_template: RetryTxTemplate, + latest_gas_price_wei: u128, + ceil: u128, + log_builder: &mut RetryLogBuilder, + ) -> PricedRetryTxTemplate { + let receiver = retry_tx_template.base.receiver_address; + let computed_gas_price_wei = + Self::compute_gas_price(retry_tx_template.prev_gas_price_wei, latest_gas_price_wei); + + let safe_gas_price_wei = if computed_gas_price_wei > ceil { + log_builder.push(receiver, computed_gas_price_wei); + ceil + } else { + computed_gas_price_wei + }; + + PricedRetryTxTemplate::new(retry_tx_template, safe_gas_price_wei) + } + + fn compute_gas_price(latest_gas_price_wei: u128, prev_gas_price_wei: u128) -> u128 { + let gas_price_wei = latest_gas_price_wei.max(prev_gas_price_wei); + + increase_gas_price_by_margin(gas_price_wei) + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct PricedRetryTxTemplates(pub Vec); + +// TODO: GH-703: Consider design changes here +impl Deref for PricedRetryTxTemplates { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +// TODO: GH-703: Consider design changes here +impl DerefMut for PricedRetryTxTemplates { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl FromIterator for PricedRetryTxTemplates { + fn from_iter>(iter: I) -> Self { + PricedRetryTxTemplates(iter.into_iter().collect()) + } +} + +impl PricedRetryTxTemplates { + pub fn from_initial_with_logging( + initial_templates: RetryTxTemplates, + latest_gas_price_wei: u128, + ceil: u128, + logger: &Logger, + ) -> Self { + let mut log_builder = RetryLogBuilder::new(initial_templates.len(), ceil); + + let templates = initial_templates + .into_iter() + .map(|retry_tx_template| { + PricedRetryTxTemplate::create_and_update_log_data( + retry_tx_template, + latest_gas_price_wei, + ceil, + &mut log_builder, + ) + }) + .collect(); + + if let Some(log_msg) = log_builder.build() { + warning!(logger, "{}", log_msg) + } + + templates + } + + pub fn total_gas_price(&self) -> u128 { + self.iter() + .map(|retry_tx_template| retry_tx_template.computed_gas_price_wei) + .sum() + } + + pub fn reorder_by_nonces(mut self, latest_nonce: u64) -> Self { + // TODO: This algorithm could be made more robust by including un-realistic permutations of tx nonces + self.sort_by_key(|template| template.prev_nonce); + + let split_index = self + .iter() + .position(|template| template.prev_nonce == latest_nonce) + .unwrap_or(0); + + let (left, right) = self.split_at(split_index); + + Self([right, left].concat()) + } +} + +pub struct RetryLogBuilder { + log_data: Vec<(Address, u128)>, + ceil: u128, +} + +impl RetryLogBuilder { + fn new(capacity: usize, ceil: u128) -> Self { + Self { + log_data: Vec::with_capacity(capacity), + ceil, + } + } + + fn push(&mut self, address: Address, gas_price: u128) { + self.log_data.push((address, gas_price)); + } + + fn build(&self) -> Option { + if self.log_data.is_empty() { + None + } else { + Some(format!( + "The computed gas price(s) in wei is \ + above the ceil value of {} wei computed by this Node.\n\ + Transaction(s) to following receivers are affected:\n\ + {}", + self.ceil.separate_with_commas(), + join_with_separator( + &self.log_data, + |(address, gas_price)| format!( + "{:?} with gas price {}", + address, + gas_price.separate_with_commas() + ), + "\n" + ) + )) + } + } +} diff --git a/node/src/accountant/scanners/payable_scanner/tx_templates/signable/mod.rs b/node/src/accountant/scanners/payable_scanner/tx_templates/signable/mod.rs new file mode 100644 index 0000000000..d1ae97ebee --- /dev/null +++ b/node/src/accountant/scanners/payable_scanner/tx_templates/signable/mod.rs @@ -0,0 +1,253 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. +use crate::accountant::scanners::payable_scanner::tx_templates::priced::new::{ + PricedNewTxTemplate, PricedNewTxTemplates, +}; +use crate::accountant::scanners::payable_scanner::tx_templates::priced::retry::{ + PricedRetryTxTemplate, PricedRetryTxTemplates, +}; +use itertools::{Either, Itertools}; +use std::ops::Deref; +use web3::types::Address; + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct SignableTxTemplate { + pub receiver_address: Address, + pub amount_in_wei: u128, + pub gas_price_wei: u128, + pub nonce: u64, +} + +impl From<(&PricedNewTxTemplate, u64)> for SignableTxTemplate { + fn from((priced_new_tx_template, nonce): (&PricedNewTxTemplate, u64)) -> Self { + SignableTxTemplate { + receiver_address: priced_new_tx_template.base.receiver_address, + amount_in_wei: priced_new_tx_template.base.amount_in_wei, + gas_price_wei: priced_new_tx_template.computed_gas_price_wei, + nonce, + } + } +} + +impl From<(&PricedRetryTxTemplate, u64)> for SignableTxTemplate { + fn from((priced_retry_tx_template, nonce): (&PricedRetryTxTemplate, u64)) -> Self { + SignableTxTemplate { + receiver_address: priced_retry_tx_template.base.receiver_address, + amount_in_wei: priced_retry_tx_template.base.amount_in_wei, + gas_price_wei: priced_retry_tx_template.computed_gas_price_wei, + nonce, + } + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct SignableTxTemplates(pub Vec); + +impl FromIterator for SignableTxTemplates { + fn from_iter>(iter: I) -> Self { + Self(iter.into_iter().collect()) + } +} + +impl From<(PricedNewTxTemplates, u64)> for SignableTxTemplates { + fn from((priced_new_tx_templates, latest_nonce): (PricedNewTxTemplates, u64)) -> Self { + priced_new_tx_templates + .iter() + .enumerate() + .map(|(i, template)| SignableTxTemplate::from((template, latest_nonce + i as u64))) + .collect() + } +} + +impl From<(PricedRetryTxTemplates, u64)> for SignableTxTemplates { + fn from((priced_retry_tx_templates, latest_nonce): (PricedRetryTxTemplates, u64)) -> Self { + priced_retry_tx_templates + .reorder_by_nonces(latest_nonce) + .iter() + .enumerate() + .map(|(i, template)| SignableTxTemplate::from((template, latest_nonce + i as u64))) + .collect() + } +} + +impl SignableTxTemplates { + pub fn new( + priced_tx_templates: Either, + latest_nonce: u64, + ) -> Self { + match priced_tx_templates { + Either::Left(priced_new_tx_templates) => { + Self::from((priced_new_tx_templates, latest_nonce)) + } + Either::Right(priced_retry_tx_templates) => { + Self::from((priced_retry_tx_templates, latest_nonce)) + } + } + } + + pub fn nonce_range(&self) -> (u64, u64) { + let sorted: Vec<&SignableTxTemplate> = self + .iter() + .sorted_by_key(|template| template.nonce) + .collect(); + let first = sorted.first().map_or(0, |template| template.nonce); + let last = sorted.last().map_or(0, |template| template.nonce); + + (first, last) + } + + pub fn largest_amount(&self) -> u128 { + self.iter() + .map(|signable_tx_template| signable_tx_template.amount_in_wei) + .max() + .expect("there aren't any templates") + } +} + +// TODO: GH-703: Consider design changes here +impl Deref for SignableTxTemplates { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[cfg(test)] +mod tests { + + use crate::accountant::scanners::payable_scanner::tx_templates::priced::new::PricedNewTxTemplates; + use crate::accountant::scanners::payable_scanner::tx_templates::priced::retry::PricedRetryTxTemplates; + use crate::accountant::scanners::payable_scanner::tx_templates::signable::SignableTxTemplates; + use crate::accountant::scanners::payable_scanner::tx_templates::test_utils::{ + make_priced_new_tx_template, make_priced_retry_tx_template, make_signable_tx_template, + }; + use itertools::Either; + + #[test] + fn signable_tx_templates_can_be_created_from_priced_new_tx_templates() { + let nonce = 10; + let priced_new_tx_templates = PricedNewTxTemplates(vec![ + make_priced_new_tx_template(1), + make_priced_new_tx_template(2), + make_priced_new_tx_template(3), + make_priced_new_tx_template(4), + make_priced_new_tx_template(5), + ]); + + let result = SignableTxTemplates::new(Either::Left(priced_new_tx_templates.clone()), nonce); + + priced_new_tx_templates + .iter() + .zip(result.iter()) + .enumerate() + .for_each(|(i, (priced, signable))| { + assert_eq!( + signable.receiver_address, priced.base.receiver_address, + "Element {i}: receiver_address mismatch", + ); + assert_eq!( + signable.amount_in_wei, priced.base.amount_in_wei, + "Element {i}: amount_in_wei mismatch", + ); + assert_eq!( + signable.gas_price_wei, priced.computed_gas_price_wei, + "Element {i}: gas_price_wei mismatch", + ); + assert_eq!( + signable.nonce, + nonce + i as u64, + "Element {i}: nonce mismatch", + ); + }); + } + + #[test] + fn signable_tx_templates_can_be_created_from_priced_retry_tx_templates() { + let nonce = 10; + let retries = PricedRetryTxTemplates(vec![ + make_priced_retry_tx_template(12), + make_priced_retry_tx_template(6), + make_priced_retry_tx_template(10), + make_priced_retry_tx_template(8), + make_priced_retry_tx_template(11), + ]); + + let result = SignableTxTemplates::new(Either::Right(retries.clone()), nonce); + + let expected_order = vec![2, 4, 0, 1, 3]; + result + .iter() + .zip(expected_order.into_iter()) + .enumerate() + .for_each(|(i, (signable, tx_order))| { + assert_eq!( + signable.receiver_address, retries[tx_order].base.receiver_address, + "Element {} (tx_order {}): receiver_address mismatch", + i, tx_order + ); + assert_eq!( + signable.nonce, + nonce + i as u64, + "Element {} (tx_order {}): nonce mismatch", + i, + tx_order + ); + assert_eq!( + signable.amount_in_wei, retries[tx_order].base.amount_in_wei, + "Element {} (tx_order {}): amount_in_wei mismatch", + i, tx_order + ); + assert_eq!( + signable.gas_price_wei, retries[tx_order].computed_gas_price_wei, + "Element {} (tx_order {}): gas_price_wei mismatch", + i, tx_order + ); + }); + } + + #[test] + fn test_largest_amount() { + let templates = SignableTxTemplates(vec![ + make_signable_tx_template(1), + make_signable_tx_template(2), + make_signable_tx_template(3), + ]); + + assert_eq!(templates.largest_amount(), 3000); + } + + #[test] + #[should_panic(expected = "there aren't any templates")] + fn largest_amount_panics_for_empty_templates() { + let empty_templates = SignableTxTemplates(vec![]); + + let _ = empty_templates.largest_amount(); + } + + #[test] + fn test_nonce_range() { + // Test case 1: Empty templates + let empty_templates = SignableTxTemplates(vec![]); + assert_eq!(empty_templates.nonce_range(), (0, 0)); + + // Test case 2: Single template + let single_template = SignableTxTemplates(vec![make_signable_tx_template(5)]); + assert_eq!(single_template.nonce_range(), (5, 5)); + + // Test case 3: Multiple templates in order + let ordered_templates = SignableTxTemplates(vec![ + make_signable_tx_template(1), + make_signable_tx_template(2), + make_signable_tx_template(3), + ]); + assert_eq!(ordered_templates.nonce_range(), (1, 3)); + + // Test case 4: Multiple templates out of order + let unordered_templates = SignableTxTemplates(vec![ + make_signable_tx_template(3), + make_signable_tx_template(1), + make_signable_tx_template(2), + ]); + assert_eq!(unordered_templates.nonce_range(), (1, 3)); + } +} diff --git a/node/src/accountant/scanners/payable_scanner/tx_templates/test_utils.rs b/node/src/accountant/scanners/payable_scanner/tx_templates/test_utils.rs new file mode 100644 index 0000000000..b91eaed764 --- /dev/null +++ b/node/src/accountant/scanners/payable_scanner/tx_templates/test_utils.rs @@ -0,0 +1,108 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. +#![cfg(test)] + +use crate::accountant::db_access_objects::payable_dao::PayableAccount; +use crate::accountant::scanners::payable_scanner::tx_templates::initial::retry::RetryTxTemplate; +use crate::accountant::scanners::payable_scanner::tx_templates::priced::new::{ + PricedNewTxTemplate, PricedNewTxTemplates, +}; +use crate::accountant::scanners::payable_scanner::tx_templates::priced::retry::PricedRetryTxTemplate; +use crate::accountant::scanners::payable_scanner::tx_templates::signable::SignableTxTemplate; +use crate::accountant::scanners::payable_scanner::tx_templates::BaseTxTemplate; +use crate::accountant::test_utils::make_payable_account; +use crate::blockchain::test_utils::make_address; +use masq_lib::constants::DEFAULT_GAS_PRICE; +use web3::types::Address; + +pub fn make_priced_new_tx_templates(vec: Vec<(PayableAccount, u128)>) -> PricedNewTxTemplates { + vec.iter() + .map(|(payable_account, gas_price_wei)| PricedNewTxTemplate { + base: BaseTxTemplate::from(payable_account), + computed_gas_price_wei: *gas_price_wei, + }) + .collect() +} + +pub fn make_priced_new_tx_template(n: u64) -> PricedNewTxTemplate { + PricedNewTxTemplate { + base: BaseTxTemplate::from(&make_payable_account(n)), + computed_gas_price_wei: DEFAULT_GAS_PRICE as u128, + } +} + +pub fn make_priced_retry_tx_template(prev_nonce: u64) -> PricedRetryTxTemplate { + PricedRetryTxTemplate { + base: BaseTxTemplate::from(&make_payable_account(prev_nonce)), + prev_nonce, + computed_gas_price_wei: DEFAULT_GAS_PRICE as u128, + } +} + +pub fn make_signable_tx_template(nonce: u64) -> SignableTxTemplate { + SignableTxTemplate { + receiver_address: make_address(1), + amount_in_wei: nonce as u128 * 1_000, + gas_price_wei: nonce as u128 * 1_000_000, + nonce, + } +} + +pub fn make_retry_tx_template(n: u32) -> RetryTxTemplate { + RetryTxTemplateBuilder::new() + .receiver_address(make_address(n)) + .amount_in_wei(n as u128 * 1_000) + .prev_gas_price_wei(n as u128 * 1_000_000) + .prev_nonce(n as u64) + .build() +} + +#[derive(Default)] +pub struct RetryTxTemplateBuilder { + receiver_address_opt: Option
, + amount_in_wei_opt: Option, + prev_gas_price_wei_opt: Option, + prev_nonce_opt: Option, +} + +impl RetryTxTemplateBuilder { + pub fn new() -> Self { + RetryTxTemplateBuilder::default() + } + + pub fn receiver_address(mut self, address: Address) -> Self { + self.receiver_address_opt = Some(address); + self + } + + pub fn amount_in_wei(mut self, amount: u128) -> Self { + self.amount_in_wei_opt = Some(amount); + self + } + + pub fn prev_gas_price_wei(mut self, gas_price: u128) -> Self { + self.prev_gas_price_wei_opt = Some(gas_price); + self + } + + pub fn prev_nonce(mut self, nonce: u64) -> Self { + self.prev_nonce_opt = Some(nonce); + self + } + + pub fn payable_account(mut self, payable_account: &PayableAccount) -> Self { + self.receiver_address_opt = Some(payable_account.wallet.address()); + self.amount_in_wei_opt = Some(payable_account.balance_wei); + self + } + + pub fn build(self) -> RetryTxTemplate { + RetryTxTemplate { + base: BaseTxTemplate { + receiver_address: self.receiver_address_opt.unwrap_or_else(|| make_address(0)), + amount_in_wei: self.amount_in_wei_opt.unwrap_or(0), + }, + prev_gas_price_wei: self.prev_gas_price_wei_opt.unwrap_or(0), + prev_nonce: self.prev_nonce_opt.unwrap_or(0), + } + } +} diff --git a/node/src/accountant/scanners/payable_scanner/utils.rs b/node/src/accountant/scanners/payable_scanner/utils.rs new file mode 100644 index 0000000000..3ace3b8b62 --- /dev/null +++ b/node/src/accountant/scanners/payable_scanner/utils.rs @@ -0,0 +1,512 @@ +// Copyright (c) 2019, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. + +use crate::accountant::db_access_objects::failed_payable_dao::{FailedTx, FailureStatus}; +use crate::accountant::db_access_objects::payable_dao::{PayableAccount, PayableDaoError}; +use crate::accountant::db_access_objects::utils::{ThresholdUtils, TxHash}; +use crate::accountant::db_access_objects::Transaction; +use crate::accountant::scanners::payable_scanner::msgs::InitialTemplatesMessage; +use crate::accountant::{join_with_commas, PendingPayable}; +use crate::blockchain::blockchain_interface::data_structures::BatchResults; +use crate::sub_lib::accountant::PaymentThresholds; +use crate::sub_lib::wallet::Wallet; +use itertools::{Either, Itertools}; +use masq_lib::logger::Logger; +use masq_lib::ui_gateway::NodeToUiMessage; +use std::cmp::Ordering; +use std::collections::{BTreeSet, HashMap}; +use std::ops::Not; +use std::time::SystemTime; +use thousands::Separable; +use web3::types::{Address, H256}; + +#[derive(Debug, PartialEq, Eq)] +pub struct PayableScanResult { + pub ui_response_opt: Option, + pub result: NextScanToRun, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum NextScanToRun { + PendingPayableScan, + NewPayableScan, + RetryPayableScan, +} + +pub fn filter_receiver_addresses_from_txs<'a, T, I>(transactions: I) -> BTreeSet
+where + T: 'a + Transaction, + I: Iterator, +{ + transactions.map(|tx| tx.receiver_address()).collect() +} + +pub fn generate_status_updates( + failed_txs: &BTreeSet, + status: FailureStatus, +) -> HashMap { + failed_txs + .iter() + .map(|tx| (tx.hash, status.clone())) + .collect() +} + +pub fn calculate_occurences(batch_results: &BatchResults) -> (usize, usize) { + (batch_results.sent_txs.len(), batch_results.failed_txs.len()) +} + +pub fn batch_stats(sent_txs_len: usize, failed_txs_len: usize) -> String { + format!( + "Total: {total}, Sent to RPC: {sent_txs_len}, Failed to send: {failed_txs_len}.", + total = sent_txs_len + failed_txs_len + ) +} + +pub fn initial_templates_msg_stats(msg: &InitialTemplatesMessage) -> String { + let (len, scan_type) = match &msg.initial_templates { + Either::Left(new_templates) => (new_templates.len(), "new"), + Either::Right(retry_templates) => (retry_templates.len(), "retry"), + }; + + format!("Found {} {} txs to process", len, scan_type) +} + +//debugging purposes only +pub fn investigate_debt_extremes( + timestamp: SystemTime, + retrieved_payables: &[PayableAccount], +) -> String { + #[derive(Clone, Copy, Default)] + struct PayableInfo { + balance_wei: u128, + age: u64, + } + fn bigger(payable_1: PayableInfo, payable_2: PayableInfo) -> PayableInfo { + match payable_1.balance_wei.cmp(&payable_2.balance_wei) { + Ordering::Greater => payable_1, + Ordering::Less => payable_2, + Ordering::Equal => { + if payable_1.age == payable_2.age { + payable_1 + } else { + older(payable_1, payable_2) + } + } + } + } + fn older(payable_1: PayableInfo, payable_2: PayableInfo) -> PayableInfo { + match payable_1.age.cmp(&payable_2.age) { + Ordering::Greater => payable_1, + Ordering::Less => payable_2, + Ordering::Equal => { + if payable_1.balance_wei == payable_2.balance_wei { + payable_1 + } else { + bigger(payable_1, payable_2) + } + } + } + } + + if retrieved_payables.is_empty() { + return "Payable scan found no debts".to_string(); + } + let (biggest, oldest) = retrieved_payables + .iter() + .map(|payable| PayableInfo { + balance_wei: payable.balance_wei, + age: timestamp + .duration_since(payable.last_paid_timestamp) + .expect("Payable time is corrupt") + .as_secs(), + }) + .fold( + Default::default(), + |(so_far_biggest, so_far_oldest): (PayableInfo, PayableInfo), payable| { + ( + bigger(so_far_biggest, payable), + older(so_far_oldest, payable), + ) + }, + ); + format!("Payable scan found {} debts; the biggest is {} owed for {}sec, the oldest is {} owed for {}sec", + retrieved_payables.len(), biggest.balance_wei, biggest.age, + oldest.balance_wei, oldest.age) +} + +pub fn payables_debug_summary(qualified_accounts: &[(PayableAccount, u128)], logger: &Logger) { + if qualified_accounts.is_empty() { + return; + } + debug!(logger, "Paying qualified debts:\n{}", { + let now = SystemTime::now(); + qualified_accounts + .iter() + .map(|(payable, threshold_point)| { + let p_age = now + .duration_since(payable.last_paid_timestamp) + .expect("Payable time is corrupt"); + format!( + "{} wei owed for {} sec exceeds the threshold {} wei for creditor {}", + payable.balance_wei.separate_with_commas(), + p_age.as_secs(), + threshold_point.separate_with_commas(), + payable.wallet + ) + }) + .join("\n") + }) +} + +#[derive(Debug, PartialEq, Eq)] +pub struct PendingPayableMissingInDb { + pub recipient: Address, + pub hash: H256, +} + +impl PendingPayableMissingInDb { + pub fn new(recipient: Address, hash: H256) -> Self { + PendingPayableMissingInDb { recipient, hash } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub struct PendingPayableMetadata<'a> { + pub recipient: &'a Wallet, + pub hash: H256, + pub rowid_opt: Option, +} + +impl<'a> PendingPayableMetadata<'a> { + pub fn new( + recipient: &'a Wallet, + hash: H256, + rowid_opt: Option, + ) -> PendingPayableMetadata<'a> { + PendingPayableMetadata { + recipient, + hash, + rowid_opt, + } + } +} + +pub fn mark_pending_payable_fatal_error( + sent_payments: &[&PendingPayable], + nonexistent: &[PendingPayableMetadata], + error: PayableDaoError, + missing_fingerprints_msg_maker: fn(&[PendingPayableMetadata]) -> String, + logger: &Logger, +) { + if !nonexistent.is_empty() { + error!(logger, "{}", missing_fingerprints_msg_maker(nonexistent)) + }; + panic!( + "Unable to create a mark in the payable table for wallets {} due to {:?}", + join_with_commas(sent_payments, |pending_p| pending_p + .recipient_wallet + .to_string()), + error + ) +} + +pub fn err_msg_for_failure_with_expected_but_missing_fingerprints( + nonexistent: Vec, + serialize_hashes: fn(&[H256]) -> String, +) -> Option { + nonexistent.is_empty().not().then_some(format!( + "Ran into failed transactions {} with missing fingerprints. System no longer reliable", + serialize_hashes(&nonexistent), + )) +} + +pub fn separate_rowids_and_hashes(ids_of_payments: Vec<(u64, H256)>) -> (Vec, Vec) { + ids_of_payments.into_iter().unzip() +} + +pub trait PayableThresholdsGauge { + fn is_innocent_age(&self, age: u64, limit: u64) -> bool; + fn is_innocent_balance(&self, balance: u128, limit: u128) -> bool; + fn calculate_payout_threshold_in_gwei( + &self, + payment_thresholds: &PaymentThresholds, + x: u64, + ) -> u128; + as_any_ref_in_trait!(); +} + +#[derive(Default)] +pub struct PayableThresholdsGaugeReal {} + +impl PayableThresholdsGauge for PayableThresholdsGaugeReal { + fn is_innocent_age(&self, age: u64, limit: u64) -> bool { + age <= limit + } + + fn is_innocent_balance(&self, balance: u128, limit: u128) -> bool { + balance <= limit + } + + fn calculate_payout_threshold_in_gwei( + &self, + payment_thresholds: &PaymentThresholds, + debt_age: u64, + ) -> u128 { + ThresholdUtils::calculate_finite_debt_limit_by_age(payment_thresholds, debt_age) + } + as_any_ref_in_trait_impl!(); +} + +#[cfg(test)] +mod tests { + use crate::accountant::db_access_objects::payable_dao::PayableAccount; + use crate::accountant::db_access_objects::receivable_dao::ReceivableAccount; + use crate::accountant::db_access_objects::utils::{from_unix_timestamp, to_unix_timestamp}; + use crate::accountant::scanners::payable_scanner::utils::{ + investigate_debt_extremes, payables_debug_summary, PayableThresholdsGauge, + PayableThresholdsGaugeReal, + }; + use crate::accountant::scanners::receivable_scanner::utils::balance_and_age; + use crate::accountant::{checked_conversion, gwei_to_wei}; + use crate::sub_lib::accountant::PaymentThresholds; + use crate::test_utils::make_wallet; + use masq_lib::constants::WEIS_IN_GWEI; + use masq_lib::logger::Logger; + use masq_lib::test_utils::logging::{init_test_logging, TestLogHandler}; + use std::time::SystemTime; + + #[test] + fn investigate_debt_extremes_picks_the_most_relevant_records() { + let now = SystemTime::now(); + let now_t = to_unix_timestamp(now); + let same_amount_significance = 2_000_000; + let same_age_significance = from_unix_timestamp(now_t - 30000); + let payables = &[ + PayableAccount { + wallet: make_wallet("wallet0"), + balance_wei: same_amount_significance, + last_paid_timestamp: from_unix_timestamp(now_t - 5000), + pending_payable_opt: None, + }, + //this debt is more significant because beside being high in amount it's also older, so should be prioritized and picked + PayableAccount { + wallet: make_wallet("wallet1"), + balance_wei: same_amount_significance, + last_paid_timestamp: from_unix_timestamp(now_t - 10000), + pending_payable_opt: None, + }, + //similarly these two wallets have debts equally old but the second has a bigger balance and should be chosen + PayableAccount { + wallet: make_wallet("wallet3"), + balance_wei: 100, + last_paid_timestamp: same_age_significance, + pending_payable_opt: None, + }, + PayableAccount { + wallet: make_wallet("wallet2"), + balance_wei: 330, + last_paid_timestamp: same_age_significance, + pending_payable_opt: None, + }, + ]; + + let result = investigate_debt_extremes(now, payables); + + assert_eq!(result, "Payable scan found 4 debts; the biggest is 2000000 owed for 10000sec, the oldest is 330 owed for 30000sec") + } + + #[test] + fn balance_and_age_is_calculated_as_expected() { + let now = SystemTime::now(); + let offset = 1000; + let receivable_account = ReceivableAccount { + wallet: make_wallet("wallet0"), + balance_wei: 10_000_000_000, + last_received_timestamp: from_unix_timestamp(to_unix_timestamp(now) - offset), + }; + + let (balance, age) = balance_and_age(now, &receivable_account); + + assert_eq!(balance, "10"); + assert_eq!(age.as_secs(), offset as u64); + } + + #[test] + fn payables_debug_summary_displays_nothing_for_no_qualified_payments() { + init_test_logging(); + let logger = + Logger::new("payables_debug_summary_displays_nothing_for_no_qualified_payments"); + + payables_debug_summary(&vec![], &logger); + + TestLogHandler::new().exists_no_log_containing( + "DEBUG: payables_debug_summary_stays_\ + inert_if_no_qualified_payments: Paying qualified debts:", + ); + } + + #[test] + fn payables_debug_summary_prints_pretty_summary() { + init_test_logging(); + let now = to_unix_timestamp(SystemTime::now()); + let payment_thresholds = PaymentThresholds { + threshold_interval_sec: 2_592_000, + debt_threshold_gwei: 1_000_000_000, + payment_grace_period_sec: 86_400, + maturity_threshold_sec: 86_400, + permanent_debt_allowed_gwei: 10_000_000, + unban_below_gwei: 10_000_000, + }; + let qualified_payables_and_threshold_points = vec![ + ( + PayableAccount { + wallet: make_wallet("wallet0"), + balance_wei: gwei_to_wei(payment_thresholds.permanent_debt_allowed_gwei + 2000), + last_paid_timestamp: from_unix_timestamp( + now - checked_conversion::( + payment_thresholds.maturity_threshold_sec + + payment_thresholds.threshold_interval_sec, + ), + ), + pending_payable_opt: None, + }, + 10_000_000_001_152_000_u128, + ), + ( + PayableAccount { + wallet: make_wallet("wallet1"), + balance_wei: gwei_to_wei(payment_thresholds.debt_threshold_gwei - 1), + last_paid_timestamp: from_unix_timestamp( + now - checked_conversion::( + payment_thresholds.maturity_threshold_sec + 55, + ), + ), + pending_payable_opt: None, + }, + 999_978_993_055_555_580, + ), + ]; + let logger = Logger::new("test"); + + payables_debug_summary(&qualified_payables_and_threshold_points, &logger); + + TestLogHandler::new().exists_log_containing("Paying qualified debts:\n\ + 10,002,000,000,000,000 wei owed for 2678400 sec exceeds the threshold \ + 10,000,000,001,152,000 wei for creditor 0x0000000000000000000000000077616c6c657430\n\ + 999,999,999,000,000,000 wei owed for 86455 sec exceeds the threshold \ + 999,978,993,055,555,580 wei for creditor 0x0000000000000000000000000077616c6c657431"); + } + + #[test] + fn payout_sloped_segment_in_payment_thresholds_goes_along_proper_line() { + let payment_thresholds = PaymentThresholds { + maturity_threshold_sec: 333, + payment_grace_period_sec: 444, + permanent_debt_allowed_gwei: 4444, + debt_threshold_gwei: 8888, + threshold_interval_sec: 1111111, + unban_below_gwei: 0, + }; + let higher_corner_timestamp = payment_thresholds.maturity_threshold_sec; + let middle_point_timestamp = payment_thresholds.maturity_threshold_sec + + payment_thresholds.threshold_interval_sec / 2; + let lower_corner_timestamp = + payment_thresholds.maturity_threshold_sec + payment_thresholds.threshold_interval_sec; + let tested_fn = |payment_thresholds: &PaymentThresholds, time| { + PayableThresholdsGaugeReal {} + .calculate_payout_threshold_in_gwei(payment_thresholds, time) as i128 + }; + + let higher_corner_point = tested_fn(&payment_thresholds, higher_corner_timestamp); + let middle_point = tested_fn(&payment_thresholds, middle_point_timestamp); + let lower_corner_point = tested_fn(&payment_thresholds, lower_corner_timestamp); + + let allowed_imprecision = WEIS_IN_GWEI; + let ideal_template_higher: i128 = gwei_to_wei(payment_thresholds.debt_threshold_gwei); + let ideal_template_middle: i128 = gwei_to_wei( + (payment_thresholds.debt_threshold_gwei + - payment_thresholds.permanent_debt_allowed_gwei) + / 2 + + payment_thresholds.permanent_debt_allowed_gwei, + ); + let ideal_template_lower: i128 = + gwei_to_wei(payment_thresholds.permanent_debt_allowed_gwei); + assert!( + higher_corner_point <= ideal_template_higher + allowed_imprecision + && ideal_template_higher - allowed_imprecision <= higher_corner_point, + "ideal: {}, real: {}", + ideal_template_higher, + higher_corner_point + ); + assert!( + middle_point <= ideal_template_middle + allowed_imprecision + && ideal_template_middle - allowed_imprecision <= middle_point, + "ideal: {}, real: {}", + ideal_template_middle, + middle_point + ); + assert!( + lower_corner_point <= ideal_template_lower + allowed_imprecision + && ideal_template_lower - allowed_imprecision <= lower_corner_point, + "ideal: {}, real: {}", + ideal_template_lower, + lower_corner_point + ) + } + + #[test] + fn is_innocent_age_works_for_age_smaller_than_innocent_age() { + let payable_age = 999; + + let result = PayableThresholdsGaugeReal::default().is_innocent_age(payable_age, 1000); + + assert_eq!(result, true) + } + + #[test] + fn is_innocent_age_works_for_age_equal_to_innocent_age() { + let payable_age = 1000; + + let result = PayableThresholdsGaugeReal::default().is_innocent_age(payable_age, 1000); + + assert_eq!(result, true) + } + + #[test] + fn is_innocent_age_works_for_excessive_age() { + let payable_age = 1001; + + let result = PayableThresholdsGaugeReal::default().is_innocent_age(payable_age, 1000); + + assert_eq!(result, false) + } + + #[test] + fn is_innocent_balance_works_for_balance_smaller_than_innocent_balance() { + let payable_balance = 999; + + let result = + PayableThresholdsGaugeReal::default().is_innocent_balance(payable_balance, 1000); + + assert_eq!(result, true) + } + + #[test] + fn is_innocent_balance_works_for_balance_equal_to_innocent_balance() { + let payable_balance = 1000; + + let result = + PayableThresholdsGaugeReal::default().is_innocent_balance(payable_balance, 1000); + + assert_eq!(result, true) + } + + #[test] + fn is_innocent_balance_works_for_excessive_balance() { + let payable_balance = 1001; + + let result = + PayableThresholdsGaugeReal::default().is_innocent_balance(payable_balance, 1000); + + assert_eq!(result, false) + } +} diff --git a/node/src/accountant/scanners/pending_payable_scanner/mod.rs b/node/src/accountant/scanners/pending_payable_scanner/mod.rs new file mode 100644 index 0000000000..4fe12add15 --- /dev/null +++ b/node/src/accountant/scanners/pending_payable_scanner/mod.rs @@ -0,0 +1,2195 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. + +mod tx_receipt_interpreter; +pub mod utils; + +use crate::accountant::db_access_objects::failed_payable_dao::{ + FailedPayableDao, FailedTx, FailureRetrieveCondition, FailureStatus, +}; +use crate::accountant::db_access_objects::payable_dao::{PayableDao, PayableDaoError}; +use crate::accountant::db_access_objects::sent_payable_dao::{ + RetrieveCondition, SentPayableDao, SentPayableDaoError, SentTx, TxStatus, +}; +use crate::accountant::db_access_objects::utils::TxHash; +use crate::accountant::db_access_objects::Transaction; +use crate::accountant::scanners::pending_payable_scanner::tx_receipt_interpreter::TxReceiptInterpreter; +use crate::accountant::scanners::pending_payable_scanner::utils::{ + CurrentPendingPayables, DetectedConfirmations, DetectedFailures, FailedValidation, + FailedValidationByTable, PendingPayableCache, PendingPayableScanResult, PresortedTxFailure, + ReceiptScanReport, RecheckRequiringFailures, Retry, TxByTable, TxCaseToBeInterpreted, + TxHashByTable, UpdatableValidationStatus, +}; +use crate::accountant::scanners::{ + PrivateScanner, Scanner, ScannerCommon, StartScanError, StartableScanner, +}; +use crate::accountant::{ + join_with_commas, RequestTransactionReceipts, ResponseSkeleton, ScanForPendingPayables, + TxReceiptResult, TxReceiptsMessage, +}; +use crate::blockchain::blockchain_interface::data_structures::TxBlock; +use crate::sub_lib::accountant::{FinancialStatistics, PaymentThresholds}; +use crate::sub_lib::wallet::Wallet; +use crate::time_marking_methods; +use itertools::{Either, Itertools}; +use masq_lib::logger::Logger; +use masq_lib::messages::{ScanType, ToMessageBody, UiScanResponse}; +use masq_lib::simple_clock::{SimpleClock, SimpleClockReal}; +use masq_lib::ui_gateway::{MessageTarget, NodeToUiMessage}; +use std::cell::RefCell; +use std::collections::{BTreeSet, HashMap}; +use std::fmt::Display; +use std::rc::Rc; +use std::str::FromStr; +use std::time::SystemTime; +use thousands::Separable; +use web3::types::H256; + +pub(in crate::accountant::scanners) trait ExtendedPendingPayablePrivateScanner: + PrivateScanner< + ScanForPendingPayables, + RequestTransactionReceipts, + TxReceiptsMessage, + PendingPayableScanResult, + > + CachesEmptiableScanner +{ +} + +pub trait CachesEmptiableScanner { + fn empty_caches(&mut self, logger: &Logger); +} + +pub struct PendingPayableScanner { + pub common: ScannerCommon, + pub payable_dao: Box, + pub sent_payable_dao: Box, + pub failed_payable_dao: Box, + pub financial_statistics: Rc>, + pub current_sent_payables: Box>, + pub suspected_failed_payables: Box>, + pub clock: Box, +} + +impl ExtendedPendingPayablePrivateScanner for PendingPayableScanner {} + +impl + PrivateScanner< + ScanForPendingPayables, + RequestTransactionReceipts, + TxReceiptsMessage, + PendingPayableScanResult, + > for PendingPayableScanner +{ +} + +impl StartableScanner + for PendingPayableScanner +{ + fn start_scan( + &mut self, + _wallet: &Wallet, + timestamp: SystemTime, + response_skeleton_opt: Option, + logger: &Logger, + ) -> Result { + self.mark_as_started(timestamp); + + info!(logger, "Scanning for pending payable"); + + let tx_hashes = self.harvest_tables(logger).map_err(|e| { + self.mark_as_ended(logger); + e + })?; + + Ok(RequestTransactionReceipts { + tx_hashes, + response_skeleton_opt, + }) + } +} + +impl Scanner for PendingPayableScanner { + fn finish_scan( + &mut self, + message: TxReceiptsMessage, + logger: &Logger, + ) -> PendingPayableScanResult { + let response_skeleton_opt = message.response_skeleton_opt; + + let scan_report = self.interpret_tx_receipts(message, logger); + + let retry_opt = scan_report.requires_payments_retry(); + + debug!(logger, "Payment retry requirement: {:?}", retry_opt); + + self.process_txs_by_state(scan_report, logger); + + self.mark_as_ended(logger); + + Self::compose_scan_result(retry_opt, response_skeleton_opt) + } + + time_marking_methods!(PendingPayables); + + as_any_ref_in_trait_impl!(); + + as_any_mut_in_trait_impl!(); +} + +impl CachesEmptiableScanner for PendingPayableScanner { + fn empty_caches(&mut self, logger: &Logger) { + self.current_sent_payables.ensure_empty_cache(logger); + self.suspected_failed_payables.ensure_empty_cache(logger); + } +} + +impl PendingPayableScanner { + pub fn new( + payable_dao: Box, + sent_payable_dao: Box, + failed_payable_dao: Box, + payment_thresholds: Rc, + financial_statistics: Rc>, + ) -> Self { + Self { + common: ScannerCommon::new(payment_thresholds), + payable_dao, + sent_payable_dao, + failed_payable_dao, + financial_statistics, + current_sent_payables: Box::new(CurrentPendingPayables::default()), + suspected_failed_payables: Box::new(RecheckRequiringFailures::default()), + clock: Box::new(SimpleClockReal::default()), + } + } + + fn harvest_tables(&mut self, logger: &Logger) -> Result, StartScanError> { + debug!(logger, "Harvesting sent_payable and failed_payable tables"); + + let pending_tx_hashes_opt = self.harvest_pending_payables(); + let failure_hashes_opt = self.harvest_suspected_failures(); + + if Self::is_there_nothing_to_process( + pending_tx_hashes_opt.as_ref(), + failure_hashes_opt.as_ref(), + ) { + return Err(StartScanError::NothingToProcess); + } + + Self::log_records_for_receipt_check( + pending_tx_hashes_opt.as_ref(), + failure_hashes_opt.as_ref(), + logger, + ); + + Ok(Self::merge_hashes( + pending_tx_hashes_opt, + failure_hashes_opt, + )) + } + + fn harvest_pending_payables(&mut self) -> Option> { + let pending_txs = self + .sent_payable_dao + .retrieve_txs(Some(RetrieveCondition::IsPending)) + .into_iter() + .collect_vec(); + + if pending_txs.is_empty() { + return None; + } + + let pending_tx_hashes = Self::wrap_hashes(&pending_txs, TxHashByTable::SentPayable); + self.current_sent_payables.load_cache(pending_txs); + Some(pending_tx_hashes) + } + + fn harvest_suspected_failures(&mut self) -> Option> { + let failures = self + .failed_payable_dao + .retrieve_txs(Some(FailureRetrieveCondition::EveryRecheckRequiredRecord)) + .into_iter() + .collect_vec(); + + if failures.is_empty() { + return None; + } + + let failure_hashes = Self::wrap_hashes(&failures, TxHashByTable::FailedPayable); + self.suspected_failed_payables.load_cache(failures); + Some(failure_hashes) + } + + fn is_there_nothing_to_process( + pending_tx_hashes_opt: Option<&Vec>, + failure_hashes_opt: Option<&Vec>, + ) -> bool { + pending_tx_hashes_opt.is_none() && failure_hashes_opt.is_none() + } + + fn merge_hashes( + pending_tx_hashes_opt: Option>, + failure_hashes_opt: Option>, + ) -> Vec { + let failures = failure_hashes_opt.unwrap_or_default(); + pending_tx_hashes_opt + .unwrap_or_default() + .into_iter() + .chain(failures) + .collect() + } + + fn wrap_hashes( + records: &[Record], + wrap_the_hash: fn(TxHash) -> TxHashByTable, + ) -> Vec + where + Record: Transaction, + { + records + .iter() + .map(|record| wrap_the_hash(record.hash())) + .collect_vec() + } + + fn emptiness_check(&self, msg: &TxReceiptsMessage) { + if msg.results.is_empty() { + panic!( + "We should never receive an empty list of results. Even receipts that could \ + not be retrieved can be interpreted" + ) + } + } + + fn compose_scan_result( + retry_opt: Option, + response_skeleton_opt: Option, + ) -> PendingPayableScanResult { + if let Some(retry) = retry_opt { + match retry { + Retry::RetryPayments => { + PendingPayableScanResult::PaymentRetryRequired(response_skeleton_opt) + } + Retry::RetryTxStatusCheckOnly => { + let ui_msg_opt = + response_skeleton_opt.map(|response_skeleton| NodeToUiMessage { + target: MessageTarget::ClientId(response_skeleton.client_id), + body: UiScanResponse {}.tmb(response_skeleton.context_id), + }); + PendingPayableScanResult::ProcedureShouldBeRepeated(ui_msg_opt) + } + } + } else { + let ui_msg_opt = response_skeleton_opt.map(|response_skeleton| NodeToUiMessage { + target: MessageTarget::ClientId(response_skeleton.client_id), + body: UiScanResponse {}.tmb(response_skeleton.context_id), + }); + PendingPayableScanResult::NoPendingPayablesLeft(ui_msg_opt) + } + } + + fn interpret_tx_receipts( + &mut self, + msg: TxReceiptsMessage, + logger: &Logger, + ) -> ReceiptScanReport { + self.emptiness_check(&msg); + + debug!(logger, "Processing receipts for {} txs", msg.results.len()); + + let interpretable_data = self.prepare_cases_to_interpret(msg, logger); + TxReceiptInterpreter::default().compose_receipt_scan_report( + interpretable_data, + self, + logger, + ) + } + + fn prepare_cases_to_interpret( + &mut self, + msg: TxReceiptsMessage, + logger: &Logger, + ) -> Vec { + let init: Either, TxHashByTable> = Either::Left(vec![]); + let either = + msg.results + .into_iter() + .fold( + init, + |acc, (tx_hash_by_table, tx_receipt_result)| match acc { + Either::Left(cases) => { + self.resolve_real_query(cases, tx_receipt_result, tx_hash_by_table) + } + Either::Right(missing_entry) => Either::Right(missing_entry), + }, + ); + + let cases = match either { + Either::Left(cases) => cases, + Either::Right(missing_entry) => self.panic_dump(missing_entry), + }; + + self.current_sent_payables.ensure_empty_cache(logger); + self.suspected_failed_payables.ensure_empty_cache(logger); + + cases + } + + fn resolve_real_query( + &mut self, + mut cases: Vec, + receipt_result: TxReceiptResult, + looked_up_hash: TxHashByTable, + ) -> Either, TxHashByTable> { + match looked_up_hash { + TxHashByTable::SentPayable(tx_hash) => { + match self.current_sent_payables.get_record_by_hash(tx_hash) { + Some(sent_tx) => { + cases.push(TxCaseToBeInterpreted::new( + TxByTable::SentPayable(sent_tx), + receipt_result, + )); + Either::Left(cases) + } + None => Either::Right(looked_up_hash), + } + } + TxHashByTable::FailedPayable(tx_hash) => { + match self.suspected_failed_payables.get_record_by_hash(tx_hash) { + Some(failed_tx) => { + cases.push(TxCaseToBeInterpreted::new( + TxByTable::FailedPayable(failed_tx), + receipt_result, + )); + Either::Left(cases) + } + None => Either::Right(looked_up_hash), + } + } + } + } + + fn panic_dump(&mut self, missing_entry: TxHashByTable) -> ! { + fn rearrange(hashmap: HashMap) -> Vec { + hashmap + .into_iter() + .sorted_by_key(|(tx_hash, _)| *tx_hash) + .map(|(_, record)| record) + .collect_vec() + } + + panic!( + "Looking up '{:?}' in the cache, the record could not be found. Dumping \ + the remaining values. Pending payables: {:?}. Suspected failures: {:?}.", + missing_entry, + rearrange(self.current_sent_payables.dump_cache()), + rearrange(self.suspected_failed_payables.dump_cache()), + ) + } + + fn process_txs_by_state(&mut self, scan_report: ReceiptScanReport, logger: &Logger) { + self.handle_confirmed_transactions(scan_report.confirmations, logger); + self.handle_failed_transactions(scan_report.failures, logger); + } + + fn handle_confirmed_transactions( + &mut self, + confirmed_txs: DetectedConfirmations, + logger: &Logger, + ) { + self.handle_tx_failure_reclaims(confirmed_txs.reclaims, logger); + self.handle_standard_confirmations(confirmed_txs.standard_confirmations, logger); + } + + fn handle_tx_failure_reclaims(&mut self, reclaimed: Vec, logger: &Logger) { + if reclaimed.is_empty() { + debug!(logger, "No failure reclaim to process"); + + return; + } + + debug!(logger, "Processing failure reclaims: {:?}", reclaimed); + + let hashes_and_blocks = Self::collect_and_sort_hashes_and_blocks(&reclaimed); + + self.replace_sent_tx_records(&reclaimed, &hashes_and_blocks, logger); + + self.delete_failed_tx_records(&hashes_and_blocks, logger); + + self.add_to_the_total_of_paid_payable(&reclaimed, logger) + } + + fn isolate_hashes(reclaimed: &[(TxHash, TxBlock)]) -> BTreeSet { + reclaimed.iter().map(|(tx_hash, _)| *tx_hash).collect() + } + + fn collect_and_sort_hashes_and_blocks(sent_txs: &[SentTx]) -> Vec<(TxHash, TxBlock)> { + Self::collect_hashes_and_blocks(sent_txs) + .into_iter() + .sorted() + .collect_vec() + } + + fn collect_hashes_and_blocks(reclaimed: &[SentTx]) -> HashMap { + reclaimed + .iter() + .map(|reclaim| { + let tx_block = if let TxStatus::Confirmed { block_hash, block_number, .. } = + &reclaim.status + { + TxBlock{ + block_hash: H256::from_str(&block_hash[2..]).expect("Failed to construct hash from str"), + block_number: (*block_number).into() + } + } else { + panic!( + "Processing a reclaim for tx {:?} which isn't filled with the confirmation details", + reclaim.hash + ) + }; + (reclaim.hash, tx_block) + }) + .collect() + } + + fn replace_sent_tx_records( + &self, + sent_txs_to_reclaim: &[SentTx], + hashes_and_blocks: &[(TxHash, TxBlock)], + logger: &Logger, + ) { + let btreeset: BTreeSet = sent_txs_to_reclaim.iter().cloned().collect(); + + match self.sent_payable_dao.replace_records(&btreeset) { + Ok(_) => { + debug!(logger, "Replaced records for txs being reclaimed") + } + Err(e) => { + panic!( + "Unable to proceed in a reclaim as the replacement of sent tx records \ + {} failed due to: {:?}", + join_with_commas(hashes_and_blocks, |(tx_hash, _)| { + format!("{:?}", tx_hash) + }), + e + ) + } + } + } + + fn delete_failed_tx_records(&self, hashes_and_blocks: &[(TxHash, TxBlock)], logger: &Logger) { + let hashes = Self::isolate_hashes(hashes_and_blocks); + match self.failed_payable_dao.delete_records(&hashes) { + Ok(_) => { + info!( + logger, + "Reclaimed txs {} as confirmed on-chain", + join_with_commas(hashes_and_blocks, |(tx_hash, tx_block)| { + format!("{:?} (block {})", tx_hash, tx_block.block_number) + }) + ) + } + Err(e) => { + panic!( + "Unable to delete failed tx records {} to finish the reclaims due to: {:?}", + join_with_commas(hashes_and_blocks, |(tx_hash, _)| { + format!("{:?}", tx_hash) + }), + e + ) + } + } + } + + fn handle_standard_confirmations(&mut self, confirmed_txs: Vec, logger: &Logger) { + if confirmed_txs.is_empty() { + debug!(logger, "No standard tx confirmations to process"); + return; + } + + debug!( + logger, + "Processing {} standard tx confirmations", + confirmed_txs.len() + ); + trace!(logger, "{:?}", confirmed_txs); + + self.confirm_transactions(&confirmed_txs); + + self.update_tx_blocks(&confirmed_txs, logger); + + self.add_to_the_total_of_paid_payable(&confirmed_txs, logger); + } + + fn confirm_transactions(&self, confirmed_sent_txs: &[SentTx]) { + if let Err(e) = self.payable_dao.transactions_confirmed(confirmed_sent_txs) { + Self::transaction_confirmed_panic(confirmed_sent_txs, e); + } + } + + fn update_tx_blocks(&self, confirmed_sent_txs: &[SentTx], logger: &Logger) { + let tx_confirmations = Self::collect_hashes_and_blocks(confirmed_sent_txs); + + if let Err(e) = self.sent_payable_dao.confirm_txs(&tx_confirmations) { + Self::update_tx_blocks_panic(&tx_confirmations, e); + } else { + Self::log_tx_success(logger, &tx_confirmations); + } + } + + fn log_tx_success(logger: &Logger, tx_hashes_and_tx_blocks: &HashMap) { + logger.info(|| { + let pretty_pairs = tx_hashes_and_tx_blocks + .iter() + .sorted() + .map(|(hash, tx_confirmation)| { + format!("{:?} (block {})", hash, tx_confirmation.block_number) + }) + .join(", "); + match tx_hashes_and_tx_blocks.len() { + 1 => format!("Tx {} recorded in local ledger", pretty_pairs), + _ => format!("Txs {} recorded in local ledger", pretty_pairs), + } + }); + } + + fn transaction_confirmed_panic(confirmed_txs: &[SentTx], e: PayableDaoError) -> ! { + panic!( + "Unable to complete the tx confirmation by the adjustment of the payable accounts \ + {} due to: {:?}", + join_with_commas( + &confirmed_txs + .iter() + .map(|tx| tx.receiver_address) + .collect_vec(), + |wallet| format!("{:?}", wallet) + ), + e + ) + } + fn update_tx_blocks_panic( + tx_hashes_and_tx_blocks: &HashMap, + e: SentPayableDaoError, + ) -> ! { + panic!( + "Unable to update sent payable records {} by their tx blocks due to: {:?}", + join_with_commas( + &tx_hashes_and_tx_blocks.keys().sorted().collect_vec(), + |tx_hash| format!("{:?}", tx_hash) + ), + e + ) + } + + fn add_to_the_total_of_paid_payable(&mut self, confirmed_payments: &[SentTx], logger: &Logger) { + let to_be_added: u128 = confirmed_payments + .iter() + .map(|sent_tx| sent_tx.amount_minor) + .sum(); + + let total_paid_payable = &mut self + .financial_statistics + .borrow_mut() + .total_paid_payable_wei; + + *total_paid_payable += to_be_added; + + debug!( + logger, + "The total paid payables increased by {} to {} wei", + to_be_added.separate_with_commas(), + total_paid_payable.separate_with_commas() + ); + } + + fn handle_failed_transactions(&self, failures: DetectedFailures, logger: &Logger) { + self.handle_tx_failures(failures.tx_failures, logger); + self.handle_rpc_failures(failures.tx_receipt_rpc_failures, logger); + } + + fn handle_tx_failures(&self, failures: Vec, logger: &Logger) { + #[derive(Default)] + struct GroupedFailures { + new_failures: Vec, + rechecks_completed: Vec, + } + + let grouped_failures = + failures + .into_iter() + .fold(GroupedFailures::default(), |mut acc, failure| { + match failure { + PresortedTxFailure::NewEntry(failed_tx) => { + acc.new_failures.push(failed_tx); + } + PresortedTxFailure::RecheckCompleted(tx_hash) => { + acc.rechecks_completed.push(tx_hash); + } + } + acc + }); + + self.add_new_failures(grouped_failures.new_failures, logger); + self.finalize_suspected_failures(grouped_failures.rechecks_completed, logger); + } + + fn add_new_failures(&self, new_failures: Vec, logger: &Logger) { + fn prepare_btreeset(failures: &[FailedTx]) -> BTreeSet { + failures.iter().map(|failure| failure.hash).collect() + } + fn log_procedure_finished(logger: &Logger, new_failures: &[FailedTx]) { + info!( + logger, + "Failed txs {} were processed in the db", + join_with_commas(new_failures, |failure| format!("{:?}", failure.hash)) + ) + } + + if new_failures.is_empty() { + debug!(logger, "No reverted txs to process"); + return; + } + + debug!(logger, "Processing reverted txs {:?}", new_failures); + + let new_failures_btree_set: BTreeSet = new_failures.iter().cloned().collect(); + + if let Err(e) = self + .failed_payable_dao + .insert_new_records(&new_failures_btree_set) + { + panic!( + "Unable to persist failed txs {} due to: {:?}", + join_with_commas(&new_failures, |failure| format!("{:?}", failure.hash)), + e + ) + } + + match self + .sent_payable_dao + .delete_records(&prepare_btreeset(&new_failures)) + { + Ok(_) => { + log_procedure_finished(logger, &new_failures); + } + Err(e) => { + panic!( + "Unable to purge sent payable records for failed txs {} due to: {:?}", + join_with_commas(&new_failures, |failure| format!("{:?}", failure.hash)), + e + ) + } + } + } + + fn finalize_suspected_failures(&self, rechecks_completed: Vec, logger: &Logger) { + fn prepare_hashmap(rechecks_completed: &[TxHash]) -> HashMap { + rechecks_completed + .iter() + .map(|tx_hash| (*tx_hash, FailureStatus::Concluded)) + .collect() + } + + if rechecks_completed.is_empty() { + debug!(logger, "No recheck-requiring failures to finalize"); + return; + } + + debug!( + logger, + "Finalizing {} double-checked failures", + rechecks_completed.len() + ); + trace!(logger, "{:?}", rechecks_completed); + + match self + .failed_payable_dao + .update_statuses(&prepare_hashmap(&rechecks_completed)) + { + Ok(_) => { + debug!( + logger, + "Concluded failures that had required rechecks: {}.", + join_with_commas(&rechecks_completed, |tx_hash| format!("{:?}", tx_hash)) + ); + } + Err(e) => { + panic!( + "Unable to conclude rechecks for failed txs {} due to: {:?}", + join_with_commas(&rechecks_completed, |tx_hash| format!("{:?}", tx_hash)), + e + ) + } + } + } + + fn handle_rpc_failures(&self, failures: Vec, logger: &Logger) { + if failures.is_empty() { + return; + } + + let (sent_payable_failures, failed_payable_failures): ( + Vec>, + Vec>, + ) = failures.into_iter().partition_map(|failure| match failure { + FailedValidationByTable::SentPayable(failed_validation) => { + Either::Left(failed_validation) + } + FailedValidationByTable::FailedPayable(failed_validation) => { + Either::Right(failed_validation) + } + }); + + self.update_validation_status_for_sent_txs(sent_payable_failures, logger); + + self.update_validation_status_for_failed_txs(failed_payable_failures, logger); + } + + fn update_validation_status_for_sent_txs( + &self, + sent_payable_failures: Vec>, + logger: &Logger, + ) { + if !sent_payable_failures.is_empty() { + let updatable = + Self::prepare_statuses_for_update(&sent_payable_failures, &*self.clock, logger); + if !updatable.is_empty() { + match self.sent_payable_dao.update_statuses(&updatable) { + Ok(_) => { + info!( + logger, + "Pending-tx statuses were processed in the db for validation failure \ + of txs {}", + join_with_commas(&sent_payable_failures, |failure| { + format!("{:?}", failure.tx_hash) + }) + ) + } + Err(e) => { + panic!( + "Unable to update pending-tx statuses for validation failures '{:?}' \ + due to: {:?}", + sent_payable_failures, e + ) + } + } + } + } + } + + fn update_validation_status_for_failed_txs( + &self, + failed_txs_validation_failures: Vec>, + logger: &Logger, + ) { + if !failed_txs_validation_failures.is_empty() { + let updatable = Self::prepare_statuses_for_update( + &failed_txs_validation_failures, + &*self.clock, + logger, + ); + if !updatable.is_empty() { + match self.failed_payable_dao.update_statuses(&updatable) { + Ok(_) => { + info!( + logger, + "Failed-tx statuses were processed in the db for validation failure \ + of txs {}", + join_with_commas(&failed_txs_validation_failures, |failure| { + format!("{:?}", failure.tx_hash) + }) + ) + } + Err(e) => { + panic!( + "Unable to update failed-tx statuses for validation failures '{:?}' \ + due to: {:?}", + failed_txs_validation_failures, e + ) + } + } + } + } + } + + fn prepare_statuses_for_update( + failures: &[FailedValidation], + clock: &dyn SimpleClock, + logger: &Logger, + ) -> HashMap { + failures + .iter() + .flat_map(|failure| { + failure + .new_status(clock) + .map(|tx_status| (failure.tx_hash, tx_status)) + .or_else(|| { + debug!( + logger, + "{}", + PendingPayableScanner::status_not_updatable_log_msg( + &failure.current_status + ) + ); + None + }) + }) + .collect() + } + + fn status_not_updatable_log_msg(status: &dyn Display) -> String { + format!( + "Handling a validation failure, but the status {} cannot be updated.", + status + ) + } + + fn log_records_for_receipt_check( + pending_tx_hashes_opt: Option<&Vec>, + failure_hashes_opt: Option<&Vec>, + logger: &Logger, + ) { + fn resolve_optional_vec(vec_opt: Option<&Vec>) -> usize { + vec_opt.map(|hashes| hashes.len()).unwrap_or_default() + } + + debug!( + logger, + "Found {} pending payables and {} suspected failures to process", + resolve_optional_vec(pending_tx_hashes_opt), + resolve_optional_vec(failure_hashes_opt) + ); + } +} + +#[cfg(test)] +mod tests { + use crate::accountant::db_access_objects::failed_payable_dao::{ + FailedPayableDaoError, FailureStatus, + }; + use crate::accountant::db_access_objects::payable_dao::PayableDaoError; + use crate::accountant::db_access_objects::sent_payable_dao::{ + Detection, SentPayableDaoError, TxStatus, + }; + use crate::accountant::db_access_objects::test_utils::{make_failed_tx, make_sent_tx}; + use crate::accountant::scanners::pending_payable_scanner::utils::{ + CurrentPendingPayables, DetectedConfirmations, DetectedFailures, FailedValidation, + FailedValidationByTable, PendingPayableCache, PendingPayableScanResult, PresortedTxFailure, + RecheckRequiringFailures, Retry, TxHashByTable, + }; + use crate::accountant::scanners::pending_payable_scanner::PendingPayableScanner; + use crate::accountant::scanners::test_utils::PendingPayableCacheMock; + use crate::accountant::scanners::{Scanner, StartScanError, StartableScanner}; + use crate::accountant::test_utils::{ + make_transaction_block, FailedPayableDaoMock, PayableDaoMock, PendingPayableScannerBuilder, + SentPayableDaoMock, + }; + use crate::accountant::{RequestTransactionReceipts, ResponseSkeleton, TxReceiptsMessage}; + use crate::blockchain::blockchain_interface::data_structures::{ + StatusReadFromReceiptCheck, TxBlock, + }; + use crate::blockchain::errors::rpc_errors::{ + AppRpcError, AppRpcErrorKind, LocalError, LocalErrorKind, RemoteErrorKind, + }; + use crate::blockchain::errors::validation_status::{PreviousAttempts, ValidationStatus}; + use crate::blockchain::errors::BlockchainErrorKind; + use crate::blockchain::test_utils::{make_block_hash, make_tx_hash}; + use crate::test_utils::{make_paying_wallet, make_wallet}; + use itertools::Itertools; + use masq_lib::logger::Logger; + use masq_lib::messages::{ToMessageBody, UiScanResponse}; + use masq_lib::simple_clock::SimpleClockReal; + use masq_lib::test_utils::logging::{init_test_logging, TestLogHandler}; + use masq_lib::test_utils::simple_clock::SimpleClockMock; + use masq_lib::ui_gateway::{MessageTarget, NodeToUiMessage}; + use std::collections::{BTreeSet, HashMap}; + use std::ops::Sub; + use std::panic::{catch_unwind, AssertUnwindSafe}; + use std::sync::{Arc, Mutex}; + use std::time::{Duration, SystemTime}; + + #[test] + fn start_scan_fills_in_caches_and_returns_msg() { + let sent_tx_1 = make_sent_tx(456); + let sent_tx_hash_1 = sent_tx_1.hash; + let sent_tx_2 = make_sent_tx(789); + let sent_tx_hash_2 = sent_tx_2.hash; + let failed_tx_1 = make_failed_tx(567); + let failed_tx_hash_1 = failed_tx_1.hash; + let failed_tx_2 = make_failed_tx(890); + let failed_tx_hash_2 = failed_tx_2.hash; + let sent_payable_dao = SentPayableDaoMock::new() + .retrieve_txs_result(btreeset![sent_tx_1.clone(), sent_tx_2.clone()]); + let failed_payable_dao = FailedPayableDaoMock::new() + .retrieve_txs_result(btreeset![failed_tx_1.clone(), failed_tx_2.clone()]); + let mut subject = PendingPayableScannerBuilder::new() + .sent_payable_dao(sent_payable_dao) + .failed_payable_dao(failed_payable_dao) + .sent_payable_cache(Box::new(CurrentPendingPayables::default())) + .failed_payable_cache(Box::new(RecheckRequiringFailures::default())) + .build(); + let logger = Logger::new("start_scan_fills_in_caches_and_returns_msg"); + let pending_payable_cache_before = subject.current_sent_payables.dump_cache(); + let failed_payable_cache_before = subject.suspected_failed_payables.dump_cache(); + + let result = subject.start_scan(&make_wallet("blah"), SystemTime::now(), None, &logger); + + assert_eq!( + result, + Ok(RequestTransactionReceipts { + tx_hashes: vec![ + TxHashByTable::SentPayable(sent_tx_hash_1), + TxHashByTable::SentPayable(sent_tx_hash_2), + TxHashByTable::FailedPayable(failed_tx_hash_1), + TxHashByTable::FailedPayable(failed_tx_hash_2) + ], + response_skeleton_opt: None + }) + ); + assert!( + pending_payable_cache_before.is_empty(), + "Should have been empty but {:?}", + pending_payable_cache_before + ); + assert!( + failed_payable_cache_before.is_empty(), + "Should have been empty but {:?}", + failed_payable_cache_before + ); + let pending_payable_cache_after = subject.current_sent_payables.dump_cache(); + let failed_payable_cache_after = subject.suspected_failed_payables.dump_cache(); + assert_eq!( + pending_payable_cache_after, + hashmap!(sent_tx_hash_1 => sent_tx_1, sent_tx_hash_2 => sent_tx_2) + ); + assert_eq!( + failed_payable_cache_after, + hashmap!(failed_tx_hash_1 => failed_tx_1, failed_tx_hash_2 => failed_tx_2) + ); + } + + #[test] + fn finish_scan_operates_caches_and_clears_them_after_use() { + let get_record_by_hash_failed_payable_cache_params_arc = Arc::new(Mutex::new(vec![])); + let get_record_by_hash_sent_payable_cache_params_arc = Arc::new(Mutex::new(vec![])); + let ensure_empty_cache_failed_payable_params_arc = Arc::new(Mutex::new(vec![])); + let ensure_empty_cache_sent_payable_params_arc = Arc::new(Mutex::new(vec![])); + let sent_tx_1 = make_sent_tx(456); + let sent_tx_hash_1 = sent_tx_1.hash; + let sent_tx_2 = make_sent_tx(789); + let sent_tx_hash_2 = sent_tx_2.hash; + let failed_tx_1 = make_failed_tx(567); + let failed_tx_hash_1 = failed_tx_1.hash; + let failed_tx_2 = make_failed_tx(890); + let failed_tx_hash_2 = failed_tx_2.hash; + let payable_dao = PayableDaoMock::new().transactions_confirmed_result(Ok(())); + let sent_payable_dao = SentPayableDaoMock::new() + .confirm_tx_result(Ok(())) + .replace_records_result(Ok(())) + .delete_records_result(Ok(())); + let failed_payable_dao = FailedPayableDaoMock::new() + .insert_new_records_result(Ok(())) + .delete_records_result(Ok(())); + let sent_payable_cache = PendingPayableCacheMock::default() + .get_record_by_hash_params(&get_record_by_hash_sent_payable_cache_params_arc) + .get_record_by_hash_result(Some(sent_tx_1.clone())) + .get_record_by_hash_result(Some(sent_tx_2)) + .ensure_empty_cache_params(&ensure_empty_cache_sent_payable_params_arc); + let failed_payable_cache = PendingPayableCacheMock::default() + .get_record_by_hash_params(&get_record_by_hash_failed_payable_cache_params_arc) + .get_record_by_hash_result(Some(failed_tx_1)) + .get_record_by_hash_result(Some(failed_tx_2)) + .ensure_empty_cache_params(&ensure_empty_cache_failed_payable_params_arc); + let mut subject = PendingPayableScannerBuilder::new() + .payable_dao(payable_dao) + .sent_payable_dao(sent_payable_dao) + .failed_payable_dao(failed_payable_dao) + .sent_payable_cache(Box::new(sent_payable_cache)) + .failed_payable_cache(Box::new(failed_payable_cache)) + .build(); + let logger = Logger::new("test"); + let confirmed_tx_block_sent_tx = make_transaction_block(901); + let confirmed_tx_block_failed_tx = make_transaction_block(902); + let msg = TxReceiptsMessage { + results: btreemap![ + TxHashByTable::SentPayable(sent_tx_hash_1) => Ok(StatusReadFromReceiptCheck::Pending), + TxHashByTable::SentPayable(sent_tx_hash_2) => Ok(StatusReadFromReceiptCheck::Succeeded(confirmed_tx_block_sent_tx)), + TxHashByTable::FailedPayable(failed_tx_hash_1) => Err(AppRpcError::Local(LocalError::Internal)), + TxHashByTable::FailedPayable(failed_tx_hash_2) => Ok(StatusReadFromReceiptCheck::Succeeded(confirmed_tx_block_failed_tx)) + ], + response_skeleton_opt: None, + }; + + let result = subject.finish_scan(msg, &logger); + + assert_eq!(result, PendingPayableScanResult::PaymentRetryRequired(None)); + let get_record_by_hash_failed_payable_cache_params = + get_record_by_hash_failed_payable_cache_params_arc + .lock() + .unwrap(); + assert_eq!( + *get_record_by_hash_failed_payable_cache_params, + vec![failed_tx_hash_1, failed_tx_hash_2] + ); + let get_record_by_hash_sent_payable_cache_params = + get_record_by_hash_sent_payable_cache_params_arc + .lock() + .unwrap(); + assert_eq!( + *get_record_by_hash_sent_payable_cache_params, + vec![sent_tx_hash_1, sent_tx_hash_2] + ); + let pending_payable_ensure_empty_cache_params = + ensure_empty_cache_sent_payable_params_arc.lock().unwrap(); + assert_eq!(*pending_payable_ensure_empty_cache_params, vec![()]); + let failed_payable_ensure_empty_cache_params = + ensure_empty_cache_failed_payable_params_arc.lock().unwrap(); + assert_eq!(*failed_payable_ensure_empty_cache_params, vec![()]); + } + + #[test] + fn finish_scan_with_missing_records_inside_caches_noticed_on_missing_sent_tx() { + let sent_tx_hash_1 = make_tx_hash(0x890); + let mut sent_tx_1 = make_sent_tx(456); + sent_tx_1.hash = sent_tx_hash_1; + let sent_tx_hash_2 = make_tx_hash(0x123); + let failed_tx_hash_1 = make_tx_hash(0x987); + let mut failed_tx_1 = make_failed_tx(567); + failed_tx_1.hash = failed_tx_hash_1; + let failed_tx_hash_2 = make_tx_hash(0x789); + let mut failed_tx_2 = make_failed_tx(890); + failed_tx_2.hash = failed_tx_hash_2; + let mut pending_payable_cache = CurrentPendingPayables::default(); + pending_payable_cache.load_cache(vec![sent_tx_1]); + let mut failed_payable_cache = RecheckRequiringFailures::default(); + failed_payable_cache.load_cache(vec![failed_tx_1, failed_tx_2]); + let mut subject = PendingPayableScannerBuilder::new().build(); + subject.current_sent_payables = Box::new(pending_payable_cache); + subject.suspected_failed_payables = Box::new(failed_payable_cache); + let logger = Logger::new("test"); + let msg = TxReceiptsMessage { + results: btreemap![TxHashByTable::SentPayable(sent_tx_hash_1) => Ok( + StatusReadFromReceiptCheck::Pending), + TxHashByTable::SentPayable(sent_tx_hash_2) => Ok(StatusReadFromReceiptCheck::Succeeded(make_transaction_block(444))), + TxHashByTable::FailedPayable(failed_tx_hash_1) => Err(AppRpcError::Local(LocalError::Internal)), + TxHashByTable::FailedPayable(failed_tx_hash_2) => Ok(StatusReadFromReceiptCheck::Succeeded(make_transaction_block(555))), + ], + response_skeleton_opt: None, + }; + + let panic = + catch_unwind(AssertUnwindSafe(|| subject.finish_scan(msg, &logger))).unwrap_err(); + + let panic_msg = panic.downcast_ref::().unwrap(); + let expected = "Looking up 'SentPayable(0x00000000000000000000000000000000000000000000\ + 00000000000000000123)' in the cache, the record could not be found. Dumping the remaining \ + values. Pending payables: [SentTx { hash: 0x0000000000000000000000000000000000000000000000\ + 000000000000000890, receiver_address: 0x0000000000000000000558000000000558000000, \ + amount_minor: 43237380096, timestamp: 29942784, gas_price_minor: 94818816, nonce: 456, \ + status: Pending(Waiting) }]. Suspected failures: []."; + assert_eq!(panic_msg, expected); + } + + #[test] + fn finish_scan_with_missing_records_inside_caches_noticed_on_missing_failed_tx() { + let sent_tx_1 = make_sent_tx(456); + let sent_tx_hash_1 = sent_tx_1.hash; + let sent_tx_2 = make_sent_tx(789); + let sent_tx_hash_2 = sent_tx_2.hash; + let failed_tx_1 = make_failed_tx(567); + let failed_tx_hash_1 = failed_tx_1.hash; + let failed_tx_hash_2 = make_tx_hash(987); + let mut pending_payable_cache = CurrentPendingPayables::default(); + pending_payable_cache.load_cache(vec![sent_tx_1, sent_tx_2]); + let mut failed_payable_cache = RecheckRequiringFailures::default(); + failed_payable_cache.load_cache(vec![failed_tx_1]); + let mut subject = PendingPayableScannerBuilder::new().build(); + subject.current_sent_payables = Box::new(pending_payable_cache); + subject.suspected_failed_payables = Box::new(failed_payable_cache); + let logger = Logger::new("test"); + let msg = TxReceiptsMessage { + results: btreemap![TxHashByTable::SentPayable(sent_tx_hash_1) => Ok(StatusReadFromReceiptCheck::Pending), + TxHashByTable::SentPayable(sent_tx_hash_2) => Ok(StatusReadFromReceiptCheck::Succeeded(make_transaction_block(444))), + TxHashByTable::FailedPayable(failed_tx_hash_1) => Err(AppRpcError::Local(LocalError::Internal)), + TxHashByTable::FailedPayable(failed_tx_hash_2) => Ok(StatusReadFromReceiptCheck::Succeeded(make_transaction_block(555))), + ], + response_skeleton_opt: None, + }; + + let panic = + catch_unwind(AssertUnwindSafe(|| subject.finish_scan(msg, &logger))).unwrap_err(); + + let panic_msg = panic.downcast_ref::().unwrap(); + let expected = "Looking up 'FailedPayable(0x000000000000000000000000000000000000000000\ + 00000000000000000003db)' in the cache, the record could not be found. Dumping the remaining \ + values. Pending payables: [SentTx { hash: 0x000000000000000000000000000000000000000000000000\ + 00000000000001c8, receiver_address: 0x0000000000000000000558000000000558000000, amount_minor: \ + 43237380096, timestamp: 29942784, gas_price_minor: 94818816, nonce: 456, status: \ + Pending(Waiting) }, SentTx { hash: 0x0000000000000000000000000000000000000000000000000000000\ + 000000315, receiver_address: 0x000000000000000000093f00000000093f000000, amount_minor: \ + 387532395441, timestamp: 89643024, gas_price_minor: 491169069, nonce: 789, status: \ + Pending(Waiting) }]. Suspected failures: []."; + assert_eq!(panic_msg, expected); + } + + #[test] + fn compose_scan_result_all_payments_resolved_in_automatic_mode() { + let result = PendingPayableScanner::compose_scan_result(None, None); + + assert_eq!( + result, + PendingPayableScanResult::NoPendingPayablesLeft(None) + ) + } + + #[test] + fn compose_scan_result_all_payments_resolved_in_manual_mode() { + let result = PendingPayableScanner::compose_scan_result( + None, + Some(ResponseSkeleton { + client_id: 2222, + context_id: 22, + }), + ); + + assert_eq!( + result, + PendingPayableScanResult::NoPendingPayablesLeft(Some(NodeToUiMessage { + target: MessageTarget::ClientId(2222), + body: UiScanResponse {}.tmb(22) + })) + ) + } + + #[test] + fn compose_scan_result_payments_retry_required_in_automatic_mode() { + let result = PendingPayableScanner::compose_scan_result(Some(Retry::RetryPayments), None); + + assert_eq!(result, PendingPayableScanResult::PaymentRetryRequired(None)) + } + + #[test] + fn compose_scan_result_payments_retry_required_in_manual_mode() { + let result = PendingPayableScanner::compose_scan_result( + Some(Retry::RetryPayments), + Some(ResponseSkeleton { + client_id: 1234, + context_id: 21, + }), + ); + + assert_eq!( + result, + PendingPayableScanResult::PaymentRetryRequired(Some(ResponseSkeleton { + client_id: 1234, + context_id: 21 + })) + ) + } + + #[test] + fn compose_scan_result_only_scan_procedure_should_be_repeated_in_automatic_mode() { + let result = + PendingPayableScanner::compose_scan_result(Some(Retry::RetryTxStatusCheckOnly), None); + + assert_eq!( + result, + PendingPayableScanResult::ProcedureShouldBeRepeated(None) + ) + } + + #[test] + fn compose_scan_result_only_scan_procedure_should_be_repeated_in_manual_mode() { + let result = PendingPayableScanner::compose_scan_result( + Some(Retry::RetryTxStatusCheckOnly), + Some(ResponseSkeleton { + client_id: 4455, + context_id: 12, + }), + ); + + assert_eq!( + result, + PendingPayableScanResult::ProcedureShouldBeRepeated(Some(NodeToUiMessage { + target: MessageTarget::ClientId(4455), + body: UiScanResponse {}.tmb(12) + })) + ) + } + + #[test] + fn throws_an_error_when_no_records_to_process_were_found() { + let now = SystemTime::now(); + let consuming_wallet = make_paying_wallet(b"consuming_wallet"); + let sent_payable_dao = SentPayableDaoMock::new().retrieve_txs_result(btreeset![]); + let failed_payable_dao = FailedPayableDaoMock::new().retrieve_txs_result(btreeset![]); + let mut subject = PendingPayableScannerBuilder::new() + .failed_payable_dao(failed_payable_dao) + .sent_payable_dao(sent_payable_dao) + .build(); + + let result = subject.start_scan(&consuming_wallet, now, None, &Logger::new("test")); + + let is_scan_running = subject.scan_started_at().is_some(); + assert_eq!(result, Err(StartScanError::NothingToProcess)); + assert_eq!(is_scan_running, false); + } + + #[test] + fn handle_failed_transactions_does_nothing_if_no_failure_detected() { + let subject = PendingPayableScannerBuilder::new().build(); + let detected_failures = DetectedFailures { + tx_failures: vec![], + tx_receipt_rpc_failures: vec![], + }; + + subject.handle_failed_transactions(detected_failures, &Logger::new("test")) + + // Mocked pending payable DAO without prepared results didn't panic which means none of its + // methods was used in this test + } + + #[test] + fn handle_failed_transactions_can_process_standard_tx_failures() { + init_test_logging(); + let test_name = "handle_failed_transactions_can_process_standard_tx_failures"; + let insert_new_records_params_arc = Arc::new(Mutex::new(vec![])); + let delete_records_params_arc = Arc::new(Mutex::new(vec![])); + let failed_payable_dao = FailedPayableDaoMock::default() + .insert_new_records_params(&insert_new_records_params_arc) + .insert_new_records_result(Ok(())); + let sent_payable_dao = SentPayableDaoMock::default() + .delete_records_params(&delete_records_params_arc) + .delete_records_result(Ok(())); + let subject = PendingPayableScannerBuilder::new() + .sent_payable_dao(sent_payable_dao) + .failed_payable_dao(failed_payable_dao) + .build(); + let hash_1 = make_tx_hash(0x321); + let hash_2 = make_tx_hash(0x654); + let mut failed_tx_1 = make_failed_tx(123); + failed_tx_1.hash = hash_1; + let mut failed_tx_2 = make_failed_tx(456); + failed_tx_2.hash = hash_2; + let detected_failures = DetectedFailures { + tx_failures: vec![ + PresortedTxFailure::NewEntry(failed_tx_1.clone()), + PresortedTxFailure::NewEntry(failed_tx_2.clone()), + ], + tx_receipt_rpc_failures: vec![], + }; + + subject.handle_failed_transactions(detected_failures, &Logger::new(test_name)); + + let insert_new_records_params = insert_new_records_params_arc.lock().unwrap(); + assert_eq!( + *insert_new_records_params, + vec![btreeset![failed_tx_1, failed_tx_2]] + ); + let delete_records_params = delete_records_params_arc.lock().unwrap(); + assert_eq!(*delete_records_params, vec![btreeset![hash_1, hash_2]]); + TestLogHandler::new().exists_log_containing(&format!( + "INFO: {test_name}: Failed txs 0x0000000000000000000000000000000000000000000000000000000000000321, \ + 0x0000000000000000000000000000000000000000000000000000000000000654 were processed in the db" + )); + } + + #[test] + fn handle_failed_transactions_can_process_receipt_retrieval_rpc_failures() { + init_test_logging(); + let test_name = "handle_failed_transactions_can_process_receipt_retrieval_rpc_failures"; + let retrieve_failed_txs_params_arc = Arc::new(Mutex::new(vec![])); + let update_statuses_sent_tx_params_arc = Arc::new(Mutex::new(vec![])); + let retrieve_sent_txs_params_arc = Arc::new(Mutex::new(vec![])); + let update_statuses_failed_tx_params_arc = Arc::new(Mutex::new(vec![])); + let hash_1 = make_tx_hash(0x321); + let hash_2 = make_tx_hash(0x654); + let hash_3 = make_tx_hash(0x987); + let timestamp_a = SystemTime::now(); + let timestamp_b = SystemTime::now().sub(Duration::from_secs(1)); + let timestamp_c = SystemTime::now().sub(Duration::from_secs(2)); + let timestamp_d = SystemTime::now().sub(Duration::from_secs(3)); + let mut failed_tx_1 = make_failed_tx(123); + failed_tx_1.hash = hash_1; + failed_tx_1.status = FailureStatus::RecheckRequired(ValidationStatus::Waiting); + let mut failed_tx_2 = make_failed_tx(456); + failed_tx_2.hash = hash_2; + failed_tx_2.status = + FailureStatus::RecheckRequired(ValidationStatus::Reattempting(PreviousAttempts::new( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Internal)), + &SimpleClockMock::default().now_result(timestamp_a), + ))); + let failed_payable_dao = FailedPayableDaoMock::default() + .retrieve_txs_params(&retrieve_failed_txs_params_arc) + .retrieve_txs_result(btreeset![failed_tx_1, failed_tx_2]) + .update_statuses_params(&update_statuses_failed_tx_params_arc) + .update_statuses_result(Ok(())); + let mut sent_tx = make_sent_tx(789); + sent_tx.hash = hash_3; + sent_tx.status = TxStatus::Pending(ValidationStatus::Waiting); + let sent_payable_dao = SentPayableDaoMock::default() + .retrieve_txs_params(&retrieve_sent_txs_params_arc) + .retrieve_txs_result(btreeset![sent_tx.clone()]) + .update_statuses_params(&update_statuses_sent_tx_params_arc) + .update_statuses_result(Ok(())); + let validation_failure_clock = SimpleClockMock::default() + .now_result(timestamp_a) + .now_result(timestamp_b) + .now_result(timestamp_c); + let subject = PendingPayableScannerBuilder::new() + .sent_payable_dao(sent_payable_dao) + .failed_payable_dao(failed_payable_dao) + .validation_failure_clock(Box::new(validation_failure_clock)) + .build(); + let detected_failures = DetectedFailures { + tx_failures: vec![], + tx_receipt_rpc_failures: vec![ + FailedValidationByTable::FailedPayable(FailedValidation::new( + hash_1, + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote( + RemoteErrorKind::Unreachable, + )), + FailureStatus::RecheckRequired(ValidationStatus::Waiting), + )), + FailedValidationByTable::FailedPayable(FailedValidation::new( + hash_2, + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Internal)), + FailureStatus::RecheckRequired(ValidationStatus::Reattempting( + PreviousAttempts::new( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local( + LocalErrorKind::Internal, + )), + &SimpleClockMock::default().now_result(timestamp_d), + ), + )), + )), + FailedValidationByTable::SentPayable(FailedValidation::new( + hash_3, + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote( + RemoteErrorKind::InvalidResponse, + )), + TxStatus::Pending(ValidationStatus::Waiting), + )), + ], + }; + + subject.handle_failed_transactions(detected_failures, &Logger::new(test_name)); + + let update_statuses_sent_tx_params = update_statuses_sent_tx_params_arc.lock().unwrap(); + assert_eq!( + *update_statuses_sent_tx_params, + vec![ + hashmap![hash_3 => TxStatus::Pending(ValidationStatus::Reattempting (PreviousAttempts::new(BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote(RemoteErrorKind::InvalidResponse)), &SimpleClockMock::default().now_result(timestamp_a))))] + ] + ); + let mut update_statuses_failed_tx_params = + update_statuses_failed_tx_params_arc.lock().unwrap(); + let actual_params = update_statuses_failed_tx_params + .remove(0) + .into_iter() + .sorted_by_key(|(key, _)| *key) + .collect::>(); + let expected_params = hashmap!( + hash_1 => FailureStatus::RecheckRequired( + ValidationStatus::Reattempting(PreviousAttempts::new(BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote(RemoteErrorKind::Unreachable)), &SimpleClockMock::default().now_result(timestamp_b))) + ), + hash_2 => FailureStatus::RecheckRequired( + ValidationStatus::Reattempting(PreviousAttempts::new(BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Internal)), &SimpleClockMock::default().now_result(timestamp_d)).add_attempt(BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Internal)), &SimpleClockReal::default()))) + ).into_iter().sorted_by_key(|(key,_)|*key).collect::>(); + assert_eq!(actual_params, expected_params); + assert!( + update_statuses_failed_tx_params.is_empty(), + "Should be empty but: {:?}", + update_statuses_sent_tx_params + ); + let test_log_handler = TestLogHandler::new(); + test_log_handler.exists_log_containing(&format!( + "INFO: {test_name}: Pending-tx statuses were processed in the db for validation failure \ + of txs 0x0000000000000000000000000000000000000000000000000000000000000987" + )); + test_log_handler.exists_log_containing(&format!( + "INFO: {test_name}: Failed-tx statuses were processed in the db for validation failure \ + of txs 0x0000000000000000000000000000000000000000000000000000000000000321, \ + 0x0000000000000000000000000000000000000000000000000000000000000654" + )); + let expectedly_missing_log_msg_fragment = "Handling a validation failure, but the status"; + let otherwise_possible_log_msg = + PendingPayableScanner::status_not_updatable_log_msg(&"Something"); + assert!( + otherwise_possible_log_msg.contains(expectedly_missing_log_msg_fragment), + "We expected to select a true log fragment '{}', but it is not included in '{}'", + expectedly_missing_log_msg_fragment, + otherwise_possible_log_msg + ); + test_log_handler.exists_no_log_containing(&format!( + "DEBUG: {test_name}: {}", + expectedly_missing_log_msg_fragment + )) + } + + #[test] + fn handle_rpc_failures_when_requested_for_a_status_which_cannot_be_updated() { + init_test_logging(); + let test_name = "handle_rpc_failures_when_requested_for_a_status_which_cannot_be_updated"; + let hash_1 = make_tx_hash(0x321); + let hash_2 = make_tx_hash(0x654); + let subject = PendingPayableScannerBuilder::new().build(); + + subject.handle_rpc_failures( + vec![ + FailedValidationByTable::FailedPayable(FailedValidation::new( + hash_1, + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote( + RemoteErrorKind::Unreachable, + )), + FailureStatus::RetryRequired, + )), + FailedValidationByTable::SentPayable(FailedValidation::new( + hash_2, + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote( + RemoteErrorKind::InvalidResponse, + )), + TxStatus::Confirmed { + block_hash: "abc".to_string(), + block_number: 0, + detection: Detection::Normal, + }, + )), + ], + &Logger::new(test_name), + ); + + let test_log_handler = TestLogHandler::new(); + test_log_handler.exists_no_log_containing(&format!("INFO: {test_name}: ")); + test_log_handler.exists_log_containing(&format!( + "DEBUG: {test_name}: Handling a validation failure, but the status \ + {{\"Confirmed\":{{\"block_hash\":\"abc\",\"block_number\":0,\"detection\":\"Normal\"}}}} \ + cannot be updated.", + )); + test_log_handler.exists_log_containing(&format!( + "DEBUG: {test_name}: Handling a validation failure, but the status \"RetryRequired\" \ + cannot be updated." + )); + // It didn't panic, which means none of the DAO methods was called because the DAOs are + // mocked in this test + } + + #[test] + #[should_panic( + expected = "Unable to update pending-tx statuses for validation failures '[FailedValidation \ + { tx_hash: 0x00000000000000000000000000000000000000000000000000000000000001c8, validation_failure: \ + AppRpc(Local(Internal)), current_status: Pending(Waiting) }]' due to: InvalidInput(\"blah\")" + )] + fn update_validation_status_for_sent_txs_panics_on_update_statuses() { + let failed_validation = FailedValidation::new( + make_tx_hash(456), + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Internal)), + TxStatus::Pending(ValidationStatus::Waiting), + ); + let sent_payable_dao = SentPayableDaoMock::default() + .update_statuses_result(Err(SentPayableDaoError::InvalidInput("blah".to_string()))); + let subject = PendingPayableScannerBuilder::new() + .sent_payable_dao(sent_payable_dao) + .validation_failure_clock(Box::new(SimpleClockReal::default())) + .build(); + + let _ = subject + .update_validation_status_for_sent_txs(vec![failed_validation], &Logger::new("test")); + } + + #[test] + #[should_panic( + expected = "Unable to update failed-tx statuses for validation failures '[FailedValidation \ + { tx_hash: 0x00000000000000000000000000000000000000000000000000000000000001c8, validation_failure: \ + AppRpc(Local(Internal)), current_status: RecheckRequired(Waiting) }]' due to: InvalidInput(\"blah\")" + )] + fn update_validation_status_for_failed_txs_panics_on_update_statuses() { + let failed_validation = FailedValidation::new( + make_tx_hash(456), + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Internal)), + FailureStatus::RecheckRequired(ValidationStatus::Waiting), + ); + let failed_payable_dao = FailedPayableDaoMock::default() + .update_statuses_result(Err(FailedPayableDaoError::InvalidInput("blah".to_string()))); + let subject = PendingPayableScannerBuilder::new() + .failed_payable_dao(failed_payable_dao) + .validation_failure_clock(Box::new(SimpleClockReal::default())) + .build(); + + let _ = subject + .update_validation_status_for_failed_txs(vec![failed_validation], &Logger::new("test")); + } + + #[test] + fn handle_failed_transactions_can_process_mixed_failures() { + let insert_new_records_params_arc = Arc::new(Mutex::new(vec![])); + let delete_records_params_arc = Arc::new(Mutex::new(vec![])); + let update_status_params_arc = Arc::new(Mutex::new(vec![])); + let tx_hash_1 = make_tx_hash(0x321); + let tx_hash_2 = make_tx_hash(0x654); + let timestamp = SystemTime::now(); + let mut failed_tx_1 = make_failed_tx(123); + failed_tx_1.hash = tx_hash_1; + let mut failed_tx_2 = make_failed_tx(456); + failed_tx_2.hash = tx_hash_2; + let failed_payable_dao = FailedPayableDaoMock::default() + .insert_new_records_params(&insert_new_records_params_arc) + .insert_new_records_result(Ok(())); + let sent_payable_dao = SentPayableDaoMock::default() + .update_statuses_params(&update_status_params_arc) + .update_statuses_result(Ok(())) + .delete_records_params(&delete_records_params_arc) + .delete_records_result(Ok(())); + let subject = PendingPayableScannerBuilder::new() + .sent_payable_dao(sent_payable_dao) + .failed_payable_dao(failed_payable_dao) + .validation_failure_clock(Box::new(SimpleClockMock::default().now_result(timestamp))) + .build(); + let detected_failures = DetectedFailures { + tx_failures: vec![PresortedTxFailure::NewEntry(failed_tx_1.clone())], + tx_receipt_rpc_failures: vec![FailedValidationByTable::SentPayable( + FailedValidation::new( + tx_hash_2, + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Internal)), + TxStatus::Pending(ValidationStatus::Waiting), + ), + )], + }; + + subject.handle_failed_transactions(detected_failures, &Logger::new("test")); + + let insert_new_records_params = insert_new_records_params_arc.lock().unwrap(); + assert_eq!( + *insert_new_records_params, + vec![BTreeSet::from([failed_tx_1])] + ); + let delete_records_params = delete_records_params_arc.lock().unwrap(); + assert_eq!(*delete_records_params, vec![btreeset![tx_hash_1]]); + let update_statuses_params = update_status_params_arc.lock().unwrap(); + assert_eq!( + *update_statuses_params, + vec![ + hashmap!(tx_hash_2 => TxStatus::Pending(ValidationStatus::Reattempting(PreviousAttempts::new(BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Internal)), &SimpleClockMock::default().now_result(timestamp))))) + ] + ); + } + + #[test] + #[should_panic(expected = "Unable to persist failed txs \ + 0x000000000000000000000000000000000000000000000000000000000000014d, \ + 0x00000000000000000000000000000000000000000000000000000000000001bc due to: NoChange")] + fn handle_failed_transactions_panics_when_it_fails_to_insert_failed_tx_record() { + let failed_payable_dao = FailedPayableDaoMock::default() + .insert_new_records_result(Err(FailedPayableDaoError::NoChange)); + let subject = PendingPayableScannerBuilder::new() + .failed_payable_dao(failed_payable_dao) + .build(); + let hash_1 = make_tx_hash(0x14d); + let hash_2 = make_tx_hash(0x1bc); + let mut failed_tx_1 = make_failed_tx(789); + failed_tx_1.hash = hash_1; + let mut failed_tx_2 = make_failed_tx(456); + failed_tx_2.hash = hash_2; + let detected_failures = DetectedFailures { + tx_failures: vec![ + PresortedTxFailure::NewEntry(failed_tx_1), + PresortedTxFailure::NewEntry(failed_tx_2), + ], + tx_receipt_rpc_failures: vec![], + }; + + subject.handle_failed_transactions(detected_failures, &Logger::new("test")); + } + + #[test] + #[should_panic(expected = "Unable to purge sent payable records for failed txs \ + 0x000000000000000000000000000000000000000000000000000000000000014d, \ + 0x00000000000000000000000000000000000000000000000000000000000001bc due to: \ + InvalidInput(\"Booga\")")] + fn handle_failed_transactions_panics_when_it_fails_to_delete_obsolete_sent_tx_records() { + let failed_payable_dao = FailedPayableDaoMock::default().insert_new_records_result(Ok(())); + let sent_payable_dao = SentPayableDaoMock::default() + .delete_records_result(Err(SentPayableDaoError::InvalidInput("Booga".to_string()))); + let subject = PendingPayableScannerBuilder::new() + .failed_payable_dao(failed_payable_dao) + .sent_payable_dao(sent_payable_dao) + .build(); + let hash_1 = make_tx_hash(0x14d); + let hash_2 = make_tx_hash(0x1bc); + let mut failed_tx_1 = make_failed_tx(789); + failed_tx_1.hash = hash_1; + let mut failed_tx_2 = make_failed_tx(456); + failed_tx_2.hash = hash_2; + let detected_failures = DetectedFailures { + tx_failures: vec![ + PresortedTxFailure::NewEntry(failed_tx_1), + PresortedTxFailure::NewEntry(failed_tx_2), + ], + tx_receipt_rpc_failures: vec![], + }; + + subject.handle_failed_transactions(detected_failures, &Logger::new("test")); + } + + #[test] + fn handle_failed_transactions_can_conclude_rechecked_failures() { + let update_status_params_arc = Arc::new(Mutex::new(vec![])); + let tx_hash_1 = make_tx_hash(0x321); + let tx_hash_2 = make_tx_hash(0x654); + let mut failed_tx_1 = make_failed_tx(123); + failed_tx_1.hash = tx_hash_1; + let mut failed_tx_2 = make_failed_tx(456); + failed_tx_2.hash = tx_hash_2; + let failed_payable_dao = FailedPayableDaoMock::default() + .update_statuses_params(&update_status_params_arc) + .update_statuses_result(Ok(())); + let subject = PendingPayableScannerBuilder::new() + .failed_payable_dao(failed_payable_dao) + .build(); + let detected_failures = DetectedFailures { + tx_failures: vec![ + PresortedTxFailure::RecheckCompleted(tx_hash_1), + PresortedTxFailure::RecheckCompleted(tx_hash_2), + ], + tx_receipt_rpc_failures: vec![], + }; + + subject.handle_failed_transactions(detected_failures, &Logger::new("test")); + + let update_status_params = update_status_params_arc.lock().unwrap(); + assert_eq!( + *update_status_params, + vec![ + hashmap!(tx_hash_1 => FailureStatus::Concluded, tx_hash_2 => FailureStatus::Concluded), + ] + ); + } + + #[test] + #[should_panic(expected = "Unable to conclude rechecks for failed txs \ + 0x0000000000000000000000000000000000000000000000000000000000000321, \ + 0x0000000000000000000000000000000000000000000000000000000000000654 due to: \ + InvalidInput(\"Booga\")")] + fn concluding_rechecks_fails_on_updating_statuses() { + let tx_hash_1 = make_tx_hash(0x321); + let tx_hash_2 = make_tx_hash(0x654); + let failed_payable_dao = FailedPayableDaoMock::default().update_statuses_result(Err( + FailedPayableDaoError::InvalidInput("Booga".to_string()), + )); + let subject = PendingPayableScannerBuilder::new() + .failed_payable_dao(failed_payable_dao) + .build(); + let detected_failures = DetectedFailures { + tx_failures: vec![ + PresortedTxFailure::RecheckCompleted(tx_hash_1), + PresortedTxFailure::RecheckCompleted(tx_hash_2), + ], + tx_receipt_rpc_failures: vec![], + }; + + subject.handle_failed_transactions(detected_failures, &Logger::new("test")); + } + + #[test] + fn handle_confirmed_transactions_does_nothing_if_no_confirmation_found_on_the_blockchain() { + let mut subject = PendingPayableScannerBuilder::new().build(); + + subject + .handle_confirmed_transactions(DetectedConfirmations::default(), &Logger::new("test")) + + // Mocked payable DAO without prepared results didn't panic, which means none of its methods + // was used in this test + } + + #[test] + fn handles_failure_reclaims_alone() { + init_test_logging(); + let test_name = "handles_failure_reclaims_alone"; + let replace_records_params_arc = Arc::new(Mutex::new(vec![])); + let delete_records_params_arc = Arc::new(Mutex::new(vec![])); + let sent_payable_dao = SentPayableDaoMock::default() + .replace_records_params(&replace_records_params_arc) + .replace_records_result(Ok(())); + let failed_payable_dao = FailedPayableDaoMock::default() + .delete_records_params(&delete_records_params_arc) + .delete_records_result(Ok(())); + let logger = Logger::new(test_name); + let mut subject = PendingPayableScannerBuilder::new() + .sent_payable_dao(sent_payable_dao) + .failed_payable_dao(failed_payable_dao) + .build(); + let tx_hash_1 = make_tx_hash(0x123); + let tx_hash_2 = make_tx_hash(0x567); + let mut sent_tx_1 = make_sent_tx(123_123); + sent_tx_1.hash = tx_hash_1; + let tx_block_1 = TxBlock { + block_hash: make_block_hash(45), + block_number: 4_578_989_878_u64.into(), + }; + sent_tx_1.status = TxStatus::Confirmed { + block_hash: format!("{:?}", tx_block_1.block_hash), + block_number: tx_block_1.block_number.as_u64(), + detection: Detection::Normal, + }; + let mut sent_tx_2 = make_sent_tx(987_987); + sent_tx_2.hash = tx_hash_2; + let tx_block_2 = TxBlock { + block_hash: make_block_hash(67), + block_number: 6_789_898_789_u64.into(), + }; + sent_tx_2.status = TxStatus::Confirmed { + block_hash: format!("{:?}", make_block_hash(123)), + block_number: tx_block_2.block_number.as_u64(), + detection: Detection::Normal, + }; + + subject.handle_confirmed_transactions( + DetectedConfirmations { + standard_confirmations: vec![], + reclaims: vec![sent_tx_1.clone(), sent_tx_2.clone()], + }, + &logger, + ); + + let replace_records_params = replace_records_params_arc.lock().unwrap(); + assert_eq!( + *replace_records_params, + vec![btreeset![sent_tx_1, sent_tx_2]] + ); + let delete_records_params = delete_records_params_arc.lock().unwrap(); + // assert_eq!(*delete_records_params, vec![hashset![tx_hash_1, tx_hash_2]]); + assert_eq!( + *delete_records_params, + vec![BTreeSet::from([tx_hash_1, tx_hash_2])] + ); + let log_handler = TestLogHandler::new(); + log_handler.exists_log_containing(&format!( + "INFO: {test_name}: Reclaimed txs 0x0000000000000000000000000000000000000000000000000000000000000123 \ + (block 4578989878), 0x0000000000000000000000000000000000000000000000000000000000000567 \ + (block 6789898789) as confirmed on-chain", + )); + } + + #[test] + #[should_panic( + expected = "Unable to proceed in a reclaim as the replacement of sent tx records \ + 0x0000000000000000000000000000000000000000000000000000000000000123, \ + 0x0000000000000000000000000000000000000000000000000000000000000567 \ + failed due to: NoChange" + )] + fn failure_reclaim_fails_on_replace_sent_tx_record() { + let sent_payable_dao = SentPayableDaoMock::default() + .replace_records_result(Err(SentPayableDaoError::NoChange)); + let mut subject = PendingPayableScannerBuilder::new() + .sent_payable_dao(sent_payable_dao) + .build(); + let tx_hash_1 = make_tx_hash(0x123); + let tx_hash_2 = make_tx_hash(0x567); + let mut sent_tx_1 = make_sent_tx(123_123); + sent_tx_1.hash = tx_hash_1; + let tx_block_1 = TxBlock { + block_hash: make_block_hash(45), + block_number: 4_578_989_878_u64.into(), + }; + sent_tx_1.status = TxStatus::Confirmed { + block_hash: format!("{:?}", tx_block_1.block_hash), + block_number: tx_block_1.block_number.as_u64(), + detection: Detection::Normal, + }; + let mut sent_tx_2 = make_sent_tx(987_987); + sent_tx_2.hash = tx_hash_2; + let tx_block_2 = TxBlock { + block_hash: make_block_hash(67), + block_number: 6_789_898_789_u64.into(), + }; + sent_tx_2.status = TxStatus::Confirmed { + block_hash: format!("{:?}", make_block_hash(123)), + block_number: tx_block_2.block_number.as_u64(), + detection: Detection::Normal, + }; + + subject.handle_confirmed_transactions( + DetectedConfirmations { + standard_confirmations: vec![], + reclaims: vec![sent_tx_1.clone(), sent_tx_2.clone()], + }, + &Logger::new("test"), + ); + } + + #[test] + #[should_panic(expected = "Unable to delete failed tx records \ + 0x0000000000000000000000000000000000000000000000000000000000000123, \ + 0x0000000000000000000000000000000000000000000000000000000000000567 \ + to finish the reclaims due to: EmptyInput")] + fn failure_reclaim_fails_on_delete_failed_tx_record() { + let sent_payable_dao = SentPayableDaoMock::default().replace_records_result(Ok(())); + let failed_payable_dao = FailedPayableDaoMock::default() + .delete_records_result(Err(FailedPayableDaoError::EmptyInput)); + let mut subject = PendingPayableScannerBuilder::new() + .sent_payable_dao(sent_payable_dao) + .failed_payable_dao(failed_payable_dao) + .build(); + let tx_hash_1 = make_tx_hash(0x123); + let tx_hash_2 = make_tx_hash(0x567); + let mut sent_tx_1 = make_sent_tx(123_123); + sent_tx_1.hash = tx_hash_1; + let tx_block_1 = TxBlock { + block_hash: make_block_hash(45), + block_number: 4_578_989_878_u64.into(), + }; + sent_tx_1.status = TxStatus::Confirmed { + block_hash: format!("{:?}", tx_block_1.block_hash), + block_number: tx_block_1.block_number.as_u64(), + detection: Detection::Normal, + }; + let mut sent_tx_2 = make_sent_tx(987_987); + sent_tx_2.hash = tx_hash_2; + let tx_block_2 = TxBlock { + block_hash: make_block_hash(67), + block_number: 6_789_898_789_u64.into(), + }; + sent_tx_2.status = TxStatus::Confirmed { + block_hash: format!("{:?}", make_block_hash(123)), + block_number: tx_block_2.block_number.as_u64(), + detection: Detection::Normal, + }; + + subject.handle_confirmed_transactions( + DetectedConfirmations { + standard_confirmations: vec![], + reclaims: vec![sent_tx_1.clone(), sent_tx_2.clone()], + }, + &Logger::new("test"), + ); + } + + #[test] + #[should_panic( + expected = "Processing a reclaim for tx 0x0000000000000000000000000000000000000000000000000\ + 000000000000123 which isn't filled with the confirmation details" + )] + fn handle_failure_reclaim_meets_a_record_without_confirmation_details() { + let mut subject = PendingPayableScannerBuilder::new().build(); + let tx_hash = make_tx_hash(0x123); + let mut sent_tx = make_sent_tx(123_123); + sent_tx.hash = tx_hash; + // Here, it should be confirmed already in this status + sent_tx.status = TxStatus::Pending(ValidationStatus::Waiting); + + subject.handle_confirmed_transactions( + DetectedConfirmations { + standard_confirmations: vec![], + reclaims: vec![sent_tx.clone()], + }, + &Logger::new("test"), + ); + } + + #[test] + fn handles_standard_confirmations_alone() { + init_test_logging(); + let test_name = "handles_standard_confirmations_alone"; + let transactions_confirmed_params_arc = Arc::new(Mutex::new(vec![])); + let confirm_tx_params_arc = Arc::new(Mutex::new(vec![])); + let payable_dao = PayableDaoMock::default() + .transactions_confirmed_params(&transactions_confirmed_params_arc) + .transactions_confirmed_result(Ok(())); + let sent_payable_dao = SentPayableDaoMock::default() + .confirm_tx_params(&confirm_tx_params_arc) + .confirm_tx_result(Ok(())); + let logger = Logger::new(test_name); + let mut subject = PendingPayableScannerBuilder::new() + .payable_dao(payable_dao) + .sent_payable_dao(sent_payable_dao) + .build(); + let tx_hash_1 = make_tx_hash(0x123); + let tx_hash_2 = make_tx_hash(0x567); + let mut sent_tx_1 = make_sent_tx(123_123); + sent_tx_1.hash = tx_hash_1; + let tx_block_1 = TxBlock { + block_hash: make_block_hash(45), + block_number: 4_578_989_878_u64.into(), + }; + sent_tx_1.status = TxStatus::Confirmed { + block_hash: format!("{:?}", tx_block_1.block_hash), + block_number: tx_block_1.block_number.as_u64(), + detection: Detection::Normal, + }; + let mut sent_tx_2 = make_sent_tx(987_987); + sent_tx_2.hash = tx_hash_2; + let tx_block_2 = TxBlock { + block_hash: make_block_hash(67), + block_number: 6_789_898_789_u64.into(), + }; + sent_tx_2.status = TxStatus::Confirmed { + block_hash: format!("{:?}", tx_block_2.block_hash), + block_number: tx_block_2.block_number.as_u64(), + detection: Detection::Normal, + }; + + subject.handle_confirmed_transactions( + DetectedConfirmations { + standard_confirmations: vec![sent_tx_1.clone(), sent_tx_2.clone()], + reclaims: vec![], + }, + &logger, + ); + + let transactions_confirmed_params = transactions_confirmed_params_arc.lock().unwrap(); + assert_eq!( + *transactions_confirmed_params, + vec![vec![sent_tx_1, sent_tx_2]] + ); + let confirm_tx_params = confirm_tx_params_arc.lock().unwrap(); + assert_eq!( + *confirm_tx_params, + vec![hashmap![tx_hash_1 => tx_block_1, tx_hash_2 => tx_block_2]] + ); + let log_handler = TestLogHandler::new(); + log_handler.exists_log_containing(&format!( + "INFO: {test_name}: Txs 0x0000000000000000000000000000000000000000000000000000000000000123 \ + (block 4578989878), 0x0000000000000000000000000000000000000000000000000000000000000567 \ + (block 6789898789) recorded in local ledger", + )); + } + + #[test] + fn mixed_tx_confirmations_work() { + init_test_logging(); + let test_name = "mixed_tx_confirmations_work"; + let transactions_confirmed_params_arc = Arc::new(Mutex::new(vec![])); + let confirm_tx_params_arc = Arc::new(Mutex::new(vec![])); + let replace_records_params_arc = Arc::new(Mutex::new(vec![])); + let delete_records_params_arc = Arc::new(Mutex::new(vec![])); + let payable_dao = PayableDaoMock::default() + .transactions_confirmed_params(&transactions_confirmed_params_arc) + .transactions_confirmed_result(Ok(())); + let sent_payable_dao = SentPayableDaoMock::default() + .confirm_tx_params(&confirm_tx_params_arc) + .confirm_tx_result(Ok(())) + .replace_records_params(&replace_records_params_arc) + .replace_records_result(Ok(())); + let failed_payable_dao = FailedPayableDaoMock::default() + .delete_records_params(&delete_records_params_arc) + .delete_records_result(Ok(())); + let logger = Logger::new(test_name); + let mut subject = PendingPayableScannerBuilder::new() + .payable_dao(payable_dao) + .sent_payable_dao(sent_payable_dao) + .failed_payable_dao(failed_payable_dao) + .build(); + let tx_hash_1 = make_tx_hash(0x123); + let tx_hash_2 = make_tx_hash(0x913); + let mut sent_tx_1 = make_sent_tx(123_123); + sent_tx_1.hash = tx_hash_1; + let tx_block_1 = TxBlock { + block_hash: make_block_hash(45), + block_number: 4_578_989_878_u64.into(), + }; + sent_tx_1.status = TxStatus::Confirmed { + block_hash: format!("{:?}", tx_block_1.block_hash), + block_number: tx_block_1.block_number.as_u64(), + detection: Detection::Normal, + }; + let mut sent_tx_2 = make_sent_tx(567_567); + sent_tx_2.hash = tx_hash_2; + let tx_block_3 = TxBlock { + block_hash: make_block_hash(78), + block_number: 7_898_989_878_u64.into(), + }; + sent_tx_2.status = TxStatus::Confirmed { + block_hash: format!("{:?}", tx_block_3.block_hash), + block_number: tx_block_3.block_number.as_u64(), + detection: Detection::Reclaim, + }; + + subject.handle_confirmed_transactions( + DetectedConfirmations { + standard_confirmations: vec![sent_tx_1.clone()], + reclaims: vec![sent_tx_2.clone()], + }, + &logger, + ); + + let transactions_confirmed_params = transactions_confirmed_params_arc.lock().unwrap(); + assert_eq!(*transactions_confirmed_params, vec![vec![sent_tx_1]]); + let confirm_tx_params = confirm_tx_params_arc.lock().unwrap(); + assert_eq!(*confirm_tx_params, vec![hashmap![tx_hash_1 => tx_block_1]]); + let replace_records_params = replace_records_params_arc.lock().unwrap(); + assert_eq!(*replace_records_params, vec![btreeset![sent_tx_2]]); + let delete_records_params = delete_records_params_arc.lock().unwrap(); + // assert_eq!(*delete_records_params, vec![hashset![tx_hash_2]]); + assert_eq!(*delete_records_params, vec![BTreeSet::from([tx_hash_2])]); + let log_handler = TestLogHandler::new(); + log_handler.exists_log_containing(&format!( + "INFO: {test_name}: Reclaimed txs \ + 0x0000000000000000000000000000000000000000000000000000000000000913 (block 7898989878) \ + as confirmed on-chain", + )); + log_handler.exists_log_containing(&format!( + "INFO: {test_name}: Tx 0x0000000000000000000000000000000000000000000000000000000000000123 \ + (block 4578989878) recorded in local ledger", + )); + } + + #[test] + #[should_panic( + expected = "Unable to update sent payable records 0x000000000000000000000000000000000000000\ + 000000000000000000000021a, 0x0000000000000000000000000000000000000000000000000000000000000315 \ + by their tx blocks due to: SqlExecutionFailed(\"The database manager is \ + a funny guy, he's fooling around with us\")" + )] + fn handle_confirmed_transactions_panics_while_updating_sent_payable_records_with_the_tx_blocks() + { + let payable_dao = PayableDaoMock::new().transactions_confirmed_result(Ok(())); + let sent_payable_dao = SentPayableDaoMock::default().confirm_tx_result(Err( + SentPayableDaoError::SqlExecutionFailed( + "The database manager is a funny guy, he's fooling around with us".to_string(), + ), + )); + let mut subject = PendingPayableScannerBuilder::new() + .payable_dao(payable_dao) + .sent_payable_dao(sent_payable_dao) + .build(); + let mut sent_tx_1 = make_sent_tx(456); + let block = make_transaction_block(678); + sent_tx_1.hash = make_tx_hash(0x315); + sent_tx_1.status = TxStatus::Confirmed { + block_hash: format!("{:?}", block.block_hash), + block_number: block.block_number.as_u64(), + detection: Detection::Normal, + }; + let mut sent_tx_2 = make_sent_tx(789); + sent_tx_2.hash = make_tx_hash(0x21a); + sent_tx_2.status = TxStatus::Confirmed { + block_hash: format!("{:?}", block.block_hash), + block_number: block.block_number.as_u64(), + detection: Detection::Normal, + }; + + subject.handle_confirmed_transactions( + DetectedConfirmations { + standard_confirmations: vec![sent_tx_1, sent_tx_2], + reclaims: vec![], + }, + &Logger::new("test"), + ); + } + + #[test] + #[should_panic( + expected = "Unable to complete the tx confirmation by the adjustment of the payable accounts \ + 0x0000000000000000000558000000000558000000 due to: \ + RusqliteError(\"record change not successful\")" + )] + fn handle_confirmed_transactions_panics_on_unchecking_payable_table() { + let hash = make_tx_hash(315); + let payable_dao = PayableDaoMock::new().transactions_confirmed_result(Err( + PayableDaoError::RusqliteError("record change not successful".to_string()), + )); + let mut subject = PendingPayableScannerBuilder::new() + .payable_dao(payable_dao) + .build(); + let mut sent_tx = make_sent_tx(456); + sent_tx.hash = hash; + + subject.handle_confirmed_transactions( + DetectedConfirmations { + standard_confirmations: vec![sent_tx], + reclaims: vec![], + }, + &Logger::new("test"), + ); + } + + #[test] + fn log_tx_success_is_agnostic_to_singular_or_plural_form() { + init_test_logging(); + let test_name = "log_tx_success_is_agnostic_to_singular_or_plural_form"; + let plural_case_name = format!("{}_testing_plural_case", test_name); + let singular_case_name = format!("{}_testing_singular_case", test_name); + let logger_plural = Logger::new(&plural_case_name); + let logger_singular = Logger::new(&singular_case_name); + let tx_hash_1 = make_tx_hash(0x123); + let tx_hash_2 = make_tx_hash(0x567); + let mut tx_block_1 = make_transaction_block(456); + tx_block_1.block_number = 1_234_501_u64.into(); + let mut tx_block_2 = make_transaction_block(789); + tx_block_2.block_number = 1_234_502_u64.into(); + let mut tx_hashes_and_blocks = hashmap!(tx_hash_1 => tx_block_1, tx_hash_2 => tx_block_2); + + PendingPayableScanner::log_tx_success(&logger_plural, &tx_hashes_and_blocks); + + tx_hashes_and_blocks.remove(&tx_hash_2); + + PendingPayableScanner::log_tx_success(&logger_singular, &tx_hashes_and_blocks); + + let log_handler = TestLogHandler::new(); + log_handler.exists_log_containing(&format!( + "INFO: {plural_case_name}: Txs 0x0000000000000000000000000000000000000000000000000000000\ + 000000123 (block 1234501), 0x00000000000000000000000000000000000000000000000000000000000\ + 00567 (block 1234502) recorded in local ledger", + )); + log_handler.exists_log_containing(&format!( + "INFO: {singular_case_name}: Tx 0x000000000000000000000000000000000000000000000000000000\ + 0000000123 (block 1234501) recorded in local ledger", + )); + } + + #[test] + fn total_paid_payable_rises_with_each_bill_paid() { + init_test_logging(); + let test_name = "total_paid_payable_rises_with_each_bill_paid"; + let mut sent_tx_1 = make_sent_tx(456); + sent_tx_1.amount_minor = 5478; + sent_tx_1.status = TxStatus::Confirmed { + block_hash: format!("{:?}", make_block_hash(123)), + block_number: 89898, + detection: Detection::Normal, + }; + let mut sent_tx_2 = make_sent_tx(789); + sent_tx_2.amount_minor = 3344; + sent_tx_2.status = TxStatus::Confirmed { + block_hash: format!("{:?}", make_block_hash(234)), + block_number: 66312, + detection: Detection::Normal, + }; + let mut sent_tx_3 = make_sent_tx(789); + sent_tx_3.amount_minor = 6543; + sent_tx_3.status = TxStatus::Confirmed { + block_hash: format!("{:?}", make_block_hash(321)), + block_number: 67676, + detection: Detection::Reclaim, + }; + let payable_dao = PayableDaoMock::default().transactions_confirmed_result(Ok(())); + let sent_payable_dao = SentPayableDaoMock::default() + .confirm_tx_result(Ok(())) + .replace_records_result(Ok(())); + let failed_payable_dao = FailedPayableDaoMock::default().delete_records_result(Ok(())); + let mut subject = PendingPayableScannerBuilder::new() + .payable_dao(payable_dao) + .failed_payable_dao(failed_payable_dao) + .sent_payable_dao(sent_payable_dao) + .build(); + let mut financial_statistics = subject.financial_statistics.borrow().clone(); + financial_statistics.total_paid_payable_wei += 1111; + subject.financial_statistics.replace(financial_statistics); + + subject.handle_confirmed_transactions( + DetectedConfirmations { + standard_confirmations: vec![sent_tx_1, sent_tx_2], + reclaims: vec![sent_tx_3], + }, + &Logger::new(test_name), + ); + + let total_paid_payable = subject.financial_statistics.borrow().total_paid_payable_wei; + assert_eq!(total_paid_payable, 1111 + 5478 + 3344 + 6543); + TestLogHandler::new().assert_logs_contain_in_order(vec![ + &format!("DEBUG: {test_name}: The total paid payables increased by 6,543 to 7,654 wei"), + &format!( + "DEBUG: {test_name}: The total paid payables increased by 8,822 to 16,476 wei" + ), + ]); + } +} diff --git a/node/src/accountant/scanners/pending_payable_scanner/tx_receipt_interpreter.rs b/node/src/accountant/scanners/pending_payable_scanner/tx_receipt_interpreter.rs new file mode 100644 index 0000000000..d04d458b45 --- /dev/null +++ b/node/src/accountant/scanners/pending_payable_scanner/tx_receipt_interpreter.rs @@ -0,0 +1,703 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. + +use crate::accountant::db_access_objects::failed_payable_dao::{FailedTx, FailureReason}; +use crate::accountant::db_access_objects::sent_payable_dao::{ + Detection, RetrieveCondition, SentPayableDao, SentTx, TxStatus, +}; +use crate::accountant::db_access_objects::utils::from_unix_timestamp; +use crate::accountant::scanners::pending_payable_scanner::utils::{ + ConfirmationType, FailedValidation, FailedValidationByTable, ReceiptScanReport, TxByTable, + TxCaseToBeInterpreted, TxHashByTable, +}; +use crate::accountant::scanners::pending_payable_scanner::PendingPayableScanner; +use crate::blockchain::blockchain_interface::data_structures::{ + StatusReadFromReceiptCheck, TxBlock, +}; +use crate::blockchain::errors::internal_errors::InternalErrorKind; +use crate::blockchain::errors::rpc_errors::AppRpcError; +use crate::blockchain::errors::BlockchainErrorKind; +use itertools::Either; +use masq_lib::logger::Logger; +use std::time::SystemTime; +use thousands::Separable; + +#[derive(Default)] +pub struct TxReceiptInterpreter {} + +impl TxReceiptInterpreter { + pub fn compose_receipt_scan_report( + &self, + tx_cases: Vec, + pending_payable_scanner: &PendingPayableScanner, + logger: &Logger, + ) -> ReceiptScanReport { + debug!(logger, "Composing receipt scan report"); + let scan_report = ReceiptScanReport::default(); + tx_cases + .into_iter() + .fold(scan_report, |scan_report_so_far, tx_case| { + match tx_case.tx_receipt_result { + Ok(tx_status) => match tx_status { + StatusReadFromReceiptCheck::Succeeded(tx_block) => { + Self::handle_tx_confirmation( + scan_report_so_far, + tx_case.tx_by_table, + tx_block, + logger, + ) + } + StatusReadFromReceiptCheck::Reverted => Self::handle_reverted_tx( + scan_report_so_far, + tx_case.tx_by_table, + logger, + ), + StatusReadFromReceiptCheck::Pending => Self::handle_still_pending_tx( + scan_report_so_far, + tx_case.tx_by_table, + &*pending_payable_scanner.sent_payable_dao, + logger, + ), + }, + Err(e) => { + Self::handle_rpc_failure(scan_report_so_far, tx_case.tx_by_table, e, logger) + } + } + }) + } + + fn handle_still_pending_tx( + mut scan_report: ReceiptScanReport, + tx: TxByTable, + sent_payable_dao: &dyn SentPayableDao, + logger: &Logger, + ) -> ReceiptScanReport { + match tx { + TxByTable::SentPayable(sent_tx) => { + info!( + logger, + "Tx {:?} not confirmed within {} ms. Will resubmit with higher gas price", + sent_tx.hash, + Self::elapsed_in_ms(from_unix_timestamp(sent_tx.timestamp)) + .separate_with_commas() + ); + let failed_tx = FailedTx::from((sent_tx, FailureReason::PendingTooLong)); + scan_report.register_new_failure(failed_tx); + } + TxByTable::FailedPayable(failed_tx) => { + if failed_tx.reason != FailureReason::PendingTooLong { + unreachable!( + "Transaction is both pending and failed (failure reason: '{:?}'). Should be \ + possible only with the reason 'PendingTooLong'", + failed_tx.reason + ) + } + let replacement_tx = sent_payable_dao + .retrieve_txs(Some(RetrieveCondition::ByNonce(vec![failed_tx.nonce]))); + let replacement_tx_hash = replacement_tx + .iter() + .next() + .unwrap_or_else(|| { + panic!( + "Attempted to display a replacement tx for {:?} but couldn't find \ + one in the database", + failed_tx.hash + ) + }) + .hash; + warning!( + logger, + "Previously failed tx {:?} found still pending unexpectedly; should have been \ + replaced by {:?}", + failed_tx.hash, + replacement_tx_hash + ); + scan_report.register_rpc_failure(FailedValidationByTable::FailedPayable( + FailedValidation::new( + failed_tx.hash, + BlockchainErrorKind::Internal(InternalErrorKind::PendingTooLongNotReplaced), + failed_tx.status, + ), + )) + } + } + scan_report + } + + fn elapsed_in_ms(timestamp: SystemTime) -> u128 { + timestamp + .elapsed() + .expect("time calculation for elapsed failed") + .as_millis() + } + + fn handle_tx_confirmation( + mut scan_report: ReceiptScanReport, + tx: TxByTable, + tx_block: TxBlock, + logger: &Logger, + ) -> ReceiptScanReport { + match tx { + TxByTable::SentPayable(sent_tx) => { + info!(logger, "Tx {:?} confirmed", sent_tx.hash,); + + let completed_sent_tx = SentTx { + status: TxStatus::Confirmed { + block_hash: format!("{:?}", tx_block.block_hash), + block_number: tx_block.block_number.as_u64(), + detection: Detection::Normal, + }, + ..sent_tx + }; + scan_report.register_confirmed_tx(completed_sent_tx, ConfirmationType::Normal); + } + TxByTable::FailedPayable(failed_tx) => { + info!( + logger, + "Previously failed tx {:?} confirmed; will be reclaimed", failed_tx.hash + ); + + let sent_tx = SentTx::from((failed_tx, tx_block)); + scan_report.register_confirmed_tx(sent_tx, ConfirmationType::Reclaim); + } + } + scan_report + } + + //TODO: if wanted, address GH-693 for more detailed failures + fn handle_reverted_tx( + mut scan_report: ReceiptScanReport, + tx: TxByTable, + logger: &Logger, + ) -> ReceiptScanReport { + match tx { + TxByTable::SentPayable(sent_tx) => { + let failure_reason = FailureReason::Reverted; + let failed_tx = FailedTx::from((sent_tx, failure_reason)); + + warning!(logger, "Tx {:?} reverted", failed_tx.hash,); + + scan_report.register_new_failure(failed_tx); + } + TxByTable::FailedPayable(failed_tx) => { + debug!( + logger, + "Reverted tx {:?} on a recheck after {}. Status will be changed to \"Concluded\"", + failed_tx.hash, + failed_tx.reason, + ); + + scan_report.register_finalization_of_suspected_failure(failed_tx.hash); + } + } + scan_report + } + + fn handle_rpc_failure( + mut scan_report: ReceiptScanReport, + tx_by_table: TxByTable, + rpc_error: AppRpcError, + logger: &Logger, + ) -> ReceiptScanReport { + warning!( + logger, + "Failed to retrieve tx receipt for {:?}: {:?}. Will retry receipt retrieval next cycle", + TxHashByTable::from(&tx_by_table), + rpc_error + ); + let hash = tx_by_table.hash(); + let validation_status_update = match tx_by_table { + TxByTable::SentPayable(sent_tx) => { + FailedValidationByTable::new(hash, rpc_error, Either::Left(sent_tx.status)) + } + TxByTable::FailedPayable(failed_tx) => { + FailedValidationByTable::new(hash, rpc_error, Either::Right(failed_tx.status)) + } + }; + scan_report.register_rpc_failure(validation_status_update); + scan_report + } +} + +#[cfg(test)] +mod tests { + use crate::accountant::db_access_objects::failed_payable_dao::{ + FailedTx, FailureReason, FailureStatus, + }; + use crate::accountant::db_access_objects::sent_payable_dao::{ + Detection, RetrieveCondition, SentTx, TxStatus, + }; + use crate::accountant::db_access_objects::test_utils::{make_failed_tx, make_sent_tx}; + use crate::accountant::db_access_objects::utils::{from_unix_timestamp, to_unix_timestamp}; + use crate::accountant::scanners::pending_payable_scanner::tx_receipt_interpreter::TxReceiptInterpreter; + use crate::accountant::scanners::pending_payable_scanner::utils::{ + DetectedConfirmations, DetectedFailures, FailedValidation, FailedValidationByTable, + PresortedTxFailure, ReceiptScanReport, TxByTable, + }; + use crate::accountant::test_utils::{make_transaction_block, SentPayableDaoMock}; + use crate::blockchain::errors::internal_errors::InternalErrorKind; + use crate::blockchain::errors::rpc_errors::{ + AppRpcError, AppRpcErrorKind, LocalError, LocalErrorKind, RemoteError, + }; + use crate::blockchain::errors::validation_status::{PreviousAttempts, ValidationStatus}; + use crate::blockchain::errors::BlockchainErrorKind; + use crate::blockchain::test_utils::make_tx_hash; + use crate::test_utils::unshared_test_utils::capture_digits_with_separators_from_str; + use masq_lib::logger::Logger; + use masq_lib::simple_clock::SimpleClockReal; + use masq_lib::test_utils::logging::{init_test_logging, TestLogHandler}; + use std::collections::BTreeSet; + use std::sync::{Arc, Mutex}; + use std::time::{Duration, SystemTime}; + + #[test] + fn interprets_receipt_for_pending_tx_if_it_is_a_success() { + init_test_logging(); + let test_name = "interprets_tx_receipt_if_it_is_a_success"; + let hash = make_tx_hash(0xcdef); + let mut sent_tx = make_sent_tx(2244); + sent_tx.hash = hash; + sent_tx.status = TxStatus::Pending(ValidationStatus::Waiting); + let tx_block = make_transaction_block(1234); + let logger = Logger::new(test_name); + let scan_report = ReceiptScanReport::default(); + + let result = TxReceiptInterpreter::handle_tx_confirmation( + scan_report, + TxByTable::SentPayable(sent_tx.clone()), + tx_block, + &logger, + ); + + let mut updated_tx = sent_tx; + updated_tx.status = TxStatus::Confirmed { + block_hash: "0x000000000000000000000000000000000000000000000000000000003b9aced2" + .to_string(), + block_number: 1879080904, + detection: Detection::Normal, + }; + assert_eq!( + result, + ReceiptScanReport { + failures: DetectedFailures::default(), + confirmations: DetectedConfirmations { + standard_confirmations: vec![updated_tx], + reclaims: vec![] + } + } + ); + TestLogHandler::new().exists_log_containing(&format!( + "INFO: {test_name}: Tx 0x000000000000000000000000000000000000000000000000000000000000\ + cdef confirmed", + )); + } + + #[test] + fn interprets_receipt_for_failed_tx_being_rechecked_when_it_is_a_success() { + init_test_logging(); + let test_name = "interprets_receipt_for_failed_tx_being_rechecked_when_it_is_a_success"; + let hash = make_tx_hash(0xcdef); + let mut failed_tx = make_failed_tx(2244); + failed_tx.hash = hash; + failed_tx.status = FailureStatus::RecheckRequired(ValidationStatus::Waiting); + failed_tx.reason = FailureReason::PendingTooLong; + let tx_block = make_transaction_block(1234); + let logger = Logger::new(test_name); + let scan_report = ReceiptScanReport::default(); + + let result = TxReceiptInterpreter::handle_tx_confirmation( + scan_report, + TxByTable::FailedPayable(failed_tx.clone()), + tx_block, + &logger, + ); + + let sent_tx = SentTx::from((failed_tx, tx_block)); + assert!( + matches!( + sent_tx.status, + TxStatus::Confirmed { + detection: Detection::Reclaim, + .. + } + ), + "We expected reclaimed tx, but it says: {:?}", + sent_tx + ); + assert_eq!( + result, + ReceiptScanReport { + failures: DetectedFailures::default(), + confirmations: DetectedConfirmations { + standard_confirmations: vec![], + reclaims: vec![sent_tx] + } + } + ); + TestLogHandler::new().exists_log_containing(&format!( + "INFO: {test_name}: Previously failed tx 0x00000000000000000000000000000000000000000000\ + 0000000000000000cdef confirmed; will be reclaimed", + )); + } + + #[test] + fn interprets_tx_receipt_for_pending_tx_when_tx_status_says_reverted() { + init_test_logging(); + let test_name = "interprets_tx_receipt_for_pending_tx_when_tx_status_says_reverted"; + let hash = make_tx_hash(0xabc); + let mut sent_tx = make_sent_tx(2244); + sent_tx.hash = hash; + let logger = Logger::new(test_name); + let scan_report = ReceiptScanReport::default(); + + let result = TxReceiptInterpreter::handle_reverted_tx( + scan_report, + TxByTable::SentPayable(sent_tx.clone()), + &logger, + ); + + let failed_tx = FailedTx::from((sent_tx, FailureReason::Reverted)); + assert_eq!( + result, + ReceiptScanReport { + failures: DetectedFailures { + tx_failures: vec![PresortedTxFailure::NewEntry(failed_tx)], + tx_receipt_rpc_failures: vec![] + }, + confirmations: DetectedConfirmations::default() + } + ); + TestLogHandler::new().exists_log_containing(&format!( + "WARN: {test_name}: Tx 0x0000000000000000000000000000000000000000000000000000000\ + 000000abc reverted", + )); + } + + #[test] + fn interprets_tx_receipt_for_failed_tx_when_newly_fetched_tx_status_says_reverted() { + init_test_logging(); + let test_name = "interprets_tx_receipt_for_failed_tx_when_tx_status_reveals_failure"; + let tx_hash = make_tx_hash(0xabc); + let mut failed_tx = make_failed_tx(2244); + failed_tx.hash = tx_hash; + failed_tx.status = FailureStatus::RecheckRequired(ValidationStatus::Waiting); + failed_tx.reason = FailureReason::PendingTooLong; + let logger = Logger::new(test_name); + let scan_report = ReceiptScanReport::default(); + + let result = TxReceiptInterpreter::handle_reverted_tx( + scan_report, + TxByTable::FailedPayable(failed_tx.clone()), + &logger, + ); + + assert_eq!( + result, + ReceiptScanReport { + failures: DetectedFailures { + tx_failures: vec![PresortedTxFailure::RecheckCompleted(tx_hash)], + tx_receipt_rpc_failures: vec![] + }, + confirmations: DetectedConfirmations::default() + } + ); + TestLogHandler::new().exists_log_containing(&format!( + "DEBUG: {test_name}: Reverted tx 0x000000000000000000000000000000000000000000000000000000\ + 0000000abc on a recheck after \"PendingTooLong\". Status will be changed to \"Concluded\"", + )); + } + + #[test] + fn interprets_tx_receipt_for_pending_payable_if_the_tx_keeps_pending() { + init_test_logging(); + let retrieve_txs_params_arc = Arc::new(Mutex::new(vec![])); + let test_name = "interprets_tx_receipt_for_pending_payable_if_the_tx_keeps_pending"; + let newer_sent_tx_for_older_failed_tx = make_sent_tx(2244); + let sent_payable_dao = SentPayableDaoMock::new() + .retrieve_txs_params(&retrieve_txs_params_arc) + .retrieve_txs_result(BTreeSet::from([newer_sent_tx_for_older_failed_tx])); + let hash = make_tx_hash(0x913); + let sent_tx_timestamp = to_unix_timestamp( + SystemTime::now() + .checked_sub(Duration::from_secs(120)) + .unwrap(), + ); + let mut sent_tx = make_sent_tx(456); + sent_tx.hash = hash; + sent_tx.timestamp = sent_tx_timestamp; + let scan_report = ReceiptScanReport::default(); + let before = SystemTime::now(); + + let result = TxReceiptInterpreter::handle_still_pending_tx( + scan_report, + TxByTable::SentPayable(sent_tx.clone()), + &sent_payable_dao, + &Logger::new(test_name), + ); + + let after = SystemTime::now(); + let expected_failed_tx = FailedTx::from((sent_tx, FailureReason::PendingTooLong)); + assert_eq!( + result, + ReceiptScanReport { + failures: DetectedFailures { + tx_failures: vec![PresortedTxFailure::NewEntry(expected_failed_tx)], + tx_receipt_rpc_failures: vec![] + }, + confirmations: DetectedConfirmations::default() + } + ); + let log_handler = TestLogHandler::new(); + let log_idx = log_handler.exists_log_matching(&format!( + "INFO: {test_name}: Tx \ + 0x0000000000000000000000000000000000000000000000000000000000000913 not confirmed within \ + \\d{{1,3}}(,\\d{{3}})* ms. Will resubmit with higher gas price" + )); + let log_msg = log_handler.get_log_at(log_idx); + let str_elapsed_ms = capture_digits_with_separators_from_str(&log_msg, 3, ','); + let elapsed_ms = str_elapsed_ms[0].replace(",", "").parse::().unwrap(); + let elapsed_ms_when_before = before + .duration_since(from_unix_timestamp(sent_tx_timestamp)) + .unwrap() + .as_millis(); + let elapsed_ms_when_after = after + .duration_since(from_unix_timestamp(sent_tx_timestamp)) + .unwrap() + .as_millis(); + assert!( + elapsed_ms_when_before <= elapsed_ms && elapsed_ms <= elapsed_ms_when_after, + "we expected the elapsed time {} ms to be between {} and {}.", + elapsed_ms, + elapsed_ms_when_before, + elapsed_ms_when_after + ); + } + + #[test] + fn interprets_tx_receipt_for_supposedly_failed_tx_if_the_tx_keeps_pending() { + init_test_logging(); + let retrieve_txs_params_arc = Arc::new(Mutex::new(vec![])); + let test_name = "interprets_tx_receipt_for_supposedly_failed_tx_if_the_tx_keeps_pending"; + let mut newer_sent_tx_for_older_failed_tx = make_sent_tx(2244); + newer_sent_tx_for_older_failed_tx.hash = make_tx_hash(0x7c6); + let sent_payable_dao = SentPayableDaoMock::new() + .retrieve_txs_params(&retrieve_txs_params_arc) + .retrieve_txs_result(BTreeSet::from([newer_sent_tx_for_older_failed_tx])); + let tx_hash = make_tx_hash(0x913); + let mut failed_tx = make_failed_tx(789); + let failed_tx_nonce = failed_tx.nonce; + failed_tx.hash = tx_hash; + failed_tx.status = FailureStatus::RecheckRequired(ValidationStatus::Waiting); + failed_tx.reason = FailureReason::PendingTooLong; + let scan_report = ReceiptScanReport::default(); + + let result = TxReceiptInterpreter::handle_still_pending_tx( + scan_report, + TxByTable::FailedPayable(failed_tx.clone()), + &sent_payable_dao, + &Logger::new(test_name), + ); + + assert_eq!( + result, + ReceiptScanReport { + failures: DetectedFailures { + tx_failures: vec![], + tx_receipt_rpc_failures: vec![FailedValidationByTable::FailedPayable( + FailedValidation::new( + tx_hash, + BlockchainErrorKind::Internal( + InternalErrorKind::PendingTooLongNotReplaced + ), + FailureStatus::RecheckRequired(ValidationStatus::Waiting) + ) + )] + }, + confirmations: DetectedConfirmations::default() + } + ); + let retrieve_txs_params = retrieve_txs_params_arc.lock().unwrap(); + assert_eq!( + *retrieve_txs_params, + vec![Some(RetrieveCondition::ByNonce(vec![failed_tx_nonce]))] + ); + TestLogHandler::new().exists_log_containing(&format!( + "WARN: {test_name}: Previously failed tx 0x00000000000000000000000000000000000000000000\ + 00000000000000000913 found still pending unexpectedly; should have been replaced \ + by 0x00000000000000000000000000000000000000000000000000000000000007c6" + )); + } + + #[test] + #[should_panic( + expected = "internal error: entered unreachable code: Transaction is both pending \ + and failed (failure reason: 'Reverted'). Should be possible only with the reason 'PendingTooLong'" + )] + fn interprets_failed_tx_recheck_as_still_pending_while_the_failure_reason_wasnt_pending_too_long( + ) { + let mut newer_sent_tx_for_older_failed_tx = make_sent_tx(2244); + newer_sent_tx_for_older_failed_tx.hash = make_tx_hash(0x7c6); + let sent_payable_dao = SentPayableDaoMock::new(); + let tx_hash = make_tx_hash(0x913); + let mut failed_tx = make_failed_tx(789); + failed_tx.hash = tx_hash; + failed_tx.status = FailureStatus::RecheckRequired(ValidationStatus::Waiting); + failed_tx.reason = FailureReason::Reverted; + let scan_report = ReceiptScanReport::default(); + + let _ = TxReceiptInterpreter::handle_still_pending_tx( + scan_report, + TxByTable::FailedPayable(failed_tx), + &sent_payable_dao, + &Logger::new("test"), + ); + } + + #[test] + #[should_panic( + expected = "Attempted to display a replacement tx for 0x000000000000000000000000000\ + 00000000000000000000000000000000001c8 but couldn't find one in the database" + )] + fn handle_still_pending_tx_if_unexpected_behavior_due_to_already_failed_tx_and_db_retrieval_fails( + ) { + let scan_report = ReceiptScanReport::default(); + let still_pending_tx = make_failed_tx(456); + let sent_payable_dao = SentPayableDaoMock::new().retrieve_txs_result(BTreeSet::new()); + + let _ = TxReceiptInterpreter::handle_still_pending_tx( + scan_report, + TxByTable::FailedPayable(still_pending_tx), + &sent_payable_dao, + &Logger::new("test"), + ); + } + + #[test] + fn interprets_failed_retrieval_of_receipt_for_pending_payable_in_first_attempt() { + let test_name = + "interprets_failed_retrieval_of_receipt_for_pending_payable_in_first_attempt"; + + test_failed_retrieval_of_receipt_for_pending_payable( + test_name, + TxStatus::Pending(ValidationStatus::Waiting), + ); + } + + #[test] + fn interprets_failed_retrieval_of_receipt_for_pending_payable_as_reattempt() { + let test_name = "interprets_failed_retrieval_of_receipt_for_pending_payable_as_reattempt"; + + test_failed_retrieval_of_receipt_for_pending_payable( + test_name, + TxStatus::Pending(ValidationStatus::Reattempting(PreviousAttempts::new( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Internal)), + &SimpleClockReal::default(), + ))), + ); + } + + fn test_failed_retrieval_of_receipt_for_pending_payable( + test_name: &str, + current_tx_status: TxStatus, + ) { + init_test_logging(); + let tx_hash = make_tx_hash(913); + let mut sent_tx = make_sent_tx(456); + sent_tx.hash = tx_hash; + sent_tx.status = current_tx_status.clone(); + let rpc_error = AppRpcError::Remote(RemoteError::InvalidResponse("blah".to_string())); + let scan_report = ReceiptScanReport::default(); + + let result = TxReceiptInterpreter::handle_rpc_failure( + scan_report, + TxByTable::SentPayable(sent_tx), + rpc_error.clone(), + &Logger::new(test_name), + ); + + assert_eq!( + result, + ReceiptScanReport { + failures: DetectedFailures { + tx_failures: vec![], + tx_receipt_rpc_failures: vec![FailedValidationByTable::SentPayable( + FailedValidation::new( + tx_hash, + BlockchainErrorKind::AppRpc((&rpc_error).into()), + current_tx_status + ) + ),] + }, + confirmations: DetectedConfirmations::default() + } + ); + TestLogHandler::new().exists_log_containing( + &format!("WARN: {test_name}: Failed to retrieve tx receipt for SentPayable(0x0000000000\ + 000000000000000000000000000000000000000000000000000391): Remote(InvalidResponse(\"blah\")). \ + Will retry receipt retrieval next cycle")); + } + + #[test] + fn interprets_failed_retrieval_of_receipt_for_failed_tx_in_first_attempt() { + let test_name = "interprets_failed_retrieval_of_receipt_for_failed_tx_in_first_attempt"; + + test_failed_retrieval_of_receipt_for_failed_tx( + test_name, + FailureStatus::RecheckRequired(ValidationStatus::Waiting), + ); + } + + #[test] + fn interprets_failed_retrieval_of_receipt_for_failed_tx_as_reattempt() { + let test_name = "interprets_failed_retrieval_of_receipt_for_failed_tx_as_reattempt"; + + test_failed_retrieval_of_receipt_for_failed_tx( + test_name, + FailureStatus::RecheckRequired(ValidationStatus::Reattempting(PreviousAttempts::new( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Internal)), + &SimpleClockReal::default(), + ))), + ); + } + + fn test_failed_retrieval_of_receipt_for_failed_tx( + test_name: &str, + current_failure_status: FailureStatus, + ) { + init_test_logging(); + let tx_hash = make_tx_hash(914); + let mut failed_tx = make_failed_tx(456); + failed_tx.hash = tx_hash; + failed_tx.status = current_failure_status.clone(); + let rpc_error = AppRpcError::Local(LocalError::Internal); + let scan_report = ReceiptScanReport::default(); + + let result = TxReceiptInterpreter::handle_rpc_failure( + scan_report, + TxByTable::FailedPayable(failed_tx), + rpc_error.clone(), + &Logger::new(test_name), + ); + + assert_eq!( + result, + ReceiptScanReport { + failures: DetectedFailures { + tx_failures: vec![], + tx_receipt_rpc_failures: vec![FailedValidationByTable::FailedPayable( + FailedValidation::new( + tx_hash, + BlockchainErrorKind::AppRpc((&rpc_error).into()), + current_failure_status + ) + )] + }, + confirmations: DetectedConfirmations::default() + } + ); + TestLogHandler::new().exists_log_containing(&format!( + "WARN: {test_name}: Failed to retrieve tx receipt for FailedPayable(0x0000000000\ + 000000000000000000000000000000000000000000000000000392): Local(Internal). \ + Will retry receipt retrieval next cycle" + )); + } +} diff --git a/node/src/accountant/scanners/pending_payable_scanner/utils.rs b/node/src/accountant/scanners/pending_payable_scanner/utils.rs new file mode 100644 index 0000000000..2b80524965 --- /dev/null +++ b/node/src/accountant/scanners/pending_payable_scanner/utils.rs @@ -0,0 +1,1204 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. + +use crate::accountant::db_access_objects::failed_payable_dao::{FailedTx, FailureStatus}; +use crate::accountant::db_access_objects::sent_payable_dao::{SentTx, TxStatus}; +use crate::accountant::db_access_objects::utils::TxHash; +use crate::accountant::{ResponseSkeleton, TxReceiptResult}; +use crate::blockchain::errors::rpc_errors::AppRpcError; +use crate::blockchain::errors::validation_status::{PreviousAttempts, ValidationStatus}; +use crate::blockchain::errors::BlockchainErrorKind; +use itertools::Either; +use masq_lib::logger::Logger; +use masq_lib::simple_clock::SimpleClock; +use masq_lib::ui_gateway::NodeToUiMessage; +use std::cmp::Ordering; +use std::collections::HashMap; + +#[derive(Debug, Default, PartialEq, Eq, Clone)] +pub struct ReceiptScanReport { + pub failures: DetectedFailures, + pub confirmations: DetectedConfirmations, +} + +impl ReceiptScanReport { + pub fn requires_payments_retry(&self) -> Option { + match ( + self.failures.requires_retry(), + self.confirmations.is_empty(), + ) { + (None, true) => unreachable!("reading tx receipts gave no results, but always should"), + (None, _) => None, + (Some(retry), _) => Some(retry), + } + } + + pub(super) fn register_confirmed_tx( + &mut self, + confirmed_tx: SentTx, + confirmation_type: ConfirmationType, + ) { + match confirmation_type { + ConfirmationType::Normal => { + self.confirmations.standard_confirmations.push(confirmed_tx) + } + ConfirmationType::Reclaim => self.confirmations.reclaims.push(confirmed_tx), + } + } + + pub(super) fn register_new_failure(&mut self, failed_tx: FailedTx) { + self.failures + .tx_failures + .push(PresortedTxFailure::NewEntry(failed_tx)); + } + + pub(super) fn register_finalization_of_suspected_failure(&mut self, tx_hash: TxHash) { + self.failures + .tx_failures + .push(PresortedTxFailure::RecheckCompleted(tx_hash)); + } + + pub(super) fn register_rpc_failure(&mut self, status_update: FailedValidationByTable) { + self.failures.tx_receipt_rpc_failures.push(status_update); + } +} + +#[derive(Debug, Default, PartialEq, Eq, Clone)] +pub struct DetectedConfirmations { + pub standard_confirmations: Vec, + pub reclaims: Vec, +} + +impl DetectedConfirmations { + pub(super) fn is_empty(&self) -> bool { + self.standard_confirmations.is_empty() && self.reclaims.is_empty() + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum ConfirmationType { + Normal, + Reclaim, +} + +#[derive(Debug, Default, PartialEq, Eq, Clone)] +pub struct DetectedFailures { + pub tx_failures: Vec, + pub tx_receipt_rpc_failures: Vec, +} + +impl DetectedFailures { + fn requires_retry(&self) -> Option { + if self.tx_failures.is_empty() && self.tx_receipt_rpc_failures.is_empty() { + None + } else if !self.tx_failures.is_empty() { + Some(Retry::RetryPayments) + } else { + Some(Retry::RetryTxStatusCheckOnly) + } + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum PresortedTxFailure { + NewEntry(FailedTx), + RecheckCompleted(TxHash), +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum FailedValidationByTable { + SentPayable(FailedValidation), + FailedPayable(FailedValidation), +} + +impl FailedValidationByTable { + pub fn new( + tx_hash: TxHash, + error: AppRpcError, + status: Either, + ) -> Self { + match status { + Either::Left(tx_status) => Self::SentPayable(FailedValidation::new( + tx_hash, + BlockchainErrorKind::AppRpc((&error).into()), + tx_status, + )), + Either::Right(failure_reason) => Self::FailedPayable(FailedValidation::new( + tx_hash, + BlockchainErrorKind::AppRpc((&error).into()), + failure_reason, + )), + } + } +} + +#[derive(Debug, Eq, PartialEq, Clone)] +pub struct FailedValidation { + pub tx_hash: TxHash, + pub validation_failure: BlockchainErrorKind, + pub current_status: RecordStatus, +} + +impl FailedValidation +where + RecordStatus: UpdatableValidationStatus, +{ + pub fn new( + tx_hash: TxHash, + validation_failure: BlockchainErrorKind, + current_status: RecordStatus, + ) -> Self { + Self { + tx_hash, + validation_failure, + current_status, + } + } + + pub fn new_status(&self, clock: &dyn SimpleClock) -> Option { + self.current_status + .update_after_failure(self.validation_failure, clock) + } +} + +pub trait UpdatableValidationStatus { + fn update_after_failure( + &self, + error: BlockchainErrorKind, + clock: &dyn SimpleClock, + ) -> Option + where + Self: Sized; +} + +impl UpdatableValidationStatus for TxStatus { + fn update_after_failure( + &self, + error: BlockchainErrorKind, + clock: &dyn SimpleClock, + ) -> Option { + match self { + TxStatus::Pending(ValidationStatus::Waiting) => Some(TxStatus::Pending( + ValidationStatus::Reattempting(PreviousAttempts::new(error, clock)), + )), + TxStatus::Pending(ValidationStatus::Reattempting(previous_attempts)) => { + Some(TxStatus::Pending(ValidationStatus::Reattempting( + previous_attempts.clone().add_attempt(error, clock), + ))) + } + TxStatus::Confirmed { .. } => None, + } + } +} + +impl UpdatableValidationStatus for FailureStatus { + fn update_after_failure( + &self, + error: BlockchainErrorKind, + clock: &dyn SimpleClock, + ) -> Option { + match self { + FailureStatus::RecheckRequired(ValidationStatus::Waiting) => { + Some(FailureStatus::RecheckRequired( + ValidationStatus::Reattempting(PreviousAttempts::new(error, clock)), + )) + } + FailureStatus::RecheckRequired(ValidationStatus::Reattempting(previous_attempts)) => { + Some(FailureStatus::RecheckRequired( + ValidationStatus::Reattempting( + previous_attempts.clone().add_attempt(error, clock), + ), + )) + } + FailureStatus::RetryRequired | FailureStatus::Concluded => None, + } + } +} + +pub trait PendingPayableCache { + fn load_cache(&mut self, records: Vec); + fn get_record_by_hash(&mut self, hash: TxHash) -> Option; + fn ensure_empty_cache(&mut self, logger: &Logger); + fn dump_cache(&mut self) -> HashMap; +} + +#[derive(Debug, PartialEq, Eq, Default)] +pub struct CurrentPendingPayables { + pub(super) sent_payables: HashMap, +} + +impl PendingPayableCache for CurrentPendingPayables { + fn load_cache(&mut self, records: Vec) { + self.sent_payables + .extend(records.into_iter().map(|tx| (tx.hash, tx))); + } + + fn get_record_by_hash(&mut self, hash: TxHash) -> Option { + self.sent_payables.remove(&hash) + } + + fn ensure_empty_cache(&mut self, logger: &Logger) { + if !self.sent_payables.is_empty() { + debug!( + logger, + "Cache misuse - some pending payables left unprocessed: {:?}. Dumping.", + self.sent_payables + ); + } + self.sent_payables.clear() + } + + fn dump_cache(&mut self) -> HashMap { + self.sent_payables.drain().collect() + } +} + +impl CurrentPendingPayables { + pub fn new() -> Self { + Self::default() + } +} + +#[derive(Debug, PartialEq, Eq, Default)] +pub struct RecheckRequiringFailures { + pub(super) failures: HashMap, +} + +impl PendingPayableCache for RecheckRequiringFailures { + fn load_cache(&mut self, records: Vec) { + self.failures + .extend(records.into_iter().map(|tx| (tx.hash, tx))); + } + + fn get_record_by_hash(&mut self, hash: TxHash) -> Option { + self.failures.remove(&hash) + } + + fn ensure_empty_cache(&mut self, logger: &Logger) { + if !self.failures.is_empty() { + debug!( + logger, + "Cache misuse - some tx failures left unprocessed: {:?}. Dumping.", self.failures + ); + } + self.failures.clear() + } + + fn dump_cache(&mut self) -> HashMap { + self.failures.drain().collect() + } +} + +impl RecheckRequiringFailures { + pub fn new() -> Self { + Self::default() + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum PendingPayableScanResult { + NoPendingPayablesLeft(Option), + PaymentRetryRequired(Option), + ProcedureShouldBeRepeated(Option), +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Retry { + RetryPayments, + RetryTxStatusCheckOnly, +} + +pub struct TxCaseToBeInterpreted { + pub tx_by_table: TxByTable, + pub tx_receipt_result: TxReceiptResult, +} + +impl TxCaseToBeInterpreted { + pub fn new(tx_by_table: TxByTable, tx_receipt_result: TxReceiptResult) -> Self { + Self { + tx_by_table, + tx_receipt_result, + } + } +} + +#[derive(Debug)] +pub enum TxByTable { + SentPayable(SentTx), + FailedPayable(FailedTx), +} + +impl TxByTable { + pub fn hash(&self) -> TxHash { + match self { + TxByTable::SentPayable(tx) => tx.hash, + TxByTable::FailedPayable(tx) => tx.hash, + } + } +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)] +pub enum TxHashByTable { + SentPayable(TxHash), + FailedPayable(TxHash), +} + +impl PartialOrd for TxHashByTable { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +// Manual impl of Ord for enums makes sense because the derive macro determines the ordering +// by the order of the enum variants in its declaration, not only alphabetically. Swiping +// the position of the variants makes a difference, which is counter-intuitive. Structs are not +// implemented the same way and are safe to be used with derive. +impl Ord for TxHashByTable { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + match (self, other) { + (TxHashByTable::FailedPayable(..), TxHashByTable::SentPayable(..)) => Ordering::Less, + (TxHashByTable::SentPayable(..), TxHashByTable::FailedPayable(..)) => Ordering::Greater, + (TxHashByTable::SentPayable(hash_1), TxHashByTable::SentPayable(hash_2)) => { + hash_1.cmp(hash_2) + } + (TxHashByTable::FailedPayable(hash_1), TxHashByTable::FailedPayable(hash_2)) => { + hash_1.cmp(hash_2) + } + } + } +} + +impl TxHashByTable { + pub fn hash(&self) -> TxHash { + match self { + TxHashByTable::SentPayable(hash) => *hash, + TxHashByTable::FailedPayable(hash) => *hash, + } + } +} + +impl From<&TxByTable> for TxHashByTable { + fn from(tx: &TxByTable) -> Self { + match tx { + TxByTable::SentPayable(tx) => TxHashByTable::SentPayable(tx.hash), + TxByTable::FailedPayable(tx) => TxHashByTable::FailedPayable(tx.hash), + } + } +} + +#[cfg(test)] +mod tests { + use crate::accountant::db_access_objects::failed_payable_dao::FailureStatus; + use crate::accountant::db_access_objects::sent_payable_dao::{Detection, TxStatus}; + use crate::accountant::db_access_objects::test_utils::{make_failed_tx, make_sent_tx}; + use crate::accountant::scanners::pending_payable_scanner::utils::{ + CurrentPendingPayables, DetectedConfirmations, DetectedFailures, FailedValidation, + FailedValidationByTable, PendingPayableCache, PresortedTxFailure, ReceiptScanReport, + RecheckRequiringFailures, Retry, TxByTable, TxHashByTable, + }; + use crate::blockchain::errors::rpc_errors::{AppRpcErrorKind, LocalErrorKind, RemoteErrorKind}; + use crate::blockchain::errors::validation_status::{PreviousAttempts, ValidationStatus}; + use crate::blockchain::errors::BlockchainErrorKind; + use crate::blockchain::test_utils::make_tx_hash; + use masq_lib::logger::Logger; + use masq_lib::simple_clock::SimpleClockReal; + use masq_lib::test_utils::logging::{init_test_logging, TestLogHandler}; + use masq_lib::test_utils::simple_clock::SimpleClockMock; + use std::cmp::Ordering; + use std::collections::BTreeSet; + use std::ops::Sub; + use std::time::{Duration, SystemTime}; + use std::vec; + + #[test] + fn detected_confirmations_is_empty_works() { + let subject = DetectedConfirmations { + standard_confirmations: vec![], + reclaims: vec![], + }; + + assert_eq!(subject.is_empty(), true); + } + + #[test] + fn requires_payments_retry() { + // Maximalist approach: exhaustive set of tested variants: + let tx_failures_feedings = vec![ + vec![PresortedTxFailure::NewEntry(make_failed_tx(456))], + vec![PresortedTxFailure::RecheckCompleted(make_tx_hash(123))], + vec![ + PresortedTxFailure::NewEntry(make_failed_tx(123)), + PresortedTxFailure::NewEntry(make_failed_tx(456)), + ], + vec![ + PresortedTxFailure::RecheckCompleted(make_tx_hash(654)), + PresortedTxFailure::RecheckCompleted(make_tx_hash(321)), + ], + vec![ + PresortedTxFailure::NewEntry(make_failed_tx(456)), + PresortedTxFailure::RecheckCompleted(make_tx_hash(654)), + ], + ]; + let tx_receipt_rpc_failures_feeding = vec![ + vec![], + vec![FailedValidationByTable::SentPayable(FailedValidation::new( + make_tx_hash(2222), + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Internal)), + TxStatus::Pending(ValidationStatus::Waiting), + ))], + vec![FailedValidationByTable::FailedPayable( + FailedValidation::new( + make_tx_hash(12121), + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote( + RemoteErrorKind::InvalidResponse, + )), + FailureStatus::RecheckRequired(ValidationStatus::Reattempting( + PreviousAttempts::new( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local( + LocalErrorKind::Internal, + )), + &SimpleClockReal::default(), + ), + )), + ), + )], + ]; + let detected_confirmations_feeding = vec![ + DetectedConfirmations { + standard_confirmations: vec![], + reclaims: vec![], + }, + DetectedConfirmations { + standard_confirmations: vec![make_sent_tx(456)], + reclaims: vec![make_sent_tx(999)], + }, + DetectedConfirmations { + standard_confirmations: vec![make_sent_tx(777)], + reclaims: vec![], + }, + DetectedConfirmations { + standard_confirmations: vec![], + reclaims: vec![make_sent_tx(999)], + }, + ]; + + for tx_failures in &tx_failures_feedings { + for rpc_failures in &tx_receipt_rpc_failures_feeding { + for detected_confirmations in &detected_confirmations_feeding { + let case = ReceiptScanReport { + failures: DetectedFailures { + tx_failures: tx_failures.clone(), + tx_receipt_rpc_failures: rpc_failures.clone(), + }, + confirmations: detected_confirmations.clone(), + }; + + let result = case.requires_payments_retry(); + + assert_eq!( + result, + Some(Retry::RetryPayments), + "Expected Some(Retry::RetryPayments) but got {:?} for case {:?}", + result, + case + ); + } + } + } + } + + #[test] + fn requires_only_receipt_retrieval_retry() { + let rpc_failure_feedings = vec![ + vec![FailedValidationByTable::SentPayable(FailedValidation::new( + make_tx_hash(2222), + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Internal)), + TxStatus::Pending(ValidationStatus::Waiting), + ))], + vec![FailedValidationByTable::FailedPayable( + FailedValidation::new( + make_tx_hash(1234), + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote( + RemoteErrorKind::Unreachable, + )), + FailureStatus::RecheckRequired(ValidationStatus::Waiting), + ), + )], + vec![ + FailedValidationByTable::SentPayable(FailedValidation::new( + make_tx_hash(2222), + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Internal)), + TxStatus::Pending(ValidationStatus::Reattempting(PreviousAttempts::new( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local( + LocalErrorKind::Internal, + )), + &SimpleClockReal::default(), + ))), + )), + FailedValidationByTable::FailedPayable(FailedValidation::new( + make_tx_hash(1234), + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote( + RemoteErrorKind::Unreachable, + )), + FailureStatus::RecheckRequired(ValidationStatus::Reattempting( + PreviousAttempts::new( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local( + LocalErrorKind::Internal, + )), + &SimpleClockReal::default(), + ), + )), + )), + ], + ]; + let detected_confirmations_feeding = vec![ + DetectedConfirmations { + standard_confirmations: vec![], + reclaims: vec![], + }, + DetectedConfirmations { + standard_confirmations: vec![make_sent_tx(777)], + reclaims: vec![make_sent_tx(999)], + }, + DetectedConfirmations { + standard_confirmations: vec![make_sent_tx(777)], + reclaims: vec![], + }, + DetectedConfirmations { + standard_confirmations: vec![], + reclaims: vec![make_sent_tx(999)], + }, + ]; + + for rpc_failures in &rpc_failure_feedings { + for detected_confirmations in &detected_confirmations_feeding { + let case = ReceiptScanReport { + failures: DetectedFailures { + tx_failures: vec![], // This is the determinant + tx_receipt_rpc_failures: rpc_failures.clone(), + }, + confirmations: detected_confirmations.clone(), + }; + + let result = case.requires_payments_retry(); + + assert_eq!( + result, + Some(Retry::RetryTxStatusCheckOnly), + "Expected Some(Retry::RetryTxStatusCheckOnly) but got {:?} for case {:?}", + result, + case + ); + } + } + } + + #[test] + fn requires_payments_retry_says_no() { + let detected_confirmations_feeding = vec![ + DetectedConfirmations { + standard_confirmations: vec![make_sent_tx(777)], + reclaims: vec![make_sent_tx(999)], + }, + DetectedConfirmations { + standard_confirmations: vec![make_sent_tx(777)], + reclaims: vec![], + }, + DetectedConfirmations { + standard_confirmations: vec![], + reclaims: vec![make_sent_tx(999)], + }, + ]; + + for detected_confirmations in detected_confirmations_feeding { + let case = ReceiptScanReport { + failures: DetectedFailures { + tx_failures: vec![], + tx_receipt_rpc_failures: vec![], + }, + confirmations: detected_confirmations.clone(), + }; + + let result = case.requires_payments_retry(); + + assert_eq!( + result, None, + "We expected None but got {:?} for case {:?}", + result, case + ); + } + } + + #[test] + #[should_panic( + expected = "internal error: entered unreachable code: reading tx receipts gave no results, \ + but always should" + )] + fn requires_payments_retry_with_no_results_in_whole_summary() { + let report = ReceiptScanReport { + failures: DetectedFailures { + tx_failures: vec![], + tx_receipt_rpc_failures: vec![], + }, + confirmations: DetectedConfirmations { + standard_confirmations: vec![], + reclaims: vec![], + }, + }; + + let _ = report.requires_payments_retry(); + } + + #[test] + fn pending_payable_cache_insert_and_get_methods_single_record() { + let mut subject = CurrentPendingPayables::new(); + let sent_tx = make_sent_tx(123); + let tx_hash = sent_tx.hash; + let records = vec![sent_tx.clone()]; + let state_before = subject.sent_payables.clone(); + subject.load_cache(records); + + let first_attempt = subject.get_record_by_hash(tx_hash); + let second_attempt = subject.get_record_by_hash(tx_hash); + + assert_eq!(state_before, hashmap!()); + assert_eq!(first_attempt, Some(sent_tx)); + assert_eq!(second_attempt, None); + assert!( + subject.sent_payables.is_empty(), + "Should be empty but was {:?}", + subject.sent_payables + ); + } + + #[test] + fn pending_payable_cache_insert_and_get_methods_multiple_records() { + let mut subject = CurrentPendingPayables::new(); + let sent_tx_1 = make_sent_tx(123); + let tx_hash_1 = sent_tx_1.hash; + let sent_tx_2 = make_sent_tx(456); + let tx_hash_2 = sent_tx_2.hash; + let sent_tx_3 = make_sent_tx(789); + let tx_hash_3 = sent_tx_3.hash; + let sent_tx_4 = make_sent_tx(101); + let tx_hash_4 = sent_tx_4.hash; + let nonexistent_tx_hash = make_tx_hash(234); + let records = vec![ + sent_tx_1.clone(), + sent_tx_2.clone(), + sent_tx_3.clone(), + sent_tx_4.clone(), + ]; + + let first_query = subject.get_record_by_hash(tx_hash_1); + subject.load_cache(records); + let second_query = subject.get_record_by_hash(nonexistent_tx_hash); + let third_query = subject.get_record_by_hash(tx_hash_2); + let fourth_query = subject.get_record_by_hash(tx_hash_1); + let fifth_query = subject.get_record_by_hash(tx_hash_4); + let sixth_query = subject.get_record_by_hash(tx_hash_1); + let seventh_query = subject.get_record_by_hash(tx_hash_1); + let eighth_query = subject.get_record_by_hash(tx_hash_3); + + assert_eq!(first_query, None); + assert_eq!(second_query, None); + assert_eq!(third_query, Some(sent_tx_2)); + assert_eq!(fourth_query, Some(sent_tx_1)); + assert_eq!(fifth_query, Some(sent_tx_4)); + assert_eq!(sixth_query, None); + assert_eq!(seventh_query, None); + assert_eq!(eighth_query, Some(sent_tx_3)); + assert!( + subject.sent_payables.is_empty(), + "Expected empty cache, but got {:?}", + subject.sent_payables + ); + } + + #[test] + fn pending_payable_cache_ensure_empty_happy_path() { + init_test_logging(); + let test_name = "pending_payable_cache_ensure_empty_happy_path"; + let mut subject = CurrentPendingPayables::new(); + let sent_tx = make_sent_tx(567); + let tx_hash = sent_tx.hash; + let records = vec![sent_tx.clone()]; + let logger = Logger::new(test_name); + subject.load_cache(records); + let _ = subject.get_record_by_hash(tx_hash); + + subject.ensure_empty_cache(&logger); + + assert!( + subject.sent_payables.is_empty(), + "Should be empty by now but was {:?}", + subject.sent_payables + ); + TestLogHandler::default().exists_no_log_containing(&format!( + "DEBUG: {test_name}: \ + Cache misuse - some pending payables left unprocessed:" + )); + } + + #[test] + fn pending_payable_cache_ensure_empty_sad_path() { + init_test_logging(); + let test_name = "pending_payable_cache_ensure_empty_sad_path"; + let mut subject = CurrentPendingPayables::new(); + let sent_tx = make_sent_tx(0x567); + let records = vec![sent_tx.clone()]; + let logger = Logger::new(test_name); + subject.load_cache(records); + + subject.ensure_empty_cache(&logger); + + assert!( + subject.sent_payables.is_empty(), + "Should be empty by now but was {:?}", + subject.sent_payables + ); + TestLogHandler::default().exists_log_containing(&format!( + "DEBUG: {test_name}: \ + Cache misuse - some pending payables left unprocessed: \ + {{0x0000000000000000000000000000000000000000000000000000000000000567: SentTx {{ hash: \ + 0x0000000000000000000000000000000000000000000000000000000000000567, receiver_address: \ + 0x0000000000000000001035000000001035000000, amount_minor: 3658379210721, timestamp: \ + 275427216, gas_price_minor: 2645248887, nonce: 1383, status: Pending(Waiting) }}}}. \ + Dumping." + )); + } + + #[test] + fn pending_payable_cache_dump_works() { + let mut subject = CurrentPendingPayables::new(); + let sent_tx_1 = make_sent_tx(567); + let tx_hash_1 = sent_tx_1.hash; + let sent_tx_2 = make_sent_tx(456); + let tx_hash_2 = sent_tx_2.hash; + let sent_tx_3 = make_sent_tx(789); + let tx_hash_3 = sent_tx_3.hash; + let records = vec![sent_tx_1.clone(), sent_tx_2.clone(), sent_tx_3.clone()]; + subject.load_cache(records); + + let result = subject.dump_cache(); + + assert_eq!( + result, + hashmap! ( + tx_hash_1 => sent_tx_1, + tx_hash_2 => sent_tx_2, + tx_hash_3 => sent_tx_3 + ) + ); + } + + #[test] + fn failure_cache_insert_and_get_methods_single_record() { + let mut subject = RecheckRequiringFailures::new(); + let failed_tx = make_failed_tx(567); + let tx_hash = failed_tx.hash; + let records = vec![failed_tx.clone()]; + let state_before = subject.failures.clone(); + subject.load_cache(records); + + let first_attempt = subject.get_record_by_hash(tx_hash); + let second_attempt = subject.get_record_by_hash(tx_hash); + + assert_eq!(state_before, hashmap!()); + assert_eq!(first_attempt, Some(failed_tx)); + assert_eq!(second_attempt, None); + assert!( + subject.failures.is_empty(), + "Should be empty but was {:?}", + subject.failures + ); + } + + #[test] + fn failure_cache_insert_and_get_methods_multiple_records() { + let mut subject = RecheckRequiringFailures::new(); + let failed_tx_1 = make_failed_tx(123); + let tx_hash_1 = failed_tx_1.hash; + let failed_tx_2 = make_failed_tx(456); + let tx_hash_2 = failed_tx_2.hash; + let failed_tx_3 = make_failed_tx(789); + let tx_hash_3 = failed_tx_3.hash; + let failed_tx_4 = make_failed_tx(101); + let tx_hash_4 = failed_tx_4.hash; + let nonexistent_tx_hash = make_tx_hash(234); + let records = vec![ + failed_tx_1.clone(), + failed_tx_2.clone(), + failed_tx_3.clone(), + failed_tx_4.clone(), + ]; + + let first_query = subject.get_record_by_hash(tx_hash_1); + subject.load_cache(records); + let second_query = subject.get_record_by_hash(nonexistent_tx_hash); + let third_query = subject.get_record_by_hash(tx_hash_2); + let fourth_query = subject.get_record_by_hash(tx_hash_1); + let fifth_query = subject.get_record_by_hash(tx_hash_4); + let sixth_query = subject.get_record_by_hash(tx_hash_1); + let seventh_query = subject.get_record_by_hash(tx_hash_1); + let eighth_query = subject.get_record_by_hash(tx_hash_3); + + assert_eq!(first_query, None); + assert_eq!(second_query, None); + assert_eq!(third_query, Some(failed_tx_2)); + assert_eq!(fourth_query, Some(failed_tx_1)); + assert_eq!(fifth_query, Some(failed_tx_4)); + assert_eq!(sixth_query, None); + assert_eq!(seventh_query, None); + assert_eq!(eighth_query, Some(failed_tx_3)); + assert!( + subject.failures.is_empty(), + "Expected empty cache, but got {:?}", + subject.failures + ); + } + + #[test] + fn failure_cache_ensure_empty_happy_path() { + init_test_logging(); + let test_name = "failure_cache_ensure_empty_happy_path"; + let mut subject = RecheckRequiringFailures::new(); + let failed_tx = make_failed_tx(567); + let tx_hash = failed_tx.hash; + let records = vec![failed_tx.clone()]; + let logger = Logger::new(test_name); + subject.load_cache(records); + let _ = subject.get_record_by_hash(tx_hash); + + subject.ensure_empty_cache(&logger); + + assert!( + subject.failures.is_empty(), + "Should be empty by now but was {:?}", + subject.failures + ); + TestLogHandler::default().exists_no_log_containing(&format!( + "DEBUG: {test_name}: \ + Cache misuse - some tx failures left unprocessed:" + )); + } + + #[test] + fn failure_cache_ensure_empty_sad_path() { + init_test_logging(); + let test_name = "failure_cache_ensure_empty_sad_path"; + let mut subject = RecheckRequiringFailures::new(); + let failed_tx = make_failed_tx(0x567); + let records = vec![failed_tx.clone()]; + let logger = Logger::new(test_name); + subject.load_cache(records); + + subject.ensure_empty_cache(&logger); + + assert!( + subject.failures.is_empty(), + "Should be empty by now but was {:?}", + subject.failures + ); + TestLogHandler::default().exists_log_containing(&format!( + "DEBUG: {test_name}: \ + Cache misuse - some tx failures left unprocessed: \ + {{0x0000000000000000000000000000000000000000000000000000000000000567: FailedTx {{ hash: \ + 0x0000000000000000000000000000000000000000000000000000000000000567, receiver_address: \ + 0x00000000000000000003cc0000000003cc000000, amount_minor: 3658379210721, timestamp: \ + 275427216, gas_price_minor: 2645248887, nonce: 1383, reason: PendingTooLong, status: \ + RetryRequired }}}}. Dumping." + )); + } + + #[test] + fn failure_cache_dump_works() { + let mut subject = RecheckRequiringFailures::new(); + let failed_tx_1 = make_failed_tx(567); + let tx_hash_1 = failed_tx_1.hash; + let failed_tx_2 = make_failed_tx(456); + let tx_hash_2 = failed_tx_2.hash; + let failed_tx_3 = make_failed_tx(789); + let tx_hash_3 = failed_tx_3.hash; + let records = vec![ + failed_tx_1.clone(), + failed_tx_2.clone(), + failed_tx_3.clone(), + ]; + subject.load_cache(records); + + let result = subject.dump_cache(); + + assert_eq!( + result, + hashmap! ( + tx_hash_1 => failed_tx_1, + tx_hash_2 => failed_tx_2, + tx_hash_3 => failed_tx_3 + ) + ); + } + + #[test] + fn failed_validation_new_status_works_for_tx_statuses() { + let timestamp_a = SystemTime::now(); + let timestamp_b = SystemTime::now().sub(Duration::from_secs(11)); + let timestamp_c = SystemTime::now().sub(Duration::from_secs(22)); + let clock = SimpleClockMock::default() + .now_result(timestamp_a) + .now_result(timestamp_c); + let cases = vec![ + ( + FailedValidation::new( + make_tx_hash(123), + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Internal)), + TxStatus::Pending(ValidationStatus::Waiting), + ), + Some(TxStatus::Pending(ValidationStatus::Reattempting( + PreviousAttempts::new( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local( + LocalErrorKind::Internal, + )), + &SimpleClockMock::default().now_result(timestamp_a), + ), + ))), + ), + ( + FailedValidation::new( + make_tx_hash(123), + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote( + RemoteErrorKind::Unreachable, + )), + TxStatus::Pending(ValidationStatus::Reattempting( + PreviousAttempts::new( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local( + LocalErrorKind::Internal, + )), + &SimpleClockMock::default().now_result(timestamp_b), + ) + .add_attempt( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local( + LocalErrorKind::Internal, + )), + &SimpleClockReal::default(), + ), + )), + ), + Some(TxStatus::Pending(ValidationStatus::Reattempting( + PreviousAttempts::new( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote( + RemoteErrorKind::Unreachable, + )), + &SimpleClockMock::default().now_result(timestamp_c), + ) + .add_attempt( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local( + LocalErrorKind::Internal, + )), + &SimpleClockMock::default().now_result(timestamp_b), + ) + .add_attempt( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local( + LocalErrorKind::Internal, + )), + &SimpleClockReal::default(), + ), + ))), + ), + ]; + + cases.into_iter().for_each(|(input, expected)| { + assert_eq!(input.new_status(&clock), expected); + }); + } + + #[test] + fn failed_validation_new_status_works_for_failure_statuses() { + let timestamp_a = SystemTime::now().sub(Duration::from_secs(222)); + let timestamp_b = SystemTime::now().sub(Duration::from_secs(3333)); + let timestamp_c = SystemTime::now().sub(Duration::from_secs(44444)); + let clock = SimpleClockMock::default() + .now_result(timestamp_a) + .now_result(timestamp_b); + let cases = vec![ + ( + FailedValidation::new( + make_tx_hash(456), + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Internal)), + FailureStatus::RecheckRequired(ValidationStatus::Waiting), + ), + Some(FailureStatus::RecheckRequired( + ValidationStatus::Reattempting(PreviousAttempts::new( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local( + LocalErrorKind::Internal, + )), + &SimpleClockMock::default().now_result(timestamp_a), + )), + )), + ), + ( + FailedValidation::new( + make_tx_hash(456), + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote( + RemoteErrorKind::Unreachable, + )), + FailureStatus::RecheckRequired(ValidationStatus::Reattempting( + PreviousAttempts::new( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote( + RemoteErrorKind::Unreachable, + )), + &SimpleClockMock::default().now_result(timestamp_b), + ) + .add_attempt( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote( + RemoteErrorKind::InvalidResponse, + )), + &SimpleClockMock::default().now_result(timestamp_c), + ), + )), + ), + Some(FailureStatus::RecheckRequired( + ValidationStatus::Reattempting( + PreviousAttempts::new( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote( + RemoteErrorKind::Unreachable, + )), + &SimpleClockMock::default().now_result(timestamp_b), + ) + .add_attempt( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote( + RemoteErrorKind::InvalidResponse, + )), + &SimpleClockMock::default().now_result(timestamp_c), + ) + .add_attempt( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote( + RemoteErrorKind::Unreachable, + )), + &SimpleClockReal::default(), + ), + ), + )), + ), + ]; + + cases.into_iter().for_each(|(input, expected)| { + assert_eq!(input.new_status(&clock), expected); + }) + } + + #[test] + fn failed_validation_new_status_has_no_effect_on_unexpected_tx_status() { + let validation_failure_clock = SimpleClockMock::default(); + let mal_validated_tx_status = FailedValidation::new( + make_tx_hash(123), + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Internal)), + TxStatus::Confirmed { + block_hash: "".to_string(), + block_number: 0, + detection: Detection::Normal, + }, + ); + + assert_eq!( + mal_validated_tx_status.new_status(&validation_failure_clock), + None + ); + } + + #[test] + fn failed_validation_new_status_has_no_effect_on_unexpected_failure_status() { + let validation_failure_clock = SimpleClockMock::default(); + let mal_validated_failure_statuses = vec![ + FailedValidation::new( + make_tx_hash(456), + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Internal)), + FailureStatus::RetryRequired, + ), + FailedValidation::new( + make_tx_hash(789), + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Remote(RemoteErrorKind::Unreachable)), + FailureStatus::Concluded, + ), + ]; + + mal_validated_failure_statuses + .into_iter() + .enumerate() + .for_each(|(idx, failed_validation)| { + let result = failed_validation.new_status(&validation_failure_clock); + assert_eq!( + result, None, + "Failed validation should evaluate to 'None' but was '{:?}' for idx: {}", + result, idx + ) + }); + } + + #[test] + fn tx_hash_by_table_provides_plain_hash() { + let expected_hash_a = make_tx_hash(123); + let a = TxHashByTable::SentPayable(expected_hash_a); + let expected_hash_b = make_tx_hash(654); + let b = TxHashByTable::FailedPayable(expected_hash_b); + + let result_a = a.hash(); + let result_b = b.hash(); + + assert_eq!(result_a, expected_hash_a); + assert_eq!(result_b, expected_hash_b); + } + + #[test] + fn tx_by_table_can_provide_hash() { + let sent_tx = make_sent_tx(123); + let expected_hash_a = sent_tx.hash; + let a = TxByTable::SentPayable(sent_tx); + let failed_tx = make_failed_tx(654); + let expected_hash_b = failed_tx.hash; + let b = TxByTable::FailedPayable(failed_tx); + + let result_a = a.hash(); + let result_b = b.hash(); + + assert_eq!(result_a, expected_hash_a); + assert_eq!(result_b, expected_hash_b); + } + + #[test] + fn tx_by_table_can_be_converted_into_tx_hash_by_table() { + let sent_tx = make_sent_tx(123); + let expected_hash_a = sent_tx.hash; + let a = TxByTable::SentPayable(sent_tx); + let failed_tx = make_failed_tx(654); + let expected_hash_b = failed_tx.hash; + let b = TxByTable::FailedPayable(failed_tx); + + let result_a = TxHashByTable::from(&a); + let result_b = TxHashByTable::from(&b); + + assert_eq!(result_a, TxHashByTable::SentPayable(expected_hash_a)); + assert_eq!(result_b, TxHashByTable::FailedPayable(expected_hash_b)); + } + + #[test] + fn tx_hash_by_table_ordering_works_correctly() { + let tx_1 = TxHashByTable::SentPayable(make_tx_hash(123)); + let tx_2 = TxHashByTable::FailedPayable(make_tx_hash(333)); + let tx_3 = TxHashByTable::SentPayable(make_tx_hash(654)); + let tx_4 = TxHashByTable::FailedPayable(make_tx_hash(222)); + let tx_1_identical = tx_1; + let tx_2_identical = tx_2; + + let mut set = BTreeSet::new(); + vec![tx_1.clone(), tx_2.clone(), tx_3.clone(), tx_4.clone()] + .into_iter() + .for_each(|tx| { + set.insert(tx); + }); + + let expected_order = vec![tx_4, tx_2, tx_1, tx_3]; + assert_eq!(set.into_iter().collect::>(), expected_order); + assert_eq!(tx_1.cmp(&tx_1_identical), Ordering::Equal); + assert_eq!(tx_2.cmp(&tx_2_identical), Ordering::Equal); + } +} diff --git a/node/src/accountant/scanners/receivable_scanner/mod.rs b/node/src/accountant/scanners/receivable_scanner/mod.rs new file mode 100644 index 0000000000..eff0df95eb --- /dev/null +++ b/node/src/accountant/scanners/receivable_scanner/mod.rs @@ -0,0 +1,195 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. + +pub mod utils; + +use crate::accountant::db_access_objects::banned_dao::BannedDao; +use crate::accountant::db_access_objects::receivable_dao::ReceivableDao; +use crate::accountant::scanners::receivable_scanner::utils::balance_and_age; +use crate::accountant::scanners::{ + PrivateScanner, Scanner, ScannerCommon, StartScanError, StartableScanner, +}; +use crate::accountant::{ReceivedPayments, ResponseSkeleton, ScanForReceivables}; +use crate::blockchain::blockchain_bridge::{BlockMarker, RetrieveTransactions}; +use crate::db_config::persistent_configuration::PersistentConfiguration; +use crate::sub_lib::accountant::{FinancialStatistics, PaymentThresholds}; +use crate::sub_lib::wallet::Wallet; +use crate::time_marking_methods; +use masq_lib::logger::Logger; +use masq_lib::messages::{ScanType, ToMessageBody, UiScanResponse}; +use masq_lib::ui_gateway::{MessageTarget, NodeToUiMessage}; +use std::cell::RefCell; +use std::rc::Rc; +use std::time::SystemTime; + +pub struct ReceivableScanner { + pub common: ScannerCommon, + pub receivable_dao: Box, + pub banned_dao: Box, + pub persistent_configuration: Box, + pub financial_statistics: Rc>, +} + +impl + PrivateScanner< + ScanForReceivables, + RetrieveTransactions, + ReceivedPayments, + Option, + > for ReceivableScanner +{ +} + +impl StartableScanner for ReceivableScanner { + fn start_scan( + &mut self, + earning_wallet: &Wallet, + timestamp: SystemTime, + response_skeleton_opt: Option, + logger: &Logger, + ) -> Result { + self.mark_as_started(timestamp); + info!(logger, "Scanning for receivables to {}", earning_wallet); + self.scan_for_delinquencies(timestamp, logger); + + Ok(RetrieveTransactions { + recipient: earning_wallet.clone(), + response_skeleton_opt, + }) + } +} + +impl Scanner> for ReceivableScanner { + fn finish_scan(&mut self, msg: ReceivedPayments, logger: &Logger) -> Option { + self.handle_new_received_payments(&msg, logger); + self.mark_as_ended(logger); + + msg.response_skeleton_opt + .map(|response_skeleton| NodeToUiMessage { + target: MessageTarget::ClientId(response_skeleton.client_id), + body: UiScanResponse {}.tmb(response_skeleton.context_id), + }) + } + + time_marking_methods!(Receivables); + + as_any_ref_in_trait_impl!(); + as_any_mut_in_trait_impl!(); +} + +impl ReceivableScanner { + pub fn new( + receivable_dao: Box, + banned_dao: Box, + persistent_configuration: Box, + payment_thresholds: Rc, + financial_statistics: Rc>, + ) -> Self { + Self { + common: ScannerCommon::new(payment_thresholds), + receivable_dao, + banned_dao, + persistent_configuration, + financial_statistics, + } + } + + fn handle_new_received_payments( + &mut self, + received_payments_msg: &ReceivedPayments, + logger: &Logger, + ) { + if received_payments_msg.transactions.is_empty() { + info!( + logger, + "No newly received payments were detected during the scanning process." + ); + let new_start_block = received_payments_msg.new_start_block; + if let BlockMarker::Value(start_block_number) = new_start_block { + match self + .persistent_configuration + .set_start_block(Some(start_block_number)) + { + Ok(()) => debug!(logger, "Start block updated to {}", start_block_number), + Err(e) => panic!( + "Attempt to advance the start block to {} failed due to: {:?}", + start_block_number, e + ), + } + } + } else { + let mut txn = self.receivable_dao.as_mut().more_money_received( + received_payments_msg.timestamp, + &received_payments_msg.transactions, + ); + let new_start_block = received_payments_msg.new_start_block; + if let BlockMarker::Value(start_block_number) = new_start_block { + match self + .persistent_configuration + .set_start_block_from_txn(Some(start_block_number), &mut txn) + { + Ok(()) => debug!(logger, "Start block updated to {}", start_block_number), + Err(e) => panic!( + "Attempt to set new start block to {} failed due to: {:?}", + start_block_number, e + ), + } + } else { + unreachable!("Failed to get start_block while transactions were present"); + } + match txn.commit() { + Ok(_) => { + debug!(logger, "Received payments have been commited to database"); + } + Err(e) => panic!("Commit of received transactions failed: {:?}", e), + } + let total_newly_paid_receivable = received_payments_msg + .transactions + .iter() + .fold(0, |so_far, now| so_far + now.wei_amount); + + self.financial_statistics + .borrow_mut() + .total_paid_receivable_wei += total_newly_paid_receivable; + } + } + + pub fn scan_for_delinquencies(&self, timestamp: SystemTime, logger: &Logger) { + info!(logger, "Scanning for delinquencies"); + self.find_and_ban_delinquents(timestamp, logger); + self.find_and_unban_reformed_nodes(timestamp, logger); + } + + fn find_and_ban_delinquents(&self, timestamp: SystemTime, logger: &Logger) { + self.receivable_dao + .new_delinquencies(timestamp, self.common.payment_thresholds.as_ref()) + .into_iter() + .for_each(|account| { + self.banned_dao.ban(&account.wallet); + let (balance_str_wei, age) = balance_and_age(timestamp, &account); + info!( + logger, + "Wallet {} (balance: {} gwei, age: {} sec) banned for delinquency", + account.wallet, + balance_str_wei, + age.as_secs() + ) + }); + } + + fn find_and_unban_reformed_nodes(&self, timestamp: SystemTime, logger: &Logger) { + self.receivable_dao + .paid_delinquencies(self.common.payment_thresholds.as_ref()) + .into_iter() + .for_each(|account| { + self.banned_dao.unban(&account.wallet); + let (balance_str_wei, age) = balance_and_age(timestamp, &account); + info!( + logger, + "Wallet {} (balance: {} gwei, age: {} sec) is no longer delinquent: unbanned", + account.wallet, + balance_str_wei, + age.as_secs() + ) + }); + } +} diff --git a/node/src/accountant/scanners/receivable_scanner/utils.rs b/node/src/accountant/scanners/receivable_scanner/utils.rs new file mode 100644 index 0000000000..45c8f68005 --- /dev/null +++ b/node/src/accountant/scanners/receivable_scanner/utils.rs @@ -0,0 +1,39 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. + +use crate::accountant::db_access_objects::receivable_dao::ReceivableAccount; +use crate::accountant::wei_to_gwei; +use std::time::{Duration, SystemTime}; +use thousands::Separable; + +pub fn balance_and_age(time: SystemTime, account: &ReceivableAccount) -> (String, Duration) { + let balance = wei_to_gwei::(account.balance_wei).separate_with_commas(); + let age = time + .duration_since(account.last_received_timestamp) + .unwrap_or_else(|_| Duration::new(0, 0)); + (balance, age) +} + +#[cfg(test)] +mod tests { + use crate::accountant::db_access_objects::receivable_dao::ReceivableAccount; + use crate::accountant::db_access_objects::utils::{from_unix_timestamp, to_unix_timestamp}; + use crate::accountant::scanners::receivable_scanner::utils::balance_and_age; + use crate::test_utils::make_wallet; + use std::time::SystemTime; + + #[test] + fn balance_and_age_is_calculated_as_expected() { + let now = SystemTime::now(); + let offset = 1000; + let receivable_account = ReceivableAccount { + wallet: make_wallet("wallet0"), + balance_wei: 10_000_000_000, + last_received_timestamp: from_unix_timestamp(to_unix_timestamp(now) - offset), + }; + + let (balance, age) = balance_and_age(now, &receivable_account); + + assert_eq!(balance, "10"); + assert_eq!(age.as_secs(), offset as u64); + } +} diff --git a/node/src/accountant/scanners/scan_schedulers.rs b/node/src/accountant/scanners/scan_schedulers.rs new file mode 100644 index 0000000000..97a3edd62e --- /dev/null +++ b/node/src/accountant/scanners/scan_schedulers.rs @@ -0,0 +1,1047 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. + +use crate::accountant::scanners::StartScanError; +use crate::accountant::{ + Accountant, ResponseSkeleton, ScanForNewPayables, ScanForPendingPayables, ScanForReceivables, + ScanForRetryPayables, +}; +use crate::sub_lib::accountant::ScanIntervals; +use crate::sub_lib::utils::{ + NotifyHandle, NotifyHandleReal, NotifyLaterHandle, NotifyLaterHandleReal, +}; +use actix::{Actor, Context, Handler}; +use masq_lib::logger::Logger; +use masq_lib::messages::ScanType; +use masq_lib::simple_clock::{SimpleClock, SimpleClockReal}; +use std::fmt::{Debug, Display, Formatter}; +use std::ops::Div; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +pub struct ScanSchedulers { + pub payable: PayableScanScheduler, + pub pending_payable: SimplePeriodicalScanScheduler, + pub receivable: SimplePeriodicalScanScheduler, + pub reschedule_on_error_resolver: Box, + pub automatic_scans_enabled: bool, +} + +impl ScanSchedulers { + pub fn new(scan_intervals: ScanIntervals, automatic_scans_enabled: bool) -> Self { + Self { + payable: PayableScanScheduler::new(scan_intervals.payable_scan_interval), + pending_payable: SimplePeriodicalScanScheduler::new( + scan_intervals.pending_payable_scan_interval, + ), + receivable: SimplePeriodicalScanScheduler::new(scan_intervals.receivable_scan_interval), + reschedule_on_error_resolver: Box::new(RescheduleScanOnErrorResolverReal::default()), + automatic_scans_enabled, + } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum PayableScanSchedulerError { + ScanForNewPayableAlreadyScheduled, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum ScanReschedulingAfterEarlyStop { + Schedule(ScanType), + DoNotSchedule, +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum PayableSequenceScanner { + NewPayables, + RetryPayables, + PendingPayables { initial_pending_payable_scan: bool }, +} + +impl Display for PayableSequenceScanner { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + PayableSequenceScanner::NewPayables => write!(f, "NewPayables"), + PayableSequenceScanner::RetryPayables => write!(f, "RetryPayables"), + PayableSequenceScanner::PendingPayables { .. } => write!(f, "PendingPayables"), + } + } +} + +impl From for ScanType { + fn from(scanner: PayableSequenceScanner) -> Self { + match scanner { + PayableSequenceScanner::NewPayables => ScanType::Payables, + PayableSequenceScanner::RetryPayables => ScanType::Payables, + PayableSequenceScanner::PendingPayables { .. } => ScanType::PendingPayables, + } + } +} + +pub struct PayableScanScheduler { + pub new_payable_notify_later: Box>, + pub interval_computer: Box, + pub new_payable_notify: Box>, + pub retry_payable_notify_later: Box>, + pub retry_payable_scan_interval: Duration, +} + +impl PayableScanScheduler { + fn new(payable_scan_interval: Duration) -> Self { + Self { + new_payable_notify_later: Box::new(NotifyLaterHandleReal::default()), + interval_computer: Box::new(NewPayableScanIntervalComputerReal::new( + payable_scan_interval, + )), + new_payable_notify: Box::new(NotifyHandleReal::default()), + retry_payable_notify_later: Box::new(NotifyLaterHandleReal::default()), + retry_payable_scan_interval: payable_scan_interval.div(2), + } + } + + pub fn schedule_new_payable_scan(&self, ctx: &mut Context, logger: &Logger) { + if let ScanTiming::WaitFor(interval) = self.interval_computer.time_until_next_scan() { + debug!( + logger, + "Scheduling a new-payable scan in {}ms", + interval.as_millis() + ); + + let _ = self.new_payable_notify_later.notify_later( + ScanForNewPayables { + response_skeleton_opt: None, + }, + interval, + ctx, + ); + } else { + debug!(logger, "Scheduling a new-payable scan asap"); + + self.new_payable_notify.notify( + ScanForNewPayables { + response_skeleton_opt: None, + }, + ctx, + ); + } + } + + pub fn reset_scan_timer(&mut self, logger: &Logger) { + debug!(logger, "NewPayableScanIntervalComputer timer reset"); + self.interval_computer.reset_last_scan_timestamp(); + } + + // This message ships into the Accountant's mailbox with no delay. + // Can also be triggered by command, following up after the PendingPayableScanner + // that requests it. That's why the response skeleton is possible to be used. + pub fn schedule_retry_payable_scan( + &self, + ctx: &mut Context, + response_skeleton_opt: Option, + logger: &Logger, + ) { + debug!(logger, "Scheduling a retry-payable scan asap"); + let delay = self.retry_payable_scan_interval; + + let _ = self.retry_payable_notify_later.notify_later( + ScanForRetryPayables { + response_skeleton_opt, + }, + delay, + ctx, + ); + } +} + +pub trait NewPayableScanIntervalComputer { + fn time_until_next_scan(&self) -> ScanTiming; + + fn reset_last_scan_timestamp(&mut self); + + as_any_ref_in_trait!(); +} + +pub struct NewPayableScanIntervalComputerReal { + scan_interval: Duration, + last_scan_timestamp: SystemTime, + clock: Box, +} + +impl NewPayableScanIntervalComputer for NewPayableScanIntervalComputerReal { + fn time_until_next_scan(&self) -> ScanTiming { + let current_time = self.clock.now(); + let time_since_last_scan = current_time + .duration_since(self.last_scan_timestamp) + .unwrap_or_else(|_| { + panic!( + "Current time ({:?}) is earlier than last scan timestamp ({:?})", + current_time, self.last_scan_timestamp + ) + }); + + if time_since_last_scan >= self.scan_interval { + ScanTiming::ReadyNow + } else { + ScanTiming::WaitFor(self.scan_interval - time_since_last_scan) + } + } + + fn reset_last_scan_timestamp(&mut self) { + self.last_scan_timestamp = SystemTime::now(); + } + + as_any_ref_in_trait_impl!(); +} + +impl NewPayableScanIntervalComputerReal { + pub fn new(scan_interval: Duration) -> Self { + Self { + scan_interval, + last_scan_timestamp: UNIX_EPOCH, + clock: Box::new(SimpleClockReal::default()), + } + } +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum ScanTiming { + ReadyNow, + WaitFor(Duration), +} + +pub struct SimplePeriodicalScanScheduler { + pub handle: Box>, + pub interval: Duration, +} + +impl SimplePeriodicalScanScheduler +where + Message: actix::Message + Default + Debug + Send + 'static, + Accountant: Actor + Handler, +{ + fn new(interval: Duration) -> Self { + Self { + handle: Box::new(NotifyLaterHandleReal::default()), + interval, + } + } + pub fn schedule(&self, ctx: &mut Context, logger: &Logger) { + // The default of the message implies response_skeleton_opt to be None because scheduled + // scans don't respond + let msg = Message::default(); + + debug!( + logger, + "Scheduling a scan via {:?} in {}ms", + msg, + self.interval.as_millis() + ); + + let _ = self.handle.notify_later(msg, self.interval, ctx); + } +} + +// In a scan sequence incorporating different scanners, one makes another dependent on the previous +// one. Such scanners must be handling StartScanErrors delicately with the regard to ensuring +// further continuity and periodicity of this process. Where possible, either the same one, some +// tightly related, or even a totally unrelated scan, just for the event of emergency, should be +// scheduled. The intention is to prevent panics while not creating any harmful conditions for +// the scans running in the future. Following this philosophy, panics should be restricted just +// to so-believed unreachable conditions (by the intended design). +pub trait RescheduleScanOnErrorResolver { + fn resolve_rescheduling_on_error( + &self, + scanner: PayableSequenceScanner, + error: &StartScanError, + is_externally_triggered: bool, + logger: &Logger, + ) -> ScanReschedulingAfterEarlyStop; +} + +#[derive(Default)] +pub struct RescheduleScanOnErrorResolverReal {} + +impl RescheduleScanOnErrorResolver for RescheduleScanOnErrorResolverReal { + fn resolve_rescheduling_on_error( + &self, + scanner: PayableSequenceScanner, + error: &StartScanError, + is_externally_triggered: bool, + logger: &Logger, + ) -> ScanReschedulingAfterEarlyStop { + let reschedule_hint = match scanner { + PayableSequenceScanner::NewPayables => { + Self::resolve_new_payables(error, is_externally_triggered) + } + PayableSequenceScanner::RetryPayables => { + Self::resolve_retry_payables(error, is_externally_triggered) + } + PayableSequenceScanner::PendingPayables { + initial_pending_payable_scan, + } => Self::resolve_pending_payables( + error, + initial_pending_payable_scan, + is_externally_triggered, + ), + }; + + Self::log_rescheduling(scanner, is_externally_triggered, logger, &reschedule_hint); + + reschedule_hint + } +} + +impl RescheduleScanOnErrorResolverReal { + fn resolve_new_payables( + err: &StartScanError, + is_externally_triggered: bool, + ) -> ScanReschedulingAfterEarlyStop { + if is_externally_triggered { + ScanReschedulingAfterEarlyStop::DoNotSchedule + } else if matches!(err, StartScanError::ScanAlreadyRunning { .. }) { + unreachable!( + "an automatic scan of NewPayableScanner should never interfere with itself {:?}", + err + ) + } else { + ScanReschedulingAfterEarlyStop::Schedule(ScanType::Payables) + } + } + + // Paradoxical at first, but this scanner is meant to be shielded by the scanner right before + // it. That should ensure this scanner will not be requested if there was already something + // fishy. We can impose strictness. + fn resolve_retry_payables( + err: &StartScanError, + is_externally_triggered: bool, + ) -> ScanReschedulingAfterEarlyStop { + if is_externally_triggered { + ScanReschedulingAfterEarlyStop::DoNotSchedule + } else { + unreachable!( + "{:?} should be impossible with RetryPayableScanner in automatic mode", + err + ) + } + } + + fn resolve_pending_payables( + err: &StartScanError, + initial_pending_payable_scan: bool, + is_externally_triggered: bool, + ) -> ScanReschedulingAfterEarlyStop { + if is_externally_triggered { + ScanReschedulingAfterEarlyStop::DoNotSchedule + } else if err == &StartScanError::NothingToProcess { + if initial_pending_payable_scan { + ScanReschedulingAfterEarlyStop::Schedule(ScanType::Payables) + } else { + unreachable!( + "the automatic pending payable scan should always be requested only in need, \ + which contradicts the current StartScanError::NothingToProcess" + ) + } + } else if err == &StartScanError::NoConsumingWalletFound { + if initial_pending_payable_scan { + // Cannot deduce there are strayed pending payables from the previous Node's run + // (StartScanError::NoConsumingWalletFound is thrown before + // StartScanError::NothingToProcess can be evaluated); but may be cautious and + // prevent starting the NewPayableScanner. Repeating this scan endlessly may alarm + // the user. + // TODO Correctly, a check-point during the bootstrap, not allowing to come + // this far, should be the solution. Part of the issue mentioned in GH-799 + ScanReschedulingAfterEarlyStop::Schedule(ScanType::PendingPayables) + } else { + unreachable!( + "PendingPayableScanner called later than the initial attempt, but \ + the consuming wallet is still missing; this should not be possible" + ) + } + } else { + unreachable!( + "{:?} should be impossible with PendingPayableScanner in automatic mode", + err + ) + } + } + + fn log_rescheduling( + scanner: PayableSequenceScanner, + is_externally_triggered: bool, + logger: &Logger, + reschedule_hint: &ScanReschedulingAfterEarlyStop, + ) { + let scan_mode = if is_externally_triggered { + "Manual" + } else { + "Automatic" + }; + + debug!( + logger, + "{} {} scan failed - rescheduling strategy: \"{:?}\"", + scan_mode, + scanner, + reschedule_hint + ); + } +} + +#[cfg(test)] +mod tests { + use crate::accountant::scanners::scan_schedulers::{ + NewPayableScanIntervalComputer, NewPayableScanIntervalComputerReal, PayableSequenceScanner, + ScanReschedulingAfterEarlyStop, ScanSchedulers, ScanTiming, + }; + use crate::accountant::scanners::test_utils::NewPayableScanIntervalComputerMock; + use crate::accountant::scanners::{ManulTriggerError, StartScanError}; + use crate::sub_lib::accountant::ScanIntervals; + use crate::test_utils::unshared_test_utils::TEST_SCAN_INTERVALS; + use itertools::Itertools; + use lazy_static::lazy_static; + use masq_lib::logger::Logger; + use masq_lib::messages::ScanType; + use masq_lib::test_utils::logging::{init_test_logging, TestLogHandler}; + use masq_lib::test_utils::simple_clock::SimpleClockMock; + use masq_lib::test_utils::utils::TEST_DEFAULT_CHAIN; + use std::panic::{catch_unwind, AssertUnwindSafe}; + use std::sync::{Arc, Mutex}; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + #[test] + fn scan_schedulers_are_initialized_correctly() { + let scan_intervals = ScanIntervals { + payable_scan_interval: Duration::from_secs(14), + pending_payable_scan_interval: Duration::from_secs(2), + receivable_scan_interval: Duration::from_secs(7), + }; + let automatic_scans_enabled = true; + + let schedulers = ScanSchedulers::new(scan_intervals, automatic_scans_enabled); + + let payable_interval_computer = schedulers + .payable + .interval_computer + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!( + payable_interval_computer.scan_interval, + scan_intervals.payable_scan_interval + ); + assert_eq!(payable_interval_computer.last_scan_timestamp, UNIX_EPOCH); + assert_eq!( + schedulers.pending_payable.interval, + scan_intervals.pending_payable_scan_interval + ); + assert_eq!( + schedulers.receivable.interval, + scan_intervals.receivable_scan_interval + ); + assert_eq!(schedulers.automatic_scans_enabled, automatic_scans_enabled) + } + + #[test] + fn scan_interval_computer_computes_remaining_time_to_standard_interval_correctly() { + let (clock, now) = set_up_mocked_clock(); + let inputs = vec![ + ( + now.checked_sub(Duration::from_secs(32)).unwrap(), + Duration::from_secs(100), + Duration::from_secs(68), + ), + ( + now.checked_sub(Duration::from_millis(1111)).unwrap(), + Duration::from_millis(3333), + Duration::from_millis(2222), + ), + ( + now.checked_sub(Duration::from_secs(200)).unwrap(), + Duration::from_secs(204), + Duration::from_secs(4), + ), + ]; + let mut subject = initialize_scan_interval_computer(); + subject.clock = Box::new(clock); + + inputs + .into_iter() + .for_each(|(past_instant, standard_interval, expected_result)| { + subject.scan_interval = standard_interval; + subject.last_scan_timestamp = past_instant; + + let result = subject.time_until_next_scan(); + + assert_eq!( + result, + ScanTiming::WaitFor(expected_result), + "We expected Some({}) ms, but got {:?} ms", + expected_result.as_millis(), + from_scan_timing_to_millis(result) + ) + }) + } + + #[test] + fn scan_interval_computer_realizes_the_standard_interval_has_been_exceeded() { + let (clock, now) = set_up_mocked_clock(); + let inputs = vec![ + ( + now.checked_sub(Duration::from_millis(32001)).unwrap(), + Duration::from_secs(32), + ), + ( + now.checked_sub(Duration::from_secs(200)).unwrap(), + Duration::from_secs(123), + ), + ]; + let mut subject = initialize_scan_interval_computer(); + subject.clock = Box::new(clock); + + inputs + .into_iter() + .enumerate() + .for_each(|(idx, (past_instant, standard_interval))| { + subject.scan_interval = standard_interval; + subject.last_scan_timestamp = past_instant; + + let result = subject.time_until_next_scan(); + + assert_eq!( + result, + ScanTiming::ReadyNow, + "We expected None ms, but got {:?} ms at idx {}", + from_scan_timing_to_millis(result), + idx + ) + }) + } + + #[test] + fn scan_interval_computer_realizes_standard_interval_just_met() { + let now = SystemTime::now(); + let mut subject = initialize_scan_interval_computer(); + subject.last_scan_timestamp = now.checked_sub(Duration::from_secs(180)).unwrap(); + subject.scan_interval = Duration::from_secs(180); + subject.clock = Box::new(SimpleClockMock::default().now_result(now)); + + let result = subject.time_until_next_scan(); + + assert_eq!( + result, + ScanTiming::ReadyNow, + "We expected None ms, but got {:?} ms", + from_scan_timing_to_millis(result) + ) + } + + fn from_scan_timing_to_millis(scan_timing: ScanTiming) -> u128 { + if let ScanTiming::WaitFor(interval) = scan_timing { + interval.as_millis() + } else { + panic!("expected an interval") + } + } + + #[test] + #[cfg(windows)] + #[should_panic( + expected = "Current time (SystemTime { intervals: 116454736000000000 }) is earlier than last \ + scan timestamp (SystemTime { intervals: 116454736010000000 })" + )] + fn scan_interval_computer_panics() { + test_scan_interval_computer_panics() + } + + #[test] + #[cfg(not(windows))] + #[should_panic( + expected = "Current time (SystemTime { tv_sec: 1000000, tv_nsec: 0 }) is earlier than last \ + scan timestamp (SystemTime { tv_sec: 1000001, tv_nsec: 0 })" + )] + fn scan_interval_computer_panics() { + test_scan_interval_computer_panics() + } + + fn test_scan_interval_computer_panics() { + let now = UNIX_EPOCH + .checked_add(Duration::from_secs(1_000_000)) + .unwrap(); + let mut subject = initialize_scan_interval_computer(); + subject.clock = Box::new(SimpleClockMock::default().now_result(now)); + subject.last_scan_timestamp = now.checked_add(Duration::from_secs(1)).unwrap(); + + let _ = subject.time_until_next_scan(); + } + + #[test] + fn reset_last_scan_timestamp_works_for_default_subject() { + let mut subject = initialize_scan_interval_computer(); + let last_scan_timestamp_before = subject.last_scan_timestamp; + let before_act = SystemTime::now(); + + subject.reset_last_scan_timestamp(); + + let after_act = SystemTime::now(); + let last_scan_timestamp_after = subject.last_scan_timestamp; + assert_eq!(last_scan_timestamp_before, UNIX_EPOCH); + assert!( + before_act <= last_scan_timestamp_after && last_scan_timestamp_after <= after_act, + "we expected the last_scan_timestamp to be reset to now, but it was not" + ); + } + + #[test] + fn reset_last_scan_timestamp_works_for_general_subject() { + let mut subject = initialize_scan_interval_computer(); + subject.last_scan_timestamp = SystemTime::now() + .checked_sub(Duration::from_secs(100)) + .unwrap(); + let before_act = SystemTime::now(); + + subject.reset_last_scan_timestamp(); + + let after_act = SystemTime::now(); + let last_scan_timestamp_after = subject.last_scan_timestamp; + assert!( + before_act <= last_scan_timestamp_after && last_scan_timestamp_after <= after_act, + "we expected the last_scan_timestamp to be reset to now, but it was not" + ); + } + + #[test] + fn reset_scan_timer_works() { + let reset_last_scan_timestamp_params_arc = Arc::new(Mutex::new(vec![])); + let scan_intervals = ScanIntervals::compute_default(TEST_DEFAULT_CHAIN); + let mut subject = ScanSchedulers::new(scan_intervals, true); + subject.payable.interval_computer = Box::new( + NewPayableScanIntervalComputerMock::default() + .reset_last_scan_timestamp_params(&reset_last_scan_timestamp_params_arc), + ); + + subject.payable.reset_scan_timer(&Logger::new("test")); + + let reset_last_scan_timestamp_params = reset_last_scan_timestamp_params_arc.lock().unwrap(); + assert_eq!(*reset_last_scan_timestamp_params, vec![()]) + } + + fn initialize_scan_interval_computer() -> NewPayableScanIntervalComputerReal { + // The interval is just a garbage value, we reset it in the tests by injection if needed + NewPayableScanIntervalComputerReal::new(Duration::from_secs(100)) + } + + fn set_up_mocked_clock() -> (SimpleClockMock, SystemTime) { + let now = SystemTime::now(); + ( + (0..3).fold(SimpleClockMock::default(), |clock, _| clock.now_result(now)), + now, + ) + } + + lazy_static! { + static ref ALL_START_SCAN_ERRORS: Vec = { + + let candidates = vec![ + StartScanError::NothingToProcess, + StartScanError::NoConsumingWalletFound, + StartScanError::ScanAlreadyRunning { cross_scan_cause_opt: None, started_at: SystemTime::now()}, + StartScanError::ManualTriggerError(ManulTriggerError::AutomaticScanConflict), + StartScanError::CalledFromNullScanner + ]; + + + let mut check_vec = candidates + .iter() + .fold(vec![],|mut acc, current|{ + acc.push(ListOfStartScanErrors::number_variant(current)); + acc + }); + // Making sure we didn't count in one variant multiple times + check_vec.dedup(); + assert_eq!(check_vec.len(), StartScanError::VARIANT_COUNT, "The check on variant \ + exhaustiveness failed."); + candidates + }; + } + + struct ListOfStartScanErrors<'a> { + errors: Vec<&'a StartScanError>, + } + + impl<'a> Default for ListOfStartScanErrors<'a> { + fn default() -> Self { + Self { + errors: ALL_START_SCAN_ERRORS.iter().collect_vec(), + } + } + } + + impl<'a> ListOfStartScanErrors<'a> { + fn eliminate_already_tested_variants( + mut self, + errors_to_eliminate: Vec, + ) -> Self { + let error_variants_to_remove: Vec<_> = errors_to_eliminate + .iter() + .map(Self::number_variant) + .collect(); + self.errors + .retain(|err| !error_variants_to_remove.contains(&Self::number_variant(*err))); + self + } + + fn number_variant(error: &StartScanError) -> usize { + match error { + StartScanError::NothingToProcess => 1, + StartScanError::NoConsumingWalletFound => 2, + StartScanError::ScanAlreadyRunning { .. } => 3, + StartScanError::CalledFromNullScanner => 4, + StartScanError::ManualTriggerError(..) => 5, + } + } + } + + #[test] + fn resolve_rescheduling_on_error_works_for_pending_payables_if_externally_triggered() { + let subject = ScanSchedulers::new(*TEST_SCAN_INTERVALS, true); + let test_name = + "resolve_rescheduling_on_error_works_for_pending_payables_if_externally_triggered"; + + test_what_if_externally_triggered( + &format!("{}(initial_pending_payable_scan = false)", test_name), + &subject, + PayableSequenceScanner::PendingPayables { + initial_pending_payable_scan: false, + }, + ); + test_what_if_externally_triggered( + &format!("{}(initial_pending_payable_scan = true)", test_name), + &subject, + PayableSequenceScanner::PendingPayables { + initial_pending_payable_scan: true, + }, + ); + } + + fn test_what_if_externally_triggered( + test_name: &str, + subject: &ScanSchedulers, + scanner: PayableSequenceScanner, + ) { + init_test_logging(); + let logger = Logger::new(test_name); + let test_log_handler = TestLogHandler::new(); + ALL_START_SCAN_ERRORS + .iter() + .enumerate() + .for_each(|(idx, error)| { + let result = subject + .reschedule_on_error_resolver + .resolve_rescheduling_on_error(scanner, error, true, &logger); + + assert_eq!( + result, + ScanReschedulingAfterEarlyStop::DoNotSchedule, + "We expected DoNotSchedule but got {:?} at idx {} for {:?}", + result, + idx, + scanner + ); + test_log_handler.exists_log_containing(&format!( + "DEBUG: {test_name}: Manual {} scan failed - rescheduling strategy: \ + \"DoNotSchedule\"", + scanner + )); + }) + } + + #[test] + fn resolve_error_for_pending_payables_if_nothing_to_process_and_initial_pending_payable_scan_true( + ) { + init_test_logging(); + let subject = ScanSchedulers::new(*TEST_SCAN_INTERVALS, true); + let test_name = "resolve_error_for_pending_payables_if_nothing_to_process_and_initial_pending_payable_scan_true"; + let logger = Logger::new(test_name); + + let result = subject + .reschedule_on_error_resolver + .resolve_rescheduling_on_error( + PayableSequenceScanner::PendingPayables { + initial_pending_payable_scan: true, + }, + &StartScanError::NothingToProcess, + false, + &logger, + ); + + assert_eq!( + result, + ScanReschedulingAfterEarlyStop::Schedule(ScanType::Payables), + "We expected Schedule(Payables) but got {:?}", + result, + ); + TestLogHandler::new().exists_log_containing(&format!( + "DEBUG: {test_name}: Automatic PendingPayables scan failed - rescheduling strategy: \ + \"Schedule(Payables)\"" + )); + } + + #[test] + #[should_panic( + expected = "internal error: entered unreachable code: the automatic pending payable scan \ + should always be requested only in need, which contradicts the current \ + StartScanError::NothingToProcess" + )] + fn resolve_error_for_pending_payables_if_nothing_to_process_and_initial_pending_payable_scan_false( + ) { + let subject = ScanSchedulers::new(*TEST_SCAN_INTERVALS, true); + + let _ = subject + .reschedule_on_error_resolver + .resolve_rescheduling_on_error( + PayableSequenceScanner::PendingPayables { + initial_pending_payable_scan: false, + }, + &StartScanError::NothingToProcess, + false, + &Logger::new("test"), + ); + } + + #[test] + fn resolve_error_for_pending_p_if_no_consuming_wallet_found_in_initial_pending_payable_scan() { + init_test_logging(); + let test_name = "resolve_error_for_pending_p_if_no_consuming_wallet_found_in_initial_pending_payable_scan"; + let logger = Logger::new(test_name); + let subject = ScanSchedulers::new(*TEST_SCAN_INTERVALS, true); + let scanner = PayableSequenceScanner::PendingPayables { + initial_pending_payable_scan: true, + }; + + let result = subject + .reschedule_on_error_resolver + .resolve_rescheduling_on_error( + scanner, + &StartScanError::NoConsumingWalletFound, + false, + &logger, + ); + + assert_eq!( + result, + ScanReschedulingAfterEarlyStop::Schedule(ScanType::PendingPayables), + "We expected Schedule(PendingPayables) but got {:?} for {:?}", + result, + scanner + ); + TestLogHandler::new().exists_log_containing(&format!( + "DEBUG: {test_name}: Automatic PendingPayables scan failed - rescheduling strategy: \ + \"Schedule(PendingPayables)\"" + )); + } + + #[test] + #[should_panic( + expected = "internal error: entered unreachable code: PendingPayableScanner called later \ + than the initial attempt, but the consuming wallet is still missing; this should not be \ + possible" + )] + fn pending_p_scan_attempt_if_no_consuming_wallet_found_mustnt_happen_if_not_initial_scan() { + let subject = ScanSchedulers::new(*TEST_SCAN_INTERVALS, true); + let scanner = PayableSequenceScanner::PendingPayables { + initial_pending_payable_scan: false, + }; + + let _ = subject + .reschedule_on_error_resolver + .resolve_rescheduling_on_error( + scanner, + &StartScanError::NoConsumingWalletFound, + false, + &Logger::new("test"), + ); + } + + #[test] + fn resolve_error_for_pending_payables_forbidden_states() { + fn test_forbidden_states( + subject: &ScanSchedulers, + inputs: &ListOfStartScanErrors, + initial_pending_payable_scan: bool, + ) { + inputs.errors.iter().for_each(|error| { + let panic = catch_unwind(AssertUnwindSafe(|| { + subject + .reschedule_on_error_resolver + .resolve_rescheduling_on_error( + PayableSequenceScanner::PendingPayables { + initial_pending_payable_scan, + }, + *error, + false, + &Logger::new("test"), + ) + })) + .unwrap_err(); + + let panic_msg = panic.downcast_ref::().unwrap(); + let expected_msg = format!( + "internal error: entered unreachable code: {:?} should be impossible with \ + PendingPayableScanner in automatic mode", + error + ); + assert_eq!( + panic_msg, &expected_msg, + "We expected '{}' but got '{}' for initial_pending_payable_scan = {}", + expected_msg, panic_msg, initial_pending_payable_scan + ) + }) + } + + let inputs = ListOfStartScanErrors::default().eliminate_already_tested_variants(vec![ + StartScanError::NothingToProcess, + StartScanError::NoConsumingWalletFound, + ]); + let subject = ScanSchedulers::new(*TEST_SCAN_INTERVALS, true); + + test_forbidden_states(&subject, &inputs, false); + test_forbidden_states(&subject, &inputs, true); + } + + #[test] + fn resolve_rescheduling_on_error_works_for_retry_payables_if_externally_triggered() { + let test_name = + "resolve_rescheduling_on_error_works_for_retry_payables_if_externally_triggered"; + let subject = ScanSchedulers::new(*TEST_SCAN_INTERVALS, false); + + test_what_if_externally_triggered( + test_name, + &subject, + PayableSequenceScanner::RetryPayables {}, + ); + } + + #[test] + fn any_automatic_scan_with_start_scan_error_is_fatal_for_retry_payables() { + let subject = ScanSchedulers::new(*TEST_SCAN_INTERVALS, true); + + ALL_START_SCAN_ERRORS.iter().for_each(|error| { + let panic = catch_unwind(AssertUnwindSafe(|| { + subject + .reschedule_on_error_resolver + .resolve_rescheduling_on_error( + PayableSequenceScanner::RetryPayables, + error, + false, + &Logger::new("test"), + ) + })) + .unwrap_err(); + + let panic_msg = panic.downcast_ref::().unwrap(); + let expected_msg = format!( + "internal error: entered unreachable code: {:?} should be impossible \ + with RetryPayableScanner in automatic mode", + error + ); + assert_eq!( + panic_msg, &expected_msg, + "We expected '{}' but got '{}'", + expected_msg, panic_msg, + ) + }) + } + + #[test] + fn resolve_rescheduling_on_error_works_for_new_payables_if_externally_triggered() { + let test_name = + "resolve_rescheduling_on_error_works_for_new_payables_if_externally_triggered"; + let subject = ScanSchedulers::new(*TEST_SCAN_INTERVALS, true); + + test_what_if_externally_triggered( + test_name, + &subject, + PayableSequenceScanner::NewPayables {}, + ); + } + + #[test] + #[should_panic( + expected = "internal error: entered unreachable code: an automatic scan of NewPayableScanner \ + should never interfere with itself ScanAlreadyRunning { cross_scan_cause_opt: None, started_at:" + )] + fn resolve_hint_for_new_payables_if_scan_is_already_running_error_and_is_automatic_scan() { + let subject = ScanSchedulers::new(*TEST_SCAN_INTERVALS, true); + + let _ = subject + .reschedule_on_error_resolver + .resolve_rescheduling_on_error( + PayableSequenceScanner::NewPayables, + &StartScanError::ScanAlreadyRunning { + cross_scan_cause_opt: None, + started_at: SystemTime::now(), + }, + false, + &Logger::new("test"), + ); + } + + #[test] + fn resolve_new_payables_with_error_cases_resulting_in_future_rescheduling() { + let test_name = "resolve_new_payables_with_error_cases_resulting_in_future_rescheduling"; + let inputs = ListOfStartScanErrors::default().eliminate_already_tested_variants(vec![ + StartScanError::ScanAlreadyRunning { + cross_scan_cause_opt: None, + started_at: SystemTime::now(), + }, + ]); + let logger = Logger::new(test_name); + let test_log_handler = TestLogHandler::new(); + let subject = ScanSchedulers::new(*TEST_SCAN_INTERVALS, true); + + inputs.errors.iter().for_each(|error| { + let result = subject + .reschedule_on_error_resolver + .resolve_rescheduling_on_error( + PayableSequenceScanner::NewPayables, + *error, + false, + &logger, + ); + + assert_eq!( + result, + ScanReschedulingAfterEarlyStop::Schedule(ScanType::Payables), + "We expected Schedule(Payables) but got '{:?}'", + result, + ); + test_log_handler.exists_log_containing(&format!( + "DEBUG: {test_name}: Automatic NewPayables scan failed - rescheduling strategy: \ + \"Schedule(Payables)\"", + )); + }) + } + + #[test] + fn conversion_between_hintable_scanner_and_scan_type_works() { + assert_eq!( + ScanType::from(PayableSequenceScanner::NewPayables), + ScanType::Payables + ); + assert_eq!( + ScanType::from(PayableSequenceScanner::RetryPayables), + ScanType::Payables + ); + assert_eq!( + ScanType::from(PayableSequenceScanner::PendingPayables { + initial_pending_payable_scan: false + }), + ScanType::PendingPayables + ); + assert_eq!( + ScanType::from(PayableSequenceScanner::PendingPayables { + initial_pending_payable_scan: true + }), + ScanType::PendingPayables + ); + } +} diff --git a/node/src/accountant/scanners/scanners_utils.rs b/node/src/accountant/scanners/scanners_utils.rs index 30b3a3d2dc..e69de29bb2 100644 --- a/node/src/accountant/scanners/scanners_utils.rs +++ b/node/src/accountant/scanners/scanners_utils.rs @@ -1,846 +0,0 @@ -// Copyright (c) 2019, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. - -pub mod payable_scanner_utils { - use crate::accountant::db_access_objects::utils::ThresholdUtils; - use crate::accountant::db_access_objects::payable_dao::{PayableAccount, PayableDaoError}; - use crate::accountant::scanners::scanners_utils::payable_scanner_utils::PayableTransactingErrorEnum::{ - LocallyCausedError, RemotelyCausedErrors, - }; - use crate::accountant::{comma_joined_stringifiable, SentPayables}; - use crate::sub_lib::accountant::PaymentThresholds; - use crate::sub_lib::wallet::Wallet; - use itertools::Itertools; - use masq_lib::logger::Logger; - use std::cmp::Ordering; - use std::ops::Not; - use std::time::SystemTime; - use thousands::Separable; - use web3::types::H256; - use crate::accountant::db_access_objects::pending_payable_dao::PendingPayable; - use crate::blockchain::blockchain_interface::data_structures::errors::PayableTransactionError; - use crate::blockchain::blockchain_interface::data_structures::{ProcessedPayableFallible, RpcPayableFailure}; - - #[derive(Debug, PartialEq, Eq)] - pub enum PayableTransactingErrorEnum { - LocallyCausedError(PayableTransactionError), - RemotelyCausedErrors(Vec), - } - - //debugging purposes only - pub fn investigate_debt_extremes( - timestamp: SystemTime, - all_non_pending_payables: &[PayableAccount], - ) -> String { - #[derive(Clone, Copy, Default)] - struct PayableInfo { - balance_wei: u128, - age: u64, - } - fn bigger(payable_1: PayableInfo, payable_2: PayableInfo) -> PayableInfo { - match payable_1.balance_wei.cmp(&payable_2.balance_wei) { - Ordering::Greater => payable_1, - Ordering::Less => payable_2, - Ordering::Equal => { - if payable_1.age == payable_2.age { - payable_1 - } else { - older(payable_1, payable_2) - } - } - } - } - fn older(payable_1: PayableInfo, payable_2: PayableInfo) -> PayableInfo { - match payable_1.age.cmp(&payable_2.age) { - Ordering::Greater => payable_1, - Ordering::Less => payable_2, - Ordering::Equal => { - if payable_1.balance_wei == payable_2.balance_wei { - payable_1 - } else { - bigger(payable_1, payable_2) - } - } - } - } - - if all_non_pending_payables.is_empty() { - return "Payable scan found no debts".to_string(); - } - let (biggest, oldest) = all_non_pending_payables - .iter() - .map(|payable| PayableInfo { - balance_wei: payable.balance_wei, - age: timestamp - .duration_since(payable.last_paid_timestamp) - .expect("Payable time is corrupt") - .as_secs(), - }) - .fold( - Default::default(), - |(so_far_biggest, so_far_oldest): (PayableInfo, PayableInfo), payable| { - ( - bigger(so_far_biggest, payable), - older(so_far_oldest, payable), - ) - }, - ); - format!("Payable scan found {} debts; the biggest is {} owed for {}sec, the oldest is {} owed for {}sec", - all_non_pending_payables.len(), biggest.balance_wei, biggest.age, - oldest.balance_wei, oldest.age) - } - - pub fn separate_errors<'a, 'b>( - sent_payables: &'a SentPayables, - logger: &'b Logger, - ) -> (Vec<&'a PendingPayable>, Option) { - match &sent_payables.payment_procedure_result { - Ok(individual_batch_responses) => { - if individual_batch_responses.is_empty() { - panic!("Broken code: An empty vector of processed payments claiming to be an Ok value") - } - let (oks, err_hashes_opt) = - separate_rpc_results(individual_batch_responses, logger); - let remote_errs_opt = err_hashes_opt.map(RemotelyCausedErrors); - (oks, remote_errs_opt) - } - Err(e) => { - warning!( - logger, - "Any persisted data from failed process will be deleted. Caused by: {}", - e - ); - - (vec![], Some(LocallyCausedError(e.clone()))) - } - } - } - - fn separate_rpc_results<'a, 'b>( - batch_request_responses: &'a [ProcessedPayableFallible], - logger: &'b Logger, - ) -> (Vec<&'a PendingPayable>, Option>) { - //TODO maybe we can return not tuple but struct with remote_errors_opt member - let (oks, errs) = batch_request_responses - .iter() - .fold((vec![], vec![]), |acc, rpc_result| { - fold_guts(acc, rpc_result, logger) - }); - - let errs_opt = if !errs.is_empty() { Some(errs) } else { None }; - - (oks, errs_opt) - } - - fn add_pending_payable<'a>( - (mut oks, errs): (Vec<&'a PendingPayable>, Vec), - pending_payable: &'a PendingPayable, - ) -> SeparateTxsByResult<'a> { - oks.push(pending_payable); - (oks, errs) - } - - fn add_rpc_failure((oks, mut errs): SeparateTxsByResult, hash: H256) -> SeparateTxsByResult { - errs.push(hash); - (oks, errs) - } - - type SeparateTxsByResult<'a> = (Vec<&'a PendingPayable>, Vec); - - fn fold_guts<'a, 'b>( - acc: SeparateTxsByResult<'a>, - rpc_result: &'a ProcessedPayableFallible, - logger: &'b Logger, - ) -> SeparateTxsByResult<'a> { - match rpc_result { - ProcessedPayableFallible::Correct(pending_payable) => { - add_pending_payable(acc, pending_payable) - } - ProcessedPayableFallible::Failed(RpcPayableFailure { - rpc_error, - recipient_wallet, - hash, - }) => { - warning!(logger, "Remote transaction failure: '{}' for payment to {} and transaction hash {:?}. \ - Please check your blockchain service URL configuration.", rpc_error, recipient_wallet, hash - ); - add_rpc_failure(acc, *hash) - } - } - } - - pub fn payables_debug_summary(qualified_accounts: &[(PayableAccount, u128)], logger: &Logger) { - if qualified_accounts.is_empty() { - return; - } - debug!(logger, "Paying qualified debts:\n{}", { - let now = SystemTime::now(); - qualified_accounts - .iter() - .map(|(payable, threshold_point)| { - let p_age = now - .duration_since(payable.last_paid_timestamp) - .expect("Payable time is corrupt"); - format!( - "{} wei owed for {} sec exceeds threshold: {} wei; creditor: {}", - payable.balance_wei.separate_with_commas(), - p_age.as_secs(), - threshold_point.separate_with_commas(), - payable.wallet - ) - }) - .join("\n") - }) - } - - pub fn debugging_summary_after_error_separation( - oks: &[&PendingPayable], - errs_opt: &Option, - ) -> String { - format!( - "Got {} properly sent payables of {} attempts", - oks.len(), - count_total_errors(errs_opt) - .map(|err_count| (err_count + oks.len()).to_string()) - .unwrap_or_else(|| "an unknown number of".to_string()) - ) - } - - pub(super) fn count_total_errors( - full_set_of_errors: &Option, - ) -> Option { - match full_set_of_errors { - Some(errors) => match errors { - LocallyCausedError(blockchain_error) => match blockchain_error { - PayableTransactionError::Sending { hashes, .. } => Some(hashes.len()), - _ => None, - }, - RemotelyCausedErrors(hashes) => Some(hashes.len()), - }, - None => Some(0), - } - } - - #[derive(Debug, PartialEq, Eq)] - pub struct PendingPayableMetadata<'a> { - pub recipient: &'a Wallet, - pub hash: H256, - pub rowid_opt: Option, - } - - impl<'a> PendingPayableMetadata<'a> { - pub fn new( - recipient: &'a Wallet, - hash: H256, - rowid_opt: Option, - ) -> PendingPayableMetadata<'a> { - PendingPayableMetadata { - recipient, - hash, - rowid_opt, - } - } - } - - pub fn mark_pending_payable_fatal_error( - sent_payments: &[&PendingPayable], - nonexistent: &[PendingPayableMetadata], - error: PayableDaoError, - missing_fingerprints_msg_maker: fn(&[PendingPayableMetadata]) -> String, - logger: &Logger, - ) { - if !nonexistent.is_empty() { - error!(logger, "{}", missing_fingerprints_msg_maker(nonexistent)) - }; - panic!( - "Unable to create a mark in the payable table for wallets {} due to {:?}", - comma_joined_stringifiable(sent_payments, |pending_p| pending_p - .recipient_wallet - .to_string()), - error - ) - } - - pub fn err_msg_for_failure_with_expected_but_missing_fingerprints( - nonexistent: Vec, - serialize_hashes: fn(&[H256]) -> String, - ) -> Option { - nonexistent.is_empty().not().then_some(format!( - "Ran into failed transactions {} with missing fingerprints. System no longer reliable", - serialize_hashes(&nonexistent), - )) - } - - pub fn separate_rowids_and_hashes(ids_of_payments: Vec<(u64, H256)>) -> (Vec, Vec) { - ids_of_payments.into_iter().unzip() - } - - pub trait PayableThresholdsGauge { - fn is_innocent_age(&self, age: u64, limit: u64) -> bool; - fn is_innocent_balance(&self, balance: u128, limit: u128) -> bool; - fn calculate_payout_threshold_in_gwei( - &self, - payment_thresholds: &PaymentThresholds, - x: u64, - ) -> u128; - as_any_ref_in_trait!(); - } - - #[derive(Default)] - pub struct PayableThresholdsGaugeReal {} - - impl PayableThresholdsGauge for PayableThresholdsGaugeReal { - fn is_innocent_age(&self, age: u64, limit: u64) -> bool { - age <= limit - } - - fn is_innocent_balance(&self, balance: u128, limit: u128) -> bool { - balance <= limit - } - - fn calculate_payout_threshold_in_gwei( - &self, - payment_thresholds: &PaymentThresholds, - debt_age: u64, - ) -> u128 { - ThresholdUtils::calculate_finite_debt_limit_by_age(payment_thresholds, debt_age) - } - as_any_ref_in_trait_impl!(); - } -} - -pub mod pending_payable_scanner_utils { - use crate::accountant::PendingPayableId; - use crate::blockchain::blockchain_bridge::PendingPayableFingerprint; - use masq_lib::logger::Logger; - use std::time::SystemTime; - - #[derive(Debug, Default, PartialEq, Eq, Clone)] - pub struct PendingPayableScanReport { - pub still_pending: Vec, - pub failures: Vec, - pub confirmed: Vec, - } - - pub fn elapsed_in_ms(timestamp: SystemTime) -> u128 { - timestamp - .elapsed() - .expect("time calculation for elapsed failed") - .as_millis() - } - - pub fn handle_none_status( - mut scan_report: PendingPayableScanReport, - fingerprint: PendingPayableFingerprint, - max_pending_interval: u64, - logger: &Logger, - ) -> PendingPayableScanReport { - info!( - logger, - "Pending transaction {:?} couldn't be confirmed at attempt \ - {} at {}ms after its sending", - fingerprint.hash, - fingerprint.attempt, - elapsed_in_ms(fingerprint.timestamp) - ); - let elapsed = fingerprint - .timestamp - .elapsed() - .expect("we should be older now"); - let elapsed = elapsed.as_secs(); - if elapsed > max_pending_interval { - error!( - logger, - "Pending transaction {:?} has exceeded the maximum pending time \ - ({}sec) with the age {}sec and the confirmation process is going to be aborted now \ - at the final attempt {}; manual resolution is required from the \ - user to complete the transaction.", - fingerprint.hash, - max_pending_interval, - elapsed, - fingerprint.attempt - ); - scan_report.failures.push(fingerprint.into()) - } else { - scan_report.still_pending.push(fingerprint.into()) - } - scan_report - } - - pub fn handle_status_with_success( - mut scan_report: PendingPayableScanReport, - fingerprint: PendingPayableFingerprint, - logger: &Logger, - ) -> PendingPayableScanReport { - info!( - logger, - "Transaction {:?} has been added to the blockchain; detected locally at attempt \ - {} at {}ms after its sending", - fingerprint.hash, - fingerprint.attempt, - elapsed_in_ms(fingerprint.timestamp) - ); - scan_report.confirmed.push(fingerprint); - scan_report - } - - //TODO: failures handling is going to need enhancement suggested by GH-693 - pub fn handle_status_with_failure( - mut scan_report: PendingPayableScanReport, - fingerprint: PendingPayableFingerprint, - logger: &Logger, - ) -> PendingPayableScanReport { - error!( - logger, - "Pending transaction {:?} announced as a failure, interpreting attempt \ - {} after {}ms from the sending", - fingerprint.hash, - fingerprint.attempt, - elapsed_in_ms(fingerprint.timestamp) - ); - scan_report.failures.push(fingerprint.into()); - scan_report - } - - pub fn handle_none_receipt( - mut scan_report: PendingPayableScanReport, - payable: PendingPayableFingerprint, - error_msg: &str, - logger: &Logger, - ) -> PendingPayableScanReport { - debug!( - logger, - "Interpreting a receipt for transaction {:?} but {}; attempt {}, {}ms since sending", - payable.hash, - error_msg, - payable.attempt, - elapsed_in_ms(payable.timestamp) - ); - - scan_report - .still_pending - .push(PendingPayableId::new(payable.rowid, payable.hash)); - scan_report - } -} - -pub mod receivable_scanner_utils { - use crate::accountant::db_access_objects::receivable_dao::ReceivableAccount; - use crate::accountant::wei_to_gwei; - use std::time::{Duration, SystemTime}; - use thousands::Separable; - - pub fn balance_and_age(time: SystemTime, account: &ReceivableAccount) -> (String, Duration) { - let balance = wei_to_gwei::(account.balance_wei).separate_with_commas(); - let age = time - .duration_since(account.last_received_timestamp) - .unwrap_or_else(|_| Duration::new(0, 0)); - (balance, age) - } -} - -#[cfg(test)] -mod tests { - use crate::accountant::db_access_objects::utils::{from_time_t, to_time_t}; - use crate::accountant::db_access_objects::payable_dao::{PayableAccount}; - use crate::accountant::db_access_objects::receivable_dao::ReceivableAccount; - use crate::accountant::scanners::scanners_utils::payable_scanner_utils::PayableTransactingErrorEnum::{ - LocallyCausedError, RemotelyCausedErrors, - }; - use crate::accountant::scanners::scanners_utils::payable_scanner_utils::{ - count_total_errors, debugging_summary_after_error_separation, investigate_debt_extremes, - payables_debug_summary, separate_errors, PayableThresholdsGauge, - PayableThresholdsGaugeReal, - }; - use crate::accountant::scanners::scanners_utils::receivable_scanner_utils::balance_and_age; - use crate::accountant::{checked_conversion, gwei_to_wei, SentPayables}; - use crate::blockchain::test_utils::make_tx_hash; - use crate::sub_lib::accountant::PaymentThresholds; - use crate::test_utils::make_wallet; - use masq_lib::constants::WEIS_IN_GWEI; - use masq_lib::logger::Logger; - use masq_lib::test_utils::logging::{init_test_logging, TestLogHandler}; - use std::time::SystemTime; - use crate::accountant::db_access_objects::pending_payable_dao::PendingPayable; - use crate::blockchain::blockchain_interface::data_structures::errors::{BlockchainError, PayableTransactionError}; - use crate::blockchain::blockchain_interface::data_structures::{ProcessedPayableFallible, RpcPayableFailure}; - - #[test] - fn investigate_debt_extremes_picks_the_most_relevant_records() { - let now = SystemTime::now(); - let now_t = to_time_t(now); - let same_amount_significance = 2_000_000; - let same_age_significance = from_time_t(now_t - 30000); - let payables = &[ - PayableAccount { - wallet: make_wallet("wallet0"), - balance_wei: same_amount_significance, - last_paid_timestamp: from_time_t(now_t - 5000), - pending_payable_opt: None, - }, - //this debt is more significant because beside being high in amount it's also older, so should be prioritized and picked - PayableAccount { - wallet: make_wallet("wallet1"), - balance_wei: same_amount_significance, - last_paid_timestamp: from_time_t(now_t - 10000), - pending_payable_opt: None, - }, - //similarly these two wallets have debts equally old but the second has a bigger balance and should be chosen - PayableAccount { - wallet: make_wallet("wallet3"), - balance_wei: 100, - last_paid_timestamp: same_age_significance, - pending_payable_opt: None, - }, - PayableAccount { - wallet: make_wallet("wallet2"), - balance_wei: 330, - last_paid_timestamp: same_age_significance, - pending_payable_opt: None, - }, - ]; - - let result = investigate_debt_extremes(now, payables); - - assert_eq!(result, "Payable scan found 4 debts; the biggest is 2000000 owed for 10000sec, the oldest is 330 owed for 30000sec") - } - - #[test] - fn balance_and_age_is_calculated_as_expected() { - let now = SystemTime::now(); - let offset = 1000; - let receivable_account = ReceivableAccount { - wallet: make_wallet("wallet0"), - balance_wei: 10_000_000_000, - last_received_timestamp: from_time_t(to_time_t(now) - offset), - }; - - let (balance, age) = balance_and_age(now, &receivable_account); - - assert_eq!(balance, "10"); - assert_eq!(age.as_secs(), offset as u64); - } - - #[test] - fn separate_errors_works_for_no_errs_just_oks() { - let correct_payment_1 = PendingPayable { - recipient_wallet: make_wallet("blah"), - hash: make_tx_hash(123), - }; - let correct_payment_2 = PendingPayable { - recipient_wallet: make_wallet("howgh"), - hash: make_tx_hash(456), - }; - let sent_payable = SentPayables { - payment_procedure_result: Ok(vec![ - ProcessedPayableFallible::Correct(correct_payment_1.clone()), - ProcessedPayableFallible::Correct(correct_payment_2.clone()), - ]), - response_skeleton_opt: None, - }; - - let (oks, errs) = separate_errors(&sent_payable, &Logger::new("test")); - - assert_eq!(oks, vec![&correct_payment_1, &correct_payment_2]); - assert_eq!(errs, None) - } - - #[test] - fn separate_errors_works_for_local_error() { - init_test_logging(); - let error = PayableTransactionError::Sending { - msg: "Bad luck".to_string(), - hashes: vec![make_tx_hash(0x7b)], - }; - let sent_payable = SentPayables { - payment_procedure_result: Err(error.clone()), - response_skeleton_opt: None, - }; - - let (oks, errs) = separate_errors(&sent_payable, &Logger::new("test_logger")); - - assert!(oks.is_empty()); - assert_eq!(errs, Some(LocallyCausedError(error))); - TestLogHandler::new().exists_log_containing( - "WARN: test_logger: Any persisted data from \ - failed process will be deleted. Caused by: Sending phase: \"Bad luck\". Signed and hashed \ - transactions: 0x000000000000000000000000000000000000000000000000000000000000007b", - ); - } - - #[test] - fn separate_errors_works_for_their_errors() { - init_test_logging(); - let payable_ok = PendingPayable { - recipient_wallet: make_wallet("blah"), - hash: make_tx_hash(123), - }; - let bad_rpc_call = RpcPayableFailure { - rpc_error: web3::Error::InvalidResponse("That jackass screwed it up".to_string()), - recipient_wallet: make_wallet("whooa"), - hash: make_tx_hash(0x315), - }; - let sent_payable = SentPayables { - payment_procedure_result: Ok(vec![ - ProcessedPayableFallible::Correct(payable_ok.clone()), - ProcessedPayableFallible::Failed(bad_rpc_call.clone()), - ]), - response_skeleton_opt: None, - }; - - let (oks, errs) = separate_errors(&sent_payable, &Logger::new("test_logger")); - - assert_eq!(oks, vec![&payable_ok]); - assert_eq!(errs, Some(RemotelyCausedErrors(vec![make_tx_hash(0x315)]))); - TestLogHandler::new().exists_log_containing("WARN: test_logger: Remote transaction failure: \ - 'Got invalid response: That jackass screwed it up' for payment to 0x000000000000000000000000\ - 00000077686f6f61 and transaction hash 0x0000000000000000000000000000000000000000000000000000\ - 000000000315. Please check your blockchain service URL configuration."); - } - - #[test] - fn payables_debug_summary_displays_nothing_for_no_qualified_payments() { - init_test_logging(); - let logger = - Logger::new("payables_debug_summary_displays_nothing_for_no_qualified_payments"); - - payables_debug_summary(&vec![], &logger); - - TestLogHandler::new().exists_no_log_containing( - "DEBUG: payables_debug_summary_stays_\ - inert_if_no_qualified_payments: Paying qualified debts:", - ); - } - - #[test] - fn payables_debug_summary_prints_pretty_summary() { - init_test_logging(); - let now = to_time_t(SystemTime::now()); - let payment_thresholds = PaymentThresholds { - threshold_interval_sec: 2_592_000, - debt_threshold_gwei: 1_000_000_000, - payment_grace_period_sec: 86_400, - maturity_threshold_sec: 86_400, - permanent_debt_allowed_gwei: 10_000_000, - unban_below_gwei: 10_000_000, - }; - let qualified_payables_and_threshold_points = vec![ - ( - PayableAccount { - wallet: make_wallet("wallet0"), - balance_wei: gwei_to_wei(payment_thresholds.permanent_debt_allowed_gwei + 2000), - last_paid_timestamp: from_time_t( - now - checked_conversion::( - payment_thresholds.maturity_threshold_sec - + payment_thresholds.threshold_interval_sec, - ), - ), - pending_payable_opt: None, - }, - 10_000_000_001_152_000_u128, - ), - ( - PayableAccount { - wallet: make_wallet("wallet1"), - balance_wei: gwei_to_wei(payment_thresholds.debt_threshold_gwei - 1), - last_paid_timestamp: from_time_t( - now - checked_conversion::( - payment_thresholds.maturity_threshold_sec + 55, - ), - ), - pending_payable_opt: None, - }, - 999_978_993_055_555_580, - ), - ]; - let logger = Logger::new("test"); - - payables_debug_summary(&qualified_payables_and_threshold_points, &logger); - - TestLogHandler::new().exists_log_containing("Paying qualified debts:\n\ - 10,002,000,000,000,000 wei owed for 2678400 sec exceeds threshold: \ - 10,000,000,001,152,000 wei; creditor: 0x0000000000000000000000000077616c6c657430\n\ - 999,999,999,000,000,000 wei owed for 86455 sec exceeds threshold: \ - 999,978,993,055,555,580 wei; creditor: 0x0000000000000000000000000077616c6c657431"); - } - - #[test] - fn payout_sloped_segment_in_payment_thresholds_goes_along_proper_line() { - let payment_thresholds = PaymentThresholds { - maturity_threshold_sec: 333, - payment_grace_period_sec: 444, - permanent_debt_allowed_gwei: 4444, - debt_threshold_gwei: 8888, - threshold_interval_sec: 1111111, - unban_below_gwei: 0, - }; - let higher_corner_timestamp = payment_thresholds.maturity_threshold_sec; - let middle_point_timestamp = payment_thresholds.maturity_threshold_sec - + payment_thresholds.threshold_interval_sec / 2; - let lower_corner_timestamp = - payment_thresholds.maturity_threshold_sec + payment_thresholds.threshold_interval_sec; - let tested_fn = |payment_thresholds: &PaymentThresholds, time| { - PayableThresholdsGaugeReal {} - .calculate_payout_threshold_in_gwei(payment_thresholds, time) as i128 - }; - - let higher_corner_point = tested_fn(&payment_thresholds, higher_corner_timestamp); - let middle_point = tested_fn(&payment_thresholds, middle_point_timestamp); - let lower_corner_point = tested_fn(&payment_thresholds, lower_corner_timestamp); - - let allowed_imprecision = WEIS_IN_GWEI; - let ideal_template_higher: i128 = gwei_to_wei(payment_thresholds.debt_threshold_gwei); - let ideal_template_middle: i128 = gwei_to_wei( - (payment_thresholds.debt_threshold_gwei - - payment_thresholds.permanent_debt_allowed_gwei) - / 2 - + payment_thresholds.permanent_debt_allowed_gwei, - ); - let ideal_template_lower: i128 = - gwei_to_wei(payment_thresholds.permanent_debt_allowed_gwei); - assert!( - higher_corner_point <= ideal_template_higher + allowed_imprecision - && ideal_template_higher - allowed_imprecision <= higher_corner_point, - "ideal: {}, real: {}", - ideal_template_higher, - higher_corner_point - ); - assert!( - middle_point <= ideal_template_middle + allowed_imprecision - && ideal_template_middle - allowed_imprecision <= middle_point, - "ideal: {}, real: {}", - ideal_template_middle, - middle_point - ); - assert!( - lower_corner_point <= ideal_template_lower + allowed_imprecision - && ideal_template_lower - allowed_imprecision <= lower_corner_point, - "ideal: {}, real: {}", - ideal_template_lower, - lower_corner_point - ) - } - - #[test] - fn is_innocent_age_works_for_age_smaller_than_innocent_age() { - let payable_age = 999; - - let result = PayableThresholdsGaugeReal::default().is_innocent_age(payable_age, 1000); - - assert_eq!(result, true) - } - - #[test] - fn is_innocent_age_works_for_age_equal_to_innocent_age() { - let payable_age = 1000; - - let result = PayableThresholdsGaugeReal::default().is_innocent_age(payable_age, 1000); - - assert_eq!(result, true) - } - - #[test] - fn is_innocent_age_works_for_excessive_age() { - let payable_age = 1001; - - let result = PayableThresholdsGaugeReal::default().is_innocent_age(payable_age, 1000); - - assert_eq!(result, false) - } - - #[test] - fn is_innocent_balance_works_for_balance_smaller_than_innocent_balance() { - let payable_balance = 999; - - let result = - PayableThresholdsGaugeReal::default().is_innocent_balance(payable_balance, 1000); - - assert_eq!(result, true) - } - - #[test] - fn is_innocent_balance_works_for_balance_equal_to_innocent_balance() { - let payable_balance = 1000; - - let result = - PayableThresholdsGaugeReal::default().is_innocent_balance(payable_balance, 1000); - - assert_eq!(result, true) - } - - #[test] - fn is_innocent_balance_works_for_excessive_balance() { - let payable_balance = 1001; - - let result = - PayableThresholdsGaugeReal::default().is_innocent_balance(payable_balance, 1000); - - assert_eq!(result, false) - } - - #[test] - fn count_total_errors_says_unknown_number_for_early_local_errors() { - let early_local_errors = [ - PayableTransactionError::TransactionID(BlockchainError::QueryFailed( - "blah".to_string(), - )), - PayableTransactionError::MissingConsumingWallet, - PayableTransactionError::GasPriceQueryFailed(BlockchainError::QueryFailed( - "ouch".to_string(), - )), - PayableTransactionError::UnusableWallet("fooo".to_string()), - PayableTransactionError::Signing("tsss".to_string()), - ]; - - early_local_errors - .into_iter() - .for_each(|err| assert_eq!(count_total_errors(&Some(LocallyCausedError(err))), None)) - } - - #[test] - fn count_total_errors_works_correctly_for_local_error_after_signing() { - let error = PayableTransactionError::Sending { - msg: "Ouuuups".to_string(), - hashes: vec![make_tx_hash(333), make_tx_hash(666)], - }; - let sent_payable = Some(LocallyCausedError(error)); - - let result = count_total_errors(&sent_payable); - - assert_eq!(result, Some(2)) - } - - #[test] - fn count_total_errors_works_correctly_for_remote_errors() { - let sent_payable = Some(RemotelyCausedErrors(vec![ - make_tx_hash(123), - make_tx_hash(456), - ])); - - let result = count_total_errors(&sent_payable); - - assert_eq!(result, Some(2)) - } - - #[test] - fn count_total_errors_works_correctly_if_no_errors_found_at_all() { - let sent_payable = None; - - let result = count_total_errors(&sent_payable); - - assert_eq!(result, Some(0)) - } - - #[test] - fn debug_summary_after_error_separation_says_the_count_cannot_be_known() { - let oks = vec![]; - let error = PayableTransactionError::MissingConsumingWallet; - let errs = Some(LocallyCausedError(error)); - - let result = debugging_summary_after_error_separation(&oks, &errs); - - assert_eq!( - result, - "Got 0 properly sent payables of an unknown number of attempts" - ) - } -} diff --git a/node/src/accountant/scanners/test_utils.rs b/node/src/accountant/scanners/test_utils.rs index c43d6f71b6..08325dedca 100644 --- a/node/src/accountant/scanners/test_utils.rs +++ b/node/src/accountant/scanners/test_utils.rs @@ -2,9 +2,587 @@ #![cfg(test)] -use crate::accountant::db_access_objects::payable_dao::PayableAccount; -use masq_lib::type_obfuscation::Obfuscated; +use crate::accountant::db_access_objects::utils::TxHash; +use crate::accountant::scanners::payable_scanner::msgs::{ + InitialTemplatesMessage, PricedTemplatesMessage, +}; +use crate::accountant::scanners::payable_scanner::payment_adjuster_integration::{ + PreparedAdjustment, SolvencySensitivePaymentInstructor, +}; +use crate::accountant::scanners::payable_scanner::utils::PayableScanResult; +use crate::accountant::scanners::payable_scanner::{MultistageDualPayableScanner, PayableScanner}; +use crate::accountant::scanners::pending_payable_scanner::utils::{ + PendingPayableCache, PendingPayableScanResult, +}; +use crate::accountant::scanners::pending_payable_scanner::{ + CachesEmptiableScanner, ExtendedPendingPayablePrivateScanner, +}; +use crate::accountant::scanners::scan_schedulers::{ + NewPayableScanIntervalComputer, PayableSequenceScanner, RescheduleScanOnErrorResolver, + ScanReschedulingAfterEarlyStop, ScanTiming, +}; +use crate::accountant::scanners::{ + PendingPayableScanner, PrivateScanner, RealScannerMarker, ReceivableScanner, Scanner, + StartScanError, StartableScanner, +}; +use crate::accountant::{ + ReceivedPayments, RequestTransactionReceipts, ResponseSkeleton, SentPayables, TxReceiptsMessage, +}; +use crate::blockchain::blockchain_bridge::RetrieveTransactions; +use crate::sub_lib::blockchain_bridge::{ConsumingWalletBalances, OutboundPaymentsInstructions}; +use crate::sub_lib::wallet::Wallet; +use actix::{Message, System}; +use itertools::Either; +use masq_lib::logger::{Logger, TIME_FORMATTING_STRING}; +use masq_lib::ui_gateway::NodeToUiMessage; +use regex::Regex; +use std::any::type_name; +use std::cell::RefCell; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use time::{format_description, PrimitiveDateTime}; -pub fn protect_payables_in_test(payables: Vec) -> Obfuscated { - Obfuscated::obfuscate_vector(payables) +pub struct NullScanner {} + +impl + PrivateScanner for NullScanner +where + TriggerMessage: Message, + StartMessage: Message, + EndMessage: Message, +{ +} + +impl StartableScanner for NullScanner +where + TriggerMessage: Message, + StartMessage: Message, +{ + fn start_scan( + &mut self, + _wallet: &Wallet, + _timestamp: SystemTime, + _response_skeleton_opt: Option, + _logger: &Logger, + ) -> Result { + Err(StartScanError::CalledFromNullScanner) + } +} + +impl Scanner for NullScanner +where + EndMessage: Message, +{ + fn finish_scan(&mut self, _message: EndMessage, _logger: &Logger) -> ScanResult { + panic!("Called finish_scan() from NullScanner"); + } + + fn scan_started_at(&self) -> Option { + None + } + + fn mark_as_started(&mut self, _timestamp: SystemTime) { + panic!("Called mark_as_started() from NullScanner"); + } + + fn mark_as_ended(&mut self, _logger: &Logger) { + panic!("Called mark_as_ended() from NullScanner"); + } + + as_any_ref_in_trait_impl!(); +} + +impl MultistageDualPayableScanner for NullScanner {} + +impl SolvencySensitivePaymentInstructor for NullScanner { + fn try_skipping_payment_adjustment( + &self, + _msg: PricedTemplatesMessage, + _logger: &Logger, + ) -> Result, String> { + intentionally_blank!() + } + + fn perform_payment_adjustment( + &self, + _setup: PreparedAdjustment, + _logger: &Logger, + ) -> OutboundPaymentsInstructions { + intentionally_blank!() + } +} + +impl ExtendedPendingPayablePrivateScanner for NullScanner {} + +impl CachesEmptiableScanner for NullScanner { + fn empty_caches(&mut self, _logger: &Logger) { + intentionally_blank!() + } +} + +impl Default for NullScanner { + fn default() -> Self { + Self::new() + } +} + +impl NullScanner { + pub fn new() -> Self { + Self {} + } +} + +pub struct ScannerMock { + start_scan_params: + Arc, Logger, String)>>>, + start_scan_results: RefCell>>, + finish_scan_params: Arc>>, + finish_scan_results: RefCell>, + scan_started_at_results: RefCell>>, + stop_system_after_last_message: RefCell, +} + +impl + PrivateScanner + for ScannerMock +where + TriggerMessage: Message, + StartMessage: Message, + EndMessage: Message, +{ +} + +impl + StartableScanner + for ScannerMock +where + TriggerMessage: Message, + StartMessage: Message, + EndMessage: Message, +{ + fn start_scan( + &mut self, + wallet: &Wallet, + timestamp: SystemTime, + response_skeleton_opt: Option, + logger: &Logger, + ) -> Result { + self.start_scan_params.lock().unwrap().push(( + wallet.clone(), + timestamp, + response_skeleton_opt, + logger.clone(), + // This serves for identification in scanners allowing different modes to start + // them up through. + type_name::().to_string(), + )); + if self.is_allowed_to_stop_the_system() && self.is_last_message() { + System::current().stop(); + } + self.start_scan_results.borrow_mut().remove(0) + } +} + +impl Scanner + for ScannerMock +where + StartMessage: Message, + EndMessage: Message, +{ + fn finish_scan(&mut self, message: EndMessage, logger: &Logger) -> ScanResult { + self.finish_scan_params + .lock() + .unwrap() + .push((message, logger.clone())); + if self.is_allowed_to_stop_the_system() && self.is_last_message() { + System::current().stop(); + } + self.finish_scan_results.borrow_mut().remove(0) + } + + fn scan_started_at(&self) -> Option { + self.scan_started_at_results.borrow_mut().remove(0) + } + + fn mark_as_started(&mut self, _timestamp: SystemTime) { + intentionally_blank!() + } + + fn mark_as_ended(&mut self, _logger: &Logger) { + intentionally_blank!() + } +} + +impl Default + for ScannerMock +{ + fn default() -> Self { + Self::new() + } +} + +impl ScannerMock { + pub fn new() -> Self { + Self { + start_scan_params: Arc::new(Mutex::new(vec![])), + start_scan_results: RefCell::new(vec![]), + finish_scan_params: Arc::new(Mutex::new(vec![])), + finish_scan_results: RefCell::new(vec![]), + scan_started_at_results: RefCell::new(vec![]), + stop_system_after_last_message: RefCell::new(false), + } + } + + pub fn start_scan_params( + mut self, + params: &Arc, Logger, String)>>>, + ) -> Self { + self.start_scan_params = params.clone(); + self + } + + pub fn start_scan_result(self, result: Result) -> Self { + self.start_scan_results.borrow_mut().push(result); + self + } + + pub fn scan_started_at_result(self, result: Option) -> Self { + self.scan_started_at_results.borrow_mut().push(result); + self + } + + pub fn finish_scan_params(mut self, params: &Arc>>) -> Self { + self.finish_scan_params = params.clone(); + self + } + + pub fn finish_scan_result(self, result: ScanResult) -> Self { + self.finish_scan_results.borrow_mut().push(result); + self + } + + pub fn stop_the_system_after_last_msg(self) -> Self { + self.stop_system_after_last_message.replace(true); + self + } + + pub fn is_allowed_to_stop_the_system(&self) -> bool { + *self.stop_system_after_last_message.borrow() + } + + pub fn is_last_message(&self) -> bool { + self.is_last_message_from_start_scan() || self.is_last_message_from_end_scan() + } + + pub fn is_last_message_from_start_scan(&self) -> bool { + self.start_scan_results.borrow().len() == 1 && self.finish_scan_results.borrow().is_empty() + } + + pub fn is_last_message_from_end_scan(&self) -> bool { + self.finish_scan_results.borrow().len() == 1 && self.start_scan_results.borrow().is_empty() + } +} + +impl MultistageDualPayableScanner + for ScannerMock +{ +} + +impl SolvencySensitivePaymentInstructor + for ScannerMock +{ + fn try_skipping_payment_adjustment( + &self, + msg: PricedTemplatesMessage, + _logger: &Logger, + ) -> Result, String> { + // Always passes... + // It would be quite inconvenient if we had to add specialized features to the generic + // mock, plus this functionality can be tested better with the other components mocked, + // not the scanner itself. + Ok(Either::Left(OutboundPaymentsInstructions { + priced_templates: msg.priced_templates, + agent: msg.agent, + response_skeleton_opt: msg.response_skeleton_opt, + })) + } + + fn perform_payment_adjustment( + &self, + _setup: PreparedAdjustment, + _logger: &Logger, + ) -> OutboundPaymentsInstructions { + intentionally_blank!() + } +} + +impl ExtendedPendingPayablePrivateScanner + for ScannerMock +{ +} + +impl CachesEmptiableScanner + for ScannerMock +{ + fn empty_caches(&mut self, _logger: &Logger) { + intentionally_blank!() + } +} + +pub trait ScannerMockMarker {} + +impl ScannerMockMarker for ScannerMock {} + +#[derive(Default)] +pub struct NewPayableScanIntervalComputerMock { + time_until_next_scan_params: Arc>>, + time_until_next_scan_results: RefCell>, + reset_last_scan_timestamp_params: Arc>>, +} + +impl NewPayableScanIntervalComputer for NewPayableScanIntervalComputerMock { + fn time_until_next_scan(&self) -> ScanTiming { + self.time_until_next_scan_params.lock().unwrap().push(()); + self.time_until_next_scan_results.borrow_mut().remove(0) + } + + fn reset_last_scan_timestamp(&mut self) { + self.reset_last_scan_timestamp_params + .lock() + .unwrap() + .push(()); + } + + as_any_ref_in_trait_impl!(); +} + +impl NewPayableScanIntervalComputerMock { + pub fn time_until_next_scan_params(mut self, params: &Arc>>) -> Self { + self.time_until_next_scan_params = params.clone(); + self + } + + pub fn time_until_next_scan_result(self, result: ScanTiming) -> Self { + self.time_until_next_scan_results.borrow_mut().push(result); + self + } + + pub fn reset_last_scan_timestamp_params(mut self, params: &Arc>>) -> Self { + self.reset_last_scan_timestamp_params = params.clone(); + self + } +} + +pub enum ReplacementType +where + ScannerReal: RealScannerMarker, + ScannerMock: ScannerMockMarker, +{ + Real(ScannerReal), + Mock(ScannerMock), + Null, +} + +// The scanners are categorized by types because we want them to become an abstract object +// represented by a private trait. Of course, such an object cannot be constructed directly in +// the outer world; therefore, we have to provide specific objects that will cast accordingly +// under the hood. +pub enum ScannerReplacement { + Payable( + ReplacementType< + PayableScanner, + ScannerMock, + >, + ), + PendingPayable( + ReplacementType< + PendingPayableScanner, + ScannerMock, + >, + ), + Receivable( + ReplacementType< + ReceivableScanner, + ScannerMock>, + >, + ), +} + +pub enum MarkScanner<'a> { + Ended(&'a Logger), + Started(SystemTime), +} + +// Cautious: Don't compare to another timestamp on an exact match. This timestamp is trimmed in +// nanoseconds down to three digits. Works only for the format bound by TIME_FORMATTING_STRING +pub fn parse_system_time_from_str(examined_str: &str) -> Vec { + let regex = Regex::new(r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})").unwrap(); + let captures = regex.captures_iter(examined_str); + captures + .map(|captures| { + let captured_str_timestamp = captures.get(0).unwrap().as_str(); + let format = format_description::parse(TIME_FORMATTING_STRING).unwrap(); + let dt = PrimitiveDateTime::parse(captured_str_timestamp, &format).unwrap(); + let duration = Duration::from_secs(dt.assume_utc().unix_timestamp() as u64) + + Duration::from_nanos(dt.nanosecond() as u64); + UNIX_EPOCH + duration + }) + .collect() +} + +pub fn trim_expected_timestamp_to_three_digits_nanos(value: SystemTime) -> SystemTime { + let duration = value.duration_since(UNIX_EPOCH).unwrap(); + let full_nanos = duration.subsec_nanos(); + let diffuser = 10_u32.pow(6); + let trimmed_nanos = (full_nanos / diffuser) * diffuser; + let duration = duration + .checked_sub(Duration::from_nanos(full_nanos as u64)) + .unwrap() + .checked_add(Duration::from_nanos(trimmed_nanos as u64)) + .unwrap(); + UNIX_EPOCH + duration +} + +pub fn assert_timestamps_from_str(examined_str: &str, expected_timestamps: Vec) { + let parsed_timestamps = parse_system_time_from_str(examined_str); + if parsed_timestamps.len() != expected_timestamps.len() { + panic!( + "You supplied {} expected timestamps, but the examined text contains only {}", + expected_timestamps.len(), + parsed_timestamps.len() + ) + } + let zipped = parsed_timestamps + .into_iter() + .zip(expected_timestamps.into_iter()); + zipped.for_each(|(parsed_timestamp, expected_timestamp)| { + let expected_timestamp_trimmed = + trim_expected_timestamp_to_three_digits_nanos(expected_timestamp); + assert_eq!( + parsed_timestamp, expected_timestamp_trimmed, + "We expected this timestamp {:?} in this fragment '{}' but found {:?}", + expected_timestamp_trimmed, examined_str, parsed_timestamp + ) + }) +} + +#[derive(Default)] +pub struct RescheduleScanOnErrorResolverMock { + resolve_rescheduling_on_error_params: + Arc>>, + resolve_rescheduling_on_error_results: RefCell>, +} + +impl RescheduleScanOnErrorResolver for RescheduleScanOnErrorResolverMock { + fn resolve_rescheduling_on_error( + &self, + scanner: PayableSequenceScanner, + error: &StartScanError, + is_externally_triggered: bool, + logger: &Logger, + ) -> ScanReschedulingAfterEarlyStop { + self.resolve_rescheduling_on_error_params + .lock() + .unwrap() + .push(( + scanner, + error.clone(), + is_externally_triggered, + logger.clone(), + )); + self.resolve_rescheduling_on_error_results + .borrow_mut() + .remove(0) + } +} + +impl RescheduleScanOnErrorResolverMock { + pub fn resolve_rescheduling_on_error_params( + mut self, + params: &Arc>>, + ) -> Self { + self.resolve_rescheduling_on_error_params = params.clone(); + self + } + pub fn resolve_rescheduling_on_error_result( + self, + result: ScanReschedulingAfterEarlyStop, + ) -> Self { + self.resolve_rescheduling_on_error_results + .borrow_mut() + .push(result); + self + } +} + +pub fn make_zeroed_consuming_wallet_balances() -> ConsumingWalletBalances { + ConsumingWalletBalances::new(0.into(), 0.into()) +} + +pub struct PendingPayableCacheMock { + load_cache_params: Arc>>>, + load_cache_results: RefCell>>, + get_record_by_hash_params: Arc>>, + get_record_by_hash_results: RefCell>>, + ensure_empty_cache_params: Arc>>, +} + +impl Default for PendingPayableCacheMock { + fn default() -> Self { + Self { + load_cache_params: Arc::new(Mutex::new(vec![])), + load_cache_results: RefCell::new(vec![]), + get_record_by_hash_params: Arc::new(Mutex::new(vec![])), + get_record_by_hash_results: RefCell::new(vec![]), + ensure_empty_cache_params: Arc::new(Mutex::new(vec![])), + } + } +} + +impl PendingPayableCache for PendingPayableCacheMock { + fn load_cache(&mut self, records: Vec) { + self.load_cache_params.lock().unwrap().push(records); + self.load_cache_results.borrow_mut().remove(0); + } + + fn get_record_by_hash(&mut self, hash: TxHash) -> Option { + self.get_record_by_hash_params.lock().unwrap().push(hash); + self.get_record_by_hash_results.borrow_mut().remove(0) + } + + fn ensure_empty_cache(&mut self, _logger: &Logger) { + self.ensure_empty_cache_params.lock().unwrap().push(()); + } + + fn dump_cache(&mut self) -> HashMap { + unimplemented!("not needed yet") + } +} + +impl PendingPayableCacheMock { + pub fn load_cache_params(mut self, params: &Arc>>>) -> Self { + self.load_cache_params = params.clone(); + self + } + + pub fn load_cache_result(self, result: HashMap) -> Self { + self.load_cache_results.borrow_mut().push(result); + self + } + + pub fn get_record_by_hash_params(mut self, params: &Arc>>) -> Self { + self.get_record_by_hash_params = params.clone(); + self + } + + pub fn get_record_by_hash_result(self, result: Option) -> Self { + self.get_record_by_hash_results.borrow_mut().push(result); + self + } + + pub fn ensure_empty_cache_params(mut self, params: &Arc>>) -> Self { + self.ensure_empty_cache_params = params.clone(); + self + } } diff --git a/node/src/accountant/test_utils.rs b/node/src/accountant/test_utils.rs index 5c9d6f14a0..9e3e06575d 100644 --- a/node/src/accountant/test_utils.rs +++ b/node/src/accountant/test_utils.rs @@ -3,35 +3,35 @@ #![cfg(test)] use crate::accountant::db_access_objects::banned_dao::{BannedDao, BannedDaoFactory}; -use crate::accountant::db_access_objects::payable_dao::{ - PayableAccount, PayableDao, PayableDaoError, PayableDaoFactory, +use crate::accountant::db_access_objects::failed_payable_dao::{ + FailedPayableDao, FailedPayableDaoError, FailedPayableDaoFactory, FailedTx, + FailureRetrieveCondition, FailureStatus, }; -use crate::accountant::db_access_objects::pending_payable_dao::{ - PendingPayableDao, PendingPayableDaoError, PendingPayableDaoFactory, TransactionHashes, +use crate::accountant::db_access_objects::payable_dao::{ + MarkPendingPayableID, PayableAccount, PayableDao, PayableDaoError, PayableDaoFactory, + PayableRetrieveCondition, }; + use crate::accountant::db_access_objects::receivable_dao::{ ReceivableAccount, ReceivableDao, ReceivableDaoError, ReceivableDaoFactory, }; -use crate::accountant::db_access_objects::utils::{from_time_t, to_time_t, CustomQuery}; -use crate::accountant::payment_adjuster::{Adjustment, AnalysisError, PaymentAdjuster}; -use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::msgs::{ - BlockchainAgentWithContextMessage, QualifiedPayablesMessage, -}; -use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::{ - MultistagePayableScanner, PreparedAdjustment, SolvencySensitivePaymentInstructor, +use crate::accountant::db_access_objects::sent_payable_dao::{ + RetrieveCondition, SentPayableDao, SentPayableDaoError, SentPayableDaoFactory, SentTx, TxStatus, }; -use crate::accountant::scanners::scanners_utils::payable_scanner_utils::PayableThresholdsGauge; -use crate::accountant::scanners::{ - BeginScanError, PayableScanner, PendingPayableScanner, PeriodicalScanScheduler, - ReceivableScanner, ScanSchedulers, Scanner, +use crate::accountant::db_access_objects::utils::{ + from_unix_timestamp, to_unix_timestamp, CustomQuery, TxHash, TxIdentifiers, }; -use crate::accountant::{ - gwei_to_wei, Accountant, ResponseSkeleton, SentPayables, DEFAULT_PENDING_TOO_LONG_SEC, -}; -use crate::blockchain::blockchain_bridge::PendingPayableFingerprint; -use crate::blockchain::blockchain_interface::blockchain_interface_web3::HashAndAmount; -use crate::blockchain::blockchain_interface::data_structures::BlockchainTransaction; -use crate::blockchain::test_utils::make_tx_hash; +use crate::accountant::payment_adjuster::{Adjustment, AnalysisError, PaymentAdjuster}; +use crate::accountant::scanners::payable_scanner::msgs::PricedTemplatesMessage; +use crate::accountant::scanners::payable_scanner::payment_adjuster_integration::PreparedAdjustment; +use crate::accountant::scanners::payable_scanner::utils::PayableThresholdsGauge; +use crate::accountant::scanners::pending_payable_scanner::utils::PendingPayableCache; +use crate::accountant::scanners::pending_payable_scanner::PendingPayableScanner; +use crate::accountant::scanners::receivable_scanner::ReceivableScanner; +use crate::accountant::scanners::test_utils::PendingPayableCacheMock; +use crate::accountant::{gwei_to_wei, Accountant}; +use crate::blockchain::blockchain_interface::data_structures::{BlockchainTransaction, TxBlock}; +use crate::blockchain::test_utils::make_block_hash; use crate::bootstrapper::BootstrapperConfig; use crate::database::rusqlite_wrappers::TransactionSafeWrapper; use crate::db_config::config_dao::{ConfigDao, ConfigDaoFactory}; @@ -39,28 +39,27 @@ use crate::db_config::mocks::ConfigDaoMock; use crate::sub_lib::accountant::{DaoFactories, FinancialStatistics}; use crate::sub_lib::accountant::{MessageIdGenerator, PaymentThresholds}; use crate::sub_lib::blockchain_bridge::OutboundPaymentsInstructions; -use crate::sub_lib::utils::NotifyLaterHandle; use crate::sub_lib::wallet::Wallet; use crate::test_utils::make_wallet; use crate::test_utils::persistent_configuration_mock::PersistentConfigurationMock; use crate::test_utils::unshared_test_utils::make_bc_with_defaults; -use actix::{Message, System}; -use ethereum_types::H256; -use itertools::Either; +use ethereum_types::U64; use masq_lib::logger::Logger; -use masq_lib::messages::ScanType; -use masq_lib::ui_gateway::NodeToUiMessage; +use masq_lib::simple_clock::SimpleClock; +use masq_lib::test_utils::simple_clock::SimpleClockMock; +use masq_lib::test_utils::utils::TEST_DEFAULT_CHAIN; use rusqlite::{Connection, OpenFlags, Row}; use std::any::type_name; use std::cell::RefCell; +use std::collections::{BTreeSet, HashMap}; use std::fmt::Debug; use std::path::Path; use std::rc::Rc; use std::sync::{Arc, Mutex}; -use std::time::{Duration, SystemTime}; +use std::time::SystemTime; pub fn make_receivable_account(n: u64, expected_delinquent: bool) -> ReceivableAccount { - let now = to_time_t(SystemTime::now()); + let now = to_unix_timestamp(SystemTime::now()); ReceivableAccount { wallet: make_wallet(&format!( "wallet{}{}", @@ -68,13 +67,13 @@ pub fn make_receivable_account(n: u64, expected_delinquent: bool) -> ReceivableA if expected_delinquent { "d" } else { "n" } )), balance_wei: gwei_to_wei(n), - last_received_timestamp: from_time_t(now - (n as i64)), + last_received_timestamp: from_unix_timestamp(now - (n as i64)), } } pub fn make_payable_account(n: u64) -> PayableAccount { - let now = to_time_t(SystemTime::now()); - let timestamp = from_time_t(now - (n as i64)); + let now = to_unix_timestamp(SystemTime::now()); + let timestamp = from_unix_timestamp(now - (n as i64)); make_payable_account_with_wallet_and_balance_and_timestamp_opt( make_wallet(&format!("wallet{}", n)), gwei_to_wei(n), @@ -95,29 +94,10 @@ pub fn make_payable_account_with_wallet_and_balance_and_timestamp_opt( } } -pub struct AccountantBuilder { - config_opt: Option, - consuming_wallet_opt: Option, - logger_opt: Option, - payable_dao_factory_opt: Option, - receivable_dao_factory_opt: Option, - pending_payable_dao_factory_opt: Option, - banned_dao_factory_opt: Option, - config_dao_factory_opt: Option, -} - -impl Default for AccountantBuilder { - fn default() -> Self { - Self { - config_opt: None, - consuming_wallet_opt: None, - logger_opt: None, - payable_dao_factory_opt: None, - receivable_dao_factory_opt: None, - pending_payable_dao_factory_opt: None, - banned_dao_factory_opt: None, - config_dao_factory_opt: None, - } +pub fn make_transaction_block(num: u64) -> TxBlock { + TxBlock { + block_hash: make_block_hash(num as u32), + block_number: U64::from(num * num * num), } } @@ -252,7 +232,12 @@ const PAYABLE_DAOS_ACCOUNTANT_INITIALIZATION_ORDER: [DestinationMarker; 3] = [ DestinationMarker::PendingPayableScanner, ]; -const PENDING_PAYABLE_DAOS_ACCOUNTANT_INITIALIZATION_ORDER: [DestinationMarker; 3] = [ +const FAILED_PAYABLE_DAOS_ACCOUNTANT_INITIALIZATION_ORDER: [DestinationMarker; 2] = [ + DestinationMarker::PayableScanner, + DestinationMarker::PendingPayableScanner, +]; + +const SENT_PAYABLE_DAOS_ACCOUNTANT_INITIALIZATION_ORDER: [DestinationMarker; 3] = [ DestinationMarker::AccountantBody, DestinationMarker::PayableScanner, DestinationMarker::PendingPayableScanner, @@ -263,6 +248,34 @@ const RECEIVABLE_DAOS_ACCOUNTANT_INITIALIZATION_ORDER: [DestinationMarker; 2] = DestinationMarker::ReceivableScanner, ]; +pub struct AccountantBuilder { + config_opt: Option, + consuming_wallet_opt: Option, + logger_opt: Option, + payable_dao_factory_opt: Option, + receivable_dao_factory_opt: Option, + sent_payable_dao_factory_opt: Option, + failed_payable_dao_factory_opt: Option, + banned_dao_factory_opt: Option, + config_dao_factory_opt: Option, +} + +impl Default for AccountantBuilder { + fn default() -> Self { + Self { + config_opt: None, + consuming_wallet_opt: None, + logger_opt: None, + payable_dao_factory_opt: None, + receivable_dao_factory_opt: None, + sent_payable_dao_factory_opt: None, + failed_payable_dao_factory_opt: None, + banned_dao_factory_opt: None, + config_dao_factory_opt: None, + } + } +} + impl AccountantBuilder { pub fn bootstrapper_config(mut self, config: BootstrapperConfig) -> Self { self.config_opt = Some(config); @@ -279,16 +292,16 @@ impl AccountantBuilder { self } - pub fn pending_payable_daos( + pub fn sent_payable_daos( mut self, - specially_configured_daos: Vec>, + specially_configured_daos: Vec>, ) -> Self { create_or_update_factory!( specially_configured_daos, - PENDING_PAYABLE_DAOS_ACCOUNTANT_INITIALIZATION_ORDER, - pending_payable_dao_factory_opt, - PendingPayableDaoFactoryMock, - PendingPayableDao, + SENT_PAYABLE_DAOS_ACCOUNTANT_INITIALIZATION_ORDER, + sent_payable_dao_factory_opt, + SentPayableDaoFactoryMock, + SentPayableDao, self ) } @@ -307,6 +320,27 @@ impl AccountantBuilder { ) } + pub fn failed_payable_daos( + mut self, + mut specially_configured_daos: Vec>, + ) -> Self { + specially_configured_daos.iter_mut().for_each(|dao| { + if let DaoWithDestination::ForPendingPayableScanner(dao) = dao { + let mut extended_queue = vec![BTreeSet::new()]; + extended_queue.append(&mut dao.retrieve_txs_results.borrow_mut()); + dao.retrieve_txs_results.replace(extended_queue); + } + }); + create_or_update_factory!( + specially_configured_daos, + FAILED_PAYABLE_DAOS_ACCOUNTANT_INITIALIZATION_ORDER, + failed_payable_dao_factory_opt, + FailedPayableDaoFactoryMock, + FailedPayableDao, + self + ) + } + pub fn receivable_daos( mut self, specially_configured_daos: Vec>, @@ -341,7 +375,9 @@ impl AccountantBuilder { } pub fn build(self) -> Accountant { - let config = self.config_opt.unwrap_or(make_bc_with_defaults()); + let config = self + .config_opt + .unwrap_or(make_bc_with_defaults(TEST_DEFAULT_CHAIN)); let payable_dao_factory = self.payable_dao_factory_opt.unwrap_or( PayableDaoFactoryMock::new() .make_result(PayableDaoMock::new()) @@ -353,11 +389,17 @@ impl AccountantBuilder { .make_result(ReceivableDaoMock::new()) .make_result(ReceivableDaoMock::new()), ); - let pending_payable_dao_factory = self.pending_payable_dao_factory_opt.unwrap_or( - PendingPayableDaoFactoryMock::new() - .make_result(PendingPayableDaoMock::new()) - .make_result(PendingPayableDaoMock::new()) - .make_result(PendingPayableDaoMock::new()), + let sent_payable_dao_factory = self.sent_payable_dao_factory_opt.unwrap_or( + SentPayableDaoFactoryMock::new() + .make_result(SentPayableDaoMock::new()) + .make_result(SentPayableDaoMock::new()) + .make_result(SentPayableDaoMock::new()), + ); + let failed_payable_dao_factory = self.failed_payable_dao_factory_opt.unwrap_or( + FailedPayableDaoFactoryMock::new() + .make_result(FailedPayableDaoMock::new()) + .make_result(FailedPayableDaoMock::new()) + .make_result(FailedPayableDaoMock::new()), ); let banned_dao_factory = self .banned_dao_factory_opt @@ -369,7 +411,8 @@ impl AccountantBuilder { config, DaoFactories { payable_dao_factory: Box::new(payable_dao_factory), - pending_payable_dao_factory: Box::new(pending_payable_dao_factory), + sent_payable_dao_factory: Box::new(sent_payable_dao_factory), + failed_payable_dao_factory: Box::new(failed_payable_dao_factory), receivable_dao_factory: Box::new(receivable_dao_factory), banned_dao_factory: Box::new(banned_dao_factory), config_dao_factory: Box::new(config_dao_factory), @@ -395,7 +438,8 @@ impl PayableDaoFactory for PayableDaoFactoryMock { fn make(&self) -> Box { if self.make_results.borrow().len() == 0 { panic!( - "PayableDao Missing. This problem mostly occurs when PayableDao is only supplied for Accountant and not for the Scanner while building Accountant." + "PayableDao Missing. This problem mostly occurs when PayableDao is only supplied \ + for Accountant and not for the Scanner while building Accountant." ) }; self.make_params.lock().unwrap().push(()); @@ -431,7 +475,8 @@ impl ReceivableDaoFactory for ReceivableDaoFactoryMock { fn make(&self) -> Box { if self.make_results.borrow().len() == 0 { panic!( - "ReceivableDao Missing. This problem mostly occurs when ReceivableDao is only supplied for Accountant and not for the Scanner while building Accountant." + "ReceivableDao Missing. This problem mostly occurs when ReceivableDao is only \ + supplied for Accountant and not for the Scanner while building Accountant." ) }; self.make_params.lock().unwrap().push(()); @@ -527,11 +572,11 @@ impl ConfigDaoFactoryMock { pub struct PayableDaoMock { more_money_payable_parameters: Arc>>, more_money_payable_results: RefCell>>, - non_pending_payables_params: Arc>>, - non_pending_payables_results: RefCell>>, + retrieve_payables_params: Arc>>>, + retrieve_payables_results: RefCell>>, mark_pending_payables_rowids_params: Arc>>>, mark_pending_payables_rowids_results: RefCell>>, - transactions_confirmed_params: Arc>>>, + transactions_confirmed_params: Arc>>>, transactions_confirmed_results: RefCell>>, custom_query_params: Arc>>>, custom_query_result: RefCell>>>, @@ -543,37 +588,36 @@ impl PayableDao for PayableDaoMock { &self, now: SystemTime, wallet: &Wallet, - amount: u128, + amount_minor: u128, ) -> Result<(), PayableDaoError> { - self.more_money_payable_parameters - .lock() - .unwrap() - .push((now, wallet.clone(), amount)); + self.more_money_payable_parameters.lock().unwrap().push(( + now, + wallet.clone(), + amount_minor, + )); self.more_money_payable_results.borrow_mut().remove(0) } fn mark_pending_payables_rowids( &self, - wallets_and_rowids: &[(&Wallet, u64)], - ) -> Result<(), PayableDaoError> { - self.mark_pending_payables_rowids_params - .lock() - .unwrap() - .push( - wallets_and_rowids - .iter() - .map(|(wallet, id)| ((*wallet).clone(), *id)) - .collect(), - ); - self.mark_pending_payables_rowids_results - .borrow_mut() - .remove(0) - } - - fn transactions_confirmed( - &self, - confirmed_payables: &[PendingPayableFingerprint], + _mark_instructions: &[MarkPendingPayableID], ) -> Result<(), PayableDaoError> { + todo!("will be removed in the associated card - GH-662") + // self.mark_pending_payables_rowids_params + // .lock() + // .unwrap() + // .push( + // mark_instructions + // .iter() + // .map(|(wallet, id)| ((*wallet).clone(), *id)) + // .collect(), + // ); + // self.mark_pending_payables_rowids_results + // .borrow_mut() + // .remove(0) + } + + fn transactions_confirmed(&self, confirmed_payables: &[SentTx]) -> Result<(), PayableDaoError> { self.transactions_confirmed_params .lock() .unwrap() @@ -581,9 +625,15 @@ impl PayableDao for PayableDaoMock { self.transactions_confirmed_results.borrow_mut().remove(0) } - fn non_pending_payables(&self) -> Vec { - self.non_pending_payables_params.lock().unwrap().push(()); - self.non_pending_payables_results.borrow_mut().remove(0) + fn retrieve_payables( + &self, + condition_opt: Option, + ) -> Vec { + self.retrieve_payables_params + .lock() + .unwrap() + .push(condition_opt); + self.retrieve_payables_results.borrow_mut().remove(0) } fn custom_query(&self, custom_query: CustomQuery) -> Option> { @@ -619,13 +669,16 @@ impl PayableDaoMock { self } - pub fn non_pending_payables_params(mut self, params: &Arc>>) -> Self { - self.non_pending_payables_params = params.clone(); + pub fn retrieve_payables_params( + mut self, + params: &Arc>>>, + ) -> Self { + self.retrieve_payables_params = params.clone(); self } - pub fn non_pending_payables_result(self, result: Vec) -> Self { - self.non_pending_payables_results.borrow_mut().push(result); + pub fn retrieve_payables_result(self, result: Vec) -> Self { + self.retrieve_payables_results.borrow_mut().push(result); self } @@ -644,10 +697,7 @@ impl PayableDaoMock { self } - pub fn transactions_confirmed_params( - mut self, - params: &Arc>>>, - ) -> Self { + pub fn transactions_confirmed_params(mut self, params: &Arc>>>) -> Self { self.transactions_confirmed_params = params.clone(); self } @@ -695,12 +745,13 @@ impl ReceivableDao for ReceivableDaoMock { &self, now: SystemTime, wallet: &Wallet, - amount: u128, + amount_minor: u128, ) -> Result<(), ReceivableDaoError> { - self.more_money_receivable_parameters - .lock() - .unwrap() - .push((now, wallet.clone(), amount)); + self.more_money_receivable_parameters.lock().unwrap().push(( + now, + wallet.clone(), + amount_minor, + )); self.more_money_receivable_results.borrow_mut().remove(0) } @@ -874,199 +925,306 @@ impl BannedDaoMock { } pub fn bc_from_earning_wallet(earning_wallet: Wallet) -> BootstrapperConfig { - let mut bc = make_bc_with_defaults(); + let mut bc = make_bc_with_defaults(TEST_DEFAULT_CHAIN); bc.earning_wallet = earning_wallet; bc } pub fn bc_from_wallets(consuming_wallet: Wallet, earning_wallet: Wallet) -> BootstrapperConfig { - let mut bc = make_bc_with_defaults(); + let mut bc = make_bc_with_defaults(TEST_DEFAULT_CHAIN); bc.consuming_wallet_opt = Some(consuming_wallet); bc.earning_wallet = earning_wallet; bc } #[derive(Default)] -pub struct PendingPayableDaoMock { - fingerprints_rowids_params: Arc>>>, - fingerprints_rowids_results: RefCell>, - delete_fingerprints_params: Arc>>>, - delete_fingerprints_results: RefCell>>, - insert_new_fingerprints_params: Arc, SystemTime)>>>, - insert_new_fingerprints_results: RefCell>>, - increment_scan_attempts_params: Arc>>>, - increment_scan_attempts_result: RefCell>>, - mark_failures_params: Arc>>>, - mark_failures_results: RefCell>>, - return_all_errorless_fingerprints_params: Arc>>, - return_all_errorless_fingerprints_results: RefCell>>, - pub have_return_all_errorless_fingerprints_shut_down_the_system: bool, -} - -impl PendingPayableDao for PendingPayableDaoMock { - fn fingerprints_rowids(&self, hashes: &[H256]) -> TransactionHashes { - self.fingerprints_rowids_params +pub struct SentPayableDaoMock { + get_tx_identifiers_params: Arc>>>, + get_tx_identifiers_results: RefCell>, + insert_new_records_params: Arc>>>, + insert_new_records_results: RefCell>>, + retrieve_txs_params: Arc>>>, + retrieve_txs_results: RefCell>>, + confirm_tx_params: Arc>>>, + confirm_tx_results: RefCell>>, + update_statuses_params: Arc>>>, + update_statuses_results: RefCell>>, + replace_records_params: Arc>>>, + replace_records_results: RefCell>>, + delete_records_params: Arc>>>, + delete_records_results: RefCell>>, +} + +impl SentPayableDao for SentPayableDaoMock { + fn get_tx_identifiers(&self, hashes: &BTreeSet) -> TxIdentifiers { + self.get_tx_identifiers_params .lock() .unwrap() - .push(hashes.to_vec()); - self.fingerprints_rowids_results.borrow_mut().remove(0) + .push(hashes.clone()); + self.get_tx_identifiers_results.borrow_mut().remove(0) } - - fn return_all_errorless_fingerprints(&self) -> Vec { - self.return_all_errorless_fingerprints_params + fn insert_new_records(&self, txs: &BTreeSet) -> Result<(), SentPayableDaoError> { + self.insert_new_records_params .lock() .unwrap() - .push(()); - if self.have_return_all_errorless_fingerprints_shut_down_the_system - && self - .return_all_errorless_fingerprints_results - .borrow() - .is_empty() - { - System::current().stop(); - return vec![]; - } - self.return_all_errorless_fingerprints_results - .borrow_mut() - .remove(0) + .push(txs.clone()); + self.insert_new_records_results.borrow_mut().remove(0) } - - fn insert_new_fingerprints( - &self, - hashes_and_amounts: &[HashAndAmount], - batch_wide_timestamp: SystemTime, - ) -> Result<(), PendingPayableDaoError> { - self.insert_new_fingerprints_params + fn retrieve_txs(&self, condition: Option) -> BTreeSet { + self.retrieve_txs_params.lock().unwrap().push(condition); + self.retrieve_txs_results.borrow_mut().remove(0) + } + fn confirm_txs(&self, hash_map: &HashMap) -> Result<(), SentPayableDaoError> { + self.confirm_tx_params .lock() .unwrap() - .push((hashes_and_amounts.to_vec(), batch_wide_timestamp)); - self.insert_new_fingerprints_results.borrow_mut().remove(0) + .push(hash_map.clone()); + self.confirm_tx_results.borrow_mut().remove(0) } - - fn delete_fingerprints(&self, ids: &[u64]) -> Result<(), PendingPayableDaoError> { - self.delete_fingerprints_params + fn replace_records(&self, new_txs: &BTreeSet) -> Result<(), SentPayableDaoError> { + self.replace_records_params .lock() .unwrap() - .push(ids.to_vec()); - self.delete_fingerprints_results.borrow_mut().remove(0) + .push(new_txs.clone()); + self.replace_records_results.borrow_mut().remove(0) } - fn increment_scan_attempts(&self, ids: &[u64]) -> Result<(), PendingPayableDaoError> { - self.increment_scan_attempts_params + fn update_statuses( + &self, + hash_map: &HashMap, + ) -> Result<(), SentPayableDaoError> { + self.update_statuses_params .lock() .unwrap() - .push(ids.to_vec()); - self.increment_scan_attempts_result.borrow_mut().remove(0) + .push(hash_map.clone()); + self.update_statuses_results.borrow_mut().remove(0) } - fn mark_failures(&self, ids: &[u64]) -> Result<(), PendingPayableDaoError> { - self.mark_failures_params.lock().unwrap().push(ids.to_vec()); - self.mark_failures_results.borrow_mut().remove(0) + fn delete_records(&self, hashes: &BTreeSet) -> Result<(), SentPayableDaoError> { + self.delete_records_params + .lock() + .unwrap() + .push(hashes.clone()); + self.delete_records_results.borrow_mut().remove(0) } } -impl PendingPayableDaoMock { +impl SentPayableDaoMock { pub fn new() -> Self { - PendingPayableDaoMock::default() + SentPayableDaoMock::default() } - pub fn fingerprints_rowids_params(mut self, params: &Arc>>>) -> Self { - self.fingerprints_rowids_params = params.clone(); + pub fn get_tx_identifiers_params(mut self, params: &Arc>>>) -> Self { + self.get_tx_identifiers_params = params.clone(); self } - pub fn fingerprints_rowids_result(self, result: TransactionHashes) -> Self { - self.fingerprints_rowids_results.borrow_mut().push(result); + pub fn get_tx_identifiers_result(self, result: TxIdentifiers) -> Self { + self.get_tx_identifiers_results.borrow_mut().push(result); self } - pub fn insert_fingerprints_params( + pub fn insert_new_records_params(mut self, params: &Arc>>>) -> Self { + self.insert_new_records_params = params.clone(); + self + } + + pub fn insert_new_records_result(self, result: Result<(), SentPayableDaoError>) -> Self { + self.insert_new_records_results.borrow_mut().push(result); + self + } + + pub fn retrieve_txs_params( mut self, - params: &Arc, SystemTime)>>>, + params: &Arc>>>, ) -> Self { - self.insert_new_fingerprints_params = params.clone(); + self.retrieve_txs_params = params.clone(); self } - pub fn insert_fingerprints_result(self, result: Result<(), PendingPayableDaoError>) -> Self { - self.insert_new_fingerprints_results - .borrow_mut() - .push(result); + pub fn retrieve_txs_result(self, result: BTreeSet) -> Self { + self.retrieve_txs_results.borrow_mut().push(result); + self + } + + pub fn confirm_tx_params(mut self, params: &Arc>>>) -> Self { + self.confirm_tx_params = params.clone(); self } - pub fn delete_fingerprints_params(mut self, params: &Arc>>>) -> Self { - self.delete_fingerprints_params = params.clone(); + pub fn confirm_tx_result(self, result: Result<(), SentPayableDaoError>) -> Self { + self.confirm_tx_results.borrow_mut().push(result); self } - pub fn delete_fingerprints_result(self, result: Result<(), PendingPayableDaoError>) -> Self { - self.delete_fingerprints_results.borrow_mut().push(result); + pub fn replace_records_params(mut self, params: &Arc>>>) -> Self { + self.replace_records_params = params.clone(); self } - pub fn return_all_errorless_fingerprints_params( + pub fn replace_records_result(self, result: Result<(), SentPayableDaoError>) -> Self { + self.replace_records_results.borrow_mut().push(result); + self + } + + pub fn update_statuses_params( mut self, - params: &Arc>>, + params: &Arc>>>, ) -> Self { - self.return_all_errorless_fingerprints_params = params.clone(); + self.update_statuses_params = params.clone(); self } - pub fn return_all_errorless_fingerprints_result( - self, - result: Vec, + pub fn update_statuses_result(self, result: Result<(), SentPayableDaoError>) -> Self { + self.update_statuses_results.borrow_mut().push(result); + self + } + + pub fn delete_records_params(mut self, params: &Arc>>>) -> Self { + self.delete_records_params = params.clone(); + self + } + + pub fn delete_records_result(self, result: Result<(), SentPayableDaoError>) -> Self { + self.delete_records_results.borrow_mut().push(result); + self + } +} + +#[derive(Default)] +pub struct FailedPayableDaoMock { + get_tx_identifiers_params: Arc>>>, + get_tx_identifiers_results: RefCell>, + insert_new_records_params: Arc>>>, + insert_new_records_results: RefCell>>, + retrieve_txs_params: Arc>>>, + retrieve_txs_results: RefCell>>, + update_statuses_params: Arc>>>, + update_statuses_results: RefCell>>, + delete_records_params: Arc>>>, + delete_records_results: RefCell>>, +} + +impl FailedPayableDao for FailedPayableDaoMock { + fn get_tx_identifiers(&self, hashes: &BTreeSet) -> TxIdentifiers { + self.get_tx_identifiers_params + .lock() + .unwrap() + .push(hashes.clone()); + self.get_tx_identifiers_results.borrow_mut().remove(0) + } + + fn insert_new_records(&self, txs: &BTreeSet) -> Result<(), FailedPayableDaoError> { + self.insert_new_records_params + .lock() + .unwrap() + .push(txs.clone()); + self.insert_new_records_results.borrow_mut().remove(0) + } + + fn retrieve_txs(&self, condition: Option) -> BTreeSet { + self.retrieve_txs_params.lock().unwrap().push(condition); + self.retrieve_txs_results.borrow_mut().remove(0) + } + + fn update_statuses( + &self, + status_updates: &HashMap, + ) -> Result<(), FailedPayableDaoError> { + self.update_statuses_params + .lock() + .unwrap() + .push(status_updates.clone()); + self.update_statuses_results.borrow_mut().remove(0) + } + + fn delete_records(&self, hashes: &BTreeSet) -> Result<(), FailedPayableDaoError> { + self.delete_records_params + .lock() + .unwrap() + .push(hashes.clone()); + self.delete_records_results.borrow_mut().remove(0) + } +} + +impl FailedPayableDaoMock { + pub fn new() -> Self { + Self::default() + } + + pub fn get_tx_identifiers_params(mut self, params: &Arc>>>) -> Self { + self.get_tx_identifiers_params = params.clone(); + self + } + + pub fn get_tx_identifiers_result(self, result: TxIdentifiers) -> Self { + self.get_tx_identifiers_results.borrow_mut().push(result); + self + } + + pub fn insert_new_records_params( + mut self, + params: &Arc>>>, ) -> Self { - self.return_all_errorless_fingerprints_results - .borrow_mut() - .push(result); + self.insert_new_records_params = params.clone(); self } - pub fn mark_failures_params(mut self, params: &Arc>>>) -> Self { - self.mark_failures_params = params.clone(); + pub fn insert_new_records_result(self, result: Result<(), FailedPayableDaoError>) -> Self { + self.insert_new_records_results.borrow_mut().push(result); self } - pub fn mark_failures_result(self, result: Result<(), PendingPayableDaoError>) -> Self { - self.mark_failures_results.borrow_mut().push(result); + pub fn retrieve_txs_params( + mut self, + params: &Arc>>>, + ) -> Self { + self.retrieve_txs_params = params.clone(); self } - pub fn increment_scan_attempts_params(mut self, params: &Arc>>>) -> Self { - self.increment_scan_attempts_params = params.clone(); + pub fn retrieve_txs_result(self, result: BTreeSet) -> Self { + self.retrieve_txs_results.borrow_mut().push(result); self } - pub fn increment_scan_attempts_result( - self, - result: Result<(), PendingPayableDaoError>, + pub fn update_statuses_params( + mut self, + params: &Arc>>>, ) -> Self { - self.increment_scan_attempts_result - .borrow_mut() - .push(result); + self.update_statuses_params = params.clone(); + self + } + + pub fn update_statuses_result(self, result: Result<(), FailedPayableDaoError>) -> Self { + self.update_statuses_results.borrow_mut().push(result); + self + } + + pub fn delete_records_params(mut self, params: &Arc>>>) -> Self { + self.delete_records_params = params.clone(); + self + } + + pub fn delete_records_result(self, result: Result<(), FailedPayableDaoError>) -> Self { + self.delete_records_results.borrow_mut().push(result); self } } -pub struct PendingPayableDaoFactoryMock { +pub struct FailedPayableDaoFactoryMock { make_params: Arc>>, - make_results: RefCell>>, + make_results: RefCell>>, } -impl PendingPayableDaoFactory for PendingPayableDaoFactoryMock { - fn make(&self) -> Box { - if self.make_results.borrow().len() == 0 { - panic!( - "PendingPayableDao Missing. This problem mostly occurs when PendingPayableDao is only supplied for Accountant and not for the Scanner while building Accountant." - ) - }; +impl FailedPayableDaoFactory for FailedPayableDaoFactoryMock { + fn make(&self) -> Box { self.make_params.lock().unwrap().push(()); self.make_results.borrow_mut().remove(0) } } -impl PendingPayableDaoFactoryMock { +impl FailedPayableDaoFactoryMock { pub fn new() -> Self { Self { make_params: Arc::new(Mutex::new(vec![])), @@ -1079,81 +1237,65 @@ impl PendingPayableDaoFactoryMock { self } - pub fn make_result(self, result: PendingPayableDaoMock) -> Self { + pub fn make_result(self, result: FailedPayableDaoMock) -> Self { self.make_results.borrow_mut().push(Box::new(result)); self } } -pub struct PayableScannerBuilder { - payable_dao: PayableDaoMock, - pending_payable_dao: PendingPayableDaoMock, - payment_thresholds: PaymentThresholds, - payment_adjuster: PaymentAdjusterMock, +pub struct SentPayableDaoFactoryMock { + make_params: Arc>>, + make_results: RefCell>>, +} + +impl SentPayableDaoFactory for SentPayableDaoFactoryMock { + fn make(&self) -> Box { + self.make_params.lock().unwrap().push(()); + self.make_results.borrow_mut().remove(0) + } } -impl PayableScannerBuilder { +impl SentPayableDaoFactoryMock { pub fn new() -> Self { Self { - payable_dao: PayableDaoMock::new(), - pending_payable_dao: PendingPayableDaoMock::new(), - payment_thresholds: PaymentThresholds::default(), - payment_adjuster: PaymentAdjusterMock::default(), + make_params: Arc::new(Mutex::new(vec![])), + make_results: RefCell::new(vec![]), } } - pub fn payable_dao(mut self, payable_dao: PayableDaoMock) -> PayableScannerBuilder { - self.payable_dao = payable_dao; - self - } - - pub fn payment_adjuster( - mut self, - payment_adjuster: PaymentAdjusterMock, - ) -> PayableScannerBuilder { - self.payment_adjuster = payment_adjuster; - self - } - - pub fn payment_thresholds(mut self, payment_thresholds: PaymentThresholds) -> Self { - self.payment_thresholds = payment_thresholds; + pub fn make_params(mut self, params: &Arc>>) -> Self { + self.make_params = params.clone(); self } - pub fn pending_payable_dao( - mut self, - pending_payable_dao: PendingPayableDaoMock, - ) -> PayableScannerBuilder { - self.pending_payable_dao = pending_payable_dao; + pub fn make_result(self, result: SentPayableDaoMock) -> Self { + self.make_results.borrow_mut().push(Box::new(result)); self } - - pub fn build(self) -> PayableScanner { - PayableScanner::new( - Box::new(self.payable_dao), - Box::new(self.pending_payable_dao), - Rc::new(self.payment_thresholds), - Box::new(self.payment_adjuster), - ) - } } pub struct PendingPayableScannerBuilder { payable_dao: PayableDaoMock, - pending_payable_dao: PendingPayableDaoMock, + sent_payable_dao: SentPayableDaoMock, + failed_payable_dao: FailedPayableDaoMock, payment_thresholds: PaymentThresholds, - when_pending_too_long_sec: u64, financial_statistics: FinancialStatistics, + current_sent_payables: Box>, + suspected_failed_payables: Box>, + clock: Box, } impl PendingPayableScannerBuilder { pub fn new() -> Self { Self { payable_dao: PayableDaoMock::new(), - pending_payable_dao: PendingPayableDaoMock::new(), + sent_payable_dao: SentPayableDaoMock::new(), + failed_payable_dao: FailedPayableDaoMock::new(), payment_thresholds: PaymentThresholds::default(), - when_pending_too_long_sec: DEFAULT_PENDING_TOO_LONG_SEC, financial_statistics: FinancialStatistics::default(), + current_sent_payables: Box::new(PendingPayableCacheMock::default()), + suspected_failed_payables: Box::new(PendingPayableCacheMock::default()), + clock: Box::new(SimpleClockMock::default()), } } @@ -1162,24 +1304,46 @@ impl PendingPayableScannerBuilder { self } - pub fn pending_payable_dao(mut self, pending_payable_dao: PendingPayableDaoMock) -> Self { - self.pending_payable_dao = pending_payable_dao; + pub fn sent_payable_dao(mut self, sent_payable_dao: SentPayableDaoMock) -> Self { + self.sent_payable_dao = sent_payable_dao; self } - pub fn when_pending_too_long_sec(mut self, interval: u64) -> Self { - self.when_pending_too_long_sec = interval; + pub fn failed_payable_dao(mut self, failed_payable_dao: FailedPayableDaoMock) -> Self { + self.failed_payable_dao = failed_payable_dao; + self + } + + pub fn sent_payable_cache(mut self, cache: Box>) -> Self { + self.current_sent_payables = cache; + self + } + + pub fn failed_payable_cache( + mut self, + failures: Box>, + ) -> Self { + self.suspected_failed_payables = failures; + self + } + + pub fn validation_failure_clock(mut self, clock: Box) -> Self { + self.clock = clock; self } pub fn build(self) -> PendingPayableScanner { - PendingPayableScanner::new( + let mut scanner = PendingPayableScanner::new( Box::new(self.payable_dao), - Box::new(self.pending_payable_dao), + Box::new(self.sent_payable_dao), + Box::new(self.failed_payable_dao), Rc::new(self.payment_thresholds), - self.when_pending_too_long_sec, Rc::new(RefCell::new(self.financial_statistics)), - ) + ); + scanner.current_sent_payables = self.current_sent_payables; + scanner.suspected_failed_payables = self.suspected_failed_payables; + scanner.clock = self.clock; + scanner } } @@ -1247,18 +1411,7 @@ pub fn make_custom_payment_thresholds() -> PaymentThresholds { } } -pub fn make_pending_payable_fingerprint() -> PendingPayableFingerprint { - PendingPayableFingerprint { - rowid: 33, - timestamp: from_time_t(222_222_222), - hash: make_tx_hash(456), - attempt: 1, - amount: 12345, - process_error: None, - } -} - -pub fn make_payables( +pub fn make_qualified_and_unqualified_payables( now: SystemTime, payment_thresholds: &PaymentThresholds, ) -> ( @@ -1269,8 +1422,8 @@ pub fn make_payables( let unqualified_payable_accounts = vec![PayableAccount { wallet: make_wallet("wallet1"), balance_wei: gwei_to_wei(payment_thresholds.permanent_debt_allowed_gwei + 1), - last_paid_timestamp: from_time_t( - to_time_t(now) - payment_thresholds.maturity_threshold_sec as i64 + 1, + last_paid_timestamp: from_unix_timestamp( + to_unix_timestamp(now) - payment_thresholds.maturity_threshold_sec as i64 + 1, ), pending_payable_opt: None, }]; @@ -1280,8 +1433,8 @@ pub fn make_payables( balance_wei: gwei_to_wei( payment_thresholds.permanent_debt_allowed_gwei + 1_000_000_000, ), - last_paid_timestamp: from_time_t( - to_time_t(now) - payment_thresholds.maturity_threshold_sec as i64 - 1, + last_paid_timestamp: from_unix_timestamp( + to_unix_timestamp(now) - payment_thresholds.maturity_threshold_sec as i64 - 1, ), pending_payable_opt: None, }, @@ -1290,21 +1443,21 @@ pub fn make_payables( balance_wei: gwei_to_wei( payment_thresholds.permanent_debt_allowed_gwei + 1_200_000_000, ), - last_paid_timestamp: from_time_t( - to_time_t(now) - payment_thresholds.maturity_threshold_sec as i64 - 100, + last_paid_timestamp: from_unix_timestamp( + to_unix_timestamp(now) - payment_thresholds.maturity_threshold_sec as i64 - 100, ), pending_payable_opt: None, }, ]; - let mut all_non_pending_payables = Vec::new(); - all_non_pending_payables.extend(qualified_payable_accounts.clone()); - all_non_pending_payables.extend(unqualified_payable_accounts.clone()); + let mut retrieved_payables = Vec::new(); + retrieved_payables.extend(qualified_payable_accounts.clone()); + retrieved_payables.extend(unqualified_payable_accounts.clone()); ( qualified_payable_accounts, unqualified_payable_accounts, - all_non_pending_payables, + retrieved_payables, ) } @@ -1339,10 +1492,10 @@ where { let conn = Connection::open_in_memory().unwrap(); let execute = |sql: &str| conn.execute(sql, []).unwrap(); - execute("create table whatever (exclamations text)"); - execute("insert into whatever (exclamations) values ('Gosh')"); + execute("create table whatever (exclamation text)"); + execute("insert into whatever (exclamation) values ('Gosh')"); - conn.query_row("select exclamations from whatever", [], tested_fn) + conn.query_row("select exclamation from whatever", [], tested_fn) .unwrap(); } @@ -1439,8 +1592,7 @@ pub fn trick_rusqlite_with_read_only_conn( #[derive(Default)] pub struct PaymentAdjusterMock { - search_for_indispensable_adjustment_params: - Arc>>, + search_for_indispensable_adjustment_params: Arc>>, search_for_indispensable_adjustment_results: RefCell, AnalysisError>>>, adjust_payments_params: Arc>>, @@ -1450,7 +1602,7 @@ pub struct PaymentAdjusterMock { impl PaymentAdjuster for PaymentAdjusterMock { fn search_for_indispensable_adjustment( &self, - msg: &BlockchainAgentWithContextMessage, + msg: &PricedTemplatesMessage, logger: &Logger, ) -> Result, AnalysisError> { self.search_for_indispensable_adjustment_params @@ -1479,7 +1631,7 @@ impl PaymentAdjuster for PaymentAdjusterMock { impl PaymentAdjusterMock { pub fn is_adjustment_required_params( mut self, - params: &Arc>>, + params: &Arc>>, ) -> Self { self.search_for_indispensable_adjustment_params = params.clone(); self @@ -1508,208 +1660,3 @@ impl PaymentAdjusterMock { self } } - -macro_rules! formal_traits_for_payable_mid_scan_msg_handling { - ($scanner:ty) => { - impl MultistagePayableScanner for $scanner {} - - impl SolvencySensitivePaymentInstructor for $scanner { - fn try_skipping_payment_adjustment( - &self, - _msg: BlockchainAgentWithContextMessage, - _logger: &Logger, - ) -> Result, String> { - intentionally_blank!() - } - - fn perform_payment_adjustment( - &self, - _setup: PreparedAdjustment, - _logger: &Logger, - ) -> OutboundPaymentsInstructions { - intentionally_blank!() - } - } - }; -} - -pub struct NullScanner {} - -impl Scanner for NullScanner -where - BeginMessage: Message, - EndMessage: Message, -{ - fn begin_scan( - &mut self, - _wallet_opt: Wallet, - _timestamp: SystemTime, - _response_skeleton_opt: Option, - _logger: &Logger, - ) -> Result { - Err(BeginScanError::CalledFromNullScanner) - } - - fn finish_scan(&mut self, _message: EndMessage, _logger: &Logger) -> Option { - panic!("Called finish_scan() from NullScanner"); - } - - fn scan_started_at(&self) -> Option { - panic!("Called scan_started_at() from NullScanner"); - } - - fn mark_as_started(&mut self, _timestamp: SystemTime) { - panic!("Called mark_as_started() from NullScanner"); - } - - fn mark_as_ended(&mut self, _logger: &Logger) { - panic!("Called mark_as_ended() from NullScanner"); - } - - as_any_ref_in_trait_impl!(); -} - -formal_traits_for_payable_mid_scan_msg_handling!(NullScanner); - -impl Default for NullScanner { - fn default() -> Self { - Self::new() - } -} - -impl NullScanner { - pub fn new() -> Self { - Self {} - } -} - -pub struct ScannerMock { - begin_scan_params: Arc, Logger)>>>, - begin_scan_results: RefCell>>, - end_scan_params: Arc>>, - end_scan_results: RefCell>>, - stop_system_after_last_message: RefCell, -} - -impl Scanner - for ScannerMock -where - BeginMessage: Message, - EndMessage: Message, -{ - fn begin_scan( - &mut self, - wallet: Wallet, - timestamp: SystemTime, - response_skeleton_opt: Option, - logger: &Logger, - ) -> Result { - self.begin_scan_params.lock().unwrap().push(( - wallet, - timestamp, - response_skeleton_opt, - logger.clone(), - )); - if self.is_allowed_to_stop_the_system() && self.is_last_message() { - System::current().stop(); - } - self.begin_scan_results.borrow_mut().remove(0) - } - - fn finish_scan(&mut self, message: EndMessage, _logger: &Logger) -> Option { - self.end_scan_params.lock().unwrap().push(message); - if self.is_allowed_to_stop_the_system() && self.is_last_message() { - System::current().stop(); - } - self.end_scan_results.borrow_mut().remove(0) - } - - fn scan_started_at(&self) -> Option { - intentionally_blank!() - } - - fn mark_as_started(&mut self, _timestamp: SystemTime) { - intentionally_blank!() - } - - fn mark_as_ended(&mut self, _logger: &Logger) { - intentionally_blank!() - } -} - -impl Default for ScannerMock { - fn default() -> Self { - Self::new() - } -} - -impl ScannerMock { - pub fn new() -> Self { - Self { - begin_scan_params: Arc::new(Mutex::new(vec![])), - begin_scan_results: RefCell::new(vec![]), - end_scan_params: Arc::new(Mutex::new(vec![])), - end_scan_results: RefCell::new(vec![]), - stop_system_after_last_message: RefCell::new(false), - } - } - - pub fn begin_scan_params( - mut self, - params: &Arc, Logger)>>>, - ) -> Self { - self.begin_scan_params = params.clone(); - self - } - - pub fn begin_scan_result(self, result: Result) -> Self { - self.begin_scan_results.borrow_mut().push(result); - self - } - - pub fn stop_the_system_after_last_msg(self) -> Self { - self.stop_system_after_last_message.replace(true); - self - } - - pub fn is_allowed_to_stop_the_system(&self) -> bool { - *self.stop_system_after_last_message.borrow() - } - - pub fn is_last_message(&self) -> bool { - self.is_last_message_from_begin_scan() || self.is_last_message_from_end_scan() - } - - pub fn is_last_message_from_begin_scan(&self) -> bool { - self.begin_scan_results.borrow().len() == 1 && self.end_scan_results.borrow().is_empty() - } - - pub fn is_last_message_from_end_scan(&self) -> bool { - self.end_scan_results.borrow().len() == 1 && self.begin_scan_results.borrow().is_empty() - } -} - -formal_traits_for_payable_mid_scan_msg_handling!(ScannerMock); - -impl ScanSchedulers { - pub fn update_scheduler( - &mut self, - scan_type: ScanType, - handle_opt: Option>>, - interval_opt: Option, - ) { - let scheduler = self - .schedulers - .get_mut(&scan_type) - .unwrap() - .as_any_mut() - .downcast_mut::>() - .unwrap(); - if let Some(new_handle) = handle_opt { - scheduler.handle = new_handle - } - if let Some(new_interval) = interval_opt { - scheduler.interval = new_interval - } - } -} diff --git a/node/src/actor_system_factory.rs b/node/src/actor_system_factory.rs index 25e20aa7eb..6b811cb093 100644 --- a/node/src/actor_system_factory.rs +++ b/node/src/actor_system_factory.rs @@ -452,7 +452,8 @@ impl ActorFactory for ActorFactoryReal { ) -> AccountantSubs { let data_directory = config.data_directory.as_path(); let payable_dao_factory = Box::new(Accountant::dao_factory(data_directory)); - let pending_payable_dao_factory = Box::new(Accountant::dao_factory(data_directory)); + let failed_payable_dao_factory = Box::new(Accountant::dao_factory(data_directory)); + let sent_payable_dao_factory = Box::new(Accountant::dao_factory(data_directory)); let receivable_dao_factory = Box::new(Accountant::dao_factory(data_directory)); let banned_dao_factory = Box::new(Accountant::dao_factory(data_directory)); let config_dao_factory = Box::new(Accountant::dao_factory(data_directory)); @@ -463,7 +464,8 @@ impl ActorFactory for ActorFactoryReal { config, DaoFactories { payable_dao_factory, - pending_payable_dao_factory, + sent_payable_dao_factory, + failed_payable_dao_factory, receivable_dao_factory, banned_dao_factory, config_dao_factory, @@ -1122,8 +1124,8 @@ mod tests { log_level: LevelFilter::Off, crash_point: CrashPoint::None, dns_servers: vec![], - scan_intervals_opt: Some(ScanIntervals::default()), - suppress_initial_scans: false, + scan_intervals_opt: Some(ScanIntervals::compute_default(TEST_DEFAULT_CHAIN)), + automatic_scans_enabled: true, clandestine_discriminator_factories: Vec::new(), ui_gateway_config: UiGatewayConfig { ui_port: 5335 }, blockchain_bridge_config: BlockchainBridgeConfig { @@ -1193,7 +1195,7 @@ mod tests { crash_point: CrashPoint::None, dns_servers: vec![], scan_intervals_opt: None, - suppress_initial_scans: false, + automatic_scans_enabled: true, clandestine_discriminator_factories: Vec::new(), ui_gateway_config: UiGatewayConfig { ui_port: 5335 }, blockchain_bridge_config: BlockchainBridgeConfig { @@ -1489,7 +1491,7 @@ mod tests { crash_point: CrashPoint::None, dns_servers: vec![], scan_intervals_opt: None, - suppress_initial_scans: false, + automatic_scans_enabled: true, clandestine_discriminator_factories: Vec::new(), ui_gateway_config: UiGatewayConfig { ui_port: 5335 }, blockchain_bridge_config: BlockchainBridgeConfig { @@ -1673,7 +1675,7 @@ mod tests { crash_point: CrashPoint::None, dns_servers: vec![], scan_intervals_opt: None, - suppress_initial_scans: false, + automatic_scans_enabled: true, clandestine_discriminator_factories: Vec::new(), ui_gateway_config: UiGatewayConfig { ui_port: 5335 }, blockchain_bridge_config: BlockchainBridgeConfig { diff --git a/node/src/blockchain/blockchain_agent/agent_web3.rs b/node/src/blockchain/blockchain_agent/agent_web3.rs new file mode 100644 index 0000000000..66df08d574 --- /dev/null +++ b/node/src/blockchain/blockchain_agent/agent_web3.rs @@ -0,0 +1,678 @@ +// Copyright (c) 2019, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. + +use crate::accountant::scanners::payable_scanner::tx_templates::initial::new::NewTxTemplates; +use crate::accountant::scanners::payable_scanner::tx_templates::initial::retry::RetryTxTemplates; +use crate::accountant::scanners::payable_scanner::tx_templates::priced::new::PricedNewTxTemplates; +use crate::accountant::scanners::payable_scanner::tx_templates::priced::retry::PricedRetryTxTemplates; +use crate::blockchain::blockchain_agent::BlockchainAgent; +use crate::sub_lib::blockchain_bridge::ConsumingWalletBalances; +use crate::sub_lib::wallet::Wallet; +use itertools::Either; +use masq_lib::blockchains::chains::Chain; +use masq_lib::logger::Logger; + +#[derive(Debug, Clone)] +pub struct BlockchainAgentWeb3 { + logger: Logger, + latest_gas_price_wei: u128, + gas_limit_const_part: u128, + consuming_wallet: Wallet, + consuming_wallet_balances: ConsumingWalletBalances, + chain: Chain, +} + +impl BlockchainAgent for BlockchainAgentWeb3 { + fn price_qualified_payables( + &self, + unpriced_tx_templates: Either, + ) -> Either { + match unpriced_tx_templates { + Either::Left(new_tx_templates) => { + let priced_new_templates = PricedNewTxTemplates::from_initial_with_logging( + new_tx_templates, + self.latest_gas_price_wei, + self.chain.rec().gas_price_safe_ceiling_minor, + &self.logger, + ); + + Either::Left(priced_new_templates) + } + Either::Right(retry_tx_templates) => { + let priced_retry_templates = PricedRetryTxTemplates::from_initial_with_logging( + retry_tx_templates, + self.latest_gas_price_wei, + self.chain.rec().gas_price_safe_ceiling_minor, + &self.logger, + ); + + Either::Right(priced_retry_templates) + } + } + } + + fn estimate_transaction_fee_total( + &self, + priced_tx_templates: &Either, + ) -> u128 { + let prices_sum = match priced_tx_templates { + Either::Left(new_tx_templates) => new_tx_templates.total_gas_price(), + Either::Right(retry_tx_templates) => retry_tx_templates.total_gas_price(), + }; + (self.gas_limit_const_part + WEB3_MAXIMAL_GAS_LIMIT_MARGIN) * prices_sum + } + + fn consuming_wallet_balances(&self) -> ConsumingWalletBalances { + self.consuming_wallet_balances + } + + fn consuming_wallet(&self) -> &Wallet { + &self.consuming_wallet + } + + fn get_chain(&self) -> Chain { + self.chain + } +} + +// 64 * (64 - 12) ... std transaction has data of 64 bytes and 12 bytes are never used with us; +// each non-zero byte costs 64 units of gas +pub const WEB3_MAXIMAL_GAS_LIMIT_MARGIN: u128 = 3328; + +impl BlockchainAgentWeb3 { + pub fn new( + latest_gas_price_wei: u128, + gas_limit_const_part: u128, + consuming_wallet: Wallet, + consuming_wallet_balances: ConsumingWalletBalances, + chain: Chain, + ) -> BlockchainAgentWeb3 { + Self { + logger: Logger::new("BlockchainAgentWeb3"), + latest_gas_price_wei, + gas_limit_const_part, + consuming_wallet, + consuming_wallet_balances, + chain, + } + } +} + +#[cfg(test)] +mod tests { + use crate::accountant::join_with_separator; + use crate::accountant::scanners::payable_scanner::tx_templates::initial::new::NewTxTemplates; + use crate::accountant::scanners::payable_scanner::tx_templates::initial::retry::{ + RetryTxTemplate, RetryTxTemplates, + }; + use crate::accountant::scanners::payable_scanner::tx_templates::priced::new::{ + PricedNewTxTemplate, PricedNewTxTemplates, + }; + use crate::accountant::scanners::payable_scanner::tx_templates::priced::retry::{ + PricedRetryTxTemplate, PricedRetryTxTemplates, + }; + use crate::accountant::scanners::payable_scanner::tx_templates::test_utils::RetryTxTemplateBuilder; + use crate::accountant::scanners::payable_scanner::tx_templates::BaseTxTemplate; + use crate::accountant::scanners::test_utils::make_zeroed_consuming_wallet_balances; + use crate::accountant::test_utils::make_payable_account; + use crate::blockchain::blockchain_agent::agent_web3::{ + BlockchainAgentWeb3, WEB3_MAXIMAL_GAS_LIMIT_MARGIN, + }; + use crate::blockchain::blockchain_agent::BlockchainAgent; + use crate::blockchain::blockchain_bridge::increase_gas_price_by_margin; + use crate::test_utils::make_wallet; + use itertools::{Either, Itertools}; + use masq_lib::blockchains::chains::Chain; + use masq_lib::constants::DEFAULT_GAS_PRICE_MARGIN; + use masq_lib::logger::Logger; + use masq_lib::test_utils::logging::{init_test_logging, TestLogHandler}; + use masq_lib::test_utils::utils::TEST_DEFAULT_CHAIN; + use thousands::Separable; + + #[test] + fn constants_are_correct() { + assert_eq!(WEB3_MAXIMAL_GAS_LIMIT_MARGIN, 3_328) + } + + #[test] + fn returns_correct_priced_qualified_payables_for_new_payable_scan() { + init_test_logging(); + let test_name = "returns_correct_priced_qualified_payables_for_new_payable_scan"; + let consuming_wallet = make_wallet("efg"); + let consuming_wallet_balances = make_zeroed_consuming_wallet_balances(); + let account_1 = make_payable_account(12); + let account_2 = make_payable_account(34); + let new_tx_templates = NewTxTemplates::from(&vec![account_1.clone(), account_2.clone()]); + let rpc_gas_price_wei = 555_666_777; + let chain = TEST_DEFAULT_CHAIN; + let mut subject = BlockchainAgentWeb3::new( + rpc_gas_price_wei, + 77_777, + consuming_wallet, + consuming_wallet_balances, + chain, + ); + subject.logger = Logger::new(test_name); + + let result = subject.price_qualified_payables(Either::Left(new_tx_templates.clone())); + + let gas_price_with_margin_wei = increase_gas_price_by_margin(rpc_gas_price_wei); + let expected_result = Either::Left(PricedNewTxTemplates::new( + new_tx_templates, + gas_price_with_margin_wei, + )); + assert_eq!(result, expected_result); + TestLogHandler::new().exists_no_log_containing(test_name); + } + + #[test] + fn returns_correct_priced_qualified_payables_for_retry_payable_scan() { + init_test_logging(); + let test_name = "returns_correct_priced_qualified_payables_for_retry_payable_scan"; + let consuming_wallet = make_wallet("efg"); + let consuming_wallet_balances = make_zeroed_consuming_wallet_balances(); + let rpc_gas_price_wei = 444_555_666; + let chain = TEST_DEFAULT_CHAIN; + let retry_tx_templates: Vec = { + vec![ + rpc_gas_price_wei - 1, + rpc_gas_price_wei, + rpc_gas_price_wei + 1, + rpc_gas_price_wei - 123_456, + rpc_gas_price_wei + 456_789, + ] + .into_iter() + .enumerate() + .map(|(idx, prev_gas_price_wei)| { + let account = make_payable_account((idx as u64 + 1) * 3_000); + RetryTxTemplate { + base: BaseTxTemplate::from(&account), + prev_gas_price_wei, + prev_nonce: idx as u64, + } + }) + .collect_vec() + }; + let mut subject = BlockchainAgentWeb3::new( + rpc_gas_price_wei, + 77_777, + consuming_wallet, + consuming_wallet_balances, + chain, + ); + subject.logger = Logger::new(test_name); + + let result = subject + .price_qualified_payables(Either::Right(RetryTxTemplates(retry_tx_templates.clone()))); + + let expected_result = { + let price_wei_for_accounts_from_1_to_5 = vec![ + increase_gas_price_by_margin(rpc_gas_price_wei), + increase_gas_price_by_margin(rpc_gas_price_wei), + increase_gas_price_by_margin(rpc_gas_price_wei + 1), + increase_gas_price_by_margin(rpc_gas_price_wei), + increase_gas_price_by_margin(rpc_gas_price_wei + 456_789), + ]; + if price_wei_for_accounts_from_1_to_5.len() != retry_tx_templates.len() { + panic!("Corrupted test") + } + + Either::Right(PricedRetryTxTemplates( + retry_tx_templates + .iter() + .zip(price_wei_for_accounts_from_1_to_5.into_iter()) + .map(|(retry_tx_template, increased_gas_price)| { + PricedRetryTxTemplate::new(retry_tx_template.clone(), increased_gas_price) + }) + .collect_vec(), + )) + }; + assert_eq!(result, expected_result); + TestLogHandler::new().exists_no_log_containing(test_name); + } + + #[test] + fn new_payables_gas_price_ceiling_test_if_latest_price_is_a_border_value() { + let test_name = "new_payables_gas_price_ceiling_test_if_latest_price_is_a_border_value"; + let chain = TEST_DEFAULT_CHAIN; + let ceiling_gas_price_wei = chain.rec().gas_price_safe_ceiling_minor; + // This should be the value that would surplus the ceiling just slightly if the margin is + // applied. + // Adding just 1 didn't work, therefore 2 + let rpc_gas_price_wei = + ((ceiling_gas_price_wei * 100) / (DEFAULT_GAS_PRICE_MARGIN as u128 + 100)) + 2; + let check_value_wei = increase_gas_price_by_margin(rpc_gas_price_wei); + + test_gas_price_must_not_break_through_ceiling_value_in_the_new_payable_mode( + test_name, + chain, + rpc_gas_price_wei, + 50_000_000_001, + ); + + assert!( + check_value_wei > ceiling_gas_price_wei, + "should be {} > {} but isn't", + check_value_wei, + ceiling_gas_price_wei + ); + } + + #[test] + fn new_payables_gas_price_ceiling_test_if_latest_price_is_a_bit_bigger_even_with_no_margin() { + let test_name = "new_payables_gas_price_ceiling_test_if_latest_price_is_a_bit_bigger_even_with_no_margin"; + let chain = TEST_DEFAULT_CHAIN; + let ceiling_gas_price_wei = chain.rec().gas_price_safe_ceiling_minor; + + test_gas_price_must_not_break_through_ceiling_value_in_the_new_payable_mode( + test_name, + chain, + ceiling_gas_price_wei + 1, + 65_000_000_001, + ); + } + + #[test] + fn new_payables_gas_price_ceiling_test_if_latest_price_is_just_gigantic() { + let test_name = "new_payables_gas_price_ceiling_test_if_latest_price_is_just_gigantic"; + let chain = TEST_DEFAULT_CHAIN; + let ceiling_gas_price_wei = chain.rec().gas_price_safe_ceiling_minor; + + test_gas_price_must_not_break_through_ceiling_value_in_the_new_payable_mode( + test_name, + chain, + 10 * ceiling_gas_price_wei, + 650_000_000_000, + ); + } + + fn test_gas_price_must_not_break_through_ceiling_value_in_the_new_payable_mode( + test_name: &str, + chain: Chain, + rpc_gas_price_wei: u128, + expected_calculated_surplus_value_wei: u128, + ) { + init_test_logging(); + let ceiling_gas_price_wei = chain.rec().gas_price_safe_ceiling_minor; + let consuming_wallet = make_wallet("efg"); + let consuming_wallet_balances = make_zeroed_consuming_wallet_balances(); + let account_1 = make_payable_account(12); + let account_2 = make_payable_account(34); + let tx_templates = NewTxTemplates::from(&vec![account_1.clone(), account_2.clone()]); + let mut subject = BlockchainAgentWeb3::new( + rpc_gas_price_wei, + 77_777, + consuming_wallet, + consuming_wallet_balances, + chain, + ); + subject.logger = Logger::new(test_name); + + let result = subject.price_qualified_payables(Either::Left(tx_templates.clone())); + + let expected_result = Either::Left(PricedNewTxTemplates::new( + tx_templates, + ceiling_gas_price_wei, + )); + assert_eq!(result, expected_result); + let addresses_str = join_with_separator( + &vec![account_1.wallet, account_2.wallet], + |wallet| format!("{}", wallet), + "\n", + ); + TestLogHandler::new().exists_log_containing(&format!( + "WARN: {test_name}: The computed gas price {} wei is above the ceil value of {} wei computed by this Node.\n\ + Transaction(s) to following receivers are affected:\n\ + {}", + expected_calculated_surplus_value_wei.separate_with_commas(), + ceiling_gas_price_wei.separate_with_commas(), + addresses_str + )); + } + + #[test] + fn retry_payables_gas_price_ceiling_test_of_border_value_if_the_latest_fetch_being_bigger() { + let test_name = "retry_payables_gas_price_ceiling_test_of_border_value_if_the_latest_fetch_being_bigger"; + let chain = TEST_DEFAULT_CHAIN; + let account_1 = make_payable_account(12); + let account_2 = make_payable_account(34); + let ceiling_gas_price_wei = chain.rec().gas_price_safe_ceiling_minor; + // This should be the value that would surplus the ceiling just slightly if the margin is + // applied. + // Adding just 1 didn't work, therefore 2 + let rpc_gas_price_wei = + (ceiling_gas_price_wei * 100) / (DEFAULT_GAS_PRICE_MARGIN as u128 + 100) + 2; + let check_value_wei = increase_gas_price_by_margin(rpc_gas_price_wei); + let template_1 = RetryTxTemplateBuilder::new() + .payable_account(&account_1) + .prev_gas_price_wei(rpc_gas_price_wei - 1) + .build(); + let template_2 = RetryTxTemplateBuilder::new() + .payable_account(&account_2) + .prev_gas_price_wei(rpc_gas_price_wei - 2) + .build(); + let retry_tx_templates = vec![template_1, template_2]; + let expected_log_msg = format!( + "The computed gas price(s) in wei is above the ceil value of 50,000,000,000 wei computed by this Node.\n\ + Transaction(s) to following receivers are affected:\n\ + 0x00000000000000000000000077616c6c65743132 with gas price 50,000,000,001\n\ + 0x00000000000000000000000077616c6c65743334 with gas price 50,000,000,001" + ); + + test_gas_price_must_not_break_through_ceiling_value_in_the_retry_payable_mode( + test_name, + chain, + rpc_gas_price_wei, + Either::Right(RetryTxTemplates(retry_tx_templates)), + &expected_log_msg, + ); + + assert!( + check_value_wei > ceiling_gas_price_wei, + "should be {} > {} but isn't", + check_value_wei, + ceiling_gas_price_wei + ); + } + + #[test] + fn retry_payables_gas_price_ceiling_test_of_border_value_if_the_previous_attempt_being_bigger() + { + let test_name = "retry_payables_gas_price_ceiling_test_of_border_value_if_the_previous_attempt_being_bigger"; + let chain = TEST_DEFAULT_CHAIN; + let account_1 = make_payable_account(12); + let account_2 = make_payable_account(34); + let ceiling_gas_price_wei = chain.rec().gas_price_safe_ceiling_minor; + // This should be the value that would surplus the ceiling just slightly if the margin is applied + let border_gas_price_wei = + (ceiling_gas_price_wei * 100) / (DEFAULT_GAS_PRICE_MARGIN as u128 + 100) + 2; + let rpc_gas_price_wei = border_gas_price_wei - 1; + let check_value_wei = increase_gas_price_by_margin(border_gas_price_wei); + let template_1 = RetryTxTemplateBuilder::new() + .payable_account(&account_1) + .prev_gas_price_wei(border_gas_price_wei) + .build(); + let template_2 = RetryTxTemplateBuilder::new() + .payable_account(&account_2) + .prev_gas_price_wei(border_gas_price_wei) + .build(); + let retry_tx_templates = vec![template_1, template_2]; + let expected_log_msg = format!( + "The computed gas price(s) in wei is above the ceil value of 50,000,000,000 wei computed by this Node.\n\ + Transaction(s) to following receivers are affected:\n\ + 0x00000000000000000000000077616c6c65743132 with gas price 50,000,000,001\n\ + 0x00000000000000000000000077616c6c65743334 with gas price 50,000,000,001" + ); + + test_gas_price_must_not_break_through_ceiling_value_in_the_retry_payable_mode( + test_name, + chain, + rpc_gas_price_wei, + Either::Right(RetryTxTemplates(retry_tx_templates)), + &expected_log_msg, + ); + assert!(check_value_wei > ceiling_gas_price_wei); + } + + #[test] + fn retry_payables_gas_price_ceiling_test_of_big_value_if_the_latest_fetch_being_bigger() { + let test_name = + "retry_payables_gas_price_ceiling_test_of_big_value_if_the_latest_fetch_being_bigger"; + let chain = TEST_DEFAULT_CHAIN; + let ceiling_gas_price_wei = chain.rec().gas_price_safe_ceiling_minor; + let fetched_gas_price_wei = ceiling_gas_price_wei - 1; + let account_1 = make_payable_account(12); + let account_2 = make_payable_account(34); + let template_1 = RetryTxTemplateBuilder::new() + .payable_account(&account_1) + .prev_gas_price_wei(fetched_gas_price_wei - 2) + .build(); + let template_2 = RetryTxTemplateBuilder::new() + .payable_account(&account_2) + .prev_gas_price_wei(fetched_gas_price_wei - 3) + .build(); + let retry_tx_templates = vec![template_1, template_2]; + let expected_log_msg = format!( + "The computed gas price(s) in wei is above the ceil value of 50,000,000,000 wei computed by this Node.\n\ + Transaction(s) to following receivers are affected:\n\ + 0x00000000000000000000000077616c6c65743132 with gas price 64,999,999,998\n\ + 0x00000000000000000000000077616c6c65743334 with gas price 64,999,999,998" + ); + + test_gas_price_must_not_break_through_ceiling_value_in_the_retry_payable_mode( + test_name, + chain, + fetched_gas_price_wei, + Either::Right(RetryTxTemplates(retry_tx_templates)), + &expected_log_msg, + ); + } + + #[test] + fn retry_payables_gas_price_ceiling_test_of_big_value_if_the_previous_attempt_being_bigger() { + let test_name = "retry_payables_gas_price_ceiling_test_of_big_value_if_the_previous_attempt_being_bigger"; + let chain = TEST_DEFAULT_CHAIN; + let ceiling_gas_price_wei = chain.rec().gas_price_safe_ceiling_minor; + let account_1 = make_payable_account(12); + let account_2 = make_payable_account(34); + let template_1 = RetryTxTemplateBuilder::new() + .payable_account(&account_1) + .prev_gas_price_wei(ceiling_gas_price_wei - 1) + .build(); + let template_2 = RetryTxTemplateBuilder::new() + .payable_account(&account_2) + .prev_gas_price_wei(ceiling_gas_price_wei - 2) + .build(); + let retry_tx_templates = vec![template_1, template_2]; + let expected_log_msg = format!( + "The computed gas price(s) in wei is above the ceil value of 50,000,000,000 wei computed by this Node.\n\ + Transaction(s) to following receivers are affected:\n\ + 0x00000000000000000000000077616c6c65743132 with gas price 64,999,999,998\n\ + 0x00000000000000000000000077616c6c65743334 with gas price 64,999,999,997" + ); + + test_gas_price_must_not_break_through_ceiling_value_in_the_retry_payable_mode( + test_name, + chain, + ceiling_gas_price_wei - 3, + Either::Right(RetryTxTemplates(retry_tx_templates)), + &expected_log_msg, + ); + } + + #[test] + fn retry_payables_gas_price_ceiling_test_of_giant_value_for_the_latest_fetch() { + let test_name = "retry_payables_gas_price_ceiling_test_of_giant_value_for_the_latest_fetch"; + let chain = TEST_DEFAULT_CHAIN; + let ceiling_gas_price_wei = chain.rec().gas_price_safe_ceiling_minor; + let fetched_gas_price_wei = 10 * ceiling_gas_price_wei; + let account_1 = make_payable_account(12); + let account_2 = make_payable_account(34); + // The values can never go above the ceiling, therefore, we can assume only values even or + // smaller than that in the previous attempts + let template_1 = RetryTxTemplateBuilder::new() + .payable_account(&account_1) + .prev_gas_price_wei(ceiling_gas_price_wei) + .build(); + let template_2 = RetryTxTemplateBuilder::new() + .payable_account(&account_2) + .prev_gas_price_wei(ceiling_gas_price_wei) + .build(); + let retry_tx_templates = vec![template_1, template_2]; + let expected_log_msg = format!( + "The computed gas price(s) in wei is above the ceil value of 50,000,000,000 wei computed by this Node.\n\ + Transaction(s) to following receivers are affected:\n\ + 0x00000000000000000000000077616c6c65743132 with gas price 650,000,000,000\n\ + 0x00000000000000000000000077616c6c65743334 with gas price 650,000,000,000" + ); + + test_gas_price_must_not_break_through_ceiling_value_in_the_retry_payable_mode( + test_name, + chain, + fetched_gas_price_wei, + Either::Right(RetryTxTemplates(retry_tx_templates)), + &expected_log_msg, + ); + } + + fn test_gas_price_must_not_break_through_ceiling_value_in_the_retry_payable_mode( + test_name: &str, + chain: Chain, + rpc_gas_price_wei: u128, + tx_templates: Either, + expected_log_msg: &str, + ) { + init_test_logging(); + let consuming_wallet = make_wallet("efg"); + let consuming_wallet_balances = make_zeroed_consuming_wallet_balances(); + let ceiling_gas_price_wei = chain.rec().gas_price_safe_ceiling_minor; + let expected_result = match &tx_templates { + Either::Left(new_tx_templates) => Either::Left(PricedNewTxTemplates( + new_tx_templates + .iter() + .map(|tx_template| { + PricedNewTxTemplate::new(tx_template.clone(), ceiling_gas_price_wei) + }) + .collect(), + )), + Either::Right(retry_tx_templates) => Either::Right(PricedRetryTxTemplates( + retry_tx_templates + .iter() + .map(|tx_template| { + PricedRetryTxTemplate::new(tx_template.clone(), ceiling_gas_price_wei) + }) + .collect(), + )), + }; + let mut subject = BlockchainAgentWeb3::new( + rpc_gas_price_wei, + 77_777, + consuming_wallet, + consuming_wallet_balances, + chain, + ); + subject.logger = Logger::new(test_name); + + let result = subject.price_qualified_payables(tx_templates); + + assert_eq!(result, expected_result); + TestLogHandler::new() + .exists_log_containing(&format!("WARN: {test_name}: {expected_log_msg}")); + } + + #[test] + fn returns_correct_non_computed_values() { + let gas_limit_const_part = 44_000; + let consuming_wallet = make_wallet("abcde"); + let consuming_wallet_balances = make_zeroed_consuming_wallet_balances(); + + let subject = BlockchainAgentWeb3::new( + 222_333_444, + gas_limit_const_part, + consuming_wallet.clone(), + consuming_wallet_balances, + TEST_DEFAULT_CHAIN, + ); + + assert_eq!(subject.consuming_wallet(), &consuming_wallet); + assert_eq!( + subject.consuming_wallet_balances(), + consuming_wallet_balances + ); + assert_eq!(subject.get_chain(), TEST_DEFAULT_CHAIN); + } + + #[test] + fn estimate_transaction_fee_total_works_for_new_payable() { + let consuming_wallet = make_wallet("efg"); + let consuming_wallet_balances = make_zeroed_consuming_wallet_balances(); + let account_1 = make_payable_account(12); + let account_2 = make_payable_account(34); + let chain = TEST_DEFAULT_CHAIN; + let tx_templates = NewTxTemplates::from(&vec![account_1, account_2]); + let subject = BlockchainAgentWeb3::new( + 444_555_666, + 77_777, + consuming_wallet, + consuming_wallet_balances, + chain, + ); + let new_tx_templates = subject.price_qualified_payables(Either::Left(tx_templates)); + + let result = subject.estimate_transaction_fee_total(&new_tx_templates); + + assert_eq!( + result, + (2 * (77_777 + WEB3_MAXIMAL_GAS_LIMIT_MARGIN)) + * increase_gas_price_by_margin(444_555_666) + ); + } + + #[test] + fn estimate_transaction_fee_total_works_for_retry_txs() { + let consuming_wallet = make_wallet("efg"); + let consuming_wallet_balances = make_zeroed_consuming_wallet_balances(); + let rpc_gas_price_wei = 444_555_666; + let chain = TEST_DEFAULT_CHAIN; + let retry_tx_templates: Vec = { + vec![ + rpc_gas_price_wei - 1, + rpc_gas_price_wei, + rpc_gas_price_wei + 1, + rpc_gas_price_wei - 123_456, + rpc_gas_price_wei + 456_789, + ] + .into_iter() + .enumerate() + .map(|(idx, prev_gas_price_wei)| { + let account = make_payable_account((idx as u64 + 1) * 3_000); + RetryTxTemplate { + base: BaseTxTemplate::from(&account), + prev_gas_price_wei, + prev_nonce: idx as u64, + } + }) + .collect() + }; + let subject = BlockchainAgentWeb3::new( + rpc_gas_price_wei, + 77_777, + consuming_wallet, + consuming_wallet_balances, + chain, + ); + let priced_qualified_payables = + subject.price_qualified_payables(Either::Right(RetryTxTemplates(retry_tx_templates))); + + let result = subject.estimate_transaction_fee_total(&priced_qualified_payables); + + let gas_prices_for_accounts_from_1_to_5 = vec![ + increase_gas_price_by_margin(rpc_gas_price_wei), + increase_gas_price_by_margin(rpc_gas_price_wei), + increase_gas_price_by_margin(rpc_gas_price_wei + 1), + increase_gas_price_by_margin(rpc_gas_price_wei), + increase_gas_price_by_margin(rpc_gas_price_wei + 456_789), + ]; + let expected_result = gas_prices_for_accounts_from_1_to_5 + .into_iter() + .sum::() + * (77_777 + WEB3_MAXIMAL_GAS_LIMIT_MARGIN); + assert_eq!(result, expected_result) + } + + #[test] + fn blockchain_agent_web3_logs_with_right_name() { + let test_name = "blockchain_agent_web3_logs_with_right_name"; + let subject = BlockchainAgentWeb3::new( + 0, + 0, + make_wallet("abcde"), + make_zeroed_consuming_wallet_balances(), + TEST_DEFAULT_CHAIN, + ); + + info!(subject.logger, "{}", test_name); + + TestLogHandler::new() + .exists_log_containing(&format!("INFO: BlockchainAgentWeb3: {}", test_name)); + } +} diff --git a/node/src/accountant/scanners/mid_scan_msg_handling/payable_scanner/blockchain_agent.rs b/node/src/blockchain/blockchain_agent/mod.rs similarity index 58% rename from node/src/accountant/scanners/mid_scan_msg_handling/payable_scanner/blockchain_agent.rs rename to node/src/blockchain/blockchain_agent/mod.rs index 2f2af4015c..2775bddd68 100644 --- a/node/src/accountant/scanners/mid_scan_msg_handling/payable_scanner/blockchain_agent.rs +++ b/node/src/blockchain/blockchain_agent/mod.rs @@ -1,10 +1,17 @@ // Copyright (c) 2019, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. +pub mod agent_web3; +pub mod test_utils; + +use crate::accountant::scanners::payable_scanner::tx_templates::initial::new::NewTxTemplates; +use crate::accountant::scanners::payable_scanner::tx_templates::initial::retry::RetryTxTemplates; +use crate::accountant::scanners::payable_scanner::tx_templates::priced::new::PricedNewTxTemplates; +use crate::accountant::scanners::payable_scanner::tx_templates::priced::retry::PricedRetryTxTemplates; use crate::arbitrary_id_stamp_in_trait; use crate::sub_lib::blockchain_bridge::ConsumingWalletBalances; use crate::sub_lib::wallet::Wallet; +use itertools::Either; use masq_lib::blockchains::chains::Chain; - // Table of chains by // // a) adoption of the fee market (variations on "gas price") @@ -22,11 +29,16 @@ use masq_lib::blockchains::chains::Chain; //* defaulted limit pub trait BlockchainAgent: Send { - fn estimated_transaction_fee_total(&self, number_of_transactions: usize) -> u128; + fn price_qualified_payables( + &self, + unpriced_tx_templates: Either, + ) -> Either; + fn estimate_transaction_fee_total( + &self, + priced_tx_templates: &Either, + ) -> u128; fn consuming_wallet_balances(&self) -> ConsumingWalletBalances; - fn agreed_fee_per_computation_unit(&self) -> u128; fn consuming_wallet(&self) -> &Wallet; - fn get_chain(&self) -> Chain; #[cfg(test)] diff --git a/node/src/accountant/scanners/mid_scan_msg_handling/payable_scanner/test_utils.rs b/node/src/blockchain/blockchain_agent/test_utils.rs similarity index 66% rename from node/src/accountant/scanners/mid_scan_msg_handling/payable_scanner/test_utils.rs rename to node/src/blockchain/blockchain_agent/test_utils.rs index d3ab972847..76e4ff17a6 100644 --- a/node/src/accountant/scanners/mid_scan_msg_handling/payable_scanner/test_utils.rs +++ b/node/src/blockchain/blockchain_agent/test_utils.rs @@ -1,18 +1,21 @@ -// Copyright (c) 2019, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. - #![cfg(test)] -use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::blockchain_agent::BlockchainAgent; +use crate::accountant::scanners::payable_scanner::tx_templates::initial::new::NewTxTemplates; +use crate::accountant::scanners::payable_scanner::tx_templates::initial::retry::RetryTxTemplates; +use crate::accountant::scanners::payable_scanner::tx_templates::priced::new::PricedNewTxTemplates; +use crate::accountant::scanners::payable_scanner::tx_templates::priced::retry::PricedRetryTxTemplates; +use crate::blockchain::blockchain_agent::BlockchainAgent; use crate::sub_lib::blockchain_bridge::ConsumingWalletBalances; use crate::sub_lib::wallet::Wallet; use crate::test_utils::unshared_test_utils::arbitrary_id_stamp::ArbitraryIdStamp; use crate::{arbitrary_id_stamp_in_trait_impl, set_arbitrary_id_stamp_in_mock_impl}; +use itertools::Either; use masq_lib::blockchains::chains::Chain; use std::cell::RefCell; pub struct BlockchainAgentMock { consuming_wallet_balances_results: RefCell>, - agreed_fee_per_computation_unit_results: RefCell>, + gas_price_results: RefCell>, consuming_wallet_result_opt: Option, arbitrary_id_stamp_opt: Option, get_chain_result_opt: Option, @@ -22,7 +25,7 @@ impl Default for BlockchainAgentMock { fn default() -> Self { BlockchainAgentMock { consuming_wallet_balances_results: RefCell::new(vec![]), - agreed_fee_per_computation_unit_results: RefCell::new(vec![]), + gas_price_results: RefCell::new(vec![]), consuming_wallet_result_opt: None, arbitrary_id_stamp_opt: None, get_chain_result_opt: None, @@ -31,18 +34,22 @@ impl Default for BlockchainAgentMock { } impl BlockchainAgent for BlockchainAgentMock { - fn estimated_transaction_fee_total(&self, _number_of_transactions: usize) -> u128 { - todo!("to be implemented by GH-711") + fn price_qualified_payables( + &self, + _tx_templates: Either, + ) -> Either { + unimplemented!("not needed yet") } - fn consuming_wallet_balances(&self) -> ConsumingWalletBalances { + fn estimate_transaction_fee_total( + &self, + _priced_tx_templates: &Either, + ) -> u128 { todo!("to be implemented by GH-711") } - fn agreed_fee_per_computation_unit(&self) -> u128 { - self.agreed_fee_per_computation_unit_results - .borrow_mut() - .remove(0) + fn consuming_wallet_balances(&self) -> ConsumingWalletBalances { + todo!("to be implemented by GH-711") } fn consuming_wallet(&self) -> &Wallet { @@ -68,10 +75,8 @@ impl BlockchainAgentMock { self } - pub fn agreed_fee_per_computation_unit_result(self, result: u128) -> Self { - self.agreed_fee_per_computation_unit_results - .borrow_mut() - .push(result); + pub fn gas_price_result(self, result: u128) -> Self { + self.gas_price_results.borrow_mut().push(result); self } diff --git a/node/src/blockchain/blockchain_bridge.rs b/node/src/blockchain/blockchain_bridge.rs index a0e501393b..183f586590 100644 --- a/node/src/blockchain/blockchain_bridge.rs +++ b/node/src/blockchain/blockchain_bridge.rs @@ -1,19 +1,24 @@ // Copyright (c) 2019, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. -use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::msgs::{ - BlockchainAgentWithContextMessage, QualifiedPayablesMessage, +use crate::accountant::db_access_objects::sent_payable_dao::SentTx; +use crate::accountant::scanners::payable_scanner::msgs::{ + InitialTemplatesMessage, PricedTemplatesMessage, }; +use crate::accountant::scanners::payable_scanner::tx_templates::priced::new::PricedNewTxTemplates; +use crate::accountant::scanners::payable_scanner::tx_templates::priced::retry::PricedRetryTxTemplates; +use crate::accountant::scanners::payable_scanner::utils::initial_templates_msg_stats; use crate::accountant::{ - ReceivedPayments, ResponseSkeleton, ScanError, - SentPayables, SkeletonOptHolder, + ReceivedPayments, ResponseSkeleton, ScanError, SentPayables, SkeletonOptHolder, }; -use crate::accountant::{ReportTransactionReceipts, RequestTransactionReceipts}; +use crate::accountant::{RequestTransactionReceipts, TxReceiptResult, TxReceiptsMessage}; use crate::actor_system_factory::SubsFactory; -use crate::blockchain::blockchain_interface::blockchain_interface_web3::HashAndAmount; +use crate::blockchain::blockchain_agent::BlockchainAgent; use crate::blockchain::blockchain_interface::data_structures::errors::{ - BlockchainError, PayableTransactionError, + BlockchainInterfaceError, LocalPayableError, +}; +use crate::blockchain::blockchain_interface::data_structures::{ + BatchResults, StatusReadFromReceiptCheck, }; -use crate::blockchain::blockchain_interface::data_structures::ProcessedPayableFallible; use crate::blockchain::blockchain_interface::BlockchainInterface; use crate::blockchain::blockchain_interface_initializer::BlockchainInterfaceInitializer; use crate::database::db_initializer::{DbInitializationConfig, DbInitializer, DbInitializerReal}; @@ -21,33 +26,28 @@ use crate::db_config::config_dao::ConfigDaoReal; use crate::db_config::persistent_configuration::{ PersistentConfiguration, PersistentConfigurationReal, }; -use crate::sub_lib::blockchain_bridge::{ - BlockchainBridgeSubs, OutboundPaymentsInstructions, -}; +use crate::sub_lib::accountant::DetailedScanType; +use crate::sub_lib::blockchain_bridge::{BlockchainBridgeSubs, OutboundPaymentsInstructions}; use crate::sub_lib::peer_actors::BindMessage; use crate::sub_lib::utils::{db_connection_launch_panic, handle_ui_crash_request}; -use crate::sub_lib::wallet::{Wallet}; +use crate::sub_lib::wallet::Wallet; use actix::Actor; use actix::Context; use actix::Handler; use actix::Message; use actix::{Addr, Recipient}; use futures::Future; -use itertools::Itertools; +use itertools::{Either, Itertools}; use masq_lib::blockchains::chains::Chain; +use masq_lib::constants::DEFAULT_GAS_PRICE_MARGIN; use masq_lib::logger::Logger; -use masq_lib::messages::ScanType; use masq_lib::ui_gateway::NodeFromUiMessage; use regex::Regex; use std::path::Path; use std::string::ToString; use std::sync::{Arc, Mutex}; use std::time::SystemTime; -use ethabi::Hash; use web3::types::H256; -use crate::accountant::db_access_objects::payable_dao::PayableAccount; -use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::blockchain_agent::BlockchainAgent; -use crate::blockchain::blockchain_interface::blockchain_interface_web3::lower_level_interface_web3::{TransactionReceiptResult, TxStatus}; pub const CRASH_KEY: &str = "BLOCKCHAINBRIDGE"; pub const DEFAULT_BLOCKCHAIN_SERVICE_URL: &str = "https://0.0.0.0"; @@ -57,16 +57,16 @@ pub struct BlockchainBridge { logger: Logger, persistent_config_arc: Arc>, sent_payable_subs_opt: Option>, - payable_payments_setup_subs_opt: Option>, + payable_payments_setup_subs_opt: Option>, received_payments_subs_opt: Option>, scan_error_subs_opt: Option>, crashable: bool, - pending_payable_confirmation: TransactionConfirmationTools, + pending_payable_confirmation: TxConfirmationTools, } -struct TransactionConfirmationTools { - new_pp_fingerprints_sub_opt: Option>, - report_transaction_receipts_sub_opt: Option>, +struct TxConfirmationTools { + register_new_pending_payables_sub_opt: Option>, + report_tx_receipts_sub_opt: Option>, } #[derive(PartialEq, Eq)] @@ -90,11 +90,10 @@ impl Handler for BlockchainBridge { fn handle(&mut self, msg: BindMessage, _ctx: &mut Self::Context) -> Self::Result { self.pending_payable_confirmation - .new_pp_fingerprints_sub_opt = - Some(msg.peer_actors.accountant.init_pending_payable_fingerprints); - self.pending_payable_confirmation - .report_transaction_receipts_sub_opt = - Some(msg.peer_actors.accountant.report_transaction_receipts); + .register_new_pending_payables_sub_opt = + Some(msg.peer_actors.accountant.register_new_pending_payables); + self.pending_payable_confirmation.report_tx_receipts_sub_opt = + Some(msg.peer_actors.accountant.report_transaction_status); self.payable_payments_setup_subs_opt = Some(msg.peer_actors.accountant.report_payable_payments_setup); self.sent_payable_subs_opt = Some(msg.peer_actors.accountant.report_sent_payments); @@ -127,7 +126,7 @@ impl Handler for BlockchainBridge { ) -> >::Result { self.handle_scan_future( Self::handle_retrieve_transactions, - ScanType::Receivables, + DetailedScanType::Receivables, msg, ) } @@ -139,17 +138,25 @@ impl Handler for BlockchainBridge { fn handle(&mut self, msg: RequestTransactionReceipts, _ctx: &mut Self::Context) { self.handle_scan_future( Self::handle_request_transaction_receipts, - ScanType::PendingPayables, + DetailedScanType::PendingPayables, msg, ) } } -impl Handler for BlockchainBridge { +pub trait MsgInterpretableAsDetailedScanType { + fn detailed_scan_type(&self) -> DetailedScanType; +} + +impl Handler for BlockchainBridge { type Result = (); - fn handle(&mut self, msg: QualifiedPayablesMessage, _ctx: &mut Self::Context) { - self.handle_scan_future(Self::handle_qualified_payable_msg, ScanType::Payables, msg); + fn handle(&mut self, msg: InitialTemplatesMessage, _ctx: &mut Self::Context) { + self.handle_scan_future( + Self::handle_initial_templates_msg, + msg.detailed_scan_type(), + msg, + ); } } @@ -159,28 +166,21 @@ impl Handler for BlockchainBridge { fn handle(&mut self, msg: OutboundPaymentsInstructions, _ctx: &mut Self::Context) { self.handle_scan_future( Self::handle_outbound_payments_instructions, - ScanType::Payables, + msg.detailed_scan_type(), msg, ) } } #[derive(Debug, Clone, PartialEq, Eq, Message)] -pub struct PendingPayableFingerprintSeeds { - pub batch_wide_timestamp: SystemTime, - pub hashes_and_balances: Vec, +pub struct RegisterNewPendingPayables { + pub new_sent_txs: Vec, } -#[derive(Debug, PartialEq, Eq, Clone)] -pub struct PendingPayableFingerprint { - // Sqlite begins counting from 1 - pub rowid: u64, - pub timestamp: SystemTime, - pub hash: H256, - // We have Sqlite begin counting from 1 - pub attempt: u16, - pub amount: u128, - pub process_error: Option, +impl RegisterNewPendingPayables { + pub fn new(new_sent_txs: Vec) -> Self { + Self { new_sent_txs } + } } impl Handler for BlockchainBridge { @@ -206,9 +206,9 @@ impl BlockchainBridge { scan_error_subs_opt: None, crashable, logger: Logger::new("BlockchainBridge"), - pending_payable_confirmation: TransactionConfirmationTools { - new_pp_fingerprints_sub_opt: None, - report_transaction_receipts_sub_opt: None, + pending_payable_confirmation: TxConfirmationTools { + register_new_pending_payables_sub_opt: None, + report_tx_receipts_sub_opt: None, }, } } @@ -248,29 +248,36 @@ impl BlockchainBridge { BlockchainBridgeSubs { bind: recipient!(addr, BindMessage), outbound_payments_instructions: recipient!(addr, OutboundPaymentsInstructions), - qualified_payables: recipient!(addr, QualifiedPayablesMessage), + qualified_payables: recipient!(addr, InitialTemplatesMessage), retrieve_transactions: recipient!(addr, RetrieveTransactions), ui_sub: recipient!(addr, NodeFromUiMessage), request_transaction_receipts: recipient!(addr, RequestTransactionReceipts), } } - fn handle_qualified_payable_msg( + fn handle_initial_templates_msg( &mut self, - incoming_message: QualifiedPayablesMessage, + incoming_message: InitialTemplatesMessage, ) -> Box> { + debug!( + &self.logger, + "{}", + initial_templates_msg_stats(&incoming_message) + ); // TODO rewrite this into a batch call as soon as GH-629 gets into master let accountant_recipient = self.payable_payments_setup_subs_opt.clone(); Box::new( self.blockchain_interface - .build_blockchain_agent(incoming_message.consuming_wallet) + .introduce_blockchain_agent(incoming_message.consuming_wallet) .map_err(|e| format!("Blockchain agent build error: {:?}", e)) .and_then(move |agent| { - let outgoing_message = BlockchainAgentWithContextMessage::new( - incoming_message.protected_qualified_payables, + let priced_templates = + agent.price_qualified_payables(incoming_message.initial_templates); + let outgoing_message = PricedTemplatesMessage { + priced_templates, agent, - incoming_message.response_skeleton_opt, - ); + response_skeleton_opt: incoming_message.response_skeleton_opt, + }; accountant_recipient .expect("Accountant is unbound") .try_send(outgoing_message) @@ -280,36 +287,53 @@ impl BlockchainBridge { ) } + fn payment_procedure_result_from_error(e: LocalPayableError) -> Result { + match e { + LocalPayableError::Sending { failed_txs, .. } => Ok(BatchResults { + sent_txs: vec![], + failed_txs, + }), + _ => Err(e.to_string()), + } + } + fn handle_outbound_payments_instructions( &mut self, msg: OutboundPaymentsInstructions, ) -> Box> { let skeleton_opt = msg.response_skeleton_opt; - let sent_payable_subs = self + let sent_payable_subs_success = self .sent_payable_subs_opt .as_ref() .expect("Accountant is unbound") .clone(); - - let send_message_if_failure = move |msg: SentPayables| { - sent_payable_subs.try_send(msg).expect("Accountant is dead"); - }; - let send_message_if_successful = send_message_if_failure.clone(); + let sent_payable_subs_err = sent_payable_subs_success.clone(); + let payable_scan_type = msg.scan_type(); Box::new( - self.process_payments(msg.agent, msg.affordable_accounts) - .map_err(move |e: PayableTransactionError| { - send_message_if_failure(SentPayables { - payment_procedure_result: Err(e.clone()), - response_skeleton_opt: skeleton_opt, - }); + self.process_payments(msg.agent, msg.priced_templates) + .map_err(move |e: LocalPayableError| { + sent_payable_subs_err + .try_send(SentPayables { + payment_procedure_result: Self::payment_procedure_result_from_error( + e.clone(), + ), + payable_scan_type, + response_skeleton_opt: skeleton_opt, + }) + .expect("Accountant is dead"); + format!("ReportAccountsPayable: {}", e) }) - .and_then(move |payment_result| { - send_message_if_successful(SentPayables { - payment_procedure_result: Ok(payment_result), - response_skeleton_opt: skeleton_opt, - }); + .and_then(move |batch_results| { + sent_payable_subs_success + .try_send(SentPayables { + payment_procedure_result: Ok(batch_results), + payable_scan_type, + response_skeleton_opt: skeleton_opt, + }) + .expect("Accountant is dead"); + Ok(()) }), ) @@ -394,21 +418,21 @@ impl BlockchainBridge { fn log_status_of_tx_receipts( logger: &Logger, - transaction_receipts_results: &[TransactionReceiptResult], + transaction_receipts_results: &[&TxReceiptResult], ) { logger.debug(|| { let (successful_count, failed_count, pending_count) = transaction_receipts_results.iter().fold( (0, 0, 0), |(success, fail, pending), transaction_receipt| match transaction_receipt { - TransactionReceiptResult::RpcResponse(tx_receipt) => { - match tx_receipt.status { - TxStatus::Failed => (success, fail + 1, pending), - TxStatus::Pending => (success, fail, pending + 1), - TxStatus::Succeeded(_) => (success + 1, fail, pending), + Ok(tx_status) => match tx_status { + StatusReadFromReceiptCheck::Reverted => (success, fail + 1, pending), + StatusReadFromReceiptCheck::Succeeded(_) => { + (success + 1, fail, pending) } - } - TransactionReceiptResult::LocalError(_) => (success, fail, pending + 1), + StatusReadFromReceiptCheck::Pending => (success, fail, pending + 1), + }, + Err(_) => (success, fail, pending + 1), }, ); format!( @@ -425,30 +449,21 @@ impl BlockchainBridge { let logger = self.logger.clone(); let accountant_recipient = self .pending_payable_confirmation - .report_transaction_receipts_sub_opt + .report_tx_receipts_sub_opt .clone() .expect("Accountant is unbound"); - - let transaction_hashes = msg - .pending_payable - .iter() - .map(|finger_print| finger_print.hash) - .collect::>(); Box::new( self.blockchain_interface - .process_transaction_receipts(transaction_hashes) + .process_transaction_receipts(msg.tx_hashes) .map_err(move |e| e.to_string()) - .and_then(move |transaction_receipts_results| { - Self::log_status_of_tx_receipts(&logger, &transaction_receipts_results); - - let pairs = transaction_receipts_results - .into_iter() - .zip(msg.pending_payable.into_iter()) - .collect_vec(); - + .and_then(move |tx_receipt_results| { + Self::log_status_of_tx_receipts( + &logger, + tx_receipt_results.values().collect_vec().as_slice(), + ); accountant_recipient - .try_send(ReportTransactionReceipts { - fingerprints_with_receipts: pairs, + .try_send(TxReceiptsMessage { + results: tx_receipt_results, response_skeleton_opt: msg.response_skeleton_opt, }) .expect("Accountant is dead"); @@ -458,7 +473,7 @@ impl BlockchainBridge { ) } - fn handle_scan_future(&mut self, handler: F, scan_type: ScanType, msg: M) + fn handle_scan_future(&mut self, handler: F, scan_type: DetailedScanType, msg: M) where F: FnOnce(&mut BlockchainBridge, M) -> Box>, M: SkeletonOptHolder, @@ -485,32 +500,20 @@ impl BlockchainBridge { fn process_payments( &self, agent: Box, - affordable_accounts: Vec, - ) -> Box, Error = PayableTransactionError>> - { - let new_fingerprints_recipient = self.new_fingerprints_recipient(); + priced_templates: Either, + ) -> Box> { let logger = self.logger.clone(); - self.blockchain_interface.submit_payables_in_batch( - logger, - agent, - new_fingerprints_recipient, - affordable_accounts, - ) - } - - fn new_fingerprints_recipient(&self) -> Recipient { - self.pending_payable_confirmation - .new_pp_fingerprints_sub_opt - .clone() - .expect("Accountant unbound") + self.blockchain_interface + .submit_payables_in_batch(logger, agent, priced_templates) } - pub fn extract_max_block_count(error: BlockchainError) -> Option { + pub fn extract_max_block_count(error: BlockchainInterfaceError) -> Option { let regex_result = Regex::new(r".* (max: |allowed for your plan: |is limited to |block range limit \(|exceeds max block range )(?P\d+).*") .expect("Invalid regex"); let max_block_count = match error { - BlockchainError::QueryFailed(msg) => match regex_result.captures(msg.as_str()) { + BlockchainInterfaceError::QueryFailed(msg) => match regex_result.captures(msg.as_str()) + { Some(captures) => match captures.name("max_block_count") { Some(m) => match m.as_str().parse::() { Ok(value) => Some(value), @@ -535,6 +538,10 @@ struct PendingTxInfo { when_sent: SystemTime, } +pub fn increase_gas_price_by_margin(gas_price: u128) -> u128 { + (gas_price * (100 + DEFAULT_GAS_PRICE_MARGIN as u128)) / 100 +} + pub struct BlockchainBridgeSubsFactoryReal {} impl SubsFactory for BlockchainBridgeSubsFactoryReal { @@ -546,44 +553,54 @@ impl SubsFactory for BlockchainBridgeSub #[cfg(test)] mod tests { use super::*; + use crate::accountant::db_access_objects::failed_payable_dao::FailedTx; + use crate::accountant::db_access_objects::failed_payable_dao::FailureReason::Submission; + use crate::accountant::db_access_objects::failed_payable_dao::FailureStatus::RetryRequired; use crate::accountant::db_access_objects::payable_dao::PayableAccount; - use crate::accountant::db_access_objects::pending_payable_dao::PendingPayable; - use crate::accountant::db_access_objects::utils::from_time_t; - use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::agent_web3::WEB3_MAXIMAL_GAS_LIMIT_MARGIN; - use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::test_utils::BlockchainAgentMock; - use crate::accountant::scanners::test_utils::protect_payables_in_test; - use crate::accountant::test_utils::{make_payable_account, make_pending_payable_fingerprint}; - use crate::blockchain::blockchain_interface::blockchain_interface_web3::BlockchainInterfaceWeb3; - use crate::blockchain::blockchain_interface::data_structures::errors::PayableTransactionError::TransactionID; - use crate::blockchain::blockchain_interface::data_structures::errors::{ - BlockchainAgentBuildError, PayableTransactionError, + use crate::accountant::db_access_objects::sent_payable_dao::TxStatus::Pending; + use crate::accountant::db_access_objects::test_utils::{ + assert_on_failed_txs, assert_on_sent_txs, }; - use crate::blockchain::blockchain_interface::data_structures::ProcessedPayableFallible::Correct; + use crate::accountant::db_access_objects::utils::{from_unix_timestamp, to_unix_timestamp}; + use crate::accountant::scanners::payable_scanner::tx_templates::initial::new::NewTxTemplates; + use crate::accountant::scanners::payable_scanner::tx_templates::priced::new::PricedNewTxTemplate; + use crate::accountant::scanners::payable_scanner::tx_templates::test_utils::make_priced_new_tx_templates; + use crate::accountant::scanners::pending_payable_scanner::utils::TxHashByTable; + use crate::accountant::test_utils::make_payable_account; + use crate::blockchain::blockchain_agent::test_utils::BlockchainAgentMock; + use crate::blockchain::blockchain_interface::data_structures::errors::BlockchainAgentBuildError; + use crate::blockchain::blockchain_interface::data_structures::errors::LocalPayableError::TransactionID; use crate::blockchain::blockchain_interface::data_structures::{ - BlockchainTransaction, RetrievedBlockchainTransactions, + BlockchainTransaction, RetrievedBlockchainTransactions, TxBlock, + }; + use crate::blockchain::errors::rpc_errors::{ + AppRpcError, AppRpcErrorKind, LocalErrorKind, RemoteError, }; + use crate::blockchain::errors::validation_status::ValidationStatus; + use crate::blockchain::errors::validation_status::ValidationStatus::Waiting; use crate::blockchain::test_utils::{ make_blockchain_interface_web3, make_tx_hash, ReceiptResponseBuilder, }; use crate::db_config::persistent_configuration::PersistentConfigError; - use crate::match_every_type_id; + use crate::match_lazily_every_type_id; use crate::node_test_utils::check_timestamp; use crate::sub_lib::blockchain_bridge::ConsumingWalletBalances; use crate::test_utils::persistent_configuration_mock::PersistentConfigurationMock; use crate::test_utils::recorder::{ - make_accountant_subs_from_recorder, make_recorder, peer_actors_builder, + make_accountant_subs_from_recorder, make_blockchain_bridge_subs_from_recorder, + make_recorder, peer_actors_builder, }; - use crate::test_utils::recorder_stop_conditions::StopCondition; use crate::test_utils::recorder_stop_conditions::StopConditions; use crate::test_utils::unshared_test_utils::arbitrary_id_stamp::ArbitraryIdStamp; use crate::test_utils::unshared_test_utils::{ assert_on_initialization_with_panic_on_migration, configure_default_persistent_config, - prove_that_crash_request_handler_is_hooked_up, AssertionsMessage, ZERO, + prove_that_crash_request_handler_is_hooked_up, AssertionsMessage, + SubsFactoryTestAddrLeaker, ZERO, }; use crate::test_utils::{make_paying_wallet, make_wallet}; use actix::System; use ethereum_types::U64; - use masq_lib::messages::ScanType; + use masq_lib::constants::DEFAULT_MAX_BLOCK_COUNT; use masq_lib::test_utils::logging::init_test_logging; use masq_lib::test_utils::logging::TestLogHandler; use masq_lib::test_utils::mock_blockchain_client_server::MBCSBuilder; @@ -597,8 +614,6 @@ mod tests { use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime}; use web3::types::{TransactionReceipt, H160}; - use masq_lib::constants::DEFAULT_MAX_BLOCK_COUNT; - use crate::blockchain::blockchain_interface::blockchain_interface_web3::lower_level_interface_web3::{TransactionBlock, TxReceipt}; impl Handler> for BlockchainBridge { type Result = (); @@ -612,6 +627,17 @@ mod tests { } } + impl SubsFactory + for SubsFactoryTestAddrLeaker + { + fn make(&self, addr: &Addr) -> BlockchainBridgeSubs { + self.send_leaker_msg_and_return_meaningless_subs( + addr, + make_blockchain_bridge_subs_from_recorder, + ) + } + } + #[test] fn constants_have_correct_values() { assert_eq!(CRASH_KEY, "BLOCKCHAINBRIDGE"); @@ -679,15 +705,15 @@ mod tests { } #[test] - fn qualified_payables_msg_is_handled_and_new_msg_with_an_added_blockchain_agent_returns_to_accountant( - ) { - let system = System::new( - "qualified_payables_msg_is_handled_and_new_msg_with_an_added_blockchain_agent_returns_to_accountant", - ); + fn handles_initial_templates_msg_in_new_payables_mode_and_sends_response_back_to_accountant() { + init_test_logging(); + let test_name = "handles_initial_templates_msg_in_new_payables_mode_and_sends_response_back_to_accountant"; + let system = System::new(test_name); let port = find_free_port(); let _blockchain_client_server = MBCSBuilder::new(port) - .ok_response("0x230000000".to_string(), 1) // 9395240960 - .ok_response("0x23".to_string(), 1) + // Fetching a recommended gas price + .ok_response("0x230000000".to_string(), 1) + .ok_response("0xAAAA".to_string(), 1) .ok_response( "0x000000000000000000000000000000000000000000000000000000000000FFFF".to_string(), 0, @@ -723,10 +749,11 @@ mod tests { Arc::new(Mutex::new(persistent_configuration)), false, ); + subject.logger = Logger::new(test_name); subject.payable_payments_setup_subs_opt = Some(accountant_recipient); - let qualified_payables = protect_payables_in_test(qualified_payables.clone()); - let qualified_payables_msg = QualifiedPayablesMessage { - protected_qualified_payables: qualified_payables.clone(), + let tx_templates = NewTxTemplates::from(&qualified_payables); + let qualified_payables_msg = InitialTemplatesMessage { + initial_templates: Either::Left(tx_templates.clone()), consuming_wallet: consuming_wallet.clone(), response_skeleton_opt: Some(ResponseSkeleton { client_id: 11122, @@ -735,48 +762,39 @@ mod tests { }; subject - .handle_qualified_payable_msg(qualified_payables_msg) + .handle_initial_templates_msg(qualified_payables_msg) .wait() .unwrap(); System::current().stop(); system.run(); - let accountant_received_payment = accountant_recording_arc.lock().unwrap(); - let blockchain_agent_with_context_msg_actual: &BlockchainAgentWithContextMessage = + let blockchain_agent_with_context_msg_actual: &PricedTemplatesMessage = accountant_received_payment.get_record(0); + let computed_gas_price_wei = increase_gas_price_by_margin(0x230000000); + let expected_tx_templates = tx_templates + .iter() + .map(|tx_template| PricedNewTxTemplate { + base: tx_template.base, + computed_gas_price_wei, + }) + .collect::(); + assert_eq!( - blockchain_agent_with_context_msg_actual.protected_qualified_payables, - qualified_payables - ); - assert_eq!( - blockchain_agent_with_context_msg_actual - .agent - .consuming_wallet(), - &consuming_wallet - ); - assert_eq!( - blockchain_agent_with_context_msg_actual - .agent - .agreed_fee_per_computation_unit(), - 0x230000000 + blockchain_agent_with_context_msg_actual.priced_templates, + Either::Left(expected_tx_templates) ); + let actual_agent = blockchain_agent_with_context_msg_actual.agent.as_ref(); + assert_eq!(actual_agent.consuming_wallet(), &consuming_wallet); assert_eq!( - blockchain_agent_with_context_msg_actual - .agent - .consuming_wallet_balances(), - ConsumingWalletBalances::new( - 35.into(), - 0x000000000000000000000000000000000000000000000000000000000000FFFF.into() - ) + actual_agent.consuming_wallet_balances(), + ConsumingWalletBalances::new(0xAAAA.into(), 0xFFFF.into()) ); - let gas_limit_const_part = - BlockchainInterfaceWeb3::web3_gas_limit_const_part(Chain::PolyMainnet); assert_eq!( - blockchain_agent_with_context_msg_actual - .agent - .estimated_transaction_fee_total(1), - (1 * 0x230000000 * (gas_limit_const_part + WEB3_MAXIMAL_GAS_LIMIT_MARGIN)) + actual_agent.estimate_transaction_fee_total( + &actual_agent.price_qualified_payables(Either::Left(tx_templates)) + ), + 1_791_228_995_698_688 ); assert_eq!( blockchain_agent_with_context_msg_actual.response_skeleton_opt, @@ -786,12 +804,15 @@ mod tests { }) ); assert_eq!(accountant_received_payment.len(), 1); + TestLogHandler::new() + .exists_log_containing(&format!("DEBUG: {test_name}: Found 2 new txs to process")); } #[test] - fn qualified_payables_msg_is_handled_but_fails_on_build_blockchain_agent() { - let system = - System::new("qualified_payables_msg_is_handled_but_fails_on_build_blockchain_agent"); + fn qualified_payables_msg_is_handled_but_fails_on_introduce_blockchain_agent() { + let system = System::new( + "qualified_payables_msg_is_handled_but_fails_on_introduce_blockchain_agent", + ); let port = find_free_port(); // build blockchain agent fails by not providing the third response. let _blockchain_client_server = MBCSBuilder::new(port) @@ -808,9 +829,9 @@ mod tests { false, ); subject.payable_payments_setup_subs_opt = Some(accountant_recipient); - let qualified_payables = protect_payables_in_test(vec![]); - let qualified_payables_msg = QualifiedPayablesMessage { - protected_qualified_payables: qualified_payables, + let new_tx_templates = NewTxTemplates::from(&vec![make_payable_account(123)]); + let qualified_payables_msg = InitialTemplatesMessage { + initial_templates: Either::Left(new_tx_templates), consuming_wallet: consuming_wallet.clone(), response_skeleton_opt: Some(ResponseSkeleton { client_id: 11122, @@ -819,18 +840,17 @@ mod tests { }; let error_msg = subject - .handle_qualified_payable_msg(qualified_payables_msg) + .handle_initial_templates_msg(qualified_payables_msg) .wait() .unwrap_err(); System::current().stop(); system.run(); - let accountant_recording = accountant_recording_arc.lock().unwrap(); assert_eq!(accountant_recording.len(), 0); let service_fee_balance_error = BlockchainAgentBuildError::ServiceFeeBalance( consuming_wallet.address(), - BlockchainError::QueryFailed( + BlockchainInterfaceError::QueryFailed( "Api error: Transport error: Error(IncompleteMessage)".to_string(), ), ); @@ -844,10 +864,10 @@ mod tests { } #[test] - fn handle_outbound_payments_instructions_sees_payments_happen_and_sends_payment_results_back_to_accountant( + fn handle_outbound_payments_instructions_sees_payment_happen_and_sends_payment_results_back_to_accountant( ) { let system = System::new( - "handle_outbound_payments_instructions_sees_payments_happen_and_sends_payment_results_back_to_accountant", + "handle_outbound_payments_instructions_sees_payment_happen_and_sends_payment_results_back_to_accountant", ); let port = find_free_port(); let _blockchain_client_server = MBCSBuilder::new(port) @@ -858,12 +878,16 @@ mod tests { .start(); let (accountant, _, accountant_recording_arc) = make_recorder(); let accountant_addr = accountant - .system_stop_conditions(match_every_type_id!(SentPayables)) + .system_stop_conditions(match_lazily_every_type_id!(SentPayables)) .start(); let wallet_account = make_wallet("blah"); let consuming_wallet = make_paying_wallet(b"consuming_wallet"); let blockchain_interface = make_blockchain_interface_web3(port); let persistent_configuration_mock = PersistentConfigurationMock::default(); + let response_skeleton = ResponseSkeleton { + client_id: 1234, + context_id: 4321, + }; let subject = BlockchainBridge::new( Box::new(blockchain_interface), Arc::new(Mutex::new(persistent_configuration_mock)), @@ -873,16 +897,15 @@ mod tests { let subject_subs = BlockchainBridge::make_subs_from(&addr); let mut peer_actors = peer_actors_builder().build(); peer_actors.accountant = make_accountant_subs_from_recorder(&accountant_addr); - let accounts = vec![PayableAccount { + let account = PayableAccount { wallet: wallet_account, balance_wei: 111_420_204, - last_paid_timestamp: from_time_t(150_000_000), + last_paid_timestamp: from_unix_timestamp(150_000_000), pending_payable_opt: None, - }]; + }; let agent_id_stamp = ArbitraryIdStamp::new(); let agent = BlockchainAgentMock::default() .set_arbitrary_id_stamp(agent_id_stamp) - .agreed_fee_per_computation_unit_result(123) .consuming_wallet_result(consuming_wallet) .get_chain_result(Chain::PolyMainnet); @@ -890,51 +913,55 @@ mod tests { let _ = addr .try_send(OutboundPaymentsInstructions { - affordable_accounts: accounts.clone(), + priced_templates: Either::Left(make_priced_new_tx_templates(vec![( + account.clone(), + 111_222_333, + )])), agent: Box::new(agent), - response_skeleton_opt: Some(ResponseSkeleton { - client_id: 1234, - context_id: 4321, - }), + response_skeleton_opt: Some(response_skeleton), }) .unwrap(); - let time_before = SystemTime::now(); system.run(); - let time_after = SystemTime::now(); let accountant_recording = accountant_recording_arc.lock().unwrap(); - let pending_payable_fingerprint_seeds_msg = - accountant_recording.get_record::(0); - let sent_payables_msg = accountant_recording.get_record::(1); - assert_eq!( - sent_payables_msg, - &SentPayables { - payment_procedure_result: Ok(vec![Correct(PendingPayable { - recipient_wallet: accounts[0].wallet.clone(), - hash: H256::from_str( - "36e9d7cdd657181317dd461192d537d9944c57a51ee950607de5a618b00e57a1" - ) - .unwrap() - })]), - response_skeleton_opt: Some(ResponseSkeleton { - client_id: 1234, - context_id: 4321 - }) - } - ); - assert!(pending_payable_fingerprint_seeds_msg.batch_wide_timestamp >= time_before); - assert!(pending_payable_fingerprint_seeds_msg.batch_wide_timestamp <= time_after); - assert_eq!( - pending_payable_fingerprint_seeds_msg.hashes_and_balances, - vec![HashAndAmount { + // TODO: GH-701: This card is related to the commented out code in this test + // let pending_payable_fingerprint_seeds_msg = + // accountant_recording.get_record::(0); + let sent_payables_msg = accountant_recording.get_record::(0); + let batch_results = sent_payables_msg.clone().payment_procedure_result.unwrap(); + assert!(batch_results.failed_txs.is_empty()); + assert_on_sent_txs( + batch_results.sent_txs, + vec![SentTx { hash: H256::from_str( - "36e9d7cdd657181317dd461192d537d9944c57a51ee950607de5a618b00e57a1" + "81d20df32920161727cd20e375e53c2f9df40fd80256a236fb39e444c999fb6c", ) .unwrap(), - amount: accounts[0].balance_wei - }] + receiver_address: account.wallet.address(), + amount_minor: account.balance_wei, + timestamp: to_unix_timestamp(SystemTime::now()), + gas_price_minor: 111_222_333, + nonce: 32, + status: Pending(Waiting), + }], ); - assert_eq!(accountant_recording.len(), 2); + assert_eq!( + sent_payables_msg.response_skeleton_opt, + Some(response_skeleton) + ); + // assert!(pending_payable_fingerprint_seeds_msg.batch_wide_timestamp >= time_before); + // assert!(pending_payable_fingerprint_seeds_msg.batch_wide_timestamp <= time_after); + // assert_eq!( + // pending_payable_fingerprint_seeds_msg.hashes_and_balances, + // vec![HashAndAmount { + // hash: H256::from_str( + // "81d20df32920161727cd20e375e53c2f9df40fd80256a236fb39e444c999fb6c" + // ) + // .unwrap(), + // amount: account.balance_wei + // }] + // ); + assert_eq!(accountant_recording.len(), 1); } #[test] @@ -949,9 +976,9 @@ mod tests { .start(); let (accountant, _, accountant_recording_arc) = make_recorder(); let accountant_addr = accountant - .system_stop_conditions(match_every_type_id!(SentPayables)) + .system_stop_conditions(match_lazily_every_type_id!(SentPayables)) .start(); - let wallet_account = make_wallet("blah"); + let account_wallet = make_wallet("blah"); let blockchain_interface = make_blockchain_interface_web3(port); let persistent_configuration_mock = PersistentConfigurationMock::default(); let subject = BlockchainBridge::new( @@ -963,22 +990,24 @@ mod tests { let subject_subs = BlockchainBridge::make_subs_from(&addr); let mut peer_actors = peer_actors_builder().build(); peer_actors.accountant = make_accountant_subs_from_recorder(&accountant_addr); - let accounts = vec![PayableAccount { - wallet: wallet_account, + let account = PayableAccount { + wallet: account_wallet.clone(), balance_wei: 111_420_204, - last_paid_timestamp: from_time_t(150_000_000), + last_paid_timestamp: from_unix_timestamp(150_000_000), pending_payable_opt: None, - }]; + }; let consuming_wallet = make_paying_wallet(b"consuming_wallet"); let agent = BlockchainAgentMock::default() .consuming_wallet_result(consuming_wallet) - .agreed_fee_per_computation_unit_result(123) + .gas_price_result(123) .get_chain_result(Chain::PolyMainnet); send_bind_message!(subject_subs, peer_actors); + let priced_new_tx_templates = + make_priced_new_tx_templates(vec![(account.clone(), 111_222_333)]); let _ = addr .try_send(OutboundPaymentsInstructions { - affordable_accounts: accounts.clone(), + priced_templates: Either::Left(priced_new_tx_templates), agent: Box::new(agent), response_skeleton_opt: Some(ResponseSkeleton { client_id: 1234, @@ -989,41 +1018,52 @@ mod tests { system.run(); let accountant_recording = accountant_recording_arc.lock().unwrap(); - let pending_payable_fingerprint_seeds_msg = - accountant_recording.get_record::(0); - let sent_payables_msg = accountant_recording.get_record::(1); - let scan_error_msg = accountant_recording.get_record::(2); - assert_sending_error( - sent_payables_msg - .payment_procedure_result - .as_ref() - .unwrap_err(), - "Transport error: Error(IncompleteMessage)", - ); - assert_eq!( - pending_payable_fingerprint_seeds_msg.hashes_and_balances, - vec![HashAndAmount { - hash: H256::from_str( - "36e9d7cdd657181317dd461192d537d9944c57a51ee950607de5a618b00e57a1" - ) - .unwrap(), - amount: accounts[0].balance_wei - }] - ); + // let pending_payable_fingerprint_seeds_msg = + // accountant_recording.get_record::(0); + let sent_payables_msg = accountant_recording.get_record::(0); + let scan_error_msg = accountant_recording.get_record::(1); + let batch_results = sent_payables_msg.clone().payment_procedure_result.unwrap(); + let failed_tx = FailedTx { + hash: H256::from_str( + "81d20df32920161727cd20e375e53c2f9df40fd80256a236fb39e444c999fb6c", + ) + .unwrap(), + receiver_address: account.wallet.address(), + amount_minor: account.balance_wei, + timestamp: to_unix_timestamp(SystemTime::now()), + gas_price_minor: 111222333, + nonce: 32, + reason: Submission(AppRpcErrorKind::Local(LocalErrorKind::Transport)), + status: RetryRequired, + }; + assert_on_failed_txs(batch_results.failed_txs, vec![failed_tx]); + // TODO: GH-701: This card is related to the commented out code in this test + // assert_eq!( + // pending_payable_fingerprint_seeds_msg.hashes_and_balances, + // vec![HashAndAmount { + // hash: H256::from_str( + // "81d20df32920161727cd20e375e53c2f9df40fd80256a236fb39e444c999fb6c" + // ) + // .unwrap(), + // amount: account.balance_wei + // }] + // ); + assert_eq!(scan_error_msg.scan_type, DetailedScanType::NewPayables); assert_eq!( - *scan_error_msg, - ScanError { - scan_type: ScanType::Payables, - response_skeleton_opt: Some(ResponseSkeleton { - client_id: 1234, - context_id: 4321 - }), - msg: format!( - "ReportAccountsPayable: Sending phase: \"Transport error: Error(IncompleteMessage)\". Signed and hashed transactions: 0x36e9d7cdd657181317dd461192d537d9944c57a51ee950607de5a618b00e57a1" - ) - } + scan_error_msg.response_skeleton_opt, + Some(ResponseSkeleton { + client_id: 1234, + context_id: 4321 + }) ); - assert_eq!(accountant_recording.len(), 3); + assert!(scan_error_msg + .msg + .contains("ReportAccountsPayable: Sending error: \"Transport error: Error(IncompleteMessage)\". Signed and hashed transactions:"), "This string didn't contain the expected: {}", scan_error_msg.msg); + assert!(scan_error_msg.msg.contains( + "FailedTx { hash: 0x81d20df32920161727cd20e375e53c2f9df40fd80256a236fb39e444c999fb6c," + )); + assert!(scan_error_msg.msg.contains("FailedTx { hash: 0x81d20df32920161727cd20e375e53c2f9df40fd80256a236fb39e444c999fb6c, receiver_address: 0x00000000000000000000000000000000626c6168, amount_minor: 111420204, timestamp:"), "This string didn't contain the expected: {}", scan_error_msg.msg); + assert_eq!(accountant_recording.len(), 2); } #[test] @@ -1039,15 +1079,22 @@ mod tests { .start(); let blockchain_interface_web3 = make_blockchain_interface_web3(port); let consuming_wallet = make_paying_wallet(b"consuming_wallet"); - let accounts_1 = make_payable_account(1); - let accounts_2 = make_payable_account(2); - let accounts = vec![accounts_1.clone(), accounts_2.clone()]; + let account_1 = make_payable_account(1); + let account_2 = make_payable_account(2); + let priced_new_tx_templates = make_priced_new_tx_templates(vec![ + (account_1.clone(), 777_777_777), + (account_2.clone(), 999_999_999), + ]); let system = System::new(test_name); let agent = BlockchainAgentMock::default() .consuming_wallet_result(consuming_wallet) - .agreed_fee_per_computation_unit_result(1) + .gas_price_result(1) .get_chain_result(Chain::PolyMainnet); - let msg = OutboundPaymentsInstructions::new(accounts, Box::new(agent), None); + let msg = OutboundPaymentsInstructions::new( + Either::Left(priced_new_tx_templates), + Box::new(agent), + None, + ); let persistent_config = PersistentConfigurationMock::new(); let mut subject = BlockchainBridge::new( Box::new(blockchain_interface_web3), @@ -1057,37 +1104,47 @@ mod tests { let (accountant, _, accountant_recording) = make_recorder(); subject .pending_payable_confirmation - .new_pp_fingerprints_sub_opt = Some(accountant.start().recipient()); + .register_new_pending_payables_sub_opt = Some(accountant.start().recipient()); let result = subject - .process_payments(msg.agent, msg.affordable_accounts) + .process_payments(msg.agent, msg.priced_templates) .wait(); System::current().stop(); system.run(); - let processed_payments = result.unwrap(); - assert_eq!( - processed_payments[0], - Correct(PendingPayable { - recipient_wallet: accounts_1.wallet, - hash: H256::from_str( - "cc73f3d5fe9fc3dac28b510ddeb157b0f8030b201e809014967396cdf365488a" - ) - .unwrap() - }) - ); - assert_eq!( - processed_payments[1], - Correct(PendingPayable { - recipient_wallet: accounts_2.wallet, - hash: H256::from_str( - "891d9ffa838aedc0bb2f6f7e9737128ce98bb33d07b4c8aa5645871e20d6cd13" - ) - .unwrap() - }) + let batch_results = result.unwrap(); + assert_on_sent_txs( + batch_results.sent_txs, + vec![ + SentTx { + hash: H256::from_str( + "c0756e8da662cee896ed979456c77931668b7f8456b9f978fc3305671f8f82ad", + ) + .unwrap(), + receiver_address: account_1.wallet.address(), + amount_minor: account_1.balance_wei, + timestamp: to_unix_timestamp(SystemTime::now()), + gas_price_minor: 777_777_777, + nonce: 1, + status: Pending(ValidationStatus::Waiting), + }, + SentTx { + hash: H256::from_str( + "9ba19f88ce43297d700b1f57ed8bc6274d01a5c366b78dd05167f9874c867ba0", + ) + .unwrap(), + receiver_address: account_2.wallet.address(), + amount_minor: account_2.balance_wei, + timestamp: to_unix_timestamp(SystemTime::now()), + gas_price_minor: 999_999_999, + nonce: 2, + status: Pending(ValidationStatus::Waiting), + }, + ], ); + assert!(batch_results.failed_txs.is_empty()); let recording = accountant_recording.lock().unwrap(); - assert_eq!(recording.len(), 1); + assert_eq!(recording.len(), 0); } #[test] @@ -1103,8 +1160,14 @@ mod tests { let agent = BlockchainAgentMock::default() .get_chain_result(TEST_DEFAULT_CHAIN) .consuming_wallet_result(consuming_wallet) - .agreed_fee_per_computation_unit_result(123); - let msg = OutboundPaymentsInstructions::new(vec![], Box::new(agent), None); + .gas_price_result(123); + let priced_new_tx_templates = + make_priced_new_tx_templates(vec![(make_payable_account(111), 111_000_000)]); + let msg = OutboundPaymentsInstructions::new( + Either::Left(priced_new_tx_templates), + Box::new(agent), + None, + ); let persistent_config = configure_default_persistent_config(ZERO); let mut subject = BlockchainBridge::new( Box::new(blockchain_interface_web3), @@ -1114,10 +1177,10 @@ mod tests { let (accountant, _, accountant_recording) = make_recorder(); subject .pending_payable_confirmation - .new_pp_fingerprints_sub_opt = Some(accountant.start().recipient()); + .register_new_pending_payables_sub_opt = Some(accountant.start().recipient()); let result = subject - .process_payments(msg.agent, msg.affordable_accounts) + .process_payments(msg.agent, msg.priced_templates) .wait(); System::current().stop(); @@ -1125,7 +1188,7 @@ mod tests { let error_result = result.unwrap_err(); assert_eq!( error_result, - TransactionID(BlockchainError::QueryFailed( + TransactionID(BlockchainInterfaceError::QueryFailed( "Decoder error: Error(\"0x prefix is missing\", line: 0, column: 0) for wallet 0x2581…7849".to_string() )) ); @@ -1133,37 +1196,16 @@ mod tests { assert_eq!(recording.len(), 0); } - fn assert_sending_error(error: &PayableTransactionError, error_msg: &str) { - if let PayableTransactionError::Sending { msg, .. } = error { - assert!( - msg.contains(error_msg), - "Actual Error message: {} does not contain this fragment {}", - msg, - error_msg - ); - } else { - panic!("Received wrong error: {:?}", error); - } - } - #[test] fn blockchain_bridge_processes_requests_for_a_complete_and_null_transaction_receipt() { let (accountant, _, accountant_recording_arc) = make_recorder(); - let accountant = accountant.system_stop_conditions(match_every_type_id!(ScanError)); - let pending_payable_fingerprint_1 = make_pending_payable_fingerprint(); - let hash_1 = pending_payable_fingerprint_1.hash; - let hash_2 = make_tx_hash(78989); - let pending_payable_fingerprint_2 = PendingPayableFingerprint { - rowid: 456, - timestamp: SystemTime::now(), - hash: hash_2, - attempt: 3, - amount: 4565, - process_error: None, - }; + let accountant = + accountant.system_stop_conditions(match_lazily_every_type_id!(TxReceiptsMessage)); + let tx_hash_1 = make_tx_hash(123); + let tx_hash_2 = make_tx_hash(456); let first_response = ReceiptResponseBuilder::default() .status(U64::from(1)) - .transaction_hash(hash_1) + .transaction_hash(tx_hash_1) .build(); let port = find_free_port(); let _blockchain_client_server = MBCSBuilder::new(port) @@ -1184,9 +1226,9 @@ mod tests { let peer_actors = peer_actors_builder().accountant(accountant).build(); send_bind_message!(subject_subs, peer_actors); let msg = RequestTransactionReceipts { - pending_payable: vec![ - pending_payable_fingerprint_1.clone(), - pending_payable_fingerprint_2.clone(), + tx_hashes: vec![ + TxHashByTable::SentPayable(tx_hash_1), + TxHashByTable::FailedPayable(tx_hash_2), ], response_skeleton_opt: Some(ResponseSkeleton { client_id: 1234, @@ -1200,26 +1242,20 @@ mod tests { system.run(); let accountant_recording = accountant_recording_arc.lock().unwrap(); assert_eq!(accountant_recording.len(), 1); - let report_transaction_receipt_message = - accountant_recording.get_record::(0); + let tx_receipts_message = accountant_recording.get_record::(0); let mut expected_receipt = TransactionReceipt::default(); - expected_receipt.transaction_hash = hash_1; + expected_receipt.transaction_hash = tx_hash_1; expected_receipt.status = Some(U64::from(1)); assert_eq!( - report_transaction_receipt_message, - &ReportTransactionReceipts { - fingerprints_with_receipts: vec![ - ( - TransactionReceiptResult::RpcResponse(expected_receipt.into()), - pending_payable_fingerprint_1 - ), - ( - TransactionReceiptResult::RpcResponse(TxReceipt { - transaction_hash: hash_2, - status: TxStatus::Pending - }), - pending_payable_fingerprint_2 + tx_receipts_message, + &TxReceiptsMessage { + results: btreemap![ + TxHashByTable::SentPayable(tx_hash_1) => Ok( + expected_receipt.into() ), + TxHashByTable::FailedPayable(tx_hash_2) => Ok( + StatusReadFromReceiptCheck::Pending + ) ], response_skeleton_opt: Some(ResponseSkeleton { client_id: 1234, @@ -1239,7 +1275,7 @@ mod tests { .start(); let (accountant, _, accountant_recording_arc) = make_recorder(); let accountant_addr = accountant - .system_stop_conditions(match_every_type_id!(ScanError)) + .system_stop_conditions(match_lazily_every_type_id!(ScanError)) .start(); let scan_error_recipient: Recipient = accountant_addr.clone().recipient(); let received_payments_subs: Recipient = accountant_addr.recipient(); @@ -1269,7 +1305,7 @@ mod tests { assert_eq!( scan_error, &ScanError { - scan_type: ScanType::Receivables, + scan_type: DetailedScanType::Receivables, response_skeleton_opt: None, msg: "Error while retrieving transactions: QueryFailed(\"Transport error: Error(IncompleteMessage)\")".to_string() } @@ -1281,8 +1317,7 @@ mod tests { } #[test] - fn handle_request_transaction_receipts_short_circuits_on_failure_from_remote_process_sends_back_all_good_results_and_logs_abort( - ) { + fn handle_request_transaction_receipts_sends_back_results() { init_test_logging(); let port = find_free_port(); let block_number = U64::from(4545454); @@ -1297,59 +1332,26 @@ mod tests { .begin_batch() .raw_response(r#"{ "jsonrpc": "2.0", "id": 1, "result": null }"#.to_string()) .raw_response(tx_receipt_response) - .raw_response(r#"{ "jsonrpc": "2.0", "id": 1, "result": null }"#.to_string()) .err_response( 429, "The requests per second (RPS) of your requests are higher than your plan allows." .to_string(), 7, ) + .raw_response(r#"{ "jsonrpc": "2.0", "id": 1, "result": null }"#.to_string()) .end_batch() .start(); let (accountant, _, accountant_recording_arc) = make_recorder(); let accountant_addr = accountant - .system_stop_conditions(match_every_type_id!(ReportTransactionReceipts, ScanError)) + .system_stop_conditions(match_lazily_every_type_id!(TxReceiptsMessage)) .start(); - let report_transaction_receipt_recipient: Recipient = + let report_transaction_receipt_recipient: Recipient = accountant_addr.clone().recipient(); let scan_error_recipient: Recipient = accountant_addr.recipient(); - let hash_1 = make_tx_hash(111334); - let hash_2 = make_tx_hash(100000); - let hash_3 = make_tx_hash(0x1348d); - let hash_4 = make_tx_hash(11111); - let mut fingerprint_1 = make_pending_payable_fingerprint(); - fingerprint_1.hash = hash_1; - let fingerprint_2 = PendingPayableFingerprint { - rowid: 454, - timestamp: SystemTime::now(), - hash: hash_2, - attempt: 3, - amount: 3333, - process_error: None, - }; - let fingerprint_3 = PendingPayableFingerprint { - rowid: 456, - timestamp: SystemTime::now(), - hash: hash_3, - attempt: 3, - amount: 4565, - process_error: None, - }; - let fingerprint_4 = PendingPayableFingerprint { - rowid: 450, - timestamp: from_time_t(230_000_000), - hash: hash_4, - attempt: 1, - amount: 7879, - process_error: None, - }; - let transaction_receipt = TxReceipt { - transaction_hash: Default::default(), - status: TxStatus::Succeeded(TransactionBlock { - block_hash: Default::default(), - block_number, - }), - }; + let tx_hash_1 = make_tx_hash(1334); + let tx_hash_2 = make_tx_hash(1000); + let tx_hash_3 = make_tx_hash(1212); + let tx_hash_4 = make_tx_hash(1111); let blockchain_interface = make_blockchain_interface_web3(port); let system = System::new("test_transaction_receipts"); let mut subject = BlockchainBridge::new( @@ -1359,14 +1361,14 @@ mod tests { ); subject .pending_payable_confirmation - .report_transaction_receipts_sub_opt = Some(report_transaction_receipt_recipient); + .report_tx_receipts_sub_opt = Some(report_transaction_receipt_recipient); subject.scan_error_subs_opt = Some(scan_error_recipient); let msg = RequestTransactionReceipts { - pending_payable: vec![ - fingerprint_1.clone(), - fingerprint_2.clone(), - fingerprint_3.clone(), - fingerprint_4.clone(), + tx_hashes: vec![ + TxHashByTable::SentPayable(tx_hash_1), + TxHashByTable::SentPayable(tx_hash_2), + TxHashByTable::SentPayable(tx_hash_3), + TxHashByTable::SentPayable(tx_hash_4), ], response_skeleton_opt: Some(ResponseSkeleton { client_id: 1234, @@ -1380,15 +1382,18 @@ mod tests { assert_eq!(system.run(), 0); let accountant_recording = accountant_recording_arc.lock().unwrap(); assert_eq!(accountant_recording.len(), 1); - let report_receipts_msg = accountant_recording.get_record::(0); + let report_receipts_msg = accountant_recording.get_record::(0); assert_eq!( *report_receipts_msg, - ReportTransactionReceipts { - fingerprints_with_receipts: vec![ - (TransactionReceiptResult::RpcResponse(TxReceipt{ transaction_hash: hash_1, status: TxStatus::Pending }), fingerprint_1), - (TransactionReceiptResult::RpcResponse(transaction_receipt), fingerprint_2), - (TransactionReceiptResult::RpcResponse(TxReceipt{ transaction_hash: hash_3, status: TxStatus::Pending }), fingerprint_3), - (TransactionReceiptResult::LocalError("RPC error: Error { code: ServerError(429), message: \"The requests per second (RPS) of your requests are higher than your plan allows.\", data: None }".to_string()), fingerprint_4) + TxReceiptsMessage { + results: btreemap![TxHashByTable::SentPayable(tx_hash_1) => Ok(StatusReadFromReceiptCheck::Pending), + TxHashByTable::SentPayable(tx_hash_2) => Ok(StatusReadFromReceiptCheck::Succeeded(TxBlock { + block_hash: Default::default(), + block_number, + })), + TxHashByTable::SentPayable(tx_hash_3) => Err( + AppRpcError:: Remote(RemoteError::Web3RpcError { code: 429, message: "The requests per second (RPS) of your requests are higher than your plan allows.".to_string()})), + TxHashByTable::SentPayable(tx_hash_4) => Ok(StatusReadFromReceiptCheck::Pending), ], response_skeleton_opt: Some(ResponseSkeleton { client_id: 1234, @@ -1402,32 +1407,17 @@ mod tests { } #[test] - fn handle_request_transaction_receipts_short_circuits_if_submit_batch_fails() { + fn handle_request_transaction_receipts_failing_submit_the_batch() { init_test_logging(); let (accountant, _, accountant_recording) = make_recorder(); let accountant_addr = accountant - .system_stop_conditions(match_every_type_id!(ScanError)) + .system_stop_conditions(match_lazily_every_type_id!(ScanError)) .start(); let scan_error_recipient: Recipient = accountant_addr.clone().recipient(); - let report_transaction_recipient: Recipient = + let report_transaction_recipient: Recipient = accountant_addr.recipient(); - let hash_1 = make_tx_hash(0x1b2e6); - let fingerprint_1 = PendingPayableFingerprint { - rowid: 454, - timestamp: SystemTime::now(), - hash: hash_1, - attempt: 3, - amount: 3333, - process_error: None, - }; - let fingerprint_2 = PendingPayableFingerprint { - rowid: 456, - timestamp: SystemTime::now(), - hash: make_tx_hash(222444), - attempt: 3, - amount: 4565, - process_error: None, - }; + let tx_hash_1 = make_tx_hash(10101); + let tx_hash_2 = make_tx_hash(10102); let port = find_free_port(); let _blockchain_client_server = MBCSBuilder::new(port).start(); let blockchain_interface = make_blockchain_interface_web3(port); @@ -1438,17 +1428,20 @@ mod tests { ); subject .pending_payable_confirmation - .report_transaction_receipts_sub_opt = Some(report_transaction_recipient); + .report_tx_receipts_sub_opt = Some(report_transaction_recipient); subject.scan_error_subs_opt = Some(scan_error_recipient); let msg = RequestTransactionReceipts { - pending_payable: vec![fingerprint_1, fingerprint_2], + tx_hashes: vec![ + TxHashByTable::SentPayable(tx_hash_1), + TxHashByTable::FailedPayable(tx_hash_2), + ], response_skeleton_opt: None, }; let system = System::new("test"); let _ = subject.handle_scan_future( BlockchainBridge::handle_request_transaction_receipts, - ScanType::PendingPayables, + DetailedScanType::PendingPayables, msg, ); @@ -1457,7 +1450,7 @@ mod tests { assert_eq!( recording.get_record::(0), &ScanError { - scan_type: ScanType::PendingPayables, + scan_type: DetailedScanType::PendingPayables, response_skeleton_opt: None, msg: "Blockchain error: Query failed: Transport error: Error(IncompleteMessage)" .to_string() @@ -1615,7 +1608,7 @@ mod tests { .start(); let (accountant, _, accountant_recording_arc) = make_recorder(); let accountant_addr = - accountant.system_stop_conditions(match_every_type_id!(ReceivedPayments)); + accountant.system_stop_conditions(match_lazily_every_type_id!(ReceivedPayments)); let some_wallet = make_wallet("somewallet"); let recipient_wallet = make_wallet("recipient_wallet"); let amount = 996000000; @@ -1708,7 +1701,7 @@ mod tests { let (accountant, _, accountant_recording_arc) = make_recorder(); let accountant_addr = - accountant.system_stop_conditions(match_every_type_id!(ReceivedPayments)); + accountant.system_stop_conditions(match_lazily_every_type_id!(ReceivedPayments)); let earning_wallet = make_wallet("earning_wallet"); let amount = 996000000; let blockchain_interface = make_blockchain_interface_web3(port); @@ -1792,7 +1785,8 @@ mod tests { .ok_response(expected_response_logs, 1) .start(); let (accountant, _, accountant_recording_arc) = make_recorder(); - let accountant_addr = accountant.system_stop_conditions(match_every_type_id!(ScanError)); + let accountant_addr = + accountant.system_stop_conditions(match_lazily_every_type_id!(ScanError)); let earning_wallet = make_wallet("earning_wallet"); let mut blockchain_interface = make_blockchain_interface_web3(port); blockchain_interface.logger = logger; @@ -1825,7 +1819,7 @@ mod tests { assert_eq!( scan_error_msg, &ScanError { - scan_type: ScanType::Receivables, + scan_type: DetailedScanType::Receivables, response_skeleton_opt: Some(ResponseSkeleton { client_id: 1234, context_id: 4321 @@ -1849,7 +1843,7 @@ mod tests { .err_response(-32005, "Blockheight too far in the past. Check params passed to eth_getLogs or eth_call requests.Range of blocks allowed for your plan: 1000", 0) .start(); let (accountant, _, accountant_recording_arc) = make_recorder(); - let accountant = accountant.system_stop_conditions(match_every_type_id!(ScanError)); + let accountant = accountant.system_stop_conditions(match_lazily_every_type_id!(ScanError)); let earning_wallet = make_wallet("earning_wallet"); let blockchain_interface = make_blockchain_interface_web3(port); let set_max_block_count_params_arc = Arc::new(Mutex::new(vec![])); @@ -1885,7 +1879,7 @@ mod tests { assert_eq!( scan_error_msg, &ScanError { - scan_type: ScanType::Receivables, + scan_type: DetailedScanType::Receivables, response_skeleton_opt: Some(ResponseSkeleton { client_id: 1234, context_id: 4321 @@ -2024,19 +2018,22 @@ mod tests { ); let system = System::new("test"); let accountant_addr = accountant - .system_stop_conditions(match_every_type_id!(ScanError)) + .system_stop_conditions(match_lazily_every_type_id!(ReceivedPayments)) .start(); subject.received_payments_subs_opt = Some(accountant_addr.clone().recipient()); subject.scan_error_subs_opt = Some(accountant_addr.recipient()); + subject.handle_scan_future( BlockchainBridge::handle_retrieve_transactions, - ScanType::Receivables, + DetailedScanType::Receivables, retrieve_transactions, ); system.run(); let accountant_recording = accountant_recording_arc.lock().unwrap(); - let msg_opt = accountant_recording.get_record_opt::(0); + let received_msg = accountant_recording.get_record::(0); + assert_eq!(received_msg.new_start_block, BlockMarker::Value(0xc8 + 1)); + let msg_opt = accountant_recording.get_record_opt::(1); assert_eq!(msg_opt, None, "We didnt expect a scan error: {:?}", msg_opt); } @@ -2075,14 +2072,14 @@ mod tests { ); let system = System::new("test"); let accountant_addr = accountant - .system_stop_conditions(match_every_type_id!(ScanError)) + .system_stop_conditions(match_lazily_every_type_id!(ScanError)) .start(); subject.received_payments_subs_opt = Some(accountant_addr.clone().recipient()); subject.scan_error_subs_opt = Some(accountant_addr.recipient()); subject.handle_scan_future( BlockchainBridge::handle_retrieve_transactions, - ScanType::Receivables, + DetailedScanType::Receivables, msg.clone(), ); @@ -2092,7 +2089,7 @@ mod tests { assert_eq!( message, &ScanError { - scan_type: ScanType::Receivables, + scan_type: DetailedScanType::Receivables, response_skeleton_opt: msg.response_skeleton_opt, msg: "Error while retrieving transactions: QueryFailed(\"RPC error: Error { code: ServerError(-32005), message: \\\"My tummy hurts\\\", data: None }\")" .to_string() @@ -2119,7 +2116,7 @@ mod tests { #[test] fn extract_max_block_range_from_error_response() { - let result = BlockchainError::QueryFailed("RPC error: Error { code: ServerError(-32005), message: \"eth_getLogs block range too large, range: 33636, max: 3500\", data: None }".to_string()); + let result = BlockchainInterfaceError::QueryFailed("RPC error: Error { code: ServerError(-32005), message: \"eth_getLogs block range too large, range: 33636, max: 3500\", data: None }".to_string()); let max_block_count = BlockchainBridge::extract_max_block_count(result); @@ -2128,7 +2125,7 @@ mod tests { #[test] fn extract_max_block_range_from_pokt_error_response() { - let result = BlockchainError::QueryFailed("Rpc(Error { code: ServerError(-32001), message: \"Relay request failed validation: invalid relay request: eth_getLogs block range limit (100000 blocks) exceeded\", data: None })".to_string()); + let result = BlockchainInterfaceError::QueryFailed("Rpc(Error { code: ServerError(-32001), message: \"Relay request failed validation: invalid relay request: eth_getLogs block range limit (100000 blocks) exceeded\", data: None })".to_string()); let max_block_count = BlockchainBridge::extract_max_block_count(result); @@ -2144,7 +2141,7 @@ mod tests { */ #[test] fn extract_max_block_range_for_ankr_error_response() { - let result = BlockchainError::QueryFailed("RPC error: Error { code: ServerError(-32600), message: \"block range is too wide\", data: None }".to_string()); + let result = BlockchainInterfaceError::QueryFailed("RPC error: Error { code: ServerError(-32600), message: \"block range is too wide\", data: None }".to_string()); let max_block_count = BlockchainBridge::extract_max_block_count(result); @@ -2157,7 +2154,7 @@ mod tests { */ #[test] fn extract_max_block_range_for_matic_vigil_error_response() { - let result = BlockchainError::QueryFailed("RPC error: Error { code: ServerError(-32005), message: \"Blockheight too far in the past. Check params passed to eth_getLogs or eth_call requests.Range of blocks allowed for your plan: 1000\", data: None }".to_string()); + let result = BlockchainInterfaceError::QueryFailed("RPC error: Error { code: ServerError(-32005), message: \"Blockheight too far in the past. Check params passed to eth_getLogs or eth_call requests.Range of blocks allowed for your plan: 1000\", data: None }".to_string()); let max_block_count = BlockchainBridge::extract_max_block_count(result); @@ -2170,7 +2167,7 @@ mod tests { */ #[test] fn extract_max_block_range_for_blockpi_error_response() { - let result = BlockchainError::QueryFailed("RPC error: Error { code: ServerError(-32005), message: \"eth_getLogs is limited to 1024 block range. Please check the parameter requirements at https://docs.blockpi.io/documentations/api-reference\", data: None }".to_string()); + let result = BlockchainInterfaceError::QueryFailed("RPC error: Error { code: ServerError(-32005), message: \"eth_getLogs is limited to 1024 block range. Please check the parameter requirements at https://docs.blockpi.io/documentations/api-reference\", data: None }".to_string()); let max_block_count = BlockchainBridge::extract_max_block_count(result); @@ -2185,7 +2182,7 @@ mod tests { #[test] fn extract_max_block_range_for_blastapi_error_response() { - let result = BlockchainError::QueryFailed("RPC error: Error { code: ServerError(-32601), message: \"Method not found\", data: \"'eth_getLogs' is not available on our public API. Head over to https://docs.blastapi.io/blast-documentation/tutorials-and-guides/using-blast-to-get-a-blockchain-endpoint for more information\" }".to_string()); + let result = BlockchainInterfaceError::QueryFailed("RPC error: Error { code: ServerError(-32601), message: \"Method not found\", data: \"'eth_getLogs' is not available on our public API. Head over to https://docs.blastapi.io/blast-documentation/tutorials-and-guides/using-blast-to-get-a-blockchain-endpoint for more information\" }".to_string()); let max_block_count = BlockchainBridge::extract_max_block_count(result); @@ -2194,7 +2191,7 @@ mod tests { #[test] fn extract_max_block_range_for_nodies_error_response() { - let result = BlockchainError::QueryFailed("RPC error: Error { code: InvalidParams, message: \"query exceeds max block range 100000\", data: None }".to_string()); + let result = BlockchainInterfaceError::QueryFailed("RPC error: Error { code: InvalidParams, message: \"query exceeds max block range 100000\", data: None }".to_string()); let max_block_count = BlockchainBridge::extract_max_block_count(result); @@ -2203,7 +2200,7 @@ mod tests { #[test] fn extract_max_block_range_for_expected_batch_got_single_error_response() { - let result = BlockchainError::QueryFailed( + let result = BlockchainInterfaceError::QueryFailed( "Got invalid response: Expected batch, got single.".to_string(), ); @@ -2225,22 +2222,10 @@ mod tests { assert_on_initialization_with_panic_on_migration(&data_dir, &act); } -} - -#[cfg(test)] -pub mod exportable_test_parts { - use super::*; - use crate::test_utils::recorder::make_blockchain_bridge_subs_from_recorder; - use crate::test_utils::unshared_test_utils::SubsFactoryTestAddrLeaker; - impl SubsFactory - for SubsFactoryTestAddrLeaker - { - fn make(&self, addr: &Addr) -> BlockchainBridgeSubs { - self.send_leaker_msg_and_return_meaningless_subs( - addr, - make_blockchain_bridge_subs_from_recorder, - ) - } + #[test] + fn increase_gas_price_by_margin_works() { + assert_eq!(increase_gas_price_by_margin(1_000_000_000), 1_300_000_000); + assert_eq!(increase_gas_price_by_margin(9_000_000_000), 11_700_000_000); } } 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 5879a47a31..7a4d6ddfb6 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 @@ -1,62 +1,17 @@ // Copyright (c) 2019, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. use crate::blockchain::blockchain_interface::blockchain_interface_web3::CONTRACT_ABI; -use crate::blockchain::blockchain_interface::data_structures::errors::BlockchainError; -use crate::blockchain::blockchain_interface::data_structures::errors::BlockchainError::QueryFailed; +use crate::blockchain::blockchain_interface::data_structures::errors::BlockchainInterfaceError; +use crate::blockchain::blockchain_interface::data_structures::errors::BlockchainInterfaceError::QueryFailed; use crate::blockchain::blockchain_interface::lower_level_interface::LowBlockchainInt; use ethereum_types::{H256, U256, U64}; use futures::Future; use serde_json::Value; use web3::contract::{Contract, Options}; use web3::transports::{Batch, Http}; -use web3::types::{Address, BlockNumber, Filter, Log, TransactionReceipt}; +use web3::types::{Address, BlockNumber, Filter, Log}; use web3::{Error, Web3}; -#[derive(Debug, PartialEq, Eq, Clone)] -pub enum TransactionReceiptResult { - RpcResponse(TxReceipt), - LocalError(String), -} - -#[derive(Debug, PartialEq, Eq, Clone)] -pub enum TxStatus { - Failed, - Pending, - Succeeded(TransactionBlock), -} - -#[derive(Debug, PartialEq, Eq, Clone)] -pub struct TxReceipt { - pub transaction_hash: H256, - pub status: TxStatus, -} - -#[derive(Debug, PartialEq, Eq, Clone)] -pub struct TransactionBlock { - pub block_hash: H256, - pub block_number: U64, -} - -impl From for TxReceipt { - fn from(receipt: TransactionReceipt) -> Self { - let status = match (receipt.status, receipt.block_hash, receipt.block_number) { - (Some(status), Some(block_hash), Some(block_number)) if status == U64::from(1) => { - TxStatus::Succeeded(TransactionBlock { - block_hash, - block_number, - }) - } - (Some(status), _, _) if status == U64::from(0) => TxStatus::Failed, - _ => TxStatus::Pending, - }; - - TxReceipt { - transaction_hash: receipt.transaction_hash, - status, - } - } -} - pub struct LowBlockchainIntWeb3 { web3: Web3, web3_batch: Web3>, @@ -68,7 +23,7 @@ impl LowBlockchainInt for LowBlockchainIntWeb3 { fn get_transaction_fee_balance( &self, address: Address, - ) -> Box> { + ) -> Box> { Box::new( self.web3 .eth() @@ -80,7 +35,7 @@ impl LowBlockchainInt for LowBlockchainIntWeb3 { fn get_service_fee_balance( &self, address: Address, - ) -> Box> { + ) -> Box> { Box::new( self.contract .query("balanceOf", address, None, Options::default(), None) @@ -88,7 +43,7 @@ impl LowBlockchainInt for LowBlockchainIntWeb3 { ) } - fn get_gas_price(&self) -> Box> { + fn get_gas_price(&self) -> Box> { Box::new( self.web3 .eth() @@ -97,7 +52,7 @@ impl LowBlockchainInt for LowBlockchainIntWeb3 { ) } - fn get_block_number(&self) -> Box> { + fn get_block_number(&self) -> Box> { Box::new( self.web3 .eth() @@ -109,7 +64,7 @@ impl LowBlockchainInt for LowBlockchainIntWeb3 { fn get_transaction_id( &self, address: Address, - ) -> Box> { + ) -> Box> { Box::new( self.web3 .eth() @@ -121,7 +76,7 @@ impl LowBlockchainInt for LowBlockchainIntWeb3 { fn get_transaction_receipt_in_batch( &self, hash_vec: Vec, - ) -> Box>, Error = BlockchainError>> { + ) -> Box>, Error = BlockchainInterfaceError>> { hash_vec.into_iter().for_each(|hash| { self.web3_batch.eth().transaction_receipt(hash); }); @@ -141,7 +96,7 @@ impl LowBlockchainInt for LowBlockchainIntWeb3 { fn get_transaction_logs( &self, filter: Filter, - ) -> Box, Error = BlockchainError>> { + ) -> Box, Error = BlockchainInterfaceError>> { Box::new( self.web3 .eth() @@ -173,9 +128,9 @@ impl LowBlockchainIntWeb3 { #[cfg(test)] mod tests { use crate::blockchain::blockchain_interface::blockchain_interface_web3::TRANSACTION_LITERAL; - use crate::blockchain::blockchain_interface::data_structures::errors::BlockchainError::QueryFailed; - use crate::blockchain::blockchain_interface::{BlockchainError, BlockchainInterface}; - use crate::blockchain::test_utils::make_blockchain_interface_web3; + use crate::blockchain::blockchain_interface::data_structures::errors::BlockchainInterfaceError::QueryFailed; + use crate::blockchain::blockchain_interface::{BlockchainInterfaceError, BlockchainInterface}; + use crate::blockchain::test_utils::{make_block_hash, make_blockchain_interface_web3, make_tx_hash, TransactionReceiptBuilder}; use crate::sub_lib::wallet::Wallet; use crate::test_utils::make_wallet; use ethereum_types::{H256, U64}; @@ -183,8 +138,8 @@ mod tests { use masq_lib::test_utils::mock_blockchain_client_server::MBCSBuilder; use masq_lib::utils::find_free_port; use std::str::FromStr; - use web3::types::{BlockNumber, Bytes, FilterBuilder, Log, TransactionReceipt, U256}; - use crate::blockchain::blockchain_interface::blockchain_interface_web3::lower_level_interface_web3::{TxReceipt, TxStatus}; + use web3::types::{BlockNumber, Bytes, FilterBuilder, Log, U256}; + use crate::blockchain::blockchain_interface::data_structures::StatusReadFromReceiptCheck; #[test] fn get_transaction_fee_balance_works() { @@ -222,7 +177,9 @@ mod tests { .wait(); match result { - Err(BlockchainError::QueryFailed(msg)) if msg.contains("invalid hex character: Q") => { + Err(BlockchainInterfaceError::QueryFailed(msg)) + if msg.contains("invalid hex character: Q") => + { () } x => panic!("Expected complaint about hex character, but got {:?}", x), @@ -330,7 +287,9 @@ mod tests { .wait(); match result { - Err(BlockchainError::QueryFailed(msg)) if msg.contains("invalid hex character: Q") => { + Err(BlockchainInterfaceError::QueryFailed(msg)) + if msg.contains("invalid hex character: Q") => + { () } x => panic!("Expected complaint about hex character, but got {:?}", x), @@ -383,8 +342,11 @@ mod tests { .wait(); let err_msg = match result { - Err(BlockchainError::QueryFailed(msg)) => msg, - x => panic!("Expected BlockchainError::QueryFailed, but got {:?}", x), + Err(BlockchainInterfaceError::QueryFailed(msg)) => msg, + x => panic!( + "Expected BlockchainInterfaceError::QueryFailed, but got {:?}", + x + ), }; assert!( err_msg.contains(expected_err_msg), @@ -547,17 +509,17 @@ mod tests { #[test] fn transaction_receipt_can_be_converted_to_successful_transaction() { - let tx_receipt: TxReceipt = create_tx_receipt( - Some(U64::from(1)), - Some(H256::from_low_u64_be(0x1234)), - Some(U64::from(10)), - H256::from_low_u64_be(0x5678), - ); - - assert_eq!(tx_receipt.transaction_hash, H256::from_low_u64_be(0x5678)); - match tx_receipt.status { - TxStatus::Succeeded(ref block) => { - assert_eq!(block.block_hash, H256::from_low_u64_be(0x1234)); + let tx_status: StatusReadFromReceiptCheck = + TransactionReceiptBuilder::new(make_tx_hash(0x5678)) + .status(U64::from(1)) + .block_hash(make_block_hash(0x1234)) + .block_number(10.into()) + .build() + .into(); + + match tx_status { + StatusReadFromReceiptCheck::Succeeded(ref block) => { + assert_eq!(block.block_hash, make_block_hash(0x1234)); assert_eq!(block.block_number, U64::from(10)); } _ => panic!("Expected status to be Succeeded"), @@ -566,71 +528,43 @@ mod tests { #[test] fn transaction_receipt_can_be_converted_to_failed_transaction() { - let tx_receipt: TxReceipt = create_tx_receipt( - Some(U64::from(0)), - None, - None, - H256::from_low_u64_be(0x5678), - ); + let tx_status: StatusReadFromReceiptCheck = + TransactionReceiptBuilder::new(make_tx_hash(0x5678)) + .status(U64::from(0)) + .build() + .into(); - assert_eq!(tx_receipt.transaction_hash, H256::from_low_u64_be(0x5678)); - assert_eq!(tx_receipt.status, TxStatus::Failed); + assert_eq!(tx_status, StatusReadFromReceiptCheck::Reverted); } #[test] fn transaction_receipt_can_be_converted_to_pending_transaction_no_status() { - let tx_receipt: TxReceipt = - create_tx_receipt(None, None, None, H256::from_low_u64_be(0x5678)); + let tx_status: StatusReadFromReceiptCheck = + TransactionReceiptBuilder::new(make_tx_hash(0x5678)) + .build() + .into(); - assert_eq!(tx_receipt.transaction_hash, H256::from_low_u64_be(0x5678)); - assert_eq!(tx_receipt.status, TxStatus::Pending); + assert_eq!(tx_status, StatusReadFromReceiptCheck::Pending); } #[test] fn transaction_receipt_can_be_converted_to_pending_transaction_no_block_info() { - let tx_receipt: TxReceipt = create_tx_receipt( - Some(U64::from(1)), - None, - None, - H256::from_low_u64_be(0x5678), - ); + let tx_status: StatusReadFromReceiptCheck = + TransactionReceiptBuilder::new(make_tx_hash(0x5678)) + .status(U64::from(1)) + .build() + .into(); - assert_eq!(tx_receipt.transaction_hash, H256::from_low_u64_be(0x5678)); - assert_eq!(tx_receipt.status, TxStatus::Pending); + assert_eq!(tx_status, StatusReadFromReceiptCheck::Pending); } #[test] fn transaction_receipt_can_be_converted_to_pending_transaction_no_status_and_block_info() { - let tx_receipt: TxReceipt = create_tx_receipt( - Some(U64::from(1)), - Some(H256::from_low_u64_be(0x1234)), - None, - H256::from_low_u64_be(0x5678), - ); + let tx_status: StatusReadFromReceiptCheck = + TransactionReceiptBuilder::new(make_tx_hash(0x5678)) + .build() + .into(); - assert_eq!(tx_receipt.transaction_hash, H256::from_low_u64_be(0x5678)); - assert_eq!(tx_receipt.status, TxStatus::Pending); - } - - fn create_tx_receipt( - status: Option, - block_hash: Option, - block_number: Option, - transaction_hash: H256, - ) -> TxReceipt { - let receipt = TransactionReceipt { - status, - root: None, - block_hash, - block_number, - cumulative_gas_used: Default::default(), - gas_used: None, - contract_address: None, - transaction_hash, - transaction_index: Default::default(), - logs: vec![], - logs_bloom: Default::default(), - }; - receipt.into() + assert_eq!(tx_status, StatusReadFromReceiptCheck::Pending); } } diff --git a/node/src/blockchain/blockchain_interface/blockchain_interface_web3/mod.rs b/node/src/blockchain/blockchain_interface/blockchain_interface_web3/mod.rs index 92f8e91453..9249c6ee0b 100644 --- a/node/src/blockchain/blockchain_interface/blockchain_interface_web3/mod.rs +++ b/node/src/blockchain/blockchain_interface/blockchain_interface_web3/mod.rs @@ -4,9 +4,9 @@ pub mod lower_level_interface_web3; mod utils; use std::cmp::PartialEq; -use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::blockchain_agent::BlockchainAgent; -use crate::blockchain::blockchain_interface::data_structures::errors::{BlockchainError, PayableTransactionError}; -use crate::blockchain::blockchain_interface::data_structures::{BlockchainTransaction, ProcessedPayableFallible}; +use std::collections::{BTreeMap}; +use crate::blockchain::blockchain_interface::data_structures::errors::{BlockchainInterfaceError, LocalPayableError}; +use crate::blockchain::blockchain_interface::data_structures::{BatchResults, BlockchainTransaction, StatusReadFromReceiptCheck}; use crate::blockchain::blockchain_interface::lower_level_interface::LowBlockchainInt; use crate::blockchain::blockchain_interface::RetrievedBlockchainTransactions; use crate::blockchain::blockchain_interface::{BlockchainAgentBuildError, BlockchainInterface}; @@ -17,14 +17,27 @@ use masq_lib::blockchains::chains::Chain; use masq_lib::logger::Logger; use std::convert::{From, TryInto}; use std::fmt::Debug; -use actix::Recipient; use ethereum_types::U64; +use itertools::Either; use web3::transports::{EventLoopHandle, Http}; use web3::types::{Address, Log, H256, U256, FilterBuilder, TransactionReceipt, BlockNumber}; -use crate::accountant::db_access_objects::payable_dao::PayableAccount; -use crate::blockchain::blockchain_bridge::{BlockMarker, BlockScanRange, PendingPayableFingerprintSeeds}; -use crate::blockchain::blockchain_interface::blockchain_interface_web3::lower_level_interface_web3::{LowBlockchainIntWeb3, TransactionReceiptResult, TxReceipt, TxStatus}; +use crate::accountant::db_access_objects::sent_payable_dao::SentTx; +use crate::blockchain::blockchain_agent::BlockchainAgent; +use crate::accountant::db_access_objects::utils::TxHash; +use crate::accountant::scanners::payable_scanner::tx_templates::priced::new::PricedNewTxTemplates; +use crate::accountant::scanners::payable_scanner::tx_templates::priced::retry::PricedRetryTxTemplates; +use crate::accountant::scanners::payable_scanner::tx_templates::signable::SignableTxTemplates; +use crate::accountant::scanners::pending_payable_scanner::utils::TxHashByTable; +use crate::accountant::TxReceiptResult; +use crate::blockchain::blockchain_bridge::{BlockMarker, BlockScanRange}; +use crate::blockchain::blockchain_interface::blockchain_interface_web3::lower_level_interface_web3::LowBlockchainIntWeb3; use crate::blockchain::blockchain_interface::blockchain_interface_web3::utils::{create_blockchain_agent_web3, send_payables_within_batch, BlockchainAgentFutureResult}; +use crate::blockchain::errors::rpc_errors::{AppRpcError, RemoteError}; +// TODO We should probably begin to attach these constants to the interfaces more tightly, so that +// we aren't baffled by which interface they belong with. I suggest to declare them inside +// their inherent impl blocks. They will then need to be preceded by the class name +// of the respective interface if you want to use them. This could be a distinction we desire, +// despite the increased wordiness. const CONTRACT_ABI: &str = indoc!( r#"[{ @@ -98,7 +111,8 @@ impl BlockchainInterface for BlockchainInterfaceWeb3 { start_block_marker: BlockMarker, scan_range: BlockScanRange, recipient: Address, - ) -> Box> { + ) -> Box> + { let lower_level_interface = self.lower_interface(); let logger = self.logger.clone(); let contract_address = lower_level_interface.get_contract_address(); @@ -156,7 +170,7 @@ impl BlockchainInterface for BlockchainInterfaceWeb3 { ) } - fn build_blockchain_agent( + fn introduce_blockchain_agent( &self, consuming_wallet: Wallet, ) -> Box, Error = BlockchainAgentBuildError>> { @@ -175,7 +189,7 @@ impl BlockchainInterface for BlockchainInterfaceWeb3 { Box::new( get_gas_price .map_err(BlockchainAgentBuildError::GasPrice) - .and_then(move |gas_price_wei| { + .and_then(move |gas_price_minor| { get_transaction_fee_balance .map_err(move |e| { BlockchainAgentBuildError::TransactionFeeBalance(wallet_address, e) @@ -188,13 +202,13 @@ impl BlockchainInterface for BlockchainInterfaceWeb3 { .and_then(move |masq_token_balance| { let blockchain_agent_future_result = BlockchainAgentFutureResult { - gas_price_wei, + gas_price_minor, transaction_fee_balance, masq_token_balance, }; Ok(create_blockchain_agent_web3( - gas_limit_const_part, blockchain_agent_future_result, + gas_limit_const_part, consuming_wallet, chain, )) @@ -206,37 +220,44 @@ impl BlockchainInterface for BlockchainInterfaceWeb3 { fn process_transaction_receipts( &self, - transaction_hashes: Vec, - ) -> Box, Error = BlockchainError>> { + tx_hashes: Vec, + ) -> Box< + dyn Future< + Item = BTreeMap, + Error = BlockchainInterfaceError, + >, + > { Box::new( self.lower_interface() - .get_transaction_receipt_in_batch(transaction_hashes.clone()) + .get_transaction_receipt_in_batch(Self::collect_plain_hashes(&tx_hashes)) .map_err(move |e| e) .and_then(move |batch_response| { Ok(batch_response .into_iter() - .zip(transaction_hashes) - .map(|(response, hash)| match response { + .zip(tx_hashes.into_iter()) + .map(|(response, tx_hash)| match response { Ok(result) => { match serde_json::from_value::(result) { Ok(receipt) => { - TransactionReceiptResult::RpcResponse(receipt.into()) + (tx_hash, Ok(StatusReadFromReceiptCheck::from(receipt))) } Err(e) => { if e.to_string().contains("invalid type: null") { - TransactionReceiptResult::RpcResponse(TxReceipt { - transaction_hash: hash, - status: TxStatus::Pending, - }) + (tx_hash, Ok(StatusReadFromReceiptCheck::Pending)) } else { - TransactionReceiptResult::LocalError(e.to_string()) + ( + tx_hash, + Err(AppRpcError::Remote( + RemoteError::InvalidResponse(e.to_string()), + )), + ) } } } } - Err(e) => TransactionReceiptResult::LocalError(e.to_string()), + Err(e) => (tx_hash, Err(AppRpcError::from(e))), }) - .collect::>()) + .collect::>()) }), ) } @@ -245,31 +266,28 @@ impl BlockchainInterface for BlockchainInterfaceWeb3 { &self, logger: Logger, agent: Box, - fingerprints_recipient: Recipient, - affordable_accounts: Vec, - ) -> Box, Error = PayableTransactionError>> - { + priced_templates: Either, + ) -> Box> { let consuming_wallet = agent.consuming_wallet().clone(); let web3_batch = self.lower_interface().get_web3_batch(); let get_transaction_id = self .lower_interface() .get_transaction_id(consuming_wallet.address()); - let gas_price_wei = agent.agreed_fee_per_computation_unit(); let chain = agent.get_chain(); Box::new( get_transaction_id - .map_err(PayableTransactionError::TransactionID) - .and_then(move |pending_nonce| { + .map_err(LocalPayableError::TransactionID) + .and_then(move |latest_nonce| { + let templates = + SignableTxTemplates::new(priced_templates, latest_nonce.as_u64()); + send_payables_within_batch( &logger, chain, &web3_batch, + templates, consuming_wallet, - gas_price_wei, - pending_nonce, - fingerprints_recipient, - affordable_accounts, ) }), ) @@ -279,7 +297,16 @@ impl BlockchainInterface for BlockchainInterfaceWeb3 { #[derive(Debug, Clone, PartialEq, Eq, Copy)] pub struct HashAndAmount { pub hash: H256, - pub amount: u128, + pub amount_minor: u128, +} + +impl From<&SentTx> for HashAndAmount { + fn from(tx: &SentTx) -> Self { + HashAndAmount { + hash: tx.hash, + amount_minor: tx.amount_minor, + } + } } impl BlockchainInterfaceWeb3 { @@ -362,7 +389,7 @@ impl BlockchainInterfaceWeb3 { fn calculate_end_block_marker( start_block_marker: BlockMarker, scan_range: BlockScanRange, - rpc_block_number_result: Result, + rpc_block_number_result: Result, logger: &Logger, ) -> BlockMarker { let locally_determined_end_block_marker = match (start_block_marker, scan_range) { @@ -394,9 +421,9 @@ impl BlockchainInterfaceWeb3 { } fn handle_transaction_logs( - logs_result: Result, BlockchainError>, + logs_result: Result, BlockchainInterfaceError>, logger: &Logger, - ) -> Result, BlockchainError> { + ) -> Result, BlockchainInterfaceError> { let logs = logs_result?; let logs_len = logs.len(); if logs @@ -408,7 +435,7 @@ impl BlockchainInterfaceWeb3 { "Invalid response from blockchain server: {:?}", logs ); - Err(BlockchainError::InvalidResponse) + Err(BlockchainInterfaceError::InvalidResponse) } else { let transactions: Vec = Self::extract_transactions_from_logs(logs); @@ -425,24 +452,38 @@ impl BlockchainInterfaceWeb3 { Ok(transactions) } } + + fn collect_plain_hashes(hashes_by_table: &[TxHashByTable]) -> Vec { + hashes_by_table + .iter() + .map(|hash_by_table| match hash_by_table { + TxHashByTable::SentPayable(hash) => *hash, + TxHashByTable::FailedPayable(hash) => *hash, + }) + .collect() + } } #[cfg(test)] mod tests { use super::*; - use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::agent_web3::WEB3_MAXIMAL_GAS_LIMIT_MARGIN; + use crate::accountant::scanners::pending_payable_scanner::utils::TxHashByTable; + use crate::accountant::test_utils::make_payable_account; + use crate::blockchain::blockchain_bridge::increase_gas_price_by_margin; use crate::blockchain::blockchain_interface::blockchain_interface_web3::{ BlockchainInterfaceWeb3, CONTRACT_ABI, REQUESTS_IN_PARALLEL, TRANSACTION_LITERAL, TRANSFER_METHOD_ID, }; - use crate::blockchain::blockchain_interface::data_structures::errors::BlockchainError::QueryFailed; - use crate::blockchain::blockchain_interface::data_structures::BlockchainTransaction; + use crate::blockchain::blockchain_interface::data_structures::errors::BlockchainInterfaceError::QueryFailed; + use crate::blockchain::blockchain_interface::data_structures::{ + BlockchainTransaction, TxBlock, + }; use crate::blockchain::blockchain_interface::{ - BlockchainAgentBuildError, BlockchainError, BlockchainInterface, + BlockchainAgentBuildError, BlockchainInterfaceError, BlockchainInterface, RetrievedBlockchainTransactions, }; use crate::blockchain::test_utils::{ - all_chains, make_blockchain_interface_web3, ReceiptResponseBuilder, + all_chains, make_blockchain_interface_web3, make_tx_hash, ReceiptResponseBuilder, }; use crate::sub_lib::blockchain_bridge::ConsumingWalletBalances; use crate::sub_lib::wallet::Wallet; @@ -457,9 +498,13 @@ mod tests { use masq_lib::utils::find_free_port; use std::net::Ipv4Addr; use std::str::FromStr; + use itertools::Either; use web3::transports::Http; use web3::types::{H256, U256}; - use crate::blockchain::blockchain_interface::blockchain_interface_web3::lower_level_interface_web3::{TransactionBlock, TxReceipt, TxStatus}; + use crate::accountant::scanners::payable_scanner::tx_templates::initial::new::NewTxTemplates; + use crate::accountant::scanners::payable_scanner::tx_templates::initial::retry::RetryTxTemplates; + use crate::accountant::scanners::payable_scanner::tx_templates::priced::retry::PricedRetryTxTemplate; + use crate::accountant::scanners::payable_scanner::tx_templates::test_utils::RetryTxTemplateBuilder; #[test] fn constants_are_correct() { @@ -729,7 +774,7 @@ mod tests { assert_eq!( result.expect_err("Expected an Err, got Ok"), - BlockchainError::InvalidResponse + BlockchainInterfaceError::InvalidResponse ); } @@ -753,7 +798,7 @@ mod tests { ) .wait(); - assert_eq!(result, Err(BlockchainError::InvalidResponse)); + assert_eq!(result, Err(BlockchainInterfaceError::InvalidResponse)); } #[test] @@ -831,11 +876,77 @@ mod tests { } #[test] - fn blockchain_interface_web3_can_build_blockchain_agent() { + fn blockchain_interface_web3_can_introduce_blockchain_agent_in_the_new_payables_mode() { + let account_1 = make_payable_account(12); + let account_2 = make_payable_account(34); + let tx_templates = NewTxTemplates::from(&vec![account_1.clone(), account_2.clone()]); + let gas_price_wei_from_rpc_hex = "0x3B9ACA00"; // 1000000000 + let gas_price_wei_from_rpc_u128_wei = + u128::from_str_radix(&gas_price_wei_from_rpc_hex[2..], 16).unwrap(); + let gas_price_wei_from_rpc_u128_wei_with_margin = + increase_gas_price_by_margin(gas_price_wei_from_rpc_u128_wei); + let expected_result = Either::Left(PricedNewTxTemplates::new( + tx_templates.clone(), + gas_price_wei_from_rpc_u128_wei_with_margin, + )); + let expected_estimated_transaction_fee_total = 190_652_800_000_000; + + test_blockchain_interface_web3_can_introduce_blockchain_agent( + Either::Left(tx_templates), + gas_price_wei_from_rpc_hex, + expected_result, + expected_estimated_transaction_fee_total, + ); + } + + #[test] + fn blockchain_interface_web3_can_introduce_blockchain_agent_in_the_retry_payables_mode() { + let gas_price_wei = "0x3B9ACA00"; // 1000000000 + let gas_price_from_rpc = u128::from_str_radix(&gas_price_wei[2..], 16).unwrap(); + let retry_1 = RetryTxTemplateBuilder::default() + .payable_account(&make_payable_account(12)) + .prev_gas_price_wei(gas_price_from_rpc - 1) + .build(); + let retry_2 = RetryTxTemplateBuilder::default() + .payable_account(&make_payable_account(34)) + .prev_gas_price_wei(gas_price_from_rpc) + .build(); + let retry_3 = RetryTxTemplateBuilder::default() + .payable_account(&make_payable_account(56)) + .prev_gas_price_wei(gas_price_from_rpc + 1) + .build(); + + let retry_tx_templates = + RetryTxTemplates(vec![retry_1.clone(), retry_2.clone(), retry_3.clone()]); + let expected_retry_tx_templates = PricedRetryTxTemplates(vec![ + PricedRetryTxTemplate::new(retry_1, increase_gas_price_by_margin(gas_price_from_rpc)), + PricedRetryTxTemplate::new(retry_2, increase_gas_price_by_margin(gas_price_from_rpc)), + PricedRetryTxTemplate::new( + retry_3, + increase_gas_price_by_margin(gas_price_from_rpc + 1), + ), + ]); + + let expected_estimated_transaction_fee_total = 285_979_200_073_328; + + test_blockchain_interface_web3_can_introduce_blockchain_agent( + Either::Right(retry_tx_templates), + gas_price_wei, + Either::Right(expected_retry_tx_templates), + expected_estimated_transaction_fee_total, + ); + } + + fn test_blockchain_interface_web3_can_introduce_blockchain_agent( + tx_templates: Either, + gas_price_wei_from_rpc_hex: &str, + expected_tx_templates: Either, + expected_estimated_transaction_fee_total: u128, + ) { let port = find_free_port(); let _blockchain_client_server = MBCSBuilder::new(port) // gas_price - .ok_response("0x3B9ACA00".to_string(), 0) // 1000000000 + .ok_response(gas_price_wei_from_rpc_hex.to_string(), 0) // transaction_fee_balance .ok_response("0xFFF0".to_string(), 0) // 65520 // masq_balance @@ -844,18 +955,16 @@ mod tests { 0, ) .start(); - let chain = Chain::PolyMainnet; let wallet = make_wallet("abc"); let subject = make_blockchain_interface_web3(port); let result = subject - .build_blockchain_agent(wallet.clone()) + .introduce_blockchain_agent(wallet.clone()) .wait() .unwrap(); let expected_transaction_fee_balance = U256::from(65_520); let expected_masq_balance = U256::from(65_535); - let expected_gas_price_wei = 1_000_000_000; assert_eq!(result.consuming_wallet(), &wallet); assert_eq!( result.consuming_wallet_balances(), @@ -864,17 +973,11 @@ mod tests { masq_token_balance_in_minor_units: expected_masq_balance } ); + let computed_tx_templates = result.price_qualified_payables(tx_templates); + assert_eq!(computed_tx_templates, expected_tx_templates); assert_eq!( - result.agreed_fee_per_computation_unit(), - expected_gas_price_wei - ); - let expected_fee_estimation = (3 - * (BlockchainInterfaceWeb3::web3_gas_limit_const_part(chain) - + WEB3_MAXIMAL_GAS_LIMIT_MARGIN) - * expected_gas_price_wei) as u128; - assert_eq!( - result.estimated_transaction_fee_total(3), - expected_fee_estimation + result.estimate_transaction_fee_total(&computed_tx_templates), + expected_estimated_transaction_fee_total ) } @@ -886,7 +989,9 @@ mod tests { { let wallet = make_wallet("bcd"); let subject = make_blockchain_interface_web3(port); - let result = subject.build_blockchain_agent(wallet.clone()).wait(); + + let result = subject.introduce_blockchain_agent(wallet.clone()).wait(); + let err = match result { Err(e) => e, _ => panic!("we expected Err() but got Ok()"), @@ -899,15 +1004,16 @@ mod tests { fn build_of_the_blockchain_agent_fails_on_fetching_gas_price() { let port = find_free_port(); let _blockchain_client_server = MBCSBuilder::new(port).start(); - let wallet = make_wallet("abc"); - let subject = make_blockchain_interface_web3(port); - - let err = subject.build_blockchain_agent(wallet).wait().err().unwrap(); + let expected_err_factory = |_wallet: &Wallet| { + BlockchainAgentBuildError::GasPrice(QueryFailed( + "Transport error: Error(IncompleteMessage)".to_string(), + )) + }; - let expected_err = BlockchainAgentBuildError::GasPrice(QueryFailed( - "Transport error: Error(IncompleteMessage)".to_string(), - )); - assert_eq!(err, expected_err) + build_of_the_blockchain_agent_fails_on_blockchain_interface_error( + port, + expected_err_factory, + ); } #[test] @@ -919,7 +1025,7 @@ mod tests { let expected_err_factory = |wallet: &Wallet| { BlockchainAgentBuildError::TransactionFeeBalance( wallet.address(), - BlockchainError::QueryFailed( + BlockchainInterfaceError::QueryFailed( "Transport error: Error(IncompleteMessage)".to_string(), ), ) @@ -941,7 +1047,7 @@ mod tests { let expected_err_factory = |wallet: &Wallet| { BlockchainAgentBuildError::ServiceFeeBalance( wallet.address(), - BlockchainError::QueryFailed( + BlockchainInterfaceError::QueryFailed( "Api error: Transport error: Error(IncompleteMessage)".to_string(), ), ) @@ -956,27 +1062,19 @@ mod tests { #[test] fn process_transaction_receipts_works() { let port = find_free_port(); - let tx_hash_1 = - H256::from_str("a128f9ca1e705cc20a936a24a7fa1df73bad6e0aaf58e8e6ffcc154a7cff6e0e") - .unwrap(); - let tx_hash_2 = - H256::from_str("a128f9ca1e705cc20a936a24a7fa1df73bad6e0aaf58e8e6ffcc154a7cff6e0f") - .unwrap(); - let tx_hash_3 = - H256::from_str("a128f9ca1e705cc20a936a24a7fa1df73bad6e0aaf58e8e6ffcc154a7cff6e0a") - .unwrap(); - let tx_hash_4 = - H256::from_str("a128f9ca1e705cc20a936a24a7fa1df73bad6e0aaf58e8e6ffcc154a7cff6e0b") - .unwrap(); - let tx_hash_5 = - H256::from_str("a128f9ca1e705cc20a936a24a7fa1df73bad6e0aaf58e8e6ffcc154a7cff6e0c") - .unwrap(); - let tx_hash_6 = - H256::from_str("a128f9ca1e705cc20a936a24a7fa1df73bad6e0aaf58e8e6ffcc154a7cff6e0d") - .unwrap(); - let tx_hash_vec = vec![ - tx_hash_1, tx_hash_2, tx_hash_3, tx_hash_4, tx_hash_5, tx_hash_6, - ]; + let tx_hash_1 = make_tx_hash(3300); + let tx_hash_2 = make_tx_hash(3401); + let tx_hash_3 = make_tx_hash(3502); + let tx_hash_4 = make_tx_hash(3603); + let tx_hash_5 = make_tx_hash(3704); + let tx_hash_6 = make_tx_hash(3805); + let tx_hbt_1 = TxHashByTable::FailedPayable(tx_hash_1); + let tx_hbt_2 = TxHashByTable::FailedPayable(tx_hash_2); + let tx_hbt_3 = TxHashByTable::SentPayable(tx_hash_3); + let tx_hbt_4 = TxHashByTable::SentPayable(tx_hash_4); + let tx_hbt_5 = TxHashByTable::SentPayable(tx_hash_5); + let tx_hbt_6 = TxHashByTable::SentPayable(tx_hash_6); + let sent_tx_vec = vec![tx_hbt_1, tx_hbt_2, tx_hbt_3, tx_hbt_4, tx_hbt_5, tx_hbt_6]; let block_hash = H256::from_str("6d0abccae617442c26104c2bc63d1bc05e1e002e555aec4ab62a46e826b18f18") .unwrap(); @@ -1018,48 +1116,45 @@ mod tests { let subject = make_blockchain_interface_web3(port); let result = subject - .process_transaction_receipts(tx_hash_vec) + .process_transaction_receipts(sent_tx_vec.clone()) .wait() .unwrap(); - assert_eq!(result[0], TransactionReceiptResult::LocalError("RPC error: Error { code: ServerError(429), message: \"The requests per second (RPS) of your requests are higher than your plan allows.\", data: None }".to_string())); + assert_eq!(result.get(&tx_hbt_1).unwrap(), &Err( + AppRpcError::Remote( + RemoteError::Web3RpcError { + code: 429, + message: + "The requests per second (RPS) of your requests are higher than your plan allows." + .to_string() + } + )) + ); assert_eq!( - result[1], - TransactionReceiptResult::RpcResponse(TxReceipt { - transaction_hash: tx_hash_2, - status: TxStatus::Pending - }) + result.get(&tx_hbt_2).unwrap(), + &Ok(StatusReadFromReceiptCheck::Pending) ); assert_eq!( - result[2], - TransactionReceiptResult::LocalError( + result.get(&tx_hbt_3).unwrap(), + &Err(AppRpcError::Remote(RemoteError::InvalidResponse( "invalid type: string \"trash\", expected struct Receipt".to_string() - ) + ))) ); assert_eq!( - result[3], - TransactionReceiptResult::RpcResponse(TxReceipt { - transaction_hash: tx_hash_4, - status: TxStatus::Pending - }) + result.get(&tx_hbt_4).unwrap(), + &Ok(StatusReadFromReceiptCheck::Pending) ); assert_eq!( - result[4], - TransactionReceiptResult::RpcResponse(TxReceipt { - transaction_hash: tx_hash_5, - status: TxStatus::Failed, - }) + result.get(&tx_hbt_5).unwrap(), + &Ok(StatusReadFromReceiptCheck::Reverted) ); assert_eq!( - result[5], - TransactionReceiptResult::RpcResponse(TxReceipt { - transaction_hash: tx_hash_6, - status: TxStatus::Succeeded(TransactionBlock { - block_hash, - block_number, - }), - }) - ); + result.get(&tx_hbt_6).unwrap(), + &Ok(StatusReadFromReceiptCheck::Succeeded(TxBlock { + block_hash, + block_number, + }),) + ) } #[test] @@ -1067,13 +1162,12 @@ mod tests { let port = find_free_port(); let _blockchain_client_server = MBCSBuilder::new(port).start(); let subject = make_blockchain_interface_web3(port); - let tx_hash_1 = - H256::from_str("a128f9ca1e705cc20a936a24a7fa1df73bad6e0aaf58e8e6ffcc154a7cff6e0e") - .unwrap(); - let tx_hash_2 = - H256::from_str("a128f9ca1e705cc20a936a24a7fa1df73bad6e0aaf58e8e6ffcc154a7cff6e0f") - .unwrap(); - let tx_hash_vec = vec![tx_hash_1, tx_hash_2]; + let tx_hash_1 = make_tx_hash(789); + let tx_hash_2 = make_tx_hash(123); + let tx_hash_vec = vec![ + TxHashByTable::SentPayable(tx_hash_1), + TxHashByTable::SentPayable(tx_hash_2), + ]; let error = subject .process_transaction_receipts(tx_hash_vec) @@ -1119,7 +1213,7 @@ mod tests { Subject::calculate_end_block_marker( BlockMarker::Uninitialized, BlockScanRange::NoLimit, - Err(BlockchainError::InvalidResponse), + Err(BlockchainInterfaceError::InvalidResponse), &logger ), BlockMarker::Uninitialized @@ -1137,7 +1231,7 @@ mod tests { Subject::calculate_end_block_marker( BlockMarker::Uninitialized, BlockScanRange::Range(100), - Err(BlockchainError::InvalidResponse), + Err(BlockchainInterfaceError::InvalidResponse), &logger ), BlockMarker::Uninitialized @@ -1155,7 +1249,7 @@ mod tests { Subject::calculate_end_block_marker( BlockMarker::Value(50), BlockScanRange::NoLimit, - Err(BlockchainError::InvalidResponse), + Err(BlockchainInterfaceError::InvalidResponse), &logger ), BlockMarker::Uninitialized @@ -1173,7 +1267,7 @@ mod tests { Subject::calculate_end_block_marker( BlockMarker::Value(50), BlockScanRange::Range(100), - Err(BlockchainError::InvalidResponse), + Err(BlockchainInterfaceError::InvalidResponse), &logger ), BlockMarker::Value(150) @@ -1256,4 +1350,33 @@ mod tests { BlockMarker::Uninitialized ); } + + #[test] + fn collect_plain_hashes_works() { + let hash_sent_tx_1 = make_tx_hash(456); + let hash_sent_tx_2 = make_tx_hash(789); + let hash_sent_tx_3 = make_tx_hash(234); + let hash_failed_tx_1 = make_tx_hash(123); + let hash_failed_tx_2 = make_tx_hash(345); + let inputs = vec![ + TxHashByTable::SentPayable(hash_sent_tx_1), + TxHashByTable::FailedPayable(hash_failed_tx_1), + TxHashByTable::SentPayable(hash_sent_tx_2), + TxHashByTable::SentPayable(hash_sent_tx_3), + TxHashByTable::FailedPayable(hash_failed_tx_2), + ]; + + let result = BlockchainInterfaceWeb3::collect_plain_hashes(&inputs); + + assert_eq!( + result, + vec![ + hash_sent_tx_1, + hash_failed_tx_1, + hash_sent_tx_2, + hash_sent_tx_3, + hash_failed_tx_2 + ] + ); + } } diff --git a/node/src/blockchain/blockchain_interface/blockchain_interface_web3/utils.rs b/node/src/blockchain/blockchain_interface/blockchain_interface_web3/utils.rs index 7b352f65fe..fa893b19ff 100644 --- a/node/src/blockchain/blockchain_interface/blockchain_interface_web3/utils.rs +++ b/node/src/blockchain/blockchain_interface/blockchain_interface_web3/utils.rs @@ -1,22 +1,25 @@ // Copyright (c) 2024, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. -use crate::accountant::db_access_objects::payable_dao::PayableAccount; -use crate::accountant::db_access_objects::pending_payable_dao::PendingPayable; -use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::agent_web3::BlockchainAgentWeb3; -use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::blockchain_agent::BlockchainAgent; -use crate::blockchain::blockchain_bridge::PendingPayableFingerprintSeeds; -use crate::blockchain::blockchain_interface::blockchain_interface_web3::{ - BlockchainInterfaceWeb3, HashAndAmount, TRANSFER_METHOD_ID, +use crate::accountant::db_access_objects::failed_payable_dao::FailedTx; +use crate::accountant::db_access_objects::sent_payable_dao::{SentTx, TxStatus}; +use crate::accountant::db_access_objects::utils::to_unix_timestamp; +use crate::accountant::scanners::payable_scanner::tx_templates::signable::{ + SignableTxTemplate, SignableTxTemplates, }; -use crate::blockchain::blockchain_interface::data_structures::errors::PayableTransactionError; -use crate::blockchain::blockchain_interface::data_structures::{ - ProcessedPayableFallible, RpcPayableFailure, +use crate::blockchain::blockchain_agent::agent_web3::BlockchainAgentWeb3; +use crate::blockchain::blockchain_agent::BlockchainAgent; +use crate::blockchain::blockchain_interface::blockchain_interface_web3::{ + BlockchainInterfaceWeb3, TRANSFER_METHOD_ID, }; +use crate::blockchain::blockchain_interface::data_structures::errors::LocalPayableError; +use crate::blockchain::blockchain_interface::data_structures::BatchResults; +use crate::blockchain::errors::validation_status::ValidationStatus; use crate::sub_lib::blockchain_bridge::ConsumingWalletBalances; use crate::sub_lib::wallet::Wallet; -use actix::Recipient; +use ethabi::Address; use futures::Future; use masq_lib::blockchains::chains::Chain; +use masq_lib::constants::WALLET_ADDRESS_LENGTH; use masq_lib::logger::Logger; use secp256k1secrets::SecretKey; use serde_json::Value; @@ -30,97 +33,93 @@ use web3::Web3; #[derive(Debug)] pub struct BlockchainAgentFutureResult { - pub gas_price_wei: U256, + pub gas_price_minor: U256, pub transaction_fee_balance: U256, pub masq_token_balance: U256, } -pub fn advance_used_nonce(current_nonce: U256) -> U256 { - current_nonce - .checked_add(U256::one()) - .expect("unexpected limits") -} -fn error_with_hashes( - error: Web3Error, - hashes_and_paid_amounts: Vec, -) -> PayableTransactionError { - let hashes = hashes_and_paid_amounts - .into_iter() - .map(|hash_and_amount| hash_and_amount.hash) - .collect(); - PayableTransactionError::Sending { - msg: error.to_string(), - hashes, +fn return_sending_error(sent_txs: &[SentTx], error: &Web3Error) -> LocalPayableError { + LocalPayableError::Sending { + error: format!("{}", error), + failed_txs: sent_txs + .iter() + .map(|sent_tx| FailedTx::from((sent_tx, error))) + .collect(), } } -pub fn merged_output_data( +pub fn return_batch_results( + txs: Vec, responses: Vec>, - hashes_and_paid_amounts: Vec, - accounts: Vec, -) -> Vec { - let iterator_with_all_data = responses - .into_iter() - .zip(hashes_and_paid_amounts.into_iter()) - .zip(accounts.iter()); - iterator_with_all_data - .map( - |((rpc_result, hash_and_amount), account)| match rpc_result { - Ok(_rpc_result) => { - // TODO: GH-547: This rpc_result should be validated - ProcessedPayableFallible::Correct(PendingPayable { - recipient_wallet: account.wallet.clone(), - hash: hash_and_amount.hash, - }) - } - Err(rpc_error) => ProcessedPayableFallible::Failed(RpcPayableFailure { - rpc_error, - recipient_wallet: account.wallet.clone(), - hash: hash_and_amount.hash, - }), - }, - ) - .collect() +) -> BatchResults { + txs.into_iter().zip(responses).fold( + BatchResults::default(), + |mut batch_results, (sent_tx, response)| { + match response { + Ok(_) => batch_results.sent_txs.push(sent_tx), // TODO: GH-547: Validate the JSON output + Err(rpc_error) => batch_results + .failed_txs + .push(FailedTx::from((&sent_tx, &rpc_error))), + } + batch_results + }, + ) } -pub fn transmission_log( - chain: Chain, - accounts: &[PayableAccount], - gas_price_in_wei: u128, -) -> String { - let chain_name = chain - .rec() - .literal_identifier - .chars() - .skip_while(|char| char != &'-') - .skip(1) - .collect::(); +fn calculate_payments_column_width(signable_tx_templates: &SignableTxTemplates) -> usize { + let label_length = "[payment wei]".len(); + let largest_amount_length = signable_tx_templates + .largest_amount() + .separate_with_commas() + .len(); + + label_length.max(largest_amount_length) +} + +pub fn transmission_log(chain: Chain, signable_tx_templates: &SignableTxTemplates) -> String { + let chain_name = chain.rec().literal_identifier; + let (first_nonce, last_nonce) = signable_tx_templates.nonce_range(); + let payment_column_width = calculate_payments_column_width(signable_tx_templates); + let introduction = once(format!( - "\ - Paying to creditors...\n\ - Transactions in the batch:\n\ + "\n\ + Paying creditors\n\ + Transactions:\n\ \n\ - gas price: {} wei\n\ - chain: {}\n\ + {:first_column_width$} {}\n\ + {:first_column_width$} {}...{}\n\ \n\ - [wallet address] [payment in wei]\n", - gas_price_in_wei, chain_name + {:first_column_width$} {: [u8; 68] { +pub fn sign_transaction_data(amount_minor: u128, receiver_address: Address) -> [u8; 68] { let mut data = [0u8; 4 + 32 + 32]; data[0..4].copy_from_slice(&TRANSFER_METHOD_ID); - data[16..36].copy_from_slice(&recipient_wallet.address().0[..]); - U256::from(amount).to_big_endian(&mut data[36..68]); + data[16..36].copy_from_slice(&receiver_address.0[..]); + U256::from(amount_minor).to_big_endian(&mut data[36..68]); data } @@ -135,24 +134,30 @@ pub fn gas_limit(data: [u8; 68], chain: Chain) -> U256 { pub fn sign_transaction( chain: Chain, web3_batch: &Web3>, - recipient_wallet: Wallet, - consuming_wallet: Wallet, - amount: u128, - nonce: U256, - gas_price_in_wei: u128, + signable_tx_template: &SignableTxTemplate, + consuming_wallet: &Wallet, ) -> SignedTransaction { - let data = sign_transaction_data(amount, recipient_wallet); + let &SignableTxTemplate { + receiver_address, + amount_in_wei, + gas_price_wei, + nonce, + } = signable_tx_template; + + let data = sign_transaction_data(amount_in_wei, receiver_address); let gas_limit = gas_limit(data, chain); - // Warning: If you set gas_price or nonce to None in transaction_parameters, sign_transaction will start making RPC calls which we don't want (Do it at your own risk). + // Warning: If you set gas_price or nonce to None in transaction_parameters, sign_transaction + // will start making RPC calls which we don't want (Do it at your own risk). let transaction_parameters = TransactionParameters { - nonce: Some(nonce), + nonce: Some(U256::from(nonce)), to: Some(chain.rec().contract), gas: gas_limit, - gas_price: Some(U256::from(gas_price_in_wei)), + gas_price: Some(U256::from(gas_price_wei)), value: ethereum_types::U256::zero(), data: Bytes(data.to_vec()), chain_id: Some(chain.rec().num_chain_id), }; + let key = consuming_wallet .prepare_secp256k1_secret() .expect("Consuming wallet doesn't contain a secret key"); @@ -183,25 +188,40 @@ pub fn sign_transaction_locally( pub fn sign_and_append_payment( chain: Chain, web3_batch: &Web3>, - recipient: &PayableAccount, - consuming_wallet: Wallet, - nonce: U256, - gas_price_in_wei: u128, -) -> HashAndAmount { - let signed_tx = sign_transaction( - chain, - web3_batch, - recipient.wallet.clone(), - consuming_wallet, - recipient.balance_wei, + signable_tx_template: &SignableTxTemplate, + consuming_wallet: &Wallet, + logger: &Logger, +) -> SentTx { + let &SignableTxTemplate { + receiver_address, + amount_in_wei, + gas_price_wei, nonce, - gas_price_in_wei, - ); + } = signable_tx_template; + + let signed_tx = sign_transaction(chain, web3_batch, signable_tx_template, consuming_wallet); + append_signed_transaction_to_batch(web3_batch, signed_tx.raw_transaction); - HashAndAmount { - hash: signed_tx.transaction_hash, - amount: recipient.balance_wei, + let hash = signed_tx.transaction_hash; + debug!( + logger, + "Appending transaction with hash {:?}, amount: {} wei, to {:?}, nonce: {}, gas price: {} wei", + hash, + amount_in_wei.separate_with_commas(), + receiver_address, + nonce, + gas_price_wei.separate_with_commas() + ); + + SentTx { + hash, + receiver_address, + amount_minor: amount_in_wei, + timestamp: to_unix_timestamp(SystemTime::now()), + gas_price_minor: gas_price_wei, + nonce, + status: TxStatus::Pending(ValidationStatus::Waiting), } } @@ -214,115 +234,86 @@ pub fn sign_and_append_multiple_payments( logger: &Logger, chain: Chain, web3_batch: &Web3>, + signable_tx_templates: &SignableTxTemplates, consuming_wallet: Wallet, - gas_price_in_wei: u128, - mut pending_nonce: U256, - accounts: &[PayableAccount], -) -> Vec { - let mut hash_and_amount_list = vec![]; - accounts.iter().for_each(|payable| { - debug!( - logger, - "Preparing payable future of {} wei to {} with nonce {}", - payable.balance_wei.separate_with_commas(), - payable.wallet, - pending_nonce - ); - - let hash_and_amount = sign_and_append_payment( - chain, - web3_batch, - payable, - consuming_wallet.clone(), - pending_nonce, - gas_price_in_wei, - ); - - pending_nonce = advance_used_nonce(pending_nonce); - hash_and_amount_list.push(hash_and_amount); - }); - hash_and_amount_list +) -> Vec { + signable_tx_templates + .iter() + .map(|signable_tx_template| { + sign_and_append_payment( + chain, + web3_batch, + signable_tx_template, + &consuming_wallet, + logger, + ) + }) + .collect() } -#[allow(clippy::too_many_arguments)] pub fn send_payables_within_batch( logger: &Logger, chain: Chain, web3_batch: &Web3>, + signable_tx_templates: SignableTxTemplates, consuming_wallet: Wallet, - gas_price_in_wei: u128, - pending_nonce: U256, - new_fingerprints_recipient: Recipient, - accounts: Vec, -) -> Box, Error = PayableTransactionError> + 'static> -{ +) -> Box + 'static> { debug!( logger, - "Common attributes of payables to be transacted: sender wallet: {}, contract: {:?}, chain_id: {}, gas_price: {}", + "Common attributes of payables to be transacted: sender wallet: {}, contract: {:?}, chain_id: {}", consuming_wallet, chain.rec().contract, chain.rec().num_chain_id, - gas_price_in_wei ); - let hashes_and_paid_amounts = sign_and_append_multiple_payments( + let sent_txs = sign_and_append_multiple_payments( logger, chain, web3_batch, + &signable_tx_templates, consuming_wallet, - gas_price_in_wei, - pending_nonce, - &accounts, ); - - let timestamp = SystemTime::now(); - let hashes_and_paid_amounts_error = hashes_and_paid_amounts.clone(); - let hashes_and_paid_amounts_ok = hashes_and_paid_amounts.clone(); - - // TODO: We are sending hashes_and_paid_amounts to the Accountant even if the payments fail. - new_fingerprints_recipient - .try_send(PendingPayableFingerprintSeeds { - batch_wide_timestamp: timestamp, - hashes_and_balances: hashes_and_paid_amounts, - }) - .expect("Accountant is dead"); + let sent_txs_for_err = sent_txs.clone(); + // TODO: GH-701: We were sending a message here to register txs at an initial stage (refer commit - 2fd4bcc72) info!( logger, "{}", - transmission_log(chain, &accounts, gas_price_in_wei) + transmission_log(chain, &signable_tx_templates) ); + let logger_clone = logger.clone(); + Box::new( web3_batch .transport() .submit_batch() - .map_err(|e| error_with_hashes(e, hashes_and_paid_amounts_error)) - .and_then(move |batch_response| { - Ok(merged_output_data( - batch_response, - hashes_and_paid_amounts_ok, - accounts, - )) - }), + .map_err(move |e| { + warning!(logger_clone, "Failed to submit batch to Web3 client: {}", e); + return_sending_error(&sent_txs_for_err, &e) + }) + .and_then(move |batch_responses| Ok(return_batch_results(sent_txs, batch_responses))), ) } pub fn create_blockchain_agent_web3( - gas_limit_const_part: u128, blockchain_agent_future_result: BlockchainAgentFutureResult, + gas_limit_const_part: u128, wallet: Wallet, chain: Chain, ) -> Box { + let transaction_fee_balance_in_minor_units = + blockchain_agent_future_result.transaction_fee_balance; + let masq_token_balance_in_minor_units = blockchain_agent_future_result.masq_token_balance; + let cons_wallet_balances = ConsumingWalletBalances::new( + transaction_fee_balance_in_minor_units, + masq_token_balance_in_minor_units, + ); Box::new(BlockchainAgentWeb3::new( - blockchain_agent_future_result.gas_price_wei.as_u128(), + blockchain_agent_future_result.gas_price_minor.as_u128(), gas_limit_const_part, wallet, - ConsumingWalletBalances { - transaction_fee_balance_in_minor_units: blockchain_agent_future_result - .transaction_fee_balance, - masq_token_balance_in_minor_units: blockchain_agent_future_result.masq_token_balance, - }, + cons_wallet_balances, chain, )) } @@ -330,52 +321,53 @@ pub fn create_blockchain_agent_web3( #[cfg(test)] mod tests { use super::*; - use crate::accountant::db_access_objects::utils::from_time_t; + use crate::accountant::db_access_objects::failed_payable_dao::{FailureReason, FailureStatus}; + use crate::accountant::db_access_objects::test_utils::{ + assert_on_failed_txs, assert_on_sent_txs, FailedTxBuilder, TxBuilder, + }; use crate::accountant::gwei_to_wei; - use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::agent_web3::WEB3_MAXIMAL_GAS_LIMIT_MARGIN; - use crate::accountant::test_utils::{ - make_payable_account, make_payable_account_with_wallet_and_balance_and_timestamp_opt, + use crate::accountant::scanners::payable_scanner::tx_templates::priced::new::{ + PricedNewTxTemplate, PricedNewTxTemplates, }; + use crate::accountant::scanners::payable_scanner::tx_templates::test_utils::make_signable_tx_template; + use crate::accountant::scanners::payable_scanner::tx_templates::BaseTxTemplate; use crate::blockchain::bip32::Bip32EncryptionKeyProvider; + use crate::blockchain::blockchain_agent::agent_web3::WEB3_MAXIMAL_GAS_LIMIT_MARGIN; use crate::blockchain::blockchain_interface::blockchain_interface_web3::{ BlockchainInterfaceWeb3, REQUESTS_IN_PARALLEL, }; - use crate::blockchain::blockchain_interface::data_structures::errors::PayableTransactionError::Sending; - use crate::blockchain::blockchain_interface::data_structures::ProcessedPayableFallible::{ - Correct, Failed, - }; + use crate::blockchain::blockchain_interface::data_structures::errors::LocalPayableError::Sending; + use crate::blockchain::errors::rpc_errors::{AppRpcErrorKind, LocalErrorKind, RemoteErrorKind}; use crate::blockchain::test_utils::{ - make_tx_hash, transport_error_code, transport_error_message, + make_address, transport_error_code, transport_error_message, }; use crate::sub_lib::wallet::Wallet; use crate::test_utils::make_paying_wallet; use crate::test_utils::make_wallet; - use crate::test_utils::recorder::make_recorder; use crate::test_utils::unshared_test_utils::decode_hex; - use actix::{Actor, System}; + use actix::System; use ethabi::Address; use ethereum_types::H256; - use jsonrpc_core::ErrorCode::ServerError; - use jsonrpc_core::{Error, ErrorCode}; + use itertools::Either; use masq_lib::constants::{DEFAULT_CHAIN, DEFAULT_GAS_PRICE}; use masq_lib::test_utils::logging::{init_test_logging, TestLogHandler}; use masq_lib::test_utils::mock_blockchain_client_server::MBCSBuilder; - use masq_lib::test_utils::utils::TEST_DEFAULT_CHAIN; use masq_lib::utils::find_free_port; use serde_json::Value; use std::net::Ipv4Addr; use std::str::FromStr; use std::time::SystemTime; use web3::api::Namespace; - use web3::Error::Rpc; #[test] fn sign_and_append_payment_works() { + init_test_logging(); + let test_name = "sign_and_append_payment_works"; let port = find_free_port(); let _blockchain_client_server = MBCSBuilder::new(port) .begin_batch() .ok_response( - "0x94881436a9c89f48b01651ff491c69e97089daf71ab8cfb240243d7ecf9b38b2".to_string(), + "0x1931f78f7ce5b43ffae11a2c22f18765508a2b2d4810e84744f53b10f7072c7f".to_string(), 7, ) .end_batch() @@ -385,258 +377,322 @@ mod tests { REQUESTS_IN_PARALLEL, ) .unwrap(); - let pending_nonce = 1; let chain = DEFAULT_CHAIN; let gas_price_in_gwei = DEFAULT_GAS_PRICE; let consuming_wallet = make_paying_wallet(b"paying_wallet"); - let account = make_payable_account(1); let web3_batch = Web3::new(Batch::new(transport)); + let signable_tx_template = SignableTxTemplate { + receiver_address: make_wallet("wallet1").address(), + amount_in_wei: 1_000_000_000, + gas_price_wei: gwei_to_wei(gas_price_in_gwei), + nonce: 1, + }; let result = sign_and_append_payment( chain, &web3_batch, - &account, - consuming_wallet, - pending_nonce.into(), - gwei_to_wei(gas_price_in_gwei), + &signable_tx_template, + &consuming_wallet, + &Logger::new(test_name), ); let mut batch_result = web3_batch.eth().transport().submit_batch().wait().unwrap(); - assert_eq!( - result, - HashAndAmount { - hash: H256::from_str( - "1931f78f7ce5b43ffae11a2c22f18765508a2b2d4810e84744f53b10f7072c7f" - ) - .unwrap(), - amount: account.balance_wei - } - ); + let hash = + H256::from_str("1931f78f7ce5b43ffae11a2c22f18765508a2b2d4810e84744f53b10f7072c7f") + .unwrap(); + let expected_tx = TxBuilder::default() + .hash(hash) + .template(signable_tx_template) + .timestamp(to_unix_timestamp(SystemTime::now())) + .status(TxStatus::Pending(ValidationStatus::Waiting)) + .build(); + assert_on_sent_txs(vec![result], vec![expected_tx]); assert_eq!( batch_result.pop().unwrap().unwrap(), Value::String( - "0x94881436a9c89f48b01651ff491c69e97089daf71ab8cfb240243d7ecf9b38b2".to_string() + "0x1931f78f7ce5b43ffae11a2c22f18765508a2b2d4810e84744f53b10f7072c7f".to_string() ) ); + TestLogHandler::new().exists_log_containing(&format!( + "DEBUG: {test_name}: Appending transaction with hash \ + 0x1931f78f7ce5b43ffae11a2c22f18765508a2b2d4810e84744f53b10f7072c7f, \ + amount: 1,000,000,000 wei, \ + to 0x0000000000000000000000000077616c6c657431, \ + nonce: 1, \ + gas price: 1,000,000,000 wei" + )); } #[test] - fn send_and_append_multiple_payments_works() { + fn sign_and_append_multiple_payments_works() { let port = find_free_port(); - let logger = Logger::new("send_and_append_multiple_payments_works"); + let logger = Logger::new("sign_and_append_multiple_payments_works"); let (_event_loop_handle, transport) = Http::with_max_parallel( &format!("http://{}:{}", &Ipv4Addr::LOCALHOST, port), REQUESTS_IN_PARALLEL, ) .unwrap(); let web3_batch = Web3::new(Batch::new(transport)); - let chain = DEFAULT_CHAIN; - let gas_price_in_gwei = DEFAULT_GAS_PRICE; - let pending_nonce = 1; - let consuming_wallet = make_paying_wallet(b"paying_wallet"); - let account_1 = make_payable_account(1); - let account_2 = make_payable_account(2); - let accounts = vec![account_1, account_2]; + let signable_tx_templates = SignableTxTemplates(vec![ + make_signable_tx_template(1), + make_signable_tx_template(2), + make_signable_tx_template(3), + make_signable_tx_template(4), + make_signable_tx_template(5), + ]); let result = sign_and_append_multiple_payments( &logger, - chain, + DEFAULT_CHAIN, &web3_batch, - consuming_wallet, - gwei_to_wei(gas_price_in_gwei), - pending_nonce.into(), - &accounts, + &signable_tx_templates, + make_paying_wallet(b"paying_wallet"), ); - assert_eq!( - result, - vec![ - HashAndAmount { - hash: H256::from_str( - "1931f78f7ce5b43ffae11a2c22f18765508a2b2d4810e84744f53b10f7072c7f" - ) - .unwrap(), - amount: 1000000000 - }, - HashAndAmount { - hash: H256::from_str( - "0d6daf751e62b89e79cac26d6376cf259d58e996cfccd63f3f43bb6408d1bae8" - ) - .unwrap(), - amount: 2000000000 - } - ] - ); + result + .iter() + .zip(signable_tx_templates.iter()) + .enumerate() + .for_each(|(index, (sent_tx, template))| { + assert_eq!( + sent_tx.receiver_address, template.receiver_address, + "Transaction {} receiver_address mismatch", + index + ); + assert_eq!( + sent_tx.amount_minor, template.amount_in_wei, + "Transaction {} amount mismatch", + index + ); + assert_eq!( + sent_tx.gas_price_minor, template.gas_price_wei, + "Transaction {} gas_price_wei mismatch", + index + ); + assert_eq!( + sent_tx.nonce, template.nonce, + "Transaction {} nonce mismatch", + index + ); + assert_eq!( + sent_tx.status, + TxStatus::Pending(ValidationStatus::Waiting), + "Transaction {} status mismatch", + index + ) + }); } #[test] - fn transmission_log_just_works() { - init_test_logging(); - let test_name = "transmission_log_just_works"; - let gas_price = 120; - let logger = Logger::new(test_name); - let amount_1 = gwei_to_wei(900_000_000_u64); - let account_1 = make_payable_account_with_wallet_and_balance_and_timestamp_opt( - make_wallet("w123"), - amount_1, - None, - ); - let amount_2 = 123_456_789_u128; - let account_2 = make_payable_account_with_wallet_and_balance_and_timestamp_opt( - make_wallet("w555"), - amount_2, - None, - ); - let amount_3 = gwei_to_wei(33_355_666_u64); - let account_3 = make_payable_account_with_wallet_and_balance_and_timestamp_opt( - make_wallet("w987"), - amount_3, - None, + fn transmission_log_is_well_formatted() { + // This test only focuses on the formatting, but there are other tests asserting printing + // this in the logs + + // Case 1 + let payments = [ + gwei_to_wei(900_000_000_u64), + 123_456_789_u128, + gwei_to_wei(33_355_666_u64), + ]; + let latest_nonce = 123456789; + let expected_format = "\n\ + Paying creditors\n\ + Transactions:\n\ + \n\ + chain: base-sepolia\n\ + nonces: 123,456,789...123,456,791\n\ + \n\ + [wallet address] [payment wei] [gas price wei]\n\ + 0x0000000000000000000000000077616c6c657430 900,000,000,000,000,000 246,913,578\n\ + 0x0000000000000000000000000077616c6c657431 123,456,789 493,827,156\n\ + 0x0000000000000000000000000077616c6c657432 33,355,666,000,000,000 740,740,734\n"; + + test_transmission_log( + 1, + payments, + Chain::BaseSepolia, + latest_nonce, + expected_format, ); - let accounts_to_process = vec![account_1, account_2, account_3]; - info!( - logger, - "{}", - transmission_log(TEST_DEFAULT_CHAIN, &accounts_to_process, gas_price) + // Case 2 + let payments = [ + gwei_to_wei(5_400_u64), + gwei_to_wei(10_000_u64), + 44_444_555_u128, + ]; + let latest_nonce = 100; + let expected_format = "\n\ + Paying creditors\n\ + Transactions:\n\ + \n\ + chain: eth-mainnet\n\ + nonces: 100...102\n\ + \n\ + [wallet address] [payment wei] [gas price wei]\n\ + 0x0000000000000000000000000077616c6c657430 5,400,000,000,000 246,913,578\n\ + 0x0000000000000000000000000077616c6c657431 10,000,000,000,000 493,827,156\n\ + 0x0000000000000000000000000077616c6c657432 44,444,555 740,740,734\n"; + + test_transmission_log( + 2, + payments, + Chain::EthMainnet, + latest_nonce, + expected_format, ); - let log_handler = TestLogHandler::new(); - log_handler.exists_log_containing( - "INFO: transmission_log_just_works: Paying to creditors...\n\ - Transactions in the batch:\n\ + // Case 3 + let payments = [45_000_888, 1_999_999, 444_444_555]; + let latest_nonce = 1; + let expected_format = "\n\ + Paying creditors\n\ + Transactions:\n\ \n\ - gas price: 120 wei\n\ - chain: sepolia\n\ + chain: polygon-mainnet\n\ + nonces: 1...3\n\ \n\ - [wallet address] [payment in wei]\n\ - 0x0000000000000000000000000000000077313233 900,000,000,000,000,000\n\ - 0x0000000000000000000000000000000077353535 123,456,789\n\ - 0x0000000000000000000000000000000077393837 33,355,666,000,000,000\n", + [wallet address] [payment wei] [gas price wei]\n\ + 0x0000000000000000000000000077616c6c657430 45,000,888 246,913,578\n\ + 0x0000000000000000000000000077616c6c657431 1,999,999 493,827,156\n\ + 0x0000000000000000000000000077616c6c657432 444,444,555 740,740,734\n"; + + test_transmission_log( + 3, + payments, + Chain::PolyMainnet, + latest_nonce, + expected_format, ); } - #[test] - fn output_by_joining_sources_works() { - let accounts = vec![ - PayableAccount { - wallet: make_wallet("4567"), - balance_wei: 2_345_678, - last_paid_timestamp: from_time_t(4500000), - pending_payable_opt: None, - }, - PayableAccount { - wallet: make_wallet("5656"), - balance_wei: 6_543_210, - last_paid_timestamp: from_time_t(333000), - pending_payable_opt: None, - }, - ]; - let fingerprint_inputs = vec![ - HashAndAmount { - hash: make_tx_hash(444), - amount: 2_345_678, - }, - HashAndAmount { - hash: make_tx_hash(333), - amount: 6_543_210, - }, - ]; - let responses = vec![ - Ok(Value::String(String::from("blah"))), - Err(web3::Error::Rpc(Error { - code: ErrorCode::ParseError, - message: "I guess we've got a problem".to_string(), - data: None, - })), - ]; + fn test_transmission_log( + case: usize, + payments: [u128; 3], + chain: Chain, + latest_nonce: u64, + expected_result: &str, + ) { + let priced_new_tx_templates = payments + .iter() + .enumerate() + .map(|(i, amount_in_wei)| { + let wallet = make_wallet(&format!("wallet{}", i)); + let computed_gas_price_wei = (i as u128 + 1) * 2 * 123_456_789; + PricedNewTxTemplate { + base: BaseTxTemplate { + receiver_address: wallet.address(), + amount_in_wei: *amount_in_wei, + }, + computed_gas_price_wei, + } + }) + .collect::(); + let signable_tx_templates = + SignableTxTemplates::new(Either::Left(priced_new_tx_templates), latest_nonce); - let result = merged_output_data(responses, fingerprint_inputs, accounts.to_vec()); + let result = transmission_log(chain, &signable_tx_templates); assert_eq!( - result, - vec![ - Correct(PendingPayable { - recipient_wallet: make_wallet("4567"), - hash: make_tx_hash(444) - }), - Failed(RpcPayableFailure { - rpc_error: web3::Error::Rpc(Error { - code: ErrorCode::ParseError, - message: "I guess we've got a problem".to_string(), - data: None, - }), - recipient_wallet: make_wallet("5656"), - hash: make_tx_hash(333) - }) - ] - ) + result, expected_result, + "Test case {}: we expected this format: \"{}\", but it was: \"{}\"", + case, expected_result, result + ); } - fn execute_send_payables_test( + fn test_send_payables_within_batch( test_name: &str, - accounts: Vec, - expected_result: Result, PayableTransactionError>, + signable_tx_templates: SignableTxTemplates, + expected_result: Result, port: u16, ) { + // TODO: GH-701: Add assertions for the new_fingerprints_message here, since it existed earlier init_test_logging(); let (_event_loop_handle, transport) = Http::with_max_parallel( &format!("http://{}:{}", &Ipv4Addr::LOCALHOST, port), REQUESTS_IN_PARALLEL, ) .unwrap(); - let gas_price = 1_000_000_000; - let pending_nonce: U256 = 1.into(); let web3_batch = Web3::new(Batch::new(transport)); - let (accountant, _, accountant_recording) = make_recorder(); let logger = Logger::new(test_name); let chain = DEFAULT_CHAIN; let consuming_wallet = make_paying_wallet(b"consuming_wallet"); - let new_fingerprints_recipient = accountant.start().recipient(); let system = System::new(test_name); - let timestamp_before = SystemTime::now(); + let expected_transmission_log = transmission_log(chain, &signable_tx_templates); let result = send_payables_within_batch( &logger, chain, &web3_batch, + signable_tx_templates, consuming_wallet.clone(), - gas_price, - pending_nonce, - new_fingerprints_recipient, - accounts.clone(), ) .wait(); System::current().stop(); system.run(); - let timestamp_after = SystemTime::now(); - let accountant_recording_result = accountant_recording.lock().unwrap(); - let ppfs_message = - accountant_recording_result.get_record::(0); - assert_eq!(accountant_recording_result.len(), 1); - assert!(timestamp_before <= ppfs_message.batch_wide_timestamp); - assert!(timestamp_after >= ppfs_message.batch_wide_timestamp); let tlh = TestLogHandler::new(); tlh.exists_log_containing( - &format!("DEBUG: {test_name}: Common attributes of payables to be transacted: sender wallet: {}, contract: {:?}, chain_id: {}, gas_price: {}", + &format!("DEBUG: {test_name}: Common attributes of payables to be transacted: sender wallet: {}, contract: {:?}, chain_id: {}", consuming_wallet, chain.rec().contract, chain.rec().num_chain_id, - gas_price ) ); - tlh.exists_log_containing(&format!( - "INFO: {test_name}: {}", - transmission_log(chain, &accounts, gas_price) - )); - assert_eq!(result, expected_result); + tlh.exists_log_containing(&format!("INFO: {test_name}: {expected_transmission_log}")); + match result { + Ok(resulted_batch) => { + let expected_batch = expected_result.unwrap(); + assert_on_failed_txs(resulted_batch.failed_txs, expected_batch.failed_txs); + assert_on_sent_txs(resulted_batch.sent_txs, expected_batch.sent_txs); + } + Err(resulted_err) => match resulted_err { + LocalPayableError::Sending { error, failed_txs } => { + if let Err(LocalPayableError::Sending { + error: expected_error, + failed_txs: expected_failed_txs, + }) = expected_result + { + assert_on_failed_txs(failed_txs, expected_failed_txs); + assert_eq!(error, expected_error) + } else { + panic!( + "Expected different error but received {}", + expected_result.unwrap_err(), + ) + } + } + other_err => { + panic!("Only LocalPayableError::Sending is returned by send_payables_within_batch but received something else: {} ", other_err) + } + }, + } } #[test] fn send_payables_within_batch_works() { - let accounts = vec![make_payable_account(1), make_payable_account(2)]; let port = find_free_port(); + let (_event_loop_handle, transport) = Http::with_max_parallel( + &format!("http://{}:{}", &Ipv4Addr::LOCALHOST.to_string(), port), + REQUESTS_IN_PARALLEL, + ) + .unwrap(); + let web3_batch = Web3::new(Batch::new(transport)); + let consuming_wallet = make_paying_wallet(b"consuming_wallet"); + let template_1 = SignableTxTemplate { + receiver_address: make_address(1), + amount_in_wei: 111_222, + gas_price_wei: 123, + nonce: 1, + }; + let template_2 = SignableTxTemplate { + receiver_address: make_address(2), + amount_in_wei: 222_333, + gas_price_wei: 234, + nonce: 2, + }; + let signable_tx_templates = + SignableTxTemplates(vec![template_1.clone(), template_2.clone()]); let _blockchain_client_server = MBCSBuilder::new(port) .begin_batch() // TODO: GH-547: This rpc_result should be validated in production code. @@ -644,57 +700,126 @@ mod tests { .ok_response("irrelevant_ok_rpc_response_2".to_string(), 8) .end_batch() .start(); - let expected_result = Ok(vec![ - Correct(PendingPayable { - recipient_wallet: accounts[0].wallet.clone(), - hash: H256::from_str( - "7bff7fd8e627d317203742a40f77be1a4155b4c3a29dfd4f96088775f0237023", - ) - .unwrap(), - }), - Correct(PendingPayable { - recipient_wallet: accounts[1].wallet.clone(), - hash: H256::from_str( - "5bc60cce367d9698b8dbdb340e2af3a3166bb4469e69db899f5074938ea0d61b", - ) - .unwrap(), - }), - ]); + let batch_results = { + let signed_tx_1 = + sign_transaction(DEFAULT_CHAIN, &web3_batch, &template_1, &consuming_wallet); + let sent_tx_1 = TxBuilder::default() + .hash(signed_tx_1.transaction_hash) + .template(template_1) + .status(TxStatus::Pending(ValidationStatus::Waiting)) + .build(); + let signed_tx_2 = + sign_transaction(DEFAULT_CHAIN, &web3_batch, &template_2, &consuming_wallet); + let sent_tx_2 = TxBuilder::default() + .hash(signed_tx_2.transaction_hash) + .template(template_2) + .status(TxStatus::Pending(ValidationStatus::Waiting)) + .build(); + + BatchResults { + sent_txs: vec![sent_tx_1, sent_tx_2], + failed_txs: vec![], + } + }; - execute_send_payables_test( + test_send_payables_within_batch( "send_payables_within_batch_works", - accounts, - expected_result, + signable_tx_templates, + Ok(batch_results), port, ); } #[test] fn send_payables_within_batch_fails_on_submit_batch_call() { - let accounts = vec![make_payable_account(1), make_payable_account(2)]; - let os_code = transport_error_code(); - let os_msg = transport_error_message(); + let test_name = "send_payables_within_batch_fails_on_submit_batch_call"; let port = find_free_port(); + let (_event_loop_handle, transport) = Http::with_max_parallel( + &format!("http://{}:{}", &Ipv4Addr::LOCALHOST.to_string(), port), + REQUESTS_IN_PARALLEL, + ) + .unwrap(); + let web3_batch = Web3::new(Batch::new(transport)); + let consuming_wallet = make_paying_wallet(b"consuming_wallet"); + let signable_tx_templates = SignableTxTemplates(vec![ + SignableTxTemplate { + receiver_address: make_address(1), + amount_in_wei: 12345, + gas_price_wei: 99, + nonce: 5, + }, + SignableTxTemplate { + receiver_address: make_address(2), + amount_in_wei: 22345, + gas_price_wei: 100, + nonce: 6, + }, + ]); + let os_specific_code = transport_error_code(); + let os_specific_msg = transport_error_message(); + let err_msg = format!( + "Transport error: Error(Connect, Os {{ code: {}, kind: ConnectionRefused, message: {:?} }})", + os_specific_code, os_specific_msg + ); + let failed_txs = signable_tx_templates + .iter() + .map(|template| { + let signed_tx = + sign_transaction(DEFAULT_CHAIN, &web3_batch, template, &consuming_wallet); + FailedTxBuilder::default() + .hash(signed_tx.transaction_hash) + .receiver_address(template.receiver_address) + .amount(template.amount_in_wei) + .timestamp(to_unix_timestamp(SystemTime::now()) - 5) + .gas_price_wei(template.gas_price_wei) + .nonce(template.nonce) + .reason(FailureReason::Submission(AppRpcErrorKind::Local( + LocalErrorKind::Transport, + ))) + .status(FailureStatus::RetryRequired) + .build() + }) + .collect(); let expected_result = Err(Sending { - msg: format!("Transport error: Error(Connect, Os {{ code: {}, kind: ConnectionRefused, message: {:?} }})", os_code, os_msg).to_string(), - hashes: vec![ - H256::from_str("7bff7fd8e627d317203742a40f77be1a4155b4c3a29dfd4f96088775f0237023").unwrap(), - H256::from_str("5bc60cce367d9698b8dbdb340e2af3a3166bb4469e69db899f5074938ea0d61b").unwrap() - ], + error: err_msg, + failed_txs, }); - execute_send_payables_test( - "send_payables_within_batch_fails_on_submit_batch_call", - accounts, - expected_result, - port, - ); + test_send_payables_within_batch(test_name, signable_tx_templates, expected_result, port); + + let os_specific_code = transport_error_code(); + let os_specific_msg = transport_error_message(); + TestLogHandler::new().exists_log_containing(&format!( + "WARN: {test_name}: Failed to submit batch to Web3 client: Transport error: \ + Error(Connect, Os {{ code: {}, kind: ConnectionRefused, message: \"{}\" }}", + os_specific_code, os_specific_msg + )); } #[test] fn send_payables_within_batch_all_payments_fail() { - let accounts = vec![make_payable_account(1), make_payable_account(2)]; let port = find_free_port(); + let (_event_loop_handle, transport) = Http::with_max_parallel( + &format!("http://{}:{}", &Ipv4Addr::LOCALHOST.to_string(), port), + REQUESTS_IN_PARALLEL, + ) + .unwrap(); + let web3_batch = Web3::new(Batch::new(transport)); + let signable_tx_templates = SignableTxTemplates(vec![ + SignableTxTemplate { + receiver_address: make_address(1), + amount_in_wei: 111_222, + gas_price_wei: 123, + nonce: 1, + }, + SignableTxTemplate { + receiver_address: make_address(2), + amount_in_wei: 222_333, + gas_price_wei: 234, + nonce: 2, + }, + ]); + let consuming_wallet = make_paying_wallet(b"consuming_wallet"); let _blockchain_client_server = MBCSBuilder::new(port) .begin_batch() .err_response( @@ -711,39 +836,61 @@ mod tests { ) .end_batch() .start(); - let expected_result = Ok(vec![ - Failed(RpcPayableFailure { - rpc_error: Rpc(Error { - code: ServerError(429), - message: "The requests per second (RPS) of your requests are higher than your plan allows.".to_string(), - data: None, - }), - recipient_wallet: accounts[0].wallet.clone(), - hash: H256::from_str("7bff7fd8e627d317203742a40f77be1a4155b4c3a29dfd4f96088775f0237023").unwrap(), - }), - Failed(RpcPayableFailure { - rpc_error: Rpc(Error { - code: ServerError(429), - message: "The requests per second (RPS) of your requests are higher than your plan allows.".to_string(), - data: None, - }), - recipient_wallet: accounts[1].wallet.clone(), - hash: H256::from_str("5bc60cce367d9698b8dbdb340e2af3a3166bb4469e69db899f5074938ea0d61b").unwrap(), - }), - ]); - - execute_send_payables_test( + let failed_txs = signable_tx_templates + .iter() + .map(|template| { + let signed_tx = + sign_transaction(DEFAULT_CHAIN, &web3_batch, template, &consuming_wallet); + FailedTxBuilder::default() + .hash(signed_tx.transaction_hash) + .receiver_address(template.receiver_address) + .amount(template.amount_in_wei) + .timestamp(to_unix_timestamp(SystemTime::now()) - 5) + .gas_price_wei(template.gas_price_wei) + .nonce(template.nonce) + .reason(FailureReason::Submission(AppRpcErrorKind::Remote( + RemoteErrorKind::Web3RpcError(429), + ))) + .status(FailureStatus::RetryRequired) + .build() + }) + .collect(); + + test_send_payables_within_batch( "send_payables_within_batch_all_payments_fail", - accounts, - expected_result, + signable_tx_templates, + Ok(BatchResults { + sent_txs: vec![], + failed_txs, + }), port, ); } #[test] fn send_payables_within_batch_one_payment_works_the_other_fails() { - let accounts = vec![make_payable_account(1), make_payable_account(2)]; let port = find_free_port(); + let (_event_loop_handle, transport) = Http::with_max_parallel( + &format!("http://{}:{}", &Ipv4Addr::LOCALHOST.to_string(), port), + REQUESTS_IN_PARALLEL, + ) + .unwrap(); + let web3_batch = Web3::new(Batch::new(transport)); + let consuming_wallet = make_paying_wallet(b"consuming_wallet"); + let template_1 = SignableTxTemplate { + receiver_address: make_address(1), + amount_in_wei: 111_222, + gas_price_wei: 123, + nonce: 1, + }; + let template_2 = SignableTxTemplate { + receiver_address: make_address(2), + amount_in_wei: 222_333, + gas_price_wei: 234, + nonce: 2, + }; + let signable_tx_templates = + SignableTxTemplates(vec![template_1.clone(), template_2.clone()]); let _blockchain_client_server = MBCSBuilder::new(port) .begin_batch() .ok_response("rpc_result".to_string(), 7) @@ -755,39 +902,41 @@ mod tests { ) .end_batch() .start(); - let expected_result = Ok(vec![ - Correct(PendingPayable { - recipient_wallet: accounts[0].wallet.clone(), - hash: H256::from_str("7bff7fd8e627d317203742a40f77be1a4155b4c3a29dfd4f96088775f0237023").unwrap(), - }), - Failed(RpcPayableFailure { - rpc_error: Rpc(Error { - code: ServerError(429), - message: "The requests per second (RPS) of your requests are higher than your plan allows.".to_string(), - data: None, - }), - recipient_wallet: accounts[1].wallet.clone(), - hash: H256::from_str("5bc60cce367d9698b8dbdb340e2af3a3166bb4469e69db899f5074938ea0d61b").unwrap(), - }), - ]); + let batch_results = { + let signed_tx_1 = + sign_transaction(DEFAULT_CHAIN, &web3_batch, &template_1, &consuming_wallet); + let sent_tx = TxBuilder::default() + .hash(signed_tx_1.transaction_hash) + .template(template_1) + .timestamp(to_unix_timestamp(SystemTime::now())) + .status(TxStatus::Pending(ValidationStatus::Waiting)) + .build(); + let signed_tx_2 = + sign_transaction(DEFAULT_CHAIN, &web3_batch, &template_2, &consuming_wallet); + let failed_tx = FailedTxBuilder::default() + .hash(signed_tx_2.transaction_hash) + .template(template_2) + .timestamp(to_unix_timestamp(SystemTime::now())) + .reason(FailureReason::Submission(AppRpcErrorKind::Remote( + RemoteErrorKind::Web3RpcError(429), + ))) + .status(FailureStatus::RetryRequired) + .build(); + + BatchResults { + sent_txs: vec![sent_tx], + failed_txs: vec![failed_tx], + } + }; - execute_send_payables_test( + test_send_payables_within_batch( "send_payables_within_batch_one_payment_works_the_other_fails", - accounts, - expected_result, + signable_tx_templates, + Ok(batch_results), port, ); } - #[test] - fn advance_used_nonce_works() { - let initial_nonce = U256::from(55); - - let result = advance_used_nonce(initial_nonce); - - assert_eq!(result, U256::from(56)) - } - #[test] #[should_panic( expected = "Consuming wallet doesn't contain a secret key: Signature(\"Cannot sign with non-keypair wallet: Address(0x000000000000000000006261645f77616c6c6574).\")" @@ -799,19 +948,20 @@ mod tests { REQUESTS_IN_PARALLEL, ) .unwrap(); - let recipient_wallet = make_wallet("unlucky man"); let consuming_wallet = make_wallet("bad_wallet"); let gas_price = 123_000_000_000; - let nonce = U256::from(1); + let signable_tx_template = SignableTxTemplate { + receiver_address: make_address(1), + amount_in_wei: 1223, + gas_price_wei: gas_price, + nonce: 1, + }; sign_transaction( Chain::PolyAmoy, &Web3::new(Batch::new(transport)), - recipient_wallet, - consuming_wallet, - 444444, - nonce, - gas_price, + &signable_tx_template, + &consuming_wallet, ); } @@ -826,14 +976,14 @@ mod tests { let web3 = Web3::new(transport.clone()); let chain = DEFAULT_CHAIN; let amount = 11_222_333_444; - let gas_price_in_wei = 123 * 10_u128.pow(18); - let nonce = U256::from(5); + let gas_price_in_wei = 123 * 10_u128.pow(9); + let nonce = 5; let recipient_wallet = make_wallet("recipient_wallet"); let consuming_wallet = make_paying_wallet(b"consuming_wallet"); let consuming_wallet_secret_key = consuming_wallet.prepare_secp256k1_secret().unwrap(); - let data = sign_transaction_data(amount, recipient_wallet.clone()); + let data = sign_transaction_data(amount, recipient_wallet.address()); let tx_parameters = TransactionParameters { - nonce: Some(nonce), + nonce: Some(U256::from(nonce)), to: Some(chain.rec().contract), gas: gas_limit(data, chain), gas_price: Some(U256::from(gas_price_in_wei)), @@ -841,14 +991,17 @@ mod tests { data: Bytes(data.to_vec()), chain_id: Some(chain.rec().num_chain_id), }; + let signable_tx_template = SignableTxTemplate { + receiver_address: recipient_wallet.address(), + amount_in_wei: amount, + gas_price_wei: gas_price_in_wei, + nonce, + }; let result = sign_transaction( chain, &Web3::new(Batch::new(transport)), - recipient_wallet, - consuming_wallet, - amount, - nonce, - gas_price_in_wei, + &signable_tx_template, + &consuming_wallet, ); let expected_tx_result = web3 @@ -875,7 +1028,7 @@ mod tests { let gas_price = U256::from(5); let recipient_wallet = make_wallet("recipient_wallet"); let consuming_wallet = make_paying_wallet(b"consuming_wallet"); - let data = sign_transaction_data(amount, recipient_wallet); + let data = sign_transaction_data(amount, recipient_wallet.address()); // sign_transaction makes a blockchain call because nonce is set to None let transaction_parameters = TransactionParameters { nonce: None, @@ -1011,7 +1164,6 @@ mod tests { let address = Address::from_slice(&recipient_address_bytes); Wallet::from(address) }; - let nonce_correct_type = U256::from(nonce); let gas_price_in_gwei = match chain { Chain::EthMainnet => TEST_GAS_PRICE_ETH, Chain::EthRopsten => TEST_GAS_PRICE_ETH, @@ -1019,20 +1171,18 @@ mod tests { Chain::PolyAmoy => TEST_GAS_PRICE_POLYGON, _ => panic!("isn't our interest in this test"), }; - let payable_account = make_payable_account_with_wallet_and_balance_and_timestamp_opt( - recipient_wallet, - TEST_PAYMENT_AMOUNT, - None, - ); + let signable_tx_template = SignableTxTemplate { + receiver_address: recipient_wallet.address(), + amount_in_wei: TEST_PAYMENT_AMOUNT, + gas_price_wei: gwei_to_wei(gas_price_in_gwei), + nonce, + }; let signed_transaction = sign_transaction( chain, &Web3::new(Batch::new(transport)), - payable_account.wallet, - consuming_wallet, - payable_account.balance_wei, - nonce_correct_type, - gwei_to_wei(gas_price_in_gwei), + &signable_tx_template, + &consuming_wallet, ); let byte_set_to_compare = signed_transaction.raw_transaction.0; @@ -1042,7 +1192,7 @@ mod tests { fn test_gas_limit_is_between_limits(chain: Chain) { let not_under_this_value = BlockchainInterfaceWeb3::web3_gas_limit_const_part(chain); let not_above_this_value = not_under_this_value + WEB3_MAXIMAL_GAS_LIMIT_MARGIN; - let data = sign_transaction_data(1_000_000_000, make_wallet("wallet1")); + let data = sign_transaction_data(1_000_000_000, make_wallet("wallet1").address()); let gas_limit = gas_limit(data, chain); diff --git a/node/src/blockchain/blockchain_interface/data_structures/errors.rs b/node/src/blockchain/blockchain_interface/data_structures/errors.rs index 3084accfb8..03899343ea 100644 --- a/node/src/blockchain/blockchain_interface/data_structures/errors.rs +++ b/node/src/blockchain/blockchain_interface/data_structures/errors.rs @@ -1,51 +1,51 @@ // Copyright (c) 2019, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. -use crate::accountant::comma_joined_stringifiable; +use crate::accountant::db_access_objects::failed_payable_dao::FailedTx; +use crate::accountant::join_with_separator; use itertools::Either; use std::fmt; use std::fmt::{Display, Formatter}; use variant_count::VariantCount; -use web3::types::{Address, H256}; +use web3::types::Address; const BLOCKCHAIN_SERVICE_URL_NOT_SPECIFIED: &str = "Uninitialized blockchain interface. To avoid \ being delinquency-banned, you should restart the Node with a value for blockchain-service-url"; #[derive(Clone, Debug, PartialEq, Eq, VariantCount)] -pub enum BlockchainError { +pub enum BlockchainInterfaceError { InvalidUrl, InvalidAddress, InvalidResponse, QueryFailed(String), - UninitializedBlockchainInterface, + UninitializedInterface, } -impl Display for BlockchainError { +impl Display for BlockchainInterfaceError { fn fmt(&self, f: &mut Formatter) -> fmt::Result { let err_spec = match self { Self::InvalidUrl => Either::Left("Invalid url"), Self::InvalidAddress => Either::Left("Invalid address"), Self::InvalidResponse => Either::Left("Invalid response"), Self::QueryFailed(msg) => Either::Right(format!("Query failed: {}", msg)), - Self::UninitializedBlockchainInterface => { - Either::Left(BLOCKCHAIN_SERVICE_URL_NOT_SPECIFIED) - } + Self::UninitializedInterface => Either::Left(BLOCKCHAIN_SERVICE_URL_NOT_SPECIFIED), }; write!(f, "Blockchain error: {}", err_spec) } } #[derive(Clone, Debug, PartialEq, Eq, VariantCount)] -pub enum PayableTransactionError { +pub enum LocalPayableError { MissingConsumingWallet, - GasPriceQueryFailed(BlockchainError), - TransactionID(BlockchainError), - UnusableWallet(String), - Signing(String), - Sending { msg: String, hashes: Vec }, - UninitializedBlockchainInterface, + GasPriceQueryFailed(BlockchainInterfaceError), + TransactionID(BlockchainInterfaceError), + Sending { + error: String, + failed_txs: Vec, + }, + UninitializedInterface, } -impl Display for PayableTransactionError { +impl Display for LocalPayableError { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { match self { Self::MissingConsumingWallet => { @@ -57,19 +57,13 @@ impl Display for PayableTransactionError { Self::TransactionID(blockchain_err) => { write!(f, "Transaction id fetching failed: {}", blockchain_err) } - Self::UnusableWallet(msg) => write!( - f, - "Unusable wallet for signing payable transactions: \"{}\"", - msg - ), - Self::Signing(msg) => write!(f, "Signing phase: \"{}\"", msg), - Self::Sending { msg, hashes } => write!( + Self::Sending { error, failed_txs } => write!( f, - "Sending phase: \"{}\". Signed and hashed transactions: {}", - msg, - comma_joined_stringifiable(hashes, |hash| format!("{:?}", hash)) + "Sending error: \"{}\". Signed and hashed transactions: \"{}\"", + error, + join_with_separator(failed_txs, |failed_tx| format!("{:?}", failed_tx), ",") ), - Self::UninitializedBlockchainInterface => { + Self::UninitializedInterface => { write!(f, "{}", BLOCKCHAIN_SERVICE_URL_NOT_SPECIFIED) } } @@ -78,10 +72,10 @@ impl Display for PayableTransactionError { #[derive(Clone, Debug, PartialEq, Eq, VariantCount)] pub enum BlockchainAgentBuildError { - GasPrice(BlockchainError), - TransactionFeeBalance(Address, BlockchainError), - ServiceFeeBalance(Address, BlockchainError), - UninitializedBlockchainInterface, + GasPrice(BlockchainInterfaceError), + TransactionFeeBalance(Address, BlockchainInterfaceError), + ServiceFeeBalance(Address, BlockchainInterfaceError), + UninitializedInterface, } impl Display for BlockchainAgentBuildError { @@ -98,7 +92,7 @@ impl Display for BlockchainAgentBuildError { "masq balance for our earning wallet {:#x} due to {}", address, blockchain_e )), - Self::UninitializedBlockchainInterface => { + Self::UninitializedInterface => { Either::Right(BLOCKCHAIN_SERVICE_URL_NOT_SPECIFIED.to_string()) } }; @@ -116,11 +110,13 @@ impl Display for BlockchainAgentBuildError { #[cfg(test)] mod tests { + use crate::accountant::db_access_objects::test_utils::make_failed_tx; use crate::blockchain::blockchain_interface::data_structures::errors::{ - PayableTransactionError, BLOCKCHAIN_SERVICE_URL_NOT_SPECIFIED, + LocalPayableError, BLOCKCHAIN_SERVICE_URL_NOT_SPECIFIED, + }; + use crate::blockchain::blockchain_interface::{ + BlockchainAgentBuildError, BlockchainInterfaceError, }; - use crate::blockchain::blockchain_interface::{BlockchainAgentBuildError, BlockchainError}; - use crate::blockchain::test_utils::make_tx_hash; use crate::test_utils::make_wallet; use masq_lib::utils::{slice_of_strs_to_vec_of_strings, to_string}; @@ -136,20 +132,20 @@ mod tests { #[test] fn blockchain_error_implements_display() { let original_errors = [ - BlockchainError::InvalidUrl, - BlockchainError::InvalidAddress, - BlockchainError::InvalidResponse, - BlockchainError::QueryFailed( + BlockchainInterfaceError::InvalidUrl, + BlockchainInterfaceError::InvalidAddress, + BlockchainInterfaceError::InvalidResponse, + BlockchainInterfaceError::QueryFailed( "Don't query so often, it gives me a headache".to_string(), ), - BlockchainError::UninitializedBlockchainInterface, + BlockchainInterfaceError::UninitializedInterface, ]; let actual_error_msgs = original_errors.iter().map(to_string).collect::>(); assert_eq!( original_errors.len(), - BlockchainError::VARIANT_COUNT, + BlockchainInterfaceError::VARIANT_COUNT, "you forgot to add all variants in this test" ); assert_eq!( @@ -167,29 +163,23 @@ mod tests { #[test] fn payable_payment_error_implements_display() { let original_errors = [ - PayableTransactionError::MissingConsumingWallet, - PayableTransactionError::GasPriceQueryFailed(BlockchainError::QueryFailed( + LocalPayableError::MissingConsumingWallet, + LocalPayableError::GasPriceQueryFailed(BlockchainInterfaceError::QueryFailed( "Gas halves shut, no drop left".to_string(), )), - PayableTransactionError::TransactionID(BlockchainError::InvalidResponse), - PayableTransactionError::UnusableWallet( - "This is a LEATHER wallet, not LEDGER wallet, stupid.".to_string(), - ), - PayableTransactionError::Signing( - "You cannot sign with just three crosses here, clever boy".to_string(), - ), - PayableTransactionError::Sending { - msg: "Sending to cosmos belongs elsewhere".to_string(), - hashes: vec![make_tx_hash(0x6f), make_tx_hash(0xde)], + LocalPayableError::TransactionID(BlockchainInterfaceError::InvalidResponse), + LocalPayableError::Sending { + error: "Terrible error!!".to_string(), + failed_txs: vec![make_failed_tx(456)], }, - PayableTransactionError::UninitializedBlockchainInterface, + LocalPayableError::UninitializedInterface, ]; let actual_error_msgs = original_errors.iter().map(to_string).collect::>(); assert_eq!( original_errors.len(), - PayableTransactionError::VARIANT_COUNT, + LocalPayableError::VARIANT_COUNT, "you forgot to add all variants in this test" ); assert_eq!( @@ -198,12 +188,10 @@ mod tests { "Missing consuming wallet to pay payable from", "Unsuccessful gas price query: \"Blockchain error: Query failed: Gas halves shut, no drop left\"", "Transaction id fetching failed: Blockchain error: Invalid response", - "Unusable wallet for signing payable transactions: \"This is a LEATHER wallet, not \ - LEDGER wallet, stupid.\"", - "Signing phase: \"You cannot sign with just three crosses here, clever boy\"", - "Sending phase: \"Sending to cosmos belongs elsewhere\". Signed and hashed \ - transactions: 0x000000000000000000000000000000000000000000000000000000000000006f, \ - 0x00000000000000000000000000000000000000000000000000000000000000de", + "Sending error: \"Terrible error!!\". Signed and hashed transactions: \"FailedTx { hash: 0x00000000000000\ + 000000000000000000000000000000000000000000000001c8, receiver_address: 0x00000000000\ + 00000002556000000002556000000, amount_minor: 43237380096, timestamp: 29942784, gas_\ + price_minor: 94818816, nonce: 456, reason: PendingTooLong, status: RetryRequired }\"", BLOCKCHAIN_SERVICE_URL_NOT_SPECIFIED ]) ) @@ -213,16 +201,16 @@ mod tests { fn blockchain_agent_build_error_implements_display() { let wallet = make_wallet("abc"); let original_errors = [ - BlockchainAgentBuildError::GasPrice(BlockchainError::InvalidResponse), + BlockchainAgentBuildError::GasPrice(BlockchainInterfaceError::InvalidResponse), BlockchainAgentBuildError::TransactionFeeBalance( wallet.address(), - BlockchainError::InvalidResponse, + BlockchainInterfaceError::InvalidResponse, ), BlockchainAgentBuildError::ServiceFeeBalance( wallet.address(), - BlockchainError::InvalidAddress, + BlockchainInterfaceError::InvalidAddress, ), - BlockchainAgentBuildError::UninitializedBlockchainInterface, + BlockchainAgentBuildError::UninitializedInterface, ]; let actual_error_msgs = original_errors.iter().map(to_string).collect::>(); diff --git a/node/src/blockchain/blockchain_interface/data_structures/mod.rs b/node/src/blockchain/blockchain_interface/data_structures/mod.rs index a33a1f889a..f79f12345b 100644 --- a/node/src/blockchain/blockchain_interface/data_structures/mod.rs +++ b/node/src/blockchain/blockchain_interface/data_structures/mod.rs @@ -2,13 +2,16 @@ pub mod errors; -use crate::accountant::db_access_objects::pending_payable_dao::PendingPayable; +use crate::accountant::db_access_objects::failed_payable_dao::FailedTx; +use crate::accountant::db_access_objects::sent_payable_dao::SentTx; +use crate::accountant::scanners::pending_payable_scanner::utils::TxHashByTable; use crate::blockchain::blockchain_bridge::BlockMarker; use crate::sub_lib::wallet::Wallet; +use ethereum_types::U64; +use serde_derive::{Deserialize, Serialize}; use std::fmt; -use std::fmt::Formatter; -use web3::types::H256; -use web3::Error; +use std::fmt::{Display, Formatter}; +use web3::types::{TransactionReceipt, H256}; #[derive(Clone, Debug, Eq, PartialEq)] pub struct BlockchainTransaction { @@ -33,15 +36,95 @@ pub struct RetrievedBlockchainTransactions { pub transactions: Vec, } -#[derive(Debug, PartialEq, Clone)] -pub struct RpcPayableFailure { - pub rpc_error: Error, - pub recipient_wallet: Wallet, - pub hash: H256, +#[derive(Default, Debug, PartialEq, Eq, Clone)] +pub struct BatchResults { + pub sent_txs: Vec, + pub failed_txs: Vec, } -#[derive(Debug, PartialEq, Clone)] -pub enum ProcessedPayableFallible { - Correct(PendingPayable), - Failed(RpcPayableFailure), +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct RetrievedTxStatus { + pub tx_hash: TxHashByTable, + pub status: StatusReadFromReceiptCheck, +} + +impl RetrievedTxStatus { + pub fn new(tx_hash: TxHashByTable, status: StatusReadFromReceiptCheck) -> Self { + Self { tx_hash, status } + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum StatusReadFromReceiptCheck { + Reverted, + Succeeded(TxBlock), + Pending, +} + +impl Display for StatusReadFromReceiptCheck { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StatusReadFromReceiptCheck::Reverted => { + write!(f, "Reverted") + } + StatusReadFromReceiptCheck::Succeeded(block) => { + write!( + f, + "Succeeded({},{:?})", + block.block_number, block.block_hash + ) + } + StatusReadFromReceiptCheck::Pending => write!(f, "Pending"), + } + } +} + +impl From for StatusReadFromReceiptCheck { + fn from(receipt: TransactionReceipt) -> Self { + match (receipt.status, receipt.block_hash, receipt.block_number) { + (Some(status), Some(block_hash), Some(block_number)) if status == U64::from(1) => { + StatusReadFromReceiptCheck::Succeeded(TxBlock { + block_hash, + block_number, + }) + } + (Some(status), _, _) if status == U64::from(0) => StatusReadFromReceiptCheck::Reverted, + _ => StatusReadFromReceiptCheck::Pending, + } + } +} + +#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Ord, PartialOrd, Serialize, Deserialize)] +pub struct TxBlock { + pub block_hash: H256, + pub block_number: U64, +} + +#[cfg(test)] +mod tests { + use crate::blockchain::blockchain_interface::data_structures::{ + StatusReadFromReceiptCheck, TxBlock, + }; + use ethereum_types::{H256, U64}; + + #[test] + fn tx_status_display_works() { + // Test Failed + assert_eq!(StatusReadFromReceiptCheck::Reverted.to_string(), "Reverted"); + + // Test Pending + assert_eq!(StatusReadFromReceiptCheck::Pending.to_string(), "Pending"); + + // Test Succeeded + let block_number = U64::from(12345); + let block_hash = H256::from_low_u64_be(0xabcdef); + let succeeded = StatusReadFromReceiptCheck::Succeeded(TxBlock { + block_hash, + block_number, + }); + assert_eq!( + succeeded.to_string(), + format!("Succeeded({},0x{:x})", block_number, block_hash) + ); + } } diff --git a/node/src/blockchain/blockchain_interface/lower_level_interface.rs b/node/src/blockchain/blockchain_interface/lower_level_interface.rs index c8653f9852..6ae07dca24 100644 --- a/node/src/blockchain/blockchain_interface/lower_level_interface.rs +++ b/node/src/blockchain/blockchain_interface/lower_level_interface.rs @@ -1,6 +1,6 @@ // Copyright (c) 2019, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. -use crate::blockchain::blockchain_interface::data_structures::errors::BlockchainError; +use crate::blockchain::blockchain_interface::data_structures::errors::BlockchainInterfaceError; use ethereum_types::{H256, U64}; use futures::Future; use serde_json::Value; @@ -15,33 +15,33 @@ pub trait LowBlockchainInt { fn get_transaction_fee_balance( &self, address: Address, - ) -> Box>; + ) -> Box>; fn get_service_fee_balance( &self, address: Address, - ) -> Box>; + ) -> Box>; - fn get_gas_price(&self) -> Box>; + fn get_gas_price(&self) -> Box>; - fn get_block_number(&self) -> Box>; + fn get_block_number(&self) -> Box>; fn get_transaction_id( &self, address: Address, - ) -> Box>; + ) -> Box>; fn get_transaction_receipt_in_batch( &self, hash_vec: Vec, - ) -> Box>, Error = BlockchainError>>; + ) -> Box>, Error = BlockchainInterfaceError>>; fn get_contract_address(&self) -> Address; fn get_transaction_logs( &self, filter: Filter, - ) -> Box, Error = BlockchainError>>; + ) -> Box, Error = BlockchainInterfaceError>>; fn get_web3_batch(&self) -> Web3>; } diff --git a/node/src/blockchain/blockchain_interface/mod.rs b/node/src/blockchain/blockchain_interface/mod.rs index ef1e8d373f..3db1bbeab9 100644 --- a/node/src/blockchain/blockchain_interface/mod.rs +++ b/node/src/blockchain/blockchain_interface/mod.rs @@ -4,20 +4,26 @@ pub mod blockchain_interface_web3; pub mod data_structures; pub mod lower_level_interface; -use actix::Recipient; -use ethereum_types::H256; -use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::blockchain_agent::BlockchainAgent; -use crate::blockchain::blockchain_interface::data_structures::errors::{BlockchainAgentBuildError, BlockchainError, PayableTransactionError}; -use crate::blockchain::blockchain_interface::data_structures::{ProcessedPayableFallible, RetrievedBlockchainTransactions}; +use crate::accountant::scanners::payable_scanner::tx_templates::priced::new::PricedNewTxTemplates; +use crate::accountant::scanners::payable_scanner::tx_templates::priced::retry::PricedRetryTxTemplates; +use crate::accountant::scanners::pending_payable_scanner::utils::TxHashByTable; +use crate::accountant::TxReceiptResult; +use crate::blockchain::blockchain_agent::BlockchainAgent; +use crate::blockchain::blockchain_bridge::{BlockMarker, BlockScanRange}; +use crate::blockchain::blockchain_interface::data_structures::errors::{ + BlockchainAgentBuildError, BlockchainInterfaceError, LocalPayableError, +}; +use crate::blockchain::blockchain_interface::data_structures::{ + BatchResults, RetrievedBlockchainTransactions, +}; use crate::blockchain::blockchain_interface::lower_level_interface::LowBlockchainInt; use crate::sub_lib::wallet::Wallet; use futures::Future; +use itertools::Either; use masq_lib::blockchains::chains::Chain; -use web3::types::Address; use masq_lib::logger::Logger; -use crate::accountant::db_access_objects::payable_dao::PayableAccount; -use crate::blockchain::blockchain_bridge::{BlockMarker, BlockScanRange, PendingPayableFingerprintSeeds}; -use crate::blockchain::blockchain_interface::blockchain_interface_web3::lower_level_interface_web3::TransactionReceiptResult; +use std::collections::BTreeMap; +use web3::types::Address; pub trait BlockchainInterface { fn contract_address(&self) -> Address; @@ -31,25 +37,29 @@ pub trait BlockchainInterface { start_block: BlockMarker, scan_range: BlockScanRange, recipient: Address, - ) -> Box>; + ) -> Box>; - fn build_blockchain_agent( + fn introduce_blockchain_agent( &self, consuming_wallet: Wallet, ) -> Box, Error = BlockchainAgentBuildError>>; fn process_transaction_receipts( &self, - transaction_hashes: Vec, - ) -> Box, Error = BlockchainError>>; + tx_hashes: Vec, + ) -> Box< + dyn Future< + Item = BTreeMap, + Error = BlockchainInterfaceError, + >, + >; fn submit_payables_in_batch( &self, logger: Logger, agent: Box, - fingerprints_recipient: Recipient, - affordable_accounts: Vec, - ) -> Box, Error = PayableTransactionError>>; + priced_templates: Either, + ) -> Box>; as_any_ref_in_trait!(); } diff --git a/node/src/blockchain/blockchain_interface_initializer.rs b/node/src/blockchain/blockchain_interface_initializer.rs index 1527b5e9f1..04838f3129 100644 --- a/node/src/blockchain/blockchain_interface_initializer.rs +++ b/node/src/blockchain/blockchain_interface_initializer.rs @@ -10,8 +10,9 @@ use web3::transports::Http; pub(in crate::blockchain) struct BlockchainInterfaceInitializer {} impl BlockchainInterfaceInitializer { - // TODO when we have multiple chains of fundamentally different architectures and are able to switch them, - // this should probably be replaced by a HashMap of distinct interfaces for each chain + // TODO if we ever have multiple chains of fundamentally different architectures and are able + // to switch them, this should probably be replaced by a HashMap of distinct interfaces for + // each chain pub fn initialize_interface( &self, blockchain_service_url: &str, @@ -43,24 +44,25 @@ impl BlockchainInterfaceInitializer { #[cfg(test)] mod tests { + use crate::accountant::scanners::payable_scanner::tx_templates::initial::new::NewTxTemplates; + use crate::accountant::scanners::payable_scanner::tx_templates::priced::new::PricedNewTxTemplates; + use crate::accountant::test_utils::make_payable_account; + use crate::blockchain::blockchain_bridge::increase_gas_price_by_margin; use crate::blockchain::blockchain_interface_initializer::BlockchainInterfaceInitializer; - use masq_lib::blockchains::chains::Chain; - - use futures::Future; - use std::net::Ipv4Addr; - use web3::transports::Http; - - use crate::blockchain::blockchain_interface::blockchain_interface_web3::{ - BlockchainInterfaceWeb3, REQUESTS_IN_PARALLEL, - }; - use crate::blockchain::blockchain_interface::BlockchainInterface; use crate::test_utils::make_wallet; + use futures::Future; + use itertools::Either; + use masq_lib::blockchains::chains::Chain; use masq_lib::constants::DEFAULT_CHAIN; use masq_lib::test_utils::mock_blockchain_client_server::MBCSBuilder; use masq_lib::utils::find_free_port; + use std::net::Ipv4Addr; #[test] fn initialize_web3_interface_works() { + // TODO this test should definitely assert on the web3 requests sent to the server, + // that's the best way to verify that this interface belongs to the web3 architecture + // (This test amplifies the importance of GH-543) let port = find_free_port(); let _blockchain_client_server = MBCSBuilder::new(port) .ok_response("0x3B9ACA00".to_string(), 0) // gas_price = 10000000000 @@ -71,22 +73,30 @@ mod tests { ) .ok_response("0x23".to_string(), 1) .start(); - let wallet = make_wallet("123"); let chain = Chain::PolyMainnet; let server_url = &format!("http://{}:{}", &Ipv4Addr::LOCALHOST, port); - let (event_loop_handle, transport) = - Http::with_max_parallel(server_url, REQUESTS_IN_PARALLEL).unwrap(); - let subject = BlockchainInterfaceWeb3::new(transport, event_loop_handle, chain); - let blockchain_agent = subject - .build_blockchain_agent(wallet.clone()) + let result = BlockchainInterfaceInitializer {}.initialize_interface(server_url, chain); + + let account_1 = make_payable_account(12); + let account_2 = make_payable_account(34); + let tx_templates = NewTxTemplates::from(&vec![account_1.clone(), account_2.clone()]); + let payable_wallet = make_wallet("payable"); + let blockchain_agent = result + .introduce_blockchain_agent(payable_wallet.clone()) .wait() .unwrap(); - - assert_eq!(blockchain_agent.consuming_wallet(), &wallet); + assert_eq!(blockchain_agent.consuming_wallet(), &payable_wallet); + let result = blockchain_agent.price_qualified_payables(Either::Left(tx_templates.clone())); + let gas_price_with_margin = increase_gas_price_by_margin(1_000_000_000); + let expected_result = Either::Left(PricedNewTxTemplates::new( + tx_templates, + gas_price_with_margin, + )); + assert_eq!(result, expected_result); assert_eq!( - blockchain_agent.agreed_fee_per_computation_unit(), - 1_000_000_000 + blockchain_agent.estimate_transaction_fee_total(&result), + 190_652_800_000_000 ); } diff --git a/node/src/blockchain/errors/internal_errors.rs b/node/src/blockchain/errors/internal_errors.rs new file mode 100644 index 0000000000..5375194808 --- /dev/null +++ b/node/src/blockchain/errors/internal_errors.rs @@ -0,0 +1,51 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. + +use serde_derive::{Deserialize, Serialize}; + +#[derive(Debug, PartialEq, Clone, Eq)] +pub enum InternalError { + PendingTooLongNotReplaced, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)] +pub enum InternalErrorKind { + PendingTooLongNotReplaced, +} + +impl From<&InternalError> for InternalErrorKind { + fn from(error: &InternalError) -> Self { + match error { + InternalError::PendingTooLongNotReplaced => { + InternalErrorKind::PendingTooLongNotReplaced + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn conversion_between_internal_error_and_internal_error_kind_works() { + assert_eq!( + InternalErrorKind::from(&InternalError::PendingTooLongNotReplaced), + InternalErrorKind::PendingTooLongNotReplaced + ); + } + + #[test] + fn app_rpc_error_kind_serialization_deserialization() { + let errors = vec![InternalErrorKind::PendingTooLongNotReplaced]; + + errors.into_iter().for_each(|error| { + let serialized = serde_json::to_string(&error).unwrap(); + let deserialized: InternalErrorKind = serde_json::from_str(&serialized).unwrap(); + assert_eq!( + error, deserialized, + "Failed serde attempt for {:?} that should look like {:?}", + deserialized, error + ); + }); + } +} diff --git a/node/src/blockchain/errors/mod.rs b/node/src/blockchain/errors/mod.rs new file mode 100644 index 0000000000..b6d1af1117 --- /dev/null +++ b/node/src/blockchain/errors/mod.rs @@ -0,0 +1,49 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. + +use crate::blockchain::errors::internal_errors::{InternalError, InternalErrorKind}; +use crate::blockchain::errors::rpc_errors::{AppRpcError, AppRpcErrorKind}; +use serde_derive::{Deserialize, Serialize}; + +pub mod internal_errors; +pub mod rpc_errors; +pub mod validation_status; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BlockchainError { + AppRpc(AppRpcError), + Internal(InternalError), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum BlockchainErrorKind { + AppRpc(AppRpcErrorKind), + Internal(InternalErrorKind), +} + +#[cfg(test)] +mod tests { + use crate::blockchain::errors::internal_errors::InternalErrorKind; + use crate::blockchain::errors::rpc_errors::{AppRpcErrorKind, LocalErrorKind}; + use crate::blockchain::errors::BlockchainErrorKind; + + #[test] + fn blockchain_error_serialization_deserialization() { + vec![ + ( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Decoder)), + r#"{"AppRpc":{"Local":"Decoder"}}"#, + ), + ( + BlockchainErrorKind::Internal(InternalErrorKind::PendingTooLongNotReplaced), + r#"{"Internal":"PendingTooLongNotReplaced"}"#, + ), + ] + .into_iter() + .for_each(|(err, expected_json)| { + let json = serde_json::to_string(&err).unwrap(); + assert_eq!(json, expected_json); + let deserialized_err = serde_json::from_str::(&json).unwrap(); + assert_eq!(deserialized_err, err); + }) + } +} diff --git a/node/src/blockchain/errors/rpc_errors.rs b/node/src/blockchain/errors/rpc_errors.rs new file mode 100644 index 0000000000..41d9d38632 --- /dev/null +++ b/node/src/blockchain/errors/rpc_errors.rs @@ -0,0 +1,221 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. + +use serde_derive::{Deserialize, Serialize}; +use web3::error::Error as Web3Error; + +// Prefixed with App to clearly distinguish app-specific errors from library errors. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)] +pub enum AppRpcError { + Local(LocalError), + Remote(RemoteError), +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)] +pub enum LocalError { + Decoder(String), + Internal, + IO(String), + Signing(String), + Transport(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)] +pub enum RemoteError { + InvalidResponse(String), + Unreachable, + Web3RpcError { code: i64, message: String }, +} + +// EVM based errors +impl From for AppRpcError { + fn from(error: Web3Error) -> Self { + match error { + // Local Errors + Web3Error::Decoder(error) => AppRpcError::Local(LocalError::Decoder(error)), + Web3Error::Internal => AppRpcError::Local(LocalError::Internal), + Web3Error::Io(error) => AppRpcError::Local(LocalError::IO(error.to_string())), + Web3Error::Signing(error) => { + // This variant cannot be tested due to import limitations. + AppRpcError::Local(LocalError::Signing(error.to_string())) + } + Web3Error::Transport(error) => AppRpcError::Local(LocalError::Transport(error)), + + // Api Errors + Web3Error::InvalidResponse(response) => { + AppRpcError::Remote(RemoteError::InvalidResponse(response)) + } + Web3Error::Rpc(web3_rpc_error) => AppRpcError::Remote(RemoteError::Web3RpcError { + code: web3_rpc_error.code.code(), + message: web3_rpc_error.message, + }), + Web3Error::Unreachable => AppRpcError::Remote(RemoteError::Unreachable), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)] +pub enum AppRpcErrorKind { + Local(LocalErrorKind), + Remote(RemoteErrorKind), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)] +pub enum LocalErrorKind { + Decoder, + Internal, + Io, + Signing, + Transport, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)] +pub enum RemoteErrorKind { + InvalidResponse, + Unreachable, + Web3RpcError(i64), // Keep only the stable error code +} + +impl From<&AppRpcError> for AppRpcErrorKind { + fn from(err: &AppRpcError) -> Self { + match err { + AppRpcError::Local(local) => match local { + LocalError::Decoder(_) => Self::Local(LocalErrorKind::Decoder), + LocalError::Internal => Self::Local(LocalErrorKind::Internal), + LocalError::IO(_) => Self::Local(LocalErrorKind::Io), + LocalError::Signing(_) => Self::Local(LocalErrorKind::Signing), + LocalError::Transport(_) => Self::Local(LocalErrorKind::Transport), + }, + AppRpcError::Remote(remote) => match remote { + RemoteError::InvalidResponse(_) => Self::Remote(RemoteErrorKind::InvalidResponse), + RemoteError::Unreachable => Self::Remote(RemoteErrorKind::Unreachable), + RemoteError::Web3RpcError { code, .. } => { + Self::Remote(RemoteErrorKind::Web3RpcError(*code)) + } + }, + } + } +} + +#[cfg(test)] +mod tests { + use crate::blockchain::errors::rpc_errors::{ + AppRpcError, AppRpcErrorKind, LocalError, LocalErrorKind, RemoteError, RemoteErrorKind, + }; + use web3::error::Error as Web3Error; + + #[test] + fn web3_error_to_failure_reason_conversion_works() { + // Local Errors + assert_eq!( + AppRpcError::from(Web3Error::Decoder("Decoder error".to_string())), + AppRpcError::Local(LocalError::Decoder("Decoder error".to_string())) + ); + assert_eq!( + AppRpcError::from(Web3Error::Internal), + AppRpcError::Local(LocalError::Internal) + ); + assert_eq!( + AppRpcError::from(Web3Error::Io(std::io::Error::new( + std::io::ErrorKind::Other, + "IO error" + ))), + AppRpcError::Local(LocalError::IO("IO error".to_string())) + ); + assert_eq!( + AppRpcError::from(Web3Error::Transport("Transport error".to_string())), + AppRpcError::Local(LocalError::Transport("Transport error".to_string())) + ); + + // Api Errors + assert_eq!( + AppRpcError::from(Web3Error::InvalidResponse("Invalid response".to_string())), + AppRpcError::Remote(RemoteError::InvalidResponse("Invalid response".to_string())) + ); + assert_eq!( + AppRpcError::from(Web3Error::Rpc(jsonrpc_core::types::error::Error { + code: jsonrpc_core::types::error::ErrorCode::ServerError(42), + message: "RPC error".to_string(), + data: None, + })), + AppRpcError::Remote(RemoteError::Web3RpcError { + code: 42, + message: "RPC error".to_string(), + }) + ); + assert_eq!( + AppRpcError::from(Web3Error::Unreachable), + AppRpcError::Remote(RemoteError::Unreachable) + ); + } + + #[test] + fn conversion_between_app_rpc_error_and_app_rpc_error_kind_works() { + assert_eq!( + AppRpcErrorKind::from(&AppRpcError::Local(LocalError::Decoder( + "Decoder error".to_string() + ))), + AppRpcErrorKind::Local(LocalErrorKind::Decoder) + ); + assert_eq!( + AppRpcErrorKind::from(&AppRpcError::Local(LocalError::Internal)), + AppRpcErrorKind::Local(LocalErrorKind::Internal) + ); + assert_eq!( + AppRpcErrorKind::from(&AppRpcError::Local(LocalError::IO("IO error".to_string()))), + AppRpcErrorKind::Local(LocalErrorKind::Io) + ); + assert_eq!( + AppRpcErrorKind::from(&AppRpcError::Local(LocalError::Signing( + "Signing error".to_string() + ))), + AppRpcErrorKind::Local(LocalErrorKind::Signing) + ); + assert_eq!( + AppRpcErrorKind::from(&AppRpcError::Local(LocalError::Transport( + "Transport error".to_string() + ))), + AppRpcErrorKind::Local(LocalErrorKind::Transport) + ); + assert_eq!( + AppRpcErrorKind::from(&AppRpcError::Remote(RemoteError::InvalidResponse( + "Invalid response".to_string() + ))), + AppRpcErrorKind::Remote(RemoteErrorKind::InvalidResponse) + ); + assert_eq!( + AppRpcErrorKind::from(&AppRpcError::Remote(RemoteError::Unreachable)), + AppRpcErrorKind::Remote(RemoteErrorKind::Unreachable) + ); + assert_eq!( + AppRpcErrorKind::from(&AppRpcError::Remote(RemoteError::Web3RpcError { + code: 55, + message: "Booga".to_string() + })), + AppRpcErrorKind::Remote(RemoteErrorKind::Web3RpcError(55)) + ); + } + + #[test] + fn app_rpc_error_kind_serialization_deserialization() { + let errors = vec![ + AppRpcErrorKind::Local(LocalErrorKind::Decoder), + AppRpcErrorKind::Local(LocalErrorKind::Internal), + AppRpcErrorKind::Local(LocalErrorKind::Io), + AppRpcErrorKind::Local(LocalErrorKind::Signing), + AppRpcErrorKind::Local(LocalErrorKind::Transport), + AppRpcErrorKind::Remote(RemoteErrorKind::InvalidResponse), + AppRpcErrorKind::Remote(RemoteErrorKind::Unreachable), + AppRpcErrorKind::Remote(RemoteErrorKind::Web3RpcError(42)), + ]; + + errors.into_iter().for_each(|error| { + let serialized = serde_json::to_string(&error).unwrap(); + let deserialized: AppRpcErrorKind = serde_json::from_str(&serialized).unwrap(); + assert_eq!( + error, deserialized, + "Failed serde attempt for {:?} that should look like {:?}", + deserialized, error + ); + }); + } +} diff --git a/node/src/blockchain/errors/validation_status.rs b/node/src/blockchain/errors/validation_status.rs new file mode 100644 index 0000000000..5fe46dc9a4 --- /dev/null +++ b/node/src/blockchain/errors/validation_status.rs @@ -0,0 +1,412 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. + +use crate::blockchain::errors::BlockchainErrorKind; +use masq_lib::simple_clock::SimpleClock; +use serde::de::{SeqAccess, Visitor}; +use serde::ser::SerializeSeq; +use serde::{ + Deserialize as ManualDeserialize, Deserializer, Serialize as ManualSerialize, Serializer, +}; +use serde_derive::{Deserialize, Serialize}; +use std::cmp::Ordering; +use std::collections::BTreeMap; +use std::fmt::Formatter; +use std::hash::Hash; +use std::time::SystemTime; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ValidationStatus { + Waiting, + Reattempting(PreviousAttempts), +} + +impl PartialOrd for ValidationStatus { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +// Manual impl of Ord for enums makes sense because the derive macro determines the ordering +// by the order of the enum variants in its declaration, not only alphabetically. Swiping +// the position of the variants makes a difference, which is counter-intuitive. Structs are not +// implemented the same way and are safe to be used with derive. +impl Ord for ValidationStatus { + fn cmp(&self, other: &Self) -> Ordering { + match (self, other) { + (ValidationStatus::Reattempting(..), ValidationStatus::Waiting) => Ordering::Less, + (ValidationStatus::Waiting, ValidationStatus::Reattempting(..)) => Ordering::Greater, + (ValidationStatus::Waiting, ValidationStatus::Waiting) => Ordering::Equal, + (ValidationStatus::Reattempting(prev1), ValidationStatus::Reattempting(prev2)) => { + prev1.cmp(prev2) + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct PreviousAttempts { + inner: BTreeMap, +} + +// had to implement it manually in an array JSON layout, as the original, default HashMap +// serialization threw errors because the values of keys were represented by nested enums that +// serde doesn't translate into a complex JSON value (unlike the plain string required for a key) +impl ManualSerialize for PreviousAttempts { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + #[derive(Serialize)] + struct Entry<'a> { + #[serde(rename = "error")] + error_kind: &'a BlockchainErrorKind, + #[serde(flatten)] + stats: &'a ErrorStats, + } + + let mut seq = serializer.serialize_seq(Some(self.inner.len()))?; + for (error_kind, stats) in self.inner.iter() { + seq.serialize_element(&Entry { error_kind, stats })?; + } + seq.end() + } +} + +impl<'de> ManualDeserialize<'de> for PreviousAttempts { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_seq(PreviousAttemptsVisitor) + } +} + +struct PreviousAttemptsVisitor; + +impl<'de> Visitor<'de> for PreviousAttemptsVisitor { + type Value = PreviousAttempts; + + fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result { + formatter.write_str("PreviousAttempts") + } + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + #[derive(Deserialize)] + struct EntryOwned { + #[serde(rename = "error")] + error_kind: BlockchainErrorKind, + #[serde(flatten)] + stats: ErrorStats, + } + + let mut error_stats_map: BTreeMap = btreemap!(); + while let Some(entry) = seq.next_element::()? { + error_stats_map.insert(entry.error_kind, entry.stats); + } + Ok(PreviousAttempts { + inner: error_stats_map, + }) + } +} + +impl PreviousAttempts { + pub fn new(error: BlockchainErrorKind, clock: &dyn SimpleClock) -> Self { + Self { + inner: btreemap!(error => ErrorStats::now(clock)), + } + } + + pub fn add_attempt(mut self, error: BlockchainErrorKind, clock: &dyn SimpleClock) -> Self { + self.inner + .entry(error) + .and_modify(|stats| stats.increment()) + .or_insert_with(|| ErrorStats::now(clock)); + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct ErrorStats { + #[serde(rename = "firstSeen")] + pub first_seen: SystemTime, + pub attempts: u16, +} + +impl ErrorStats { + pub fn now(clock: &dyn SimpleClock) -> Self { + Self { + first_seen: clock.now(), + attempts: 1, + } + } + + pub fn increment(&mut self) { + self.attempts += 1; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::blockchain::errors::internal_errors::InternalErrorKind; + use crate::blockchain::errors::rpc_errors::{AppRpcErrorKind, LocalErrorKind}; + use crate::test_utils::serde_serializer_mock::{SerdeSerializerMock, SerializeSeqMock}; + use masq_lib::simple_clock::SimpleClockReal; + use masq_lib::test_utils::simple_clock::SimpleClockMock; + use serde::ser::Error as SerdeError; + use std::collections::BTreeSet; + use std::time::Duration; + use std::time::UNIX_EPOCH; + + #[test] + fn previous_attempts_and_validation_failure_clock_work_together_fine() { + let validation_failure_clock = SimpleClockReal::default(); + // new() + let timestamp_a = SystemTime::now(); + let subject = PreviousAttempts::new( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Decoder)), + &validation_failure_clock, + ); + // add_attempt() + let timestamp_b = SystemTime::now(); + let subject = subject.add_attempt( + BlockchainErrorKind::Internal(InternalErrorKind::PendingTooLongNotReplaced), + &validation_failure_clock, + ); + let timestamp_c = SystemTime::now(); + let subject = subject.add_attempt( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Io)), + &validation_failure_clock, + ); + let timestamp_d = SystemTime::now(); + let subject = subject.add_attempt( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Decoder)), + &validation_failure_clock, + ); + let subject = subject.add_attempt( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Io)), + &validation_failure_clock, + ); + + let decoder_error_stats = subject + .inner + .get(&BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local( + LocalErrorKind::Decoder, + ))) + .unwrap(); + assert!( + timestamp_a <= decoder_error_stats.first_seen + && decoder_error_stats.first_seen <= timestamp_b, + "Was expected from {:?} to {:?} but was {:?}", + timestamp_a, + timestamp_b, + decoder_error_stats.first_seen + ); + assert_eq!(decoder_error_stats.attempts, 2); + let internal_error_stats = subject + .inner + .get(&BlockchainErrorKind::Internal( + InternalErrorKind::PendingTooLongNotReplaced, + )) + .unwrap(); + assert!( + timestamp_b <= internal_error_stats.first_seen + && internal_error_stats.first_seen <= timestamp_c, + "Was expected from {:?} to {:?} but was {:?}", + timestamp_b, + timestamp_c, + internal_error_stats.first_seen + ); + assert_eq!(internal_error_stats.attempts, 1); + let io_error_stats = subject + .inner + .get(&BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local( + LocalErrorKind::Io, + ))) + .unwrap(); + assert!( + timestamp_c <= io_error_stats.first_seen && io_error_stats.first_seen <= timestamp_d, + "Was expected from {:?} to {:?} but was {:?}", + timestamp_c, + timestamp_d, + io_error_stats.first_seen + ); + assert_eq!(io_error_stats.attempts, 2); + let other_error_stats = + subject + .inner + .get(&BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local( + LocalErrorKind::Signing, + ))); + assert_eq!(other_error_stats, None); + } + + #[test] + fn previous_attempts_ordering_works_correctly_with_mock() { + let now = SystemTime::now(); + let clock = SimpleClockMock::default() + .now_result(now) + .now_result(now + Duration::from_secs(1)) + .now_result(now + Duration::from_secs(2)) + .now_result(now + Duration::from_secs(3)); + let mut attempts1 = PreviousAttempts::new( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Decoder)), + &clock, + ); + attempts1 = attempts1.add_attempt( + BlockchainErrorKind::Internal(InternalErrorKind::PendingTooLongNotReplaced), + &clock, + ); + let mut attempts2 = PreviousAttempts::new( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Io)), + &clock, + ); + attempts2 = attempts2.add_attempt( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Signing)), + &clock, + ); + + assert_eq!(attempts2.partial_cmp(&attempts1), Some(Ordering::Greater)); + } + + #[test] + fn previous_attempts_custom_serialize_seq_happy_path() { + let err = BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Internal)); + let timestamp = UNIX_EPOCH + .checked_add(Duration::from_secs(1234567890)) + .unwrap(); + let clock = SimpleClockMock::default().now_result(timestamp); + + let result = serde_json::to_string(&PreviousAttempts::new(err, &clock)).unwrap(); + + assert_eq!( + result, + r#"[{"error":{"AppRpc":{"Local":"Internal"}},"firstSeen":{"secs_since_epoch":1234567890,"nanos_since_epoch":0},"attempts":1}]"# + ); + } + + #[test] + fn previous_attempts_custom_serialize_seq_initialization_err() { + let mock = SerdeSerializerMock::default() + .serialize_seq_result(Err(serde_json::Error::custom("lethally acid bobbles"))); + let err = BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Internal)); + let timestamp = UNIX_EPOCH + .checked_add(Duration::from_secs(1234567890)) + .unwrap(); + let clock = SimpleClockMock::default().now_result(timestamp); + + let result = PreviousAttempts::new(err, &clock).serialize(mock); + + assert_eq!(result.unwrap_err().to_string(), "lethally acid bobbles"); + } + + #[test] + fn previous_attempts_custom_serialize_seq_element_err() { + let mock = SerdeSerializerMock::default() + .serialize_seq_result(Ok(SerializeSeqMock::default().serialize_element_result( + Err(serde_json::Error::custom("jelly gummies gone off")), + ))); + let err = BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Internal)); + let timestamp = UNIX_EPOCH + .checked_add(Duration::from_secs(1234567890)) + .unwrap(); + let clock = SimpleClockMock::default().now_result(timestamp); + + let result = PreviousAttempts::new(err, &clock).serialize(mock); + + assert_eq!(result.unwrap_err().to_string(), "jelly gummies gone off"); + } + + #[test] + fn previous_attempts_custom_serialize_end_err() { + let mock = + SerdeSerializerMock::default().serialize_seq_result(Ok(SerializeSeqMock::default() + .serialize_element_result(Ok(())) + .end_result(Err(serde_json::Error::custom("funny belly ache"))))); + let err = BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Internal)); + let timestamp = UNIX_EPOCH + .checked_add(Duration::from_secs(1234567890)) + .unwrap(); + let clock = SimpleClockMock::default().now_result(timestamp); + + let result = PreviousAttempts::new(err, &clock).serialize(mock); + + assert_eq!(result.unwrap_err().to_string(), "funny belly ache"); + } + + #[test] + fn previous_attempts_custom_deserialize_happy_path() { + let str = r#"[{"error":{"AppRpc":{"Local":"Internal"}},"firstSeen":{"secs_since_epoch":1234567890,"nanos_since_epoch":0},"attempts":1}]"#; + + let result = serde_json::from_str::(str); + + let timestamp = UNIX_EPOCH + .checked_add(Duration::from_secs(1234567890)) + .unwrap(); + let clock = SimpleClockMock::default().now_result(timestamp); + assert_eq!( + result.unwrap().inner, + btreemap!(BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Internal)) => ErrorStats::now(&clock)) + ); + } + + #[test] + fn previous_attempts_custom_deserialize_sad_path() { + let str = + r#"[{"error":{"AppRpc":{"Local":"Internal"}},"firstSeen":"Yesterday","attempts":1}]"#; + + let result = serde_json::from_str::(str); + + assert_eq!( + result.unwrap_err().to_string(), + "invalid type: string \"Yesterday\", expected struct SystemTime at line 1 column 79" + ); + } + + #[test] + fn validation_status_ordering_works_correctly() { + let now = SystemTime::now(); + let clock = SimpleClockMock::default() + .now_result(now) + .now_result(now + Duration::from_secs(1)); + + let waiting = ValidationStatus::Waiting; + let reattempting_early = ValidationStatus::Reattempting(PreviousAttempts::new( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Decoder)), + &clock, + )); + let reattempting_late = ValidationStatus::Reattempting(PreviousAttempts::new( + BlockchainErrorKind::AppRpc(AppRpcErrorKind::Local(LocalErrorKind::Io)), + &clock, + )); + let waiting_identical = waiting.clone(); + let reattempting_early_identical = reattempting_early.clone(); + + let mut set = BTreeSet::new(); + vec![ + reattempting_early.clone(), + waiting.clone(), + reattempting_late.clone(), + waiting_identical.clone(), + reattempting_early_identical.clone(), + ] + .into_iter() + .for_each(|tx| { + set.insert(tx); + }); + + let expected_order = vec![ + reattempting_early.clone(), + reattempting_late, + waiting.clone(), + ]; + assert_eq!(set.into_iter().collect::>(), expected_order); + assert_eq!(waiting.cmp(&waiting_identical), Ordering::Equal); + assert_eq!( + reattempting_early.cmp(&reattempting_early_identical), + Ordering::Equal + ); + } +} diff --git a/node/src/blockchain/mod.rs b/node/src/blockchain/mod.rs index 4c51e726e2..f3ef3d3236 100644 --- a/node/src/blockchain/mod.rs +++ b/node/src/blockchain/mod.rs @@ -1,9 +1,11 @@ // Copyright (c) 2019, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. pub mod bip32; pub mod bip39; +pub mod blockchain_agent; pub mod blockchain_bridge; pub mod blockchain_interface; pub mod blockchain_interface_initializer; +pub mod errors; pub mod payer; pub mod signature; #[cfg(test)] diff --git a/node/src/blockchain/test_utils.rs b/node/src/blockchain/test_utils.rs index 4124e283ab..238703d981 100644 --- a/node/src/blockchain/test_utils.rs +++ b/node/src/blockchain/test_utils.rs @@ -16,7 +16,7 @@ use serde_derive::Deserialize; use std::fmt::Debug; use std::net::Ipv4Addr; use web3::transports::{EventLoopHandle, Http}; -use web3::types::{Index, Log, SignedTransaction, TransactionReceipt, H2048, U256}; +use web3::types::{Address, Index, Log, SignedTransaction, TransactionReceipt, H2048, U256}; lazy_static! { static ref BIG_MEANINGLESS_PHRASE: Vec<&'static str> = vec![ @@ -185,10 +185,31 @@ pub fn make_default_signed_transaction() -> SignedTransaction { } } -pub fn make_tx_hash(base: u32) -> H256 { +fn make_hash(base: u32) -> H256 { 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 make_address(base: u32) -> Address { + let base = base % 0xfff; + let value = U256::from(base * 3); + let shifted = value << 72; + let value = U256::from(value) << 24; + let value = value | shifted; + let mut full_bytes = [0u8; 32]; + value.to_big_endian(&mut full_bytes); + let mut bytes = [0u8; 20]; + bytes.copy_from_slice(&full_bytes[12..]); + H160(bytes) +} + pub fn all_chains() -> [Chain; 4] { [ Chain::EthMainnet, @@ -217,3 +238,52 @@ pub fn transport_error_message() -> String { "Connection refused".to_string() } } + +pub struct TransactionReceiptBuilder { + status_opt: Option, + block_hash_opt: Option, + block_number_opt: Option, + transaction_hash: H256, +} + +impl TransactionReceiptBuilder { + pub fn new(transaction_hash: H256) -> Self { + Self { + status_opt: None, + block_hash_opt: None, + block_number_opt: None, + transaction_hash, + } + } + + pub fn status(mut self, status: U64) -> Self { + self.status_opt = Some(status); + self + } + + pub fn block_hash(mut self, block_hash: H256) -> Self { + self.block_hash_opt = Some(block_hash); + self + } + + pub fn block_number(mut self, block_number: U64) -> Self { + self.block_number_opt = Some(block_number); + self + } + + pub fn build(self) -> TransactionReceipt { + TransactionReceipt { + status: self.status_opt, + root: None, + block_hash: self.block_hash_opt, + block_number: self.block_number_opt, + cumulative_gas_used: Default::default(), + gas_used: None, + contract_address: None, + transaction_hash: self.transaction_hash, + transaction_index: Default::default(), + logs: vec![], + logs_bloom: Default::default(), + } + } +} diff --git a/node/src/bootstrapper.rs b/node/src/bootstrapper.rs index 688d143368..d0b92755c6 100644 --- a/node/src/bootstrapper.rs +++ b/node/src/bootstrapper.rs @@ -350,7 +350,7 @@ pub struct BootstrapperConfig { pub log_level: LevelFilter, pub dns_servers: Vec, pub scan_intervals_opt: Option, - pub suppress_initial_scans: bool, + pub automatic_scans_enabled: bool, pub when_pending_too_long_sec: u64, pub crash_point: CrashPoint, pub clandestine_discriminator_factories: Vec>, @@ -385,7 +385,7 @@ impl BootstrapperConfig { log_level: LevelFilter::Off, dns_servers: vec![], scan_intervals_opt: None, - suppress_initial_scans: false, + automatic_scans_enabled: true, crash_point: CrashPoint::None, clandestine_discriminator_factories: vec![], ui_gateway_config: UiGatewayConfig { @@ -434,7 +434,7 @@ impl BootstrapperConfig { self.cryptde_pair = unprivileged.cryptde_pair; self.db_password_opt = unprivileged.db_password_opt; self.scan_intervals_opt = unprivileged.scan_intervals_opt; - self.suppress_initial_scans = unprivileged.suppress_initial_scans; + self.automatic_scans_enabled = unprivileged.automatic_scans_enabled; self.payment_thresholds_opt = unprivileged.payment_thresholds_opt; self.when_pending_too_long_sec = unprivileged.when_pending_too_long_sec; } @@ -1197,6 +1197,7 @@ mod tests { vec![SocketAddr::new(IpAddr::from_str("1.2.3.4").unwrap(), 1111)]; let mut unprivileged_config = BootstrapperConfig::new(); //values from unprivileged config + let chain = unprivileged_config.blockchain_bridge_config.chain; let gas_price = 123; let blockchain_url_opt = Some("some.service@earth.abc".to_string()); let clandestine_port_opt = Some(44444); @@ -1216,8 +1217,8 @@ mod tests { unprivileged_config.earning_wallet = earning_wallet.clone(); unprivileged_config.consuming_wallet_opt = consuming_wallet_opt.clone(); unprivileged_config.db_password_opt = db_password_opt.clone(); - unprivileged_config.scan_intervals_opt = Some(ScanIntervals::default()); - unprivileged_config.suppress_initial_scans = false; + unprivileged_config.scan_intervals_opt = Some(ScanIntervals::compute_default(chain)); + unprivileged_config.automatic_scans_enabled = true; unprivileged_config.when_pending_too_long_sec = DEFAULT_PENDING_TOO_LONG_SEC; privileged_config.merge_unprivileged(unprivileged_config); @@ -1240,9 +1241,9 @@ mod tests { assert_eq!(privileged_config.db_password_opt, db_password_opt); assert_eq!( privileged_config.scan_intervals_opt, - Some(ScanIntervals::default()) + Some(ScanIntervals::compute_default(chain)) ); - assert_eq!(privileged_config.suppress_initial_scans, false); + assert_eq!(privileged_config.automatic_scans_enabled, true); assert_eq!( privileged_config.when_pending_too_long_sec, DEFAULT_PENDING_TOO_LONG_SEC diff --git a/node/src/daemon/setup_reporter.rs b/node/src/daemon/setup_reporter.rs index a7448eba44..dd1e2182a3 100644 --- a/node/src/daemon/setup_reporter.rs +++ b/node/src/daemon/setup_reporter.rs @@ -21,7 +21,6 @@ use crate::node_configurator::{ data_directory_from_context, determine_user_specific_data, DirsWrapper, DirsWrapperReal, }; use crate::sub_lib::accountant::PaymentThresholds as PaymentThresholdsFromAccountant; -use crate::sub_lib::accountant::DEFAULT_SCAN_INTERVALS; use crate::sub_lib::cryptde::CryptDE; use crate::sub_lib::cryptde_real::CryptDEReal; use crate::sub_lib::neighborhood::NodeDescriptor; @@ -1104,12 +1103,16 @@ impl ValueRetriever for ScanIntervals { fn computed_default( &self, - _bootstrapper_config: &BootstrapperConfig, + bootstrapper_config: &BootstrapperConfig, pc: &dyn PersistentConfiguration, _db_password_opt: &Option, ) -> Option<(String, UiSetupResponseValueStatus)> { let pc_value = pc.scan_intervals().expectv("scan-intervals"); - payment_thresholds_rate_pack_and_scan_intervals(pc_value, *DEFAULT_SCAN_INTERVALS) + let chain = bootstrapper_config.blockchain_bridge_config.chain; + payment_thresholds_rate_pack_and_scan_intervals( + pc_value, + crate::sub_lib::accountant::ScanIntervals::compute_default(chain), + ) } fn is_required(&self, _params: &SetupCluster) -> bool { @@ -1229,7 +1232,9 @@ mod tests { use crate::daemon::dns_inspector::dns_inspector::DnsInspector; use crate::daemon::dns_inspector::DnsInspectionError; use crate::daemon::setup_reporter; - use crate::database::db_initializer::{DbInitializer, DbInitializerReal, DATABASE_FILE}; + use crate::database::db_initializer::{ + DbInitializer, DbInitializerReal, InitializationMode, DATABASE_FILE, + }; use crate::database::rusqlite_wrappers::ConnectionWrapperReal; use crate::db_config::config_dao::{ConfigDao, ConfigDaoReal}; use crate::db_config::persistent_configuration::{ @@ -1251,6 +1256,7 @@ mod tests { use crate::test_utils::unshared_test_utils::{ make_persistent_config_real_with_config_dao_null, make_pre_populated_mocked_directory_wrapper, make_simplified_multi_config, + TEST_SCAN_INTERVALS, }; use crate::test_utils::{assert_string_contains, rate_pack}; use core::option::Option; @@ -1357,15 +1363,19 @@ mod tests { "setup_reporter", "get_modified_setup_database_populated_only_requireds_set", ); + let chain = DEFAULT_CHAIN; + let mut init_config = DbInitializationConfig::test_default(); + if let InitializationMode::CreationAndMigration { external_data } = &mut init_config.mode { + external_data.chain = chain + } else { + panic!("unexpected initialization mode"); + } let data_dir = home_dir.join("data_dir"); - let chain_specific_data_dir = data_dir.join(DEFAULT_CHAIN.rec().literal_identifier); + let chain_specific_data_dir = data_dir.join(chain.rec().literal_identifier); create_dir_all(&chain_specific_data_dir).unwrap(); let db_initializer = DbInitializerReal::default(); let conn = db_initializer - .initialize( - &chain_specific_data_dir, - DbInitializationConfig::test_default(), - ) + .initialize(&chain_specific_data_dir, init_config) .unwrap(); let mut config = PersistentConfigurationReal::from(conn); config.change_password(None, "password").unwrap(); @@ -1470,7 +1480,7 @@ mod tests { ), ( "scan-intervals", - &DEFAULT_SCAN_INTERVALS.to_string(), + &accountant::ScanIntervals::compute_default(chain).to_string(), Default, ), ("scans", "on", Default), @@ -3438,6 +3448,7 @@ mod tests { fn rate_pack_computed_default_when_persistent_config_like_default() { assert_computed_default_when_persistent_config_like_default( &RatePack {}, + None, DEFAULT_RATE_PACK.to_string(), ) } @@ -3517,19 +3528,26 @@ mod tests { #[test] fn scan_intervals_computed_default_when_persistent_config_like_default() { + let chain = DEFAULT_CHAIN; + let mut bootstrapper_config = BootstrapperConfig::new(); + bootstrapper_config.blockchain_bridge_config.chain = chain; assert_computed_default_when_persistent_config_like_default( &ScanIntervals {}, - *DEFAULT_SCAN_INTERVALS, + Some(bootstrapper_config), + accountant::ScanIntervals::compute_default(chain), ) } #[test] fn scan_intervals_computed_default_persistent_config_unequal_to_default() { - let mut scan_intervals = *DEFAULT_SCAN_INTERVALS; - scan_intervals.pending_payable_scan_interval = scan_intervals - .pending_payable_scan_interval + let mut scan_intervals = *TEST_SCAN_INTERVALS; + scan_intervals.payable_scan_interval = scan_intervals + .payable_scan_interval .add(Duration::from_secs(15)); scan_intervals.pending_payable_scan_interval = scan_intervals + .pending_payable_scan_interval + .add(Duration::from_secs(20)); + scan_intervals.receivable_scan_interval = scan_intervals .receivable_scan_interval .sub(Duration::from_secs(33)); @@ -3546,6 +3564,7 @@ mod tests { fn payment_thresholds_computed_default_when_persistent_config_like_default() { assert_computed_default_when_persistent_config_like_default( &PaymentThresholds {}, + None, DEFAULT_PAYMENT_THRESHOLDS.to_string(), ) } @@ -3568,12 +3587,13 @@ mod tests { fn assert_computed_default_when_persistent_config_like_default( subject: &dyn ValueRetriever, + bootstrapper_config_opt: Option, default: T, ) where T: Display + PartialEq, { - let mut bootstrapper_config = BootstrapperConfig::new(); - //the rate_pack within the mode setting does not determine the result, so I just set a nonsense + let mut bootstrapper_config = bootstrapper_config_opt.unwrap_or(BootstrapperConfig::new()); + //the rate_pack within the mode setting does not affect the result, so I set nonsense bootstrapper_config.neighborhood_config.mode = NeighborhoodModeEnum::OriginateOnly(vec![], rate_pack(0)); let persistent_config = diff --git a/node/src/database/config_dumper.rs b/node/src/database/config_dumper.rs index 90ecc119e9..6ad888208e 100644 --- a/node/src/database/config_dumper.rs +++ b/node/src/database/config_dumper.rs @@ -169,7 +169,8 @@ mod tests { use crate::db_config::typed_config_layer::encode_bytes; use crate::node_configurator::DirsWrapperReal; use crate::node_test_utils::DirsWrapperMock; - use crate::sub_lib::accountant::{DEFAULT_PAYMENT_THRESHOLDS, DEFAULT_SCAN_INTERVALS}; + use crate::sub_lib::accountant; + use crate::sub_lib::accountant::DEFAULT_PAYMENT_THRESHOLDS; use crate::sub_lib::cryptde::PlainData; use crate::sub_lib::neighborhood::{NodeDescriptor, DEFAULT_RATE_PACK}; use crate::test_utils::database_utils::bring_db_0_back_to_life_and_return_connection; @@ -331,6 +332,7 @@ mod tests { .initialize(&database_path, DbInitializationConfig::panic_on_migration()) .unwrap(); let dao = ConfigDaoReal::new(conn); + let chain = DEFAULT_CHAIN; assert_value("blockchainServiceUrl", "https://infura.io/ID", &map); assert_value("clandestinePort", "3456", &map); assert_encrypted_value( @@ -344,7 +346,7 @@ mod tests { "0x0123456789012345678901234567890123456789", &map, ); - assert_value("chainName", DEFAULT_CHAIN.rec().literal_identifier, &map); + assert_value("chainName", chain.rec().literal_identifier, &map); assert_value("gasPrice", "1", &map); assert_value( "pastNeighbors", @@ -365,8 +367,12 @@ mod tests { &map, ); assert_value("ratePack", &DEFAULT_RATE_PACK.to_string(), &map); - assert_value("scanIntervals", &DEFAULT_SCAN_INTERVALS.to_string(), &map); - assert!(output.ends_with("\n}\n")) //asserting that there is a blank line at the end + assert_value( + "scanIntervals", + &accountant::ScanIntervals::compute_default(chain).to_string(), + &map, + ); + assert!(output.ends_with("\n}\n")) // To assert a blank line at the end } #[test] @@ -510,7 +516,11 @@ mod tests { &map, ); assert_value("ratePack", &DEFAULT_RATE_PACK.to_string(), &map); - assert_value("scanIntervals", &DEFAULT_SCAN_INTERVALS.to_string(), &map); + assert_value( + "scanIntervals", + &accountant::ScanIntervals::compute_default(Chain::PolyMainnet).to_string(), + &map, + ); } #[test] @@ -586,6 +596,7 @@ mod tests { .initialize(&data_dir, DbInitializationConfig::panic_on_migration()) .unwrap(); let dao = Box::new(ConfigDaoReal::new(conn)); + let chain = Chain::PolyMainnet; assert_value("blockchainServiceUrl", "https://infura.io/ID", &map); assert_value("clandestinePort", "3456", &map); assert_encrypted_value( @@ -599,11 +610,7 @@ mod tests { "0x0123456789012345678901234567890123456789", &map, ); - assert_value( - "chainName", - Chain::PolyMainnet.rec().literal_identifier, - &map, - ); + assert_value("chainName", chain.rec().literal_identifier, &map); assert_value("gasPrice", "1", &map); assert_value( "pastNeighbors", @@ -624,7 +631,11 @@ mod tests { &map, ); assert_value("ratePack", &DEFAULT_RATE_PACK.to_string(), &map); - assert_value("scanIntervals", &DEFAULT_SCAN_INTERVALS.to_string(), &map); + assert_value( + "scanIntervals", + &accountant::ScanIntervals::compute_default(chain).to_string(), + &map, + ); } #[test] diff --git a/node/src/database/db_initializer.rs b/node/src/database/db_initializer.rs index cd5c5b1bb5..906d6673a9 100644 --- a/node/src/database/db_initializer.rs +++ b/node/src/database/db_initializer.rs @@ -4,7 +4,8 @@ use crate::database::rusqlite_wrappers::{ConnectionWrapper, ConnectionWrapperRea use crate::database::db_migrations::db_migrator::{DbMigrator, DbMigratorReal}; use crate::db_config::secure_config_layer::EXAMPLE_ENCRYPTED; use crate::neighborhood::DEFAULT_MIN_HOPS; -use crate::sub_lib::accountant::{DEFAULT_PAYMENT_THRESHOLDS, DEFAULT_SCAN_INTERVALS}; +use crate::sub_lib::accountant; +use crate::sub_lib::accountant::DEFAULT_PAYMENT_THRESHOLDS; use crate::sub_lib::neighborhood::DEFAULT_RATE_PACK; use crate::sub_lib::utils::db_connection_launch_panic; use masq_lib::blockchains::chains::Chain; @@ -135,7 +136,8 @@ impl DbInitializerReal { Self::create_config_table(conn); Self::initialize_config(conn, external_params); Self::create_payable_table(conn); - Self::create_pending_payable_table(conn); + Self::create_sent_payable_table(conn); + Self::create_failed_payable_table(conn); Self::create_receivable_table(conn); Self::create_banned_table(conn); } @@ -259,32 +261,62 @@ impl DbInitializerReal { Self::set_config_value( conn, "scan_intervals", - Some(&DEFAULT_SCAN_INTERVALS.to_string()), + Some(&accountant::ScanIntervals::compute_default(external_params.chain).to_string()), false, "scan intervals", ); Self::set_config_value(conn, "max_block_count", None, false, "maximum block count"); } - pub fn create_pending_payable_table(conn: &Connection) { + pub fn create_sent_payable_table(conn: &Connection) { conn.execute( - "create table if not exists pending_payable ( - rowid integer primary key, - transaction_hash text not null, - amount_high_b integer not null, - amount_low_b integer not null, - payable_timestamp integer not null, - attempt integer not null, - process_error text null + "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_high_b integer not null, + gas_price_wei_low_b integer not null, + nonce integer not null, + status text not null )", [], ) - .expect("Can't create pending_payable table"); + .expect("Can't create sent_payable table"); + conn.execute( - "CREATE UNIQUE INDEX pending_payable_hash_idx ON pending_payable (transaction_hash)", + "CREATE UNIQUE INDEX sent_payable_tx_hash_idx ON sent_payable (tx_hash)", [], ) - .expect("Can't create transaction hash index in pending payments"); + .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, + status text 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_payable_table(conn: &Connection) { @@ -629,6 +661,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_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, @@ -661,7 +696,7 @@ mod tests { #[test] fn constants_have_correct_values() { assert_eq!(DATABASE_FILE, "node-data.db"); - assert_eq!(CURRENT_SCHEMA_VERSION, 11); + assert_eq!(CURRENT_SCHEMA_VERSION, 12); } #[test] @@ -679,7 +714,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"], @@ -690,34 +725,84 @@ mod tests { } #[test] - fn db_initialize_creates_pending_payable_table() { + fn db_initialize_creates_sent_payable_table() { let home_dir = ensure_node_home_directory_does_not_exist( "db_initializer", - "db_initialize_creates_pending_payable_table", + "db_initialize_creates_sent_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, + status + FROM sent_payable", + ) + .unwrap(); + let result = stmt.execute([]).unwrap(); + assert_eq!(result, 1); + assert_create_table_stm_contains_all_parts( + &*conn, + "sent_payable", + SQL_ATTRIBUTES_FOR_CREATING_SENT_PAYABLE, + ); + let expected_key_words: &[&[&str]] = &[&["tx_hash"]]; + assert_index_stm_is_coupled_with_right_parameter( + conn.as_ref(), + "sent_payable_tx_hash_idx", + expected_key_words, + ) + } - 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 expected_key_words: &[&[&str]] = &[ - &["rowid", "integer", "primary", "key"], - &["transaction_hash", "text", "not", "null"], - &["amount_high_b", "integer", "not", "null"], - &["amount_low_b", "integer", "not", "null"], - &["payable_timestamp", "integer", "not", "null"], - &["attempt", "integer", "not", "null"], - &["process_error", "text", "null"], - ]; - assert_create_table_stm_contains_all_parts(&*conn, "pending_payable", expected_key_words); - let expected_key_words: &[&[&str]] = &[&["transaction_hash"]]; + #[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, + status + 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(), - "pending_payable_hash_idx", + "failed_payable_tx_hash_idx", expected_key_words, ) } @@ -734,9 +819,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"], @@ -762,10 +856,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"], @@ -791,8 +891,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") @@ -962,7 +1062,7 @@ mod tests { verify( &mut config_vec, "scan_intervals", - Some(&DEFAULT_SCAN_INTERVALS.to_string()), + Some(&accountant::ScanIntervals::compute_default(TEST_DEFAULT_CHAIN).to_string()), false, ); verify( @@ -1003,9 +1103,10 @@ mod tests { HashSet::from([ "config".to_string(), "payable".to_string(), - "pending_payable".to_string(), "receivable".to_string(), + "sent_payable".to_string(), "banned".to_string(), + "failed_payable".to_string() ]), ); let config_map = extract_configurations(&conn); @@ -1074,7 +1175,7 @@ mod tests { verify( &mut config_vec, "scan_intervals", - Some(&DEFAULT_SCAN_INTERVALS.to_string()), + Some(&accountant::ScanIntervals::compute_default(TEST_DEFAULT_CHAIN).to_string()), false, ); verify( diff --git a/node/src/database/db_migrations/db_migrator.rs b/node/src/database/db_migrations/db_migrator.rs index 369a78788f..f3a3252565 100644 --- a/node/src/database/db_migrations/db_migrator.rs +++ b/node/src/database/db_migrations/db_migrator.rs @@ -3,6 +3,7 @@ use crate::database::db_initializer::ExternalData; use crate::database::db_migrations::migrations::migration_0_to_1::Migrate_0_to_1; use crate::database::db_migrations::migrations::migration_10_to_11::Migrate_10_to_11; +use crate::database::db_migrations::migrations::migration_11_to_12::Migrate_11_to_12; use crate::database::db_migrations::migrations::migration_1_to_2::Migrate_1_to_2; use crate::database::db_migrations::migrations::migration_2_to_3::Migrate_2_to_3; use crate::database::db_migrations::migrations::migration_3_to_4::Migrate_3_to_4; @@ -82,6 +83,7 @@ impl DbMigratorReal { &Migrate_8_to_9, &Migrate_9_to_10, &Migrate_10_to_11, + &Migrate_11_to_12, ] } diff --git a/node/src/database/db_migrations/migrations/migration_11_to_12.rs b/node/src/database/db_migrations/migrations/migration_11_to_12.rs new file mode 100644 index 0000000000..30196cdddc --- /dev/null +++ b/node/src/database/db_migrations/migrations/migration_11_to_12.rs @@ -0,0 +1,111 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. +use crate::database::db_migrations::db_migrator::DatabaseMigration; +use crate::database::db_migrations::migrator_utils::DBMigDeclarator; + +#[allow(non_camel_case_types)] +pub struct Migrate_11_to_12; + +impl DatabaseMigration for Migrate_11_to_12 { + fn migrate<'a>( + &self, + declaration_utils: Box, + ) -> rusqlite::Result<()> { + 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_high_b integer not null, + gas_price_wei_low_b integer not null, + nonce integer not null, + status text not null + )"; + + 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, + status text not null + )"; + + let sql_statement_for_pending_payable = "drop table pending_payable"; + + declaration_utils.execute_upon_transaction(&[ + &sql_statement_for_sent_payable, + &sql_statement_for_failed_payable, + &sql_statement_for_pending_payable, + ]) + } + + fn old_version(&self) -> usize { + 11 + } +} + +#[cfg(test)] +mod tests { + use crate::database::db_initializer::{ + DbInitializationConfig, DbInitializer, DbInitializerReal, DATABASE_FILE, + }; + 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_does_not_exist, assert_table_exists, bring_db_0_back_to_life_and_return_connection, make_external_data}; + use masq_lib::test_utils::logging::{init_test_logging, TestLogHandler}; + use masq_lib::test_utils::utils::ensure_node_home_directory_exists; + use std::fs::create_dir_all; + + #[test] + fn migration_from_11_to_12_is_applied_correctly() { + init_test_logging(); + let dir_path = ensure_node_home_directory_exists( + "db_migrations", + "migration_from_11_to_12_is_properly_set", + ); + create_dir_all(&dir_path).unwrap(); + let db_path = dir_path.join(DATABASE_FILE); + let _ = bring_db_0_back_to_life_and_return_connection(&db_path); + let subject = DbInitializerReal::default(); + let _prev_connection = subject + .initialize_to_version( + &dir_path, + 11, + DbInitializationConfig::create_or_migrate(make_external_data()), + ) + .unwrap(); + + let connection = subject + .initialize_to_version( + &dir_path, + 12, + DbInitializationConfig::create_or_migrate(make_external_data()), + ) + .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, + ); + assert_table_does_not_exist(connection.as_ref(), "pending_payable"); + TestLogHandler::new().assert_logs_contain_in_order(vec![ + "DbMigrator: Database successfully migrated from version 11 to 12", + ]); + } +} diff --git a/node/src/database/db_migrations/migrations/migration_4_to_5.rs b/node/src/database/db_migrations/migrations/migration_4_to_5.rs index 06deb809fb..204ab49a56 100644 --- a/node/src/database/db_migrations/migrations/migration_4_to_5.rs +++ b/node/src/database/db_migrations/migrations/migration_4_to_5.rs @@ -76,7 +76,7 @@ impl DatabaseMigration for Migrate_4_to_5 { #[cfg(test)] mod tests { - use crate::accountant::db_access_objects::utils::{from_time_t, to_time_t}; + use crate::accountant::db_access_objects::utils::{from_unix_timestamp, to_unix_timestamp}; use crate::database::db_initializer::{ DbInitializationConfig, DbInitializer, DbInitializerReal, ExternalData, DATABASE_FILE, }; @@ -124,7 +124,7 @@ mod tests { None, &wallet_1, 113344, - from_time_t(250_000_000), + from_unix_timestamp(250_000_000), ); let config_table_before = fetch_all_from_config_table(conn.as_ref()); @@ -150,7 +150,7 @@ mod tests { conn: &dyn ConnectionWrapper, transaction_hash_opt: Option, wallet: &Wallet, - amount: i64, + amount_minor: i64, timestamp: SystemTime, ) { let hash_str = transaction_hash_opt @@ -159,8 +159,8 @@ mod tests { let mut stm = conn.prepare("insert into payable (wallet_address, balance, last_paid_timestamp, pending_payment_transaction) values (?,?,?,?)").unwrap(); let params: &[&dyn ToSql] = &[ &wallet, - &amount, - &to_time_t(timestamp), + &amount_minor, + &to_unix_timestamp(timestamp), if !hash_str.is_empty() { &hash_str } else { @@ -208,7 +208,7 @@ mod tests { Some(transaction_hash_2), &wallet_2, 1111111, - from_time_t(200_000_000), + from_unix_timestamp(200_000_000), ); let config_table_before = fetch_all_from_config_table(&conn); diff --git a/node/src/database/db_migrations/migrations/migration_5_to_6.rs b/node/src/database/db_migrations/migrations/migration_5_to_6.rs index a5f902cb96..b32e3b2d0b 100644 --- a/node/src/database/db_migrations/migrations/migration_5_to_6.rs +++ b/node/src/database/db_migrations/migrations/migration_5_to_6.rs @@ -2,8 +2,10 @@ use crate::database::db_migrations::db_migrator::DatabaseMigration; use crate::database::db_migrations::migrator_utils::DBMigDeclarator; -use crate::sub_lib::accountant::{DEFAULT_PAYMENT_THRESHOLDS, DEFAULT_SCAN_INTERVALS}; +use crate::sub_lib::accountant; +use crate::sub_lib::accountant::DEFAULT_PAYMENT_THRESHOLDS; use crate::sub_lib::neighborhood::DEFAULT_RATE_PACK; +use masq_lib::blockchains::chains::Chain; #[allow(non_camel_case_types)] pub struct Migrate_5_to_6; @@ -19,9 +21,18 @@ impl DatabaseMigration for Migrate_5_to_6 { ); let statement_2 = Self::make_initialization_statement("rate_pack", &DEFAULT_RATE_PACK.to_string()); + let tx = declaration_utils.transaction(); + let chain = tx + .prepare("SELECT value FROM config WHERE name = 'chain_name'") + .expect("internal error") + .query_row([], |row| { + let res_str = row.get::<_, String>(0); + res_str.map(|str| Chain::from(str.as_str())) + }) + .expect("failed to read the chain from db"); let statement_3 = Self::make_initialization_statement( "scan_intervals", - &DEFAULT_SCAN_INTERVALS.to_string(), + &accountant::ScanIntervals::compute_default(chain).to_string(), ); declaration_utils.execute_upon_transaction(&[&statement_1, &statement_2, &statement_3]) } @@ -45,11 +56,13 @@ mod tests { use crate::database::db_initializer::{ DbInitializationConfig, DbInitializer, DbInitializerReal, DATABASE_FILE, }; - use crate::sub_lib::accountant::{DEFAULT_PAYMENT_THRESHOLDS, DEFAULT_SCAN_INTERVALS}; + use crate::sub_lib::accountant; + use crate::sub_lib::accountant::DEFAULT_PAYMENT_THRESHOLDS; use crate::sub_lib::neighborhood::DEFAULT_RATE_PACK; use crate::test_utils::database_utils::{ bring_db_0_back_to_life_and_return_connection, make_external_data, retrieve_config_row, }; + use masq_lib::blockchains::chains::Chain; use masq_lib::test_utils::utils::ensure_node_home_directory_exists; #[test] @@ -59,15 +72,21 @@ mod tests { let db_path = dir_path.join(DATABASE_FILE); let _ = bring_db_0_back_to_life_and_return_connection(&db_path); let subject = DbInitializerReal::default(); - { - subject + let chain = { + let conn = subject .initialize_to_version( &dir_path, 5, DbInitializationConfig::create_or_migrate(make_external_data()), ) .unwrap(); - } + let chain = conn + .prepare("SELECT value FROM config WHERE name = 'chain_name'") + .unwrap() + .query_row([], |row| row.get::<_, String>(0)) + .unwrap(); + chain + }; let result = subject.initialize_to_version( &dir_path, @@ -88,7 +107,12 @@ mod tests { assert_eq!(encrypted, false); let (scan_intervals, encrypted) = retrieve_config_row(connection.as_ref(), "scan_intervals"); - assert_eq!(scan_intervals, Some(DEFAULT_SCAN_INTERVALS.to_string())); + assert_eq!( + scan_intervals, + Some( + accountant::ScanIntervals::compute_default(Chain::from(chain.as_str())).to_string() + ) + ); assert_eq!(encrypted, false); } } diff --git a/node/src/database/db_migrations/migrations/migration_8_to_9.rs b/node/src/database/db_migrations/migrations/migration_8_to_9.rs index 4bf95e9550..eb89ac0020 100644 --- a/node/src/database/db_migrations/migrations/migration_8_to_9.rs +++ b/node/src/database/db_migrations/migrations/migration_8_to_9.rs @@ -43,21 +43,22 @@ mod tests { let _ = bring_db_0_back_to_life_and_return_connection(&db_path); let subject = DbInitializerReal::default(); - let result = subject.initialize_to_version( - &dir_path, - 8, - DbInitializationConfig::create_or_migrate(make_external_data()), - ); - - assert!(result.is_ok()); + let _prev_connection = subject + .initialize_to_version( + &dir_path, + 8, + DbInitializationConfig::create_or_migrate(make_external_data()), + ) + .unwrap(); - let result = subject.initialize_to_version( - &dir_path, - 9, - DbInitializationConfig::create_or_migrate(make_external_data()), - ); + let connection = subject + .initialize_to_version( + &dir_path, + 9, + DbInitializationConfig::create_or_migrate(make_external_data()), + ) + .unwrap(); - let connection = result.unwrap(); let (mp_value, mp_encrypted) = retrieve_config_row(connection.as_ref(), "max_block_count"); let (cs_value, cs_encrypted) = retrieve_config_row(connection.as_ref(), "schema_version"); assert_eq!(mp_value, None); diff --git a/node/src/database/db_migrations/migrations/migration_9_to_10.rs b/node/src/database/db_migrations/migrations/migration_9_to_10.rs index 7622ef01f8..be240429a4 100644 --- a/node/src/database/db_migrations/migrations/migration_9_to_10.rs +++ b/node/src/database/db_migrations/migrations/migration_9_to_10.rs @@ -43,21 +43,22 @@ mod tests { let _ = bring_db_0_back_to_life_and_return_connection(&db_path); let subject = DbInitializerReal::default(); - let result = subject.initialize_to_version( - &dir_path, - 9, - DbInitializationConfig::create_or_migrate(make_external_data()), - ); - - assert!(result.is_ok()); + let _prev_connection = subject + .initialize_to_version( + &dir_path, + 9, + DbInitializationConfig::create_or_migrate(make_external_data()), + ) + .unwrap(); - let result = subject.initialize_to_version( - &dir_path, - 10, - DbInitializationConfig::create_or_migrate(make_external_data()), - ); + let connection = subject + .initialize_to_version( + &dir_path, + 10, + DbInitializationConfig::create_or_migrate(make_external_data()), + ) + .unwrap(); - let connection = result.unwrap(); let (mp_value, mp_encrypted) = retrieve_config_row(connection.as_ref(), "max_block_count"); let (cs_value, cs_encrypted) = retrieve_config_row(connection.as_ref(), "schema_version"); assert_eq!(mp_value, Some(100_000u64.to_string())); diff --git a/node/src/database/db_migrations/migrations/mod.rs b/node/src/database/db_migrations/migrations/mod.rs index 53b7b7bb67..1abcf911c4 100644 --- a/node/src/database/db_migrations/migrations/mod.rs +++ b/node/src/database/db_migrations/migrations/mod.rs @@ -1,7 +1,6 @@ // Copyright (c) 2019, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. pub mod migration_0_to_1; -pub mod migration_10_to_11; pub mod migration_1_to_2; pub mod migration_2_to_3; pub mod migration_3_to_4; @@ -11,3 +10,7 @@ pub mod migration_6_to_7; pub mod migration_7_to_8; pub mod migration_8_to_9; pub mod migration_9_to_10; +#[rustfmt::skip] +pub mod migration_10_to_11; +#[rustfmt::skip] +pub mod migration_11_to_12; diff --git a/node/src/database/rusqlite_wrappers.rs b/node/src/database/rusqlite_wrappers.rs index ec867482f7..2177a250b9 100644 --- a/node/src/database/rusqlite_wrappers.rs +++ b/node/src/database/rusqlite_wrappers.rs @@ -5,15 +5,15 @@ use crate::masq_lib::utils::ExpectValue; use rusqlite::{Connection, Error, Statement, ToSql, Transaction}; use std::fmt::Debug; -// We were challenged multiple times to device mocks for testing stubborn, hard to tame, data +// We were challenged multiple times to devise mocks for testing stubborn, hard to tame, data // structures from the 'rusqlite' library. After all, we've adopted two of them, the Connection, // that came first, and the Transaction to come much later. Of these, only the former complies // with the standard policy we follow for mock designs. // // The delay until the second one became a thing, even though we would've been glad having it -// on hand much earlier, was caused by vacuum of ideas on how we could create a mock of these +// on hand much earlier, was caused by a vacuum of ideas on how we could create a mock of these // parameters and have it accepted by the compiler. Passing a lot of time, we came up with a hybrid, -// at least. That said, it has costed us a considerably high price of giving up on simplicity. +// at least. That said, it has cost us a considerably high price of giving up on simplicity. // // The firmest blocker of the design has always rooted in a relationship of serialized lifetimes, // affecting each other, that has been so hard to maintain right. Yet the choices made @@ -74,12 +74,12 @@ impl ConnectionWrapperReal { } } -// Whole point of this outer wrapper, that is common to both the real and mock transactions, is to +// The whole point of this outer wrapper that is common to both the real and mock transactions is to // make a chance to deconstruct all components of a transaction in place. It plays a crucial role -// during the final commit. Note that an usual mock based on the direct use of a trait object +// during the final commit. Note that a usual mock based on the direct use of a trait object // cannot be consumed by any of its methods because of the Rust rules for trait objects. They say // clearly that we can access it via '&self', '&mut self' but not 'self'. However, to have a thing -// consume itself we need to be provided with the full ownership. +// consume itself, we need to be provided with the full ownership. // // Leaving remains of an already committed transaction around would expose us to a risk. Let's // imagine somebody trying to make use of it the second time, while the inner element providing diff --git a/node/src/database/test_utils/mod.rs b/node/src/database/test_utils/mod.rs index e8b9060f19..7b415b95b1 100644 --- a/node/src/database/test_utils/mod.rs +++ b/node/src/database/test_utils/mod.rs @@ -12,6 +12,33 @@ use std::fmt::Debug; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; +pub const SQL_ATTRIBUTES_FOR_CREATING_SENT_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"], + &["status", "text", "not", "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"], + &["status", "text", "not", "null"], +]; + #[derive(Debug, Default)] pub struct ConnectionWrapperMock<'conn> { prepare_params: Arc>>, diff --git a/node/src/database/test_utils/transaction_wrapper_mock.rs b/node/src/database/test_utils/transaction_wrapper_mock.rs index d0577c72f6..5b9a717e9e 100644 --- a/node/src/database/test_utils/transaction_wrapper_mock.rs +++ b/node/src/database/test_utils/transaction_wrapper_mock.rs @@ -137,7 +137,7 @@ impl TransactionInnerWrapper for TransactionInnerWrapperMock { // is to be formed. // With that said, we're relieved to have at least one working solution now. Speaking of the 'prepare' -// method, an error would be hardly needed because the production code simply unwraps the results by +// method, an error would hardly be needed because the production code simply unwraps the results by // using 'expect'. That is a function excluded from the requirement of writing tests for. // The 'Statement' produced by this method must be better understood. The 'prepare' method has @@ -199,12 +199,12 @@ impl SetupForProdCodeAndAlteredStmts { // necessary base. If the continuity is broken the later statement might not work. If // we record some changes on the transaction, other changes tried to be done from // a different connection might meet a different state of the database and thwart the - // efforts. (This behaviour probably depends on the global setup of the db). + // efforts. (This behavior probably depends on the global setup of the db). // // // Also imagine a 'Statement' that wouldn't cause an error whereupon any potential // rollback of this txn should best drag off both the prod code and altered statements - // all together, disappearing. If we did not use this txn some of the changes would stay. + // all together, disappearing. If we did not use this txn some changes would stay. { self.txn_bearing_prod_code_stmts_opt .as_ref() diff --git a/node/src/db_config/config_dao_null.rs b/node/src/db_config/config_dao_null.rs index fd7fb7e059..1c988caeaf 100644 --- a/node/src/db_config/config_dao_null.rs +++ b/node/src/db_config/config_dao_null.rs @@ -4,13 +4,13 @@ use crate::database::db_initializer::DbInitializerReal; use crate::database::rusqlite_wrappers::TransactionSafeWrapper; use crate::db_config::config_dao::{ConfigDao, ConfigDaoError, ConfigDaoRecord}; use crate::neighborhood::DEFAULT_MIN_HOPS; -use crate::sub_lib::accountant::{DEFAULT_PAYMENT_THRESHOLDS, DEFAULT_SCAN_INTERVALS}; +use crate::sub_lib::accountant; +use crate::sub_lib::accountant::DEFAULT_PAYMENT_THRESHOLDS; use crate::sub_lib::neighborhood::DEFAULT_RATE_PACK; use itertools::Itertools; use masq_lib::blockchains::chains::Chain; use masq_lib::constants::{CURRENT_SCHEMA_VERSION, DEFAULT_GAS_PRICE}; use std::collections::HashMap; - /* This class exists because the Daemon uses the same configuration code that the Node uses, and @@ -140,7 +140,10 @@ impl Default for ConfigDaoNull { ); data.insert( "scan_intervals".to_string(), - (Some(DEFAULT_SCAN_INTERVALS.to_string()), false), + ( + Some(accountant::ScanIntervals::compute_default(Chain::default()).to_string()), + false, + ), ); data.insert("max_block_count".to_string(), (None, false)); Self { data } @@ -208,7 +211,7 @@ mod tests { subject.get("scan_intervals").unwrap(), ConfigDaoRecord::new( "scan_intervals", - Some(&DEFAULT_SCAN_INTERVALS.to_string()), + Some(&accountant::ScanIntervals::compute_default(Chain::default()).to_string()), false ) ); diff --git a/node/src/db_config/persistent_configuration.rs b/node/src/db_config/persistent_configuration.rs index ba25999ccb..8567d78078 100644 --- a/node/src/db_config/persistent_configuration.rs +++ b/node/src/db_config/persistent_configuration.rs @@ -2288,10 +2288,10 @@ mod tests { fn scan_intervals_get_method_works() { persistent_config_plain_data_assertions_for_simple_get_method!( "scan_intervals", - "40|60|50", + "60|5|50", ScanIntervals { - pending_payable_scan_interval: Duration::from_secs(40), payable_scan_interval: Duration::from_secs(60), + pending_payable_scan_interval: Duration::from_secs(5), receivable_scan_interval: Duration::from_secs(50), } ); diff --git a/node/src/hopper/routing_service.rs b/node/src/hopper/routing_service.rs index 7f7134d203..1ad3ba578d 100644 --- a/node/src/hopper/routing_service.rs +++ b/node/src/hopper/routing_service.rs @@ -606,6 +606,7 @@ mod tests { #[test] fn logs_and_ignores_message_that_cannot_be_deserialized() { init_test_logging(); + let test_name = "logs_and_ignores_message_that_cannot_be_deserialized"; let cryptde_pair = CRYPTDE_PAIR.clone(); let route = route_from_proxy_client(&cryptde_pair.main.public_key(), cryptde_pair.main.as_ref()); @@ -634,7 +635,7 @@ mod tests { data: data_enc.into(), }; let peer_actors = peer_actors_builder().build(); - let subject = RoutingService::new( + let mut subject = RoutingService::new( cryptde_pair, RoutingServiceSubs { proxy_client_subs_opt: peer_actors.proxy_client_opt, @@ -648,17 +649,19 @@ mod tests { 200, false, ); + subject.logger = Logger::new(test_name); subject.route(inbound_client_data); TestLogHandler::new().exists_log_containing( - "ERROR: RoutingService: Couldn't expire CORES package with 35-byte payload to ProxyClient using main key", + &format!("ERROR: {test_name}: Couldn't expire CORES package with 35-byte payload to ProxyClient using main key"), ); } #[test] fn logs_and_ignores_message_that_cannot_be_decrypted() { init_test_logging(); + let test_name = "logs_and_ignores_message_that_cannot_be_decrypted"; let main_cryptde = CryptDEReal::new(TEST_DEFAULT_CHAIN); let rogue_cryptde = CryptDEReal::new(TEST_DEFAULT_CHAIN); let route = route_from_proxy_client(main_cryptde.public_key(), &main_cryptde); @@ -681,7 +684,7 @@ mod tests { main_cryptde.dup(), Box::new(CryptDEReal::new(TEST_DEFAULT_CHAIN)), ); - let subject = RoutingService::new( + let mut subject = RoutingService::new( cryptde_pair, RoutingServiceSubs { proxy_client_subs_opt: peer_actors.proxy_client_opt, @@ -695,11 +698,12 @@ mod tests { 200, false, ); + subject.logger = Logger::new(test_name); subject.route(inbound_client_data); TestLogHandler::new().exists_log_containing( - "ERROR: RoutingService: Couldn't expire CORES package with 51-byte payload to ProxyClient using main key: DecryptionError(OpeningFailed)", + &format!("ERROR: {test_name}: Couldn't expire CORES package with 51-byte payload to ProxyClient using main key: DecryptionError(OpeningFailed)") ); } @@ -822,6 +826,7 @@ mod tests { let _eg = EnvironmentGuard::new(); init_test_logging(); BAN_CACHE.clear(); + let test_name = "complains_about_live_message_for_nonexistent_proxy_client"; let main_cryptde = CRYPTDE_PAIR.main.as_ref(); let route = route_to_proxy_client(&main_cryptde.public_key(), main_cryptde); let payload = make_request_payload(0, main_cryptde); @@ -850,7 +855,7 @@ mod tests { let system = System::new("converts_live_message_to_expired_for_proxy_client"); let peer_actors = peer_actors_builder().build(); - let subject = RoutingService::new( + let mut subject = RoutingService::new( CRYPTDE_PAIR.clone(), RoutingServiceSubs { proxy_client_subs_opt: None, @@ -864,6 +869,7 @@ mod tests { 0, false, ); + subject.logger = Logger::new(test_name); subject.route(inbound_client_data); @@ -871,7 +877,7 @@ mod tests { system.run(); let tlh = TestLogHandler::new(); tlh.exists_no_log_containing("Couldn't decode CORES package in 8-byte buffer"); - tlh.exists_log_containing("WARN: RoutingService: Received CORES package from 1.2.3.4:5678 for Proxy Client, but Proxy Client isn't running"); + tlh.exists_log_containing(&format!("WARN: {test_name}: Received CORES package from 1.2.3.4:5678 for Proxy Client, but Proxy Client isn't running")); } #[test] @@ -1272,6 +1278,8 @@ mod tests { let _eg = EnvironmentGuard::new(); BAN_CACHE.clear(); init_test_logging(); + let test_name = + "route_logs_and_ignores_cores_package_that_demands_routing_without_paying_wallet"; let main_cryptde = CRYPTDE_PAIR.main.as_ref(); let origin_key = PublicKey::new(&[1, 2]); let origin_cryptde = CryptDENull::from(&origin_key, TEST_DEFAULT_CHAIN); @@ -1303,9 +1311,7 @@ mod tests { sequence_number: None, data: data_enc.into(), }; - let system = System::new( - "route_logs_and_ignores_cores_package_that_demands_routing_without_paying_wallet", - ); + let system = System::new(test_name); let (proxy_client, _, proxy_client_recording_arc) = make_recorder(); let (proxy_server, _, proxy_server_recording_arc) = make_recorder(); let (neighborhood, _, neighborhood_recording_arc) = make_recorder(); @@ -1318,7 +1324,7 @@ mod tests { .dispatcher(dispatcher) .accountant(accountant) .build(); - let subject = RoutingService::new( + let mut subject = RoutingService::new( CRYPTDE_PAIR.clone(), RoutingServiceSubs { proxy_client_subs_opt: peer_actors.proxy_client_opt, @@ -1332,13 +1338,14 @@ mod tests { 200, true, ); + subject.logger = Logger::new(test_name); subject.route(inbound_client_data); System::current().stop_with_code(0); system.run(); TestLogHandler::new().exists_log_matching( - "WARN: RoutingService: Refusing to route Live CORES package with \\d+-byte payload without paying wallet", + &format!("WARN: {test_name}: Refusing to route Live CORES package with \\d+-byte payload without paying wallet"), ); assert_eq!(proxy_client_recording_arc.lock().unwrap().len(), 0); assert_eq!(proxy_server_recording_arc.lock().unwrap().len(), 0); @@ -1353,6 +1360,7 @@ mod tests { let _eg = EnvironmentGuard::new(); BAN_CACHE.clear(); init_test_logging(); + let test_name = "route_logs_and_ignores_cores_package_that_demands_proxy_client_routing_with_paying_wallet_that_cant_pay"; let main_cryptde = CRYPTDE_PAIR.main.as_ref(); let public_key = main_cryptde.public_key(); let payload = ClientRequest(VersionedData::new( @@ -1400,9 +1408,7 @@ mod tests { sequence_number: None, data: data_enc.into(), }; - let system = System::new( - "route_logs_and_ignores_cores_package_that_demands_proxy_client_routing_with_paying_wallet_that_cant_pay", - ); + let system = System::new(test_name); let (proxy_client, _, proxy_client_recording_arc) = make_recorder(); let (proxy_server, _, proxy_server_recording_arc) = make_recorder(); let (neighborhood, _, neighborhood_recording_arc) = make_recorder(); @@ -1415,7 +1421,7 @@ mod tests { .dispatcher(dispatcher) .accountant(accountant) .build(); - let subject = RoutingService::new( + let mut subject = RoutingService::new( CRYPTDE_PAIR.clone(), RoutingServiceSubs { proxy_client_subs_opt: peer_actors.proxy_client_opt, @@ -1429,13 +1435,14 @@ mod tests { 200, true, ); + subject.logger = Logger::new(test_name); subject.route(inbound_client_data); System::current().stop_with_code(0); system.run(); TestLogHandler::new().exists_log_matching( - "WARN: RoutingService: Refusing to route Expired CORES package with \\d+-byte payload without proof of 0x0a26dc9ebb2124baf1efe9d460f1ce59cd7944bd paying wallet ownership.", + &format!("WARN: {test_name}: Refusing to route Expired CORES package with \\d+-byte payload without proof of 0x0a26dc9ebb2124baf1efe9d460f1ce59cd7944bd paying wallet ownership."), ); assert_eq!(proxy_client_recording_arc.lock().unwrap().len(), 0); assert_eq!(proxy_server_recording_arc.lock().unwrap().len(), 0); @@ -1450,6 +1457,7 @@ mod tests { let _eg = EnvironmentGuard::new(); BAN_CACHE.clear(); init_test_logging(); + let test_name = "route_logs_and_ignores_cores_package_that_demands_hopper_routing_with_paying_wallet_that_cant_pay"; let main_cryptde = CRYPTDE_PAIR.main.as_ref(); let current_key = main_cryptde.public_key(); let origin_key = PublicKey::new(&[1, 2]); @@ -1494,9 +1502,7 @@ mod tests { encodex(main_cryptde, &destination_key, &payload).unwrap(), ); - let system = System::new( - "route_logs_and_ignores_cores_package_that_demands_hopper_routing_with_paying_wallet_that_cant_pay", - ); + let system = System::new(test_name); let (proxy_client, _, proxy_client_recording_arc) = make_recorder(); let (proxy_server, _, proxy_server_recording_arc) = make_recorder(); let (neighborhood, _, neighborhood_recording_arc) = make_recorder(); @@ -1509,7 +1515,7 @@ mod tests { .dispatcher(dispatcher) .accountant(accountant) .build(); - let subject = RoutingService::new( + let mut subject = RoutingService::new( CRYPTDE_PAIR.clone(), RoutingServiceSubs { proxy_client_subs_opt: peer_actors.proxy_client_opt, @@ -1523,6 +1529,7 @@ mod tests { 200, true, ); + subject.logger = Logger::new(test_name); subject.route_data_externally( lcp, @@ -1533,7 +1540,7 @@ mod tests { System::current().stop_with_code(0); system.run(); TestLogHandler::new().exists_log_matching( - "WARN: RoutingService: Refusing to route Live CORES package with \\d+-byte payload without proof of 0x0a26dc9ebb2124baf1efe9d460f1ce59cd7944bd paying wallet ownership.", + &format!("WARN: {test_name}: Refusing to route Live CORES package with \\d+-byte payload without proof of 0x0a26dc9ebb2124baf1efe9d460f1ce59cd7944bd paying wallet ownership."), ); assert_eq!(proxy_client_recording_arc.lock().unwrap().len(), 0); assert_eq!(proxy_server_recording_arc.lock().unwrap().len(), 0); @@ -1547,6 +1554,8 @@ mod tests { let _eg = EnvironmentGuard::new(); BAN_CACHE.clear(); init_test_logging(); + let test_name = + "route_logs_and_ignores_cores_package_from_delinquent_that_demands_external_routing"; let main_cryptde = CRYPTDE_PAIR.main.as_ref(); let paying_wallet = make_paying_wallet(b"wallet"); let contract_address = TEST_DEFAULT_CHAIN.rec().contract; @@ -1581,7 +1590,7 @@ mod tests { .dispatcher(dispatcher) .accountant(accountant) .build(); - let subject = RoutingService::new( + let mut subject = RoutingService::new( CRYPTDE_PAIR.clone(), RoutingServiceSubs { proxy_client_subs_opt: peer_actors.proxy_client_opt, @@ -1595,6 +1604,7 @@ mod tests { rate_pack_routing_byte(103), false, ); + subject.logger = Logger::new(test_name); subject.route(inbound_client_data); @@ -1605,7 +1615,7 @@ mod tests { assert_eq!(dispatcher_recording.len(), 0); let accountant_recording = accountant_recording_arc.lock().unwrap(); assert_eq!(accountant_recording.len(), 0); - TestLogHandler::new().exists_log_containing("WARN: RoutingService: Node with consuming wallet 0x71d0fc7d1c570b1ed786382b551a09391c91e33d is delinquent; electing not to route 7-byte payload further"); + TestLogHandler::new().exists_log_containing(&format!("WARN: {test_name}: Node with consuming wallet 0x71d0fc7d1c570b1ed786382b551a09391c91e33d is delinquent; electing not to route 7-byte payload further")); } #[test] @@ -1613,6 +1623,8 @@ mod tests { let _eg = EnvironmentGuard::new(); BAN_CACHE.clear(); init_test_logging(); + let test_name = + "route_logs_and_ignores_cores_package_from_delinquent_that_demands_internal_routing"; let main_cryptde = CRYPTDE_PAIR.main.as_ref(); let paying_wallet = make_paying_wallet(b"wallet"); BAN_CACHE.insert(paying_wallet.clone()); @@ -1651,7 +1663,7 @@ mod tests { .dispatcher(dispatcher) .accountant(accountant) .build(); - let subject = RoutingService::new( + let mut subject = RoutingService::new( CRYPTDE_PAIR.clone(), RoutingServiceSubs { proxy_client_subs_opt: peer_actors.proxy_client_opt, @@ -1665,6 +1677,7 @@ mod tests { rate_pack_routing_byte(103), false, ); + subject.logger = Logger::new(test_name); subject.route(inbound_client_data); @@ -1675,12 +1688,14 @@ mod tests { assert_eq!(dispatcher_recording.len(), 0); let accountant_recording = accountant_recording_arc.lock().unwrap(); assert_eq!(accountant_recording.len(), 0); - TestLogHandler::new().exists_log_containing("WARN: RoutingService: Node with consuming wallet 0x71d0fc7d1c570b1ed786382b551a09391c91e33d is delinquent; electing not to route 36-byte payload to ProxyServer"); + TestLogHandler::new().exists_log_containing(&format!("WARN: {test_name}: Node with consuming wallet 0x71d0fc7d1c570b1ed786382b551a09391c91e33d is delinquent; electing not to route 36-byte payload to ProxyServer")); } #[test] fn route_logs_and_ignores_inbound_client_data_that_doesnt_deserialize_properly() { init_test_logging(); + let test_name = + "route_logs_and_ignores_inbound_client_data_that_doesnt_deserialize_properly"; let inbound_client_data = InboundClientData { timestamp: SystemTime::now(), client_addr: SocketAddr::from_str("1.2.3.4:5678").unwrap(), @@ -1701,7 +1716,7 @@ mod tests { .neighborhood(neighborhood) .dispatcher(dispatcher) .build(); - let subject = RoutingService::new( + let mut subject = RoutingService::new( CRYPTDE_PAIR.clone(), RoutingServiceSubs { proxy_client_subs_opt: peer_actors.proxy_client_opt, @@ -1715,13 +1730,14 @@ mod tests { 200, false, ); + subject.logger = Logger::new(test_name); subject.route(inbound_client_data); System::current().stop_with_code(0); system.run(); TestLogHandler::new().exists_log_containing( - "ERROR: RoutingService: Couldn't decode CORES package in 0-byte buffer from 1.2.3.4:5678: DecryptionError(EmptyData)", + &format!("ERROR: {test_name}: Couldn't decode CORES package in 0-byte buffer from 1.2.3.4:5678: DecryptionError(EmptyData)"), ); assert_eq!(proxy_client_recording_arc.lock().unwrap().len(), 0); assert_eq!(proxy_server_recording_arc.lock().unwrap().len(), 0); @@ -1732,6 +1748,7 @@ mod tests { #[test] fn route_logs_and_ignores_invalid_live_cores_package() { init_test_logging(); + let test_name = "route_logs_and_ignores_invalid_live_cores_package"; let main_cryptde = CRYPTDE_PAIR.main.as_ref(); let lcp = LiveCoresPackage::new(Route { hops: vec![] }, CryptData::new(&[])); let data_ser = PlainData::new(&serde_cbor::ser::to_vec(&lcp).unwrap()[..]); @@ -1758,7 +1775,7 @@ mod tests { .neighborhood(neighborhood) .dispatcher(dispatcher) .build(); - let subject = RoutingService::new( + let mut subject = RoutingService::new( CRYPTDE_PAIR.clone(), RoutingServiceSubs { proxy_client_subs_opt: peer_actors.proxy_client_opt, @@ -1772,14 +1789,15 @@ mod tests { 200, false, ); + subject.logger = Logger::new(test_name); subject.route(inbound_client_data); System::current().stop_with_code(0); system.run(); - TestLogHandler::new().exists_log_containing( - "ERROR: RoutingService: Invalid 67-byte CORES package: RoutingError(EmptyRoute)", - ); + TestLogHandler::new().exists_log_containing(&format!( + "ERROR: {test_name}: Invalid 67-byte CORES package: RoutingError(EmptyRoute)" + )); assert_eq!(proxy_client_recording_arc.lock().unwrap().len(), 0); assert_eq!(proxy_server_recording_arc.lock().unwrap().len(), 0); assert_eq!(neighborhood_recording_arc.lock().unwrap().len(), 0); @@ -1789,8 +1807,9 @@ mod tests { #[test] fn route_data_around_again_logs_and_ignores_bad_lcp() { init_test_logging(); + let test_name = "route_data_around_again_logs_and_ignores_bad_lcp"; let peer_actors = peer_actors_builder().build(); - let subject = RoutingService::new( + let mut subject = RoutingService::new( CRYPTDE_PAIR.clone(), RoutingServiceSubs { proxy_client_subs_opt: peer_actors.proxy_client_opt, @@ -1804,6 +1823,7 @@ mod tests { 200, false, ); + subject.logger = Logger::new(test_name); let lcp = LiveCoresPackage::new(Route { hops: vec![] }, CryptData::new(&[])); let ibcd = InboundClientData { timestamp: SystemTime::now(), @@ -1817,9 +1837,9 @@ mod tests { subject.route_data_around_again(lcp, &ibcd); - TestLogHandler::new().exists_log_containing( - "ERROR: RoutingService: bad zero-hop route: RoutingError(EmptyRoute)", - ); + TestLogHandler::new().exists_log_containing(&format!( + "ERROR: {test_name}: bad zero-hop route: RoutingError(EmptyRoute)" + )); } fn make_routing_service_subs(peer_actors: PeerActors) -> RoutingServiceSubs { @@ -1953,9 +1973,10 @@ mod tests { #[test] fn route_expired_package_handles_unmigratable_gossip() { init_test_logging(); + let test_name = "route_expired_package_handles_unmigratable_gossip"; let (neighborhood, _, neighborhood_recording_arc) = make_recorder(); let peer_actors = peer_actors_builder().neighborhood(neighborhood).build(); - let subject = RoutingService::new( + let mut subject = RoutingService::new( CRYPTDE_PAIR.clone(), RoutingServiceSubs { proxy_client_subs_opt: peer_actors.proxy_client_opt, @@ -1969,6 +1990,7 @@ mod tests { 200, false, ); + subject.logger = Logger::new(test_name); let expired_package = ExpiredCoresPackage::new( SocketAddr::from_str("1.2.3.4:1234").unwrap(), None, @@ -1976,7 +1998,7 @@ mod tests { MessageType::Gossip(VersionedData::test_new(dv!(0, 0), vec![])), 0, ); - let system = System::new("route_expired_package_handles_unmigratable_gossip"); + let system = System::new(test_name); subject.route_expired_package(Component::Neighborhood, expired_package, true); @@ -1985,7 +2007,7 @@ mod tests { let neighborhood_recording = neighborhood_recording_arc.lock().unwrap(); assert_eq!(neighborhood_recording.len(), 0); TestLogHandler::new().exists_log_containing( - "ERROR: RoutingService: Received unmigratable Gossip: MigrationNotFound(DataVersion { major: 0, minor: 0 }, DataVersion { major: 0, minor: 1 })", + &format!("ERROR: {test_name}: Received unmigratable Gossip: MigrationNotFound(DataVersion {{ major: 0, minor: 0 }}, DataVersion {{ major: 0, minor: 1 }})"), ); } @@ -2031,9 +2053,10 @@ mod tests { #[test] fn route_expired_package_handles_unmigratable_client_response() { init_test_logging(); + let test_name = "route_expired_package_handles_unmigratable_client_response"; let (proxy_server, _, proxy_server_recording_arc) = make_recorder(); let peer_actors = peer_actors_builder().proxy_server(proxy_server).build(); - let subject = RoutingService::new( + let mut subject = RoutingService::new( CRYPTDE_PAIR.clone(), RoutingServiceSubs { proxy_client_subs_opt: peer_actors.proxy_client_opt, @@ -2047,6 +2070,7 @@ mod tests { 200, false, ); + subject.logger = Logger::new(test_name); let expired_package = ExpiredCoresPackage::new( SocketAddr::from_str("1.2.3.4:1234").unwrap(), None, @@ -2054,7 +2078,7 @@ mod tests { MessageType::ClientResponse(VersionedData::test_new(dv!(0, 0), vec![])), 0, ); - let system = System::new("route_expired_package_handles_unmigratable_client_response"); + let system = System::new(test_name); subject.route_expired_package(Component::ProxyServer, expired_package, true); @@ -2063,16 +2087,17 @@ mod tests { let proxy_server_recording = proxy_server_recording_arc.lock().unwrap(); assert_eq!(proxy_server_recording.len(), 0); TestLogHandler::new().exists_log_containing( - "ERROR: RoutingService: Received unmigratable ClientResponsePayload: MigrationNotFound(DataVersion { major: 0, minor: 0 }, DataVersion { major: 0, minor: 1 })", + &format!("ERROR: {test_name}: Received unmigratable ClientResponsePayload: MigrationNotFound(DataVersion {{ major: 0, minor: 0 }}, DataVersion {{ major: 0, minor: 1 }})"), ); } #[test] fn route_expired_package_handles_unmigratable_dns_resolve_failure() { init_test_logging(); + let test_name = "route_expired_package_handles_unmigratable_dns_resolve_failure"; let (hopper, _, hopper_recording_arc) = make_recorder(); let peer_actors = peer_actors_builder().hopper(hopper).build(); - let subject = RoutingService::new( + let mut subject = RoutingService::new( CRYPTDE_PAIR.clone(), RoutingServiceSubs { proxy_client_subs_opt: peer_actors.proxy_client_opt, @@ -2086,6 +2111,7 @@ mod tests { 200, false, ); + subject.logger = Logger::new(test_name); let expired_package = ExpiredCoresPackage::new( SocketAddr::from_str("1.2.3.4:1234").unwrap(), None, @@ -2093,7 +2119,7 @@ mod tests { MessageType::DnsResolveFailed(VersionedData::test_new(dv!(0, 0), vec![])), 0, ); - let system = System::new("route_expired_package_handles_unmigratable_dns_resolve_failure"); + let system = System::new(test_name); subject.route_expired_package(Component::ProxyServer, expired_package, true); @@ -2102,16 +2128,17 @@ mod tests { let hopper_recording = hopper_recording_arc.lock().unwrap(); assert_eq!(hopper_recording.len(), 0); TestLogHandler::new().exists_log_containing( - "ERROR: RoutingService: Received unmigratable DnsResolveFailed: MigrationNotFound(DataVersion { major: 0, minor: 0 }, DataVersion { major: 0, minor: 1 })", + &format!("ERROR: {test_name}: Received unmigratable DnsResolveFailed: MigrationNotFound(DataVersion {{ major: 0, minor: 0 }}, DataVersion {{ major: 0, minor: 1 }})"), ); } #[test] fn route_expired_package_handles_unmigratable_gossip_failure() { init_test_logging(); + let test_name = "route_expired_package_handles_unmigratable_gossip_failure"; let (neighborhood, _, neighborhood_recording_arc) = make_recorder(); let peer_actors = peer_actors_builder().neighborhood(neighborhood).build(); - let subject = RoutingService::new( + let mut subject = RoutingService::new( CRYPTDE_PAIR.clone(), RoutingServiceSubs { proxy_client_subs_opt: peer_actors.proxy_client_opt, @@ -2125,6 +2152,7 @@ mod tests { 200, false, ); + subject.logger = Logger::new(test_name); let expired_package = ExpiredCoresPackage::new( SocketAddr::from_str("1.2.3.4:1234").unwrap(), None, @@ -2141,7 +2169,7 @@ mod tests { let neighborhood_recording = neighborhood_recording_arc.lock().unwrap(); assert_eq!(neighborhood_recording.len(), 0); TestLogHandler::new().exists_log_containing( - "ERROR: RoutingService: Received unmigratable GossipFailure: MigrationNotFound(DataVersion { major: 0, minor: 0 }, DataVersion { major: 0, minor: 1 })", + &format!("ERROR: {test_name}: Received unmigratable GossipFailure: MigrationNotFound(DataVersion {{ major: 0, minor: 0 }}, DataVersion {{ major: 0, minor: 1 }})"), ); } } diff --git a/node/src/node_configurator/configurator.rs b/node/src/node_configurator/configurator.rs index fb5dd565bc..95ad7115e5 100644 --- a/node/src/node_configurator/configurator.rs +++ b/node/src/node_configurator/configurator.rs @@ -660,8 +660,8 @@ impl Configurator { }, start_block_opt, scan_intervals: UiScanIntervals { - pending_payable_sec, payable_sec, + pending_payable_sec, receivable_sec, }, }; @@ -2606,8 +2606,8 @@ mod tests { }, start_block_opt: Some(3456), scan_intervals: UiScanIntervals { - pending_payable_sec: 122, payable_sec: 125, + pending_payable_sec: 122, receivable_sec: 128 } } @@ -2625,8 +2625,8 @@ mod tests { exit_service_rate: 13, })) .scan_intervals_result(Ok(ScanIntervals { - pending_payable_scan_interval: Duration::from_secs(122), payable_scan_interval: Duration::from_secs(125), + pending_payable_scan_interval: Duration::from_secs(122), receivable_scan_interval: Duration::from_secs(128), })) .payment_thresholds_result(Ok(PaymentThresholds { @@ -2738,8 +2738,8 @@ mod tests { }, start_block_opt: Some(3456), scan_intervals: UiScanIntervals { - pending_payable_sec: 122, payable_sec: 125, + pending_payable_sec: 122, receivable_sec: 128 } } @@ -2776,8 +2776,8 @@ mod tests { exit_service_rate: 0, })) .scan_intervals_result(Ok(ScanIntervals { - pending_payable_scan_interval: Default::default(), payable_scan_interval: Default::default(), + pending_payable_scan_interval: Default::default(), receivable_scan_interval: Default::default(), })) .payment_thresholds_result(Ok(PaymentThresholds { @@ -2831,8 +2831,8 @@ mod tests { }, start_block_opt: Some(3456), scan_intervals: UiScanIntervals { - pending_payable_sec: 0, payable_sec: 0, + pending_payable_sec: 0, receivable_sec: 0 } } diff --git a/node/src/node_configurator/unprivileged_parse_args_configuration.rs b/node/src/node_configurator/unprivileged_parse_args_configuration.rs index 8ff2ed7c4c..313bf30482 100644 --- a/node/src/node_configurator/unprivileged_parse_args_configuration.rs +++ b/node/src/node_configurator/unprivileged_parse_args_configuration.rs @@ -349,7 +349,7 @@ fn get_public_ip(multi_config: &MultiConfig) -> Result match IpAddr::from_str(&ip_str) { Ok(ip_addr) => Ok(ip_addr), - Err(_) => todo!("Drive in a better error message"), //Err(ConfiguratorError::required("ip", &format! ("blockety blip: '{}'", ip_str), + Err(_) => todo!("Drive in a better error message. The multiconfig wouldn't allow a bad format, though."), //Err(ConfiguratorError::required("ip", &format! ("blockety blip: '{}'", ip_str), }, None => Ok(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))), // sentinel: means "Try Automap" } @@ -513,7 +513,7 @@ fn configure_accountant_config( |pc: &mut dyn PersistentConfiguration, curves| pc.set_payment_thresholds(curves), )?; - check_payment_thresholds(&payment_thresholds)?; + validate_payment_thresholds(&payment_thresholds)?; let scan_intervals = process_combined_params( "scan-intervals", @@ -523,22 +523,26 @@ fn configure_accountant_config( |pc: &dyn PersistentConfiguration| pc.scan_intervals(), |pc: &mut dyn PersistentConfiguration, intervals| pc.set_scan_intervals(intervals), )?; - let suppress_initial_scans = - value_m!(multi_config, "scans", String).unwrap_or_else(|| "on".to_string()) == *"off"; + + validate_scan_intervals(&scan_intervals)?; + + let automatic_scans_enabled = + value_m!(multi_config, "scans", String).unwrap_or_else(|| "on".to_string()) == "on"; config.payment_thresholds_opt = Some(payment_thresholds); config.scan_intervals_opt = Some(scan_intervals); - config.suppress_initial_scans = suppress_initial_scans; + config.automatic_scans_enabled = automatic_scans_enabled; config.when_pending_too_long_sec = DEFAULT_PENDING_TOO_LONG_SEC; Ok(()) } -fn check_payment_thresholds( +fn validate_payment_thresholds( payment_thresholds: &PaymentThresholds, ) -> Result<(), ConfiguratorError> { if payment_thresholds.debt_threshold_gwei <= payment_thresholds.permanent_debt_allowed_gwei { let msg = format!( - "Value of DebtThresholdGwei ({}) must be bigger than PermanentDebtAllowedGwei ({})", + "Value of DebtThresholdGwei ({}) must be bigger than PermanentDebtAllowedGwei ({}) \ + as the smallest value", payment_thresholds.debt_threshold_gwei, payment_thresholds.permanent_debt_allowed_gwei ); return Err(ConfiguratorError::required("payment-thresholds", &msg)); @@ -552,6 +556,21 @@ fn check_payment_thresholds( Ok(()) } +fn validate_scan_intervals(scan_intervals: &ScanIntervals) -> Result<(), ConfiguratorError> { + if scan_intervals.payable_scan_interval < scan_intervals.pending_payable_scan_interval { + Err(ConfiguratorError::required( + "scan-intervals", + &format!( + "The PendingPayableScanInterval value ({} s) must not exceed the PayableScanInterval \ + value ({} s) and should ideally be approximately half of it", + scan_intervals.pending_payable_scan_interval.as_secs(), + scan_intervals.payable_scan_interval.as_secs()), + )) + } else { + Ok(()) + } +} + fn configure_rate_pack( multi_config: &MultiConfig, persist_config: &mut dyn PersistentConfiguration, @@ -1800,7 +1819,7 @@ mod tests { "--ip", "1.2.3.4", "--scan-intervals", - "180|150|130", + "180|50|130", "--payment-thresholds", "100000|10000|1000|20000|1000|20000", ]; @@ -1809,8 +1828,8 @@ mod tests { let mut persistent_configuration = configure_default_persistent_config(RATE_PACK | MAPPING_PROTOCOL) .scan_intervals_result(Ok(ScanIntervals { - pending_payable_scan_interval: Duration::from_secs(100), payable_scan_interval: Duration::from_secs(101), + pending_payable_scan_interval: Duration::from_secs(33), receivable_scan_interval: Duration::from_secs(102), })) .payment_thresholds_result(Ok(PaymentThresholds { @@ -1837,8 +1856,8 @@ mod tests { .unwrap(); let expected_scan_intervals = ScanIntervals { - pending_payable_scan_interval: Duration::from_secs(180), - payable_scan_interval: Duration::from_secs(150), + payable_scan_interval: Duration::from_secs(180), + pending_payable_scan_interval: Duration::from_secs(50), receivable_scan_interval: Duration::from_secs(130), }; let expected_payment_thresholds = PaymentThresholds { @@ -1854,13 +1873,13 @@ mod tests { Some(expected_payment_thresholds) ); assert_eq!(config.scan_intervals_opt, Some(expected_scan_intervals)); - assert_eq!(config.suppress_initial_scans, false); + assert_eq!(config.automatic_scans_enabled, true); assert_eq!( config.when_pending_too_long_sec, DEFAULT_PENDING_TOO_LONG_SEC ); let set_scan_intervals_params = set_scan_intervals_params_arc.lock().unwrap(); - assert_eq!(*set_scan_intervals_params, vec!["180|150|130".to_string()]); + assert_eq!(*set_scan_intervals_params, vec!["180|50|130".to_string()]); let set_payment_thresholds_params = set_payment_thresholds_params_arc.lock().unwrap(); assert_eq!( *set_payment_thresholds_params, @@ -1878,7 +1897,7 @@ mod tests { "--ip", "1.2.3.4", "--scan-intervals", - "180|150|130", + "180|15|130", "--payment-thresholds", "100000|1000|1000|20000|1000|20000", ]; @@ -1887,8 +1906,8 @@ mod tests { let mut persistent_configuration = configure_default_persistent_config(RATE_PACK | MAPPING_PROTOCOL) .scan_intervals_result(Ok(ScanIntervals { - pending_payable_scan_interval: Duration::from_secs(180), - payable_scan_interval: Duration::from_secs(150), + payable_scan_interval: Duration::from_secs(180), + pending_payable_scan_interval: Duration::from_secs(15), receivable_scan_interval: Duration::from_secs(130), })) .payment_thresholds_result(Ok(PaymentThresholds { @@ -1919,11 +1938,11 @@ mod tests { unban_below_gwei: 20000, }; let expected_scan_intervals = ScanIntervals { - pending_payable_scan_interval: Duration::from_secs(180), - payable_scan_interval: Duration::from_secs(150), + payable_scan_interval: Duration::from_secs(180), + pending_payable_scan_interval: Duration::from_secs(15), receivable_scan_interval: Duration::from_secs(130), }; - let expected_suppress_initial_scans = false; + let expected_automatic_scans_enabled = true; let expected_when_pending_too_long_sec = DEFAULT_PENDING_TOO_LONG_SEC; assert_eq!( config.payment_thresholds_opt, @@ -1931,8 +1950,8 @@ mod tests { ); assert_eq!(config.scan_intervals_opt, Some(expected_scan_intervals)); assert_eq!( - config.suppress_initial_scans, - expected_suppress_initial_scans + config.automatic_scans_enabled, + expected_automatic_scans_enabled ); assert_eq!( config.when_pending_too_long_sec, @@ -2088,8 +2107,8 @@ mod tests { } #[test] - fn configure_accountant_config_discovers_invalid_payment_thresholds_params_combination_given_from_users_input( - ) { + fn configure_accountant_config_discovers_invalid_payment_thresholds_combination_in_users_input() + { let multi_config = make_simplified_multi_config([ "--payment-thresholds", "19999|10000|1000|20000|1000|20000", @@ -2105,7 +2124,8 @@ mod tests { &mut persistent_config, ); - let expected_msg = "Value of DebtThresholdGwei (19999) must be bigger than PermanentDebtAllowedGwei (20000)"; + let expected_msg = "Value of DebtThresholdGwei (19999) must be bigger than \ + PermanentDebtAllowedGwei (20000) as the smallest value"; assert_eq!( result, Err(ConfiguratorError::required( @@ -2116,14 +2136,15 @@ mod tests { } #[test] - fn check_payment_thresholds_works_for_equal_debt_parameters() { + fn validate_payment_thresholds_works_for_equal_debt_parameters() { let mut payment_thresholds = *DEFAULT_PAYMENT_THRESHOLDS; payment_thresholds.permanent_debt_allowed_gwei = 10000; payment_thresholds.debt_threshold_gwei = 10000; - let result = check_payment_thresholds(&payment_thresholds); + let result = validate_payment_thresholds(&payment_thresholds); - let expected_msg = "Value of DebtThresholdGwei (10000) must be bigger than PermanentDebtAllowedGwei (10000)"; + let expected_msg = "Value of DebtThresholdGwei (10000) must be bigger than \ + PermanentDebtAllowedGwei (10000) as the smallest value"; assert_eq!( result, Err(ConfiguratorError::required( @@ -2134,14 +2155,15 @@ mod tests { } #[test] - fn check_payment_thresholds_works_for_too_small_debt_threshold() { + fn validate_payment_thresholds_works_for_too_small_debt_threshold() { let mut payment_thresholds = *DEFAULT_PAYMENT_THRESHOLDS; payment_thresholds.permanent_debt_allowed_gwei = 10000; payment_thresholds.debt_threshold_gwei = 9999; - let result = check_payment_thresholds(&payment_thresholds); + let result = validate_payment_thresholds(&payment_thresholds); - let expected_msg = "Value of DebtThresholdGwei (9999) must be bigger than PermanentDebtAllowedGwei (10000)"; + let expected_msg = "Value of DebtThresholdGwei (9999) must be bigger than \ + PermanentDebtAllowedGwei (10000) as the smallest value"; assert_eq!( result, Err(ConfiguratorError::required( @@ -2152,7 +2174,8 @@ mod tests { } #[test] - fn check_payment_thresholds_does_not_permit_threshold_interval_longer_than_1_000_000_000_s() { + fn validate_payment_thresholds_does_not_permit_threshold_interval_longer_than_1_000_000_000_s() + { //this goes to the furthest extreme where the delta of debt limits is just 1 gwei, which, //if divided by the slope interval equal or longer 10^9 and rounded, gives 0 let mut payment_thresholds = *DEFAULT_PAYMENT_THRESHOLDS; @@ -2160,7 +2183,7 @@ mod tests { payment_thresholds.debt_threshold_gwei = 101; payment_thresholds.threshold_interval_sec = 1_000_000_001; - let result = check_payment_thresholds(&payment_thresholds); + let result = validate_payment_thresholds(&payment_thresholds); let expected_msg = "Value of ThresholdIntervalSec must not exceed 1,000,000,000 s"; assert_eq!( @@ -2175,6 +2198,28 @@ mod tests { assert_eq!(last_value_possible, -1) } + #[test] + fn configure_accountant_config_discovers_invalid_scan_intervals_combination_in_users_input() { + let multi_config = make_simplified_multi_config(["--scan-intervals", "600|601|600"]); + let mut bootstrapper_config = BootstrapperConfig::new(); + let mut persistent_config = + configure_default_persistent_config(ACCOUNTANT_CONFIG_PARAMS | MAPPING_PROTOCOL) + .set_scan_intervals_result(Ok(())); + + let result = configure_accountant_config( + &multi_config, + &mut bootstrapper_config, + &mut persistent_config, + ); + + let expected_msg = "The PendingPayableScanInterval value (601 s) must not exceed \ + the PayableScanInterval value (600 s) and should ideally be approximately half of it"; + assert_eq!( + result, + Err(ConfiguratorError::required("scan-intervals", expected_msg)) + ) + } + #[test] fn unprivileged_parse_args_with_invalid_consuming_wallet_private_key_reacts_correctly() { running_test(); @@ -2575,7 +2620,7 @@ mod tests { ) .unwrap(); - assert_eq!(bootstrapper_config.suppress_initial_scans, true); + assert_eq!(bootstrapper_config.automatic_scans_enabled, false); } #[test] @@ -2603,7 +2648,7 @@ mod tests { ) .unwrap(); - assert_eq!(bootstrapper_config.suppress_initial_scans, false); + assert_eq!(bootstrapper_config.automatic_scans_enabled, true); } #[test] @@ -2624,7 +2669,7 @@ mod tests { ) .unwrap(); - assert_eq!(bootstrapper_config.suppress_initial_scans, false); + assert_eq!(bootstrapper_config.automatic_scans_enabled, true); } fn make_persistent_config( diff --git a/node/src/proxy_server/mod.rs b/node/src/proxy_server/mod.rs index 459acf668c..ec9166bc50 100644 --- a/node/src/proxy_server/mod.rs +++ b/node/src/proxy_server/mod.rs @@ -1355,7 +1355,7 @@ impl Hostname { mod tests { use super::*; use crate::bootstrapper::CryptDEPair; - use crate::match_every_type_id; + use crate::match_lazily_every_type_id; use crate::proxy_server::protocol_pack::ServerImpersonator; use crate::proxy_server::server_impersonator_http::ServerImpersonatorHttp; use crate::proxy_server::server_impersonator_tls::ServerImpersonatorTls; @@ -1384,7 +1384,7 @@ mod tests { use crate::test_utils::recorder::make_recorder; use crate::test_utils::recorder::peer_actors_builder; use crate::test_utils::recorder::Recorder; - use crate::test_utils::recorder_stop_conditions::{StopCondition, StopConditions}; + use crate::test_utils::recorder_stop_conditions::StopConditions; use crate::test_utils::unshared_test_utils::{ make_request_payload, prove_that_crash_request_handler_is_hooked_up, AssertionsMessage, }; @@ -2620,8 +2620,8 @@ mod tests { let test_name = "proxy_server_sends_a_message_when_dns_retry_cannot_find_a_route"; let http_request = b"GET /index.html HTTP/1.1\r\nHost: nowhere.com\r\n\r\n"; let (proxy_server_mock, _, proxy_server_recording_arc) = make_recorder(); - let proxy_server_mock = - proxy_server_mock.system_stop_conditions(match_every_type_id!(AddRouteResultMessage)); + let proxy_server_mock = proxy_server_mock + .system_stop_conditions(match_lazily_every_type_id!(AddRouteResultMessage)); let route_query_response = None; let (neighborhood_mock, _, _) = make_recorder(); let neighborhood_mock = @@ -5189,7 +5189,7 @@ mod tests { ), }; let neighborhood_mock = neighborhood_mock - .system_stop_conditions(match_every_type_id!(RouteQueryMessage)) + .system_stop_conditions(match_lazily_every_type_id!(RouteQueryMessage)) .route_query_response(Some(route_query_response_expected.clone())); let cryptde = CRYPTDE_PAIR.main.as_ref(); let mut subject = ProxyServer::new( @@ -5357,7 +5357,7 @@ mod tests { ), }; let neighborhood_mock = neighborhood_mock - .system_stop_conditions(match_every_type_id!( + .system_stop_conditions(match_lazily_every_type_id!( RouteQueryMessage, RouteQueryMessage, RouteQueryMessage diff --git a/node/src/stream_handler_pool.rs b/node/src/stream_handler_pool.rs index 7aa6d0ff6c..3e929140c1 100644 --- a/node/src/stream_handler_pool.rs +++ b/node/src/stream_handler_pool.rs @@ -1765,7 +1765,7 @@ mod tests { }) .unwrap(); - tx.send(subject_subs).expect("Tx failure"); + tx.send(subject_subs).expect("SentTx failure"); system.run(); }); @@ -1932,7 +1932,7 @@ mod tests { }) .unwrap(); - tx.send(subject_subs).expect("Tx failure"); + tx.send(subject_subs).expect("SentTx failure"); system.run(); }); diff --git a/node/src/sub_lib/accountant.rs b/node/src/sub_lib/accountant.rs index 4b005f7139..039b1fe4f7 100644 --- a/node/src/sub_lib/accountant.rs +++ b/node/src/sub_lib/accountant.rs @@ -1,15 +1,15 @@ // Copyright (c) 2019, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. use crate::accountant::db_access_objects::banned_dao::BannedDaoFactory; +use crate::accountant::db_access_objects::failed_payable_dao::FailedPayableDaoFactory; use crate::accountant::db_access_objects::payable_dao::PayableDaoFactory; -use crate::accountant::db_access_objects::pending_payable_dao::PendingPayableDaoFactory; use crate::accountant::db_access_objects::receivable_dao::ReceivableDaoFactory; -use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::msgs::BlockchainAgentWithContextMessage; +use crate::accountant::db_access_objects::sent_payable_dao::SentPayableDaoFactory; +use crate::accountant::scanners::payable_scanner::msgs::PricedTemplatesMessage; use crate::accountant::{ - checked_conversion, Accountant, ReceivedPayments, ReportTransactionReceipts, ScanError, - SentPayables, + checked_conversion, Accountant, ReceivedPayments, ScanError, SentPayables, TxReceiptsMessage, }; use crate::actor_system_factory::SubsFactory; -use crate::blockchain::blockchain_bridge::PendingPayableFingerprintSeeds; +use crate::blockchain::blockchain_bridge::RegisterNewPendingPayables; use crate::db_config::config_dao::ConfigDaoFactory; use crate::sub_lib::neighborhood::ConfigChangeMsg; use crate::sub_lib::peer_actors::{BindMessage, StartMessage}; @@ -17,6 +17,7 @@ use crate::sub_lib::wallet::Wallet; use actix::Recipient; use actix::{Addr, Message}; use lazy_static::lazy_static; +use masq_lib::blockchains::chains::Chain; use masq_lib::ui_gateway::NodeFromUiMessage; use std::fmt::{Debug, Formatter}; use std::str::FromStr; @@ -37,11 +38,6 @@ lazy_static! { threshold_interval_sec: 21600, unban_below_gwei: 500_000_000, }; - pub static ref DEFAULT_SCAN_INTERVALS: ScanIntervals = ScanIntervals { - pending_payable_scan_interval: Duration::from_secs(600), - payable_scan_interval: Duration::from_secs(600), - receivable_scan_interval: Duration::from_secs(600) - }; } //please, alphabetical order @@ -71,7 +67,8 @@ impl PaymentThresholds { pub struct DaoFactories { pub payable_dao_factory: Box, - pub pending_payable_dao_factory: Box, + pub sent_payable_dao_factory: Box, + pub failed_payable_dao_factory: Box, pub receivable_dao_factory: Box, pub banned_dao_factory: Box, pub config_dao_factory: Box, @@ -84,9 +81,15 @@ pub struct ScanIntervals { pub receivable_scan_interval: Duration, } -impl Default for ScanIntervals { - fn default() -> Self { - *DEFAULT_SCAN_INTERVALS +impl ScanIntervals { + pub fn compute_default(chain: Chain) -> Self { + Self { + payable_scan_interval: Duration::from_secs(600), + pending_payable_scan_interval: Duration::from_secs( + chain.rec().default_pending_payable_interval_sec, + ), + receivable_scan_interval: Duration::from_secs(600), + } } } @@ -98,10 +101,10 @@ pub struct AccountantSubs { pub report_routing_service_provided: Recipient, pub report_exit_service_provided: Recipient, pub report_services_consumed: Recipient, - pub report_payable_payments_setup: Recipient, + pub report_payable_payments_setup: Recipient, pub report_inbound_payments: Recipient, - pub init_pending_payable_fingerprints: Recipient, - pub report_transaction_receipts: Recipient, + pub register_new_pending_payables: Recipient, + pub report_transaction_status: Recipient, pub report_sent_payments: Recipient, pub scan_errors: Recipient, pub ui_message_sub: Recipient, @@ -191,23 +194,44 @@ impl MessageIdGenerator for MessageIdGeneratorReal { as_any_ref_in_trait_impl!(); } +#[derive(Debug, Clone, PartialEq, Eq, Copy)] +pub enum DetailedScanType { + NewPayables, + RetryPayables, + PendingPayables, + Receivables, +} + #[cfg(test)] mod tests { use crate::accountant::test_utils::AccountantBuilder; use crate::accountant::{checked_conversion, Accountant}; use crate::sub_lib::accountant::{ - AccountantSubsFactoryReal, MessageIdGenerator, MessageIdGeneratorReal, PaymentThresholds, - ScanIntervals, SubsFactory, DEFAULT_EARNING_WALLET, DEFAULT_PAYMENT_THRESHOLDS, - DEFAULT_SCAN_INTERVALS, MSG_ID_INCREMENTER, TEMPORARY_CONSUMING_WALLET, + AccountantSubsFactoryReal, DetailedScanType, MessageIdGenerator, MessageIdGeneratorReal, + PaymentThresholds, ScanIntervals, SubsFactory, DEFAULT_EARNING_WALLET, + DEFAULT_PAYMENT_THRESHOLDS, MSG_ID_INCREMENTER, TEMPORARY_CONSUMING_WALLET, }; use crate::sub_lib::wallet::Wallet; use crate::test_utils::recorder::{make_accountant_subs_from_recorder, Recorder}; use actix::Actor; + use masq_lib::blockchains::chains::Chain; + use masq_lib::messages::ScanType; use std::str::FromStr; use std::sync::atomic::Ordering; use std::sync::Mutex; use std::time::Duration; + impl From for ScanType { + fn from(scan_type: DetailedScanType) -> Self { + match scan_type { + DetailedScanType::NewPayables => ScanType::Payables, + DetailedScanType::RetryPayables => ScanType::Payables, + DetailedScanType::PendingPayables => ScanType::PendingPayables, + DetailedScanType::Receivables => ScanType::Receivables, + } + } + } + static MSG_ID_GENERATOR_TEST_GUARD: Mutex<()> = Mutex::new(()); impl PaymentThresholds { @@ -230,12 +254,6 @@ mod tests { threshold_interval_sec: 21600, unban_below_gwei: 500_000_000, }; - let scan_intervals_expected = ScanIntervals { - pending_payable_scan_interval: Duration::from_secs(600), - payable_scan_interval: Duration::from_secs(600), - receivable_scan_interval: Duration::from_secs(600), - }; - assert_eq!(*DEFAULT_SCAN_INTERVALS, scan_intervals_expected); assert_eq!(*DEFAULT_PAYMENT_THRESHOLDS, payment_thresholds_expected); assert_eq!(*DEFAULT_EARNING_WALLET, default_earning_wallet_expected); assert_eq!( @@ -288,4 +306,34 @@ mod tests { assert_eq!(id, 0) } + + #[test] + fn default_for_scan_intervals_can_be_computed() { + let chain_a = Chain::BaseMainnet; + let chain_b = Chain::PolyMainnet; + + let result_a = ScanIntervals::compute_default(chain_a); + let result_b = ScanIntervals::compute_default(chain_b); + + assert_eq!( + result_a, + ScanIntervals { + payable_scan_interval: Duration::from_secs(600), + pending_payable_scan_interval: Duration::from_secs( + chain_a.rec().default_pending_payable_interval_sec + ), + receivable_scan_interval: Duration::from_secs(600), + } + ); + assert_eq!( + result_b, + ScanIntervals { + payable_scan_interval: Duration::from_secs(600), + pending_payable_scan_interval: Duration::from_secs( + chain_b.rec().default_pending_payable_interval_sec + ), + receivable_scan_interval: Duration::from_secs(600), + } + ); + } } diff --git a/node/src/sub_lib/blockchain_bridge.rs b/node/src/sub_lib/blockchain_bridge.rs index 2ec840cc93..25834d5f65 100644 --- a/node/src/sub_lib/blockchain_bridge.rs +++ b/node/src/sub_lib/blockchain_bridge.rs @@ -1,13 +1,20 @@ // Copyright (c) 2019, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. -use crate::accountant::db_access_objects::payable_dao::PayableAccount; -use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::blockchain_agent::BlockchainAgent; -use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::msgs::QualifiedPayablesMessage; -use crate::accountant::{RequestTransactionReceipts, ResponseSkeleton, SkeletonOptHolder}; -use crate::blockchain::blockchain_bridge::RetrieveTransactions; +use crate::accountant::scanners::payable_scanner::msgs::InitialTemplatesMessage; +use crate::accountant::scanners::payable_scanner::tx_templates::priced::new::PricedNewTxTemplates; +use crate::accountant::scanners::payable_scanner::tx_templates::priced::retry::PricedRetryTxTemplates; +use crate::accountant::{ + PayableScanType, RequestTransactionReceipts, ResponseSkeleton, SkeletonOptHolder, +}; +use crate::blockchain::blockchain_agent::BlockchainAgent; +use crate::blockchain::blockchain_bridge::{ + MsgInterpretableAsDetailedScanType, RetrieveTransactions, +}; +use crate::sub_lib::accountant::DetailedScanType; use crate::sub_lib::peer_actors::BindMessage; use actix::Message; use actix::Recipient; +use itertools::Either; use masq_lib::blockchains::chains::Chain; use masq_lib::ui_gateway::NodeFromUiMessage; use std::fmt; @@ -27,7 +34,7 @@ pub struct BlockchainBridgeConfig { pub struct BlockchainBridgeSubs { pub bind: Recipient, pub outbound_payments_instructions: Recipient, - pub qualified_payables: Recipient, + pub qualified_payables: Recipient, pub retrieve_transactions: Recipient, pub ui_sub: Recipient, pub request_transaction_receipts: Recipient, @@ -41,23 +48,39 @@ impl Debug for BlockchainBridgeSubs { #[derive(Message)] pub struct OutboundPaymentsInstructions { - pub affordable_accounts: Vec, + pub priced_templates: Either, pub agent: Box, pub response_skeleton_opt: Option, } +impl MsgInterpretableAsDetailedScanType for OutboundPaymentsInstructions { + fn detailed_scan_type(&self) -> DetailedScanType { + match self.priced_templates { + Either::Left(_) => DetailedScanType::NewPayables, + Either::Right(_) => DetailedScanType::RetryPayables, + } + } +} + impl OutboundPaymentsInstructions { pub fn new( - affordable_accounts: Vec, + priced_templates: Either, agent: Box, response_skeleton_opt: Option, ) -> Self { Self { - affordable_accounts, + priced_templates, agent, response_skeleton_opt, } } + + pub fn scan_type(&self) -> PayableScanType { + match &self.priced_templates { + Either::Left(_new_templates) => PayableScanType::New, + Either::Right(_retry_templates) => PayableScanType::Retry, + } + } } impl SkeletonOptHolder for OutboundPaymentsInstructions { @@ -83,12 +106,21 @@ impl ConsumingWalletBalances { #[cfg(test)] mod tests { + use crate::accountant::scanners::payable_scanner::tx_templates::priced::retry::PricedRetryTxTemplates; + use crate::accountant::scanners::payable_scanner::tx_templates::test_utils::make_priced_new_tx_templates; + use crate::accountant::test_utils::make_payable_account; use crate::actor_system_factory::SubsFactory; - use crate::blockchain::blockchain_bridge::{BlockchainBridge, BlockchainBridgeSubsFactoryReal}; + use crate::blockchain::blockchain_agent::test_utils::BlockchainAgentMock; + use crate::blockchain::blockchain_bridge::{ + BlockchainBridge, BlockchainBridgeSubsFactoryReal, MsgInterpretableAsDetailedScanType, + }; use crate::blockchain::test_utils::make_blockchain_interface_web3; + use crate::sub_lib::accountant::DetailedScanType; + use crate::sub_lib::blockchain_bridge::OutboundPaymentsInstructions; use crate::test_utils::persistent_configuration_mock::PersistentConfigurationMock; use crate::test_utils::recorder::{make_blockchain_bridge_subs_from_recorder, Recorder}; use actix::{Actor, System}; + use itertools::Either; use masq_lib::utils::find_free_port; use std::sync::{Arc, Mutex}; @@ -120,4 +152,24 @@ mod tests { system.run(); assert_eq!(subs, BlockchainBridge::make_subs_from(&addr)) } + + #[test] + fn detailed_scan_type_is_implemented_for_outbound_payments_instructions() { + let msg_a = OutboundPaymentsInstructions { + priced_templates: Either::Left(make_priced_new_tx_templates(vec![( + make_payable_account(123), + 123, + )])), + agent: Box::new(BlockchainAgentMock::default()), + response_skeleton_opt: None, + }; + let msg_b = OutboundPaymentsInstructions { + priced_templates: Either::Right(PricedRetryTxTemplates(vec![])), + agent: Box::new(BlockchainAgentMock::default()), + response_skeleton_opt: None, + }; + + assert_eq!(msg_a.detailed_scan_type(), DetailedScanType::NewPayables); + assert_eq!(msg_b.detailed_scan_type(), DetailedScanType::RetryPayables) + } } diff --git a/node/src/sub_lib/combined_parameters.rs b/node/src/sub_lib/combined_parameters.rs index f70f60f0fc..bd26eb627f 100644 --- a/node/src/sub_lib/combined_parameters.rs +++ b/node/src/sub_lib/combined_parameters.rs @@ -177,8 +177,8 @@ impl CombinedParams { ScanIntervals, &parsed_values, Duration::from_secs, - "pending_payable_scan_interval", "payable_scan_interval", + "pending_payable_scan_interval", "receivable_scan_interval" ))) } @@ -208,8 +208,8 @@ impl From<&CombinedParams> for &[(&str, CombinedParamsDataTypes)] { ("unban_below_gwei", U64), ], CombinedParams::ScanIntervals(Uninitialized) => &[ - ("pending_payable_scan_interval", U64), ("payable_scan_interval", U64), + ("pending_payable_scan_interval", U64), ("receivable_scan_interval", U64), ], _ => panic!( @@ -225,8 +225,8 @@ impl Display for ScanIntervals { write!( f, "{}|{}|{}", - self.pending_payable_scan_interval.as_secs(), self.payable_scan_interval.as_secs(), + self.pending_payable_scan_interval.as_secs(), self.receivable_scan_interval.as_secs() ) } @@ -307,6 +307,7 @@ mod tests { use super::*; use crate::sub_lib::combined_parameters::CombinedParamsDataTypes::U128; use crate::sub_lib::neighborhood::DEFAULT_RATE_PACK; + use crate::test_utils::unshared_test_utils::TEST_SCAN_INTERVALS; use std::panic::catch_unwind; #[test] @@ -400,8 +401,8 @@ mod tests { assert_eq!( scan_interval, &[ - ("pending_payable_scan_interval", U64), ("payable_scan_interval", U64), + ("pending_payable_scan_interval", U64), ("receivable_scan_interval", U64), ] ); @@ -455,7 +456,7 @@ mod tests { let panic_3 = catch_unwind(|| { let _: &[(&str, CombinedParamsDataTypes)] = - (&CombinedParams::ScanIntervals(Initialized(ScanIntervals::default()))).into(); + (&CombinedParams::ScanIntervals(Initialized(*TEST_SCAN_INTERVALS))).into(); }) .unwrap_err(); let panic_3_msg = panic_3.downcast_ref::().unwrap(); @@ -464,7 +465,7 @@ mod tests { panic_3_msg, &format!( "should be called only on uninitialized object, not: ScanIntervals(Initialized({:?}))", - ScanIntervals::default() + *TEST_SCAN_INTERVALS ) ); } @@ -502,7 +503,7 @@ mod tests { ); let panic_3 = catch_unwind(|| { - (&CombinedParams::ScanIntervals(Initialized(ScanIntervals::default()))) + (&CombinedParams::ScanIntervals(Initialized(*TEST_SCAN_INTERVALS))) .initialize_objects(HashMap::new()); }) .unwrap_err(); @@ -512,7 +513,7 @@ mod tests { panic_3_msg, &format!( "should be called only on uninitialized object, not: ScanIntervals(Initialized({:?}))", - ScanIntervals::default() + *TEST_SCAN_INTERVALS ) ); } @@ -550,15 +551,15 @@ mod tests { #[test] fn scan_intervals_from_combined_params() { - let scan_intervals_str = "110|115|113"; + let scan_intervals_str = "115|55|113"; let result = ScanIntervals::try_from(scan_intervals_str).unwrap(); assert_eq!( result, ScanIntervals { - pending_payable_scan_interval: Duration::from_secs(110), payable_scan_interval: Duration::from_secs(115), + pending_payable_scan_interval: Duration::from_secs(55), receivable_scan_interval: Duration::from_secs(113) } ) @@ -567,14 +568,14 @@ mod tests { #[test] fn scan_intervals_to_combined_params() { let scan_intervals = ScanIntervals { - pending_payable_scan_interval: Duration::from_secs(60), - payable_scan_interval: Duration::from_secs(70), + payable_scan_interval: Duration::from_secs(90), + pending_payable_scan_interval: Duration::from_secs(40), receivable_scan_interval: Duration::from_secs(100), }; let result = scan_intervals.to_string(); - assert_eq!(result, "60|70|100".to_string()); + assert_eq!(result, "90|40|100".to_string()); } #[test] diff --git a/node/src/sub_lib/peer_actors.rs b/node/src/sub_lib/peer_actors.rs index 3a51be868d..571eca3fe1 100644 --- a/node/src/sub_lib/peer_actors.rs +++ b/node/src/sub_lib/peer_actors.rs @@ -14,6 +14,8 @@ use std::fmt::Debug; use std::fmt::Formatter; use std::net::IpAddr; +// TODO This file should be test only + #[derive(Clone, PartialEq, Eq)] pub struct PeerActors { pub proxy_server: ProxyServerSubs, diff --git a/node/src/sub_lib/utils.rs b/node/src/sub_lib/utils.rs index d68d721bba..5bd7a655a4 100644 --- a/node/src/sub_lib/utils.rs +++ b/node/src/sub_lib/utils.rs @@ -222,6 +222,7 @@ impl NLSpawnHandleHolder for NLSpawnHandleHolderReal { } } +#[derive(Default)] pub struct NotifyHandleReal { phantom: PhantomData, } diff --git a/node/src/test_utils/database_utils.rs b/node/src/test_utils/database_utils.rs index 02ba441a4d..a2b6d9ee1a 100644 --- a/node/src/test_utils/database_utils.rs +++ b/node/src/test_utils/database_utils.rs @@ -103,10 +103,16 @@ pub fn retrieve_config_row(conn: &dyn ConnectionWrapper, name: &str) -> (Option< }) } +pub fn assert_table_exists(conn: &dyn ConnectionWrapper, table_name: &str) { + let result = conn.prepare(&format!("select * from {}", table_name)); + assert!(result.is_ok(), "Table {} should exist", table_name); +} + pub fn assert_table_does_not_exist(conn: &dyn ConnectionWrapper, table_name: &str) { - let error_stm = conn - .prepare(&format!("select * from {}", table_name)) - .unwrap_err(); + let error_stm = match conn.prepare(&format!("select * from {}", table_name)) { + Ok(_) => panic!("Table {} should not exist, but it does", table_name), + Err(e) => e, + }; let error_msg = match error_stm { Error::SqliteFailure(_, Some(msg)) => msg, x => panic!("we expected SqliteFailure but we got: {:?}", x), diff --git a/node/src/test_utils/mod.rs b/node/src/test_utils/mod.rs index 351b27711c..eeb2c9560d 100644 --- a/node/src/test_utils/mod.rs +++ b/node/src/test_utils/mod.rs @@ -12,7 +12,9 @@ pub mod logfile_name_guard; pub mod neighborhood_test_utils; pub mod persistent_configuration_mock; pub mod recorder; +pub mod recorder_counter_msgs; pub mod recorder_stop_conditions; +pub mod serde_serializer_mock; pub mod stream_connector_mock; pub mod tcp_wrapper_mocks; pub mod tokio_wrapper_mocks; @@ -520,13 +522,14 @@ pub mod unshared_test_utils { use crate::test_utils::neighborhood_test_utils::MIN_HOPS_FOR_TEST; use crate::test_utils::persistent_configuration_mock::PersistentConfigurationMock; use crate::test_utils::recorder::{make_recorder, Recorder, Recording}; - use crate::test_utils::recorder_stop_conditions::{StopCondition, StopConditions}; + use crate::test_utils::recorder_stop_conditions::{MsgIdentification, StopConditions}; use crate::test_utils::unshared_test_utils::system_killer_actor::SystemKillerActor; use actix::{Actor, Addr, AsyncContext, Context, Handler, Recipient, System}; use actix::{Message, SpawnHandle}; use crossbeam_channel::{unbounded, Receiver, Sender}; use itertools::Either; use lazy_static::lazy_static; + use masq_lib::blockchains::chains::Chain; use masq_lib::constants::HTTP_PORT; use masq_lib::messages::{ToMessageBody, UiCrashRequest}; use masq_lib::multi_config::MultiConfig; @@ -549,6 +552,18 @@ pub mod unshared_test_utils { pub assertions: Box, } + pub fn capture_digits_with_separators_from_str( + surveyed_str: &str, + length_between_separators: usize, + separator: char, + ) -> Vec { + let regex = + format!("(\\d{{1,{length_between_separators}}}(?:{separator}\\d{{{length_between_separators}}})+)"); + let re = regex::Regex::new(®ex).unwrap(); + let captures = re.captures_iter(surveyed_str); + captures.map(|capture| capture[1].to_string()).collect() + } + pub fn assert_on_initialization_with_panic_on_migration(data_dir: &Path, act: &A) where A: Fn(&Path) + ?Sized, @@ -609,6 +624,14 @@ pub mod unshared_test_utils { MultiConfig::new_test_only(arg_matches) } + lazy_static! { + pub static ref TEST_SCAN_INTERVALS: ScanIntervals = ScanIntervals { + payable_scan_interval: Duration::from_secs(600), + pending_payable_scan_interval: Duration::from_secs(360), + receivable_scan_interval: Duration::from_secs(600), + }; + } + pub const ZERO: u32 = 0b0; pub const MAPPING_PROTOCOL: u32 = 0b000010; pub const ACCOUNTANT_CONFIG_PARAMS: u32 = 0b000100; @@ -653,17 +676,17 @@ pub mod unshared_test_utils { ) -> PersistentConfigurationMock { persistent_config_mock .payment_thresholds_result(Ok(PaymentThresholds::default())) - .scan_intervals_result(Ok(ScanIntervals::default())) + .scan_intervals_result(Ok(*TEST_SCAN_INTERVALS)) } pub fn make_persistent_config_real_with_config_dao_null() -> PersistentConfigurationReal { PersistentConfigurationReal::new(Box::new(ConfigDaoNull::default())) } - pub fn make_bc_with_defaults() -> BootstrapperConfig { + pub fn make_bc_with_defaults(chain: Chain) -> BootstrapperConfig { let mut config = BootstrapperConfig::new(); - config.scan_intervals_opt = Some(ScanIntervals::default()); - config.suppress_initial_scans = false; + config.scan_intervals_opt = Some(ScanIntervals::compute_default(chain)); + config.automatic_scans_enabled = true; config.when_pending_too_long_sec = DEFAULT_PENDING_TOO_LONG_SEC; config.payment_thresholds_opt = Some(PaymentThresholds::default()); config @@ -679,9 +702,9 @@ pub mod unshared_test_utils { { let (recorder, _, recording_arc) = make_recorder(); let recorder = match stopping_message { - Some(type_id) => recorder.system_stop_conditions(StopConditions::All(vec![ - StopCondition::StopOnType(type_id), - ])), // No need to write stop message after this + Some(type_id) => recorder.system_stop_conditions(StopConditions::AllLazily(vec![ + MsgIdentification::ByType(type_id), + ])), // This will take care of stopping the system None => recorder, }; let addr = recorder.start(); @@ -852,17 +875,23 @@ pub mod unshared_test_utils { pub mod notify_handlers { use super::*; + use std::fmt::Debug; pub struct NotifyLaterHandleMock { notify_later_params: Arc>>, + stop_system_on_count_received_opt: RefCell>, send_message_out: bool, + // To prove that no msg was tried to be scheduled + panic_on_schedule_attempt: bool, } impl Default for NotifyLaterHandleMock { fn default() -> Self { Self { notify_later_params: Arc::new(Mutex::new(vec![])), + stop_system_on_count_received_opt: RefCell::new(None), send_message_out: false, + panic_on_schedule_attempt: false, } } } @@ -873,15 +902,30 @@ pub mod unshared_test_utils { self } + pub fn stop_system_on_count_received(self, count: usize) -> Self { + if count == 0 { + panic!("Should be a none-zero value") + } + let system_killer = SystemKillerActor::new(Duration::from_secs(10)); + system_killer.start(); + self.stop_system_on_count_received_opt.replace(Some(count)); + self + } + pub fn capture_msg_and_let_it_fly_on(mut self) -> Self { self.send_message_out = true; self } + + pub fn panic_on_schedule_attempt(mut self) -> Self { + self.panic_on_schedule_attempt = true; + self + } } impl NotifyLaterHandle for NotifyLaterHandleMock where - M: Message + 'static + Clone, + M: Message + Clone + Debug + Send + 'static, A: Actor> + Handler, { fn notify_later<'a>( @@ -890,10 +934,25 @@ pub mod unshared_test_utils { interval: Duration, ctx: &'a mut Context, ) -> Box { + if self.panic_on_schedule_attempt { + panic!( + "Message scheduling request for {:?} and interval {}ms, thought not expected", + msg, + interval.as_millis() + ); + } self.notify_later_params .lock() .unwrap() .push((msg.clone(), interval)); + if let Some(remaining) = + self.stop_system_on_count_received_opt.borrow_mut().as_mut() + { + *remaining -= 1; + if remaining == &0 { + System::current().stop(); + } + } if self.send_message_out { let handle = ctx.notify_later(msg, interval); Box::new(NLSpawnHandleHolderReal::new(handle)) @@ -914,6 +973,8 @@ pub mod unshared_test_utils { pub struct NotifyHandleMock { notify_params: Arc>>, send_message_out: bool, + stop_system_on_count_received_opt: RefCell>, + panic_on_schedule_attempt: bool, } impl Default for NotifyHandleMock { @@ -921,6 +982,8 @@ pub mod unshared_test_utils { Self { notify_params: Arc::new(Mutex::new(vec![])), send_message_out: false, + stop_system_on_count_received_opt: RefCell::new(None), + panic_on_schedule_attempt: false, } } } @@ -931,19 +994,50 @@ pub mod unshared_test_utils { self } - pub fn permit_to_send_out(mut self) -> Self { + pub fn capture_msg_and_let_it_fly_on(mut self) -> Self { self.send_message_out = true; self } + + pub fn stop_system_on_count_received(self, msg_count: usize) -> Self { + if msg_count == 0 { + panic!("Should be a non-zero value") + } + let system_killer = SystemKillerActor::new(Duration::from_secs(10)); + system_killer.start(); + self.stop_system_on_count_received_opt + .replace(Some(msg_count)); + self + } + + pub fn panic_on_schedule_attempt(mut self) -> Self { + self.panic_on_schedule_attempt = true; + self + } } impl NotifyHandle for NotifyHandleMock where - M: Message + 'static + Clone, + M: Message + Debug + Clone + 'static, A: Actor> + Handler, { fn notify<'a>(&'a self, msg: M, ctx: &'a mut Context) { + if self.panic_on_schedule_attempt { + panic!( + "Message scheduling request for {:?}, thought not expected", + msg + ) + } self.notify_params.lock().unwrap().push(msg.clone()); + if let Some(remaining) = + self.stop_system_on_count_received_opt.borrow_mut().as_mut() + { + *remaining -= 1; + if remaining == &0 { + System::current().stop(); + return; + } + } if self.send_message_out { ctx.notify(msg) } @@ -965,7 +1059,7 @@ pub mod unshared_test_utils { // you've pasted in before at the other end. // 3) Using raw pointers to link the real memory address to your objects does not lead to good // results in all cases (It was found confusing and hard to be done correctly or even impossible - // to implement especially for references pointing to a dereferenced Box that was originally + // to implement, especially for references pointing to a dereferenced Box that was originally // supplied as an owned argument into the testing environment at the beginning, or we can // suspect the memory link already broken because of moves of the owned boxed instance // around the subjected code) diff --git a/node/src/test_utils/recorder.rs b/node/src/test_utils/recorder.rs index f66125182c..f52b1a0c83 100644 --- a/node/src/test_utils/recorder.rs +++ b/node/src/test_utils/recorder.rs @@ -1,14 +1,15 @@ // Copyright (c) 2019, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. #![cfg(test)] -use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::msgs::BlockchainAgentWithContextMessage; -use crate::accountant::scanners::mid_scan_msg_handling::payable_scanner::msgs::QualifiedPayablesMessage; -use crate::accountant::ReportTransactionReceipts; +use crate::accountant::scanners::payable_scanner::msgs::{ + InitialTemplatesMessage, PricedTemplatesMessage, +}; use crate::accountant::{ - ReceivedPayments, RequestTransactionReceipts, ScanError, ScanForPayables, - ScanForPendingPayables, ScanForReceivables, SentPayables, + ReceivedPayments, RequestTransactionReceipts, ScanError, ScanForNewPayables, + ScanForReceivables, SentPayables, }; -use crate::blockchain::blockchain_bridge::PendingPayableFingerprintSeeds; +use crate::accountant::{ScanForPendingPayables, ScanForRetryPayables, TxReceiptsMessage}; +use crate::blockchain::blockchain_bridge::RegisterNewPendingPayables; use crate::blockchain::blockchain_bridge::RetrieveTransactions; use crate::daemon::crash_notification::CrashNotification; use crate::daemon::DaemonBindMessage; @@ -20,20 +21,19 @@ use crate::sub_lib::accountant::ReportRoutingServiceProvidedMessage; use crate::sub_lib::accountant::ReportServicesConsumedMessage; use crate::sub_lib::blockchain_bridge::BlockchainBridgeSubs; use crate::sub_lib::blockchain_bridge::OutboundPaymentsInstructions; +use crate::sub_lib::configurator::ConfiguratorSubs; use crate::sub_lib::dispatcher::InboundClientData; use crate::sub_lib::dispatcher::{DispatcherSubs, StreamShutdownMsg}; use crate::sub_lib::hopper::IncipientCoresPackage; use crate::sub_lib::hopper::{ExpiredCoresPackage, NoLookupIncipientCoresPackage}; use crate::sub_lib::hopper::{HopperSubs, MessageType}; use crate::sub_lib::neighborhood::NeighborhoodSubs; -use crate::sub_lib::neighborhood::{ConfigChangeMsg, ConnectionProgressMessage}; - -use crate::sub_lib::configurator::ConfiguratorSubs; use crate::sub_lib::neighborhood::NodeQueryResponseMetadata; use crate::sub_lib::neighborhood::RemoveNeighborMessage; use crate::sub_lib::neighborhood::RouteQueryMessage; use crate::sub_lib::neighborhood::RouteQueryResponse; use crate::sub_lib::neighborhood::UpdateNodeRecordMetadataMessage; +use crate::sub_lib::neighborhood::{ConfigChangeMsg, ConnectionProgressMessage}; use crate::sub_lib::neighborhood::{DispatcherNodeQueryMessage, GossipFailure_0v1}; use crate::sub_lib::peer_actors::PeerActors; use crate::sub_lib::peer_actors::{BindMessage, NewPublicIp, StartMessage}; @@ -47,8 +47,11 @@ use crate::sub_lib::stream_handler_pool::DispatcherNodeQueryResponse; use crate::sub_lib::stream_handler_pool::TransmitDataMsg; use crate::sub_lib::ui_gateway::UiGatewaySubs; use crate::sub_lib::utils::MessageScheduler; +use crate::test_utils::recorder_counter_msgs::{ + CounterMessages, CounterMsgGear, SingleTypeCounterMsgSetup, +}; use crate::test_utils::recorder_stop_conditions::{ - ForcedMatchable, PretendedMatchableWrapper, StopCondition, StopConditions, + ForcedMatchable, MsgIdentification, PretendedMatchableWrapper, StopConditions, }; use crate::test_utils::to_millis; use crate::test_utils::unshared_test_utils::system_killer_actor::SystemKillerActor; @@ -59,7 +62,7 @@ use actix::MessageResult; use actix::System; use actix::{Actor, Message}; use masq_lib::ui_gateway::{NodeFromUiMessage, NodeToUiMessage}; -use std::any::{Any, TypeId}; +use std::any::{type_name, Any, TypeId}; use std::sync::{Arc, Mutex}; use std::thread; use std::time::Duration; @@ -70,6 +73,7 @@ pub struct Recorder { recording: Arc>, node_query_responses: Vec>, route_query_responses: Vec>, + counter_msgs_opt: Option, stop_conditions_opt: Option, } @@ -101,7 +105,7 @@ macro_rules! message_handler_common { macro_rules! matchable { ($message_type: ty) => { impl ForcedMatchable<$message_type> for $message_type { - fn correct_msg_type_id(&self) -> TypeId { + fn trigger_msg_type_id(&self) -> TypeId { TypeId::of::<$message_type>() } } @@ -127,7 +131,7 @@ recorder_message_handler_t_m_p!(AddReturnRouteMessage); recorder_message_handler_t_m_p!(AddRouteResultMessage); recorder_message_handler_t_p!(AddStreamMsg); recorder_message_handler_t_m_p!(BindMessage); -recorder_message_handler_t_p!(BlockchainAgentWithContextMessage); +recorder_message_handler_t_p!(PricedTemplatesMessage); recorder_message_handler_t_m_p!(ConfigChangeMsg); recorder_message_handler_t_m_p!(ConnectionProgressMessage); recorder_message_handler_t_m_p!(CrashNotification); @@ -149,20 +153,21 @@ recorder_message_handler_t_m_p!(NodeFromUiMessage); recorder_message_handler_t_m_p!(NodeToUiMessage); recorder_message_handler_t_m_p!(NoLookupIncipientCoresPackage); recorder_message_handler_t_p!(OutboundPaymentsInstructions); -recorder_message_handler_t_m_p!(PendingPayableFingerprintSeeds); +recorder_message_handler_t_m_p!(RegisterNewPendingPayables); recorder_message_handler_t_m_p!(PoolBindMessage); -recorder_message_handler_t_m_p!(QualifiedPayablesMessage); +recorder_message_handler_t_m_p!(InitialTemplatesMessage); recorder_message_handler_t_m_p!(ReceivedPayments); recorder_message_handler_t_m_p!(RemoveNeighborMessage); recorder_message_handler_t_m_p!(RemoveStreamMsg); recorder_message_handler_t_m_p!(ReportExitServiceProvidedMessage); recorder_message_handler_t_m_p!(ReportRoutingServiceProvidedMessage); recorder_message_handler_t_m_p!(ReportServicesConsumedMessage); -recorder_message_handler_t_m_p!(ReportTransactionReceipts); +recorder_message_handler_t_m_p!(TxReceiptsMessage); recorder_message_handler_t_m_p!(RequestTransactionReceipts); recorder_message_handler_t_m_p!(RetrieveTransactions); recorder_message_handler_t_m_p!(ScanError); -recorder_message_handler_t_m_p!(ScanForPayables); +recorder_message_handler_t_m_p!(ScanForNewPayables); +recorder_message_handler_t_m_p!(ScanForRetryPayables); recorder_message_handler_t_m_p!(ScanForPendingPayables); recorder_message_handler_t_m_p!(ScanForReceivables); recorder_message_handler_t_m_p!(SentPayables); @@ -187,7 +192,7 @@ where OuterM: PartialEq + 'static, InnerM: PartialEq + Send + Message, { - fn correct_msg_type_id(&self) -> TypeId { + fn trigger_msg_type_id(&self) -> TypeId { TypeId::of::() } } @@ -210,6 +215,16 @@ impl Handler for Recorder { matchable!(RouteQueryMessage); +impl Handler for Recorder { + type Result = (); + + fn handle(&mut self, msg: SetUpCounterMsgs, _ctx: &mut Self::Context) -> Self::Result { + msg.setups + .into_iter() + .for_each(|msg_setup| self.add_counter_msg(msg_setup)) + } +} + fn extract_response(responses: &mut Vec, err_msg: &str) -> T where T: Clone, @@ -261,11 +276,21 @@ impl Recorder { self.start_system_killer(); self.stop_conditions_opt = Some(stop_conditions) } else { - panic!("Stop conditions must be set by a single method call. Consider to use StopConditions::All") + panic!("Stop conditions must be set by a single method call. Consider using StopConditions::All") }; self } + fn add_counter_msg(&mut self, counter_msg_setup: SingleTypeCounterMsgSetup) { + if let Some(counter_msgs) = self.counter_msgs_opt.as_mut() { + counter_msgs.add_msg(counter_msg_setup) + } else { + let mut counter_msgs = CounterMessages::default(); + counter_msgs.add_msg(counter_msg_setup); + self.counter_msgs_opt = Some(counter_msgs) + } + } + fn start_system_killer(&mut self) { let system_killer = SystemKillerActor::new(Duration::from_secs(15)); system_killer.start(); @@ -275,7 +300,9 @@ impl Recorder { where M: 'static + ForcedMatchable + Send, { - let kill_system = if let Some(stop_conditions) = &mut self.stop_conditions_opt { + let counter_msg_opt = self.check_on_counter_msg(&msg); + + let stop_system = if let Some(stop_conditions) = &mut self.stop_conditions_opt { stop_conditions.resolve_stop_conditions::(&msg) } else { false @@ -283,7 +310,11 @@ impl Recorder { self.record(msg); - if kill_system { + if let Some(sendable_msgs) = counter_msg_opt { + sendable_msgs.into_iter().for_each(|msg| msg.try_send()) + } + + if stop_system { System::current().stop() } } @@ -295,6 +326,17 @@ impl Recorder { { self.handle_msg_t_m_p(PretendedMatchableWrapper(msg)) } + + fn check_on_counter_msg(&mut self, msg: &M) -> Option>> + where + M: ForcedMatchable + 'static, + { + if let Some(counter_msgs) = self.counter_msgs_opt.as_mut() { + counter_msgs.search_for_msg_gear(msg) + } else { + None + } + } } impl Recording { @@ -344,14 +386,15 @@ impl Recording { match item_box.downcast_ref::() { Some(item) => Ok(item), None => { - // double-checking for an uncommon, yet possible other type of an actor message, which doesn't implement PartialEq + // double-checking for an uncommon, yet possible other type of actor message, which doesn't implement PartialEq let item_opt = item_box.downcast_ref::>(); match item_opt { Some(item) => Ok(&item.0), None => Err(format!( - "Message {:?} could not be downcast to the expected type", - item_box + "Message {:?} could not be downcast to the expected type {}.", + item_box, + type_name::() )), } } @@ -385,6 +428,27 @@ impl RecordAwaiter { } } +#[derive(Message)] +pub struct SetUpCounterMsgs { + // Trigger msg - it arrives at the Recorder from the Actor being tested and matches one of the + // msg ID methods. + // Counter msg - it is sent back from the Recorder when a trigger msg is recognized + // + // In general, the triggering is data driven. Shuffling with the setups of differently typed + // trigger messages can't have any adverse effect. + // + // However, setups of the same trigger message types compose clusters. + // Keep in mind these are tested over their ID method sequentially, according to the order + // in which they are fed into this vector, with the other messages ignored. + setups: Vec, +} + +impl SetUpCounterMsgs { + pub fn new(setups: Vec) -> Self { + Self { setups } + } +} + pub fn make_recorder() -> (Recorder, RecordAwaiter, Arc>) { let recorder = Recorder::new(); let awaiter = recorder.get_awaiter(); @@ -463,10 +527,10 @@ pub fn make_accountant_subs_from_recorder(addr: &Addr) -> AccountantSu report_routing_service_provided: recipient!(addr, ReportRoutingServiceProvidedMessage), report_exit_service_provided: recipient!(addr, ReportExitServiceProvidedMessage), report_services_consumed: recipient!(addr, ReportServicesConsumedMessage), - report_payable_payments_setup: recipient!(addr, BlockchainAgentWithContextMessage), + report_payable_payments_setup: recipient!(addr, PricedTemplatesMessage), report_inbound_payments: recipient!(addr, ReceivedPayments), - init_pending_payable_fingerprints: recipient!(addr, PendingPayableFingerprintSeeds), - report_transaction_receipts: recipient!(addr, ReportTransactionReceipts), + register_new_pending_payables: recipient!(addr, RegisterNewPendingPayables), + report_transaction_status: recipient!(addr, TxReceiptsMessage), report_sent_payments: recipient!(addr, SentPayables), scan_errors: recipient!(addr, ScanError), ui_message_sub: recipient!(addr, NodeFromUiMessage), @@ -485,7 +549,7 @@ pub fn make_blockchain_bridge_subs_from_recorder(addr: &Addr) -> Block BlockchainBridgeSubs { bind: recipient!(addr, BindMessage), outbound_payments_instructions: recipient!(addr, OutboundPaymentsInstructions), - qualified_payables: recipient!(addr, QualifiedPayablesMessage), + qualified_payables: recipient!(addr, InitialTemplatesMessage), retrieve_transactions: recipient!(addr, RetrieveTransactions), ui_sub: recipient!(addr, NodeFromUiMessage), request_transaction_receipts: recipient!(addr, RequestTransactionReceipts), @@ -576,8 +640,9 @@ impl PeerActorsBuilder { self } - // This must be called after System.new and before System.run - pub fn build(self) -> PeerActors { + // This must be called after System.new and before System.run. + // These addresses may be helpful for setting up the Counter Messages. + pub fn build_and_provide_addresses(self) -> (PeerActors, PeerActorAddrs) { let proxy_server_addr = self.proxy_server.start(); let dispatcher_addr = self.dispatcher.start(); let hopper_addr = self.hopper.start(); @@ -588,27 +653,73 @@ impl PeerActorsBuilder { let blockchain_bridge_addr = self.blockchain_bridge.start(); let configurator_addr = self.configurator.start(); - PeerActors { - proxy_server: make_proxy_server_subs_from_recorder(&proxy_server_addr), - dispatcher: make_dispatcher_subs_from_recorder(&dispatcher_addr), - hopper: make_hopper_subs_from_recorder(&hopper_addr), - proxy_client_opt: Some(make_proxy_client_subs_from_recorder(&proxy_client_addr)), - neighborhood: make_neighborhood_subs_from_recorder(&neighborhood_addr), - accountant: make_accountant_subs_from_recorder(&accountant_addr), - ui_gateway: make_ui_gateway_subs_from_recorder(&ui_gateway_addr), - blockchain_bridge: make_blockchain_bridge_subs_from_recorder(&blockchain_bridge_addr), - configurator: make_configurator_subs_from_recorder(&configurator_addr), - } + ( + PeerActors { + proxy_server: make_proxy_server_subs_from_recorder(&proxy_server_addr), + dispatcher: make_dispatcher_subs_from_recorder(&dispatcher_addr), + hopper: make_hopper_subs_from_recorder(&hopper_addr), + proxy_client_opt: Some(make_proxy_client_subs_from_recorder(&proxy_client_addr)), + neighborhood: make_neighborhood_subs_from_recorder(&neighborhood_addr), + accountant: make_accountant_subs_from_recorder(&accountant_addr), + ui_gateway: make_ui_gateway_subs_from_recorder(&ui_gateway_addr), + blockchain_bridge: make_blockchain_bridge_subs_from_recorder( + &blockchain_bridge_addr, + ), + configurator: make_configurator_subs_from_recorder(&configurator_addr), + }, + PeerActorAddrs { + proxy_server_addr, + dispatcher_addr, + hopper_addr, + proxy_client_addr, + neighborhood_addr, + accountant_addr, + ui_gateway_addr, + blockchain_bridge_addr, + configurator_addr, + }, + ) + } + + // This must be called after System.new and before System.run + pub fn build(self) -> PeerActors { + let (peer_actors, _) = self.build_and_provide_addresses(); + peer_actors } } +pub struct PeerActorAddrs { + pub proxy_server_addr: Addr, + pub dispatcher_addr: Addr, + pub hopper_addr: Addr, + pub proxy_client_addr: Addr, + pub neighborhood_addr: Addr, + pub accountant_addr: Addr, + pub ui_gateway_addr: Addr, + pub blockchain_bridge_addr: Addr, + pub configurator_addr: Addr, +} + #[cfg(test)] mod tests { use super::*; - use crate::match_every_type_id; + use crate::blockchain::blockchain_bridge::BlockchainBridge; + use crate::sub_lib::neighborhood::{ConfigChange, Hops, WalletPair}; + use crate::test_utils::make_wallet; + use crate::test_utils::recorder_counter_msgs::SendableCounterMsgWithRecipient; + use crate::{ + match_lazily_every_type_id, setup_for_counter_msg_triggered_via_specific_msg_id_method, + setup_for_counter_msg_triggered_via_type_id, + }; use actix::Message; use actix::System; + use masq_lib::messages::{ + SerializableLogLevel, ToMessageBody, UiLogBroadcast, UiUnmarshalError, + }; + use masq_lib::ui_gateway::MessageTarget; use std::any::TypeId; + use std::net::{IpAddr, Ipv4Addr}; + use std::vec; #[derive(Debug, PartialEq, Eq, Message)] struct FirstMessageType { @@ -669,7 +780,7 @@ mod tests { fn recorder_can_be_stopped_on_a_particular_message() { let system = System::new("recorder_can_be_stopped_on_a_particular_message"); let recorder = - Recorder::new().system_stop_conditions(match_every_type_id!(FirstMessageType)); + Recorder::new().system_stop_conditions(match_lazily_every_type_id!(FirstMessageType)); let recording_arc = recorder.get_recording(); let rec_addr: Addr = recorder.start(); @@ -705,4 +816,324 @@ mod tests { TypeId::of::>() ) } + + #[test] + fn counter_msgs_with_diff_id_methods_are_used_together_and_one_was_not_triggered() { + let (respondent, _, respondent_recording_arc) = make_recorder(); + let respondent = respondent.system_stop_conditions(match_lazily_every_type_id!( + ScanForReceivables, + NodeToUiMessage + )); + let respondent_addr = respondent.start(); + // Case 1 + // This msg will trigger as the recorder will detect the arrival of StartMessage (no more + // requirement). + let (trigger_message_1, cm_setup_1) = { + let trigger_msg = StartMessage {}; + let counter_msg = ScanForReceivables { + response_skeleton_opt: None, + }; + // Taking an opportunity to test a setup via the macro for the simplest identification, + // by the TypeId. + ( + trigger_msg, + setup_for_counter_msg_triggered_via_type_id!( + StartMessage, + counter_msg, + &respondent_addr + ), + ) + }; + // Case two + // This msg will not trigger as it is declared with a wrong TypeId of the supposed trigger + // msg. The supplied ID does not even belong to an Actor msg type. + let cm_setup_2 = { + let counter_msg_strayed = StartMessage {}; + let screwed_id = TypeId::of::(); + let id_method = MsgIdentification::ByType(screwed_id); + SingleTypeCounterMsgSetup::new( + screwed_id, + id_method, + vec![Box::new(SendableCounterMsgWithRecipient::new( + counter_msg_strayed, + respondent_addr.clone().recipient(), + ))], + ) + }; + // Case three + // This msg will not trigger as it is declared to have to be matched entirely (The message + // type, plus the data of the message). The expected msg and the actual sent msg bear + // different IP addresses. + let (trigger_msg_3_unmatching, cm_setup_3) = { + let trigger_msg = NewPublicIp { + new_ip: IpAddr::V4(Ipv4Addr::new(7, 7, 7, 7)), + }; + let type_id = trigger_msg.type_id(); + let counter_msg = NodeToUiMessage { + target: MessageTarget::ClientId(4), + body: UiUnmarshalError { + message: "abc".to_string(), + bad_data: "456".to_string(), + } + .tmb(0), + }; + let id_method = MsgIdentification::ByMatch { + exemplar: Box::new(NewPublicIp { + new_ip: IpAddr::V4(Ipv4Addr::new(7, 6, 5, 4)), + }), + }; + ( + trigger_msg, + SingleTypeCounterMsgSetup::new( + type_id, + id_method, + vec![Box::new(SendableCounterMsgWithRecipient::new( + counter_msg, + respondent_addr.clone().recipient(), + ))], + ), + ) + }; + // Case four + // This msg will trigger as the performed msg is an exact match of the expected msg. + let (trigger_msg_4_matching, cm_setup_4, counter_msg_4) = { + let trigger_msg = NewPublicIp { + new_ip: IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), + }; + let msg_type_id = trigger_msg.type_id(); + let counter_msg = NodeToUiMessage { + target: MessageTarget::ClientId(234), + body: UiLogBroadcast { + msg: "Good one".to_string(), + log_level: SerializableLogLevel::Error, + } + .tmb(0), + }; + let id_method = MsgIdentification::ByMatch { + exemplar: Box::new(trigger_msg.clone()), + }; + ( + trigger_msg, + SingleTypeCounterMsgSetup::new( + msg_type_id, + id_method, + vec![Box::new(SendableCounterMsgWithRecipient::new( + counter_msg.clone(), + respondent_addr.clone().recipient(), + ))], + ), + counter_msg, + ) + }; + let system = System::new("test"); + let (subject, _, subject_recording_arc) = make_recorder(); + let subject_addr = subject.start(); + // Supplying messages deliberately in a tangled manner to express that the mechanism is + // robust enough to compensate for it. + // This works because we don't supply overlapping setups, such as that could apply to + // a single trigger msg. + subject_addr + .try_send(SetUpCounterMsgs { + setups: vec![cm_setup_3, cm_setup_1, cm_setup_2, cm_setup_4], + }) + .unwrap(); + + subject_addr.try_send(trigger_message_1).unwrap(); + subject_addr + .try_send(trigger_msg_3_unmatching.clone()) + .unwrap(); + subject_addr + .try_send(trigger_msg_4_matching.clone()) + .unwrap(); + + system.run(); + // Actual counter-messages that flew in this test + let respondent_recording = respondent_recording_arc.lock().unwrap(); + let _first_counter_msg_recorded = respondent_recording.get_record::(0); + let second_counter_msg_recorded = respondent_recording.get_record::(1); + assert_eq!(second_counter_msg_recorded, &counter_msg_4); + assert_eq!(respondent_recording.len(), 2); + // Recorded trigger messages + let subject_recording = subject_recording_arc.lock().unwrap(); + let _first_recorded_trigger_msg = subject_recording.get_record::(0); + let second_recorded_trigger_msg = subject_recording.get_record::(1); + assert_eq!(second_recorded_trigger_msg, &trigger_msg_3_unmatching); + let third_recorded_trigger_msg = subject_recording.get_record::(2); + assert_eq!(third_recorded_trigger_msg, &trigger_msg_4_matching); + assert_eq!(subject_recording.len(), 3) + } + + #[test] + fn counter_msgs_evaluate_lazily_so_the_msgs_with_the_same_triggers_are_eliminated_sequentially() + { + // This test demonstrates the need for caution in setups where multiple messages are sent + // at different times and should be responded to by different counter-messages. However, + // the trigger methods of these setups also apply to each other. Which setup gets + // triggered depends purely on the order used to supply them to the recorder + // in SetUpCounterMsgs. + + // Notice that three of the messages share the same data type, with one additional message + // serving a special purpose in assertions. Two of the three use only TypeId for + // identification. This already requires greater caution since you probably need the three + // messages to be dispatched in a specific sequence. However, this wasn't considered + // properly and, as you can see in the test, the trigger messages aren't sent in the same + // order as the counter-message setups were supplied. + + // This results in an inevitable mismatch. The first counter-message that was sent should + // have belonged to the second trigger message, but was triggered by the third trigger + // message (which actually introduces the test). Similarly, the second trigger message + // activates a message rightfully meant for the first trigger message. To complete + // the picture, even the first trigger message is matched with the third counter-message. + + // This shows how important it is to avoid ambiguous setups. When operating with multiple + // calls of the same typed message as triggers, it is highly recommended not to use + // MsgIdentification::ByTypeId but to use more specific, unmistakable settings instead: + // MsgIdentification::ByMatch or MsgIdentification::ByPredicate. + let (respondent, _, respondent_recording_arc) = make_recorder(); + let respondent = respondent.system_stop_conditions(match_lazily_every_type_id!( + ConfigChangeMsg, + ConfigChangeMsg, + ConfigChangeMsg + )); + let respondent_addr = respondent.start(); + // Case 1 + let (trigger_msg_1, cm_setup_1) = { + let trigger_msg = CrashNotification { + process_id: 7777777, + exit_code: None, + stderr: Some("blah".to_string()), + }; + let counter_msg = ConfigChangeMsg { + change: ConfigChange::UpdateMinHops(Hops::SixHops), + }; + let id_method = MsgIdentification::ByPredicate { + predicate: Box::new(|msg_boxed| { + let msg = msg_boxed.downcast_ref::().unwrap(); + msg.process_id == 1010 + }), + }; + ( + trigger_msg, + // Taking an opportunity to test a setup via the macro allowing more specific + // identification methods. + setup_for_counter_msg_triggered_via_specific_msg_id_method!( + CrashNotification, + id_method, + counter_msg, + &respondent_addr + ), + ) + }; + // Case two + let (trigger_msg_2, cm_setup_2) = { + let trigger_msg = CrashNotification { + process_id: 1010, + exit_code: Some(11), + stderr: None, + }; + let counter_msg = ConfigChangeMsg { + change: ConfigChange::UpdatePassword("betterPassword".to_string()), + }; + ( + trigger_msg, + setup_for_counter_msg_triggered_via_type_id!( + CrashNotification, + counter_msg, + &respondent_addr + ), + ) + }; + // Case three + let (trigger_msg_3, cm_setup_3) = { + let trigger_msg = CrashNotification { + process_id: 9999999, + exit_code: None, + stderr: None, + }; + let counter_msg = ConfigChangeMsg { + change: ConfigChange::UpdateWallets(WalletPair { + consuming_wallet: make_wallet("abc"), + earning_wallet: make_wallet("def"), + }), + }; + ( + trigger_msg, + setup_for_counter_msg_triggered_via_type_id!( + CrashNotification, + counter_msg, + &respondent_addr + ), + ) + }; + // Case four + let (trigger_msg_4, cm_setup_4) = { + let trigger_msg = StartMessage {}; + let counter_msg = ScanForReceivables { + response_skeleton_opt: None, + }; + ( + trigger_msg, + setup_for_counter_msg_triggered_via_type_id!( + StartMessage, + counter_msg, + &respondent_addr + ), + ) + }; + let system = System::new("test"); + let (subject, _, subject_recording_arc) = make_recorder(); + let subject_addr = subject.start(); + // Adding messages in standard order + subject_addr + .try_send(SetUpCounterMsgs { + setups: vec![cm_setup_1, cm_setup_2, cm_setup_3, cm_setup_4], + }) + .unwrap(); + + // Now the fun begins, the trigger messages are shuffled + subject_addr.try_send(trigger_msg_3.clone()).unwrap(); + // The fourth message demonstrates that the previous trigger didn't activate two messages + // at once, even though this trigger actually matches two different setups. This shows + // that each trigger can only be matched with one setup at a time, consuming it. If you + // want to trigger multiple messages in response, you must configure that setup with + // multiple counter-messages (a one-to-many scenario). + subject_addr.try_send(trigger_msg_4.clone()).unwrap(); + subject_addr.try_send(trigger_msg_2.clone()).unwrap(); + subject_addr.try_send(trigger_msg_1.clone()).unwrap(); + + system.run(); + // Actual counter-messages that flew in this test + let respondent_recording = respondent_recording_arc.lock().unwrap(); + let first_counter_msg_recorded = respondent_recording.get_record::(0); + assert_eq!( + first_counter_msg_recorded.change, + ConfigChange::UpdatePassword("betterPassword".to_string()) + ); + let _ = respondent_recording.get_record::(1); + let third_counter_msg_recorded = respondent_recording.get_record::(2); + assert_eq!( + third_counter_msg_recorded.change, + ConfigChange::UpdateMinHops(Hops::SixHops) + ); + let fourth_counter_msg_recorded = respondent_recording.get_record::(3); + assert_eq!( + fourth_counter_msg_recorded.change, + ConfigChange::UpdateWallets(WalletPair { + consuming_wallet: make_wallet("abc"), + earning_wallet: make_wallet("def") + }) + ); + assert_eq!(respondent_recording.len(), 4); + // Recorded trigger messages + let subject_recording = subject_recording_arc.lock().unwrap(); + let first_recorded_trigger_msg = subject_recording.get_record::(0); + assert_eq!(first_recorded_trigger_msg, &trigger_msg_3); + let second_recorded_trigger_msg = subject_recording.get_record::(1); + assert_eq!(second_recorded_trigger_msg, &trigger_msg_4); + let third_recorded_trigger_msg = subject_recording.get_record::(2); + assert_eq!(third_recorded_trigger_msg, &trigger_msg_2); + let fourth_recorded_trigger_msg = subject_recording.get_record::(3); + assert_eq!(fourth_recorded_trigger_msg, &trigger_msg_1); + assert_eq!(subject_recording.len(), 4) + } } diff --git a/node/src/test_utils/recorder_counter_msgs.rs b/node/src/test_utils/recorder_counter_msgs.rs new file mode 100644 index 0000000000..ee56936f6d --- /dev/null +++ b/node/src/test_utils/recorder_counter_msgs.rs @@ -0,0 +1,172 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. + +#![cfg(test)] + +use crate::test_utils::recorder_stop_conditions::{ForcedMatchable, MsgIdentification}; +use actix::{Message, Recipient}; +use std::any::TypeId; +use std::cell::RefCell; +use std::collections::hash_map::Entry; +use std::collections::HashMap; + +// Counter-messages are a powerful tool that allows you to actively simulate communication within +// 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 +// 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 +// providing their addresses, supporting complex interaction patterns. This design facilitates +// sophisticated testing scenarios by mimicking real communication flows between multiple Actors. +// The actual preparation of the Recorder needs to be carried out somewhat specifically during the +// late stage of configuring the test, when all participating Actors are already started and their +// 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 +// about setup ordering. + +pub trait CounterMsgGear: Send { + fn try_send(&self); +} + +pub struct SendableCounterMsgWithRecipient +where + Msg: Message + Send, + Msg::Result: Send, +{ + msg_opt: RefCell>, + recipient: Recipient, +} + +impl CounterMsgGear for SendableCounterMsgWithRecipient +where + Msg: Message + Send, + Msg::Result: Send, +{ + fn try_send(&self) { + let msg = self.msg_opt.take().unwrap(); + self.recipient.try_send(msg).unwrap() + } +} + +impl SendableCounterMsgWithRecipient +where + Msg: Message + Send + 'static, + Msg::Result: Send, +{ + pub fn new(msg: Msg, recipient: Recipient) -> SendableCounterMsgWithRecipient { + Self { + msg_opt: RefCell::new(Some(msg)), + recipient, + } + } +} + +pub struct SingleTypeCounterMsgSetup { + // Leave them private + trigger_msg_type_id: TriggerMsgTypeId, + trigger_msg_id_method: MsgIdentification, + // Responding by multiple outbound messages to a single incoming (trigger) message is supported. + // (Imitates a message handler whose execution implies a couple of message dispatches) + msg_gears: Vec>, +} + +impl SingleTypeCounterMsgSetup { + pub fn new( + trigger_msg_type_id: TriggerMsgTypeId, + trigger_msg_id_method: MsgIdentification, + msg_gears: Vec>, + ) -> Self { + Self { + trigger_msg_type_id, + trigger_msg_id_method, + msg_gears, + } + } +} + +pub type TriggerMsgTypeId = TypeId; + +#[derive(Default)] +pub struct CounterMessages { + msgs: HashMap>, +} + +impl CounterMessages { + pub fn search_for_msg_gear( + &mut self, + trigger_msg: &Msg, + ) -> Option>> + where + Msg: ForcedMatchable + 'static, + { + let type_id = trigger_msg.trigger_msg_type_id(); + if let Some(msgs_vec) = self.msgs.get_mut(&type_id) { + msgs_vec + .iter_mut() + .position(|cm_setup| { + cm_setup + .trigger_msg_id_method + .resolve_condition(trigger_msg) + }) + .map(|idx| msgs_vec.remove(idx).msg_gears) + } else { + None + } + } + + pub fn add_msg(&mut self, counter_msg_setup: SingleTypeCounterMsgSetup) { + let type_id = counter_msg_setup.trigger_msg_type_id; + match self.msgs.entry(type_id) { + Entry::Occupied(mut existing_vec) => existing_vec.get_mut().push(counter_msg_setup), + Entry::Vacant(vacancy) => { + vacancy.insert(vec![counter_msg_setup]); + } + } + } +} + +// Note that you're not limited to triggering only one message at a time, but you can supply more +// messages to this macro, all triggered by the same type id. +#[macro_export] +macro_rules! setup_for_counter_msg_triggered_via_type_id{ + ($trigger_msg_type: ty, $($owned_counter_msg: expr, $respondent_actor_addr_ref: expr),+) => { + + crate::setup_for_counter_msg_triggered_via_specific_msg_id_method!( + $trigger_msg_type, + MsgIdentification::ByType(TypeId::of::<$trigger_msg_type>()), + $($owned_counter_msg, $respondent_actor_addr_ref),+ + ) + }; +} + +#[macro_export] +macro_rules! setup_for_counter_msg_triggered_via_specific_msg_id_method{ + ($trigger_msg_type: ty, $msg_id_method: expr, $($owned_counter_msg: expr, $respondent_actor_addr_ref: expr),+) => { + // This macro returns a block of operations. That's why it begins with these curly brackets + { + let msg_gears: Vec< + Box + > = vec![ + // This part can be repeated as long as there are more expression pairs suplied + $(Box::new( + crate::test_utils::recorder_counter_msgs::SendableCounterMsgWithRecipient::new( + $owned_counter_msg, + $respondent_actor_addr_ref.clone().recipient() + ) + )),+ + ]; + + SingleTypeCounterMsgSetup::new( + TypeId::of::<$trigger_msg_type>(), + $msg_id_method, + msg_gears + ) + } + }; +} diff --git a/node/src/test_utils/recorder_stop_conditions.rs b/node/src/test_utils/recorder_stop_conditions.rs index b3dca287d6..f10e0e4a62 100644 --- a/node/src/test_utils/recorder_stop_conditions.rs +++ b/node/src/test_utils/recorder_stop_conditions.rs @@ -1,4 +1,4 @@ -// Copyright (c) 2019, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. +// Copyright (c) 2023, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. #![cfg(test)] @@ -6,16 +6,21 @@ use itertools::Itertools; use std::any::{Any, TypeId}; pub enum StopConditions { - Any(Vec), - All(Vec), + Any(Vec), + // Single message can eliminate _multiple_ ID Methods (previously stop conditions) by matching + // on them. + AllGreedily(Vec), + // Single message can eliminate _only one_ ID Method (previously stop conditions) by matching + // on them. To remove others, a new message must be received. + AllLazily(Vec), } -pub enum StopCondition { - StopOnType(TypeId), - StopOnMatch { +pub enum MsgIdentification { + ByType(TypeId), + ByMatch { exemplar: BoxedMsgExpected, }, - StopOnPredicate { + ByPredicate { predicate: Box bool + Send>, }, } @@ -24,43 +29,48 @@ pub type BoxedMsgExpected = Box; pub type RefMsgExpected<'a> = &'a (dyn Any + Send); impl StopConditions { - pub fn resolve_stop_conditions + Send + 'static>( + pub fn resolve_stop_conditions + Send + 'static>( &mut self, - msg: &T, + msg: &Msg, ) -> bool { match self { - StopConditions::Any(conditions) => Self::resolve_any::(conditions, msg), - StopConditions::All(conditions) => Self::resolve_all::(conditions, msg), + StopConditions::Any(conditions) => Self::resolve_any::(conditions, msg), + StopConditions::AllGreedily(conditions) => { + Self::resolve_all_greedily::(conditions, msg) + } + StopConditions::AllLazily(conditions) => { + Self::resolve_all_lazily::(conditions, msg) + } } } - fn resolve_any + Send + 'static>( - conditions: &Vec, - msg: &T, + fn resolve_any + Send + 'static>( + conditions: &Vec, + msg: &Msg, ) -> bool { conditions .iter() - .any(|condition| condition.resolve_condition::(msg)) + .any(|condition| condition.resolve_condition::(msg)) } - fn resolve_all + Send + 'static>( - conditions: &mut Vec, - msg: &T, + fn resolve_all_greedily + Send + 'static>( + conditions: &mut Vec, + msg: &Msg, ) -> bool { let indexes_to_remove = Self::indexes_of_matched_conditions(conditions, msg); Self::remove_matched_conditions(conditions, indexes_to_remove); conditions.is_empty() } - fn indexes_of_matched_conditions + Send + 'static>( - conditions: &[StopCondition], - msg: &T, + fn indexes_of_matched_conditions + Send + 'static>( + conditions: &[MsgIdentification], + msg: &Msg, ) -> Vec { conditions .iter() .enumerate() .fold(vec![], |mut acc, (idx, condition)| { - let matches = condition.resolve_condition::(msg); + let matches = condition.resolve_condition::(msg); if matches { acc.push(idx) } @@ -68,8 +78,21 @@ impl StopConditions { }) } + fn resolve_all_lazily + Send + 'static>( + conditions: &mut Vec, + msg: &Msg, + ) -> bool { + if let Some(idx) = conditions + .iter() + .position(|condition| condition.resolve_condition::(msg)) + { + conditions.remove(idx); + } + conditions.is_empty() + } + fn remove_matched_conditions( - conditions: &mut Vec, + conditions: &mut Vec, indexes_to_remove: Vec, ) { if !indexes_to_remove.is_empty() { @@ -84,44 +107,42 @@ impl StopConditions { } } -impl StopCondition { - fn resolve_condition + Send + 'static>(&self, msg: &T) -> bool { +impl MsgIdentification { + pub fn resolve_condition + Send + 'static>(&self, msg: &Msg) -> bool { match self { - StopCondition::StopOnType(type_id) => Self::matches_stop_on_type::(msg, *type_id), - StopCondition::StopOnMatch { exemplar } => { - Self::matches_stop_on_match::(exemplar, msg) - } - StopCondition::StopOnPredicate { predicate } => { - Self::matches_stop_on_predicate(predicate.as_ref(), msg) + MsgIdentification::ByType(type_id) => Self::matches_by_type::(msg, *type_id), + MsgIdentification::ByMatch { exemplar } => Self::is_identical::(exemplar, msg), + MsgIdentification::ByPredicate { predicate } => { + Self::matches_by_predicate(predicate.as_ref(), msg) } } } - fn matches_stop_on_type>(msg: &T, expected_type_id: TypeId) -> bool { - let correct_msg_type_id = msg.correct_msg_type_id(); - correct_msg_type_id == expected_type_id + fn matches_by_type>(msg: &Msg, expected_type_id: TypeId) -> bool { + let trigger_msg_type_id = msg.trigger_msg_type_id(); + trigger_msg_type_id == expected_type_id } - fn matches_stop_on_match + 'static + Send>( + fn is_identical + 'static + Send>( exemplar: &BoxedMsgExpected, - msg: &T, + msg: &Msg, ) -> bool { - if let Some(downcast_exemplar) = exemplar.downcast_ref::() { + if let Some(downcast_exemplar) = exemplar.downcast_ref::() { return downcast_exemplar == msg; } false } - fn matches_stop_on_predicate( + fn matches_by_predicate( predicate: &dyn Fn(RefMsgExpected) -> bool, - msg: &T, + msg: &Msg, ) -> bool { predicate(msg as RefMsgExpected) } } -pub trait ForcedMatchable: PartialEq + Send { - fn correct_msg_type_id(&self) -> TypeId; +pub trait ForcedMatchable: PartialEq + Send { + fn trigger_msg_type_id(&self) -> TypeId; } pub struct PretendedMatchableWrapper(pub M); @@ -131,7 +152,7 @@ where OuterM: PartialEq, InnerM: Send, { - fn correct_msg_type_id(&self) -> TypeId { + fn trigger_msg_type_id(&self) -> TypeId { TypeId::of::() } } @@ -139,7 +160,7 @@ where impl PartialEq for PretendedMatchableWrapper { fn eq(&self, _other: &Self) -> bool { panic!( - r#"You requested StopCondition::StopOnMatch for message + r#"You requested MsgIdentification::ByMatch for message that does not implement PartialEq. Consider two other options: matching the type simply by its TypeId or using a predicate."# @@ -148,53 +169,59 @@ impl PartialEq for PretendedMatchableWrapper { } #[macro_export] -macro_rules! match_every_type_id{ +macro_rules! match_lazily_every_type_id{ ($($single_message: ident),+) => { - StopConditions::All(vec![$(StopCondition::StopOnType(TypeId::of::<$single_message>())),+]) + StopConditions::AllLazily(vec![ + $( + crate::test_utils::recorder_stop_conditions::MsgIdentification::ByType( + TypeId::of::<$single_message>() + ) + ),+ + ]) } } mod tests { - use crate::accountant::{ResponseSkeleton, ScanError, ScanForPayables}; + use crate::accountant::{ResponseSkeleton, ScanError, ScanForNewPayables}; use crate::daemon::crash_notification::CrashNotification; + use crate::sub_lib::accountant::DetailedScanType; use crate::sub_lib::peer_actors::{NewPublicIp, StartMessage}; - use crate::test_utils::recorder_stop_conditions::{StopCondition, StopConditions}; - use masq_lib::messages::ScanType; + use crate::test_utils::recorder_stop_conditions::{MsgIdentification, StopConditions}; use std::any::TypeId; - use std::net::{IpAddr, Ipv4Addr}; + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use std::vec; #[test] fn remove_matched_conditions_works_with_unsorted_indexes() { let mut conditions = vec![ - StopCondition::StopOnType(TypeId::of::()), - StopCondition::StopOnType(TypeId::of::()), - StopCondition::StopOnType(TypeId::of::()), + MsgIdentification::ByType(TypeId::of::()), + MsgIdentification::ByType(TypeId::of::()), + MsgIdentification::ByType(TypeId::of::()), ]; let indexes = vec![2, 0]; StopConditions::remove_matched_conditions(&mut conditions, indexes); assert_eq!(conditions.len(), 1); - let type_id = if let StopCondition::StopOnType(type_id) = conditions[0] { + let type_id = if let MsgIdentification::ByType(type_id) = conditions[0] { type_id } else { - panic!("expected StopOnType but got a different variant") + panic!("expected ByType but got a different variant") }; - assert_eq!(type_id, TypeId::of::()) + assert_eq!(type_id, TypeId::of::()) } #[test] fn stop_on_match_works() { - let mut cond1 = StopConditions::All(vec![StopCondition::StopOnMatch { + let mut cond1 = StopConditions::AllGreedily(vec![MsgIdentification::ByMatch { exemplar: Box::new(StartMessage {}), }]); - let mut cond2 = StopConditions::All(vec![StopCondition::StopOnMatch { + let mut cond2 = StopConditions::AllGreedily(vec![MsgIdentification::ByMatch { exemplar: Box::new(NewPublicIp { new_ip: IpAddr::V4(Ipv4Addr::new(1, 8, 6, 4)), }), }]); - let mut cond3 = StopConditions::All(vec![StopCondition::StopOnMatch { + let mut cond3 = StopConditions::AllGreedily(vec![MsgIdentification::ByMatch { exemplar: Box::new(NewPublicIp { new_ip: IpAddr::V4(Ipv4Addr::new(44, 2, 3, 1)), }), @@ -219,19 +246,19 @@ mod tests { #[test] fn stop_on_predicate_works() { - let mut cond_set = StopConditions::All(vec![StopCondition::StopOnPredicate { + let mut cond_set = StopConditions::AllGreedily(vec![MsgIdentification::ByPredicate { predicate: Box::new(|msg| { let scan_err_msg: &ScanError = msg.downcast_ref().unwrap(); - scan_err_msg.scan_type == ScanType::PendingPayables + scan_err_msg.scan_type == DetailedScanType::PendingPayables }), }]); let wrong_msg = ScanError { - scan_type: ScanType::Payables, + scan_type: DetailedScanType::NewPayables, response_skeleton_opt: None, msg: "booga".to_string(), }; let good_msg = ScanError { - scan_type: ScanType::PendingPayables, + scan_type: DetailedScanType::PendingPayables, response_skeleton_opt: None, msg: "blah".to_string(), }; @@ -249,12 +276,12 @@ mod tests { #[test] fn match_any_works_with_every_matching_condition_and_no_need_to_take_elements_out() { let mut cond_set = StopConditions::Any(vec![ - StopCondition::StopOnType(TypeId::of::()), - StopCondition::StopOnMatch { + MsgIdentification::ByType(TypeId::of::()), + MsgIdentification::ByMatch { exemplar: Box::new(StartMessage {}), }, ]); - let first_msg = ScanForPayables { + let first_msg = ScanForNewPayables { response_skeleton_opt: None, }; let second_msg = StartMessage {}; @@ -265,11 +292,16 @@ mod tests { }; let inspect_len_of_any = |cond_set: &StopConditions, msg_number: usize| match cond_set { StopConditions::Any(conditions) => conditions.len(), - StopConditions::All(_) => panic!("stage {}: expected Any but got All", msg_number), + StopConditions::AllGreedily(_) => { + panic!("stage {}: expected Any but got AllGreedily", msg_number) + } + StopConditions::AllLazily(_) => { + panic!("stage {}: expected Any but got AllLazily", msg_number) + } }; assert_eq!( - cond_set.resolve_stop_conditions::(&first_msg), + cond_set.resolve_stop_conditions::(&first_msg), false ); let len_after_stage_1 = inspect_len_of_any(&cond_set, 1); @@ -289,9 +321,9 @@ mod tests { } #[test] - fn match_all_with_conditions_gradually_eliminated_until_vector_is_emptied_and_it_is_match() { - let mut cond_set = StopConditions::All(vec![ - StopCondition::StopOnPredicate { + fn match_all_with_conditions_gradually_eliminated_greedily_until_empty() { + let mut cond_set = StopConditions::AllGreedily(vec![ + MsgIdentification::ByPredicate { predicate: Box::new(|msg| { if let Some(ip_msg) = msg.downcast_ref::() { ip_msg.new_ip.is_ipv4() @@ -300,34 +332,31 @@ mod tests { } }), }, - StopCondition::StopOnMatch { - exemplar: Box::new(ScanForPayables { + MsgIdentification::ByMatch { + exemplar: Box::new(ScanForNewPayables { response_skeleton_opt: Some(ResponseSkeleton { client_id: 1234, context_id: 789, }), }), }, - StopCondition::StopOnType(TypeId::of::()), + MsgIdentification::ByType(TypeId::of::()), ]); - let tested_msg_1 = ScanForPayables { + let tested_msg_1 = ScanForNewPayables { response_skeleton_opt: Some(ResponseSkeleton { client_id: 1234, context_id: 789, }), }; - let kill_system = cond_set.resolve_stop_conditions::(&tested_msg_1); + let kill_system = cond_set.resolve_stop_conditions::(&tested_msg_1); assert_eq!(kill_system, false); - match &cond_set { - StopConditions::All(conds) => { - assert_eq!(conds.len(), 2); - assert!(matches!(conds[0], StopCondition::StopOnPredicate { .. })); - assert!(matches!(conds[1], StopCondition::StopOnType(_))); - } - StopConditions::Any(_) => panic!("Stage 1: expected StopConditions::All, not ...Any"), - } + assert_state_after_greedily_matched(1, &cond_set, |conds| { + assert_eq!(conds.len(), 2); + assert!(matches!(conds[0], MsgIdentification::ByPredicate { .. })); + assert!(matches!(conds[1], MsgIdentification::ByType(_))); + }); let tested_msg_2 = NewPublicIp { new_ip: IpAddr::V4(Ipv4Addr::new(1, 2, 4, 1)), }; @@ -335,11 +364,109 @@ mod tests { let kill_system = cond_set.resolve_stop_conditions::(&tested_msg_2); assert_eq!(kill_system, true); + assert_state_after_greedily_matched(2, &cond_set, |conds| assert!(conds.is_empty())) + } + + fn assert_state_after_greedily_matched( + stage: usize, + cond_set: &StopConditions, + apply_assertions: fn(&[MsgIdentification]), + ) { match cond_set { - StopConditions::All(conds) => { - assert!(conds.is_empty()) + StopConditions::AllGreedily(conds) => apply_assertions(conds), + StopConditions::Any(_) => { + panic!("Stage {stage}: expected StopConditions::AllGreedily, not Any") + } + StopConditions::AllLazily(_) => { + panic!("Stage {stage}: expected StopConditions::AllGreedily, not AllLazily") + } + } + } + + #[test] + fn match_all_with_conditions_gradually_eliminated_lazily_until_empty() { + let mut cond_set = StopConditions::AllLazily(vec![ + MsgIdentification::ByPredicate { + predicate: Box::new(|msg| { + if let Some(ip_msg) = msg.downcast_ref::() { + ip_msg.new_ip.is_ipv6() + } else { + false + } + }), + }, + MsgIdentification::ByType(TypeId::of::()), + MsgIdentification::ByType(TypeId::of::()), + ]); + //////////////////////////////////////////////////////////////////////////////////////////// + // Stage one + let tested_msg_1 = ScanForNewPayables { + response_skeleton_opt: None, + }; + + let kill_system = cond_set.resolve_stop_conditions::(&tested_msg_1); + + assert_eq!(kill_system, false); + assert_state_after_lazily_matched(1, &cond_set, |conds| { + assert_eq!(conds.len(), 3); + assert!(matches!(conds[0], MsgIdentification::ByPredicate { .. })); + assert!(matches!(conds[1], MsgIdentification::ByType(_))); + assert!(matches!(conds[2], MsgIdentification::ByType(_))); + }); + //////////////////////////////////////////////////////////////////////////////////////////// + // Stage two + let tested_msg_2 = NewPublicIp { + new_ip: IpAddr::V4(Ipv4Addr::new(6, 7, 8, 9)), + }; + + let kill_system = cond_set.resolve_stop_conditions::(&tested_msg_2); + + assert_eq!(kill_system, false); + assert_state_after_lazily_matched(2, &cond_set, |conds| { + assert_eq!(conds.len(), 2); + assert!(matches!(conds[0], MsgIdentification::ByPredicate { .. })); + assert!(matches!(conds[1], MsgIdentification::ByType(_))); + }); + //////////////////////////////////////////////////////////////////////////////////////////// + // Stage three + let tested_msg_3 = NewPublicIp { + new_ip: IpAddr::V6(Ipv6Addr::new(1, 2, 4, 1, 4, 3, 2, 1)), + }; + + let kill_system = cond_set.resolve_stop_conditions::(&tested_msg_3); + + assert_eq!(kill_system, false); + assert_state_after_lazily_matched(3, &cond_set, |conds| { + assert_eq!(conds.len(), 1); + assert!(matches!(conds[0], MsgIdentification::ByType(_))) + }); + //////////////////////////////////////////////////////////////////////////////////////////// + // Stage four + let tested_msg_4 = NewPublicIp { + new_ip: IpAddr::V4(Ipv4Addr::new(45, 45, 45, 45)), + }; + + let kill_system = cond_set.resolve_stop_conditions::(&tested_msg_4); + + assert_eq!(kill_system, true); + assert_state_after_lazily_matched(4, &cond_set, |conds| { + assert!(conds.is_empty()); + }); + } + + fn assert_state_after_lazily_matched( + stage: usize, + cond_set: &StopConditions, + apply_assertions: fn(&[MsgIdentification]), + ) { + match &cond_set { + StopConditions::AllLazily(conds) => apply_assertions(conds), + StopConditions::Any(_) => { + panic!("Stage {stage}: expected StopConditions::AllLazily, not Any") + } + StopConditions::AllGreedily(_) => { + panic!("Stage {stage}: expected StopConditions::AllLazily, not AllGreedily") } - StopConditions::Any(_) => panic!("Stage 2: expected StopConditions::All, not ...Any"), } } } diff --git a/node/src/test_utils/serde_serializer_mock.rs b/node/src/test_utils/serde_serializer_mock.rs new file mode 100644 index 0000000000..7130cd0c0b --- /dev/null +++ b/node/src/test_utils/serde_serializer_mock.rs @@ -0,0 +1,348 @@ +// Copyright (c) 2025, MASQ (https://masq.ai) and/or its affiliates. All rights reserved. + +#![cfg(test)] + +use serde::ser::{ + SerializeMap, SerializeSeq, SerializeStruct, SerializeStructVariant, SerializeTuple, + SerializeTupleStruct, SerializeTupleVariant, +}; +use serde::{Serialize, Serializer}; +use serde_json::Error; +use std::cell::RefCell; + +#[derive(Default)] +pub struct SerdeSerializerMock { + serialize_seq_results: RefCell>>, +} + +impl Serializer for SerdeSerializerMock { + type Ok = (); + type Error = Error; + type SerializeSeq = SerializeSeqMock; + type SerializeTuple = SerializeTupleMock; + type SerializeTupleStruct = SerializeTupleStructMock; + type SerializeTupleVariant = SerializeTupleVariantMock; + type SerializeMap = SerializeMapMock; + type SerializeStruct = SerializeStructMock; + type SerializeStructVariant = SerializeStructVariantMock; + + fn serialize_bool(self, _v: bool) -> Result { + unimplemented!("Not yet needed") + } + + fn serialize_i8(self, _v: i8) -> Result { + unimplemented!("Not yet needed") + } + + fn serialize_i16(self, _v: i16) -> Result { + unimplemented!("Not yet needed") + } + + fn serialize_i32(self, _v: i32) -> Result { + unimplemented!("Not yet needed") + } + + fn serialize_i64(self, _v: i64) -> Result { + unimplemented!("Not yet needed") + } + + fn serialize_u8(self, _v: u8) -> Result { + unimplemented!("Not yet needed") + } + + fn serialize_u16(self, _v: u16) -> Result { + unimplemented!("Not yet needed") + } + + fn serialize_u32(self, _v: u32) -> Result { + unimplemented!("Not yet needed") + } + + fn serialize_u64(self, _v: u64) -> Result { + unimplemented!("Not yet needed") + } + + fn serialize_f32(self, _v: f32) -> Result { + unimplemented!("Not yet needed") + } + + fn serialize_f64(self, _v: f64) -> Result { + unimplemented!("Not yet needed") + } + + fn serialize_char(self, _v: char) -> Result { + unimplemented!("Not yet needed") + } + + fn serialize_str(self, _v: &str) -> Result { + unimplemented!("Not yet needed") + } + + fn serialize_bytes(self, _v: &[u8]) -> Result { + unimplemented!("Not yet needed") + } + + fn serialize_none(self) -> Result { + unimplemented!("Not yet needed") + } + + fn serialize_some(self, _value: &T) -> Result + where + T: Serialize, + { + unimplemented!("Not yet needed") + } + + fn serialize_unit(self) -> Result { + unimplemented!("Not yet needed") + } + + fn serialize_unit_struct(self, _name: &'static str) -> Result { + unimplemented!("Not yet needed") + } + + fn serialize_unit_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + ) -> Result { + unimplemented!("Not yet needed") + } + + fn serialize_newtype_struct( + self, + _name: &'static str, + _value: &T, + ) -> Result + where + T: Serialize, + { + unimplemented!("Not yet needed") + } + + fn serialize_newtype_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _value: &T, + ) -> Result + where + T: Serialize, + { + unimplemented!("Not yet needed") + } + + fn serialize_seq(self, _len: Option) -> Result { + self.serialize_seq_results.borrow_mut().remove(0) + } + + fn serialize_tuple(self, _len: usize) -> Result { + unimplemented!("Not yet needed") + } + + fn serialize_tuple_struct( + self, + _name: &'static str, + _len: usize, + ) -> Result { + unimplemented!("Not yet needed") + } + + fn serialize_tuple_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _len: usize, + ) -> Result { + unimplemented!("Not yet needed") + } + + fn serialize_map(self, _len: Option) -> Result { + unimplemented!("Not yet needed") + } + + fn serialize_struct( + self, + _name: &'static str, + _len: usize, + ) -> Result { + unimplemented!("Not yet needed") + } + + fn serialize_struct_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _len: usize, + ) -> Result { + unimplemented!("Not yet needed") + } +} + +impl SerdeSerializerMock { + pub fn serialize_seq_result(self, serializer: Result) -> Self { + self.serialize_seq_results.borrow_mut().push(serializer); + self + } +} + +#[derive(Default)] +pub struct SerializeSeqMock { + serialize_element_results: RefCell>>, + end_results: RefCell>>, +} + +impl SerializeSeq for SerializeSeqMock { + type Ok = (); + type Error = Error; + + fn serialize_element(&mut self, _value: &T) -> Result<(), Self::Error> + where + T: Serialize, + { + self.serialize_element_results.borrow_mut().remove(0) + } + + fn end(self) -> Result { + self.end_results.borrow_mut().remove(0) + } +} + +impl SerializeSeqMock { + pub fn serialize_element_result(self, result: Result<(), Error>) -> Self { + self.serialize_element_results.borrow_mut().push(result); + self + } + + pub fn end_result(self, result: Result<(), Error>) -> Self { + self.end_results.borrow_mut().push(result); + self + } +} + +pub struct SerializeTupleMock {} + +impl SerializeTuple for SerializeTupleMock { + type Ok = (); + type Error = Error; + + fn serialize_element(&mut self, _value: &T) -> Result<(), Self::Error> + where + T: Serialize, + { + unimplemented!("Not yet needed") + } + + fn end(self) -> Result { + unimplemented!("Not yet needed") + } +} + +pub struct SerializeTupleStructMock {} + +impl SerializeTupleStruct for SerializeTupleStructMock { + type Ok = (); + type Error = Error; + + fn serialize_field(&mut self, _value: &T) -> Result<(), Self::Error> + where + T: Serialize, + { + unimplemented!("Not yet needed") + } + + fn end(self) -> Result { + unimplemented!("Not yet needed") + } +} + +pub struct SerializeTupleVariantMock {} + +impl SerializeTupleVariant for SerializeTupleVariantMock { + type Ok = (); + type Error = Error; + + fn serialize_field(&mut self, _value: &T) -> Result<(), Self::Error> + where + T: Serialize, + { + unimplemented!("Not yet needed") + } + + fn end(self) -> Result { + unimplemented!("Not yet needed") + } +} + +pub struct SerializeMapMock {} + +impl SerializeMap for SerializeMapMock { + type Ok = (); + type Error = Error; + + fn serialize_key(&mut self, _key: &T) -> Result<(), Self::Error> + where + T: Serialize, + { + unimplemented!("Not yet needed") + } + + fn serialize_value(&mut self, _value: &T) -> Result<(), Self::Error> + where + T: Serialize, + { + unimplemented!("Not yet needed") + } + + fn end(self) -> Result { + unimplemented!("Not yet needed") + } +} + +pub struct SerializeStructMock {} + +impl SerializeStruct for SerializeStructMock { + type Ok = (); + type Error = Error; + + fn serialize_field( + &mut self, + _key: &'static str, + _value: &T, + ) -> Result<(), Self::Error> + where + T: Serialize, + { + unimplemented!("Not yet needed") + } + + fn end(self) -> Result { + unimplemented!("Not yet needed") + } +} + +pub struct SerializeStructVariantMock {} + +impl SerializeStructVariant for SerializeStructVariantMock { + type Ok = (); + type Error = Error; + + fn serialize_field( + &mut self, + _key: &'static str, + _value: &T, + ) -> Result<(), Self::Error> + where + T: Serialize, + { + unimplemented!("Not yet needed") + } + + fn end(self) -> Result { + unimplemented!("Not yet needed") + } +} diff --git a/node/tests/contract_test.rs b/node/tests/contract_test.rs index 42d6fce375..35b7e0f852 100644 --- a/node/tests/contract_test.rs +++ b/node/tests/contract_test.rs @@ -133,7 +133,10 @@ fn masq_erc20_contract_exists_on_polygon_mainnet_integration() { #[test] fn masq_erc20_contract_exists_on_ethereum_mainnet_integration() { - let blockchain_urls = vec!["https://mainnet.infura.io/v3/0ead23143b174f6983c76f69ddcf4026"]; + let blockchain_urls = vec![ + "https://eth.llamarpc.com", + "https://mainnet.infura.io/v3/0ead23143b174f6983c76f69ddcf4026", + ]; let chain = Chain::EthMainnet; let assertion_body = |url, chain| assert_contract_existence(url, chain, "MASQ", 18); @@ -207,7 +210,10 @@ fn assert_total_supply( #[test] fn max_token_supply_matches_corresponding_constant_integration() { - let blockchain_urls = vec!["https://mainnet.infura.io/v3/0ead23143b174f6983c76f69ddcf4026"]; + let blockchain_urls = vec![ + "https://eth.llamarpc.com", + "https://mainnet.infura.io/v3/0ead23143b174f6983c76f69ddcf4026", + ]; let chain = Chain::EthMainnet; let assertion_body = |url, chain| assert_total_supply(url, chain, MASQ_TOTAL_SUPPLY); diff --git a/node/tests/financials_test.rs b/node/tests/financials_test.rs index 0237d002da..1dbfd89aa8 100644 --- a/node/tests/financials_test.rs +++ b/node/tests/financials_test.rs @@ -13,7 +13,7 @@ use masq_lib::test_utils::utils::{ensure_node_home_directory_exists, open_all_fi use masq_lib::utils::find_free_port; use node_lib::accountant::db_access_objects::payable_dao::{PayableDao, PayableDaoReal}; use node_lib::accountant::db_access_objects::receivable_dao::{ReceivableDao, ReceivableDaoReal}; -use node_lib::accountant::db_access_objects::utils::{from_time_t, to_time_t}; +use node_lib::accountant::db_access_objects::utils::{from_unix_timestamp, to_unix_timestamp}; use node_lib::accountant::gwei_to_wei; use node_lib::database::db_initializer::{ DbInitializationConfig, DbInitializer, DbInitializerReal, @@ -30,9 +30,9 @@ fn financials_command_retrieves_payable_and_receivable_records_integration() { let port = find_free_port(); let home_dir = ensure_node_home_directory_exists("integration", test_name); let now = SystemTime::now(); - let timestamp_payable = from_time_t(to_time_t(now) - 678); - let timestamp_receivable_1 = from_time_t(to_time_t(now) - 10000); - let timestamp_receivable_2 = from_time_t(to_time_t(now) - 1111); + let timestamp_payable = from_unix_timestamp(to_unix_timestamp(now) - 678); + let timestamp_receivable_1 = from_unix_timestamp(to_unix_timestamp(now) - 10000); + let timestamp_receivable_2 = from_unix_timestamp(to_unix_timestamp(now) - 1111); let wallet_payable = make_wallet("efef"); let wallet_receivable_1 = make_wallet("abcde"); let wallet_receivable_2 = make_wallet("ccccc");