Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

waddrmgr+wallet: support transaction creation and signing for watch-only accounts #733

Merged
merged 10 commits into from
Mar 29, 2021
1 change: 0 additions & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,3 @@ script:
- make lint
- make unit-race
- make unit-cover
- make goveralls
11 changes: 0 additions & 11 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
PKG := github.com/btcsuite/btcwallet

GOVERALLS_PKG := github.com/mattn/goveralls
LINT_PKG := github.com/golangci/golangci-lint/cmd/golangci-lint
GOACC_PKG := github.com/ory/go-acc
GOIMPORTS_PKG := golang.org/x/tools/cmd/goimports

GO_BIN := ${GOPATH}/bin
GOVERALLS_BIN := $(GO_BIN)/goveralls
LINT_BIN := $(GO_BIN)/golangci-lint
GOACC_BIN := $(GO_BIN)/go-acc

Expand Down Expand Up @@ -49,10 +47,6 @@ all: build check
# DEPENDENCIES
# ============

$(GOVERALLS_BIN):
@$(call print, "Fetching goveralls.")
go get -u $(GOVERALLS_PKG)

$(LINT_BIN):
@$(call print, "Fetching linter")
$(DEPGET) $(LINT_PKG)@$(LINT_COMMIT)
Expand Down Expand Up @@ -97,10 +91,6 @@ unit-race:
@$(call print, "Running unit race tests.")
env CGO_ENABLED=1 GORACE="history_size=7 halt_on_errors=1" $(GOLIST) | $(XARGS) env $(GOTEST) -race

goveralls: $(GOVERALLS_BIN)
@$(call print, "Sending coverage report.")
$(GOVERALLS_BIN) -coverprofile=coverage.txt -service=travis-ci

# =========
# UTILITIES
# =========
Expand All @@ -126,7 +116,6 @@ clean:
unit \
unit-cover \
unit-race \
goveralls \
fmt \
lint \
clean
13 changes: 11 additions & 2 deletions cmd/sweepaccount/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/btcsuite/btcwallet/netparams"
"github.com/btcsuite/btcwallet/wallet/txauthor"
"github.com/btcsuite/btcwallet/wallet/txrules"
"github.com/btcsuite/btcwallet/wallet/txsizes"
"github.com/jessevdk/go-flags"
)

Expand Down Expand Up @@ -190,14 +191,22 @@ func makeInputSource(outputs []btcjson.ListUnspentResult) txauthor.InputSource {
// makeDestinationScriptSource creates a ChangeSource which is used to receive
// all correlated previous input value. A non-change address is created by this
// function.
func makeDestinationScriptSource(rpcClient *rpcclient.Client, accountName string) txauthor.ChangeSource {
return func() ([]byte, error) {
func makeDestinationScriptSource(rpcClient *rpcclient.Client, accountName string) *txauthor.ChangeSource {

// GetNewAddress always returns a P2PKH address since it assumes
// BIP-0044.
newChangeScript := func() ([]byte, error) {
destinationAddress, err := rpcClient.GetNewAddress(accountName)
if err != nil {
return nil, err
}
return txscript.PayToAddrScript(destinationAddress)
}

return &txauthor.ChangeSource{
ScriptSize: txsizes.P2PKHPkScriptSize,
NewScript: newChangeScript,
}
}

func main() {
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/btcsuite/btcutil/psbt v1.0.3-0.20201208143702-a53e38424cce
github.com/btcsuite/btcwallet/wallet/txauthor v1.0.0
github.com/btcsuite/btcwallet/wallet/txrules v1.0.0
github.com/btcsuite/btcwallet/wallet/txsizes v1.0.0
github.com/btcsuite/btcwallet/walletdb v1.3.4
github.com/btcsuite/btcwallet/wtxmgr v1.2.0
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792
Expand Down
27 changes: 11 additions & 16 deletions rpc/legacyrpc/methods.go
Original file line number Diff line number Diff line change
Expand Up @@ -1293,20 +1293,14 @@ func listAllTransactions(icmd interface{}, w *wallet.Wallet) (interface{}, error
func listUnspent(icmd interface{}, w *wallet.Wallet) (interface{}, error) {
cmd := icmd.(*btcjson.ListUnspentCmd)

var addresses map[string]struct{}
if cmd.Addresses != nil {
addresses = make(map[string]struct{})
// confirm that all of them are good:
for _, as := range *cmd.Addresses {
a, err := decodeAddress(as, w.ChainParams())
if err != nil {
return nil, err
}
addresses[a.EncodeAddress()] = struct{}{}
if cmd.Addresses != nil && len(*cmd.Addresses) > 0 {
return nil, &btcjson.RPCError{
Code: btcjson.ErrRPCInvalidParameter,
Message: "Filtering by addresses has been deprecated",
}
}

return w.ListUnspent(int32(*cmd.MinConf), int32(*cmd.MaxConf), addresses)
return w.ListUnspent(int32(*cmd.MinConf), int32(*cmd.MaxConf), "")
}

// lockUnspent handles the lockunspent command.
Expand Down Expand Up @@ -1359,13 +1353,14 @@ func makeOutputs(pairs map[string]btcutil.Amount, chainParams *chaincfg.Params)
// It returns the transaction hash in string format upon success
// All errors are returned in btcjson.RPCError format
func sendPairs(w *wallet.Wallet, amounts map[string]btcutil.Amount,
account uint32, minconf int32, feeSatPerKb btcutil.Amount) (string, error) {
keyScope waddrmgr.KeyScope, account uint32, minconf int32,
Roasbeef marked this conversation as resolved.
Show resolved Hide resolved
feeSatPerKb btcutil.Amount) (string, error) {

outputs, err := makeOutputs(amounts, w.ChainParams())
if err != nil {
return "", err
}
tx, err := w.SendOutputs(outputs, account, minconf, feeSatPerKb, "")
tx, err := w.SendOutputs(outputs, &keyScope, account, minconf, feeSatPerKb, "")
if err != nil {
if err == txrules.ErrAmountNegative {
return "", ErrNeedPositiveAmount
Expand Down Expand Up @@ -1433,7 +1428,7 @@ func sendFrom(icmd interface{}, w *wallet.Wallet, chainClient *chain.RPCClient)
cmd.ToAddress: amt,
}

return sendPairs(w, pairs, account, minConf,
return sendPairs(w, pairs, waddrmgr.KeyScopeBIP0044, account, minConf,
txrules.DefaultRelayFeePerKb)
}

Expand Down Expand Up @@ -1475,7 +1470,7 @@ func sendMany(icmd interface{}, w *wallet.Wallet) (interface{}, error) {
pairs[k] = amt
}

return sendPairs(w, pairs, account, minConf, txrules.DefaultRelayFeePerKb)
return sendPairs(w, pairs, waddrmgr.KeyScopeBIP0044, account, minConf, txrules.DefaultRelayFeePerKb)
}

// sendToAddress handles a sendtoaddress RPC request by creating a new
Expand Down Expand Up @@ -1511,7 +1506,7 @@ func sendToAddress(icmd interface{}, w *wallet.Wallet) (interface{}, error) {
}

// sendtoaddress always spends from the default account, this matches bitcoind
return sendPairs(w, pairs, waddrmgr.DefaultAccountNum, 1,
return sendPairs(w, pairs, waddrmgr.KeyScopeBIP0044, waddrmgr.DefaultAccountNum, 1,
txrules.DefaultRelayFeePerKb)
}

Expand Down
2 changes: 1 addition & 1 deletion rpc/rpcserver/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,7 @@ func (s *walletServer) GetTransactions(ctx context.Context, req *pb.GetTransacti

_ = minRecentTxs

gtr, err := s.wallet.GetTransactions(startBlock, endBlock, ctx.Done())
gtr, err := s.wallet.GetTransactions(startBlock, endBlock, "", ctx.Done())
if err != nil {
return nil, translateError(err)
}
Expand Down
44 changes: 43 additions & 1 deletion waddrmgr/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,29 @@ func (m *Manager) watchOnly() bool {
return m.watchingOnly
}

// IsWatchOnlyAccount determines if the account with the given key scope is set
// up as watch-only.
func (m *Manager) IsWatchOnlyAccount(ns walletdb.ReadBucket, keyScope KeyScope,
account uint32) (bool, error) {

if m.WatchOnly() {
return true, nil
}

// Assume the default imported account has no private keys.
//
// TODO: Actually check whether it does.
if account == ImportedAddrAccount {
return true, nil
}

scopedMgr, err := m.FetchScopedKeyManager(keyScope)
if err != nil {
return false, err
}
return scopedMgr.IsWatchOnlyAccount(ns, account)
}

// lock performs a best try effort to remove and zero all secret keys associated
// with the address manager.
//
Expand Down Expand Up @@ -1218,7 +1241,7 @@ func (m *Manager) Unlock(ns walletdb.ReadBucket, passphrase []byte) error {
// We'll also derive any private keys that are pending due to
// them being created while the address manager was locked.
for _, info := range manager.deriveOnUnlock {
addressKey, _, err := manager.deriveKeyFromPath(
addressKey, _, _, err := manager.deriveKeyFromPath(
ns, info.managedAddr.InternalAccount(),
info.branch, info.index, true,
)
Expand Down Expand Up @@ -1276,6 +1299,25 @@ func ValidateAccountName(name string) error {
return nil
}

// LookupAccount returns the corresponding key scope and account number for the
// account with the given name.
func (m *Manager) LookupAccount(ns walletdb.ReadBucket, name string) (KeyScope,
uint32, error) {

m.mtx.RLock()
defer m.mtx.RUnlock()

for keyScope, scopedMgr := range m.scopedManagers {
acct, err := scopedMgr.LookupAccount(ns, name)
if err == nil {
return keyScope, acct, nil
}
}

str := fmt.Sprintf("account name '%s' not found", name)
return KeyScope{}, 0, managerError(ErrAccountNotFound, str, nil)
}

// selectCryptoKey selects the appropriate crypto key based on the key type. An
// error is returned when an invalid key type is specified or the requested key
// requires the manager to be unlocked when it isn't.
Expand Down
61 changes: 43 additions & 18 deletions waddrmgr/scoped_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ type DerivationPath struct {
// Index is the final child in the derivation path. This denotes the
// key index within as a child of the account and branch.
Index uint32

// MasterKeyFingerprint represents the fingerprint of the root key (also
// known as the key with derivation path m/) corresponding to the
// account public key. This may be required by some hardware wallets for
// proper identification and signing.
MasterKeyFingerprint uint32
}

// KeyScope represents a restricted key scope from the primary root key within
Expand Down Expand Up @@ -444,10 +450,11 @@ func (s *ScopedKeyManager) loadAccountInfo(ns walletdb.ReadBucket,
index--
}
lastExtAddrPath := DerivationPath{
InternalAccount: account,
Account: acctInfo.acctKeyPub.ChildIndex(),
Branch: branch,
Index: index,
InternalAccount: account,
Account: acctInfo.acctKeyPub.ChildIndex(),
Branch: branch,
Index: index,
MasterKeyFingerprint: acctInfo.masterKeyFingerprint,
}
lastExtKey, err := s.deriveKey(acctInfo, branch, index, hasPrivateKey)
if err != nil {
Expand All @@ -465,10 +472,11 @@ func (s *ScopedKeyManager) loadAccountInfo(ns walletdb.ReadBucket,
index--
}
lastIntAddrPath := DerivationPath{
InternalAccount: account,
Account: acctInfo.acctKeyPub.ChildIndex(),
Branch: branch,
Index: index,
InternalAccount: account,
Account: acctInfo.acctKeyPub.ChildIndex(),
Branch: branch,
Index: index,
MasterKeyFingerprint: acctInfo.masterKeyFingerprint,
}
lastIntKey, err := s.deriveKey(acctInfo, branch, index, hasPrivateKey)
if err != nil {
Expand Down Expand Up @@ -580,7 +588,7 @@ func (s *ScopedKeyManager) DeriveFromKeyPath(ns walletdb.ReadBucket,
watchOnly := s.rootManager.WatchOnly()
private := !s.rootManager.IsLocked() && !watchOnly

addrKey, _, err := s.deriveKeyFromPath(
addrKey, _, _, err := s.deriveKeyFromPath(
ns, kp.InternalAccount, kp.Branch, kp.Index, private,
)
if err != nil {
Expand All @@ -601,26 +609,26 @@ func (s *ScopedKeyManager) DeriveFromKeyPath(ns walletdb.ReadBucket,
// This function MUST be called with the manager lock held for writes.
func (s *ScopedKeyManager) deriveKeyFromPath(ns walletdb.ReadBucket,
internalAccount, branch, index uint32, private bool) (
*hdkeychain.ExtendedKey, *hdkeychain.ExtendedKey, error) {
*hdkeychain.ExtendedKey, *hdkeychain.ExtendedKey, uint32, error) {

// Look up the account key information.
acctInfo, err := s.loadAccountInfo(ns, internalAccount)
if err != nil {
return nil, nil, err
return nil, nil, 0, err
}
private = private && acctInfo.acctKeyPriv != nil

addrKey, err := s.deriveKey(acctInfo, branch, index, private)
if err != nil {
return nil, nil, err
return nil, nil, 0, err
}

acctKey := acctInfo.acctKeyPub
if private {
acctKey = acctInfo.acctKeyPriv
}

return addrKey, acctKey, nil
return addrKey, acctKey, acctInfo.masterKeyFingerprint, nil
}

// chainAddressRowToManaged returns a new managed address based on chained
Expand All @@ -634,7 +642,7 @@ func (s *ScopedKeyManager) chainAddressRowToManaged(ns walletdb.ReadBucket,
// function, we use the internal isLocked to avoid a deadlock.
private := !s.rootManager.isLocked() && !s.rootManager.watchOnly()

addressKey, acctKey, err := s.deriveKeyFromPath(
addressKey, acctKey, masterKeyFingerprint, err := s.deriveKeyFromPath(
ns, row.account, row.branch, row.index, private,
)
if err != nil {
Expand All @@ -647,10 +655,11 @@ func (s *ScopedKeyManager) chainAddressRowToManaged(ns walletdb.ReadBucket,
}
return s.keyToManaged(
addressKey, DerivationPath{
InternalAccount: row.account,
Account: acctKey.ChildIndex(),
Branch: row.branch,
Index: row.index,
InternalAccount: row.account,
Account: acctKey.ChildIndex(),
Branch: row.branch,
Index: row.index,
MasterKeyFingerprint: masterKeyFingerprint,
}, acctInfo,
)
}
Expand Down Expand Up @@ -2177,6 +2186,22 @@ func (s *ScopedKeyManager) ForEachInternalActiveAddress(ns walletdb.ReadBucket,
return nil
}

// IsWatchOnlyAccount determines if the given account belonging to this scoped
// manager is set up as watch-only.
func (s *ScopedKeyManager) IsWatchOnlyAccount(ns walletdb.ReadBucket,
account uint32) (bool, error) {

s.mtx.Lock()
defer s.mtx.Unlock()

acctInfo, err := s.loadAccountInfo(ns, account)
if err != nil {
return false, err
}

return acctInfo.acctKeyPriv == nil, nil
}

// cloneKeyWithVersion clones an extended key to use the version corresponding
// to the manager's key scope. This should only be used for non-watch-only
// accounts as they are stored within the database using the legacy BIP-0044
Expand Down
Loading