support discovery of singlesig connected hardware wallet accounts

This commit is contained in:
Craig Raw 2021-10-29 11:22:34 +02:00
parent d3b1c51115
commit 180e76f0f8
7 changed files with 237 additions and 8 deletions

View file

@ -5,6 +5,8 @@ import com.sparrowwallet.drongo.wallet.StandardAccount;
import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.ServerType;
import com.sparrowwallet.sparrow.whirlpool.WhirlpoolServices; import com.sparrowwallet.sparrow.whirlpool.WhirlpoolServices;
import javafx.collections.FXCollections; import javafx.collections.FXCollections;
import javafx.scene.control.*; import javafx.scene.control.*;
@ -59,7 +61,9 @@ public class AddAccountDialog extends Dialog<List<StandardAccount>> {
} }
final ButtonType discoverButtonType = new javafx.scene.control.ButtonType("Discover", ButtonBar.ButtonData.LEFT); final ButtonType discoverButtonType = new javafx.scene.control.ButtonType("Discover", ButtonBar.ButtonData.LEFT);
if(!availableAccounts.isEmpty() && masterWallet.getKeystores().stream().allMatch(ks -> ks.getSource() == KeystoreSource.SW_SEED)) { if(!availableAccounts.isEmpty() && Config.get().getServerType() != ServerType.BITCOIN_CORE &&
(masterWallet.getKeystores().stream().allMatch(ks -> ks.getSource() == KeystoreSource.SW_SEED)
|| (masterWallet.getKeystores().size() == 1 && masterWallet.getKeystores().stream().allMatch(ks -> ks.getSource() == KeystoreSource.HW_USB)))) {
dialogPane.getButtonTypes().add(discoverButtonType); dialogPane.getButtonTypes().add(discoverButtonType);
Button discoverButton = (Button)dialogPane.lookupButton(discoverButtonType); Button discoverButton = (Button)dialogPane.lookupButton(discoverButtonType);
discoverButton.disableProperty().bind(AppServices.onlineProperty().not()); discoverButton.disableProperty().bind(AppServices.onlineProperty().not());

View file

@ -0,0 +1,39 @@
package com.sparrowwallet.sparrow.control;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.StandardAccount;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.KeystoresDiscoveredEvent;
import com.sparrowwallet.sparrow.io.Device;
import java.util.Collections;
import java.util.List;
import java.util.Map;
public class DeviceKeystoreDiscoverDialog extends DeviceDialog<Map<StandardAccount, Keystore>> {
private final Wallet masterWallet;
private final List<StandardAccount> availableAccounts;
public DeviceKeystoreDiscoverDialog(List<String> operationFingerprints, Wallet masterWallet, List<StandardAccount> availableAccounts) {
super(operationFingerprints);
this.masterWallet = masterWallet;
this.availableAccounts = availableAccounts;
EventManager.get().register(this);
setOnCloseRequest(event -> {
EventManager.get().unregister(this);
});
setResultConverter(dialogButton -> dialogButton.getButtonData().isCancelButton() ? null : Collections.emptyMap());
}
@Override
protected DevicePane getDevicePane(Device device, boolean defaultDevice) {
return new DevicePane(masterWallet, availableAccounts, device, defaultDevice);
}
@Subscribe
public void keystoresDiscovered(KeystoresDiscoveredEvent event) {
setResult(event.getDiscoveredKeystores());
}
}

View file

@ -10,12 +10,14 @@ import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.psbt.PSBT; import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.wallet.Keystore; import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.KeystoreSource; import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.StandardAccount;
import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.*; import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.Device; import com.sparrowwallet.sparrow.io.Device;
import com.sparrowwallet.sparrow.io.Hwi; import com.sparrowwallet.sparrow.io.Hwi;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.SimpleStringProperty;
import javafx.geometry.Insets; import javafx.geometry.Insets;
@ -33,8 +35,7 @@ import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.util.Arrays; import java.util.*;
import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
public class DevicePane extends TitledDescriptionPane { public class DevicePane extends TitledDescriptionPane {
@ -46,6 +47,7 @@ public class DevicePane extends TitledDescriptionPane {
private final OutputDescriptor outputDescriptor; private final OutputDescriptor outputDescriptor;
private final KeyDerivation keyDerivation; private final KeyDerivation keyDerivation;
private final String message; private final String message;
private final List<StandardAccount> availableAccounts;
private final Device device; private final Device device;
private CustomPasswordField pinField; private CustomPasswordField pinField;
@ -56,6 +58,7 @@ public class DevicePane extends TitledDescriptionPane {
private Button signButton; private Button signButton;
private Button displayAddressButton; private Button displayAddressButton;
private Button signMessageButton; private Button signMessageButton;
private Button discoverKeystoresButton;
private final SimpleStringProperty passphrase = new SimpleStringProperty(""); private final SimpleStringProperty passphrase = new SimpleStringProperty("");
@ -69,6 +72,7 @@ public class DevicePane extends TitledDescriptionPane {
this.outputDescriptor = null; this.outputDescriptor = null;
this.keyDerivation = requiredDerivation; this.keyDerivation = requiredDerivation;
this.message = null; this.message = null;
this.availableAccounts = null;
this.device = device; this.device = device;
this.defaultDevice = defaultDevice; this.defaultDevice = defaultDevice;
@ -91,6 +95,7 @@ public class DevicePane extends TitledDescriptionPane {
this.outputDescriptor = null; this.outputDescriptor = null;
this.keyDerivation = null; this.keyDerivation = null;
this.message = null; this.message = null;
this.availableAccounts = null;
this.device = device; this.device = device;
this.defaultDevice = defaultDevice; this.defaultDevice = defaultDevice;
@ -113,6 +118,7 @@ public class DevicePane extends TitledDescriptionPane {
this.outputDescriptor = outputDescriptor; this.outputDescriptor = outputDescriptor;
this.keyDerivation = null; this.keyDerivation = null;
this.message = null; this.message = null;
this.availableAccounts = null;
this.device = device; this.device = device;
this.defaultDevice = defaultDevice; this.defaultDevice = defaultDevice;
@ -135,6 +141,7 @@ public class DevicePane extends TitledDescriptionPane {
this.outputDescriptor = null; this.outputDescriptor = null;
this.keyDerivation = keyDerivation; this.keyDerivation = keyDerivation;
this.message = message; this.message = message;
this.availableAccounts = null;
this.device = device; this.device = device;
this.defaultDevice = defaultDevice; this.defaultDevice = defaultDevice;
@ -149,6 +156,29 @@ public class DevicePane extends TitledDescriptionPane {
buttonBox.getChildren().addAll(setPassphraseButton, signMessageButton); buttonBox.getChildren().addAll(setPassphraseButton, signMessageButton);
} }
public DevicePane(Wallet wallet, List<StandardAccount> availableAccounts, Device device, boolean defaultDevice) {
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png");
this.deviceOperation = DeviceOperation.DISCOVER_KEYSTORES;
this.wallet = wallet;
this.psbt = null;
this.outputDescriptor = null;
this.keyDerivation = null;
this.message = null;
this.device = device;
this.defaultDevice = defaultDevice;
this.availableAccounts = availableAccounts;
setDefaultStatus();
showHideLink.setVisible(false);
createSetPassphraseButton();
createDiscoverKeystoresButton();
initialise(device);
buttonBox.getChildren().addAll(setPassphraseButton, discoverKeystoresButton);
}
private void initialise(Device device) { private void initialise(Device device) {
if(device.isNeedsPinSent()) { if(device.isNeedsPinSent()) {
unlockButton.setDefaultButton(defaultDevice); unlockButton.setDefaultButton(defaultDevice);
@ -281,6 +311,17 @@ public class DevicePane extends TitledDescriptionPane {
} }
} }
private void createDiscoverKeystoresButton() {
discoverKeystoresButton = new Button("Discover");
discoverKeystoresButton.setAlignment(Pos.CENTER_RIGHT);
discoverKeystoresButton.setOnAction(event -> {
discoverKeystoresButton.setDisable(true);
discoverKeystores();
});
discoverKeystoresButton.managedProperty().bind(discoverKeystoresButton.visibleProperty());
discoverKeystoresButton.setVisible(false);
}
private void unlock(Device device) { private void unlock(Device device) {
if(device.getModel().requiresPinPrompt()) { if(device.getModel().requiresPinPrompt()) {
promptPin(); promptPin();
@ -620,6 +661,63 @@ public class DevicePane extends TitledDescriptionPane {
signMessageService.start(); signMessageService.start();
} }
private void discoverKeystores() {
if(wallet.getKeystores().size() != 1) {
setError("Could not discover keystores", "Only single signature wallets are supported for keystore discovery");
return;
}
String masterFingerprint = wallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint();
Wallet copyWallet = wallet.copy();
Map<StandardAccount, String> accountDerivationPaths = new LinkedHashMap<>();
for(StandardAccount availableAccount : availableAccounts) {
Wallet availableWallet = copyWallet.addChildWallet(availableAccount);
Keystore availableKeystore = availableWallet.getKeystores().get(0);
String derivationPath = availableKeystore.getKeyDerivation().getDerivationPath();
accountDerivationPaths.put(availableAccount, derivationPath);
}
Map<StandardAccount, Keystore> importedKeystores = new LinkedHashMap<>();
Hwi.GetXpubsService getXpubsService = new Hwi.GetXpubsService(device, passphrase.get(), accountDerivationPaths);
getXpubsService.setOnSucceeded(workerStateEvent -> {
Map<StandardAccount, String> accountXpubs = getXpubsService.getValue();
for(Map.Entry<StandardAccount, String> entry : accountXpubs.entrySet()) {
try {
Keystore keystore = new Keystore();
keystore.setLabel(device.getModel().toDisplayString());
keystore.setSource(KeystoreSource.HW_USB);
keystore.setWalletModel(device.getModel());
keystore.setKeyDerivation(new KeyDerivation(masterFingerprint, accountDerivationPaths.get(entry.getKey())));
keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(entry.getValue()));
importedKeystores.put(entry.getKey(), keystore);
} catch(Exception e) {
setError("Could not retrieve xpub", e.getMessage());
}
}
ElectrumServer.WalletDiscoveryService walletDiscoveryService = new ElectrumServer.WalletDiscoveryService(wallet, importedKeystores);
walletDiscoveryService.setOnSucceeded(event -> {
importedKeystores.keySet().retainAll(walletDiscoveryService.getValue());
EventManager.get().post(new KeystoresDiscoveredEvent(importedKeystores));
});
walletDiscoveryService.setOnFailed(event -> {
log.error("Failed to discover accounts", event.getSource().getException());
setError("Failed to discover accounts", event.getSource().getException().getMessage());
discoverKeystoresButton.setDisable(false);
});
walletDiscoveryService.start();
});
getXpubsService.setOnFailed(workerStateEvent -> {
setError("Could not retrieve xpub", getXpubsService.getException().getMessage());
discoverKeystoresButton.setDisable(false);
});
setDescription("Discovering...");
showHideLink.setVisible(false);
getXpubsService.start();
}
private void showOperationButton() { private void showOperationButton() {
if(deviceOperation.equals(DeviceOperation.IMPORT)) { if(deviceOperation.equals(DeviceOperation.IMPORT)) {
if(defaultDevice) { if(defaultDevice) {
@ -642,6 +740,10 @@ public class DevicePane extends TitledDescriptionPane {
signMessageButton.setDefaultButton(defaultDevice); signMessageButton.setDefaultButton(defaultDevice);
signMessageButton.setVisible(true); signMessageButton.setVisible(true);
showHideLink.setVisible(false); showHideLink.setVisible(false);
} else if(deviceOperation.equals(DeviceOperation.DISCOVER_KEYSTORES)) {
discoverKeystoresButton.setDefaultButton(defaultDevice);
discoverKeystoresButton.setVisible(true);
showHideLink.setVisible(false);
} }
} }
@ -689,6 +791,6 @@ public class DevicePane extends TitledDescriptionPane {
} }
public enum DeviceOperation { public enum DeviceOperation {
IMPORT, SIGN, DISPLAY_ADDRESS, SIGN_MESSAGE; IMPORT, SIGN, DISPLAY_ADDRESS, SIGN_MESSAGE, DISCOVER_KEYSTORES;
} }
} }

View file

@ -0,0 +1,18 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.StandardAccount;
import java.util.Map;
public class KeystoresDiscoveredEvent {
private final Map<StandardAccount, Keystore> discoveredKeystores;
public KeystoresDiscoveredEvent(Map<StandardAccount, Keystore> discoveredKeystores) {
this.discoveredKeystores = discoveredKeystores;
}
public Map<StandardAccount, Keystore> getDiscoveredKeystores() {
return discoveredKeystores;
}
}

View file

@ -8,6 +8,7 @@ import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.protocol.ScriptType; import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.psbt.PSBT; import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTParseException; import com.sparrowwallet.drongo.psbt.PSBTParseException;
import com.sparrowwallet.drongo.wallet.StandardAccount;
import com.sparrowwallet.drongo.wallet.WalletModel; import com.sparrowwallet.drongo.wallet.WalletModel;
import javafx.concurrent.ScheduledService; import javafx.concurrent.ScheduledService;
import javafx.concurrent.Service; import javafx.concurrent.Service;
@ -91,6 +92,15 @@ public class Hwi {
} }
} }
public Map<StandardAccount, String> getXpubs(Device device, String passphrase, Map<StandardAccount, String> accountDerivationPaths) throws ImportException {
Map<StandardAccount, String> accountXpubs = new LinkedHashMap<>();
for(Map.Entry<StandardAccount, String> entry : accountDerivationPaths.entrySet()) {
accountXpubs.put(entry.getKey(), getXpub(device, passphrase, entry.getValue()));
}
return accountXpubs;
}
public String getXpub(Device device, String passphrase, String derivationPath) throws ImportException { public String getXpub(Device device, String passphrase, String derivationPath) throws ImportException {
try { try {
String output; String output;
@ -580,6 +590,28 @@ public class Hwi {
} }
} }
public static class GetXpubsService extends Service<Map<StandardAccount, String>> {
private final Device device;
private final String passphrase;
private final Map<StandardAccount, String> accountDerivationPaths;
public GetXpubsService(Device device, String passphrase, Map<StandardAccount, String> accountDerivationPaths) {
this.device = device;
this.passphrase = passphrase;
this.accountDerivationPaths = accountDerivationPaths;
}
@Override
protected Task<Map<StandardAccount, String>> createTask() {
return new Task<>() {
protected Map<StandardAccount, String> call() throws ImportException {
Hwi hwi = new Hwi();
return hwi.getXpubs(device, passphrase, accountDerivationPaths);
}
};
}
}
public static class SignPSBTService extends Service<PSBT> { public static class SignPSBTService extends Service<PSBT> {
private final Device device; private final Device device;
private final String passphrase; private final String passphrase;

View file

@ -1419,10 +1419,18 @@ public class ElectrumServer {
public static class WalletDiscoveryService extends Service<List<StandardAccount>> { public static class WalletDiscoveryService extends Service<List<StandardAccount>> {
private final Wallet masterWalletCopy; private final Wallet masterWalletCopy;
private final List<StandardAccount> standardAccounts; private final List<StandardAccount> standardAccounts;
private final Map<StandardAccount, Keystore> importedKeystores;
public WalletDiscoveryService(Wallet masterWallet, List<StandardAccount> standardAccounts) { public WalletDiscoveryService(Wallet masterWallet, List<StandardAccount> standardAccounts) {
this.masterWalletCopy = masterWallet.copy(); this.masterWalletCopy = masterWallet.copy();
this.standardAccounts = standardAccounts; this.standardAccounts = standardAccounts;
this.importedKeystores = new HashMap<>();
}
public WalletDiscoveryService(Wallet masterWallet, Map<StandardAccount, Keystore> importedKeystores) {
this.masterWalletCopy = masterWallet.copy();
this.standardAccounts = new ArrayList<>(importedKeystores.keySet());
this.importedKeystores = importedKeystores;
} }
@Override @Override
@ -1434,6 +1442,11 @@ public class ElectrumServer {
for(StandardAccount standardAccount : standardAccounts) { for(StandardAccount standardAccount : standardAccounts) {
Wallet wallet = masterWalletCopy.addChildWallet(standardAccount); Wallet wallet = masterWalletCopy.addChildWallet(standardAccount);
if(importedKeystores.containsKey(standardAccount)) {
wallet.getKeystores().clear();
wallet.getKeystores().add(importedKeystores.get(standardAccount));
}
Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap = new TreeMap<>(); Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap = new TreeMap<>();
electrumServer.getReferences(wallet, wallet.getNode(KeyPurpose.RECEIVE).getChildren(), nodeTransactionMap, 0); electrumServer.getReferences(wallet, wallet.getNode(KeyPurpose.RECEIVE).getChildren(), nodeTransactionMap, 0);
if(nodeTransactionMap.values().stream().anyMatch(blockTransactionHashes -> !blockTransactionHashes.isEmpty())) { if(nodeTransactionMap.values().stream().anyMatch(blockTransactionHashes -> !blockTransactionHashes.isEmpty())) {

View file

@ -430,7 +430,8 @@ public class SettingsController extends WalletFormController implements Initiali
throw new IllegalStateException("Cannot export unsaved wallet"); throw new IllegalStateException("Cannot export unsaved wallet");
} }
Optional<Wallet> optWallet = AppServices.get().getOpenWallets().entrySet().stream().filter(entry -> walletForm.getWalletFile().equals(entry.getValue().getWalletFile())).map(Map.Entry::getKey).findFirst(); Optional<Wallet> optWallet = AppServices.get().getOpenWallets().entrySet().stream()
.filter(entry -> walletForm.getWalletFile().equals(entry.getValue().getWalletFile()) && entry.getKey().isMasterWallet()).map(Map.Entry::getKey).findFirst();
if(optWallet.isPresent()) { if(optWallet.isPresent()) {
Wallet wallet = optWallet.get(); Wallet wallet = optWallet.get();
if(!walletForm.getWallet().getName().equals(wallet.getName())) { if(!walletForm.getWallet().getName().equals(wallet.getName())) {
@ -524,9 +525,25 @@ public class SettingsController extends WalletFormController implements Initiali
} }
} }
} else { } else {
for(StandardAccount standardAccount : standardAccounts) { if(discoverAccounts && masterWallet.getKeystores().size() == 1 && masterWallet.getKeystores().stream().allMatch(ks -> ks.getSource() == KeystoreSource.HW_USB)) {
Wallet childWallet = masterWallet.addChildWallet(standardAccount); String fingerprint = masterWallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint();
EventManager.get().post(new ChildWalletAddedEvent(getWalletForm().getStorage(), masterWallet, childWallet)); DeviceKeystoreDiscoverDialog deviceKeystoreDiscoverDialog = new DeviceKeystoreDiscoverDialog(List.of(fingerprint), masterWallet, standardAccounts);
Optional<Map<StandardAccount, Keystore>> optDiscoveredKeystores = deviceKeystoreDiscoverDialog.showAndWait();
if(optDiscoveredKeystores.isPresent()) {
Map<StandardAccount, Keystore> discoveredKeystores = optDiscoveredKeystores.get();
for(Map.Entry<StandardAccount, Keystore> entry : discoveredKeystores.entrySet()) {
Wallet childWallet = masterWallet.addChildWallet(entry.getKey());
childWallet.getKeystores().clear();
childWallet.getKeystores().add(entry.getValue());
EventManager.get().post(new ChildWalletAddedEvent(getWalletForm().getStorage(), masterWallet, childWallet));
}
saveChildWallets(masterWallet);
}
} else {
for(StandardAccount standardAccount : standardAccounts) {
Wallet childWallet = masterWallet.addChildWallet(standardAccount);
EventManager.get().post(new ChildWalletAddedEvent(getWalletForm().getStorage(), masterWallet, childWallet));
}
} }
} }
} }
@ -559,6 +576,10 @@ public class SettingsController extends WalletFormController implements Initiali
EventManager.get().post(new ChildWalletAddedEvent(getWalletForm().getStorage(), masterWallet, childWallet)); EventManager.get().post(new ChildWalletAddedEvent(getWalletForm().getStorage(), masterWallet, childWallet));
} }
saveChildWallets(masterWallet);
}
private void saveChildWallets(Wallet masterWallet) {
for(Wallet childWallet : masterWallet.getChildWallets()) { for(Wallet childWallet : masterWallet.getChildWallets()) {
Storage storage = AppServices.get().getOpenWallets().get(childWallet); Storage storage = AppServices.get().getOpenWallets().get(childWallet);
if(!storage.isPersisted(childWallet)) { if(!storage.isPersisted(childWallet)) {