// Copyright (c) 2025-2026, The Monero Project // // All rights reserved. // // Redistribution and use in source and binary forms, with or without modification, are // permitted provided that the following conditions are met: // // 1. Redistributions of source code must retain the above copyright notice, this list of // conditions and the following disclaimer. // // 2. Redistributions in binary form must reproduce the above copyright notice, this list // of conditions and the following disclaimer in the documentation and/or other // materials provided with the distribution. // // 3. Neither the name of the copyright holder nor the names of its contributors may be // used to endorse or promote products derived from this software without specific // prior written permission. // // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY // EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF // MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL // THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, // PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS // INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, // STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF // THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. #include "gtest/gtest.h" #include "carrot_core/config.h" #include "carrot_core/exceptions.h" #include "carrot_impl/format_utils.h" #include "carrot_impl/subaddress_map_legacy.h" #include "carrot_mock_helpers.h" #include "cryptonote_core/blockchain.h" #include "cryptonote_core/tx_verification_utils.h" #include "fake_pruned_blockchain.h" #include "fcmp_pp/prove.h" #include "ringct/rctOps.h" #include "ringct/rctSigs.h" #include "tx_construction_helpers.h" #include "wallet/tx_builder.h" #undef MONERO_DEFAULT_LOG_CATEGORY #define MONERO_DEFAULT_LOG_CATEGORY "unit_tests.wallet_scanning" //---------------------------------------------------------------------------------------------------------------------- //---------------------------------------------------------------------------------------------------------------------- static tools::wallet2::transfer_details gen_transfer_details() { cryptonote::transaction carrot_tx{}; carrot_tx.version = 2; carrot_tx.vin.push_back(cryptonote::txin_to_key{.k_image = carrot::mock::gen_key_image()}); carrot_tx.vout.push_back(cryptonote::tx_out{.target = cryptonote::txout_to_carrot_v1{ .key = carrot::mock::gen_public_key()}}); cryptonote::add_tx_pub_key_to_extra(carrot_tx.extra, {}); return tools::wallet2::transfer_details{ .m_block_height = crypto::rand_idx(CRYPTONOTE_MAX_BLOCK_NUMBER), .m_tx = carrot_tx, .m_txid = crypto::rand(), .m_internal_output_index = crypto::rand_idx(carrot_tx.vout.size()), .m_global_output_index = crypto::rand_idx(CRYPTONOTE_MAX_BLOCK_NUMBER * 1000ull), .m_spent = false, .m_frozen = false, .m_spent_height = 0, .m_key_image = crypto::key_image{rct::rct2pk(rct::pkGen())}, .m_mask = rct::skGen(), .m_amount = crypto::rand_range(COIN, 2 * COIN), // [1, 2] XMR i.e. [1e12, 2e12] pXMR .m_rct = true, .m_key_image_known = true, .m_key_image_request = false, .m_pk_index = 1, .m_subaddr_index = {}, .m_key_image_partial = false, .m_multisig_k = {}, .m_multisig_info = {}, .m_uses = {}, }; } //---------------------------------------------------------------------------------------------------------------------- //---------------------------------------------------------------------------------------------------------------------- TEST(wallet_tx_builder, make_carrot_transaction_proposals_wallet2_transfer_1) { cryptonote::account_base alice; alice.generate(); cryptonote::account_base bob; bob.generate(); const tools::wallet2::transfer_container transfers{ gen_transfer_details()}; const rct::xmr_amount out_amount = rct::randXmrAmount(transfers.front().amount() / 2); const std::vector dsts{ cryptonote::tx_destination_entry(out_amount, bob.get_keys().m_account_address, false) }; const uint64_t top_block_index = std::max(transfers.front().m_block_height, transfers.back().m_block_height) + CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE; const std::vector tx_proposals = tools::wallet::make_carrot_transaction_proposals_wallet2_transfer( transfers, carrot::subaddress_map_legacy{{{alice.get_keys().m_account_address.m_spend_public_key, {}}}}, dsts, /*fee_per_weight=*/1, /*extra=*/{}, /*subaddr_account=*/0, /*subaddr_indices=*/{}, /*ignore_above=*/MONEY_SUPPLY, /*ignore_below=*/0, /*max_n_inputs=*/0, {}, top_block_index); ASSERT_EQ(1, tx_proposals.size()); const carrot::CarrotTransactionProposalV1 tx_proposal = tx_proposals.at(0); // Assert basic length facts about tx proposal ASSERT_EQ(1, tx_proposal.input_proposals.size()); // we always try 2 when available EXPECT_EQ(transfers.front().get_public_key(), onetime_address_ref(tx_proposal.input_proposals.at(0))); ASSERT_EQ(1, tx_proposal.normal_payment_proposals.size()); ASSERT_EQ(1, tx_proposal.selfsend_payment_proposals.size()); EXPECT_EQ(0, tx_proposal.extra.size()); // Assert amounts EXPECT_EQ(out_amount, tx_proposal.normal_payment_proposals.front().amount); EXPECT_EQ(out_amount + tx_proposal.selfsend_payment_proposals.front().amount + tx_proposal.fee, transfers.front().amount()); } //---------------------------------------------------------------------------------------------------------------------- TEST(wallet_tx_builder, make_carrot_transaction_proposals_wallet2_transfer_2) { carrot::mock::mock_carrot_and_legacy_keys alice; alice.generate(); carrot::mock::mock_carrot_and_legacy_keys bob; bob.generate(); static constexpr uint32_t spending_subaddr_account = 2; static_assert(spending_subaddr_account); tools::wallet2::transfer_container transfers; std::uint64_t top_block_index = 0; std::unordered_map allowed_transfers; for (size_t i = 0; i < FCMP_PLUS_PLUS_MAX_INPUTS + 2; ++i) { tools::wallet2::transfer_details &td = transfers.emplace_back(); td = gen_transfer_details(); td.m_subaddr_index.major = (i % 2 == 0) ? spending_subaddr_account : (spending_subaddr_account - 1); td.m_subaddr_index.minor = crypto::rand_range(0, carrot::mock::MAX_SUBADDRESS_MINOR_INDEX); top_block_index = std::max(top_block_index, td.m_block_height); if (td.m_subaddr_index.major == spending_subaddr_account) allowed_transfers.emplace(td.get_public_key(), i); } top_block_index += CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE; const rct::xmr_amount out_amount = COIN * 3 / 4; const std::vector dsts{ carrot::mock::convert_destination_v1(bob.cryptonote_address(), out_amount) }; const std::vector tx_proposals = tools::wallet::make_carrot_transaction_proposals_wallet2_transfer( transfers, alice.subaddress_map, dsts, /*fee_per_weight=*/1, /*extra=*/{}, /*subaddr_account=*/spending_subaddr_account, /*subaddr_indices=*/{}, /*ignore_above=*/MONEY_SUPPLY, /*ignore_below=*/0, /*max_n_inputs=*/0, {}, top_block_index); ASSERT_EQ(1, tx_proposals.size()); const carrot::CarrotTransactionProposalV1 &tx_proposal = tx_proposals.at(0); // Assert basic length facts about tx proposal ASSERT_LE(tx_proposal.input_proposals.size(), 2); ASSERT_EQ(1, tx_proposal.normal_payment_proposals.size()); ASSERT_EQ(1, tx_proposal.selfsend_payment_proposals.size()); EXPECT_EQ(0, tx_proposal.extra.size()); const carrot::CarrotPaymentProposalV1 &normal_payment_proposal = tx_proposal.normal_payment_proposals.at(0); const carrot::CarrotPaymentProposalVerifiableSelfSendV1 &selfsend_payment_proposal = tx_proposal.selfsend_payment_proposals.at(0); // Assert that selected transfers have spending_subaddr_account subaddr major index boost::multiprecision::uint128_t in_sum = 0; for (std::size_t in_idx = 0; in_idx < tx_proposal.input_proposals.size(); ++in_idx) { const crypto::public_key &ota = onetime_address_ref(tx_proposal.input_proposals.at(in_idx)); ASSERT_EQ(1, allowed_transfers.count(ota)); const tools::wallet2::transfer_details &td = transfers.at(allowed_transfers.at(ota)); ASSERT_EQ(spending_subaddr_account, td.m_subaddr_index.major); in_sum += td.amount(); } // Assert balanced amounts boost::multiprecision::uint128_t out_sum = tx_proposal.fee; out_sum += normal_payment_proposal.amount; out_sum += selfsend_payment_proposal.amount; ASSERT_EQ(in_sum, out_sum); // Assert pubkeys/subaddr indices/amounts of payment proposals EXPECT_EQ(carrot::mock::convert_normal_payment_proposal_v1(dsts.at(0), normal_payment_proposal.randomness), normal_payment_proposal); EXPECT_NE(normal_payment_proposal.randomness, carrot::janus_anchor_t{}); EXPECT_EQ(spending_subaddr_account, selfsend_payment_proposal.subaddr_index.index.major); EXPECT_EQ(0, selfsend_payment_proposal.subaddr_index.index.minor); EXPECT_EQ(alice.subaddress({{spending_subaddr_account, 0}, carrot::AddressDeriveType::Carrot}).address_spend_pubkey, selfsend_payment_proposal.destination_address_spend_pubkey); EXPECT_EQ(carrot::CarrotEnoteType::CHANGE, selfsend_payment_proposal.enote_type); EXPECT_FALSE(selfsend_payment_proposal.internal_message); EXPECT_FALSE(selfsend_payment_proposal.enote_ephemeral_privkey); } //---------------------------------------------------------------------------------------------------------------------- TEST(wallet_tx_builder, make_carrot_transaction_proposals_wallet2_transfer_3) { // 1 input candidate, fee subtractable cryptonote::account_base alice; alice.generate(); cryptonote::account_base bob; bob.generate(); const tools::wallet2::transfer_container transfers{ gen_transfer_details()}; const rct::xmr_amount out_amount = rct::randXmrAmount(transfers.front().amount() / 2); const std::vector dsts{ cryptonote::tx_destination_entry(out_amount, bob.get_keys().m_account_address, false) }; const uint64_t top_block_index = std::max(transfers.front().m_block_height, transfers.back().m_block_height) + CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE; const std::vector tx_proposals = tools::wallet::make_carrot_transaction_proposals_wallet2_transfer( transfers, carrot::subaddress_map_legacy{{{alice.get_keys().m_account_address.m_spend_public_key, {}}}}, dsts, /*fee_per_weight=*/1, /*extra=*/{}, /*subaddr_account=*/0, /*subaddr_indices=*/{}, /*ignore_above=*/MONEY_SUPPLY, /*ignore_below=*/0, /*max_n_inputs=*/0, /*subtract_fee_from_outputs=*/{0}, top_block_index); ASSERT_EQ(1, tx_proposals.size()); const carrot::CarrotTransactionProposalV1 tx_proposal = tx_proposals.at(0); // Assert basic length facts about tx proposal ASSERT_EQ(1, tx_proposal.input_proposals.size()); // we always try 2 when available EXPECT_EQ(transfers.front().get_public_key(), onetime_address_ref(tx_proposal.input_proposals.at(0))); ASSERT_EQ(1, tx_proposal.normal_payment_proposals.size()); ASSERT_EQ(1, tx_proposal.selfsend_payment_proposals.size()); EXPECT_EQ(0, tx_proposal.extra.size()); // Assert amounts EXPECT_EQ(out_amount, tx_proposal.normal_payment_proposals.front().amount + tx_proposal.fee); EXPECT_EQ(out_amount + tx_proposal.selfsend_payment_proposals.front().amount, transfers.front().amount()); } //---------------------------------------------------------------------------------------------------------------------- TEST(wallet_tx_builder, make_carrot_transaction_proposals_wallet2_transfer_4) { // 1 input candidate, fee subtractable, but not possible if it wasn't fee subtractable cryptonote::account_base alice; alice.generate(); cryptonote::account_base bob; bob.generate(); const tools::wallet2::transfer_container transfers{gen_transfer_details()}; // Set output amount as generated input amount - fee * x, where x sampled in [0, 1) // This makes a non-fee-subtractable tx impossible, b/c the required money is the generated input amount + fee const rct::xmr_amount fee_per_weight = mock::fake_fee_per_weight; const rct::xmr_amount expected_fee = carrot::get_fee_by_input_count(2, 0, fee_per_weight).at(1); EXPECT_GT(expected_fee, 1); const rct::xmr_amount out_amount = transfers.at(0).amount() - rct::randXmrAmount(expected_fee); const std::vector dsts{ cryptonote::tx_destination_entry(out_amount, bob.get_keys().m_account_address, false) }; const uint64_t top_block_index = std::max(transfers.front().m_block_height, transfers.back().m_block_height) + CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE; const std::vector tx_proposals = tools::wallet::make_carrot_transaction_proposals_wallet2_transfer( transfers, carrot::subaddress_map_legacy{{{alice.get_keys().m_account_address.m_spend_public_key, {}}}}, dsts, fee_per_weight, /*extra=*/{}, /*subaddr_account=*/0, /*subaddr_indices=*/{}, /*ignore_above=*/MONEY_SUPPLY, /*ignore_below=*/0, /*max_n_inputs=*/0, /*subtract_fee_from_outputs=*/{0}, top_block_index); ASSERT_EQ(1, tx_proposals.size()); const carrot::CarrotTransactionProposalV1 &tx_proposal = tx_proposals.at(0); // Assert basic length facts about tx proposal ASSERT_EQ(1, tx_proposal.input_proposals.size()); // we always try 2 when available EXPECT_EQ(transfers.front().get_public_key(), onetime_address_ref(tx_proposal.input_proposals.at(0))); ASSERT_EQ(1, tx_proposal.normal_payment_proposals.size()); ASSERT_EQ(1, tx_proposal.selfsend_payment_proposals.size()); EXPECT_EQ(0, tx_proposal.extra.size()); EXPECT_EQ(expected_fee, tx_proposal.fee); // Assert amounts EXPECT_EQ(out_amount, tx_proposal.normal_payment_proposals.front().amount + tx_proposal.fee); EXPECT_EQ(out_amount + tx_proposal.selfsend_payment_proposals.front().amount, transfers.front().amount()); // Check that non-fee-subtractable fails EXPECT_THROW(tools::wallet::make_carrot_transaction_proposals_wallet2_transfer( transfers, carrot::subaddress_map_legacy{{{alice.get_keys().m_account_address.m_spend_public_key, {}}}}, dsts, fee_per_weight, /*extra=*/{}, /*subaddr_account=*/0, /*subaddr_indices=*/{}, /*ignore_above=*/MONEY_SUPPLY, /*ignore_below=*/0, /*max_n_inputs=*/0, /*subtract_fee_from_outputs=*/{}, top_block_index), carrot::not_enough_money); } //---------------------------------------------------------------------------------------------------------------------- TEST(wallet_tx_builder, make_carrot_transaction_proposals_wallet2_sweep_1) { cryptonote::account_base alice; alice.generate(); cryptonote::account_base bob; bob.generate(); const tools::wallet2::transfer_container transfers{gen_transfer_details()}; const std::vector tx_proposals = tools::wallet::make_carrot_transaction_proposals_wallet2_sweep( transfers, carrot::subaddress_map_legacy{{{alice.get_keys().m_account_address.m_spend_public_key, {}}}}, {transfers.front().m_key_image}, bob.get_keys().m_account_address, /*is_subaddress=*/false, /*n_dests_per_tx=*/1, /*fee_per_weight=*/1, /*extra=*/{}, transfers.front().m_block_height + CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE); ASSERT_EQ(1, tx_proposals.size()); const carrot::CarrotTransactionProposalV1 &tx_proposal = tx_proposals.at(0); // Assert basic length facts about tx proposal ASSERT_EQ(1, tx_proposal.input_proposals.size()); EXPECT_EQ(transfers.front().get_public_key(), onetime_address_ref(tx_proposal.input_proposals.front())); ASSERT_EQ(1, tx_proposal.normal_payment_proposals.size()); ASSERT_EQ(1, tx_proposal.selfsend_payment_proposals.size()); EXPECT_EQ(0, tx_proposal.extra.size()); // Assert amounts EXPECT_EQ(0, tx_proposal.selfsend_payment_proposals.front().amount); EXPECT_EQ(transfers.front().amount(), tx_proposal.fee + tx_proposal.normal_payment_proposals.front().amount); } //---------------------------------------------------------------------------------------------------------------------- TEST(wallet_tx_builder, make_carrot_transaction_proposals_wallet2_sweep_2) { cryptonote::account_base alice; alice.generate(); cryptonote::account_base bob; bob.generate(); const tools::wallet2::transfer_container transfers{gen_transfer_details()}; const std::vector tx_proposals = tools::wallet::make_carrot_transaction_proposals_wallet2_sweep( transfers, carrot::subaddress_map_legacy{{{alice.get_keys().m_account_address.m_spend_public_key, {}}}}, {transfers.front().m_key_image}, bob.get_keys().m_account_address, /*is_subaddress=*/false, /*n_dests_per_tx=*/FCMP_PLUS_PLUS_MAX_OUTPUTS - 1, /*fee_per_weight=*/1, /*extra=*/{}, transfers.front().m_block_height + CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE); ASSERT_EQ(1, tx_proposals.size()); const carrot::CarrotTransactionProposalV1 &tx_proposal = tx_proposals.at(0); // Assert basic length facts about tx proposal ASSERT_EQ(1, tx_proposal.input_proposals.size()); EXPECT_EQ(transfers.front().get_public_key(), onetime_address_ref(tx_proposal.input_proposals.front())); ASSERT_EQ(FCMP_PLUS_PLUS_MAX_OUTPUTS - 1, tx_proposal.normal_payment_proposals.size()); ASSERT_EQ(1, tx_proposal.selfsend_payment_proposals.size()); EXPECT_EQ(0, tx_proposal.extra.size()); // Assert amounts EXPECT_EQ(0, tx_proposal.selfsend_payment_proposals.front().amount); rct::xmr_amount total_output_amount = tx_proposal.fee; const rct::xmr_amount first_output_amount = tx_proposal.normal_payment_proposals.at(0).amount; for (const auto &normal_payment_proposal : tx_proposal.normal_payment_proposals) { const rct::xmr_amount amount = normal_payment_proposal.amount; const rct::xmr_amount max_amount = std::max(amount, first_output_amount); const rct::xmr_amount min_amount = std::min(amount, first_output_amount); EXPECT_LE(max_amount - min_amount, 1); total_output_amount += amount; } EXPECT_EQ(transfers.front().amount(), total_output_amount); } //---------------------------------------------------------------------------------------------------------------------- TEST(wallet_tx_builder, make_carrot_transaction_proposals_wallet2_sweep_3) { cryptonote::account_base alice; alice.generate(); const tools::wallet2::transfer_container transfers{gen_transfer_details()}; const std::vector tx_proposals = tools::wallet::make_carrot_transaction_proposals_wallet2_sweep( transfers, carrot::subaddress_map_legacy{{{alice.get_keys().m_account_address.m_spend_public_key, {}}}}, {transfers.front().m_key_image}, alice.get_keys().m_account_address, /*is_subaddress=*/false, /*n_dests_per_tx=*/FCMP_PLUS_PLUS_MAX_OUTPUTS, /*fee_per_weight=*/1, /*extra=*/{}, transfers.front().m_block_height + CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE); ASSERT_EQ(1, tx_proposals.size()); const carrot::CarrotTransactionProposalV1 &tx_proposal = tx_proposals.at(0); // Assert basic length facts about tx proposal ASSERT_EQ(1, tx_proposal.input_proposals.size()); EXPECT_EQ(transfers.front().get_public_key(), onetime_address_ref(tx_proposal.input_proposals.front())); ASSERT_EQ(0, tx_proposal.normal_payment_proposals.size()); ASSERT_EQ(FCMP_PLUS_PLUS_MAX_OUTPUTS, tx_proposal.selfsend_payment_proposals.size()); EXPECT_EQ(0, tx_proposal.extra.size()); // Assert amounts rct::xmr_amount total_output_amount = tx_proposal.fee; const rct::xmr_amount first_output_amount = tx_proposal.selfsend_payment_proposals.at(0).amount; for (const auto &selfsend_payment_proposal : tx_proposal.selfsend_payment_proposals) { const rct::xmr_amount amount = selfsend_payment_proposal.amount; const rct::xmr_amount max_amount = std::max(amount, first_output_amount); const rct::xmr_amount min_amount = std::min(amount, first_output_amount); EXPECT_LE(max_amount - min_amount, 1); total_output_amount += amount; } EXPECT_EQ(transfers.front().amount(), total_output_amount); } //---------------------------------------------------------------------------------------------------------------------- TEST(wallet_tx_builder, make_carrot_transaction_proposals_wallet2_sweep_4) { // output-limited sweep cryptonote::account_base alice; alice.generate(); cryptonote::account_base bob; bob.generate(); // generate transfers list static constexpr size_t n_transfers = FCMP_PLUS_PLUS_MAX_INPUTS * 2; tools::wallet2::transfer_container transfers; transfers.reserve(n_transfers); for (size_t i = 0; i < n_transfers; ++i) transfers.push_back(gen_transfer_details()); // generate random indices into transfer list static constexpr size_t n_selected_transfers = FCMP_PLUS_PLUS_MAX_INPUTS + 1; std::set selected_transfer_indices; while (selected_transfer_indices.size() < n_selected_transfers) selected_transfer_indices.insert(crypto::rand_idx(n_transfers)); // generate map of amounts by key image, key image vector, and height of chain std::vector selected_key_images; std::unordered_map amounts_by_ota; uint64_t top_block_index = 0; for (const size_t selected_transfer_index : selected_transfer_indices) { const tools::wallet2::transfer_details &td = transfers.at(selected_transfer_index); selected_key_images.push_back(td.m_key_image); amounts_by_ota.emplace(td.get_public_key(), td.amount()); top_block_index = std::max(top_block_index, td.m_block_height); } top_block_index += CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE; ASSERT_EQ(n_selected_transfers, selected_key_images.size()); ASSERT_EQ(n_selected_transfers, amounts_by_ota.size()); const size_t n_dests_per_tx = 4; // make tx proposals const std::vector tx_proposals = tools::wallet::make_carrot_transaction_proposals_wallet2_sweep( transfers, carrot::subaddress_map_legacy{{{alice.get_keys().m_account_address.m_spend_public_key, {}}}}, selected_key_images, bob.get_keys().m_account_address, /*is_subaddress=*/false, /*n_dests_per_tx=*/n_dests_per_tx, /*fee_per_weight=*/1, /*extra=*/{}, top_block_index); static constexpr size_t n_txs = (n_selected_transfers+FCMP_PLUS_PLUS_MAX_INPUTS-1) / FCMP_PLUS_PLUS_MAX_INPUTS; static_assert(n_txs > 1); ASSERT_EQ(n_txs, tx_proposals.size()); std::set actual_seen_otas; size_t n_actual_inputs = 0; for (const carrot::CarrotTransactionProposalV1 &tx_proposal : tx_proposals) { ASSERT_LE(tx_proposal.input_proposals.size(), FCMP_PLUS_PLUS_MAX_INPUTS); ASSERT_EQ(n_dests_per_tx, tx_proposal.normal_payment_proposals.size()); ASSERT_EQ(1, tx_proposal.selfsend_payment_proposals.size()); ASSERT_EQ(0, tx_proposal.selfsend_payment_proposals.at(0).amount); EXPECT_EQ(0, tx_proposal.extra.size()); rct::xmr_amount tx_inputs_amount = 0; for (const auto &input_proposal : tx_proposal.input_proposals) { const crypto::public_key ota = onetime_address_ref(input_proposal); ASSERT_TRUE(amounts_by_ota.count(ota)); ASSERT_FALSE(actual_seen_otas.count(ota)); actual_seen_otas.insert(ota); tx_inputs_amount += amounts_by_ota.at(ota); } rct::xmr_amount tx_outputs_amount = tx_proposal.fee; for (const carrot::CarrotPaymentProposalV1 &normal_payment_proposal : tx_proposal.normal_payment_proposals) tx_outputs_amount += normal_payment_proposal.amount; ASSERT_EQ(tx_inputs_amount, tx_outputs_amount); n_actual_inputs += tx_proposal.input_proposals.size(); } EXPECT_EQ(n_selected_transfers, n_actual_inputs); } //---------------------------------------------------------------------------------------------------------------------- TEST(wallet_tx_builder, make_carrot_transaction_proposals_wallet2_sweep_5) { // output-limited sweep to self cryptonote::account_base alice; alice.generate(); // generate transfers list static constexpr size_t n_transfers = FCMP_PLUS_PLUS_MAX_INPUTS * 2; tools::wallet2::transfer_container transfers; transfers.reserve(n_transfers); for (size_t i = 0; i < n_transfers; ++i) transfers.push_back(gen_transfer_details()); // generate random indices into transfer list static constexpr size_t n_selected_transfers = FCMP_PLUS_PLUS_MAX_INPUTS + 1; static_assert(n_selected_transfers < n_transfers); std::set selected_transfer_indices; while (selected_transfer_indices.size() < n_selected_transfers) selected_transfer_indices.insert(crypto::rand_idx(n_transfers)); // generate map of amounts by key image, key image vector, and height of chain std::vector selected_key_images; std::unordered_map amounts_by_ota; uint64_t top_block_index = 0; for (const size_t selected_transfer_index : selected_transfer_indices) { const tools::wallet2::transfer_details &td = transfers.at(selected_transfer_index); selected_key_images.push_back(td.m_key_image); amounts_by_ota.emplace(td.get_public_key(), td.amount()); top_block_index = std::max(top_block_index, td.m_block_height); } top_block_index += CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE; ASSERT_EQ(n_selected_transfers, selected_key_images.size()); ASSERT_EQ(n_selected_transfers, amounts_by_ota.size()); const size_t n_dests_per_tx = 8; // make tx proposals const std::vector tx_proposals = tools::wallet::make_carrot_transaction_proposals_wallet2_sweep( transfers, carrot::subaddress_map_legacy{{{alice.get_keys().m_account_address.m_spend_public_key, {}}}}, selected_key_images, alice.get_keys().m_account_address, /*is_subaddress=*/false, /*n_dests_per_tx=*/n_dests_per_tx, /*fee_per_weight=*/1, /*extra=*/{}, top_block_index); static constexpr size_t n_txs = (n_selected_transfers+FCMP_PLUS_PLUS_MAX_INPUTS-1) / FCMP_PLUS_PLUS_MAX_INPUTS; static_assert(n_txs > 1); ASSERT_EQ(n_txs, tx_proposals.size()); std::set actual_seen_otas; size_t n_actual_inputs = 0; for (const carrot::CarrotTransactionProposalV1 &tx_proposal : tx_proposals) { ASSERT_LE(tx_proposal.input_proposals.size(), FCMP_PLUS_PLUS_MAX_INPUTS); ASSERT_EQ(n_dests_per_tx == 1 ? 1 : 0, tx_proposal.normal_payment_proposals.size()); ASSERT_EQ(n_dests_per_tx, tx_proposal.selfsend_payment_proposals.size()); if (!tx_proposal.normal_payment_proposals.empty()) { ASSERT_EQ(0, tx_proposal.normal_payment_proposals.at(0).amount); } EXPECT_EQ(0, tx_proposal.extra.size()); rct::xmr_amount tx_inputs_amount = 0; for (const carrot::InputProposalV1 &input_proposal : tx_proposal.input_proposals) { const crypto::public_key ota = onetime_address_ref(input_proposal); ASSERT_TRUE(amounts_by_ota.count(ota)); ASSERT_FALSE(actual_seen_otas.count(ota)); actual_seen_otas.insert(ota); tx_inputs_amount += amounts_by_ota.at(ota); } rct::xmr_amount tx_outputs_amount = tx_proposal.fee; for (const carrot::CarrotPaymentProposalVerifiableSelfSendV1 &selfsend_payment_proposal : tx_proposal.selfsend_payment_proposals) tx_outputs_amount += selfsend_payment_proposal.amount; ASSERT_EQ(tx_inputs_amount, tx_outputs_amount); n_actual_inputs += tx_proposal.input_proposals.size(); } EXPECT_EQ(n_selected_transfers, n_actual_inputs); } //---------------------------------------------------------------------------------------------------------------------- TEST(wallet_tx_builder, make_carrot_transaction_proposals_wallet2_sweep_6) { // 2-dest, 2-out sweep to self cryptonote::account_base alice; alice.generate(); // generate transfers list static constexpr size_t n_transfers = 5; tools::wallet2::transfer_container transfers; transfers.reserve(n_transfers); for (size_t i = 0; i < n_transfers; ++i) transfers.push_back(gen_transfer_details()); // generate random indices into transfer list static constexpr size_t n_selected_transfers = 3; static_assert(n_selected_transfers < n_transfers); std::set selected_transfer_indices; while (selected_transfer_indices.size() < n_selected_transfers) selected_transfer_indices.insert(crypto::rand_idx(n_transfers)); // generate map of amounts by key image, key image vector, and height of chain std::vector selected_key_images; std::unordered_map amounts_by_ota; uint64_t top_block_index = 0; for (const size_t selected_transfer_index : selected_transfer_indices) { const tools::wallet2::transfer_details &td = transfers.at(selected_transfer_index); selected_key_images.push_back(td.m_key_image); amounts_by_ota.emplace(td.get_public_key(), td.amount()); top_block_index = std::max(top_block_index, td.m_block_height); } top_block_index += CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE; ASSERT_EQ(n_selected_transfers, selected_key_images.size()); ASSERT_EQ(n_selected_transfers, amounts_by_ota.size()); const size_t n_dests_per_tx = 2; // make tx proposals const std::vector tx_proposals = tools::wallet::make_carrot_transaction_proposals_wallet2_sweep( transfers, carrot::subaddress_map_legacy{{{alice.get_keys().m_account_address.m_spend_public_key, {}}}}, selected_key_images, alice.get_keys().m_account_address, /*is_subaddress=*/false, /*n_dests_per_tx=*/n_dests_per_tx, /*fee_per_weight=*/1, /*extra=*/{}, top_block_index); ASSERT_EQ(1, tx_proposals.size()); const carrot::CarrotTransactionProposalV1 &tx_proposal = tx_proposals.at(0); std::set actual_seen_otas; ASSERT_EQ(n_selected_transfers, tx_proposal.input_proposals.size()); ASSERT_EQ(0, tx_proposal.normal_payment_proposals.size()); ASSERT_EQ(2, tx_proposal.selfsend_payment_proposals.size()); EXPECT_EQ(0, tx_proposal.extra.size()); rct::xmr_amount tx_inputs_amount = 0; for (const carrot::InputProposalV1 &input_proposal : tx_proposal.input_proposals) { const crypto::public_key ota = onetime_address_ref(input_proposal); ASSERT_TRUE(amounts_by_ota.count(ota)); ASSERT_FALSE(actual_seen_otas.count(ota)); actual_seen_otas.insert(ota); tx_inputs_amount += amounts_by_ota.at(ota); } const rct::xmr_amount output_amount_0 = tx_proposal.selfsend_payment_proposals.at(0).amount; const rct::xmr_amount output_amount_1 = tx_proposal.selfsend_payment_proposals.at(1).amount; const rct::xmr_amount tx_outputs_amount = tx_proposal.fee + output_amount_0 + output_amount_1; ASSERT_EQ(tx_inputs_amount, tx_outputs_amount); ASSERT_LE(std::max(output_amount_0, output_amount_1) - std::min(output_amount_0, output_amount_1), 1); const carrot::CarrotEnoteType enote_type_0 = tx_proposal.selfsend_payment_proposals.at(0).enote_type; const carrot::CarrotEnoteType enote_type_1 = tx_proposal.selfsend_payment_proposals.at(1).enote_type; ASSERT_NE(enote_type_0, enote_type_1); } //---------------------------------------------------------------------------------------------------------------------- TEST(wallet_tx_builder, wallet2_scan_propose_sign_prove_member_and_scan_1) { // 1. create fake blockchain // 2. create Alice, Bob wallet2 instance // 3. send a mix of fake-input legacy and carrot txs to Alice // 4. step blockchain forward 10 blocks // 5. scan blockchain with Alice wallet // 6. create carrot transaction proposal // 7. construct proofs for transaction // 8. serialize tx // 9. deserialize tx // 10. check ver_non_input_consensus() // 11. check verRctNonSemanticsSimple() // 12. add Alice's transaction to blockchain // 13. scan blockchain with Bob's wallet and assert money received // 14. scan blockchain with Alice's wallet and assert money left // 1. LOG_PRINT_L2("Initiating my imaginary, friendly chain of blocks"); mock::fake_pruned_blockchain bc(0); // 2. LOG_PRINT_L2("Generating wallets for Alice and Bob, the usual suspects"); tools::wallet2 alice(cryptonote::MAINNET, /*kdf_rounds=*/1, /*unattended=*/true); tools::wallet2 bob(cryptonote::MAINNET, /*kdf_rounds=*/1, /*unattended=*/true); alice.set_offline(true); bob.set_offline(true); alice.generate("", ""); bob.generate("", ""); const cryptonote::account_public_address alice_main_addr = alice.get_account().get_keys().m_account_address; const cryptonote::account_public_address bob_main_addr = bob.get_account().get_keys().m_account_address; bc.init_wallet_for_starting_block(alice); bc.init_wallet_for_starting_block(bob); // 3. LOG_PRINT_L2("Sending transactions from the aether to Alice (0)"); const rct::xmr_amount amount0 = rct::randXmrAmount(COIN); std::vector dests0{cryptonote::tx_destination_entry(amount0, alice_main_addr, false)}; cryptonote::transaction tx = mock::construct_pre_carrot_tx_with_fake_inputs(dests0, /*fee=*/1234, /*hf_version=*/2); bc.add_block(2, {std::move(tx)}, mock::null_addr); LOG_PRINT_L2("Sending transactions from the aether to Alice (1)"); const rct::xmr_amount amount1 = rct::randXmrAmount(COIN); std::vector dests1{cryptonote::tx_destination_entry(amount1, alice.get_subaddress({0, 13}), true)}; cryptonote::account_base aether; aether.generate(); tx = mock::construct_carrot_pruned_transaction_fake_inputs({carrot::mock::convert_normal_payment_proposal_v1(dests1.front())}, {}, aether.get_keys()); bc.add_block(HF_VERSION_CARROT, {std::move(tx)}, mock::null_addr); // 4. //!@TODO: figure out why membership proving fails if there's fewer leaves than the curve1 width const size_t target_num_outputs = fcmp_pp::curve_trees::SELENE_CHUNK_WIDTH * fcmp_pp::curve_trees::HELIOS_CHUNK_WIDTH + 7; while (bc.num_outputs() < target_num_outputs) bc.add_block(HF_VERSION_CARROT, {}, mock::null_addr, target_num_outputs - bc.num_outputs()); LOG_PRINT_L2("Twiddling thumbs"); for (size_t i = 0; i < CRYPTONOTE_MINED_MONEY_UNLOCK_WINDOW; ++i) bc.add_block(HF_VERSION_CARROT, {}, mock::null_addr); // 5. LOG_PRINT_L2("Alice's vision is filled with shadowy keys, hashes, points, rings, trees, curves, chains, all flowing in and out of one another"); uint64_t blocks_added = bc.refresh_wallet(alice); ASSERT_EQ(bc.height()-1, blocks_added); ASSERT_EQ(2, alice.m_transfers.size()); ASSERT_EQ(amount0 + amount1, alice.balance_all(true)); // really, we care about unlocked_balance_all() for sending, but that call uses RPC // 6. LOG_PRINT_L2("Alice feels pity on Bob and proposes to send his broke ass some dough"); const rct::xmr_amount out_amount = rct::randXmrAmount(amount0 + amount1) / 2; // divide by 2 to make sure Alice has enough for the fee const std::vector tx_proposals = tools::wallet::make_carrot_transaction_proposals_wallet2_transfer( // stupidly long function name ;( alice.m_transfers, carrot::subaddress_map_legacy(alice.m_subaddresses), {cryptonote::tx_destination_entry(out_amount, bob_main_addr, false)}, /*fee_per_weight=*/1, /*extra=*/{}, /*subaddr_account=*/0, /*subaddr_indices=*/{}, /*ignore_above=*/std::numeric_limits::max(), /*ignore_below=*/0, /*max_n_inputs=*/0, {}, /*top_block_index=*/bc.height()-1); ASSERT_EQ(1, tx_proposals.size()); const carrot::CarrotTransactionProposalV1 tx_proposal = tx_proposals.at(0); // 7. LOG_PRINT_L2("Alice has something to prove"); tx = tools::wallet::finalize_all_fcmp_pp_proofs(tx_proposal, alice.m_tree_cache, *alice.m_curve_trees, *alice.get_address_device(), *alice.get_view_incoming_key_device(), alice.get_view_balance_secret_device().get(), *alice.get_spend_device()); // 8. LOG_PRINT_L2("Hello, Mr. Blobby"); const cryptonote::blobdata alicebob_tx_blob = cryptonote::tx_to_blob(tx); // 9. LOG_PRINT_L2("Goodbye, Mr. Blobby"); cryptonote::transaction alicebob_tx; ASSERT_TRUE(cryptonote::parse_and_validate_tx_from_blob(alicebob_tx_blob, alicebob_tx)); // 10. LOG_PRINT_L2("Bob couldn't believe someone to be so generous in his time of need, so he verifies"); ASSERT_GE(bc.hf_version(), HF_VERSION_FCMP_PLUS_PLUS); cryptonote::tx_verification_context tvc{}; ASSERT_TRUE(cryptonote::ver_non_input_consensus(alicebob_tx, tvc, bc.hf_version())); EXPECT_FALSE(tvc.m_verifivation_failed); // 11. LOG_PRINT_L2("'Perhaps this is valid money that belongs to another chain', Bob postulates"); const auto tree_root = bc.get_fcmp_tree_root_at(bc.height() - 1); ASSERT_TRUE(cryptonote::Blockchain::expand_transaction_2(alicebob_tx, cryptonote::get_transaction_prefix_hash(alicebob_tx), /*pubkeys=*/{}, tree_root)); EXPECT_TRUE(rct::verRctNonSemanticsSimple(alicebob_tx.rct_signatures)); // 12. LOG_PRINT_L2("'Chain, chain, chain (Chain, chain, chain)' - Aretha Franklin"); const rct::xmr_amount alicebob_tx_fee = alicebob_tx.rct_signatures.txnFee; bc.add_block(HF_VERSION_CARROT, {std::move(alicebob_tx)}, mock::null_addr); // 13. LOG_PRINT_L2("A great day for Bob"); ASSERT_EQ(0, bob.balance_all(true)); blocks_added = bc.refresh_wallet(bob); ASSERT_EQ(bc.height()-1, blocks_added); ASSERT_EQ(1, bob.m_transfers.size()); EXPECT_EQ(out_amount, bob.balance_all(true)); // 14. LOG_PRINT_L2("Alice obtains the fulfillment that only stems from selfless generosity"); const rct::xmr_amount alice_old_balance = alice.balance_all(true); ASSERT_GE(alice_old_balance, out_amount + alicebob_tx_fee); blocks_added = bc.refresh_wallet(alice); ASSERT_EQ(1, blocks_added); const rct::xmr_amount alice_new_balance = alice.balance_all(true); ASSERT_LT(alice_new_balance, alice_old_balance); EXPECT_EQ(alice_new_balance + out_amount + alicebob_tx_fee, alice_old_balance); } //----------------------------------------------------------------------------------------------------------------------