From 6e036356dfc2fecff516dcac808dd8f4198e0a22 Mon Sep 17 00:00:00 2001 From: sowle Date: Tue, 2 Apr 2024 22:00:53 +0200 Subject: [PATCH] wallet: sweeping of bare unspent outputs implemented --- src/simplewallet/password_container.cpp | 17 ++- src/simplewallet/password_container.h | 6 +- src/simplewallet/simplewallet.cpp | 122 ++++++++++++++++ src/simplewallet/simplewallet.h | 3 +- src/wallet/wallet2.cpp | 187 ++++++++++++++++++++++++ src/wallet/wallet2.h | 14 ++ src/wallet/wallet_helpers.h | 1 + src/wallet/wallet_public_structs_defs.h | 2 + 8 files changed, 343 insertions(+), 9 deletions(-) diff --git a/src/simplewallet/password_container.cpp b/src/simplewallet/password_container.cpp index b4ca7448..6203eee9 100644 --- a/src/simplewallet/password_container.cpp +++ b/src/simplewallet/password_container.cpp @@ -1,4 +1,4 @@ -// Copyright (c) 2014-2018 Zano Project +// Copyright (c) 2014-2024 Zano Project // Copyright (c) 2014-2018 The Louisdor Project // Copyright (c) 2012-2013 The Cryptonote developers // Distributed under the MIT/X11 software license, see the accompanying @@ -63,6 +63,11 @@ namespace tools } bool password_container::read_password(const std::string& prompt_text) + { + return read_input(prompt_text, '*'); + } + + bool password_container::read_input(const std::string& prompt_text, char char_to_replace_user_input /* = '\0' */) { clear(); @@ -70,7 +75,7 @@ namespace tools if (is_cin_tty()) { std::cout << prompt_text; - r = read_from_tty(); + r = read_from_tty(char_to_replace_user_input); } else { @@ -122,7 +127,7 @@ namespace tools } } - bool password_container::read_from_tty() + bool password_container::read_from_tty(char char_to_replace_user_input) { const char BACKSPACE = 8; @@ -162,7 +167,7 @@ namespace tools else { m_password.push_back(ch); - std::cout << '*'; + std::cout << (char_to_replace_user_input != '\0' ? char_to_replace_user_input : ch); } } @@ -198,7 +203,7 @@ namespace tools } } - bool password_container::read_from_tty() + bool password_container::read_from_tty(char char_to_replace_user_input) { const char BACKSPACE = 127; @@ -227,7 +232,7 @@ namespace tools else { m_password.push_back(ch); - std::cout << '*'; + std::cout << (char_to_replace_user_input != '\0' ? char_to_replace_user_input : ch); } } diff --git a/src/simplewallet/password_container.h b/src/simplewallet/password_container.h index 49a3412a..5bca195b 100644 --- a/src/simplewallet/password_container.h +++ b/src/simplewallet/password_container.h @@ -1,4 +1,4 @@ -// Copyright (c) 2014-2018 Zano Project +// Copyright (c) 2014-2024 Zano Project // Copyright (c) 2014-2018 The Louisdor Project // Copyright (c) 2012-2013 The Cryptonote developers // Distributed under the MIT/X11 software license, see the accompanying @@ -24,13 +24,15 @@ namespace tools void clear(); bool empty() const { return m_empty; } const std::string& password() const { return m_password; } + const std::string& get_input() const { return m_password; } // TODO: refactor this void password(std::string&& val) { m_password = std::move(val); m_empty = false; } bool read_password(); bool read_password(const std::string& prompt_text); + bool read_input(const std::string& prompt_text, char char_to_replace_user_input = '\0'); private: bool read_from_file(); - bool read_from_tty(); + bool read_from_tty(char char_to_replace_user_input); private: bool m_empty; diff --git a/src/simplewallet/simplewallet.cpp b/src/simplewallet/simplewallet.cpp index 24a2053f..bb1ac622 100644 --- a/src/simplewallet/simplewallet.cpp +++ b/src/simplewallet/simplewallet.cpp @@ -315,6 +315,7 @@ simple_wallet::simple_wallet() m_cmd_binder.set_handler("scan_transfers_for_ki", boost::bind(&simple_wallet::scan_transfers_for_ki, this,ph::_1), "Rescan transfers for key image"); m_cmd_binder.set_handler("print_utxo_distribution", boost::bind(&simple_wallet::print_utxo_distribution, this,ph::_1), "Prints utxo distribution"); m_cmd_binder.set_handler("sweep_below", boost::bind(&simple_wallet::sweep_below, this,ph::_1), "sweep_below
[payment_id] - Tries to transfers all coins with amount below the given limit to the given address"); + m_cmd_binder.set_handler("sweep_bare_outs", boost::bind(&simple_wallet::sweep_bare_outs, this,ph::_1), "sweep_bare_outs - Transfers all bare unspent outputs to itself. Uses several txs if necessary."); m_cmd_binder.set_handler("address", boost::bind(&simple_wallet::print_address, this,ph::_1), "Show current wallet public address"); m_cmd_binder.set_handler("integrated_address", boost::bind(&simple_wallet::integrated_address, this,ph::_1), "integrated_address [| &args) SIMPLE_WALLET_CATCH_TRY_ENTRY(); return true; } +//---------------------------------------------------------------------------------------------------- +bool simple_wallet::sweep_bare_outs(const std::vector &args) +{ + CONFIRM_WITH_PASSWORD(); + SIMPLE_WALLET_BEGIN_TRY_ENTRY(); + bool r = false; + if (args.size() > 1) + { + fail_msg_writer() << "Invalid number of agruments given"; + return true; + } + currency::account_public_address target_address = m_wallet->get_account().get_public_address(); + currency::payment_id_t integrated_payment_id{}; + if (args.size() == 1) + { + if (!m_wallet->get_transfer_address(args[0], target_address, integrated_payment_id)) + { + fail_msg_writer() << "Unable to parse address from " << args[1]; + return true; + } + if (!integrated_payment_id.empty()) + { + fail_msg_writer() << "Payment id is not supported. Please, don't use integrated address with this command."; + return true; + } + } + + if (!m_wallet->has_bare_unspent_outputs()) + { + success_msg_writer(true) << "This wallet doesn't have bare unspent outputs.\nNothing to do. Everything looks good."; + return true; + } + + std::vector groups; + m_wallet->get_bare_unspent_outputs_stats(groups); + if (groups.empty()) + { + uint64_t unlocked_balance = 0; + uint64_t balance = m_wallet->balance(unlocked_balance); + if (balance < COIN) + success_msg_writer(false) << "Looks like it's not enough coins to perform this operation. Transferring " << print_money_brief(TX_MINIMUM_FEE) << " ZANO or more to this wallet may help."; + else if (unlocked_balance < COIN) + success_msg_writer(false) << "Not enough spendable outputs to perform this operation. Please, try again later."; + else + { + success_msg_writer(false) << "This operation couldn't be performed for some reason. Please, copy simplewallet's log file and ask for support. Nothing was done."; + LOG_PRINT_L0("strange situation: balance: " << print_money_brief(balance) << ", unlocked_balance: " << print_money_brief(unlocked_balance) << " but get_bare_unspent_outputs_stats returned empty result"); + } + return true; + } + + size_t i = 0, total_bare_outs = 0; + uint64_t total_mount = 0; + std::stringstream details_ss; + for(auto &g : groups) + { + details_ss << std::setw(2) << i << ": "; + for (auto& tid: g.tids) + { + tools::transfer_details td{}; + CHECK_AND_ASSERT_THROW_MES(m_wallet->get_transfer_info_by_index(tid, td), "get_transfer_info_by_index failed with index " << tid); + details_ss << tid << " (" << print_money_brief(td.m_amount) << "), "; + total_mount += td.m_amount; + } + + if (g.additional_tid) + details_ss << "additional tid: " << g.tids.back() << "( " << print_money_brief(g.additional_tid_amount) << ")"; + else + details_ss.seekp(-2, std::ios_base::end); + + details_ss << ENDL; + ++i; + total_bare_outs += g.tids.size(); + } + + LOG_PRINT_L1("bare UTXO:" << ENDL << details_ss.str()); + + success_msg_writer(true) << "This wallet contains " << total_bare_outs << " bare outputs with total amount of " << print_money_brief(total_mount) << + ". They can be converted in " << groups.size() << " transaction" << (groups.size() > 1 ? "s" : "") << ", with total fee = " << print_money_brief(TX_DEFAULT_FEE * i) << "."; + if (target_address != m_wallet->get_account().get_public_address()) + message_writer(epee::log_space::console_color_yellow, false) << print_money_brief(total_mount) << " coins will be sent to address " << get_account_address_as_str(target_address); + + tools::password_container reader; + if (!reader.read_input("Would you like to continue? (y/yes/n/no):\n") || (reader.get_input() != "y" && reader.get_input() != "yes")) + { + success_msg_writer(false) << "Operatation terminated as requested by user."; + return true; + } + + size_t total_tx_sent = 0; + uint64_t total_fee_spent = 0; + uint64_t total_amount_sent = 0; + auto on_tx_sent_callback = [&](size_t batch_index, const currency::transaction& tx, uint64_t amount, uint64_t fee, bool sent_ok, const std::string& err) { + auto mw = success_msg_writer(false); + mw << std::setw(2) << batch_index << ": transaction "; + if (!sent_ok) + { + mw << "failed (" << err << ")"; + return; + } + mw << get_transaction_hash(tx) << ", fee: " << print_money_brief(fee) << ", amount: " << print_money_brief(amount); + ++total_tx_sent; + total_fee_spent += fee; + total_amount_sent += amount; + }; + + if (!m_wallet->sweep_bare_unspent_outputs(target_address, groups, on_tx_sent_callback)) + { + auto mw = fail_msg_writer(); + mw << "Operatation failed."; + if (total_tx_sent > 0) + mw << " However, " << total_tx_sent << " transaction" << (total_tx_sent == 1 ? " was" : "s were") << " successfully sent, " << print_money_brief(total_amount_sent) << " coins transferred, and " << print_money_brief(total_fee_spent) << " was spent for fees."; + } + else + { + success_msg_writer(true) << "Operatation succeeded. " << ENDL << total_tx_sent << " transaction" << (total_tx_sent == 1 ? " was" : "s were") << " successfully sent, " << print_money_brief(total_amount_sent) << " coins transferred, and " << print_money_brief(total_fee_spent) << " was spent for fees."; + } + + SIMPLE_WALLET_CATCH_TRY_ENTRY(); + return true; +} //---------------------------------------------------------------------------------------------------- uint64_t get_tick_count__() diff --git a/src/simplewallet/simplewallet.h b/src/simplewallet/simplewallet.h index 81958f18..0b0843d0 100644 --- a/src/simplewallet/simplewallet.h +++ b/src/simplewallet/simplewallet.h @@ -1,4 +1,4 @@ -// Copyright (c) 2014-2018 Zano Project +// Copyright (c) 2014-2024 Zano Project // Copyright (c) 2014-2018 The Louisdor Project // Copyright (c) 2012-2013 The Cryptonote developers // Distributed under the MIT/X11 software license, see the accompanying @@ -87,6 +87,7 @@ namespace currency bool sign_transfer(const std::vector &args); bool submit_transfer(const std::vector &args); bool sweep_below(const std::vector &args); + bool sweep_bare_outs(const std::vector &args); bool tor_enable(const std::vector &args); bool tor_disable(const std::vector &args); bool deploy_new_asset(const std::vector &args); diff --git a/src/wallet/wallet2.cpp b/src/wallet/wallet2.cpp index 5b09b36d..93189335 100644 --- a/src/wallet/wallet2.cpp +++ b/src/wallet/wallet2.cpp @@ -2200,6 +2200,189 @@ bool wallet2::has_related_alias_entry_unconfirmed(const currency::transaction& t return false; } //---------------------------------------------------------------------------------------------------- +#define HARDFORK_04_TIMESTAMP_ACTUAL 1711021795ull // block 2555000, 2024-03-21 11:49:55 UTC +bool wallet2::has_bare_unspent_outputs() const +{ + if (m_account.get_createtime() > HARDFORK_04_TIMESTAMP_ACTUAL) + return false; + + [[maybe_unused]] uint64_t bal = 0; + if (!m_has_bare_unspent_outputs.has_value()) + bal = balance(); + + WLT_THROW_IF_FALSE_WALLET_INT_ERR_EX(m_has_bare_unspent_outputs.has_value(), "m_has_bare_unspent_outputs has no value after balance()"); + + return m_has_bare_unspent_outputs.value(); +} +//---------------------------------------------------------------------------------------------------- +#define MAX_INPUTS_FOR_SIMPLE_TX_EURISTIC 20 +bool wallet2::get_bare_unspent_outputs_stats(std::vector& tids_grouped_by_txs) const +{ + tids_grouped_by_txs.clear(); + + // 1/3. Populate a list of bare unspent outputs + std::unordered_map> buo_ids; // tx hash -> Bare Unspent Outs list + for(size_t tid = 0; tid != m_transfers.size(); ++tid) + { + const auto& td = m_transfers[tid]; + if (!td.is_zc() && td.is_spendable()) + { + buo_ids[td.tx_hash()].push_back(tid); + } + } + + if (buo_ids.empty()) + return true; + + // 2/3. Split them into groups + tids_grouped_by_txs.emplace_back(); + for(auto& buo_el : buo_ids) + { + if (tids_grouped_by_txs.back().tids.size() + buo_el.second.size() > MAX_INPUTS_FOR_SIMPLE_TX_EURISTIC) + tids_grouped_by_txs.emplace_back(); + + for(auto& tid : buo_el.second) + { + if (tids_grouped_by_txs.back().tids.size() >= MAX_INPUTS_FOR_SIMPLE_TX_EURISTIC) + tids_grouped_by_txs.emplace_back(); + tids_grouped_by_txs.back().tids.push_back(tid); + tids_grouped_by_txs.back().total_amount += m_transfers[tid].m_amount; + } + } + + + // 3/3. Iterate through groups and check whether total amount is big enough to cover min fee. + // Add additional zc output if not. + std::multimap usable_zc_outs_tids; // grouped by amount + bool usable_zc_outs_tids_precalculated = false; + auto precalculate_usable_zc_outs_if_needed = [&](){ + if (usable_zc_outs_tids_precalculated) + return; + size_t decoys = is_auditable() ? 0 : m_core_runtime_config.hf4_minimum_mixins; + for(size_t tid = 0; tid != m_transfers.size(); ++tid) + { + auto& td = m_transfers[tid]; + if (td.is_zc() && td.is_native_coin() && is_transfer_ready_to_go(td, decoys)) + usable_zc_outs_tids.insert(std::make_pair(td.m_amount, tid)); + } + usable_zc_outs_tids_precalculated = true; + }; + + std::unordered_set used_zc_outs; + for(auto it = tids_grouped_by_txs.begin(); it != tids_grouped_by_txs.end(); ) + { + auto& group = *it; + if (group.total_amount < TX_MINIMUM_FEE) + { + precalculate_usable_zc_outs_if_needed(); + + uint64_t min_required_amount = TX_MINIMUM_FEE - group.total_amount; + auto jt = usable_zc_outs_tids.lower_bound(min_required_amount); + bool found = false; + while(jt != usable_zc_outs_tids.end()) + { + WLT_THROW_IF_FALSE_WALLET_INT_ERR_EX(jt->first >= min_required_amount, "jt->first=" << jt->first << ", min_required_amount=" << min_required_amount); + if (used_zc_outs.count(jt->second) == 0) + { + group.tids.push_back(jt->second); + used_zc_outs.insert(jt->second); + group.additional_tid = true; + group.additional_tid_amount = jt->first; + found = true; + break; + } + ++jt; + } + + if (!found) + { + // no usable outs for required amount, remove this group and go to the next + it = tids_grouped_by_txs.erase(it); + continue; + } + } + + ++it; + } + + return true; +} +//---------------------------------------------------------------------------------------------------- +bool wallet2::sweep_bare_unspent_outputs(const currency::account_public_address& target_address, const std::vector& tids_grouped_by_txs, + std::function on_tx_sent) +{ + if (m_watch_only) + return false; + + size_t decoys_count = is_auditable() ? 0 : CURRENCY_DEFAULT_DECOY_SET_SIZE; + + bool send_to_network = true; + + size_t batch_index = 0; + for(const batch_of_bare_unspent_outs& group : tids_grouped_by_txs) + { + currency::finalized_tx ftx{}; + currency::finalize_tx_param ftp{}; + ftp.pevents_dispatcher = &m_debug_events_dispatcher; + ftp.tx_version = this->get_current_tx_version(); + + if (!prepare_tx_sources(decoys_count, ftp.sources, group.tids)) + { + on_tx_sent(batch_index, transaction{}, 0, 0, false, "sources for tx couldn't be prepared"); + LOG_PRINT_L0("prepare_tx_sources failed, batch_index = " << batch_index); + return false; + } + uint64_t fee = TX_DEFAULT_FEE; + std::vector destinations{tx_destination_entry(group.total_amount + group.additional_tid_amount - fee, target_address)}; + assets_selection_context needed_money_map{std::make_pair(native_coin_asset_id, selection_for_amount{group.total_amount + group.additional_tid_amount, group.total_amount + group.additional_tid_amount})}; + try + { + prepare_tx_destinations(needed_money_map, get_current_split_strategy(), tx_dust_policy{}, destinations, 0 /* tx_flags */, ftp.prepared_destinations); + } + catch(...) + { + on_tx_sent(batch_index, transaction{}, 0, 0, false, "destinations for tx couldn't be prepared"); + LOG_PRINT_L0("prepare_tx_destinations failed, batch_index = " << batch_index); + return false; + } + + mark_transfers_as_spent(ftp.selected_transfers, std::string("sweep bare UTXO, tx: ") + epee::string_tools::pod_to_hex(get_transaction_hash(ftx.tx))); + try + { + finalize_transaction(ftp, ftx, send_to_network); + on_tx_sent(batch_index, ftx.tx, group.total_amount + group.additional_tid_amount, fee, true, std::string()); + } + catch(std::exception& e) + { + clear_transfers_from_flag(ftp.selected_transfers, WALLET_TRANSFER_DETAIL_FLAG_SPENT, std::string("exception on sweep bare UTXO, tx: ") + epee::string_tools::pod_to_hex(get_transaction_hash(ftx.tx))); + on_tx_sent(batch_index, transaction{}, 0, 0, false, e.what()); + return false; + } + + ++batch_index; + } + + return true; +} +//---------------------------------------------------------------------------------------------------- +bool wallet2::sweep_bare_unspent_outputs(const currency::account_public_address& target_address, const std::vector& tids_grouped_by_txs, + size_t& total_txs_sent, uint64_t& total_amount_sent, uint64_t& total_fee_spent) +{ + total_txs_sent = 0; + total_amount_sent = 0; + total_fee_spent = 0; + auto on_tx_sent_callback = [&](size_t batch_index, const currency::transaction& tx, uint64_t amount, uint64_t fee, bool sent_ok, const std::string& err) { + if (sent_ok) + { + ++total_txs_sent; + total_fee_spent += fee; + total_amount_sent += amount; + } + }; + + return sweep_bare_unspent_outputs(target_address, tids_grouped_by_txs, on_tx_sent_callback); +} +//---------------------------------------------------------------------------------------------------- uint64_t wallet2::get_directly_spent_transfer_index_by_input_in_tracking_wallet(const currency::txin_to_key& intk) { return get_directly_spent_transfer_index_by_input_in_tracking_wallet(intk.amount, intk.key_offsets); @@ -3444,6 +3627,7 @@ uint64_t wallet2::balance(const crypto::public_key& asset_id) const bool wallet2::balance(std::unordered_map& balances, uint64_t& mined) const { mined = 0; + m_has_bare_unspent_outputs = false; for(auto& td : m_transfers) { @@ -3464,6 +3648,9 @@ bool wallet2::balance(std::unordered_map m_last_sync_percent = 0; mutable uint64_t m_current_wallet_file_size = 0; bool m_use_assets_whitelisting = true; + mutable std::optional m_has_bare_unspent_outputs; // recalculated each time the balance() is called //=============================================================== @@ -342,6 +343,13 @@ namespace tools mutable crypto::hash tx_hash_ = currency::null_hash; }; + struct batch_of_bare_unspent_outs + { + std::vector tids; + uint64_t total_amount = 0; + bool additional_tid = false; // additional zc transfer if total_amount < min fee + uint64_t additional_tid_amount = 0; + }; @@ -377,6 +385,12 @@ namespace tools void set_do_rise_transfer(bool do_rise) { m_do_rise_transfer = do_rise; } bool has_related_alias_entry_unconfirmed(const currency::transaction& tx); + bool has_bare_unspent_outputs() const; + bool get_bare_unspent_outputs_stats(std::vector& buo_txs) const; + bool sweep_bare_unspent_outputs(const currency::account_public_address& target_address, const std::vector& tids_grouped_by_txs, + std::function on_tx_sent); + bool sweep_bare_unspent_outputs(const currency::account_public_address& target_address, const std::vector& tids_grouped_by_txs, + size_t& total_txs_sent, uint64_t& total_amount_sent, uint64_t& total_fee); void handle_unconfirmed_tx(process_transaction_context& ptc); void scan_tx_pool(bool& has_related_alias_in_unconfirmed); void refresh(); diff --git a/src/wallet/wallet_helpers.h b/src/wallet/wallet_helpers.h index 12a75d7a..6c24ef88 100644 --- a/src/wallet/wallet_helpers.h +++ b/src/wallet/wallet_helpers.h @@ -20,6 +20,7 @@ namespace tools wi.path = epee::string_encoding::wstring_to_utf8(w.get_wallet_path()); wi.is_auditable = w.is_auditable(); wi.is_watch_only = w.is_watch_only(); + wi.has_bare_unspent_outputs = w.has_bare_unspent_outputs(); return true; } diff --git a/src/wallet/wallet_public_structs_defs.h b/src/wallet/wallet_public_structs_defs.h index 523aa2d4..db98aad3 100644 --- a/src/wallet/wallet_public_structs_defs.h +++ b/src/wallet/wallet_public_structs_defs.h @@ -1550,6 +1550,7 @@ namespace wallet_public std::string path; bool is_auditable; bool is_watch_only; + bool has_bare_unspent_outputs; BEGIN_KV_SERIALIZE_MAP() KV_SERIALIZE(balances) @@ -1559,6 +1560,7 @@ namespace wallet_public KV_SERIALIZE(path) KV_SERIALIZE(is_auditable) KV_SERIALIZE(is_watch_only) + KV_SERIALIZE(has_bare_unspent_outputs) END_KV_SERIALIZE_MAP() };