mirror of
https://github.com/sparrowwallet/sparrow.git
synced 2024-11-05 05:46:44 +00:00
add ability to import keystores and wallets with QR, add part protocol to QR scanner
This commit is contained in:
parent
20f5e4d8db
commit
76e148a107
14 changed files with 179 additions and 42 deletions
|
@ -559,17 +559,14 @@ public class AppController implements Initializable {
|
|||
if(result.transaction != null) {
|
||||
Tab tab = addTransactionTab(null, result.transaction);
|
||||
tabs.getSelectionModel().select(tab);
|
||||
}
|
||||
if(result.psbt != null) {
|
||||
} else if(result.psbt != null) {
|
||||
Tab tab = addTransactionTab(null, result.psbt);
|
||||
tabs.getSelectionModel().select(tab);
|
||||
}
|
||||
if(result.error != null) {
|
||||
showErrorDialog("Invalid QR Code", result.error);
|
||||
}
|
||||
if(result.exception != null) {
|
||||
} else if(result.exception != null) {
|
||||
log.error("Error opening webcam", result.exception);
|
||||
showErrorDialog("Error opening webcam", result.exception.getMessage());
|
||||
} else {
|
||||
AppController.showErrorDialog("Invalid QR Code", "Cannot parse QR code into a transaction or PSBT");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.control;
|
|||
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.sparrowwallet.drongo.crypto.InvalidPasswordException;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.io.FileImport;
|
||||
import com.sparrowwallet.sparrow.io.ImportException;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
|
@ -9,41 +10,72 @@ import javafx.geometry.Insets;
|
|||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.ButtonBase;
|
||||
import javafx.scene.control.Control;
|
||||
import javafx.scene.control.ToggleButton;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.stage.FileChooser;
|
||||
import javafx.stage.Stage;
|
||||
import org.controlsfx.control.SegmentedButton;
|
||||
import org.controlsfx.control.textfield.CustomPasswordField;
|
||||
import org.controlsfx.control.textfield.TextFields;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Optional;
|
||||
|
||||
public abstract class FileImportPane extends TitledDescriptionPane {
|
||||
private static final Logger log = LoggerFactory.getLogger(FileImportPane.class);
|
||||
|
||||
private final FileImport importer;
|
||||
protected Button importButton;
|
||||
protected ButtonBase importButton;
|
||||
private final SimpleStringProperty password = new SimpleStringProperty("");
|
||||
private final boolean scannable;
|
||||
|
||||
public FileImportPane(FileImport importer, String title, String description, String content, String imageUrl) {
|
||||
public FileImportPane(FileImport importer, String title, String description, String content, String imageUrl, boolean scannable) {
|
||||
super(title, description, content, imageUrl);
|
||||
this.importer = importer;
|
||||
this.scannable = scannable;
|
||||
|
||||
buttonBox.getChildren().clear();
|
||||
buttonBox.getChildren().add(createButton());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Control createButton() {
|
||||
importButton = new Button("Import File...");
|
||||
importButton.setAlignment(Pos.CENTER_RIGHT);
|
||||
importButton.setOnAction(event -> {
|
||||
importFile();
|
||||
});
|
||||
return importButton;
|
||||
if(scannable) {
|
||||
ToggleButton scanButton = new ToggleButton("Scan...");
|
||||
Glyph cameraGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CAMERA);
|
||||
cameraGlyph.setFontSize(12);
|
||||
scanButton.setGraphic(cameraGlyph);
|
||||
scanButton.setOnAction(event -> {
|
||||
scanButton.setSelected(false);
|
||||
importQR();
|
||||
});
|
||||
|
||||
ToggleButton fileButton = new ToggleButton("Import File...");
|
||||
fileButton.setAlignment(Pos.CENTER_RIGHT);
|
||||
fileButton.setOnAction(event -> {
|
||||
fileButton.setSelected(false);
|
||||
importFile();
|
||||
});
|
||||
importButton = fileButton;
|
||||
|
||||
SegmentedButton segmentedButton = new SegmentedButton();
|
||||
segmentedButton.getButtons().addAll(scanButton, fileButton);
|
||||
return segmentedButton;
|
||||
} else {
|
||||
importButton = new Button("Import File...");
|
||||
importButton.setAlignment(Pos.CENTER_RIGHT);
|
||||
importButton.setOnAction(event -> {
|
||||
importFile();
|
||||
});
|
||||
return importButton;
|
||||
}
|
||||
}
|
||||
|
||||
private void importFile() {
|
||||
|
@ -94,6 +126,29 @@ public abstract class FileImportPane extends TitledDescriptionPane {
|
|||
}
|
||||
}
|
||||
|
||||
private void importQR() {
|
||||
QRScanDialog qrScanDialog = new QRScanDialog();
|
||||
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
|
||||
if(optionalResult.isPresent()) {
|
||||
QRScanDialog.Result result = optionalResult.get();
|
||||
if(result.payload != null) {
|
||||
try {
|
||||
importFile(importer.getName(), new ByteArrayInputStream(result.payload.getBytes(StandardCharsets.UTF_8)), null);
|
||||
} catch(Exception e) {
|
||||
log.error("Error importing QR", e);
|
||||
String errorMessage = e.getMessage();
|
||||
if(e.getCause() != null && e.getCause().getMessage() != null && !e.getCause().getMessage().isEmpty()) {
|
||||
errorMessage = e.getCause().getMessage();
|
||||
}
|
||||
if(e instanceof JsonParseException || e.getCause() instanceof JsonParseException) {
|
||||
errorMessage = "QR contents were not in JSON format";
|
||||
}
|
||||
setError("Import Error", errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract void importFile(String fileName, InputStream inputStream, String password) throws ImportException;
|
||||
|
||||
private Node getPasswordEntry(File file) {
|
||||
|
|
|
@ -14,7 +14,7 @@ public class FileKeystoreImportPane extends FileImportPane {
|
|||
private final KeystoreFileImport importer;
|
||||
|
||||
public FileKeystoreImportPane(Wallet wallet, KeystoreFileImport importer) {
|
||||
super(importer, importer.getName(), "Keystore file import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png");
|
||||
super(importer, importer.getName(), "Keystore import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isScannable());
|
||||
this.wallet = wallet;
|
||||
this.importer = importer;
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ public class FileWalletImportPane extends FileImportPane {
|
|||
private final WalletImport importer;
|
||||
|
||||
public FileWalletImportPane(WalletImport importer) {
|
||||
super(importer, importer.getName(), "Wallet file import", importer.getWalletImportDescription(), "image/" + importer.getWalletModel().getType() + ".png");
|
||||
super(importer, importer.getName(), "Wallet import", importer.getWalletImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isScannable());
|
||||
this.importer = importer;
|
||||
}
|
||||
|
||||
|
|
|
@ -36,7 +36,7 @@ public class FileWalletKeystoreImportPane extends FileImportPane {
|
|||
private byte[] fileBytes;
|
||||
|
||||
public FileWalletKeystoreImportPane(KeystoreFileImport importer) {
|
||||
super(importer, importer.getName(), "Wallet file import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png");
|
||||
super(importer, importer.getName(), "Wallet import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isScannable());
|
||||
this.importer = importer;
|
||||
}
|
||||
|
||||
|
|
|
@ -22,13 +22,23 @@ import javafx.scene.control.DialogPane;
|
|||
import javafx.scene.layout.StackPane;
|
||||
import org.controlsfx.tools.Borders;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
private final URDecoder decoder;
|
||||
private final WebcamService webcamService;
|
||||
private List<String> parts;
|
||||
|
||||
private boolean isUr;
|
||||
private QRScanDialog.Result result;
|
||||
|
||||
private static final Pattern PART_PATTERN = Pattern.compile("p(\\d+)of(\\d+) (.+)");
|
||||
|
||||
public QRScanDialog() {
|
||||
this.decoder = new URDecoder();
|
||||
|
||||
|
@ -69,6 +79,8 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
|||
|
||||
//Try text first
|
||||
String qrtext = qrResult.getText();
|
||||
Matcher partMatcher = PART_PATTERN.matcher(qrtext);
|
||||
|
||||
if(isUr || qrtext.toLowerCase().startsWith(UR.UR_PREFIX)) {
|
||||
isUr = true;
|
||||
decoder.receivePart(qrtext);
|
||||
|
@ -102,6 +114,37 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
|||
result = new Result(urResult.error);
|
||||
}
|
||||
}
|
||||
} else if(partMatcher.matches()) {
|
||||
int m = Integer.parseInt(partMatcher.group(1));
|
||||
int n = Integer.parseInt(partMatcher.group(2));
|
||||
String payload = partMatcher.group(3);
|
||||
|
||||
if(parts == null) {
|
||||
parts = new ArrayList<>(n);
|
||||
IntStream.range(0, n).forEach(i -> parts.add(null));
|
||||
}
|
||||
parts.set(m - 1, payload);
|
||||
|
||||
if(parts.stream().filter(Objects::nonNull).count() == n) {
|
||||
String complete = String.join("", parts);
|
||||
try {
|
||||
PSBT psbt = PSBT.fromString(complete);
|
||||
result = new Result(psbt);
|
||||
return;
|
||||
} catch(Exception e) {
|
||||
//ignore, bytes not parsable as PSBT
|
||||
}
|
||||
|
||||
try {
|
||||
Transaction transaction = new Transaction(Utils.hexToBytes(complete));
|
||||
result = new Result(transaction);
|
||||
return;
|
||||
} catch(Exception e) {
|
||||
//ignore, bytes not parsable as tx
|
||||
}
|
||||
|
||||
result = new Result("Parsed QR parts were not a PSBT or transaction");
|
||||
}
|
||||
} else {
|
||||
PSBT psbt;
|
||||
Transaction transaction;
|
||||
|
@ -166,7 +209,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
|||
|
||||
//Try Base43 used by Electrum
|
||||
try {
|
||||
psbt = new PSBT(Base43.decode(qrResult.getText()));
|
||||
psbt = new PSBT(Base43.decode(qrtext));
|
||||
result = new Result(psbt);
|
||||
return;
|
||||
} catch(Exception e) {
|
||||
|
@ -174,14 +217,14 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
|||
}
|
||||
|
||||
try {
|
||||
transaction = new Transaction(Base43.decode(qrResult.getText()));
|
||||
transaction = new Transaction(Base43.decode(qrtext));
|
||||
result = new Result(transaction);
|
||||
return;
|
||||
} catch(Exception e) {
|
||||
//Ignore, not parseable as base43 decoded bytes
|
||||
}
|
||||
|
||||
result = new Result("Cannot parse QR code into a PSBT, transaction or address");
|
||||
result = new Result(qrtext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -191,7 +234,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
|||
public final PSBT psbt;
|
||||
public final BitcoinURI uri;
|
||||
public final ExtendedKey extendedKey;
|
||||
public final String error;
|
||||
public final String payload;
|
||||
public final Throwable exception;
|
||||
|
||||
public Result(Transaction transaction) {
|
||||
|
@ -199,7 +242,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
|||
this.psbt = null;
|
||||
this.uri = null;
|
||||
this.extendedKey = null;
|
||||
this.error = null;
|
||||
this.payload = null;
|
||||
this.exception = null;
|
||||
}
|
||||
|
||||
|
@ -208,7 +251,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
|||
this.psbt = psbt;
|
||||
this.uri = null;
|
||||
this.extendedKey = null;
|
||||
this.error = null;
|
||||
this.payload = null;
|
||||
this.exception = null;
|
||||
}
|
||||
|
||||
|
@ -217,7 +260,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
|||
this.psbt = null;
|
||||
this.uri = uri;
|
||||
this.extendedKey = null;
|
||||
this.error = null;
|
||||
this.payload = null;
|
||||
this.exception = null;
|
||||
}
|
||||
|
||||
|
@ -226,7 +269,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
|||
this.psbt = null;
|
||||
this.uri = BitcoinURI.fromAddress(address);
|
||||
this.extendedKey = null;
|
||||
this.error = null;
|
||||
this.payload = null;
|
||||
this.exception = null;
|
||||
}
|
||||
|
||||
|
@ -235,16 +278,16 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
|||
this.psbt = null;
|
||||
this.uri = null;
|
||||
this.extendedKey = extendedKey;
|
||||
this.error = null;
|
||||
this.payload = null;
|
||||
this.exception = null;
|
||||
}
|
||||
|
||||
public Result(String error) {
|
||||
public Result(String payload) {
|
||||
this.transaction = null;
|
||||
this.psbt = null;
|
||||
this.uri = null;
|
||||
this.extendedKey = null;
|
||||
this.error = error;
|
||||
this.payload = payload;
|
||||
this.exception = null;
|
||||
}
|
||||
|
||||
|
@ -253,7 +296,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
|||
this.psbt = null;
|
||||
this.uri = null;
|
||||
this.extendedKey = null;
|
||||
this.error = null;
|
||||
this.payload = null;
|
||||
this.exception = exception;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,7 +29,7 @@ public class CoboVaultMultisig extends ColdcardMultisig {
|
|||
|
||||
@Override
|
||||
public String getKeystoreImportDescription() {
|
||||
return "Import file created by using the Multisig Wallet > Show/Export XPUB > Export All > Export feature on your Cobo Vault.";
|
||||
return "Import file or QR created by using the Multisig Wallet > Show/Export XPUB > Export All > Export feature on your Cobo Vault.";
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -45,11 +45,16 @@ public class CoboVaultMultisig extends ColdcardMultisig {
|
|||
|
||||
@Override
|
||||
public String getWalletImportDescription() {
|
||||
return "Import file created by using the Multisig Wallet > Create Multisig Wallet feature on your Cobo Vault.";
|
||||
return "Import file or QR created by using the Multisig Wallet > Create Multisig Wallet feature on your Cobo Vault.";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getWalletExportDescription() {
|
||||
return "Export file that can be read by your Cobo Vault using the Multisig Wallet > Import Multisig Wallet feature.";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isScannable() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@ public class CoboVaultSinglesig implements KeystoreFileImport, WalletImport {
|
|||
|
||||
@Override
|
||||
public String getKeystoreImportDescription() {
|
||||
return "Import file created by using the Watch-Only Wallet > Generic Wallet > Export Wallet feature on your Cobo Vault.";
|
||||
return "Import file or QR created by using the Watch-Only Wallet > Generic Wallet > Export Wallet feature on your Cobo Vault.";
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -35,7 +35,11 @@ public class CoboVaultSinglesig implements KeystoreFileImport, WalletImport {
|
|||
public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
|
||||
try {
|
||||
Gson gson = new Gson();
|
||||
CoboVaultKeystore coboKeystore = gson.fromJson(new InputStreamReader(inputStream), CoboVaultKeystore.class);
|
||||
CoboVaultSinglesigKeystore coboKeystore = gson.fromJson(new InputStreamReader(inputStream), CoboVaultSinglesigKeystore.class);
|
||||
|
||||
if(coboKeystore.MasterFingerprint == null || coboKeystore.AccountKeyPath == null || coboKeystore.ExtPubKey == null) {
|
||||
throw new ImportException("Not a valid " + getName() + " keystore export");
|
||||
}
|
||||
|
||||
Keystore keystore = new Keystore();
|
||||
keystore.setLabel(getName());
|
||||
|
@ -83,7 +87,12 @@ public class CoboVaultSinglesig implements KeystoreFileImport, WalletImport {
|
|||
return false;
|
||||
}
|
||||
|
||||
private static class CoboVaultKeystore {
|
||||
@Override
|
||||
public boolean isScannable() {
|
||||
return true;
|
||||
}
|
||||
|
||||
private static class CoboVaultSinglesigKeystore {
|
||||
public String ExtPubKey;
|
||||
public String MasterFingerprint;
|
||||
public String AccountKeyPath;
|
||||
|
|
|
@ -37,7 +37,14 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
|
|||
keystore.setSource(KeystoreSource.HW_AIRGAPPED);
|
||||
keystore.setWalletModel(WalletModel.COLDCARD);
|
||||
|
||||
if(scriptType.equals(ScriptType.P2SH)) {
|
||||
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)) {
|
||||
|
@ -60,6 +67,8 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
|
|||
public String p2wsh_p2sh;
|
||||
public String p2wsh_deriv;
|
||||
public String p2wsh;
|
||||
public String xpub;
|
||||
public String path;
|
||||
public String xfp;
|
||||
}
|
||||
|
||||
|
@ -200,4 +209,9 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
|
|||
public boolean isEncrypted(File file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isScannable() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,6 +40,11 @@ public class ColdcardSinglesig implements KeystoreFileImport, WalletImport {
|
|||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isScannable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
|
||||
try {
|
||||
|
|
|
@ -317,6 +317,11 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
|
|||
return (FileType.BINARY.equals(IOUtils.getFileType(file)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isScannable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getWalletExportDescription() {
|
||||
return "Export this wallet as an Electrum wallet file.";
|
||||
|
|
|
@ -4,4 +4,5 @@ import java.io.File;
|
|||
|
||||
public interface FileImport extends Import {
|
||||
boolean isEncrypted(File file);
|
||||
boolean isScannable();
|
||||
}
|
||||
|
|
|
@ -75,6 +75,11 @@ public class Specter implements WalletImport, WalletExport {
|
|||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isScannable() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "Specter";
|
||||
|
|
|
@ -362,8 +362,6 @@ public class KeystoreController extends WalletFormController implements Initiali
|
|||
QRScanDialog.Result result = optionalResult.get();
|
||||
if(result.extendedKey != null && result.extendedKey.getKey().isPubKeyOnly()) {
|
||||
xpub.setText(result.extendedKey.getExtendedKey());
|
||||
} else if(result.error != null) {
|
||||
AppController.showErrorDialog("Invalid QR Code", result.error);
|
||||
} else if(result.exception != null) {
|
||||
log.error("Error opening webcam", result.exception);
|
||||
AppController.showErrorDialog("Error opening webcam", result.exception.getMessage());
|
||||
|
|
Loading…
Reference in a new issue