Skip to content
This repository was archived by the owner on Oct 16, 2024. It is now read-only.

Commit b3987b6

Browse files
authored
Add gRPC method to serialize xpub from public key material (#22)
1 parent 8131ce9 commit b3987b6

File tree

5 files changed

+217
-4
lines changed

5 files changed

+217
-4
lines changed

go.sum

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,8 @@ github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13P
1010
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f h1:bAs4lUbRJpnnkd9VhRV3jjAVU7DJVjMaK+IsvSeZvFo=
1111
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
1212
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
13-
github.com/btcsuite/btcutil v1.0.3-0.20200713135911-4649e4b73b34 h1:tyHQjooNSvGmKffWEyJyJWdA+c1iGEOKqj6h1rHKhlY=
14-
github.com/btcsuite/btcutil v1.0.3-0.20200713135911-4649e4b73b34/go.mod h1:j9HUFwoQRsZL3V4n+qG+CUnEGHOarIxfC3Le2Yhbcts=
1513
github.com/btcsuite/btcutil v1.0.3-0.20201104004401-a21f014935da h1:uDo+NUUipe4L9ncJddoEkIKilF46Oipow3/lFYfGuZA=
1614
github.com/btcsuite/btcutil v1.0.3-0.20201104004401-a21f014935da/go.mod h1:0DVlHczLPewLcPGEIeUEzfOJhqGPQ0mJJRDBtD307+o=
17-
github.com/btcsuite/btcwallet v0.11.0 h1:XhwqdhEchy5a0q6R+y3F82roD2hYycPCHovgNyJS08w=
1815
github.com/btcsuite/btcwallet/wallet/txauthor v1.0.0 h1:KGHMW5sd7yDdDMkCZ/JpP0KltolFsQcB973brBnfj4c=
1916
github.com/btcsuite/btcwallet/wallet/txauthor v1.0.0/go.mod h1:VufDts7bd/zs3GV13f/lXc/0lXrPnvxD/NvmpG/FEKU=
2017
github.com/btcsuite/btcwallet/wallet/txrules v1.0.0 h1:2VsfS0sBedcM5KmDzRMT3+b6xobqWveZGvjb+jFez5w=
@@ -25,9 +22,11 @@ github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd h1:R/opQEbFEy9JG
2522
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
2623
github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd h1:qdGvebPBDuYDPGi1WCPjy1tGyMpmDK8IEapSsszn7HE=
2724
github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY=
25+
github.com/btcsuite/goleveldb v1.0.0 h1:Tvd0BfvqX9o823q1j2UZ/epQo09eJh6dTcRp79ilIN4=
2826
github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I=
2927
github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723 h1:ZA/jbKoGcVAnER6pCHPEkGdZOV7U1oLUedErBHCUMs0=
3028
github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
29+
github.com/btcsuite/snappy-go v1.0.0 h1:ZxaA6lo2EpxGddsA8JwWOcxlzRybb444sgmeJQMJGQE=
3130
github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
3231
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 h1:R8vQdOQdZ9Y3SkEwmHoWBmX1DNXhXZqlTpq6s4tyJGc=
3332
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
@@ -70,6 +69,7 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
7069
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
7170
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
7271
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
72+
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
7373
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
7474
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89 h1:12K8AlpT0/6QUXSfV0yi4Q0jkbq8NDtIKFtF61AoqV0=
7575
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
@@ -79,8 +79,10 @@ github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23 h1:FOOIBWrEkLgmlgGfM
7979
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
8080
github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
8181
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
82+
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
8283
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
8384
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
85+
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
8486
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
8587
github.com/magefile/mage v1.10.0 h1:3HiXzCUY12kh9bIuyXShaVe529fJfyqoVM42o/uom2g=
8688
github.com/magefile/mage v1.10.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
@@ -89,8 +91,10 @@ github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czP
8991
github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
9092
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
9193
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
94+
github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs=
9295
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
9396
github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
97+
github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU=
9498
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
9599
github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
96100
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
@@ -199,8 +203,11 @@ google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4
199203
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
200204
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
201205
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
206+
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
202207
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
208+
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
203209
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
210+
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
204211
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
205212
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
206213
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=

grpc/bitcoin.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,25 @@ func (c *controller) DeriveExtendedKey(
8080
}, nil
8181
}
8282

83+
func (c *controller) GetAccountExtendedKey(
84+
ctx context.Context, request *pb.GetAccountExtendedKeyRequest,
85+
) (*pb.GetAccountExtendedKeyResponse, error) {
86+
chainParams, err := BitcoinChainParams(request.ChainParams)
87+
if err != nil {
88+
return nil, status.Errorf(codes.InvalidArgument, err.Error())
89+
}
90+
91+
key, err := c.svc.GetAccountExtendedKey(
92+
request.PublicKey, request.ChainCode, request.AccountIndex, chainParams)
93+
if err != nil {
94+
return nil, status.Errorf(codes.InvalidArgument, err.Error())
95+
}
96+
97+
return &pb.GetAccountExtendedKeyResponse{
98+
ExtendedKey: key,
99+
}, nil
100+
}
101+
83102
func (c *controller) CreateTransaction(
84103
ctx context.Context, txRequest *pb.CreateTransactionRequest,
85104
) (*pb.RawTransactionResponse, error) {

pb/bitcoin/service.proto

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ service CoinService {
3131
// HD version bytes during encoding.
3232
rpc EncodeAddress(EncodeAddressRequest) returns (EncodeAddressResponse) {}
3333

34+
// GetAccountExtendedKey accepts public key material and parameters, and
35+
// returns the serialized extended public key.
36+
rpc GetAccountExtendedKey(GetAccountExtendedKeyRequest) returns (GetAccountExtendedKeyResponse) {}
37+
3438
// CreateTransaction prepares a transaction and returns a raw tx in order to be signed.
3539
rpc CreateTransaction(CreateTransactionRequest) returns (RawTransactionResponse) {}
3640

@@ -130,6 +134,35 @@ message DeriveExtendedKeyResponse {
130134
bytes chain_code = 3;
131135
}
132136

137+
// GetAccountExtendedKeyRequest models the request passed to GetAccountExtendedKey
138+
// RPC.
139+
message GetAccountExtendedKeyRequest {
140+
// Serialized public key associated with the extended key derived
141+
// at the account-level derivation path.
142+
//
143+
// Both compressed as well as uncompressed public keys are accepted.
144+
bytes public_key = 1;
145+
146+
// Serialized chain code associated with the extended key derived at the
147+
// account-level derivation path.
148+
//
149+
// This field is 32 bytes long.
150+
bytes chain_code = 2;
151+
152+
// Index at BIP32 level 3.
153+
uint32 account_index = 3;
154+
155+
// Chain params to identify the coin and network for which the extended
156+
// public key must be generated.
157+
ChainParams chain_params = 4;
158+
}
159+
160+
// GetAccountExtendedKeyResponse wraps the output response of GetAccountExtendedKey RPC.
161+
message GetAccountExtendedKeyResponse {
162+
// Extended key serialized as a base58-encoded string.
163+
string extended_key = 1;
164+
}
165+
133166
// AddressEncoding enumerates the list of all supported encoding formats, for
134167
// serializing addresses.
135168
//

pkg/bitcoin/hd.go

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package bitcoin
22

33
import (
4+
"encoding/hex"
5+
6+
"github.com/btcsuite/btcd/btcec"
7+
"github.com/btcsuite/btcutil"
48
"github.com/btcsuite/btcutil/hdkeychain"
59
"github.com/pkg/errors"
610
)
@@ -71,6 +75,62 @@ func (s *Service) DeriveExtendedKey(
7175
return response, nil
7276
}
7377

78+
// GetAccountExtendedKey returns the serialized extended key from public key
79+
// material, and various parameters. This is typically provided by the HSM.
80+
//
81+
// Certain assumptions have been made for the parent fingerprint. Please read
82+
// the corresponding note in the code.
83+
//
84+
// accountIndex must NOT add the BIP32 harden bit. The account MUST have
85+
// been derived using the following scheme:
86+
// m / purpose' / coin_type' / account'
87+
//
88+
// It also implies that accountIndex is at BIP32 level 3.
89+
func (s *Service) GetAccountExtendedKey(
90+
publicKey []byte,
91+
chainCode []byte,
92+
accountIndex uint32,
93+
chainParams ChainParams,
94+
) (string, error) {
95+
// Load the serialized public key to a btcec.PublicKey type, in order to
96+
// ensure that the:
97+
// * public point is on the secp256k1 elliptic curve.
98+
// * public point coordinates belong to the finite field of secp256k1.
99+
// * public key is well formed (valid magic, length, etc).
100+
// * public key used for serializing the extended key is compressed.
101+
//
102+
// Both compressed and uncompressed public keys are accepted.
103+
loadedPublicKey, err := btcec.ParsePubKey(publicKey, btcec.S256())
104+
if err != nil {
105+
return "", errors.Wrapf(err, "failed to parse public key %s",
106+
hex.EncodeToString(publicKey))
107+
}
108+
109+
serializedPublicKey := loadedPublicKey.SerializeCompressed()
110+
const depth = 3
111+
childNum := accountIndex + hdkeychain.HardenedKeyStart
112+
113+
// The fingerprint of the parent for the derived child is the first 4
114+
// bytes of the RIPEMD160(SHA256(parentPubKey)).
115+
//
116+
// Caution: The HSM does NOT provide the parent fingerprint, so we use
117+
// the fingerprint of the child (BIP32 depth 3). While this is incorrect,
118+
// the fingerprint has no impact on the derived addresses.
119+
parentFP := btcutil.Hash160(serializedPublicKey)[:4]
120+
121+
key := hdkeychain.NewExtendedKey(
122+
chainParams.HDPublicKeyID[:],
123+
serializedPublicKey,
124+
chainCode,
125+
parentFP,
126+
depth,
127+
childNum,
128+
false,
129+
)
130+
131+
return key.String(), nil
132+
}
133+
74134
// Useful service to get keypair (xpub + privKey) from a seed for testing.
75135
// Random seed is generated if no seed is provided.
76136
func (s *Service) GetKeypair(seed string, chainParams ChainParams, derivation []uint32) (Keypair, error) {
@@ -107,8 +167,11 @@ func (s *Service) GetKeypair(seed string, chainParams ChainParams, derivation []
107167
}
108168
}
109169

110-
// Get the humand readable extended public key
170+
// Get the human readable extended public key
111171
accountExtendedPublicKey, err := extendedKey.Neuter()
172+
if err != nil {
173+
return response, err
174+
}
112175

113176
response.ExtendedPublicKey = accountExtendedPublicKey.String()
114177
response.PrivateKey = extendedKey.String()

pkg/bitcoin/hd_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,97 @@ func TestDeriveExtendedKey(t *testing.T) {
153153
}
154154
}
155155

156+
func TestGetAccountExtendedKey(t *testing.T) {
157+
tests := []struct {
158+
name string
159+
publicKey []byte
160+
chainCode []byte
161+
accountIndex uint32
162+
chainParams ChainParams
163+
want string
164+
wantAddress string
165+
encodingForAddress AddressEncoding
166+
wantErr error
167+
}{
168+
{
169+
// https://github.com/LedgerHQ/lib-ledger-core/blob/54ddf50/core/test/bitcoin/address_test.cpp#L81
170+
name: "mainnet legacy",
171+
accountIndex: 0,
172+
publicKey: []byte{
173+
0x02, 0xc3, 0x68, 0xbd, 0xec, 0x47, 0xa1, 0xb6,
174+
0xfa, 0xa7, 0x6d, 0x62, 0x4e, 0xad, 0x0c, 0xd2,
175+
0x78, 0x32, 0x34, 0x98, 0x3c, 0x46, 0x67, 0x67,
176+
0x21, 0x6e, 0xcd, 0xac, 0x8c, 0x47, 0x2d, 0xf3,
177+
0xa6,
178+
},
179+
chainCode: []byte{
180+
0xb6, 0xb8, 0xa4, 0x9c, 0x62, 0x34, 0xb2, 0x6c,
181+
0x91, 0xbf, 0xaf, 0xac, 0xd9, 0x05, 0x4c, 0x18,
182+
0x56, 0x21, 0x30, 0x23, 0x4d, 0xc3, 0x9e, 0x94,
183+
0x63, 0x56, 0x1c, 0xa6, 0x66, 0x7f, 0x40, 0xf8,
184+
},
185+
chainParams: Mainnet,
186+
wantAddress: "14QcTVDFpuGsmNSLeDexB1kWCdoBnTTtgr",
187+
encodingForAddress: Legacy,
188+
want: "xpub6DVHQNhjvVchuKeMGnKbbNSdczQ4yMqEW1H1qhQzk1oPxkSqyHZR9Pn7zZ494sVhZqK2WD8kxo9rqiJFL41P67JCdNYka2W5LnANDVWSjzm",
189+
},
190+
}
191+
192+
s := &Service{}
193+
194+
for _, tt := range tests {
195+
t.Run(tt.name, func(t *testing.T) {
196+
got, err := s.GetAccountExtendedKey(
197+
tt.publicKey, tt.chainCode, tt.accountIndex, tt.chainParams)
198+
199+
if err != nil && tt.wantErr == nil {
200+
t.Fatalf("GetAccountExtendedKey() unexpected error: %v", err)
201+
}
202+
203+
if err == nil && tt.wantErr != nil {
204+
t.Fatalf("GetAccountExtendedKey() got no error, want '%v'",
205+
tt.wantErr)
206+
}
207+
208+
if err != nil && tt.wantErr.Error() != errors.Cause(err).Error() {
209+
t.Fatalf("GetAccountExtendedKey() got error '%v', want '%v'",
210+
err, tt.wantErr)
211+
}
212+
213+
if !reflect.DeepEqual(got, tt.want) {
214+
t.Fatalf("GetAccountExtendedKey() got error '%v', want '%v'",
215+
got, tt.want)
216+
}
217+
218+
deriveAddress := func(key string, derivation []uint32) (string, error) {
219+
keyMaterial, err := s.DeriveExtendedKey(key, derivation)
220+
if err != nil {
221+
return "", err
222+
}
223+
224+
addr, err := s.EncodeAddress(
225+
keyMaterial.PublicKey, tt.encodingForAddress, tt.chainParams)
226+
if err != nil {
227+
return "", err
228+
}
229+
230+
return addr, nil
231+
}
232+
233+
gotAddress, err := deriveAddress(tt.want, []uint32{0, 0})
234+
if err != nil {
235+
t.Fatalf("error: '%v' - cannot derive for '%v' at path 0/0",
236+
err.Error(), tt.want)
237+
}
238+
239+
if gotAddress != tt.wantAddress {
240+
t.Fatalf("GetAccountExtendedKey() got wrong address: '%v', want '%v'",
241+
gotAddress, tt.wantAddress)
242+
}
243+
})
244+
}
245+
}
246+
156247
func TestGetKeypair(t *testing.T) {
157248
tests := []struct {
158249
name string

0 commit comments

Comments
 (0)