diff --git a/src/main/java/com/sparrowwallet/sparrow/control/AddAccountDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/AddAccountDialog.java index 0632fd4c..e5ca895c 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/AddAccountDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/AddAccountDialog.java @@ -5,6 +5,8 @@ import com.sparrowwallet.drongo.wallet.StandardAccount; import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.sparrow.AppServices; 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 javafx.collections.FXCollections; import javafx.scene.control.*; @@ -59,7 +61,9 @@ public class AddAccountDialog extends Dialog> { } 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); Button discoverButton = (Button)dialogPane.lookupButton(discoverButtonType); discoverButton.disableProperty().bind(AppServices.onlineProperty().not()); diff --git a/src/main/java/com/sparrowwallet/sparrow/control/DeviceKeystoreDiscoverDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/DeviceKeystoreDiscoverDialog.java new file mode 100644 index 00000000..de9b44f9 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/control/DeviceKeystoreDiscoverDialog.java @@ -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> { + private final Wallet masterWallet; + private final List availableAccounts; + + public DeviceKeystoreDiscoverDialog(List operationFingerprints, Wallet masterWallet, List 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()); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/control/DevicePane.java b/src/main/java/com/sparrowwallet/sparrow/control/DevicePane.java index 792aebab..95a39af7 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/DevicePane.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/DevicePane.java @@ -10,12 +10,14 @@ import com.sparrowwallet.drongo.protocol.ScriptType; import com.sparrowwallet.drongo.psbt.PSBT; import com.sparrowwallet.drongo.wallet.Keystore; import com.sparrowwallet.drongo.wallet.KeystoreSource; +import com.sparrowwallet.drongo.wallet.StandardAccount; import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.event.*; import com.sparrowwallet.sparrow.io.Device; import com.sparrowwallet.sparrow.io.Hwi; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; +import com.sparrowwallet.sparrow.net.ElectrumServer; import javafx.application.Platform; import javafx.beans.property.SimpleStringProperty; import javafx.geometry.Insets; @@ -33,8 +35,7 @@ import org.controlsfx.validation.decoration.StyleClassValidationDecoration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.Arrays; -import java.util.List; +import java.util.*; import java.util.stream.Collectors; public class DevicePane extends TitledDescriptionPane { @@ -46,6 +47,7 @@ public class DevicePane extends TitledDescriptionPane { private final OutputDescriptor outputDescriptor; private final KeyDerivation keyDerivation; private final String message; + private final List availableAccounts; private final Device device; private CustomPasswordField pinField; @@ -56,6 +58,7 @@ public class DevicePane extends TitledDescriptionPane { private Button signButton; private Button displayAddressButton; private Button signMessageButton; + private Button discoverKeystoresButton; private final SimpleStringProperty passphrase = new SimpleStringProperty(""); @@ -69,6 +72,7 @@ public class DevicePane extends TitledDescriptionPane { this.outputDescriptor = null; this.keyDerivation = requiredDerivation; this.message = null; + this.availableAccounts = null; this.device = device; this.defaultDevice = defaultDevice; @@ -91,6 +95,7 @@ public class DevicePane extends TitledDescriptionPane { this.outputDescriptor = null; this.keyDerivation = null; this.message = null; + this.availableAccounts = null; this.device = device; this.defaultDevice = defaultDevice; @@ -113,6 +118,7 @@ public class DevicePane extends TitledDescriptionPane { this.outputDescriptor = outputDescriptor; this.keyDerivation = null; this.message = null; + this.availableAccounts = null; this.device = device; this.defaultDevice = defaultDevice; @@ -135,6 +141,7 @@ public class DevicePane extends TitledDescriptionPane { this.outputDescriptor = null; this.keyDerivation = keyDerivation; this.message = message; + this.availableAccounts = null; this.device = device; this.defaultDevice = defaultDevice; @@ -149,6 +156,29 @@ public class DevicePane extends TitledDescriptionPane { buttonBox.getChildren().addAll(setPassphraseButton, signMessageButton); } + public DevicePane(Wallet wallet, List 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) { if(device.isNeedsPinSent()) { 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) { if(device.getModel().requiresPinPrompt()) { promptPin(); @@ -620,6 +661,63 @@ public class DevicePane extends TitledDescriptionPane { 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 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 importedKeystores = new LinkedHashMap<>(); + Hwi.GetXpubsService getXpubsService = new Hwi.GetXpubsService(device, passphrase.get(), accountDerivationPaths); + getXpubsService.setOnSucceeded(workerStateEvent -> { + Map accountXpubs = getXpubsService.getValue(); + + for(Map.Entry 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() { if(deviceOperation.equals(DeviceOperation.IMPORT)) { if(defaultDevice) { @@ -642,6 +740,10 @@ public class DevicePane extends TitledDescriptionPane { signMessageButton.setDefaultButton(defaultDevice); signMessageButton.setVisible(true); 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 { - IMPORT, SIGN, DISPLAY_ADDRESS, SIGN_MESSAGE; + IMPORT, SIGN, DISPLAY_ADDRESS, SIGN_MESSAGE, DISCOVER_KEYSTORES; } } diff --git a/src/main/java/com/sparrowwallet/sparrow/event/KeystoresDiscoveredEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/KeystoresDiscoveredEvent.java new file mode 100644 index 00000000..f16ca528 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/event/KeystoresDiscoveredEvent.java @@ -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 discoveredKeystores; + + public KeystoresDiscoveredEvent(Map discoveredKeystores) { + this.discoveredKeystores = discoveredKeystores; + } + + public Map getDiscoveredKeystores() { + return discoveredKeystores; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/Hwi.java b/src/main/java/com/sparrowwallet/sparrow/io/Hwi.java index 2296b8b2..d1e5e771 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/Hwi.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/Hwi.java @@ -8,6 +8,7 @@ import com.sparrowwallet.drongo.OutputDescriptor; import com.sparrowwallet.drongo.protocol.ScriptType; import com.sparrowwallet.drongo.psbt.PSBT; import com.sparrowwallet.drongo.psbt.PSBTParseException; +import com.sparrowwallet.drongo.wallet.StandardAccount; import com.sparrowwallet.drongo.wallet.WalletModel; import javafx.concurrent.ScheduledService; import javafx.concurrent.Service; @@ -91,6 +92,15 @@ public class Hwi { } } + public Map getXpubs(Device device, String passphrase, Map accountDerivationPaths) throws ImportException { + Map accountXpubs = new LinkedHashMap<>(); + for(Map.Entry 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 { try { String output; @@ -580,6 +590,28 @@ public class Hwi { } } + public static class GetXpubsService extends Service> { + private final Device device; + private final String passphrase; + private final Map accountDerivationPaths; + + public GetXpubsService(Device device, String passphrase, Map accountDerivationPaths) { + this.device = device; + this.passphrase = passphrase; + this.accountDerivationPaths = accountDerivationPaths; + } + + @Override + protected Task> createTask() { + return new Task<>() { + protected Map call() throws ImportException { + Hwi hwi = new Hwi(); + return hwi.getXpubs(device, passphrase, accountDerivationPaths); + } + }; + } + } + public static class SignPSBTService extends Service { private final Device device; private final String passphrase; diff --git a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java index 12c292fe..b961b98c 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java @@ -1419,10 +1419,18 @@ public class ElectrumServer { public static class WalletDiscoveryService extends Service> { private final Wallet masterWalletCopy; private final List standardAccounts; + private final Map importedKeystores; public WalletDiscoveryService(Wallet masterWallet, List standardAccounts) { this.masterWalletCopy = masterWallet.copy(); this.standardAccounts = standardAccounts; + this.importedKeystores = new HashMap<>(); + } + + public WalletDiscoveryService(Wallet masterWallet, Map importedKeystores) { + this.masterWalletCopy = masterWallet.copy(); + this.standardAccounts = new ArrayList<>(importedKeystores.keySet()); + this.importedKeystores = importedKeystores; } @Override @@ -1434,6 +1442,11 @@ public class ElectrumServer { for(StandardAccount standardAccount : standardAccounts) { Wallet wallet = masterWalletCopy.addChildWallet(standardAccount); + if(importedKeystores.containsKey(standardAccount)) { + wallet.getKeystores().clear(); + wallet.getKeystores().add(importedKeystores.get(standardAccount)); + } + Map> nodeTransactionMap = new TreeMap<>(); electrumServer.getReferences(wallet, wallet.getNode(KeyPurpose.RECEIVE).getChildren(), nodeTransactionMap, 0); if(nodeTransactionMap.values().stream().anyMatch(blockTransactionHashes -> !blockTransactionHashes.isEmpty())) { diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsController.java index b968336c..add4e8a2 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsController.java @@ -430,7 +430,8 @@ public class SettingsController extends WalletFormController implements Initiali throw new IllegalStateException("Cannot export unsaved wallet"); } - Optional optWallet = AppServices.get().getOpenWallets().entrySet().stream().filter(entry -> walletForm.getWalletFile().equals(entry.getValue().getWalletFile())).map(Map.Entry::getKey).findFirst(); + Optional 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()) { Wallet wallet = optWallet.get(); if(!walletForm.getWallet().getName().equals(wallet.getName())) { @@ -524,9 +525,25 @@ public class SettingsController extends WalletFormController implements Initiali } } } else { - for(StandardAccount standardAccount : standardAccounts) { - Wallet childWallet = masterWallet.addChildWallet(standardAccount); - EventManager.get().post(new ChildWalletAddedEvent(getWalletForm().getStorage(), masterWallet, childWallet)); + if(discoverAccounts && masterWallet.getKeystores().size() == 1 && masterWallet.getKeystores().stream().allMatch(ks -> ks.getSource() == KeystoreSource.HW_USB)) { + String fingerprint = masterWallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint(); + DeviceKeystoreDiscoverDialog deviceKeystoreDiscoverDialog = new DeviceKeystoreDiscoverDialog(List.of(fingerprint), masterWallet, standardAccounts); + Optional> optDiscoveredKeystores = deviceKeystoreDiscoverDialog.showAndWait(); + if(optDiscoveredKeystores.isPresent()) { + Map discoveredKeystores = optDiscoveredKeystores.get(); + for(Map.Entry 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)); } + saveChildWallets(masterWallet); + } + + private void saveChildWallets(Wallet masterWallet) { for(Wallet childWallet : masterWallet.getChildWallets()) { Storage storage = AppServices.get().getOpenWallets().get(childWallet); if(!storage.isPersisted(childWallet)) {