From 6a78190b2fcf86bbbc4df25700e6f189e6e80126 Mon Sep 17 00:00:00 2001 From: Oliver Gugger Date: Mon, 21 Feb 2022 14:08:40 +0100 Subject: [PATCH] mod+waddrmgr: add taproot script address type --- go.mod | 3 + go.sum | 2 + waddrmgr/address.go | 122 ++++++++++++++++---- waddrmgr/scoped_manager.go | 30 ++++- waddrmgr/tlv.go | 226 +++++++++++++++++++++++++++++++++++++ waddrmgr/tlv_test.go | 86 ++++++++++++++ 6 files changed, 443 insertions(+), 26 deletions(-) create mode 100644 waddrmgr/tlv.go create mode 100644 waddrmgr/tlv_test.go diff --git a/go.mod b/go.mod index 021182b8db..5eb72d7a5f 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf github.com/lightninglabs/neutrino v0.13.2 github.com/lightningnetwork/lnd/ticker v1.0.0 + github.com/lightningnetwork/lnd/tlv v1.0.2 github.com/stretchr/testify v1.7.0 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc @@ -36,6 +37,8 @@ replace github.com/btcsuite/btcd/btcec/v2 => github.com/Roasbeef/btcd/btcec/v2 v replace github.com/btcsuite/btcwallet/wallet/txauthor => ./wallet/txauthor +replace github.com/lightningnetwork/lnd/tlv => github.com/guggero/lnd/tlv v0.0.0-20220221153005-553bce541120 + // The old version of ginko that's used in btcd imports an ancient version of // gopkg.in/fsnotify.v1 that isn't go mod compatible. We fix that import error // by replacing ginko (which is only a test library anyway) with a more recent diff --git a/go.sum b/go.sum index ab3eab79ad..dc37ec5420 100644 --- a/go.sum +++ b/go.sum @@ -57,6 +57,8 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/guggero/lnd/tlv v0.0.0-20220221153005-553bce541120 h1:QwR6EGCvOBeHe0AigubF03eBwL/DazWgfzweEJd1JxQ= +github.com/guggero/lnd/tlv v0.0.0-20220221153005-553bce541120/go.mod h1:1MC5EfYK+3VS/JTVNyvCAy8BOQicaAorj5CfrTRVDYQ= github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jrick/logrotate v1.0.0 h1:lQ1bL/n9mBNeIXoTUoYRlK4dHuNJVofX9oWqBtPnSzI= diff --git a/waddrmgr/address.go b/waddrmgr/address.go index da5c96df79..4b966a1499 100644 --- a/waddrmgr/address.go +++ b/waddrmgr/address.go @@ -55,6 +55,10 @@ const ( // TaprootPubKey represents a p2tr (pay-to-taproot) address type. TaprootPubKey + + // TaprootScript represents a p2tr (pay-to-taproot) address type that + // commits to a script and not just a single key. + TaprootScript ) const ( @@ -794,38 +798,108 @@ var _ ManagedScriptAddress = (*witnessScriptAddress)(nil) // newWitnessScriptAddress initializes and returns a new // pay-to-witness-script-hash address. -func newWitnessScriptAddress(m *ScopedKeyManager, account uint32, scriptHash, +func newWitnessScriptAddress(m *ScopedKeyManager, account uint32, scriptIdent, scriptEncrypted []byte, witnessVersion byte, - isSecretScript bool) (*witnessScriptAddress, error) { - - var ( - address btcutil.Address - err error - ) + isSecretScript bool) (ManagedScriptAddress, error) { switch witnessVersion { - case 0x00: - address, err = btcutil.NewAddressWitnessScriptHash( - scriptHash, m.rootManager.chainParams, + case witnessVersionV0: + address, err := btcutil.NewAddressWitnessScriptHash( + scriptIdent, m.rootManager.chainParams, ) + if err != nil { + return nil, err + } - case 0x01: - address, err = btcutil.NewAddressTaproot( - scriptHash, m.rootManager.chainParams, + return &witnessScriptAddress{ + baseScriptAddress: baseScriptAddress{ + manager: m, + account: account, + scriptEncrypted: scriptEncrypted, + }, + address: address, + witnessVersion: witnessVersion, + isSecretScript: isSecretScript, + }, nil + + case witnessVersionV1: + address, err := btcutil.NewAddressTaproot( + scriptIdent, m.rootManager.chainParams, ) + if err != nil { + return nil, err + } + + // Lift the x-only coordinate of the tweaked public key. + tweakedPubKey, err := schnorr.ParsePubKey(scriptIdent) + if err != nil { + return nil, fmt.Errorf("error lifting public key from "+ + "script ident: %v", err) + } + + return &taprootScriptAddress{ + witnessScriptAddress: witnessScriptAddress{ + baseScriptAddress: baseScriptAddress{ + manager: m, + account: account, + scriptEncrypted: scriptEncrypted, + }, + address: address, + witnessVersion: witnessVersion, + isSecretScript: isSecretScript, + }, + TweakedPubKey: tweakedPubKey, + }, nil + + default: + return nil, fmt.Errorf("unsupported witness version %d", + witnessVersion) } +} + +// taprootScriptAddress represents a pay-to-taproot address that commits to a +// script. +type taprootScriptAddress struct { + witnessScriptAddress + + TweakedPubKey *btcec.PublicKey +} + +// AddrType returns the address type of the managed address. This can be used +// to quickly discern the address type without further processing +// +// This is part of the ManagedAddress interface implementation. +func (a *taprootScriptAddress) AddrType() AddressType { + return TaprootScript +} + +// Address returns the btcutil.Address which represents the managed address. +// This will be a pay-to-witness-script-hash address. +// +// This is part of the ManagedAddress interface implementation. +func (a *taprootScriptAddress) Address() btcutil.Address { + return a.address +} + +// AddrHash returns the script hash for the address. +// +// This is part of the ManagedAddress interface implementation. +func (a *taprootScriptAddress) AddrHash() []byte { + return schnorr.SerializePubKey(a.TweakedPubKey) +} + +// TaprootScript returns the internal key and the full list of tap leaves used +// to construct this taproot address. +func (a *taprootScriptAddress) TaprootScript() (*btcec.PublicKey, + []txscript.TapLeaf, error) { + + // Need to decrypt our internal script first. We need to be unlocked for + // this. + script, err := a.Script() if err != nil { - return nil, err + return nil, nil, err } - return &witnessScriptAddress{ - baseScriptAddress: baseScriptAddress{ - manager: m, - account: account, - scriptEncrypted: scriptEncrypted, - }, - address: address, - witnessVersion: witnessVersion, - isSecretScript: isSecretScript, - }, nil + // Decode the additional TLV encoded data. + return tlvDecodeTaprootTaprootScript(script) } diff --git a/waddrmgr/scoped_manager.go b/waddrmgr/scoped_manager.go index 45bbd66e43..9b89f3da86 100644 --- a/waddrmgr/scoped_manager.go +++ b/waddrmgr/scoped_manager.go @@ -7,6 +7,7 @@ import ( "sync" "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/chaincfg" @@ -2093,6 +2094,31 @@ func (s *ScopedKeyManager) ImportWitnessScript(ns walletdb.ReadWriteBucket, ) } +// ImportTaprootScript imports a user-provided taproot script into the address +// manager. The imported script will act as a pay-to-taproot address. +// +// TODO(guggero): Add version that allows knowing only a partial script, would +// need to store the inclusion proof alongside the partially revealed script. +func (s *ScopedKeyManager) ImportTaprootScript(ns walletdb.ReadWriteBucket, + internalKey *btcec.PublicKey, leaves []txscript.TapLeaf, bs *BlockStamp, + witnessVersion byte, isSecretScript bool) (ManagedScriptAddress, + error) { + + tree := txscript.AssembleTaprootScriptTree(leaves...) + rootHash := tree.RootNode.TapHash() + taprootKey := txscript.ComputeTaprootOutputKey(internalKey, rootHash[:]) + + script, err := tlvEncodeTaprootScript(internalKey, leaves) + if err != nil { + return nil, fmt.Errorf("error encoding taproot script: %v", err) + } + + return s.importScriptAddress( + ns, schnorr.SerializePubKey(taprootKey), script, bs, + TaprootScript, witnessVersion, isSecretScript, + ) +} + // importScriptAddress imports a new pay-to-script or pay-to-witness-script // address. func (s *ScopedKeyManager) importScriptAddress(ns walletdb.ReadWriteBucket, @@ -2161,7 +2187,7 @@ func (s *ScopedKeyManager) importScriptAddress(ns walletdb.ReadWriteBucket, // Save the new imported address to the db and update start block (if // needed) in a single transaction. switch addrType { - case WitnessScript: + case WitnessScript, TaprootScript: err = putWitnessScriptAddress( ns, &s.scope, scriptIdent, ImportedAddrAccount, ssNone, witnessVersion, isSecretScript, encryptedHash, @@ -2199,7 +2225,7 @@ func (s *ScopedKeyManager) importScriptAddress(ns walletdb.ReadWriteBucket, // should not be cleared out from under the caller. var managedAddr ManagedScriptAddress switch addrType { - case WitnessScript: + case WitnessScript, TaprootScript: managedAddr, err = newWitnessScriptAddress( s, ImportedAddrAccount, scriptIdent, encryptedScript, witnessVersion, isSecretScript, diff --git a/waddrmgr/tlv.go b/waddrmgr/tlv.go new file mode 100644 index 0000000000..7b967a0831 --- /dev/null +++ b/waddrmgr/tlv.go @@ -0,0 +1,226 @@ +package waddrmgr + +import ( + "bytes" + "io" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/txscript" + "github.com/lightningnetwork/lnd/tlv" +) + +const ( + typeTaprootInternalKey tlv.Type = 1 + typeTaprootLeavesKey tlv.Type = 2 + + typeTapLeafVersion tlv.Type = 1 + typeTapLeafScript tlv.Type = 2 +) + +// tlvEncodeTaprootScript encodes the given internal key and full set of taproot +// script leaves into a byte slice encoded as a TLV stream. +func tlvEncodeTaprootScript(internalKey *btcec.PublicKey, + leaves []txscript.TapLeaf) ([]byte, error) { + + var tlvRecords []tlv.Record + if internalKey != nil { + tlvRecords = append(tlvRecords, tlv.MakePrimitiveRecord( + typeTaprootInternalKey, &internalKey, + )) + } + + tlvRecords = append(tlvRecords, tlv.MakeDynamicRecord( + typeTaprootLeavesKey, &leaves, func() uint64 { + return recordSize(leavesEncoder, &leaves) + }, leavesEncoder, leavesDecoder, + )) + + tlvStream, err := tlv.NewStream(tlvRecords...) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + err = tlvStream.Encode(&buf) + if err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +// tlvDecodeTaprootTaprootScript decodes the given byte slice as a TLV stream +// and attempts to parse the taproot internal key and full set of leaves from +// it. +func tlvDecodeTaprootTaprootScript(tlvData []byte) (*btcec.PublicKey, + []txscript.TapLeaf, error) { + + var ( + internalKey *btcec.PublicKey + leaves []txscript.TapLeaf + ) + + tlvStream, err := tlv.NewStream( + tlv.MakePrimitiveRecord(typeTaprootInternalKey, &internalKey), + tlv.MakeDynamicRecord( + typeTaprootLeavesKey, &leaves, func() uint64 { + return recordSize(leavesEncoder, &leaves) + }, leavesEncoder, leavesDecoder, + ), + ) + if err != nil { + return nil, nil, err + } + + _, err = tlvStream.DecodeWithParsedTypes(bytes.NewReader(tlvData)) + if err != nil { + return nil, nil, err + } + + return internalKey, leaves, nil +} + +// leavesEncoder is a custom TLV decoder for a slice of tap leaf records. +func leavesEncoder(w io.Writer, val interface{}, buf *[8]byte) error { + if v, ok := val.(*[]txscript.TapLeaf); ok { + for _, c := range *v { + leafVersion := uint8(c.LeafVersion) + tlvRecords := []tlv.Record{ + tlv.MakePrimitiveRecord( + typeTapLeafVersion, &leafVersion, + ), + } + + if len(c.Script) > 0 { + tlvRecords = append( + tlvRecords, tlv.MakePrimitiveRecord( + typeTapLeafScript, &c.Script, + ), + ) + + } + + tlvStream, err := tlv.NewStream(tlvRecords...) + if err != nil { + return err + } + + var leafTLVBytes bytes.Buffer + err = tlvStream.Encode(&leafTLVBytes) + if err != nil { + return err + } + + // We encode the record with a varint length followed by + // the _raw_ TLV bytes. + tlvLen := uint64(len(leafTLVBytes.Bytes())) + if err := tlv.WriteVarInt(w, tlvLen, buf); err != nil { + return err + } + + _, err = w.Write(leafTLVBytes.Bytes()) + if err != nil { + return err + } + } + + return nil + } + + return tlv.NewTypeForEncodingErr(val, "[]txscript.TapLeaf") +} + +// leavesDecoder is a custom TLV decoder for a slice of tap leaf records. +func leavesDecoder(r io.Reader, val interface{}, buf *[8]byte, l uint64) error { + if v, ok := val.(*[]txscript.TapLeaf); ok { + var leaves []txscript.TapLeaf + + // Using the length information given, we'll create a new + // limited reader that'll return an EOF once the end has been + // reached so the stream stops consuming bytes. + innerTlvReader := io.LimitedReader{ + R: r, + N: int64(l), + } + + for { + // Read out the varint that encodes the size of this + // inner TLV record. + blobSize, err := tlv.ReadVarInt(r, buf) + if err == io.EOF { + break + } else if err != nil { + return err + } + + innerInnerTlvReader := io.LimitedReader{ + R: &innerTlvReader, + N: int64(blobSize), + } + + var ( + leafVersion uint8 + script []byte + ) + tlvStream, err := tlv.NewStream( + tlv.MakePrimitiveRecord( + typeTapLeafVersion, &leafVersion, + ), + tlv.MakePrimitiveRecord( + typeTapLeafScript, &script, + ), + ) + if err != nil { + return err + } + + parsedTypes, err := tlvStream.DecodeWithParsedTypes( + &innerInnerTlvReader, + ) + if err != nil { + return err + } + + leaf := txscript.TapLeaf{ + LeafVersion: txscript.TapscriptLeafVersion( + leafVersion, + ), + } + + // Only set script when actually parsed to make + // difference between nil and empty slice work + // correctly. The parsedTypes entry must be nil if it + // was parsed fully. + if t, ok := parsedTypes[typeTapLeafScript]; ok && t == nil { + leaf.Script = script + } + + leaves = append(leaves, leaf) + } + + *v = leaves + return nil + } + + return tlv.NewTypeForDecodingErr(val, "[]txscript.TapLeaf", l, l) +} + +// recordSize returns the amount of bytes this TLV record will occupy when +// encoded. +func recordSize(encoder tlv.Encoder, v interface{}) uint64 { + var ( + b bytes.Buffer + buf [8]byte + ) + + // We know that encoding works since the tests pass in the build this + // file is checked into, so we'll simplify things and simply encode it + // ourselves then report the total amount of bytes used. + if err := encoder(&b, v, &buf); err != nil { + // This should never error out, but we log it just in case it + // does. + log.Errorf("encoding the record failed: %v", err) + } + + return uint64(len(b.Bytes())) +} diff --git a/waddrmgr/tlv_test.go b/waddrmgr/tlv_test.go new file mode 100644 index 0000000000..b52f1bccc8 --- /dev/null +++ b/waddrmgr/tlv_test.go @@ -0,0 +1,86 @@ +package waddrmgr + +import ( + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/txscript" + "github.com/stretchr/testify/require" +) + +var ( + testPubKey, _ = btcec.ParsePubKey(hexToBytes( + "0329faddf1254d490d6add49e2b08cf52b561038c72baec0edb3cfacff71" + + "ff1021", + )) + testScript = []byte{99, 88, 77, 66, 55, 44} +) + +// TestTlvEncodeDecode tests encoding and decoding of taproot script TLV data. +func TestTlvEncodeDecode(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + internalKey *btcec.PublicKey + leaves []txscript.TapLeaf + }{{ + name: "both nil", + }, { + name: "no leaves", + internalKey: testPubKey, + }, { + name: "no pubkey", + leaves: []txscript.TapLeaf{{ + LeafVersion: 7, + Script: testScript, + }}, + }, { + name: "no leaves", + internalKey: testPubKey, + leaves: []txscript.TapLeaf{}, + }, { + name: "empty leaf", + internalKey: testPubKey, + leaves: []txscript.TapLeaf{{}}, + }, { + name: "full key and leaves", + }, { + name: "no pubkey", + internalKey: testPubKey, + leaves: []txscript.TapLeaf{{ + LeafVersion: 7, + Script: testScript, + }, { + LeafVersion: 123, + Script: []byte{0}, + }}, + }} + + for _, tc := range testCases { + tc := tc + + t.Run(tc.name, func(tt *testing.T) { + data, err := tlvEncodeTaprootScript( + tc.internalKey, tc.leaves, + ) + require.NoError(tt, err) + require.NotEmpty(tt, data) + + internalKey, leaves, err := tlvDecodeTaprootTaprootScript( + data, + ) + require.NoError(tt, err) + + require.Equal(tt, tc.internalKey, internalKey) + require.Len(tt, leaves, len(tc.leaves)) + + // Only compare the content if we expect at least one + // leaf to be in there. Otherwise, we have the nil vs. + // empty slice comparison problem. + if len(tc.leaves) > 0 { + require.Equal(tt, tc.leaves, leaves) + } + }) + } +}