diff --git a/src/main/java/com/sparrowwallet/sparrow/AppController.java b/src/main/java/com/sparrowwallet/sparrow/AppController.java index 52a11b9e..8a27692a 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppController.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppController.java @@ -938,7 +938,8 @@ public class AppController implements Initializable { new CoboVaultSinglesig(), new CoboVaultMultisig(), new PassportSinglesig(), new KeystoneSinglesig(), new KeystoneMultisig(), - new CaravanMultisig()); + new CaravanMultisig(), + new NthKeyMultisig()); 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/WalletExportDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/WalletExportDialog.java index b4c588a9..4698bd9f 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/WalletExportDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/WalletExportDialog.java @@ -44,7 +44,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 CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new KeystoneMultisig(), new PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new SpecterDIY(), new Sparrow()); + exporters = List.of(new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new KeystoneMultisig(), new PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new SpecterDIY(), new Sparrow(), new NthKeyMultisig()); } 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 4eb87fe9..825d4f76 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/WalletImportDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/WalletImportDialog.java @@ -54,7 +54,7 @@ public class WalletImportDialog extends Dialog { importAccordion.getPanes().add(importPane); } - List walletImporters = List.of(new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new KeystoneMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new Sparrow()); + List walletImporters = List.of(new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new KeystoneMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new Sparrow(), new NthKeyMultisig()); for(WalletImport importer : walletImporters) { FileWalletImportPane importPane = new FileWalletImportPane(importer); importAccordion.getPanes().add(importPane); diff --git a/src/main/java/com/sparrowwallet/sparrow/io/NthKeyMultisig.java b/src/main/java/com/sparrowwallet/sparrow/io/NthKeyMultisig.java new file mode 100644 index 00000000..c281ed2d --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/NthKeyMultisig.java @@ -0,0 +1,242 @@ +package com.sparrowwallet.sparrow.io; + +import com.google.common.io.CharStreams; +import com.sparrowwallet.drongo.ExtendedKey; +import com.sparrowwallet.drongo.KeyDerivation; +import com.sparrowwallet.drongo.Utils; +import com.sparrowwallet.drongo.policy.Policy; +import com.sparrowwallet.drongo.policy.PolicyType; +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.drongo.wallet.WalletModel; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class NthKeyMultisig implements WalletImport, KeystoreFileImport, WalletExport { + private static final Logger log = LoggerFactory.getLogger(NthKeyMultisig.class); + + @Override + public String getName() { + return "nthKey Multisig"; + } + + @Override + public WalletModel getWalletModel() { + return WalletModel.NTHKEY; + } + + @Override + public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException { + InputStreamReader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8); + NthKeyKeystore cck = JsonPersistence.getGson().fromJson(reader, NthKeyKeystore.class); + + Keystore keystore = new Keystore("NthKey"); + keystore.setSource(KeystoreSource.HW_AIRGAPPED); + keystore.setWalletModel(WalletModel.NTHKEY); + + if(cck.xpub != null && cck.path != null) { + ExtendedKey.Header header = ExtendedKey.Header.fromExtendedKey(cck.xpub); + if(header.getDefaultScriptType() != scriptType) { + throw new ImportException("This wallet's script type (" + scriptType + ") does not match the " + getName() + " script type (" + header.getDefaultScriptType() + ")"); + } + keystore.setKeyDerivation(new KeyDerivation(cck.xfp, cck.path)); + keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(cck.xpub)); + } else if(scriptType.equals(ScriptType.P2SH)) { + keystore.setKeyDerivation(new KeyDerivation(cck.xfp, cck.p2sh_deriv)); + keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(cck.p2sh)); + } else if(scriptType.equals(ScriptType.P2SH_P2WSH)) { + keystore.setKeyDerivation(new KeyDerivation(cck.xfp, cck.p2wsh_p2sh_deriv != null ? cck.p2wsh_p2sh_deriv : cck.p2sh_p2wsh_deriv)); + keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(cck.p2wsh_p2sh != null ? cck.p2wsh_p2sh : cck.p2sh_p2wsh)); + } else if(scriptType.equals(ScriptType.P2WSH)) { + keystore.setKeyDerivation(new KeyDerivation(cck.xfp, cck.p2wsh_deriv)); + keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(cck.p2wsh)); + } else { + throw new ImportException("Correct derivation not found for script type: " + scriptType); + } + + return keystore; + } + + private static class NthKeyKeystore { + public String p2sh_deriv; + public String p2sh; + public String p2wsh_p2sh_deriv; + public String p2wsh_p2sh; + public String p2sh_p2wsh_deriv; + public String p2sh_p2wsh; + public String p2wsh_deriv; + public String p2wsh; + public String xpub; + public String path; + public String xfp; + } + + @Override + public String getKeystoreImportDescription() { + return "Import file created by using the Settings > Announce feature in nthKey."; + } + + @Override + public String getExportFileExtension(Wallet wallet) { + return "txt"; + } + + @Override + public Wallet importWallet(InputStream inputStream, String password) throws ImportException { + Wallet wallet = new Wallet(); + wallet.setPolicyType(PolicyType.MULTI); + + int threshold = 2; + ScriptType scriptType = ScriptType.P2SH; + String derivation = null; + + try { + List lines = CharStreams.readLines(new InputStreamReader(inputStream, StandardCharsets.UTF_8)); + for (String line : lines) { + line = line.trim(); + if (line.isEmpty()) { + continue; + } + + String[] keyValue = line.split(":"); + if (keyValue.length == 2) { + String key = keyValue[0].trim(); + String value = keyValue[1].trim(); + + switch (key) { + case "Name": + wallet.setName(value.trim()); + break; + case "Policy": + threshold = Integer.parseInt(value.split(" ")[0]); + break; + case "Derivation": + case "# derivation": + derivation = value; + break; + case "Format": + scriptType = ScriptType.valueOf(value.replace("P2SH_P2WSH")); + break; + default: + if (key.length() == 8 && Utils.isHex(key)) { + Keystore keystore = new Keystore("NthKey"); + keystore.setSource(KeystoreSource.HW_AIRGAPPED); + keystore.setWalletModel(WalletModel.NTHKEY); + keystore.setKeyDerivation(new KeyDerivation(key, derivation)); + keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(value)); + wallet.makeLabelsUnique(keystore); + wallet.getKeystores().add(keystore); + } + } + } + } + + + Policy policy = Policy.getPolicy(PolicyType.MULTI, scriptType, wallet.getKeystores(), threshold); + wallet.setDefaultPolicy(policy); + wallet.setScriptType(scriptType); + + if(!wallet.isValid()) { + throw new IllegalStateException("This file does not describe a valid wallet. " + getKeystoreImportDescription()); + } + + return wallet; + } catch(Exception e) { + log.error("Error importing " + getName() + " wallet", e); + throw new ImportException("Error importing " + getName() + " wallet", e); + } + } + + @Override + public String getWalletImportDescription() { + return "Import file created by using the Settings > Announce -> Save as JSON Mainnet in nthKey."; + } + + @Override + public void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException { + if(!wallet.isValid()) { + throw new ExportException("Cannot export an incomplete wallet"); + } + + if(!wallet.getPolicyType().equals(PolicyType.MULTI)) { + throw new ExportException(getName() + " import requires a multisig wallet"); + } + + boolean multipleDerivations = false; + Set derivationSet = new HashSet<>(); + for(Keystore keystore : wallet.getKeystores()) { + derivationSet.add(keystore.getKeyDerivation().getDerivationPath()); + } + if(derivationSet.size() > 1) { + multipleDerivations = true; + } + + try { + // TODO: should produce a JSON file like Specter does + + // BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)); + // writer.append("# " + getName() + " setup file (created by Sparrow)\n"); + // writer.append("#\n"); + // writer.append("Name: ").append(wallet.getFullName()).append("\n"); + // writer.append("Policy: ").append(Integer.toString(wallet.getDefaultPolicy().getNumSignaturesRequired())).append(" of ").append(Integer.toString(wallet.getKeystores().size())).append("\n"); + // if(!multipleDerivations) { + // writer.append("Derivation: ").append(wallet.getKeystores().get(0).getKeyDerivation().getDerivationPath()).append("\n"); + // } + // writer.append("Format: ").append(wallet.getScriptType().toString().replace("P2SH-P2WSH", "P2WSH-P2SH")).append("\n"); + // writer.append("\n"); + // + // for(Keystore keystore : wallet.getKeystores()) { + // if(multipleDerivations) { + // writer.append("# derivation: ").append(keystore.getKeyDerivation().getDerivationPath()).append("\n"); + // } + // writer.append(keystore.getKeyDerivation().getMasterFingerprint().toUpperCase()).append(": ").append(keystore.getExtendedPublicKey().toString()).append("\n"); + // if(multipleDerivations) { + // writer.append("\n"); + // } + // } + // + // writer.flush(); + } catch(Exception e) { + log.error("Error exporting " + getName() + " wallet", e); + throw new ExportException("Error exporting " + getName() + " wallet", e); + } + } + + @Override + public String getWalletExportDescription() { + return "Export file that can be read by nthKey using the Settings > Import wallet > Import wallet JSON."; + } + + @Override + public boolean isEncrypted(File file) { + return false; + } + + @Override + public boolean isWalletImportScannable() { + return false; + } + + @Override + public boolean isKeystoreImportScannable() { + return true; + } + + @Override + public boolean isWalletExportScannable() { + return true; + } + + @Override + public boolean walletExportRequiresDecryption() { + return false; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/SpecterDesktop.java b/src/main/java/com/sparrowwallet/sparrow/io/SpecterDesktop.java index b8280bc7..18d1c9c2 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/SpecterDesktop.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/SpecterDesktop.java @@ -73,7 +73,7 @@ public class SpecterDesktop implements WalletImport, WalletExport { keystore.setWalletModel(walletModel); if(walletModel == WalletModel.TREZOR_1 || walletModel == WalletModel.TREZOR_T || walletModel == WalletModel.KEEPKEY || walletModel == WalletModel.LEDGER_NANO_S || walletModel == WalletModel.LEDGER_NANO_X || - walletModel == WalletModel.BITBOX_02 || walletModel == WalletModel.COLDCARD) { + walletModel == WalletModel.BITBOX_02 || walletModel == WalletModel.COLDCARD || walletModel == WalletModel.NTHKEY) { keystore.setSource(KeystoreSource.HW_USB); } else if(walletModel == WalletModel.BITCOIN_CORE) { keystore.setSource(KeystoreSource.SW_WATCH); diff --git a/src/main/resources/image/nthkey-orig.png b/src/main/resources/image/nthkey-orig.png new file mode 100644 index 00000000..ffff4a10 Binary files /dev/null and b/src/main/resources/image/nthkey-orig.png differ diff --git a/src/main/resources/image/nthkey.png b/src/main/resources/image/nthkey.png new file mode 100644 index 00000000..8d7ee913 Binary files /dev/null and b/src/main/resources/image/nthkey.png differ diff --git a/src/main/resources/image/nthkey@2x.png b/src/main/resources/image/nthkey@2x.png new file mode 100644 index 00000000..70c6338e Binary files /dev/null and b/src/main/resources/image/nthkey@2x.png differ diff --git a/src/main/resources/image/nthkey@3x.png b/src/main/resources/image/nthkey@3x.png new file mode 100644 index 00000000..b5ce8ebf Binary files /dev/null and b/src/main/resources/image/nthkey@3x.png differ