diff --git a/hdkeychain/extendedkey.go b/hdkeychain/extendedkey.go index 2b59f04c0..3fe51c17d 100644 --- a/hdkeychain/extendedkey.go +++ b/hdkeychain/extendedkey.go @@ -378,6 +378,36 @@ func (k *ExtendedKey) Neuter() (*ExtendedKey, error) { k.depth, k.childNum, false), nil } +// CloneWithVersion returns a new extended key cloned from this extended key, +// but using the provided HD version bytes. The version must be a private HD +// key ID for an extended private key, and a public HD key ID for an extended +// public key. +// +// This method creates a new copy and therefore does not mutate the original +// extended key instance. +// +// Unlike Neuter(), this does NOT convert an extended private key to an +// extended public key. It is particularly useful for converting between +// standard BIP0032 extended keys (serializable to xprv/xpub) and keys based +// on the SLIP132 standard (serializable to yprv/ypub, zprv/zpub, etc.). +// +// References: +// [SLIP132]: SLIP-0132 - Registered HD version bytes for BIP-0032 +// https://github.com/satoshilabs/slips/blob/master/slip-0132.md +func (k *ExtendedKey) CloneWithVersion(version []byte) (*ExtendedKey, error) { + if len(version) != 4 { + // TODO: The semantically correct error to return here is + // ErrInvalidHDKeyID (introduced in btcsuite/btcd#1617). Update the + // error type once available in a stable btcd / chaincfg release. + return nil, chaincfg.ErrUnknownHDKeyID + } + + // Initialize a new extended key instance with the same fields as the + // current extended private/public key and the provided HD version bytes. + return NewExtendedKey(version, k.key, k.chainCode, k.parentFP, + k.depth, k.childNum, k.isPrivate), nil +} + // ECPubKey converts the extended key to a btcec public key and returns it. func (k *ExtendedKey) ECPubKey() (*btcec.PublicKey, error) { return btcec.ParsePubKey(k.pubKeyBytes(), btcec.S256()) diff --git a/hdkeychain/extendedkey_test.go b/hdkeychain/extendedkey_test.go index 0fe49f213..868b840bf 100644 --- a/hdkeychain/extendedkey_test.go +++ b/hdkeychain/extendedkey_test.go @@ -1088,3 +1088,71 @@ func TestMaximumDepth(t *testing.T) { t.Fatal("Child: deriving 256th key should not succeed") } } + +// TestCloneWithVersion ensures proper conversion between standard and SLIP132 +// extended keys. +// +// The following tool was used for generating the tests: +// https://jlopp.github.io/xpub-converter +func TestCloneWithVersion(t *testing.T) { + tests := []struct { + name string + key string + version []byte + want string + wantErr error + }{ + { + name: "test xpub to zpub", + key: "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8", + version: []byte{0x04, 0xb2, 0x47, 0x46}, + want: "zpub6jftahH18ngZxUuv6oSniLNrBCSSE1B4EEU59bwTCEt8x6aS6b2mdfLxbS4QS53g85SWWP6wexqeer516433gYpZQoJie2tcMYdJ1SYYYAL", + }, + { + name: "test zpub to xpub", + key: "zpub6jftahH18ngZxUuv6oSniLNrBCSSE1B4EEU59bwTCEt8x6aS6b2mdfLxbS4QS53g85SWWP6wexqeer516433gYpZQoJie2tcMYdJ1SYYYAL", + version: []byte{0x04, 0x88, 0xb2, 0x1e}, + want: "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8", + }, + { + name: "test xprv to zprv", + key: "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi", + version: []byte{0x04, 0xb2, 0x43, 0x0c}, + want: "zprvAWgYBBk7JR8GjzqSzmunMCS7dAbwpYTCs1YUMDXqduMA5JFHZ3iX5s2UkAR6vBdcCYYa1S5o1fVLrKsrnpCQ4WpUd6aVUWP1bS2Yy5DoaKv", + }, + { + name: "test zprv to xprv", + key: "zprvAWgYBBk7JR8GjzqSzmunMCS7dAbwpYTCs1YUMDXqduMA5JFHZ3iX5s2UkAR6vBdcCYYa1S5o1fVLrKsrnpCQ4WpUd6aVUWP1bS2Yy5DoaKv", + version: []byte{0x04, 0x88, 0xad, 0xe4}, + want: "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi", + }, + { + name: "test invalid key id", + key: "zprvAWgYBBk7JR8GjzqSzmunMCS7dAbwpYTCs1YUMDXqduMA5JFHZ3iX5s2UkAR6vBdcCYYa1S5o1fVLrKsrnpCQ4WpUd6aVUWP1bS2Yy5DoaKv", + version: []byte{0x4B, 0x1D}, + wantErr: chaincfg.ErrUnknownHDKeyID, + }, + } + + for i, test := range tests { + extKey, err := NewKeyFromString(test.key) + if err != nil { + panic(err) // This is never expected to fail. + } + + got, err := extKey.CloneWithVersion(test.version) + if !reflect.DeepEqual(err, test.wantErr) { + t.Errorf("CloneWithVersion #%d (%s): unexpected error -- "+ + "want %v, got %v", i, test.name, test.wantErr, err) + continue + } + + if test.wantErr == nil { + if k := got.String(); k != test.want { + t.Errorf("CloneWithVersion #%d (%s): "+ + "got %s, want %s", i, test.name, k, test.want) + continue + } + } + } +}