diff --git a/CHANGELOG.md b/CHANGELOG.md index f600b7def..5ed18daa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,11 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/ (The actual consensus tightening will happen after a fork, the height is yet to be decided.) +### Fixed + - Mempool: + - Fixed an issue where the mempool wouldn't track non-UTXO dependencies between transactions, which could + prevent e.g. several token management transactions from being included in the same block. + ## [1.3.1] - 2026-06-03 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 27846f3cf..101be123c 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 449ec09b1..fe1477d04 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 ec855b278..3819136e1 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 000000000..dfca2c03d --- /dev/null +++ b/blockprod/src/detail/tests/produce_block/tx_selection_by_deps.rs @@ -0,0 +1,1416 @@ +// 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, time::Duration}; + +use itertools::Itertools as _; +use randomness::CryptoRng; +use rstest::rstest; +use strum::IntoEnumIterator as _; + +use chainstate_test_framework::{ + TransactionBuilder, create_stake_pool_data_with_all_reward_to_staker, +}; +use common::{ + chain::{ + AccountCommand, AccountCommandTag, AccountNonce, AccountOutPoint, AccountSpending, + ChainConfig, CoinUnit, Destination, GenBlock, OrderAccountCommand, OrderAccountCommandTag, + OrderData, OutPointSourceId, PoolId, SignedTransaction, Transaction, TxOutput, + UtxoOutPoint, make_delegation_id, make_order_id, make_token_id, + output_value::OutputValue, + signature::inputsig::InputWitness, + timelock::OutputTimeLock, + tokens::{IsTokenUnfreezable, TokenId, TokenIssuance, TokenIssuanceV1, TokenTotalSupply}, + transaction::TxInput, + }, + primitives::{Amount, BlockHeight, Id, Idable}, + time_getter::TimeGetter, +}; +use crypto::vrf::{VRFKeyKind, VRFPrivateKey}; +use logging::log; +use mempool::{AncestorScore, FeeRate, MempoolConfig, tx_accumulator::PackingStrategy}; +use serialization::Encode as _; +use test_utils::{ + BasicTestTimeGetter, + random::{RngExt as _, Seed, make_seedable_rng}, + random_ascii_alphanumeric_string, + token_utils::random_token_issuance_v1_with_min_supply, +}; +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, + }, +}; + +// In the tests below we'll be creating chains of txs where each following tx depends on the previous +// one, adding those txs to the mempool, asking blockprod to create a block, and expecting that all +// of the txs have been included in the block. +// This enum defines how the tx fees will be selected. +#[derive(Debug, Copy, Clone)] +enum FeesSelection { + // The main case - the fees are progressively increasing, so that a dependent tx on its own would + // look more lucrative than the dependency. I.e. if the mempool doesn't honor the dependency, + // then during block creation the dependent txs will be selected first, fail the validation due + // to missing dependency and be omitted from the block, which will cause the test to fail. + Increasing, + // Supplementary case - the fees are first created as in the Increases case, but then are + // shuffled. + Random, +} + +#[rstest_reuse::template] +pub fn fees_selection_param( + #[values(FeesSelection::Increasing, FeesSelection::Random)] fees_selection: FeesSelection, +) { +} + +// Check that token creation and token management commands form a dependency chain. +#[rstest_reuse::apply(fees_selection_param)] +#[rstest] +// Note: this 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::with_secs_since_epoch(rng.random_range(1_000_000..1_000_000_000)) + .get_time_getter(); + + let tx_count = rng.random_range(10..=15); + 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 * 10u128.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(); + let encoded_sizes = txs.iter().map(|tx| tx.encoded_size()).collect_vec(); + + log::debug!("txs = {txs:?}"); + log::debug!("encoded_sizes = {encoded_sizes:?}"); + + 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; + + match fees_selection { + FeesSelection::Increasing => { + // Sanity check: ancestor scores are ordered the same way as fees. + assert_ancestor_scores(&blockprod_setup, &expected_tx_ids, &fees).await; + } + FeesSelection::Random => {} + } + + 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, + }, + tokens::{IsTokenFreezable, TokenTotalSupplyTag}, + }; + use crypto::key::{KeyKind, PrivateKey}; + use randomness::{CryptoRng, seq::IteratorRandom as _}; + + 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, + } + + fn random_metadata_uri(rng: &mut impl CryptoRng) -> Vec { + // Use a small uri to prevent the corresponding tx from becoming too big, which would + // affect its score. + random_ascii_alphanumeric_string(rng, 5..=10).as_bytes().to_vec() + } + + 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 = { + let max_dec_count = chain_config.token_max_dec_count(); + let supply = match TokenTotalSupplyTag::iter().choose(rng).unwrap() { + TokenTotalSupplyTag::Fixed => { + let supply = Amount::from_atoms(rng.random_range(1_000_000..=2_000_000)); + TokenTotalSupply::Fixed(supply) + } + TokenTotalSupplyTag::Lockable => TokenTotalSupply::Lockable, + TokenTotalSupplyTag::Unlimited => TokenTotalSupply::Unlimited, + }; + + let is_freezable = if rng.random::() { + IsTokenFreezable::Yes + } else { + IsTokenFreezable::No + }; + + TokenIssuanceV1 { + token_ticker: random_ascii_alphanumeric_string(rng, 3..=5).as_bytes().to_vec(), + number_of_decimals: rng.random_range(1..=max_dec_count), + metadata_uri: random_metadata_uri(rng), + total_supply: supply, + is_freezable, + authority: authority.clone(), + } + }; + + 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_metadata_uri(rng)) + } + + 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() + } +} + +// Check that pool creation and decommissioning form a dependency chain. +// Note: this test creates only 2 txs, so Random case doesn't make much sense, so we only check +// the Increasing case here. +#[rstest] +#[case(Seed::from_entropy())] +#[trace] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pool_creation_and_decommissioning(#[case] seed: Seed) { + let mut rng = make_seedable_rng(seed); + let time_getter = + BasicTestTimeGetter::with_secs_since_epoch(rng.random_range(1_000_000..1_000_000_000)) + .get_time_getter(); + + let genesis_extra_txo_amount = Amount::from_atoms(100_000_000 * CoinUnit::ATOMS_PER_COIN); + let extra_genesis_txo = TxOutput::Transfer( + OutputValue::Coin(genesis_extra_txo_amount), + Destination::AnyoneCanSpend, + ); + let pos_setup = PoSTestSetupBuilder::new() + .with_extra_genesis_txos(vec![extra_genesis_txo]) + .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 * 10)]; + + 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_outpoint1 = UtxoOutPoint::new(chain_config.genesis_block_id().into(), 1); + let pool_id = PoolId::from_utxo(&genesis_outpoint1); + let create_pool_tx = TransactionBuilder::new() + .add_input(genesis_outpoint1.into(), InputWitness::NoSignature(None)) + .add_output(TxOutput::CreateStakePool( + pool_id, + Box::new(stake_pool_data), + )) + .add_output(TxOutput::Transfer( + OutputValue::Coin(((genesis_extra_txo_amount - 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 maturity_block_count = + chain_config.staking_pool_spend_maturity_block_count(BlockHeight::one()); + let decommission_pool_tx = TransactionBuilder::new() + .add_input(create_pool_outpoint.into(), InputWitness::NoSignature(None)) + .add_output(TxOutput::LockThenTransfer( + OutputValue::Coin((pool_size - fees[1]).unwrap()), + Destination::AnyoneCanSpend, + OutputTimeLock::ForBlockCount(maturity_block_count.to_int()), + )) + .build(); + + let txs = vec![create_pool_tx, decommission_pool_tx]; + let expected_tx_ids = txs.iter().map(|tx| tx.transaction().get_id()).collect_vec(); + + log::debug!("fees = {fees:?}"); + log::debug!("expected_tx_ids = {expected_tx_ids:?}"); + + 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; + + assert_ancestor_scores(&blockprod_setup, &expected_tx_ids, &fees).await; + + 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(); +} + +// This test checks 2 separate things in 2 different blocks: +// 1) pool creation, delegation creation and the actual delegation form a dependency chain. +// 2) delegation withdrawals form a dependency chain (based on their nonce). +// Note: we can't put everything into one block here, because there is no dependency between +// actual delegations and delegation withdrawals. +#[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_delegation(#[case] seed: Seed, fees_selection: FeesSelection) { + let mut rng = make_seedable_rng(seed); + let time_getter = + BasicTestTimeGetter::with_secs_since_epoch(rng.random_range(1_000_000..1_000_000_000)) + .get_time_getter(); + + let withdrawal_tx_count = rng.random_range(2..=5); + let genesis_extra_txo_amount = Amount::from_atoms(100_000_000 * CoinUnit::ATOMS_PER_COIN); + let extra_genesis_txos = (0..2 + withdrawal_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 creation_fees = (0..3).map(|i| Amount::from_atoms(base_fee * 10u128.pow(i))).collect_vec(); + let creation_fees = reorder_fees(creation_fees, fees_selection, &mut rng); + let withdrawal_fees = (0..withdrawal_tx_count) + .map(|i| Amount::from_atoms(base_fee * 10u128.pow(i))) + .collect_vec(); + let withdrawal_fees = reorder_fees(withdrawal_fees, fees_selection, &mut rng); + + 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_outpoint1 = UtxoOutPoint::new(chain_config.genesis_block_id().into(), 1); + let genesis_outpoint2 = UtxoOutPoint::new(chain_config.genesis_block_id().into(), 2); + let genesis_outpoint3 = UtxoOutPoint::new(chain_config.genesis_block_id().into(), 3); + + let pool_id = PoolId::from_utxo(&genesis_outpoint1); + let create_pool_tx = TransactionBuilder::new() + .add_input(genesis_outpoint1.into(), InputWitness::NoSignature(None)) + .add_output(TxOutput::CreateStakePool( + pool_id, + Box::new(stake_pool_data), + )) + .add_output(TxOutput::Transfer( + OutputValue::Coin( + ((genesis_extra_txo_amount - pool_size).unwrap() - creation_fees[0]).unwrap(), + ), + Destination::AnyoneCanSpend, + )) + .build(); + + let create_delegation_tx = TransactionBuilder::new() + .add_input(genesis_outpoint2.into(), InputWitness::NoSignature(None)) + .add_output(TxOutput::CreateDelegationId( + Destination::AnyoneCanSpend, + pool_id, + )) + .add_output(TxOutput::Transfer( + OutputValue::Coin((genesis_extra_txo_amount - creation_fees[1]).unwrap()), + Destination::AnyoneCanSpend, + )) + .build(); + let delegation_id = make_delegation_id(create_delegation_tx.inputs()).unwrap(); + + let delegation_amount = Amount::from_atoms(rng.random_range(min_pledge..min_pledge * 2)); + let delegate_tx = TransactionBuilder::new() + .add_input(genesis_outpoint3.into(), InputWitness::NoSignature(None)) + .add_output(TxOutput::DelegateStaking(delegation_amount, delegation_id)) + .add_output(TxOutput::Transfer( + OutputValue::Coin( + ((genesis_extra_txo_amount - delegation_amount).unwrap() - creation_fees[2]) + .unwrap(), + ), + Destination::AnyoneCanSpend, + )) + .build(); + let creation_txs = vec![create_pool_tx, create_delegation_tx, delegate_tx]; + + log::debug!("delegation_amount = {delegation_amount:?}"); + + let maturity_block_count = + chain_config.staking_pool_spend_maturity_block_count(BlockHeight::new(2)); + + let withdrawal_txs = (0..withdrawal_tx_count) + .map(|i| { + let fee = withdrawal_fees[i as usize]; + let withdrawal_amount_with_fee = Amount::from_atoms(rng.random_range( + fee.into_atoms() + 1..delegation_amount.into_atoms() / withdrawal_tx_count as u128, + )); + + log::debug!("withdrawal_amount_with_fee = {withdrawal_amount_with_fee:?}"); + + TransactionBuilder::new() + .add_input( + TxInput::Account(AccountOutPoint::new( + AccountNonce::new(i as u64), + AccountSpending::DelegationBalance( + delegation_id, + withdrawal_amount_with_fee, + ), + )), + InputWitness::NoSignature(None), + ) + .add_output(TxOutput::LockThenTransfer( + OutputValue::Coin((withdrawal_amount_with_fee - fee).unwrap()), + Destination::AnyoneCanSpend, + OutputTimeLock::ForBlockCount(maturity_block_count.to_int()), + )) + .build() + }) + .collect_vec(); + + let expected_creation_tx_ids = + creation_txs.iter().map(|tx| tx.transaction().get_id()).collect_vec(); + let expected_withdrawal_tx_ids = + withdrawal_txs.iter().map(|tx| tx.transaction().get_id()).collect_vec(); + + log::debug!("fees = {creation_fees:?}"); + log::debug!("expected_creation_tx_ids = {expected_creation_tx_ids:?}"); + log::debug!("expected_withdrawal_tx_ids = {expected_withdrawal_tx_ids:?}"); + + 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(); + }); + + let block_production = blockprod_setup.make_blockprod_builder().build(); + + let mut expected_best_block_id = chain_config.genesis_block_id(); + for (txs, fees, expected_tx_ids, description) in [ + ( + creation_txs, + creation_fees, + expected_creation_tx_ids, + "creation", + ), + ( + withdrawal_txs, + withdrawal_fees, + expected_withdrawal_tx_ids, + "withdrawal", + ), + ] { + log::debug!("Adding block with {description} txs"); + + mempool_wait_for_best_block(&blockprod_setup, expected_best_block_id).await; + + assert_fees(&blockprod_setup, &time_getter, &txs, &fees).await; + + add_local_txs_to_mempool(&blockprod_setup.mempool, txs).await; + + match fees_selection { + FeesSelection::Increasing => { + // Sanity check: ancestor scores are ordered the same way as fees. + assert_ancestor_scores(&blockprod_setup, &expected_tx_ids, &fees).await; + } + FeesSelection::Random => {} + } + + let input_data = pos_setup.make_block_input_data(expected_best_block_id); + + let (new_block, job_finished_receiver) = block_production + .produce_block( + input_data, + vec![], + vec![], + PackingStrategy::FillSpaceFromMempool, + ) + .await + .unwrap(); + let new_block_id = new_block.get_id(); + + 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); + + expected_best_block_id = new_block_id.into(); + } + } + }); + + manager.main().await; + join_handle.await.unwrap(); +} + +// This test checks that pool creation, delegation creation and delegation withdrawal +// form a dependency chain. +// Note that this is not a super useful scenario, we mostly check it just because it's implemented +// in the mempool. +// Also note: since delegations and delegation withdrawals are not ordered with respect to each +// other, we don't delegate and, instead, always withdraw zero amounts. +#[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_delegation_withdrawals( + #[case] seed: Seed, + fees_selection: FeesSelection, +) { + let mut rng = make_seedable_rng(seed); + let time_getter = + BasicTestTimeGetter::with_secs_since_epoch(rng.random_range(1_000_000..1_000_000_000)) + .get_time_getter(); + + let withdrawal_tx_count = rng.random_range(2..=5); + let genesis_extra_txo_amount = Amount::from_atoms(100_000_000 * CoinUnit::ATOMS_PER_COIN); + let extra_genesis_txos = (0..2 + withdrawal_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..2 + withdrawal_tx_count) + .map(|i| Amount::from_atoms(base_fee * 10u128.pow(i))) + .collect_vec(); + let fees = reorder_fees(fees, fees_selection, &mut rng); + + 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_outpoint1 = UtxoOutPoint::new(chain_config.genesis_block_id().into(), 1); + let genesis_outpoint2 = UtxoOutPoint::new(chain_config.genesis_block_id().into(), 2); + + let pool_id = PoolId::from_utxo(&genesis_outpoint1); + let create_pool_tx = TransactionBuilder::new() + .add_input(genesis_outpoint1.into(), InputWitness::NoSignature(None)) + .add_output(TxOutput::CreateStakePool( + pool_id, + Box::new(stake_pool_data), + )) + .add_output(TxOutput::Transfer( + OutputValue::Coin(((genesis_extra_txo_amount - pool_size).unwrap() - fees[0]).unwrap()), + Destination::AnyoneCanSpend, + )) + .build(); + + let create_delegation_tx = TransactionBuilder::new() + .add_input(genesis_outpoint2.into(), InputWitness::NoSignature(None)) + .add_output(TxOutput::CreateDelegationId( + Destination::AnyoneCanSpend, + pool_id, + )) + .add_output(TxOutput::Transfer( + OutputValue::Coin((genesis_extra_txo_amount - fees[1]).unwrap()), + Destination::AnyoneCanSpend, + )) + .build(); + let delegation_id = make_delegation_id(create_delegation_tx.inputs()).unwrap(); + + let withdrawal_txs_iter = (0..withdrawal_tx_count).map(|i| { + let genesis_outpoint = UtxoOutPoint::new(chain_config.genesis_block_id().into(), 3 + i); + + TransactionBuilder::new() + .add_input(genesis_outpoint.into(), InputWitness::NoSignature(None)) + .add_input( + TxInput::Account(AccountOutPoint::new( + AccountNonce::new(i as u64), + AccountSpending::DelegationBalance(delegation_id, Amount::ZERO), + )), + InputWitness::NoSignature(None), + ) + .add_output(TxOutput::Transfer( + OutputValue::Coin((genesis_extra_txo_amount - fees[2 + i as usize]).unwrap()), + Destination::AnyoneCanSpend, + )) + .build() + }); + + let txs = [create_pool_tx, create_delegation_tx] + .into_iter() + .chain(withdrawal_txs_iter) + .collect_vec(); + let expected_tx_ids = txs.iter().map(|tx| tx.transaction().get_id()).collect_vec(); + + log::debug!("fees = {fees:?}"); + log::debug!("expected_tx_ids = {expected_tx_ids:?}"); + + 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; + + match fees_selection { + FeesSelection::Increasing => { + // Sanity check: ancestor scores are ordered the same way as fees. + assert_ancestor_scores(&blockprod_setup, &expected_tx_ids, &fees).await; + } + FeesSelection::Random => {} + } + + 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(); +} + +// Check that order creation and an order command form a dependency chain. +#[rstest] +#[case(Seed::from_entropy())] +#[trace] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn order_creation_and_usage( + #[case] seed: Seed, + #[values( + OrderAccountCommandTag::FillOrder, + OrderAccountCommandTag::FreezeOrder, + OrderAccountCommandTag::ConcludeOrder + )] + command_tag: OrderAccountCommandTag, +) { + let mut rng = make_seedable_rng(seed); + let time_getter = BasicTestTimeGetter::new().get_time_getter(); + + let genesis_extra_txo_amount = Amount::from_atoms(100_000_000 * CoinUnit::ATOMS_PER_COIN); + let extra_genesis_txo = TxOutput::Transfer( + OutputValue::Coin(genesis_extra_txo_amount), + Destination::AnyoneCanSpend, + ); + let pos_setup = PoSTestSetupBuilder::new() + .with_extra_genesis_txos(vec![ + extra_genesis_txo.clone(), + extra_genesis_txo.clone(), + extra_genesis_txo, + ]) + .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 token_mint_amount = Amount::from_atoms(rng.random_range(100..=1000)); + let token_issuance = random_token_issuance_v1_with_min_supply( + &chain_config, + Destination::AnyoneCanSpend, + token_mint_amount.into_atoms(), + &mut rng, + ); + + let genesis_outpoint1 = UtxoOutPoint::new(chain_config.genesis_block_id().into(), 1); + let genesis_outpoint2 = UtxoOutPoint::new(chain_config.genesis_block_id().into(), 2); + let genesis_outpoint3 = UtxoOutPoint::new(chain_config.genesis_block_id().into(), 3); + + let token_issuance_tx = TransactionBuilder::new() + .add_input(genesis_outpoint1.into(), InputWitness::NoSignature(None)) + .add_output(TxOutput::Transfer( + OutputValue::Coin( + (genesis_extra_txo_amount - chain_config.fungible_token_issuance_fee()).unwrap(), + ), + Destination::AnyoneCanSpend, + )) + .add_output(TxOutput::IssueFungibleToken(Box::new(TokenIssuance::V1( + token_issuance, + )))) + .build(); + let token_issuance_tx_id = token_issuance_tx.transaction().get_id(); + // 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), + token_issuance_tx.transaction().inputs(), + ) + .unwrap(); + + let token_mint_tx = TransactionBuilder::new() + // Use a utxo dep between token creation and token minting, because the purpose of this + // test is not to check token tx deps. + .add_input( + UtxoOutPoint::new(token_issuance_tx_id.into(), 0).into(), + InputWitness::NoSignature(None), + ) + .add_input( + TxInput::AccountCommand( + AccountNonce::new(0), + AccountCommand::MintTokens(token_id, token_mint_amount), + ), + InputWitness::NoSignature(None), + ) + .add_output(TxOutput::Transfer( + OutputValue::TokenV1(token_id, token_mint_amount), + Destination::AnyoneCanSpend, + )) + .build(); + let token_mint_tx_id = token_mint_tx.transaction().get_id(); + + let base_fee = rng.random_range(10..20); + let fees = vec![Amount::from_atoms(base_fee), Amount::from_atoms(base_fee * 10)]; + + // The order gives tokens for the same amount of coins. + let order_creation_tx = TransactionBuilder::new() + .add_input(genesis_outpoint2.into(), InputWitness::NoSignature(None)) + .add_input( + UtxoOutPoint::new(token_mint_tx_id.into(), 0).into(), + InputWitness::NoSignature(None), + ) + .add_output(TxOutput::CreateOrder(Box::new(OrderData::new( + Destination::AnyoneCanSpend, + OutputValue::Coin(token_mint_amount), + OutputValue::TokenV1(token_id, token_mint_amount), + )))) + .add_output(TxOutput::Transfer( + OutputValue::Coin((genesis_extra_txo_amount - fees[0]).unwrap()), + Destination::AnyoneCanSpend, + )) + .build(); + let order_id = make_order_id(order_creation_tx.inputs()).unwrap(); + + let order_command_tx = match command_tag { + OrderAccountCommandTag::FillOrder => TransactionBuilder::new() + .add_input(genesis_outpoint3.into(), InputWitness::NoSignature(None)) + .add_input( + TxInput::OrderAccountCommand(OrderAccountCommand::FillOrder( + order_id, + token_mint_amount, + )), + InputWitness::NoSignature(None), + ) + .add_output(TxOutput::Transfer( + OutputValue::Coin( + ((genesis_extra_txo_amount - token_mint_amount).unwrap() - fees[1]).unwrap(), + ), + Destination::AnyoneCanSpend, + )) + .add_output(TxOutput::Transfer( + OutputValue::TokenV1(token_id, token_mint_amount), + Destination::AnyoneCanSpend, + )) + .build(), + OrderAccountCommandTag::FreezeOrder => TransactionBuilder::new() + .add_input(genesis_outpoint3.into(), InputWitness::NoSignature(None)) + .add_input( + TxInput::OrderAccountCommand(OrderAccountCommand::FreezeOrder(order_id)), + InputWitness::NoSignature(None), + ) + .add_output(TxOutput::Transfer( + OutputValue::Coin((genesis_extra_txo_amount - fees[1]).unwrap()), + Destination::AnyoneCanSpend, + )) + .build(), + OrderAccountCommandTag::ConcludeOrder => TransactionBuilder::new() + .add_input(genesis_outpoint3.into(), InputWitness::NoSignature(None)) + .add_input( + TxInput::OrderAccountCommand(OrderAccountCommand::ConcludeOrder(order_id)), + InputWitness::NoSignature(None), + ) + .add_output(TxOutput::Transfer( + OutputValue::Coin((genesis_extra_txo_amount - fees[1]).unwrap()), + Destination::AnyoneCanSpend, + )) + .build(), + }; + + let preparation_txs = vec![token_issuance_tx, token_mint_tx]; + let expected_preparation_tx_ids = + preparation_txs.iter().map(|tx| tx.transaction().get_id()).collect_vec(); + + let test_txs = vec![order_creation_tx, order_command_tx]; + let expected_test_tx_ids = test_txs.iter().map(|tx| tx.transaction().get_id()).collect_vec(); + + log::debug!("fees = {fees:?}"); + log::debug!("expected_preparation_tx_ids = {expected_preparation_tx_ids:?}"); + log::debug!("expected_test_tx_ids = {expected_test_tx_ids:?}"); + + 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(); + }); + + let block_production = blockprod_setup.make_blockprod_builder().build(); + + let preparation_block_id = { + add_local_txs_to_mempool(&blockprod_setup.mempool, preparation_txs).await; + 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 new_block_id = new_block.get_id(); + + 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_preparation_tx_ids); + + new_block_id + }; + + mempool_wait_for_best_block(&blockprod_setup, preparation_block_id.into()).await; + + assert_fees(&blockprod_setup, &time_getter, &test_txs, &fees).await; + + add_local_txs_to_mempool(&blockprod_setup.mempool, test_txs).await; + + assert_ancestor_scores(&blockprod_setup, &expected_test_tx_ids, &fees).await; + + let input_data = pos_setup.make_next_block_input_data(preparation_block_id.into()); + + 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_test_tx_ids); + } + }); + + manager.main().await; + join_handle.await.unwrap(); +} + +fn reorder_fees( + fees: Vec, + fees_selection: FeesSelection, + rng: &mut impl CryptoRng, +) -> Vec { + match fees_selection { + FeesSelection::Increasing => { + assert_eq!(fees.clone().sorted(), fees); + 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(); + + let height = best_block_index.block_height().next_height(); + 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, height), + tx, + &BlockTimestamp::from_time(time_getter.get_time()), + ) + .unwrap() + .map_into_block_fees(&blockprod_setup.chain_config, height) + .unwrap(); + assert_eq!(actual_fee.0, *fee); + } +} + +// Assert that ancestor scores of the specified mempool txs have the same order as their fees. +async fn assert_ancestor_scores( + blockprod_setup: &BlockprodTestSetup, + tx_ids: &[Id], + fees: &[Amount], +) { + let tx_ids = tx_ids.to_vec(); + let scores = blockprod_setup + .mempool + .call(move |mp| { + tx_ids + .iter() + .map(|tx_id| mp.get_tx_score(tx_id).unwrap().unwrap()) + .collect_vec() + }) + .await + .unwrap(); + + log::debug!("scores = {scores:?}"); + + let fees_with_index = fees.iter().enumerate().collect_vec(); + let sorted_fees = fees_with_index + .sorted_by(|(_, fee1): &(_, &Amount), (_, fee2): &(_, &Amount)| fee1.cmp(fee2)); + let fees_sort_indices = sorted_fees.iter().map(|(idx, _)| idx).collect_vec(); + + let scores_with_index = scores.iter().enumerate().collect_vec(); + let sorted_scores = scores_with_index.sorted_by( + |(_, score1): &(_, &AncestorScore), (_, score2): &(_, &AncestorScore)| score1.cmp(score2), + ); + let score_sort_indices = sorted_scores.iter().map(|(idx, _)| idx).collect_vec(); + + assert_eq!(fees_sort_indices, score_sort_indices); +} + +async fn mempool_wait_for_best_block( + blockprod_setup: &BlockprodTestSetup, + expected_best_block_id: Id, +) { + let wait_loop = async { + loop { + let best_block_id = + blockprod_setup.mempool.call(move |mp| mp.best_block_id()).await.unwrap(); + + if best_block_id == expected_best_block_id { + break; + } + } + }; + + tokio::time::timeout(Duration::from_secs(10), wait_loop).await.unwrap(); +} diff --git a/blockprod/src/detail/timestamp_searcher/tests.rs b/blockprod/src/detail/timestamp_searcher/tests.rs index cf1da29d6..7c03e062a 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 c0d377760..b44164b5d 100644 --- a/blockprod/src/tests/helpers.rs +++ b/blockprod/src/tests/helpers.rs @@ -27,14 +27,14 @@ use chainstate_storage::inmemory::Store; use common::{ Uint256, Uint512, chain::{ - self, Block, ConsensusUpgrade, Destination, Genesis, NetUpgrades, OutPointSourceId, - PoSChainConfigBuilder, PoolId, TxInput, TxOutput, + self, Block, ConsensusUpgrade, Destination, GenBlock, Genesis, NetUpgrades, + OutPointSourceId, PoSChainConfigBuilder, PoolId, SignedTransaction, TxInput, TxOutput, block::timestamp::BlockTimestamp, config::{ChainConfig, ChainType, create_unit_test_config}, pos_initial_difficulty, stakelock::StakePoolData, }, - primitives::{Amount, BlockHeight, H256, Idable, per_thousand::PerThousand}, + primitives::{Amount, BlockHeight, H256, Id, Idable, per_thousand::PerThousand}, time_getter::{MonotonicTimeGetter, TimeGetter}, }; use consensus::{ @@ -42,7 +42,7 @@ use consensus::{ compact_target_to_target, }; use crypto::{ - key::{KeyKind, PrivateKey}, + key::{KeyKind, PrivateKey, PublicKey}, vrf::{VRFKeyKind, VRFPrivateKey}, }; use mempool::{MempoolConfig, MempoolHandle, MempoolInit}; @@ -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_default(); let chainstate = chainstate::make_chainstate( Arc::clone(&chain_config), @@ -246,6 +253,31 @@ impl PoSTestSetup { vec![self.create_genesis_pool_utxo.clone()], ))) } + + pub fn make_next_block_input_data( + &self, + parent_block_id: Id, + ) -> GenerateBlockInputData { + let genesis_pubkey = PublicKey::from_private_key(&self.genesis_stake_private_key); + GenerateBlockInputData::PoS(Box::new(PoSGenerateBlockInputData::new( + self.genesis_stake_private_key.clone(), + self.genesis_vrf_private_key.clone(), + PoolId::new(H256::zero()), + vec![TxInput::from_utxo(OutPointSourceId::BlockReward(parent_block_id), 0)], + vec![TxOutput::ProduceBlockFromStake( + Destination::PublicKey(genesis_pubkey), + PoolId::new(H256::zero()), + )], + ))) + } + + pub fn make_block_input_data(&self, parent_block_id: Id) -> GenerateBlockInputData { + if parent_block_id == self.chain_config.genesis_block_id() { + self.make_first_pos_block_input_data() + } else { + self.make_next_block_input_data(parent_block_id) + } + } } /// A builder that produces `PoSTestSetup`. @@ -493,3 +525,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 f100a53e8..f65531b86 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/chainstate/test-framework/src/transaction_builder.rs b/chainstate/test-framework/src/transaction_builder.rs index 6623b05bf..44ca5876b 100644 --- a/chainstate/test-framework/src/transaction_builder.rs +++ b/chainstate/test-framework/src/transaction_builder.rs @@ -16,7 +16,7 @@ use common::{ chain::{ Destination, Transaction, TxInput, TxOutput, output_value::OutputValue, - signature::inputsig::InputWitness, signed_transaction::SignedTransaction, + signature::inputsig::InputWitness, signed_transaction::SignedTransaction, tokens::TokenId, }, primitives::Amount, }; @@ -72,6 +72,23 @@ impl TransactionBuilder { self } + // Helper function to add a token transfer output only if the amount is non-zero (zero token + // transfers are no longer allowed after a fork). + pub fn add_token_transfer_output_if_non_zero( + mut self, + token_id: TokenId, + amount: Amount, + destination: Destination, + ) -> Self { + if amount != Amount::ZERO { + self = self.add_output(TxOutput::Transfer( + OutputValue::TokenV1(token_id, amount), + destination, + )); + } + self + } + pub fn add_output_n_times(mut self, n: usize, output: &TxOutput) -> Self { for _ in 0..n { self = self.add_output(output.clone()) diff --git a/chainstate/test-suite/src/tests/orders_tests.rs b/chainstate/test-suite/src/tests/orders_tests.rs index ac89bff95..876f5ef3a 100644 --- a/chainstate/test-suite/src/tests/orders_tests.rs +++ b/chainstate/test-suite/src/tests/orders_tests.rs @@ -1209,10 +1209,11 @@ fn fill_then_conclude(#[case] seed: Seed, #[case] version: OrdersVersion) { }; let tx = TransactionBuilder::new() .add_input(conclude_input, InputWitness::NoSignature(None)) - .add_output(TxOutput::Transfer( - OutputValue::TokenV1(token_id, (give_amount - filled_amount).unwrap()), + .add_token_transfer_output_if_non_zero( + token_id, + (give_amount - filled_amount).unwrap(), Destination::AnyoneCanSpend, - )) + ) .add_output(TxOutput::Transfer( OutputValue::Coin(fill_amount), Destination::AnyoneCanSpend, diff --git a/common/src/chain/transaction/account_outpoint.rs b/common/src/chain/transaction/account_outpoint.rs index 9034e9c0c..59d17f831 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 2372dbd8c..a833aef6e 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 e672ef18f..a420977e7 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 d786f1c5e..2d25b93e3 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 4fbe4561c..004897eda 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> { + 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 b23aa391b..49c2a5338 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 000000000..44f65bb4f --- /dev/null +++ b/mempool/src/pool/dependency.rs @@ -0,0 +1,432 @@ +// 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, OrderAccountCommand, OrderId, + PoolId, TokenIdGenerationVersion, Transaction, TxInput, TxOutput, make_delegation_id, + make_order_id, make_token_id_with_version, tokens::TokenId, + }, + primitives::Id, +}; +use utils::debug_panic_or_log; + +use crate::pool::entry::TxEntry; + +/// A dependency that is required by a transaction. +/// +/// Note: +/// * For accounts that have a nonce, the stored nonce is the one that is consumed by the +/// requiring tx. +/// * The deprecated order V0 commands are ignored. +/// * We avoid creating permanent pseudo-dependencies between transactions (such as putting all order +/// fills before the corresponding freeze and the freeze before the conclusion, or putting all +/// delegations before delegation withdrawals for the given delegation id); this would be redundant +/// and it would contradict the mempool's goal of selecting the most lucrative transactions for +/// inclusion in a block (also note that putting all fills before freeze/conclude would also be +/// exploitable - an attacker would be able to postpone freezing/conclusion of an order by flooding +/// the mempool with fills). +/// * For some of the dependencies, there is not much sense in tracking them because having both +/// txs in the mempool at the same time would be a pathological case. E.g. delegation creation +/// and delegation withdrawal don't make sense together. But we do track them, for completeness. +/// * The dependency of order commands on order creation is not redundant, at least in the +/// "freeze -> order creation" case - if an order has been created by mistake, the creator may +/// want to freeze it immediately (in the same block). +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, strum::EnumDiscriminants)] +#[strum_discriminants(name(TxRequiredDependencyTag), derive(strum::EnumIter))] +pub enum TxRequiredDependency { + TxOutput(Id, u32), + PoolCreation(PoolId), + DelegationCreation(DelegationId), + DelegationSpending(DelegationId, AccountNonce), + TokenCreation(TokenId), + TokenAccountManagement(TokenId, AccountNonce), + OrderCreation(OrderId), +} + +impl TxRequiredDependency { + pub fn from_tx(entry: &TxEntry) -> impl Iterator { + let from_inputs = entry.transaction().inputs().iter().flat_map(Self::from_input); + + let from_outputs = entry.transaction().outputs().iter().filter_map(Self::from_output); + + from_inputs.chain(from_outputs) + } + + 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::DelegationSpending(*delegation_id, acct.nonce())); + + if acct.nonce().value() == 0 { + result.push(Self::DelegationCreation(*delegation_id)); + } + } + }, + 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::TokenAccountManagement(*token_id, *nonce)); + + if nonce.value() == 0 { + result.push(Self::TokenCreation(*token_id)); + } + } + // Orders V0 are not tracked + AccountCommand::ConcludeOrder(_) | AccountCommand::FillOrder(_, _, _) => {} + } + } + TxInput::OrderAccountCommand(cmd) => { + let order_id = match cmd { + OrderAccountCommand::FillOrder(id, _) + | OrderAccountCommand::FreezeOrder(id) + | OrderAccountCommand::ConcludeOrder(id) => id, + }; + + result.push(Self::OrderCreation(*order_id)); + } + } + + result.into_iter() + } + + fn from_output(output: &TxOutput) -> Option { + match output { + TxOutput::CreateDelegationId(_, pool_id) => Some(Self::PoolCreation(*pool_id)), + TxOutput::DelegateStaking(_, delegation_id) => { + Some(Self::DelegationCreation(*delegation_id)) + } + + TxOutput::Transfer(_, _) + | TxOutput::LockThenTransfer(_, _, _) + | TxOutput::Burn(_) + | TxOutput::CreateStakePool(_, _) + | TxOutput::ProduceBlockFromStake(_, _) + | TxOutput::IssueFungibleToken(_) + | TxOutput::IssueNft(_, _, _) + | TxOutput::DataDeposit(_) + | TxOutput::Htlc(_, _) + | TxOutput::CreateOrder(_) => None, + } + } + + pub fn into_consumed(self) -> Option { + match self { + Self::TxOutput(tx_id, output_idx) => { + Some(TxConsumedDependency::TxOutput(tx_id, output_idx)) + } + Self::DelegationSpending(delegation_id, nonce) => Some( + TxConsumedDependency::DelegationSpending(delegation_id, nonce), + ), + Self::TokenAccountManagement(token_id, nonce) => Some( + TxConsumedDependency::TokenAccountManagement(token_id, nonce), + ), + Self::PoolCreation(_) + | Self::DelegationCreation(_) + | Self::TokenCreation(_) + | Self::OrderCreation(_) => 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), + DelegationSpending(DelegationId, AccountNonce), + TokenAccountManagement(TokenId, AccountNonce), +} + +/// A dependency that is provided by a transaction, not counting UTXO-based dependencies. +/// +/// 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 TxProvidedNonUtxoDependency { + PoolCreation(PoolId), + DelegationCreation(DelegationId), + DelegationSpending(DelegationId, AccountNonce), + TokenCreation(TokenId), + TokenAccountManagement(TokenId, AccountNonce), + OrderCreation(OrderId), +} + +impl TxProvidedNonUtxoDependency { + 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().filter_map(|output| { + Self::from_output(output, 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, 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 => {} + } + } + + // Note: an error shouldn't be possible here for a valid tx. + match make_token_id_with_version(TokenIdGenerationVersion::V1, inputs) { + Ok(token_id) => Some(Self::TokenCreation(token_id)), + Err(err) => { + debug_panic_or_log!( + "Error creating token id from inputs of tx {tx_id:x}: {err}" + ); + None + } + } + } + + TxOutput::CreateDelegationId(_, _) => { + // Note: an error shouldn't be possible here for a valid tx. + match make_delegation_id(inputs) { + Ok(delegation_id) => Some(Self::DelegationCreation(delegation_id)), + Err(err) => { + debug_panic_or_log!( + "Error creating delegation id from inputs of tx {tx_id:x}: {err}" + ); + None + } + } + } + + TxOutput::CreateStakePool(pool_id, _) => Some(Self::PoolCreation(*pool_id)), + + TxOutput::CreateOrder(_) => { + // Note: an error shouldn't be possible here for a valid tx. + match make_order_id(inputs) { + Ok(order_id) => Some(Self::OrderCreation(order_id)), + Err(err) => { + debug_panic_or_log!( + "Error creating order id from inputs of tx {tx_id:x}: {err}" + ); + None + } + } + } + + TxOutput::Transfer(_, _) + | TxOutput::LockThenTransfer(_, _, _) + | TxOutput::IssueNft(_, _, _) + | TxOutput::Htlc(_, _) + | TxOutput::ProduceBlockFromStake(_, _) + | TxOutput::Burn(_) + | TxOutput::DelegateStaking(_, _) + | TxOutput::DataDeposit(_) => None, + } + } + + fn from_account(account: &AccountSpending, nonce: AccountNonce) -> Option { + match account { + AccountSpending::DelegationBalance(delegation_id, _) => { + Some(Self::DelegationSpending(*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::TokenAccountManagement(*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 + // TxProvidedNonUtxoDependency's nonce is the providing tx's nonce plus one, which is what + // the requiring tx consumes. + match self { + Self::PoolCreation(pool_id) => TxRequiredDependency::PoolCreation(pool_id), + Self::DelegationCreation(delegation_id) => { + TxRequiredDependency::DelegationCreation(delegation_id) + } + Self::DelegationSpending(delegation_id, nonce) => { + TxRequiredDependency::DelegationSpending(delegation_id, nonce) + } + Self::TokenCreation(token_id) => TxRequiredDependency::TokenCreation(token_id), + Self::TokenAccountManagement(token_id, nonce) => { + TxRequiredDependency::TokenAccountManagement(token_id, nonce) + } + Self::OrderCreation(order_id) => TxRequiredDependency::OrderCreation(order_id), + } + } + + pub fn from_requirement(req: TxRequiredDependency) -> Option { + // Note: account nonces are put into TxProvidedNonUtxoDependency as is, this is because + // TxProvidedNonUtxoDependency's nonce is the providing tx's nonce plus one, which is what + // the requiring tx consumes. + match req { + TxRequiredDependency::TxOutput(_, _) => None, + TxRequiredDependency::PoolCreation(pool_id) => Some(Self::PoolCreation(pool_id)), + TxRequiredDependency::DelegationCreation(delegation_id) => { + Some(Self::DelegationCreation(delegation_id)) + } + TxRequiredDependency::DelegationSpending(delegation_id, nonce) => { + Some(Self::DelegationSpending(delegation_id, nonce)) + } + TxRequiredDependency::TokenCreation(token_id) => Some(Self::TokenCreation(token_id)), + TxRequiredDependency::TokenAccountManagement(token_id, nonce) => { + Some(Self::TokenAccountManagement(token_id, nonce)) + } + TxRequiredDependency::OrderCreation(order_id) => Some(Self::OrderCreation(order_id)), + } + } +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use randomness::RngExt; + use test_utils::random::{Seed, make_seedable_rng}; + + use super::*; + + #[rstest] + #[case(Seed::from_entropy())] + #[trace] + fn provided_required_dep_conversion(#[case] seed: Seed) { + let mut rng = make_seedable_rng(seed); + + for tag in TxRequiredDependencyTag::iter() { + match tag { + TxRequiredDependencyTag::TxOutput => { + let required = + TxRequiredDependency::TxOutput(Id::random_using(&mut rng), rng.random()); + let provided = TxProvidedNonUtxoDependency::from_requirement(required); + assert!(provided.is_none()); + } + TxRequiredDependencyTag::PoolCreation => { + let pool_id = Id::random_using(&mut rng); + let required = TxRequiredDependency::PoolCreation(pool_id); + let provided = + TxProvidedNonUtxoDependency::from_requirement(required.clone()).unwrap(); + assert_eq!(provided, TxProvidedNonUtxoDependency::PoolCreation(pool_id)); + let required_conv = provided.into_requirement(); + assert_eq!(required_conv, required); + } + TxRequiredDependencyTag::DelegationCreation => { + let delegation_id = Id::random_using(&mut rng); + let required = TxRequiredDependency::DelegationCreation(delegation_id); + let provided = + TxProvidedNonUtxoDependency::from_requirement(required.clone()).unwrap(); + assert_eq!( + provided, + TxProvidedNonUtxoDependency::DelegationCreation(delegation_id) + ); + let required_conv = provided.into_requirement(); + assert_eq!(required_conv, required); + } + TxRequiredDependencyTag::DelegationSpending => { + let delegation_id = Id::random_using(&mut rng); + let nonce = AccountNonce::new(rng.random()); + let required = TxRequiredDependency::DelegationSpending(delegation_id, nonce); + let provided = + TxProvidedNonUtxoDependency::from_requirement(required.clone()).unwrap(); + assert_eq!( + provided, + TxProvidedNonUtxoDependency::DelegationSpending(delegation_id, nonce) + ); + let required_conv = provided.into_requirement(); + assert_eq!(required_conv, required); + } + TxRequiredDependencyTag::TokenCreation => { + let token_id = Id::random_using(&mut rng); + let required = TxRequiredDependency::TokenCreation(token_id); + let provided = + TxProvidedNonUtxoDependency::from_requirement(required.clone()).unwrap(); + assert_eq!( + provided, + TxProvidedNonUtxoDependency::TokenCreation(token_id) + ); + let required_conv = provided.into_requirement(); + assert_eq!(required_conv, required); + } + TxRequiredDependencyTag::TokenAccountManagement => { + let token_id = Id::random_using(&mut rng); + let nonce = AccountNonce::new(rng.random()); + let required = TxRequiredDependency::TokenAccountManagement(token_id, nonce); + let provided = + TxProvidedNonUtxoDependency::from_requirement(required.clone()).unwrap(); + assert_eq!( + provided, + TxProvidedNonUtxoDependency::TokenAccountManagement(token_id, nonce) + ); + let required_conv = provided.into_requirement(); + assert_eq!(required_conv, required); + } + TxRequiredDependencyTag::OrderCreation => { + let order_id = Id::random_using(&mut rng); + let required = TxRequiredDependency::OrderCreation(order_id); + let provided = + TxProvidedNonUtxoDependency::from_requirement(required.clone()).unwrap(); + assert_eq!( + provided, + TxProvidedNonUtxoDependency::OrderCreation(order_id) + ); + let required_conv = provided.into_requirement(); + assert_eq!(required_conv, required); + } + } + } + } +} diff --git a/mempool/src/pool/entry.rs b/mempool/src/pool/entry.rs index 0cf04c566..39f3ec2ae 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::{TxProvidedNonUtxoDependency, 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) - } - - /// 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) + pub fn required_deps(&self) -> impl Iterator + '_ { + TxRequiredDependency::from_tx(self) } - fn inputs_iter(&self) -> impl ExactSizeIterator + '_ { - self.transaction().inputs().iter() + /// Dependency graph edges this entry provides, not including utxos. + pub fn provided_non_utxo_deps(&self) -> impl Iterator + '_ { + TxProvidedNonUtxoDependency::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 5a0d23413..fe275ae99 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 147b8e44e..c6614d277 100644 --- a/mempool/src/pool/orphans/mod.rs +++ b/mempool/src/pool/orphans/mod.rs @@ -21,8 +21,8 @@ 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::TxRequiredDependency, tx_origin::RemoteTxOrigin}; pub use detect::OrphanType; mod detect; @@ -66,8 +66,8 @@ 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 their required dependencies + by_deps: BTreeSet<(TxRequiredDependency, InternalId)>, /// Transactions indexed by the origin by_origin: BTreeSet<(RemoteTxOrigin, InternalId)>, @@ -93,7 +93,7 @@ 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_deps.extend(entry.required_deps().map(|dep| (dep, iid))); } fn remove(&mut self, entry: &TxEntry) { @@ -105,9 +105,9 @@ impl TxOrphanPoolMaps { let removed = self.by_origin.remove(&(entry.origin(), iid)); assert!(removed, "Tx entry not present in the origin map"); - entry.requires().for_each(|dep| { + entry.required_deps().for_each(|dep| { self.by_deps.remove(&(dep, iid)); - }) + }); } } @@ -157,12 +157,29 @@ impl TxOrphanPool { &'a self, entry: &'a super::TxEntry, ) -> impl Iterator + 'a { - entry.provides().flat_map(move |dep| { + let utxo_deps = self + .maps + .by_deps + .range( + ( + TxRequiredDependency::TxOutput(*entry.tx_id(), 0), + InternalId::ZERO, + ) + ..=( + TxRequiredDependency::TxOutput(*entry.tx_id(), u32::MAX), + InternalId::MAX, + ), + ) + .map(|(_, iid)| self.get_at(*iid)); + let non_utxo_deps = entry.provided_non_utxo_deps().flat_map(move |provided_dep| { + let required_dep = provided_dep.into_requirement(); self.maps .by_deps - .range((dep.clone(), InternalId::ZERO)..=(dep, InternalId::MAX)) + .range((required_dep.clone(), InternalId::ZERO)..=(required_dep, InternalId::MAX)) .map(|(_, iid)| self.get_at(*iid)) - }) + }); + + utxo_deps.chain(non_utxo_deps) } /// Number of transactions in the orphan pool @@ -305,17 +322,27 @@ 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, 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.required_deps().any(|dep| match dep { + TxRequiredDependency::TxOutput(tx_id, _) => { + self.pool.maps.by_tx_id.contains_key(&tx_id) + } + + // Always consider account deps. + // Note: simply checking whether any of the orphan txs' provided dependencies are + // required by this tx (like we do in the utxo case) won't work because there can be + // multiple potential parents for it in the orphan pool; if one of the parents becomes + // non-orphan and the other stays in the orphan pool, it will prevent the child from + // entering the normal mempool even though in reality it's no longer an orphan. + TxRequiredDependency::DelegationSpending(_, _) + | TxRequiredDependency::TokenAccountManagement(_, _) + | TxRequiredDependency::PoolCreation(_) + | TxRequiredDependency::DelegationCreation(_) + | TxRequiredDependency::TokenCreation(_) + | TxRequiredDependency::OrderCreation(_) => false, }) } diff --git a/mempool/src/pool/orphans/test.rs b/mempool/src/pool/orphans/test.rs index ae2d003f3..c26ecf83e 100644 --- a/mempool/src/pool/orphans/test.rs +++ b/mempool/src/pool/orphans/test.rs @@ -50,7 +50,7 @@ fn check_integrity(orphans: &TxOrphanPool) { ); }); orphans.maps.by_deps.iter().for_each(|(dep, iid)| { - let tx_dep = orphans.get_at(*iid).requires().find(|r| r == dep); + let tx_dep = orphans.get_at(*iid).required_deps().find(|r| r == dep); assert!(tx_dep.is_some(), "Entry {iid:?} outpoint missing"); }); } @@ -103,7 +103,7 @@ fn insert_and_delete(#[case] seed: Seed) { let entry = random_tx_entry(&mut rng); let tx_id = *entry.tx_id(); - let n_deps = BTreeSet::from_iter(entry.requires()).len(); + let n_deps = BTreeSet::from_iter(entry.required_deps()).len(); assert_eq!(orphans.insert(entry), Ok(TxStatus::InOrphanPool)); diff --git a/mempool/src/pool/tests/basic.rs b/mempool/src/pool/tests/basic.rs index da4b1a353..748cc2135 100644 --- a/mempool/src/pool/tests/basic.rs +++ b/mempool/src/pool/tests/basic.rs @@ -18,7 +18,10 @@ use std::time::Duration; use tokio::sync::mpsc; use chainstate::BlockSource; -use common::chain::block::timestamp::BlockTimestamp; +use chainstate_test_framework::helpers::split_utxo; +use common::chain::{ + AccountNonce, AccountOutPoint, AccountSpending, UtxoOutPoint, block::timestamp::BlockTimestamp, +}; use crate::event::NewTipEvent; @@ -115,7 +118,7 @@ async fn one_ancestor_replaceability_signal_is_enough(#[case] seed: Seed) -> any )?, vec![InputWitness::NoSignature(Some(DUMMY_WITNESS_MSG.to_vec()))], ) - .expect("invalid witness count"); + .unwrap(); let result = mempool.add_transaction_test(replacing_tx); if ENABLE_RBF { @@ -374,3 +377,142 @@ async fn ibd_transition(#[case] seed: Seed) { let event = events_broadcast_rx.recv().await; assert_eq!(event.as_ref(), Some(&expected_event)); } + +// Regression test: `orphans::PoolEntry::is_ready` should not return false if a non-utxo parent +// is present in the orphans pool. +// * Set up a pool and a delegation. +// * Create 2 grandparent txs. +// * Create and add 3 orphan txs - 2 parent ones that use nonce 0 and a child one that uses nonce 1. +// There are no utxo-based relationships between the child and each of its potential parents. +// Each parent depends on the corresponding grandparent via a utxo relationship. +// * Add the first grandparent tx. +// Expected result: the second parent is still an orphan but the first parent and the child are +// no longer orphans. +#[rstest] +#[case(Seed::from_entropy())] +#[trace] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn non_utxo_orphan_dependency_can_be_resolved_by_mempool_parent(#[case] seed: Seed) { + let mut rng = make_seedable_rng(seed); + + let mut tf = TestFramework::builder(&mut rng).build(); + + let genesis_outpoint = UtxoOutPoint::new(tf.genesis().get_id().into(), 0); + let min_pledge = tf.chain_config().min_stake_pool_pledge().into_atoms(); + let pool_size = Amount::from_atoms(rng.random_range(min_pledge..min_pledge * 2)); + let delegation_size = Amount::from_atoms(rng.random_range(min_pledge / 2..min_pledge * 2)); + let (_, delegation_id, change_outpoint) = setup_pool_and_delegation( + &mut rng, + &mut tf, + genesis_outpoint, + pool_size, + delegation_size, + ); + + let tx_with_coins_id = split_utxo(&mut rng, &mut tf, change_outpoint, 2); + + let parent_delegation_spend_amount = Amount::from_atoms(rng.random_range(1_000_000..2_000_000)); + let child_delegation_spend_amount = Amount::from_atoms(rng.random_range(1_000_000..2_000_000)); + + let grandparents_with_amounts = (0..2) + .map(|grandparent_idx| { + let input_utxo = UtxoOutPoint::new(tx_with_coins_id.into(), grandparent_idx); + let input_amount = tf.coin_amount_from_utxo(&input_utxo); + + let output_amount = + (input_amount - get_relay_fee_from_tx_size(estimate_tx_size(1, 1))).unwrap(); + let tx = TransactionBuilder::new() + .add_input(input_utxo.into(), InputWitness::NoSignature(None)) + .add_output(TxOutput::Transfer( + OutputValue::Coin(output_amount), + Destination::AnyoneCanSpend, + )) + .build(); + (tx, output_amount) + }) + .collect::>(); + + let parents = (0..2) + .map(|idx| { + let (grandparent, grandparent_amount) = &grandparents_with_amounts[idx]; + TransactionBuilder::new() + .add_input( + UtxoOutPoint::new(grandparent.transaction().get_id().into(), 0).into(), + InputWitness::NoSignature(None), + ) + .add_input( + TxInput::Account(AccountOutPoint::new( + AccountNonce::new(0), + AccountSpending::DelegationBalance( + delegation_id, + parent_delegation_spend_amount, + ), + )), + InputWitness::NoSignature(None), + ) + .add_output(TxOutput::Transfer( + OutputValue::Coin( + ((*grandparent_amount + parent_delegation_spend_amount).unwrap() + - get_relay_fee_from_tx_size(estimate_tx_size(2, 1))) + .unwrap(), + ), + Destination::AnyoneCanSpend, + )) + .build() + }) + .collect::>(); + + let child = TransactionBuilder::new() + .add_input( + TxInput::Account(AccountOutPoint::new( + AccountNonce::new(1), + AccountSpending::DelegationBalance(delegation_id, child_delegation_spend_amount), + )), + InputWitness::NoSignature(None), + ) + .add_output(TxOutput::Transfer( + OutputValue::Coin( + (child_delegation_spend_amount + - get_relay_fee_from_tx_size(estimate_tx_size(1, 1))) + .unwrap(), + ), + Destination::AnyoneCanSpend, + )) + .build(); + let child_id = child.transaction().get_id(); + + let parent1_id = parents[0].transaction().get_id(); + let parent2_id = parents[1].transaction().get_id(); + + let mut mempool = setup_with_chainstate(tf.chainstate()); + + mempool + .add_transaction_test(parents[0].clone()) + .unwrap() + .assert_in_orphan_pool(); + mempool + .add_transaction_test(parents[1].clone()) + .unwrap() + .assert_in_orphan_pool(); + mempool.add_transaction_test(child).unwrap().assert_in_orphan_pool(); + assert_eq!(mempool.work_queue_total_len(), 0); + + mempool + .add_transaction_test(grandparents_with_amounts[0].0.clone()) + .unwrap() + .assert_in_mempool(); + + // parent2 is still an orphan + assert!(mempool.contains_orphan_transaction(&parent2_id)); + assert!(!mempool.contains_transaction(&parent2_id)); + + // parent1 is no longer an orphan + assert!(!mempool.contains_orphan_transaction(&parent1_id)); + assert!(mempool.contains_transaction(&parent1_id)); + + // The child is no longer an orphan + assert!(!mempool.contains_orphan_transaction(&child_id)); + assert!(mempool.contains_transaction(&child_id)); + + mempool.tx_store().assert_valid(); +} diff --git a/mempool/src/pool/tests/utils.rs b/mempool/src/pool/tests/utils.rs index f80c3f204..0a24912e4 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/collect_txs.rs b/mempool/src/pool/tx_pool/collect_txs.rs index 2078c8e7c..85c4214ee 100644 --- a/mempool/src/pool/tx_pool/collect_txs.rs +++ b/mempool/src/pool/tx_pool/collect_txs.rs @@ -198,24 +198,21 @@ pub fn collect_txs( tx_verifier.connect_transaction(&tx_source, next_tx.transaction(), &unlock_timestamp); if let Err(err) = verification_result { - // TODO: this will fire because "parents" only reflect utxo-based relationships and not: - // 1) account-nonce-based ones - token commands and delegation withdrawals. - // 2) token creation vs token commands. - // 3) order creation vs order commands (though it's not a super useful scenario). - // 4) delegation id creation vs the delegation itself. - // 5) delegation id creation vs delegation withdrawal (not a super useful scenario). - // 6) pool creation vs delegation id creation. - // Need to update TxDependency to handle these relationships too and use TxDependency - // when determining "parents" for TxMempoolEntry. - // Also see https://github.com/mintlayer/mintlayer-core/issues/2065. - // The old TODO goes below. - - // TODO Narrow down when the critical error is presented. Printing the error may be a - // false positive if the tip moves during the execution of this function. - log::error!( - "CRITICAL: Verifier and mempool do not agree on transaction deps for {}: {err}", - next_tx.tx_id() - ); + // Note: it's still possible to get here because: + // 1) Order V1 commands are not ordered with respect to each other, so it's possible + // that e.g. a freeze will be selected before a fill, after which the fill will no + // longer be valid. + // 2) Delegations are not ordered with respect to delegation withdrawals, so it's possible + // that a withdrawal is selected first and fails because the delegation has insufficient + // balance. + // TODO: the latter is a pathological case and probably isn't worth bothering about, + // but the former one should probably be fixed eventually, because otherwise blockprod + // may miss some lucrative transactions. One way to fix this is to introduce temporary + // pseudo-dependencies between transactions (e.g. where an order freeze depends on all its + // fills and a delegation withdrawal depends on all delegations); then, when a tx is selected + // but fails to be added to the tx verifier, this function may check if already selected txs + // have pseudo-dependencies on this tx and reorder the txs accordingly. + log::debug!("Selected tx {:x} failed validation: {err}", next_tx.tx_id()); continue; } diff --git a/mempool/src/pool/tx_pool/mod.rs b/mempool/src/pool/tx_pool/mod.rs index 9dbc5aea6..182fbbe71 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 + .required_deps() + .filter_map(TxRequiredDependency::into_consumed) + .filter_map(|dep| self.store.find_conflicting_tx(&dep)) } fn spends_unconfirmed(&self, input: &TxInput) -> bool { @@ -908,7 +914,7 @@ impl TxPool { let result = connect_result .and_then(|fee| { let fee = fee - .map_into_block_fees(self.chain_config.as_ref(), current_best.block_height()) + .map_into_block_fees(self.chain_config.as_ref(), effective_height) .map_err(|e| { let outpt = tx_id.into(); ConnectTransactionError::ConstrainedValueAccumulatorError(e, outpt) @@ -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 7fd0a423b..b125a3bd8 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 4381589b3..f67e9072d 100644 --- a/mempool/src/pool/tx_pool/store/mod.rs +++ b/mempool/src/pool/tx_pool/store/mod.rs @@ -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, TxProvidedNonUtxoDependency, TxRequiredDependency}, + tx_pool::store::mem_usage::MemUsageTracker, + }, }; pub use mem_usage::Tracked; @@ -150,8 +153,11 @@ pub struct MempoolStore { txs_by_creation_time: TrackedTxIdMultiMap