From a09931b1075573456637d95fc5e0b27bfe1d11c0 Mon Sep 17 00:00:00 2001 From: Mykhailo Kremniov Date: Thu, 30 Apr 2026 16:24:37 +0300 Subject: [PATCH 01/10] p2p: when a duplicate tx with local origin is added to the mempool, it will be broadcast to the peers again. Rename mempool event types. --- mempool/src/event.rs | 79 +++++--- mempool/src/interface/mempool_interface.rs | 3 +- .../src/interface/mempool_interface_impl.rs | 27 ++- mempool/src/lib.rs | 2 +- mempool/src/pool/mod.rs | 44 +++-- mempool/src/pool/tests/basic.rs | 4 +- mempool/src/rpc.rs | 6 +- mempool/types/src/lib.rs | 6 + mempool/types/src/tx_origin.rs | 7 +- mocks/src/mempool.rs | 4 +- p2p/src/interface/p2p_interface_impl.rs | 4 +- p2p/src/sync/mod.rs | 62 ++++-- p2p/src/sync/peer/block_manager.rs | 2 +- p2p/src/sync/peer/transaction_manager.rs | 2 +- p2p/src/sync/tests/helpers/mod.rs | 15 +- .../tests/no_discouragement_after_tx_reorg.rs | 2 +- p2p/src/sync/tests/tx_announcement.rs | 179 +++++++++++++++++- .../src/handles_client/mod.rs | 19 +- 18 files changed, 375 insertions(+), 92 deletions(-) diff --git a/mempool/src/event.rs b/mempool/src/event.rs index 399b35ef6c..8641b6787b 100644 --- a/mempool/src/event.rs +++ b/mempool/src/event.rs @@ -17,6 +17,7 @@ use common::{ chain::{GenBlock, Transaction}, primitives::{BlockHeight, Id}, }; +use mempool_types::TransactionDuplicateStatus; use crate::{ error::{Error, MempoolBanScore}, @@ -26,19 +27,19 @@ use crate::{ /// Event triggered when a transaction has been fully validated #[derive(Debug, Clone, Eq, PartialEq)] -pub struct TransactionProcessed { +pub struct TransactionProcessedEvent { tx_id: Id, origin: TxOrigin, relay_policy: TxRelayPolicy, - result: crate::Result<()>, + result: crate::Result, } -impl TransactionProcessed { +impl TransactionProcessedEvent { fn new( tx_id: Id, origin: TxOrigin, relay_policy: TxRelayPolicy, - result: crate::Result<()>, + result: crate::Result, ) -> Self { Self { tx_id, @@ -48,20 +49,15 @@ impl TransactionProcessed { } } - pub fn accepted(tx_id: Id, relay_policy: TxRelayPolicy, origin: TxOrigin) -> Self { - Self::new(tx_id, origin, relay_policy, Ok(())) - } - - pub fn rejected(tx_id: Id, err: Error, origin: TxOrigin) -> Self { - Self::new(tx_id, origin, TxRelayPolicy::DontRelay, Err(err)) - } - - pub fn result(&self) -> &crate::Result<()> { + pub fn result(&self) -> &crate::Result { &self.result } - pub fn was_accepted(&self) -> bool { - self.result.is_ok() + pub fn new_tx_accepted(&self) -> bool { + self.result.as_ref().is_ok_and(|duplicate_status| match duplicate_status { + TransactionDuplicateStatus::Duplicate => false, + TransactionDuplicateStatus::New => true, + }) } pub fn ban_score(&self) -> u32 { @@ -81,14 +77,51 @@ impl TransactionProcessed { } } +pub fn make_new_tx_accepted_event( + tx_id: Id, + relay_policy: TxRelayPolicy, + origin: TxOrigin, +) -> TransactionProcessedEvent { + TransactionProcessedEvent::new( + tx_id, + origin, + relay_policy, + Ok(TransactionDuplicateStatus::New), + ) +} + +pub fn make_local_duplicate_tx_event( + tx_id: Id, + relay_policy: TxRelayPolicy, + origin: TxOrigin, +) -> Option { + match &origin { + TxOrigin::Local(_) => Some(TransactionProcessedEvent::new( + tx_id, + origin, + relay_policy, + Ok(TransactionDuplicateStatus::Duplicate), + )), + TxOrigin::Remote(_) => None, + } +} + +pub fn make_tx_rejected_event( + tx_id: Id, + err: Error, + origin: TxOrigin, +) -> TransactionProcessedEvent { + TransactionProcessedEvent::new(tx_id, origin, TxRelayPolicy::DontRelay, Err(err)) +} + /// Event triggered when mempool has synced up to given tip #[derive(Debug, Clone, Eq, PartialEq)] -pub struct NewTip { +pub struct NewTipEvent { block_id: Id, height: BlockHeight, } -impl NewTip { +impl NewTipEvent { pub fn new(block_id: Id, height: BlockHeight) -> Self { Self { block_id, height } } @@ -105,18 +138,18 @@ impl NewTip { /// Events emitted by mempool #[derive(Debug, Clone, Eq, PartialEq)] pub enum MempoolEvent { - NewTip(NewTip), - TransactionProcessed(TransactionProcessed), + NewTip(NewTipEvent), + TransactionProcessed(TransactionProcessedEvent), } -impl From for MempoolEvent { - fn from(event: TransactionProcessed) -> Self { +impl From for MempoolEvent { + fn from(event: TransactionProcessedEvent) -> Self { Self::TransactionProcessed(event) } } -impl From for MempoolEvent { - fn from(event: NewTip) -> Self { +impl From for MempoolEvent { + fn from(event: NewTipEvent) -> Self { Self::NewTip(event) } } diff --git a/mempool/src/interface/mempool_interface.rs b/mempool/src/interface/mempool_interface.rs index b12da196b4..190c857d84 100644 --- a/mempool/src/interface/mempool_interface.rs +++ b/mempool/src/interface/mempool_interface.rs @@ -24,6 +24,7 @@ use common::{ chain::{GenBlock, SignedTransaction, Transaction}, primitives::Id, }; +use mempool_types::TransactionDuplicateStatus; use std::{num::NonZeroUsize, sync::Arc}; pub trait MempoolInterface: Send + Sync { @@ -41,7 +42,7 @@ pub trait MempoolInterface: Send + Sync { tx: SignedTransaction, origin: LocalTxOrigin, options: TxOptions, - ) -> Result<(), Error>; + ) -> Result; /// Get all transactions from mempool fn get_all(&self) -> Vec; diff --git a/mempool/src/interface/mempool_interface_impl.rs b/mempool/src/interface/mempool_interface_impl.rs index e86200900f..446c87fa3d 100644 --- a/mempool/src/interface/mempool_interface_impl.rs +++ b/mempool/src/interface/mempool_interface_impl.rs @@ -29,8 +29,9 @@ use common::{ time_getter::TimeGetter, }; use logging::log; +use mempool_types::TransactionDuplicateStatus; use std::{num::NonZeroUsize, sync::Arc}; -use utils::{const_value::ConstValue, tap_log::TapLog}; +use utils::{const_value::ConstValue, debug_panic_or_log, tap_log::TapLog}; type Mempool = crate::pool::Mempool; @@ -95,14 +96,28 @@ impl MempoolInterface for Mempool { tx: SignedTransaction, origin: LocalTxOrigin, options: TxOptions, - ) -> Result<(), Error> { + ) -> Result { let tx = self.make_entry(tx, origin.into(), options); let status = self.add_transaction(tx)?; - // TODO The following assertion could be avoided by parametrizing the above - // `add_transaction` by the origin type and have the return type depend on it. - assert!(status.in_mempool()); - Ok(()) + // Note: the transaction cannot be an orphan here, since `add_transaction` should return + // an error in such a case. + // TODO: the panics below could be avoided by parametrizing `add_transaction` by the origin + // type and have the return type depend on it. + let duplicate_status = match status { + TxStatus::InMempool => TransactionDuplicateStatus::New, + TxStatus::InMempoolDuplicate => TransactionDuplicateStatus::Duplicate, + TxStatus::InOrphanPool => { + debug_panic_or_log!("Unexpected local orphan"); + TransactionDuplicateStatus::New + } + TxStatus::InOrphanPoolDuplicate => { + debug_panic_or_log!("Unexpected local duplicate orphan"); + TransactionDuplicateStatus::Duplicate + } + }; + + Ok(duplicate_status) } #[tracing::instrument(skip_all, fields(tx_id = %tx.transaction().get_id()))] diff --git a/mempool/src/lib.rs b/mempool/src/lib.rs index d621839487..c7fb768822 100644 --- a/mempool/src/lib.rs +++ b/mempool/src/lib.rs @@ -17,7 +17,7 @@ pub use config::MempoolMaxSize; pub use interface::{MempoolInit, MempoolInterface}; -pub use mempool_types::{tx_options, tx_origin, TxOptions, TxStatus}; +pub use mempool_types::{tx_options, tx_origin, TransactionDuplicateStatus, TxOptions, TxStatus}; mod config; pub mod error; diff --git a/mempool/src/pool/mod.rs b/mempool/src/pool/mod.rs index 5d759bb253..a10cf20aa2 100644 --- a/mempool/src/pool/mod.rs +++ b/mempool/src/pool/mod.rs @@ -32,7 +32,10 @@ use crate::{ error::{ BlockConstructionError, Error, MempoolPolicyError, OrphanPoolError, TxValidationError, }, - event::{self, MempoolEvent}, + event::{ + make_local_duplicate_tx_event, make_new_tx_accepted_event, make_tx_rejected_event, + MempoolEvent, NewTipEvent, + }, tx_accumulator::{PackingStrategy, TransactionAccumulator}, tx_options::{TxOptions, TxTrustPolicy}, tx_origin::{RemoteTxOrigin, TxOrigin}, @@ -353,7 +356,7 @@ impl Mempool { log::trace!("Performing orphan processing work"); let orphan = state.work_queue.pick(|peer, orphan_id| { - log::debug!("Processing orphan tx {orphan_id:?} coming from peer{peer}"); + log::debug!("Processing orphan tx {orphan_id:?} coming from peer {peer}"); match state.orphans.entry(&orphan_id) { Some(orphan) if orphan.is_ready() => { @@ -493,8 +496,7 @@ impl Mempool { } }; - let new_tip = event::NewTip::new(block_id, height); - let event = new_tip.into(); + let event = NewTipEvent::new(block_id, height).into(); self.events_broadcast_mut().broadcast(event); Ok(()) @@ -570,8 +572,6 @@ impl<'a> TxFinalizer<'a> { match outcome { TxAdditionOutcome::Added { transaction } => { let tx_id = *transaction.tx_id(); - let relay_policy = transaction.tx_entry().options().relay_policy(); - let origin = transaction.tx_entry().origin(); log::trace!("Added transaction {tx_id}"); self.enqueue_children(transaction.tx_entry()); @@ -579,17 +579,36 @@ impl<'a> TxFinalizer<'a> { match &mut self.events_mode { TxFinalizerEventsMode::Silent => {} TxFinalizerEventsMode::Broadcast(events_broadcast) => { - let event = - event::TransactionProcessed::accepted(tx_id, relay_policy, origin); - let event = event.into(); - events_broadcast.broadcast(event); + let relay_policy = transaction.tx_entry().options().relay_policy(); + let origin = transaction.tx_entry().origin(); + let event = make_new_tx_accepted_event(tx_id, relay_policy, origin); + + events_broadcast.broadcast(event.into()); } } Ok(TxStatus::InMempool) } TxAdditionOutcome::Duplicate { transaction } => { - log::trace!("Duplicate transaction {}", transaction.tx_id()); + let tx_id = *transaction.tx_id(); + log::trace!("Duplicate transaction {tx_id}"); + + match &mut self.events_mode { + TxFinalizerEventsMode::Silent => {} + TxFinalizerEventsMode::Broadcast(events_broadcast) => { + let relay_policy = transaction.tx_entry().options().relay_policy(); + let origin = transaction.tx_entry().origin(); + + // Even if the tx is duplicate, if it has the local origin, broadcast the event + // anyway (p2p will want to re-relay it if it's relayable). + if let Some(event) = + make_local_duplicate_tx_event(tx_id, relay_policy, origin) + { + events_broadcast.broadcast(event.into()); + } + } + } + Ok(TxStatus::InMempoolDuplicate) } TxAdditionOutcome::Rejected { transaction, error } => { @@ -603,8 +622,7 @@ impl<'a> TxFinalizer<'a> { .inspect_err(|err| match &mut self.events_mode { TxFinalizerEventsMode::Silent => {} TxFinalizerEventsMode::Broadcast(events_broadcast) => { - let event = - event::TransactionProcessed::rejected(tx_id, err.clone(), origin); + let event = make_tx_rejected_event(tx_id, err.clone(), origin); let event = event.into(); events_broadcast.broadcast(event); } diff --git a/mempool/src/pool/tests/basic.rs b/mempool/src/pool/tests/basic.rs index 3deac9f3f7..da4b1a353d 100644 --- a/mempool/src/pool/tests/basic.rs +++ b/mempool/src/pool/tests/basic.rs @@ -20,7 +20,7 @@ use tokio::sync::mpsc; use chainstate::BlockSource; use common::chain::block::timestamp::BlockTimestamp; -use crate::event::NewTip; +use crate::event::NewTipEvent; use super::*; @@ -366,7 +366,7 @@ async fn ibd_transition(#[case] seed: Seed) { assert!(mempool.contains_transaction(&tx_id)); // Check that the new tip event was sent. - let expected_event = MempoolEvent::NewTip(NewTip::new(block_id.into(), block_height)); + let expected_event = MempoolEvent::NewTip(NewTipEvent::new(block_id.into(), block_height)); let event = events_rx.recv().await; assert_eq!(event.as_ref(), Some(&expected_event)); diff --git a/mempool/src/rpc.rs b/mempool/src/rpc.rs index 41adfb59c1..82577de00e 100644 --- a/mempool/src/rpc.rs +++ b/mempool/src/rpc.rs @@ -65,8 +65,8 @@ trait MempoolRpc { /// Submit a transaction to the mempool. /// - /// Note that submitting a transaction to the mempool does not guarantee broadcasting it. - /// Use the p2p rpc interface for that. + /// Note that transactions submitted to the mempool this way will not be relayed to the peers. + /// Use the p2p rpc interface if you need that. #[method(name = "submit_transaction")] async fn submit_transaction( &self, @@ -157,7 +157,7 @@ impl MempoolRpcServer for super::MempoolHandle { let origin = LocalTxOrigin::Mempool; let options = TxOptions::default_for(origin.into()).with_overrides(options); let res = self - .call_mut(move |m| m.add_transaction_local(tx.take(), origin, options)) + .call_mut(move |m| m.add_transaction_local(tx.take(), origin, options).map(|_| ())) .await .log_err(); rpc::handle_result(res) diff --git a/mempool/types/src/lib.rs b/mempool/types/src/lib.rs index b40699d586..30da75edfd 100644 --- a/mempool/types/src/lib.rs +++ b/mempool/types/src/lib.rs @@ -19,3 +19,9 @@ mod tx_status; pub use tx_options::TxOptions; pub use tx_status::TxStatus; + +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum TransactionDuplicateStatus { + Duplicate, + New, +} diff --git a/mempool/types/src/tx_origin.rs b/mempool/types/src/tx_origin.rs index 196208fde7..570cba2c8d 100644 --- a/mempool/types/src/tx_origin.rs +++ b/mempool/types/src/tx_origin.rs @@ -40,13 +40,16 @@ impl From for TxOrigin { /// Signifies transaction originates in our local node #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] pub enum LocalTxOrigin { - /// Transaction was submitted to local node's mempool. It should not be propagated further. + /// Transaction was submitted directly to local node's mempool. + /// It should not be propagated further. Mempool, - /// Transaction was submitted via local node's RPC subsystem. It should be propagated if valid. + /// Transaction was submitted to the mempool via local node's P2P subsystem. + /// It should be propagated if valid. P2p, /// Transaction was in a block but moved into the mempool upon a reorg. + /// It should not be propagated further. PastBlock, } diff --git a/mocks/src/mempool.rs b/mocks/src/mempool.rs index cf8b07841d..baa269e10c 100644 --- a/mocks/src/mempool.rs +++ b/mocks/src/mempool.rs @@ -26,7 +26,7 @@ use mempool::{ event::MempoolEvent, tx_accumulator::{PackingStrategy, TransactionAccumulator}, tx_origin::{LocalTxOrigin, RemoteTxOrigin}, - FeeRate, MempoolInterface, MempoolMaxSize, TxOptions, TxStatus, + FeeRate, MempoolInterface, MempoolMaxSize, TransactionDuplicateStatus, TxOptions, TxStatus, }; mockall::mock! { @@ -38,7 +38,7 @@ mockall::mock! { tx: SignedTransaction, origin: LocalTxOrigin, options: TxOptions, - ) -> Result<(), Error>; + ) -> Result; fn add_transaction_remote( &mut self, diff --git a/p2p/src/interface/p2p_interface_impl.rs b/p2p/src/interface/p2p_interface_impl.rs index 9e2574c783..d6172f557a 100644 --- a/p2p/src/interface/p2p_interface_impl.rs +++ b/p2p/src/interface/p2p_interface_impl.rs @@ -167,11 +167,11 @@ where ) -> crate::Result<()> { let origin = LocalTxOrigin::P2p; let options = TxOptions::default_for(origin.into()).with_overrides(options); - let res = self + let _tx_duplicate_status = self .mempool_handle .call_mut(move |mempool| mempool.add_transaction_local(tx, origin, options)) .await??; - Ok(res) + Ok(()) } fn subscribe_to_events( diff --git a/p2p/src/sync/mod.rs b/p2p/src/sync/mod.rs index a43acb4652..ef141a86ee 100644 --- a/p2p/src/sync/mod.rs +++ b/p2p/src/sync/mod.rs @@ -38,8 +38,12 @@ use common::{ time_getter::TimeGetter, }; use logging::log; -use mempool::{event::TransactionProcessed, tx_origin::TxOrigin, MempoolHandle}; -use utils::{sync::Arc, tap_log::TapLog, tokio_spawn_in_join_set}; +use mempool::{ + event::{MempoolEvent, TransactionProcessedEvent}, + tx_origin::TxOrigin, + MempoolHandle, +}; +use utils::{debug_panic_or_log, sync::Arc, tap_log::TapLog, tokio_spawn_in_join_set}; use crate::{ config::P2pConfig, @@ -58,8 +62,12 @@ use self::chainstate_handle::ChainstateHandle; #[derive(Debug, Clone)] pub enum LocalEvent { + /// Chainstate got new tip. ChainstateNewTip(Id), - MempoolNewTx(Id), + + /// SyncManager got a tx from the mempool that should be relayed to other peers + /// (the tx is not necessarily a new one). + MempoolRelayableTx(Id), } pub struct PeerContext { @@ -288,17 +296,47 @@ where Ok(()) } - fn handle_transaction_processed(&mut self, tx_proc_event: &TransactionProcessed) -> Result<()> { + fn handle_transaction_processed( + &mut self, + tx_proc_event: &TransactionProcessedEvent, + ) -> Result<()> { let tx_id = *tx_proc_event.tx_id(); let origin = tx_proc_event.origin(); match tx_proc_event.result() { - Ok(()) => { - use mempool::tx_options::TxRelayPolicy; + Ok(duplicate_status) => { + use mempool::{tx_options::TxRelayPolicy, TransactionDuplicateStatus}; match tx_proc_event.relay_policy() { TxRelayPolicy::DoRelay => { - log::info!("Broadcasting transaction {tx_id} originating in {origin}"); - self.send_local_event(&LocalEvent::MempoolNewTx(tx_id)); + let (need_relay, status_str) = match duplicate_status { + TransactionDuplicateStatus::New => (true, "new"), + + TransactionDuplicateStatus::Duplicate => { + let need_relay = match tx_proc_event.origin() { + TxOrigin::Local(_) => true, + TxOrigin::Remote(_) => { + // The mempool is supposed to only send TransactionProcessedEvent's for duplicate + // transactions if they have the local origin. + debug_panic_or_log!( + "Unexpected TransactionProcessedEvent with non-local duplicate transaction received from mempool" + ); + false + } + }; + (need_relay, "duplicate") + } + }; + + if need_relay { + log::info!( + "Propagating {status_str} transaction {tx_id} originating in {origin}" + ); + self.send_local_event(&LocalEvent::MempoolRelayableTx(tx_id)); + } else { + log::trace!( + "Not propagating {status_str} transaction {tx_id} originating in {origin}" + ); + } } TxRelayPolicy::DontRelay => { log::trace!("Not propagating transaction {tx_id} originating in {origin}"); @@ -412,14 +450,14 @@ pub async fn subscribe_to_new_tip( /// Returns a receiver for the mempool `TransactionProcessed` events. pub async fn subscribe_to_tx_processed( mempool_handle: &MempoolHandle, -) -> Result> { +) -> Result> { let (sender, receiver) = mpsc::unbounded_channel(); - let subscribe_func = move |event: mempool::event::MempoolEvent| match event { - mempool::event::MempoolEvent::TransactionProcessed(tpe) => { + let subscribe_func = move |event: MempoolEvent| match event { + MempoolEvent::TransactionProcessed(tpe) => { let _ = sender.send(tpe).log_err_pfx("The tx processed receiver closed"); } - mempool::event::MempoolEvent::NewTip(_) => (), + MempoolEvent::NewTip(_) => (), }; let subscribe_func = Arc::new(subscribe_func); diff --git a/p2p/src/sync/peer/block_manager.rs b/p2p/src/sync/peer/block_manager.rs index 3ceb1900c3..0dba860d3b 100644 --- a/p2p/src/sync/peer/block_manager.rs +++ b/p2p/src/sync/peer/block_manager.rs @@ -329,7 +329,7 @@ where match event { LocalEvent::ChainstateNewTip(new_tip_id) => self.handle_new_tip(&new_tip_id).await, - LocalEvent::MempoolNewTx(_) => Ok(()), + LocalEvent::MempoolRelayableTx(_) => Ok(()), } } diff --git a/p2p/src/sync/peer/transaction_manager.rs b/p2p/src/sync/peer/transaction_manager.rs index 58ba513bb8..47803116a2 100644 --- a/p2p/src/sync/peer/transaction_manager.rs +++ b/p2p/src/sync/peer/transaction_manager.rs @@ -180,7 +180,7 @@ where match event { LocalEvent::ChainstateNewTip(_) => Ok(()), - LocalEvent::MempoolNewTx(txid) => { + LocalEvent::MempoolRelayableTx(txid) => { if !self.known_transactions.contains(&txid) && self.common_services.has_service(Service::Transactions) { diff --git a/p2p/src/sync/tests/helpers/mod.rs b/p2p/src/sync/tests/helpers/mod.rs index d095fb37ba..339b6339f0 100644 --- a/p2p/src/sync/tests/helpers/mod.rs +++ b/p2p/src/sync/tests/helpers/mod.rs @@ -45,7 +45,7 @@ use common::{ time_getter::TimeGetter, }; use logging::log; -use mempool::{event::TransactionProcessed, MempoolConfig, MempoolHandle, MempoolInit}; +use mempool::{event::TransactionProcessedEvent, MempoolConfig, MempoolHandle, MempoolInit}; use networking::transport::TcpTransportSocket; use p2p_test_utils::{expect_future_val, expect_no_recv, expect_recv, SHORT_TIMEOUT}; use p2p_types::{bannable_address::BannableAddress, socket_address::SocketAddress}; @@ -86,7 +86,7 @@ pub struct TestNode { chainstate_handle: ChainstateHandle, mempool_handle: MempoolHandle, new_tip_receiver: UnboundedReceiver>, - tx_processed_receiver: UnboundedReceiver, + tx_processed_receiver: UnboundedReceiver, sync_mgr_notification_receiver: UnboundedReceiver, protocol_version: ProtocolVersion, } @@ -249,6 +249,15 @@ impl TestNode { expect_recv!(self.transaction_sync_msg_receiver) } + pub fn try_get_sent_transaction_sync_message( + &mut self, + ) -> Option<(PeerId, TransactionSyncMessage)> { + match self.transaction_sync_msg_receiver.try_recv() { + Ok(message) => Some(message), + Err(mpsc::error::TryRecvError::Empty) => None, + Err(mpsc::error::TryRecvError::Disconnected) => panic!("Failed to receive event"), + } + } /// Panics if the sync manager returns an error. pub async fn assert_no_error(&mut self) { log::debug!("Asserting no error"); @@ -285,7 +294,7 @@ impl TestNode { pub async fn receive_transaction_processed_event_from_mempool( &mut self, - ) -> TransactionProcessed { + ) -> TransactionProcessedEvent { expect_recv!(self.tx_processed_receiver) } diff --git a/p2p/src/sync/tests/no_discouragement_after_tx_reorg.rs b/p2p/src/sync/tests/no_discouragement_after_tx_reorg.rs index af8a4e3dbb..fb212ef18f 100644 --- a/p2p/src/sync/tests/no_discouragement_after_tx_reorg.rs +++ b/p2p/src/sync/tests/no_discouragement_after_tx_reorg.rs @@ -335,7 +335,7 @@ async fn no_discouragement_after_tx_reorg(#[case] seed: Seed) { let tx_processed_by_mempool = node.receive_transaction_processed_event_from_mempool().await; - assert!(tx_processed_by_mempool.was_accepted()); + assert!(tx_processed_by_mempool.new_tx_accepted()); assert_eq!(tx_processed_by_mempool.tx_id(), &tx_id); let tx_in_mempool = diff --git a/p2p/src/sync/tests/tx_announcement.rs b/p2p/src/sync/tests/tx_announcement.rs index 453ca7c2ac..2b3e8238f6 100644 --- a/p2p/src/sync/tests/tx_announcement.rs +++ b/p2p/src/sync/tests/tx_announcement.rs @@ -16,22 +16,27 @@ use std::{collections::BTreeSet, sync::Arc, time::Duration}; use chainstate::{ban_score::BanScore, BlockSource}; -use chainstate_test_framework::{anyonecanspend_address, TestFramework}; +use chainstate_test_framework::{ + anyonecanspend_address, empty_witness, helpers::split_utxo, TestFramework, TransactionBuilder, +}; use common::{ chain::{ config::create_unit_test_config, output_value::OutputValue, signature::inputsig::InputWitness, timelock::OutputTimeLock, GenBlock, OutPointSourceId, - SignedTransaction, Transaction, TxInput, TxOutput, + SignedTransaction, Transaction, TxInput, TxOutput, UtxoOutPoint, }, primitives::{Amount, Id, Idable}, }; use mempool::{ error::{Error as MempoolError, MempoolPolicyError}, tx_origin::RemoteTxOrigin, - FeeRate, MempoolConfig, + FeeRate, MempoolConfig, TxOptions, }; use serialization::Encode; -use test_utils::{random::Seed, BasicTestTimeGetter}; +use test_utils::{ + random::{make_seedable_rng, Seed}, + BasicTestTimeGetter, +}; use crate::{ config::NodeType, @@ -589,7 +594,7 @@ async fn transaction_sequence_via_orphan_pool(#[case] seed: Seed) { // The transaction should be held up in the orphan pool for now, so we don't expect it to be // propagated at this point - assert_eq!(node.try_get_sent_block_sync_message(), None); + assert_eq!(node.try_get_sent_transaction_sync_message(), None); let res = node .mempool() @@ -622,17 +627,173 @@ async fn transaction_sequence_via_orphan_pool(#[case] seed: Seed) { .await; } +// When an duplicate tx is added to the mempool, it should be re-broadcast to peers if it has +// local origin and not re-broadcast if it's remote. +#[tracing::instrument(skip(seed))] +#[rstest::rstest] +#[trace] +#[case(Seed::from_entropy())] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rebroadcast_transaction(#[case] seed: Seed) { + for_each_protocol_version(|protocol_version| async move { + use mempool::tx_origin::LocalTxOrigin; + + let mut rng = make_seedable_rng(seed); + + let chain_config = Arc::new(create_unit_test_config()); + let mut tf = TestFramework::builder(&mut rng) + .with_chain_config(chain_config.as_ref().clone()) + .build(); + + // This will process a block to finish the initial block download and also create utxos + // to spend. + let fund_tx_id: Id = split_utxo( + &mut rng, + &mut tf, + UtxoOutPoint::new(chain_config.genesis_block_id().into(), 0), + 2, + ) + .into(); + + let p2p_config = Arc::new(test_p2p_config()); + let mut node = TestNode::builder(protocol_version) + .with_chain_config(Arc::clone(&chain_config)) + .with_p2p_config(Arc::clone(&p2p_config)) + .with_chainstate(tf.into_chainstate()) + .build() + .await; + + let peer1_id = PeerId::new(); + let peer2_id = PeerId::new(); + let another_peer_id = PeerId::new(); + + // Peer1 connects + let _peer1 = node.connect_peer(peer1_id, protocol_version).await; + + let local_tx = TransactionBuilder::new() + .add_input( + TxInput::from_utxo(fund_tx_id.into(), 0), + empty_witness(&mut rng), + ) + .add_output(TxOutput::Transfer( + OutputValue::Coin(Amount::from_atoms(100_000_000)), + common::chain::Destination::AnyoneCanSpend, + )) + .build(); + let local_tx_id = local_tx.transaction().get_id(); + + let remote_tx = TransactionBuilder::new() + .add_input( + TxInput::from_utxo(fund_tx_id.into(), 1), + empty_witness(&mut rng), + ) + .add_output(TxOutput::Transfer( + OutputValue::Coin(Amount::from_atoms(100_000_000)), + common::chain::Destination::AnyoneCanSpend, + )) + .build(); + let remote_tx_id = remote_tx.transaction().get_id(); + + let local_tx_origin = LocalTxOrigin::P2p; + let local_tx_options = TxOptions::default_for(local_tx_origin.into()); + + let remote_tx_origin = RemoteTxOrigin::new(another_peer_id); + let remote_tx_options = TxOptions::default_for(remote_tx_origin.clone().into()); + + // Add the local tx. The node should send a NewTransaction message to peer1. + { + let local_tx = local_tx.clone(); + let local_tx_options = local_tx_options.clone(); + let status = node + .mempool() + .call_mut(move |m| { + m.add_transaction_local(local_tx, local_tx_origin, local_tx_options) + }) + .await + .unwrap() + .unwrap(); + assert_eq!(status, mempool::TransactionDuplicateStatus::New); + + let (peer_id, msg) = node.get_sent_transaction_sync_message().await; + assert_eq!(peer_id, peer1_id); + assert_eq!(msg, TransactionSyncMessage::NewTransaction(local_tx_id)); + } + + // Add the remote tx. The node should send a NewTransaction message to peer1. + { + let remote_tx = remote_tx.clone(); + let remote_tx_options = remote_tx_options.clone(); + let status = node + .mempool() + .call_mut(move |m| { + m.add_transaction_remote(remote_tx, remote_tx_origin, remote_tx_options) + }) + .await + .unwrap() + .unwrap(); + assert_eq!(status, mempool::TxStatus::InMempool); + + let (peer_id, msg) = node.get_sent_transaction_sync_message().await; + assert_eq!(peer_id, peer1_id); + assert_eq!(msg, TransactionSyncMessage::NewTransaction(remote_tx_id)); + } + + // Peer2 connects + let _peer2 = node.connect_peer(peer2_id, protocol_version).await; + + // Add the local tx again. The sync manager should rebroadcast the tx. + // Since peer1 has already been offered this tx, the NewTransaction message + // should only be sent to peer2. + { + let status = node + .mempool() + .call_mut(move |m| { + m.add_transaction_local(local_tx, local_tx_origin, local_tx_options) + }) + .await + .unwrap() + .unwrap(); + assert_eq!(status, mempool::TransactionDuplicateStatus::Duplicate); + + let (peer_id, msg) = node.get_sent_transaction_sync_message().await; + assert_eq!(peer_id, peer2_id); + assert_eq!(msg, TransactionSyncMessage::NewTransaction(local_tx_id)); + + node.assert_no_sync_message().await; + } + + // Add the remote tx again. The sync manager should NOT rebroadcast the tx, + // so event peer2 will not be sent the NewTransaction message. + { + let status = node + .mempool() + .call_mut(move |m| { + m.add_transaction_remote(remote_tx, remote_tx_origin, remote_tx_options) + }) + .await + .unwrap() + .unwrap(); + assert_eq!(status, mempool::TxStatus::InMempoolDuplicate); + + node.assert_no_sync_message().await; + } + + node.join_subsystem_manager().await; + }) + .await; +} + /// Creates a simple transaction. -fn transaction_with_amount(out_point: Id, amount_atoms: u128) -> SignedTransaction { +fn transaction_with_amount(block_id: Id, amount_atoms: u128) -> SignedTransaction { let tx = Transaction::new( 0x00, - vec![TxInput::from_utxo(OutPointSourceId::from(out_point), 0)], + vec![TxInput::from_utxo(OutPointSourceId::from(block_id), 0)], vec![TxOutput::Burn(OutputValue::Coin(Amount::from_atoms(amount_atoms)))], ) .unwrap(); SignedTransaction::new(tx, vec![InputWitness::NoSignature(None)]).unwrap() } -fn transaction(out_point: Id) -> SignedTransaction { - transaction_with_amount(out_point, 1) +fn transaction(block_id: Id) -> SignedTransaction { + transaction_with_amount(block_id, 1) } diff --git a/wallet/wallet-node-client/src/handles_client/mod.rs b/wallet/wallet-node-client/src/handles_client/mod.rs index 5624b51d69..c04d0722f6 100644 --- a/wallet/wallet-node-client/src/handles_client/mod.rs +++ b/wallet/wallet-node-client/src/handles_client/mod.rs @@ -462,16 +462,15 @@ impl NodeInterface for WalletHandlesClient { async fn mempool_subscribe_to_events(&self) -> Result { let res = self.mempool.call_mut(move |this| this.subscribe_to_rpc_events()).await?; - let subscription = - res.into_stream().filter_map(|event| { - futures::future::ready(match event { - MempoolEvent::NewTip { .. } => None, - - MempoolEvent::TransactionProcessed(tx) => tx.was_accepted().then_some( - crate::node_traits::MempoolEvent::NewTransaction { tx_id: *tx.tx_id() }, - ), - }) - }); + let subscription = res.into_stream().filter_map(|event| { + futures::future::ready(match event { + MempoolEvent::NewTip { .. } => None, + + MempoolEvent::TransactionProcessed(tx) => tx.new_tx_accepted().then_some( + crate::node_traits::MempoolEvent::NewTransaction { tx_id: *tx.tx_id() }, + ), + }) + }); Ok(Box::new(subscription)) } From b3a58f759677cecae9e40172dd439a2788683f8e Mon Sep 17 00:00:00 2001 From: Mykhailo Kremniov Date: Thu, 30 Apr 2026 16:24:18 +0300 Subject: [PATCH 02/10] Fix exponential_rand and remove MAX_DELAY_FACTOR in p2p. Use a smaller tx relay delay for outbound connections. Replace "inbound: bool" with "direction: ConnectionDirection" in P2pEvent for consistency. --- Cargo.lock | 2 ++ node-gui/Cargo.toml | 1 + .../main_widget/tabs/networking.rs | 9 ++++-- node-gui/src/main_window/mod.rs | 7 +++-- p2p/backend-test-suite/src/ban.rs | 1 + .../src/block_announcement.rs | 2 ++ p2p/backend-test-suite/src/events.rs | 2 +- p2p/src/net/default_backend/backend.rs | 13 +++++---- p2p/src/net/default_backend/peer/mod.rs | 10 +++++++ p2p/src/net/types.rs | 2 ++ .../peer_manager/peerdb/address_data/mod.rs | 10 +------ .../peer_manager/peerdb/address_data/tests.rs | 7 +++-- p2p/src/sync/mod.rs | 5 ++++ p2p/src/sync/peer/transaction_manager.rs | 28 +++++++++++++------ p2p/src/sync/tests/helpers/mod.rs | 3 +- p2p/types/Cargo.toml | 1 + p2p/types/src/p2p_event.rs | 7 ++--- randomness/src/lib.rs | 2 +- utils/src/exp_rand/mod.rs | 13 ++++----- 19 files changed, 80 insertions(+), 45 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9475b5206a..eedd6b7d2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5447,6 +5447,7 @@ dependencies = [ "iced_aw", "iced_fonts", "logging", + "networking", "node-comm", "node-gui-backend", "node-lib", @@ -6272,6 +6273,7 @@ name = "p2p-types" version = "1.3.0" dependencies = [ "common", + "networking", "parity-scale-codec", "rpc-description", "serde", diff --git a/node-gui/Cargo.toml b/node-gui/Cargo.toml index dfbe0fab38..8e5e165866 100644 --- a/node-gui/Cargo.toml +++ b/node-gui/Cargo.toml @@ -10,6 +10,7 @@ rust-version.workspace = true chainstate = { path = "../chainstate" } common = { path = "../common" } logging = { path = "../logging" } +networking = { path = "../networking" } node-comm = { path = "../wallet/wallet-node-client", default-features = false } node-gui-backend = { path = "./backend", default-features = false } node-lib = { path = "../node-lib" } diff --git a/node-gui/src/main_window/main_widget/tabs/networking.rs b/node-gui/src/main_window/main_widget/tabs/networking.rs index 5d563bade8..750f3d4fdf 100644 --- a/node-gui/src/main_window/main_widget/tabs/networking.rs +++ b/node-gui/src/main_window/main_widget/tabs/networking.rs @@ -20,6 +20,7 @@ use iced::{ Element, Length, Task, }; use iced_aw::{tab_bar::TabLabel, Grid, GridRow}; +use networking::types::ConnectionDirection; use crate::main_window::NodeState; @@ -65,11 +66,15 @@ impl Tab for NetworkingTab { .connected_peers .iter() .map(|(peer_id, peer)| { - let inbound_str = if peer.inbound { "Inbound" } else { "Outbound" }; + let direction_str = match peer.direction { + ConnectionDirection::Inbound => "Inbound", + ConnectionDirection::Outbound => "Outbound", + }; + GridRow::new() .push(field(peer_id.to_string())) .push(field(peer.address.clone())) - .push(field(inbound_str.to_string())) + .push(field(direction_str.to_string())) .push(field(peer.user_agent.to_string())) .push(field(peer.version.to_string())) }) diff --git a/node-gui/src/main_window/mod.rs b/node-gui/src/main_window/mod.rs index 2cf24249f8..cf46724e93 100644 --- a/node-gui/src/main_window/mod.rs +++ b/node-gui/src/main_window/mod.rs @@ -25,6 +25,7 @@ use iced::{ Element, Length, Task, }; use logging::log; +use networking::types::ConnectionDirection; use node_gui_backend::{ messages::{ BackendEvent, BackendRequest, EncryptionAction, SignedTransactionWrapper, TransactionInfo, @@ -122,7 +123,7 @@ fn print_block_timestamp(timestamp: BlockTimestamp) -> String { #[derive(Debug)] pub struct Peer { address: String, - inbound: bool, + direction: ConnectionDirection, _services: Services, user_agent: UserAgent, version: SemVer, @@ -394,7 +395,7 @@ impl MainWindow { id, services, address, - inbound, + direction, user_agent, software_version: version, }) => { @@ -402,7 +403,7 @@ impl MainWindow { id, Peer { address, - inbound, + direction, _services: services, user_agent, version, diff --git a/p2p/backend-test-suite/src/ban.rs b/p2p/backend-test-suite/src/ban.rs index a1c8d81076..701a799b70 100644 --- a/p2p/backend-test-suite/src/ban.rs +++ b/p2p/backend-test-suite/src/ban.rs @@ -126,6 +126,7 @@ where SyncingEvent::Connected { peer_id, common_services: _, + direction: _, protocol_version: _, block_sync_msg_receiver, transaction_sync_msg_receiver: _, diff --git a/p2p/backend-test-suite/src/block_announcement.rs b/p2p/backend-test-suite/src/block_announcement.rs index 7c55551873..26ee32ec98 100644 --- a/p2p/backend-test-suite/src/block_announcement.rs +++ b/p2p/backend-test-suite/src/block_announcement.rs @@ -103,6 +103,7 @@ where SyncingEvent::Connected { peer_id: _, common_services: _, + direction: _, protocol_version: _, block_sync_msg_receiver, transaction_sync_msg_receiver: _, @@ -140,6 +141,7 @@ where SyncingEvent::Connected { peer_id: _, common_services: _, + direction: _, protocol_version: _, block_sync_msg_receiver, transaction_sync_msg_receiver: _, diff --git a/p2p/backend-test-suite/src/events.rs b/p2p/backend-test-suite/src/events.rs index 3e000f2681..8da6b6e70f 100644 --- a/p2p/backend-test-suite/src/events.rs +++ b/p2p/backend-test-suite/src/events.rs @@ -97,7 +97,7 @@ where id, services, address: _, - inbound: _, + direction: _, user_agent: _, software_version: _, }) => { diff --git a/p2p/src/net/default_backend/backend.rs b/p2p/src/net/default_backend/backend.rs index e5c9946ef0..2a76a80834 100644 --- a/p2p/src/net/default_backend/backend.rs +++ b/p2p/src/net/default_backend/backend.rs @@ -32,7 +32,10 @@ use common::{ time_getter::TimeGetter, }; use logging::log; -use networking::transport::{ConnectedSocketInfo, TransportListener, TransportSocket}; +use networking::{ + transport::{ConnectedSocketInfo, TransportListener, TransportSocket}, + types::ConnectionDirection, +}; use p2p_types::socket_address::SocketAddress; use randomness::{make_pseudo_rng, RngExt as _}; use utils::{ @@ -90,7 +93,7 @@ struct PeerContext { peer_address: SocketAddress, - inbound: bool, + direction: ConnectionDirection, protocol_version: SupportedProtocolVersion, @@ -277,6 +280,7 @@ where SyncingEvent::Connected { peer_id, common_services: peer.common_services, + direction: peer.direction, protocol_version: peer.protocol_version, block_sync_msg_receiver, transaction_sync_msg_receiver, @@ -287,7 +291,7 @@ where id: peer_id, services: peer.common_services, address: peer.peer_address.to_string(), - inbound: peer.inbound, + direction: peer.direction, user_agent: peer.user_agent.clone(), software_version: peer.software_version, }); @@ -474,7 +478,6 @@ where let common_services = peer_info.common_services; let protocol_version = peer_info.protocol_version; - let inbound = connection_info == ConnectionInfo::Inbound; let user_agent = peer_info.user_agent.clone(); let software_version = peer_info.software_version; @@ -505,7 +508,7 @@ where PeerContext { handle, peer_address, - inbound, + direction: connection_info.as_direction(), protocol_version, user_agent, software_version, diff --git a/p2p/src/net/default_backend/peer/mod.rs b/p2p/src/net/default_backend/peer/mod.rs index 3f54fd49fb..ddfad175b9 100644 --- a/p2p/src/net/default_backend/peer/mod.rs +++ b/p2p/src/net/default_backend/peer/mod.rs @@ -33,6 +33,7 @@ use networking::{ new_message_stream, ConnectedSocketInfo, MessageReader, MessageWriter, PeerStream, TransportSocket, }, + types::ConnectionDirection, }; use p2p_types::{peer_address::PeerAddress, services::Services, socket_addr_ext::SocketAddrExt}; use serialization::Encode as _; @@ -65,6 +66,15 @@ pub enum ConnectionInfo { }, } +impl ConnectionInfo { + pub fn as_direction(&self) -> ConnectionDirection { + match self { + ConnectionInfo::Inbound => ConnectionDirection::Inbound, + ConnectionInfo::Outbound { .. } => ConnectionDirection::Outbound, + } + } +} + pub struct Peer { /// Peer ID of the remote node peer_id: PeerId, diff --git a/p2p/src/net/types.rs b/p2p/src/net/types.rs index ddc7cf90dc..9918ce7a0d 100644 --- a/p2p/src/net/types.rs +++ b/p2p/src/net/types.rs @@ -21,6 +21,7 @@ use common::{ chain::{config::MagicBytes, ChainConfig}, primitives::{semver::SemVer, user_agent::UserAgent}, }; +use networking::types::ConnectionDirection; use p2p_types::socket_address::SocketAddress; use tokio::sync::mpsc::Receiver; @@ -308,6 +309,7 @@ pub enum SyncingEvent { Connected { peer_id: PeerId, common_services: Services, + direction: ConnectionDirection, protocol_version: SupportedProtocolVersion, block_sync_msg_receiver: Receiver, transaction_sync_msg_receiver: Receiver, diff --git a/p2p/src/peer_manager/peerdb/address_data/mod.rs b/p2p/src/peer_manager/peerdb/address_data/mod.rs index d0bacbcf13..40df54ba94 100644 --- a/p2p/src/peer_manager/peerdb/address_data/mod.rs +++ b/p2p/src/peer_manager/peerdb/address_data/mod.rs @@ -38,14 +38,6 @@ const PURGE_REACHABLE_TIME: Duration = Duration::from_secs(3600 * 24 * 7 * 4); pub const PURGE_REACHABLE_FAIL_COUNT: u32 = (PURGE_REACHABLE_TIME.as_secs() / MAX_DELAY_REACHABLE.as_secs()) as u32; -/// The maximum value for the random factor by which reconnection delays will be multiplied. -/// -/// Note that the value was chosen based on bitcoin's implementation of GetExponentialRand -/// (https://github.com/bitcoin/bitcoin/blob/5bbf735defac20f58133bea95226e13a5d8209bc/src/random.cpp#L689) -/// which they use to scale delays. In their implementation, the maximum scale factor will be -/// -ln(0.0000000000000035527136788) which is about 33. -const MAX_DELAY_FACTOR: u32 = 30; - // Note: AddressState/AddressData only track outbound connections, so if an inbound connection exists // from a given address, its AddressState may still be Disconnected or even Unreachable. #[derive(Debug, Clone)] @@ -200,7 +192,7 @@ impl AddressData { // so it's possible for both of them to be non-zero. let effective_fail_count = std::cmp::max(fail_count, connections_without_activity_count); - let factor = utils::exp_rand::exponential_rand(rng).clamp(0.0, MAX_DELAY_FACTOR as f64); + let factor = utils::exp_rand::exponential_rand(rng); let offset = Self::next_connect_delay(effective_fail_count, reserved).mul_f64(factor); (now + offset).expect("Unexpected time addition overflow") } diff --git a/p2p/src/peer_manager/peerdb/address_data/tests.rs b/p2p/src/peer_manager/peerdb/address_data/tests.rs index 89f2b7cd2f..e881ffd48b 100644 --- a/p2p/src/peer_manager/peerdb/address_data/tests.rs +++ b/p2p/src/peer_manager/peerdb/address_data/tests.rs @@ -114,8 +114,11 @@ fn reachable_reconnects(#[case] seed: Seed) { } fn next_connect_time_test_impl(rng: &mut impl Rng) { - let limit_reserved = MAX_DELAY_RESERVED * MAX_DELAY_FACTOR; - let limit_reachable = MAX_DELAY_REACHABLE * MAX_DELAY_FACTOR; + // Factors are produced by exponential_rand, which can't generate numbers bigger than this. + let max_delay_factor = 37; + + let limit_reserved = MAX_DELAY_RESERVED * max_delay_factor; + let limit_reachable = MAX_DELAY_REACHABLE * max_delay_factor; let start_time = Time::from_secs_since_epoch(0); let max_time_reserved = (start_time + limit_reserved).unwrap(); diff --git a/p2p/src/sync/mod.rs b/p2p/src/sync/mod.rs index ef141a86ee..bab7628613 100644 --- a/p2p/src/sync/mod.rs +++ b/p2p/src/sync/mod.rs @@ -26,6 +26,7 @@ use std::collections::HashMap; use dyn_clone::DynClone; use futures::never::Never; +use networking::types::ConnectionDirection; use tokio::{ sync::mpsc::{self, Receiver, UnboundedReceiver, UnboundedSender}, task::JoinSet, @@ -192,6 +193,7 @@ where &mut self, peer_id: PeerId, common_services: Services, + direction: ConnectionDirection, _protocol_version: SupportedProtocolVersion, block_sync_msg_receiver: Receiver, transaction_sync_msg_receiver: Receiver, @@ -230,6 +232,7 @@ where let mut mgr = peer::transaction_manager::PeerTransactionSyncManager::::new( peer_id, common_services, + direction, Arc::clone(&self.p2p_config), self.chainstate_handle.clone(), self.mempool_handle.clone(), @@ -387,12 +390,14 @@ where SyncingEvent::Connected { peer_id, common_services, + direction, protocol_version, block_sync_msg_receiver, transaction_sync_msg_receiver, } => self.register_peer( peer_id, common_services, + direction, protocol_version, block_sync_msg_receiver, transaction_sync_msg_receiver, diff --git a/p2p/src/sync/peer/transaction_manager.rs b/p2p/src/sync/peer/transaction_manager.rs index 47803116a2..09e344e2b3 100644 --- a/p2p/src/sync/peer/transaction_manager.rs +++ b/p2p/src/sync/peer/transaction_manager.rs @@ -15,6 +15,7 @@ use std::time::Duration; +use networking::types::ConnectionDirection; use randomness::make_pseudo_rng; use tokio::{ sync::mpsc::{Receiver, UnboundedReceiver, UnboundedSender}, @@ -52,8 +53,8 @@ use super::{ pending_transactions::PendingTransactions, requested_transactions::RequestedTransactions, }; -// TODO: add smaller interval for outbound connections -pub const TX_RELAY_DELAY_INTERVAL: Duration = Duration::from_secs(5); +pub const TX_RELAY_DELAY_INTERVAL_INBOUND: Duration = Duration::from_secs(5); +pub const TX_RELAY_DELAY_INTERVAL_OUTBOUND: Duration = Duration::from_secs(2); // TODO: Take into account the chain work when syncing. /// Transaction sync manager. @@ -63,6 +64,7 @@ pub struct PeerTransactionSyncManager { id: ConstValue, p2p_config: Arc, common_services: Services, + direction: ConnectionDirection, chainstate_handle: ChainstateHandle, mempool_handle: MempoolHandle, peer_mgr_event_sender: UnboundedSender, @@ -90,6 +92,7 @@ where pub fn new( id: PeerId, common_services: Services, + direction: ConnectionDirection, p2p_config: Arc, chainstate_handle: ChainstateHandle, mempool_handle: MempoolHandle, @@ -106,6 +109,7 @@ where id: id.into(), p2p_config, common_services, + direction, chainstate_handle, mempool_handle, peer_mgr_event_sender, @@ -188,7 +192,11 @@ where // TODO: whitelisted peers should get txs without delay let now = Instant::now(); - let delay = TX_RELAY_DELAY_INTERVAL + let base_delay = match self.direction { + ConnectionDirection::Inbound => TX_RELAY_DELAY_INTERVAL_INBOUND, + ConnectionDirection::Outbound => TX_RELAY_DELAY_INTERVAL_OUTBOUND, + }; + let delay = base_delay .mul_f64(utils::exp_rand::exponential_rand(&mut make_pseudo_rng())); self.pending_transactions.push(txid, now + delay); } @@ -263,7 +271,7 @@ where // because we purge "requested_transactions" from time to time. So it's technically // possible for such a response to be "solicited" but forgotten later. // So we just ignore the response. - log::warn!("Ignoring unsolicited TransactionResponse for tx {id}"); + log::warn!("Ignoring unsolicited TransactionResponse for tx {id:x}"); return Ok(()); } @@ -298,7 +306,7 @@ where } async fn handle_transaction_announcement(&mut self, tx: Id) -> Result<()> { - log::debug!("Handling transaction announcement: {tx}"); + log::debug!("Handling transaction announcement: {tx:x}"); self.add_known_transaction(tx); @@ -332,9 +340,11 @@ where // In our case, this is not that important, at least not until we implement a similar // kind of tx request de-duplication. // But still, it doesn't make sense to request an already requested tx again. - // Also, we don't punish the peer, mainly for consistency with other places, where - // we handle requested_transactions-related mischiefs leniently. - log::warn!("Ignoring duplicate announcement for tx {tx}"); + // Also, we don't punish the peer, because there are valid scenarios where a node may + // want to re-broadcast a tx; and since the rolling bloom filter used to track known + // txs can have false negatives, it's possible for a well-functioning peer to announce + // the same tx twice. + log::info!("Ignoring duplicate announcement for tx {tx:x}"); return Ok(()); } @@ -350,7 +360,7 @@ where // in such a situation. Note that after certain time, older requests will be purged // from requested_transactions, after which we'll start to handle peer's tx // announcements again. - log::warn!("Ignoring announcement for tx {tx} because requested_transactions is over the limit"); + log::warn!("Ignoring announcement for tx {tx:x} because requested_transactions is over the limit"); return Ok(()); } diff --git a/p2p/src/sync/tests/helpers/mod.rs b/p2p/src/sync/tests/helpers/mod.rs index 339b6339f0..effcf3393f 100644 --- a/p2p/src/sync/tests/helpers/mod.rs +++ b/p2p/src/sync/tests/helpers/mod.rs @@ -46,7 +46,7 @@ use common::{ }; use logging::log; use mempool::{event::TransactionProcessedEvent, MempoolConfig, MempoolHandle, MempoolInit}; -use networking::transport::TcpTransportSocket; +use networking::{transport::TcpTransportSocket, types::ConnectionDirection}; use p2p_test_utils::{expect_future_val, expect_no_recv, expect_recv, SHORT_TIMEOUT}; use p2p_types::{bannable_address::BannableAddress, socket_address::SocketAddress}; use randomness::{Rng, RngExt as _}; @@ -203,6 +203,7 @@ impl TestNode { .send(SyncingEvent::Connected { peer_id, common_services: (*self.p2p_config.node_type).into(), + direction: ConnectionDirection::Outbound, protocol_version: common_protocol_version, block_sync_msg_receiver, transaction_sync_msg_receiver, diff --git a/p2p/types/Cargo.toml b/p2p/types/Cargo.toml index 1145189912..40601766b7 100644 --- a/p2p/types/Cargo.toml +++ b/p2p/types/Cargo.toml @@ -8,6 +8,7 @@ rust-version.workspace = true [dependencies] common = { path = "../../common" } +networking = { path = "../../networking" } rpc-description = { path = "../../rpc/description" } serialization = { path = "../../serialization" } diff --git a/p2p/types/src/p2p_event.rs b/p2p/types/src/p2p_event.rs index 8dcf5bcdd2..b3a721aea4 100644 --- a/p2p/types/src/p2p_event.rs +++ b/p2p/types/src/p2p_event.rs @@ -15,21 +15,20 @@ use std::sync::Arc; -use serde::Serialize; - use common::primitives::{semver::SemVer, user_agent::UserAgent}; +use networking::types::ConnectionDirection; use crate::{peer_id::PeerId, services::Services}; pub type P2pEventHandler = Arc; -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum P2pEvent { PeerConnected { id: PeerId, services: Services, address: String, - inbound: bool, + direction: ConnectionDirection, user_agent: UserAgent, software_version: SemVer, }, diff --git a/randomness/src/lib.rs b/randomness/src/lib.rs index 652d2a15f7..eda29b1ea1 100644 --- a/randomness/src/lib.rs +++ b/randomness/src/lib.rs @@ -22,7 +22,7 @@ pub use rand::{seq, CryptoRng, Rng, RngExt, SeedableRng, TryCryptoRng, TryRng}; pub mod distributions { pub use rand::distr::{ - weighted::WeightedIndex, Alphanumeric, Distribution, SampleString, StandardUniform, + weighted::WeightedIndex, Alphanumeric, Distribution, Open01, SampleString, StandardUniform, }; pub mod uniform { pub use rand::distr::uniform::SampleRange; diff --git a/utils/src/exp_rand/mod.rs b/utils/src/exp_rand/mod.rs index 5f92d27b7e..05000df1c9 100644 --- a/utils/src/exp_rand/mod.rs +++ b/utils/src/exp_rand/mod.rs @@ -13,18 +13,15 @@ // See the License for the specific language governing permissions and // limitations under the License. -use randomness::{Rng, RngExt as _}; +use randomness::{distributions::Open01, Rng, RngExt as _}; /// Returns a value sampled from an exponential distribution with a mean of 1.0. /// -/// The result will be in the range (0, max], where max = -ln(f64::MIN_POSITIVE) ~= 708.396418532264. +/// The result will be in the range (0, max], where max = -ln(f64::EPSILON/2) ~= 36.7368. pub fn exponential_rand(rng: &mut impl Rng) -> f64 { - let mut random_f64 = rng.random::(); - // The generated number will be in the range [0, 1). Turn it into (0, 1) to avoid - // infinity when taking the logarithm. - if random_f64 == 0.0 { - random_f64 = f64::MIN_POSITIVE; - } + // This generates a number uniformly distributed in (0, 1) and having the form `n * ε + ε/2`, + // where n is a 52-bit number and ε is f64::EPSILON, which is roughly 1/2^52. + let random_f64: f64 = rng.sample(Open01); #[allow(clippy::float_arithmetic)] -random_f64.ln() From da95066e5a429c4a681f38ea7687cca093dd3d8f Mon Sep 17 00:00:00 2001 From: Mykhailo Kremniov Date: Thu, 30 Apr 2026 21:39:18 +0300 Subject: [PATCH 03/10] p2p: revamp tx sending (send multiple txs at once at random intervals instead of adding a random delay before each tx). Track which txs with local origin were broadcast but not sent, and rebroadcast them. --- mempool/src/error/ban_score.rs | 16 +- mempool/src/error/mod.rs | 23 +++ mempool/src/interface/mempool_interface.rs | 12 +- .../src/interface/mempool_interface_impl.rs | 11 +- mempool/src/pool/mod.rs | 26 ++- mempool/src/pool/tx_pool/collect_txs.rs | 151 ++++++++++++++- mempool/src/pool/tx_pool/mod.rs | 10 +- mempool/src/pool/tx_pool/store/mod.rs | 2 +- mempool/src/pool/tx_pool/tests/mod.rs | 1 + .../tests/tx_ids_by_score_and_ancestry.rs | 174 +++++++++++++++++ mocks/src/mempool.rs | 8 +- node-daemon/docs/RPC.md | 4 +- p2p/src/sync/mod.rs | 160 +++++++++++++--- p2p/src/sync/peer/block_manager.rs | 20 +- p2p/src/sync/peer/mod.rs | 2 - p2p/src/sync/peer/pending_transactions.rs | 158 --------------- p2p/src/sync/peer/transaction_manager.rs | 180 +++++++++++++----- utils/src/lib.rs | 1 + utils/src/sender_with_id.rs | 42 ++++ 19 files changed, 749 insertions(+), 252 deletions(-) create mode 100644 mempool/src/pool/tx_pool/tests/tx_ids_by_score_and_ancestry.rs delete mode 100644 p2p/src/sync/peer/pending_transactions.rs create mode 100644 utils/src/sender_with_id.rs diff --git a/mempool/src/error/ban_score.rs b/mempool/src/error/ban_score.rs index 6fab80f674..e9a199262d 100644 --- a/mempool/src/error/ban_score.rs +++ b/mempool/src/error/ban_score.rs @@ -25,7 +25,7 @@ use chainstate::{ }; use common::chain::IdCreationError; -use crate::error::{Error, MempoolPolicyError, ReorgError, TxValidationError}; +use crate::error::{Error, MempoolPolicyError, ReorgError, TxCollectionError, TxValidationError}; /// Ban score for transactions pub trait MempoolBanScore { @@ -47,6 +47,7 @@ impl MempoolBanScore for Error { // Inspect these errors as well, just in case Error::ChainstateError(err) => err.mempool_ban_score(), Error::ReorgError(err) => err.mempool_ban_score(), + Error::TxCollectionError(err) => err.mempool_ban_score(), // Internal error Error::SubsystemCallError(_) => 0, @@ -138,6 +139,19 @@ impl MempoolBanScore for ReorgError { } } +impl MempoolBanScore for TxCollectionError { + fn mempool_ban_score(&self) -> u32 { + match self { + // This represents a function contract violation by the caller code. + TxCollectionError::SpecifiedTxNotFound(_) => 0, + + // These 2 are mempool invariant errors. + TxCollectionError::TxParentNotFound { .. } + | TxCollectionError::TxChildNotFound { .. } => 0, + } + } +} + impl MempoolBanScore for ConnectTransactionError { fn mempool_ban_score(&self) -> u32 { match self { diff --git a/mempool/src/error/mod.rs b/mempool/src/error/mod.rs index 4449b74647..cb73652b86 100644 --- a/mempool/src/error/mod.rs +++ b/mempool/src/error/mod.rs @@ -43,6 +43,26 @@ pub enum BlockConstructionError { TxNotFound(Id), } +/// Error related to the collecting of a transaction sequence for purposes other than +/// block construction. +#[derive(Debug, Clone, Error, PartialEq, Eq)] +pub enum TxCollectionError { + #[error("Specified transaction {0:x} not found in mempool")] + SpecifiedTxNotFound(Id), + + #[error("Transaction {tx_id:x} has a parent {parent_tx_id:x} that is not in mempool")] + TxParentNotFound { + tx_id: Id, + parent_tx_id: Id, + }, + + #[error("Transaction {tx_id:x} has a child {child_tx_id:x} that is not in mempool")] + TxChildNotFound { + tx_id: Id, + child_tx_id: Id, + }, +} + #[derive(Debug, Clone, Error, PartialEq, Eq)] pub enum Error { #[error(transparent)] @@ -65,6 +85,9 @@ pub enum Error { #[error("Reorg error: {0}")] ReorgError(#[from] ReorgError), + + #[error("Transaction collection error: {0}")] + TxCollectionError(#[from] TxCollectionError), } #[derive(Debug, Clone, Error, PartialEq, Eq)] diff --git a/mempool/src/interface/mempool_interface.rs b/mempool/src/interface/mempool_interface.rs index 190c857d84..cf338a03ef 100644 --- a/mempool/src/interface/mempool_interface.rs +++ b/mempool/src/interface/mempool_interface.rs @@ -25,7 +25,7 @@ use common::{ primitives::Id, }; use mempool_types::TransactionDuplicateStatus; -use std::{num::NonZeroUsize, sync::Arc}; +use std::{collections::BTreeSet, num::NonZeroUsize, sync::Arc}; pub trait MempoolInterface: Send + Sync { /// Add a transaction from remote peer to mempool @@ -73,6 +73,16 @@ pub trait MempoolInterface: Send + Sync { packing_strategy: PackingStrategy, ) -> Result>, BlockConstructionError>; + /// Return at most `tx_count` transaction ids from `tx_ids`, ordering them by score and ancestry: + /// transactions with better score will come first and ancestors will come before their descendants. + /// + /// All transactions in `tx_ids` must be present in the mempool before the call. + fn get_best_tx_ids_by_score_and_ancestry( + &self, + tx_ids: &BTreeSet>, + tx_count: usize, + ) -> Result>, Error>; + /// Subscribe to events emitted by mempool subsystem fn subscribe_to_subsystem_events(&mut self, handler: Arc); diff --git a/mempool/src/interface/mempool_interface_impl.rs b/mempool/src/interface/mempool_interface_impl.rs index 446c87fa3d..35f9c34c6a 100644 --- a/mempool/src/interface/mempool_interface_impl.rs +++ b/mempool/src/interface/mempool_interface_impl.rs @@ -30,7 +30,7 @@ use common::{ }; use logging::log; use mempool_types::TransactionDuplicateStatus; -use std::{num::NonZeroUsize, sync::Arc}; +use std::{collections::BTreeSet, num::NonZeroUsize, sync::Arc}; use utils::{const_value::ConstValue, debug_panic_or_log, tap_log::TapLog}; type Mempool = crate::pool::Mempool; @@ -165,6 +165,15 @@ impl MempoolInterface for Mempool { self.collect_txs(tx_accumulator, transaction_ids, packing_strategy) } + #[tracing::instrument(skip_all)] + fn get_best_tx_ids_by_score_and_ancestry( + &self, + tx_ids: &BTreeSet>, + tx_count: usize, + ) -> Result>, Error> { + Ok(self.get_best_tx_ids_by_score_and_ancestry(tx_ids, tx_count)?) + } + fn subscribe_to_subsystem_events(&mut self, handler: Arc) { self.subscribe_to_events(handler); } diff --git a/mempool/src/pool/mod.rs b/mempool/src/pool/mod.rs index a10cf20aa2..77f7c79b4e 100644 --- a/mempool/src/pool/mod.rs +++ b/mempool/src/pool/mod.rs @@ -13,7 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::{num::NonZeroUsize, sync::Arc}; +use std::{collections::BTreeSet, num::NonZeroUsize, sync::Arc}; use chainstate::{ChainstateError, ChainstateEvent}; use common::{ @@ -30,7 +30,8 @@ use utils_networking::broadcaster; use crate::{ config, error::{ - BlockConstructionError, Error, MempoolPolicyError, OrphanPoolError, TxValidationError, + BlockConstructionError, Error, MempoolPolicyError, OrphanPoolError, TxCollectionError, + TxValidationError, }, event::{ make_local_duplicate_tx_event, make_new_tx_accepted_event, make_tx_rejected_event, @@ -445,6 +446,27 @@ impl Mempool { } } } + + pub fn get_best_tx_ids_by_score_and_ancestry( + &self, + tx_ids: &BTreeSet>, + tx_count: usize, + ) -> Result>, TxCollectionError> { + match &self.0 { + MempoolState::InIbd(_) => { + // The mempool is empty during IBD; return an error if `tx_ids` is non-empty, + // so that the function's contract is the same in and out of IBD. + if let Some(tx_id) = tx_ids.first() { + Err(TxCollectionError::SpecifiedTxNotFound(*tx_id)) + } else { + Ok(Vec::new()) + } + } + MempoolState::AfterIbd(state) => { + state.tx_pool.get_best_tx_ids_by_score_and_ancestry(tx_ids, tx_count) + } + } + } } // Mempool Event Reactions diff --git a/mempool/src/pool/tx_pool/collect_txs.rs b/mempool/src/pool/tx_pool/collect_txs.rs index 0cd96aec04..3723b70a71 100644 --- a/mempool/src/pool/tx_pool/collect_txs.rs +++ b/mempool/src/pool/tx_pool/collect_txs.rs @@ -14,7 +14,7 @@ // limitations under the License. use crate::{ - error::{BlockConstructionError, TxValidationError}, + error::{BlockConstructionError, TxCollectionError, TxValidationError}, pool::tx_pool::{tx_verifier, TxMempoolEntry, TxPool}, tx_accumulator::{PackingStrategy, TransactionAccumulator}, }; @@ -198,6 +198,17 @@ pub fn collect_txs( tx_verifier.connect_transaction(&tx_source, next_tx.transaction(), &unlock_timestamp); if let Err(err) = verification_result { + // TODO: this will fire because "parents" only reflect utxo-based relationships and not: + // 1) account-nonce-based ones - token commands and delegation withdrawals. + // 2) token creation vs token commands. + // 3) order creation vs order commands (though it's not a super useful scenario). + // 4) delegation id creation vs the delegation itself. + // 5) delegation id creation vs delegation withdrawal (not a super useful scenario). + // 6) pool creation vs delegation id creation. + // Need to update TxDependency to handle these relationships too and use TxDependency + // when determining "parents" for TxMempoolEntry. + // The old TODO goes below. + // TODO Narrow down when the critical error is presented. Printing the error may be a // false positive if the tip moves during the execution of this function. log::error!( @@ -243,3 +254,141 @@ pub fn collect_txs( Ok(Some(tx_accumulator)) } + +/// Return at most `tx_count` tx ids from `tx_ids`, ordering them by score and ancestry +/// (txs with better score will come first, ancestors will come before their descendants). +/// +/// All txs in `tx_ids` must be present in the mempool. +/// +/// Note: the ancestry is determined using TxMempoolEntry's `parents` and `children` collections, +/// which at the moment only reflect utxo-based relationships, see the TODO inside `collect_txs`. +pub fn get_best_tx_ids_by_score_and_ancestry( + mempool: &TxPool, + tx_ids: &BTreeSet>, + tx_count: usize, +) -> Result>, TxCollectionError> { + if tx_count == 0 { + return Ok(Vec::new()); + } + + // Map from a tx id to all ancestors of the tx that are present in `tx_ids`. + let mut selected_ancestors_map = BTreeMap::, BTreeSet>>::new(); + // Map from a tx id to all descendants of the tx that are present in `tx_ids`. + let mut selected_descendants_map = + BTreeMap::, BTreeSet>>::new(); + // Map from a tx id to the number of its ancestors that haven't been emitted so far. + let mut missing_ancestors_count_map = BTreeMap::, usize>::new(); + // Heap of tx entries all of whose ancestors have already been emitted. + let mut ready_txs = BinaryHeap::::with_capacity(tx_ids.len()); + + for tx_id in tx_ids { + let entry = mempool + .store + .get_entry(tx_id) + .ok_or(TxCollectionError::SpecifiedTxNotFound(*tx_id))?; + + collect_selected_ancestors(mempool, entry, &tx_ids, &mut selected_ancestors_map)?; + let selected_ancestors = selected_ancestors_map.get(&tx_id).expect("must be present"); + + if selected_ancestors.is_empty() { + ready_txs.push(entry.into()); + } else { + missing_ancestors_count_map.insert(*tx_id, selected_ancestors.len()); + for ancestor_id in selected_ancestors { + selected_descendants_map.entry(*ancestor_id).or_default().insert(*tx_id); + } + } + } + + let selected_descendants_map = selected_descendants_map; + drop(selected_ancestors_map); + + let mut result = Vec::with_capacity(std::cmp::min(tx_count, tx_ids.len())); + + while result.len() < tx_count { + let Some(tx_entry) = ready_txs.pop() else { + break; + }; + let tx_id = tx_entry.entry.tx_id(); + result.push(*tx_id); + + if let Some(child_ids) = selected_descendants_map.get(&tx_id) { + for child_id in child_ids { + match missing_ancestors_count_map.entry(*child_id) { + btree_map::Entry::Vacant(_) => {} + btree_map::Entry::Occupied(mut missing_ancestors_count) => { + match missing_ancestors_count.get_mut() { + 0 => { + // Should not be possible by construction. + panic!("pending child with 0 missing parents"); + } + 1 => { + let child_id = *missing_ancestors_count.key(); + missing_ancestors_count.remove(); + let child = + mempool.store.get_entry(&child_id).ok_or_else(|| { + TxCollectionError::TxChildNotFound { + tx_id: *tx_id, + child_tx_id: child_id, + } + })?; + ready_txs.push(child.into()); + } + missing_count => *missing_count -= 1, + } + } + } + } + } + } + + Ok(result) +} + +/// Collect all ancestors (both direct and indirect) of the specified tx that are present in `selected_tx_ids`. +/// +/// After the call, `ancestors_map` is guaranteed to contain the id of the tx as a key, and the value +/// will be the set of ancestors. +/// +/// Note: it'd be better if the function returned a reference to the collected ancestors set instead +/// of forcing the caller to do an additional lookup with `expect`, but the borrow checker throws +/// a tantrum in this case and there doesn't seem to be a way to pacify it without doing extra lookups +/// or cloning. +fn collect_selected_ancestors( + mempool: &TxPool, + tx_entry: &TxMempoolEntry, + selected_tx_ids: &BTreeSet>, + ancestors_map: &mut BTreeMap, BTreeSet>>, +) -> Result<(), TxCollectionError> { + let tx_id = tx_entry.tx_id(); + + if !ancestors_map.contains_key(tx_id) { + let mut tx_ancestors = BTreeSet::new(); + + for parent_id in tx_entry.parents() { + if selected_tx_ids.contains(parent_id) { + tx_ancestors.insert(*parent_id); + } else { + let parent_tx_entry = mempool.store.get_entry(parent_id).ok_or_else(|| { + TxCollectionError::TxParentNotFound { + tx_id: *tx_id, + parent_tx_id: *parent_id, + } + })?; + collect_selected_ancestors( + mempool, + parent_tx_entry, + selected_tx_ids, + ancestors_map, + )?; + + let parent_ancestors = ancestors_map.get(parent_id).expect("must be present"); + tx_ancestors.extend(parent_ancestors); + } + } + + ancestors_map.insert(*tx_id, tx_ancestors); + } + + Ok(()) +} diff --git a/mempool/src/pool/tx_pool/mod.rs b/mempool/src/pool/tx_pool/mod.rs index 15bb652e8e..b4203b3f14 100644 --- a/mempool/src/pool/tx_pool/mod.rs +++ b/mempool/src/pool/tx_pool/mod.rs @@ -54,7 +54,7 @@ use crate::{ config::{self, MempoolConfig, MempoolMaxSize}, error::{ BlockConstructionError, Error, MempoolConflictError, MempoolPolicyError, OrphanPoolError, - ReorgError, TxValidationError, + ReorgError, TxCollectionError, TxValidationError, }, pool::{ entry::{TxEntry, TxEntryWithFee}, @@ -876,6 +876,14 @@ impl TxPool { collect_txs::collect_txs(self, tx_accumulator, transaction_ids, packing_strategy) } + pub fn get_best_tx_ids_by_score_and_ancestry( + &self, + tx_ids: &BTreeSet>, + tx_count: usize, + ) -> Result>, TxCollectionError> { + collect_txs::get_best_tx_ids_by_score_and_ancestry(self, tx_ids, tx_count) + } + pub fn reorg( &mut self, block_id: Id, diff --git a/mempool/src/pool/tx_pool/store/mod.rs b/mempool/src/pool/tx_pool/store/mod.rs index 4749a3ee46..bea4ffc58f 100644 --- a/mempool/src/pool/tx_pool/store/mod.rs +++ b/mempool/src/pool/tx_pool/store/mod.rs @@ -103,7 +103,7 @@ pub struct MempoolStore { // Mempool entries sorted by ancestor score. // This is used to select the most economically attractive transactions for block production. // The ancestor score of an entry is defined as - // min(score/size of entry's tx, score/size with all ancestors). + // min(fee/size of entry's tx, fee/size with all ancestors). pub txs_by_ancestor_score: TrackedTxIdMultiMap, // Entries that have remained in the mempool for a long time (see DEFAULT_MEMPOOL_EXPIRY) are diff --git a/mempool/src/pool/tx_pool/tests/mod.rs b/mempool/src/pool/tx_pool/tests/mod.rs index d6923720b5..d67b172a8e 100644 --- a/mempool/src/pool/tx_pool/tests/mod.rs +++ b/mempool/src/pool/tx_pool/tests/mod.rs @@ -46,6 +46,7 @@ mod basic; mod expiry; mod reorg; mod replacement; +mod tx_ids_by_score_and_ancestry; pub mod utils; use self::utils::*; diff --git a/mempool/src/pool/tx_pool/tests/tx_ids_by_score_and_ancestry.rs b/mempool/src/pool/tx_pool/tests/tx_ids_by_score_and_ancestry.rs new file mode 100644 index 0000000000..7f7d1e0d44 --- /dev/null +++ b/mempool/src/pool/tx_pool/tests/tx_ids_by_score_and_ancestry.rs @@ -0,0 +1,174 @@ +// Copyright (c) 2026 RBB S.r.l +// opensource@mintlayer.org +// SPDX-License-Identifier: MIT +// Licensed under the MIT License; +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://github.com/mintlayer/mintlayer-core/blob/master/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::*; + +#[rstest] +#[trace] +#[case(Seed::from_entropy())] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tx_ids_by_score_and_ancestry(#[case] seed: Seed) { + let mut rng = make_seedable_rng(seed); + let mut tf = TestFramework::builder(&mut rng).build(); + let genesis_id = tf.genesis().get_id(); + + let output_amount = 1000; + let funding_tx = { + let mut builder = TransactionBuilder::new().add_input( + TxInput::from_utxo(OutPointSourceId::BlockReward(genesis_id.into()), 0), + empty_witness(&mut rng), + ); + + for _ in 0..7 { + builder = builder.add_output(TxOutput::Transfer( + OutputValue::Coin(Amount::from_atoms(output_amount)), + Destination::AnyoneCanSpend, + )); + } + + builder.build() + }; + let funding_tx_id = funding_tx.transaction().get_id(); + tf.make_block_builder() + .add_transaction(funding_tx) + .build_and_process(&mut rng) + .unwrap(); + + let mut mempool = setup_with_chainstate_generic( + tf.chainstate(), + MempoolConfig { + min_tx_relay_fee_rate: FeeRate::from_amount_per_kb(Amount::ZERO).into(), + }, + Default::default(), + ); + + // Create 3 independent transactions paying different fees ("high", "mid" and "low"). + let high_fee = rng.random_range(800..900); + let high_fee_tx = make_tx( + &mut rng, + &[(funding_tx_id.into(), 0)], + &[output_amount - high_fee], + ); + let high_fee_tx_id = high_fee_tx.transaction().get_id(); + let mid_fee = rng.random_range(700..800); + let mid_fee_tx = make_tx( + &mut rng, + &[(funding_tx_id.into(), 1)], + &[output_amount - mid_fee], + ); + let mid_fee_tx_id = mid_fee_tx.transaction().get_id(); + let low_fee = rng.random_range(600..700); + let low_fee_tx = make_tx( + &mut rng, + &[(funding_tx_id.into(), 2)], + &[output_amount - low_fee], + ); + let low_fee_tx_id = low_fee_tx.transaction().get_id(); + + // Create a chain of transactions where the parent pays fee event lower than the "low" one + // above, but descendants pay random fee that may be even higher than the "high" one. + let tx_chain_root_fee = rng.random_range(1..600); + let tx_chain_root_tx = make_tx( + &mut rng, + &[(funding_tx_id.into(), 3)], + &[1, output_amount - tx_chain_root_fee - 1], + ); + let tx_chain_root_tx_id = tx_chain_root_tx.transaction().get_id(); + + let tx_chain_tx1_fee = rng.random_range(500..1000); + let tx_chain_tx1 = make_tx( + &mut rng, + &[(funding_tx_id.into(), 4), (tx_chain_root_tx_id.into(), 0)], + &[1, output_amount - tx_chain_tx1_fee], + ); + let tx_chain_tx1_id = tx_chain_tx1.transaction().get_id(); + + let tx_chain_tx2_fee = rng.random_range(500..1000); + let tx_chain_tx2 = make_tx( + &mut rng, + &[(funding_tx_id.into(), 5), (tx_chain_tx1_id.into(), 0)], + &[1, output_amount - tx_chain_tx2_fee], + ); + let tx_chain_tx2_id = tx_chain_tx2.transaction().get_id(); + + let tx_chain_tx3_fee = rng.random_range(500..1000); + let tx_chain_tx3 = make_tx( + &mut rng, + &[(funding_tx_id.into(), 6), (tx_chain_tx2_id.into(), 0)], + &[1, output_amount - tx_chain_tx3_fee], + ); + let tx_chain_tx3_id = tx_chain_tx3.transaction().get_id(); + + for tx in [ + high_fee_tx, + mid_fee_tx, + low_fee_tx, + tx_chain_root_tx, + tx_chain_tx1, + tx_chain_tx2, + tx_chain_tx3, + ] { + assert_eq!(mempool.add_transaction_test(tx), Ok(TxStatus::InMempool)); + } + + // The set contains all 3 independent txs, and only the start and end txs of the chain. + let selected_tx_ids = std::collections::BTreeSet::from([ + high_fee_tx_id, + mid_fee_tx_id, + low_fee_tx_id, + tx_chain_root_tx_id, + tx_chain_tx3_id, + ]); + let result = mempool.get_best_tx_ids_by_score_and_ancestry(&selected_tx_ids, 1).unwrap(); + assert_eq!(result, [high_fee_tx_id]); + + let result = mempool.get_best_tx_ids_by_score_and_ancestry(&selected_tx_ids, 2).unwrap(); + assert_eq!(result, [high_fee_tx_id, mid_fee_tx_id]); + + let result = mempool.get_best_tx_ids_by_score_and_ancestry(&selected_tx_ids, 3).unwrap(); + assert_eq!(result, [high_fee_tx_id, mid_fee_tx_id, low_fee_tx_id]); + + let result = mempool.get_best_tx_ids_by_score_and_ancestry(&selected_tx_ids, 4).unwrap(); + assert_eq!( + result, + [high_fee_tx_id, mid_fee_tx_id, low_fee_tx_id, tx_chain_root_tx_id] + ); + + let result = mempool.get_best_tx_ids_by_score_and_ancestry(&selected_tx_ids, 5).unwrap(); + assert_eq!( + result, + [ + high_fee_tx_id, + mid_fee_tx_id, + low_fee_tx_id, + tx_chain_root_tx_id, + tx_chain_tx3_id + ] + ); + + let result = mempool + .get_best_tx_ids_by_score_and_ancestry(&selected_tx_ids, rng.random_range(6..100)) + .unwrap(); + assert_eq!( + result, + [ + high_fee_tx_id, + mid_fee_tx_id, + low_fee_tx_id, + tx_chain_root_tx_id, + tx_chain_tx3_id + ] + ); +} diff --git a/mocks/src/mempool.rs b/mocks/src/mempool.rs index baa269e10c..936cc117f6 100644 --- a/mocks/src/mempool.rs +++ b/mocks/src/mempool.rs @@ -15,7 +15,7 @@ #![allow(clippy::unwrap_used)] -use std::{num::NonZeroUsize, sync::Arc}; +use std::{collections::BTreeSet, num::NonZeroUsize, sync::Arc}; use common::{ chain::{GenBlock, SignedTransaction, Transaction}, @@ -61,6 +61,12 @@ mockall::mock! { packing_strategy: PackingStrategy, ) -> Result>, BlockConstructionError>; + fn get_best_tx_ids_by_score_and_ancestry( + &self, + tx_ids: &BTreeSet>, + tx_count: usize, + ) -> Result>, Error>; + fn subscribe_to_subsystem_events(&mut self, handler: Arc); fn subscribe_to_rpc_events(&mut self) -> utils_networking::broadcaster::Receiver; diff --git a/node-daemon/docs/RPC.md b/node-daemon/docs/RPC.md index 70e9dc8ef7..c8808df13a 100644 --- a/node-daemon/docs/RPC.md +++ b/node-daemon/docs/RPC.md @@ -883,8 +883,8 @@ Returns: Submit a transaction to the mempool. -Note that submitting a transaction to the mempool does not guarantee broadcasting it. -Use the p2p rpc interface for that. +Note that transactions submitted to the mempool this way will not be relayed to the peers. +Use the p2p rpc interface if you need that. Parameters: diff --git a/p2p/src/sync/mod.rs b/p2p/src/sync/mod.rs index bab7628613..3ca7e68270 100644 --- a/p2p/src/sync/mod.rs +++ b/p2p/src/sync/mod.rs @@ -22,14 +22,19 @@ mod peer_activity; mod peer_common; pub mod sync_status; -use std::collections::HashMap; +use std::{ + collections::{BTreeSet, HashMap}, + time::Duration, +}; use dyn_clone::DynClone; use futures::never::Never; use networking::types::ConnectionDirection; +use randomness::{make_pseudo_rng, RngExt as _}; use tokio::{ sync::mpsc::{self, Receiver, UnboundedReceiver, UnboundedSender}, task::JoinSet, + time::{Instant, MissedTickBehavior}, }; use tracing::Instrument; @@ -44,7 +49,10 @@ use mempool::{ tx_origin::TxOrigin, MempoolHandle, }; -use utils::{debug_panic_or_log, sync::Arc, tap_log::TapLog, tokio_spawn_in_join_set}; +use utils::{ + debug_panic_or_log, sender_with_id::MpscUnboundedSenderWithId, sync::Arc, tap_log::TapLog, + tokio_spawn_in_join_set, +}; use crate::{ config::P2pConfig, @@ -55,25 +63,26 @@ use crate::{ MessagingService, NetworkingService, SyncingEventReceiver, }, protocol::SupportedProtocolVersion, + sync::peer::{ + block_manager::PeerBlockSyncManagerLocalEvent, + transaction_manager::{ + PeerTransactionSyncManagerLocalEvent, PeerTransactionSyncManagerLocalNotification, + }, + }, types::peer_id::PeerId, PeerManagerEvent, Result, }; use self::chainstate_handle::ChainstateHandle; -#[derive(Debug, Clone)] -pub enum LocalEvent { - /// Chainstate got new tip. - ChainstateNewTip(Id), - - /// SyncManager got a tx from the mempool that should be relayed to other peers - /// (the tx is not necessarily a new one). - MempoolRelayableTx(Id), -} +// 1 to 1.5 average distances between blocks. +const UNCONFIRMED_TX_REQUEUE_MIN_DELAY: Duration = Duration::from_secs(120); +const UNCONFIRMED_TX_REQUEUE_MAX_DELAY: Duration = Duration::from_secs(180); pub struct PeerContext { tasks: JoinSet<()>, - local_event_senders: Vec>, + block_mgr_event_sender: UnboundedSender, + tx_mgr_event_sender: UnboundedSender, } /// Sync manager is responsible for syncing the local blockchain to the chain with most trust @@ -91,12 +100,21 @@ pub struct SyncManager { /// A sender for the peer manager events. peer_mgr_event_sender: UnboundedSender, + tx_mgr_notification_sender: + UnboundedSender<(PeerId, PeerTransactionSyncManagerLocalNotification)>, + tx_mgr_notification_receiver: + UnboundedReceiver<(PeerId, PeerTransactionSyncManagerLocalNotification)>, + chainstate_handle: ChainstateHandle, mempool_handle: MempoolHandle, /// The list of connected peers peers: HashMap, + /// Transactions with local origin that were forwarded to peer tasks to be announced to the peers + /// and for which the actual sending has not been confirmed yet. + unconfirmed_local_transactions: BTreeSet>, + time_getter: TimeGetter, /// SyncManager's observer for use by tests. @@ -147,15 +165,20 @@ where time_getter: TimeGetter, observer: Option, ) -> Self { + let (tx_mgr_notification_sender, tx_mgr_notification_receiver) = mpsc::unbounded_channel(); + Self { chain_config, p2p_config, messaging_handle, syncing_event_receiver, peer_mgr_event_sender, + tx_mgr_notification_sender, + tx_mgr_notification_receiver, chainstate_handle: ChainstateHandle::new(chainstate_handle), mempool_handle, peers: Default::default(), + unconfirmed_local_transactions: BTreeSet::new(), time_getter, observer, } @@ -165,9 +188,18 @@ where pub async fn run(mut self) -> Result { log::info!("Starting SyncManager"); + let maintenance_interval_duration = Duration::from_secs(1); + let mut maintenance_interval = tokio::time::interval_at( + Instant::now() + maintenance_interval_duration, + maintenance_interval_duration, + ); + maintenance_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); + let mut new_tip_receiver = subscribe_to_new_tip(&self.chainstate_handle).await?; let mut tx_processed_receiver = subscribe_to_tx_processed(&self.mempool_handle).await?; + let mut next_time_to_requeue_unconfirmed_local_txs = Instant::now(); + loop { tokio::select! { block_id = new_tip_receiver.recv() => { @@ -184,8 +216,55 @@ where event = self.syncing_event_receiver.poll_next() => { self.handle_peer_event(event?).await; }, + + notif_with_id = self.tx_mgr_notification_receiver.recv() => { + if let Some((peer_id, notif)) = notif_with_id { + self.handle_tx_mgr_notification(peer_id, notif).await; + } + } + + _ = maintenance_interval.tick() => {} } + + let now = Instant::now(); + + if now >= next_time_to_requeue_unconfirmed_local_txs { + self.requeue_unconfirmed_local_transactions().await?; + + let delay = make_pseudo_rng().random_range( + UNCONFIRMED_TX_REQUEUE_MIN_DELAY..UNCONFIRMED_TX_REQUEUE_MAX_DELAY, + ); + + next_time_to_requeue_unconfirmed_local_txs = now + delay; + } + } + } + + async fn requeue_unconfirmed_local_transactions(&mut self) -> Result<()> { + if !self.unconfirmed_local_transactions.is_empty() { + // Filter out transactions that are no longer in the mempool. + // Note that PeerTransactionSyncManager will check this too, but we have to do + // it here as well, to make sure that txs that were removed from the mempool (e.g. + // due to having been mined) don't remain in `unconfirmed_local_transactions` forever. + let tx_ids = std::mem::take(&mut self.unconfirmed_local_transactions); + self.unconfirmed_local_transactions = self + .mempool_handle + .call(move |m| { + let mut tx_ids = tx_ids; + tx_ids.retain(|tx_id| m.transaction(tx_id).is_some()); + tx_ids + }) + .await?; + } + + if !self.unconfirmed_local_transactions.is_empty() { + let txs = Arc::new(self.unconfirmed_local_transactions.clone()); + self.send_tx_mgr_event( + &PeerTransactionSyncManagerLocalEvent::UnconfirmedLocalTxsReannouncement(txs), + ); } + + Ok(()) } /// Starts a task for the new peer. @@ -198,12 +277,11 @@ where block_sync_msg_receiver: Receiver, transaction_sync_msg_receiver: Receiver, ) { - log::debug!("Register peer {peer_id} to sync manager"); + log::debug!("Registering peer {peer_id} to sync manager"); let mut peer_tasks = JoinSet::new(); - let mut peer_local_event_senders = Vec::new(); - let (local_event_sender, local_event_receiver) = mpsc::unbounded_channel(); + let (block_mgr_event_sender, block_mgr_event_receiver) = mpsc::unbounded_channel(); let mut mgr = peer::block_manager::PeerBlockSyncManager::::new( peer_id, common_services, @@ -213,7 +291,7 @@ where self.peer_mgr_event_sender.clone(), block_sync_msg_receiver, self.messaging_handle.clone(), - local_event_receiver, + block_mgr_event_receiver, self.time_getter.clone(), ); @@ -226,9 +304,7 @@ where &format!("Peer[id={peer_id}] block sync mgr"), ); - peer_local_event_senders.push(local_event_sender); - - let (local_event_sender, local_event_receiver) = mpsc::unbounded_channel(); + let (tx_mgr_event_sender, tx_mgr_event_receiver) = mpsc::unbounded_channel(); let mut mgr = peer::transaction_manager::PeerTransactionSyncManager::::new( peer_id, common_services, @@ -239,7 +315,8 @@ where self.peer_mgr_event_sender.clone(), transaction_sync_msg_receiver, self.messaging_handle.clone(), - local_event_receiver, + tx_mgr_event_receiver, + MpscUnboundedSenderWithId::new(peer_id, self.tx_mgr_notification_sender.clone()), self.time_getter.clone(), self.observer.clone(), ); @@ -253,11 +330,10 @@ where &format!("Peer[id={peer_id}] tx sync mgr"), ); - peer_local_event_senders.push(local_event_sender); - let peer_context = PeerContext { tasks: peer_tasks, - local_event_senders: peer_local_event_senders, + block_mgr_event_sender, + tx_mgr_event_sender, }; let prev_task = self.peers.insert(peer_id, peer_context); @@ -275,11 +351,15 @@ where peer.tasks.abort_all(); } - fn send_local_event(&mut self, event: &LocalEvent) { + fn send_block_mgr_event(&mut self, event: &PeerBlockSyncManagerLocalEvent) { for peer_ctx in self.peers.values_mut() { - for sender in &peer_ctx.local_event_senders { - let _ = sender.send(event.clone()); - } + let _ = peer_ctx.block_mgr_event_sender.send(event.clone()); + } + } + + fn send_tx_mgr_event(&mut self, event: &PeerTransactionSyncManagerLocalEvent) { + for peer_ctx in self.peers.values_mut() { + let _ = peer_ctx.tx_mgr_event_sender.send(event.clone()); } } @@ -294,7 +374,7 @@ where } log::debug!("Broadcasting a new tip {}", block_id); - self.send_local_event(&LocalEvent::ChainstateNewTip(block_id)); + self.send_block_mgr_event(&PeerBlockSyncManagerLocalEvent::ChainstateNewTip(block_id)); Ok(()) } @@ -334,7 +414,17 @@ where log::info!( "Propagating {status_str} transaction {tx_id} originating in {origin}" ); - self.send_local_event(&LocalEvent::MempoolRelayableTx(tx_id)); + + self.send_tx_mgr_event( + &PeerTransactionSyncManagerLocalEvent::MempoolRelayableTx(tx_id), + ); + + match tx_proc_event.origin() { + TxOrigin::Local(_) => { + self.unconfirmed_local_transactions.insert(tx_id); + } + TxOrigin::Remote(_) => {} + } } else { log::trace!( "Not propagating {status_str} transaction {tx_id} originating in {origin}" @@ -409,6 +499,18 @@ where } } + async fn handle_tx_mgr_notification( + &mut self, + _peer_id: PeerId, + notif: PeerTransactionSyncManagerLocalNotification, + ) { + match notif { + PeerTransactionSyncManagerLocalNotification::TransactionSent(id) => { + self.unconfirmed_local_transactions.remove(&id); + } + } + } + async fn notify_mempool_peer_disconnected(mempool_handle: &MempoolHandle, peer_id: PeerId) { mempool_handle .call_mut(move |mempool| mempool.notify_peer_disconnected(peer_id)) diff --git a/p2p/src/sync/peer/block_manager.rs b/p2p/src/sync/peer/block_manager.rs index 0dba860d3b..2fe77265ab 100644 --- a/p2p/src/sync/peer/block_manager.rs +++ b/p2p/src/sync/peer/block_manager.rs @@ -57,13 +57,18 @@ use crate::{ handle_message_processing_result, }, sync_status::PeerBlockSyncStatus, - LocalEvent, }, types::peer_id::PeerId, utils::oneshot_nofail, MessagingService, PeerManagerEvent, Result, }; +#[derive(Debug, Clone)] +pub enum PeerBlockSyncManagerLocalEvent { + /// Chainstate got new tip. + ChainstateNewTip(Id), +} + // TODO: Take into account the chain work when syncing. /// Block syncing manager. /// @@ -77,7 +82,7 @@ pub struct PeerBlockSyncManager { peer_mgr_event_sender: UnboundedSender, messaging_handle: T::MessagingHandle, sync_msg_receiver: Receiver, - local_event_receiver: UnboundedReceiver, + local_event_receiver: UnboundedReceiver, time_getter: TimeGetter, /// Incoming data state. incoming: IncomingDataState, @@ -128,7 +133,7 @@ where peer_mgr_event_sender: UnboundedSender, sync_msg_receiver: Receiver, messaging_handle: T::MessagingHandle, - local_event_receiver: UnboundedReceiver, + local_event_receiver: UnboundedReceiver, time_getter: TimeGetter, ) -> Self { Self { @@ -324,12 +329,13 @@ where Ok(()) } - async fn handle_local_event(&mut self, event: LocalEvent) -> Result<()> { - log::debug!("Handling local peer mgr event: {event:?}"); + async fn handle_local_event(&mut self, event: PeerBlockSyncManagerLocalEvent) -> Result<()> { + log::debug!("Handling local event: {event:?}"); match event { - LocalEvent::ChainstateNewTip(new_tip_id) => self.handle_new_tip(&new_tip_id).await, - LocalEvent::MempoolRelayableTx(_) => Ok(()), + PeerBlockSyncManagerLocalEvent::ChainstateNewTip(new_tip_id) => { + self.handle_new_tip(&new_tip_id).await + } } } diff --git a/p2p/src/sync/peer/mod.rs b/p2p/src/sync/peer/mod.rs index 56acb8eb8d..9f2882dcb9 100644 --- a/p2p/src/sync/peer/mod.rs +++ b/p2p/src/sync/peer/mod.rs @@ -16,5 +16,3 @@ pub mod block_manager; pub mod requested_transactions; pub mod transaction_manager; - -mod pending_transactions; diff --git a/p2p/src/sync/peer/pending_transactions.rs b/p2p/src/sync/peer/pending_transactions.rs deleted file mode 100644 index 2d93f27c18..0000000000 --- a/p2p/src/sync/peer/pending_transactions.rs +++ /dev/null @@ -1,158 +0,0 @@ -// Copyright (c) 2021-2023 RBB S.r.l -// opensource@mintlayer.org -// SPDX-License-Identifier: MIT -// Licensed under the MIT License; -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// https://github.com/mintlayer/mintlayer-core/blob/master/LICENSE -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use std::{cmp::Reverse, collections::BinaryHeap}; -use tokio::time::Instant; - -use common::{chain::Transaction, primitives::Id}; - -pub struct PendingTransactions { - txs: BinaryHeap)>>, -} - -impl PendingTransactions { - pub fn new() -> Self { - Self { - txs: Default::default(), - } - } - - pub fn push(&mut self, tx: Id, due_time: Instant) { - self.txs.push(Reverse((due_time, tx))); - } - - pub fn pop(&mut self) -> Option> { - self.txs.pop().map(|item| { - let (_, tx) = item.0; - tx - }) - } - - pub async fn due(&self) { - match self.txs.peek() { - Some(item) => { - let (due, _) = item.0; - tokio::time::sleep_until(due).await; - } - None => std::future::pending().await, - } - } -} - -#[cfg(test)] -mod tests { - use std::time::Duration; - - use super::*; - - use common::primitives::H256; - use rstest::rstest; - use test_utils::random::{make_seedable_rng, RngExt as _, Seed}; - - #[rstest] - #[trace] - #[case(Seed::from_entropy())] - fn modification_test(#[case] seed: Seed) { - let mut rng = make_seedable_rng(seed); - - let tx1 = Id::::new(H256::random_using(&mut rng)); - let tx2 = Id::::new(H256::random_using(&mut rng)); - let tx3 = Id::::new(H256::random_using(&mut rng)); - - let instant1 = Instant::now() + Duration::from_secs(1); - let instant2 = Instant::now() + Duration::from_secs(2); - let instant3 = Instant::now() + Duration::from_secs(3); - - let mut txs = PendingTransactions::new(); - assert_eq!(None, txs.pop()); - - txs.push(tx3, instant3); - txs.push(tx1, instant1); - txs.push(tx2, instant2); - - assert_eq!(Some(tx1), txs.pop()); - assert_eq!(Some(tx2), txs.pop()); - assert_eq!(Some(tx3), txs.pop()); - assert_eq!(None, txs.pop()); - } - - #[tokio::test] - async fn due_test() { - let before = Instant::now(); - - let tx = Id::::new(H256::zero()); - let due_instant = Instant::now() + Duration::from_secs(1); - - let mut txs = PendingTransactions::new(); - txs.push(tx, due_instant); - - tokio::time::pause(); - tokio::spawn(async { - tokio::time::advance(Duration::from_secs(1)).await; - }); - txs.due().await; - - let after = Instant::now(); - assert!(after.duration_since(before) >= Duration::from_secs(1)); - } - - #[tokio::test] - async fn due_from_the_past_test() { - let before = Instant::now(); - - let tx = Id::::new(H256::zero()); - let due_instant = Instant::now(); - - let mut txs = PendingTransactions::new(); - txs.push(tx, due_instant); - - tokio::time::pause(); - tokio::time::advance(Duration::from_secs(1)).await; - - // due is in the past now - assert!(due_instant < Instant::now()); - - txs.due().await; - - let after = Instant::now(); - assert!(after.duration_since(before) >= Duration::from_secs(1)); - } - - #[rstest] - #[trace] - #[case(Seed::from_entropy())] - #[tokio::test] - async fn due_pending_test(#[case] seed: Seed) { - let mut rng = make_seedable_rng(seed); - - tokio::time::pause(); - - // Spawn a task with a timeout - let timeout_duration = Duration::from_secs(rng.random_range(1..120)); - let test_task = tokio::spawn(async move { - let txs = PendingTransactions::new(); - tokio::time::timeout(timeout_duration, txs.due()).await - }); - - // Advance time manually - tokio::time::advance(timeout_duration).await; - - let result = test_task.await.unwrap(); - assert!( - result.is_err(), - "The due method should not complete when the queue is empty." - ); - } -} diff --git a/p2p/src/sync/peer/transaction_manager.rs b/p2p/src/sync/peer/transaction_manager.rs index 09e344e2b3..7dafd5a84d 100644 --- a/p2p/src/sync/peer/transaction_manager.rs +++ b/p2p/src/sync/peer/transaction_manager.rs @@ -13,7 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::time::Duration; +use std::{collections::BTreeSet, time::Duration}; use networking::types::ConnectionDirection; use randomness::make_pseudo_rng; @@ -29,8 +29,8 @@ use common::{ }; use logging::log; use mempool::{MempoolHandle, TxOptions}; -use utils::const_value::ConstValue; use utils::sync::Arc; +use utils::{const_value::ConstValue, sender_with_id::MpscUnboundedSenderWithId}; use crate::{ config::P2pConfig, @@ -43,18 +43,35 @@ use crate::{ sync::{ chainstate_handle::ChainstateHandle, peer_common::{handle_message_processing_result, KnownTransactions}, - BoxedObserver, LocalEvent, + BoxedObserver, }, types::peer_id::PeerId, MessagingService, PeerManagerEvent, Result, }; -use super::{ - pending_transactions::PendingTransactions, requested_transactions::RequestedTransactions, -}; +use super::requested_transactions::RequestedTransactions; + +const TX_RELAY_DELAY_INTERVAL_INBOUND: Duration = Duration::from_secs(5); +const TX_RELAY_DELAY_INTERVAL_OUTBOUND: Duration = Duration::from_secs(2); -pub const TX_RELAY_DELAY_INTERVAL_INBOUND: Duration = Duration::from_secs(5); -pub const TX_RELAY_DELAY_INTERVAL_OUTBOUND: Duration = Duration::from_secs(2); +#[derive(Debug, Clone)] +pub enum PeerTransactionSyncManagerLocalEvent { + /// SyncManager got a tx from the mempool that should be relayed to other peers + /// (the tx is not necessarily a new one). + MempoolRelayableTx(Id), + + /// There are some unconfirmed local txs that need to be re-announced. + UnconfirmedLocalTxsReannouncement(Arc>>), +} + +#[derive(Debug, Clone)] +pub enum PeerTransactionSyncManagerLocalNotification { + /// The transaction has been sent to the peer. + TransactionSent(Id), +} + +type PeerTransactionSyncManagerLocalNotificationSender = + MpscUnboundedSenderWithId; // TODO: Take into account the chain work when syncing. /// Transaction sync manager. @@ -70,15 +87,21 @@ pub struct PeerTransactionSyncManager { peer_mgr_event_sender: UnboundedSender, messaging_handle: T::MessagingHandle, sync_msg_receiver: Receiver, - local_event_receiver: UnboundedReceiver, + local_event_receiver: UnboundedReceiver, + local_notification_sender: PeerTransactionSyncManagerLocalNotificationSender, + /// A rolling filter of all known transactions (sent to us or sent by us) known_transactions: KnownTransactions, + /// This tracks transactions that we've requested from this peer but for which we haven't /// received a response yet. requested_transactions: RequestedTransactions, - /// Txs aren't relayed immediately but rather put into a collection to be propagated later - /// with random delay to make tracing transactions' origin harder - pending_transactions: PendingTransactions, + + /// Txs aren't announced immediately but rather put into a collection. The announcements + /// happen in batches at random time intervals, this makes tracing transactions' origin harder. + /// Note that we don't preserve the original order of the announcements, for the same reason. + transactions_to_announce: BTreeSet>, + /// SyncManager's observer for use by tests. observer: Option, } @@ -99,7 +122,8 @@ where peer_mgr_event_sender: UnboundedSender, sync_msg_receiver: Receiver, messaging_handle: T::MessagingHandle, - local_event_receiver: UnboundedReceiver, + local_event_receiver: UnboundedReceiver, + local_notification_sender: PeerTransactionSyncManagerLocalNotificationSender, time_getter: TimeGetter, observer: Option, ) -> Self { @@ -116,9 +140,10 @@ where messaging_handle, sync_msg_receiver, local_event_receiver, + local_notification_sender, known_transactions, requested_transactions: RequestedTransactions::new(time_getter), - pending_transactions: PendingTransactions::new(), + transactions_to_announce: BTreeSet::new(), observer, } } @@ -139,6 +164,7 @@ where async fn main_loop(&mut self) -> Result<()> { let peer_id = self.id(); + let maintenance_interval_duration = Duration::from_secs(1); let mut maintenance_interval = tokio::time::interval_at( Instant::now() + maintenance_interval_duration, @@ -146,6 +172,8 @@ where ); maintenance_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); + let mut next_time_to_announce_txs = Instant::now(); + loop { if let Some(o) = self.observer.as_mut() { o.on_new_transaction_sync_mgr_main_loop_iteration(peer_id); @@ -162,16 +190,27 @@ where self.handle_local_event(event)?; } - _ = self.pending_transactions.due() => { - if let Some(new_tx) = self.pending_transactions.pop(){ - self.send_message(TransactionSyncMessage::NewTransaction(new_tx))?; - } - } - _ = maintenance_interval.tick() => {} } self.requested_transactions.purge_if_needed(); + + let now = Instant::now(); + + if now >= next_time_to_announce_txs { + self.announce_transactions().await?; + + // TODO: whitelisted peers should get txs without delay, + // see https://github.com/mintlayer/mintlayer-core/issues/1406. + let base_delay = match self.direction { + ConnectionDirection::Inbound => TX_RELAY_DELAY_INTERVAL_INBOUND, + ConnectionDirection::Outbound => TX_RELAY_DELAY_INTERVAL_OUTBOUND, + }; + let delay = + base_delay.mul_f64(utils::exp_rand::exponential_rand(&mut make_pseudo_rng())); + + next_time_to_announce_txs = now + delay; + } } } @@ -179,32 +218,77 @@ where self.messaging_handle.send_transaction_sync_message(self.id(), message) } - fn handle_local_event(&mut self, event: LocalEvent) -> Result<()> { - log::debug!("Handling local peer mgr event: {event:?}"); + fn handle_local_event(&mut self, event: PeerTransactionSyncManagerLocalEvent) -> Result<()> { + log::debug!("Handling local event: {event:?}"); match event { - LocalEvent::ChainstateNewTip(_) => Ok(()), - LocalEvent::MempoolRelayableTx(txid) => { - if !self.known_transactions.contains(&txid) - && self.common_services.has_service(Service::Transactions) - { - self.add_known_transaction(txid); - - // TODO: whitelisted peers should get txs without delay - let now = Instant::now(); - let base_delay = match self.direction { - ConnectionDirection::Inbound => TX_RELAY_DELAY_INTERVAL_INBOUND, - ConnectionDirection::Outbound => TX_RELAY_DELAY_INTERVAL_OUTBOUND, - }; - let delay = base_delay - .mul_f64(utils::exp_rand::exponential_rand(&mut make_pseudo_rng())); - self.pending_transactions.push(txid, now + delay); + PeerTransactionSyncManagerLocalEvent::MempoolRelayableTx(tx_id) => { + self.enqueue_transactions_to_announce([tx_id]); + } + + PeerTransactionSyncManagerLocalEvent::UnconfirmedLocalTxsReannouncement(tx_ids) => { + // Note: no special handling for this case, i.e. each tx id still has to pass + // the `known_transactions` filter for the re-announcement attempt to be made + // (the re-announcement mostly targets peers that were not present when the tx + // was first announced). + self.enqueue_transactions_to_announce(tx_ids.iter().cloned()); + } + } + + Ok(()) + } + + fn enqueue_transactions_to_announce( + &mut self, + tx_ids: impl IntoIterator>, + ) { + if self.common_services.has_service(Service::Transactions) { + for tx_id in tx_ids.into_iter() { + if !self.known_transactions.contains(&tx_id) { + self.transactions_to_announce.insert(tx_id); } - Ok(()) } } } + async fn announce_transactions(&mut self) -> Result<()> { + let tx_ids = std::mem::take(&mut self.transactions_to_announce); + + let sorted_tx_ids = self + .mempool_handle + .call(move |m| { + let mut tx_ids = tx_ids; + + // Remove transactions that are no longer in the mempool, otherwise the next call may fail. + tx_ids.retain(|tx_id| m.transaction(tx_id).is_some()); + + // Sort the txs so that they don't become orphans immediately on the peer's side. + // Note: + // 1) At the moment the mempool tx sorting only reflects utxo-based relationships. + // So, some txs may become orphans on the peer's side, but it shouldn't be a big + // deal, since we announce all txs at once and the peer will normally request them + // immediately. + // 2) Ideally, we should limit the number of txs that are sent at a time, to put pressure + // against low-fee transactions during low-fee transaction floods (and this is where + // `get_best_tx_ids_by_score_and_ancestry`'s sorting by tx score will come in handy, + // which is redundant at the moment). But it's better to first improve mempool's + // tx sorting, to avoid creating "long-term" orphans on the peer's side. + // (note that in Bitcoin the max number of txs to announce at a time is + // INVENTORY_BROADCAST_TARGET, which is 70 at the moment, plus some small extra + // number based on the current number of unannounced txs, with the hard cap of + // INVENTORY_BROADCAST_MAX, which is 1000). + m.get_best_tx_ids_by_score_and_ancestry(&tx_ids, tx_ids.len()) + }) + .await??; + + for tx_id in sorted_tx_ids { + self.add_known_transaction(tx_id); + self.send_message(TransactionSyncMessage::NewTransaction(tx_id))?; + } + + Ok(()) + } + async fn handle_message(&mut self, message: TransactionSyncMessage) -> Result<()> { log::trace!("Handling tx sync message from the peer: {message:?}"); @@ -222,7 +306,7 @@ where handle_message_processing_result(&self.peer_mgr_event_sender, self.id(), res).await } - async fn handle_transaction_request(&mut self, id: Id) -> Result<()> { + async fn handle_transaction_request(&mut self, tx_id: Id) -> Result<()> { // TODO: should we handle a request if we haven't actually announced the tx? // Currently we do. // Note that in bitcoin-core they don't answer requests for txs that didn't exist @@ -236,13 +320,19 @@ where ))); } - let tx = self.mempool_handle.call(move |m| m.transaction(&id)).await?; - let res = match tx { - Some(tx) => TransactionResponse::Found(tx), - None => TransactionResponse::NotFound(id), + let tx = self.mempool_handle.call(move |m| m.transaction(&tx_id)).await?; + let (response, will_send_tx) = match tx { + Some(tx) => (TransactionResponse::Found(tx), true), + None => (TransactionResponse::NotFound(tx_id), false), }; - self.send_message(TransactionSyncMessage::TransactionResponse(res))?; + self.send_message(TransactionSyncMessage::TransactionResponse(response))?; + + if will_send_tx { + let _ = self + .local_notification_sender + .send(PeerTransactionSyncManagerLocalNotification::TransactionSent(tx_id)); + } Ok(()) } diff --git a/utils/src/lib.rs b/utils/src/lib.rs index 796f7651ea..b26bd093da 100644 --- a/utils/src/lib.rs +++ b/utils/src/lib.rs @@ -43,6 +43,7 @@ pub mod once_destructor; pub mod qrcode; pub mod root_user; pub mod rust_backtrace; +pub mod sender_with_id; pub mod set_flag; pub mod shallow_clone; pub mod sorted; diff --git a/utils/src/sender_with_id.rs b/utils/src/sender_with_id.rs new file mode 100644 index 0000000000..e7e917beb4 --- /dev/null +++ b/utils/src/sender_with_id.rs @@ -0,0 +1,42 @@ +// Copyright (c) 2026 RBB S.r.l +// opensource@mintlayer.org +// SPDX-License-Identifier: MIT +// Licensed under the MIT License; +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://github.com/mintlayer/mintlayer-core/blob/master/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use tokio::sync::mpsc; + +/// A wrapper that imitates `UnboundedSender`, while actually sending `(Id, T)`. +pub struct MpscUnboundedSenderWithId { + id: Id, + sender: mpsc::UnboundedSender<(Id, T)>, +} + +impl MpscUnboundedSenderWithId { + pub fn new(id: Id, sender: mpsc::UnboundedSender<(Id, T)>) -> Self { + Self { id, sender } + } + + pub fn send(&self, message: T) -> Result<(), mpsc::error::SendError> { + self.sender + .send((self.id.clone(), message)) + .map_err(|err| mpsc::error::SendError(err.0 .1)) + } + + pub async fn closed(&self) { + self.sender.closed().await + } + + pub fn is_closed(&self) -> bool { + self.sender.is_closed() + } +} From 09ed5a1bf54d18307c66396c313f18d244ce3c8f Mon Sep 17 00:00:00 2001 From: Mykhailo Kremniov Date: Mon, 4 May 2026 21:35:33 +0300 Subject: [PATCH 04/10] p2p: add a test for tx announcement order; introduce MonotonicTimeGetter. --- blockprod/src/lib.rs | 3 +- chainstate/test-framework/src/helpers.rs | 22 +- common/src/time_getter.rs | 58 ++++- mempool/src/pool/tx_pool/tests/utils.rs | 13 +- node-lib/src/runner.rs | 1 + p2p/backend-test-suite/src/ban.rs | 3 +- p2p/src/lib.rs | 8 +- p2p/src/peer_manager/mod.rs | 1 + p2p/src/sync/mod.rs | 21 +- p2p/src/sync/peer/transaction_manager.rs | 25 +- p2p/src/sync/tests/helpers/mod.rs | 52 ++-- p2p/src/sync/tests/tx_announcement.rs | 230 ++++++++++++++++-- p2p/src/tests/helpers/test_node.rs | 1 + p2p/tests/shutdown.rs | 1 + test-utils/src/basic_test_time_getter.rs | 16 +- test-utils/src/mock_time_getter.rs | 65 ++++- wallet/wallet-node-client/tests/call_tests.rs | 1 + wallet/wallet-test-node/src/lib.rs | 1 + 18 files changed, 435 insertions(+), 87 deletions(-) diff --git a/blockprod/src/lib.rs b/blockprod/src/lib.rs index 3e243a59c5..d8a25639aa 100644 --- a/blockprod/src/lib.rs +++ b/blockprod/src/lib.rs @@ -170,7 +170,7 @@ mod tests { TxOutput, }, primitives::{per_thousand::PerThousand, Amount, BlockHeight, Idable, H256}, - time_getter::TimeGetter, + time_getter::{MonotonicTimeGetter, TimeGetter}, Uint256, Uint512, }; use consensus::{calculate_effective_pool_balance, compact_target_to_target}; @@ -310,6 +310,7 @@ mod tests { subsystem::Handle::clone(&chainstate), mempool.clone(), time_getter, + MonotonicTimeGetter::default(), PeerDbStorageImpl::new(InMemory::new()).unwrap(), ) .expect("P2p initialization was successful") diff --git a/chainstate/test-framework/src/helpers.rs b/chainstate/test-framework/src/helpers.rs index 3e9636d836..cd50dc23c2 100644 --- a/chainstate/test-framework/src/helpers.rs +++ b/chainstate/test-framework/src/helpers.rs @@ -22,7 +22,8 @@ use common::{ signature::inputsig::InputWitness, tokens::{IsTokenFreezable, TokenId, TokenIssuance, TokenIssuanceV1, TokenTotalSupply}, AccountCommand, AccountNonce, AccountType, Block, Destination, GenBlock, OrderId, - OrdersVersion, Transaction, TxInput, TxOutput, UtxoOutPoint, + OrdersVersion, OutPointSourceId, SignedTransaction, Transaction, TxInput, TxOutput, + UtxoOutPoint, }, primitives::{Amount, BlockHeight, Id, Idable}, }; @@ -30,7 +31,7 @@ use orders_accounting::{OrdersAccountingDB, OrdersAccountingView as _}; use randomness::{CryptoRng, Rng, RngExt as _, SliceRandom as _}; use test_utils::{random_ascii_alphanumeric_string, token_utils::random_nft_issuance}; -use crate::{get_output_value, TestFramework, TransactionBuilder}; +use crate::{empty_witness, get_output_value, TestFramework, TransactionBuilder}; // Note: this function will create 2 blocks pub fn issue_and_mint_random_token_from_best_block( @@ -393,3 +394,20 @@ pub fn output_value_with_amount(output_value: &OutputValue, new_amount: Amount) OutputValue::TokenV1(id, _) => OutputValue::TokenV1(*id, new_amount), } } + +pub fn make_simple_coin_tx( + rng: &mut impl CryptoRng, + ins: &[(OutPointSourceId, u32)], + outs: &[u128], +) -> SignedTransaction { + let builder = ins.iter().fold(TransactionBuilder::new(), |b, (s, n)| { + b.add_input(TxInput::from_utxo(s.clone(), *n), empty_witness(rng)) + }); + let builder = outs.iter().fold(builder, |b, a| { + b.add_output(TxOutput::Transfer( + OutputValue::Coin(Amount::from_atoms(*a)), + Destination::AnyoneCanSpend, + )) + }); + builder.build() +} diff --git a/common/src/time_getter.rs b/common/src/time_getter.rs index 1024483862..eeea6db7eb 100644 --- a/common/src/time_getter.rs +++ b/common/src/time_getter.rs @@ -21,7 +21,7 @@ pub trait TimeGetterFn: Send + Sync { fn get_time(&self) -> Time; } -/// A function wrapper that contains the function that will be used to get the current time in chainstate +/// A time getter representing the wall clock. #[derive(Clone)] pub struct TimeGetter { f: Arc, @@ -66,3 +66,59 @@ impl TimeGetterFn for DefaultTimeGetterFn { time::get_time() } } + +pub trait MonotonicTimeGetterFn: Send + Sync { + fn get_time(&self) -> std::time::Instant; +} + +/// A time getter representing a monotonically non-decreasing clock. +/// +/// Note that mocking this one only makes sense in places where different `Instant` values are +/// compared explicitly, instead of e.g. relying on tokio's `sleep_until` or `interval_at`. In +/// the latter case, `tokio::time::advance` and `pause` can be used. But note that they require +/// the `current_thread` runtime, which is not always possible (e.g. subsystems' `BlockingHandle` +/// needs a multithreaded one), and this is the reason why we have this `MonotonicTimeGetter`. +#[derive(Clone)] +pub struct MonotonicTimeGetter { + f: Arc, +} + +impl utils::shallow_clone::ShallowClone for MonotonicTimeGetter { + fn shallow_clone(&self) -> Self { + Self::clone(self) + } +} + +impl MonotonicTimeGetter { + pub fn new(f: Arc) -> Self { + Self { f } + } + + pub fn get_time(&self) -> std::time::Instant { + self.f.get_time() + } + + pub fn getter(&self) -> &dyn MonotonicTimeGetterFn { + &*self.f + } +} + +impl Default for MonotonicTimeGetter { + fn default() -> Self { + Self::new(Arc::new(DefaultMonotonicTimeGetterFn::new())) + } +} + +struct DefaultMonotonicTimeGetterFn; + +impl DefaultMonotonicTimeGetterFn { + fn new() -> Self { + Self + } +} + +impl MonotonicTimeGetterFn for DefaultMonotonicTimeGetterFn { + fn get_time(&self) -> std::time::Instant { + std::time::Instant::now() + } +} diff --git a/mempool/src/pool/tx_pool/tests/utils.rs b/mempool/src/pool/tx_pool/tests/utils.rs index 98ad4cea9e..728e81c946 100644 --- a/mempool/src/pool/tx_pool/tests/utils.rs +++ b/mempool/src/pool/tx_pool/tests/utils.rs @@ -13,6 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +use chainstate_test_framework::helpers::make_simple_coin_tx; use common::chain::output_value::OutputValue; use common::chain::UtxoOutPoint; use common::primitives::H256; @@ -218,21 +219,13 @@ fn output_coin_amount(output: &TxOutput) -> Amount { val.coin_amount().unwrap_or(Amount::ZERO) } +// TODO: remove it, call make_simple_coin_tx directly pub fn make_tx( rng: &mut impl CryptoRng, ins: &[(OutPointSourceId, u32)], outs: &[u128], ) -> SignedTransaction { - let builder = ins.iter().fold(TransactionBuilder::new(), |b, (s, n)| { - b.add_input(TxInput::from_utxo(s.clone(), *n), empty_witness(rng)) - }); - let builder = outs.iter().fold(builder, |b, a| { - b.add_output(TxOutput::Transfer( - OutputValue::Coin(Amount::from_atoms(*a)), - Destination::AnyoneCanSpend, - )) - }); - builder.build() + make_simple_coin_tx(rng, ins, outs) } /// Generate a valid transaction graph. diff --git a/node-lib/src/runner.rs b/node-lib/src/runner.rs index 039ba3880e..7707249814 100644 --- a/node-lib/src/runner.rs +++ b/node-lib/src/runner.rs @@ -167,6 +167,7 @@ async fn initialize( subsystem::Handle::clone(&chainstate), subsystem::Handle::clone(&mempool), Default::default(), + Default::default(), peerdb_storage, )? .add_to_manager("p2p", &mut manager); diff --git a/p2p/backend-test-suite/src/ban.rs b/p2p/backend-test-suite/src/ban.rs index 701a799b70..82af405af6 100644 --- a/p2p/backend-test-suite/src/ban.rs +++ b/p2p/backend-test-suite/src/ban.rs @@ -21,7 +21,7 @@ use chainstate::{ban_score::BanScore, BlockError, ChainstateError, CheckBlockErr use common::{ chain::block::{timestamp::BlockTimestamp, Block, BlockReward, ConsensusData}, primitives::Idable, - time_getter::TimeGetter, + time_getter::{MonotonicTimeGetter, TimeGetter}, }; use networking::test_helpers::TestTransportMaker; use p2p::{ @@ -86,6 +86,7 @@ where mempool, peer_mgr_event_sender, time_getter.clone(), + MonotonicTimeGetter::default(), ); let (_shutdown_sender, shutdown_receiver) = oneshot::channel(); diff --git a/p2p/src/lib.rs b/p2p/src/lib.rs index 8b2f18e738..bbc0af5a6d 100644 --- a/p2p/src/lib.rs +++ b/p2p/src/lib.rs @@ -47,7 +47,7 @@ use ::utils::{atomics::SeqCstAtomicBool, ensure, tokio_spawn_in_current_tracing_ use common::{ chain::{config::ChainType, ChainConfig}, primitives::BlockHeight, - time_getter::TimeGetter, + time_getter::{MonotonicTimeGetter, TimeGetter}, }; use interface::p2p_interface::P2pInterface; use logging::log; @@ -119,6 +119,7 @@ where chainstate_handle: chainstate::ChainstateHandle, mempool_handle: MempoolHandle, time_getter: TimeGetter, + monotonic_time_getter: MonotonicTimeGetter, peerdb_storage: S, ) -> Result { let shutdown = Arc::new(SeqCstAtomicBool::new(false)); @@ -186,6 +187,7 @@ where mempool_handle.clone(), peer_mgr_event_sender.clone(), time_getter, + monotonic_time_getter, ); let shutdown_ = Arc::clone(&shutdown); let sync_manager_task = tokio_spawn_in_current_tracing_span( @@ -269,6 +271,7 @@ pub struct P2pInit { chainstate_handle: chainstate::ChainstateHandle, mempool_handle: MempoolHandle, time_getter: TimeGetter, + monotonic_time_getter: MonotonicTimeGetter, peerdb_storage: S, bind_addresses: Vec, } @@ -290,6 +293,7 @@ impl P2pInit { self.chainstate_handle, self.mempool_handle, self.time_getter, + self.monotonic_time_getter, self.peerdb_storage, ) .await @@ -321,6 +325,7 @@ pub fn make_p2p( chainstate_handle: chainstate::ChainstateHandle, mempool_handle: MempoolHandle, time_getter: TimeGetter, + monotonic_time_getter: MonotonicTimeGetter, peerdb_storage: S, ) -> Result> { match chain_config.chain_type() { @@ -396,6 +401,7 @@ pub fn make_p2p( chainstate_handle, mempool_handle, time_getter, + monotonic_time_getter, peerdb_storage, bind_addresses, }) diff --git a/p2p/src/peer_manager/mod.rs b/p2p/src/peer_manager/mod.rs index a820023036..c371232428 100644 --- a/p2p/src/peer_manager/mod.rs +++ b/p2p/src/peer_manager/mod.rs @@ -2343,6 +2343,7 @@ where log::warn!("Starting with networking disabled"); } + // TODO: switch to using MonotonicTimeGetter for calculating delays. let mut last_time = self.time_getter.get_time(); let mut next_time_resend_own_address = self.time_getter.get_time(); diff --git a/p2p/src/sync/mod.rs b/p2p/src/sync/mod.rs index 3ca7e68270..8821933820 100644 --- a/p2p/src/sync/mod.rs +++ b/p2p/src/sync/mod.rs @@ -34,14 +34,14 @@ use randomness::{make_pseudo_rng, RngExt as _}; use tokio::{ sync::mpsc::{self, Receiver, UnboundedReceiver, UnboundedSender}, task::JoinSet, - time::{Instant, MissedTickBehavior}, + time::MissedTickBehavior, }; use tracing::Instrument; use common::{ chain::{config::ChainConfig, GenBlock, Transaction}, primitives::Id, - time_getter::TimeGetter, + time_getter::{MonotonicTimeGetter, TimeGetter}, }; use logging::log; use mempool::{ @@ -115,7 +115,11 @@ pub struct SyncManager { /// and for which the actual sending has not been confirmed yet. unconfirmed_local_transactions: BTreeSet>, + // TODO: most (or maybe all) places in the sync mgr where `time_getter` is currently used + // should probably use `monotonic_time_getter` instead (because we're dealing with delays here + // and don't need absolute time). time_getter: TimeGetter, + monotonic_time_getter: MonotonicTimeGetter, /// SyncManager's observer for use by tests. observer: Option, @@ -139,6 +143,7 @@ where mempool_handle: MempoolHandle, peer_mgr_event_sender: UnboundedSender, time_getter: TimeGetter, + monotonic_time_getter: MonotonicTimeGetter, ) -> Self { Self::new_generic( chain_config, @@ -149,6 +154,7 @@ where mempool_handle, peer_mgr_event_sender, time_getter, + monotonic_time_getter, None, ) } @@ -163,6 +169,7 @@ where mempool_handle: MempoolHandle, peer_mgr_event_sender: UnboundedSender, time_getter: TimeGetter, + monotonic_time_getter: MonotonicTimeGetter, observer: Option, ) -> Self { let (tx_mgr_notification_sender, tx_mgr_notification_receiver) = mpsc::unbounded_channel(); @@ -180,6 +187,7 @@ where peers: Default::default(), unconfirmed_local_transactions: BTreeSet::new(), time_getter, + monotonic_time_getter, observer, } } @@ -190,7 +198,7 @@ where let maintenance_interval_duration = Duration::from_secs(1); let mut maintenance_interval = tokio::time::interval_at( - Instant::now() + maintenance_interval_duration, + tokio::time::Instant::now() + maintenance_interval_duration, maintenance_interval_duration, ); maintenance_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); @@ -198,7 +206,7 @@ where let mut new_tip_receiver = subscribe_to_new_tip(&self.chainstate_handle).await?; let mut tx_processed_receiver = subscribe_to_tx_processed(&self.mempool_handle).await?; - let mut next_time_to_requeue_unconfirmed_local_txs = Instant::now(); + let mut next_time_to_requeue_unconfirmed_local_txs = self.monotonic_time_getter.get_time(); loop { tokio::select! { @@ -226,7 +234,7 @@ where _ = maintenance_interval.tick() => {} } - let now = Instant::now(); + let now = self.monotonic_time_getter.get_time(); if now >= next_time_to_requeue_unconfirmed_local_txs { self.requeue_unconfirmed_local_transactions().await?; @@ -318,6 +326,7 @@ where tx_mgr_event_receiver, MpscUnboundedSenderWithId::new(peer_id, self.tx_mgr_notification_sender.clone()), self.time_getter.clone(), + self.monotonic_time_getter.clone(), self.observer.clone(), ); @@ -579,7 +588,7 @@ pub async fn subscribe_to_tx_processed( pub trait Observer: DynClone { /// This will be called on each iteration of PeerTransactionSyncManager's main loop /// (currently only used by Peer V2). - fn on_new_transaction_sync_mgr_main_loop_iteration(&mut self, peer_id: PeerId); + fn on_transaction_sync_mgr_main_loop_iteration_completed(&mut self, peer_id: PeerId); } pub type BoxedObserver = Box; diff --git a/p2p/src/sync/peer/transaction_manager.rs b/p2p/src/sync/peer/transaction_manager.rs index 7dafd5a84d..101e82531e 100644 --- a/p2p/src/sync/peer/transaction_manager.rs +++ b/p2p/src/sync/peer/transaction_manager.rs @@ -19,13 +19,13 @@ use networking::types::ConnectionDirection; use randomness::make_pseudo_rng; use tokio::{ sync::mpsc::{Receiver, UnboundedReceiver, UnboundedSender}, - time::{Instant, MissedTickBehavior}, + time::MissedTickBehavior, }; use common::{ chain::Transaction, primitives::{Id, Idable}, - time_getter::TimeGetter, + time_getter::{MonotonicTimeGetter, TimeGetter}, }; use logging::log; use mempool::{MempoolHandle, TxOptions}; @@ -51,8 +51,8 @@ use crate::{ use super::requested_transactions::RequestedTransactions; -const TX_RELAY_DELAY_INTERVAL_INBOUND: Duration = Duration::from_secs(5); -const TX_RELAY_DELAY_INTERVAL_OUTBOUND: Duration = Duration::from_secs(2); +pub const TX_RELAY_DELAY_INTERVAL_INBOUND: Duration = Duration::from_secs(5); +pub const TX_RELAY_DELAY_INTERVAL_OUTBOUND: Duration = Duration::from_secs(2); #[derive(Debug, Clone)] pub enum PeerTransactionSyncManagerLocalEvent { @@ -89,6 +89,7 @@ pub struct PeerTransactionSyncManager { sync_msg_receiver: Receiver, local_event_receiver: UnboundedReceiver, local_notification_sender: PeerTransactionSyncManagerLocalNotificationSender, + monotonic_time_getter: MonotonicTimeGetter, /// A rolling filter of all known transactions (sent to us or sent by us) known_transactions: KnownTransactions, @@ -125,6 +126,7 @@ where local_event_receiver: UnboundedReceiver, local_notification_sender: PeerTransactionSyncManagerLocalNotificationSender, time_getter: TimeGetter, + monotonic_time_getter: MonotonicTimeGetter, observer: Option, ) -> Self { let known_transactions = KnownTransactions::new(); @@ -141,6 +143,7 @@ where sync_msg_receiver, local_event_receiver, local_notification_sender, + monotonic_time_getter, known_transactions, requested_transactions: RequestedTransactions::new(time_getter), transactions_to_announce: BTreeSet::new(), @@ -167,18 +170,14 @@ where let maintenance_interval_duration = Duration::from_secs(1); let mut maintenance_interval = tokio::time::interval_at( - Instant::now() + maintenance_interval_duration, + tokio::time::Instant::now() + maintenance_interval_duration, maintenance_interval_duration, ); maintenance_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); - let mut next_time_to_announce_txs = Instant::now(); + let mut next_time_to_announce_txs = self.monotonic_time_getter.get_time(); loop { - if let Some(o) = self.observer.as_mut() { - o.on_new_transaction_sync_mgr_main_loop_iteration(peer_id); - } - tokio::select! { message = self.sync_msg_receiver.recv() => { let message = message.ok_or(P2pError::ChannelClosed)?; @@ -195,7 +194,7 @@ where self.requested_transactions.purge_if_needed(); - let now = Instant::now(); + let now = self.monotonic_time_getter.get_time(); if now >= next_time_to_announce_txs { self.announce_transactions().await?; @@ -211,6 +210,10 @@ where next_time_to_announce_txs = now + delay; } + + if let Some(o) = self.observer.as_mut() { + o.on_transaction_sync_mgr_main_loop_iteration_completed(peer_id); + } } } diff --git a/p2p/src/sync/tests/helpers/mod.rs b/p2p/src/sync/tests/helpers/mod.rs index effcf3393f..60831aef62 100644 --- a/p2p/src/sync/tests/helpers/mod.rs +++ b/p2p/src/sync/tests/helpers/mod.rs @@ -42,7 +42,7 @@ use common::{ TxOutput, }, primitives::{Amount, BlockHeight, Id, Idable, H256}, - time_getter::TimeGetter, + time_getter::{MonotonicTimeGetter, TimeGetter}, }; use logging::log; use mempool::{event::TransactionProcessedEvent, MempoolConfig, MempoolHandle, MempoolInit}; @@ -51,7 +51,7 @@ use p2p_test_utils::{expect_future_val, expect_no_recv, expect_recv, SHORT_TIMEO use p2p_types::{bannable_address::BannableAddress, socket_address::SocketAddress}; use randomness::{Rng, RngExt as _}; use subsystem::{ManagerJoinHandle, ShutdownTrigger}; -use test_utils::random::Seed; +use test_utils::{random::Seed, BasicTestTimeGetter}; use utils::{atomics::SeqCstAtomicBool, tokio_spawn_in_current_tracing_span}; use utils_networking::IpOrSocketAddress; @@ -111,6 +111,7 @@ impl TestNode { shutdown_trigger: ShutdownTrigger, subsystem_manager_handle: ManagerJoinHandle, time_getter: TimeGetter, + monotonic_time_getter: MonotonicTimeGetter, protocol_version: ProtocolVersion, ) -> Self { let (peer_manager_event_sender, peer_manager_event_receiver) = mpsc::unbounded_channel(); @@ -139,6 +140,7 @@ impl TestNode { mempool_handle.clone(), peer_manager_event_sender, time_getter, + monotonic_time_getter, Some(sync_mgr_observer), ); @@ -238,27 +240,14 @@ impl TestNode { expect_recv!(self.block_sync_msg_receiver) } - pub fn try_get_sent_block_sync_message(&mut self) -> Option<(PeerId, BlockSyncMessage)> { - match self.block_sync_msg_receiver.try_recv() { - Ok(message) => Some(message), - Err(mpsc::error::TryRecvError::Empty) => None, - Err(mpsc::error::TryRecvError::Disconnected) => panic!("Failed to receive event"), - } - } - pub async fn get_sent_transaction_sync_message(&mut self) -> (PeerId, TransactionSyncMessage) { expect_recv!(self.transaction_sync_msg_receiver) } - pub fn try_get_sent_transaction_sync_message( - &mut self, - ) -> Option<(PeerId, TransactionSyncMessage)> { - match self.transaction_sync_msg_receiver.try_recv() { - Ok(message) => Some(message), - Err(mpsc::error::TryRecvError::Empty) => None, - Err(mpsc::error::TryRecvError::Disconnected) => panic!("Failed to receive event"), - } + pub async fn assert_no_sent_transaction_sync_message(&mut self) { + expect_no_recv!(self.transaction_sync_msg_receiver); } + /// Panics if the sync manager returns an error. pub async fn assert_no_error(&mut self) { log::debug!("Asserting no error"); @@ -565,6 +554,7 @@ pub struct TestNodeBuilder { chainstate_config: Option, chainstate: Option>, time_getter: TimeGetter, + monotonic_time_getter: MonotonicTimeGetter, blocks: Vec, protocol_version: ProtocolVersion, } @@ -578,6 +568,7 @@ impl TestNodeBuilder { chainstate_config: None, chainstate: None, time_getter: TimeGetter::default(), + monotonic_time_getter: MonotonicTimeGetter::default(), blocks: Vec::new(), protocol_version, } @@ -613,6 +604,19 @@ impl TestNodeBuilder { self } + pub fn with_monotonic_time_getter( + mut self, + monotonic_time_getter: MonotonicTimeGetter, + ) -> Self { + self.monotonic_time_getter = monotonic_time_getter; + self + } + + pub fn with_common_time_getter(self, common_time_getter: &BasicTestTimeGetter) -> Self { + self.with_time_getter(common_time_getter.get_time_getter()) + .with_monotonic_time_getter(common_time_getter.get_monotonic_time_getter()) + } + pub fn with_blocks(mut self, blocks: Vec) -> Self { self.blocks = blocks; self @@ -626,6 +630,7 @@ impl TestNodeBuilder { chainstate_config, chainstate, time_getter, + monotonic_time_getter, blocks, protocol_version, } = self; @@ -672,6 +677,7 @@ impl TestNodeBuilder { shutdown_trigger, manager_handle, time_getter, + monotonic_time_getter, protocol_version, ) .await @@ -851,7 +857,7 @@ impl SyncingEventReceiver for SyncingEventReceiverMock { #[derive(Debug, Clone, PartialEq, Eq)] pub enum SyncManagerNotification { - NewTxSyncManagerMainLoopIteration { peer_id: PeerId }, + TxSyncManagerMainLoopIterationCompleted { peer_id: PeerId }, } #[derive(Clone)] @@ -876,10 +882,10 @@ impl SyncManagerObserver { } impl Observer for SyncManagerObserver { - fn on_new_transaction_sync_mgr_main_loop_iteration(&mut self, peer_id: PeerId) { - self.send_notification(SyncManagerNotification::NewTxSyncManagerMainLoopIteration { - peer_id, - }); + fn on_transaction_sync_mgr_main_loop_iteration_completed(&mut self, peer_id: PeerId) { + self.send_notification( + SyncManagerNotification::TxSyncManagerMainLoopIterationCompleted { peer_id }, + ); } } diff --git a/p2p/src/sync/tests/tx_announcement.rs b/p2p/src/sync/tests/tx_announcement.rs index 2b3e8238f6..5176932f14 100644 --- a/p2p/src/sync/tests/tx_announcement.rs +++ b/p2p/src/sync/tests/tx_announcement.rs @@ -17,7 +17,9 @@ use std::{collections::BTreeSet, sync::Arc, time::Duration}; use chainstate::{ban_score::BanScore, BlockSource}; use chainstate_test_framework::{ - anyonecanspend_address, empty_witness, helpers::split_utxo, TestFramework, TransactionBuilder, + anyonecanspend_address, empty_witness, + helpers::{make_simple_coin_tx, split_utxo}, + TestFramework, TransactionBuilder, }; use common::{ chain::{ @@ -29,11 +31,13 @@ use common::{ }; use mempool::{ error::{Error as MempoolError, MempoolPolicyError}, - tx_origin::RemoteTxOrigin, + tx_origin::{LocalTxOrigin, RemoteTxOrigin}, FeeRate, MempoolConfig, TxOptions, }; +use randomness::{CryptoRng, RngExt as _, SliceRandom}; use serialization::Encode; use test_utils::{ + assert_matches_return_val, random::{make_seedable_rng, Seed}, BasicTestTimeGetter, }; @@ -45,6 +49,7 @@ use crate::{ protocol::ProtocolConfig, sync::{ peer::requested_transactions::REQUESTED_TX_EXPIRY_PERIOD, + peer::transaction_manager::TX_RELAY_DELAY_INTERVAL_OUTBOUND, tests::helpers::{PeerManagerEventDesc, SyncManagerNotification, TestNode}, }, test_helpers::{for_each_protocol_version, test_p2p_config}, @@ -112,10 +117,15 @@ async fn invalid_transaction(#[case] seed: Seed) { // Transaction announcements are ignored during the initial block download, but it isn't considered // an error or misbehavior. -#[tracing::instrument] +#[tracing::instrument(skip(seed))] +#[rstest::rstest] +#[trace] +#[case(Seed::from_entropy())] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn initial_block_download() { +async fn initial_block_download(#[case] seed: Seed) { for_each_protocol_version(|protocol_version| async move { + let mut rng = make_seedable_rng(seed); + let chain_config = Arc::new(create_unit_test_config()); let mut node = TestNode::builder(protocol_version) .with_chain_config(Arc::clone(&chain_config)) @@ -124,7 +134,7 @@ async fn initial_block_download() { let peer = node.connect_peer(PeerId::new(), protocol_version).await; - let tx = transaction(chain_config.genesis_block_id()); + let tx = transaction(&mut rng, chain_config.genesis_block_id()); peer.send_transaction_sync_message(TransactionSyncMessage::NewTransaction( tx.transaction().get_id(), )) @@ -185,7 +195,7 @@ async fn no_transaction_service(#[case] seed: Seed) { let peer = node.connect_peer(PeerId::new(), protocol_version).await; - let tx = transaction(chain_config.genesis_block_id()); + let tx = transaction(&mut rng, chain_config.genesis_block_id()); peer.send_transaction_sync_message(TransactionSyncMessage::NewTransaction( tx.transaction().get_id(), )) @@ -264,7 +274,7 @@ async fn too_many_announcements(#[case] seed: Seed) { // Respond to node's initial header request (made inside connect_peer) peer.send_headers(vec![]).await; - let tx1 = transaction_with_amount(chain_config.genesis_block_id(), 1); + let tx1 = transaction_with_amount(&mut rng, chain_config.genesis_block_id(), 1); let tx1_id = tx1.transaction().get_id(); peer.send_transaction_sync_message(TransactionSyncMessage::NewTransaction(tx1_id)) .await; @@ -274,7 +284,7 @@ async fn too_many_announcements(#[case] seed: Seed) { assert_eq!(message, TransactionSyncMessage::TransactionRequest(tx1_id)); // Do not respond to the tx request, make another announcement - let tx2 = transaction_with_amount(chain_config.genesis_block_id(), 2); + let tx2 = transaction_with_amount(&mut rng, chain_config.genesis_block_id(), 2); let tx2_id = tx2.transaction().get_id(); peer.send_transaction_sync_message(TransactionSyncMessage::NewTransaction(tx2_id)) .await; @@ -290,9 +300,11 @@ async fn too_many_announcements(#[case] seed: Seed) { // Make sure that the Peer task has an opportunity to handle expired requests. node.clear_notifications().await; - node.wait_for_notification(SyncManagerNotification::NewTxSyncManagerMainLoopIteration { - peer_id: peer.get_id(), - }) + node.wait_for_notification( + SyncManagerNotification::TxSyncManagerMainLoopIterationCompleted { + peer_id: peer.get_id(), + }, + ) .await; // Announce the same tx @@ -338,7 +350,7 @@ async fn duplicated_announcement(#[case] seed: Seed) { let peer = node.connect_peer(PeerId::new(), protocol_version).await; - let tx = transaction(chain_config.genesis_block_id()); + let tx = transaction(&mut rng, chain_config.genesis_block_id()); peer.send_transaction_sync_message(TransactionSyncMessage::NewTransaction( tx.transaction().get_id(), )) @@ -392,7 +404,7 @@ async fn valid_transaction(#[case] seed: Seed) { let peer = node.connect_peer(PeerId::new(), protocol_version).await; - let tx = transaction(chain_config.genesis_block_id()); + let tx = transaction(&mut rng, chain_config.genesis_block_id()); peer.send_transaction_sync_message(TransactionSyncMessage::NewTransaction( tx.transaction().get_id(), )) @@ -459,19 +471,24 @@ async fn valid_transaction_with_fee_below_minimum(#[case] seed: Seed) { let peer = node.connect_peer(PeerId::new(), protocol_version).await; - let estimated_tx_size = - transaction_with_amount(block1_id.into(), new_block_reward_amount.into_atoms()) - .encoded_size(); + let estimated_tx_size = transaction_with_amount( + &mut rng, + block1_id.into(), + new_block_reward_amount.into_atoms(), + ) + .encoded_size(); let min_tx_fee = min_fee_rate.compute_fee(estimated_tx_size).unwrap().into_atoms(); // tx1's fee is below the minimum let tx1 = transaction_with_amount( + &mut rng, block1_id.into(), new_block_reward_amount.into_atoms() - min_tx_fee / 2, ); let tx1_id = tx1.transaction().get_id(); // tx2's fee is exactly the minimal one. let tx2 = transaction_with_amount( + &mut rng, block2_id.into(), new_block_reward_amount.into_atoms() - min_tx_fee, ); @@ -594,7 +611,7 @@ async fn transaction_sequence_via_orphan_pool(#[case] seed: Seed) { // The transaction should be held up in the orphan pool for now, so we don't expect it to be // propagated at this point - assert_eq!(node.try_get_sent_transaction_sync_message(), None); + node.assert_no_sent_transaction_sync_message().await; let res = node .mempool() @@ -783,17 +800,176 @@ async fn rebroadcast_transaction(#[case] seed: Seed) { .await; } -/// Creates a simple transaction. -fn transaction_with_amount(block_id: Id, amount_atoms: u128) -> SignedTransaction { - let tx = Transaction::new( - 0x00, - vec![TxInput::from_utxo(OutPointSourceId::from(block_id), 0)], - vec![TxOutput::Burn(OutputValue::Coin(Amount::from_atoms(amount_atoms)))], +// Check that the tx sync mgr announces all txs at once after a delay and that the announcements +// have a specific order. +// * Create 3 transactions that should have a specific order in the mempool. +// * Add them to the mempool, expect no announcement. +// * Advance the mock time, the transactions should now be announced, in the above-mentioned order. +#[tracing::instrument(skip(seed))] +#[rstest::rstest] +#[trace] +#[case(Seed::from_entropy())] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn transaction_announcements_are_batched_and_sorted(#[case] seed: Seed) { + for_each_protocol_version(|protocol_version| async move { + let mut rng = make_seedable_rng(seed); + + let chain_config = Arc::new(create_unit_test_config()); + let time_getter = BasicTestTimeGetter::new(); + let mut tf = TestFramework::builder(&mut rng) + .with_chain_config(chain_config.as_ref().clone()) + .with_time_getter(time_getter.get_time_getter()) + .build(); + + let output_amount = 1_000; + let funding_tx = { + let mut builder = TransactionBuilder::new().add_input( + TxInput::from_utxo( + OutPointSourceId::BlockReward(tf.genesis().get_id().into()), + 0, + ), + empty_witness(&mut rng), + ); + + for _ in 0..3 { + builder = builder.add_output(TxOutput::Transfer( + OutputValue::Coin(Amount::from_atoms(output_amount)), + common::chain::Destination::AnyoneCanSpend, + )); + } + + builder.build() + }; + let funding_tx_id = funding_tx.transaction().get_id(); + tf.make_block_builder() + .add_transaction(funding_tx) + .build_and_process(&mut rng) + .unwrap() + .unwrap(); + + let p2p_config = Arc::new(test_p2p_config()); + + let mut node = TestNode::builder(protocol_version) + .with_chain_config(Arc::clone(&chain_config)) + .with_p2p_config(Arc::clone(&p2p_config)) + .with_mempool_config(MempoolConfig { + min_tx_relay_fee_rate: FeeRate::from_amount_per_kb(Amount::ZERO).into(), + }) + .with_chainstate(tf.into_chainstate()) + .with_common_time_getter(&time_getter) + .build() + .await; + + let peer_id = PeerId::new(); + let remote_origin_peer_id = PeerId::new(); + let _peer = node.connect_peer(peer_id, protocol_version).await; + + // Make sure that the tx sync mgr's main loop had at least one iteration, this ensures + // that its `next_time_to_announce_txs` has been updated. + node.wait_for_notification( + SyncManagerNotification::TxSyncManagerMainLoopIterationCompleted { peer_id }, + ) + .await; + + let independent_tx_fee = rng.random_range(100..200); + let parent_tx_fee = rng.random_range(10..20); + let child_tx_fee = rng.random_range(500..600); + + let independent_tx = make_simple_coin_tx( + &mut rng, + &[(funding_tx_id.into(), 0)], + &[output_amount - independent_tx_fee], + ); + let independent_tx_id = independent_tx.transaction().get_id(); + + let parent_tx = make_simple_coin_tx( + &mut rng, + &[(funding_tx_id.into(), 1)], + &[1, output_amount - parent_tx_fee - 1], + ); + let parent_tx_id = parent_tx.transaction().get_id(); + + let child_tx = make_simple_coin_tx( + &mut rng, + &[(funding_tx_id.into(), 2), (parent_tx_id.into(), 0)], + &[1, output_amount - child_tx_fee], + ); + let child_tx_id = child_tx.transaction().get_id(); + + { + let mut txs = [independent_tx, parent_tx, child_tx]; + txs.shuffle(&mut rng); + for tx in txs { + mempool_add_new_tx_with_random_origin(&node, tx, remote_origin_peer_id, &mut rng) + .await; + } + } + + node.assert_no_sent_transaction_sync_message().await; + + // Advance the time sufficiently, so that tx sync mgr would consider announcing the txs. + // Note: 37 is the upper bound of what `exponential_rand` can return. + time_getter.advance_time(TX_RELAY_DELAY_INTERVAL_OUTBOUND * 37); + + let expected_tx_ids = vec![independent_tx_id, parent_tx_id, child_tx_id]; + for expected_tx_id in &expected_tx_ids { + let (sent_to, msg) = node.get_sent_transaction_sync_message().await; + assert_eq!(sent_to, peer_id); + let tx_id = assert_matches_return_val!( + msg, + TransactionSyncMessage::NewTransaction(tx_id), + tx_id + ); + assert_eq!(&tx_id, expected_tx_id); + } + node.assert_no_sent_transaction_sync_message().await; + + node.join_subsystem_manager().await; + }) + .await; +} + +async fn mempool_add_new_tx_with_random_origin( + node: &TestNode, + tx: SignedTransaction, + remote_origin_peer_id: PeerId, + rng: &mut impl test_utils::random::Rng, +) { + if rng.random_bool(0.5) { + let origin = LocalTxOrigin::P2p; + let options = TxOptions::default_for(origin.into()); + let status = node + .mempool() + .call_mut(move |m| m.add_transaction_local(tx, origin, options)) + .await + .unwrap() + .unwrap(); + assert_eq!(status, mempool::TransactionDuplicateStatus::New); + } else { + let origin = RemoteTxOrigin::new(remote_origin_peer_id); + let options = TxOptions::default_for(origin.into()); + let status = node + .mempool() + .call_mut(move |m| m.add_transaction_remote(tx, origin, options)) + .await + .unwrap() + .unwrap(); + assert_eq!(status, mempool::TxStatus::InMempool); + } +} + +fn transaction_with_amount( + rng: &mut impl CryptoRng, + block_id: Id, + amount_atoms: u128, +) -> SignedTransaction { + make_simple_coin_tx( + rng, + &[(OutPointSourceId::from(block_id), 0)], + &[amount_atoms], ) - .unwrap(); - SignedTransaction::new(tx, vec![InputWitness::NoSignature(None)]).unwrap() } -fn transaction(block_id: Id) -> SignedTransaction { - transaction_with_amount(block_id, 1) +fn transaction(rng: &mut impl CryptoRng, block_id: Id) -> SignedTransaction { + transaction_with_amount(rng, block_id, 1) } diff --git a/p2p/src/tests/helpers/test_node.rs b/p2p/src/tests/helpers/test_node.rs index c1ccad42ef..5fe14cb109 100644 --- a/p2p/src/tests/helpers/test_node.rs +++ b/p2p/src/tests/helpers/test_node.rs @@ -231,6 +231,7 @@ where mempool, peer_mgr_event_sender.clone(), time_getter.get_time_getter(), + time_getter.get_monotonic_time_getter(), ); let sync_mgr_join_handle = tokio_spawn_in_tracing_span( async move { diff --git a/p2p/tests/shutdown.rs b/p2p/tests/shutdown.rs index 2133663efc..25189a8781 100644 --- a/p2p/tests/shutdown.rs +++ b/p2p/tests/shutdown.rs @@ -72,6 +72,7 @@ async fn shutdown_timeout() { chainstate.clone(), mempool.clone(), Default::default(), + Default::default(), peerdb_storage, ) .unwrap() diff --git a/test-utils/src/basic_test_time_getter.rs b/test-utils/src/basic_test_time_getter.rs index 1af2161f5c..9b6ea9582e 100644 --- a/test-utils/src/basic_test_time_getter.rs +++ b/test-utils/src/basic_test_time_getter.rs @@ -17,14 +17,17 @@ use std::{sync::Arc, time::Duration}; -use common::time_getter::TimeGetter; +use common::time_getter::{MonotonicTimeGetter, TimeGetter}; use utils::atomics::SeqCstAtomicU64; -use crate::mock_time_getter::mocked_time_getter_milliseconds; +use crate::mock_time_getter::{ + mocked_monotonic_time_getter_milliseconds, mocked_time_getter_milliseconds, +}; #[derive(Clone)] pub struct BasicTestTimeGetter { current_time_millis: Arc, + initial_instant_for_monotonic_time_getter: std::time::Instant, } impl BasicTestTimeGetter { @@ -33,8 +36,10 @@ impl BasicTestTimeGetter { .duration_since(std::time::SystemTime::UNIX_EPOCH) .unwrap(); let current_time_millis = Arc::new(SeqCstAtomicU64::new(current_time.as_millis() as u64)); + let initial_instant_for_monotonic_time_getter = std::time::Instant::now(); Self { current_time_millis, + initial_instant_for_monotonic_time_getter, } } @@ -42,6 +47,13 @@ impl BasicTestTimeGetter { mocked_time_getter_milliseconds(Arc::clone(&self.current_time_millis)) } + pub fn get_monotonic_time_getter(&self) -> MonotonicTimeGetter { + mocked_monotonic_time_getter_milliseconds( + self.initial_instant_for_monotonic_time_getter, + Arc::clone(&self.current_time_millis), + ) + } + pub fn advance_time(&self, duration: Duration) { self.current_time_millis.fetch_add(duration.as_millis() as u64); } diff --git a/test-utils/src/mock_time_getter.rs b/test-utils/src/mock_time_getter.rs index 4d1161f763..39409ce865 100644 --- a/test-utils/src/mock_time_getter.rs +++ b/test-utils/src/mock_time_getter.rs @@ -13,9 +13,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -use common::primitives::time::Time; -use common::time_getter::{TimeGetter, TimeGetterFn}; use std::{sync::Arc, time::Duration}; + +use common::{ + primitives::time::Time, + time_getter::{MonotonicTimeGetter, MonotonicTimeGetterFn, TimeGetter, TimeGetterFn}, +}; use utils::atomics::SeqCstAtomicU64; pub fn mocked_time_getter_seconds(seconds: Arc) -> TimeGetter { @@ -43,6 +46,40 @@ impl TimeGetterFn for MockedMsecTimeGetterFn { } } +pub fn mocked_monotonic_time_getter_seconds( + initial: std::time::Instant, + seconds: Arc, +) -> MonotonicTimeGetter { + MonotonicTimeGetter::new(Arc::new(MockedMonotonicMsecTimeGetterFn { + initial, + count: seconds, + multiplier: 1000, + })) +} + +pub fn mocked_monotonic_time_getter_milliseconds( + initial: std::time::Instant, + milliseconds: Arc, +) -> MonotonicTimeGetter { + MonotonicTimeGetter::new(Arc::new(MockedMonotonicMsecTimeGetterFn { + initial, + count: milliseconds, + multiplier: 1, + })) +} + +struct MockedMonotonicMsecTimeGetterFn { + initial: std::time::Instant, + count: Arc, + multiplier: u64, +} + +impl MonotonicTimeGetterFn for MockedMonotonicMsecTimeGetterFn { + fn get_time(&self) -> std::time::Instant { + self.initial + Duration::from_millis(self.multiplier * self.count.load()) + } +} + #[cfg(test)] mod test { use crate::mock_time_getter::mocked_time_getter_seconds; @@ -72,4 +109,28 @@ mod test { Duration::from_millis(123) ); } + + #[test] + fn test_mocked_monotonic_time_getter_seconds() { + let seconds = Arc::new(SeqCstAtomicU64::new(12345)); + let time_getter = + mocked_monotonic_time_getter_seconds(std::time::Instant::now(), Arc::clone(&seconds)); + let time = time_getter.get_time(); + seconds.fetch_add(123); + let later_time = time_getter.get_time(); + assert_eq!(later_time.duration_since(time), Duration::from_secs(123)); + } + + #[test] + fn test_mocked_monotonic_time_getter_milliseconds() { + let milliseconds = Arc::new(SeqCstAtomicU64::new(12345)); + let time_getter = mocked_monotonic_time_getter_milliseconds( + std::time::Instant::now(), + Arc::clone(&milliseconds), + ); + let time = time_getter.get_time(); + milliseconds.fetch_add(123); + let later_time = time_getter.get_time(); + assert_eq!(later_time.duration_since(time), Duration::from_millis(123)); + } } diff --git a/wallet/wallet-node-client/tests/call_tests.rs b/wallet/wallet-node-client/tests/call_tests.rs index 462bb6dbe2..c80b00f9c2 100644 --- a/wallet/wallet-node-client/tests/call_tests.rs +++ b/wallet/wallet-node-client/tests/call_tests.rs @@ -103,6 +103,7 @@ pub async fn start_subsystems( chainstate_handle.clone(), mempool_handle.clone(), Default::default(), + Default::default(), peerdb_storage, ) .unwrap() diff --git a/wallet/wallet-test-node/src/lib.rs b/wallet/wallet-test-node/src/lib.rs index a9f348c6ad..3a248030eb 100644 --- a/wallet/wallet-test-node/src/lib.rs +++ b/wallet/wallet-test-node/src/lib.rs @@ -225,6 +225,7 @@ pub async fn start_node(chain_config: Arc) -> (subsystem::Manager, chainstate.clone(), mempool.clone(), Default::default(), + Default::default(), peerdb_storage, ) .unwrap() From 6f6eeb79889e690086e8b89f25f65f0b65dfca78 Mon Sep 17 00:00:00 2001 From: Mykhailo Kremniov Date: Tue, 5 May 2026 14:57:49 +0300 Subject: [PATCH 05/10] p2p: add test for unconfirmed tx reannouncement --- p2p/src/sync/mod.rs | 4 +- p2p/src/sync/peer/block_manager.rs | 2 +- p2p/src/sync/peer/transaction_manager.rs | 1 + p2p/src/sync/tests/helpers/mod.rs | 33 +- p2p/src/sync/tests/tx_announcement.rs | 383 ++++++++++++++++++++--- utils/src/exp_rand/mod.rs | 5 + utils/src/exp_rand/test.rs | 11 +- 7 files changed, 392 insertions(+), 47 deletions(-) diff --git a/p2p/src/sync/mod.rs b/p2p/src/sync/mod.rs index 8821933820..23e676caee 100644 --- a/p2p/src/sync/mod.rs +++ b/p2p/src/sync/mod.rs @@ -76,8 +76,8 @@ use crate::{ use self::chainstate_handle::ChainstateHandle; // 1 to 1.5 average distances between blocks. -const UNCONFIRMED_TX_REQUEUE_MIN_DELAY: Duration = Duration::from_secs(120); -const UNCONFIRMED_TX_REQUEUE_MAX_DELAY: Duration = Duration::from_secs(180); +pub const UNCONFIRMED_TX_REQUEUE_MIN_DELAY: Duration = Duration::from_secs(120); +pub const UNCONFIRMED_TX_REQUEUE_MAX_DELAY: Duration = Duration::from_secs(180); pub struct PeerContext { tasks: JoinSet<()>, diff --git a/p2p/src/sync/peer/block_manager.rs b/p2p/src/sync/peer/block_manager.rs index 2fe77265ab..39c4a004e8 100644 --- a/p2p/src/sync/peer/block_manager.rs +++ b/p2p/src/sync/peer/block_manager.rs @@ -97,7 +97,7 @@ pub struct PeerBlockSyncManager { } struct IncomingDataState { - /// A list of headers received via the `HeaderListResponse` message that we haven't yet + /// A list of headers received via the `HeaderList` message that we haven't yet /// requested the blocks for. pending_headers: Vec, /// A list of blocks that we requested from this peer. diff --git a/p2p/src/sync/peer/transaction_manager.rs b/p2p/src/sync/peer/transaction_manager.rs index 101e82531e..3abf159da0 100644 --- a/p2p/src/sync/peer/transaction_manager.rs +++ b/p2p/src/sync/peer/transaction_manager.rs @@ -256,6 +256,7 @@ where async fn announce_transactions(&mut self) -> Result<()> { let tx_ids = std::mem::take(&mut self.transactions_to_announce); + log::debug!("Announcing {} transactions", tx_ids.len()); let sorted_tx_ids = self .mempool_handle diff --git a/p2p/src/sync/tests/helpers/mod.rs b/p2p/src/sync/tests/helpers/mod.rs index 60831aef62..3a0ece46c1 100644 --- a/p2p/src/sync/tests/helpers/mod.rs +++ b/p2p/src/sync/tests/helpers/mod.rs @@ -45,7 +45,11 @@ use common::{ time_getter::{MonotonicTimeGetter, TimeGetter}, }; use logging::log; -use mempool::{event::TransactionProcessedEvent, MempoolConfig, MempoolHandle, MempoolInit}; +use mempool::{ + event::TransactionProcessedEvent, + tx_origin::{LocalTxOrigin, RemoteTxOrigin}, + MempoolConfig, MempoolHandle, MempoolInit, TxOptions, +}; use networking::{transport::TcpTransportSocket, types::ConnectionDirection}; use p2p_test_utils::{expect_future_val, expect_no_recv, expect_recv, SHORT_TIMEOUT}; use p2p_types::{bannable_address::BannableAddress, socket_address::SocketAddress}; @@ -507,6 +511,33 @@ impl TestNode { .is_ok() {} } + + pub async fn add_local_tx_to_mempool( + &self, + tx: SignedTransaction, + ) -> mempool::TransactionDuplicateStatus { + let origin = LocalTxOrigin::P2p; + let options = TxOptions::default_for(origin.into()); + self.mempool_handle + .call_mut(move |m| m.add_transaction_local(tx, origin, options)) + .await + .unwrap() + .unwrap() + } + + pub async fn add_remote_tx_to_mempool( + &self, + tx: SignedTransaction, + remote_origin_peer_id: PeerId, + ) -> mempool::TxStatus { + let origin = RemoteTxOrigin::new(remote_origin_peer_id); + let options = TxOptions::default_for(origin.into()); + self.mempool_handle + .call_mut(move |m| m.add_transaction_remote(tx, origin, options)) + .await + .unwrap() + .unwrap() + } } // Represents a peer that can send messages to a node it is connected to diff --git a/p2p/src/sync/tests/tx_announcement.rs b/p2p/src/sync/tests/tx_announcement.rs index 5176932f14..19c53c3396 100644 --- a/p2p/src/sync/tests/tx_announcement.rs +++ b/p2p/src/sync/tests/tx_announcement.rs @@ -23,34 +23,40 @@ use chainstate_test_framework::{ }; use common::{ chain::{ - config::create_unit_test_config, output_value::OutputValue, - signature::inputsig::InputWitness, timelock::OutputTimeLock, GenBlock, OutPointSourceId, - SignedTransaction, Transaction, TxInput, TxOutput, UtxoOutPoint, + block::{timestamp::BlockTimestamp, BlockReward, ConsensusData}, + config::create_unit_test_config, + output_value::OutputValue, + signature::inputsig::InputWitness, + timelock::OutputTimeLock, + Block, GenBlock, OutPointSourceId, SignedTransaction, Transaction, TxInput, TxOutput, + UtxoOutPoint, }, primitives::{Amount, Id, Idable}, }; +use logging::log; use mempool::{ error::{Error as MempoolError, MempoolPolicyError}, - tx_origin::{LocalTxOrigin, RemoteTxOrigin}, + tx_origin::RemoteTxOrigin, FeeRate, MempoolConfig, TxOptions, }; -use randomness::{CryptoRng, RngExt as _, SliceRandom}; +use randomness::{CryptoRng, IndexedRandom as _, RngExt as _, SliceRandom}; use serialization::Encode; use test_utils::{ - assert_matches_return_val, random::{make_seedable_rng, Seed}, BasicTestTimeGetter, }; +use utils::exp_rand::EXPONENTIAL_RAND_UPPER_LIMIT; use crate::{ config::NodeType, error::ProtocolError, - message::{TransactionResponse, TransactionSyncMessage}, + message::{BlockSyncMessage, HeaderList, TransactionResponse, TransactionSyncMessage}, protocol::ProtocolConfig, sync::{ peer::requested_transactions::REQUESTED_TX_EXPIRY_PERIOD, peer::transaction_manager::TX_RELAY_DELAY_INTERVAL_OUTBOUND, tests::helpers::{PeerManagerEventDesc, SyncManagerNotification, TestNode}, + UNCONFIRMED_TX_REQUEUE_MAX_DELAY, }, test_helpers::{for_each_protocol_version, test_p2p_config}, types::peer_id::PeerId, @@ -651,7 +657,7 @@ async fn transaction_sequence_via_orphan_pool(#[case] seed: Seed) { #[trace] #[case(Seed::from_entropy())] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn rebroadcast_transaction(#[case] seed: Seed) { +async fn rebroadcast_duplicate_transaction(#[case] seed: Seed) { for_each_protocol_version(|protocol_version| async move { use mempool::tx_origin::LocalTxOrigin; @@ -802,9 +808,10 @@ async fn rebroadcast_transaction(#[case] seed: Seed) { // Check that the tx sync mgr announces all txs at once after a delay and that the announcements // have a specific order. +// * Connect to a peer. // * Create 3 transactions that should have a specific order in the mempool. // * Add them to the mempool, expect no announcement. -// * Advance the mock time, the transactions should now be announced, in the above-mentioned order. +// * Advance the mock time, the transactions should now be announced, in the specific order. #[tracing::instrument(skip(seed))] #[rstest::rstest] #[trace] @@ -821,7 +828,7 @@ async fn transaction_announcements_are_batched_and_sorted(#[case] seed: Seed) { .with_time_getter(time_getter.get_time_getter()) .build(); - let output_amount = 1_000; + let output_amount = 1000; let funding_tx = { let mut builder = TransactionBuilder::new().add_input( TxInput::from_utxo( @@ -862,7 +869,10 @@ async fn transaction_announcements_are_batched_and_sorted(#[case] seed: Seed) { let peer_id = PeerId::new(); let remote_origin_peer_id = PeerId::new(); - let _peer = node.connect_peer(peer_id, protocol_version).await; + let peer = node.connect_peer(peer_id, protocol_version).await; + + peer.send_block_sync_message(BlockSyncMessage::HeaderList(HeaderList::new(Vec::new()))) + .await; // Make sure that the tx sync mgr's main loop had at least one iteration, this ensures // that its `next_time_to_announce_txs` has been updated. @@ -871,6 +881,8 @@ async fn transaction_announcements_are_batched_and_sorted(#[case] seed: Seed) { ) .await; + // The child tx has the highest fee, but it has to come after the parent tx, which has the + // lowest fee, so the resulting order should be independent_tx, parent_tx, child_tx. let independent_tx_fee = rng.random_range(100..200); let parent_tx_fee = rng.random_range(10..20); let child_tx_fee = rng.random_range(500..600); @@ -897,30 +909,27 @@ async fn transaction_announcements_are_batched_and_sorted(#[case] seed: Seed) { let child_tx_id = child_tx.transaction().get_id(); { - let mut txs = [independent_tx, parent_tx, child_tx]; - txs.shuffle(&mut rng); - for tx in txs { - mempool_add_new_tx_with_random_origin(&node, tx, remote_origin_peer_id, &mut rng) - .await; + for tx in [independent_tx, parent_tx, child_tx] { + if rng.random_bool(0.5) { + let status = node.add_local_tx_to_mempool(tx).await; + assert_eq!(status, mempool::TransactionDuplicateStatus::New); + } else { + let status = node.add_remote_tx_to_mempool(tx, remote_origin_peer_id).await; + assert_eq!(status, mempool::TxStatus::InMempool); + } } } node.assert_no_sent_transaction_sync_message().await; // Advance the time sufficiently, so that tx sync mgr would consider announcing the txs. - // Note: 37 is the upper bound of what `exponential_rand` can return. - time_getter.advance_time(TX_RELAY_DELAY_INTERVAL_OUTBOUND * 37); + time_getter.advance_time(TX_RELAY_DELAY_INTERVAL_OUTBOUND * EXPONENTIAL_RAND_UPPER_LIMIT); let expected_tx_ids = vec![independent_tx_id, parent_tx_id, child_tx_id]; for expected_tx_id in &expected_tx_ids { let (sent_to, msg) = node.get_sent_transaction_sync_message().await; assert_eq!(sent_to, peer_id); - let tx_id = assert_matches_return_val!( - msg, - TransactionSyncMessage::NewTransaction(tx_id), - tx_id - ); - assert_eq!(&tx_id, expected_tx_id); + assert_eq!(msg, TransactionSyncMessage::NewTransaction(*expected_tx_id)); } node.assert_no_sent_transaction_sync_message().await; @@ -929,33 +938,323 @@ async fn transaction_announcements_are_batched_and_sorted(#[case] seed: Seed) { .await; } -async fn mempool_add_new_tx_with_random_origin( - node: &TestNode, - tx: SignedTransaction, - remote_origin_peer_id: PeerId, - rng: &mut impl test_utils::random::Rng, +// Check that unconfirmed local transactions are requeued for announcement. +// * Connect to a peer. +// * Create 2 sets of txs, one set with local origin and another with remote. +// * Add the txs to the mempool, expect no announcement. +// * Advance time, expect the txs to be announced to the peer. +// * Optionally, make the peer request one local and one remote tx. Expect the node to +// send those txs. +// * Optionally, mine a block containing some of the local and remote txs. +// * Connect to a new peer. +// * Advance time 2 times - first to make sure that the local event about local tx re-announcement +// reaches tx sync managers, second to make the txs stored by the tx sync managers due for +// announcement. +// Expected result: tx announcements should be made to the new peer. Only the local txs should +// be announced and only those that weren't sent to the first peer and that weren't mined. +// The original peer shouldn't get any announcements. +#[tracing::instrument(skip(seed))] +#[rstest::rstest] +#[trace] +#[case(Seed::from_entropy())] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unconfirmed_local_transactions_reannouncement( + #[case] seed: Seed, + #[values(false, true)] peer_requests_txs: bool, + #[values(false, true)] mine_txs: bool, ) { - if rng.random_bool(0.5) { - let origin = LocalTxOrigin::P2p; - let options = TxOptions::default_for(origin.into()); - let status = node + for_each_protocol_version(|protocol_version| async move { + let mut rng = make_seedable_rng(seed); + + let chain_config = Arc::new(create_unit_test_config()); + let time_getter = BasicTestTimeGetter::new(); + let mut tf = TestFramework::builder(&mut rng) + .with_chain_config(chain_config.as_ref().clone()) + .with_time_getter(time_getter.get_time_getter()) + .build(); + + let output_amount = 1000; + let funding_tx = { + let mut builder = TransactionBuilder::new().add_input( + TxInput::from_utxo( + OutPointSourceId::BlockReward(tf.genesis().get_id().into()), + 0, + ), + empty_witness(&mut rng), + ); + + for _ in 0..6 { + builder = builder.add_output(TxOutput::Transfer( + OutputValue::Coin(Amount::from_atoms(output_amount)), + common::chain::Destination::AnyoneCanSpend, + )); + } + + builder.build() + }; + let funding_tx_id = funding_tx.transaction().get_id(); + let best_block_id = *tf + .make_block_builder() + .add_transaction(funding_tx) + .build_and_process(&mut rng) + .unwrap() + .unwrap() + .block_id(); + + let p2p_config = Arc::new(test_p2p_config()); + let mut node = TestNode::builder(protocol_version) + .with_chain_config(Arc::clone(&chain_config)) + .with_p2p_config(Arc::clone(&p2p_config)) + .with_mempool_config(MempoolConfig { + min_tx_relay_fee_rate: FeeRate::from_amount_per_kb(Amount::ZERO).into(), + }) + .with_chainstate(tf.into_chainstate()) + .with_common_time_getter(&time_getter) + .build() + .await; + + let peer1_id = PeerId::new(); + let peer2_id = PeerId::new(); + let remote_origin_peer_id = PeerId::new(); + let peer1 = node.connect_peer(peer1_id, protocol_version).await; + + peer1 + .send_block_sync_message(BlockSyncMessage::HeaderList(HeaderList::new(Vec::new()))) + .await; + + // Make sure that the tx sync mgr's main loop had at least one iteration, this ensures + // that its `next_time_to_announce_txs` has been updated. + node.wait_for_notification( + SyncManagerNotification::TxSyncManagerMainLoopIterationCompleted { peer_id: peer1_id }, + ) + .await; + + let local_independent_tx_fee = rng.random_range(100..200); + let local_parent_tx_fee = rng.random_range(10..20); + let local_child_tx_fee = rng.random_range(500..600); + + let local_independent_tx = make_simple_coin_tx( + &mut rng, + &[(funding_tx_id.into(), 0)], + &[output_amount - local_independent_tx_fee], + ); + let local_independent_tx_id = local_independent_tx.transaction().get_id(); + + let local_parent_tx = make_simple_coin_tx( + &mut rng, + &[(funding_tx_id.into(), 1)], + &[1, output_amount - local_parent_tx_fee - 1], + ); + let local_parent_tx_id = local_parent_tx.transaction().get_id(); + + let local_child_tx = make_simple_coin_tx( + &mut rng, + &[(funding_tx_id.into(), 2), (local_parent_tx_id.into(), 0)], + &[1, output_amount - local_child_tx_fee], + ); + let local_child_tx_id = local_child_tx.transaction().get_id(); + + let remote_independent_tx_fee = rng.random_range(100..200); + let remote_parent_tx_fee = rng.random_range(10..20); + let remote_child_tx_fee = rng.random_range(500..600); + + let remote_independent_tx = make_simple_coin_tx( + &mut rng, + &[(funding_tx_id.into(), 3)], + &[output_amount - remote_independent_tx_fee], + ); + let remote_independent_tx_id = remote_independent_tx.transaction().get_id(); + + let remote_parent_tx = make_simple_coin_tx( + &mut rng, + &[(funding_tx_id.into(), 4)], + &[1, output_amount - remote_parent_tx_fee - 1], + ); + let remote_parent_tx_id = remote_parent_tx.transaction().get_id(); + + let remote_child_tx = make_simple_coin_tx( + &mut rng, + &[(funding_tx_id.into(), 5), (remote_parent_tx_id.into(), 0)], + &[1, output_amount - remote_child_tx_fee], + ); + let remote_child_tx_id = remote_child_tx.transaction().get_id(); + + for tx in [&local_independent_tx, &local_parent_tx, &local_child_tx] { + node.add_local_tx_to_mempool(tx.clone()).await; + } + + for tx in [&remote_independent_tx, &remote_parent_tx, &remote_child_tx] { + let _ = node.add_remote_tx_to_mempool(tx.clone(), remote_origin_peer_id).await; + } + + let all_tx_ids = [ + local_independent_tx_id, + local_parent_tx_id, + local_child_tx_id, + remote_independent_tx_id, + remote_parent_tx_id, + remote_child_tx_id, + ]; + log::debug!("all_tx_ids = {all_tx_ids:?}"); + let all_tx_ids_set = BTreeSet::from(all_tx_ids); + + // No announcements until we advance the time + node.assert_no_sent_transaction_sync_message().await; + + let expected_initial_tx_ids = node .mempool() - .call_mut(move |m| m.add_transaction_local(tx, origin, options)) + .call(move |m| { + m.get_best_tx_ids_by_score_and_ancestry(&all_tx_ids_set, all_tx_ids_set.len()) + }) .await .unwrap() .unwrap(); - assert_eq!(status, mempool::TransactionDuplicateStatus::New); - } else { - let origin = RemoteTxOrigin::new(remote_origin_peer_id); - let options = TxOptions::default_for(origin.into()); - let status = node + + // Advance the time sufficiently, so that tx sync mgr would consider announcing the txs. + time_getter.advance_time(TX_RELAY_DELAY_INTERVAL_OUTBOUND * EXPONENTIAL_RAND_UPPER_LIMIT); + + for expected_tx_id in expected_initial_tx_ids { + log::debug!("Expecting initial announcement of tx {expected_tx_id:x}"); + let (sent_to, msg) = node.get_sent_transaction_sync_message().await; + assert_eq!(sent_to, peer1_id); + assert_eq!(msg, TransactionSyncMessage::NewTransaction(expected_tx_id)); + } + node.assert_no_sent_transaction_sync_message().await; + + // Only txs with local origin may be re-announced. + let mut expected_reannounced_tx_ids = + BTreeSet::from([local_independent_tx_id, local_parent_tx_id, local_child_tx_id]); + + // The peer optionally requests one local and one remote tx (randomly chosen). + if peer_requests_txs { + let local_tx_to_request = *[&local_independent_tx, &local_parent_tx, &local_child_tx] + .choose(&mut rng) + .unwrap(); + let remote_tx_to_request = + *[&remote_independent_tx, &remote_parent_tx, &remote_child_tx] + .choose(&mut rng) + .unwrap(); + + let mut txs_to_request = [local_tx_to_request, remote_tx_to_request]; + txs_to_request.shuffle(&mut rng); + + for tx in txs_to_request { + peer1 + .send_transaction_sync_message(TransactionSyncMessage::TransactionRequest( + tx.transaction().get_id(), + )) + .await; + + let (sent_to, msg) = node.get_sent_transaction_sync_message().await; + assert_eq!(sent_to, peer1_id); + assert_eq!( + msg, + TransactionSyncMessage::TransactionResponse(TransactionResponse::Found( + tx.clone() + )) + ); + } + + // Since the tx has been sent to the peer, it shouldn't be re-announced. + expected_reannounced_tx_ids.remove(&local_tx_to_request.transaction().get_id()); + } + + // Optionally, a block is produced containing one local and one remote tx. + // (The txs are chosen at random and if one of them happens to be one of the children, + // then its parent has to be included as well, obviously). + if mine_txs { + let mut txs_to_mine = Vec::new(); + + let local_tx_to_mine = *[&local_independent_tx, &local_parent_tx, &local_child_tx] + .choose(&mut rng) + .unwrap(); + if local_tx_to_mine.transaction().get_id() == local_child_tx_id { + txs_to_mine.push(local_parent_tx.clone()); + } + txs_to_mine.push(local_tx_to_mine.clone()); + + let remote_tx_to_mine = *[&remote_independent_tx, &remote_parent_tx, &remote_child_tx] + .choose(&mut rng) + .unwrap(); + if remote_tx_to_mine.transaction().get_id() == remote_child_tx_id { + txs_to_mine.push(remote_parent_tx.clone()); + } + txs_to_mine.push(remote_tx_to_mine.clone()); + + // Txs that have been mined should not be re-announced. + for tx in &txs_to_mine { + expected_reannounced_tx_ids.remove(&tx.transaction().get_id()); + } + + let block = Block::new( + txs_to_mine, + best_block_id.into(), + BlockTimestamp::from_time(time_getter.get_time_getter().get_time()), + ConsensusData::None, + BlockReward::new(vec![]), + ) + .unwrap(); + node.chainstate() + .call_mut(move |cs| { + cs.process_block(block, BlockSource::Local).unwrap(); + }) + .await + .unwrap(); + } + + let peer2 = node.connect_peer(peer2_id, protocol_version).await; + + peer2 + .send_block_sync_message(BlockSyncMessage::HeaderList(HeaderList::new(Vec::new()))) + .await; + + // Again, ensures that peer's `next_time_to_announce_txs` has been updated. + node.wait_for_notification( + SyncManagerNotification::TxSyncManagerMainLoopIterationCompleted { peer_id: peer2_id }, + ) + .await; + + // No announcements until we advance the time + node.assert_no_sent_transaction_sync_message().await; + + let expected_reannounced_tx_ids = node .mempool() - .call_mut(move |m| m.add_transaction_remote(tx, origin, options)) + .call(move |m| { + m.get_best_tx_ids_by_score_and_ancestry( + &expected_reannounced_tx_ids, + expected_reannounced_tx_ids.len(), + ) + }) .await .unwrap() .unwrap(); - assert_eq!(status, mempool::TxStatus::InMempool); - } + + // Advance the time so that the unconfirmed txs can get requeued. + time_getter.advance_time(UNCONFIRMED_TX_REQUEUE_MAX_DELAY); + + // Make sure peer2's tx sync mgr has the chance to handle the corresponding event and + // remember the tx ids for the future announcement. + node.clear_notifications().await; + node.wait_for_notification( + SyncManagerNotification::TxSyncManagerMainLoopIterationCompleted { peer_id: peer2_id }, + ) + .await; + + // Advance the time, so that the tx announcement can happen. + time_getter.advance_time(TX_RELAY_DELAY_INTERVAL_OUTBOUND * EXPONENTIAL_RAND_UPPER_LIMIT); + node.clear_notifications().await; + + for expected_tx_id in &expected_reannounced_tx_ids { + log::debug!("Expecting re-announcement of tx {expected_tx_id:x}"); + let (sent_to, msg) = node.get_sent_transaction_sync_message().await; + assert_eq!(sent_to, peer2_id); + assert_eq!(msg, TransactionSyncMessage::NewTransaction(*expected_tx_id)); + } + node.assert_no_sent_transaction_sync_message().await; + + node.join_subsystem_manager().await; + }) + .await; } fn transaction_with_amount( diff --git a/utils/src/exp_rand/mod.rs b/utils/src/exp_rand/mod.rs index 05000df1c9..9a0ef5bd23 100644 --- a/utils/src/exp_rand/mod.rs +++ b/utils/src/exp_rand/mod.rs @@ -27,5 +27,10 @@ pub fn exponential_rand(rng: &mut impl Rng) -> f64 { -random_f64.ln() } +/// `exponential_rand` will always return values smaller than this. +/// +/// This is mainly intended for testing. +pub const EXPONENTIAL_RAND_UPPER_LIMIT: u32 = 37; + #[cfg(test)] mod test; diff --git a/utils/src/exp_rand/test.rs b/utils/src/exp_rand/test.rs index eee2735708..f120134ffc 100644 --- a/utils/src/exp_rand/test.rs +++ b/utils/src/exp_rand/test.rs @@ -16,6 +16,7 @@ use super::*; use rstest::rstest; + use test_utils::random::{make_seedable_rng, Seed, StepRng}; #[rstest] @@ -25,7 +26,13 @@ fn test_average_value(#[case] seed: Seed) { let mut rng = make_seedable_rng(seed); let count = 1000; - let sum: f64 = (0..count).map(|_| exponential_rand(&mut rng)).sum(); + let sum: f64 = (0..count) + .map(|_| { + let val = exponential_rand(&mut rng); + assert!(val < EXPONENTIAL_RAND_UPPER_LIMIT as f64); + val + }) + .sum(); let average = sum / count as f64; assert!(0.8 < average && average < 1.2); } @@ -35,8 +42,10 @@ fn expect_finite_values_in_degenerate_cases() { let mut always_zero_rng = StepRng::new(0, 0); let val = exponential_rand(&mut always_zero_rng); assert!(val.is_finite()); + assert!(val < EXPONENTIAL_RAND_UPPER_LIMIT as f64); let mut always_max_rng = StepRng::new(u64::MAX, 0); let val = exponential_rand(&mut always_max_rng); assert!(val.is_finite()); + assert!(val < EXPONENTIAL_RAND_UPPER_LIMIT as f64); } From 9164682f122d234792352bcc919b5c565ec94ddc Mon Sep 17 00:00:00 2001 From: Mykhailo Kremniov Date: Tue, 5 May 2026 17:25:25 +0300 Subject: [PATCH 06/10] Appease clippy --- mempool/src/pool/tx_pool/collect_txs.rs | 25 ++++++++++++------------- p2p/src/lib.rs | 1 + p2p/src/sync/mod.rs | 4 ++-- p2p/src/sync/tests/tx_announcement.rs | 5 ++--- 4 files changed, 17 insertions(+), 18 deletions(-) diff --git a/mempool/src/pool/tx_pool/collect_txs.rs b/mempool/src/pool/tx_pool/collect_txs.rs index 3723b70a71..b4baf3582b 100644 --- a/mempool/src/pool/tx_pool/collect_txs.rs +++ b/mempool/src/pool/tx_pool/collect_txs.rs @@ -287,8 +287,8 @@ pub fn get_best_tx_ids_by_score_and_ancestry( .get_entry(tx_id) .ok_or(TxCollectionError::SpecifiedTxNotFound(*tx_id))?; - collect_selected_ancestors(mempool, entry, &tx_ids, &mut selected_ancestors_map)?; - let selected_ancestors = selected_ancestors_map.get(&tx_id).expect("must be present"); + collect_selected_ancestors(mempool, entry, tx_ids, &mut selected_ancestors_map)?; + let selected_ancestors = selected_ancestors_map.get(tx_id).expect("must be present"); if selected_ancestors.is_empty() { ready_txs.push(entry.into()); @@ -312,7 +312,7 @@ pub fn get_best_tx_ids_by_score_and_ancestry( let tx_id = tx_entry.entry.tx_id(); result.push(*tx_id); - if let Some(child_ids) = selected_descendants_map.get(&tx_id) { + if let Some(child_ids) = selected_descendants_map.get(tx_id) { for child_id in child_ids { match missing_ancestors_count_map.entry(*child_id) { btree_map::Entry::Vacant(_) => {} @@ -325,13 +325,12 @@ pub fn get_best_tx_ids_by_score_and_ancestry( 1 => { let child_id = *missing_ancestors_count.key(); missing_ancestors_count.remove(); - let child = - mempool.store.get_entry(&child_id).ok_or_else(|| { - TxCollectionError::TxChildNotFound { - tx_id: *tx_id, - child_tx_id: child_id, - } - })?; + let child = mempool.store.get_entry(&child_id).ok_or( + TxCollectionError::TxChildNotFound { + tx_id: *tx_id, + child_tx_id: child_id, + }, + )?; ready_txs.push(child.into()); } missing_count => *missing_count -= 1, @@ -369,12 +368,12 @@ fn collect_selected_ancestors( if selected_tx_ids.contains(parent_id) { tx_ancestors.insert(*parent_id); } else { - let parent_tx_entry = mempool.store.get_entry(parent_id).ok_or_else(|| { + let parent_tx_entry = mempool.store.get_entry(parent_id).ok_or( TxCollectionError::TxParentNotFound { tx_id: *tx_id, parent_tx_id: *parent_id, - } - })?; + }, + )?; collect_selected_ancestors( mempool, parent_tx_entry, diff --git a/p2p/src/lib.rs b/p2p/src/lib.rs index bbc0af5a6d..407b68d0c3 100644 --- a/p2p/src/lib.rs +++ b/p2p/src/lib.rs @@ -318,6 +318,7 @@ impl P2pInit { } } +#[allow(clippy::too_many_arguments)] pub fn make_p2p( networking_enabled: bool, chain_config: Arc, diff --git a/p2p/src/sync/mod.rs b/p2p/src/sync/mod.rs index 23e676caee..630c42be87 100644 --- a/p2p/src/sync/mod.rs +++ b/p2p/src/sync/mod.rs @@ -227,7 +227,7 @@ where notif_with_id = self.tx_mgr_notification_receiver.recv() => { if let Some((peer_id, notif)) = notif_with_id { - self.handle_tx_mgr_notification(peer_id, notif).await; + self.handle_tx_mgr_notification(peer_id, notif); } } @@ -508,7 +508,7 @@ where } } - async fn handle_tx_mgr_notification( + fn handle_tx_mgr_notification( &mut self, _peer_id: PeerId, notif: PeerTransactionSyncManagerLocalNotification, diff --git a/p2p/src/sync/tests/tx_announcement.rs b/p2p/src/sync/tests/tx_announcement.rs index 19c53c3396..13ea58a4e0 100644 --- a/p2p/src/sync/tests/tx_announcement.rs +++ b/p2p/src/sync/tests/tx_announcement.rs @@ -675,8 +675,7 @@ async fn rebroadcast_duplicate_transaction(#[case] seed: Seed) { &mut tf, UtxoOutPoint::new(chain_config.genesis_block_id().into(), 0), 2, - ) - .into(); + ); let p2p_config = Arc::new(test_p2p_config()); let mut node = TestNode::builder(protocol_version) @@ -721,7 +720,7 @@ async fn rebroadcast_duplicate_transaction(#[case] seed: Seed) { let local_tx_options = TxOptions::default_for(local_tx_origin.into()); let remote_tx_origin = RemoteTxOrigin::new(another_peer_id); - let remote_tx_options = TxOptions::default_for(remote_tx_origin.clone().into()); + let remote_tx_options = TxOptions::default_for(remote_tx_origin.into()); // Add the local tx. The node should send a NewTransaction message to peer1. { From 852693773d5e3d2208892457cda93c59be795d7c Mon Sep 17 00:00:00 2001 From: Mykhailo Kremniov Date: Tue, 5 May 2026 18:55:25 +0300 Subject: [PATCH 07/10] Fix duplicate tx rebroadcasting: use the "origin" of the duplicate instead of the existing one. Cleanup. --- mempool/src/pool/mod.rs | 22 +++-- mempool/src/pool/tx_pool/mod.rs | 14 ++- p2p/src/sync/tests/tx_announcement.rs | 122 ++++++++++++-------------- 3 files changed, 83 insertions(+), 75 deletions(-) diff --git a/mempool/src/pool/mod.rs b/mempool/src/pool/mod.rs index 77f7c79b4e..f2c93209d1 100644 --- a/mempool/src/pool/mod.rs +++ b/mempool/src/pool/mod.rs @@ -23,7 +23,8 @@ use common::{ }; use logging::log; use utils::{ - const_value::ConstValue, ensure, eventhandler::EventsController, shallow_clone::ShallowClone, + const_value::ConstValue, debug_assert_or_log, ensure, eventhandler::EventsController, + shallow_clone::ShallowClone, }; use utils_networking::broadcaster; @@ -611,18 +612,25 @@ impl<'a> TxFinalizer<'a> { Ok(TxStatus::InMempool) } - TxAdditionOutcome::Duplicate { transaction } => { - let tx_id = *transaction.tx_id(); + TxAdditionOutcome::Duplicate { + existing_transaction, + new_transaction, + } => { + debug_assert_or_log!( + existing_transaction.tx_id() == new_transaction.tx_id(), + "Duplicate tx is not a duplicate" + ); + let tx_id = *existing_transaction.tx_id(); log::trace!("Duplicate transaction {tx_id}"); match &mut self.events_mode { TxFinalizerEventsMode::Silent => {} TxFinalizerEventsMode::Broadcast(events_broadcast) => { - let relay_policy = transaction.tx_entry().options().relay_policy(); - let origin = transaction.tx_entry().origin(); + let relay_policy = new_transaction.options().relay_policy(); + let origin = new_transaction.origin(); - // Even if the tx is duplicate, if it has the local origin, broadcast the event - // anyway (p2p will want to re-relay it if it's relayable). + // Even if the new tx is a duplicate, if it has the local origin, broadcast + // the event anyway (p2p will want to re-relay it if it's relayable). if let Some(event) = make_local_duplicate_tx_event(tx_id, relay_policy, origin) { diff --git a/mempool/src/pool/tx_pool/mod.rs b/mempool/src/pool/tx_pool/mod.rs index b4203b3f14..a53f5fb73b 100644 --- a/mempool/src/pool/tx_pool/mod.rs +++ b/mempool/src/pool/tx_pool/mod.rs @@ -676,7 +676,12 @@ pub enum TxAdditionOutcome<'a> { Added { transaction: &'a TxMempoolEntry }, /// Transaction already in mempool - Duplicate { transaction: &'a TxMempoolEntry }, + Duplicate { + existing_transaction: &'a TxMempoolEntry, + // Note: the transaction part of the new entry will be the same as the old one (except for + // the signatures), but the tx origin and options may be different. + new_transaction: TxEntry, + }, /// Transaction was rejected from the mempool since it is not valid at the current tip Rejected { @@ -737,8 +742,11 @@ impl TxPool { } let tx_id = *transaction.tx_id(); - if let Some(transaction) = self.store.get_entry(&tx_id) { - let outcome = TxAdditionOutcome::Duplicate { transaction }; + if let Some(existing_transaction) = self.store.get_entry(&tx_id) { + let outcome = TxAdditionOutcome::Duplicate { + existing_transaction, + new_transaction: transaction, + }; return Ok(finalizer(outcome, self)); } diff --git a/p2p/src/sync/tests/tx_announcement.rs b/p2p/src/sync/tests/tx_announcement.rs index 13ea58a4e0..d4fe08ec21 100644 --- a/p2p/src/sync/tests/tx_announcement.rs +++ b/p2p/src/sync/tests/tx_announcement.rs @@ -37,7 +37,7 @@ use logging::log; use mempool::{ error::{Error as MempoolError, MempoolPolicyError}, tx_origin::RemoteTxOrigin, - FeeRate, MempoolConfig, TxOptions, + FeeRate, MempoolConfig, }; use randomness::{CryptoRng, IndexedRandom as _, RngExt as _, SliceRandom}; use serialization::Encode; @@ -650,17 +650,21 @@ async fn transaction_sequence_via_orphan_pool(#[case] seed: Seed) { .await; } -// When an duplicate tx is added to the mempool, it should be re-broadcast to peers if it has -// local origin and not re-broadcast if it's remote. +// When a duplicate tx is added to the mempool, it should be re-announced to peers if it has +// local origin and not re-announced if it's remote. +// * Connect to a peer. +// * Add 2 txs to the mempool using a random origin, expect them to be announced to the peer. +// * Connect to another peer. +// * Add the txs to the mempool again, this time tx1 always has local origin and tx2 remote. +// Expected result: tx1 should be announced to the second peer, but tx2 should be not. +// The first peer shouldn't get any announcements. #[tracing::instrument(skip(seed))] #[rstest::rstest] #[trace] #[case(Seed::from_entropy())] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn rebroadcast_duplicate_transaction(#[case] seed: Seed) { +async fn reannounce_duplicate_transaction(#[case] seed: Seed) { for_each_protocol_version(|protocol_version| async move { - use mempool::tx_origin::LocalTxOrigin; - let mut rng = make_seedable_rng(seed); let chain_config = Arc::new(create_unit_test_config()); @@ -687,12 +691,16 @@ async fn rebroadcast_duplicate_transaction(#[case] seed: Seed) { let peer1_id = PeerId::new(); let peer2_id = PeerId::new(); - let another_peer_id = PeerId::new(); + let remote_origin_peer_id = PeerId::new(); // Peer1 connects - let _peer1 = node.connect_peer(peer1_id, protocol_version).await; + let peer1 = node.connect_peer(peer1_id, protocol_version).await; + + peer1 + .send_block_sync_message(BlockSyncMessage::HeaderList(HeaderList::new(Vec::new()))) + .await; - let local_tx = TransactionBuilder::new() + let tx1 = TransactionBuilder::new() .add_input( TxInput::from_utxo(fund_tx_id.into(), 0), empty_witness(&mut rng), @@ -702,9 +710,10 @@ async fn rebroadcast_duplicate_transaction(#[case] seed: Seed) { common::chain::Destination::AnyoneCanSpend, )) .build(); - let local_tx_id = local_tx.transaction().get_id(); + let tx1_id = tx1.transaction().get_id(); + log::debug!("tx1_id = {tx1_id:x}"); - let remote_tx = TransactionBuilder::new() + let tx2 = TransactionBuilder::new() .add_input( TxInput::from_utxo(fund_tx_id.into(), 1), empty_witness(&mut rng), @@ -714,87 +723,70 @@ async fn rebroadcast_duplicate_transaction(#[case] seed: Seed) { common::chain::Destination::AnyoneCanSpend, )) .build(); - let remote_tx_id = remote_tx.transaction().get_id(); - - let local_tx_origin = LocalTxOrigin::P2p; - let local_tx_options = TxOptions::default_for(local_tx_origin.into()); - - let remote_tx_origin = RemoteTxOrigin::new(another_peer_id); - let remote_tx_options = TxOptions::default_for(remote_tx_origin.into()); + let tx2_id = tx2.transaction().get_id(); + log::debug!("tx2_id = {tx2_id:x}"); - // Add the local tx. The node should send a NewTransaction message to peer1. + // Add tx1 with a randomly chosen origin. The node should send a NewTransaction message to peer1. { - let local_tx = local_tx.clone(); - let local_tx_options = local_tx_options.clone(); - let status = node - .mempool() - .call_mut(move |m| { - m.add_transaction_local(local_tx, local_tx_origin, local_tx_options) - }) - .await - .unwrap() - .unwrap(); - assert_eq!(status, mempool::TransactionDuplicateStatus::New); + if rng.random_bool(0.5) { + log::debug!("Sending tx1 with local origin"); + let status = node.add_local_tx_to_mempool(tx1.clone()).await; + assert_eq!(status, mempool::TransactionDuplicateStatus::New); + } else { + log::debug!("Sending tx1 with remote origin"); + let status = + node.add_remote_tx_to_mempool(tx1.clone(), remote_origin_peer_id).await; + assert_eq!(status, mempool::TxStatus::InMempool); + } let (peer_id, msg) = node.get_sent_transaction_sync_message().await; assert_eq!(peer_id, peer1_id); - assert_eq!(msg, TransactionSyncMessage::NewTransaction(local_tx_id)); + assert_eq!(msg, TransactionSyncMessage::NewTransaction(tx1_id)); } - // Add the remote tx. The node should send a NewTransaction message to peer1. + // Same for tx2. { - let remote_tx = remote_tx.clone(); - let remote_tx_options = remote_tx_options.clone(); - let status = node - .mempool() - .call_mut(move |m| { - m.add_transaction_remote(remote_tx, remote_tx_origin, remote_tx_options) - }) - .await - .unwrap() - .unwrap(); - assert_eq!(status, mempool::TxStatus::InMempool); + if rng.random_bool(0.5) { + log::debug!("Sending tx2 with local origin"); + let status = node.add_local_tx_to_mempool(tx2.clone()).await; + assert_eq!(status, mempool::TransactionDuplicateStatus::New); + } else { + log::debug!("Sending tx2 with remote origin"); + let status = + node.add_remote_tx_to_mempool(tx2.clone(), remote_origin_peer_id).await; + assert_eq!(status, mempool::TxStatus::InMempool); + } let (peer_id, msg) = node.get_sent_transaction_sync_message().await; assert_eq!(peer_id, peer1_id); - assert_eq!(msg, TransactionSyncMessage::NewTransaction(remote_tx_id)); + assert_eq!(msg, TransactionSyncMessage::NewTransaction(tx2_id)); } // Peer2 connects - let _peer2 = node.connect_peer(peer2_id, protocol_version).await; + let peer2 = node.connect_peer(peer2_id, protocol_version).await; - // Add the local tx again. The sync manager should rebroadcast the tx. + peer2 + .send_block_sync_message(BlockSyncMessage::HeaderList(HeaderList::new(Vec::new()))) + .await; + + // Add tx1 again specifying the local origin. The sync manager should rebroadcast the tx. // Since peer1 has already been offered this tx, the NewTransaction message // should only be sent to peer2. { - let status = node - .mempool() - .call_mut(move |m| { - m.add_transaction_local(local_tx, local_tx_origin, local_tx_options) - }) - .await - .unwrap() - .unwrap(); + let status = node.add_local_tx_to_mempool(tx1).await; assert_eq!(status, mempool::TransactionDuplicateStatus::Duplicate); let (peer_id, msg) = node.get_sent_transaction_sync_message().await; assert_eq!(peer_id, peer2_id); - assert_eq!(msg, TransactionSyncMessage::NewTransaction(local_tx_id)); + assert_eq!(msg, TransactionSyncMessage::NewTransaction(tx1_id)); node.assert_no_sync_message().await; } - // Add the remote tx again. The sync manager should NOT rebroadcast the tx, - // so event peer2 will not be sent the NewTransaction message. + // Add tx2 again specifying a remote origin. The sync manager should NOT rebroadcast the tx, + // so even peer2 will not be sent the NewTransaction message. { - let status = node - .mempool() - .call_mut(move |m| { - m.add_transaction_remote(remote_tx, remote_tx_origin, remote_tx_options) - }) - .await - .unwrap() - .unwrap(); + let status = node.add_remote_tx_to_mempool(tx2, remote_origin_peer_id).await; assert_eq!(status, mempool::TxStatus::InMempoolDuplicate); node.assert_no_sync_message().await; From a642e449ab8da7dbc00c0bf5219946540a5b60c8 Mon Sep 17 00:00:00 2001 From: Mykhailo Kremniov Date: Tue, 5 May 2026 19:14:33 +0300 Subject: [PATCH 08/10] P2p: sync mgr's next_time_to_requeue_unconfirmed_local_txs and next_time_to_announce_txs are now delayed at the start. --- p2p/src/sync/mod.rs | 15 +++++++++------ p2p/src/sync/peer/transaction_manager.rs | 24 +++++++++++++----------- p2p/src/sync/tests/tx_announcement.rs | 20 -------------------- 3 files changed, 22 insertions(+), 37 deletions(-) diff --git a/p2p/src/sync/mod.rs b/p2p/src/sync/mod.rs index 630c42be87..bbeb7fb02f 100644 --- a/p2p/src/sync/mod.rs +++ b/p2p/src/sync/mod.rs @@ -206,7 +206,8 @@ where let mut new_tip_receiver = subscribe_to_new_tip(&self.chainstate_handle).await?; let mut tx_processed_receiver = subscribe_to_tx_processed(&self.mempool_handle).await?; - let mut next_time_to_requeue_unconfirmed_local_txs = self.monotonic_time_getter.get_time(); + let mut next_time_to_requeue_unconfirmed_local_txs = self.monotonic_time_getter.get_time() + + Self::make_random_unconfirmed_tx_requeue_delay(); loop { tokio::select! { @@ -239,15 +240,17 @@ where if now >= next_time_to_requeue_unconfirmed_local_txs { self.requeue_unconfirmed_local_transactions().await?; - let delay = make_pseudo_rng().random_range( - UNCONFIRMED_TX_REQUEUE_MIN_DELAY..UNCONFIRMED_TX_REQUEUE_MAX_DELAY, - ); - - next_time_to_requeue_unconfirmed_local_txs = now + delay; + next_time_to_requeue_unconfirmed_local_txs = + now + Self::make_random_unconfirmed_tx_requeue_delay(); } } } + fn make_random_unconfirmed_tx_requeue_delay() -> Duration { + make_pseudo_rng() + .random_range(UNCONFIRMED_TX_REQUEUE_MIN_DELAY..UNCONFIRMED_TX_REQUEUE_MAX_DELAY) + } + async fn requeue_unconfirmed_local_transactions(&mut self) -> Result<()> { if !self.unconfirmed_local_transactions.is_empty() { // Filter out transactions that are no longer in the mempool. diff --git a/p2p/src/sync/peer/transaction_manager.rs b/p2p/src/sync/peer/transaction_manager.rs index 3abf159da0..01d48b3570 100644 --- a/p2p/src/sync/peer/transaction_manager.rs +++ b/p2p/src/sync/peer/transaction_manager.rs @@ -175,7 +175,8 @@ where ); maintenance_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); - let mut next_time_to_announce_txs = self.monotonic_time_getter.get_time(); + let mut next_time_to_announce_txs = + self.monotonic_time_getter.get_time() + self.make_random_tx_announcement_delay(); loop { tokio::select! { @@ -199,16 +200,7 @@ where if now >= next_time_to_announce_txs { self.announce_transactions().await?; - // TODO: whitelisted peers should get txs without delay, - // see https://github.com/mintlayer/mintlayer-core/issues/1406. - let base_delay = match self.direction { - ConnectionDirection::Inbound => TX_RELAY_DELAY_INTERVAL_INBOUND, - ConnectionDirection::Outbound => TX_RELAY_DELAY_INTERVAL_OUTBOUND, - }; - let delay = - base_delay.mul_f64(utils::exp_rand::exponential_rand(&mut make_pseudo_rng())); - - next_time_to_announce_txs = now + delay; + next_time_to_announce_txs = now + self.make_random_tx_announcement_delay(); } if let Some(o) = self.observer.as_mut() { @@ -217,6 +209,16 @@ where } } + // TODO: whitelisted peers should get txs without delay, + // see https://github.com/mintlayer/mintlayer-core/issues/1406. + fn make_random_tx_announcement_delay(&self) -> Duration { + let base_delay = match self.direction { + ConnectionDirection::Inbound => TX_RELAY_DELAY_INTERVAL_INBOUND, + ConnectionDirection::Outbound => TX_RELAY_DELAY_INTERVAL_OUTBOUND, + }; + base_delay.mul_f64(utils::exp_rand::exponential_rand(&mut make_pseudo_rng())) + } + fn send_message(&mut self, message: TransactionSyncMessage) -> Result<()> { self.messaging_handle.send_transaction_sync_message(self.id(), message) } diff --git a/p2p/src/sync/tests/tx_announcement.rs b/p2p/src/sync/tests/tx_announcement.rs index d4fe08ec21..7857b8ada2 100644 --- a/p2p/src/sync/tests/tx_announcement.rs +++ b/p2p/src/sync/tests/tx_announcement.rs @@ -865,13 +865,6 @@ async fn transaction_announcements_are_batched_and_sorted(#[case] seed: Seed) { peer.send_block_sync_message(BlockSyncMessage::HeaderList(HeaderList::new(Vec::new()))) .await; - // Make sure that the tx sync mgr's main loop had at least one iteration, this ensures - // that its `next_time_to_announce_txs` has been updated. - node.wait_for_notification( - SyncManagerNotification::TxSyncManagerMainLoopIterationCompleted { peer_id }, - ) - .await; - // The child tx has the highest fee, but it has to come after the parent tx, which has the // lowest fee, so the resulting order should be independent_tx, parent_tx, child_tx. let independent_tx_fee = rng.random_range(100..200); @@ -1013,13 +1006,6 @@ async fn unconfirmed_local_transactions_reannouncement( .send_block_sync_message(BlockSyncMessage::HeaderList(HeaderList::new(Vec::new()))) .await; - // Make sure that the tx sync mgr's main loop had at least one iteration, this ensures - // that its `next_time_to_announce_txs` has been updated. - node.wait_for_notification( - SyncManagerNotification::TxSyncManagerMainLoopIterationCompleted { peer_id: peer1_id }, - ) - .await; - let local_independent_tx_fee = rng.random_range(100..200); let local_parent_tx_fee = rng.random_range(10..20); let local_child_tx_fee = rng.random_range(500..600); @@ -1199,12 +1185,6 @@ async fn unconfirmed_local_transactions_reannouncement( .send_block_sync_message(BlockSyncMessage::HeaderList(HeaderList::new(Vec::new()))) .await; - // Again, ensures that peer's `next_time_to_announce_txs` has been updated. - node.wait_for_notification( - SyncManagerNotification::TxSyncManagerMainLoopIterationCompleted { peer_id: peer2_id }, - ) - .await; - // No announcements until we advance the time node.assert_no_sent_transaction_sync_message().await; From 100d1fa7d02e1cdb33652fae00b996ed7dd6df88 Mon Sep 17 00:00:00 2001 From: Mykhailo Kremniov Date: Tue, 5 May 2026 21:53:29 +0300 Subject: [PATCH 09/10] Minor cleanup --- mempool/src/interface/mempool_interface.rs | 15 +++--- .../src/interface/mempool_interface_impl.rs | 22 +++++---- mempool/src/pool/mod.rs | 6 +-- mempool/src/pool/tx_pool/collect_txs.rs | 7 ++- mempool/src/pool/tx_pool/mod.rs | 4 +- .../tests/tx_ids_by_score_and_ancestry.rs | 2 +- .../peer_manager/peerdb/address_data/tests.rs | 3 +- p2p/src/sync/mod.rs | 12 +++-- p2p/src/sync/peer/transaction_manager.rs | 7 ++- p2p/src/sync/tests/tx_announcement.rs | 49 +++++++------------ 10 files changed, 58 insertions(+), 69 deletions(-) diff --git a/mempool/src/interface/mempool_interface.rs b/mempool/src/interface/mempool_interface.rs index cf338a03ef..28e1423570 100644 --- a/mempool/src/interface/mempool_interface.rs +++ b/mempool/src/interface/mempool_interface.rs @@ -13,6 +13,14 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::{collections::BTreeSet, num::NonZeroUsize, sync::Arc}; + +use common::{ + chain::{GenBlock, SignedTransaction, Transaction}, + primitives::Id, +}; +use mempool_types::TransactionDuplicateStatus; + use crate::{ error::{BlockConstructionError, Error}, event::MempoolEvent, @@ -20,13 +28,6 @@ use crate::{ tx_origin::{LocalTxOrigin, RemoteTxOrigin}, FeeRate, MempoolMaxSize, TxOptions, TxStatus, }; -use common::{ - chain::{GenBlock, SignedTransaction, Transaction}, - primitives::Id, -}; -use mempool_types::TransactionDuplicateStatus; -use std::{collections::BTreeSet, num::NonZeroUsize, sync::Arc}; - pub trait MempoolInterface: Send + Sync { /// Add a transaction from remote peer to mempool fn add_transaction_remote( diff --git a/mempool/src/interface/mempool_interface_impl.rs b/mempool/src/interface/mempool_interface_impl.rs index 35f9c34c6a..a73c69abb0 100644 --- a/mempool/src/interface/mempool_interface_impl.rs +++ b/mempool/src/interface/mempool_interface_impl.rs @@ -13,15 +13,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::{ - config::MempoolConfig, - error::{BlockConstructionError, Error}, - event::MempoolEvent, - pool::memory_usage_estimator::StoreMemoryUsageEstimator, - tx_accumulator::{PackingStrategy, TransactionAccumulator}, - tx_origin::{LocalTxOrigin, RemoteTxOrigin}, - FeeRate, MempoolInterface, MempoolMaxSize, TxOptions, TxStatus, -}; +use std::{collections::BTreeSet, num::NonZeroUsize, sync::Arc}; + use chainstate::ChainstateEventTracingWrapper; use common::{ chain::{ChainConfig, GenBlock, SignedTransaction, Transaction}, @@ -30,9 +23,18 @@ use common::{ }; use logging::log; use mempool_types::TransactionDuplicateStatus; -use std::{collections::BTreeSet, num::NonZeroUsize, sync::Arc}; use utils::{const_value::ConstValue, debug_panic_or_log, tap_log::TapLog}; +use crate::{ + config::MempoolConfig, + error::{BlockConstructionError, Error}, + event::MempoolEvent, + pool::memory_usage_estimator::StoreMemoryUsageEstimator, + tx_accumulator::{PackingStrategy, TransactionAccumulator}, + tx_origin::{LocalTxOrigin, RemoteTxOrigin}, + FeeRate, MempoolInterface, MempoolMaxSize, TxOptions, TxStatus, +}; + type Mempool = crate::pool::Mempool; /// Mempool initializer diff --git a/mempool/src/pool/mod.rs b/mempool/src/pool/mod.rs index f2c93209d1..6bac74adf4 100644 --- a/mempool/src/pool/mod.rs +++ b/mempool/src/pool/mod.rs @@ -595,7 +595,7 @@ impl<'a> TxFinalizer<'a> { match outcome { TxAdditionOutcome::Added { transaction } => { let tx_id = *transaction.tx_id(); - log::trace!("Added transaction {tx_id}"); + log::trace!("Added transaction {tx_id:x}"); self.enqueue_children(transaction.tx_entry()); @@ -618,10 +618,10 @@ impl<'a> TxFinalizer<'a> { } => { debug_assert_or_log!( existing_transaction.tx_id() == new_transaction.tx_id(), - "Duplicate tx is not a duplicate" + "Duplicate tx has different id" ); let tx_id = *existing_transaction.tx_id(); - log::trace!("Duplicate transaction {tx_id}"); + log::trace!("Duplicate transaction {tx_id:x}"); match &mut self.events_mode { TxFinalizerEventsMode::Silent => {} diff --git a/mempool/src/pool/tx_pool/collect_txs.rs b/mempool/src/pool/tx_pool/collect_txs.rs index b4baf3582b..ef896632e2 100644 --- a/mempool/src/pool/tx_pool/collect_txs.rs +++ b/mempool/src/pool/tx_pool/collect_txs.rs @@ -320,15 +320,14 @@ pub fn get_best_tx_ids_by_score_and_ancestry( match missing_ancestors_count.get_mut() { 0 => { // Should not be possible by construction. - panic!("pending child with 0 missing parents"); + panic!("Pending child with 0 missing parents"); } 1 => { - let child_id = *missing_ancestors_count.key(); missing_ancestors_count.remove(); - let child = mempool.store.get_entry(&child_id).ok_or( + let child = mempool.store.get_entry(child_id).ok_or( TxCollectionError::TxChildNotFound { tx_id: *tx_id, - child_tx_id: child_id, + child_tx_id: *child_id, }, )?; ready_txs.push(child.into()); diff --git a/mempool/src/pool/tx_pool/mod.rs b/mempool/src/pool/tx_pool/mod.rs index a53f5fb73b..12c4f40dde 100644 --- a/mempool/src/pool/tx_pool/mod.rs +++ b/mempool/src/pool/tx_pool/mod.rs @@ -678,8 +678,8 @@ pub enum TxAdditionOutcome<'a> { /// Transaction already in mempool Duplicate { existing_transaction: &'a TxMempoolEntry, - // Note: the transaction part of the new entry will be the same as the old one (except for - // the signatures), but the tx origin and options may be different. + // Note: the transaction part of the new entry will be the same as the old one (maybe except + // for the signatures), but the tx origin and options may be different. new_transaction: TxEntry, }, diff --git a/mempool/src/pool/tx_pool/tests/tx_ids_by_score_and_ancestry.rs b/mempool/src/pool/tx_pool/tests/tx_ids_by_score_and_ancestry.rs index 7f7d1e0d44..47c8af6953 100644 --- a/mempool/src/pool/tx_pool/tests/tx_ids_by_score_and_ancestry.rs +++ b/mempool/src/pool/tx_pool/tests/tx_ids_by_score_and_ancestry.rs @@ -77,7 +77,7 @@ async fn tx_ids_by_score_and_ancestry(#[case] seed: Seed) { ); let low_fee_tx_id = low_fee_tx.transaction().get_id(); - // Create a chain of transactions where the parent pays fee event lower than the "low" one + // Create a chain of transactions where the parent pays fee even lower than the "low" one // above, but descendants pay random fee that may be even higher than the "high" one. let tx_chain_root_fee = rng.random_range(1..600); let tx_chain_root_tx = make_tx( diff --git a/p2p/src/peer_manager/peerdb/address_data/tests.rs b/p2p/src/peer_manager/peerdb/address_data/tests.rs index e881ffd48b..ababe59e7d 100644 --- a/p2p/src/peer_manager/peerdb/address_data/tests.rs +++ b/p2p/src/peer_manager/peerdb/address_data/tests.rs @@ -23,6 +23,7 @@ use randomness::{ RngExt as _, }; use test_utils::random::{make_seedable_rng, Seed, StepRng}; +use utils::exp_rand::EXPONENTIAL_RAND_UPPER_LIMIT; use super::*; @@ -115,7 +116,7 @@ fn reachable_reconnects(#[case] seed: Seed) { fn next_connect_time_test_impl(rng: &mut impl Rng) { // Factors are produced by exponential_rand, which can't generate numbers bigger than this. - let max_delay_factor = 37; + let max_delay_factor = EXPONENTIAL_RAND_UPPER_LIMIT; let limit_reserved = MAX_DELAY_RESERVED * max_delay_factor; let limit_reachable = MAX_DELAY_REACHABLE * max_delay_factor; diff --git a/p2p/src/sync/mod.rs b/p2p/src/sync/mod.rs index bbeb7fb02f..6c784456dc 100644 --- a/p2p/src/sync/mod.rs +++ b/p2p/src/sync/mod.rs @@ -248,7 +248,7 @@ where fn make_random_unconfirmed_tx_requeue_delay() -> Duration { make_pseudo_rng() - .random_range(UNCONFIRMED_TX_REQUEUE_MIN_DELAY..UNCONFIRMED_TX_REQUEUE_MAX_DELAY) + .random_range(UNCONFIRMED_TX_REQUEUE_MIN_DELAY..=UNCONFIRMED_TX_REQUEUE_MAX_DELAY) } async fn requeue_unconfirmed_local_transactions(&mut self) -> Result<()> { @@ -385,7 +385,7 @@ where return Ok(()); } - log::debug!("Broadcasting a new tip {}", block_id); + log::debug!("Broadcasting a new tip {:x}", block_id); self.send_block_mgr_event(&PeerBlockSyncManagerLocalEvent::ChainstateNewTip(block_id)); Ok(()) @@ -424,7 +424,7 @@ where if need_relay { log::info!( - "Propagating {status_str} transaction {tx_id} originating in {origin}" + "Propagating {status_str} transaction {tx_id:x} originating in {origin}" ); self.send_tx_mgr_event( @@ -439,12 +439,14 @@ where } } else { log::trace!( - "Not propagating {status_str} transaction {tx_id} originating in {origin}" + "Not propagating {status_str} transaction {tx_id:x} originating in {origin}" ); } } TxRelayPolicy::DontRelay => { - log::trace!("Not propagating transaction {tx_id} originating in {origin}"); + log::trace!( + "Not propagating transaction {tx_id:x} originating in {origin}" + ); } } } diff --git a/p2p/src/sync/peer/transaction_manager.rs b/p2p/src/sync/peer/transaction_manager.rs index 01d48b3570..eabc172747 100644 --- a/p2p/src/sync/peer/transaction_manager.rs +++ b/p2p/src/sync/peer/transaction_manager.rs @@ -15,8 +15,6 @@ use std::{collections::BTreeSet, time::Duration}; -use networking::types::ConnectionDirection; -use randomness::make_pseudo_rng; use tokio::{ sync::mpsc::{Receiver, UnboundedReceiver, UnboundedSender}, time::MissedTickBehavior, @@ -29,8 +27,9 @@ use common::{ }; use logging::log; use mempool::{MempoolHandle, TxOptions}; -use utils::sync::Arc; -use utils::{const_value::ConstValue, sender_with_id::MpscUnboundedSenderWithId}; +use networking::types::ConnectionDirection; +use randomness::make_pseudo_rng; +use utils::{const_value::ConstValue, sender_with_id::MpscUnboundedSenderWithId, sync::Arc}; use crate::{ config::P2pConfig, diff --git a/p2p/src/sync/tests/tx_announcement.rs b/p2p/src/sync/tests/tx_announcement.rs index 7857b8ada2..423d61ce42 100644 --- a/p2p/src/sync/tests/tx_announcement.rs +++ b/p2p/src/sync/tests/tx_announcement.rs @@ -50,7 +50,7 @@ use utils::exp_rand::EXPONENTIAL_RAND_UPPER_LIMIT; use crate::{ config::NodeType, error::ProtocolError, - message::{BlockSyncMessage, HeaderList, TransactionResponse, TransactionSyncMessage}, + message::{TransactionResponse, TransactionSyncMessage}, protocol::ProtocolConfig, sync::{ peer::requested_transactions::REQUESTED_TX_EXPIRY_PERIOD, @@ -672,9 +672,7 @@ async fn reannounce_duplicate_transaction(#[case] seed: Seed) { .with_chain_config(chain_config.as_ref().clone()) .build(); - // This will process a block to finish the initial block download and also create utxos - // to spend. - let fund_tx_id: Id = split_utxo( + let funding_tx_id: Id = split_utxo( &mut rng, &mut tf, UtxoOutPoint::new(chain_config.genesis_block_id().into(), 0), @@ -693,16 +691,13 @@ async fn reannounce_duplicate_transaction(#[case] seed: Seed) { let peer2_id = PeerId::new(); let remote_origin_peer_id = PeerId::new(); - // Peer1 connects + // Connect to peer1 let peer1 = node.connect_peer(peer1_id, protocol_version).await; - - peer1 - .send_block_sync_message(BlockSyncMessage::HeaderList(HeaderList::new(Vec::new()))) - .await; + peer1.send_headers(Vec::new()).await; let tx1 = TransactionBuilder::new() .add_input( - TxInput::from_utxo(fund_tx_id.into(), 0), + TxInput::from_utxo(funding_tx_id.into(), 0), empty_witness(&mut rng), ) .add_output(TxOutput::Transfer( @@ -715,7 +710,7 @@ async fn reannounce_duplicate_transaction(#[case] seed: Seed) { let tx2 = TransactionBuilder::new() .add_input( - TxInput::from_utxo(fund_tx_id.into(), 1), + TxInput::from_utxo(funding_tx_id.into(), 1), empty_witness(&mut rng), ) .add_output(TxOutput::Transfer( @@ -762,12 +757,9 @@ async fn reannounce_duplicate_transaction(#[case] seed: Seed) { assert_eq!(msg, TransactionSyncMessage::NewTransaction(tx2_id)); } - // Peer2 connects + // Connect to peer2 let peer2 = node.connect_peer(peer2_id, protocol_version).await; - - peer2 - .send_block_sync_message(BlockSyncMessage::HeaderList(HeaderList::new(Vec::new()))) - .await; + peer2.send_headers(Vec::new()).await; // Add tx1 again specifying the local origin. The sync manager should rebroadcast the tx. // Since peer1 has already been offered this tx, the NewTransaction message @@ -800,7 +792,7 @@ async fn reannounce_duplicate_transaction(#[case] seed: Seed) { // Check that the tx sync mgr announces all txs at once after a delay and that the announcements // have a specific order. // * Connect to a peer. -// * Create 3 transactions that should have a specific order in the mempool. +// * Create 3 transactions that should have a specific order according to the mempool. // * Add them to the mempool, expect no announcement. // * Advance the mock time, the transactions should now be announced, in the specific order. #[tracing::instrument(skip(seed))] @@ -860,10 +852,9 @@ async fn transaction_announcements_are_batched_and_sorted(#[case] seed: Seed) { let peer_id = PeerId::new(); let remote_origin_peer_id = PeerId::new(); - let peer = node.connect_peer(peer_id, protocol_version).await; - peer.send_block_sync_message(BlockSyncMessage::HeaderList(HeaderList::new(Vec::new()))) - .await; + let peer = node.connect_peer(peer_id, protocol_version).await; + peer.send_headers(Vec::new()).await; // The child tx has the highest fee, but it has to come after the parent tx, which has the // lowest fee, so the resulting order should be independent_tx, parent_tx, child_tx. @@ -931,7 +922,7 @@ async fn transaction_announcements_are_batched_and_sorted(#[case] seed: Seed) { // send those txs. // * Optionally, mine a block containing some of the local and remote txs. // * Connect to a new peer. -// * Advance time 2 times - first to make sure that the local event about local tx re-announcement +// * Advance time twice - first to make sure that the local event about local tx re-announcement // reaches tx sync managers, second to make the txs stored by the tx sync managers due for // announcement. // Expected result: tx announcements should be made to the new peer. Only the local txs should @@ -1000,11 +991,9 @@ async fn unconfirmed_local_transactions_reannouncement( let peer1_id = PeerId::new(); let peer2_id = PeerId::new(); let remote_origin_peer_id = PeerId::new(); - let peer1 = node.connect_peer(peer1_id, protocol_version).await; - peer1 - .send_block_sync_message(BlockSyncMessage::HeaderList(HeaderList::new(Vec::new()))) - .await; + let peer1 = node.connect_peer(peer1_id, protocol_version).await; + peer1.send_headers(Vec::new()).await; let local_independent_tx_fee = rng.random_range(100..200); let local_parent_tx_fee = rng.random_range(10..20); @@ -1075,7 +1064,7 @@ async fn unconfirmed_local_transactions_reannouncement( log::debug!("all_tx_ids = {all_tx_ids:?}"); let all_tx_ids_set = BTreeSet::from(all_tx_ids); - // No announcements until we advance the time + // No announcements until we advance the time. node.assert_no_sent_transaction_sync_message().await; let expected_initial_tx_ids = node @@ -1180,12 +1169,9 @@ async fn unconfirmed_local_transactions_reannouncement( } let peer2 = node.connect_peer(peer2_id, protocol_version).await; + peer2.send_headers(Vec::new()).await; - peer2 - .send_block_sync_message(BlockSyncMessage::HeaderList(HeaderList::new(Vec::new()))) - .await; - - // No announcements until we advance the time + // No announcements until we advance the time. node.assert_no_sent_transaction_sync_message().await; let expected_reannounced_tx_ids = node @@ -1213,7 +1199,6 @@ async fn unconfirmed_local_transactions_reannouncement( // Advance the time, so that the tx announcement can happen. time_getter.advance_time(TX_RELAY_DELAY_INTERVAL_OUTBOUND * EXPONENTIAL_RAND_UPPER_LIMIT); - node.clear_notifications().await; for expected_tx_id in &expected_reannounced_tx_ids { log::debug!("Expecting re-announcement of tx {expected_tx_id:x}"); From 854d769a637e2d8dc156b1d4c40b5d930b209bce Mon Sep 17 00:00:00 2001 From: Mykhailo Kremniov Date: Wed, 6 May 2026 11:21:38 +0300 Subject: [PATCH 10/10] Update changelog --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4defc16a89..ff0814eb79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,15 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/ - `wallet-create`/`wallet-recover`/`wallet-open` support the `ledger` subcommand, in addition to the existing `software` and `trezor`, which specifies the type of the wallet to operate on. +### Changed + - P2p: + - Various improvements to transaction announcement: + - Local transactions that were already announced but never sent to any peer will now be re-announced + after a few minutes. + - Adding an already existing relayable local transaction to the mempool will cause p2p to re-announce it. + - Transactions are now announced in batches at irregular intervals (previously, a random delay was added + before each individual transaction announcement). + ### Fixed - Wallet: - Fixed handling of confirmed and unconfirmed conflicting order transactions in the wallet.