Merge bitcoin/bitcoin#35984: sign: skip signing SIGHASH_SINGLE inputs with no corresponding output
What changed, and why it matters
This Bitcoin Core update fixes a wallet-signing quirk. When a user chose the SIGHASH_SINGLE signature mode, an input that had no matching output index would sign essentially nothing meaningful. That signature could then stay valid even if someone later changed where the money goes, creating a risk of funds being redirected without the original owner's consent. The fix makes the signer refuse to create such signatures in the first place, closing the gap for both normal transaction signing and PSBT signing.
Treat as a security-hardening fix and include in release notes. Users relying on SIGHASH_SINGLE with fewer outputs than inputs should be aware signing will now fail for unmatched inputs; if a legitimate segwit-v0 use case exists, consider the explicit opt-in mechanism the author mentioned rather than reverting the default behavior.
Security signals we found
Funds-redirection footgun from SIGHASH_SINGLE signatures with no committed output
Inconsistent guard between SignTransaction and SignPSBTInput paths
Fix centralizes the guard in the low-level signature creator to cover future signing paths
Functional test added verifying walletprocesspsbt refuses to finalize the unmatched input
Evidence from the diff
The patch moves the SIGHASH_SINGLE ‘no corresponding output’ guard from SignTransaction into MutableTransactionSignatureCreator::CreateSig. Previously SignTransaction skipped signing SIGHASH_SINGLE inputs whose index exceeded vout size, but SignPSBTInput (used by walletprocesspsbt) did not, so PSBTs could still produce detached signatures that commit to no output. By placing the check in CreateSig, all signing paths now refuse to produce these signatures. The change affects legacy (fixed sighash 1) and segwit v0 (zeroed hashOutputs) behavior equally; the author notes they would rather re-allow the segwit v0 case only via explicit opt-in.
Changed components
src/script/sign.cpp (MutableTransactionSignatureCreator::CreateSig, SignTransaction)walletprocesspsbt RPC path via SignPSBTInputtest/functional/rpc_psbt.pyInspect captured patch +37 / −8
### src/script/sign.cpp
@@ -69,6 +69,11 @@ bool MutableTransactionSignatureCreator::CreateSig(const SigningProvider& provid
// BASE/WITNESS_V0 signatures don't support explicit SIGHASH_DEFAULT, use SIGHASH_ALL instead.
const int hashtype = m_options.sighash_type == SIGHASH_DEFAULT ? SIGHASH_ALL : m_options.sighash_type;
+ // If an input is signed with SIGHASH_SINGLE but there is no output at the same index, the
+ // signature commits to no output at all. Which means such a signature stays valid if the
+ // output is swapped, which is a footgun. So don't produce it.
+ if ((hashtype & SIGHASH_OUTPUT_MASK) == SIGHASH_SINGLE && nIn >= m_txto.vout.size()) return false;
+
uint256 hash = SignatureHash(scriptCode, m_txto, nIn, hashtype, amount, sigversion, m_txdata);
if (!key.Sign(hash, vchSig))
return false;
@@ -1024,8 +1029,6 @@ bool IsSegWitOutput(const SigningProvider& provider, const CScript& script)
bool SignTransaction(CMutableTransaction& mtx, const SigningProvider* keystore, const std::map<COutPoint, Coin>& coins, const SignOptions& options, std::map<int, bilingual_str>& input_errors)
{
- bool fHashSingle = ((options.sighash_type & ~SIGHASH_ANYONECANPAY) == SIGHASH_SINGLE);
-
// Use CTransaction for the constant parts of the
// transaction to avoid rehashing.
const CTransaction txConst(mtx);
@@ -1058,10 +1061,7 @@ bool SignTransaction(CMutableTransaction& mtx, const SigningProvider* keystore,
const CAmount& amount = coin->second.out.nValue;
SignatureData sigdata = DataFromTransaction(mtx, i, coin->second.out);
- // Only sign SIGHASH_SINGLE if there's a corresponding output:
- if (!fHashSingle || (i < mtx.vout.size())) {
- ProduceSignature(*keystore, MutableTransactionSignatureCreator(mtx, i, amount, &txdata, options), prevPubKey, sigdata);
- }
+ ProduceSignature(*keystore, MutableTransactionSignatureCreator(mtx, i, amount, &txdata, options), prevPubKey, sigdata);
UpdateInput(txin, sigdata);
### test/functional/rpc_psbt.py
@@ -721,6 +721,32 @@ def test_combinepsbt_sighash_type(self):
wallet.unloadwallet()
+ def test_sighash_single(self):
+ self.log.info("Test that SIGHASH_SINGLE won't sign an input with no matching output")
+ node = self.nodes[0]
+ node.createwallet("sighash_single")
+ wallet = node.get_wallet_rpc("sighash_single")
+ def_wallet = node.get_wallet_rpc(self.default_wallet_name)
+
+ for addr_type in ["legacy", "bech32", "bech32m"]:
+ addrs = [wallet.getnewaddress("", addr_type) for _ in range(2)]
+ for addr in addrs:
+ def_wallet.sendtoaddress(addr, 1)
+ self.generatetoaddress(node, 1, def_wallet.getnewaddress())
+ node.syncwithvalidationinterfacequeue()
+ ins = [{"txid": u["txid"], "vout": u["vout"]} for u in wallet.listunspent(addresses=addrs)]
+ assert_equal(len(ins), 2)
+
+ raw = node.createrawtransaction(ins, [{wallet.getnewaddress(): 1.9999}])
+ signed = wallet.walletprocesspsbt(node.converttopsbt(raw), True, "SINGLE")["psbt"]
+ state = wallet.analyzepsbt(signed)["inputs"]
+ # Output at index 0 exist, so input 0 signs and finalizes
+ assert state[0]["is_final"]
+ # No output at index 1, so SIGHASH_SINGLE won't sign input 1
+ assert not state[1]["is_final"]
+
+ wallet.unloadwallet()
+
def assert_change_type(self, psbtx, expected_type):
"""Assert that the given PSBT has a change output with the given type."""
@@ -768,7 +794,8 @@ def test_psbt_named_parameter_handling(self):
def test_psbt_roundtrip(self):
self.log.info("Test that PSBTs roundtrip when RPC does nothing")
- utxo = self.nodes[0].listunspent()[0]
+ # Pick mature coinbase so unspent ordering do not affect the test behavior
+ utxo = next(out for out in self.nodes[0].listunspent() if out["amount"] == Decimal(50))
for ver in [0, 2]:
psbt = self.nodes[0].walletcreatefundedpsbt(inputs=[utxo], outputs=[{self.nodes[0].getnewaddress(): utxo["amount"] / 2}], psbt_version=ver)["psbt"]
@@ -784,7 +811,8 @@ def test_psbt_roundtrip(self):
def test_psbt_version(self):
tobump = self.nodes[0].sendtoaddress(self.nodes[0].getnewaddress(), 1)
- utxo = self.nodes[0].listunspent()[0]
+ # Pick mature coinbase so unspent ordering do not affect the test behavior
+ utxo = next(out for out in self.nodes[0].listunspent() if out["amount"] == Decimal(50))
outputs = [{self.nodes[0].getnewaddress(): utxo["amount"] / 2}]
rawtx = self.nodes[0].createrawtransaction(inputs=[utxo], outputs=outputs)
for ver in [0, 2]:
@@ -1785,6 +1813,7 @@ def global_xpub_key(extended_pubkey):
if not self.options.usecli:
self.test_sighash_mismatch()
self.test_sighash_adding()
+ self.test_sighash_single()
self.test_decodepsbt_long_sighash_type()
self.test_combinepsbt_sighash_type()
self.test_psbt_named_parameter_handling()Why this scored 64/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.