Add expired transactions to the mempool rejected list (#2852)

* Add expired transactions to the mempool rejected list

* Apply suggestions from code review

Co-authored-by: Deirdre Connolly <deirdre@zfnd.org>

* Apply suggestions from code review

Co-authored-by: teor <teor@riseup.net>

* Refactor contains_rejected() by just calling rejection_error()

* Improve rejection_error() documentation

* Improve rejected_transaction_count() documentation

Co-authored-by: Deirdre Connolly <deirdre@zfnd.org>
Co-authored-by: teor <teor@riseup.net>
This commit is contained in:
Conrado Gouvea 2021-10-11 18:23:43 -03:00 committed by GitHub
parent 7a5f419cb6
commit 31e7a21721
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 63 additions and 25 deletions

View File

@ -273,7 +273,7 @@ async fn mempool_transaction_expiration() -> Result<(), crate::BoxError> {
// Get services // Get services
let ( let (
inbound_service, inbound_service,
_mempool_guard, mempool,
_committed_blocks, _committed_blocks,
_added_transactions, _added_transactions,
mut tx_verifier, mut tx_verifier,
@ -402,6 +402,25 @@ async fn mempool_transaction_expiration() -> Result<(), crate::BoxError> {
"`MempoolTransactionIds` requests should always respond `Ok(Vec<UnminedTxId>)`" "`MempoolTransactionIds` requests should always respond `Ok(Vec<UnminedTxId>)`"
), ),
}; };
// Check if tx1 was added to the rejected list as well
let response = mempool
.clone()
.oneshot(mempool::Request::Queue(vec![tx1_id.into()]))
.await
.unwrap();
let queued_responses = match response {
mempool::Response::Queued(queue_responses) => queue_responses,
_ => unreachable!("will never happen in this test"),
};
assert_eq!(queued_responses.len(), 1);
assert_eq!(
queued_responses[0],
Err(mempool::MempoolError::StorageEffectsChain(
mempool::SameEffectsChainRejectionError::Expired
))
);
// Test transaction 2 is gossiped // Test transaction 2 is gossiped
let mut hs = HashSet::new(); let mut hs = HashSet::new();

View File

@ -365,17 +365,26 @@ fn remove_expired_transactions(
tip_height: zebra_chain::block::Height, tip_height: zebra_chain::block::Height,
) { ) {
let mut txid_set = HashSet::new(); let mut txid_set = HashSet::new();
// we need a separate set, since reject() takes the original unmined ID,
// then extracts the mined ID out of it
let mut unmined_id_set = HashSet::new();
for t in storage.transactions() { for t in storage.transactions() {
if let Some(expiry_height) = t.transaction.expiry_height() { if let Some(expiry_height) = t.transaction.expiry_height() {
if tip_height >= expiry_height { if tip_height >= expiry_height {
txid_set.insert(t.id.mined_id()); txid_set.insert(t.id.mined_id());
unmined_id_set.insert(t.id);
} }
} }
} }
// expiry height is effecting data, so we match by non-malleable TXID // expiry height is effecting data, so we match by non-malleable TXID
storage.remove_same_effects(&txid_set); storage.remove_same_effects(&txid_set);
// also reject it
for id in unmined_id_set {
storage.reject(id, SameEffectsChainRejectionError::Expired.into());
}
} }
/// Add a transaction that failed download and verification to the rejected list /// Add a transaction that failed download and verification to the rejected list

View File

@ -60,7 +60,7 @@ pub enum SameEffectsTipRejectionError {
/// ///
/// Rollbacks and network upgrades clear these rejections, because they can lower the tip height, /// Rollbacks and network upgrades clear these rejections, because they can lower the tip height,
/// or change the consensus rules. /// or change the consensus rules.
#[derive(Error, Clone, Debug, PartialEq, Eq)] #[derive(Error, Clone, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(any(test, feature = "proptest-impl"), derive(Arbitrary))] #[cfg_attr(any(test, feature = "proptest-impl"), derive(Arbitrary))]
#[allow(dead_code)] #[allow(dead_code)]
pub enum SameEffectsChainRejectionError { pub enum SameEffectsChainRejectionError {
@ -107,11 +107,12 @@ pub struct Storage {
/// Any transaction with the same `transaction::Hash` is invalid. /// Any transaction with the same `transaction::Hash` is invalid.
tip_rejected_same_effects: HashMap<transaction::Hash, SameEffectsTipRejectionError>, tip_rejected_same_effects: HashMap<transaction::Hash, SameEffectsTipRejectionError>,
/// The set of transactions rejected for their effects, and their rejection reasons. /// Sets of transactions rejected for their effects, keyed by rejection reason.
/// These rejections apply until a rollback or network upgrade. /// These rejections apply until a rollback or network upgrade.
/// ///
/// Any transaction with the same `transaction::Hash` is invalid. /// Any transaction with the same `transaction::Hash` is invalid.
chain_rejected_same_effects: HashMap<transaction::Hash, SameEffectsChainRejectionError>, chain_rejected_same_effects:
HashMap<SameEffectsChainRejectionError, HashSet<transaction::Hash>>,
} }
impl Storage { impl Storage {
@ -142,8 +143,6 @@ impl Storage {
return Err(rejection_error.into()); return Err(rejection_error.into());
} }
// Security: stop the transaction or rejection lists using too much memory
// Once inserted, we evict transactions over the pool size limit. // Once inserted, we evict transactions over the pool size limit.
while self.verified.transaction_count() > MEMPOOL_SIZE { while self.verified.transaction_count() > MEMPOOL_SIZE {
let evicted_tx = self let evicted_tx = self
@ -151,14 +150,16 @@ impl Storage {
.evict_one() .evict_one()
.expect("mempool is empty, but was expected to be full"); .expect("mempool is empty, but was expected to be full");
let _ = self.chain_rejected_same_effects.insert( let _ = self
evicted_tx.id.mined_id(), .chain_rejected_same_effects
SameEffectsChainRejectionError::RandomlyEvicted, .entry(SameEffectsChainRejectionError::RandomlyEvicted)
); .or_default()
.insert(evicted_tx.id.mined_id());
} }
assert!(self.verified.transaction_count() <= MEMPOOL_SIZE); assert!(self.verified.transaction_count() <= MEMPOOL_SIZE);
// Security: stop the transaction or rejection lists using too much memory
self.limit_rejection_list_memory(); self.limit_rejection_list_memory();
Ok(tx_id) Ok(tx_id)
@ -230,8 +231,10 @@ impl Storage {
if self.tip_rejected_same_effects.len() > MAX_EVICTION_MEMORY_ENTRIES { if self.tip_rejected_same_effects.len() > MAX_EVICTION_MEMORY_ENTRIES {
self.tip_rejected_same_effects.clear(); self.tip_rejected_same_effects.clear();
} }
if self.chain_rejected_same_effects.len() > MAX_EVICTION_MEMORY_ENTRIES { for (_, map) in self.chain_rejected_same_effects.iter_mut() {
self.chain_rejected_same_effects.clear(); if map.len() > MAX_EVICTION_MEMORY_ENTRIES {
map.clear();
}
} }
} }
@ -273,11 +276,17 @@ impl Storage {
} }
/// Returns the number of rejected [`UnminedTxId`]s or [`transaction::Hash`]es. /// Returns the number of rejected [`UnminedTxId`]s or [`transaction::Hash`]es.
///
/// Transactions on multiple rejected lists are counted multiple times.
#[allow(dead_code)] #[allow(dead_code)]
pub fn rejected_transaction_count(&self) -> usize { pub fn rejected_transaction_count(&self) -> usize {
self.tip_rejected_exact.len() self.tip_rejected_exact.len()
+ self.tip_rejected_same_effects.len() + self.tip_rejected_same_effects.len()
+ self.chain_rejected_same_effects.len() + self
.chain_rejected_same_effects
.iter()
.map(|(_, map)| map.len())
.sum::<usize>()
} }
/// Add a transaction to the rejected list for the given reason. /// Add a transaction to the rejected list for the given reason.
@ -290,16 +299,21 @@ impl Storage {
self.tip_rejected_same_effects.insert(txid.mined_id(), e); self.tip_rejected_same_effects.insert(txid.mined_id(), e);
} }
RejectionError::SameEffectsChain(e) => { RejectionError::SameEffectsChain(e) => {
self.chain_rejected_same_effects.insert(txid.mined_id(), e); self.chain_rejected_same_effects
.entry(e)
.or_default()
.insert(txid.mined_id());
} }
} }
self.limit_rejection_list_memory(); self.limit_rejection_list_memory();
} }
/// Returns `true` if a [`UnminedTx`] matching an [`UnminedTxId`] is in /// Returns the rejection error if a [`UnminedTx`] matching an [`UnminedTxId`]
/// any mempool rejected list. /// is in any mempool rejected list.
/// ///
/// This matches transactions based on each rejection list's matching rule. /// This matches transactions based on each rejection list's matching rule.
///
/// Returns an arbitrary error if the transaction is in multiple lists.
pub fn rejection_error(&self, txid: &UnminedTxId) -> Option<MempoolError> { pub fn rejection_error(&self, txid: &UnminedTxId) -> Option<MempoolError> {
if let Some(error) = self.tip_rejected_exact.get(txid) { if let Some(error) = self.tip_rejected_exact.get(txid) {
return Some(error.clone().into()); return Some(error.clone().into());
@ -309,8 +323,10 @@ impl Storage {
return Some(error.clone().into()); return Some(error.clone().into());
} }
if let Some(error) = self.chain_rejected_same_effects.get(&txid.mined_id()) { for (error, set) in self.chain_rejected_same_effects.iter() {
return Some(error.clone().into()); if set.contains(&txid.mined_id()) {
return Some(error.clone().into());
}
} }
None None
@ -333,12 +349,6 @@ impl Storage {
/// ///
/// This matches transactions based on each rejection list's matching rule. /// This matches transactions based on each rejection list's matching rule.
pub fn contains_rejected(&self, txid: &UnminedTxId) -> bool { pub fn contains_rejected(&self, txid: &UnminedTxId) -> bool {
self.tip_rejected_exact.contains_key(txid) self.rejection_error(txid).is_some()
|| self
.tip_rejected_same_effects
.contains_key(&txid.mined_id())
|| self
.chain_rejected_same_effects
.contains_key(&txid.mined_id())
} }
} }