From 7f9b15d581596eba8faa3171b0e8e27f73181346 Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Mon, 24 Aug 2026 16:21:33 +0300 Subject: [PATCH 1/2] feat(wallet): let a deliberate unlock opt out of the automatic coin locks AutoLockMasternodeCollaterals() and LockExistingDustOutputs() run on every wallet load and lock every masternode collateral and dust-protection target they find. They recompute lock policy, so they cannot tell an outpoint that was never unlocked from one the user unlocked on purpose with `lockunspent`, which is the documented way to spend a protected output. Restarting the node therefore silently took the decision back and the output became unspendable again with no indication why. Give the wallet a way to record that decision instead of inferring it. A deliberate unlock adds the outpoint to m_autolock_optout, persisted as DBKeys::AUTOLOCK_OPTOUT because the automatic locks outlive a restart; locking it again clears the record and hands the outpoint back to them. The two chokepoints that apply those locks, LockProTxCoins() and IsDustProtectionTarget(), skip outpoints carrying the record, which covers every path that reapplies them. Only genuinely user-driven paths record intent: `lockunspent`, and the coin control and transaction view in the GUI, which call the new by-user variants. interfaces::Wallet::lockCoin()/unlockCoin() keep the meaning they have upstream, so the transient holds CollateralLockGuard and the registration wizard take are unchanged, and a caller backported later cannot acquire intent semantics by accident. test/lint/lint-coin-lock-callers.py pins that split: a new caller of the raw primitives has to be a deliberate choice. The record also carries whether the outpoint already was a collateral when it was unlocked. One that was not, and is later registered, has the decision dropped and is locked again: it was made about an ordinary coin and does not carry over to live collateral. ReclaimRegisteredCollaterals() rechecks those records as each block arrives, so this holds however the registration reached us, including a ProRegTx broadcast outside the wallet's own flow. The record is written only alongside the lock change it belongs to: a lock the caller keeps in memory only leaves the decision standing, an unlock always persists both, and a failed opt-out write is rolled back in memory, so the record the process holds matches the one a reload would find. Records whose output the wallet no longer knows about are dropped after a clean load. Co-Authored-By: Claude Opus 5 --- src/interfaces/wallet.h | 8 + src/qt/coincontroldialog.cpp | 4 +- src/qt/transactionview.cpp | 2 +- src/test/evo_deterministicmns_tests.cpp | 45 ---- src/test/util/masternode.cpp | 49 +++++ src/test/util/masternode.h | 10 + src/wallet/interfaces.cpp | 16 +- src/wallet/rpc/coins.cpp | 4 +- src/wallet/test/availablecoins_tests.cpp | 245 +++++++++++++++++++++- src/wallet/test/coinjoin_tests.cpp | 4 +- src/wallet/test/wallet_tests.cpp | 145 ++++++++++++- src/wallet/test/walletload_tests.cpp | 31 +++ src/wallet/wallet.cpp | 148 ++++++++++++- src/wallet/wallet.h | 24 +++ src/wallet/walletdb.cpp | 24 +++ src/wallet/walletdb.h | 4 + test/functional/wallet_dust_protection.py | 72 +++++++ test/lint/lint-coin-lock-callers.py | 78 +++++++ 18 files changed, 849 insertions(+), 64 deletions(-) create mode 100755 test/lint/lint-coin-lock-callers.py diff --git a/src/interfaces/wallet.h b/src/interfaces/wallet.h index dee4fedcf332..6eed5c854bec 100644 --- a/src/interfaces/wallet.h +++ b/src/interfaces/wallet.h @@ -224,6 +224,14 @@ class Wallet //! Unlock the provided coins in a single batch. virtual bool unlockCoins(const std::vector& outputs) = 0; + //! Lock a coin because the user asked for it, handing it back to the automatic + //! masternode-collateral and dust locks. Use lockCoin() for anything else. + virtual bool lockCoinByUser(const COutPoint& output, bool write_to_db) = 0; + + //! Unlock a coin because the user asked for it, opting it out of those automatic + //! locks. Use unlockCoin() to release an internal or transient hold. + virtual bool unlockCoinByUser(const COutPoint& output) = 0; + //! Set dust protection threshold (does not lock anything by itself). virtual void setDustProtectionThreshold(CAmount threshold) = 0; diff --git a/src/qt/coincontroldialog.cpp b/src/qt/coincontroldialog.cpp index 487693a498a3..40f0de651408 100644 --- a/src/qt/coincontroldialog.cpp +++ b/src/qt/coincontroldialog.cpp @@ -312,7 +312,7 @@ void CoinControlDialog::lockCoin() contextMenuItem->setCheckState(COLUMN_CHECKBOX, Qt::Unchecked); COutPoint outpt(uint256S(contextMenuItem->data(COLUMN_ADDRESS, TxHashRole).toString().toStdString()), contextMenuItem->data(COLUMN_ADDRESS, VOutRole).toUInt()); - model->wallet().lockCoin(outpt, /*write_to_db=*/true); + model->wallet().lockCoinByUser(outpt, /*write_to_db=*/true); contextMenuItem->setDisabled(true); contextMenuItem->setIcon(COLUMN_CHECKBOX, GUIUtil::getIcon("lock_closed", GUIUtil::ThemedColor::RED)); updateLabelLocked(); @@ -322,7 +322,7 @@ void CoinControlDialog::lockCoin() void CoinControlDialog::unlockCoin() { COutPoint outpt(uint256S(contextMenuItem->data(COLUMN_ADDRESS, TxHashRole).toString().toStdString()), contextMenuItem->data(COLUMN_ADDRESS, VOutRole).toUInt()); - model->wallet().unlockCoin(outpt); + model->wallet().unlockCoinByUser(outpt); contextMenuItem->setDisabled(false); contextMenuItem->setIcon(COLUMN_CHECKBOX, QIcon()); updateLabelLocked(); diff --git a/src/qt/transactionview.cpp b/src/qt/transactionview.cpp index a9421e3793c7..b5d3381a7ef5 100644 --- a/src/qt/transactionview.cpp +++ b/src/qt/transactionview.cpp @@ -491,7 +491,7 @@ void TransactionView::unlockDust() // Create the outpoint and unlock COutPoint outpoint(hash, outputIdx); - model->wallet().unlockCoin(outpoint); + model->wallet().unlockCoinByUser(outpoint); // Refresh the transaction view to update the display model->getTransactionTableModel()->refreshWallet(true); diff --git a/src/test/evo_deterministicmns_tests.cpp b/src/test/evo_deterministicmns_tests.cpp index e76b07e6059c..44b292f4567b 100644 --- a/src/test/evo_deterministicmns_tests.cpp +++ b/src/test/evo_deterministicmns_tests.cpp @@ -40,51 +40,6 @@ #include #include -static CMutableTransaction CreateSpendTx(const ChainstateManager& chainman, SimpleUTXOMap& utxos, const CScript& scriptPayout, CAmount amount, const CKey& coinbaseKey) -{ - CMutableTransaction tx; - const auto spent = FundTransaction(chainman, tx, utxos, scriptPayout, amount); - SignTransaction(tx, spent, coinbaseKey); - return tx; -} - -static COutPoint GetCollateralOutpoint(const CMutableTransaction& tx) -{ - for (size_t i = 0; i < tx.vout.size(); ++i) { - if (tx.vout[i].nValue == dmn_types::Regular.collat_amount) { - return COutPoint(tx.GetHash(), i); - } - } - return COutPoint(); -} - -// ProRegTx that references a pre-existing collateral output instead of funding the collateral inline. -static CMutableTransaction CreateProRegTxExternalCollateral(const ChainstateManager& chainman, SimpleUTXOMap& utxos, int port, const COutPoint& collateralOutpoint, const CScript& scriptPayout, const CKey& ownerKey, const CBLSSecretKey& operatorKey, const CKey& collateralKey, const CKey& coinbaseKey) -{ - CProRegTx proTx; - proTx.nVersion = ProTxVersion::GetMax(!bls::bls_legacy_scheme, /*is_extended_addr=*/false); - proTx.netInfo = NetInfoInterface::MakeNetInfo(proTx.nVersion); - BOOST_CHECK_EQUAL(proTx.netInfo->AddEntry(NetInfoPurpose::CORE_P2P, strprintf("1.1.1.1:%d", port)), - NetInfoStatus::Success); - proTx.collateralOutpoint = collateralOutpoint; - proTx.keyIDOwner = ownerKey.GetPubKey().GetID(); - proTx.pubKeyOperator.Set(operatorKey.GetPublicKey(), bls::bls_legacy_scheme.load()); - proTx.keyIDVoting = ownerKey.GetPubKey().GetID(); - proTx.scriptPayout = scriptPayout; - - CMutableTransaction tx; - tx.nVersion = 3; - tx.nType = TRANSACTION_PROVIDER_REGISTER; - // The collateral is external (referenced via collateralOutpoint), so this tx only needs to fund a fee. - const auto spent = FundTransaction(chainman, tx, utxos, scriptPayout, /*amount=*/1 * COIN); - proTx.inputsHash = CalcTxInputsHash(CTransaction(tx)); - CMessageSigner::SignMessage(proTx.MakeSignString(), proTx.vchSig, collateralKey); - SetTxPayload(tx, proTx); - SignTransaction(tx, spent, coinbaseKey); - - return tx; -} - static CMutableTransaction CreateProUpServTx(const ChainstateManager& chainman, SimpleUTXOMap& utxos, const uint256& proTxHash, const CBLSSecretKey& operatorKey, int port, const CScript& scriptOperatorPayout, const CKey& coinbaseKey, uint16_t version = ProTxVersion::GetMax(!bls::bls_legacy_scheme, /*is_extended_addr=*/false)) { diff --git a/src/test/util/masternode.cpp b/src/test/util/masternode.cpp index 0551591a2f94..277049747a0f 100644 --- a/src/test/util/masternode.cpp +++ b/src/test/util/masternode.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include