add caravan import/export, minor ui fixes

This commit is contained in:
Craig Raw 2021-07-15 11:09:39 +02:00
parent 4a3ad9f4ff
commit ced4d4d337
11 changed files with 274 additions and 12 deletions

2
drongo

@ -1 +1 @@
Subproject commit c71979966bbdbff58b1f63946549b15f54890ab5
Subproject commit db081695e84f38a184567b00cad1af0f0e7e1b67

View file

@ -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) {

View file

@ -28,6 +28,7 @@ import java.util.stream.Collectors;
public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
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<String, Number> {
@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<String, Number> {
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) {

View file

@ -44,7 +44,7 @@ public class WalletExportDialog extends Dialog<Wallet> {
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());
}

View file

@ -54,7 +54,7 @@ public class WalletImportDialog extends Dialog<Wallet> {
importAccordion.getPanes().add(importPane);
}
List<WalletImport> walletImporters = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new KeystoneMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new Sparrow());
List<WalletImport> 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);

View file

@ -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<ExtPublicKey> 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;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

View file

@ -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);
}
}

View file

@ -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
}