walletprocesspsbt bip32derivs=false makes no nonce for tr(musig(A,B)/<0;1>/*) #36413

issue fametrano opened this issue on October 2, 2026
  1. fametrano commented at 1:14 PM on October 2, 2026: contributor

    Current behaviour

    With bip32derivs=false, walletprocesspsbt makes no MuSig2 nonce for a wallet whose descriptor derives after the aggregate, such as tr(musig(A,B)/<0;1>/*). It returns no error. Without nonces there are no partial signatures: after two rounds decodepsbt shows no musig2_pubnonces and no musig2_partial_sigs, and finalizepsbt returns "complete": false. With the default bip32derivs=true the same steps sign and the transaction is accepted.

    The signer needs the derivation path from the aggregate key to the Taproot internal key. bip32derivs=false hides key origins from the signer, so SignMuSig2 skips the aggregate at if (agg_info.path.empty()) continue;.

    Two cases sign fine with false: tr(musig(A/<0;1>/*,B/<0;1>/*)), where the aggregate is itself the internal key, and a PSBT that already carries the derivations, for example from walletcreatefundedpsbt or from another participant's bip32derivs=true call.

    v31.1.0 behaves the same. #35370 adds a "strip" mode with the same problem: #35370#pullrequestreview-5391507690.

    Expected behaviour

    Either the PSBT signs and finalizes as with bip32derivs=true, or walletprocesspsbt returns an error.

    Steps to reproduce

    Regtest, master a829aeaac1.

    1. Create two blank descriptor wallets. Import tr(musig(A,B)/<0;1>/*) into both as an active descriptor, each with its own key as xprv and the other as xpub.
    2. Send to getnewaddress "" bech32m (the same address in both wallets) and confirm.
    3. createpsbt spending that output.
    4. In each wallet: bitcoin-cli -named walletprocesspsbt psbt=<psbt> bip32derivs=false.
    5. combinepsbt the two results and repeat step 4 on the combined PSBT.
    6. combinepsbt the two new results, then decodepsbt and finalizepsbt.

    How did you obtain Bitcoin Core

    Compiled from source

    What version of Bitcoin Core are you using?

    master@a829aeaac1

    Operating system and version

    macOS 27.0.1

    Made with my usual tools: a computer, the Internet and an LLM. The mistakes, as usual, are all mine.

  2. maflcko added the label Wallet on Oct 2, 2026
  3. maflcko added the label RPC/REST/ZMQ on Oct 2, 2026
  4. maflcko added the label PSBT on Oct 2, 2026
  5. avicdr commented at 11:34 PM on October 6, 2026: none

    Hi! I'd like to investigate and work on this issue if it's still available. I've gone through the reproduction and the distinction between tr(musig(A,B)/<0;1>/) and tr(musig(A/<0;1>/,B/<0;1>/*)) looks particularly interesting. I'd like to trace how bip32derivs=false affects the MuSig2 signing path, understand whether the derivation information can be obtained internally without exposing it in the PSBT, and then work on a fix and regression test. Is anyone currently working on this? If not, would it be okay for me to take it?

  6. fametrano commented at 8:50 PM on October 7, 2026: contributor

    I didn't start a fix because it needs a choice first: sign anyway, with the wallet using the key origins that bip32derivs=false keeps out of the PSBT, or return an error. @achow101, which would you prefer? @avicdr, fine by me if you take it. Which approach do you have in mind?

  7. avicdr commented at 2:28 PM on October 9, 2026: none

    My initial preference would be to sign using the wallet's internal key-origin and derivation information without exposing those derivations in the PSBT.

    As I understand it, bip32derivs=false controls whether BIP32 derivation information is included in the PSBT, but it shouldn't necessarily prevent the wallet from using that information internally when signing. In this case, SignMuSig2 skips the aggregate because agg_info.path is empty, even though the wallet has the information needed to derive the aggregate's path to the Taproot internal key.

    I'll investigate whether we can resolve the required path from the wallet's internal metadata while keeping the PSBT unchanged with respect to derivation fields. I'll also add a regression test covering tr(musig(A,B)/<0;1>/*) with bip32derivs=false, alongside the existing cases where the aggregate key itself is the internal key or the PSBT already contains derivations.

    If the required information cannot be resolved reliably without the PSBT derivations, returning an explicit error would be preferable to silently producing an incomplete signing result.

  8. avicdr commented at 9:51 PM on October 21, 2026: none

    @fametrano i'm still waiting for your response

  9. fametrano commented at 7:29 AM on October 22, 2026: contributor

    @avicdr, sorry for the slow reply: yes, signing with the wallet's own key origins, with an explicit error where it has none, sounds right to me.

    One thing for your test: the PSBT will then carry partial signatures but no Taproot derivations, so finalizepsbt cannot aggregate them. It reads the path from the aggregate key to the internal key only from those derivations. On master de96f3ae6d I signed a PSBT with bip32derivs=true and removed the input's Taproot derivations: finalizepsbt returned "complete": false, and walletprocesspsbt with the default bip32derivs=true in one participant's wallet returned "complete": true. So the test's last step has to be a walletprocesspsbt in a wallet with the descriptor, not the issue's finalizepsbt. I can share the script.

  10. avicdr commented at 1:02 PM on October 22, 2026: none

    @fametrano Thanks for the clarification! That makes sense. I’ll adjust the regression test to verify signing and completion through walletprocesspsbt in a wallet with the descriptor, rather than relying on finalizepsbt with the stripped PSBT. If you have the reproduction script handy, I’d appreciate it. It would help me align the test with the existing workflow.

  11. fametrano commented at 8:18 PM on October 22, 2026: contributor

    @avicdr, here is the script. On master de96f3ae6d it shows that bip32derivs=false makes no nonces, and that a PSBT signed and then stripped of its Taproot derivations completes through walletprocesspsbt with bip32derivs=true, but not through finalizepsbt. I wrote it with help from an LLM.

    <details><summary>repro36413.py</summary>

    #!/usr/bin/env python3
    """Reproduce bitcoin/bitcoin#36413: MuSig2 signing of tr(musig(A,B)/<0;1>/*).
    
    Usage: copy into <build>/test/functional/ and run
        python3 <build>/test/functional/repro36413.py
    Set BITCOIND=/path/to/bitcoind to test another binary.
    """
    
    import base64
    import re
    
    from test_framework.descriptors import descsum_create
    from test_framework.test_framework import BitcoinTestFramework
    from test_framework.util import assert_equal
    
    PRIVKEY_RE = re.compile(r"^tr\((.+?)/.+\)#.{8}$")
    PUBKEY_RE = re.compile(r"^tr\((\[.+?\].+?)/.+\)#.{8}$")
    ORIGIN_PATH_RE = re.compile(r"^\[\w{8}(/.*)\].*$")
    PSBT_IN_TAP_BIP32_DERIVATION = 0x16
    
    
    def read_compact(b, i):
        n = b[i]
        if n < 0xfd:
            return n, i + 1
        if n == 0xfd:
            return int.from_bytes(b[i + 1:i + 3], "little"), i + 3
        if n == 0xfe:
            return int.from_bytes(b[i + 1:i + 5], "little"), i + 5
        return int.from_bytes(b[i + 1:i + 9], "little"), i + 9
    
    
    def strip_input_tap_derivations(psbt_b64):
        """Remove every PSBT_IN_TAP_BIP32_DERIVATION from the first input."""
        b = base64.b64decode(psbt_b64)
        assert b[:5] == b"psbt\xff"
        out = bytearray(b[:5])
        i = 5
        map_index = 0
        while i < len(b):
            start = i
            klen, i = read_compact(b, i)
            if klen == 0:
                out += b[start:i]
                map_index += 1
                continue
            key = b[i:i + klen]
            i += klen
            vlen, i = read_compact(b, i)
            i += vlen
            if map_index == 1 and key[0] == PSBT_IN_TAP_BIP32_DERIVATION:
                continue
            out += b[start:i]
        return base64.b64encode(bytes(out)).decode()
    
    
    class Repro36413(BitcoinTestFramework):
        def set_test_params(self):
            self.num_nodes = 1
    
        def skip_test_if_missing_module(self):
            self.skip_if_no_wallet()
    
        def run_test(self):
            node = self.nodes[0]
            node.createwallet("funder")
            funder = node.get_wallet_rpc("funder")
            self.generatetoaddress(node, 101, funder.getnewaddress())
    
            # Wallets a and b: each imports tr(musig(A,B)/<0;1>/*), its own key as xprv.
            keys = []
            wallets = []
            for name in ("a", "b"):
                node.createwallet(name)
                w = node.get_wallet_rpc(name)
                wallets.append(w)
                priv = next(PRIVKEY_RE.search(d["desc"]).group(1) for d in w.listdescriptors(True)["descriptors"] if d["desc"].startswith("tr("))
                pub = next(PUBKEY_RE.search(d["desc"]).group(1) for d in w.listdescriptors()["descriptors"] if d["desc"].startswith("tr("))
                priv += ORIGIN_PATH_RE.search(pub).group(1)
                keys.append((priv, pub))
            for i, w in enumerate(wallets):
                parts = [keys[j][0] if j == i else keys[j][1] for j in range(2)]
                desc = descsum_create(f"tr(musig({parts[0]},{parts[1]})/<0;1>/*)")
                assert all(r["success"] for r in w.importdescriptors([{"desc": desc, "active": True, "timestamp": "now"}]))
            addr = wallets[0].getnewaddress(address_type="bech32m")
            assert_equal(addr, wallets[1].getnewaddress(address_type="bech32m"))
    
            def fresh_psbt():
                txid = funder.sendtoaddress(addr, 1)
                self.generate(node, 1)
                vout = next(u["vout"] for u in wallets[0].listunspent() if u["txid"] == txid)
                return node.createpsbt([{"txid": txid, "vout": vout}], [{funder.getnewaddress(): 0.999}])
    
            def two_rounds(psbt, bip32derivs):
                for _ in range(2):
                    psbt = node.combinepsbt([w.walletprocesspsbt(psbt=psbt, bip32derivs=bip32derivs)["psbt"] for w in wallets])
                return psbt
    
            def report(label, psbt):
                inp = node.decodepsbt(psbt)["inputs"][0]
                complete = node.finalizepsbt(psbt, False)["complete"]
                self.log.info(f"{label}: tap_bip32_derivs={len(inp.get('taproot_bip32_derivs', []))} "
                              f"pubnonces={len(inp.get('musig2_pubnonces', []))} "
                              f"partial_sigs={len(inp.get('musig2_partial_sigs', []))} "
                              f"finalizepsbt complete={complete}")
                return complete
    
            signed = two_rounds(fresh_psbt(), True)
            assert report("bip32derivs=true, two rounds", signed)
    
            report("bip32derivs=false, two rounds", two_rounds(fresh_psbt(), False))
    
            stripped = strip_input_tap_derivations(signed)
            report("bip32derivs=true, then input derivations removed", stripped)
            for bip32derivs in (False, True):
                complete = wallets[0].walletprocesspsbt(psbt=stripped, bip32derivs=bip32derivs)["complete"]
                self.log.info(f"removed derivations, walletprocesspsbt bip32derivs={bip32derivs}: complete={complete}")
    
    
    if __name__ == "__main__":
        Repro36413(__file__).main()
    

    </details>

  12. avicdr commented at 6:13 PM on October 24, 2026: none

    Thanks for sharing the reproduction script! I'd be happy to take ownership of this issue and work on the fix. I'll focus on using the wallet's internal key-origin and derivation information when bip32derivs=false, without exposing derivation fields in the PSBT, and returning an explicit error when the required information is unavailable. I'll also add regression tests covering the affected descriptor and existing working cases. Please assign the issue to me so I can proceed.


github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin/bitcoin. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2026-10-30 09:40 UTC