zebrad/components/mempool/storage.rs
1//! Mempool transaction storage.
2//!
3//! The main struct [`Storage`] holds verified and rejected transactions.
4//! [`Storage`] is effectively the data structure of the mempool. Convenient methods to
5//! manage it are included.
6//!
7//! [`Storage`] does not expose a service so it can only be used by other code directly.
8//! Only code inside the [`crate::components::mempool`] module has access to it.
9
10use std::{
11 collections::{HashMap, HashSet},
12 mem::size_of,
13 sync::Arc,
14 time::Duration,
15};
16
17use thiserror::Error;
18
19use zcash_script::solver;
20use zebra_chain::{
21 block::Height,
22 transaction::{self, Hash, Transaction, UnminedTx, UnminedTxId, VerifiedUnminedTx},
23 transparent,
24};
25use zebra_node_services::mempool::TransactionDependencies;
26
27use self::{eviction_list::EvictionList, verified_set::VerifiedSet};
28use super::{
29 config, downloads::TransactionDownloadVerifyError, pending_outputs::PendingOutputs,
30 MempoolError,
31};
32
33#[cfg(any(test, feature = "proptest-impl"))]
34use proptest_derive::Arbitrary;
35
36#[cfg(test)]
37pub mod tests;
38
39mod eviction_list;
40mod policy;
41mod verified_set;
42
43/// The size limit for mempool transaction rejection lists per [ZIP-401].
44///
45/// > The size of RecentlyEvicted SHOULD never exceed `eviction_memory_entries`
46/// > entries, which is the constant 40000.
47///
48/// We use the specified value for all lists for consistency.
49///
50/// [ZIP-401]: https://zips.z.cash/zip-0401#specification
51pub(crate) const MAX_EVICTION_MEMORY_ENTRIES: usize = 40_000;
52
53/// Transactions rejected based on transaction authorizing data (scripts, proofs, signatures),
54/// or lock times. These rejections are only valid for the current tip.
55///
56/// Each committed block clears these rejections, because new blocks can supply missing inputs.
57#[derive(Error, Clone, Debug, PartialEq, Eq)]
58#[cfg_attr(any(test, feature = "proptest-impl"), derive(Arbitrary))]
59#[allow(dead_code)]
60pub enum ExactTipRejectionError {
61 /// Skip this variant in proptest because `TransactionError` is a large enum
62 /// that causes stack overflow during arbitrary value generation.
63 #[error("transaction did not pass consensus validation: {0}")]
64 #[cfg_attr(any(test, feature = "proptest-impl"), proptest(skip))]
65 FailedVerification(#[from] zebra_consensus::error::TransactionError),
66 #[error("transaction did not pass standard validation: {0}")]
67 FailedStandard(#[from] NonStandardTransactionError),
68}
69
70/// Transactions rejected based only on their effects (spends, outputs, transaction header).
71/// These rejections are only valid for the current tip.
72///
73/// Each committed block clears these rejections, because new blocks can evict other transactions.
74#[derive(Error, Clone, Debug, PartialEq, Eq)]
75#[cfg_attr(any(test, feature = "proptest-impl"), derive(Arbitrary))]
76#[allow(dead_code)]
77pub enum SameEffectsTipRejectionError {
78 #[error(
79 "transaction rejected because another transaction in the mempool has already spent some of \
80 its inputs"
81 )]
82 SpendConflict,
83
84 #[error(
85 "transaction rejected because it spends missing outputs from \
86 another transaction in the mempool"
87 )]
88 MissingOutput,
89}
90
91/// Transactions rejected based only on their effects (spends, outputs, transaction header).
92/// These rejections are valid while the current chain continues to grow.
93///
94/// Rollbacks and network upgrades clear these rejections, because they can lower the tip height,
95/// or change the consensus rules.
96#[derive(Error, Clone, Debug, PartialEq, Eq, Hash)]
97#[cfg_attr(any(test, feature = "proptest-impl"), derive(Arbitrary))]
98#[allow(dead_code)]
99pub enum SameEffectsChainRejectionError {
100 #[error("best chain tip has reached transaction expiry height")]
101 Expired,
102
103 #[error("transaction inputs were spent, or nullifiers were revealed, in the best chain")]
104 DuplicateSpend,
105
106 #[error("transaction was committed to the best chain")]
107 Mined,
108
109 /// Otherwise valid transaction removed from mempool due to [ZIP-401] random
110 /// eviction.
111 ///
112 /// Consensus rule:
113 /// > The txid (rather than the wtxid ...) is used even for version 5 transactions
114 ///
115 /// [ZIP-401]: https://zips.z.cash/zip-0401#specification
116 #[error("transaction evicted from the mempool due to ZIP-401 denial of service limits")]
117 RandomlyEvicted,
118}
119
120/// Storage error that combines all other specific error types.
121#[derive(Error, Clone, Debug, PartialEq, Eq)]
122#[cfg_attr(any(test, feature = "proptest-impl"), derive(Arbitrary))]
123#[allow(dead_code)]
124pub enum RejectionError {
125 #[error(transparent)]
126 ExactTip(#[from] ExactTipRejectionError),
127 #[error(transparent)]
128 SameEffectsTip(#[from] SameEffectsTipRejectionError),
129 #[error(transparent)]
130 SameEffectsChain(#[from] SameEffectsChainRejectionError),
131 #[error(transparent)]
132 NonStandardTransaction(#[from] NonStandardTransactionError),
133}
134
135/// Non-standard transaction error.
136#[derive(Error, Clone, Debug, PartialEq, Eq)]
137#[cfg_attr(any(test, feature = "proptest-impl"), derive(Arbitrary))]
138pub enum NonStandardTransactionError {
139 #[error("transaction is dust")]
140 IsDust,
141 #[error("transaction scriptSig is too large")]
142 ScriptSigTooLarge,
143 #[error("transaction scriptSig is not push-only")]
144 ScriptSigNotPushOnly,
145 #[error("transaction scriptPubKey is non-standard")]
146 ScriptPubKeyNonStandard,
147 #[error("transaction has a bare multisig output")]
148 BareMultiSig,
149 #[error("transaction has multiple OP_RETURN outputs")]
150 MultiOpReturn,
151 #[error("transaction has an OP_RETURN output that exceeds the size limit")]
152 DataCarrierTooLarge,
153 #[error("transaction has too many signature operations")]
154 TooManySigops,
155 #[error("transaction has non-standard inputs")]
156 NonStandardInputs,
157}
158
159/// Represents a set of transactions that have been removed from the mempool, either because
160/// they were mined, or because they were invalidated by another transaction that was mined.
161#[derive(Clone, Debug, PartialEq, Eq)]
162pub struct RemovedTransactionIds {
163 /// A list of ids for transactions that were removed mined onto the best chain.
164 pub mined: HashSet<UnminedTxId>,
165 /// A list of ids for transactions that were invalidated by other transactions
166 /// that were mined onto the best chain.
167 pub invalidated: HashSet<UnminedTxId>,
168}
169
170impl RemovedTransactionIds {
171 /// Returns the total number of transactions that were removed from the mempool.
172 pub fn total_len(&self) -> usize {
173 self.mined.len() + self.invalidated.len()
174 }
175}
176
177/// Hold mempool verified and rejected mempool transactions.
178pub struct Storage {
179 /// The set of verified transactions in the mempool.
180 verified: VerifiedSet,
181
182 /// The set of outpoints with pending requests for their associated transparent::Output.
183 pub(super) pending_outputs: PendingOutputs,
184
185 /// The set of transactions rejected due to bad authorizations, or for other
186 /// reasons, and their rejection reasons. These rejections only apply to the
187 /// current tip.
188 ///
189 /// Only transactions with the exact [`UnminedTxId`] are invalid.
190 tip_rejected_exact: HashMap<UnminedTxId, ExactTipRejectionError>,
191
192 /// A set of transactions rejected for their effects, and their rejection
193 /// reasons. These rejections only apply to the current tip.
194 ///
195 /// Any transaction with the same [`transaction::Hash`] is invalid.
196 tip_rejected_same_effects: HashMap<transaction::Hash, SameEffectsTipRejectionError>,
197
198 /// Sets of transactions rejected for their effects, keyed by rejection reason.
199 /// These rejections apply until a rollback or network upgrade.
200 ///
201 /// Any transaction with the same [`transaction::Hash`] is invalid.
202 ///
203 /// An [`EvictionList`] is used for both randomly evicted and expired
204 /// transactions, even if it is only needed for the evicted ones. This was
205 /// done just to simplify the existing code; there is no harm in having a
206 /// timeout for expired transactions too since re-checking expired
207 /// transactions is cheap.
208 // If this code is ever refactored and the lists are split in different
209 // fields, then we can use an `EvictionList` just for the evicted list.
210 chain_rejected_same_effects: HashMap<SameEffectsChainRejectionError, EvictionList>,
211
212 /// The mempool transaction eviction age limit.
213 /// Same as [`config::Config::eviction_memory_time`].
214 eviction_memory_time: Duration,
215
216 /// Max total cost of the verified mempool set, beyond which transactions
217 /// are evicted to make room.
218 tx_cost_limit: u64,
219
220 /// Maximum allowed size of OP_RETURN scripts, in bytes.
221 max_datacarrier_bytes: u32,
222}
223
224impl Drop for Storage {
225 fn drop(&mut self) {
226 self.clear();
227 }
228}
229
230impl Storage {
231 #[allow(clippy::field_reassign_with_default)]
232 pub(crate) fn new(config: &config::Config) -> Self {
233 Self {
234 tx_cost_limit: config.tx_cost_limit,
235 eviction_memory_time: config.eviction_memory_time,
236 max_datacarrier_bytes: config
237 .max_datacarrier_bytes
238 .unwrap_or(config::DEFAULT_MAX_DATACARRIER_BYTES),
239 verified: Default::default(),
240 pending_outputs: Default::default(),
241 tip_rejected_exact: Default::default(),
242 tip_rejected_same_effects: Default::default(),
243 chain_rejected_same_effects: Default::default(),
244 }
245 }
246
247 /// Check and reject non-standard transaction.
248 ///
249 /// Zcashd defines non-consensus standard transaction checks in
250 /// <https://github.com/zcash/zcash/blob/v6.11.0/src/policy/policy.cpp#L58-L135>
251 ///
252 /// This checks are applied before inserting a transaction in `AcceptToMemoryPool`:
253 /// <https://github.com/zcash/zcash/blob/v6.11.0/src/main.cpp#L1819>
254 ///
255 /// Currently, we implement: per-transaction sigops limit, standard input script checks,
256 /// input scriptSig size/push-only checks, standard output script checks (including OP_RETURN
257 /// limits), and dust checks.
258 fn reject_if_non_standard_tx(&mut self, tx: &VerifiedUnminedTx) -> Result<(), MempoolError> {
259 use zcash_script::script::{self, Evaluable as _};
260
261 let transaction = tx.transaction.transaction.as_ref();
262 let spent_outputs = &tx.spent_outputs;
263
264 for input in transaction.inputs() {
265 let unlock_script = match input {
266 transparent::Input::PrevOut { unlock_script, .. } => unlock_script,
267 transparent::Input::Coinbase { .. } => continue,
268 };
269
270 // Rule: scriptSig size must be within the standard limit.
271 if unlock_script.as_raw_bytes().len() > policy::MAX_STANDARD_SCRIPTSIG_SIZE {
272 return self
273 .reject_non_standard(tx, NonStandardTransactionError::ScriptSigTooLarge);
274 }
275
276 let code = script::Code(unlock_script.as_raw_bytes().to_vec());
277 // Rule: scriptSig must be push-only.
278 if !code.is_push_only() {
279 return self
280 .reject_non_standard(tx, NonStandardTransactionError::ScriptSigNotPushOnly);
281 }
282 }
283
284 if !spent_outputs.is_empty() {
285 // Validate that spent_outputs aligns with transparent inputs.
286 if transaction.inputs().len() != spent_outputs.len() {
287 tracing::warn!(
288 inputs = transaction.inputs().len(),
289 spent_outputs = spent_outputs.len(),
290 "spent_outputs length mismatch, rejecting as non-standard"
291 );
292 return self
293 .reject_non_standard(tx, NonStandardTransactionError::NonStandardInputs);
294 }
295
296 // Rule: all transparent inputs must pass `AreInputsStandard()` checks:
297 // https://github.com/zcash/zcash/blob/v6.11.0/src/policy/policy.cpp#L137
298 if !policy::are_inputs_standard(transaction, spent_outputs) {
299 return self
300 .reject_non_standard(tx, NonStandardTransactionError::NonStandardInputs);
301 }
302
303 // Rule: per-transaction sigops (legacy + P2SH) must not exceed the limit.
304 // zcashd sums GetLegacySigOpCount + GetP2SHSigOpCount for AcceptToMemoryPool:
305 // https://github.com/zcash/zcash/blob/v6.11.0/src/main.cpp#L1819
306 let total_sigops = tx.block_sigop_count();
307 if total_sigops > policy::MAX_STANDARD_TX_SIGOPS {
308 return self.reject_non_standard(tx, NonStandardTransactionError::TooManySigops);
309 }
310 } else {
311 // No spent outputs available (e.g. shielded-only transaction).
312 // Only check legacy sigops.
313 if tx.legacy_sigop_count > policy::MAX_STANDARD_TX_SIGOPS {
314 return self.reject_non_standard(tx, NonStandardTransactionError::TooManySigops);
315 }
316 }
317
318 // Rule: outputs must be standard script kinds, with special handling for OP_RETURN.
319 let mut data_out_count = 0u32;
320
321 for output in transaction.outputs() {
322 let lock_script = &output.lock_script;
323 let script_len = lock_script.as_raw_bytes().len();
324 let script_kind = policy::standard_script_kind(lock_script);
325
326 match script_kind {
327 None => {
328 // Rule: output script must be standard (P2PKH/P2SH/P2PK/multisig/OP_RETURN).
329 return self.reject_non_standard(
330 tx,
331 NonStandardTransactionError::ScriptPubKeyNonStandard,
332 );
333 }
334 Some(solver::ScriptKind::NullData { .. }) => {
335 // Rule: OP_RETURN script size is limited.
336 // Cast is safe: u32 always fits in usize on 32-bit and 64-bit platforms.
337 if script_len > self.max_datacarrier_bytes as usize {
338 return self.reject_non_standard(
339 tx,
340 NonStandardTransactionError::DataCarrierTooLarge,
341 );
342 }
343 // Rule: count OP_RETURN outputs to enforce the one-output limit.
344 data_out_count += 1;
345 }
346 Some(solver::ScriptKind::MultiSig { pubkeys, .. }) => {
347 // Rule: multisig must be at most 3-of-3 for standardness.
348 // Note: This check is technically subsumed by the unconditional BareMultiSig
349 // rejection below, but we keep it to distinguish the two error reasons
350 // (ScriptPubKeyNonStandard for >3 keys vs BareMultiSig for valid multisig).
351 if pubkeys.len() > policy::MAX_STANDARD_MULTISIG_PUBKEYS {
352 return self.reject_non_standard(
353 tx,
354 NonStandardTransactionError::ScriptPubKeyNonStandard,
355 );
356 }
357 // Rule: bare multisig outputs are non-standard (fIsBareMultisigStd = false).
358 return self.reject_non_standard(tx, NonStandardTransactionError::BareMultiSig);
359 }
360 Some(_) => {
361 // Rule: non-OP_RETURN outputs must not be dust.
362 if output.is_dust() {
363 return self.reject_non_standard(tx, NonStandardTransactionError::IsDust);
364 }
365 }
366 }
367 }
368
369 // Rule: only one OP_RETURN output is permitted.
370 if data_out_count > 1 {
371 return self.reject_non_standard(tx, NonStandardTransactionError::MultiOpReturn);
372 }
373
374 Ok(())
375 }
376
377 /// Rejects a transaction as non-standard, caches the rejection, and returns the mempool error.
378 ///
379 /// Note: The returned error is `MempoolError::NonStandardTransaction`, while the cached
380 /// rejection (via `reject()`) is stored as
381 /// `ExactTipRejectionError::FailedStandard`. Callers that later check
382 /// `rejection_error()` will get `MempoolError::StorageExactTip(FailedStandard(...))`.
383 fn reject_non_standard(
384 &mut self,
385 tx: &VerifiedUnminedTx,
386 rejection_error: NonStandardTransactionError,
387 ) -> Result<(), MempoolError> {
388 self.reject(tx.transaction.id, rejection_error.clone().into());
389 Err(MempoolError::NonStandardTransaction(rejection_error))
390 }
391
392 /// Insert a [`VerifiedUnminedTx`] into the mempool, caching any rejections.
393 ///
394 /// Accepts the [`VerifiedUnminedTx`] being inserted and `spent_mempool_outpoints`,
395 /// a list of transparent inputs of the provided [`VerifiedUnminedTx`] that were found
396 /// as newly created transparent outputs in the mempool during transaction verification.
397 ///
398 /// Returns an error if the mempool's verified transactions or rejection caches
399 /// prevent this transaction from being inserted.
400 /// These errors should not be propagated to peers, because the transactions are valid.
401 ///
402 /// If inserting this transaction evicts other transactions, they will be tracked
403 /// as [`SameEffectsChainRejectionError::RandomlyEvicted`].
404 #[allow(clippy::unwrap_in_result)]
405 pub fn insert(
406 &mut self,
407 tx: VerifiedUnminedTx,
408 spent_mempool_outpoints: Vec<transparent::OutPoint>,
409 height: Option<Height>,
410 ) -> Result<UnminedTxId, MempoolError> {
411 // # Security
412 //
413 // This method must call `reject`, rather than modifying the rejection lists directly.
414 let unmined_tx_id = tx.transaction.id;
415 let tx_id = unmined_tx_id.mined_id();
416
417 // First, check if we have a cached rejection for this transaction.
418 if let Some(error) = self.rejection_error(&unmined_tx_id) {
419 tracing::trace!(
420 ?tx_id,
421 ?error,
422 stored_transaction_count = ?self.verified.transaction_count(),
423 "returning cached error for transaction",
424 );
425
426 return Err(error);
427 }
428
429 // If `tx` is already in the mempool, we don't change anything.
430 //
431 // Security: transactions must not get refreshed by new queries,
432 // because that allows malicious peers to keep transactions live forever.
433 if self.verified.contains(&tx_id) {
434 tracing::trace!(
435 ?tx_id,
436 stored_transaction_count = ?self.verified.transaction_count(),
437 "returning InMempool error for transaction that is already in the mempool",
438 );
439
440 return Err(MempoolError::InMempool);
441 }
442
443 // Check that the transaction is standard.
444 self.reject_if_non_standard_tx(&tx)?;
445
446 // Then, we try to insert into the pool. If this fails the transaction is rejected.
447 let mut result = Ok(unmined_tx_id);
448 if let Err(rejection_error) = self.verified.insert(
449 tx,
450 spent_mempool_outpoints,
451 &mut self.pending_outputs,
452 height,
453 ) {
454 tracing::debug!(
455 ?tx_id,
456 ?rejection_error,
457 stored_transaction_count = ?self.verified.transaction_count(),
458 "insertion error for transaction",
459 );
460
461 // We could return here, but we still want to check the mempool size
462 self.reject(unmined_tx_id, rejection_error.clone().into());
463 result = Err(rejection_error.into());
464 }
465
466 // Once inserted, we evict transactions over the pool size limit per [ZIP-401];
467 //
468 // > On receiving a transaction: (...)
469 // > Calculate its cost. If the total cost of transactions in the mempool including this
470 // > one would `exceed mempooltxcostlimit`, then the node MUST repeatedly call
471 // > EvictTransaction (with the new transaction included as a candidate to evict) until the
472 // > total cost does not exceed `mempooltxcostlimit`.
473 //
474 // 'EvictTransaction' is equivalent to [`VerifiedSet::evict_one()`] in
475 // our implementation.
476 //
477 // [ZIP-401]: https://zips.z.cash/zip-0401
478 while self.verified.total_cost() > self.tx_cost_limit {
479 // > EvictTransaction MUST do the following:
480 // > Select a random transaction to evict, with probability in direct proportion to
481 // > eviction weight. (...) Remove it from the mempool.
482 let victim_tx = self
483 .verified
484 .evict_one()
485 .expect("mempool is empty, but was expected to be full");
486
487 // > Add the txid and the current time to RecentlyEvicted, dropping the oldest entry in
488 // > RecentlyEvicted if necessary to keep it to at most `eviction_memory_entries entries`.
489 self.reject(
490 victim_tx.transaction.id,
491 SameEffectsChainRejectionError::RandomlyEvicted.into(),
492 );
493
494 // If this transaction gets evicted, set its result to the same error
495 if victim_tx.transaction.id == unmined_tx_id {
496 result = Err(SameEffectsChainRejectionError::RandomlyEvicted.into());
497 }
498 }
499
500 result
501 }
502
503 /// Remove transactions from the mempool via exact [`UnminedTxId`].
504 ///
505 /// For v5 transactions, transactions are matched by WTXID, using both the:
506 /// - non-malleable transaction ID, and
507 /// - authorizing data hash.
508 ///
509 /// This matches the exact transaction, with identical blockchain effects, signatures, and proofs.
510 ///
511 /// Returns the number of transactions which were removed.
512 ///
513 /// Removes from the 'verified' set, if present.
514 /// Maintains the order in which the other unmined transactions have been inserted into the mempool.
515 ///
516 /// Does not add or remove from the 'rejected' tracking set.
517 #[allow(dead_code)]
518 pub fn remove_exact(&mut self, exact_wtxids: &HashSet<UnminedTxId>) -> usize {
519 self.verified
520 .remove_all_that(|tx| exact_wtxids.contains(&tx.transaction.id))
521 .len()
522 }
523
524 /// Clears a list of mined transaction ids from the verified set's tracked transaction dependencies.
525 pub fn clear_mined_dependencies(&mut self, mined_ids: &HashSet<transaction::Hash>) {
526 self.verified.clear_mined_dependencies(mined_ids);
527 }
528
529 /// Reject and remove transactions from the mempool via non-malleable [`transaction::Hash`].
530 /// - For v5 transactions, transactions are matched by TXID,
531 /// using only the non-malleable transaction ID.
532 /// This matches any transaction with the same effect on the blockchain state,
533 /// even if its signatures and proofs are different.
534 /// - Returns the number of transactions which were removed.
535 /// - Removes from the 'verified' set, if present.
536 /// Maintains the order in which the other unmined transactions have been inserted into the mempool.
537 /// - Prunes `pending_outputs` of any closed channels.
538 ///
539 /// Reject and remove transactions from the mempool that contain any spent outpoints or revealed
540 /// nullifiers from the passed in `transactions`.
541 ///
542 /// Returns the number of transactions that were removed.
543 pub fn reject_and_remove_same_effects(
544 &mut self,
545 mined_ids: &HashSet<transaction::Hash>,
546 transactions: Vec<Arc<Transaction>>,
547 ) -> RemovedTransactionIds {
548 let removed_mined = self
549 .verified
550 .remove_all_that(|tx| mined_ids.contains(&tx.transaction.id.mined_id()));
551
552 let spent_outpoints: HashSet<_> = transactions
553 .iter()
554 .flat_map(|tx| tx.spent_outpoints())
555 .collect();
556 let sprout_nullifiers: HashSet<_> = transactions
557 .iter()
558 .flat_map(|transaction| transaction.sprout_nullifiers())
559 .collect();
560 let sapling_nullifiers: HashSet<_> = transactions
561 .iter()
562 .flat_map(|transaction| transaction.sapling_nullifiers())
563 .collect();
564 let orchard_nullifiers: HashSet<_> = transactions
565 .iter()
566 .flat_map(|transaction| transaction.orchard_nullifiers())
567 .collect();
568 let ironwood_nullifiers: HashSet<_> = transactions
569 .iter()
570 .flat_map(|transaction| transaction.ironwood_nullifiers())
571 .collect();
572
573 let duplicate_spend_ids: HashSet<_> = self
574 .verified
575 .transactions()
576 .values()
577 .map(|tx| (tx.transaction.id, &tx.transaction.transaction))
578 .filter_map(|(tx_id, tx)| {
579 (tx.spent_outpoints()
580 .any(|outpoint| spent_outpoints.contains(&outpoint))
581 || tx
582 .sprout_nullifiers()
583 .any(|nullifier| sprout_nullifiers.contains(nullifier))
584 || tx
585 .sapling_nullifiers()
586 .any(|nullifier| sapling_nullifiers.contains(nullifier))
587 || tx
588 .orchard_nullifiers()
589 .any(|nullifier| orchard_nullifiers.contains(nullifier))
590 // `ironwood_nullifiers()` yields owned `ironwood::Nullifier`s.
591 || tx
592 .ironwood_nullifiers()
593 .any(|nullifier| ironwood_nullifiers.contains(&nullifier)))
594 .then_some(tx_id)
595 })
596 .collect();
597
598 let removed_duplicate_spend = self
599 .verified
600 .remove_all_that(|tx| duplicate_spend_ids.contains(&tx.transaction.id));
601
602 for &mined_id in mined_ids {
603 self.reject(
604 // the reject and rejection_error fns that store and check `SameEffectsChainRejectionError`s
605 // only use the mined id, so using `Legacy` ids will apply to v5 transactions as well.
606 UnminedTxId::Legacy(mined_id),
607 SameEffectsChainRejectionError::Mined.into(),
608 );
609 }
610
611 for duplicate_spend_id in duplicate_spend_ids {
612 self.reject(
613 duplicate_spend_id,
614 SameEffectsChainRejectionError::DuplicateSpend.into(),
615 );
616 }
617
618 self.pending_outputs.prune();
619
620 RemovedTransactionIds {
621 mined: removed_mined,
622 invalidated: removed_duplicate_spend,
623 }
624 }
625
626 /// Clears the whole mempool storage.
627 #[allow(dead_code)]
628 pub fn clear(&mut self) {
629 self.verified.clear();
630 self.tip_rejected_exact.clear();
631 self.pending_outputs.clear();
632 self.tip_rejected_same_effects.clear();
633 self.chain_rejected_same_effects.clear();
634 self.update_rejected_metrics();
635 }
636
637 /// Clears rejections that only apply to the current tip.
638 pub fn clear_tip_rejections(&mut self) {
639 self.tip_rejected_exact.clear();
640 self.tip_rejected_same_effects.clear();
641 self.update_rejected_metrics();
642 }
643
644 /// Clears rejections that only apply to the current tip.
645 ///
646 /// # Security
647 ///
648 /// This method must be called at the end of every method that adds rejections.
649 /// Otherwise, peers could make our reject lists use a lot of RAM.
650 fn limit_rejection_list_memory(&mut self) {
651 // These lists are an optimisation - it's ok to totally clear them as needed.
652 if self.tip_rejected_exact.len() > MAX_EVICTION_MEMORY_ENTRIES {
653 self.tip_rejected_exact.clear();
654 }
655 if self.tip_rejected_same_effects.len() > MAX_EVICTION_MEMORY_ENTRIES {
656 self.tip_rejected_same_effects.clear();
657 }
658 // `chain_rejected_same_effects` limits its size by itself
659 self.update_rejected_metrics();
660 }
661
662 /// Returns the set of [`UnminedTxId`]s in the mempool.
663 pub fn tx_ids(&self) -> impl Iterator<Item = UnminedTxId> + '_ {
664 self.transactions().values().map(|tx| tx.transaction.id)
665 }
666
667 /// Returns a reference to the [`HashMap`] of [`VerifiedUnminedTx`]s in the verified set.
668 ///
669 /// Each [`VerifiedUnminedTx`] contains an [`UnminedTx`],
670 /// and adds extra fields from the transaction verifier result.
671 pub fn transactions(&self) -> &HashMap<transaction::Hash, VerifiedUnminedTx> {
672 self.verified.transactions()
673 }
674
675 /// Returns a reference to the [`TransactionDependencies`] in the verified set.
676 pub fn transaction_dependencies(&self) -> &TransactionDependencies {
677 self.verified.transaction_dependencies()
678 }
679
680 /// Returns a [`transparent::Output`] created by a mempool transaction for the provided
681 /// [`transparent::OutPoint`] if one exists, or None otherwise.
682 pub fn created_output(&self, outpoint: &transparent::OutPoint) -> Option<transparent::Output> {
683 self.verified.created_output(outpoint)
684 }
685
686 /// Returns true if a tx in the set has spent the output at the provided outpoint.
687 pub fn has_spent_outpoint(&self, outpoint: &transparent::OutPoint) -> bool {
688 self.verified.has_spent_outpoint(outpoint)
689 }
690
691 /// Returns the number of transactions in the mempool.
692 #[allow(dead_code)]
693 pub fn transaction_count(&self) -> usize {
694 self.verified.transaction_count()
695 }
696
697 /// Returns the cost of the transactions in the mempool, according to ZIP-401.
698 #[allow(dead_code)]
699 pub fn total_cost(&self) -> u64 {
700 self.verified.total_cost()
701 }
702
703 /// Returns the total serialized size of the verified transactions in the set.
704 ///
705 /// See [`VerifiedSet::total_serialized_size()`] for details.
706 pub fn total_serialized_size(&self) -> usize {
707 self.verified.total_serialized_size()
708 }
709
710 /// Returns the set of [`UnminedTx`]es with exactly matching `tx_ids` in the
711 /// mempool.
712 ///
713 /// This matches the exact transaction, with identical blockchain effects,
714 /// signatures, and proofs.
715 pub fn transactions_exact(
716 &self,
717 tx_ids: HashSet<UnminedTxId>,
718 ) -> impl Iterator<Item = &UnminedTx> {
719 tx_ids.into_iter().filter_map(|tx_id| {
720 self.transactions()
721 .get(&tx_id.mined_id())
722 .map(|tx| &tx.transaction)
723 })
724 }
725
726 /// Returns the set of [`UnminedTx`]es with matching [`transaction::Hash`]es
727 /// in the mempool.
728 ///
729 /// This matches transactions with the same effects, regardless of
730 /// [`transaction::AuthDigest`].
731 pub fn transactions_same_effects(
732 &self,
733 tx_ids: HashSet<Hash>,
734 ) -> impl Iterator<Item = &UnminedTx> {
735 self.verified
736 .transactions()
737 .iter()
738 .filter(move |(tx_id, _)| tx_ids.contains(tx_id))
739 .map(|(_, tx)| &tx.transaction)
740 }
741
742 /// Returns a transaction and the transaction ids of its dependencies, if it is in the verified set.
743 pub fn transaction_with_deps(
744 &self,
745 tx_id: transaction::Hash,
746 ) -> Option<(VerifiedUnminedTx, HashSet<transaction::Hash>)> {
747 let tx = self.verified.transactions().get(&tx_id).cloned()?;
748 let deps = self
749 .verified
750 .transaction_dependencies()
751 .dependencies()
752 .get(&tx_id)
753 .cloned()
754 .unwrap_or_default();
755
756 Some((tx, deps))
757 }
758
759 /// Returns `true` if a transaction exactly matching an [`UnminedTxId`] is in
760 /// the mempool.
761 ///
762 /// This matches the exact transaction, with identical blockchain effects,
763 /// signatures, and proofs.
764 pub fn contains_transaction_exact(&self, tx_id: &transaction::Hash) -> bool {
765 self.verified.contains(tx_id)
766 }
767
768 /// Returns the number of rejected [`UnminedTxId`]s or [`transaction::Hash`]es.
769 ///
770 /// Transactions on multiple rejected lists are counted multiple times.
771 #[allow(dead_code)]
772 pub fn rejected_transaction_count(&mut self) -> usize {
773 self.tip_rejected_exact.len()
774 + self.tip_rejected_same_effects.len()
775 + self
776 .chain_rejected_same_effects
777 .iter_mut()
778 .map(|(_, map)| map.len())
779 .sum::<usize>()
780 }
781
782 /// Add a transaction to the rejected list for the given reason.
783 pub fn reject(&mut self, tx_id: UnminedTxId, reason: RejectionError) {
784 match reason {
785 RejectionError::ExactTip(e) => {
786 self.tip_rejected_exact.insert(tx_id, e);
787 }
788 RejectionError::SameEffectsTip(e) => {
789 self.tip_rejected_same_effects.insert(tx_id.mined_id(), e);
790 }
791 RejectionError::SameEffectsChain(e) => {
792 let eviction_memory_time = self.eviction_memory_time;
793 self.chain_rejected_same_effects
794 .entry(e)
795 .or_insert_with(|| {
796 EvictionList::new(MAX_EVICTION_MEMORY_ENTRIES, eviction_memory_time)
797 })
798 .insert(tx_id.mined_id());
799 }
800 RejectionError::NonStandardTransaction(e) => {
801 // Non-standard transactions are rejected based on their exact
802 // transaction data.
803 self.tip_rejected_exact
804 .insert(tx_id, ExactTipRejectionError::from(e));
805 }
806 }
807 self.limit_rejection_list_memory();
808 }
809
810 /// Returns the rejection error if a transaction matching an [`UnminedTxId`]
811 /// is in any mempool rejected list.
812 ///
813 /// This matches transactions based on each rejection list's matching rule.
814 ///
815 /// Returns an arbitrary error if the transaction is in multiple lists.
816 pub fn rejection_error(&self, txid: &UnminedTxId) -> Option<MempoolError> {
817 if let Some(error) = self.tip_rejected_exact.get(txid) {
818 return Some(error.clone().into());
819 }
820
821 if let Some(error) = self.tip_rejected_same_effects.get(&txid.mined_id()) {
822 return Some(error.clone().into());
823 }
824
825 for (error, set) in self.chain_rejected_same_effects.iter() {
826 if set.contains_key(&txid.mined_id()) {
827 return Some(error.clone().into());
828 }
829 }
830
831 None
832 }
833
834 /// Returns the set of [`UnminedTxId`]s matching `tx_ids` in the rejected list.
835 ///
836 /// This matches transactions based on each rejection list's matching rule.
837 pub fn rejected_transactions(
838 &self,
839 tx_ids: HashSet<UnminedTxId>,
840 ) -> impl Iterator<Item = UnminedTxId> + '_ {
841 tx_ids
842 .into_iter()
843 .filter(move |txid| self.contains_rejected(txid))
844 }
845
846 /// Returns `true` if a transaction matching the supplied [`UnminedTxId`] is in
847 /// the mempool rejected list.
848 ///
849 /// This matches transactions based on each rejection list's matching rule.
850 pub fn contains_rejected(&self, txid: &UnminedTxId) -> bool {
851 self.rejection_error(txid).is_some()
852 }
853
854 /// Add a transaction that failed download and verification to the rejected list
855 /// if needed, depending on the reason for the failure.
856 pub fn reject_if_needed(&mut self, tx_id: UnminedTxId, e: TransactionDownloadVerifyError) {
857 match e {
858 // Rejecting a transaction already in state would speed up further
859 // download attempts without checking the state. However it would
860 // make the reject list grow forever.
861 //
862 // TODO: revisit after reviewing the rejected list cleanup criteria?
863 // TODO: if we decide to reject it, then we need to pass the block hash
864 // to State::Confirmed. This would require the zs::Response::Transaction
865 // to include the hash, which would need to be implemented.
866 TransactionDownloadVerifyError::InState |
867 // An unknown error in the state service, better do nothing
868 TransactionDownloadVerifyError::StateError(_) |
869 // If download failed, do nothing; the crawler will end up trying to
870 // download it again.
871 TransactionDownloadVerifyError::DownloadFailed(_) |
872 // If it was cancelled then a block was mined, or there was a network
873 // upgrade, etc. No reason to reject it.
874 TransactionDownloadVerifyError::Cancelled => {}
875
876 // Consensus verification failed. Reject transaction to avoid
877 // having to download and verify it again just for it to fail again.
878 TransactionDownloadVerifyError::Invalid { error, .. } => {
879 self.reject(tx_id, ExactTipRejectionError::FailedVerification(error).into())
880 }
881 }
882 }
883
884 /// Remove transactions from the mempool if they have not been mined after a
885 /// specified height, per [ZIP-203].
886 ///
887 /// > Transactions will have a new field, nExpiryHeight, which will set the
888 /// > block height after which transactions will be removed from the mempool
889 /// > if they have not been mined.
890 ///
891 ///
892 /// [ZIP-203]: https://zips.z.cash/zip-0203#specification
893 pub fn remove_expired_transactions(
894 &mut self,
895 tip_height: zebra_chain::block::Height,
896 ) -> HashSet<UnminedTxId> {
897 let mut tx_ids = HashSet::new();
898 let mut unmined_tx_ids = HashSet::new();
899
900 for (&tx_id, tx) in self.transactions() {
901 if let Some(expiry_height) = tx.transaction.transaction.expiry_height() {
902 if tip_height >= expiry_height {
903 tx_ids.insert(tx_id);
904 unmined_tx_ids.insert(tx.transaction.id);
905 }
906 }
907 }
908
909 // expiry height is effecting data, so we match by non-malleable TXID
910 self.verified
911 .remove_all_that(|tx| tx_ids.contains(&tx.transaction.id.mined_id()));
912
913 // also reject it
914 for id in tx_ids {
915 self.reject(
916 // It's okay to omit the auth digest here as we know that `reject()` will always
917 // use mined ids for `SameEffectsChainRejectionError`s.
918 UnminedTxId::Legacy(id),
919 SameEffectsChainRejectionError::Expired.into(),
920 );
921 }
922
923 unmined_tx_ids
924 }
925
926 /// Check if transaction should be downloaded and/or verified.
927 ///
928 /// If it is already in the mempool (or in its rejected list)
929 /// then it shouldn't be downloaded/verified.
930 pub fn should_download_or_verify(&mut self, txid: UnminedTxId) -> Result<(), MempoolError> {
931 // Check if the transaction is already in the mempool.
932 if self.contains_transaction_exact(&txid.mined_id()) {
933 return Err(MempoolError::InMempool);
934 }
935 if let Some(error) = self.rejection_error(&txid) {
936 return Err(error);
937 }
938 Ok(())
939 }
940
941 /// Update metrics related to the rejected lists.
942 ///
943 /// Must be called every time the rejected lists change.
944 fn update_rejected_metrics(&mut self) {
945 metrics::gauge!("mempool.rejected.transaction.ids",)
946 .set(self.rejected_transaction_count() as f64);
947 // This is just an approximation.
948 // TODO: make it more accurate #2869
949 let item_size = size_of::<(transaction::Hash, SameEffectsTipRejectionError)>();
950 metrics::gauge!("mempool.rejected.transaction.ids.bytes",)
951 .set((self.rejected_transaction_count() * item_size) as f64);
952 }
953}