diff --git a/src/main/java/com/sparrowwallet/sparrow/AppController.java b/src/main/java/com/sparrowwallet/sparrow/AppController.java index fb30b375..3ad0027f 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppController.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppController.java @@ -896,7 +896,8 @@ public class AppController implements Initializable { new Electrum(), new SpecterDesktop(), new CoboVaultSinglesig(), new CoboVaultMultisig(), - new PassportSinglesig()); + new PassportSinglesig(), + new KeystoneSinglesig(), new KeystoneMultisig()); for(WalletImport importer : walletImporters) { try(FileInputStream inputStream = new FileInputStream(file)) { if(importer.isEncrypted(file) && password == null) { diff --git a/src/main/java/com/sparrowwallet/sparrow/control/FileImportPane.java b/src/main/java/com/sparrowwallet/sparrow/control/FileImportPane.java index 2c6e1ec1..6d2919c0 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/FileImportPane.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/FileImportPane.java @@ -4,6 +4,7 @@ import com.google.gson.JsonParseException; import com.sparrowwallet.drongo.crypto.InvalidPasswordException; import com.sparrowwallet.drongo.protocol.ScriptType; import com.sparrowwallet.drongo.wallet.Keystore; +import com.sparrowwallet.drongo.wallet.KeystoreSource; import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; @@ -172,11 +173,15 @@ public abstract class FileImportPane extends TitledDescriptionPane { if(wallets != null) { for(Wallet wallet : wallets) { if(scriptType.equals(wallet.getScriptType()) && !wallet.getKeystores().isEmpty()) { - return wallet.getKeystores().get(0); + Keystore keystore = wallet.getKeystores().get(0); + keystore.setLabel(importer.getName().replace(" Multisig", "")); + keystore.setSource(KeystoreSource.HW_AIRGAPPED); + keystore.setWalletModel(importer.getWalletModel()); + return keystore; } } - throw new ImportException("Script type " + scriptType + " is not supported"); + throw new ImportException("Script type " + scriptType.getDescription() + " is not supported in this QR. Check you are displaying the correct QR code."); } return null; diff --git a/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java index 27d81dd2..6670a9d3 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java @@ -180,7 +180,7 @@ public class QRScanDialog extends Dialog { } } else { decoder.receivePart(qrtext); - Platform.runLater(() -> percentComplete.setValue(decoder.getEstimatedPercentComplete())); + Platform.runLater(() -> percentComplete.setValue(decoder.getProcessedPartsCount() > 0 ? decoder.getEstimatedPercentComplete() : 0)); if(decoder.getResult() != null) { URDecoder.Result urResult = decoder.getResult(); @@ -469,7 +469,7 @@ public class QRScanDialog extends Dialog { ExtendedKey extendedKey = outputDescriptor.getSingletonExtendedPublicKey(); wallet.setScriptType(outputDescriptor.getScriptType()); Keystore keystore = new Keystore(); - keystore.setKeyDerivation(new KeyDerivation(masterFingerprint, outputDescriptor.getKeyDerivation(extendedKey).getDerivationPath())); + keystore.setKeyDerivation(new KeyDerivation(masterFingerprint, KeyDerivation.writePath(outputDescriptor.getKeyDerivation(extendedKey).getDerivation()))); keystore.setExtendedPublicKey(extendedKey); wallet.getKeystores().add(keystore); wallets.add(wallet); diff --git a/src/main/java/com/sparrowwallet/sparrow/control/WalletExportDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/WalletExportDialog.java index f52eb4df..aea52bf5 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/WalletExportDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/WalletExportDialog.java @@ -43,7 +43,7 @@ public class WalletExportDialog extends Dialog { if(wallet.getPolicyType() == PolicyType.SINGLE) { exporters = List.of(new Electrum(), new SpecterDesktop(), new Sparrow()); } else if(wallet.getPolicyType() == PolicyType.MULTI) { - exporters = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new SpecterDIY(), new Sparrow()); + exporters = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new KeystoneMultisig(), new PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new SpecterDIY(), new Sparrow()); } else { throw new UnsupportedOperationException("Cannot export wallet with policy type " + wallet.getPolicyType()); } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/WalletImportDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/WalletImportDialog.java index 51e3177d..b00c002e 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/WalletImportDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/WalletImportDialog.java @@ -47,13 +47,13 @@ public class WalletImportDialog extends Dialog { AnchorPane.setRightAnchor(scrollPane, 0.0); importAccordion = new Accordion(); - List keystoreImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new PassportSinglesig()); + List keystoreImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new KeystoneSinglesig(), new PassportSinglesig()); for(KeystoreFileImport importer : keystoreImporters) { FileWalletKeystoreImportPane importPane = new FileWalletKeystoreImportPane(importer); importAccordion.getPanes().add(importPane); } - List walletImporters = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new SpecterDesktop(), new BlueWalletMultisig()); + List walletImporters = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new KeystoneMultisig(), new SpecterDesktop(), new BlueWalletMultisig()); for(WalletImport importer : walletImporters) { FileWalletImportPane importPane = new FileWalletImportPane(importer); importAccordion.getPanes().add(importPane); diff --git a/src/main/java/com/sparrowwallet/sparrow/io/KeystoneMultisig.java b/src/main/java/com/sparrowwallet/sparrow/io/KeystoneMultisig.java new file mode 100644 index 00000000..16d49f1a --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/KeystoneMultisig.java @@ -0,0 +1,70 @@ +package com.sparrowwallet.sparrow.io; + +import com.sparrowwallet.drongo.protocol.ScriptType; +import com.sparrowwallet.drongo.wallet.Keystore; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.drongo.wallet.WalletModel; + +import java.io.InputStream; + +public class KeystoneMultisig extends ColdcardMultisig { + @Override + public String getName() { + return "Keystone Multisig"; + } + + @Override + public WalletModel getWalletModel() { + return WalletModel.KEYSTONE; + } + + @Override + public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException { + Keystore keystore = super.getKeystore(scriptType, inputStream, password); + keystore.setLabel("Keystone"); + keystore.setWalletModel(getWalletModel()); + + return keystore; + } + + @Override + public String getKeystoreImportDescription() { + return "Import file or QR created by using the Multisig Wallet > ... > Show/Export XPUB feature on your Keystone."; + } + + @Override + public Wallet importWallet(InputStream inputStream, String password) throws ImportException { + Wallet wallet = super.importWallet(inputStream, password); + for(Keystore keystore : wallet.getKeystores()) { + keystore.setLabel(keystore.getLabel().replace("Coldcard", "Keystone")); + keystore.setWalletModel(WalletModel.KEYSTONE); + } + + return wallet; + } + + @Override + public String getWalletImportDescription() { + return "Import file or QR created by using the Multisig Wallet > ... > Create Multisig Wallet feature on your Keystone."; + } + + @Override + public String getWalletExportDescription() { + return "Export file or QR that can be read by your Keystone using the Multisig Wallet > ... > Import Multisig Wallet feature."; + } + + @Override + public boolean isWalletImportScannable() { + return true; + } + + @Override + public boolean isKeystoreImportScannable() { + return true; + } + + @Override + public boolean isWalletExportScannable() { + return true; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/KeystoneSinglesig.java b/src/main/java/com/sparrowwallet/sparrow/io/KeystoneSinglesig.java new file mode 100644 index 00000000..0a1149c0 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/KeystoneSinglesig.java @@ -0,0 +1,109 @@ +package com.sparrowwallet.sparrow.io; + +import com.google.gson.Gson; +import com.sparrowwallet.drongo.ExtendedKey; +import com.sparrowwallet.drongo.KeyDerivation; +import com.sparrowwallet.drongo.OutputDescriptor; +import com.sparrowwallet.drongo.policy.Policy; +import com.sparrowwallet.drongo.policy.PolicyType; +import com.sparrowwallet.drongo.protocol.ScriptType; +import com.sparrowwallet.drongo.wallet.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedReader; +import java.io.File; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.stream.Collectors; + +public class KeystoneSinglesig implements KeystoreFileImport, WalletImport { + private static final Logger log = LoggerFactory.getLogger(KeystoneSinglesig.class); + + @Override + public String getName() { + return "Keystone"; + } + + @Override + public String getKeystoreImportDescription() { + return "Import file or QR created by using the My Keystone > ... > Export Wallet feature on your Keystone. Make sure to set the Watch-only Wallet to Sparrow in the Settings first."; + } + + @Override + public WalletModel getWalletModel() { + return WalletModel.KEYSTONE; + } + + @Override + public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException { + try { + String outputDescriptor = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8)).lines().collect(Collectors.joining("\n")); + OutputDescriptor descriptor = OutputDescriptor.getOutputDescriptor(outputDescriptor); + + if(descriptor.isMultisig()) { + throw new IllegalArgumentException("Output descriptor describes a multisig wallet"); + } + + if(descriptor.getScriptType() != scriptType) { + throw new IllegalArgumentException("Output descriptor describes a " + descriptor.getScriptType().getDescription() + " wallet"); + } + + ExtendedKey xpub = descriptor.getSingletonExtendedPublicKey(); + KeyDerivation keyDerivation = descriptor.getKeyDerivation(xpub); + + Keystore keystore = new Keystore(); + keystore.setLabel(getName()); + keystore.setSource(KeystoreSource.HW_AIRGAPPED); + keystore.setWalletModel(WalletModel.KEYSTONE); + keystore.setKeyDerivation(keyDerivation); + keystore.setExtendedPublicKey(xpub); + + return keystore; + } catch (Exception e) { + log.error("Error getting Keystone keystore", e); + throw new ImportException("Error getting Keystone keystore", e); + } + } + + @Override + public String getWalletImportDescription() { + return getKeystoreImportDescription(); + } + + @Override + public Wallet importWallet(InputStream inputStream, String password) throws ImportException { + //Use default of P2WPKH + Keystore keystore = getKeystore(ScriptType.P2WPKH, inputStream, ""); + + Wallet wallet = new Wallet(); + wallet.setPolicyType(PolicyType.SINGLE); + wallet.setScriptType(ScriptType.P2WPKH); + wallet.getKeystores().add(keystore); + wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2WPKH, wallet.getKeystores(), null)); + + try { + wallet.checkWallet(); + } catch(InvalidWalletException e) { + throw new ImportException("Imported Keystone wallet was invalid: " + e.getMessage()); + } + + return wallet; + } + + @Override + public boolean isEncrypted(File file) { + return false; + } + + @Override + public boolean isWalletImportScannable() { + return true; + } + + @Override + public boolean isKeystoreImportScannable() { + return true; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/keystoreimport/HwAirgappedController.java b/src/main/java/com/sparrowwallet/sparrow/keystoreimport/HwAirgappedController.java index 37e68084..16432a65 100644 --- a/src/main/java/com/sparrowwallet/sparrow/keystoreimport/HwAirgappedController.java +++ b/src/main/java/com/sparrowwallet/sparrow/keystoreimport/HwAirgappedController.java @@ -16,9 +16,9 @@ public class HwAirgappedController extends KeystoreImportDetailController { public void initializeView() { List importers = Collections.emptyList(); if(getMasterController().getWallet().getPolicyType().equals(PolicyType.SINGLE)) { - importers = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new PassportSinglesig(), new SpecterDIY()); + importers = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new KeystoneSinglesig(), new PassportSinglesig(), new SpecterDIY()); } else if(getMasterController().getWallet().getPolicyType().equals(PolicyType.MULTI)) { - importers = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new PassportMultisig(), new SpecterDIY()); + importers = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new KeystoneMultisig(), new PassportMultisig(), new SpecterDIY()); } for(KeystoreImport importer : importers) { diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsController.java index c8e11d21..b3eedb94 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsController.java @@ -451,10 +451,16 @@ public class SettingsController extends WalletFormController implements Initiali } @Subscribe - public void walletAddressesChanged(WalletAddressesChangedEvent event) { + public void walletSettingsChanged(WalletSettingsChangedEvent event) { if(event.getWalletId().equals(walletForm.getWalletId())) { export.setDisable(!event.getWallet().isValid()); scanDescriptorQR.setVisible(!event.getWallet().isValid()); + } + } + + @Subscribe + public void walletAddressesChanged(WalletAddressesChangedEvent event) { + if(event.getWalletId().equals(walletForm.getWalletId())) { updateBirthDate(event.getWallet()); } } diff --git a/src/main/resources/image/keystone.png b/src/main/resources/image/keystone.png new file mode 100644 index 00000000..b2b20445 Binary files /dev/null and b/src/main/resources/image/keystone.png differ diff --git a/src/main/resources/image/keystone@2x.png b/src/main/resources/image/keystone@2x.png new file mode 100644 index 00000000..ddf04df8 Binary files /dev/null and b/src/main/resources/image/keystone@2x.png differ diff --git a/src/main/resources/image/keystone@3x.png b/src/main/resources/image/keystone@3x.png new file mode 100644 index 00000000..2aaf7caa Binary files /dev/null and b/src/main/resources/image/keystone@3x.png differ diff --git a/src/test/java/com/sparrowwallet/sparrow/io/KeystoneSinglesigTest.java b/src/test/java/com/sparrowwallet/sparrow/io/KeystoneSinglesigTest.java new file mode 100644 index 00000000..8690b0d6 --- /dev/null +++ b/src/test/java/com/sparrowwallet/sparrow/io/KeystoneSinglesigTest.java @@ -0,0 +1,33 @@ +package com.sparrowwallet.sparrow.io; + +import com.sparrowwallet.drongo.ExtendedKey; +import com.sparrowwallet.drongo.protocol.ScriptType; +import com.sparrowwallet.drongo.wallet.Keystore; +import org.junit.Assert; +import org.junit.Test; + +public class KeystoneSinglesigTest extends IoTest { + @Test + public void testImport() throws ImportException { + KeystoneSinglesig keystoneSingleSig = new KeystoneSinglesig(); + Keystore keystore = keystoneSingleSig.getKeystore(ScriptType.P2WPKH, getInputStream("keystone-singlesig-keystore-1.txt"), null); + + Assert.assertEquals("Keystone", keystore.getLabel()); + Assert.assertEquals("m/84'/0'/0'", keystore.getKeyDerivation().getDerivationPath()); + Assert.assertEquals("5271c071", keystore.getKeyDerivation().getMasterFingerprint()); + Assert.assertEquals(ExtendedKey.fromDescriptor("zpub6rcabYFcdr41zyUNRWRyHYs2Sm86E5XV8RjjRzTFYsiCngteeZnkwaF2xuhjmM6kpHjuNpFW42BMhzPmFwXt48e1FhddMB7xidZzN4SF24K"), keystore.getExtendedPublicKey()); + Assert.assertTrue(keystore.isValid()); + } + + @Test(expected = ImportException.class) + public void testIncorrectScriptType() throws ImportException { + KeystoneSinglesig keystoneSingleSig = new KeystoneSinglesig(); + Keystore keystore = keystoneSingleSig.getKeystore(ScriptType.P2SH_P2WPKH, getInputStream("keystone-singlesig-keystore-1.txt"), null); + + Assert.assertEquals("Keystone", keystore.getLabel()); + Assert.assertEquals("m/84'/0'/0'", keystore.getKeyDerivation().getDerivationPath()); + Assert.assertEquals("5271c071", keystore.getKeyDerivation().getMasterFingerprint()); + Assert.assertEquals(ExtendedKey.fromDescriptor("zpub6rcabYFcdr41zyUNRWRyHYs2Sm86E5XV8RjjRzTFYsiCngteeZnkwaF2xuhjmM6kpHjuNpFW42BMhzPmFwXt48e1FhddMB7xidZzN4SF24K"), keystore.getExtendedPublicKey()); + Assert.assertTrue(keystore.isValid()); + } +} diff --git a/src/test/resources/com/sparrowwallet/sparrow/io/keystone-singlesig-keystore-1.txt b/src/test/resources/com/sparrowwallet/sparrow/io/keystone-singlesig-keystore-1.txt new file mode 100644 index 00000000..84a3d5a6 --- /dev/null +++ b/src/test/resources/com/sparrowwallet/sparrow/io/keystone-singlesig-keystore-1.txt @@ -0,0 +1 @@ +wpkh([5271C071/84'/0'/0']zpub6rcabYFcdr41zyUNRWRyHYs2Sm86E5XV8RjjRzTFYsiCngteeZnkwaF2xuhjmM6kpHjuNpFW42BMhzPmFwXt48e1FhddMB7xidZzN4SF24K/1/*) \ No newline at end of file