diff options
-rw-r--r-- | doc/release-notes-23065.md | 15 | ||||
-rw-r--r-- | src/interfaces/wallet.h | 4 | ||||
-rw-r--r-- | src/qt/coincontroldialog.cpp | 2 | ||||
-rw-r--r-- | src/rpc/client.cpp | 1 | ||||
-rw-r--r-- | src/wallet/interfaces.cpp | 10 | ||||
-rw-r--r-- | src/wallet/rpcwallet.cpp | 29 | ||||
-rw-r--r-- | src/wallet/wallet.cpp | 37 | ||||
-rw-r--r-- | src/wallet/wallet.h | 10 | ||||
-rw-r--r-- | src/wallet/walletdb.cpp | 17 | ||||
-rw-r--r-- | src/wallet/walletdb.h | 4 | ||||
-rwxr-xr-x | test/functional/feature_asmap.py | 6 | ||||
-rwxr-xr-x | test/functional/rpc_blockchain.py | 34 | ||||
-rwxr-xr-x | test/functional/wallet_basic.py | 36 |
13 files changed, 168 insertions, 37 deletions
diff --git a/doc/release-notes-23065.md b/doc/release-notes-23065.md new file mode 100644 index 0000000000..6ec002b2df --- /dev/null +++ b/doc/release-notes-23065.md @@ -0,0 +1,15 @@ +Notable changes +=============== + +Updated RPCs +------------ + +- `lockunspent` now optionally takes a third parameter, `persistent`, which +causes the lock to be written persistently to the wallet database. This +allows UTXOs to remain locked even after node restarts or crashes. + +GUI changes +----------- + +- UTXOs which are locked via the GUI are now stored persistently in the +wallet database, so are not lost on node shutdown or crash.
\ No newline at end of file diff --git a/src/interfaces/wallet.h b/src/interfaces/wallet.h index a85db04b8b..6766e0510f 100644 --- a/src/interfaces/wallet.h +++ b/src/interfaces/wallet.h @@ -122,10 +122,10 @@ public: virtual bool displayAddress(const CTxDestination& dest) = 0; //! Lock coin. - virtual void lockCoin(const COutPoint& output) = 0; + virtual bool lockCoin(const COutPoint& output, const bool write_to_db) = 0; //! Unlock coin. - virtual void unlockCoin(const COutPoint& output) = 0; + virtual bool unlockCoin(const COutPoint& output) = 0; //! Return whether coin is locked. virtual bool isLockedCoin(const COutPoint& output) = 0; diff --git a/src/qt/coincontroldialog.cpp b/src/qt/coincontroldialog.cpp index d2a9365890..86dbd05b1a 100644 --- a/src/qt/coincontroldialog.cpp +++ b/src/qt/coincontroldialog.cpp @@ -241,7 +241,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); + model->wallet().lockCoin(outpt, /* write_to_db = */ true); contextMenuItem->setDisabled(true); contextMenuItem->setIcon(COLUMN_CHECKBOX, platformStyle->SingleColorIcon(":/icons/lock_closed")); updateLabelLocked(); diff --git a/src/rpc/client.cpp b/src/rpc/client.cpp index d6943e066a..93e49cb9a8 100644 --- a/src/rpc/client.cpp +++ b/src/rpc/client.cpp @@ -131,6 +131,7 @@ static const CRPCConvertParam vRPCConvertParams[] = { "gettxoutsetinfo", 2, "use_index"}, { "lockunspent", 0, "unlock" }, { "lockunspent", 1, "transactions" }, + { "lockunspent", 2, "persistent" }, { "send", 0, "outputs" }, { "send", 1, "conf_target" }, { "send", 3, "fee_rate"}, diff --git a/src/wallet/interfaces.cpp b/src/wallet/interfaces.cpp index 9a8c1e3c02..d9fc6de79b 100644 --- a/src/wallet/interfaces.cpp +++ b/src/wallet/interfaces.cpp @@ -214,15 +214,17 @@ public: LOCK(m_wallet->cs_wallet); return m_wallet->DisplayAddress(dest); } - void lockCoin(const COutPoint& output) override + bool lockCoin(const COutPoint& output, const bool write_to_db) override { LOCK(m_wallet->cs_wallet); - return m_wallet->LockCoin(output); + std::unique_ptr<WalletBatch> batch = write_to_db ? std::make_unique<WalletBatch>(m_wallet->GetDatabase()) : nullptr; + return m_wallet->LockCoin(output, batch.get()); } - void unlockCoin(const COutPoint& output) override + bool unlockCoin(const COutPoint& output) override { LOCK(m_wallet->cs_wallet); - return m_wallet->UnlockCoin(output); + std::unique_ptr<WalletBatch> batch = std::make_unique<WalletBatch>(m_wallet->GetDatabase()); + return m_wallet->UnlockCoin(output, batch.get()); } bool isLockedCoin(const COutPoint& output) override { diff --git a/src/wallet/rpcwallet.cpp b/src/wallet/rpcwallet.cpp index 77757e79a3..c101f6560d 100644 --- a/src/wallet/rpcwallet.cpp +++ b/src/wallet/rpcwallet.cpp @@ -2140,8 +2140,9 @@ static RPCHelpMan lockunspent() "If no transaction outputs are specified when unlocking then all current locked transaction outputs are unlocked.\n" "A locked transaction output will not be chosen by automatic coin selection, when spending bitcoins.\n" "Manually selected coins are automatically unlocked.\n" - "Locks are stored in memory only. Nodes start with zero locked outputs, and the locked output list\n" - "is always cleared (by virtue of process exit) when a node stops or fails.\n" + "Locks are stored in memory only, unless persistent=true, in which case they will be written to the\n" + "wallet database and loaded on node start. Unwritten (persistent=false) locks are always cleared\n" + "(by virtue of process exit) when a node stops or fails. Unlocking will clear both persistent and not.\n" "Also see the listunspent call\n", { {"unlock", RPCArg::Type::BOOL, RPCArg::Optional::NO, "Whether to unlock (true) or lock (false) the specified transactions"}, @@ -2155,6 +2156,7 @@ static RPCHelpMan lockunspent() }, }, }, + {"persistent", RPCArg::Type::BOOL, RPCArg::Default{false}, "Whether to write/erase this lock in the wallet database, or keep the change in memory only. Ignored for unlocking."}, }, RPCResult{ RPCResult::Type::BOOL, "", "Whether the command was successful or not" @@ -2168,6 +2170,8 @@ static RPCHelpMan lockunspent() + HelpExampleCli("listlockunspent", "") + "\nUnlock the transaction again\n" + HelpExampleCli("lockunspent", "true \"[{\\\"txid\\\":\\\"a08e6907dbbd3d809776dbfc5d82e371b764ed838b5655e72f463568df1aadf0\\\",\\\"vout\\\":1}]\"") + + "\nLock the transaction persistently in the wallet database\n" + + HelpExampleCli("lockunspent", "false \"[{\\\"txid\\\":\\\"a08e6907dbbd3d809776dbfc5d82e371b764ed838b5655e72f463568df1aadf0\\\",\\\"vout\\\":1}]\" true") + "\nAs a JSON-RPC call\n" + HelpExampleRpc("lockunspent", "false, \"[{\\\"txid\\\":\\\"a08e6907dbbd3d809776dbfc5d82e371b764ed838b5655e72f463568df1aadf0\\\",\\\"vout\\\":1}]\"") }, @@ -2186,9 +2190,13 @@ static RPCHelpMan lockunspent() bool fUnlock = request.params[0].get_bool(); + const bool persistent{request.params[2].isNull() ? false : request.params[2].get_bool()}; + if (request.params[1].isNull()) { - if (fUnlock) - pwallet->UnlockAllCoins(); + if (fUnlock) { + if (!pwallet->UnlockAllCoins()) + throw JSONRPCError(RPC_WALLET_ERROR, "Unlocking coins failed"); + } return true; } @@ -2239,17 +2247,24 @@ static RPCHelpMan lockunspent() throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, expected locked output"); } - if (!fUnlock && is_locked) { + if (!fUnlock && is_locked && !persistent) { throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, output already locked"); } outputs.push_back(outpt); } + std::unique_ptr<WalletBatch> batch = nullptr; + // Unlock is always persistent + if (fUnlock || persistent) batch = std::make_unique<WalletBatch>(pwallet->GetDatabase()); + // Atomically set (un)locked status for the outputs. for (const COutPoint& outpt : outputs) { - if (fUnlock) pwallet->UnlockCoin(outpt); - else pwallet->LockCoin(outpt); + if (fUnlock) { + if (!pwallet->UnlockCoin(outpt, batch.get())) throw JSONRPCError(RPC_WALLET_ERROR, "Unlocking coin failed"); + } else { + if (!pwallet->LockCoin(outpt, batch.get())) throw JSONRPCError(RPC_WALLET_ERROR, "Locking coin failed"); + } } return true; diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index 70349b2455..a9610d05a4 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -589,11 +589,16 @@ bool CWallet::IsSpent(const uint256& hash, unsigned int n) const return false; } -void CWallet::AddToSpends(const COutPoint& outpoint, const uint256& wtxid) +void CWallet::AddToSpends(const COutPoint& outpoint, const uint256& wtxid, WalletBatch* batch) { mapTxSpends.insert(std::make_pair(outpoint, wtxid)); - setLockedCoins.erase(outpoint); + if (batch) { + UnlockCoin(outpoint, batch); + } else { + WalletBatch temp_batch(GetDatabase()); + UnlockCoin(outpoint, &temp_batch); + } std::pair<TxSpends::iterator, TxSpends::iterator> range; range = mapTxSpends.equal_range(outpoint); @@ -601,7 +606,7 @@ void CWallet::AddToSpends(const COutPoint& outpoint, const uint256& wtxid) } -void CWallet::AddToSpends(const uint256& wtxid) +void CWallet::AddToSpends(const uint256& wtxid, WalletBatch* batch) { auto it = mapWallet.find(wtxid); assert(it != mapWallet.end()); @@ -610,7 +615,7 @@ void CWallet::AddToSpends(const uint256& wtxid) return; for (const CTxIn& txin : thisTx.tx->vin) - AddToSpends(txin.prevout, wtxid); + AddToSpends(txin.prevout, wtxid, batch); } bool CWallet::EncryptWallet(const SecureString& strWalletPassphrase) @@ -910,7 +915,7 @@ CWalletTx* CWallet::AddToWallet(CTransactionRef tx, const CWalletTx::Confirmatio wtx.nOrderPos = IncOrderPosNext(&batch); wtx.m_it_wtxOrdered = wtxOrdered.insert(std::make_pair(wtx.nOrderPos, &wtx)); wtx.nTimeSmart = ComputeTimeSmart(wtx); - AddToSpends(hash); + AddToSpends(hash, &batch); } if (!fInsertedNew) @@ -2260,22 +2265,36 @@ bool CWallet::DisplayAddress(const CTxDestination& dest) return signer_spk_man->DisplayAddress(scriptPubKey, signer); } -void CWallet::LockCoin(const COutPoint& output) +bool CWallet::LockCoin(const COutPoint& output, WalletBatch* batch) { AssertLockHeld(cs_wallet); setLockedCoins.insert(output); + if (batch) { + return batch->WriteLockedUTXO(output); + } + return true; } -void CWallet::UnlockCoin(const COutPoint& output) +bool CWallet::UnlockCoin(const COutPoint& output, WalletBatch* batch) { AssertLockHeld(cs_wallet); - setLockedCoins.erase(output); + bool was_locked = setLockedCoins.erase(output); + if (batch && was_locked) { + return batch->EraseLockedUTXO(output); + } + return true; } -void CWallet::UnlockAllCoins() +bool CWallet::UnlockAllCoins() { AssertLockHeld(cs_wallet); + bool success = true; + WalletBatch batch(GetDatabase()); + for (auto it = setLockedCoins.begin(); it != setLockedCoins.end(); ++it) { + success &= batch.EraseLockedUTXO(*it); + } setLockedCoins.clear(); + return success; } bool CWallet::IsLockedCoin(uint256 hash, unsigned int n) const diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h index 2dc9eff712..6b4bcf31c4 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -256,8 +256,8 @@ private: */ typedef std::multimap<COutPoint, uint256> TxSpends; TxSpends mapTxSpends GUARDED_BY(cs_wallet); - void AddToSpends(const COutPoint& outpoint, const uint256& wtxid) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); - void AddToSpends(const uint256& wtxid) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); + void AddToSpends(const COutPoint& outpoint, const uint256& wtxid, WalletBatch* batch = nullptr) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); + void AddToSpends(const uint256& wtxid, WalletBatch* batch = nullptr) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); /** * Add a transaction to the wallet, or update it. pIndex and posInBlock should @@ -449,9 +449,9 @@ public: bool DisplayAddress(const CTxDestination& dest) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); bool IsLockedCoin(uint256 hash, unsigned int n) const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); - void LockCoin(const COutPoint& output) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); - void UnlockCoin(const COutPoint& output) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); - void UnlockAllCoins() EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); + bool LockCoin(const COutPoint& output, WalletBatch* batch = nullptr) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); + bool UnlockCoin(const COutPoint& output, WalletBatch* batch = nullptr) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); + bool UnlockAllCoins() EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); void ListLockedCoins(std::vector<COutPoint>& vOutpts) const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); /* diff --git a/src/wallet/walletdb.cpp b/src/wallet/walletdb.cpp index 03464cd2c8..c697534c06 100644 --- a/src/wallet/walletdb.cpp +++ b/src/wallet/walletdb.cpp @@ -40,6 +40,7 @@ const std::string FLAGS{"flags"}; const std::string HDCHAIN{"hdchain"}; const std::string KEYMETA{"keymeta"}; const std::string KEY{"key"}; +const std::string LOCKED_UTXO{"lockedutxo"}; const std::string MASTER_KEY{"mkey"}; const std::string MINVERSION{"minversion"}; const std::string NAME{"name"}; @@ -284,6 +285,16 @@ bool WalletBatch::WriteDescriptorCacheItems(const uint256& desc_id, const Descri return true; } +bool WalletBatch::WriteLockedUTXO(const COutPoint& output) +{ + return WriteIC(std::make_pair(DBKeys::LOCKED_UTXO, std::make_pair(output.hash, output.n)), uint8_t{'1'}); +} + +bool WalletBatch::EraseLockedUTXO(const COutPoint& output) +{ + return EraseIC(std::make_pair(DBKeys::LOCKED_UTXO, std::make_pair(output.hash, output.n))); +} + class CWalletScanState { public: unsigned int nKeys{0}; @@ -701,6 +712,12 @@ ReadKeyValue(CWallet* pwallet, CDataStream& ssKey, CDataStream& ssValue, wss.m_descriptor_crypt_keys.insert(std::make_pair(std::make_pair(desc_id, pubkey.GetID()), std::make_pair(pubkey, privkey))); wss.fIsEncrypted = true; + } else if (strType == DBKeys::LOCKED_UTXO) { + uint256 hash; + uint32_t n; + ssKey >> hash; + ssKey >> n; + pwallet->LockCoin(COutPoint(hash, n)); } else if (strType != DBKeys::BESTBLOCK && strType != DBKeys::BESTBLOCK_NOMERKLE && strType != DBKeys::MINVERSION && strType != DBKeys::ACENTRY && strType != DBKeys::VERSION && strType != DBKeys::SETTINGS && diff --git a/src/wallet/walletdb.h b/src/wallet/walletdb.h index 25c2ec5909..a549c8039c 100644 --- a/src/wallet/walletdb.h +++ b/src/wallet/walletdb.h @@ -65,6 +65,7 @@ extern const std::string FLAGS; extern const std::string HDCHAIN; extern const std::string KEY; extern const std::string KEYMETA; +extern const std::string LOCKED_UTXO; extern const std::string MASTER_KEY; extern const std::string MINVERSION; extern const std::string NAME; @@ -250,6 +251,9 @@ public: bool WriteDescriptorLastHardenedCache(const CExtPubKey& xpub, const uint256& desc_id, uint32_t key_exp_index); bool WriteDescriptorCacheItems(const uint256& desc_id, const DescriptorCache& cache); + bool WriteLockedUTXO(const COutPoint& output); + bool EraseLockedUTXO(const COutPoint& output); + /// Write destination data key,value tuple to database bool WriteDestData(const std::string &address, const std::string &key, const std::string &value); /// Erase destination data tuple from wallet database diff --git a/test/functional/feature_asmap.py b/test/functional/feature_asmap.py index 2dc1e3a7cb..debd87962f 100755 --- a/test/functional/feature_asmap.py +++ b/test/functional/feature_asmap.py @@ -42,8 +42,8 @@ class AsmapTest(BitcoinTestFramework): self.extra_args = [["-checkaddrman=1"]] # Do addrman checks on all operations. def fill_addrman(self, node_id): - """Add 2 tried addresses to the addrman, followed by 2 new addresses.""" - for addr, tried in [[0, True], [1, True], [2, False], [3, False]]: + """Add 1 tried address to the addrman, followed by 1 new address.""" + for addr, tried in [[0, True], [1, False]]: self.nodes[node_id].addpeeraddress(address=f"101.{addr}.0.0", tried=tried, port=8333) def test_without_asmap_arg(self): @@ -89,7 +89,7 @@ class AsmapTest(BitcoinTestFramework): self.restart_node(0, ["-asmap", "-checkaddrman=1"]) with self.node.assert_debug_log( expected_msgs=[ - "Addrman checks started: new 2, tried 2, total 4", + "Addrman checks started: new 1, tried 1, total 2", "Addrman checks completed successfully", ] ): diff --git a/test/functional/rpc_blockchain.py b/test/functional/rpc_blockchain.py index b8b5c5a519..c3c6ade684 100755 --- a/test/functional/rpc_blockchain.py +++ b/test/functional/rpc_blockchain.py @@ -127,7 +127,29 @@ class BlockchainTest(BitcoinTestFramework): # should have exact keys assert_equal(sorted(res.keys()), keys) - self.restart_node(0, ['-stopatheight=207', '-prune=550']) + self.stop_node(0) + self.nodes[0].assert_start_raises_init_error( + extra_args=['-testactivationheight=name@2'], + expected_msg='Error: Invalid name (name@2) for -testactivationheight=name@height.', + ) + self.nodes[0].assert_start_raises_init_error( + extra_args=['-testactivationheight=bip34@-2'], + expected_msg='Error: Invalid height value (bip34@-2) for -testactivationheight=name@height.', + ) + self.nodes[0].assert_start_raises_init_error( + extra_args=['-testactivationheight='], + expected_msg='Error: Invalid format () for -testactivationheight=name@height.', + ) + self.start_node(0, extra_args=[ + '-stopatheight=207', + '-prune=550', + '-testactivationheight=bip34@2', + '-testactivationheight=dersig@3', + '-testactivationheight=cltv@4', + '-testactivationheight=csv@5', + '-testactivationheight=segwit@6', + ]) + res = self.nodes[0].getblockchaininfo() # result should have these additional pruning keys if prune=550 assert_equal(sorted(res.keys()), sorted(['pruneheight', 'automatic_pruning', 'prune_target_size'] + keys)) @@ -140,11 +162,11 @@ class BlockchainTest(BitcoinTestFramework): assert_greater_than(res['size_on_disk'], 0) assert_equal(res['softforks'], { - 'bip34': {'type': 'buried', 'active': True, 'height': 1}, - 'bip66': {'type': 'buried', 'active': True, 'height': 1}, - 'bip65': {'type': 'buried', 'active': True, 'height': 1}, - 'csv': {'type': 'buried', 'active': True, 'height': 1}, - 'segwit': {'type': 'buried', 'active': True, 'height': 1}, + 'bip34': {'type': 'buried', 'active': True, 'height': 2}, + 'bip66': {'type': 'buried', 'active': True, 'height': 3}, + 'bip65': {'type': 'buried', 'active': True, 'height': 4}, + 'csv': {'type': 'buried', 'active': True, 'height': 5}, + 'segwit': {'type': 'buried', 'active': True, 'height': 6}, 'testdummy': { 'type': 'bip9', 'bip9': { diff --git a/test/functional/wallet_basic.py b/test/functional/wallet_basic.py index 6372e1acd7..5c464ae142 100755 --- a/test/functional/wallet_basic.py +++ b/test/functional/wallet_basic.py @@ -121,13 +121,49 @@ class WalletTest(BitcoinTestFramework): # Exercise locking of unspent outputs unspent_0 = self.nodes[2].listunspent()[0] unspent_0 = {"txid": unspent_0["txid"], "vout": unspent_0["vout"]} + # Trying to unlock an output which isn't locked should error assert_raises_rpc_error(-8, "Invalid parameter, expected locked output", self.nodes[2].lockunspent, True, [unspent_0]) + + # Locking an already-locked output should error self.nodes[2].lockunspent(False, [unspent_0]) assert_raises_rpc_error(-8, "Invalid parameter, output already locked", self.nodes[2].lockunspent, False, [unspent_0]) + + # Restarting the node should clear the lock + self.restart_node(2) + self.nodes[2].lockunspent(False, [unspent_0]) + + # Unloading and reloating the wallet should clear the lock + assert_equal(self.nodes[0].listwallets(), [self.default_wallet_name]) + self.nodes[2].unloadwallet(self.default_wallet_name) + self.nodes[2].loadwallet(self.default_wallet_name) + assert_equal(len(self.nodes[2].listlockunspent()), 0) + + # Locking non-persistently, then re-locking persistently, is allowed + self.nodes[2].lockunspent(False, [unspent_0]) + self.nodes[2].lockunspent(False, [unspent_0], True) + + # Restarting the node with the lock written to the wallet should keep the lock + self.restart_node(2) + assert_raises_rpc_error(-8, "Invalid parameter, output already locked", self.nodes[2].lockunspent, False, [unspent_0]) + + # Unloading and reloading the wallet with a persistent lock should keep the lock + self.nodes[2].unloadwallet(self.default_wallet_name) + self.nodes[2].loadwallet(self.default_wallet_name) + assert_raises_rpc_error(-8, "Invalid parameter, output already locked", self.nodes[2].lockunspent, False, [unspent_0]) + + # Locked outputs should not be used, even if they are the only available funds assert_raises_rpc_error(-6, "Insufficient funds", self.nodes[2].sendtoaddress, self.nodes[2].getnewaddress(), 20) assert_equal([unspent_0], self.nodes[2].listlockunspent()) + + # Unlocking should remove the persistent lock self.nodes[2].lockunspent(True, [unspent_0]) + self.restart_node(2) assert_equal(len(self.nodes[2].listlockunspent()), 0) + + # Reconnect node 2 after restarts + self.connect_nodes(1, 2) + self.connect_nodes(0, 2) + assert_raises_rpc_error(-8, "txid must be of length 64 (not 34, for '0000000000000000000000000000000000')", self.nodes[2].lockunspent, False, [{"txid": "0000000000000000000000000000000000", "vout": 0}]) |