mirror of
https://github.com/sparrowwallet/sparrow.git
synced 2024-11-04 21:36:45 +00:00
support discovery of singlesig connected hardware wallet accounts
This commit is contained in:
parent
d3b1c51115
commit
180e76f0f8
7 changed files with 237 additions and 8 deletions
|
@ -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<List<StandardAccount>> {
|
|||
}
|
||||
|
||||
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());
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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<StandardAccount> 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<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) {
|
||||
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<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() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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<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 {
|
||||
try {
|
||||
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> {
|
||||
private final Device device;
|
||||
private final String passphrase;
|
||||
|
|
|
@ -1419,10 +1419,18 @@ public class ElectrumServer {
|
|||
public static class WalletDiscoveryService extends Service<List<StandardAccount>> {
|
||||
private final Wallet masterWalletCopy;
|
||||
private final List<StandardAccount> standardAccounts;
|
||||
private final Map<StandardAccount, Keystore> importedKeystores;
|
||||
|
||||
public WalletDiscoveryService(Wallet masterWallet, List<StandardAccount> standardAccounts) {
|
||||
this.masterWalletCopy = masterWallet.copy();
|
||||
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
|
||||
|
@ -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<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap = new TreeMap<>();
|
||||
electrumServer.getReferences(wallet, wallet.getNode(KeyPurpose.RECEIVE).getChildren(), nodeTransactionMap, 0);
|
||||
if(nodeTransactionMap.values().stream().anyMatch(blockTransactionHashes -> !blockTransactionHashes.isEmpty())) {
|
||||
|
|
|
@ -430,7 +430,8 @@ public class SettingsController extends WalletFormController implements Initiali
|
|||
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()) {
|
||||
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<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));
|
||||
}
|
||||
|
||||
saveChildWallets(masterWallet);
|
||||
}
|
||||
|
||||
private void saveChildWallets(Wallet masterWallet) {
|
||||
for(Wallet childWallet : masterWallet.getChildWallets()) {
|
||||
Storage storage = AppServices.get().getOpenWallets().get(childWallet);
|
||||
if(!storage.isPersisted(childWallet)) {
|
||||
|
|
Loading…
Reference in a new issue