diff --git a/build.gradle b/build.gradle index 6328a2f9..4b02db75 100644 --- a/build.gradle +++ b/build.gradle @@ -51,7 +51,7 @@ dependencies { implementation('com.github.arteam:simple-json-rpc-server:1.0') { exclude group: 'org.slf4j' } - implementation('com.sparrowwallet:hummingbird:1.2') + implementation('com.sparrowwallet:hummingbird:1.3') implementation('com.nativelibs4java:bridj:0.7-20140918-3') { exclude group: 'com.google.android.tools', module: 'dx' } diff --git a/drongo b/drongo index f3e1fe6d..49799fc0 160000 --- a/drongo +++ b/drongo @@ -1 +1 @@ -Subproject commit f3e1fe6df4d64e39fb0080151255adfd057e7dbc +Subproject commit 49799fc0c8b5245a7931d0437a68172f9b6efbbc diff --git a/src/main/java/com/sparrowwallet/sparrow/AppController.java b/src/main/java/com/sparrowwallet/sparrow/AppController.java index 11a2c1b7..f13f4fc6 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppController.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppController.java @@ -569,8 +569,8 @@ public class AppController implements Initializable { Tab tab = addTransactionTab(null, result.psbt); tabs.getSelectionModel().select(tab); } else if(result.exception != null) { - log.error("Error opening webcam", result.exception); - showErrorDialog("Error opening webcam", result.exception.getMessage()); + log.error("Error scanning QR", result.exception); + showErrorDialog("Error scanning QR", result.exception.getMessage()); } else { AppController.showErrorDialog("Invalid QR Code", "Cannot parse QR code into a transaction or PSBT"); } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/FileImportPane.java b/src/main/java/com/sparrowwallet/sparrow/control/FileImportPane.java index 8271bbec..128ba7a7 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/FileImportPane.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/FileImportPane.java @@ -2,6 +2,9 @@ package com.sparrowwallet.sparrow.control; import com.google.gson.JsonParseException; import com.sparrowwallet.drongo.crypto.InvalidPasswordException; +import com.sparrowwallet.drongo.protocol.ScriptType; +import com.sparrowwallet.drongo.wallet.Keystore; +import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.io.FileImport; import com.sparrowwallet.sparrow.io.ImportException; @@ -27,6 +30,7 @@ import org.slf4j.LoggerFactory; import java.io.*; import java.nio.charset.StandardCharsets; +import java.util.List; import java.util.Optional; public abstract class FileImportPane extends TitledDescriptionPane { @@ -36,6 +40,7 @@ public abstract class FileImportPane extends TitledDescriptionPane { protected ButtonBase importButton; private final SimpleStringProperty password = new SimpleStringProperty(""); private final boolean scannable; + protected List wallets; public FileImportPane(FileImport importer, String title, String description, String content, String imageUrl, boolean scannable) { super(title, description, content, imageUrl); @@ -132,7 +137,14 @@ public abstract class FileImportPane extends TitledDescriptionPane { Optional optionalResult = qrScanDialog.showAndWait(); if(optionalResult.isPresent()) { QRScanDialog.Result result = optionalResult.get(); - if(result.payload != null) { + if(result.wallets != null) { + wallets = result.wallets; + try { + importFile(importer.getName(), null, null); + } catch(ImportException e) { + setError("Import Error", e.getMessage()); + } + } else if(result.payload != null) { try { importFile(importer.getName(), new ByteArrayInputStream(result.payload.getBytes(StandardCharsets.UTF_8)), null); } catch(Exception e) { @@ -146,10 +158,27 @@ public abstract class FileImportPane extends TitledDescriptionPane { } setError("Import Error", errorMessage); } + } else if(result.exception != null) { + log.error("Error importing QR", result.exception); + setError("Import Error", result.exception.getMessage()); } } } + protected Keystore getScannedKeystore(ScriptType scriptType) throws ImportException { + if(wallets != null) { + for(Wallet wallet : wallets) { + if(scriptType.equals(wallet.getScriptType()) && !wallet.getKeystores().isEmpty()) { + return wallet.getKeystores().get(0); + } + } + + throw new ImportException("Script type " + scriptType + " is not supported"); + } + + return null; + } + protected abstract void importFile(String fileName, InputStream inputStream, String password) throws ImportException; private Node getPasswordEntry(File file) { diff --git a/src/main/java/com/sparrowwallet/sparrow/control/FileKeystoreImportPane.java b/src/main/java/com/sparrowwallet/sparrow/control/FileKeystoreImportPane.java index 5d612e0f..01d66e89 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/FileKeystoreImportPane.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/FileKeystoreImportPane.java @@ -20,7 +20,11 @@ public class FileKeystoreImportPane extends FileImportPane { } protected void importFile(String fileName, InputStream inputStream, String password) throws ImportException { - Keystore keystore = importer.getKeystore(wallet.getScriptType(), inputStream, password); + Keystore keystore = getScannedKeystore(wallet.getScriptType()); + if(keystore == null) { + keystore = importer.getKeystore(wallet.getScriptType(), inputStream, password); + } + EventManager.get().post(new KeystoreImportEvent(keystore)); } } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java index fa488889..60562c88 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java @@ -2,12 +2,22 @@ package com.sparrowwallet.sparrow.control; import com.github.sarxos.webcam.WebcamResolution; import com.sparrowwallet.drongo.ExtendedKey; +import com.sparrowwallet.drongo.KeyDerivation; +import com.sparrowwallet.drongo.OutputDescriptor; import com.sparrowwallet.drongo.Utils; import com.sparrowwallet.drongo.address.Address; +import com.sparrowwallet.drongo.address.P2PKHAddress; +import com.sparrowwallet.drongo.address.P2SHAddress; +import com.sparrowwallet.drongo.address.P2WPKHAddress; +import com.sparrowwallet.drongo.crypto.*; import com.sparrowwallet.drongo.protocol.Base43; +import com.sparrowwallet.drongo.protocol.ScriptType; import com.sparrowwallet.drongo.protocol.Transaction; import com.sparrowwallet.drongo.psbt.PSBT; import com.sparrowwallet.drongo.uri.BitcoinURI; +import com.sparrowwallet.drongo.wallet.Keystore; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.hummingbird.registry.*; import com.sparrowwallet.sparrow.AppController; import com.sparrowwallet.hummingbird.ResultType; import com.sparrowwallet.hummingbird.UR; @@ -21,15 +31,17 @@ import javafx.scene.control.Dialog; import javafx.scene.control.DialogPane; import javafx.scene.layout.StackPane; import org.controlsfx.tools.Borders; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; +import java.util.*; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.IntStream; public class QRScanDialog extends Dialog { + private static final Logger log = LoggerFactory.getLogger(QRScanDialog.class); + private final URDecoder decoder; private final WebcamService webcamService; private List parts; @@ -88,30 +100,9 @@ public class QRScanDialog extends Dialog { if(decoder.getResult() != null) { URDecoder.Result urResult = decoder.getResult(); if(urResult.type == ResultType.SUCCESS) { - //TODO: Confirm once UR type registry is updated - if(urResult.ur.getType().contains(UR.BYTES_TYPE) || urResult.ur.getType().equals(UR.CRYPTO_PSBT_TYPE)) { - try { - PSBT psbt = new PSBT(urResult.ur.toBytes()); - result = new Result(psbt); - return; - } catch(Exception e) { - //ignore, bytes not parsable as PSBT - } - - try { - Transaction transaction = new Transaction(urResult.ur.toBytes()); - result = new Result(transaction); - return; - } catch(Exception e) { - //ignore, bytes not parsable as tx - } - - result = new Result("Parsed UR of type " + urResult.ur.getType() + " was not a PSBT or transaction"); - } else { - result = new Result("Cannot parse UR type of " + urResult.ur.getType()); - } + result = extractResultFromUR(urResult.ur); } else { - result = new Result(urResult.error); + result = new Result(new URException(urResult.error)); } } } else if(partMatcher.matches()) { @@ -143,7 +134,7 @@ public class QRScanDialog extends Dialog { //ignore, bytes not parsable as tx } - result = new Result("Parsed QR parts were not a PSBT or transaction"); + result = new Result(new ScanException("Parsed QR parts were not a PSBT or transaction")); } } else { PSBT psbt; @@ -227,6 +218,180 @@ public class QRScanDialog extends Dialog { result = new Result(qrtext); } } + + private Result extractResultFromUR(UR ur) { + try { + RegistryType urRegistryType = ur.getRegistryType(); + + if(urRegistryType.equals(RegistryType.BYTES)) { + byte[] urBytes = (byte[])ur.decodeFromRegistry(); + try { + PSBT psbt = new PSBT(urBytes); + return new Result(psbt); + } catch(Exception e) { + //ignore, bytes not parsable as PSBT + } + + try { + Transaction transaction = new Transaction(urBytes); + return new Result(transaction); + } catch(Exception e) { + //ignore, bytes not parsable as tx + } + + result = new Result(new URException("Parsed UR of type " + urRegistryType + " was not a PSBT or transaction")); + } else if(urRegistryType.equals(RegistryType.CRYPTO_PSBT)) { + CryptoPSBT cryptoPSBT = (CryptoPSBT)ur.decodeFromRegistry(); + try { + PSBT psbt = new PSBT(cryptoPSBT.getPsbt()); + return new Result(psbt); + } catch(Exception e) { + log.error("Error parsing PSBT from UR type " + urRegistryType, e); + return new Result(new URException("Error parsing PSBT from UR type " + urRegistryType, e)); + } + } else if(urRegistryType.equals(RegistryType.CRYPTO_ADDRESS)) { + CryptoAddress cryptoAddress = (CryptoAddress)ur.decodeFromRegistry(); + Address address = getAddress(cryptoAddress); + if(address != null) { + return new Result(BitcoinURI.fromAddress(address)); + } else { + return new Result(new URException("Unknown " + urRegistryType + " type of " + cryptoAddress.getType())); + } + } else if(urRegistryType.equals(RegistryType.CRYPTO_HDKEY)) { + CryptoHDKey cryptoHDKey = (CryptoHDKey)ur.decodeFromRegistry(); + ExtendedKey extendedKey = getExtendedKey(cryptoHDKey); + return new Result(extendedKey); + } else if(urRegistryType.equals(RegistryType.CRYPTO_OUTPUT)) { + CryptoOutput cryptoOutput = (CryptoOutput)ur.decodeFromRegistry(); + OutputDescriptor outputDescriptor = getOutputDescriptor(cryptoOutput); + return new Result(outputDescriptor); + } else if(urRegistryType.equals(RegistryType.CRYPTO_ACCOUNT)) { + CryptoAccount cryptoAccount = (CryptoAccount)ur.decodeFromRegistry(); + List wallets = getWallets(cryptoAccount); + return new Result(wallets); + } else { + log.error("Unsupported UR type " + urRegistryType); + return new Result(new URException("UR type " + urRegistryType + " is not supported")); + } + } catch(IllegalArgumentException e) { + log.error("Unknown UR type of " + ur.getType(), e); + return new Result(new URException("Unknown UR type of " + ur.getType(), e)); + } catch(UR.InvalidCBORException e) { + log.error("Invalid CBOR in UR", e); + return new Result(new URException("Invalid CBOR in UR", e)); + } catch(Exception e) { + log.error("Error parsing UR CBOR", e); + return new Result(new URException("Error parsing UR CBOR", e)); + } + + return null; + } + + private Address getAddress(CryptoAddress cryptoAddress) { + Address address = null; + if(cryptoAddress.getType() == CryptoAddress.Type.P2PKH) { + address = new P2PKHAddress(cryptoAddress.getData()); + } else if(cryptoAddress.getType() == CryptoAddress.Type.P2SH) { + address = new P2SHAddress(cryptoAddress.getData()); + } else if(cryptoAddress.getType() == CryptoAddress.Type.P2WPKH) { + address = new P2WPKHAddress(cryptoAddress.getData()); + } + return address; + } + + private ExtendedKey getExtendedKey(CryptoHDKey cryptoHDKey) { + if(cryptoHDKey.isPrivateKey()) { + DeterministicKey prvKey = HDKeyDerivation.createMasterPrivKeyFromBytes(Arrays.copyOfRange(cryptoHDKey.getKey(), 1, 33), cryptoHDKey.getChainCode(), List.of(ChildNumber.ZERO)); + return new ExtendedKey(prvKey, new byte[4], ChildNumber.ZERO); + } else { + ChildNumber lastChild = ChildNumber.ZERO; + int depth = 1; + byte[] parentFingerprint = new byte[4]; + if(cryptoHDKey.getOrigin() != null) { + if(!cryptoHDKey.getOrigin().getComponents().isEmpty()) { + PathComponent lastComponent = cryptoHDKey.getOrigin().getComponents().get(cryptoHDKey.getOrigin().getComponents().size() - 1); + lastChild = new ChildNumber(lastComponent.getIndex(), lastComponent.isHardened()); + depth = cryptoHDKey.getOrigin().getComponents().size(); + } + if(cryptoHDKey.getOrigin().getParentFingerprint() != null) { + parentFingerprint = cryptoHDKey.getOrigin().getParentFingerprint(); + } + } + DeterministicKey pubKey = new DeterministicKey(List.of(lastChild), cryptoHDKey.getChainCode(), cryptoHDKey.getKey(), depth, parentFingerprint); + return new ExtendedKey(pubKey, parentFingerprint, lastChild); + } + } + + private OutputDescriptor getOutputDescriptor(CryptoOutput cryptoOutput) { + ScriptType scriptType = getScriptType(cryptoOutput.getScriptExpressions()); + + if(cryptoOutput.getMultiKey() != null) { + MultiKey multiKey = cryptoOutput.getMultiKey(); + Map extendedPublicKeys = new LinkedHashMap<>(); + for(CryptoHDKey cryptoHDKey : multiKey.getHdKeys()) { + ExtendedKey extendedKey = getExtendedKey(cryptoHDKey); + KeyDerivation keyDerivation = getKeyDerivation(cryptoHDKey.getOrigin()); + extendedPublicKeys.put(extendedKey, keyDerivation); + } + return new OutputDescriptor(scriptType, multiKey.getThreshold(), extendedPublicKeys); + } else if(cryptoOutput.getEcKey() != null) { + throw new IllegalArgumentException("EC keys are currently unsupported"); + } else if(cryptoOutput.getHdKey() != null) { + ExtendedKey extendedKey = getExtendedKey(cryptoOutput.getHdKey()); + KeyDerivation keyDerivation = getKeyDerivation(cryptoOutput.getHdKey().getOrigin()); + return new OutputDescriptor(scriptType, extendedKey, keyDerivation); + } + + throw new IllegalStateException("CryptoOutput did not contain sufficient information"); + } + + private List getWallets(CryptoAccount cryptoAccount) { + List wallets = new ArrayList<>(); + String masterFingerprint = Utils.bytesToHex(cryptoAccount.getMasterFingerprint()); + for(CryptoOutput cryptoOutput : cryptoAccount.getOutputDescriptors()) { + Wallet wallet = new Wallet(); + OutputDescriptor outputDescriptor = getOutputDescriptor(cryptoOutput); + if(outputDescriptor.isMultisig()) { + throw new IllegalStateException("Multisig output descriptors are unsupported in CryptoAccount"); + } + + ExtendedKey extendedKey = outputDescriptor.getSingletonExtendedPublicKey(); + wallet.setScriptType(outputDescriptor.getScriptType()); + Keystore keystore = new Keystore(); + keystore.setKeyDerivation(new KeyDerivation(masterFingerprint, outputDescriptor.getKeyDerivation(extendedKey).getDerivationPath())); + keystore.setExtendedPublicKey(extendedKey); + wallet.getKeystores().add(keystore); + wallets.add(wallet); + } + + return wallets; + } + + private ScriptType getScriptType(List expressions) { + if(List.of(ScriptExpression.PUBLIC_KEY_HASH).equals(expressions)) { + return ScriptType.P2PKH; + } else if(List.of(ScriptExpression.SCRIPT_HASH, ScriptExpression.WITNESS_PUBLIC_KEY_HASH).equals(expressions)) { + return ScriptType.P2SH_P2WPKH; + } else if(List.of(ScriptExpression.WITNESS_PUBLIC_KEY_HASH).equals(expressions)) { + return ScriptType.P2WPKH; + } else if(List.of(ScriptExpression.SCRIPT_HASH).equals(expressions)) { + return ScriptType.P2SH; + } else if(List.of(ScriptExpression.SCRIPT_HASH, ScriptExpression.WITNESS_SCRIPT_HASH).equals(expressions)) { + return ScriptType.P2SH_P2WSH; + } else if(List.of(ScriptExpression.WITNESS_SCRIPT_HASH).equals(expressions)) { + return ScriptType.P2WSH; + } + + throw new IllegalArgumentException("Unknown script of " + expressions); + } + + private KeyDerivation getKeyDerivation(CryptoKeypath cryptoKeypath) { + if(cryptoKeypath != null) { + return new KeyDerivation(Utils.bytesToHex(cryptoKeypath.getParentFingerprint()), cryptoKeypath.getPath()); + } + + return null; + } } public static class Result { @@ -234,6 +399,8 @@ public class QRScanDialog extends Dialog { public final PSBT psbt; public final BitcoinURI uri; public final ExtendedKey extendedKey; + public final OutputDescriptor outputDescriptor; + public final List wallets; public final String payload; public final Throwable exception; @@ -242,6 +409,8 @@ public class QRScanDialog extends Dialog { this.psbt = null; this.uri = null; this.extendedKey = null; + this.outputDescriptor = null; + this.wallets = null; this.payload = null; this.exception = null; } @@ -251,6 +420,8 @@ public class QRScanDialog extends Dialog { this.psbt = psbt; this.uri = null; this.extendedKey = null; + this.outputDescriptor = null; + this.wallets = null; this.payload = null; this.exception = null; } @@ -260,6 +431,8 @@ public class QRScanDialog extends Dialog { this.psbt = null; this.uri = uri; this.extendedKey = null; + this.outputDescriptor = null; + this.wallets = null; this.payload = null; this.exception = null; } @@ -269,6 +442,8 @@ public class QRScanDialog extends Dialog { this.psbt = null; this.uri = BitcoinURI.fromAddress(address); this.extendedKey = null; + this.outputDescriptor = null; + this.wallets = null; this.payload = null; this.exception = null; } @@ -278,6 +453,30 @@ public class QRScanDialog extends Dialog { this.psbt = null; this.uri = null; this.extendedKey = extendedKey; + this.outputDescriptor = null; + this.wallets = null; + this.payload = null; + this.exception = null; + } + + public Result(OutputDescriptor outputDescriptor) { + this.transaction = null; + this.psbt = null; + this.uri = null; + this.extendedKey = null; + this.outputDescriptor = outputDescriptor; + this.wallets = null; + this.payload = null; + this.exception = null; + } + + public Result(List wallets) { + this.transaction = null; + this.psbt = null; + this.uri = null; + this.extendedKey = null; + this.outputDescriptor = null; + this.wallets = wallets; this.payload = null; this.exception = null; } @@ -287,6 +486,8 @@ public class QRScanDialog extends Dialog { this.psbt = null; this.uri = null; this.extendedKey = null; + this.outputDescriptor = null; + this.wallets = null; this.payload = payload; this.exception = null; } @@ -296,8 +497,46 @@ public class QRScanDialog extends Dialog { this.psbt = null; this.uri = null; this.extendedKey = null; + this.outputDescriptor = null; + this.wallets = null; this.payload = null; this.exception = exception; } } + + public static class ScanException extends Exception { + public ScanException() { + super(); + } + + public ScanException(String message) { + super(message); + } + + public ScanException(Throwable cause) { + super(cause); + } + + public ScanException(String message, Throwable cause) { + super(message, cause); + } + } + + public static class URException extends ScanException { + public URException() { + super(); + } + + public URException(String message) { + super(message); + } + + public URException(Throwable cause) { + super(cause); + } + + public URException(String message, Throwable cause) { + super(message, cause); + } + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java index 71b15a4f..043bc38a 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java @@ -10,6 +10,7 @@ import com.sparrowwallet.drongo.psbt.PSBTInput; import com.sparrowwallet.drongo.uri.BitcoinURI; import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.hummingbird.UR; +import com.sparrowwallet.hummingbird.registry.RegistryType; import com.sparrowwallet.sparrow.AppController; import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.control.*; @@ -620,7 +621,7 @@ public class HeadersController extends TransactionFormController implements Init toggleButton.setSelected(false); try { - QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(UR.CRYPTO_PSBT_TYPE, headersForm.getPsbt().serialize()); + QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(RegistryType.CRYPTO_PSBT.toString(), headersForm.getPsbt().serialize()); qrDisplayDialog.show(); } catch(UR.URException e) { log.error("Error creating PSBT UR", e); diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/KeystoreController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/KeystoreController.java index fbcd36e7..33f8f718 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/KeystoreController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/KeystoreController.java @@ -363,8 +363,8 @@ public class KeystoreController extends WalletFormController implements Initiali if(result.extendedKey != null && result.extendedKey.getKey().isPubKeyOnly()) { xpub.setText(result.extendedKey.getExtendedKey()); } else if(result.exception != null) { - log.error("Error opening webcam", result.exception); - AppController.showErrorDialog("Error opening webcam", result.exception.getMessage()); + log.error("Error scanning QR", result.exception); + AppController.showErrorDialog("Error scanning QR", result.exception.getMessage()); } else { AppController.showErrorDialog("Invalid QR Code", "QR Code did not contain a valid " + Network.get().getXpubHeader().getDisplayName()); }