From 96abcd794818bbee464af0b39a4d28b45b14eb12 Mon Sep 17 00:00:00 2001 From: Mykhailo Kremniov Date: Mon, 15 Jun 2026 13:02:11 +0300 Subject: [PATCH 1/6] Mempool non-utxo deps handling - WIP --- Cargo.lock | 5 + blockprod/Cargo.toml | 3 + .../src/detail/tests/produce_block/mod.rs | 1 + .../produce_block/tx_selection_by_deps.rs | 682 ++++++++++++++++++ .../src/detail/timestamp_searcher/tests.rs | 6 +- blockprod/src/tests/helpers.rs | 25 +- blockprod/src/tests/mod.rs | 5 + .../src/chain/transaction/account_outpoint.rs | 2 +- .../chain/upgrades/chainstate_upgrade/mod.rs | 2 +- mempool/Cargo.toml | 4 +- mempool/src/interface/mempool_interface.rs | 14 +- .../src/interface/mempool_interface_impl.rs | 6 +- mempool/src/lib.rs | 3 +- mempool/src/pool/dependency.rs | 263 +++++++ mempool/src/pool/entry.rs | 121 +--- mempool/src/pool/mod.rs | 17 +- mempool/src/pool/orphans/mod.rs | 56 +- mempool/src/pool/orphans/test.rs | 6 +- mempool/src/pool/tests/utils.rs | 82 ++- mempool/src/pool/tx_pool/mod.rs | 14 +- mempool/src/pool/tx_pool/store/mem_usage.rs | 3 +- mempool/src/pool/tx_pool/store/mod.rs | 72 +- mempool/src/pool/tx_pool/tx_verifier/mod.rs | 3 + mocks/src/mempool.rs | 5 +- test-utils/src/token_utils.rs | 6 +- 25 files changed, 1219 insertions(+), 187 deletions(-) create mode 100644 blockprod/src/detail/tests/produce_block/tx_selection_by_deps.rs create mode 100644 mempool/src/pool/dependency.rs diff --git a/Cargo.lock b/Cargo.lock index 27846f3cf7..101be123c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1004,6 +1004,7 @@ dependencies = [ "common", "consensus", "crypto", + "ctor", "futures", "hex", "itertools 0.14.0", @@ -1019,11 +1020,13 @@ dependencies = [ "rayon", "rpc", "rstest", + "rstest_reuse", "serde", "serialization", "slave-pool", "static_assertions", "storage-inmemory", + "strum 0.26.3", "subsystem", "test-utils", "thiserror 1.0.69", @@ -5051,7 +5054,9 @@ dependencies = [ "rstest", "serde", "serialization", + "smallvec", "static_assertions", + "strum 0.26.3", "subsystem", "test-utils", "thiserror 1.0.69", diff --git a/blockprod/Cargo.toml b/blockprod/Cargo.toml index 449ec09b1d..fe1477d04e 100644 --- a/blockprod/Cargo.toml +++ b/blockprod/Cargo.toml @@ -42,5 +42,8 @@ pos-accounting = { path = "../pos-accounting" } storage-inmemory = { path = "../storage/inmemory" } test-utils = { path = "../test-utils" } +ctor.workspace = true rstest.workspace = true +rstest_reuse.workspace = true static_assertions.workspace = true +strum.workspace = true diff --git a/blockprod/src/detail/tests/produce_block/mod.rs b/blockprod/src/detail/tests/produce_block/mod.rs index ec855b278a..3819136e1c 100644 --- a/blockprod/src/detail/tests/produce_block/mod.rs +++ b/blockprod/src/detail/tests/produce_block/mod.rs @@ -13,6 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +mod tx_selection_by_deps; mod tx_selection_mtp; use std::{sync::Arc, time::Duration}; diff --git a/blockprod/src/detail/tests/produce_block/tx_selection_by_deps.rs b/blockprod/src/detail/tests/produce_block/tx_selection_by_deps.rs new file mode 100644 index 0000000000..5259dbdfd7 --- /dev/null +++ b/blockprod/src/detail/tests/produce_block/tx_selection_by_deps.rs @@ -0,0 +1,682 @@ +// 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 std::{collections::BTreeSet, sync::Arc}; + +use itertools::Itertools as _; +use randomness::CryptoRng; +use rstest::rstest; +use strum::IntoEnumIterator as _; + +use chainstate_test_framework::TransactionBuilder; +use common::{ + chain::{ + AccountCommand, AccountCommandTag, AccountNonce, ChainConfig, Destination, + OutPointSourceId, SignedTransaction, TxOutput, UtxoOutPoint, make_token_id, + output_value::OutputValue, + signature::inputsig::InputWitness, + tokens::{IsTokenUnfreezable, TokenId, TokenIssuance, TokenIssuanceV1, TokenTotalSupply}, + transaction::TxInput, + }, + primitives::{Amount, BlockHeight, Idable}, + time_getter::TimeGetter, +}; +use logging::log; +use mempool::{FeeRate, MempoolConfig, tx_accumulator::PackingStrategy}; +use test_utils::{ + BasicTestTimeGetter, + random::{RngExt as _, Seed, make_seedable_rng}, + random_ascii_alphanumeric_string, +}; +use utils::{once_destructor::OnceDestructor, shuffled::Shuffled as _, sorted::Sorted as _}; + +use crate::{ + detail::tests::produce_block::assert_job_count, + tests::helpers::{ + BlockprodTestSetup, BlockprodTestSetupBuilder, PoSTestSetupBuilder, + add_local_txs_to_mempool, make_genesis_timestamp, + }, +}; + +#[derive(Debug)] +enum FeesSelection { + Increasing, + // Note: decreasing fees are likely to ensure the correct tx ordering on their own even if + // the mempool handles dependencies incorrectly. I.e. this case is not very useful, but we + // keep it for completeness. + Decreasing, + Random, +} + +#[rstest_reuse::template] +pub fn fees_selection_param( + #[values( + FeesSelection::Increasing, + FeesSelection::Decreasing, + FeesSelection::Random + )] + fees_selection: FeesSelection, +) { +} + +#[rstest_reuse::apply(fees_selection_param)] +#[rstest] +// The test is heavily randomized, so we run it a few times to increase the likelihood of catching a problem. +#[case(Seed::from_entropy())] +#[case(Seed::from_entropy())] +#[case(Seed::from_entropy())] +#[trace] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn token_account_deps(#[case] seed: Seed, fees_selection: FeesSelection) { + use token_account_deps_test_details::*; + + let mut rng = make_seedable_rng(seed); + let time_getter = BasicTestTimeGetter::new().get_time_getter(); + + let tx_count = rng.random_range(10..=20); + log::debug!("tx_count = {tx_count}"); + + let extra_genesis_txos = (0..tx_count) + .map(|_| { + TxOutput::Transfer( + OutputValue::Coin(GENESIS_EXTRA_TXO_AMOUNT), + Destination::AnyoneCanSpend, + ) + }) + .collect(); + + let pos_setup = PoSTestSetupBuilder::new() + .with_extra_genesis_txos(extra_genesis_txos) + .build(make_genesis_timestamp(&time_getter, &mut rng), &mut rng); + + let chain_config = Arc::clone(&pos_setup.chain_config); + let (blockprod_setup, manager) = BlockprodTestSetupBuilder::new() + .with_chain_config(Arc::clone(&chain_config)) + .with_mempool_config(MempoolConfig { + min_tx_relay_fee_rate: FeeRate::from_amount_per_kb(Amount::ZERO).into(), + max_cluster_tx_count: Default::default(), + max_cluster_size_bytes: Default::default(), + }) + .with_time_getter(time_getter.clone()) + .build(); + + let base_fee = rng.random_range(10..20); + let fees = (0..tx_count).map(|i| Amount::from_atoms(base_fee * 4u128.pow(i))).collect_vec(); + let fees = reorder_fees(fees, fees_selection, &mut rng); + + log::debug!("fees = {fees:?}"); + + let mut token_state = + TokenState::new(Arc::clone(&blockprod_setup.chain_config), fees[0], &mut rng); + + let txs = std::iter::once(token_state.issuance_tx().clone()) + .chain(fees[1..].iter().map(|fee| token_state.make_next_tx(*fee, &mut rng))) + .collect_vec(); + + log::debug!("txs = {txs:?}"); + + let expected_tx_ids = txs.iter().map(|tx| tx.transaction().get_id()).collect_vec(); + + let join_handle = tokio::spawn({ + let shutdown_trigger = manager.make_shutdown_trigger(); + async move { + // Ensure a shutdown signal will be sent by the end of the scope + let _shutdown_signal = OnceDestructor::new(move || { + shutdown_trigger.initiate(); + }); + + assert_fees(&blockprod_setup, &time_getter, &txs, &fees).await; + + let block_production = blockprod_setup.make_blockprod_builder().build(); + + add_local_txs_to_mempool(&blockprod_setup.mempool, txs).await; + + // FIXME check score - get_tx_score + + let input_data = pos_setup.make_first_pos_block_input_data(); + + let (new_block, job_finished_receiver) = block_production + .produce_block( + input_data, + vec![], + vec![], + PackingStrategy::FillSpaceFromMempool, + ) + .await + .unwrap(); + + let block_tx_ids = new_block + .transactions() + .iter() + .map(|tx| tx.transaction().get_id()) + .collect::>(); + + job_finished_receiver.await.unwrap(); + + assert_job_count(&block_production, 0).await; + blockprod_setup.assert_process_block(new_block).await; + assert_eq!(block_tx_ids, expected_tx_ids); + } + }); + + manager.main().await; + join_handle.await.unwrap(); +} + +mod token_account_deps_test_details { + use std::borrow::Cow; + + use common::chain::{ + CoinUnit, SignedTransaction, Transaction, UtxoOutPoint, + signature::{ + inputsig::standard_signature::StandardInputSignature, + sighash::input_commitments::SighashInputCommitment, + }, + }; + use crypto::key::{KeyKind, PrivateKey}; + use randomness::{CryptoRng, seq::IteratorRandom as _}; + use test_utils::token_utils::random_token_issuance_v1_with_min_supply; + + use super::*; + + pub const GENESIS_EXTRA_TXO_AMOUNT: Amount = + Amount::from_atoms(100_000_000 * CoinUnit::ATOMS_PER_COIN); + // The 0th genesis txo is consumed when producing the 1st block, so "extra" ones start + // from index 1. + const GENESIS_EXTRA_TXO_START_IDX: u32 = 1; + + pub struct TokenState { + chain_config: Arc, + issuance: TokenIssuanceV1, + issuance_tx: SignedTransaction, + token_id: TokenId, + authority_sk: PrivateKey, + authority: Destination, + is_frozen: bool, + is_locked: bool, + token_utxo_and_amount: Option<(UtxoOutPoint, TxOutput, Amount)>, + // Index of the next tx in the test sequence, not counting the issuance tx. + next_extra_tx_idx: u32, + } + + impl TokenState { + pub fn new( + chain_config: Arc, + issuance_tx_fee: Amount, + rng: &mut impl CryptoRng, + ) -> Self { + let (authority_sk, authority_pk) = + PrivateKey::new_from_rng(rng, KeyKind::Secp256k1Schnorr); + let authority = Destination::PublicKey(authority_pk.clone()); + + let issuance = random_token_issuance_v1_with_min_supply( + &chain_config, + authority.clone(), + 1_000_000, + rng, + ); + + let issuance_tx = make_issuance_tx( + issuance.clone(), + &chain_config, + issuance_tx_fee, + GENESIS_EXTRA_TXO_START_IDX, + ); + // Note: the height in make_token_id doesn't matter because the issuance tx's first input is a utxo. + let token_id = make_token_id( + chain_config.as_ref(), + BlockHeight::new(1), + issuance_tx.transaction().inputs(), + ) + .unwrap(); + + Self { + chain_config, + issuance, + issuance_tx, + token_id, + authority_sk, + authority, + is_frozen: false, + is_locked: false, + token_utxo_and_amount: None, + next_extra_tx_idx: 0, + } + } + + pub fn issuance_tx(&self) -> &SignedTransaction { + &self.issuance_tx + } + + pub fn make_next_tx(&mut self, fee: Amount, rng: &mut impl CryptoRng) -> SignedTransaction { + let genesis_txo_idx = GENESIS_EXTRA_TXO_START_IDX + self.next_extra_tx_idx + 1; + let nonce = AccountNonce::new(self.next_extra_tx_idx as u64); + + let genesis_outpoint = + UtxoOutPoint::new(self.chain_config.genesis_block_id().into(), genesis_txo_idx); + let genesis_txo = + self.chain_config.genesis_block().utxos()[genesis_txo_idx as usize].clone(); + let mut tx_inputs_and_commitments: Vec<(TxInput, _)> = vec![( + genesis_outpoint.into(), + SighashInputCommitment::Utxo(Cow::Owned(genesis_txo)), + )]; + let mut tx_outputs: Vec = vec![]; + + let cmd_tag = *self.possible_commands().iter().choose(rng).unwrap(); + + let mut new_token_output_idx_and_amount: Option<(u32, Amount)> = None; + let mut new_authority_sk_and_destination: Option<(PrivateKey, Destination)> = None; + let extra_fee; + + let command = match cmd_tag { + AccountCommandTag::MintTokens => { + let amount = Amount::from_atoms(rng.random_range(100..=1000)); + + new_token_output_idx_and_amount = Some((tx_outputs.len() as u32, amount)); + + tx_outputs.push(TxOutput::Transfer( + OutputValue::TokenV1(self.token_id, amount), + Destination::AnyoneCanSpend, + )); + + extra_fee = self.chain_config.token_supply_change_fee(BlockHeight::new(1)); + + AccountCommand::MintTokens(self.token_id, amount) + } + AccountCommandTag::UnmintTokens => { + let (token_outpoint, token_utxo, token_amount) = + self.token_utxo_and_amount.as_ref().unwrap(); + + assert!(*token_amount > Amount::ZERO); + let amount_to_unmint = + Amount::from_atoms(rng.random_range(1..=token_amount.into_atoms())); + let change = (*token_amount - amount_to_unmint).unwrap(); + + tx_inputs_and_commitments.push(( + token_outpoint.clone().into(), + SighashInputCommitment::Utxo(Cow::Owned(token_utxo.clone())), + )); + tx_outputs.push(TxOutput::Burn(OutputValue::TokenV1( + self.token_id, + amount_to_unmint, + ))); + + if change > Amount::ZERO { + new_token_output_idx_and_amount = Some((tx_outputs.len() as u32, change)); + + tx_outputs.push(TxOutput::Transfer( + OutputValue::TokenV1(self.token_id, change), + Destination::AnyoneCanSpend, + )); + } else { + self.token_utxo_and_amount = None; + } + + extra_fee = self.chain_config.token_supply_change_fee(BlockHeight::new(1)); + + AccountCommand::UnmintTokens(self.token_id) + } + AccountCommandTag::LockTokenSupply => { + self.is_locked = true; + + extra_fee = self.chain_config.token_supply_change_fee(BlockHeight::new(1)); + + AccountCommand::LockTokenSupply(self.token_id) + } + AccountCommandTag::FreezeToken => { + self.is_frozen = true; + + extra_fee = self.chain_config.token_freeze_fee(BlockHeight::new(1)); + + AccountCommand::FreezeToken(self.token_id, IsTokenUnfreezable::Yes) + } + AccountCommandTag::UnfreezeToken => { + self.is_frozen = false; + + extra_fee = self.chain_config.token_freeze_fee(BlockHeight::new(1)); + + AccountCommand::UnfreezeToken(self.token_id) + } + AccountCommandTag::ChangeTokenAuthority => { + let (new_authority_sk, new_authority_pk) = + PrivateKey::new_from_rng(rng, KeyKind::Secp256k1Schnorr); + let new_authority = Destination::PublicKey(new_authority_pk.clone()); + + new_authority_sk_and_destination = + Some((new_authority_sk, new_authority.clone())); + + extra_fee = self.chain_config.token_change_authority_fee(BlockHeight::new(1)); + + AccountCommand::ChangeTokenAuthority(self.token_id, new_authority) + } + AccountCommandTag::ChangeTokenMetadataUri => { + extra_fee = self.chain_config.token_change_metadata_uri_fee(); + + AccountCommand::ChangeTokenMetadataUri( + self.token_id, + random_ascii_alphanumeric_string( + rng, + 1..=self.chain_config.token_max_uri_len(), + ) + .into_bytes(), + ) + } + + AccountCommandTag::ConcludeOrder | AccountCommandTag::FillOrder => { + panic!("Unexpected non-token command"); + } + }; + + let total_fee = (fee + extra_fee).unwrap(); + let change = (GENESIS_EXTRA_TXO_AMOUNT - total_fee).unwrap(); + assert!(change > Amount::ZERO); + tx_outputs.push(TxOutput::Transfer( + OutputValue::Coin(change), + Destination::AnyoneCanSpend, + )); + + tx_inputs_and_commitments.push(( + TxInput::AccountCommand(nonce, command), + SighashInputCommitment::None, + )); + + let (tx_inputs, tx_input_commitments): (Vec<_>, Vec<_>) = + tx_inputs_and_commitments.into_iter().unzip(); + + let inputs_count = tx_inputs.len(); + let tx = Transaction::new(0, tx_inputs, tx_outputs).unwrap(); + + let account_cmd_sig = StandardInputSignature::produce_uniparty_signature_for_input( + &self.authority_sk, + Default::default(), + self.authority.clone(), + &tx, + &tx_input_commitments, + 0, + rng, + ) + .unwrap(); + + self.next_extra_tx_idx += 1; + + if let Some((idx, amount)) = new_token_output_idx_and_amount { + if amount != Amount::ZERO { + let txo = tx.outputs()[idx as usize].clone(); + self.token_utxo_and_amount = + Some((UtxoOutPoint::new(tx.get_id().into(), idx), txo, amount)) + } else { + self.token_utxo_and_amount = None; + } + } + + if let Some((new_authority_sk, new_authority)) = new_authority_sk_and_destination { + self.authority_sk = new_authority_sk; + self.authority = new_authority; + } + + SignedTransaction::new( + tx, + std::iter::repeat_n(InputWitness::NoSignature(None), inputs_count - 1) + .chain([InputWitness::Standard(account_cmd_sig)]) + .collect(), + ) + .unwrap() + } + + fn possible_commands(&self) -> BTreeSet { + if self.is_frozen { + // The only things we can do with a frozen token is unfreeze. + return BTreeSet::from([AccountCommandTag::UnfreezeToken]); + } + + let mut result = BTreeSet::new(); + + for tag in AccountCommandTag::iter() { + let can_add = match tag { + AccountCommandTag::MintTokens => { + // We assume that mint amounts and the number of mints will always be less than the total supply. + !self.is_locked + } + AccountCommandTag::UnmintTokens => { + !self.is_locked + && self + .token_utxo_and_amount + .as_ref() + .is_some_and(|(_, _, amount)| *amount > Amount::ZERO) + } + AccountCommandTag::LockTokenSupply => match self.issuance.total_supply { + TokenTotalSupply::Fixed(_) | TokenTotalSupply::Unlimited => false, + TokenTotalSupply::Lockable => !self.is_locked, + }, + + AccountCommandTag::ChangeTokenAuthority + | AccountCommandTag::ChangeTokenMetadataUri => true, + + AccountCommandTag::FreezeToken => self.issuance.is_freezable.as_bool(), + AccountCommandTag::UnfreezeToken => false, + + // These are not token commands. + AccountCommandTag::ConcludeOrder | AccountCommandTag::FillOrder => false, + }; + + if can_add { + result.insert(tag); + } + } + + result + } + } + + fn make_issuance_tx( + issuance: TokenIssuanceV1, + chain_config: &ChainConfig, + fee: Amount, + genesis_txo_idx: u32, + ) -> SignedTransaction { + let total_fee = (chain_config.fungible_token_issuance_fee() + fee).unwrap(); + let change = (GENESIS_EXTRA_TXO_AMOUNT - total_fee).unwrap(); + assert!(change > Amount::ZERO); + + TransactionBuilder::new() + .add_input( + TxInput::from_utxo( + OutPointSourceId::BlockReward(chain_config.genesis_block_id()), + genesis_txo_idx, + ), + InputWitness::NoSignature(None), + ) + .add_output(TxOutput::Transfer( + OutputValue::Coin(change), + Destination::AnyoneCanSpend, + )) + .add_output(TxOutput::IssueFungibleToken(Box::new(TokenIssuance::V1( + issuance, + )))) + .build() + } +} + +#[rstest_reuse::apply(fees_selection_param)] +#[rstest] +#[case(Seed::from_entropy())] +#[trace] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pool_creation_and_decommissioning(#[case] seed: Seed, fees_selection: FeesSelection) { + use chainstate_test_framework::create_stake_pool_data_with_all_reward_to_staker; + use common::chain::{PoolId, config::emission_schedule::DEFAULT_INITIAL_MINT}; + use crypto::vrf::{VRFKeyKind, VRFPrivateKey}; + + let mut rng = make_seedable_rng(seed); + let time_getter = BasicTestTimeGetter::new().get_time_getter(); + + let pos_setup = + PoSTestSetupBuilder::new().build(make_genesis_timestamp(&time_getter, &mut rng), &mut rng); + + let chain_config = Arc::clone(&pos_setup.chain_config); + let (blockprod_setup, manager) = BlockprodTestSetupBuilder::new() + .with_chain_config(Arc::clone(&chain_config)) + .with_mempool_config(MempoolConfig { + min_tx_relay_fee_rate: FeeRate::from_amount_per_kb(Amount::ZERO).into(), + max_cluster_tx_count: Default::default(), + max_cluster_size_bytes: Default::default(), + }) + .with_time_getter(time_getter.clone()) + .build(); + + let base_fee = rng.random_range(10..20); + let fees = vec![Amount::from_atoms(base_fee), Amount::from_atoms(base_fee * 2)]; + let fees = reorder_fees(fees, fees_selection, &mut rng); + + log::debug!("fees = {fees:?}"); + + let min_pledge = chain_config.min_stake_pool_pledge().into_atoms(); + let pool_size = Amount::from_atoms(rng.random_range(min_pledge..min_pledge * 2)); + + let (_, vrf_pk) = VRFPrivateKey::new_from_rng(&mut rng, VRFKeyKind::Schnorrkel); + let (stake_pool_data, _) = + create_stake_pool_data_with_all_reward_to_staker(&mut rng, pool_size, vrf_pk); + + let genesis_outpoint = UtxoOutPoint::new(chain_config.genesis_block_id().into(), 0); + let pool_id = PoolId::from_utxo(&genesis_outpoint); + let create_pool_tx = TransactionBuilder::new() + .add_input(genesis_outpoint.into(), InputWitness::NoSignature(None)) + .add_output(TxOutput::CreateStakePool( + pool_id, + Box::new(stake_pool_data), + )) + .add_output(TxOutput::Transfer( + OutputValue::Coin(((DEFAULT_INITIAL_MINT - pool_size).unwrap() - fees[0]).unwrap()), + Destination::AnyoneCanSpend, + )) + .build(); + let create_pool_tx_id = create_pool_tx.transaction().get_id(); + let create_pool_outpoint = UtxoOutPoint::new(create_pool_tx_id.into(), 0); + + let decommission_pool_tx = TransactionBuilder::new() + .add_input(create_pool_outpoint.into(), InputWitness::NoSignature(None)) + .add_output(TxOutput::Transfer( + OutputValue::Coin((pool_size - fees[1]).unwrap()), + Destination::AnyoneCanSpend, + )) + .build(); + + let txs = vec![create_pool_tx, decommission_pool_tx]; + let expected_tx_ids = txs.iter().map(|tx| tx.transaction().get_id()).collect_vec(); + + let join_handle = tokio::spawn({ + let shutdown_trigger = manager.make_shutdown_trigger(); + async move { + // Ensure a shutdown signal will be sent by the end of the scope + let _shutdown_signal = OnceDestructor::new(move || { + shutdown_trigger.initiate(); + }); + + assert_fees(&blockprod_setup, &time_getter, &txs, &fees).await; + + let block_production = blockprod_setup.make_blockprod_builder().build(); + + add_local_txs_to_mempool(&blockprod_setup.mempool, txs).await; + + // FIXME check score - get_tx_score + + let input_data = pos_setup.make_first_pos_block_input_data(); + + let (new_block, job_finished_receiver) = block_production + .produce_block( + input_data, + vec![], + vec![], + PackingStrategy::FillSpaceFromMempool, + ) + .await + .unwrap(); + + let block_tx_ids = new_block + .transactions() + .iter() + .map(|tx| tx.transaction().get_id()) + .collect::>(); + + job_finished_receiver.await.unwrap(); + + assert_job_count(&block_production, 0).await; + blockprod_setup.assert_process_block(new_block).await; + assert_eq!(block_tx_ids, expected_tx_ids); + } + }); + + manager.main().await; + join_handle.await.unwrap(); +} + +fn reorder_fees( + fees: Vec, + fees_selection: FeesSelection, + rng: &mut impl CryptoRng, +) -> Vec { + assert_eq!(fees.clone().sorted(), fees); + + match fees_selection { + FeesSelection::Increasing => fees, + FeesSelection::Decreasing => { + let mut fees = fees; + fees.reverse(); + fees + } + FeesSelection::Random => fees.shuffled(rng), + } +} + +async fn assert_fees( + blockprod_setup: &BlockprodTestSetup, + time_getter: &TimeGetter, + txs: &[SignedTransaction], + fees: &[Amount], +) { + let mut tx_verifier = mempool::tx_verifier::create( + Arc::clone(&blockprod_setup.chain_config), + blockprod_setup.chainstate.clone(), + ); + + let best_block_index = blockprod_setup + .chainstate + .call(|cs| { + let tip = cs.get_best_block_id().unwrap(); + let tip_index = cs.get_gen_block_index_for_persisted_block(&tip).unwrap().unwrap(); + tip_index + }) + .await + .unwrap(); + + for (tx, fee) in txs.iter().zip_eq(fees.iter()) { + use chainstate::tx_verifier::transaction_verifier::TransactionSourceForConnect; + use common::chain::block::timestamp::BlockTimestamp; + + let actual_fee = tx_verifier + .connect_transaction( + &TransactionSourceForConnect::for_mempool_with_height( + &best_block_index, + BlockHeight::new(1), + ), + &tx, + &BlockTimestamp::from_time(time_getter.get_time()), + ) + .unwrap() + .map_into_block_fees(&blockprod_setup.chain_config, BlockHeight::new(1)) + .unwrap(); + assert_eq!(actual_fee.0, *fee); + } +} diff --git a/blockprod/src/detail/timestamp_searcher/tests.rs b/blockprod/src/detail/timestamp_searcher/tests.rs index cf1da29d69..7c03e062ae 100644 --- a/blockprod/src/detail/timestamp_searcher/tests.rs +++ b/blockprod/src/detail/timestamp_searcher/tests.rs @@ -17,7 +17,7 @@ use rstest::rstest; use test_utils::random::{Seed, make_seedable_rng}; -use logging::{init_logging, log}; +use logging::log; use randomness::{CryptoRng, RngExt as _}; use crate::{TimestampSearchData, detail::timestamp_searcher::SearchDataForHeight}; @@ -55,8 +55,6 @@ mod collect_search_data { #[case(Seed::from_entropy())] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test(#[case] seed: Seed) { - init_logging(); - let mut rng = make_seedable_rng(seed); let consensus_version = if rng.random_bool(0.5) { @@ -517,8 +515,6 @@ mod search { use crate::find_timestamps_for_staking; - init_logging(); - let mut rng = make_seedable_rng(seed); let start_height = BlockHeight::new(rng.random_range(0..10)); diff --git a/blockprod/src/tests/helpers.rs b/blockprod/src/tests/helpers.rs index c0d377760e..a0feb4fd5e 100644 --- a/blockprod/src/tests/helpers.rs +++ b/blockprod/src/tests/helpers.rs @@ -28,7 +28,7 @@ use common::{ Uint256, Uint512, chain::{ self, Block, ConsensusUpgrade, Destination, Genesis, NetUpgrades, OutPointSourceId, - PoSChainConfigBuilder, PoolId, TxInput, TxOutput, + PoSChainConfigBuilder, PoolId, SignedTransaction, TxInput, TxOutput, block::timestamp::BlockTimestamp, config::{ChainConfig, ChainType, create_unit_test_config}, pos_initial_difficulty, @@ -89,6 +89,7 @@ impl BlockprodTestSetup { pub struct BlockprodTestSetupBuilder { chain_config: Option>, time_getter: Option, + mempool_config: Option, } impl BlockprodTestSetupBuilder { @@ -96,6 +97,7 @@ impl BlockprodTestSetupBuilder { Self { chain_config: None, time_getter: None, + mempool_config: None, } } @@ -109,6 +111,11 @@ impl BlockprodTestSetupBuilder { self } + pub fn with_mempool_config(mut self, mempool_config: MempoolConfig) -> Self { + self.mempool_config = Some(mempool_config); + self + } + pub fn build(self) -> (BlockprodTestSetup, Manager) { let chain_config = self.chain_config.unwrap_or_else(|| Arc::new(create_unit_test_config())); let time_getter = self.time_getter.unwrap_or_default(); @@ -130,7 +137,7 @@ impl BlockprodTestSetupBuilder { allow_checkpoints_mismatch: Default::default(), }; - let mempool_config = MempoolConfig::new(); + let mempool_config = self.mempool_config.unwrap_or_else(MempoolConfig::new); let chainstate = chainstate::make_chainstate( Arc::clone(&chain_config), @@ -493,3 +500,17 @@ pub fn build_chain_config_for_pos(builder: chain::config::Builder) -> ChainConfi chain_config } + +pub async fn add_local_txs_to_mempool(mempool: &MempoolHandle, txs: Vec) { + mempool + .call_mut(move |mp| { + let origin = mempool::tx_origin::LocalTxOrigin::Mempool; + let options = mempool::TxOptions::default_for(origin.into()); + + for tx in txs { + mp.add_transaction_local(tx, origin, options.clone()).unwrap(); + } + }) + .await + .unwrap() +} diff --git a/blockprod/src/tests/mod.rs b/blockprod/src/tests/mod.rs index f100a53e8a..f65531b864 100644 --- a/blockprod/src/tests/mod.rs +++ b/blockprod/src/tests/mod.rs @@ -21,6 +21,11 @@ use crate::{ make_blockproduction, test_blockprod_config, tests::helpers::BlockprodTestSetupBuilder, }; +#[ctor::ctor] +fn init() { + logging::init_logging(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_make_blockproduction() { let (blockprod_setup, mut manager) = BlockprodTestSetupBuilder::new().build(); diff --git a/common/src/chain/transaction/account_outpoint.rs b/common/src/chain/transaction/account_outpoint.rs index 9034e9c0c0..59d17f8312 100644 --- a/common/src/chain/transaction/account_outpoint.rs +++ b/common/src/chain/transaction/account_outpoint.rs @@ -114,7 +114,7 @@ pub enum AccountSpending { serde::Deserialize, strum::EnumDiscriminants, )] -#[strum_discriminants(name(AccountCommandTag), derive(strum::EnumIter))] +#[strum_discriminants(name(AccountCommandTag), derive(Ord, PartialOrd, strum::EnumIter))] pub enum AccountCommand { // Create certain amount of tokens and add them to circulating supply #[codec(index = 0)] diff --git a/common/src/chain/upgrades/chainstate_upgrade/mod.rs b/common/src/chain/upgrades/chainstate_upgrade/mod.rs index 2372dbd8c0..a833aef6e8 100644 --- a/common/src/chain/upgrades/chainstate_upgrade/mod.rs +++ b/common/src/chain/upgrades/chainstate_upgrade/mod.rs @@ -102,7 +102,7 @@ pub enum StakerDestinationUpdateForbidden { // b) For both testnet and mainnet, do one full sync with the upgrade present and another one with // it removed (i.e. where the V1 generation is always used); if the logged ids are the same, // the upgrade can be removed permanently. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, strum::EnumIter)] pub enum TokenIdGenerationVersion { // Token id is generated from the 1st input of the issuing transaction. V0, diff --git a/mempool/Cargo.toml b/mempool/Cargo.toml index e672ef18fb..a420977e7f 100644 --- a/mempool/Cargo.toml +++ b/mempool/Cargo.toml @@ -33,12 +33,14 @@ byte-unit.workspace = true hashbrown.workspace = true hex.workspace = true jsonrpsee = { workspace = true, features = ["macros"] } +num-traits.workspace = true parking_lot.workspace = true serde.workspace = true +smallvec.workspace = true static_assertions.workspace = true +strum.workspace = true thiserror.workspace = true tokio = { workspace = true, default-features = false, features = ["io-util", "macros", "net", "rt", "rt-multi-thread", "sync", "time"] } -num-traits.workspace = true tracing.workspace = true [dev-dependencies] diff --git a/mempool/src/interface/mempool_interface.rs b/mempool/src/interface/mempool_interface.rs index d786f1c5e2..2d25b93e3d 100644 --- a/mempool/src/interface/mempool_interface.rs +++ b/mempool/src/interface/mempool_interface.rs @@ -22,7 +22,7 @@ use common::{ use mempool_types::TransactionDuplicateStatus; use crate::{ - FeeRate, MempoolConfig, MempoolMaxSize, TxOptions, TxStatus, + AncestorScore, FeeRate, MempoolConfig, MempoolMaxSize, TxOptions, TxStatus, error::{BlockConstructionError, Error}, event::MempoolEvent, tx_accumulator::{PackingStrategy, TransactionAccumulator}, @@ -66,8 +66,8 @@ pub trait MempoolInterface: Send + Sync { /// Return the mempool config. fn config(&self) -> &MempoolConfig; - /// Collect transactions by putting them in given accumulator - /// Returns the accumulator with the collected transactions + /// Collect transactions by putting them in given accumulator. + /// Returns the accumulator with the collected transactions. /// Ok(None) is returned on recoverable errors, such as if /// the tip changed before collecting transactions started. fn collect_txs( @@ -77,7 +77,7 @@ 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: + /// Return at most `tx_count` transaction ids from `tx_ids`, ordering them by "ancestor 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. @@ -110,6 +110,12 @@ pub trait MempoolInterface: Send + Sync { fn get_fee_rate_points(&self, num_points: NonZeroUsize) -> Result, Error>; + /// Get the "ancestor score" of the specified transaction (the bigger the score, the more lucrative + /// the transaction is for inclusion in a block). + /// + /// This is mainly intended for testing purposes. + fn get_tx_score(&self, tx_id: &Id) -> Result, Error>; + /// Notify mempool given peer has disconnected fn notify_peer_disconnected(&mut self, peer_id: p2p_types::PeerId); diff --git a/mempool/src/interface/mempool_interface_impl.rs b/mempool/src/interface/mempool_interface_impl.rs index 4fbe4561c1..2abccd1134 100644 --- a/mempool/src/interface/mempool_interface_impl.rs +++ b/mempool/src/interface/mempool_interface_impl.rs @@ -26,7 +26,7 @@ use mempool_types::TransactionDuplicateStatus; use utils::{debug_panic_or_log, tap_log::TapLog}; use crate::{ - FeeRate, MempoolInterface, MempoolMaxSize, TxOptions, TxStatus, + AncestorScore, FeeRate, MempoolInterface, MempoolMaxSize, TxOptions, TxStatus, config::MempoolConfig, error::{BlockConstructionError, Error}, event::MempoolEvent, @@ -213,6 +213,10 @@ impl MempoolInterface for Mempool { Ok(self.get_fee_rate_points(num_points)?) } + fn get_tx_score(&self, tx_id: &Id) -> Result, Error> { + Ok(self.get_tx_score(tx_id)?) + } + fn notify_peer_disconnected(&mut self, peer_id: p2p_types::PeerId) { self.on_peer_disconnected(peer_id); } diff --git a/mempool/src/lib.rs b/mempool/src/lib.rs index b23aa391b6..49c2a5338e 100644 --- a/mempool/src/lib.rs +++ b/mempool/src/lib.rs @@ -30,8 +30,7 @@ pub mod tx_accumulator; pub use { config::{MempoolConfig, RpcMempoolConfig}, - pool::FeeRate, - pool::feerate_points::find_interpolated_value, + pool::{AncestorScore, FeeRate, feerate_points::find_interpolated_value, tx_verifier}, }; pub type MempoolHandle = subsystem::Handle; diff --git a/mempool/src/pool/dependency.rs b/mempool/src/pool/dependency.rs new file mode 100644 index 0000000000..6e4d87dd68 --- /dev/null +++ b/mempool/src/pool/dependency.rs @@ -0,0 +1,263 @@ +// Copyright (c) 2023-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 mempool_types::tx_origin::IsOrigin; +use smallvec::SmallVec; +use strum::IntoEnumIterator as _; + +use common::{ + chain::{ + AccountCommand, AccountNonce, AccountSpending, DelegationId, IdCreationError, + TokenIdGenerationVersion, Transaction, TxInput, TxOutput, make_token_id_with_version, + tokens::TokenId, + }, + primitives::Id, +}; + +use crate::pool::entry::TxEntry; + +/// A dependency that is provided by a transaction. +/// +/// Note: for accounts that have a nonce, the stored nonce is the one that will be available +/// for consumption by other txs (i.e. it's the nonce from the providing tx plus one). +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum TxProvidedDependency { + TxOutput(Id, u32), + DelegationAccount(DelegationId, AccountNonce), + TokenAccount(TokenId, AccountNonce), + TokenCreation(TokenId), +} + +// FIXME TxProvidedNonUtxoDependency? + +impl TxProvidedDependency { + pub fn from_tx(entry: &TxEntry) -> impl Iterator { + let from_inputs = entry.transaction().inputs().iter().filter_map(Self::from_input); + + let from_outputs = + entry + .transaction() + .outputs() + .iter() + .enumerate() + .filter_map(|(output_idx, output)| { + Self::from_output( + output, + output_idx as u32, + entry.tx_id(), + entry.transaction().inputs(), + ) + }); + + from_inputs.chain(from_outputs) + } + + fn from_input(input: &TxInput) -> Option { + match input { + TxInput::Utxo(_) => None, + TxInput::Account(acct) => Self::from_account(acct.account(), acct.nonce().increment()?), + TxInput::AccountCommand(nonce, op) => Self::from_account_cmd(op, nonce.increment()?), + TxInput::OrderAccountCommand(_) => None, + } + } + + fn from_output( + output: &TxOutput, + output_idx: u32, + tx_id: &Id, + inputs: &[TxInput], + ) -> Option { + match output { + TxOutput::IssueFungibleToken(_) => { + // This will produce a compilation failure if TokenIdGenerationVersion gets a new + // variant (shouldn't happen, but just in case). + for ver in TokenIdGenerationVersion::iter() { + match ver { + TokenIdGenerationVersion::V0 | TokenIdGenerationVersion::V1 => {} + } + } + + // FIXME return Result? + let token_id = + make_token_id_with_version(TokenIdGenerationVersion::V1, inputs).ok()?; + + Some(Self::TokenCreation(token_id)) + } + + TxOutput::Transfer(_, _) + | TxOutput::LockThenTransfer(_, _, _) + | TxOutput::IssueNft(_, _, _) + | TxOutput::Htlc(_, _) + | TxOutput::CreateStakePool(_, _) + | TxOutput::ProduceBlockFromStake(_, _) => Some(Self::TxOutput(*tx_id, output_idx)), + // These outputs are not spendable + | TxOutput::Burn(_) + | TxOutput::CreateDelegationId(_, _) + | TxOutput::DelegateStaking(_, _) + | TxOutput::DataDeposit(_) + | TxOutput::CreateOrder(_) => None, + } + } + + fn from_account(account: &AccountSpending, nonce: AccountNonce) -> Option { + match account { + AccountSpending::DelegationBalance(delegation_id, _) => { + Some(Self::DelegationAccount(*delegation_id, nonce)) + } + } + } + + fn from_account_cmd(cmd: &AccountCommand, nonce: AccountNonce) -> Option { + match cmd { + AccountCommand::MintTokens(token_id, _) + | AccountCommand::UnmintTokens(token_id) + | AccountCommand::LockTokenSupply(token_id) + | AccountCommand::FreezeToken(token_id, _) + | AccountCommand::UnfreezeToken(token_id) + | AccountCommand::ChangeTokenMetadataUri(token_id, _) + | AccountCommand::ChangeTokenAuthority(token_id, _) => { + Some(Self::TokenAccount(*token_id, nonce)) + } + // Orders V0 are not tracked + AccountCommand::ConcludeOrder(_) | AccountCommand::FillOrder(_, _, _) => None, + } + } + + pub fn into_requirement(self) -> TxRequiredDependency { + // Note: account nonces are put into TxRequiredDependency as is, this is because + // TxProvidedDependency's nonce is the providing tx's nonce plus one, which is what + // the requiring tx consumes. + match self { + Self::TxOutput(tx_id, output_idx) => TxRequiredDependency::TxOutput(tx_id, output_idx), + Self::DelegationAccount(delg_id, nonce) => { + TxRequiredDependency::DelegationAccount(delg_id, nonce) + } + Self::TokenAccount(token_id, nonce) => { + TxRequiredDependency::TokenAccount(token_id, nonce) + } + Self::TokenCreation(token_id) => TxRequiredDependency::TokenCreation(token_id), + } + } + + // FIXME consistency tests + pub fn from_requirement(req: TxRequiredDependency) -> Self { + // Note: account nonces are put into TxRequiredDependency as is, this is because + // TxProvidedDependency's nonce is the providing tx's nonce plus one, which is what + // the requiring tx consumes. + match req { + TxRequiredDependency::TxOutput(tx_id, output_idx) => Self::TxOutput(tx_id, output_idx), + TxRequiredDependency::DelegationAccount(delg_id, nonce) => { + Self::DelegationAccount(delg_id, nonce) + } + TxRequiredDependency::TokenAccount(token_id, nonce) => { + Self::TokenAccount(token_id, nonce) + } + TxRequiredDependency::TokenCreation(token_id) => Self::TokenCreation(token_id), + } + } +} + +/// A dependency that is required by a transaction. +/// +/// Note: +/// * structurally this is identical to `TxProvidedDependency`. A distinct type is used +/// for logical separation; +/// * for accounts that have a nonce, the stored nonce is the one that is consumed by the +/// requiring tx. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum TxRequiredDependency { + TxOutput(Id, u32), + DelegationAccount(DelegationId, AccountNonce), + TokenAccount(TokenId, AccountNonce), + TokenCreation(TokenId), +} + +impl TxRequiredDependency { + pub fn from_tx(entry: &TxEntry) -> impl Iterator { + entry.transaction().inputs().iter().flat_map(Self::from_input) + } + + fn from_input(input: &TxInput) -> impl Iterator { + // Use SmallVec to avoid allocations (we'll be producing at most 2 values here). + let mut result = SmallVec::<[_; 2]>::new(); + + match input { + TxInput::Utxo(outpoint) => { + if let Some(tx_id) = outpoint.source_id().get_tx_id() { + result.push(Self::TxOutput(*tx_id, outpoint.output_index())); + } + } + TxInput::Account(acct) => match acct.account() { + AccountSpending::DelegationBalance(delegation_id, _) => { + result.push(Self::DelegationAccount(*delegation_id, acct.nonce())); + } + }, + TxInput::AccountCommand(nonce, cmd) => { + match cmd { + AccountCommand::MintTokens(token_id, _) + | AccountCommand::UnmintTokens(token_id) + | AccountCommand::LockTokenSupply(token_id) + | AccountCommand::FreezeToken(token_id, _) + | AccountCommand::UnfreezeToken(token_id) + | AccountCommand::ChangeTokenMetadataUri(token_id, _) + | AccountCommand::ChangeTokenAuthority(token_id, _) => { + result.push(Self::TokenAccount(*token_id, *nonce)); + + if nonce.value() == 0 { + result.push(Self::TokenCreation(*token_id)); + } + } + // Orders V0 are not tracked + AccountCommand::ConcludeOrder(_) | AccountCommand::FillOrder(_, _, _) => {} + } + } + TxInput::OrderAccountCommand(_) => {} + } + + result.into_iter() + } + + pub fn into_consumed(self) -> Option { + match self { + Self::TxOutput(tx_id, output_idx) => { + Some(TxConsumedDependency::TxOutput(tx_id, output_idx)) + } + Self::DelegationAccount(delg_id, nonce) => { + Some(TxConsumedDependency::DelegationAccount(delg_id, nonce)) + } + Self::TokenAccount(token_id, nonce) => { + Some(TxConsumedDependency::TokenAccount(token_id, nonce)) + } + Self::TokenCreation(_) => None, + } + } +} + +/// A dependency that is consumed by a transaction. +/// +/// This is a subset of `TxRequiredDependency`. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum TxConsumedDependency { + TxOutput(Id, u32), + DelegationAccount(DelegationId, AccountNonce), + TokenAccount(TokenId, AccountNonce), +} + +// FIXME +#[derive(thiserror::Error, Debug, PartialEq, Eq, Clone)] +pub enum DependencyError { + #[error(transparent)] + IdCreationError(#[from] IdCreationError), +} diff --git a/mempool/src/pool/entry.rs b/mempool/src/pool/entry.rs index 0cf04c5669..f255a4a876 100644 --- a/mempool/src/pool/entry.rs +++ b/mempool/src/pool/entry.rs @@ -16,111 +16,15 @@ use std::num::NonZeroUsize; use common::{ - chain::{ - AccountCommand, AccountNonce, AccountSpending, DelegationId, OrderId, SignedTransaction, - Transaction, TxInput, UtxoOutPoint, tokens::TokenId, - }, + chain::{SignedTransaction, Transaction}, primitives::{Id, Idable}, }; use super::{Fee, Time, TxOptions, TxOrigin}; -use crate::tx_origin::IsOrigin; - -/// A dependency of a transaction. May be another transaction or a previous account state. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -pub enum TxDependency { - DelegationAccount(DelegationId, AccountNonce), - TokenSupplyAccount(TokenId, AccountNonce), - // TODO: remove OrderV0Account after OrdersVersion::V1 is activated - // https://github.com/mintlayer/mintlayer-core/issues/1901 - OrderV0Account(OrderId, AccountNonce), - TxOutput(Id, u32), - // TODO: Block reward? - - // Note that orders v1 are not needed here, because: - // 1) Since they don't use nonces, they don't create dependencies the way other account-based - // inputs do. - // 2) We could introduce a pseudo-dependency, e.g. in the form of an `enum { Fillable, Freezable, Concludable }` - // (we'd have to differentiate between dependencies that a tx requires vs those that it consumes, - // so e.g. a `FreezeOrder` input would require `Freezable` but consume both `Freezable` and `Fillable`). - // However, this doesn't seem to be useful because currently, with RBF disabled, `TxDependency` - // itself has limited use: - // a) It's used to check for conflicts (`check_mempool_policy` calls `conflicting_tx_ids` - // and returns `MempoolConflictError::Irreplacable` if any), but this check doesn't seem - // to be really needed, because a conflicting tx will always be rejected by the tx verifier - // anyway (also, since the tx verifier call happens first, it doesn't seem that this - // `Irreplacable` result is possible at all, unless it's a bug). - // Though technically, we could use the pseudo-dependency as an optimization, to avoid calling - // the tx verifier when we know it'll fail anyway. - // b) The orphan pool uses a TxDependency map to check whether tx's dependencies could have become - // satisfied. The pseudo-dependency won't be useful here at all. - // (Also note that even when RBF is finally implemented, RBFing an order-related tx will probably - // be based on re-using one of the UTXOs of the original tx, so tracking order inputs will probably - // not be needed anyway). - // TODO: return to this when enabling RBF. -} - -impl TxDependency { - fn from_utxo(output: &UtxoOutPoint) -> Option { - output - .source_id() - .get_tx_id() - .map(|id| Self::TxOutput(*id, output.output_index())) - } - - fn from_account(account: &AccountSpending, nonce: AccountNonce) -> Self { - match account { - AccountSpending::DelegationBalance(delegation_id, _) => { - Self::DelegationAccount(*delegation_id, nonce) - } - } - } - - fn from_account_cmd(cmd: &AccountCommand, nonce: AccountNonce) -> Self { - match cmd { - AccountCommand::MintTokens(token_id, _) - | AccountCommand::UnmintTokens(token_id) - | AccountCommand::LockTokenSupply(token_id) - | AccountCommand::FreezeToken(token_id, _) - | AccountCommand::UnfreezeToken(token_id) - | AccountCommand::ChangeTokenMetadataUri(token_id, _) - | AccountCommand::ChangeTokenAuthority(token_id, _) => { - Self::TokenSupplyAccount(*token_id, nonce) - } - AccountCommand::ConcludeOrder(order_id) | AccountCommand::FillOrder(order_id, _, _) => { - Self::OrderV0Account(*order_id, nonce) - } - } - } - - fn from_input_requires(input: &TxInput) -> Option { - // TODO: the "nonce().decrement().map()" calls below don't seem to be correct, because - // returning None for account-based inputs with zero nonce means that such inputs will - // never be considered as conflicting. Perhaps we should store `Option` - // inside TxDependency's variants instead. - // (Note that this issue doesn't seem to have a noticeable impact at this moment, - // with disabled RBF). - match input { - TxInput::Utxo(utxo) => Self::from_utxo(utxo), - TxInput::Account(acct) => { - acct.nonce().decrement().map(|nonce| Self::from_account(acct.account(), nonce)) - } - TxInput::AccountCommand(nonce, op) => { - nonce.decrement().map(|nonce| Self::from_account_cmd(op, nonce)) - } - TxInput::OrderAccountCommand(_) => None, - } - } - - fn from_input_provides(input: &TxInput) -> Option { - match input { - TxInput::Utxo(_) => None, - TxInput::Account(acct) => Some(Self::from_account(acct.account(), acct.nonce())), - TxInput::AccountCommand(nonce, op) => Some(Self::from_account_cmd(op, *nonce)), - TxInput::OrderAccountCommand(_) => None, - } - } -} +use crate::{ + pool::dependency::{TxProvidedDependency, TxRequiredDependency}, + tx_origin::IsOrigin, +}; /// A transaction together with its creation time #[derive(Debug, Clone, PartialEq, Eq)] @@ -185,20 +89,13 @@ impl TxEntry { } /// Dependency graph edges this entry requires - pub fn requires(&self) -> impl Iterator + '_ { - self.inputs_iter().filter_map(TxDependency::from_input_requires) + pub fn requires(&self) -> impl Iterator + '_ { + TxRequiredDependency::from_tx(self) } /// Dependency graph edges this entry provides - pub fn provides(&self) -> impl Iterator + '_ { - let n_outputs = self.transaction().outputs().len() as u32; - let from_outputs = (0..n_outputs).map(|i| TxDependency::TxOutput(*self.tx_id(), i)); - let from_inputs = self.inputs_iter().filter_map(TxDependency::from_input_provides); - from_outputs.chain(from_inputs) - } - - fn inputs_iter(&self) -> impl ExactSizeIterator + '_ { - self.transaction().inputs().iter() + pub fn provides(&self) -> impl Iterator + '_ { + TxProvidedDependency::from_tx(self) } pub fn map_origin(self, func: impl FnOnce(O) -> R) -> TxEntry { diff --git a/mempool/src/pool/mod.rs b/mempool/src/pool/mod.rs index 5a0d23413b..fe275ae99a 100644 --- a/mempool/src/pool/mod.rs +++ b/mempool/src/pool/mod.rs @@ -43,15 +43,19 @@ use crate::{ }; use self::{ - entry::{TxDependency, TxEntry}, + entry::TxEntry, fee::Fee, memory_usage_estimator::MemoryUsageEstimator, orphans::{OrphanType, TxOrphanPool}, tx_pool::{TxAdditionOutcome, TxPool}, }; -pub use self::{feerate::FeeRate, tx_pool::feerate_points}; +pub use self::{ + feerate::FeeRate, + tx_pool::{AncestorScore, feerate_points, memory_usage_estimator, tx_verifier}, +}; +mod dependency; mod entry; pub mod fee; mod feerate; @@ -59,8 +63,6 @@ mod orphans; mod tx_pool; mod work_queue; -pub use tx_pool::memory_usage_estimator; - pub type WorkQueue = work_queue::WorkQueue>; /// Top-level mempool object. @@ -482,6 +484,13 @@ impl Mempool { } } } + + pub fn get_tx_score(&self, tx_id: &Id) -> Result, Error> { + match &self.0 { + MempoolState::InIbd(_) => Ok(None), + MempoolState::AfterIbd(state) => state.tx_pool.get_tx_score(tx_id), + } + } } // Mempool Event Reactions diff --git a/mempool/src/pool/orphans/mod.rs b/mempool/src/pool/orphans/mod.rs index 147b8e44e6..9dd144147c 100644 --- a/mempool/src/pool/orphans/mod.rs +++ b/mempool/src/pool/orphans/mod.rs @@ -21,8 +21,12 @@ use mempool_types::TxStatus; use randomness::{RngExt as _, make_pseudo_rng}; use utils::{const_value::ConstValue, ensure}; -use super::{OrphanPoolError, Time, TxDependency}; -use crate::{config, tx_origin::RemoteTxOrigin}; +use super::{OrphanPoolError, Time}; +use crate::{ + config, + pool::dependency::{TxProvidedDependency, TxRequiredDependency}, + tx_origin::RemoteTxOrigin, +}; pub use detect::OrphanType; mod detect; @@ -66,8 +70,11 @@ struct TxOrphanPoolMaps { /// Transactions indexed by the insertion time. Useful for removing stale transactions by_insertion_time: BTreeSet<(Time, InternalId)>, - /// Transactions indexed by their dependencies - by_deps: BTreeSet<(TxDependency, InternalId)>, + /// Transactions indexed by what they require + by_required_deps: BTreeSet<(TxRequiredDependency, InternalId)>, + + /// Transactions indexed by what they provide + by_provided_deps: BTreeSet<(TxProvidedDependency, InternalId)>, /// Transactions indexed by the origin by_origin: BTreeSet<(RemoteTxOrigin, InternalId)>, @@ -78,7 +85,8 @@ impl TxOrphanPoolMaps { Self { by_tx_id: BTreeMap::new(), by_insertion_time: BTreeSet::new(), - by_deps: BTreeSet::new(), + by_required_deps: BTreeSet::new(), + by_provided_deps: BTreeSet::new(), by_origin: BTreeSet::new(), } } @@ -93,7 +101,8 @@ impl TxOrphanPoolMaps { let inserted = self.by_origin.insert((entry.origin(), iid)); assert!(inserted, "Tx entry already in the origin map"); - self.by_deps.extend(entry.requires().map(|dep| (dep, iid))); + self.by_required_deps.extend(entry.requires().map(|dep| (dep, iid))); + self.by_provided_deps.extend(entry.provides().map(|dep| (dep, iid))); } fn remove(&mut self, entry: &TxEntry) { @@ -106,8 +115,12 @@ impl TxOrphanPoolMaps { assert!(removed, "Tx entry not present in the origin map"); entry.requires().for_each(|dep| { - self.by_deps.remove(&(dep, iid)); - }) + self.by_required_deps.remove(&(dep, iid)); + }); + + entry.provides().for_each(|dep| { + self.by_provided_deps.remove(&(dep, iid)); + }); } } @@ -157,10 +170,11 @@ impl TxOrphanPool { &'a self, entry: &'a super::TxEntry, ) -> impl Iterator + 'a { - entry.provides().flat_map(move |dep| { + entry.provides().flat_map(move |provided_dep| { + let required_dep = provided_dep.into_requirement(); self.maps - .by_deps - .range((dep.clone(), InternalId::ZERO)..=(dep, InternalId::MAX)) + .by_required_deps + .range((required_dep.clone(), InternalId::ZERO)..=(required_dep, InternalId::MAX)) .map(|(_, iid)| self.get_at(*iid)) }) } @@ -305,17 +319,19 @@ impl<'p> PoolEntry<'p> { /// Check no dependencies of given transaction are still in orphan pool so it can be considered /// as a candidate to move out. /// - /// Note: this function is allowed to produce false positives - if true is returned but - /// the tx is still an orphan (e.g. due to account-based dependencies), the tx will be returned - /// to the orphan pool. + /// Note: this function is allowed to produce false positives - if true is returned but the tx + /// is still an orphan (e.g. because none of its orphan parents are in the orphan pool), the tx + /// will be returned to the orphan pool. pub fn is_ready(&self) -> bool { let entry = self.get(); - !entry.requires().any(|dep| match dep { - // Always consider account deps. TODO: can be optimized in the future - TxDependency::DelegationAccount(_, _) - | TxDependency::TokenSupplyAccount(_, _) - | TxDependency::OrderV0Account(_, _) => false, - TxDependency::TxOutput(tx_id, _) => self.pool.maps.by_tx_id.contains_key(&tx_id), + !entry.requires().any(|required_dep| { + let provided_dep = TxProvidedDependency::from_requirement(required_dep); + self.pool + .maps + .by_provided_deps + .range((provided_dep.clone(), InternalId::ZERO)..=(provided_dep, InternalId::MAX)) + .next() + .is_some() }) } diff --git a/mempool/src/pool/orphans/test.rs b/mempool/src/pool/orphans/test.rs index ae2d003f3d..53a2c7b3b5 100644 --- a/mempool/src/pool/orphans/test.rs +++ b/mempool/src/pool/orphans/test.rs @@ -49,7 +49,7 @@ fn check_integrity(orphans: &TxOrphanPool) { "Entry {iid:?} insertion time inconsistent", ); }); - orphans.maps.by_deps.iter().for_each(|(dep, iid)| { + orphans.maps.by_required_deps.iter().for_each(|(dep, iid)| { let tx_dep = orphans.get_at(*iid).requires().find(|r| r == dep); assert!(tx_dep.is_some(), "Entry {iid:?} outpoint missing"); }); @@ -113,7 +113,7 @@ fn insert_and_delete(#[case] seed: Seed) { orphans.maps.by_tx_id.keys().collect::>(), vec![&tx_id], ); - assert_eq!(orphans.maps.by_deps.len(), n_deps); + assert_eq!(orphans.maps.by_required_deps.len(), n_deps); assert_eq!(orphans.maps.by_insertion_time.len(), 1); check_integrity(&orphans); @@ -122,7 +122,7 @@ fn insert_and_delete(#[case] seed: Seed) { assert!(orphans.transactions.is_empty()); assert!(orphans.maps.by_tx_id.is_empty()); assert!(orphans.maps.by_insertion_time.is_empty()); - assert!(orphans.maps.by_deps.is_empty()); + assert!(orphans.maps.by_required_deps.is_empty()); check_integrity(&orphans); } diff --git a/mempool/src/pool/tests/utils.rs b/mempool/src/pool/tests/utils.rs index f80c3f2045..0a24912e4c 100644 --- a/mempool/src/pool/tests/utils.rs +++ b/mempool/src/pool/tests/utils.rs @@ -14,11 +14,16 @@ // limitations under the License. use chainstate::chainstate_interface::ChainstateInterface; +use chainstate_test_framework::create_stake_pool_data_with_all_reward_to_staker; use common::{ - chain::{SignedTransaction, Transaction}, - primitives::Id, + chain::{ + DelegationId, Destination, PoolId, SignedTransaction, Transaction, TxOutput, UtxoOutPoint, + make_delegation_id, output_value::OutputValue, + }, + primitives::{Amount, Id, Idable as _}, time_getter::TimeGetter, }; +use crypto::vrf::{VRFKeyKind, VRFPrivateKey}; use mempool_types::{TxOptions, TxStatus, tx_origin::TxOrigin}; pub use crate::pool::tx_pool::tests::utils::*; @@ -84,3 +89,76 @@ impl Mempool { } } } + +pub fn setup_pool_and_delegation( + rng: &mut impl CryptoRng, + tf: &mut TestFramework, + outpoint: UtxoOutPoint, + pool_size: Amount, + delegation_size: Amount, +) -> (PoolId, DelegationId, /*change utxo*/ UtxoOutPoint) { + let coins_amount = tf.coin_amount_from_utxo(&outpoint); + + let (_, vrf_pk) = VRFPrivateKey::new_from_rng(rng, VRFKeyKind::Schnorrkel); + let (stake_pool_data, _) = + create_stake_pool_data_with_all_reward_to_staker(rng, pool_size, vrf_pk); + + let pool_id = PoolId::from_utxo(&outpoint); + let change_amount = (coins_amount - pool_size).unwrap(); + let create_pool_tx = TransactionBuilder::new() + .add_input(outpoint.into(), empty_witness(rng)) + .add_output(TxOutput::CreateStakePool( + pool_id, + Box::new(stake_pool_data), + )) + .add_output(TxOutput::Transfer( + OutputValue::Coin(change_amount), + Destination::AnyoneCanSpend, + )) + .build(); + let create_pool_tx_id = create_pool_tx.transaction().get_id(); + let change_utxo = UtxoOutPoint::new(create_pool_tx_id.into(), 1); + tf.make_block_builder() + .add_transaction(create_pool_tx) + .build_and_process(rng) + .unwrap(); + + let create_delegation_tx = TransactionBuilder::new() + .add_input(change_utxo.into(), empty_witness(rng)) + .add_output(TxOutput::CreateDelegationId( + Destination::AnyoneCanSpend, + pool_id, + )) + .add_output(TxOutput::Transfer( + OutputValue::Coin(change_amount), + Destination::AnyoneCanSpend, + )) + .build(); + let create_delegation_tx_id = create_delegation_tx.transaction().get_id(); + let change_utxo = UtxoOutPoint::new(create_delegation_tx_id.into(), 1); + let delegation_id = make_delegation_id(create_delegation_tx.inputs()).unwrap(); + + tf.make_block_builder() + .add_transaction(create_delegation_tx) + .build_and_process(rng) + .unwrap(); + + let change_amount = (change_amount - delegation_size).unwrap(); + let delegate_staking_tx = TransactionBuilder::new() + .add_input(change_utxo.into(), empty_witness(rng)) + .add_output(TxOutput::DelegateStaking(delegation_size, delegation_id)) + .add_output(TxOutput::Transfer( + OutputValue::Coin(change_amount), + Destination::AnyoneCanSpend, + )) + .build(); + let delegate_staking_tx_id = delegate_staking_tx.transaction().get_id(); + let change_utxo = UtxoOutPoint::new(delegate_staking_tx_id.into(), 1); + + tf.make_block_builder() + .add_transaction(delegate_staking_tx) + .build_and_process(rng) + .unwrap(); + + (pool_id, delegation_id, change_utxo) +} diff --git a/mempool/src/pool/tx_pool/mod.rs b/mempool/src/pool/tx_pool/mod.rs index 9dbc5aea61..a26c17a0fe 100644 --- a/mempool/src/pool/tx_pool/mod.rs +++ b/mempool/src/pool/tx_pool/mod.rs @@ -19,7 +19,7 @@ pub mod memory_usage_estimator; mod reorg; mod rolling_fee_rate; mod store; -mod tx_verifier; +pub mod tx_verifier; use std::{ collections::{BTreeMap, BTreeSet}, @@ -57,6 +57,7 @@ use crate::{ ReorgError, TxCollectionError, TxValidationError, }, pool::{ + dependency::TxRequiredDependency, entry::{TxEntry, TxEntryWithFee}, fee::Fee, feerate::FeeRate, @@ -74,6 +75,8 @@ use self::{ store::{Conflicts, DescendantScore, MempoolRemovalReason, MempoolStore, TxMempoolEntry}, }; +pub use store::AncestorScore; + pub struct TxPool { chain_config: Arc, mempool_config: Arc, @@ -366,7 +369,10 @@ impl TxPool { &'a self, entry: &'a TxEntry, ) -> impl Iterator> + 'a { - entry.requires().filter_map(|dep| self.store.find_conflicting_tx(&dep)) + entry + .requires() + .filter_map(TxRequiredDependency::into_consumed) + .filter_map(|dep| self.store.find_conflicting_tx(&dep)) } fn spends_unconfirmed(&self, input: &TxInput) -> bool { @@ -939,6 +945,10 @@ impl TxPool { collect_txs::get_best_tx_ids_by_score_and_ancestry(self, tx_ids, tx_count) } + pub fn get_tx_score(&self, tx_id: &Id) -> Result, Error> { + Ok(self.store.get_entry(tx_id).map(|entry| entry.ancestor_score())) + } + pub fn reorg( &mut self, block_id: Id, diff --git a/mempool/src/pool/tx_pool/store/mem_usage.rs b/mempool/src/pool/tx_pool/store/mem_usage.rs index 7fd0a423ba..b125a3bd85 100644 --- a/mempool/src/pool/tx_pool/store/mem_usage.rs +++ b/mempool/src/pool/tx_pool/store/mem_usage.rs @@ -30,7 +30,7 @@ use common::chain::{ }; use logging::log; -use super::{StoreHashMap, StoreHashSet, TxDependency, TxMempoolEntry}; +use super::{StoreHashMap, StoreHashSet, TxMempoolEntry}; /// Structure that stores the current memory usage and keeps track of its changes #[derive(Debug)] @@ -411,7 +411,6 @@ impl MemoryUsage for InputWitness { impl_no_indirect_memory_usage!( StakePoolData, - TxDependency, TxInput, TokenIssuance, NftIssuance, diff --git a/mempool/src/pool/tx_pool/store/mod.rs b/mempool/src/pool/tx_pool/store/mod.rs index 4381589b33..e49da1715d 100644 --- a/mempool/src/pool/tx_pool/store/mod.rs +++ b/mempool/src/pool/tx_pool/store/mod.rs @@ -24,7 +24,7 @@ use std::{ }; use common::{ - chain::{SignedTransaction, Transaction, TxInput}, + chain::{SignedTransaction, Transaction}, primitives::Id, }; use logging::log; @@ -35,7 +35,10 @@ use super::{Fee, Time, TxEntry, TxEntryWithFee}; use crate::{ FeeRate, MempoolConfig, error::{MempoolPolicyError, MempoolStoreError, MempoolStoreInvariantError}, - pool::{entry::TxDependency, tx_pool::store::mem_usage::MemUsageTracker}, + pool::{ + dependency::{TxConsumedDependency, TxProvidedDependency, TxRequiredDependency}, + tx_pool::store::mem_usage::MemUsageTracker, + }, }; pub use mem_usage::Tracked; @@ -150,8 +153,11 @@ pub struct MempoolStore { txs_by_creation_time: TrackedTxIdMultiMap