From 31a82b4824b43ba824eee84343a8122045c5a6e9 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 7 Sep 2026 12:02:22 -0500 Subject: [PATCH 1/3] fix: require full wallet unlock for PSBT signing --- src/wallet/wallet.cpp | 5 +++-- test/functional/wallet_encryption.py | 27 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index cbe4878932a4..d4f6e97af74d 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -2323,6 +2323,7 @@ TransactionError CWallet::FillPSBT(PartiallySignedTransaction& psbtx, bool& comp } const PrecomputedTransactionData txdata = PrecomputePSBTData(psbtx); LOCK(cs_wallet); + const bool can_sign = sign && !IsLocked(); // Get all of the previous transactions for (unsigned int i = 0; i < psbtx.tx->vin.size(); ++i) { const CTxIn& txin = psbtx.tx->vin[i]; @@ -2348,12 +2349,12 @@ TransactionError CWallet::FillPSBT(PartiallySignedTransaction& psbtx, bool& comp // Fill in information from ScriptPubKeyMans for (ScriptPubKeyMan* spk_man : GetAllScriptPubKeyMans()) { int n_signed_this_spkm = 0; - TransactionError res = spk_man->FillPSBT(psbtx, txdata, sighash_type, sign, bip32derivs, &n_signed_this_spkm, finalize); + TransactionError res = spk_man->FillPSBT(psbtx, txdata, sighash_type, can_sign, bip32derivs, &n_signed_this_spkm, finalize); if (res != TransactionError::OK) { return res; } - if (n_signed) { + if (n_signed && (!sign || can_sign)) { (*n_signed) += n_signed_this_spkm; } } diff --git a/test/functional/wallet_encryption.py b/test/functional/wallet_encryption.py index 77cbab26672e..1a9f9930ecc9 100755 --- a/test/functional/wallet_encryption.py +++ b/test/functional/wallet_encryption.py @@ -31,6 +31,7 @@ def run_test(self): # Make sure the wallet isn't encrypted first msg = "test message" address = self.nodes[0].getnewaddress() + self.generatetoaddress(self.nodes[0], 101, address) sig = self.nodes[0].signmessage(address, msg) assert self.nodes[0].verifymessage(address, sig, msg) assert_raises_rpc_error(-15, "Error: running with an unencrypted wallet, but walletpassphrase was called", self.nodes[0].walletpassphrase, 'ff', 1) @@ -100,8 +101,34 @@ def run_test(self): self.nodes[0].walletpassphrase(passphrase_with_nulls, 999000) sig = self.nodes[0].signmessage(address, msg) assert self.nodes[0].verifymessage(address, sig, msg) + self.nodes[0].keypoolrefill(10) self.nodes[0].walletlock() + self.log.info("Locked and mixing-only wallets must return unsigned send/sendall PSBTs") + node = self.nodes[0] + destination = node.getnewaddress() + for mixing_only in (False, True): + if mixing_only: + node.walletpassphrase(passphrase_with_nulls, 999000, True) + for options in ({}, {"psbt": True}, {"add_to_wallet": False}): + for send, recipients in ((node.send, {destination: 1}), (node.sendall, [destination])): + result = send(recipients, options=options) + assert_equal(result["complete"], False) + assert "txid" not in result + assert "hex" not in result + for txin in node.decodepsbt(result["psbt"])["inputs"]: + assert "partial_signatures" not in txin + assert "final_scriptSig" not in txin + assert_equal(node.getrawmempool(), []) + node.walletlock() + + node.walletpassphrase(passphrase_with_nulls, 999000) + for send, recipients in ((node.send, {destination: 1}), (node.sendall, [destination])): + result = send(recipients, options={"add_to_wallet": False}) + assert_equal(result["complete"], True) + assert "hex" in result + node.walletlock() + if __name__ == '__main__': WalletEncryptionTest().main() From b2cfd7a78e62ce46aa20d9cec4a41890508da8b7 Mon Sep 17 00:00:00 2001 From: Konstantin Akimov Date: Tue, 8 Sep 2026 22:19:08 +0700 Subject: [PATCH 2/3] refactor: separate sign / no-sign scenarios more error-prune for case of locked wallet and psbt --- src/wallet/wallet.cpp | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index d4f6e97af74d..127d3f308c27 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -2323,7 +2323,12 @@ TransactionError CWallet::FillPSBT(PartiallySignedTransaction& psbtx, bool& comp } const PrecomputedTransactionData txdata = PrecomputePSBTData(psbtx); LOCK(cs_wallet); - const bool can_sign = sign && !IsLocked(); + if (sign && IsLocked()) { + // A mixing-only unlock still exposes private keys to the + // ScriptPubKeyMans, so refuse to sign here and report zero signed inputs. + sign = false; + n_signed = nullptr; + } // Get all of the previous transactions for (unsigned int i = 0; i < psbtx.tx->vin.size(); ++i) { const CTxIn& txin = psbtx.tx->vin[i]; @@ -2349,12 +2354,12 @@ TransactionError CWallet::FillPSBT(PartiallySignedTransaction& psbtx, bool& comp // Fill in information from ScriptPubKeyMans for (ScriptPubKeyMan* spk_man : GetAllScriptPubKeyMans()) { int n_signed_this_spkm = 0; - TransactionError res = spk_man->FillPSBT(psbtx, txdata, sighash_type, can_sign, bip32derivs, &n_signed_this_spkm, finalize); + TransactionError res = spk_man->FillPSBT(psbtx, txdata, sighash_type, sign, bip32derivs, &n_signed_this_spkm, finalize); if (res != TransactionError::OK) { return res; } - if (n_signed && (!sign || can_sign)) { + if (n_signed) { (*n_signed) += n_signed_this_spkm; } } From c58379d26c50baf75e5c0aca0c4d0b3440b3aeb8 Mon Sep 17 00:00:00 2001 From: Konstantin Akimov Date: Tue, 8 Sep 2026 22:19:32 +0700 Subject: [PATCH 3/3] test: move testing functionality of psbt for locked wallets to rpc_psbt.py where it has proper surrounding --- test/functional/rpc_psbt.py | 4 ++++ test/functional/wallet_encryption.py | 27 --------------------------- 2 files changed, 4 insertions(+), 27 deletions(-) diff --git a/test/functional/rpc_psbt.py b/test/functional/rpc_psbt.py index 7cd93f499aab..bc2c3bb719bf 100755 --- a/test/functional/rpc_psbt.py +++ b/test/functional/rpc_psbt.py @@ -77,6 +77,10 @@ def run_test(self): unsigned_tx = self.nodes[0].walletprocesspsbt(psbtx, False) assert_equal(unsigned_tx['complete'], False) + # Unlocking for mixing only must not allow signing either + self.nodes[0].walletpassphrase("password", 1000000, True) + assert_equal(self.nodes[0].send({self.nodes[2].getnewaddress(): 10})['complete'], False) + self.nodes[0].walletpassphrase(passphrase="password", timeout=1000000) # Sign the transaction but don't finalize diff --git a/test/functional/wallet_encryption.py b/test/functional/wallet_encryption.py index 1a9f9930ecc9..77cbab26672e 100755 --- a/test/functional/wallet_encryption.py +++ b/test/functional/wallet_encryption.py @@ -31,7 +31,6 @@ def run_test(self): # Make sure the wallet isn't encrypted first msg = "test message" address = self.nodes[0].getnewaddress() - self.generatetoaddress(self.nodes[0], 101, address) sig = self.nodes[0].signmessage(address, msg) assert self.nodes[0].verifymessage(address, sig, msg) assert_raises_rpc_error(-15, "Error: running with an unencrypted wallet, but walletpassphrase was called", self.nodes[0].walletpassphrase, 'ff', 1) @@ -101,34 +100,8 @@ def run_test(self): self.nodes[0].walletpassphrase(passphrase_with_nulls, 999000) sig = self.nodes[0].signmessage(address, msg) assert self.nodes[0].verifymessage(address, sig, msg) - self.nodes[0].keypoolrefill(10) self.nodes[0].walletlock() - self.log.info("Locked and mixing-only wallets must return unsigned send/sendall PSBTs") - node = self.nodes[0] - destination = node.getnewaddress() - for mixing_only in (False, True): - if mixing_only: - node.walletpassphrase(passphrase_with_nulls, 999000, True) - for options in ({}, {"psbt": True}, {"add_to_wallet": False}): - for send, recipients in ((node.send, {destination: 1}), (node.sendall, [destination])): - result = send(recipients, options=options) - assert_equal(result["complete"], False) - assert "txid" not in result - assert "hex" not in result - for txin in node.decodepsbt(result["psbt"])["inputs"]: - assert "partial_signatures" not in txin - assert "final_scriptSig" not in txin - assert_equal(node.getrawmempool(), []) - node.walletlock() - - node.walletpassphrase(passphrase_with_nulls, 999000) - for send, recipients in ((node.send, {destination: 1}), (node.sendall, [destination])): - result = send(recipients, options={"add_to_wallet": False}) - assert_equal(result["complete"], True) - assert "hex" in result - node.walletlock() - if __name__ == '__main__': WalletEncryptionTest().main()