diff --git a/chainstate/test-framework/src/framework.rs b/chainstate/test-framework/src/framework.rs index 5d942c216e..d6e84a0534 100644 --- a/chainstate/test-framework/src/framework.rs +++ b/chainstate/test-framework/src/framework.rs @@ -49,6 +49,7 @@ use common::{ use crypto::{key::PrivateKey, vrf::VRFPrivateKey}; use randomness::{CryptoRng, Rng}; use utils::atomics::SeqCstAtomicU64; +use utxo::Utxo; /// The `Chainstate` wrapper that simplifies operations and checks in the tests. #[must_use] @@ -590,11 +591,12 @@ impl TestFramework { self.best_block_height().next_height() } + pub fn utxo(&self, outpoint: &UtxoOutPoint) -> Utxo { + self.chainstate.utxo(outpoint).unwrap().unwrap() + } + pub fn coin_amount_from_utxo(&self, outpoint: &UtxoOutPoint) -> Amount { - get_output_value(self.chainstate.utxo(outpoint).unwrap().unwrap().output()) - .unwrap() - .coin_amount() - .unwrap() + get_output_value(self.utxo(outpoint).output()).unwrap().coin_amount().unwrap() } } diff --git a/chainstate/test-suite/src/tests/helpers/mod.rs b/chainstate/test-suite/src/tests/helpers/mod.rs index d27a6dff89..046c80c51d 100644 --- a/chainstate/test-suite/src/tests/helpers/mod.rs +++ b/chainstate/test-suite/src/tests/helpers/mod.rs @@ -24,12 +24,13 @@ use common::{ signature::inputsig::InputWitness, timelock::OutputTimeLock, tokens::{TokenId, TokenIssuance}, - AccountCommand, AccountNonce, AccountType, Block, Destination, GenBlock, Transaction, - TxInput, TxOutput, UtxoOutPoint, + AccountCommand, AccountNonce, AccountType, Block, Destination, GenBlock, OrderId, + OrdersVersion, Transaction, TxInput, TxOutput, UtxoOutPoint, }, primitives::{Amount, BlockDistance, BlockHeight, Id, Idable}, }; use crypto::key::{KeyKind, PrivateKey}; +use orders_accounting::OrdersAccountingDB; use randomness::{CryptoRng, Rng}; pub mod block_creation_helpers; @@ -185,3 +186,21 @@ pub fn mint_tokens_in_block( (block_id, tx_id) } + +/// Given the fill amount in the "ask" currency, return the filled amount in the "give" currency. +pub fn calculate_fill_order( + tf: &TestFramework, + order_id: &OrderId, + fill_amount_in_ask_currency: Amount, + orders_version: OrdersVersion, +) -> Amount { + let db_tx = tf.storage.transaction_ro().unwrap(); + let orders_db = OrdersAccountingDB::new(&db_tx); + orders_accounting::calculate_fill_order( + &orders_db, + *order_id, + fill_amount_in_ask_currency, + orders_version, + ) + .unwrap() +} diff --git a/chainstate/test-suite/src/tests/orders_tests.rs b/chainstate/test-suite/src/tests/orders_tests.rs index 0f3ebae9f8..4650f66a6b 100644 --- a/chainstate/test-suite/src/tests/orders_tests.rs +++ b/chainstate/test-suite/src/tests/orders_tests.rs @@ -16,7 +16,6 @@ use rstest::rstest; use chainstate::{CheckBlockError, CheckBlockTransactionsError, ConnectTransactionError}; -use chainstate_storage::Transactional; use chainstate_test_framework::{output_value_amount, TestFramework, TransactionBuilder}; use common::{ address::pubkeyhash::PublicKeyHash, @@ -25,21 +24,22 @@ use common::{ output_value::OutputValue, signature::{ inputsig::{standard_signature::StandardInputSignature, InputWitness}, + sighash::sighashtype::SigHashType, DestinationSigError, }, tokens::{IsTokenFreezable, TokenId, TokenIssuance, TokenIssuanceV1, TokenTotalSupply}, AccountCommand, AccountNonce, ChainstateUpgradeBuilder, Destination, OrderAccountCommand, - OrderData, OrderId, OrdersVersion, SignedTransaction, TxInput, TxOutput, UtxoOutPoint, + OrderData, OrderId, OrdersVersion, SignedTransaction, Transaction, TxInput, TxOutput, + UtxoOutPoint, }, primitives::{Amount, BlockHeight, CoinOrTokenId, Idable, H256}, }; use crypto::key::{KeyKind, PrivateKey}; use logging::log; -use orders_accounting::OrdersAccountingDB; use randomness::{CryptoRng, Rng, SliceRandom}; use test_utils::{ nft_utils::random_nft_issuance, - random::{make_seedable_rng, Seed}, + random::{gen_random_bytes, make_seedable_rng, Seed}, random_ascii_alphanumeric_string, }; use tx_verifier::{ @@ -47,7 +47,7 @@ use tx_verifier::{ CheckTransactionError, }; -use crate::tests::helpers::{issue_token_from_block, mint_tokens_in_block}; +use crate::tests::helpers::{calculate_fill_order, issue_token_from_block, mint_tokens_in_block}; fn create_test_framework_with_orders( rng: &mut (impl Rng + CryptoRng), @@ -677,12 +677,7 @@ fn fill_order_check_storage(#[case] seed: Seed, #[case] version: OrdersVersion) // Fill the order partially let fill_amount = Amount::from_atoms(rng.gen_range(1..ask_amount.into_atoms())); - let filled_amount = { - let db_tx = tf.storage.transaction_ro().unwrap(); - let orders_db = OrdersAccountingDB::new(&db_tx); - orders_accounting::calculate_fill_order(&orders_db, order_id, fill_amount, version) - .unwrap() - }; + let filled_amount = calculate_fill_order(&tf, &order_id, fill_amount, version); let left_to_fill = (ask_amount - fill_amount).unwrap(); let fill_order_input = match version { @@ -725,12 +720,7 @@ fn fill_order_check_storage(#[case] seed: Seed, #[case] version: OrdersVersion) ); // Fill the rest of the order - let filled_amount = { - let db_tx = tf.storage.transaction_ro().unwrap(); - let orders_db = OrdersAccountingDB::new(&db_tx); - orders_accounting::calculate_fill_order(&orders_db, order_id, left_to_fill, version) - .unwrap() - }; + let filled_amount = calculate_fill_order(&tf, &order_id, left_to_fill, version); let fill_order_input = match version { OrdersVersion::V0 => TxInput::AccountCommand( @@ -821,12 +811,7 @@ fn fill_partially_then_conclude(#[case] seed: Seed, #[case] version: OrdersVersi // Fill the order partially let fill_amount = Amount::from_atoms(rng.gen_range(1..=ask_amount.into_atoms())); - let filled_amount = { - let db_tx = tf.storage.transaction_ro().unwrap(); - let orders_db = OrdersAccountingDB::new(&db_tx); - orders_accounting::calculate_fill_order(&orders_db, order_id, fill_amount, version) - .unwrap() - }; + let filled_amount = calculate_fill_order(&tf, &order_id, fill_amount, version); let fill_input = match version { OrdersVersion::V0 => TxInput::AccountCommand( @@ -1541,12 +1526,7 @@ fn reorg_before_create(#[case] seed: Seed, #[case] version: OrdersVersion) { // Fill the order partially let fill_amount = Amount::from_atoms(rng.gen_range(1..ask_amount.into_atoms())); - let filled_amount = { - let db_tx = tf.storage.transaction_ro().unwrap(); - let orders_db = OrdersAccountingDB::new(&db_tx); - orders_accounting::calculate_fill_order(&orders_db, order_id, fill_amount, version) - .unwrap() - }; + let filled_amount = calculate_fill_order(&tf, &order_id, fill_amount, version); let left_to_fill = (ask_amount - fill_amount).unwrap(); let fill_input = match version { @@ -1667,12 +1647,7 @@ fn reorg_after_create(#[case] seed: Seed, #[case] version: OrdersVersion) { // Fill the order partially let fill_amount = Amount::from_atoms(rng.gen_range(1..ask_amount.into_atoms())); - let filled_amount = { - let db_tx = tf.storage.transaction_ro().unwrap(); - let orders_db = OrdersAccountingDB::new(&db_tx); - orders_accounting::calculate_fill_order(&orders_db, order_id, fill_amount, version) - .unwrap() - }; + let filled_amount = calculate_fill_order(&tf, &order_id, fill_amount, version); let left_to_fill = (ask_amount - fill_amount).unwrap(); let fill_input = match version { @@ -2799,17 +2774,7 @@ fn create_order_fill_activate_fork_fill_conclude(#[case] seed: Seed) { // Fill the order partially let fill_amount = Amount::from_atoms(100); - let filled_amount = { - let db_tx = tf.storage.transaction_ro().unwrap(); - let orders_db = OrdersAccountingDB::new(&db_tx); - orders_accounting::calculate_fill_order( - &orders_db, - order_id, - fill_amount, - OrdersVersion::V0, - ) - .unwrap() - }; + let filled_amount = calculate_fill_order(&tf, &order_id, fill_amount, OrdersVersion::V0); let fill_tx_1 = TransactionBuilder::new() .add_input(coins_outpoint.into(), InputWitness::NoSignature(None)) @@ -2839,17 +2804,7 @@ fn create_order_fill_activate_fork_fill_conclude(#[case] seed: Seed) { assert_eq!(BlockHeight::new(4), tf.best_block_index().block_height()); // Fill again now with V1 - let filled_amount = { - let db_tx = tf.storage.transaction_ro().unwrap(); - let orders_db = OrdersAccountingDB::new(&db_tx); - orders_accounting::calculate_fill_order( - &orders_db, - order_id, - fill_amount, - OrdersVersion::V1, - ) - .unwrap() - }; + let filled_amount = calculate_fill_order(&tf, &order_id, fill_amount, OrdersVersion::V1); tf.make_block_builder() .add_transaction( @@ -3194,17 +3149,7 @@ fn fill_freeze_conclude_order(#[case] seed: Seed) { // Fill order partially let fill_amount = Amount::from_atoms(rng.gen_range(1..ask_amount.into_atoms())); - let filled_amount = { - let db_tx = tf.storage.transaction_ro().unwrap(); - let orders_db = OrdersAccountingDB::new(&db_tx); - orders_accounting::calculate_fill_order( - &orders_db, - order_id, - fill_amount, - OrdersVersion::V1, - ) - .unwrap() - }; + let filled_amount = calculate_fill_order(&tf, &order_id, fill_amount, OrdersVersion::V1); let left_to_fill = (ask_amount - fill_amount).unwrap(); let fill_tx = TransactionBuilder::new() .add_input(coins_outpoint.into(), InputWitness::NoSignature(None)) @@ -3411,3 +3356,229 @@ fn order_with_zero_value(#[case] seed: Seed, #[case] version: OrdersVersion) { ); }); } + +// The destination specified in FillOrder inputs is there only to make the inputs distinct +// across multiple transactions. So this test proves that: +// 1) The destination doesn't have to be the same as the actual output destination. +// 2) The signature for a FillOrder input is not enforced, i.e. it can be empty or correspond +// to some unrelated destination or contain arbitrary data. +#[rstest] +#[trace] +#[case(Seed::from_entropy(), OrdersVersion::V0)] +#[case(Seed::from_entropy(), OrdersVersion::V1)] +fn fill_order_destination_irrelevancy(#[case] seed: Seed, #[case] 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, mut coins_outpoint) = + issue_and_mint_token_from_genesis(&mut rng, &mut tf); + + let initial_ask_amount = Amount::from_atoms(1000); + let initial_give_amount = Amount::from_atoms(1000); + let order_data = OrderData::new( + Destination::AnyoneCanSpend, + OutputValue::Coin(initial_ask_amount), + OutputValue::TokenV1(token_id, initial_give_amount), + ); + + let tx = TransactionBuilder::new() + .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(); + tf.make_block_builder().add_transaction(tx).build_and_process(&mut rng).unwrap(); + + let (_, pk1) = PrivateKey::new_from_rng(&mut rng, KeyKind::Secp256k1Schnorr); + let (sk2, pk2) = PrivateKey::new_from_rng(&mut rng, KeyKind::Secp256k1Schnorr); + let (_, pk3) = PrivateKey::new_from_rng(&mut rng, KeyKind::Secp256k1Schnorr); + + let output_destination = if rng.gen_bool(0.5) { + Destination::PublicKey(pk3) + } else { + Destination::AnyoneCanSpend + }; + + let mut total_fill_amount = Amount::ZERO; + let mut total_filled_amount = Amount::ZERO; + + // The destination in FillOrder is PublicKey(pk1), the input is not signed. + // The actual output destination is output_destination. + { + let fill_amount1 = + Amount::from_atoms(rng.gen_range(1..initial_ask_amount.into_atoms() / 10)); + let filled_amount1 = calculate_fill_order(&tf, &order_id, fill_amount1, version); + let fill_order_input1 = make_fill_order_input( + version, + AccountNonce::new(0), + &order_id, + fill_amount1, + Destination::PublicKey(pk1.clone()), + ); + + let coins_left = tf.coin_amount_from_utxo(&coins_outpoint); + let change = (coins_left - fill_amount1).unwrap(); + let fill_tx_1 = TransactionBuilder::new() + .add_input(coins_outpoint.into(), InputWitness::NoSignature(None)) + .add_input(fill_order_input1, InputWitness::NoSignature(None)) + .add_output(TxOutput::Transfer( + OutputValue::TokenV1(token_id, filled_amount1), + output_destination.clone(), + )) + .add_output(TxOutput::Transfer( + OutputValue::Coin(change), + Destination::AnyoneCanSpend, + )) + .build(); + let fill_tx_1_id = fill_tx_1.transaction().get_id(); + coins_outpoint = UtxoOutPoint::new(fill_tx_1_id.into(), 1); + + tf.make_block_builder() + .add_transaction(fill_tx_1) + .build_and_process(&mut rng) + .unwrap(); + + total_fill_amount = (total_fill_amount + fill_amount1).unwrap(); + total_filled_amount = (total_filled_amount + filled_amount1).unwrap(); + } + + // The destination in FillOrder is PublicKey(pk1), the input is signed by pk2. + // The actual output destination is output_destination. + { + let fill_amount2 = + Amount::from_atoms(rng.gen_range(1..initial_ask_amount.into_atoms() / 10)); + let filled_amount2 = calculate_fill_order(&tf, &order_id, fill_amount2, version); + let fill_order_input2 = make_fill_order_input( + version, + AccountNonce::new(1), + &order_id, + fill_amount2, + Destination::PublicKey(pk1.clone()), + ); + + let coins_left = tf.coin_amount_from_utxo(&coins_outpoint); + let change = (coins_left - fill_amount2).unwrap(); + let coins_utxo = tf.utxo(&coins_outpoint).take_output(); + let fill_tx_2 = Transaction::new( + 0, + vec![coins_outpoint.clone().into(), fill_order_input2], + vec![ + TxOutput::Transfer( + OutputValue::TokenV1(token_id, filled_amount2), + output_destination.clone(), + ), + TxOutput::Transfer(OutputValue::Coin(change), Destination::AnyoneCanSpend), + ], + ) + .unwrap(); + let fill_tx_2_id = fill_tx_2.get_id(); + let fill_input_sig = StandardInputSignature::produce_uniparty_signature_for_input( + &sk2, + Default::default(), + Destination::PublicKey(pk2), + &fill_tx_2, + &[Some(&coins_utxo), None], + 0, + &mut rng, + ) + .unwrap(); + let fill_tx_2 = SignedTransaction::new( + fill_tx_2, + vec![InputWitness::NoSignature(None), InputWitness::Standard(fill_input_sig)], + ) + .unwrap(); + + tf.make_block_builder() + .add_transaction(fill_tx_2) + .build_and_process(&mut rng) + .unwrap(); + + total_fill_amount = (total_fill_amount + fill_amount2).unwrap(); + total_filled_amount = (total_filled_amount + filled_amount2).unwrap(); + coins_outpoint = UtxoOutPoint::new(fill_tx_2_id.into(), 1); + } + + // The destination in FillOrder is PublicKey(pk1), the signature is bogus. + // The actual output destination is output_destination. + { + let fill_amount3 = + Amount::from_atoms(rng.gen_range(1..initial_ask_amount.into_atoms() / 10)); + let filled_amount3 = calculate_fill_order(&tf, &order_id, fill_amount3, version); + let fill_order_input3 = make_fill_order_input( + version, + AccountNonce::new(2), + &order_id, + fill_amount3, + Destination::PublicKey(pk1), + ); + + let coins_left = tf.coin_amount_from_utxo(&coins_outpoint); + let change = (coins_left - fill_amount3).unwrap(); + let fill_tx_3 = Transaction::new( + 0, + vec![coins_outpoint.into(), fill_order_input3], + vec![ + TxOutput::Transfer( + OutputValue::TokenV1(token_id, filled_amount3), + output_destination, + ), + TxOutput::Transfer(OutputValue::Coin(change), Destination::AnyoneCanSpend), + ], + ) + .unwrap(); + let fill_input_sig = StandardInputSignature::new( + SigHashType::all(), + gen_random_bytes(&mut rng, 100, 200), + ); + let fill_tx_3 = SignedTransaction::new( + fill_tx_3, + vec![InputWitness::NoSignature(None), InputWitness::Standard(fill_input_sig)], + ) + .unwrap(); + + tf.make_block_builder() + .add_transaction(fill_tx_3) + .build_and_process(&mut rng) + .unwrap(); + + total_fill_amount = (total_fill_amount + fill_amount3).unwrap(); + total_filled_amount = (total_filled_amount + filled_amount3).unwrap(); + } + + let expected_ask_balance = (initial_ask_amount - total_fill_amount).unwrap(); + let expected_give_balance = (initial_give_amount - total_filled_amount).unwrap(); + + assert_eq!( + tf.chainstate.get_order_data(&order_id).unwrap(), + Some(order_data.into()), + ); + assert_eq!( + tf.chainstate.get_order_ask_balance(&order_id).unwrap(), + Some(expected_ask_balance), + ); + assert_eq!( + tf.chainstate.get_order_give_balance(&order_id).unwrap(), + Some(expected_give_balance), + ); + }); +} + +fn make_fill_order_input( + orders_version: OrdersVersion, + nonce: AccountNonce, + order_id: &OrderId, + fill_amount: Amount, + destination: Destination, +) -> TxInput { + match orders_version { + OrdersVersion::V0 => TxInput::AccountCommand( + nonce, + AccountCommand::FillOrder(*order_id, fill_amount, destination), + ), + OrdersVersion::V1 => TxInput::OrderAccountCommand(OrderAccountCommand::FillOrder( + *order_id, + fill_amount, + destination, + )), + } +} diff --git a/common/src/chain/config/builder.rs b/common/src/chain/config/builder.rs index 45d206a340..7c76bfbed8 100644 --- a/common/src/chain/config/builder.rs +++ b/common/src/chain/config/builder.rs @@ -230,19 +230,7 @@ impl ChainType { ChainType::Regtest | ChainType::Signet => { let upgrades = vec![( BlockHeight::new(0), - ChainstateUpgrade::new( - TokenIssuanceVersion::V1, - RewardDistributionVersion::V1, - TokensFeeVersion::V1, - DataDepositFeeVersion::V1, - ChangeTokenMetadataUriActivated::Yes, - FrozenTokensValidationVersion::V1, - HtlcActivated::Yes, - OrdersActivated::Yes, - OrdersVersion::V1, - StakerDestinationUpdateForbidden::Yes, - TokenIdGenerationVersion::V1, - ), + default_regtest_chainstate_upgrade_at_genesis(), )]; NetUpgrades::initialize(upgrades).expect("net upgrades") } @@ -292,6 +280,22 @@ impl ChainType { } } +pub fn default_regtest_chainstate_upgrade_at_genesis() -> ChainstateUpgrade { + ChainstateUpgrade::new( + TokenIssuanceVersion::V1, + RewardDistributionVersion::V1, + TokensFeeVersion::V1, + DataDepositFeeVersion::V1, + ChangeTokenMetadataUriActivated::Yes, + FrozenTokensValidationVersion::V1, + HtlcActivated::Yes, + OrdersActivated::Yes, + OrdersVersion::V1, + StakerDestinationUpdateForbidden::Yes, + TokenIdGenerationVersion::V1, + ) +} + // Builder support types #[derive(Clone)] diff --git a/common/src/chain/config/regtest_options.rs b/common/src/chain/config/regtest_options.rs index f177c10e76..0a57d8f38d 100644 --- a/common/src/chain/config/regtest_options.rs +++ b/common/src/chain/config/regtest_options.rs @@ -20,11 +20,13 @@ use clap::Args; use crate::{ chain::{ config::{ + builder::default_regtest_chainstate_upgrade_at_genesis, regtest::{create_regtest_pos_genesis, create_regtest_pow_genesis}, Builder, ChainType, EmissionScheduleTabular, MagicBytes, }, pos::{DEFAULT_BLOCK_COUNT_TO_AVERAGE, DEFAULT_MATURITY_BLOCK_COUNT_V0}, - pos_initial_difficulty, ConsensusUpgrade, Destination, NetUpgrades, PoSChainConfig, + pos_initial_difficulty, ChainstateUpgradeBuilder, ChainstateUpgradesBuilder, + ConsensusUpgrade, Destination, NetUpgrades, OrdersVersion, PoSChainConfig, PoSConsensusVersion, }, primitives::{self, per_thousand::PerThousand, semver::SemVer, BlockHeight}, @@ -78,11 +80,11 @@ pub struct ChainConfigOptions { pub chain_initial_difficulty: Option, /// If set, the consensus type will be switched to PoS at the specified height. - #[clap(long)] + #[clap(long, conflicts_with_all(["chain_pos_netupgrades_v0_to_v1"]))] pub chain_pos_netupgrades: Option, /// PoS NetUpgrade override after Genesis with upgrade of consensus version from V0 to V1 - /// at specific height + /// at specific height. #[clap(long)] pub chain_pos_netupgrades_v0_to_v1: Option, @@ -93,6 +95,11 @@ pub struct ChainConfigOptions { /// PoS Genesis staking settings #[clap(long, default_value_t)] pub chain_genesis_staking_settings: GenesisStakingSettings, + + /// If set, chainstate will upgrade from orders v0 to v1 at the specified height + /// (if not specified, the latest orders version will be used from height 0). + #[clap(long)] + pub chain_chainstate_orders_v1_upgrade_height: Option, } pub fn regtest_chain_config_builder(options: &ChainConfigOptions) -> Result { @@ -111,6 +118,7 @@ pub fn regtest_chain_config_builder(options: &ChainConfigOptions) -> Result Result), @@ -191,14 +207,23 @@ impl AccountOutPoint { )] pub enum OrderAccountCommand { // Satisfy an order completely or partially. - // Second parameter is an amount provided to fill an order which corresponds to order's ask currency. + // The second parameter is the fill amount in the order's "ask" currency. + // The third parameter is an arbitrary destination, whose purpose is to make sure that all + // "FillOrder" inputs in a block are distinct, so that the same order can be filled in the same + // block multiple times (note: all transaction inputs in a block must be distinct, + // see CheckBlockTransactionsError::DuplicateInputInBlock). + // Also note that though a FillOrder input can technically have a signature, it is not checked. + // So it's better not to provide one, to reduce the transaction size and avoid needlessly exposing + // the corresponding public key. #[codec(index = 0)] FillOrder(OrderId, Amount, Destination), + // Freeze an order which effectively forbids any fill operations. // Frozen order can only be concluded. // Only the address specified as `conclude_key` can authorize this command. #[codec(index = 1)] FreezeOrder(OrderId), + // Close an order and withdraw all remaining funds from both give and ask balances. // Only the address specified as `conclude_key` can authorize this command. #[codec(index = 2)] diff --git a/common/src/chain/transaction/signature/inputsig/arbitrary_message/tests.rs b/common/src/chain/transaction/signature/inputsig/arbitrary_message/tests.rs index 78e8e5b8f7..67bce2ea13 100644 --- a/common/src/chain/transaction/signature/inputsig/arbitrary_message/tests.rs +++ b/common/src/chain/transaction/signature/inputsig/arbitrary_message/tests.rs @@ -170,7 +170,6 @@ fn sign_verify_unsupported_destination(#[case] seed: Seed) { sig_err, SignArbitraryMessageError::AttemptedToProduceSignatureForAnyoneCanSpend ); - // Verifying a random signature should also produce an "Unsupported" error. let ver_err = random_sig .verify_signature(&chain_config, &destination, &message_challenge) .unwrap_err(); diff --git a/mintscript/src/script/verify.rs b/mintscript/src/script/verify.rs index 0db71d128d..50e071b3a7 100644 --- a/mintscript/src/script/verify.rs +++ b/mintscript/src/script/verify.rs @@ -45,7 +45,7 @@ pub trait ScriptVisitor { /// Check timelock fn visit_timelock(&mut self, timelock: &OutputTimeLock) -> Result<(), Self::TimelockError>; - ///Check hashlock + /// Check hashlock fn visit_hashlock( &mut self, hash_challenge: &HashChallenge, diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index b0a51ab36b..f854efc4eb 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -166,7 +166,10 @@ class UnicodeOnWindowsError(ValueError): 'wallet_connect_to_rpc.py', 'wallet_multisig_address.py', 'wallet_watch_address.py', - 'wallet_orders.py', + 'wallet_orders_v0.py', + 'wallet_orders_v1.py', + 'wallet_order_double_fill_with_same_dest_v0.py', + 'wallet_order_double_fill_with_same_dest_v1.py', 'mempool_basic_reorg.py', 'mempool_eviction.py', 'mempool_ibd.py', diff --git a/test/functional/wallet_order_double_fill_with_same_dest_impl.py b/test/functional/wallet_order_double_fill_with_same_dest_impl.py new file mode 100644 index 0000000000..e618e75892 --- /dev/null +++ b/test/functional/wallet_order_double_fill_with_same_dest_impl.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +# Copyright (c) 2023 RBB S.r.l +# Copyright (c) 2017-2021 The Bitcoin Core developers +# opensource@mintlayer.org +# SPDX-License-Identifier: MIT +# Licensed under the MIT License; +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://github.com/mintlayer/mintlayer-core/blob/master/LICENSE +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" Try submitting two FillOrder transactions using the same "destination", without mining a block in between. + +Note: the exact result differs depending on the orders version; the purpose of the test is to +prove that nothing bad can happen as a result (e.g. in orders v1, where orders don't use nonces, using +the same destination and the same amount results in exactly the same FillOrder input being produced; +if both of the txs are included in the same block, the block will be invalid). +""" + +from test_framework.test_framework import BitcoinTestFramework +from test_framework.mintlayer import (make_tx, reward_input, ATOMS_PER_COIN) +from test_framework.util import assert_in, assert_equal, assert_not_in +from test_framework.mintlayer import block_input_data_obj +from test_framework.wallet_rpc_controller import WalletRpcController + +import asyncio +import sys +import random + +ATOMS_PER_TOKEN = 100 + +class WalletOrderDoubleFillWithSameDestImpl(BitcoinTestFramework): + def set_test_params(self, use_orders_v1): + self.use_orders_v1 = use_orders_v1 + self.setup_clean_chain = True + self.num_nodes = 1 + + extra_args = ["--blockprod-min-peers-to-produce-blocks=0"] + extra_args.extend(self.chain_config_args()) + + self.extra_args = [extra_args] + + def chain_config_args(self): + return [f"--chain-chainstate-orders-v1-upgrade-height={1 if self.use_orders_v1 else 999}"] + + def setup_network(self): + self.setup_nodes() + self.sync_all(self.nodes[0:1]) + + def generate_block(self): + node = self.nodes[0] + + block_input_data = { "PoW": { "reward_destination": "AnyoneCanSpend" } } + block_input_data = block_input_data_obj.encode(block_input_data).to_hex()[2:] + + # create a new block, taking transactions from mempool + block = node.blockprod_generate_block(block_input_data, [], [], "FillSpaceFromMempool") + node.chainstate_submit_block(block) + block_id = node.chainstate_best_block_id() + + # Wait for mempool to sync + self.wait_until(lambda: node.mempool_local_best_block_id() == block_id, timeout = 5) + + return block_id + + async def switch_to_wallet(self, wallet, wallet_name): + await wallet.close_wallet() + await wallet.open_wallet(wallet_name) + await wallet.sync() + + def run_test(self): + if 'win32' in sys.platform: + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + asyncio.run(self.async_test()) + + async def async_test(self): + node = self.nodes[0] + + # new wallet + async with WalletRpcController(node, self.config, self.log, [], self.chain_config_args()) as wallet: + await wallet.create_wallet('alice_wallet') + + # check it is on genesis + assert_equal('0', await wallet.get_best_block_height()) + + # new addresses for both accounts to have some coins + alice_address = await wallet.new_address() + alice_pub_key_bytes = await wallet.new_public_key(alice_address) + assert_equal(len(alice_pub_key_bytes), 33) + + await wallet.close_wallet() + await wallet.create_wallet('bob_wallet') + + bob_address = await wallet.new_address() + bob_pub_key_bytes = await wallet.new_public_key(bob_address) + assert_equal(len(bob_pub_key_bytes), 33) + + await self.switch_to_wallet(wallet, 'alice_wallet') + + # Get chain tip + tip_id = node.chainstate_best_block_id() + self.log.debug(f'Tip: {tip_id}') + + # Submit a valid transaction + outputs = [{ + 'Transfer': [ { 'Coin': 151 * ATOMS_PER_COIN }, { 'PublicKey': {'key': {'Secp256k1Schnorr' : {'pubkey_data': alice_pub_key_bytes}}} } ], + }, { + 'Transfer': [ { 'Coin': 151 * ATOMS_PER_COIN }, { 'PublicKey': {'key': {'Secp256k1Schnorr' : {'pubkey_data': bob_pub_key_bytes}}} } ], + }] + encoded_tx, tx_id = make_tx([reward_input(tip_id)], outputs, 0) + + node.mempool_submit_transaction(encoded_tx, {}) + assert node.mempool_contains_tx(tx_id) + + block_id = self.generate_block() # Block 1 + assert not node.mempool_contains_tx(tx_id) + + # sync the wallet + assert_in("Success", await wallet.sync()) + + # check wallet best block if it is synced + assert_equal(await wallet.get_best_block_height(), '1') + assert_equal(await wallet.get_best_block(), block_id) + + balance = await wallet.get_balance() + assert_in(f"Coins amount: 151", balance) + assert_not_in("Tokens", balance) + + # issue a valid token + token_id, _, _ = (await wallet.issue_new_token("XXXX", 2, "http://uri", alice_address)) + assert token_id is not None + self.log.info(f"new token id: {token_id}") + + self.generate_block() + assert_in("Success", await wallet.sync()) + balance = await wallet.get_balance() + assert_in(f"Coins amount: 50", balance) + assert_not_in("Tokens", balance) + + amount_to_mint = random.randint(100, 10000) + mint_result = await wallet.mint_tokens(token_id, alice_address, amount_to_mint) + assert mint_result['tx_id'] is not None + + self.generate_block() + assert_in("Success", await wallet.sync()) + balance = await wallet.get_balance() + assert_in(f"Coins amount: 0", balance) + assert_in(f"Token: {token_id} amount: {amount_to_mint}", balance) + + ######################################################################################## + # Alice creates an order selling tokens for coins + create_order_result = await wallet.create_order(None, amount_to_mint * 2, token_id, amount_to_mint, alice_address) + assert create_order_result['result']['tx_id'] is not None + order_id = create_order_result['result']['order_id'] + + self.generate_block() + assert_in("Success", await wallet.sync()) + balance = await wallet.get_balance() + assert_in(f"Coins amount: 0", balance) + assert_not_in("Tokens", balance) + + ######################################################################################## + # Bob fills the order + await self.switch_to_wallet(wallet, 'bob_wallet') + balance = await wallet.get_balance() + assert_in(f"Coins amount: 151", balance) + assert_not_in("Tokens", balance) + + fill_dest_address = await wallet.new_address() + + # Buy 1 token + result = await wallet.fill_order(order_id, 2, fill_dest_address) + fill_tx1_id = result['result']['tx_id'] + assert fill_tx1_id is not None + + if self.use_orders_v1: + # Immediately buy 1 more token using the same destination address. Since the wallet also uses + # the passed destination as the destination in the FillOrder input, mempool will think that the + # second transaction conflicts with the first one. + result = await wallet.fill_order(order_id, 2, fill_dest_address) + assert_in("Mempool error: Transaction conflicts with another, irreplaceable transaction", result['error']['message']) + + # We are able to successfully generate a block. + self.generate_block() + assert_in("Success", await wallet.sync()) + + # Try creating the transaction again, in a new block. Now it should succeed. + result = await wallet.fill_order(order_id, 2, fill_dest_address) + fill_tx2_id = result['result']['tx_id'] + assert fill_tx2_id is not None + else: + # In orders v0 the destination shouldn't be a problem due to nonces. + # However, at this moment the wallet gets the nonce from the chainstate only, + # so creating another "fill" tx when the previos one hasn't been mined yet + # will use the same nonce. + result = await wallet.fill_order(order_id, 2, fill_dest_address) + assert_in("Mempool error: Nonce is not incremental", result['error']['message']) + + self.generate_block() + assert_in("Success", await wallet.sync()) + + # After the first tx has been mined, a new one will be created with the correct nonce. + result = await wallet.fill_order(order_id, 2, fill_dest_address) + fill_tx2_id = result['result']['tx_id'] + assert fill_tx2_id is not None + + self.generate_block() + assert_in("Success", await wallet.sync()) + + balance = await wallet.get_balance() + assert_in(f"Coins amount: 146.99", balance) + assert_in(f"Token: {token_id} amount: 2", balance) diff --git a/test/functional/wallet_order_double_fill_with_same_dest_v0.py b/test/functional/wallet_order_double_fill_with_same_dest_v0.py new file mode 100644 index 0000000000..5555d1d440 --- /dev/null +++ b/test/functional/wallet_order_double_fill_with_same_dest_v0.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# Copyright (c) 2023 RBB S.r.l +# Copyright (c) 2017-2021 The Bitcoin Core developers +# opensource@mintlayer.org +# SPDX-License-Identifier: MIT +# Licensed under the MIT License; +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://github.com/mintlayer/mintlayer-core/blob/master/LICENSE +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from wallet_order_double_fill_with_same_dest_impl import WalletOrderDoubleFillWithSameDestImpl + + +class WalletOrderDoubleFillWithSameDestV0(WalletOrderDoubleFillWithSameDestImpl): + def set_test_params(self): + super().set_test_params(False) + + +if __name__ == '__main__': + WalletOrderDoubleFillWithSameDestV0().main() diff --git a/test/functional/wallet_order_double_fill_with_same_dest_v1.py b/test/functional/wallet_order_double_fill_with_same_dest_v1.py new file mode 100644 index 0000000000..47e126277d --- /dev/null +++ b/test/functional/wallet_order_double_fill_with_same_dest_v1.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# Copyright (c) 2023 RBB S.r.l +# Copyright (c) 2017-2021 The Bitcoin Core developers +# opensource@mintlayer.org +# SPDX-License-Identifier: MIT +# Licensed under the MIT License; +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://github.com/mintlayer/mintlayer-core/blob/master/LICENSE +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from wallet_order_double_fill_with_same_dest_impl import WalletOrderDoubleFillWithSameDestImpl + + +class WalletOrderDoubleFillWithSameDestV1(WalletOrderDoubleFillWithSameDestImpl): + def set_test_params(self): + super().set_test_params(True) + + +if __name__ == '__main__': + WalletOrderDoubleFillWithSameDestV1().main() diff --git a/test/functional/wallet_orders.py b/test/functional/wallet_orders_impl.py similarity index 83% rename from test/functional/wallet_orders.py rename to test/functional/wallet_orders_impl.py index 142b355e62..88257712fc 100644 --- a/test/functional/wallet_orders.py +++ b/test/functional/wallet_orders_impl.py @@ -38,14 +38,19 @@ ATOMS_PER_TOKEN = 100 -class WalletOrders(BitcoinTestFramework): - - def set_test_params(self): +class WalletOrdersImpl(BitcoinTestFramework): + def set_test_params(self, use_orders_v1): + self.use_orders_v1 = use_orders_v1 self.setup_clean_chain = True self.num_nodes = 1 - self.extra_args = [[ - "--blockprod-min-peers-to-produce-blocks=0", - ]] + + extra_args = ["--blockprod-min-peers-to-produce-blocks=0"] + extra_args.extend(self.chain_config_args()) + + self.extra_args = [extra_args] + + def chain_config_args(self): + return [f"--chain-chainstate-orders-v1-upgrade-height={1 if self.use_orders_v1 else 999}"] def setup_network(self): self.setup_nodes() @@ -81,7 +86,7 @@ async def async_test(self): node = self.nodes[0] # new wallet - async with WalletRpcController(node, self.config, self.log) as wallet: + async with WalletRpcController(node, self.config, self.log, [], self.chain_config_args()) as wallet: await wallet.create_wallet('alice_wallet') # check it is on genesis @@ -114,13 +119,12 @@ async def async_test(self): # Submit a valid transaction outputs = [{ - 'Transfer': [ { 'Coin': 151 * ATOMS_PER_COIN }, { 'PublicKey': {'key': {'Secp256k1Schnorr' : {'pubkey_data': alice_pub_key_bytes}}} } ], + 'Transfer': [ { 'Coin': 151 * ATOMS_PER_COIN }, { 'PublicKey': {'key': {'Secp256k1Schnorr' : {'pubkey_data': alice_pub_key_bytes}}} } ], }, { - 'Transfer': [ { 'Coin': 151 * ATOMS_PER_COIN }, { 'PublicKey': {'key': {'Secp256k1Schnorr' : {'pubkey_data': bob_pub_key_bytes}}} } ], + 'Transfer': [ { 'Coin': 151 * ATOMS_PER_COIN }, { 'PublicKey': {'key': {'Secp256k1Schnorr' : {'pubkey_data': bob_pub_key_bytes}}} } ], }, { - 'Transfer': [ { 'Coin': 151 * ATOMS_PER_COIN }, { 'PublicKey': {'key': {'Secp256k1Schnorr' : {'pubkey_data': carol_pub_key_bytes}}} } ], - } - ] + 'Transfer': [ { 'Coin': 151 * ATOMS_PER_COIN }, { 'PublicKey': {'key': {'Secp256k1Schnorr' : {'pubkey_data': carol_pub_key_bytes}}} } ], + }] encoded_tx, tx_id = make_tx([reward_input(tip_id)], outputs, 0) node.mempool_submit_transaction(encoded_tx, {}) @@ -217,21 +221,22 @@ async def async_test(self): conclude_order_result = await wallet.conclude_order(order_id) assert_in("Failed to convert partially signed tx to signed", conclude_order_result['error']['message']) - ######################################################################################## - # Alice freezes the order - await self.switch_to_wallet(wallet, 'alice_wallet') - assert_in("Success", await wallet.sync()) + if self.use_orders_v1: + ######################################################################################## + # Alice freezes the order + await self.switch_to_wallet(wallet, 'alice_wallet') + assert_in("Success", await wallet.sync()) - freeze_order_result = await wallet.freeze_order(order_id) - assert freeze_order_result['result']['tx_id'] is not None - self.generate_block() - assert_in("Success", await wallet.sync()) + freeze_order_result = await wallet.freeze_order(order_id) + assert freeze_order_result['result']['tx_id'] is not None + self.generate_block() + assert_in("Success", await wallet.sync()) - ######################################################################################## - # Carol tries filling again - await self.switch_to_wallet(wallet, 'carol_wallet') - fill_order_result = await wallet.fill_order(order_id, 1) - assert_in("Attempt to fill frozen order", fill_order_result['error']['message']) + ######################################################################################## + # Carol tries filling again + await self.switch_to_wallet(wallet, 'carol_wallet') + fill_order_result = await wallet.fill_order(order_id, 1) + assert_in("Attempt to fill frozen order", fill_order_result['error']['message']) ######################################################################################## # Alice concludes the order @@ -251,7 +256,3 @@ async def async_test(self): await self.switch_to_wallet(wallet, 'carol_wallet') fill_order_result = await wallet.fill_order(order_id, 1) assert_in("Unknown order", fill_order_result['error']['message']) - - -if __name__ == '__main__': - WalletOrders().main() diff --git a/test/functional/wallet_orders_v0.py b/test/functional/wallet_orders_v0.py new file mode 100644 index 0000000000..60b74f6daa --- /dev/null +++ b/test/functional/wallet_orders_v0.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# Copyright (c) 2023 RBB S.r.l +# Copyright (c) 2017-2021 The Bitcoin Core developers +# opensource@mintlayer.org +# SPDX-License-Identifier: MIT +# Licensed under the MIT License; +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://github.com/mintlayer/mintlayer-core/blob/master/LICENSE +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from wallet_orders_impl import WalletOrdersImpl + + +class WalletOrdersV0(WalletOrdersImpl): + def set_test_params(self): + super().set_test_params(False) + + +if __name__ == '__main__': + WalletOrdersV0().main() diff --git a/test/functional/wallet_orders_v1.py b/test/functional/wallet_orders_v1.py new file mode 100644 index 0000000000..757491e2dc --- /dev/null +++ b/test/functional/wallet_orders_v1.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# Copyright (c) 2023 RBB S.r.l +# Copyright (c) 2017-2021 The Bitcoin Core developers +# opensource@mintlayer.org +# SPDX-License-Identifier: MIT +# Licensed under the MIT License; +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://github.com/mintlayer/mintlayer-core/blob/master/LICENSE +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from wallet_orders_impl import WalletOrdersImpl + + +class WalletOrdersV1(WalletOrdersImpl): + def set_test_params(self): + super().set_test_params(True) + + +if __name__ == '__main__': + WalletOrdersV1().main() diff --git a/wallet/wallet-controller/src/synced_controller.rs b/wallet/wallet-controller/src/synced_controller.rs index c8ff5a088f..e5e20171ea 100644 --- a/wallet/wallet-controller/src/synced_controller.rs +++ b/wallet/wallet-controller/src/synced_controller.rs @@ -1330,15 +1330,20 @@ where &mut self, tx: SignedTransaction, ) -> Result> { - self.wallet - .add_account_unconfirmed_tx(self.account_index, &tx, self.wallet_events) - .map_err(ControllerError::WalletError)?; - + // Submit the tx first and only add it to the wallet after the submission has succeeded. + // Note: if the call order is reversed and the mempool rejects the tx for some reason, we'll + // end up having a bogus unconfirmed tx in the wallet; after that, any new tx created by + // the wallet has a chance to be an orphan if it happens to consume some outputs of the + // bogus tx. self.rpc_client .submit_transaction(tx.clone(), Default::default()) .await .map_err(ControllerError::NodeCallError)?; + self.wallet + .add_account_unconfirmed_tx(self.account_index, &tx, self.wallet_events) + .map_err(ControllerError::WalletError)?; + Ok(tx) } diff --git a/wallet/wallet-rpc-lib/tests/utils.rs b/wallet/wallet-rpc-lib/tests/utils.rs index d631f9c852..d87b670239 100644 --- a/wallet/wallet-rpc-lib/tests/utils.rs +++ b/wallet/wallet-rpc-lib/tests/utils.rs @@ -106,6 +106,7 @@ impl TestFramework { chain_max_future_block_time_offset: None, chain_max_block_size_with_standard_txs: None, chain_max_block_size_with_smart_contracts: None, + chain_chainstate_orders_v1_upgrade_height: None, }; // Start the wallet service diff --git a/wallet/wallet-test-node/src/lib.rs b/wallet/wallet-test-node/src/lib.rs index 04a1fd96b1..8dc3e02af4 100644 --- a/wallet/wallet-test-node/src/lib.rs +++ b/wallet/wallet-test-node/src/lib.rs @@ -131,6 +131,7 @@ pub fn default_chain_config_options() -> ChainConfigOptions { chain_pos_netupgrades_v0_to_v1: None, chain_genesis_block_timestamp: None, chain_genesis_staking_settings: GenesisStakingSettings::default(), + chain_chainstate_orders_v1_upgrade_height: None, } } diff --git a/wasm-wrappers/WASM-API.md b/wasm-wrappers/WASM-API.md index e144ddf779..daab9d7dc8 100644 --- a/wasm-wrappers/WASM-API.md +++ b/wasm-wrappers/WASM-API.md @@ -288,11 +288,24 @@ Given a token_id, new metadata uri and nonce return an encoded change token meta ### Function: `encode_input_for_fill_order` -Given an amount to fill an order (which is described in terms of ask currency) and a destination -for result outputs create an input that fills the order. - -Note: the nonce is only needed before the orders V1 fork activation. After the fork the nonce is -ignored and any value can be passed for the parameter. +Given an order id and an amount in the order's ask currency, create an input that fills the order. + +Note: +1) The nonce is only needed before the orders V1 fork activation. After the fork the nonce is + ignored and any value can be passed for the parameter. +2) Regarding the destination parameter: + a) It can be arbitrary, i.e. it doesn't have to be the same as the destination used + in the output that will transfer away the result. + b) Though a FillOrder input is technically allowed to have a signature, it is not enforced. + I.e. not only you don't have to sign it with the private key corresponding to this + destination, you may just provide an empty signature (use `encode_witness_no_signature` + for the input instead of `encode_witness`). + c) The reasons for having a destination in FillOrder inputs are historical, however it does + serve a purpose in orders V1. This is because the current consensus rules require all + transaction inputs in a block to be distinct. And since orders V1 don't use nonces, + re-using the same destination in the inputs of multiple order-filling transactions + for the same order may result in the later transactions being rejected, if they are + broadcast to mempool at the same time. ### Function: `encode_input_for_freeze_order` diff --git a/wasm-wrappers/src/encode_input.rs b/wasm-wrappers/src/encode_input.rs index 6985b5b862..4089cd83e9 100644 --- a/wasm-wrappers/src/encode_input.rs +++ b/wasm-wrappers/src/encode_input.rs @@ -181,11 +181,24 @@ pub fn encode_input_for_change_token_metadata_uri( Ok(input.encode()) } -/// Given an amount to fill an order (which is described in terms of ask currency) and a destination -/// for result outputs create an input that fills the order. +/// Given an order id and an amount in the order's ask currency, create an input that fills the order. /// -/// Note: the nonce is only needed before the orders V1 fork activation. After the fork the nonce is -/// ignored and any value can be passed for the parameter. +/// Note: +/// 1) The nonce is only needed before the orders V1 fork activation. After the fork the nonce is +/// ignored and any value can be passed for the parameter. +/// 2) Regarding the destination parameter: +/// a) It can be arbitrary, i.e. it doesn't have to be the same as the destination used +/// in the output that will transfer away the result. +/// b) Though a FillOrder input is technically allowed to have a signature, it is not enforced. +/// I.e. not only you don't have to sign it with the private key corresponding to this +/// destination, you may just provide an empty signature (use `encode_witness_no_signature` +/// for the input instead of `encode_witness`). +/// c) The reasons for having a destination in FillOrder inputs are historical, however it does +/// serve a purpose in orders V1. This is because the current consensus rules require all +/// transaction inputs in a block to be distinct. And since orders V1 don't use nonces, +/// re-using the same destination in the inputs of multiple order-filling transactions +/// for the same order may result in the later transactions being rejected, if they are +/// broadcast to mempool at the same time. #[wasm_bindgen] pub fn encode_input_for_fill_order( order_id: &str, diff --git a/wasm-wrappers/wasm-doc-gen/src/main.rs b/wasm-wrappers/wasm-doc-gen/src/main.rs index 0fc07f0ad0..a3c0281c42 100644 --- a/wasm-wrappers/wasm-doc-gen/src/main.rs +++ b/wasm-wrappers/wasm-doc-gen/src/main.rs @@ -185,9 +185,40 @@ fn pull_docs_from_attribute(attrs: &[syn::Attribute]) -> String { syn::Lit::Str(s) => Some(s), _ => None, }) - .map(|s| s.value().trim().to_string()) + .map(|s| s.value()) .collect::>(); + // Remove the common number of spaces from each line, but keep the rest to preserve indentation. + // If a line consists only of whitespaces, it is emptied. + let docs = { + let mut docs = docs; + let mut min_leading_spaces = usize::MAX; + + for doc in &mut docs { + if let Some(non_ws_idx) = doc + .chars() + .enumerate() + .find_map(|(idx, ch)| (!ch.is_whitespace()).then_some(idx)) + { + min_leading_spaces = std::cmp::min(min_leading_spaces, non_ws_idx); + } else { + doc.replace_range(.., ""); + } + } + + if min_leading_spaces != 0 { + for doc in &mut docs { + let bytes_to_skip = doc + .chars() + .take(min_leading_spaces) + .fold(0, |bytes_to_skip, ch| bytes_to_skip + ch.len_utf8()); + doc.replace_range(0..bytes_to_skip, ""); + } + } + + docs + }; + docs.join("\n") }