From ced4d4d337a1752fddaa35fb05ea889200d22e42 Mon Sep 17 00:00:00 2001 From: Craig Raw Date: Thu, 15 Jul 2021 11:09:39 +0200 Subject: [PATCH] add caravan import/export, minor ui fixes --- drongo | 2 +- .../sparrowwallet/sparrow/AppController.java | 13 +- .../control/MempoolSizeFeeRatesChart.java | 7 +- .../sparrow/control/WalletExportDialog.java | 2 +- .../sparrow/control/WalletImportDialog.java | 2 +- .../sparrow/io/CaravanMultisig.java | 180 ++++++++++++++++++ src/main/resources/image/caravan.png | Bin 0 -> 2847 bytes src/main/resources/image/caravan@2x.png | Bin 0 -> 5214 bytes src/main/resources/image/caravan@3x.png | Bin 0 -> 9728 bytes .../sparrow/io/CaravanMultisigTest.java | 44 +++++ .../sparrow/io/caravan-multisig-export-1.json | 36 ++++ 11 files changed, 274 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/sparrowwallet/sparrow/io/CaravanMultisig.java create mode 100644 src/main/resources/image/caravan.png create mode 100644 src/main/resources/image/caravan@2x.png create mode 100644 src/main/resources/image/caravan@3x.png create mode 100644 src/test/java/com/sparrowwallet/sparrow/io/CaravanMultisigTest.java create mode 100644 src/test/resources/com/sparrowwallet/sparrow/io/caravan-multisig-export-1.json diff --git a/drongo b/drongo index c7197996..db081695 160000 --- a/drongo +++ b/drongo @@ -1 +1 @@ -Subproject commit c71979966bbdbff58b1f63946549b15f54890ab5 +Subproject commit db081695e84f38a184567b00cad1af0f0e7e1b67 diff --git a/src/main/java/com/sparrowwallet/sparrow/AppController.java b/src/main/java/com/sparrowwallet/sparrow/AppController.java index 09c14972..2bf87e93 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppController.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppController.java @@ -3,10 +3,7 @@ package com.sparrowwallet.sparrow; import com.google.common.base.Charsets; import com.google.common.eventbus.Subscribe; import com.google.common.io.ByteSource; -import com.sparrowwallet.drongo.BitcoinUnit; -import com.sparrowwallet.drongo.Network; -import com.sparrowwallet.drongo.SecureString; -import com.sparrowwallet.drongo.Utils; +import com.sparrowwallet.drongo.*; import com.sparrowwallet.drongo.crypto.ECKey; import com.sparrowwallet.drongo.crypto.EncryptionType; import com.sparrowwallet.drongo.crypto.InvalidPasswordException; @@ -801,7 +798,10 @@ public class AppController implements Initializable { } catch(StorageException e) { showErrorDialog("Error Opening Wallet", e.getMessage()); } catch(Exception e) { - if(!attemptImportWallet(file, null)) { + if(e instanceof IOException && e.getMessage().startsWith("The process cannot access the file because another process has locked")) { + log.error("Error opening wallet", e); + showErrorDialog("Error Opening Wallet", "The wallet file is locked. Is another instance of " + MainApp.APP_NAME + " already running?"); + } else if(!attemptImportWallet(file, null)) { log.error("Error opening wallet", e); showErrorDialog("Error Opening Wallet", e.getMessage() == null ? "Unsupported file format" : e.getMessage()); } @@ -897,7 +897,8 @@ public class AppController implements Initializable { new SpecterDesktop(), new CoboVaultSinglesig(), new CoboVaultMultisig(), new PassportSinglesig(), - new KeystoneSinglesig(), new KeystoneMultisig()); + new KeystoneSinglesig(), new KeystoneMultisig(), + new CaravanMultisig()); 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/MempoolSizeFeeRatesChart.java b/src/main/java/com/sparrowwallet/sparrow/control/MempoolSizeFeeRatesChart.java index 97134e50..db58ee62 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/MempoolSizeFeeRatesChart.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/MempoolSizeFeeRatesChart.java @@ -28,6 +28,7 @@ import java.util.stream.Collectors; public class MempoolSizeFeeRatesChart extends StackedAreaChart { private static final DateFormat dateFormatter = new SimpleDateFormat("HH:mm"); public static final int MAX_PERIOD_HOURS = 2; + private static final double Y_VALUE_BREAK_MVB = 3.0; private Tooltip tooltip; @@ -108,7 +109,7 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart { @Override public String toString(Number object) { long vSizeBytes = object.longValue(); - if(maxMvB > 1.0) { + if(maxMvB > Y_VALUE_BREAK_MVB) { return (vSizeBytes / (1000 * 1000)) + " MvB"; } else { return (vSizeBytes / (1000)) + " kvB"; @@ -197,8 +198,8 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart { if(data.getXValue().equals(category)) { double kvb = data.getYValue().doubleValue() / 1000; double mvb = kvb / 1000; - if(mvb >= 0.01 || (maxMvB < 1.0 && mvb > 0.001)) { - String amount = (maxMvB < 1.0 ? (int)kvb + " kvB" : String.format("%.2f", mvb) + " MvB"); + if(mvb >= 0.01 || (maxMvB < Y_VALUE_BREAK_MVB && mvb > 0.001)) { + String amount = (maxMvB < Y_VALUE_BREAK_MVB ? (int)kvb + " kvB" : String.format("%.2f", mvb) + " MvB"); Label label = new Label(series.getName() + ": " + amount); Glyph circle = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CIRCLE); if(i < 8) { diff --git a/src/main/java/com/sparrowwallet/sparrow/control/WalletExportDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/WalletExportDialog.java index cf930cf0..b4c588a9 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 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()); } 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 45242e32..c7bb6570 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 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()); for(WalletImport importer : walletImporters) { FileWalletImportPane importPane = new FileWalletImportPane(importer); importAccordion.getPanes().add(importPane); diff --git a/src/main/java/com/sparrowwallet/sparrow/io/CaravanMultisig.java b/src/main/java/com/sparrowwallet/sparrow/io/CaravanMultisig.java new file mode 100644 index 00000000..79acad0a --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/CaravanMultisig.java @@ -0,0 +1,180 @@ +package com.sparrowwallet.sparrow.io; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.sparrowwallet.drongo.ExtendedKey; +import com.sparrowwallet.drongo.KeyDerivation; +import com.sparrowwallet.drongo.Network; +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.File; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +public class CaravanMultisig implements WalletImport, WalletExport { + private static final Logger log = LoggerFactory.getLogger(ColdcardMultisig.class); + + @Override + public String getName() { + return "Caravan Multisig"; + } + + @Override + public WalletModel getWalletModel() { + return WalletModel.CARAVAN; + } + + @Override + public String getWalletImportDescription() { + return "Import the file created via the Download Wallet Details button in Caravan."; + } + + @Override + public Wallet importWallet(InputStream inputStream, String password) throws ImportException { + try { + InputStreamReader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8); + CaravanFile cf = JsonPersistence.getGson().fromJson(reader, CaravanFile.class); + + Wallet wallet = new Wallet(); + wallet.setName(cf.name); + wallet.setPolicyType(PolicyType.MULTI); + + for(ExtPublicKey extKey : cf.extendedPublicKeys) { + Keystore keystore = new Keystore(extKey.name); + keystore.setKeyDerivation(new KeyDerivation(extKey.xfp, extKey.bip32Path)); + keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(extKey.xpub)); + + WalletModel walletModel = WalletModel.fromType(extKey.method); + if(walletModel == null) { + keystore.setWalletModel(WalletModel.SPARROW); + keystore.setSource(KeystoreSource.SW_WATCH); + } else { + keystore.setWalletModel(walletModel); + keystore.setSource(KeystoreSource.HW_USB); + } + wallet.getKeystores().add(keystore); + } + + ScriptType scriptType = ScriptType.valueOf(cf.addressType); + wallet.setScriptType(scriptType); + wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.MULTI, scriptType, wallet.getKeystores(), cf.quorum.requiredSigners)); + + return wallet; + } catch(Exception e) { + log.error("Error importing " + getName() + " wallet", e); + throw new ImportException("Error importing " + getName() + " wallet", e); + } + } + + @Override + public boolean isWalletImportScannable() { + return false; + } + + @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"); + } + + try { + CaravanFile cf = new CaravanFile(); + cf.name = wallet.getName(); + cf.addressType = wallet.getScriptType().toString().replace('-', '_'); + cf.network = Network.get().getName(); + cf.client = new Client(); + + Quorum quorum = new Quorum(); + quorum.requiredSigners = wallet.getDefaultPolicy().getNumSignaturesRequired(); + quorum.totalSigners = wallet.getKeystores().size(); + cf.quorum = quorum; + + cf.extendedPublicKeys = new ArrayList<>(); + for(Keystore keystore : wallet.getKeystores()) { + ExtPublicKey extKey = new ExtPublicKey(); + extKey.name = keystore.getLabel(); + extKey.bip32Path = keystore.getKeyDerivation().getDerivationPath(); + extKey.xpub = keystore.getExtendedPublicKey().toString(); + extKey.xfp = keystore.getKeyDerivation().getMasterFingerprint(); + extKey.method = keystore.getWalletModel().getType(); + cf.extendedPublicKeys.add(extKey); + } + + Gson gson = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create(); + String json = gson.toJson(cf); + outputStream.write(json.getBytes(StandardCharsets.UTF_8)); + outputStream.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 a file that can be imported via the Import Wallet Configuration button in Caravan."; + } + + @Override + public String getExportFileExtension(Wallet wallet) { + return "json"; + } + + @Override + public boolean isWalletExportScannable() { + return false; + } + + @Override + public boolean walletExportRequiresDecryption() { + return false; + } + + @Override + public boolean isEncrypted(File file) { + return false; + } + + private static final class CaravanFile { + public String name; + public String addressType; + public String network; + public Client client; + public Quorum quorum; + public List extendedPublicKeys; + public int startingAddressIndex = 0; + } + + private static final class Client { + public String type = "public"; + } + + private static final class Quorum { + public int requiredSigners; + public int totalSigners; + } + + private static final class ExtPublicKey { + public String name; + public String bip32Path; + public String xpub; + public String xfp; + public String method; + } +} diff --git a/src/main/resources/image/caravan.png b/src/main/resources/image/caravan.png new file mode 100644 index 0000000000000000000000000000000000000000..34678e167cc34eb4424a7a007b6f51c2f5c544de GIT binary patch literal 2847 zcmZ`*3piB!7T*(+K@3uPHFrn~^YR*F=%n!~(~+b>Y940BXqYh%#tlb@!5~s6k0R2! z_tWj>5Z$Je3e!uTojRz*(1Sjs$T{U6k8}5olH=B%@4wgD>;L<$|5|IWwZD-D?ef!y znn3{o&=2q@27?l-TB}xqcdM=0b)bL))4SR^%yOJg9#u{;$EAc*mx8p{w;;o?|M zoB%KOM5qyXP*<5z2)G&|JmiTWlY-#exqJrP6^TZo5nfO@98TazG4a7f-;Z?g$rBMR z6!P#WRAOQxGSLai<+D&2cXxLb8jHeW9YKVnASq5r6+6ZWtQVYo@l_^1ezEvYB5w zOSSV=S`>ZpD4vMVQ5PbLj$&{av5YvO0OVm%i}{1;5mb@mw{tmM{vIBc&hWwz)X6M0 zeXhg;THQuuA{+E50ITNZg8s-^zVSt3*w=_lHkJ^<;^DXR8LFlY;c_`%%X^rBT8>yo zCUK+K%p@XJ$na8iyCd4o5#ti_soNLmOE62=szT#~83Haxr0R~nacrR%mVo+_c{yu4 zHKcO}&LA7|I4JInv$;^6&@t~JTc@q%0MsLzX;i!;joz0aNrItMzO1>ZSUxQ{5$ zOqGp%Alw{rzfxUoD1q7KeM8lRT2E}vYpU~8lp81caXI6ii6Rzaq~@CMQRaI8N&(IVrjdS6ZITDZJf}?tDSE!c1id>u9I{#y%^vR=9MCA9Dlph>;4w7Ze-syg?6llT z!#y!_ue|iBBoSkNfqMKLZ|(hVHj#AevE31mF7Lpm;%xKMdJ)uMFb%I4;P$9xlF^v1 zF=~(~+a+&5N1PU?-0tY9gNY1R52lDeK)Y}rXFJZ8RY2NmbZ)}6-o`&TtaPrjH8Okm z+!P@Y$_I~ISSA@aw9k@}@&w|Jw3*dD{Y0!sMUvH%sgmmvjQO9gvy=^tu`8qD#BfD_ zuW7t7`*3agi6HF2@n@6DF(tF*gIombtbf~@WEtZ+vekI@flC8!t}LRuFbqLe^e2=b zdCx?J8r`rtD3yJ)p;`0aS>prJ_F4flM*d4!Muj3_;ALtCtmh0hOlxC-5`$?7biK#Qr)f3!#{+|&2_=E|&LG5vJwn0Q@pqqq)|%=~YuFK@l@ z4ty0wu*kkXF+ZXx2t48MEF1sTZhX$(mnZUcNWVJKc0+TF5<)d)(hT)(4i8poX(+O1 z#(FNtCp>v;G1q!Y`{47#B@G*Pp5RyZ!e?EdJ15>E-5P)3qx?3`BRofYLN8N35m67T zS>qulD1*2^G&T204#*7W$-iA2QpkEji?#FG74p3g>E=5oxkOTW326pm7#z%LMUH8$<$|Mk+ zju+RXroepCDc>K_Q@gQ#ebEo)kvS{LroNaO`LE=xq*{6IgS^eP4~w3$(dSKP(@wzb zkGE%g_(R{|iV8g*?j+|M7W0D*+FA=myCd*Zlco;MYbonHn$Uekx{aEfr>(R%2a~Vu zt~zzvwz-hh>?qVeb9YZ4Ku5o67Rt<%D$3(mMy^er-fvwLY0r`C4{gC+upQ5&U{k08 zsYfo3qN{9&!)vWm6mdUxkTu(G|M@IuCP>SJy9q1b^@p1qv{$;Z!K&uMp>Ev)>zS8- zpFFdXw`mM*A*mknCuLe9CFPeJi)eORV9d0Ke`d5sc$s6?9_6JazhP%=b9~i*8Ee3$ zZ@?eLNi*&Q4!h!VQeWu#J$cu<=7wA|VOZb6ze0cuNj2bGtbQw?C}-_?M~V41AU5*e z@70+vkJ%TVd41C@zARlIT^5T#%8$9Axm14$&V^3iv3B#c$8;& z4tG_9YTEyM;JXxZjkM~5G^6zAVh&A4n@BasHTRY|e literal 0 HcmV?d00001 diff --git a/src/main/resources/image/caravan@2x.png b/src/main/resources/image/caravan@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9a9aafe59ebaf85e6bd6a5e57af78f57b10c5db0 GIT binary patch literal 5214 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 literal 0 HcmV?d00001 diff --git a/src/main/resources/image/caravan@3x.png b/src/main/resources/image/caravan@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..3ca29ca6a1d550a14ee8f2a49887a51a1628300b GIT binary patch literal 9728 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 ln=kSI`a^)qbMjB1Wlvmk