|
| 1 | +// Copyright (c) 2026 The btcsuite developers |
| 2 | +// Use of this source code is governed by an ISC |
| 3 | +// license that can be found in the LICENSE file. |
| 4 | + |
| 5 | +package psbt |
| 6 | + |
| 7 | +import ( |
| 8 | + "bytes" |
| 9 | + "crypto/hmac" |
| 10 | + "crypto/sha512" |
| 11 | + "encoding/binary" |
| 12 | + "encoding/hex" |
| 13 | + "testing" |
| 14 | + |
| 15 | + "github.com/btcsuite/btcd/btcec/v2" |
| 16 | + "github.com/btcsuite/btcd/btcec/v2/schnorr" |
| 17 | + "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" |
| 18 | + "github.com/btcsuite/btcd/btcutil/v2/hdkeychain" |
| 19 | + "github.com/btcsuite/btcd/chaincfg/v2" |
| 20 | + "github.com/btcsuite/btcd/chainhash/v2" |
| 21 | + "github.com/btcsuite/btcd/txscript/v2" |
| 22 | + "github.com/btcsuite/btcd/wire/v2" |
| 23 | + "github.com/stretchr/testify/require" |
| 24 | +) |
| 25 | + |
| 26 | +// makeTestParticipants returns three deterministic (priv, pub) pairs to use |
| 27 | +// as the MuSig2 signing set. The test only needs a self-consistent 3-party |
| 28 | +// setup; it does not depend on the specific keys matching any external |
| 29 | +// vector. |
| 30 | +func makeTestParticipants(t *testing.T) ( |
| 31 | + []*btcec.PrivateKey, []*btcec.PublicKey) { |
| 32 | + |
| 33 | + t.Helper() |
| 34 | + |
| 35 | + scalars := []string{ |
| 36 | + "f5dd1de7b85c0e8c1ada7c0c95eaa42d2bcb29ee71f5e0e63d8df1eb9e3a0e75", |
| 37 | + "7da2bf6e2e09f9da7e1f60af26b0e94649ada55da00bdc8a7d8b4eaf72fe69bb", |
| 38 | + "0000000000000000000000000000000000000000000000000000000000000003", |
| 39 | + } |
| 40 | + |
| 41 | + privs := make([]*btcec.PrivateKey, len(scalars)) |
| 42 | + pubs := make([]*btcec.PublicKey, len(scalars)) |
| 43 | + for i, hexStr := range scalars { |
| 44 | + raw, err := hex.DecodeString(hexStr) |
| 45 | + require.NoError(t, err) |
| 46 | + |
| 47 | + priv, pub := btcec.PrivKeyFromBytes(raw) |
| 48 | + privs[i] = priv |
| 49 | + pubs[i] = pub |
| 50 | + } |
| 51 | + |
| 52 | + return privs, pubs |
| 53 | +} |
| 54 | + |
| 55 | +// bip32ChildTweak computes the per-step BIP-32 tweak (the IL half of the |
| 56 | +// HMAC-SHA512) for an unhardened child derivation. |
| 57 | +func bip32ChildTweak(t *testing.T, parent *hdkeychain.ExtendedKey, |
| 58 | + idx uint32) [32]byte { |
| 59 | + |
| 60 | + t.Helper() |
| 61 | + |
| 62 | + pub, err := parent.ECPubKey() |
| 63 | + require.NoError(t, err) |
| 64 | + |
| 65 | + var idxBytes [4]byte |
| 66 | + binary.BigEndian.PutUint32(idxBytes[:], idx) |
| 67 | + |
| 68 | + h := hmac.New(sha512.New, parent.ChainCode()) |
| 69 | + h.Write(pub.SerializeCompressed()) |
| 70 | + h.Write(idxBytes[:]) |
| 71 | + ilr := h.Sum(nil) |
| 72 | + |
| 73 | + var tweak [32]byte |
| 74 | + copy(tweak[:], ilr[:32]) |
| 75 | + return tweak |
| 76 | +} |
| 77 | + |
| 78 | +// computeTaprootTweak computes BIP-86 (when merkleRoot is nil) or |
| 79 | +// taproot-with-merkle-root tap tweak for the given x-only key. |
| 80 | +func computeTaprootTweak(t *testing.T, xOnlyKey []byte, |
| 81 | + merkleRoot []byte) [32]byte { |
| 82 | + |
| 83 | + t.Helper() |
| 84 | + |
| 85 | + hashedTweak := chainhash.TaggedHash( |
| 86 | + chainhash.TagTapTweak, xOnlyKey, merkleRoot, |
| 87 | + ) |
| 88 | + var out [32]byte |
| 89 | + copy(out[:], hashedTweak[:]) |
| 90 | + return out |
| 91 | +} |
| 92 | + |
| 93 | +// TestFinalize_MuSig2_BIP32Derived_WithGlobalXpub builds a synthetic PSBT |
| 94 | +// that exercises the BIP-373 case 4 finalize path: the taproot internal |
| 95 | +// key is an unhardened BIP-32 child of the bare MuSig2 aggregate, and the |
| 96 | +// PSBT carries the synthetic aggregate xpub via PSBT_GLOBAL_XPUB. The |
| 97 | +// test generates fresh nonces and partial signatures programmatically, |
| 98 | +// then runs Finalize and verifies the produced witness with the script |
| 99 | +// engine. |
| 100 | +func TestFinalize_MuSig2_BIP32Derived_WithGlobalXpub(t *testing.T) { |
| 101 | + privs, pubs := makeTestParticipants(t) |
| 102 | + |
| 103 | + // Bare aggregate is the KeyAgg of the three participant keys. |
| 104 | + bareAgg, _, _, err := musig2.AggregateKeys(pubs, true) |
| 105 | + require.NoError(t, err) |
| 106 | + |
| 107 | + // Synthetic aggregate xpub: pick a deterministic 32-byte chain code. |
| 108 | + chainCode := bytes.Repeat([]byte{0xa5}, 32) |
| 109 | + parentFP := []byte{0, 0, 0, 0} |
| 110 | + xpubAgg := hdkeychain.NewExtendedKey( |
| 111 | + chaincfg.MainNetParams.HDPublicKeyID[:], |
| 112 | + bareAgg.PreTweakedKey.SerializeCompressed(), |
| 113 | + chainCode, parentFP, 0, 0, false, |
| 114 | + ) |
| 115 | + |
| 116 | + // Derive the internal key at path 1/2. |
| 117 | + derivPath := []uint32{1, 2} |
| 118 | + |
| 119 | + bip32T1 := bip32ChildTweak(t, xpubAgg, derivPath[0]) |
| 120 | + xpubChild1, err := xpubAgg.Derive(derivPath[0]) |
| 121 | + require.NoError(t, err) |
| 122 | + |
| 123 | + bip32T2 := bip32ChildTweak(t, xpubChild1, derivPath[1]) |
| 124 | + xpubChild2, err := xpubChild1.Derive(derivPath[1]) |
| 125 | + require.NoError(t, err) |
| 126 | + |
| 127 | + internalKey, err := xpubChild2.ECPubKey() |
| 128 | + require.NoError(t, err) |
| 129 | + |
| 130 | + // BIP-86: output key = internal_key + tap_tweak * G, where |
| 131 | + // tap_tweak = TaggedHash("TapTweak", x_only(internal_key)). |
| 132 | + internalKeyXOnly := schnorr.SerializePubKey(internalKey) |
| 133 | + tapTweak := computeTaprootTweak(t, internalKeyXOnly, nil) |
| 134 | + |
| 135 | + allTweaks := []musig2.KeyTweakDesc{ |
| 136 | + {Tweak: bip32T1, IsXOnly: false}, |
| 137 | + {Tweak: bip32T2, IsXOnly: false}, |
| 138 | + {Tweak: tapTweak, IsXOnly: true}, |
| 139 | + } |
| 140 | + |
| 141 | + // FinalKey of the full tweak chain = the taproot output key. |
| 142 | + fullAgg, _, _, err := musig2.AggregateKeys( |
| 143 | + pubs, true, musig2.WithKeyTweaks(allTweaks...), |
| 144 | + ) |
| 145 | + require.NoError(t, err) |
| 146 | + outputKey := fullAgg.FinalKey |
| 147 | + |
| 148 | + // Build a P2TR pkScript for the output key and a dummy spending |
| 149 | + // transaction. The transaction sends a single input (referencing an |
| 150 | + // arbitrary outpoint) to a single dummy P2WPKH output. |
| 151 | + pkScript, err := txscript.PayToTaprootScript(outputKey) |
| 152 | + require.NoError(t, err) |
| 153 | + |
| 154 | + const inputAmount = int64(100_000_000) |
| 155 | + prevHash := chainhash.Hash{0xde, 0xad, 0xbe, 0xef} |
| 156 | + |
| 157 | + tx := wire.NewMsgTx(2) |
| 158 | + tx.AddTxIn(&wire.TxIn{ |
| 159 | + PreviousOutPoint: wire.OutPoint{Hash: prevHash, Index: 0}, |
| 160 | + Sequence: 0xfffffffd, |
| 161 | + }) |
| 162 | + dummyOutScript := append( |
| 163 | + []byte{0x00, 0x14}, bytes.Repeat([]byte{1}, 20)..., |
| 164 | + ) |
| 165 | + tx.AddTxOut(wire.NewTxOut(inputAmount-1000, dummyOutScript)) |
| 166 | + |
| 167 | + // Compute the sighash that the participants will sign over. |
| 168 | + prevFetcher := txscript.NewCannedPrevOutputFetcher( |
| 169 | + pkScript, inputAmount, |
| 170 | + ) |
| 171 | + sigHashes := txscript.NewTxSigHashes(tx, prevFetcher) |
| 172 | + sigHash, err := txscript.CalcTaprootSignatureHash( |
| 173 | + sigHashes, txscript.SigHashDefault, tx, 0, prevFetcher, |
| 174 | + ) |
| 175 | + require.NoError(t, err) |
| 176 | + var sigHashMsg [32]byte |
| 177 | + copy(sigHashMsg[:], sigHash) |
| 178 | + |
| 179 | + // Each participant generates a (sec, pub) nonce pair. |
| 180 | + type nonceEntry struct { |
| 181 | + sec [musig2.SecNonceSize]byte |
| 182 | + pub [musig2.PubNonceSize]byte |
| 183 | + } |
| 184 | + nonces := make([]nonceEntry, len(privs)) |
| 185 | + for i, priv := range privs { |
| 186 | + n, err := musig2.GenNonces( |
| 187 | + musig2.WithPublicKey(priv.PubKey()), |
| 188 | + musig2.WithNonceCombinedKeyAux(outputKey), |
| 189 | + ) |
| 190 | + require.NoError(t, err) |
| 191 | + nonces[i] = nonceEntry{sec: n.SecNonce, pub: n.PubNonce} |
| 192 | + } |
| 193 | + |
| 194 | + pubNonces := make([][musig2.PubNonceSize]byte, len(nonces)) |
| 195 | + for i, n := range nonces { |
| 196 | + pubNonces[i] = n.pub |
| 197 | + } |
| 198 | + combinedNonce, err := musig2.AggregateNonces(pubNonces) |
| 199 | + require.NoError(t, err) |
| 200 | + |
| 201 | + // Each participant computes a partial signature using the full tweak |
| 202 | + // chain so the resulting sigs combine under the post-tweak output |
| 203 | + // key. |
| 204 | + partialSigs := make([]*musig2.PartialSignature, len(privs)) |
| 205 | + for i, priv := range privs { |
| 206 | + ps, err := musig2.Sign( |
| 207 | + nonces[i].sec, priv, combinedNonce, pubs, sigHashMsg, |
| 208 | + musig2.WithSortedKeys(), |
| 209 | + musig2.WithTweaks(allTweaks...), |
| 210 | + ) |
| 211 | + require.NoError(t, err) |
| 212 | + partialSigs[i] = ps |
| 213 | + } |
| 214 | + |
| 215 | + // Construct the PSBT. |
| 216 | + p, err := NewFromUnsignedTx(tx) |
| 217 | + require.NoError(t, err) |
| 218 | + |
| 219 | + updater, err := NewUpdater(p) |
| 220 | + require.NoError(t, err) |
| 221 | + |
| 222 | + require.NoError(t, updater.AddInWitnessUtxo( |
| 223 | + &wire.TxOut{Value: inputAmount, PkScript: pkScript}, 0, |
| 224 | + )) |
| 225 | + |
| 226 | + // Add the synthetic aggregate xpub to PSBT_GLOBAL_XPUB. We use a |
| 227 | + // zero master fingerprint and an empty path because the xpub *is* |
| 228 | + // the master in this synthetic setup. |
| 229 | + p.XPubs = append(p.XPubs, XPub{ |
| 230 | + ExtendedKey: EncodeExtendedKey(xpubAgg), |
| 231 | + MasterKeyFingerprint: 0, |
| 232 | + Bip32Path: nil, |
| 233 | + }) |
| 234 | + |
| 235 | + // Internal key + its derivation entry pinning the BIP-32 path. |
| 236 | + p.Inputs[0].TaprootInternalKey = internalKeyXOnly |
| 237 | + p.Inputs[0].TaprootBip32Derivation = append( |
| 238 | + p.Inputs[0].TaprootBip32Derivation, |
| 239 | + &TaprootBip32Derivation{ |
| 240 | + XOnlyPubKey: internalKeyXOnly, |
| 241 | + MasterKeyFingerprint: 0, |
| 242 | + Bip32Path: derivPath, |
| 243 | + }, |
| 244 | + ) |
| 245 | + |
| 246 | + // MuSig2 fields. |
| 247 | + require.NoError(t, updater.AddInMuSig2Participants( |
| 248 | + 0, &MuSig2Participants{ |
| 249 | + AggregateKey: bareAgg.PreTweakedKey, |
| 250 | + Keys: pubs, |
| 251 | + }, |
| 252 | + )) |
| 253 | + |
| 254 | + for i, priv := range privs { |
| 255 | + require.NoError(t, updater.AddInMuSig2PubNonce( |
| 256 | + 0, &MuSig2PubNonce{ |
| 257 | + PubKey: priv.PubKey(), |
| 258 | + AggregateKey: outputKey, |
| 259 | + PubNonce: nonces[i].pub, |
| 260 | + }, |
| 261 | + )) |
| 262 | + require.NoError(t, updater.AddInMuSig2PartialSig( |
| 263 | + 0, &MuSig2PartialSig{ |
| 264 | + PubKey: priv.PubKey(), |
| 265 | + AggregateKey: outputKey, |
| 266 | + PartialSig: *partialSigs[i], |
| 267 | + }, |
| 268 | + )) |
| 269 | + } |
| 270 | + |
| 271 | + // Round-trip through serialize/deserialize to make sure the wire |
| 272 | + // form survives, then finalize and verify. |
| 273 | + var buf bytes.Buffer |
| 274 | + require.NoError(t, p.Serialize(&buf)) |
| 275 | + |
| 276 | + parsed, err := NewFromRawBytes(bytes.NewReader(buf.Bytes()), false) |
| 277 | + require.NoError(t, err) |
| 278 | + |
| 279 | + require.NoError(t, MaybeFinalizeAll(parsed)) |
| 280 | + require.NotNil(t, parsed.Inputs[0].FinalScriptWitness) |
| 281 | + |
| 282 | + verifyFinalized(t, parsed) |
| 283 | +} |
0 commit comments