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 00000000..34678e16 Binary files /dev/null and b/src/main/resources/image/caravan.png differ diff --git a/src/main/resources/image/caravan@2x.png b/src/main/resources/image/caravan@2x.png new file mode 100644 index 00000000..9a9aafe5 Binary files /dev/null and b/src/main/resources/image/caravan@2x.png differ diff --git a/src/main/resources/image/caravan@3x.png b/src/main/resources/image/caravan@3x.png new file mode 100644 index 00000000..3ca29ca6 Binary files /dev/null and b/src/main/resources/image/caravan@3x.png differ diff --git a/src/test/java/com/sparrowwallet/sparrow/io/CaravanMultisigTest.java b/src/test/java/com/sparrowwallet/sparrow/io/CaravanMultisigTest.java new file mode 100644 index 00000000..d79c4de3 --- /dev/null +++ b/src/test/java/com/sparrowwallet/sparrow/io/CaravanMultisigTest.java @@ -0,0 +1,44 @@ +package com.sparrowwallet.sparrow.io; + +import com.google.common.io.ByteStreams; +import com.sparrowwallet.drongo.policy.PolicyType; +import com.sparrowwallet.drongo.protocol.ScriptType; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.drongo.wallet.WalletModel; +import org.junit.Assert; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +public class CaravanMultisigTest extends IoTest { + @Test + public void importWallet1() throws ImportException { + CaravanMultisig ccMultisig = new CaravanMultisig(); + Wallet wallet = ccMultisig.importWallet(getInputStream("caravan-multisig-export-1.json"), null); + Assert.assertEquals("Test Wallet", wallet.getName()); + Assert.assertEquals(PolicyType.MULTI, wallet.getPolicyType()); + Assert.assertEquals(ScriptType.P2WSH, wallet.getScriptType()); + Assert.assertEquals(2, wallet.getDefaultPolicy().getNumSignaturesRequired()); + Assert.assertEquals("wsh(sortedmulti(2,mercury,venus,earth))", wallet.getDefaultPolicy().getMiniscript().getScript().toLowerCase()); + Assert.assertTrue(wallet.isValid()); + Assert.assertEquals("8188029f", wallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint()); + Assert.assertEquals("m/48'/0'/0'/2'", wallet.getKeystores().get(0).getKeyDerivation().getDerivationPath()); + Assert.assertEquals(WalletModel.TREZOR_1, wallet.getKeystores().get(0).getWalletModel()); + Assert.assertEquals("xpub6EMVvcTUbaABdaPLaVWE72CjcN72URa5pKK1knrKLz1hKaDwUkgddc3832a8MHEpLyuow7MfjMRomt2iMtwPH4pWrFLft4JsquHjeZfKsYp", wallet.getKeystores().get(0).getExtendedPublicKey().toString()); + } + + @Test + public void exportWallet1() throws ImportException, ExportException, IOException { + CaravanMultisig ccMultisig = new CaravanMultisig(); + byte[] walletBytes = ByteStreams.toByteArray(getInputStream("caravan-multisig-export-1.json")); + Wallet wallet = ccMultisig.importWallet(new ByteArrayInputStream(walletBytes), null); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ccMultisig.exportWallet(wallet, baos); + byte[] exportedBytes = baos.toByteArray(); + String original = new String(walletBytes); + String exported = new String(exportedBytes); + Assert.assertEquals(original, exported); + } +} diff --git a/src/test/resources/com/sparrowwallet/sparrow/io/caravan-multisig-export-1.json b/src/test/resources/com/sparrowwallet/sparrow/io/caravan-multisig-export-1.json new file mode 100644 index 00000000..08ac18d1 --- /dev/null +++ b/src/test/resources/com/sparrowwallet/sparrow/io/caravan-multisig-export-1.json @@ -0,0 +1,36 @@ +{ + "name": "Test Wallet", + "addressType": "P2WSH", + "network": "mainnet", + "client": { + "type": "public" + }, + "quorum": { + "requiredSigners": 2, + "totalSigners": 3 + }, + "extendedPublicKeys": [ + { + "name": "Mercury", + "bip32Path": "m/48'/0'/0'/2'", + "xpub": "xpub6EMVvcTUbaABdaPLaVWE72CjcN72URa5pKK1knrKLz1hKaDwUkgddc3832a8MHEpLyuow7MfjMRomt2iMtwPH4pWrFLft4JsquHjeZfKsYp", + "xfp": "8188029f", + "method": "trezor" + }, + { + "name": "Venus", + "bip32Path": "m/48'/0'/0'/2'", + "xpub": "xpub6EbTHAgvzZtVppjLPXia2prt6T2w7DD2VQ8gysgV6ECzmKkA6zt4WR3hX4bgciTDrnaneZoJbA19tYKBMrWuA89SbgYAdbMNWmtzrgLhqco", + "xfp": "a15e8133", + "method": "ledger" + }, + { + "name": "Earth", + "bip32Path": "m/48'/0'/0'/2'", + "xpub": "xpub6Ea6qWGr6BWpCSyLo8ggypvXg1MwadiAGZxAHvrBUwvT2ki6pNVG61bg3YEdxkj7FRJ1cLFK7Vvqd9rcDvwCLX7VfEMfJJfdbA1NGsFazvz", + "xfp": "d31cf7f9", + "method": "coldcard" + } + ], + "startingAddressIndex": 0 +} \ No newline at end of file