diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e8ee268b8..f600b7def3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,14 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/ of a cluster that exceeds the limits. The default cluster limits are: max 64 transactions, max 100'000 bytes in total. + - Consensus: + - Tightening of certain consensus rules have been implemented, in particular: + - When changing token metadata URI, the URI can no longer contain arbitrary characters (the restrictions are + the same as when a token is created). + - Transferring or burning zero amount of a token is no longer allowed. + + (The actual consensus tightening will happen after a fork, the height is yet to be decided.) + ## [1.3.1] - 2026-06-03 ### Fixed diff --git a/chainstate/src/detail/ban_score.rs b/chainstate/src/detail/ban_score.rs index dce7b16e8e..c39d4f4bf2 100644 --- a/chainstate/src/detail/ban_score.rs +++ b/chainstate/src/detail/ban_score.rs @@ -144,6 +144,7 @@ impl BanScore for ConnectTransactionError { ConnectTransactionError::ConcludeInputAmountsDontMatch(_, _) => 100, ConnectTransactionError::ProduceBlockFromStakeChangesStakerDestination(_, _) => 100, ConnectTransactionError::IdCreationError(err) => err.ban_score(), + ConnectTransactionError::ZeroTokenTransfer(_) => 100, } } } @@ -358,6 +359,7 @@ impl BanScore for TokensError { TokensError::InvariantBrokenUndoIssuanceOnNonexistentToken(_) => 100, TokensError::InvariantBrokenRegisterIssuanceWithDuplicateId(_) => 100, TokensError::TokenMetadataUriTooLarge(_) => 100, + TokensError::IncorrectMetadataUri(_) => 100, } } } @@ -476,6 +478,7 @@ impl BanScore for ConsensusPoSError { ConsensusPoSError::EmptyTimespan => 100, ConsensusPoSError::FailedToCalculateCappedBalance => 100, ConsensusPoSError::InvalidOutputTypeInStakeKernel(_) => 100, + ConsensusPoSError::PoolIdsInKernelUtxoAndPoSDataMismatch { .. } => 100, } } } diff --git a/chainstate/src/detail/error_classification.rs b/chainstate/src/detail/error_classification.rs index ecdde43ce8..bfe3245970 100644 --- a/chainstate/src/detail/error_classification.rs +++ b/chainstate/src/detail/error_classification.rs @@ -304,9 +304,8 @@ impl BlockProcessingErrorClassification for ConnectTransactionError { | ConnectTransactionError::InsufficientCoinsFee(_, _) | ConnectTransactionError::AttemptToSpendFrozenToken(_) | ConnectTransactionError::ConcludeInputAmountsDontMatch(_, _) - | ConnectTransactionError::ProduceBlockFromStakeChangesStakerDestination(_, _) => { - BlockProcessingErrorClass::BadBlock - } + | ConnectTransactionError::ProduceBlockFromStakeChangesStakerDestination(_, _) + | ConnectTransactionError::ZeroTokenTransfer(_) => BlockProcessingErrorClass::BadBlock, ConnectTransactionError::StorageError(err) => err.classify(), ConnectTransactionError::UtxoError(err) => err.classify(), @@ -494,6 +493,7 @@ impl BlockProcessingErrorClassification for TokensError { | TokensError::CoinOrTokenOverflow(_) | TokensError::InsufficientTokenFees(_) | TokensError::TokenMetadataUriTooLarge(_) + | TokensError::IncorrectMetadataUri(_) | TokensError::InvariantBrokenUndoIssuanceOnNonexistentToken(_) | TokensError::InvariantBrokenRegisterIssuanceWithDuplicateId(_) => { BlockProcessingErrorClass::BadBlock @@ -663,7 +663,8 @@ impl BlockProcessingErrorClassification for ConsensusPoSError { | ConsensusPoSError::FiniteTotalSupplyIsRequired | ConsensusPoSError::UnsupportedConsensusVersion | ConsensusPoSError::FailedToCalculateCappedBalance - | ConsensusPoSError::InvalidOutputTypeInStakeKernel(_) => { + | ConsensusPoSError::InvalidOutputTypeInStakeKernel(_) + | ConsensusPoSError::PoolIdsInKernelUtxoAndPoSDataMismatch { .. } => { BlockProcessingErrorClass::BadBlock } diff --git a/chainstate/src/detail/mod.rs b/chainstate/src/detail/mod.rs index 6b9980073a..7ee1be69fc 100644 --- a/chainstate/src/detail/mod.rs +++ b/chainstate/src/detail/mod.rs @@ -86,7 +86,7 @@ pub use self::{ median_time::calculate_median_time_past_from_blocktimestamps, }; pub use chainstate_types::Locator; -pub use chainstateref::NonZeroPoolBalances; +pub use chainstateref::{EpochSealError, NonZeroPoolBalances}; pub use error::{ BlockError, CheckBlockError, CheckBlockTransactionsError, DbCommittingContext, InitializationError, OrphanCheckError, StorageCompatibilityCheckError, diff --git a/chainstate/src/lib.rs b/chainstate/src/lib.rs index 43322e41bf..2ca6b9b613 100644 --- a/chainstate/src/lib.rs +++ b/chainstate/src/lib.rs @@ -39,9 +39,9 @@ pub use crate::{ detail::{ BlockError, BlockProcessingErrorClass, BlockProcessingErrorClassification, BlockSource, ChainInfo, CheckBlockError, CheckBlockTransactionsError, ConnectTransactionError, - IOPolicyError, InitializationError, Locator, MEDIAN_TIME_SPAN, NonZeroPoolBalances, - OrphanCheckError, SpendStakeError, StorageCompatibilityCheckError, TokenIssuanceError, - TokensError, TransactionVerifierStorageError, ban_score, + EpochSealError, IOPolicyError, InitializationError, Locator, MEDIAN_TIME_SPAN, + NonZeroPoolBalances, OrphanCheckError, SpendStakeError, StorageCompatibilityCheckError, + TokenIssuanceError, TokensError, TransactionVerifierStorageError, ban_score, block_invalidation::BlockInvalidatorError, bootstrap::BootstrapError, calculate_median_time_past, calculate_median_time_past_from_blocktimestamps, }, diff --git a/chainstate/test-suite/src/tests/fungible_tokens_v1.rs b/chainstate/test-suite/src/tests/fungible_tokens_v1.rs index a2736b4aea..3f7122bbb3 100644 --- a/chainstate/test-suite/src/tests/fungible_tokens_v1.rs +++ b/chainstate/test-suite/src/tests/fungible_tokens_v1.rs @@ -30,9 +30,10 @@ use chainstate_test_framework::{ }; use common::{ chain::{ - AccountCommand, AccountNonce, AccountType, Block, ChainstateUpgradeBuilder, Destination, - GenBlock, OrderAccountCommand, OrderData, OutPointSourceId, SignedTransaction, Transaction, - TxInput, TxOutput, UtxoOutPoint, + self, AccountCommand, AccountNonce, AccountType, Block, ChainstateUpgradeBuilder, + ChangeTokenMetadataUriValidityCheckRequired, Destination, GenBlock, NetUpgrades, + OrderAccountCommand, OrderData, OutPointSourceId, SignedTransaction, Transaction, TxInput, + TxOutput, TxOutputTag, UtxoOutPoint, ZeroTokenTransferForbidden, htlc::{HashedTimelockContract, HtlcSecret}, make_order_id, make_token_id, output_value::OutputValue, @@ -50,9 +51,10 @@ use common::{ primitives::{Amount, BlockHeight, CoinOrTokenId, Id, Idable, amount::SignedAmount}, }; use crypto::key::{KeyKind, PrivateKey}; -use randomness::{CryptoRng, RngExt as _}; +use randomness::{CryptoRng, Rng, RngExt as _}; +use strum::IntoEnumIterator as _; use test_utils::{ - assert_matches_return_val, gen_text_with_non_ascii, + assert_matches, assert_matches_return_val, gen_text_with_non_ascii, random::{Seed, make_seedable_rng}, random_ascii_alphanumeric_string, split_value, }; @@ -6677,6 +6679,164 @@ fn check_change_metadata_uri(#[case] seed: Seed) { }); } +// Historically, we allowed changing token's metadata uri to one with invalid (non-alphanum and +// non-rfc3986) chars even though issuing a token with such a uri would fail. After the corresponding +// fork, ChangeTokenMetadataUri commands having a uri with invalid chars are no longer allowed. +#[rstest] +#[trace] +#[case(Seed::from_entropy())] +fn check_change_metadata_uri_invalid_chars(#[case] seed: Seed) { + utils::concurrency::model(move || { + let mut rng = make_seedable_rng(seed); + + // We'll be creating 1 block after the genesis, so the fork height should be at least 2. + let fork_height = BlockHeight::new(rng.random_range(2..=5)); + let chain_config = chain::config::create_unit_test_config_builder() + .chainstate_upgrades( + NetUpgrades::initialize(vec![ + ( + BlockHeight::zero(), + ChainstateUpgradeBuilder::latest() + .change_token_metadata_uri_validity_check_required( + ChangeTokenMetadataUriValidityCheckRequired::No, + ) + .build(), + ), + ( + fork_height, + ChainstateUpgradeBuilder::latest() + .change_token_metadata_uri_validity_check_required( + ChangeTokenMetadataUriValidityCheckRequired::Yes, + ) + .build(), + ), + ]) + .unwrap(), + ) + .build(); + + let mut tf = TestFramework::builder(&mut rng).with_chain_config(chain_config).build(); + + let (token_id, issuance_block_id, issuance_tx, issuance, mut utxo_with_change) = + issue_token_from_genesis( + &mut rng, + &mut tf, + TokenTotalSupply::Lockable, + IsTokenFreezable::No, + ); + let issuance_v1 = + assert_matches_return_val!(issuance, TokenIssuance::V1(issuance), issuance); + let uri_with_invalid_chars = "https://💖🚁🌭.🦠🚀🚖🚧"; + let fee = tf.chain_config().token_change_metadata_uri_fee(); + + let mut next_nonce = AccountNonce::new(0); + let mut coins_amount = tf.coin_amount_from_utxo(&utxo_with_change); + + // Before the fork, changing the uri to one with invalid chars is allowed. + { + let initial_block_height = tf.best_block_index().block_height().next_height(); + let block_count = (fork_height - initial_block_height).unwrap().to_int(); + for i in 0..block_count { + coins_amount = (coins_amount - fee).unwrap(); + + // Make sure the uri is different every time. + let new_metadata_uri = format!("{uri_with_invalid_chars}{i}").as_bytes().to_vec(); + + let tx = TransactionBuilder::new() + .add_input( + TxInput::from_command( + next_nonce, + AccountCommand::ChangeTokenMetadataUri( + token_id, + new_metadata_uri.clone(), + ), + ), + InputWitness::NoSignature(None), + ) + .add_input(utxo_with_change.into(), InputWitness::NoSignature(None)) + .add_output(TxOutput::Transfer( + OutputValue::Coin(coins_amount), + Destination::AnyoneCanSpend, + )) + .build(); + + let tx_id = tx.transaction().get_id(); + utxo_with_change = UtxoOutPoint::new(tx_id.into(), 0); + next_nonce = next_nonce.increment().unwrap(); + + tf.make_block_builder() + .add_transaction(tx) + .build_and_process(&mut rng) + .unwrap() + .unwrap(); + + check_fungible_token( + &tf, + &mut rng, + &token_id, + &ExpectedFungibleTokenData { + issuance: TokenIssuance::V1(TokenIssuanceV1 { + token_ticker: issuance_v1.token_ticker.clone(), + number_of_decimals: issuance_v1.number_of_decimals, + metadata_uri: new_metadata_uri, + total_supply: issuance_v1.total_supply, + authority: issuance_v1.authority.clone(), + is_freezable: issuance_v1.is_freezable, + }), + issuance_tx: issuance_tx.clone(), + issuance_block_id, + circulating_supply: None, + is_locked: false, + is_frozen: IsTokenFrozen::No(IsTokenFreezable::No), + }, + true, + ); + } + } + + // Sanity check + { + let new_block_height = tf.best_block_index().block_height().next_height(); + assert_eq!(new_block_height, fork_height); + } + + // After the fork, changing the uri to one with invalid chars is forbidden. + let result = tf + .make_block_builder() + .add_transaction( + TransactionBuilder::new() + .add_input( + TxInput::from_command( + next_nonce, + AccountCommand::ChangeTokenMetadataUri( + token_id, + uri_with_invalid_chars.as_bytes().to_vec(), + ), + ), + InputWitness::NoSignature(None), + ) + .add_input( + utxo_with_change.clone().into(), + InputWitness::NoSignature(None), + ) + .build(), + ) + .build_and_process(&mut rng); + assert_eq!( + result.unwrap_err(), + ChainstateError::ProcessBlockError(BlockError::CheckBlockFailed( + chainstate::CheckBlockError::CheckTransactionFailed( + chainstate::CheckBlockTransactionsError::CheckTransactionError( + tx_verifier::CheckTransactionError::TokensError( + TokensError::IncorrectMetadataUri(token_id) + ) + ) + ) + )) + ); + }); +} + #[rstest] #[trace] #[case(Seed::from_entropy())] @@ -7530,47 +7690,219 @@ fn token_id_generation_v1_activation(#[case] seed: Seed) { }); } -// Transferring zero tokens is allowed. -// TODO: perhaps we should prohibit it? +// Transferring zero tokens is allowed before the corresponding fork and forbidden after. #[rstest] -#[trace] #[case(Seed::from_entropy())] +#[trace] fn zero_amount_transfer(#[case] seed: Seed) { utils::concurrency::model(move || { let mut rng = make_seedable_rng(seed); - let mut tf = TestFramework::builder(&mut rng).build(); - let (token_id, _, _, _, utxo_with_change) = issue_token_from_genesis( + // We'll be creating 1 or 2 blocks after the genesis, so the fork height should be at least 3. + let fork_height = BlockHeight::new(rng.random_range(3..=5)); + let chain_config = chain::config::create_unit_test_config_builder() + .chainstate_upgrades( + NetUpgrades::initialize(vec![ + ( + BlockHeight::zero(), + ChainstateUpgradeBuilder::latest() + .zero_token_transfer_forbidden(ZeroTokenTransferForbidden::No) + .build(), + ), + ( + fork_height, + ChainstateUpgradeBuilder::latest() + .zero_token_transfer_forbidden(ZeroTokenTransferForbidden::Yes) + .build(), + ), + ]) + .unwrap(), + ) + .build(); + + let mut tf = TestFramework::builder(&mut rng).with_chain_config(chain_config).build(); + + let (real_token_id, _, _, _, utxo_with_change) = issue_token_from_genesis( &mut rng, &mut tf, TokenTotalSupply::Unlimited, IsTokenFreezable::No, ); - let tx = TransactionBuilder::new() - .add_input(utxo_with_change.into(), InputWitness::NoSignature(None)) - .add_output(TxOutput::Transfer( - OutputValue::TokenV1(token_id, Amount::ZERO), - Destination::AnyoneCanSpend, - )) - .build(); + // Optionally, mint some tokens. + let mut utxo_with_change = if rng.random_bool(0.5) { + let amount_to_mint = Amount::from_atoms(rng.random_range(1..100_000)); + let best_block_id = tf.best_block_id(); + let (_, mint_tx_id) = mint_tokens_in_block( + &mut rng, + &mut tf, + best_block_id, + utxo_with_change, + real_token_id, + amount_to_mint, + true, + ); - tf.make_block_builder() - .add_transaction(tx) - .build_and_process(&mut rng) - .unwrap() - .unwrap(); + UtxoOutPoint::new(mint_tx_id.into(), 1) + } else { + utxo_with_change + }; + let coins_amount = tf.coin_amount_from_utxo(&utxo_with_change); + + let bogus_token_id = TokenId::random_using(&mut rng); + + let zero_transfer_outputs_with_real_token = + make_zero_transfer_outputs_for_token_zero_amount_transfer_test( + &real_token_id, + &mut rng, + ); + let zero_transfer_outputs_with_bogus_token = + make_zero_transfer_outputs_for_token_zero_amount_transfer_test( + &bogus_token_id, + &mut rng, + ); + + // Before the fork zero transfers are allowed. + { + let initial_block_height = tf.best_block_index().block_height().next_height(); + for _ in initial_block_height.iter_up_to(fork_height) { + let mut block_builder = tf.make_block_builder(); + + for tx_output in zero_transfer_outputs_with_real_token + .iter() + .chain(zero_transfer_outputs_with_bogus_token.iter()) + { + let tx = TransactionBuilder::new() + .add_input(utxo_with_change.into(), InputWitness::NoSignature(None)) + .add_output(TxOutput::Transfer( + OutputValue::Coin(coins_amount), + Destination::AnyoneCanSpend, + )) + .add_output(tx_output.clone()) + .build(); + let tx_id = tx.transaction().get_id(); + + block_builder = block_builder.add_transaction(tx); + + utxo_with_change = UtxoOutPoint::new(tx_id.into(), 0) + } + + block_builder.build_and_process(&mut rng).unwrap().unwrap(); + } + } + + // Sanity check + { + let new_block_height = tf.best_block_index().block_height().next_height(); + assert_eq!(new_block_height, fork_height); + } + + // After the fork zero transfers are not allowed. + { + for tx_output in zero_transfer_outputs_with_real_token + .iter() + .chain(zero_transfer_outputs_with_bogus_token.iter()) + { + let tx = TransactionBuilder::new() + .add_input( + utxo_with_change.clone().into(), + InputWitness::NoSignature(None), + ) + .add_output(TxOutput::Transfer( + OutputValue::Coin(coins_amount), + Destination::AnyoneCanSpend, + )) + .add_output(tx_output.clone()) + .build(); + + let err = tf + .make_block_builder() + .add_transaction(tx) + .build_and_process(&mut rng) + .unwrap_err(); + assert_matches!( + err, + ChainstateError::ProcessBlockError(BlockError::StateUpdateFailed( + ConnectTransactionError::ZeroTokenTransfer(_), + )) + ) + } + } }); } -// For a frozen token, even zero amount transfers are not allowed. +// Create zero amount transfer outputs for the given token. This is used in the zero amount transfer +// tests - the one above and the one in nft_transfer tests. +// Note: CreateOrder output is missing here; this is because orders with zero value are checked +// separately, see order_with_zero_value in orders_tests. +pub fn make_zero_transfer_outputs_for_token_zero_amount_transfer_test( + token_id: &TokenId, + rng: &mut impl Rng, +) -> Vec { + TxOutputTag::iter() + .filter_map(|tag| match tag { + TxOutputTag::Transfer => Some(TxOutput::Transfer( + OutputValue::TokenV1(*token_id, Amount::ZERO), + Destination::AnyoneCanSpend, + )), + TxOutputTag::LockThenTransfer => Some(TxOutput::LockThenTransfer( + OutputValue::TokenV1(*token_id, Amount::ZERO), + Destination::AnyoneCanSpend, + OutputTimeLock::UntilHeight(BlockHeight::new(rng.random())), + )), + TxOutputTag::Burn => Some(TxOutput::Burn(OutputValue::TokenV1( + *token_id, + Amount::ZERO, + ))), + TxOutputTag::Htlc => Some(TxOutput::Htlc( + OutputValue::TokenV1(*token_id, Amount::ZERO), + Box::new(HashedTimelockContract { + secret_hash: rng.random(), + spend_key: Destination::AnyoneCanSpend, + refund_timelock: OutputTimeLock::UntilHeight(BlockHeight::new(rng.random())), + refund_key: Destination::AnyoneCanSpend, + }), + )), + + TxOutputTag::CreateStakePool + | TxOutputTag::ProduceBlockFromStake + | TxOutputTag::CreateDelegationId + | TxOutputTag::DelegateStaking + | TxOutputTag::IssueFungibleToken + | TxOutputTag::IssueNft + | TxOutputTag::DataDeposit + | TxOutputTag::CreateOrder => None, + }) + .collect() +} + +// For a frozen token, zero amount transfers are not allowed even before the fork. #[rstest] #[trace] #[case(Seed::from_entropy())] -fn zero_amount_transfer_of_frozen_token(#[case] seed: Seed) { +fn zero_amount_transfer_of_frozen_token( + #[case] seed: Seed, + #[values(ZeroTokenTransferForbidden::Yes, ZeroTokenTransferForbidden::No)] + zero_token_transfer_forbidden: ZeroTokenTransferForbidden, +) { utils::concurrency::model(move || { let mut rng = make_seedable_rng(seed); - let mut tf = TestFramework::builder(&mut rng).build(); + + let mut tf = TestFramework::builder(&mut rng) + .with_chain_config( + chain::config::create_unit_test_config_builder() + .chainstate_upgrades( + NetUpgrades::initialize(vec![( + BlockHeight::zero(), + ChainstateUpgradeBuilder::latest() + .zero_token_transfer_forbidden(zero_token_transfer_forbidden) + .build(), + )]) + .unwrap(), + ) + .build(), + ) + .build(); let (token_id, _, _, _, utxo_with_change) = issue_token_from_genesis( &mut rng, @@ -7616,11 +7948,17 @@ fn zero_amount_transfer_of_frozen_token(#[case] seed: Seed) { let result = tf.make_block_builder().add_transaction(tx).build_and_process(&mut rng); - assert_eq!( - result.unwrap_err(), - ChainstateError::ProcessBlockError(BlockError::StateUpdateFailed( - ConnectTransactionError::AttemptToSpendFrozenToken(token_id) - )) - ); + let expected_err = match zero_token_transfer_forbidden { + ZeroTokenTransferForbidden::Yes => ChainstateError::ProcessBlockError( + BlockError::StateUpdateFailed(ConnectTransactionError::ZeroTokenTransfer(token_id)), + ), + ZeroTokenTransferForbidden::No => { + ChainstateError::ProcessBlockError(BlockError::StateUpdateFailed( + ConnectTransactionError::AttemptToSpendFrozenToken(token_id), + )) + } + }; + + assert_eq!(result.unwrap_err(), expected_err); }); } diff --git a/chainstate/test-suite/src/tests/nft_burn.rs b/chainstate/test-suite/src/tests/nft_burn.rs index 3862cf1d98..7586889df6 100644 --- a/chainstate/test-suite/src/tests/nft_burn.rs +++ b/chainstate/test-suite/src/tests/nft_burn.rs @@ -19,9 +19,9 @@ use chainstate::{BlockError, ChainstateError, ConnectTransactionError}; use chainstate_test_framework::{TestFramework, TransactionBuilder}; use common::{ chain::{ - ChainstateUpgradeBuilder, Destination, OutPointSourceId, TokenIssuanceVersion, TxInput, - TxOutput, UtxoOutPoint, output_value::OutputValue, signature::inputsig::InputWitness, - tokens::TokenId, + self, ChainstateUpgradeBuilder, Destination, NetUpgrades, OutPointSourceId, + TokenIssuanceVersion, TxInput, TxOutput, UtxoOutPoint, ZeroTokenTransferForbidden, + output_value::OutputValue, signature::inputsig::InputWitness, tokens::TokenId, }, primitives::{Amount, BlockHeight, CoinOrTokenId, Idable}, }; @@ -34,10 +34,30 @@ use test_utils::{ #[rstest] #[trace] #[case(Seed::from_entropy())] -fn nft_burn_invalid_amount(#[case] seed: Seed) { +fn nft_burn_invalid_amount( + #[case] seed: Seed, + #[values(ZeroTokenTransferForbidden::Yes, ZeroTokenTransferForbidden::No)] + zero_token_transfer_forbidden: ZeroTokenTransferForbidden, +) { utils::concurrency::model(move || { let mut rng = make_seedable_rng(seed); - let mut tf = TestFramework::builder(&mut rng).build(); + + let mut tf = TestFramework::builder(&mut rng) + .with_chain_config( + chain::config::create_unit_test_config_builder() + .chainstate_upgrades( + NetUpgrades::initialize(vec![( + BlockHeight::zero(), + ChainstateUpgradeBuilder::latest() + .zero_token_transfer_forbidden(zero_token_transfer_forbidden) + .build(), + )]) + .unwrap(), + ) + .build(), + ) + .build(); + let genesis_outpoint_id = OutPointSourceId::BlockReward(tf.genesis().get_id().into()); let first_tx_input = TxInput::from_utxo(genesis_outpoint_id, 0); let token_id = TokenId::from_tx_input(&first_tx_input); @@ -88,8 +108,8 @@ fn nft_burn_invalid_amount(#[case] seed: Seed) { ) ); - // Burn zero NFT - let _ = tf + // Burn zero NFT; this is only allowed if zero_token_transfer_forbidden is No. + let result = tf .make_block_builder() .add_transaction( TransactionBuilder::new() @@ -100,8 +120,20 @@ fn nft_burn_invalid_amount(#[case] seed: Seed) { .add_output(TxOutput::Burn(OutputValue::TokenV1(token_id, Amount::ZERO))) .build(), ) - .build_and_process(&mut rng) - .unwrap(); + .build_and_process(&mut rng); + + match zero_token_transfer_forbidden { + ZeroTokenTransferForbidden::Yes => { + let expected_err = + ChainstateError::ProcessBlockError(BlockError::StateUpdateFailed( + ConnectTransactionError::ZeroTokenTransfer(token_id), + )); + assert_eq!(result.unwrap_err(), expected_err); + } + ZeroTokenTransferForbidden::No => { + result.unwrap(); + } + } }) } diff --git a/chainstate/test-suite/src/tests/nft_transfer.rs b/chainstate/test-suite/src/tests/nft_transfer.rs index c418c36b27..13a7f225f3 100644 --- a/chainstate/test-suite/src/tests/nft_transfer.rs +++ b/chainstate/test-suite/src/tests/nft_transfer.rs @@ -19,8 +19,8 @@ use chainstate::{BlockError, ChainstateError, ConnectTransactionError}; use chainstate_test_framework::{TestFramework, TransactionBuilder, get_output_value}; use common::{ chain::{ - ChainstateUpgradeBuilder, Destination, NetUpgrades, OutPointSourceId, TokenIssuanceVersion, - TxInput, TxOutput, UtxoOutPoint, + self, ChainstateUpgradeBuilder, Destination, NetUpgrades, OutPointSourceId, + TokenIssuanceVersion, TxInput, TxOutput, UtxoOutPoint, ZeroTokenTransferForbidden, output_value::OutputValue, signature::inputsig::InputWitness, tokens::{NftIssuance, TokenId}, @@ -29,10 +29,13 @@ use common::{ }; use randomness::RngExt; use test_utils::{ + assert_matches, random::{Seed, make_seedable_rng}, token_utils::random_nft_issuance, }; +use crate::tests::fungible_tokens_v1::make_zero_transfer_outputs_for_token_zero_amount_transfer_test; + #[rstest] #[trace] #[case(Seed::from_entropy())] @@ -151,15 +154,37 @@ fn nft_invalid_transfer(#[case] seed: Seed) { }) } -// Transferring zero amount of NFTs is allowed. -// TODO: perhaps we should prohibit it? +// Transferring zero amount of NFTs is allowed before the corresponding fork and forbidden after. #[rstest] -#[trace] #[case(Seed::from_entropy())] +#[trace] fn nft_zero_transfer(#[case] seed: Seed) { utils::concurrency::model(move || { let mut rng = make_seedable_rng(seed); - let mut tf = TestFramework::builder(&mut rng).build(); + + // We'll be creating 1 block after the genesis, so the fork height should be at least 2. + let fork_height = BlockHeight::new(rng.random_range(2..=5)); + let chain_config = chain::config::create_unit_test_config_builder() + .chainstate_upgrades( + NetUpgrades::initialize(vec![ + ( + BlockHeight::zero(), + ChainstateUpgradeBuilder::latest() + .zero_token_transfer_forbidden(ZeroTokenTransferForbidden::No) + .build(), + ), + ( + fork_height, + ChainstateUpgradeBuilder::latest() + .zero_token_transfer_forbidden(ZeroTokenTransferForbidden::Yes) + .build(), + ), + ]) + .unwrap(), + ) + .build(); + + let mut tf = TestFramework::builder(&mut rng).with_chain_config(chain_config).build(); let coins_outpoint = UtxoOutPoint::new( OutPointSourceId::BlockReward(tf.genesis().get_id().into()), @@ -185,57 +210,78 @@ fn nft_zero_transfer(#[case] seed: Seed) { )) .build(); let tx_id = tx.transaction().get_id(); - let issuance_outpoint = UtxoOutPoint::new(OutPointSourceId::Transaction(tx_id), 0); - let coins_outpoint = UtxoOutPoint::new(OutPointSourceId::Transaction(tx_id), 1); + let mut utxo_with_change = UtxoOutPoint::new(OutPointSourceId::Transaction(tx_id), 1); tf.make_block_builder() .add_transaction(tx.clone()) .build_and_process(&mut rng) .unwrap() .unwrap(); - // Create a tx that consumes the NFT in an input and produces a bunch of zero amount outputs - // and optionally a normal output as well. - let mut tx_builder = TransactionBuilder::new() - .add_input(issuance_outpoint.into(), InputWitness::NoSignature(None)); - let zero_outputs_count = rng.random_range(1..5); - for _ in 0..zero_outputs_count { - tx_builder = tx_builder.add_output(TxOutput::Transfer( - OutputValue::TokenV1(token_id, Amount::ZERO), - Destination::AnyoneCanSpend, - )); - } + let coins_amount = tf.coin_amount_from_utxo(&utxo_with_change); - if rng.random_bool(0.5) { - // Also make the actual transfer - tx_builder = tx_builder.add_output(TxOutput::Transfer( - OutputValue::TokenV1(token_id, Amount::from_atoms(1)), - Destination::AnyoneCanSpend, - )); - } + let zero_transfer_outputs = + make_zero_transfer_outputs_for_token_zero_amount_transfer_test(&token_id, &mut rng); - let tx = tx_builder.build(); - tf.make_block_builder() - .add_transaction(tx) - .build_and_process(&mut rng) - .unwrap() - .unwrap(); + // Before the fork zero transfers are allowed. + { + let initial_block_height = tf.best_block_index().block_height().next_height(); + for _ in initial_block_height.iter_up_to(fork_height) { + let mut block_builder = tf.make_block_builder(); - // Special case - transfer zero amount of the NFT when it's not present in the inputs. - let mut tx_builder = TransactionBuilder::new() - .add_input(coins_outpoint.into(), InputWitness::NoSignature(None)); - for _ in 0..zero_outputs_count { - tx_builder = tx_builder.add_output(TxOutput::Transfer( - OutputValue::TokenV1(token_id, Amount::ZERO), - Destination::AnyoneCanSpend, - )); + for tx_output in &zero_transfer_outputs { + let tx = TransactionBuilder::new() + .add_input(utxo_with_change.into(), InputWitness::NoSignature(None)) + .add_output(TxOutput::Transfer( + OutputValue::Coin(coins_amount), + Destination::AnyoneCanSpend, + )) + .add_output(tx_output.clone()) + .build(); + let tx_id = tx.transaction().get_id(); + + block_builder = block_builder.add_transaction(tx); + + utxo_with_change = UtxoOutPoint::new(tx_id.into(), 0) + } + + block_builder.build_and_process(&mut rng).unwrap().unwrap(); + } } - let tx = tx_builder.build(); - tf.make_block_builder() - .add_transaction(tx) - .build_and_process(&mut rng) - .unwrap() - .unwrap(); + // Sanity check + { + let new_block_height = tf.best_block_index().block_height().next_height(); + assert_eq!(new_block_height, fork_height); + } + + // After the fork zero transfers are not allowed. + { + for tx_output in &zero_transfer_outputs { + let tx = TransactionBuilder::new() + .add_input( + utxo_with_change.clone().into(), + InputWitness::NoSignature(None), + ) + .add_output(TxOutput::Transfer( + OutputValue::Coin(coins_amount), + Destination::AnyoneCanSpend, + )) + .add_output(tx_output.clone()) + .build(); + + let err = tf + .make_block_builder() + .add_transaction(tx) + .build_and_process(&mut rng) + .unwrap_err(); + assert_matches!( + err, + ChainstateError::ProcessBlockError(BlockError::StateUpdateFailed( + ConnectTransactionError::ZeroTokenTransfer(_), + )) + ) + } + } }) } diff --git a/chainstate/test-suite/src/tests/orders_tests.rs b/chainstate/test-suite/src/tests/orders_tests.rs index 9da0b64552..ac89bff957 100644 --- a/chainstate/test-suite/src/tests/orders_tests.rs +++ b/chainstate/test-suite/src/tests/orders_tests.rs @@ -26,7 +26,7 @@ use chainstate_test_framework::{ TestFramework, TransactionBuilder, helpers::{ calculate_fill_order, issue_and_mint_random_token_from_best_block, - issue_random_nft_from_best_block, order_min_non_zero_fill_amount, + issue_random_nft_from_best_block, order_min_non_zero_fill_amount, output_value_with_amount, }, output_value_amount, }; @@ -35,8 +35,8 @@ use common::{ chain::{ self, AccountCommand, AccountNonce, AccountType, ChainstateUpgradeBuilder, Currency, Destination, IdCreationError, OrderAccountCommand, OrderData, OrderId, OrdersVersion, - RpcOrderInfo, SignedTransaction, TokenIssuanceVersion, Transaction, TxInput, TxOutput, - UtxoOutPoint, make_order_id, make_token_id, + OutPointSourceId, RpcOrderInfo, SignedTransaction, TokenIssuanceVersion, Transaction, + TxInput, TxOutput, UtxoOutPoint, ZeroTokenTransferForbidden, make_order_id, make_token_id, output_value::{OutputValue, RpcOutputValue}, signature::{ DestinationSigError, EvaluatedInputWitness, @@ -57,6 +57,7 @@ use randomness::{CryptoRng, RngExt as _, SliceRandom}; use test_utils::{ random::{Seed, gen_random_bytes, make_seedable_rng}, random_ascii_alphanumeric_string, + token_utils::random_nft_issuance, }; use tx_verifier::{ CheckTransactionError, @@ -2159,6 +2160,9 @@ fn partially_fill_order_with_nft_v0(#[case] seed: Seed) { BlockHeight::zero(), ChainstateUpgradeBuilder::latest() .orders_version(OrdersVersion::V0) + // ZeroTokenTransferForbidden was introduced after orders v1 activation, + // no point in testing ZeroTokenTransferForbidden::Yes with orders v0. + .zero_token_transfer_forbidden(ZeroTokenTransferForbidden::No) .build(), )]) .unwrap(), @@ -3490,70 +3494,130 @@ fn fill_freeze_conclude_order(#[case] seed: Seed) { // Orders with zero values are not allowed. #[rstest] -#[case(Seed::from_entropy(), OrdersVersion::V0)] -#[case(Seed::from_entropy(), OrdersVersion::V1)] +#[case(Seed::from_entropy())] #[trace] -fn order_with_zero_value(#[case] seed: Seed, #[case] version: OrdersVersion) { +fn order_with_zero_value( + #[case] seed: Seed, + #[values(OrdersVersion::V0, OrdersVersion::V1)] version: OrdersVersion, +) { utils::concurrency::model(move || { let mut rng = make_seedable_rng(seed); let mut tf = create_test_framework_with_orders(&mut rng, version); let (token_id, tokens_outpoint, coins_outpoint) = issue_and_mint_token_from_genesis(&mut rng, &mut tf); - let tokens_circulating_supply = - tf.chainstate.get_token_circulating_supply(&token_id).unwrap().unwrap(); + let coins_amount = tf.coin_amount_from_utxo(&coins_outpoint); + let token_amount = tf.chainstate.get_token_circulating_supply(&token_id).unwrap().unwrap(); - let (coins, tokens) = match rng.random_range(0..3) { - 0 => { - let token_amount = Amount::from_atoms( - rng.random_range(1u128..=tokens_circulating_supply.into_atoms()), - ); - ( - OutputValue::Coin(Amount::ZERO), - OutputValue::TokenV1(token_id, token_amount), - ) - } - 1 => { - let coin_amount = Amount::from_atoms(rng.random_range(1u128..1000)); - ( - OutputValue::Coin(coin_amount), - OutputValue::TokenV1(token_id, Amount::ZERO), - ) - } - _ => ( - OutputValue::Coin(Amount::ZERO), - OutputValue::TokenV1(token_id, Amount::ZERO), - ), - }; + let nft_issuance_fee = + tf.chainstate.get_chain_config().nft_issuance_fee(BlockHeight::zero()); - let (ask, give) = if rng.random_bool(0.5) { - (coins, tokens) - } else { - (tokens, coins) - }; + let nft_issuance_tx_first_input = TxInput::Utxo(coins_outpoint.clone()); + let nft_id = TokenId::from_tx_input(&nft_issuance_tx_first_input); - log::debug!("ask = {ask:?}, give = {give:?}"); + let nft_issuance_tx = TransactionBuilder::new() + .add_input(nft_issuance_tx_first_input, InputWitness::NoSignature(None)) + .add_output(TxOutput::IssueNft( + nft_id, + Box::new(random_nft_issuance(tf.chain_config().as_ref(), &mut rng).into()), + Destination::AnyoneCanSpend, + )) + .add_output(TxOutput::Transfer( + OutputValue::Coin((coins_amount - nft_issuance_fee).unwrap()), + Destination::AnyoneCanSpend, + )) + .build(); + let nft_issuance_tx_id = nft_issuance_tx.transaction().get_id(); + let nft_outpoint = UtxoOutPoint::new(OutPointSourceId::Transaction(nft_issuance_tx_id), 0); - let order_data = OrderData::new(Destination::AnyoneCanSpend, ask, give); + tf.make_block_builder() + .add_transaction(nft_issuance_tx) + .build_and_process(&mut rng) + .unwrap() + .unwrap(); - let tx = TransactionBuilder::new() - .add_input(coins_outpoint.into(), InputWitness::NoSignature(None)) - .add_input(tokens_outpoint.into(), InputWitness::NoSignature(None)) - .add_output(TxOutput::CreateOrder(Box::new(order_data.clone()))) - .build(); - let order_id = make_order_id(tx.inputs()).unwrap(); - let result = tf.make_block_builder().add_transaction(tx).build_and_process(&mut rng); + let coins_outpoint = + UtxoOutPoint::new(OutPointSourceId::Transaction(nft_issuance_tx_id), 1); + let coins_amount = tf.coin_amount_from_utxo(&coins_outpoint); - assert_eq!( - result.unwrap_err(), - chainstate::ChainstateError::ProcessBlockError( - chainstate::BlockError::StateUpdateFailed( - ConnectTransactionError::OrdersAccountingError( - orders_accounting::Error::OrderWithZeroValue(order_id) - ) - ) - ) - ); + let assets = [ + (OutputValue::Coin(coins_amount), coins_outpoint), + ( + OutputValue::TokenV1(token_id, token_amount), + tokens_outpoint, + ), + ( + OutputValue::TokenV1(nft_id, Amount::from_atoms(1)), + nft_outpoint, + ), + ]; + + for asked in &assets { + for given in &assets { + if asked != given { + for (ask_zero, give_zero) in [(true, false), (false, true), (true, true)] { + let ask_value = if ask_zero { + output_value_with_amount(&asked.0, Amount::ZERO) + } else { + asked.0.clone() + }; + let give_value = if give_zero { + output_value_with_amount(&given.0, Amount::ZERO) + } else { + given.0.clone() + }; + + log::debug!("ask_value = {ask_value:?}, give_value = {give_value:?}"); + + let order_data = + OrderData::new(Destination::AnyoneCanSpend, ask_value, give_value); + + let mut tx_builder = TransactionBuilder::new(); + + if !give_zero { + tx_builder = tx_builder + .add_input(given.1.clone().into(), InputWitness::NoSignature(None)); + } else { + // Since we're giving zero, the given currency may not be present in + // the inputs. But if we omit it, we should add something else, + // because order creation needs a utxo input. So we add the asked + // currency utxo instead. + if rng.random_bool(0.5) { + tx_builder = tx_builder.add_input( + given.1.clone().into(), + InputWitness::NoSignature(None), + ); + } else { + tx_builder = tx_builder.add_input( + asked.1.clone().into(), + InputWitness::NoSignature(None), + ); + } + } + + tx_builder = tx_builder + .add_output(TxOutput::CreateOrder(Box::new(order_data.clone()))); + + let tx = tx_builder.build(); + + let order_id = make_order_id(tx.inputs()).unwrap(); + let result = + tf.make_block_builder().add_transaction(tx).build_and_process(&mut rng); + + assert_eq!( + result.unwrap_err(), + chainstate::ChainstateError::ProcessBlockError( + chainstate::BlockError::StateUpdateFailed( + ConnectTransactionError::OrdersAccountingError( + orders_accounting::Error::OrderWithZeroValue(order_id) + ) + ) + ) + ); + } + } + } + } }); } diff --git a/chainstate/test-suite/src/tests/pos_processing_tests.rs b/chainstate/test-suite/src/tests/pos_processing_tests.rs index e5d20a57de..b92b3e7ec3 100644 --- a/chainstate/test-suite/src/tests/pos_processing_tests.rs +++ b/chainstate/test-suite/src/tests/pos_processing_tests.rs @@ -18,8 +18,8 @@ use std::{borrow::Cow, num::NonZeroU64, time::Duration}; use rstest::rstest; use chainstate::{ - BlockError, BlockSource, ChainstateError, CheckBlockError, ConnectTransactionError, - SpendStakeError, chainstate_interface::ChainstateInterface, + BlockError, BlockIndex, BlockSource, ChainstateError, CheckBlockError, ConnectTransactionError, + EpochSealError, SpendStakeError, chainstate_interface::ChainstateInterface, }; use chainstate_storage::Transactional; use chainstate_test_framework::{ @@ -36,8 +36,9 @@ use common::{ chain::{ AccountNonce, AccountOutPoint, AccountSpending, ChainConfig, ChainstateUpgradeBuilder, ConsensusUpgrade, Destination, GenBlock, NetUpgrades, OutPointSourceId, PoSChainConfig, - PoSChainConfigBuilder, PoolId, RequiredConsensus, SignedTransaction, - StakerDestinationUpdateForbidden, TxInput, TxOutput, UtxoOutPoint, + PoSChainConfigBuilder, PoolId, PoolIdMismatchInKernelUtxoAndPoSDataForbidden, + RequiredConsensus, SignedTransaction, StakerDestinationUpdateForbidden, TxInput, TxOutput, + UtxoOutPoint, block::{ BlockRewardTransactable, ConsensusData, consensus_data::PoSData, timestamp::BlockTimestamp, @@ -273,13 +274,9 @@ fn produce_kernel_signature( staking_sk: &PrivateKey, reward_outputs: &[TxOutput], staking_destination: Destination, - kernel_utxo_block_id: Id, kernel_outpoint: UtxoOutPoint, ) -> StandardInputSignature { - let block_outputs = tf.outputs_from_genblock(kernel_utxo_block_id); - - let kernel_input_utxo = &block_outputs.get(&kernel_outpoint.source_id()).unwrap() - [kernel_outpoint.output_index() as usize]; + let kernel_input_utxo = tf.utxo(&kernel_outpoint).take_output(); let kernel_inputs = vec![kernel_outpoint.into()]; let block_reward_tx = @@ -289,7 +286,7 @@ fn produce_kernel_signature( SigHashType::default(), staking_destination, &block_reward_tx, - &[SighashInputCommitment::Utxo(Cow::Borrowed(kernel_input_utxo))], + &[SighashInputCommitment::Utxo(Cow::Borrowed(&kernel_input_utxo))], 0, rng, ) @@ -369,7 +366,6 @@ fn pos_basic(#[case] seed: Seed) { &staking_sk, reward_outputs.as_slice(), staking_destination, - tf.best_block_id(), stake_pool_outpoint.clone(), ); @@ -551,7 +547,6 @@ fn pos_block_signature(#[case] seed: Seed) { &staking_sk, reward_outputs.as_slice(), staking_destination, - tf.best_block_id(), stake_pool_outpoint.clone(), ); let new_block_height = tf.best_block_index().block_height().next_height(); @@ -694,7 +689,6 @@ fn pos_invalid_vrf(#[case] seed: Seed) { &staking_sk, reward_outputs.as_slice(), staking_destination, - tf.best_block_id(), stake_pool_outpoint.clone(), ); @@ -850,7 +844,6 @@ fn pos_invalid_pool_id(#[case] seed: Seed) { &staking_sk, reward_outputs.as_slice(), staking_destination, - tf.best_block_id(), stake_pool_outpoint.clone(), ); @@ -1018,7 +1011,6 @@ fn spend_stake_pool_in_block_reward(#[case] seed: Seed) { &staking_sk, reward_outputs.as_slice(), staking_destination, - tf.best_block_id(), stake_pool_outpoint.clone(), ); @@ -1067,7 +1059,6 @@ fn spend_stake_pool_in_block_reward(#[case] seed: Seed) { &staking_sk, reward_outputs.as_slice(), staking_destination, - tf.best_block_id(), block_2_reward_outpoint.clone(), ); let new_block_height = tf.best_block_index().block_height().next_height(); @@ -1113,7 +1104,6 @@ fn spend_stake_pool_in_block_reward(#[case] seed: Seed) { &staking_sk, reward_outputs.as_slice(), staking_destination, - tf.best_block_id(), block_3_reward_outpoint.clone(), ); @@ -1407,8 +1397,6 @@ fn decommission_from_produce_block(#[case] seed: Seed) { ) = setup_test_chain_with_2_stake_pools(&mut rng, vrf_pk_1, vrf_pk_2); let target_block_time = tf.chain_config().target_block_spacing(); - let stake_pool_block_id = tf.best_block_id(); - // prepare and process block_2 with StakePool -> ProduceBlockFromStake kernel let staking_destination = Destination::PublicKey(PublicKey::from_private_key(&staking_sk1)); let reward_outputs = @@ -1420,7 +1408,6 @@ fn decommission_from_produce_block(#[case] seed: Seed) { &staking_sk1, reward_outputs.as_slice(), staking_destination, - tf.best_block_id(), stake_pool_outpoint1.clone(), ); @@ -1466,7 +1453,6 @@ fn decommission_from_produce_block(#[case] seed: Seed) { &staking_sk2, reward_outputs.as_slice(), staking_destination, - stake_pool_block_id, stake_pool_outpoint2.clone(), ); let block_2_reward_outpoint = UtxoOutPoint::new( @@ -1575,7 +1561,6 @@ fn decommission_from_not_best_block(#[case] seed: Seed) { &staking_sk1, produce_block_output.as_slice(), staking_destination, - tf.best_block_id(), stake_pool_outpoint1.clone(), ); @@ -1704,7 +1689,6 @@ fn pos_stake_testnet_genesis(#[case] seed: Seed) { &staker_sk, reward_outputs.as_slice(), staking_destination.clone(), - tf.best_block_id(), stake_pool_outpoint.clone(), ); @@ -1758,7 +1742,6 @@ fn pos_stake_testnet_genesis(#[case] seed: Seed) { &staker_sk, reward_outputs.as_slice(), staking_destination, - tf.best_block_id(), block_1_reward_outpoint.clone(), ); @@ -2018,7 +2001,6 @@ fn pos_decommission_genesis_pool(#[case] seed: Seed) { &genesis_staking_sk, reward_outputs.as_slice(), staking_destination, - tf.best_block_id(), genesis_outpoint_1.clone(), ); @@ -2093,7 +2075,6 @@ fn pos_decommission_genesis_pool(#[case] seed: Seed) { &staking_sk, reward_outputs.as_slice(), staking_destination, - tf.best_block_id(), UtxoOutPoint::new(create_new_pool_tx_id.into(), 0), ); @@ -2431,7 +2412,6 @@ mod staker_destination_change_test_utils { cur_staking_sk, reward_outputs.as_slice(), cur_staking_destination, - tf.best_block_id(), kernel_outpoint.clone(), ); @@ -2461,3 +2441,373 @@ mod staker_destination_change_test_utils { (pos_data, block_timestamp, reward_outputs) } } + +// Check that using mismatched PoSData and kernel input is allowed before the corresponding +// fork and prohibited afterwards. +// * The fork height is several blocks above the height 2 (where the chain switches to PoS); +// there are 2 pools. +// * Produce blocks until the fork height is reached, using mismatched data: PoSData is based on +// one pool and the kernel utxo is from the other one. +// Note that this can only be done if the produced block is not the last one in the epoch. +// If it is the last one, try the mismatched data, expect vrf verification error, and then +// produce the block using consistent data. +// * At the fork height try producing a block using mismatched data - it fails with a specific +// error related to the mismatch. +// * Produce a few more blocks using consistent data - it succeeds. +// * Try producing a block using mismatched data - it still fails, with the same error. +#[rstest] +#[trace] +#[case(Seed::from_entropy())] +fn pool_id_mismatch_in_kernel_utxo_and_pos_data(#[case] seed: Seed) { + use pool_id_mismatch_in_kernel_utxo_and_pos_data_test_utils::*; + + let mut rng = make_seedable_rng(seed); + + let pos_height = BlockHeight::new(2); + let block_count_before_fork = + rng.random_range((TEST_EPOCH_LENGTH.get() * 2)..(TEST_EPOCH_LENGTH.get() * 5)); + let fork_height = BlockHeight::new(pos_height.into_int() + block_count_before_fork); + let chain_config = ConfigBuilder::test_chain() + .consensus_upgrades(consensus_upgrades_with_pos_at_height(pos_height)) + .epoch_length(TEST_EPOCH_LENGTH) + .sealed_epoch_distance_from_tip(TEST_SEALED_EPOCH_DISTANCE) + .chainstate_upgrades( + NetUpgrades::initialize(vec![ + ( + BlockHeight::zero(), + ChainstateUpgradeBuilder::latest() + .pool_id_mismatch_in_kernel_input_utxo_and_pos_data_forbidden( + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::No, + ) + .build(), + ), + ( + fork_height, + ChainstateUpgradeBuilder::latest() + .pool_id_mismatch_in_kernel_input_utxo_and_pos_data_forbidden( + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::Yes, + ) + .build(), + ), + ]) + .unwrap(), + ) + .build(); + + let mut tf = TestFramework::builder(&mut rng).with_chain_config(chain_config).build(); + + let (mut pool1_data, mut pool2_data) = create_pools(&mut tf, &mut rng); + + let min_pool_pledge = tf.chainstate.get_chain_config().min_stake_pool_pledge(); + let mut pool1_balance = min_pool_pledge; + let mut pool2_balance = min_pool_pledge; + + // Create blocks using mismatched PoSData and kernel input until the fork height is reached. + // Note: at epoch boundaries, randomness sealing verifies block's vrf_data using the vrf key + // corresponding to the kernel utxo's pool id. So if a block is the last block in an epoch, + // the mismatch will lead to vrf verification error. + { + let initial_block_height = tf.best_block_index().block_height().next_height(); + + for new_block_height in initial_block_height.iter_up_to(fork_height) { + let (pool_data, pool_balance, pool_outpoint) = if rng.random_bool(0.5) { + ( + pool1_data.with_kernel_outpoint_from(&pool2_data), + &mut pool1_balance, + &mut pool2_data.kernel_outpoint, + ) + } else { + ( + pool2_data.with_kernel_outpoint_from(&pool1_data), + &mut pool2_balance, + &mut pool1_data.kernel_outpoint, + ) + }; + + let (block_id, pool_data, pool_balance, pool_outpoint) = + if !tf.chain_config().is_last_block_in_epoch(&new_block_height) { + let block_id = + *mine_block(&mut tf, &pool_data, &mut rng).unwrap().unwrap().block_id(); + (block_id, pool_data, pool_balance, pool_outpoint) + } else { + let err = mine_block(&mut tf, &pool_data, &mut rng).unwrap_err(); + assert_eq!( + err, + ChainstateError::ProcessBlockError(BlockError::EpochSealError( + EpochSealError::RandomnessError( + PoSRandomnessError::VRFDataVerificationFailed( + ProofOfStakeVRFError::VRFDataVerificationFailed( + VRFError::VerificationError + ) + ) + ) + )) + ); + + let (pool_data, pool_balance, pool_outpoint) = if rng.random_bool(0.5) { + ( + pool1_data.as_generic(), + &mut pool1_balance, + &mut pool1_data.kernel_outpoint, + ) + } else { + ( + pool2_data.as_generic(), + &mut pool2_balance, + &mut pool2_data.kernel_outpoint, + ) + }; + + let block_id = + *mine_block(&mut tf, &pool_data, &mut rng).unwrap().unwrap().block_id(); + + (block_id, pool_data, pool_balance, pool_outpoint) + }; + + let new_pool_balance = PoSAccountingStorageRead::::get_pool_balance( + &tf.storage, + pool_data.pool_id_in_pos_data, + ) + .unwrap() + .unwrap(); + let subsidy = + tf.chainstate.get_chain_config().block_subsidy_at_height(&new_block_height); + let expected_pool_balance = (*pool_balance + subsidy).unwrap(); + assert_eq!(new_pool_balance, expected_pool_balance); + + *pool_outpoint = UtxoOutPoint::new(OutPointSourceId::BlockReward(block_id.into()), 0); + *pool_balance = new_pool_balance; + + tf.progress_time_seconds_since_epoch(rng.random_range(1..10)); + } + }; + + // Sanity check + { + let new_block_height = tf.best_block_index().block_height().next_height(); + assert_eq!(new_block_height, fork_height); + } + + // The fork height has been reached, so attempts to use mismatched PoSData and kernel input + // should fail with ConsensusPoSError::PoolIdsInKernelUtxoAndPoSDataMismatch. + { + let pool_data = if rng.random_bool(0.5) { + pool1_data.with_kernel_outpoint_from(&pool2_data) + } else { + pool2_data.with_kernel_outpoint_from(&pool1_data) + }; + + let err = mine_block(&mut tf, &pool_data, &mut rng).unwrap_err(); + + assert_eq!( + err, + ChainstateError::ProcessBlockError(BlockError::CheckBlockFailed( + CheckBlockError::ConsensusVerificationFailed(ConsensusVerificationError::PoSError( + ConsensusPoSError::PoolIdsInKernelUtxoAndPoSDataMismatch { + kernel_utxo_pool_id: pool_data.pool_id_in_kernel_utxo, + pos_data_pool_id: pool_data.pool_id_in_pos_data + } + )) + )) + ); + } + + // Produce a few blocks using matching PoSData and kernel input. + let block_count_after_fork = rng.random_range(1..(TEST_EPOCH_LENGTH.get() * 3)); + for i in 0..block_count_after_fork { + let new_block_height = fork_height.checked_add(i).unwrap(); + let (pool_data, pool_balance, pool_outpoint) = if rng.random_bool(0.5) { + ( + pool1_data.as_generic(), + &mut pool1_balance, + &mut pool1_data.kernel_outpoint, + ) + } else { + ( + pool2_data.as_generic(), + &mut pool2_balance, + &mut pool2_data.kernel_outpoint, + ) + }; + + let block_id = *mine_block(&mut tf, &pool_data, &mut rng).unwrap().unwrap().block_id(); + + let new_pool_balance = PoSAccountingStorageRead::::get_pool_balance( + &tf.storage, + pool_data.pool_id_in_pos_data, + ) + .unwrap() + .unwrap(); + let subsidy = tf.chainstate.get_chain_config().block_subsidy_at_height(&new_block_height); + let expected_pool_balance = (*pool_balance + subsidy).unwrap(); + assert_eq!(new_pool_balance, expected_pool_balance); + + *pool_outpoint = UtxoOutPoint::new(OutPointSourceId::BlockReward(block_id.into()), 0); + *pool_balance = new_pool_balance; + + tf.progress_time_seconds_since_epoch(rng.random_range(1..10)); + } + + // Attempt to use mismatched PoSData and kernel input again - it still fails, with the same + // error. + { + let pool_data = if rng.random_bool(0.5) { + pool1_data.with_kernel_outpoint_from(&pool2_data) + } else { + pool2_data.with_kernel_outpoint_from(&pool1_data) + }; + + let err = mine_block(&mut tf, &pool_data, &mut rng).unwrap_err(); + + assert_eq!( + err, + ChainstateError::ProcessBlockError(BlockError::CheckBlockFailed( + CheckBlockError::ConsensusVerificationFailed(ConsensusVerificationError::PoSError( + ConsensusPoSError::PoolIdsInKernelUtxoAndPoSDataMismatch { + kernel_utxo_pool_id: pool_data.pool_id_in_kernel_utxo, + pos_data_pool_id: pool_data.pool_id_in_pos_data + } + )) + )) + ); + } +} + +mod pool_id_mismatch_in_kernel_utxo_and_pos_data_test_utils { + use super::*; + + pub struct PoolData { + pub pool_id: PoolId, + pub staking_sk: PrivateKey, + pub vrf_sk: VRFPrivateKey, + pub kernel_outpoint: UtxoOutPoint, + } + + impl PoolData { + pub fn with_kernel_outpoint_from(&self, other_data: &Self) -> GenericPoolData { + GenericPoolData { + pool_id_in_pos_data: self.pool_id, + vrf_sk: self.vrf_sk.clone(), + + pool_id_in_kernel_utxo: other_data.pool_id, + kernel_outpoint: other_data.kernel_outpoint.clone(), + staking_sk: other_data.staking_sk.clone(), + } + } + + pub fn as_generic(&self) -> GenericPoolData { + GenericPoolData { + pool_id_in_pos_data: self.pool_id, + vrf_sk: self.vrf_sk.clone(), + + pool_id_in_kernel_utxo: self.pool_id, + kernel_outpoint: self.kernel_outpoint.clone(), + staking_sk: self.staking_sk.clone(), + } + } + } + + pub struct GenericPoolData { + pub pool_id_in_pos_data: PoolId, + pub vrf_sk: VRFPrivateKey, + + pub pool_id_in_kernel_utxo: PoolId, + pub kernel_outpoint: UtxoOutPoint, + pub staking_sk: PrivateKey, + } + + pub fn create_pools(tf: &mut TestFramework, rng: &mut impl CryptoRng) -> (PoolData, PoolData) { + let (vrf_sk1, vrf_pk1) = VRFPrivateKey::new_from_rng(rng, VRFKeyKind::Schnorrkel); + let (vrf_sk2, vrf_pk2) = VRFPrivateKey::new_from_rng(rng, VRFKeyKind::Schnorrkel); + let (stake_pool_data1, staking_sk1) = create_stake_pool_data_with_all_reward_to_staker( + rng, + tf.chainstate.get_chain_config().min_stake_pool_pledge(), + vrf_pk1, + ); + let (stake_pool_data2, staking_sk2) = create_stake_pool_data_with_all_reward_to_staker( + rng, + tf.chainstate.get_chain_config().min_stake_pool_pledge(), + vrf_pk2, + ); + + let (kernel_outpoint1, pool1_id, kernel_outpoint2, pool2_id) = + add_block_with_2_stake_pools(rng, tf, stake_pool_data1, stake_pool_data2); + + let pool_data1 = PoolData { + pool_id: pool1_id, + staking_sk: staking_sk1, + vrf_sk: vrf_sk1, + kernel_outpoint: kernel_outpoint1, + }; + let pool_data2 = PoolData { + pool_id: pool2_id, + staking_sk: staking_sk2, + vrf_sk: vrf_sk2, + kernel_outpoint: kernel_outpoint2, + }; + (pool_data1, pool_data2) + } + + fn pos_mine( + tf: &TestFramework, + pool_data: &GenericPoolData, + rng: &mut impl CryptoRng, + ) -> (PoSData, BlockTimestamp, Vec) { + let staking_destination = + Destination::PublicKey(PublicKey::from_private_key(&pool_data.staking_sk)); + + let reward_outputs = vec![TxOutput::ProduceBlockFromStake( + staking_destination.clone(), + pool_data.pool_id_in_kernel_utxo, + )]; + + let kernel_sig = produce_kernel_signature( + rng, + tf, + &pool_data.staking_sk, + reward_outputs.as_slice(), + staking_destination, + pool_data.kernel_outpoint.clone(), + ); + + let chain_config = tf.chainstate.get_chain_config(); + let new_block_height = tf.best_block_index().block_height().next_height(); + let current_difficulty = calculate_new_target(tf, new_block_height).unwrap(); + let final_supply = chain_config.final_supply().unwrap(); + let epoch_index = chain_config.epoch_index_from_height(&new_block_height); + let randomness = tf.pos_randomness_for_height(&new_block_height); + + let (pos_data, block_timestamp) = chainstate_test_framework::pos_mine( + rng, + &tf.storage.transaction_ro().unwrap(), + &get_pos_chain_config(chain_config, new_block_height), + BlockTimestamp::from_time(tf.current_time()), + pool_data.kernel_outpoint.clone(), + InputWitness::Standard(kernel_sig), + &pool_data.vrf_sk, + randomness, + pool_data.pool_id_in_pos_data, + final_supply, + epoch_index, + current_difficulty, + ) + .unwrap(); + + (pos_data, block_timestamp, reward_outputs) + } + + pub fn mine_block( + tf: &mut TestFramework, + pool_data: &GenericPoolData, + rng: &mut impl CryptoRng, + ) -> Result, ChainstateError> { + let (pos_data, block_timestamp, reward_outputs) = pos_mine(tf, pool_data, rng); + + tf.make_block_builder() + .with_consensus_data(ConsensusData::PoS(Box::new(pos_data))) + .with_block_signing_key(pool_data.staking_sk.clone()) + .with_timestamp(block_timestamp) + .with_reward(reward_outputs) + .build_and_process(rng) + } +} diff --git a/chainstate/tx-verifier/src/transaction_verifier/check_transaction.rs b/chainstate/tx-verifier/src/transaction_verifier/check_transaction.rs index 8fde00ed0c..6b8c6770c0 100644 --- a/chainstate/tx-verifier/src/transaction_verifier/check_transaction.rs +++ b/chainstate/tx-verifier/src/transaction_verifier/check_transaction.rs @@ -18,9 +18,9 @@ use std::collections::BTreeSet; use chainstate_types::PropertyQueryError; use common::{ chain::{ - AccountCommand, ChainConfig, ChangeTokenMetadataUriActivated, HtlcActivated, OrderId, - OrdersVersion, SignedTransaction, TokenIssuanceVersion, Transaction, TransactionSize, - TxInput, TxOutput, + AccountCommand, ChainConfig, ChangeTokenMetadataUriActivated, + ChangeTokenMetadataUriValidityCheckRequired, HtlcActivated, OrderId, OrdersVersion, + SignedTransaction, TokenIssuanceVersion, Transaction, TransactionSize, TxInput, TxOutput, output_value::OutputValue, signature::inputsig::InputWitness, tokens::{NftIssuance, get_tokens_issuance_count}, @@ -32,7 +32,9 @@ use utils::ensure; use crate::{ error::TokensError, - transaction_verifier::tokens_check::{check_nft_issuance_data, check_tokens_issuance}, + transaction_verifier::tokens_check::{ + check_nft_issuance_data, check_tokens_issuance, check_utils::is_uri_valid, + }, }; #[derive(Error, Debug, PartialEq, Eq, Clone)] @@ -152,12 +154,10 @@ fn check_tokens_tx( block_height: BlockHeight, tx: &SignedTransaction, ) -> Result<(), CheckTransactionError> { + let cs_upgrade = &chain_config.chainstate_upgrades().version_at_height(block_height).1; + // Check if v0 tokens are allowed to be used at this height - let latest_token_version = chain_config - .chainstate_upgrades() - .version_at_height(block_height) - .1 - .token_issuance_version(); + let latest_token_version = cs_upgrade.token_issuance_version(); match latest_token_version { TokenIssuanceVersion::V0 => { /* do nothing */ } @@ -208,11 +208,9 @@ fn check_tokens_tx( ),) ); - let change_token_metadata_uri_activated = chain_config - .chainstate_upgrades() - .version_at_height(block_height) - .1 - .change_token_metadata_uri_activated(); + let change_token_metadata_uri_activated = cs_upgrade.change_token_metadata_uri_activated(); + let change_token_metadata_uri_validity_check_required = + cs_upgrade.change_token_metadata_uri_validity_check_required(); // Check token metadata uri change tx.inputs().iter().try_for_each(|input| match input { @@ -240,6 +238,19 @@ fn check_tokens_tx( *token_id )) ); + + match change_token_metadata_uri_validity_check_required { + ChangeTokenMetadataUriValidityCheckRequired::Yes => { + ensure!( + is_uri_valid(metadata_uri), + CheckTransactionError::TokensError(TokensError::IncorrectMetadataUri( + *token_id + )) + ); + } + ChangeTokenMetadataUriValidityCheckRequired::No => { /* do nothing */ } + } + Ok(()) } }, diff --git a/chainstate/tx-verifier/src/transaction_verifier/error.rs b/chainstate/tx-verifier/src/transaction_verifier/error.rs index cedfa12155..70f4597a7a 100644 --- a/chainstate/tx-verifier/src/transaction_verifier/error.rs +++ b/chainstate/tx-verifier/src/transaction_verifier/error.rs @@ -43,19 +43,19 @@ pub enum ConnectTransactionError { StorageError(chainstate_storage::Error), #[error("Output is not found in the cache or database: {0:?}")] MissingOutputOrSpent(UtxoOutPoint), - #[error("While disconnecting a block, undo info for transaction `{0}` doesn't exist ")] + #[error("While disconnecting a block, undo info for transaction `{0:x}` doesn't exist ")] MissingTxUndo(Id), #[error("While disconnecting a block, block undo info doesn't exist for block `{0:?}`")] MissingBlockUndo(TransactionSource), - #[error("While disconnecting a block, block reward undo info doesn't exist for block `{0}`")] + #[error("While disconnecting a block, block reward undo info doesn't exist for block `{0:x}`")] MissingBlockRewardUndo(Id), #[error("Transaction index for header found but header not found")] InvariantErrorHeaderCouldNotBeLoadedFromHeight(GetAncestorError, BlockHeight), - #[error("Unable to find block index")] + #[error("Unable to find block index for block {0:x}")] BlockIndexCouldNotBeLoaded(Id), - #[error("Addition of all fees in block `{0}` failed")] + #[error("Addition of all fees in block `{0:x}` failed")] FailedToAddAllFeesOfBlock(Id), - #[error("Block reward addition error for block {0}")] + #[error("Block reward addition error for block {0:x}")] RewardAdditionError(Id), #[error("Utxo error: {0}")] UtxoError(#[from] utxo::Error), @@ -67,7 +67,7 @@ pub enum ConnectTransactionError { UtxoBlockUndoError(#[from] utxo::UtxosBlockUndoError), #[error("Accounting BlockUndo error: {0}")] AccountingBlockUndoError(#[from] accounting::BlockUndoError), - #[error("Failed to sum amounts of burns in transaction: {0}")] + #[error("Failed to sum amounts of burns in transaction: {0:x}")] BurnAmountSumError(Id), #[error("Attempt to spend burned amount in transaction")] AttemptToSpendBurnedAmount, @@ -75,9 +75,9 @@ pub enum ConnectTransactionError { PoSAccountingError(#[from] pos_accounting::Error), #[error("Error during stake spending: {0}")] SpendStakeError(#[from] SpendStakeError), - #[error("Staker balance of pool {0} not found")] + #[error("Staker balance of pool {0:x} not found")] StakerBalanceNotFound(PoolId), - #[error("Pool id provided in the tx output {0} doesn't match calculated pool id {1}")] + #[error("Pool id provided in the tx output {0:x} doesn't match calculated pool id {1:x}")] UnexpectedPoolId(PoolId, PoolId), // TODO The following should contain more granular inner error information @@ -92,7 +92,7 @@ pub enum ConnectTransactionError { #[error("Nonce is not found: {0:?}")] MissingTransactionNonce(AccountType), #[error( - "Transaction {0} has not enough pledge to create a stake pool: giver {1:?}, required {2:?}" + "Transaction {0:x} has not enough pledge to create a stake pool: giver {1:?}, required {2:?}" )] NotEnoughPledgeToCreateStakePool(Id, Amount, Amount), #[error("Failed to increment account nonce")] @@ -107,7 +107,7 @@ pub enum ConnectTransactionError { TotalFeeRequiredOverflow, #[error("Insufficient coins fee provided in a transaction: {0:?} actual, {1:?} required")] InsufficientCoinsFee(Amount, Amount), - #[error("Cannot perform any operations for frozen token {0}")] + #[error("Cannot perform any operations for frozen token {0:x}")] AttemptToSpendFrozenToken(TokenId), #[error("Reward distribution error: {0}")] RewardDistributionError(#[from] reward_distribution::RewardDistributionError), @@ -117,14 +117,18 @@ pub enum ConnectTransactionError { OrdersAccountingError(#[from] orders_accounting::Error), #[error(transparent)] InputCheck(#[from] InputCheckError), - #[error("Transaction {0} has conclude order input {1} with amounts that don't match the db")] + #[error( + "Transaction {0:x} has conclude input for order {1:x} with amounts that don't match the db" + )] ConcludeInputAmountsDontMatch(Id, OrderId), #[error( - "ProduceBlockFromStake for block {0} modifies staker destination for pool {1}; this is no longer allowed" + "ProduceBlockFromStake for block {0:x} modifies staker destination for pool {1:x}; this is no longer allowed" )] ProduceBlockFromStakeChangesStakerDestination(Id, PoolId), #[error("Id creation error: {0}")] IdCreationError(#[from] IdCreationError), + #[error("Zero amount token transfers are not allowed (token id = {0:x})")] + ZeroTokenTransfer(TokenId), } impl From for ConnectTransactionError { @@ -149,13 +153,13 @@ pub enum SignatureDestinationGetterError { SpendingFromAccountInBlockReward, #[error("Attempted to verify signature for not spendable output")] SigVerifyOfNotSpendableOutput, - #[error("Pool data not found for signature verification {0}")] + #[error("Pool data not found for signature verification {0:x}")] PoolDataNotFound(PoolId), - #[error("Delegation data not found for signature verification {0}")] + #[error("Delegation data not found for signature verification {0:x}")] DelegationDataNotFound(DelegationId), - #[error("Token data not found for signature verification {0}")] + #[error("Token data not found for signature verification {0:x}")] TokenDataNotFound(TokenId), - #[error("Order data not found for signature verification {0}")] + #[error("Order data not found for signature verification {0:x}")] OrderDataNotFound(OrderId), #[error("Utxo for the outpoint not fount: {0:?}")] UtxoOutputNotFound(UtxoOutPoint), @@ -197,7 +201,7 @@ pub enum TokenIssuanceError { MediaHashTooShort, #[error("The media hash is too long")] MediaHashTooLong, - #[error("Token id {0} from issuance does not match calculated token id {1}")] + #[error("Token id {0:x} from issuance does not match calculated token id {1:x}")] TokenIdMismatch(TokenId, TokenId), } @@ -205,20 +209,22 @@ pub enum TokenIssuanceError { pub enum TokensError { #[error("Blockchain storage error: {0}")] StorageError(#[from] chainstate_storage::Error), - #[error("Issuance error {0} in transaction {1}")] + #[error("Issuance error {0} in transaction {1:x}")] IssueError(TokenIssuanceError, Id), - #[error("Too many tokens issuance in transaction {0}")] + #[error("Too many tokens issuance in transaction {0:x}")] MultipleTokenIssuanceInTransaction(Id), #[error("Coin or token overflow {0:?}")] CoinOrTokenOverflow(CoinOrTokenId), - #[error("Insufficient token issuance fee in transaction {0}")] + #[error("Insufficient token issuance fee in transaction {0:x}")] InsufficientTokenFees(Id), - #[error("Invariant broken - attempt undo issuance on non-existent token {0}")] + #[error("Invariant broken - attempt undo issuance on non-existent token {0:x}")] InvariantBrokenUndoIssuanceOnNonexistentToken(TokenId), - #[error("Invariant broken - attempt register issuance on non-existent token {0}")] + #[error("Invariant broken - attempt register issuance on non-existent token {0:x}")] InvariantBrokenRegisterIssuanceWithDuplicateId(TokenId), - #[error("Token {0} metadata uri is to large")] + #[error("Token {0:x} metadata uri is to large")] TokenMetadataUriTooLarge(TokenId), + #[error("Token {0:x} metadata URI is incorrect")] + IncorrectMetadataUri(TokenId), } #[derive(Error, Debug, PartialEq, Eq, Clone)] @@ -231,7 +237,7 @@ pub enum SpendStakeError { InvalidBlockRewardOutputType, #[error("Stake pool data in kernel doesn't match data in block reward output")] StakePoolDataMismatch, - #[error("Pool id in kernel {0} doesn't match the expected pool id {1}")] + #[error("Pool id in kernel {0:x} doesn't match the expected pool id {1:x}")] StakePoolIdMismatch(PoolId, PoolId), #[error("Consensus PoS error: {0}")] ConsensusPoSError(#[from] consensus::ConsensusPoSError), diff --git a/chainstate/tx-verifier/src/transaction_verifier/input_output_policy/mod.rs b/chainstate/tx-verifier/src/transaction_verifier/input_output_policy/mod.rs index fd3e929d98..881f2b950a 100644 --- a/chainstate/tx-verifier/src/transaction_verifier/input_output_policy/mod.rs +++ b/chainstate/tx-verifier/src/transaction_verifier/input_output_policy/mod.rs @@ -16,6 +16,7 @@ use common::{ chain::{ Block, ChainConfig, TokenIssuanceVersion, Transaction, TxInput, TxOutput, + ZeroTokenTransferForbidden, block::{BlockRewardTransactable, ConsensusData}, output_value::OutputValue, signature::Signable, @@ -162,13 +163,10 @@ pub fn check_tx_inputs_outputs_policy( purposes_check::check_tx_inputs_outputs_purposes(tx, &inputs_utxos) .map_err(|e| ConnectTransactionError::IOPolicyError(e, tx.get_id().into()))?; + let cs_upgrades = &chain_config.chainstate_upgrades().version_at_height(block_height).1; + // For TokenIssuanceVersion::V0 it is required to provide explicit Burn outputs as token issuance fee. - let latest_token_version = chain_config - .chainstate_upgrades() - .version_at_height(block_height) - .1 - .token_issuance_version(); - match latest_token_version { + match cs_upgrades.token_issuance_version() { TokenIssuanceVersion::V0 => { check_issuance_fee_burn_v0(chain_config, tx)?; } @@ -215,6 +213,11 @@ pub fn check_tx_inputs_outputs_policy( ConnectTransactionError::ConstrainedValueAccumulatorError(e, tx.get_id().into()) })?; + match cs_upgrades.zero_token_transfer_forbidden() { + ZeroTokenTransferForbidden::Yes => check_zero_token_transfers(tx)?, + ZeroTokenTransferForbidden::No => { /* do nothing */ } + } + Ok(consumed_accumulator) } @@ -255,6 +258,53 @@ fn check_issuance_fee_burn_v0( Ok(()) } +// Forbid transferring/burning zero amount of tokens. Note: +// 1) Zero coin transfers/burns are still allowed. This is because, logically, transferring/burning +// a zero amount of coins is similar to transferring/burning a few atoms - the transfers just produce +// dust utxos, the burns just reduce the total coin supply by an insignificant amount. +// Zero token transfers/burns are different, because, depending on the token, they may create token +// activity that is not supposed to happen. E.g. +// a) it makes no sense to have more than 1 utxo for an NFT; +// b) burning a zero amount of an NFT can be a source of bugs in external tools, which might +// check the fact of burning but not the amount; +// c) the issuer of a fungible token may want to control who can own the token, and the ability +// to create transactions mentioning the token without owning it can mess this up. +// 2) Fungible tokens v0 are ignored, because they were already obsolete when this check was added. +fn check_zero_token_transfers(tx: &Transaction) -> Result<(), ConnectTransactionError> { + for tx_output in tx.outputs() { + let output_value = match tx_output { + TxOutput::Transfer(output_value, _) => Some(output_value), + TxOutput::LockThenTransfer(output_value, _, _) => Some(output_value), + TxOutput::Burn(output_value) => Some(output_value), + TxOutput::CreateStakePool(_, _) => None, + TxOutput::ProduceBlockFromStake(_, _) => None, + TxOutput::CreateDelegationId(_, _) => None, + TxOutput::DelegateStaking(_, _) => None, + TxOutput::IssueFungibleToken(_) => None, + TxOutput::IssueNft(_, _, _) => None, + TxOutput::DataDeposit(_) => None, + TxOutput::Htlc(output_value, _) => Some(output_value), + // Note: creating orders with zero values is not allowed in general, see the error + // `OrderWithZeroValue` in orders-accounting. So we don't check token ids inside + // `OrderData` here. + TxOutput::CreateOrder(_) => None, + }; + + if let Some(output_value) = output_value { + match output_value { + OutputValue::Coin(_) | OutputValue::TokenV0(_) => {} + OutputValue::TokenV1(token_id, amount) => { + if amount == &Amount::ZERO { + return Err(ConnectTransactionError::ZeroTokenTransfer(*token_id)); + } + } + } + } + } + + Ok(()) +} + fn collect_inputs_utxos( utxo_view: &impl utxo::UtxosView, inputs: &[TxInput], diff --git a/chainstate/tx-verifier/src/transaction_verifier/tokens_check/mod.rs b/chainstate/tx-verifier/src/transaction_verifier/tokens_check/mod.rs index 46e0631aae..17a4010dc6 100644 --- a/chainstate/tx-verifier/src/transaction_verifier/tokens_check/mod.rs +++ b/chainstate/tx-verifier/src/transaction_verifier/tokens_check/mod.rs @@ -24,7 +24,7 @@ use common::chain::{ use serialization::{DecodeAll, Encode}; use utils::ensure; -mod check_utils; +pub mod check_utils; pub fn check_nft_issuance_data( chain_config: &ChainConfig, diff --git a/common/src/chain/config/builder.rs b/common/src/chain/config/builder.rs index 4b9399703d..8fc9f0d18e 100644 --- a/common/src/chain/config/builder.rs +++ b/common/src/chain/config/builder.rs @@ -18,12 +18,14 @@ use std::{collections::BTreeMap, net::SocketAddr, num::NonZeroU64, sync::Arc, ti use crate::{ Uint256, chain::{ - ChainstateUpgrade, ChainstateUpgradesBuilder, ChangeTokenMetadataUriActivated, CoinUnit, - ConsensusUpgrade, DataDepositFeeVersion, Destination, FrozenTokensValidationVersion, - GenBlock, Genesis, HtlcActivated, NetUpgrades, OrdersActivated, OrdersVersion, - PoSChainConfig, PoSConsensusVersion, PoWChainConfig, RewardDistributionVersion, - SighashInputCommitmentVersion, StakerDestinationUpdateForbidden, TokenIdGenerationVersion, - TokenIssuanceVersion, TokensFeeVersion, + ChainstateUpgrade, ChainstateUpgradesBuilder, ChangeTokenMetadataUriActivated, + ChangeTokenMetadataUriValidityCheckRequired, CoinUnit, ConsensusUpgrade, + DataDepositFeeVersion, Destination, FrozenTokensValidationVersion, GenBlock, Genesis, + HtlcActivated, NetUpgrades, OrdersActivated, OrdersVersion, PoSChainConfig, + PoSConsensusVersion, PoWChainConfig, PoolIdMismatchInKernelUtxoAndPoSDataForbidden, + RewardDistributionVersion, SighashInputCommitmentVersion, StakerDestinationUpdateForbidden, + TokenIdGenerationVersion, TokenIssuanceVersion, TokensFeeVersion, + ZeroTokenTransferForbidden, config::{ ChainConfig, ChainType, EmissionScheduleTabular, create_mainnet_genesis, create_testnet_genesis, create_unit_test_genesis, emission_schedule, @@ -84,6 +86,12 @@ const TESTNET_FORK_HEIGHT_4_ORDERS: BlockHeight = BlockHeight::new(325_180); // * prohibit updating the staker destination in ProduceBlockFromStake. const TESTNET_FORK_HEIGHT_5_ORDERS_V1: BlockHeight = BlockHeight::new(566_060); +// The fork where we tighten certain consensus rules: +// * Using mismatched kernel utxo and PoSData is no longer allowed. +// * Transferring zero amount of a token is no longer allowed. +// * In AccountCommand::ChangeTokenMetadataUri, using a URI with invalid characters is no longer allowed. +const TESTNET_FORK_HEIGHT_6_CONSENSUS_TIGHTENING: BlockHeight = BlockHeight::new(999_999_999); + // The fork at which: // * txs with htlc and order outputs become valid; // * data deposit fee and size are changed; @@ -99,6 +107,12 @@ const MAINNET_FORK_HEIGHT_1_HTLC_AND_ORDERS: BlockHeight = BlockHeight::new(254_ // * prohibit updating the staker destination in ProduceBlockFromStake. const MAINNET_FORK_HEIGHT_2_ORDERS_V1: BlockHeight = BlockHeight::new(517_700); +// The fork where we tighten certain consensus rules: +// * Using mismatched kernel utxo and PoSData is no longer allowed. +// * Transferring zero amount of a token is no longer allowed. +// * In AccountCommand::ChangeTokenMetadataUri, using a URI with invalid characters is no longer allowed. +const MAINNET_FORK_HEIGHT_3_CONSENSUS_TIGHTENING: BlockHeight = BlockHeight::new(999_999_999); + impl ChainType { fn default_genesis_init(&self) -> GenesisBlockInit { match self { @@ -216,6 +230,9 @@ impl ChainType { StakerDestinationUpdateForbidden::No, TokenIdGenerationVersion::V0, SighashInputCommitmentVersion::V0, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::No, + ZeroTokenTransferForbidden::No, + ChangeTokenMetadataUriValidityCheckRequired::No, )) .then(MAINNET_FORK_HEIGHT_1_HTLC_AND_ORDERS, |builder| { builder @@ -232,6 +249,16 @@ impl ChainType { .token_id_generation_version(TokenIdGenerationVersion::V1) .sighash_input_commitment_version(SighashInputCommitmentVersion::V1) }) + .then(MAINNET_FORK_HEIGHT_3_CONSENSUS_TIGHTENING, |builder| { + builder + .pool_id_mismatch_in_kernel_input_utxo_and_pos_data_forbidden( + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::Yes, + ) + .zero_token_transfer_forbidden(ZeroTokenTransferForbidden::Yes) + .change_token_metadata_uri_validity_check_required( + ChangeTokenMetadataUriValidityCheckRequired::Yes, + ) + }) .build(), ChainType::Regtest | ChainType::Signet => { let upgrades = vec![( @@ -253,6 +280,9 @@ impl ChainType { StakerDestinationUpdateForbidden::No, TokenIdGenerationVersion::V0, SighashInputCommitmentVersion::V0, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::No, + ZeroTokenTransferForbidden::No, + ChangeTokenMetadataUriValidityCheckRequired::No, )) .then(TESTNET_FORK_HEIGHT_1_TOKENS_V1, |builder| { builder.token_issuance_version(TokenIssuanceVersion::V1) @@ -283,6 +313,16 @@ impl ChainType { .token_id_generation_version(TokenIdGenerationVersion::V1) .sighash_input_commitment_version(SighashInputCommitmentVersion::V1) }) + .then(TESTNET_FORK_HEIGHT_6_CONSENSUS_TIGHTENING, |builder| { + builder + .pool_id_mismatch_in_kernel_input_utxo_and_pos_data_forbidden( + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::Yes, + ) + .zero_token_transfer_forbidden(ZeroTokenTransferForbidden::Yes) + .change_token_metadata_uri_validity_check_required( + ChangeTokenMetadataUriValidityCheckRequired::Yes, + ) + }) .build(), } } @@ -302,6 +342,9 @@ pub fn default_regtest_chainstate_upgrade_at_genesis() -> ChainstateUpgrade { StakerDestinationUpdateForbidden::Yes, TokenIdGenerationVersion::V1, SighashInputCommitmentVersion::V1, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::Yes, + ZeroTokenTransferForbidden::Yes, + ChangeTokenMetadataUriValidityCheckRequired::Yes, ) } @@ -761,7 +804,10 @@ mod tests { OrdersVersion::V0, StakerDestinationUpdateForbidden::No, TokenIdGenerationVersion::V0, - SighashInputCommitmentVersion::V0 + SighashInputCommitmentVersion::V0, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::No, + ZeroTokenTransferForbidden::No, + ChangeTokenMetadataUriValidityCheckRequired::No, ), ), ( @@ -778,7 +824,10 @@ mod tests { OrdersVersion::V0, StakerDestinationUpdateForbidden::No, TokenIdGenerationVersion::V0, - SighashInputCommitmentVersion::V0 + SighashInputCommitmentVersion::V0, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::No, + ZeroTokenTransferForbidden::No, + ChangeTokenMetadataUriValidityCheckRequired::No, ), ), ( @@ -795,7 +844,30 @@ mod tests { OrdersVersion::V1, StakerDestinationUpdateForbidden::Yes, TokenIdGenerationVersion::V1, - SighashInputCommitmentVersion::V1 + SighashInputCommitmentVersion::V1, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::No, + ZeroTokenTransferForbidden::No, + ChangeTokenMetadataUriValidityCheckRequired::No, + ), + ), + ( + MAINNET_FORK_HEIGHT_3_CONSENSUS_TIGHTENING, + ChainstateUpgrade::new( + TokenIssuanceVersion::V1, + RewardDistributionVersion::V1, + TokensFeeVersion::V1, + DataDepositFeeVersion::V1, + ChangeTokenMetadataUriActivated::Yes, + FrozenTokensValidationVersion::V1, + HtlcActivated::Yes, + OrdersActivated::Yes, + OrdersVersion::V1, + StakerDestinationUpdateForbidden::Yes, + TokenIdGenerationVersion::V1, + SighashInputCommitmentVersion::V1, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::Yes, + ZeroTokenTransferForbidden::Yes, + ChangeTokenMetadataUriValidityCheckRequired::Yes, ), ), ]) @@ -824,7 +896,10 @@ mod tests { OrdersVersion::V0, StakerDestinationUpdateForbidden::No, TokenIdGenerationVersion::V0, - SighashInputCommitmentVersion::V0 + SighashInputCommitmentVersion::V0, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::No, + ZeroTokenTransferForbidden::No, + ChangeTokenMetadataUriValidityCheckRequired::No, ), ), ( @@ -841,7 +916,10 @@ mod tests { OrdersVersion::V0, StakerDestinationUpdateForbidden::No, TokenIdGenerationVersion::V0, - SighashInputCommitmentVersion::V0 + SighashInputCommitmentVersion::V0, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::No, + ZeroTokenTransferForbidden::No, + ChangeTokenMetadataUriValidityCheckRequired::No, ), ), ( @@ -858,7 +936,10 @@ mod tests { OrdersVersion::V0, StakerDestinationUpdateForbidden::No, TokenIdGenerationVersion::V0, - SighashInputCommitmentVersion::V0 + SighashInputCommitmentVersion::V0, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::No, + ZeroTokenTransferForbidden::No, + ChangeTokenMetadataUriValidityCheckRequired::No, ), ), ( @@ -875,7 +956,10 @@ mod tests { OrdersVersion::V0, StakerDestinationUpdateForbidden::No, TokenIdGenerationVersion::V0, - SighashInputCommitmentVersion::V0 + SighashInputCommitmentVersion::V0, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::No, + ZeroTokenTransferForbidden::No, + ChangeTokenMetadataUriValidityCheckRequired::No, ), ), ( @@ -892,7 +976,10 @@ mod tests { OrdersVersion::V0, StakerDestinationUpdateForbidden::No, TokenIdGenerationVersion::V0, - SighashInputCommitmentVersion::V0 + SighashInputCommitmentVersion::V0, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::No, + ZeroTokenTransferForbidden::No, + ChangeTokenMetadataUriValidityCheckRequired::No, ), ), ( @@ -909,7 +996,30 @@ mod tests { OrdersVersion::V1, StakerDestinationUpdateForbidden::Yes, TokenIdGenerationVersion::V1, - SighashInputCommitmentVersion::V1 + SighashInputCommitmentVersion::V1, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::No, + ZeroTokenTransferForbidden::No, + ChangeTokenMetadataUriValidityCheckRequired::No, + ), + ), + ( + TESTNET_FORK_HEIGHT_6_CONSENSUS_TIGHTENING, + ChainstateUpgrade::new( + TokenIssuanceVersion::V1, + RewardDistributionVersion::V1, + TokensFeeVersion::V1, + DataDepositFeeVersion::V1, + ChangeTokenMetadataUriActivated::Yes, + FrozenTokensValidationVersion::V1, + HtlcActivated::Yes, + OrdersActivated::Yes, + OrdersVersion::V1, + StakerDestinationUpdateForbidden::Yes, + TokenIdGenerationVersion::V1, + SighashInputCommitmentVersion::V1, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::Yes, + ZeroTokenTransferForbidden::Yes, + ChangeTokenMetadataUriValidityCheckRequired::Yes, ), ), ]) diff --git a/common/src/chain/config/mod.rs b/common/src/chain/config/mod.rs index a71b81888a..64d5c92a81 100644 --- a/common/src/chain/config/mod.rs +++ b/common/src/chain/config/mod.rs @@ -55,11 +55,13 @@ use crate::{ }; use super::{ - ChainstateUpgrade, ChangeTokenMetadataUriActivated, ConsensusUpgrade, DataDepositFeeVersion, + ChainstateUpgrade, ChangeTokenMetadataUriActivated, + ChangeTokenMetadataUriValidityCheckRequired, ConsensusUpgrade, DataDepositFeeVersion, DestinationTag, FrozenTokensValidationVersion, HtlcActivated, OrdersActivated, OrdersVersion, - RequiredConsensus, RewardDistributionVersion, SighashInputCommitmentVersion, - StakerDestinationUpdateForbidden, TokenIdGenerationVersion, TokenIssuanceVersion, - TokensFeeVersion, output_value::OutputValue, stakelock::StakePoolData, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden, RequiredConsensus, RewardDistributionVersion, + SighashInputCommitmentVersion, StakerDestinationUpdateForbidden, TokenIdGenerationVersion, + TokenIssuanceVersion, TokensFeeVersion, ZeroTokenTransferForbidden, output_value::OutputValue, + stakelock::StakePoolData, }; use self::emission_schedule::{CoinUnit, DEFAULT_INITIAL_MINT}; @@ -922,6 +924,9 @@ pub fn create_unit_test_config_builder() -> Builder { StakerDestinationUpdateForbidden::Yes, TokenIdGenerationVersion::V1, SighashInputCommitmentVersion::V1, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::Yes, + ZeroTokenTransferForbidden::Yes, + ChangeTokenMetadataUriValidityCheckRequired::Yes, ), )]) .expect("cannot fail"), @@ -1242,7 +1247,13 @@ mod tests { #[should_panic(expected = "The net-upgrade at height 1 must not be IgnoreConsensus")] fn test_ignore_consensus_outside_regtest_with_deliberate_bad_upgrades() { let config = Builder::new(ChainType::Mainnet) - .consensus_upgrades(NetUpgrades::deliberate_ignore_consensus_twice()) + .consensus_upgrades( + NetUpgrades::initialize(vec![ + (BlockHeight::zero(), ConsensusUpgrade::IgnoreConsensus), + (BlockHeight::new(1), ConsensusUpgrade::IgnoreConsensus), + ]) + .unwrap(), + ) .build(); assert_no_ignore_consensus_in_chain_config(&config); diff --git a/common/src/chain/upgrades/chainstate_upgrade/builder.rs b/common/src/chain/upgrades/chainstate_upgrade/builder.rs index f7f2824fa8..546cab3fe9 100644 --- a/common/src/chain/upgrades/chainstate_upgrade/builder.rs +++ b/common/src/chain/upgrades/chainstate_upgrade/builder.rs @@ -17,7 +17,10 @@ use crate::chain::{ ChainstateUpgrade, ChangeTokenMetadataUriActivated, DataDepositFeeVersion, FrozenTokensValidationVersion, HtlcActivated, OrdersActivated, OrdersVersion, RewardDistributionVersion, SighashInputCommitmentVersion, StakerDestinationUpdateForbidden, - TokenIdGenerationVersion, TokenIssuanceVersion, TokensFeeVersion, + TokenIdGenerationVersion, TokenIssuanceVersion, TokensFeeVersion, ZeroTokenTransferForbidden, + upgrades::chainstate_upgrade::{ + ChangeTokenMetadataUriValidityCheckRequired, PoolIdMismatchInKernelUtxoAndPoSDataForbidden, + }, }; /// A builder for `ChainstateUpgrade`. @@ -74,6 +77,11 @@ impl ChainstateUpgradeBuilder { staker_destination_update_forbidden: StakerDestinationUpdateForbidden::Yes, token_id_generation_version: TokenIdGenerationVersion::V1, sighash_input_commitment_version: SighashInputCommitmentVersion::V1, + pool_id_mismatch_in_kernel_input_utxo_and_pos_data_forbidden: + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::Yes, + zero_token_transfer_forbidden: ZeroTokenTransferForbidden::Yes, + change_token_metadata_uri_validity_check_required: + ChangeTokenMetadataUriValidityCheckRequired::Yes, }) } @@ -93,4 +101,7 @@ impl ChainstateUpgradeBuilder { builder_method!(staker_destination_update_forbidden: StakerDestinationUpdateForbidden); builder_method!(token_id_generation_version: TokenIdGenerationVersion); builder_method!(sighash_input_commitment_version: SighashInputCommitmentVersion); + builder_method!(pool_id_mismatch_in_kernel_input_utxo_and_pos_data_forbidden: PoolIdMismatchInKernelUtxoAndPoSDataForbidden); + builder_method!(zero_token_transfer_forbidden: ZeroTokenTransferForbidden); + builder_method!(change_token_metadata_uri_validity_check_required: ChangeTokenMetadataUriValidityCheckRequired); } diff --git a/common/src/chain/upgrades/chainstate_upgrade/mod.rs b/common/src/chain/upgrades/chainstate_upgrade/mod.rs index 2b6cd3ece5..2372dbd8c0 100644 --- a/common/src/chain/upgrades/chainstate_upgrade/mod.rs +++ b/common/src/chain/upgrades/chainstate_upgrade/mod.rs @@ -116,6 +116,42 @@ pub enum SighashInputCommitmentVersion { V1, } +// The original implementation allows pool ids in the kernel stake utxo and PoSData to be different. +// TODO: same as StakerDestinationUpdateForbidden, this upgrade can probably be removed after +// the "fork height + reorg limit" height has been passed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd)] +pub enum PoolIdMismatchInKernelUtxoAndPoSDataForbidden { + Yes, + No, +} + +// Originally, it was allowed to have zero amount of a token in a TxOutput; after the fork +// we prohibit both transferring and burning zero amount of a token. +// TODO: after the "fork height + reorg limit" height has been passed, check if we really had +// zero-token outputs; if not, this fork can be removed completely after that. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd)] +pub enum ZeroTokenTransferForbidden { + Yes, + No, +} + +// During token issuance, a metadata uri validity check has always been performed (which at the +// moment ensures that the uri only contains alphanumeric or valid rfc 3986 characters, see +// `is_uri_valid` in the tx verifier). But during `AccountCommand::ChangeTokenMetadataUri` handling, +// this check historically has not been performed. After the fork we perform the validation in +// ChangeTokenMetadataUri too. +// TODO: same as for the similar upgrades above, after it's complete, check whether we've ever had +// a ChangeTokenMetadataUri transaction with a uri for which the uri validity check would fail; +// if not, the upgrade can be removed completely after that. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd)] +pub enum ChangeTokenMetadataUriValidityCheckRequired { + Yes, + No, +} + +// Note: we have 2 upgrade types - `ConsensusUpgrade` and `ChainstateUpgrade`. Despite the names, +// they both represent consensus upgrades. All upgrades not directly related to target difficulty +// calculation should probably go to `ChainstateUpgrade`. #[derive(Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] pub struct ChainstateUpgrade { token_issuance_version: TokenIssuanceVersion, @@ -130,6 +166,10 @@ pub struct ChainstateUpgrade { staker_destination_update_forbidden: StakerDestinationUpdateForbidden, token_id_generation_version: TokenIdGenerationVersion, sighash_input_commitment_version: SighashInputCommitmentVersion, + pool_id_mismatch_in_kernel_input_utxo_and_pos_data_forbidden: + PoolIdMismatchInKernelUtxoAndPoSDataForbidden, + zero_token_transfer_forbidden: ZeroTokenTransferForbidden, + change_token_metadata_uri_validity_check_required: ChangeTokenMetadataUriValidityCheckRequired, } impl ChainstateUpgrade { @@ -147,6 +187,10 @@ impl ChainstateUpgrade { staker_destination_update_forbidden: StakerDestinationUpdateForbidden, token_id_generation_version: TokenIdGenerationVersion, sighash_input_commitment_version: SighashInputCommitmentVersion, + pool_id_mismatch_in_kernel_input_utxo_and_pos_data_forbidden: + PoolIdMismatchInKernelUtxoAndPoSDataForbidden, + zero_token_transfer_forbidden: ZeroTokenTransferForbidden, + change_token_metadata_uri_validity_check_required: ChangeTokenMetadataUriValidityCheckRequired, ) -> Self { Self { token_issuance_version, @@ -161,6 +205,9 @@ impl ChainstateUpgrade { staker_destination_update_forbidden, token_id_generation_version, sighash_input_commitment_version, + pool_id_mismatch_in_kernel_input_utxo_and_pos_data_forbidden, + zero_token_transfer_forbidden, + change_token_metadata_uri_validity_check_required, } } @@ -211,4 +258,20 @@ impl ChainstateUpgrade { pub fn sighash_input_commitment_version(&self) -> SighashInputCommitmentVersion { self.sighash_input_commitment_version } + + pub fn pool_id_mismatch_in_kernel_input_utxo_and_pos_data_forbidden( + &self, + ) -> PoolIdMismatchInKernelUtxoAndPoSDataForbidden { + self.pool_id_mismatch_in_kernel_input_utxo_and_pos_data_forbidden + } + + pub fn zero_token_transfer_forbidden(&self) -> ZeroTokenTransferForbidden { + self.zero_token_transfer_forbidden + } + + pub fn change_token_metadata_uri_validity_check_required( + &self, + ) -> ChangeTokenMetadataUriValidityCheckRequired { + self.change_token_metadata_uri_validity_check_required + } } diff --git a/common/src/chain/upgrades/chainstate_upgrades_builder.rs b/common/src/chain/upgrades/chainstate_upgrades_builder.rs index 9323acb92d..a89cf1512b 100644 --- a/common/src/chain/upgrades/chainstate_upgrades_builder.rs +++ b/common/src/chain/upgrades/chainstate_upgrades_builder.rs @@ -52,10 +52,11 @@ impl ChainstateUpgradesBuilder { #[cfg(test)] mod tests { use crate::chain::{ - ChangeTokenMetadataUriActivated, DataDepositFeeVersion, FrozenTokensValidationVersion, - HtlcActivated, OrdersActivated, OrdersVersion, RewardDistributionVersion, + ChangeTokenMetadataUriActivated, ChangeTokenMetadataUriValidityCheckRequired, + DataDepositFeeVersion, FrozenTokensValidationVersion, HtlcActivated, OrdersActivated, + OrdersVersion, PoolIdMismatchInKernelUtxoAndPoSDataForbidden, RewardDistributionVersion, SighashInputCommitmentVersion, StakerDestinationUpdateForbidden, TokenIdGenerationVersion, - TokenIssuanceVersion, TokensFeeVersion, + TokenIssuanceVersion, TokensFeeVersion, ZeroTokenTransferForbidden, }; use super::*; @@ -75,6 +76,9 @@ mod tests { StakerDestinationUpdateForbidden::No, TokenIdGenerationVersion::V0, SighashInputCommitmentVersion::V0, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::No, + ZeroTokenTransferForbidden::No, + ChangeTokenMetadataUriValidityCheckRequired::No, )) .then(BlockHeight::new(1), |builder| { builder.token_issuance_version(TokenIssuanceVersion::V1) @@ -112,6 +116,19 @@ mod tests { .then(BlockHeight::new(12), |builder| { builder.sighash_input_commitment_version(SighashInputCommitmentVersion::V1) }) + .then(BlockHeight::new(13), |builder| { + builder.pool_id_mismatch_in_kernel_input_utxo_and_pos_data_forbidden( + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::Yes, + ) + }) + .then(BlockHeight::new(14), |builder| { + builder.zero_token_transfer_forbidden(ZeroTokenTransferForbidden::Yes) + }) + .then(BlockHeight::new(15), |builder| { + builder.change_token_metadata_uri_validity_check_required( + ChangeTokenMetadataUriValidityCheckRequired::Yes, + ) + }) .build(); let expected_upgrades = NetUpgrades::initialize(vec![ @@ -130,6 +147,9 @@ mod tests { StakerDestinationUpdateForbidden::No, TokenIdGenerationVersion::V0, SighashInputCommitmentVersion::V0, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::No, + ZeroTokenTransferForbidden::No, + ChangeTokenMetadataUriValidityCheckRequired::No, ), ), ( @@ -147,6 +167,9 @@ mod tests { StakerDestinationUpdateForbidden::No, TokenIdGenerationVersion::V0, SighashInputCommitmentVersion::V0, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::No, + ZeroTokenTransferForbidden::No, + ChangeTokenMetadataUriValidityCheckRequired::No, ), ), ( @@ -164,6 +187,9 @@ mod tests { StakerDestinationUpdateForbidden::No, TokenIdGenerationVersion::V0, SighashInputCommitmentVersion::V0, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::No, + ZeroTokenTransferForbidden::No, + ChangeTokenMetadataUriValidityCheckRequired::No, ), ), ( @@ -181,6 +207,9 @@ mod tests { StakerDestinationUpdateForbidden::No, TokenIdGenerationVersion::V0, SighashInputCommitmentVersion::V0, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::No, + ZeroTokenTransferForbidden::No, + ChangeTokenMetadataUriValidityCheckRequired::No, ), ), ( @@ -198,6 +227,9 @@ mod tests { StakerDestinationUpdateForbidden::No, TokenIdGenerationVersion::V0, SighashInputCommitmentVersion::V0, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::No, + ZeroTokenTransferForbidden::No, + ChangeTokenMetadataUriValidityCheckRequired::No, ), ), ( @@ -215,6 +247,9 @@ mod tests { StakerDestinationUpdateForbidden::No, TokenIdGenerationVersion::V0, SighashInputCommitmentVersion::V0, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::No, + ZeroTokenTransferForbidden::No, + ChangeTokenMetadataUriValidityCheckRequired::No, ), ), ( @@ -232,6 +267,9 @@ mod tests { StakerDestinationUpdateForbidden::No, TokenIdGenerationVersion::V0, SighashInputCommitmentVersion::V0, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::No, + ZeroTokenTransferForbidden::No, + ChangeTokenMetadataUriValidityCheckRequired::No, ), ), ( @@ -249,6 +287,9 @@ mod tests { StakerDestinationUpdateForbidden::No, TokenIdGenerationVersion::V0, SighashInputCommitmentVersion::V0, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::No, + ZeroTokenTransferForbidden::No, + ChangeTokenMetadataUriValidityCheckRequired::No, ), ), ( @@ -266,6 +307,9 @@ mod tests { StakerDestinationUpdateForbidden::No, TokenIdGenerationVersion::V0, SighashInputCommitmentVersion::V0, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::No, + ZeroTokenTransferForbidden::No, + ChangeTokenMetadataUriValidityCheckRequired::No, ), ), ( @@ -283,6 +327,9 @@ mod tests { StakerDestinationUpdateForbidden::No, TokenIdGenerationVersion::V0, SighashInputCommitmentVersion::V0, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::No, + ZeroTokenTransferForbidden::No, + ChangeTokenMetadataUriValidityCheckRequired::No, ), ), ( @@ -300,6 +347,9 @@ mod tests { StakerDestinationUpdateForbidden::Yes, TokenIdGenerationVersion::V0, SighashInputCommitmentVersion::V0, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::No, + ZeroTokenTransferForbidden::No, + ChangeTokenMetadataUriValidityCheckRequired::No, ), ), ( @@ -317,6 +367,9 @@ mod tests { StakerDestinationUpdateForbidden::Yes, TokenIdGenerationVersion::V1, SighashInputCommitmentVersion::V0, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::No, + ZeroTokenTransferForbidden::No, + ChangeTokenMetadataUriValidityCheckRequired::No, ), ), ( @@ -334,6 +387,69 @@ mod tests { StakerDestinationUpdateForbidden::Yes, TokenIdGenerationVersion::V1, SighashInputCommitmentVersion::V1, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::No, + ZeroTokenTransferForbidden::No, + ChangeTokenMetadataUriValidityCheckRequired::No, + ), + ), + ( + BlockHeight::new(13), + ChainstateUpgrade::new( + TokenIssuanceVersion::V1, + RewardDistributionVersion::V1, + TokensFeeVersion::V1, + DataDepositFeeVersion::V1, + ChangeTokenMetadataUriActivated::Yes, + FrozenTokensValidationVersion::V1, + HtlcActivated::Yes, + OrdersActivated::Yes, + OrdersVersion::V1, + StakerDestinationUpdateForbidden::Yes, + TokenIdGenerationVersion::V1, + SighashInputCommitmentVersion::V1, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::Yes, + ZeroTokenTransferForbidden::No, + ChangeTokenMetadataUriValidityCheckRequired::No, + ), + ), + ( + BlockHeight::new(14), + ChainstateUpgrade::new( + TokenIssuanceVersion::V1, + RewardDistributionVersion::V1, + TokensFeeVersion::V1, + DataDepositFeeVersion::V1, + ChangeTokenMetadataUriActivated::Yes, + FrozenTokensValidationVersion::V1, + HtlcActivated::Yes, + OrdersActivated::Yes, + OrdersVersion::V1, + StakerDestinationUpdateForbidden::Yes, + TokenIdGenerationVersion::V1, + SighashInputCommitmentVersion::V1, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::Yes, + ZeroTokenTransferForbidden::Yes, + ChangeTokenMetadataUriValidityCheckRequired::No, + ), + ), + ( + BlockHeight::new(15), + ChainstateUpgrade::new( + TokenIssuanceVersion::V1, + RewardDistributionVersion::V1, + TokensFeeVersion::V1, + DataDepositFeeVersion::V1, + ChangeTokenMetadataUriActivated::Yes, + FrozenTokensValidationVersion::V1, + HtlcActivated::Yes, + OrdersActivated::Yes, + OrdersVersion::V1, + StakerDestinationUpdateForbidden::Yes, + TokenIdGenerationVersion::V1, + SighashInputCommitmentVersion::V1, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::Yes, + ZeroTokenTransferForbidden::Yes, + ChangeTokenMetadataUriValidityCheckRequired::Yes, ), ), ]) @@ -358,6 +474,9 @@ mod tests { StakerDestinationUpdateForbidden::No, TokenIdGenerationVersion::V0, SighashInputCommitmentVersion::V0, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::No, + ZeroTokenTransferForbidden::No, + ChangeTokenMetadataUriValidityCheckRequired::No, )) .then(BlockHeight::new(2), |builder| { builder.token_issuance_version(TokenIssuanceVersion::V1) @@ -384,6 +503,9 @@ mod tests { StakerDestinationUpdateForbidden::No, TokenIdGenerationVersion::V0, SighashInputCommitmentVersion::V0, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::No, + ZeroTokenTransferForbidden::No, + ChangeTokenMetadataUriValidityCheckRequired::No, )) .then(BlockHeight::new(1), |builder| { builder.token_issuance_version(TokenIssuanceVersion::V1) @@ -410,6 +532,9 @@ mod tests { StakerDestinationUpdateForbidden::No, TokenIdGenerationVersion::V0, SighashInputCommitmentVersion::V0, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::No, + ZeroTokenTransferForbidden::No, + ChangeTokenMetadataUriValidityCheckRequired::No, )) .then(BlockHeight::new(0), |builder| { builder.token_issuance_version(TokenIssuanceVersion::V1) @@ -433,6 +558,9 @@ mod tests { StakerDestinationUpdateForbidden::No, TokenIdGenerationVersion::V0, SighashInputCommitmentVersion::V0, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::No, + ZeroTokenTransferForbidden::No, + ChangeTokenMetadataUriValidityCheckRequired::No, )) .then(BlockHeight::new(1), |builder| { builder.token_issuance_version(TokenIssuanceVersion::V0) diff --git a/common/src/chain/upgrades/consensus_upgrade.rs b/common/src/chain/upgrades/consensus_upgrade.rs index 6a0250e6d6..9cb9ca3b31 100644 --- a/common/src/chain/upgrades/consensus_upgrade.rs +++ b/common/src/chain/upgrades/consensus_upgrade.rs @@ -16,13 +16,17 @@ use crate::Uint256; use crate::chain::config::ChainType; use crate::chain::pos::{DEFAULT_BLOCK_COUNT_TO_AVERAGE, DEFAULT_MATURITY_BLOCK_COUNT_V0}; -use crate::chain::pow::limit; use crate::chain::{PoSChainConfig, PoSConsensusVersion, pos_initial_difficulty}; use crate::primitives::per_thousand::PerThousand; use crate::primitives::{BlockHeight, Compact}; use super::NetUpgrades; +// Note: we have 2 upgrade types - `ConsensusUpgrade` and `ChainstateUpgrade`. Despite the names, +// they both represent consensus upgrades. All upgrades not directly related to target difficulty +// calculation should probably go to `ChainstateUpgrade`. +// TODO: `PoSChainConfig` currently holds `staking_pool_spend_maturity_block_count`, which doesn't +// participate in the difficulty calculation, so it should probably be moved to `ChainstateUpgrade`. #[derive(Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] pub enum ConsensusUpgrade { PoW { @@ -90,16 +94,6 @@ impl From for RequiredConsensus { } impl NetUpgrades { - pub fn new_for_chain(chain_type: ChainType) -> Self { - Self::initialize(vec![( - BlockHeight::zero(), - ConsensusUpgrade::PoW { - initial_difficulty: limit(chain_type).into(), - }, - )]) - .expect("cannot fail") - } - pub fn unit_tests() -> Self { Self::initialize(vec![( BlockHeight::zero(), @@ -108,15 +102,6 @@ impl NetUpgrades { .expect("cannot fail") } - #[cfg(test)] - pub fn deliberate_ignore_consensus_twice() -> Self { - Self::initialize(vec![ - (BlockHeight::zero(), ConsensusUpgrade::IgnoreConsensus), - (BlockHeight::new(1), ConsensusUpgrade::IgnoreConsensus), - ]) - .expect("cannot fail") - } - pub fn regtest_with_pos() -> Self { Self::regtest_with_pos_generic( BlockHeight::new(1), diff --git a/common/src/chain/upgrades/mod.rs b/common/src/chain/upgrades/mod.rs index de6be74965..963db9b40b 100644 --- a/common/src/chain/upgrades/mod.rs +++ b/common/src/chain/upgrades/mod.rs @@ -20,10 +20,11 @@ mod netupgrade; pub use chainstate_upgrade::{ ChainstateUpgrade, ChainstateUpgradeBuilder, ChangeTokenMetadataUriActivated, - DataDepositFeeVersion, FrozenTokensValidationVersion, HtlcActivated, OrdersActivated, - OrdersVersion, RewardDistributionVersion, SighashInputCommitmentVersion, - StakerDestinationUpdateForbidden, TokenIdGenerationVersion, TokenIssuanceVersion, - TokensFeeVersion, + ChangeTokenMetadataUriValidityCheckRequired, DataDepositFeeVersion, + FrozenTokensValidationVersion, HtlcActivated, OrdersActivated, OrdersVersion, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden, RewardDistributionVersion, + SighashInputCommitmentVersion, StakerDestinationUpdateForbidden, TokenIdGenerationVersion, + TokenIssuanceVersion, TokensFeeVersion, ZeroTokenTransferForbidden, }; pub use chainstate_upgrades_builder::ChainstateUpgradesBuilder; pub use consensus_upgrade::{ConsensusUpgrade, PoSStatus, PoWStatus, RequiredConsensus}; diff --git a/consensus/src/pos/error.rs b/consensus/src/pos/error.rs index 016d5cd5c2..cacd8904b7 100644 --- a/consensus/src/pos/error.rs +++ b/consensus/src/pos/error.rs @@ -108,4 +108,11 @@ pub enum ConsensusPoSError { FailedToCalculateCappedBalance, #[error("Invalid kernel output type in block {0}")] InvalidOutputTypeInStakeKernel(Id), + #[error( + "Pool id inside kernel input utxo ({kernel_utxo_pool_id:x}) doesn't match pool id from PoSData ({pos_data_pool_id:x})" + )] + PoolIdsInKernelUtxoAndPoSDataMismatch { + kernel_utxo_pool_id: PoolId, + pos_data_pool_id: PoolId, + }, } diff --git a/consensus/src/pos/mod.rs b/consensus/src/pos/mod.rs index 2e1de27193..6300193178 100644 --- a/consensus/src/pos/mod.rs +++ b/consensus/src/pos/mod.rs @@ -30,7 +30,8 @@ use common::{ Uint256, address::Address, chain::{ - ChainConfig, CoinUnit, PoSChainConfig, PoSStatus, TxOutput, + ChainConfig, CoinUnit, PoSChainConfig, PoSStatus, + PoolIdMismatchInKernelUtxoAndPoSDataForbidden, TxOutput, block::{ BlockHeader, ConsensusData, consensus_data::PoSData, signed_block_header::SignedBlockHeader, timestamp::BlockTimestamp, @@ -166,7 +167,7 @@ where .get_pool_data(pool_id)? .ok_or(ConsensusPoSError::PoolDataNotFound(pool_id))?; - let staker_dest = { + let (pool_id_in_kernel_utxo, staker_dest) = { let kernel_output = get_kernel_output(pos_data.kernel_inputs(), utxos_view)?; match kernel_output { @@ -184,11 +185,30 @@ where header.get_id(), )); } - TxOutput::CreateStakePool(_, stake_pool) => stake_pool.staker().clone(), - TxOutput::ProduceBlockFromStake(dest, _) => dest, + TxOutput::CreateStakePool(pool_id, pool_data) => (pool_id, pool_data.staker().clone()), + TxOutput::ProduceBlockFromStake(dest, pool_id) => (pool_id, dest), } }; + let pool_id_mismatch_forbidden = chain_config + .chainstate_upgrades() + .version_at_height(current_height) + .1 + .pool_id_mismatch_in_kernel_input_utxo_and_pos_data_forbidden(); + + match pool_id_mismatch_forbidden { + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::Yes => { + ensure!( + pool_id == pool_id_in_kernel_utxo, + ConsensusPoSError::PoolIdsInKernelUtxoAndPoSDataMismatch { + kernel_utxo_pool_id: pool_id_in_kernel_utxo, + pos_data_pool_id: pool_id + } + ); + } + PoolIdMismatchInKernelUtxoAndPoSDataForbidden::No => {} + } + // Proof of stake mandates signing the block with the same key of the kernel output check_block_signature(header, &staker_dest)?; diff --git a/mempool/src/error/ban_score.rs b/mempool/src/error/ban_score.rs index 51a288f287..c3fe7dfb08 100644 --- a/mempool/src/error/ban_score.rs +++ b/mempool/src/error/ban_score.rs @@ -213,6 +213,7 @@ impl MempoolBanScore for ConnectTransactionError { ConnectTransactionError::InsufficientCoinsFee(_, _) => 100, ConnectTransactionError::AttemptToSpendFrozenToken(_) => 100, ConnectTransactionError::ProduceBlockFromStakeChangesStakerDestination(_, _) => 100, + ConnectTransactionError::ZeroTokenTransfer(_) => 100, // Need to drill down deeper into the error in these cases ConnectTransactionError::IOPolicyError(err, _) => err.ban_score(), diff --git a/mempool/src/pool/orphans/detect.rs b/mempool/src/pool/orphans/detect.rs index 8ebf2c5ddf..b8994bab2a 100644 --- a/mempool/src/pool/orphans/detect.rs +++ b/mempool/src/pool/orphans/detect.rs @@ -87,6 +87,7 @@ impl OrphanType { | CTE::ConcludeInputAmountsDontMatch(_, _) | CTE::IOPolicyError(_, _) | CTE::ProduceBlockFromStakeChangesStakerDestination(_, _) + | CTE::ZeroTokenTransfer(_) | CTE::IdCreationError(_) => Err(err), } } diff --git a/p2p/src/sync/tests/block_announcement.rs b/p2p/src/sync/tests/block_announcement.rs index ff141429f3..638ebfce67 100644 --- a/p2p/src/sync/tests/block_announcement.rs +++ b/p2p/src/sync/tests/block_announcement.rs @@ -18,12 +18,13 @@ use std::sync::Arc; use chainstate::{BlockError, ChainstateError, CheckBlockError, ban_score::BanScore}; use chainstate_test_framework::TestFramework; use common::{ + Uint256, chain::{ - Block, NetUpgrades, + Block, ConsensusUpgrade, NetUpgrades, block::{BlockReward, ConsensusData, timestamp::BlockTimestamp}, config::{Builder as ChainConfigBuilder, ChainType, create_unit_test_config}, }, - primitives::{Idable, user_agent::mintlayer_core_user_agent}, + primitives::{BlockHeight, Idable, user_agent::mintlayer_core_user_agent}, }; use consensus::ConsensusVerificationError; use logging::log; @@ -137,8 +138,16 @@ async fn invalid_consensus_data() { for_each_protocol_version(|protocol_version| async move { let chain_config = Arc::new( ChainConfigBuilder::new(ChainType::Regtest) - // Enable consensus, so blocks with `ConsensusData::None` would be rejected. - .consensus_upgrades(NetUpgrades::new_for_chain(ChainType::Regtest)) + // Enable PoW consensus, so blocks with `ConsensusData::None` would be rejected. + .consensus_upgrades( + NetUpgrades::initialize(vec![( + BlockHeight::new(0), + ConsensusUpgrade::PoW { + initial_difficulty: Uint256::MAX.into(), + }, + )]) + .unwrap(), + ) .build(), ); let mut node = TestNode::builder(protocol_version) diff --git a/p2p/src/sync/tests/no_discouragement_after_tx_reorg.rs b/p2p/src/sync/tests/no_discouragement_after_tx_reorg.rs index e508c16751..4d1ab34b51 100644 --- a/p2p/src/sync/tests/no_discouragement_after_tx_reorg.rs +++ b/p2p/src/sync/tests/no_discouragement_after_tx_reorg.rs @@ -168,11 +168,12 @@ async fn no_discouragement_after_tx_reorg(#[case] seed: Seed) { Destination::PublicKeyHash(PublicKeyHash::random_using(&mut tfxt.rng)); let another_unminted_token2_id = tfxt.issue_token(); - let another_unminted_token2_new_metadata_uri = gen_random_bytes( + let another_unminted_token2_new_metadata_uri = random_ascii_alphanumeric_string( &mut tfxt.rng, - 1, - tfxt.tfrm.chain_config().token_max_uri_len(), - ); + 1..=tfxt.tfrm.chain_config().token_max_uri_len(), + ) + .as_bytes() + .to_vec(); let another_pool_pledge = tfxt .rng diff --git a/test/functional/test_framework/mintlayer.py b/test/functional/test_framework/mintlayer.py index c57f5051e0..1d5f5ad5ca 100644 --- a/test/functional/test_framework/mintlayer.py +++ b/test/functional/test_framework/mintlayer.py @@ -18,6 +18,7 @@ import hashlib import random import scalecodec +import string import time from decimal import Decimal @@ -171,3 +172,6 @@ def make_delegation_id(outpoint): def random_decimal_amount(min: int, max: int, num_decimals: int) -> Decimal: atoms_per_unit = 10 ** num_decimals return Decimal(random.randint(min * atoms_per_unit, max * atoms_per_unit)) / atoms_per_unit + +def random_alphanum_str(len: int) -> str: + return ''.join(random.choices(string.ascii_lowercase + string.ascii_uppercase + string.digits, k=len)) diff --git a/test/functional/wallet_tokens_change_metadata_uri.py b/test/functional/wallet_tokens_change_metadata_uri.py index 57a1196a32..4b33aa7c20 100644 --- a/test/functional/wallet_tokens_change_metadata_uri.py +++ b/test/functional/wallet_tokens_change_metadata_uri.py @@ -29,7 +29,7 @@ """ from test_framework.test_framework import BitcoinTestFramework -from test_framework.mintlayer import (make_tx, reward_input, ATOMS_PER_COIN) +from test_framework.mintlayer import (make_tx, random_alphanum_str, reward_input, ATOMS_PER_COIN) from test_framework.util import assert_in, assert_equal from test_framework.mintlayer import block_input_data_obj from test_framework.wallet_cli_controller import WalletCliController @@ -89,7 +89,7 @@ async def async_test(self): # Submit a valid transaction output = { - 'Transfer': [ { 'Coin': 1001 * ATOMS_PER_COIN }, { 'PublicKey': {'key': {'Secp256k1Schnorr' : {'pubkey_data': pub_key_bytes}}} } ], + 'Transfer': [ { 'Coin': 1001 * ATOMS_PER_COIN }, { 'PublicKey': {'key': {'Secp256k1Schnorr' : {'pubkey_data': pub_key_bytes}}} } ], } encoded_tx, tx_id = make_tx([reward_input(tip_id)], [output], 0) @@ -122,7 +122,7 @@ async def async_test(self): token_info = node.chainstate_token_info(token_id) assert_equal(metadata_uri, token_info['content']['metadata_uri']['text']); - new_metadata_uri = bytes([random.randint(0, 255) for _ in range(random.randint(1, 128))]).hex() + new_metadata_uri = random_alphanum_str(random.randint(1, 128)).encode().hex() assert_in("The transaction was submitted successfully", await wallet.change_token_metadata_uri(token_id, new_metadata_uri)) self.generate_block()