From c73be6f3dc48153d37b7b74740b242399e1020b1 Mon Sep 17 00:00:00 2001 From: ohcee Date: Sun, 19 Jul 2026 16:27:01 -0700 Subject: [PATCH 1/2] Add opt-in scheduled conversion of basecoin and CT balances to RingCT When -autoconvert is enabled, a wallet scheduler task periodically converts batches of confirmed outputs toward RingCT. Consensus only permits RingCT outputs from CT or RingCT inputs, so basecoin batches convert to CT and later cycles lift the matured CT outputs to RingCT. * runs off the validation path on the wallet scheduler with random jitter between attempts * converts at most 32 outputs with 12 or more confirmations per transaction, once the batch reaches -autoconvertthreshold (default 10) * tests mempool acceptance before committing to the wallet * unwinds the transaction record and pending spend flags if a built transaction cannot be broadcast, and backs off exponentially after repeated failures * skips during IBD, before full sync, while locked or staking only unlocked, and when wallet broadcast is disabled Testing and review before merge. --- src/veil/ringct/anonwallet.cpp | 22 ++++ src/veil/ringct/anonwallet.h | 3 + src/wallet/init.cpp | 10 ++ src/wallet/wallet.cpp | 184 +++++++++++++++++++++++++++++++++ src/wallet/wallet.h | 16 +++ 5 files changed, 235 insertions(+) diff --git a/src/veil/ringct/anonwallet.cpp b/src/veil/ringct/anonwallet.cpp index 6b91027957..6b52c0e8f0 100644 --- a/src/veil/ringct/anonwallet.cpp +++ b/src/veil/ringct/anonwallet.cpp @@ -1290,6 +1290,28 @@ void AnonWallet::MarkInputsAsPendingSpend(const std::vector& vin) } } +void AnonWallet::UnwindPendingTransaction(const uint256& txid) +{ + LOCK(pwalletParent->cs_wallet); + auto mi = mapRecords.find(txid); + if (mi == mapRecords.end()) + return; + for (const COutPoint& prevout : mi->second.vin) { + auto mit = mapRecords.find(prevout.hash); + if (mit == mapRecords.end()) + continue; + COutputRecord* pout = mit->second.GetOutput(prevout.n); + if (!pout) + continue; + pout->MarkPendingSpend(false); + SaveRecord(prevout.hash, mit->second); + } + AnonWalletDB wdb(*walletDatabase); + wdb.EraseTxRecord(txid); + mapRecords.erase(mi); + LogPrintf("%s: removed unbroadcast transaction record %s\n", __func__, txid.ToString()); +} + void AnonWallet::AddOutputRecordMetaData(CTransactionRecord &rtx, std::vector &vecSend) { for (const auto &r : vecSend) { diff --git a/src/veil/ringct/anonwallet.h b/src/veil/ringct/anonwallet.h index baef69f664..52102f106c 100644 --- a/src/veil/ringct/anonwallet.h +++ b/src/veil/ringct/anonwallet.h @@ -219,6 +219,9 @@ class AnonWallet void AddOutputRecordMetaData(CTransactionRecord &rtx, std::vector &vecSend); bool ExpandTempRecipients(std::vector &vecSend, std::string &sError); void MarkInputsAsPendingSpend(const std::vector& rtxvin); + /** Remove the record of a transaction that was built but never accepted for + * broadcast, unmarking the pending spend state of its inputs. */ + void UnwindPendingTransaction(const uint256& txid); bool AddCTData(CTxOutBase *txout, CTempRecipient &r, std::string &sError); diff --git a/src/wallet/init.cpp b/src/wallet/init.cpp index e2ddd38a1d..44bad91fdd 100644 --- a/src/wallet/init.cpp +++ b/src/wallet/init.cpp @@ -23,6 +23,8 @@ void WalletInit::AddWalletOptions() const { gArgs.AddArg("-addresstype", strprintf("What type of addresses to use (\"legacy\", \"p2sh-segwit\", or \"bech32\", default: \"%s\")", FormatOutputType(DEFAULT_ADDRESS_TYPE)), false, OptionsCategory::WALLET); + gArgs.AddArg("-autoconvert", strprintf("Automatically convert confirmed basecoin and CT balances in all loaded wallets to RingCT (default: %u). Each conversion is a normal transaction that pays the standard fee. Requires the wallet to be fully unlocked", DEFAULT_AUTOCONVERT), false, OptionsCategory::WALLET); + gArgs.AddArg("-autoconvertthreshold=", strprintf("Minimum amount of eligible outputs required before an automatic conversion transaction is created (default: %s)", FormatMoney(DEFAULT_AUTOCONVERT_THRESHOLD)), false, OptionsCategory::WALLET); gArgs.AddArg("-avoidpartialspends", strprintf(_("Group outputs by address, selecting all or none, instead of selecting on a per-output basis. Privacy is improved as an address is only used once (unless someone sends to it after spending from it), but may result in slightly higher fees as suboptimal coin selection may result due to the added limitation (default: %u)"), DEFAULT_AVOIDPARTIALSPENDS), false, OptionsCategory::WALLET); gArgs.AddArg("-backupzerocoin", "Enable automatic backups of zerocoin", false, OptionsCategory::WALLET); gArgs.AddArg("-changetype", "What type of change to use (\"legacy\", \"p2sh-segwit\", or \"bech32\"). Default is same as -addresstype, except when -addresstype=p2sh-segwit a native segwit output is used when sending to a native segwit address)", false, OptionsCategory::WALLET); @@ -247,6 +249,14 @@ void WalletInit::Start(CScheduler& scheduler) const // Run a thread to flush wallet periodically scheduler.scheduleEvery(MaybeCompactWalletDB, 500); + + if (gArgs.GetBoolArg("-autoconvert", DEFAULT_AUTOCONVERT)) { + scheduler.scheduleEvery([]() { + for (const std::shared_ptr& pwallet : GetWallets()) { + pwallet->AutoConvertToRingCT(); + } + }, AUTOCONVERT_TIMER_INTERVAL_MS); + } } void WalletInit::Flush() const diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index bba0fd0bba..0e96eafdc8 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -5,6 +5,7 @@ // file COPYING or http://www.opensource.org/licenses/mit-license.php. #include +#include #include #include @@ -4091,6 +4092,180 @@ bool CWallet::CommitTransaction(CTransactionRef tx, mapValue_t mapValue, std::ve return true; } +// Tuning for -autoconvert. +// Outputs need this many confirmations before they are converted, which keeps +// conversions clear of short reorgs and decorrelates them from the block that +// paid the wallet. +static const int AUTOCONVERT_MIN_DEPTH = 12; +// Cap the inputs per conversion so a sweep of a large wallet can never +// approach the standard transaction size limits. +static const size_t AUTOCONVERT_MAX_INPUTS = MAX_ANON_INPUTS; +// Delay between conversion attempts: a minimum plus a random jitter so the +// broadcasts do not fingerprint the wallet by trailing blocks or each other. +static const int AUTOCONVERT_MIN_DELAY = 60; +static const int AUTOCONVERT_JITTER = 9 * 60; +// Exponential backoff applied after a failed conversion attempt. +static const int64_t AUTOCONVERT_BACKOFF_BASE = 5 * 60; +static const int64_t AUTOCONVERT_BACKOFF_MAX = 6 * 60 * 60; + +void CWallet::AutoConvertToRingCT() +{ + if (!pAnonWalletMain || !fBroadcastTransactions) + return; + if (IsInitialBlockDownload() || !HeadersAndBlocksSynced()) + return; + // Requires a full unlock; AnonWallet::IsLocked also covers unlock-for-staking-only + if (pAnonWalletMain->IsLocked()) + return; + + int64_t nNow = GetTime(); + if (m_autoconvert_not_before == 0) { + // First pass after startup: schedule with jitter instead of converting immediately + m_autoconvert_not_before = nNow + AUTOCONVERT_MIN_DELAY + GetRandInt(AUTOCONVERT_JITTER); + return; + } + if (nNow < m_autoconvert_not_before) + return; + + // Lift matured CT outputs to RingCT first, then start new basecoin batches + AutoConvertResult result = AutoConvertBatch(/*fFromBasecoin*/false); + if (result == AutoConvertResult::NOTHING_TO_CONVERT) + result = AutoConvertBatch(/*fFromBasecoin*/true); + + switch (result) { + case AutoConvertResult::NOTHING_TO_CONVERT: + // Randomize the next check so an output maturing past the depth + // floor is not converted at a predictable offset from its receipt + m_autoconvert_not_before = nNow + GetRandInt(AUTOCONVERT_JITTER); + break; + case AutoConvertResult::CONVERTED: + m_autoconvert_failures = 0; + m_autoconvert_not_before = nNow + AUTOCONVERT_MIN_DELAY + GetRandInt(AUTOCONVERT_JITTER); + break; + case AutoConvertResult::FAILED: + m_autoconvert_failures = std::min(m_autoconvert_failures + 1, 6); + m_autoconvert_not_before = nNow + std::min(AUTOCONVERT_BACKOFF_BASE << m_autoconvert_failures, AUTOCONVERT_BACKOFF_MAX); + LogPrintf("%s: %d consecutive failure(s), next attempt in %d seconds\n", __func__, + m_autoconvert_failures, (int)(m_autoconvert_not_before - nNow)); + break; + } +} + +CWallet::AutoConvertResult CWallet::AutoConvertBatch(bool fFromBasecoin) +{ + CCoinControl coinControl; + coinControl.fAllowOtherInputs = false; + + // Select up to AUTOCONVERT_MAX_INPUTS confirmed outputs, largest first, so + // the amount checked below is exactly the amount the transaction spends. + CAmount nTotal = 0; + { + LOCK2(cs_main, cs_wallet); + if (fFromBasecoin) { + std::vector vCoins; + AvailableCoins(vCoins, true, nullptr, 1, MAX_MONEY, MAX_MONEY, 0, AUTOCONVERT_MIN_DEPTH); + std::sort(vCoins.begin(), vCoins.end(), [](const COutput& a, const COutput& b) { + return a.tx->tx->vpout[a.i]->GetValue() > b.tx->tx->vpout[b.i]->GetValue(); + }); + for (const COutput& out : vCoins) { + if (coinControl.setSelected.size() >= AUTOCONVERT_MAX_INPUTS) + break; + if (!out.fSpendable) + continue; + CAmount nValue = out.tx->tx->vpout[out.i]->GetValue(); + coinControl.Select(COutPoint(out.tx->GetHash(), out.i), nValue); + nTotal += nValue; + } + } else { + std::vector vCoins; + pAnonWalletMain->AvailableBlindedCoins(vCoins, true, &coinControl, 1, MAX_MONEY, MAX_MONEY, 0, AUTOCONVERT_MIN_DEPTH); + auto recordValue = [](const COutputR& out) -> CAmount { + const COutputRecord* pout = out.rtx->second.GetOutput(out.i); + return pout ? pout->GetAmount() : 0; + }; + std::sort(vCoins.begin(), vCoins.end(), [&recordValue](const COutputR& a, const COutputR& b) { + return recordValue(a) > recordValue(b); + }); + for (const COutputR& out : vCoins) { + if (coinControl.setSelected.size() >= AUTOCONVERT_MAX_INPUTS) + break; + if (!out.fSpendable) + continue; + CAmount nValue = recordValue(out); + if (nValue <= 0) + continue; + coinControl.Select(COutPoint(out.txhash, out.i), nValue); + nTotal += nValue; + } + } + } + + if (coinControl.setSelected.empty() || nTotal < m_autoconvert_threshold) + return AutoConvertResult::NOTHING_TO_CONVERT; + + std::vector vecSend; + CTempRecipient r; + // Consensus only allows RingCT outputs when the inputs are CT or RingCT + // (bad-txns-ringct-output-no-anonin), so basecoin converts to CT here and + // a later cycle lifts the matured CT outputs to RingCT. + r.nType = fFromBasecoin ? OUTPUT_CT : OUTPUT_RINGCT; + r.SetAmount(nTotal); + r.fSubtractFeeFromAmount = true; + r.address = pAnonWalletMain->GetStealthChangeAddress(); + vecSend.push_back(r); + + CWalletTx wtx(this, nullptr); + CTransactionRecord rtx; + CAmount nFeeRet = 0; + std::string sError; + int nResult; + if (fFromBasecoin) { + nResult = pAnonWalletMain->AddStandardInputs(wtx, rtx, vecSend, true, nFeeRet, &coinControl, sError, false, 0); + } else { + nResult = pAnonWalletMain->AddBlindedInputs(wtx, rtx, vecSend, true, 0, nFeeRet, &coinControl, sError); + } + if (nResult != 0) { + // A failure after the transaction was fully built has already saved a + // record and marked its inputs as pending spend; undo that so the + // selected outputs remain spendable. + if (wtx.tx) + pAnonWalletMain->UnwindPendingTransaction(wtx.tx->GetHash()); + LogPrintf("%s: %s conversion failed: %s\n", __func__, fFromBasecoin ? "basecoin->CT" : "CT->RingCT", sError); + return AutoConvertResult::FAILED; + } + + const uint256 txid = wtx.tx->GetHash(); + + // Confirm the mempool would accept the transaction before committing it to + // the wallet, so a rejection cannot strand the inputs as spent. + { + LOCK(cs_main); + CValidationState state; + if (!AcceptToMemoryPool(mempool, state, wtx.tx, nullptr /* pfMissingInputs */, nullptr /* plTxnReplaced */, + false /* bypass_limits */, maxTxFee, true /* test accept */)) { + pAnonWalletMain->UnwindPendingTransaction(txid); + LogPrintf("%s: %s conversion rejected by mempool: %s\n", __func__, fFromBasecoin ? "basecoin->CT" : "CT->RingCT", + FormatStateMessage(state)); + return AutoConvertResult::FAILED; + } + } + + CValidationState state; + if (!CommitTransaction(wtx.tx, wtx.mapValue, wtx.vOrderForm, nullptr, g_connman.get(), state)) { + // CommitTransaction added the transaction to the wallet before the + // mempool rejected it; abandon it so its inputs become spendable again. + AbandonTransaction(txid); + pAnonWalletMain->UnwindPendingTransaction(txid); + LogPrintf("%s: %s conversion commit failed: %s\n", __func__, fFromBasecoin ? "basecoin->CT" : "CT->RingCT", + FormatStateMessage(state)); + return AutoConvertResult::FAILED; + } + + LogPrintf("%s: %s: converted %d output(s) in tx %s, fee %s\n", __func__, + fFromBasecoin ? "basecoin->CT" : "CT->RingCT", (int)coinControl.setSelected.size(), txid.ToString(), FormatMoney(nFeeRet)); + return AutoConvertResult::CONVERTED; +} + DBErrors CWallet::LoadWallet(bool& fFirstRunRet) { LOCK2(cs_main, cs_wallet); @@ -5378,6 +5553,15 @@ std::shared_ptr CWallet::CreateWalletFromFile(const std::string& name, walletInstance->m_min_fee = CFeeRate(n); } + if (gArgs.IsArgSet("-autoconvertthreshold")) { + CAmount n = 0; + if (!ParseMoney(gArgs.GetArg("-autoconvertthreshold", ""), n) || 0 == n) { + InitError(AmountErrMsg("autoconvertthreshold", gArgs.GetArg("-autoconvertthreshold", ""))); + return nullptr; + } + walletInstance->m_autoconvert_threshold = n; + } + walletInstance->m_allow_fallback_fee = Params().IsFallbackFeeEnabled(); if (gArgs.IsArgSet("-fallbackfee")) { CAmount nFeePerK = 0; diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h index ab96111006..291c43d523 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -68,6 +68,12 @@ static const bool DEFAULT_SPEND_ZEROCONF_CHANGE = true; static const bool DEFAULT_WALLET_REJECT_LONG_CHAINS = false; //! Default for -avoidpartialspends static const bool DEFAULT_AVOIDPARTIALSPENDS = false; +//! Default for -autoconvert +static const bool DEFAULT_AUTOCONVERT = false; +//! -autoconvertthreshold default +static const CAmount DEFAULT_AUTOCONVERT_THRESHOLD = 10 * COIN; +//! How often the -autoconvert scheduler task runs (milliseconds) +static const int64_t AUTOCONVERT_TIMER_INTERVAL_MS = 60 * 1000; //! -txconfirmtarget default static const unsigned int DEFAULT_TX_CONFIRM_TARGET = 6; //! -walletrbf default @@ -698,6 +704,12 @@ class CWallet final : public CCryptoKeyStore, public CValidationInterface bool fStakingEnabled = true; bool fHasStakableInputs = false; + //! -autoconvert state, only touched from the scheduler thread + enum class AutoConvertResult { NOTHING_TO_CONVERT, CONVERTED, FAILED }; + AutoConvertResult AutoConvertBatch(bool fFromBasecoin); + int64_t m_autoconvert_not_before = 0; + int m_autoconvert_failures = 0; + WalletBatch *encrypted_batch = nullptr; //! the current wallet version: clients below this version are not able to load the wallet @@ -1075,6 +1087,9 @@ class CWallet final : public CCryptoKeyStore, public CValidationInterface void ResendWalletTransactions(int64_t nBestBlockTime, CConnman* connman) override; // ResendWalletTransactionsBefore may only be called if fBroadcastTransactions! std::vector ResendWalletTransactionsBefore(int64_t nTime, CConnman* connman); + /** Convert a batch of confirmed basecoin or CT outputs to RingCT. Runs from + * a scheduler task when -autoconvert is enabled. */ + void AutoConvertToRingCT(); CAmount GetBasecoinBalance(const isminefilter& filter=ISMINE_SPENDABLE, const int min_depth=0) const; CAmount GetBalance(const isminefilter& filter=ISMINE_SPENDABLE, const int min_depth=0) const; bool GetBalances(BalanceList& bal); @@ -1137,6 +1152,7 @@ class CWallet final : public CCryptoKeyStore, public CValidationInterface bool m_signal_rbf{DEFAULT_WALLET_RBF}; bool m_allow_fallback_fee{true}; // Date: Wed, 29 Jul 2026 16:12:37 -0700 Subject: [PATCH 2/2] test: cover the -autoconvert failure path The one part of the feature mainnet happy-path testing cannot reach: a conversion rejected by the mempool must unwind completely. autoconvert_mempool_reject_unwinds drives the public AutoConvertToRingCT() through its gates with mock time. A conflicting spend of one of the wallet's own coinbases, signed with the raw key and placed in the mempool from outside the wallet, guarantees the next conversion attempt builds successfully (record saved, inputs marked pending-spend) and then fails mempool test-acceptance. Asserts the unwind leaves no transaction record, no wallet transaction, and the balance and spendable-coin set bit-identical. The backoff probe doubles as proof the failure really executed: a failed attempt backs off exactly 600s while a NOTHING_TO_CONVERT pass reschedules within at most 540s, so probing at +570s with the conflict cleared distinguishes a genuine failure (still blocked) from an attempt that silently never ran (would convert and fail the mempool-empty check). Past the backoff the conversion must then go through for real: a batch spending all mature coinbases, CT outputs, record and wallet transaction keyed by the same txid. unwind_restores_pending_spend locks the do/undo symmetry directly: MarkInputsAsPendingSpend + SaveRecord (what AddStandardInputs and AddBlindedInputs do at build time) followed by UnwindPendingTransaction restores ORF_PENDING_SPEND and erases the record. The fixture wires a wallet the way CreateWalletFromFile does (HD seed, zerocoin wallet, AnonWallet on a mock db, stealth/blinding contexts) and creates an in-memory pzerocoinDB, which ConnectBlock needs once the regtest chain passes height 20 (accumulator checksums every 10 blocks). X16RT blocks after the PowUpdateTimestamp always retarget to the pow limit, so extending the fixture chain is safe from the regtest DGW overflow. --- src/Makefile.test.include | 1 + src/wallet/test/autoconvert_tests.cpp | 305 ++++++++++++++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 src/wallet/test/autoconvert_tests.cpp diff --git a/src/Makefile.test.include b/src/Makefile.test.include index 9be9f82a56..d77e3e655b 100644 --- a/src/Makefile.test.include +++ b/src/Makefile.test.include @@ -111,6 +111,7 @@ BITCOIN_TESTS =\ if ENABLE_WALLET BITCOIN_TESTS += \ test/mnemonic_cleanse_tests.cpp \ + wallet/test/autoconvert_tests.cpp \ wallet/test/psbt_wallet_tests.cpp \ wallet/test/wallet_tests.cpp \ wallet/test/wallet_crypto_tests.cpp \ diff --git a/src/wallet/test/autoconvert_tests.cpp b/src/wallet/test/autoconvert_tests.cpp new file mode 100644 index 0000000000..0b0e4d2539 --- /dev/null +++ b/src/wallet/test/autoconvert_tests.cpp @@ -0,0 +1,305 @@ +// Copyright (c) 2026 The Veil developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +// Tests for the -autoconvert failure handling (wallet.cpp AutoConvertToRingCT): +// a conversion transaction that is rejected by the mempool must leave no +// trace in the wallet — no transaction record, no inputs stuck in the +// pending-spend state, no balance change — and the backoff must delay the +// next attempt. This is the one part of the feature that mainnet happy-path +// testing cannot exercise. + +#include + +#include +#include +#include