From 69d3ad6d44eda2c9d2f7dcd78c9bae1ed4ed15e4 Mon Sep 17 00:00:00 2001 From: Oliver Gugger Date: Thu, 30 Jun 2022 22:17:23 +0200 Subject: [PATCH] poolscript+account+log: add new Taproot scripts --- account/interfaces.go | 2 + account/recovery_test.go | 2 + log.go | 4 + poolscript/log.go | 25 +++ poolscript/script.go | 414 ++++++++++++++++++++++++++++++++++++-- poolscript/script_test.go | 354 ++++++++++++++++++++++++++++++++ 6 files changed, 789 insertions(+), 12 deletions(-) create mode 100644 poolscript/log.go diff --git a/account/interfaces.go b/account/interfaces.go index 0b33858..591a578 100644 --- a/account/interfaces.go +++ b/account/interfaces.go @@ -222,6 +222,7 @@ const ( // Output returns the current on-chain output associated with the account. func (a *Account) Output() (*wire.TxOut, error) { script, err := poolscript.AccountScript( + poolscript.VersionWitnessScript, a.Expiry, a.TraderKey.PubKey, a.AuctioneerKey, a.BatchKey, a.Secret, ) @@ -241,6 +242,7 @@ func (a *Account) Output() (*wire.TxOut, error) { func (a *Account) NextOutputScript() ([]byte, error) { nextBatchKey := poolscript.IncrementKey(a.BatchKey) return poolscript.AccountScript( + poolscript.VersionWitnessScript, a.Expiry, a.TraderKey.PubKey, a.AuctioneerKey, nextBatchKey, a.Secret, ) diff --git a/account/recovery_test.go b/account/recovery_test.go index b434b0b..185f202 100644 --- a/account/recovery_test.go +++ b/account/recovery_test.go @@ -113,6 +113,7 @@ func TestFindInitialAccountState(t *testing.T) { tc.config.InitialBatchKey, ) script, _ := poolscript.AccountScript( + poolscript.VersionWitnessScript, 177, acc.TraderKey.PubKey, tc.config.AuctioneerPubKey, batchKey, acc.Secret, @@ -218,6 +219,7 @@ func TestFindAccountUpdate(t *testing.T) { batchKey := poolscript.IncrementKey(acc.BatchKey) script, _ := poolscript.AccountScript( + poolscript.VersionWitnessScript, tc.expectedExpiry, acc.TraderKey.PubKey, acc.AuctioneerKey, batchKey, acc.Secret, ) diff --git a/log.go b/log.go index dcebed4..4a337e7 100644 --- a/log.go +++ b/log.go @@ -13,6 +13,7 @@ import ( "github.com/lightninglabs/pool/clientdb" "github.com/lightninglabs/pool/funding" "github.com/lightninglabs/pool/order" + "github.com/lightninglabs/pool/poolscript" "github.com/lightningnetwork/lnd" "github.com/lightningnetwork/lnd/build" "github.com/lightningnetwork/lnd/signal" @@ -56,6 +57,9 @@ func SetupLoggers(root *build.RotatingLogWriter, intercept signal.Interceptor) { lnd.AddSubLogger( root, clientdb.Subsystem, intercept, clientdb.UseLogger, ) + lnd.AddSubLogger( + root, poolscript.Subsystem, intercept, poolscript.UseLogger, + ) } // genSubLogger creates a logger for a subsystem. We provide an instance of diff --git a/poolscript/log.go b/poolscript/log.go new file mode 100644 index 0000000..8694f61 --- /dev/null +++ b/poolscript/log.go @@ -0,0 +1,25 @@ +package poolscript + +import ( + "github.com/btcsuite/btclog" + "github.com/lightningnetwork/lnd/build" +) + +const Subsystem = "SCRP" + +// log is a logger that is initialized with no output filters. This +// means the package will not perform any logging by default until the caller +// requests it. +var log btclog.Logger + +// The default amount of logging is none. +func init() { + UseLogger(build.NewSubLogger(Subsystem, nil)) +} + +// UseLogger uses a specified Logger to output package logging info. +// This should be used in preference to SetLogWriter if the caller is also +// using btclog. +func UseLogger(logger btclog.Logger) { + log = logger +} diff --git a/poolscript/script.go b/poolscript/script.go index f48344c..b3b9b00 100644 --- a/poolscript/script.go +++ b/poolscript/script.go @@ -2,21 +2,38 @@ package poolscript import ( "bytes" + "context" "crypto/sha256" + "fmt" "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" secp "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/lightninglabs/lndclient" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/keychain" ) +// Version represents the type of Pool account script that is used for either +// the trader or auctioneer accounts. +type Version uint8 + const ( + // VersionWitnessScript is the legacy script version that used a single + // p2wsh script for both spend paths. + VersionWitnessScript Version = 0 + + // VersionTaprootMuSig2 is the script version that uses a MuSig2 + // combined key of the auctioneer's and trader's public keys as the + // internal key and a single script leaf of the expiry path as the + // taproot script tree merkle root. + VersionTaprootMuSig2 Version = 1 + // AccountKeyFamily is the key family used to derive keys which will be // used in the 2 of 2 multi-sig construction of a CLM account. - // - // TODO(wilmer): decide on actual value. AccountKeyFamily keychain.KeyFamily = 220 // MaxWitnessSigLen is the maximum length of a DER encoded signature and @@ -28,7 +45,7 @@ const ( // 0x30 + <1-byte> + 0x02 + 0x21 + <33 bytes> + 0x2 + 0x21 + <33 bytes>. MaxWitnessSigLen = 72 + 1 - // AccountWitnessScriptSize: 79 bytes + // AccountWitnessScriptSize evaluates to 79 bytes: // - OP_DATA: 1 byte (trader_key length) // - : 33 bytes // - OP_CHECKSIGVERIFY: 1 byte @@ -43,8 +60,8 @@ const ( // - OP_ENDIF: 1 byte AccountWitnessScriptSize = 1 + 33 + 1 + 1 + 33 + 1 + 1 + 1 + 1 + 4 + 1 + 1 - // MultiSigWitnessSize: 227 bytes - // - num_witness_elements: 1 byte + // MultiSigWitnessSize evaluates to 227 bytes: + // - num_witness_elements: 1 byte // - trader_sig_varint_len: 1 byte // - : 73 bytes // - auctioneer_sig_varint_len: 1 byte @@ -54,16 +71,43 @@ const ( MultiSigWitnessSize = 1 + 1 + MaxWitnessSigLen + 1 + MaxWitnessSigLen + 1 + AccountWitnessScriptSize - // ExpiryWitnessSize: 154 bytes - // - num_witness_elements: 1 byte + // ExpiryWitnessSize evaluates to 154 bytes: + // - num_witness_elements: 1 byte // - trader_sig_varint_len: 1 byte (trader_sig length) // - : 73 bytes // - witness_script_varint_len: 1 byte (nil length) // - : 79 bytes ExpiryWitnessSize = 1 + 1 + MaxWitnessSigLen + 1 + AccountWitnessScriptSize + + // TaprootMultiSigWitnessSize evaluates to 66 bytes: + // - num_witness_elements: 1 byte + // - sig_varint_len: 1 byte + // - : 64 bytes + TaprootMultiSigWitnessSize = 1 + 1 + 64 + + // TaprootExpiryScriptSize evaluates to 39 bytes: + // - OP_DATA: 1 byte (trader_key length) + // - : 32 bytes + // - OP_CHECKSIGVERIFY: 1 byte + // - : 4 bytes + // - OP_CHECKLOCKTIMEVERIFY: 1 byte + TaprootExpiryScriptSize = 1 + 32 + 1 + 4 + 1 + + // TaprootExpiryWitnessSize evaluates to 140 bytes: + // - num_witness_elements: 1 byte + // - trader_sig_varint_len: 1 byte (trader_sig length) + // - : 64 bytes + // - witness_script_varint_len: 1 byte (script length) + // - : 39 bytes + // - control_block_varint_len: 1 byte (control block length) + // - : 33 bytes + TaprootExpiryWitnessSize = 1 + 1 + 64 + 1 + TaprootExpiryScriptSize + 1 + 33 ) +// MuSig2Nonces is a type for a MuSig2 nonce pair (2 times 33-byte). +type MuSig2Nonces [musig2.PubNonceSize]byte + // TraderKeyTweak computes the tweak based on the current per-batch key and // shared secret that should be applied to an account's base trader key. The // tweak is computed as the following: @@ -230,20 +274,148 @@ func (r *RecoveryHelper) LocateAnyOutput(expiry uint32, // AccountScript returns the output script of an account on-chain. // +// For version 0 (p2wsh) this returns the hash of the following script: // OP_CHECKSIGVERIFY // OP_CHECKSIG OP_IFDUP OP_NOTIF // OP_CHECKLOCKTIMEVERIFY -// OP_ENDIF. -func AccountScript(expiry uint32, traderKey, auctioneerKey, +// OP_ENDIF +// +// For version 1 (p2tr) this returns the taproot key of a MuSig2 combined key +// of the auctioneer's and trader's public keys as the internal key, tweaked +// with the hash of a single script leaf that has the following script: +// OP_CHECKSIGVERIFY OP_CHECKLOCKTIMEVERIFY. +func AccountScript(version Version, expiry uint32, traderKey, auctioneerKey, batchKey *btcec.PublicKey, secret [32]byte) ([]byte, error) { - witnessScript, err := AccountWitnessScript( - expiry, traderKey, auctioneerKey, batchKey, secret, + switch version { + case VersionWitnessScript: + witnessScript, err := AccountWitnessScript( + expiry, traderKey, auctioneerKey, batchKey, secret, + ) + if err != nil { + return nil, err + } + return input.WitnessScriptHash(witnessScript) + + case VersionTaprootMuSig2: + aggregateKey, _, err := TaprootKey( + expiry, traderKey, auctioneerKey, batchKey, secret, + ) + if err != nil { + return nil, err + } + + return payToWitnessTaprootScript(aggregateKey.FinalKey) + + default: + return nil, fmt.Errorf("invalid script version <%d>", version) + } +} + +// TaprootKey returns the aggregated MuSig2 combined internal key and the +// tweaked Taproot key of an account output, as well as the expiry script tap +// leaf. +func TaprootKey(expiry uint32, traderKey, auctioneerKey, + batchKey *btcec.PublicKey, secret [32]byte) (*musig2.AggregateKey, + *txscript.TapLeaf, error) { + + expiryLeaf, err := TaprootExpiryScript( + expiry, traderKey, batchKey, secret, ) + if err != nil { + return nil, nil, err + } + + rootHash := expiryLeaf.TapHash() + + auctioneerKeySchnorr, err := schnorr.ParsePubKey( + schnorr.SerializePubKey(auctioneerKey), + ) + if err != nil { + return nil, nil, fmt.Errorf("error parsing auctioneer key: %v", + err) + } + + traderKeySchnorr, err := schnorr.ParsePubKey( + schnorr.SerializePubKey(traderKey), + ) + if err != nil { + return nil, nil, fmt.Errorf("error parsing trader key: %v", err) + } + + aggregateKey, err := input.MuSig2CombineKeys( + []*btcec.PublicKey{ + auctioneerKeySchnorr, traderKeySchnorr, + }, + &input.MuSig2Tweaks{ + TaprootTweak: rootHash[:], + }, + ) + if err != nil { + return nil, nil, fmt.Errorf("error combining keys: %v", err) + } + + return aggregateKey, expiryLeaf, nil +} + +// payToWitnessTaprootScript creates a new script to pay to a version 1 +// (taproot) witness program. The passed hash is expected to be valid. +func payToWitnessTaprootScript(taprootKey *btcec.PublicKey) ([]byte, error) { + builder := txscript.NewScriptBuilder() + + builder.AddOp(txscript.OP_1) + builder.AddData(schnorr.SerializePubKey(taprootKey)) + + return builder.Script() +} + +// TaprootExpiryScript returns the leaf script of the expiry script path. +// +// OP_CHECKSIGVERIFY OP_CHECKLOCKTIMEVERIFY. +func TaprootExpiryScript(expiry uint32, traderKey, batchKey *btcec.PublicKey, + secret [32]byte) (*txscript.TapLeaf, error) { + + traderKeyTweak := TraderKeyTweak(batchKey, secret, traderKey) + tweakedTraderKey := input.TweakPubKeyWithTweak( + traderKey, traderKeyTweak, + ) + + builder := txscript.NewScriptBuilder() + + builder.AddData(schnorr.SerializePubKey(tweakedTraderKey)) + builder.AddOp(txscript.OP_CHECKSIGVERIFY) + + builder.AddInt64(int64(expiry)) + builder.AddOp(txscript.OP_CHECKLOCKTIMEVERIFY) + + script, err := builder.Script() if err != nil { return nil, err } - return input.WitnessScriptHash(witnessScript) + + leaf := txscript.NewBaseTapLeaf(script) + return &leaf, nil +} + +// SpendExpiryTaproot returns the witness required to spend an account through +// the expiration script path of a tapscript spend. +func SpendExpiryTaproot(witnessScript, traderSig, + serializedControlBlock []byte) wire.TxWitness { + + witness := make(wire.TxWitness, 3) + witness[0] = traderSig + witness[1] = witnessScript + witness[2] = serializedControlBlock + return witness +} + +// SpendMuSig2Taproot returns the witness required to spend an account through +// the internal key which is a MuSig2 combined key that requires a single +// Schnorr signature. +func SpendMuSig2Taproot(combinedSig []byte) wire.TxWitness { + witness := make(wire.TxWitness, 1) + witness[0] = combinedSig + return witness } // SpendExpiry returns the witness required to spend an account through the @@ -278,6 +450,54 @@ func IsExpirySpend(witness wire.TxWitness) bool { return true } +// IsTaprootExpirySpend determines whether the provided witness corresponds to +// the expiration script path of a Taproot enabled (version 1) account. +func IsTaprootExpirySpend(witness wire.TxWitness) bool { + // We still have 3 elements, the trader's signature, the script and the + // control block. + if len(witness) != 3 { + return false + } + + // An expiry spend is a script spend and therefore always has to have a + // control block. And a valid control block is at _least_ 33 bytes long + // (leaf version of 1 byte and the 32-byte x-only internal key). + ctrlBlock := witness[2] + if len(ctrlBlock) < 33 { + return false + } + + // The expiry is the only variably-sized part in the script, it can be + // between 1 and 4 bytes. So the min script length is 3 bytes smaller + // than the maximum possible length. + const minScriptLen = TaprootExpiryScriptSize - 3 + script := witness[1] + if len(script) < minScriptLen || len(script) > TaprootExpiryScriptSize { + return false + } + + // The control block must start with the leaf version, which will always + // be 0xc0 or 0xc1 in our case (depending on the parity of the key). + const ( + leafVersionEven = byte(txscript.BaseLeafVersion) + leafVersionOdd = byte(txscript.BaseLeafVersion | 1) + ) + if ctrlBlock[0] != leafVersionEven && ctrlBlock[0] != leafVersionOdd { + return false + } + + // The witness script should start with a 32-byte data push opcode and + // end with CLTV. There's not much else we can assert about the script + // since in this context we don't know the trader key or the actual + // expiration. + if script[0] != txscript.OP_DATA_32 || + script[len(script)-1] != txscript.OP_CHECKLOCKTIMEVERIFY { + + return false + } + return true +} + // IsMultiSigSpend determines whether the provided witness corresponds to the // multi-sig script path of an account. func IsMultiSigSpend(witness wire.TxWitness) bool { @@ -290,6 +510,176 @@ func IsMultiSigSpend(witness wire.TxWitness) bool { return true } +// IsTaprootMultiSigSpend determines whether the provided witness corresponds to +// the MuSig2 multi-sig key spend path of a Taproot enabled (version 1) account. +func IsTaprootMultiSigSpend(witness wire.TxWitness) bool { + if len(witness) != 1 { + return false + } + + // We'll always use SigHashDefault, so our signature will always be 64 + // bytes long exactly. + if len(witness[0]) != schnorr.SignatureSize { + return false + } + return true +} + +// TaprootMuSig2SigningSession creates a MuSig2 signing session for a Taproot +// account spend. +func TaprootMuSig2SigningSession(ctx context.Context, expiry uint32, traderKey, + batchKey *btcec.PublicKey, sharedSecret [32]byte, + auctioneerKey *btcec.PublicKey, signer lndclient.SignerClient, + localKeyLocator *keychain.KeyLocator, + remoteNonces *MuSig2Nonces) (*input.MuSig2SessionInfo, func(), error) { + + expiryLeaf, err := TaprootExpiryScript( + expiry, traderKey, batchKey, sharedSecret, + ) + if err != nil { + return nil, nil, fmt.Errorf("error creating expiry leaf "+ + "script: %v", err) + } + + rootHash := expiryLeaf.TapHash() + sessionOpts := []lndclient.MuSig2SessionOpts{ + lndclient.MuSig2TaprootTweakOpt(rootHash[:], false), + } + + var remoteNonceBytes []byte + if remoteNonces != nil { + remoteNonceBytes = remoteNonces[:] + sessionOpts = append(sessionOpts, lndclient.MuSig2NonceOpt( + [][musig2.PubNonceSize]byte{*remoteNonces}, + )) + } + + signers := make([][32]byte, 2) + copy(signers[0][:], schnorr.SerializePubKey(traderKey)) + copy(signers[1][:], schnorr.SerializePubKey(auctioneerKey)) + sessionInfo, err := signer.MuSig2CreateSession( + ctx, localKeyLocator, signers, sessionOpts..., + ) + if err != nil { + return nil, nil, fmt.Errorf("error creating MuSig2 session: %v", + err) + } + + log.Tracef("Created MuSig2 signing session for expiry=%d, "+ + "traderKey=%x, batchKey=%x, sharedSecret=%x, "+ + "auctioneerKey=%x, rootHash=%x, combinedKey=%x, "+ + "localNonces=%x, remoteNonces=%x", + expiry, traderKey.SerializeCompressed(), + batchKey.SerializeCompressed(), sharedSecret[:], + auctioneerKey.SerializeCompressed(), rootHash[:], + sessionInfo.CombinedKey.SerializeCompressed(), + sessionInfo.PublicNonce[:], remoteNonceBytes) + + return sessionInfo, func() { + err = signer.MuSig2Cleanup(ctx, sessionInfo.SessionID) + if err != nil { + log.Errorf("Error cleaning up MuSig2 session %x: %v", + sessionInfo.SessionID[:], err) + } + }, nil +} + +// TaprootMuSig2Sign creates a partial MuSig2 signature for a Taproot account +// spend. If remoteSigs is not empty, we expect to be the second (and last) +// signer and will also attempt to combine the signatures. The return value in +// that case is the full, final signature instead of the partial signature. +func TaprootMuSig2Sign(ctx context.Context, inputIdx int, + sessionInfo *input.MuSig2SessionInfo, signer lndclient.SignerClient, + spendTx *wire.MsgTx, previousOutputs []*wire.TxOut, + remoteNonces *MuSig2Nonces, + remotePartialSig *[input.MuSig2PartialSigSize]byte) ([]byte, error) { + + // In some cases we can already register all nonces during session + // creation. + var remoteNonceBytes []byte + if remoteNonces != nil { + remoteNonceBytes = remoteNonces[:] + allNonces, err := signer.MuSig2RegisterNonces( + ctx, sessionInfo.SessionID, + [][musig2.PubNonceSize]byte{*remoteNonces}, + ) + if err != nil { + return nil, fmt.Errorf("error registering remote "+ + "nonces: %v", err) + } + if !allNonces { + return nil, fmt.Errorf("don't have all nonces after " + + "registering remote nonces") + } + } + + // We now need to create the raw sighash of the transaction, as that + // will be the message we're signing collaboratively. + prevOutputFetcher := txscript.NewMultiPrevOutFetcher(nil) + for idx := range spendTx.TxIn { + prevOutputFetcher.AddPrevOut( + spendTx.TxIn[idx].PreviousOutPoint, + previousOutputs[idx], + ) + } + sighashes := txscript.NewTxSigHashes(spendTx, prevOutputFetcher) + + sigHash, err := txscript.CalcTaprootSignatureHash( + sighashes, txscript.SigHashDefault, spendTx, inputIdx, + prevOutputFetcher, + ) + if err != nil { + return nil, fmt.Errorf("error creating sighash: %v", err) + } + + // We'll attempt to combine our signature with the remote one if there + // is one. In that case we won't clean up after signing, since we still + // need the session for the combine step. + shouldCombine := remotePartialSig != nil + + var digest [32]byte + copy(digest[:], sigHash) + partialSig, err := signer.MuSig2Sign( + ctx, sessionInfo.SessionID, digest, !shouldCombine, + ) + if err != nil { + return nil, fmt.Errorf("error creating partial signature: %v", + err) + } + + log.Tracef("Signed MuSig2 sighash=%x for taprootKey=%x, "+ + "partialSig=%x, remoteNonce=%x", sigHash, + schnorr.SerializePubKey(sessionInfo.CombinedKey), + partialSig, remoteNonceBytes) + + // If we're not the last signer, we're done now and the session should + // have been cleaned up. We return our partial signature, the remote + // party will do the combine step. + if !shouldCombine { + return partialSig, nil + } + + // We have the remote signature, so we're the last signer. + log.Tracef("Combining ourPartialSig=%x with remotePartialSig=%x", + partialSig, remotePartialSig[:]) + haveFinalSig, finalSig, err := signer.MuSig2CombineSig( + ctx, sessionInfo.SessionID, [][]byte{remotePartialSig[:]}, + ) + if err != nil { + return nil, fmt.Errorf("error combining sigs: %v", err) + } + + // We should now have a full, complete signature. If this is true then + // the signer also automatically cleaned up the signing session, so we + // don't need to do anything. + if !haveFinalSig { + return nil, fmt.Errorf("don't have final sig after combining " + + "remote partial signature with ours") + } + + return finalSig, nil +} + // IncrementKey increments the given key by the backing curve's base point. func IncrementKey(pubKey *btcec.PublicKey) *btcec.PublicKey { var ( diff --git a/poolscript/script_test.go b/poolscript/script_test.go index 9678f30..7e4efe2 100644 --- a/poolscript/script_test.go +++ b/poolscript/script_test.go @@ -1,19 +1,31 @@ package poolscript import ( + "bytes" + "crypto/sha256" "encoding/hex" + "fmt" "math/rand" + "reflect" "testing" "testing/quick" "time" "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/ecdsa" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/input" "github.com/stretchr/testify/require" ) const ( numOperations = 10000 numOperationsQuickTest = 1000 + oddByte = input.PubKeyFormatCompressedOdd ) var ( @@ -31,6 +43,12 @@ var ( "03d9dfc4971c9cbabb1b9a4c991914211aa21286e007c15d7e9d828da0b8" + "f07763", ) + + sharedSecret = [32]byte{11, 22, 33, 44, 55} + expiry = uint32(144 * 365) + batchPubKey, _ = btcec.ParsePubKey( + initialBatchKeyBytes, + ) ) // TestIncrementDecrementKey makes sure that incrementing and decrementing an EC @@ -109,5 +127,341 @@ func FuzzWitnessSpendDetection(f *testing.F) { } _ = IsMultiSigSpend(witness) _ = IsExpirySpend(witness) + _ = IsTaprootMultiSigSpend(witness) + _ = IsTaprootExpirySpend(witness) }) } + +// TestWitnessCorrectness tests that our witness sizes are correct and that they +// can actually spend an output of the given type. +func TestWitnessCorrectness(t *testing.T) { + t.Parallel() + + dummyHash := sha256.Sum256([]byte("imagine this was a transaction")) + testCases := []struct { + name string + version Version + timeout bool + expectedSize int + witness func(t *testing.T, trader, + auctioneer *btcec.PrivateKey) wire.TxWitness + check func(witness wire.TxWitness) bool + }{{ + name: "v0 multisig", + version: VersionWitnessScript, + expectedSize: MultiSigWitnessSize, + witness: func(t *testing.T, trader, + auctioneer *btcec.PrivateKey) wire.TxWitness { + + script, err := AccountWitnessScript( + expiry, trader.PubKey(), auctioneer.PubKey(), + batchPubKey, sharedSecret, + ) + require.NoError(t, err) + + traderSig := ecdsa.Sign(trader, dummyHash[:]) + auctioneerSig := ecdsa.Sign(auctioneer, dummyHash[:]) + return SpendMultiSig( + script, serializeSigHashAll(traderSig), + serializeSigHashAll(auctioneerSig), + ) + }, + check: IsMultiSigSpend, + }, { + name: "v0 timeout", + version: VersionWitnessScript, + timeout: true, + expectedSize: ExpiryWitnessSize, + witness: func(t *testing.T, trader, + auctioneer *btcec.PrivateKey) wire.TxWitness { + + script, err := AccountWitnessScript( + expiry, trader.PubKey(), auctioneer.PubKey(), + batchPubKey, sharedSecret, + ) + require.NoError(t, err) + + traderSig := ecdsa.Sign(trader, dummyHash[:]) + return SpendExpiry( + script, serializeSigHashAll(traderSig), + ) + }, + check: IsExpirySpend, + }, { + name: "v1 multisig", + version: VersionTaprootMuSig2, + expectedSize: TaprootMultiSigWitnessSize, + witness: func(t *testing.T, trader, + auctioneer *btcec.PrivateKey) wire.TxWitness { + + traderSig, err := schnorr.Sign(trader, dummyHash[:]) + require.NoError(t, err) + return SpendMuSig2Taproot(traderSig.Serialize()) + }, + check: IsTaprootMultiSigSpend, + }, { + name: "v1 timeout", + version: VersionTaprootMuSig2, + timeout: true, + expectedSize: TaprootExpiryWitnessSize, + witness: func(t *testing.T, trader, + auctioneer *btcec.PrivateKey) wire.TxWitness { + + auctioneerPub := auctioneer.PubKey() + _, tapLeaf, err := TaprootKey( + expiry, trader.PubKey(), auctioneerPub, + batchPubKey, sharedSecret, + ) + require.NoError(t, err) + + traderSig, err := schnorr.Sign(trader, dummyHash[:]) + require.NoError(t, err) + + odd := auctioneerPub.SerializeCompressed()[0] == oddByte + controlBlock := txscript.ControlBlock{ + InternalKey: auctioneerPub, + LeafVersion: txscript.BaseLeafVersion, + OutputKeyYIsOdd: odd, + } + blockBytes, err := controlBlock.ToBytes() + require.NoError(t, err) + + return SpendExpiryTaproot( + tapLeaf.Script, traderSig.Serialize(), + blockBytes, + ) + }, + check: IsTaprootExpirySpend, + }} + + scenario := func(trader, auctioneer *btcec.PrivateKey) bool { + for _, tc := range testCases { + txWitness := tc.witness(t, trader, auctioneer) + witness, err := serializeTxWitness(txWitness) + if err != nil { + t.Logf("Unexpected error: %v", err) + return false + } + + // For Taproot scripts we can actually enforce exact + // witness size estimations! The only variable size item + // is the expiry because that's encoded as a VarInt. But + // we chose an expiry >32k for this test to enforce the + // 4-byte serialization. + if tc.version == VersionTaprootMuSig2 { + if len(witness) != tc.expectedSize { + t.Logf("Unexpected witness size %d: %x", + len(witness), witness) + return false + } + } else { + if len(witness) > tc.expectedSize { + t.Logf("Unexpected witness size %d: %x", + len(witness), witness) + return false + } + } + + passesCheck := tc.check(txWitness) + if !passesCheck { + t.Logf("Did not pass check, trader key %x, "+ + "auctioneer key %x", trader.Serialize(), + auctioneer.Serialize()) + return false + } + } + + return true + } + quickCfg := &quick.Config{ + MaxCount: 1000, + Values: func(values []reflect.Value, r *rand.Rand) { + pkBytes := make([]byte, 32) + _, _ = r.Read(pkBytes) + _, _ = r.Read(pkBytes) + _, _ = r.Read(pkBytes) + trader, _ := btcec.PrivKeyFromBytes(pkBytes) + _, _ = r.Read(pkBytes) + auctioneer, _ := btcec.PrivKeyFromBytes(pkBytes) + + values[1] = reflect.ValueOf(trader) + values[0] = reflect.ValueOf(auctioneer) + }, + } + require.NoError(t, quick.Check(scenario, quickCfg)) +} + +// TestTaprootSpend tests that the taproot key and script spends can be executed +// correctly. +func TestTaprootSpend(t *testing.T) { + t.Parallel() + + t.Run("MuSig2", func(tt *testing.T) { + testTaprootSpend(tt, false) + }) + t.Run("Expiry", func(tt *testing.T) { + testTaprootSpend(tt, true) + }) +} + +// testTaprootSpend executes a Taproot spend, either using the MuSig2 key spend +// path or the expiry script path. +func testTaprootSpend(t *testing.T, expiryPath bool) { + trader, err := btcec.NewPrivateKey() + require.NoError(t, err) + traderPub := trader.PubKey() + + auctioneer, err := btcec.NewPrivateKey() + require.NoError(t, err) + auctioneerPub := auctioneer.PubKey() + + const outputSize = 2000000 + + tx := wire.NewMsgTx(2) + tx.LockTime = expiry + tx.TxIn = []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{ + Hash: [32]byte{1, 2, 3}, + Index: 2, + }, + }} + + taprootKey, tapLeaf, err := TaprootKey( + expiry, traderPub, auctioneerPub, batchPubKey, sharedSecret, + ) + require.NoError(t, err) + + pkScript, err := AccountScript( + VersionTaprootMuSig2, expiry, traderPub, auctioneerPub, + batchPubKey, sharedSecret, + ) + require.NoError(t, err) + tx.TxOut = []*wire.TxOut{{ + Value: outputSize - 800, + PkScript: pkScript, + }} + + prevOutputFetcher := txscript.NewCannedPrevOutputFetcher( + pkScript, outputSize, + ) + sigHashes := txscript.NewTxSigHashes(tx, prevOutputFetcher) + + if expiryPath { + // For the expiry path we sign the tap script sighash with the + // tweaked trader key. + sigHash, err := txscript.CalcTapscriptSignaturehash( + sigHashes, txscript.SigHashDefault, tx, 0, + prevOutputFetcher, *tapLeaf, + ) + require.NoError(t, err) + + traderKeyTweak := TraderKeyTweak( + batchPubKey, sharedSecret, traderPub, + ) + traderTweaked := input.TweakPrivKey(trader, traderKeyTweak) + traderSig, err := schnorr.Sign(traderTweaked, sigHash) + require.NoError(t, err) + + odd := taprootKey.FinalKey.SerializeCompressed()[0] == oddByte + controlBlock := txscript.ControlBlock{ + InternalKey: taprootKey.PreTweakedKey, + LeafVersion: txscript.BaseLeafVersion, + OutputKeyYIsOdd: odd, + } + blockBytes, err := controlBlock.ToBytes() + require.NoError(t, err) + + tx.TxIn[0].Witness = SpendExpiryTaproot( + tapLeaf.Script, traderSig.Serialize(), blockBytes, + ) + } else { + // For the MuSig2 key spend path we sign the normal Taproot + // sighash with the combined MuSig2 key. + sigHash, err := txscript.CalcTaprootSignatureHash( + sigHashes, txscript.SigHashDefault, tx, 0, + prevOutputFetcher, + ) + require.NoError(t, err) + + traderPubSchnorr, _ := schnorr.ParsePubKey( + schnorr.SerializePubKey(traderPub), + ) + auctioneerPubSchnorr, _ := schnorr.ParsePubKey( + schnorr.SerializePubKey(auctioneerPub), + ) + signerKeys := []*btcec.PublicKey{ + traderPubSchnorr, auctioneerPubSchnorr, + } + + rootHash := tapLeaf.TapHash() + rootHashOpt := musig2.WithTaprootTweakCtx(rootHash[:]) + signerOpts := musig2.WithKnownSigners(signerKeys) + + traderCtx, err := musig2.NewContext( + trader, true, rootHashOpt, signerOpts, + ) + require.NoError(t, err) + auctioneerCtx, err := musig2.NewContext( + auctioneer, true, rootHashOpt, signerOpts, + ) + require.NoError(t, err) + + traderSession, err := traderCtx.NewSession() + require.NoError(t, err) + auctioneerSession, err := auctioneerCtx.NewSession() + require.NoError(t, err) + + allNonces, err := traderSession.RegisterPubNonce( + auctioneerSession.PublicNonce(), + ) + require.NoError(t, err) + require.True(t, allNonces) + allNonces, err = auctioneerSession.RegisterPubNonce( + traderSession.PublicNonce(), + ) + require.NoError(t, err) + require.True(t, allNonces) + + var msg [32]byte + copy(msg[:], sigHash) + traderSig, err := traderSession.Sign(msg) + require.NoError(t, err) + + _, err = auctioneerSession.Sign(msg) + require.NoError(t, err) + + fullSigOk, err := auctioneerSession.CombineSig(traderSig) + require.NoError(t, err) + require.True(t, fullSigOk) + + fullSig := auctioneerSession.FinalSig() + tx.TxIn[0].Witness = SpendMuSig2Taproot(fullSig.Serialize()) + } + + vm, err := txscript.NewEngine( + pkScript, tx, 0, txscript.StandardVerifyFlags, nil, sigHashes, + outputSize, txscript.NewCannedPrevOutputFetcher( + pkScript, outputSize, + ), + ) + require.NoError(t, err) + err = vm.Execute() + require.NoError(t, err, "invalid witness") +} + +// serializeSigHash serializes the given signature to its raw byte form and also +// appends the txscript.SigHashAll flag. +func serializeSigHashAll(s input.Signature) []byte { + return append(s.Serialize(), byte(txscript.SigHashAll)) +} + +// serializeTxWitness return the wire witness stack into raw bytes. +func serializeTxWitness(txWitness wire.TxWitness) ([]byte, error) { + var witnessBytes bytes.Buffer + err := psbt.WriteTxWitness(&witnessBytes, txWitness) + if err != nil { + return nil, fmt.Errorf("error serializing witness: %v", err) + } + + return witnessBytes.Bytes(), nil +}