Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow UTXO locks to be written to wallet DB #23065

Merged
merged 5 commits into from
Sep 26, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions doc/release-notes-23065.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions src/interfaces/wallet.h
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,10 @@ class Wallet
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;
Expand Down
2 changes: 1 addition & 1 deletion src/qt/coincontroldialog.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions src/rpc/client.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
10 changes: 6 additions & 4 deletions src/wallet/interfaces.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -214,15 +214,17 @@ class WalletImpl : public Wallet
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
{
Expand Down
29 changes: 22 additions & 7 deletions src/wallet/rpcwallet.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2137,8 +2137,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"},
Expand All @@ -2152,6 +2153,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"
meshcollider marked this conversation as resolved.
Show resolved Hide resolved
Expand All @@ -2165,6 +2167,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}]\"")
},
Expand All @@ -2183,9 +2187,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;
}

Expand Down Expand Up @@ -2236,17 +2244,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;
Expand Down
37 changes: 28 additions & 9 deletions src/wallet/wallet.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -589,19 +589,24 @@ 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);
SyncMetaData(range);
}


void CWallet::AddToSpends(const uint256& wtxid)
void CWallet::AddToSpends(const uint256& wtxid, WalletBatch* batch)
{
auto it = mapWallet.find(wtxid);
assert(it != mapWallet.end());
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re #23065 (comment)

lock(utxo, persistent=false) but utxo already has persisted lock - should delete from db?

I don't think we should allow re-locking in any case, better to require explicitly unlocking then locking again.

If you want to implement this breaking change then use the return value of insert() to know if the output was already locked and return false.

Otherwise, the lock can be persisted but not in memory.

Copy link
Contributor Author

@meshcollider meshcollider Sep 25, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Otherwise, the lock can be persisted but not in memory.

Sorry, I'm not sure how this could happen. Upon thinking further, I think its fine to upgrade a memory-only lock to a persistent lock (this is useful if fundrawtransaction locked the spends and we want to persistently lock them afterward). But how could we end up with a persistent lock not in memory?

EDIT: I've updated the PR to allow persistently locking even if we already have a lock, to allow upgrading.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But how could we end up with a persistent lock not in memory?

Right, doesn't happen now that unlock clears from memory and db.

Copy link
Member

@promag promag Sep 25, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But does it make sense to allow memory-only lock if it is already persisted?

EDIT: looks like this is not possible since lockunspent RPC checks if unspent is already locked.

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
Expand Down
10 changes: 5 additions & 5 deletions src/wallet/wallet.h
Original file line number Diff line number Diff line change
Expand Up @@ -256,8 +256,8 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati
*/
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
Expand Down Expand Up @@ -449,9 +449,9 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati
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);

/*
Expand Down
17 changes: 17 additions & 0 deletions src/wallet/walletdb.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"};
Expand Down Expand Up @@ -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};
Expand Down Expand Up @@ -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 &&
Expand Down
4 changes: 4 additions & 0 deletions src/wallet/walletdb.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -250,6 +251,9 @@ class WalletBatch
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
Expand Down
36 changes: 36 additions & 0 deletions test/functional/wallet_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,49 @@ def run_test(self):
# 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
meshcollider marked this conversation as resolved.
Show resolved Hide resolved
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}])
Expand Down