mirror of
https://github.com/sparrowwallet/sparrow.git
synced 2026-07-31 03:56:16 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6460cb88a5 | ||
|
|
90ac786707 | ||
|
|
c86006335b | ||
|
|
a444e2af2f | ||
|
|
bcd2c966a5 | ||
|
|
f826c2d423 | ||
|
|
37bb1c5d97 | ||
|
|
68da8e6027 | ||
|
|
87fe0accd5 | ||
|
|
8d413e839c | ||
|
|
1392199f5c | ||
|
|
dc33b64a9c | ||
|
|
1b220c72ea | ||
|
|
bcf6f77340 | ||
|
|
0aac8bbea7 | ||
|
|
e1acaa8a78 | ||
|
|
13a486597c | ||
|
|
389ce2180d | ||
|
|
a7a3ad012c | ||
|
|
9f5eb41d90 | ||
|
|
3a4d385560 | ||
|
|
d3881f76b9 | ||
|
|
be5a48ce71 |
+2
-2
@@ -5,7 +5,7 @@ plugins {
|
||||
id 'org.beryx.jlink' version '2.22.0'
|
||||
}
|
||||
|
||||
def sparrowVersion = '0.9.6'
|
||||
def sparrowVersion = '0.9.7'
|
||||
def os = org.gradle.internal.os.OperatingSystem.current()
|
||||
def osName = os.getFamilyName()
|
||||
if(os.macOsX) {
|
||||
@@ -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.4')
|
||||
implementation('com.nativelibs4java:bridj:0.7-20140918-3') {
|
||||
exclude group: 'com.google.android.tools', module: 'dx'
|
||||
}
|
||||
|
||||
+1
-1
Submodule drongo updated: c4f5218f29...49799fc0c8
@@ -21,7 +21,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.9.6</string>
|
||||
<string>0.9.7</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.sparrowwallet.drongo.BitcoinUnit;
|
||||
import com.sparrowwallet.drongo.Network;
|
||||
import com.sparrowwallet.drongo.SecureString;
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
import com.sparrowwallet.drongo.crypto.ECKey;
|
||||
import com.sparrowwallet.drongo.crypto.EncryptionType;
|
||||
import com.sparrowwallet.drongo.crypto.InvalidPasswordException;
|
||||
@@ -17,6 +18,7 @@ import com.sparrowwallet.drongo.protocol.Sha256Hash;
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.drongo.psbt.PSBT;
|
||||
import com.sparrowwallet.drongo.psbt.PSBTParseException;
|
||||
import com.sparrowwallet.drongo.uri.BitcoinURI;
|
||||
import com.sparrowwallet.drongo.wallet.*;
|
||||
import com.sparrowwallet.sparrow.control.*;
|
||||
import com.sparrowwallet.sparrow.event.*;
|
||||
@@ -146,6 +148,8 @@ public class AppController implements Initializable {
|
||||
|
||||
private static List<Device> devices;
|
||||
|
||||
private static Map<Address, BitcoinURI> payjoinURIs = new HashMap<>();
|
||||
|
||||
@Override
|
||||
public void initialize(URL location, ResourceBundle resources) {
|
||||
EventManager.get().register(this);
|
||||
@@ -447,7 +451,7 @@ public class AppController implements Initializable {
|
||||
FileChooser fileChooser = new FileChooser();
|
||||
fileChooser.setTitle("Open Transaction");
|
||||
fileChooser.getExtensionFilters().addAll(
|
||||
new FileChooser.ExtensionFilter("All Files", "*"),
|
||||
new FileChooser.ExtensionFilter("All Files", org.controlsfx.tools.Platform.getCurrent().equals(org.controlsfx.tools.Platform.UNIX) ? "*" : "*.*"),
|
||||
new FileChooser.ExtensionFilter("PSBT", "*.psbt"),
|
||||
new FileChooser.ExtensionFilter("TXN", "*.txn")
|
||||
);
|
||||
@@ -565,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");
|
||||
}
|
||||
@@ -657,6 +661,17 @@ public class AppController implements Initializable {
|
||||
return devices == null ? new ArrayList<>() : devices;
|
||||
}
|
||||
|
||||
public static BitcoinURI getPayjoinURI(Address address) {
|
||||
return payjoinURIs.get(address);
|
||||
}
|
||||
|
||||
public static void addPayjoinURI(BitcoinURI bitcoinURI) {
|
||||
if(bitcoinURI.getPayjoinUrl() == null) {
|
||||
throw new IllegalArgumentException("Not a payjoin URI");
|
||||
}
|
||||
payjoinURIs.put(bitcoinURI.getAddress(), bitcoinURI);
|
||||
}
|
||||
|
||||
public Map<Wallet, Storage> getOpenWallets() {
|
||||
Map<Wallet, Storage> openWallets = new LinkedHashMap<>();
|
||||
|
||||
@@ -1031,6 +1046,9 @@ public class AppController implements Initializable {
|
||||
if(name.endsWith(".json")) {
|
||||
name = name.substring(0, name.lastIndexOf('.'));
|
||||
}
|
||||
if(!name.equals(wallet.getName())) {
|
||||
wallet.setName(name);
|
||||
}
|
||||
Tab tab = new Tab(name);
|
||||
tab.setContextMenu(getTabContextMenu(tab));
|
||||
tab.setClosable(true);
|
||||
|
||||
@@ -31,7 +31,7 @@ public class MainApp extends Application {
|
||||
private static final Logger log = LoggerFactory.getLogger(MainApp.class);
|
||||
|
||||
public static final String APP_NAME = "Sparrow";
|
||||
public static final String APP_VERSION = "0.9.6";
|
||||
public static final String APP_VERSION = "0.9.7";
|
||||
public static final String APP_HOME_PROPERTY = "sparrow.home";
|
||||
public static final String NETWORK_ENV_PROPERTY = "SPARROW_NETWORK";
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.control;
|
||||
import com.sparrowwallet.drongo.ExtendedKey;
|
||||
import com.sparrowwallet.drongo.KeyDerivation;
|
||||
import com.sparrowwallet.drongo.crypto.ChildNumber;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.psbt.PSBT;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.KeystoreSource;
|
||||
@@ -31,10 +32,14 @@ import org.controlsfx.validation.ValidationResult;
|
||||
import org.controlsfx.validation.ValidationSupport;
|
||||
import org.controlsfx.validation.Validator;
|
||||
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class DevicePane extends TitledDescriptionPane {
|
||||
private static final Logger log = LoggerFactory.getLogger(DevicePane.class);
|
||||
|
||||
private final DeviceOperation deviceOperation;
|
||||
private final Wallet wallet;
|
||||
private final PSBT psbt;
|
||||
@@ -199,7 +204,8 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
importKeystore(wallet.getScriptType().getDefaultDerivation());
|
||||
});
|
||||
String[] accounts = new String[] {"Default Account #0", "Account #1", "Account #2", "Account #3", "Account #4", "Account #5", "Account #6", "Account #7", "Account #8", "Account #9"};
|
||||
for(int i = 0; i < accounts.length; i++) {
|
||||
int scriptAccountsLength = ScriptType.P2SH.equals(wallet.getScriptType()) ? 1 : accounts.length;
|
||||
for(int i = 0; i < scriptAccountsLength; i++) {
|
||||
MenuItem item = new MenuItem(accounts[i]);
|
||||
final List<ChildNumber> derivation = wallet.getScriptType().getDefaultDerivation(i);
|
||||
item.setOnAction(event -> {
|
||||
@@ -465,6 +471,7 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
});
|
||||
signPSBTService.setOnFailed(workerStateEvent -> {
|
||||
setError("Signing Error", signPSBTService.getException().getMessage());
|
||||
log.error("Signing Error: " + signPSBTService.getException().getMessage());
|
||||
signButton.setDisable(false);
|
||||
});
|
||||
setDescription("Signing...");
|
||||
|
||||
@@ -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;
|
||||
@@ -21,11 +24,13 @@ import org.controlsfx.control.SegmentedButton;
|
||||
import org.controlsfx.control.textfield.CustomPasswordField;
|
||||
import org.controlsfx.control.textfield.TextFields;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
import org.controlsfx.tools.Platform;
|
||||
import org.slf4j.Logger;
|
||||
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 {
|
||||
@@ -35,6 +40,7 @@ public abstract class FileImportPane extends TitledDescriptionPane {
|
||||
protected ButtonBase importButton;
|
||||
private final SimpleStringProperty password = new SimpleStringProperty("");
|
||||
private final boolean scannable;
|
||||
protected List<Wallet> wallets;
|
||||
|
||||
public FileImportPane(FileImport importer, String title, String description, String content, String imageUrl, boolean scannable) {
|
||||
super(title, description, content, imageUrl);
|
||||
@@ -84,7 +90,7 @@ public abstract class FileImportPane extends TitledDescriptionPane {
|
||||
FileChooser fileChooser = new FileChooser();
|
||||
fileChooser.setTitle("Open " + importer.getWalletModel().toDisplayString() + " File");
|
||||
fileChooser.getExtensionFilters().addAll(
|
||||
new FileChooser.ExtensionFilter("All Files", "*"),
|
||||
new FileChooser.ExtensionFilter("All Files", Platform.getCurrent().equals(Platform.UNIX) ? "*" : "*.*"),
|
||||
new FileChooser.ExtensionFilter("JSON", "*.json"),
|
||||
new FileChooser.ExtensionFilter("TXT", "*.txt")
|
||||
);
|
||||
@@ -131,7 +137,14 @@ public abstract class FileImportPane extends TitledDescriptionPane {
|
||||
Optional<QRScanDialog.Result> 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) {
|
||||
@@ -145,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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.KeyDerivation;
|
||||
import com.sparrowwallet.drongo.crypto.ChildNumber;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.wallet.*;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.KeystoreImportEvent;
|
||||
@@ -107,7 +108,8 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
|
||||
importKeystore(wallet.getScriptType().getDefaultDerivation(), false);
|
||||
});
|
||||
String[] accounts = new String[] {"Default Account #0", "Account #1", "Account #2", "Account #3", "Account #4", "Account #5", "Account #6", "Account #7", "Account #8", "Account #9"};
|
||||
for(int i = 0; i < accounts.length; i++) {
|
||||
int scriptAccountsLength = ScriptType.P2SH.equals(wallet.getScriptType()) ? 1 : accounts.length;
|
||||
for(int i = 0; i < scriptAccountsLength; i++) {
|
||||
MenuItem item = new MenuItem(accounts[i]);
|
||||
final List<ChildNumber> derivation = wallet.getScriptType().getDefaultDerivation(i);
|
||||
item.setOnAction(event -> {
|
||||
|
||||
@@ -32,6 +32,8 @@ public class QRDisplayDialog extends Dialog<UR> {
|
||||
private static final int MIN_FRAGMENT_LENGTH = 10;
|
||||
private static final int MAX_FRAGMENT_LENGTH = 100;
|
||||
|
||||
private static final int ANIMATION_PERIOD_MILLIS = 200;
|
||||
|
||||
private final UR ur;
|
||||
private final UREncoder encoder;
|
||||
|
||||
@@ -61,7 +63,7 @@ public class QRDisplayDialog extends Dialog<UR> {
|
||||
qrImageView.setImage(getQrCode(currentPart));
|
||||
} else {
|
||||
AnimateQRService animateQRService = new AnimateQRService();
|
||||
animateQRService.setPeriod(Duration.millis(100));
|
||||
animateQRService.setPeriod(Duration.millis(ANIMATION_PERIOD_MILLIS));
|
||||
animateQRService.start();
|
||||
setOnCloseRequest(event -> {
|
||||
animateQRService.cancel();
|
||||
|
||||
@@ -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<QRScanDialog.Result> {
|
||||
private static final Logger log = LoggerFactory.getLogger(QRScanDialog.class);
|
||||
|
||||
private final URDecoder decoder;
|
||||
private final WebcamService webcamService;
|
||||
private List<String> parts;
|
||||
@@ -88,30 +100,9 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
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<QRScanDialog.Result> {
|
||||
//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<QRScanDialog.Result> {
|
||||
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<Wallet> 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() == null || 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<ExtendedKey, KeyDerivation> 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<Wallet> getWallets(CryptoAccount cryptoAccount) {
|
||||
List<Wallet> 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<ScriptExpression> 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<QRScanDialog.Result> {
|
||||
public final PSBT psbt;
|
||||
public final BitcoinURI uri;
|
||||
public final ExtendedKey extendedKey;
|
||||
public final OutputDescriptor outputDescriptor;
|
||||
public final List<Wallet> wallets;
|
||||
public final String payload;
|
||||
public final Throwable exception;
|
||||
|
||||
@@ -242,6 +409,8 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
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<QRScanDialog.Result> {
|
||||
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<QRScanDialog.Result> {
|
||||
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<QRScanDialog.Result> {
|
||||
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<QRScanDialog.Result> {
|
||||
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<Wallet> 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<QRScanDialog.Result> {
|
||||
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<QRScanDialog.Result> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.KeyDerivation;
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
import com.sparrowwallet.drongo.protocol.Sha256Hash;
|
||||
import com.sparrowwallet.drongo.uri.BitcoinURI;
|
||||
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
|
||||
import com.sparrowwallet.drongo.wallet.Payment;
|
||||
import com.sparrowwallet.drongo.wallet.WalletNode;
|
||||
import com.sparrowwallet.drongo.wallet.WalletTransaction;
|
||||
import com.sparrowwallet.sparrow.AppController;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.ExcludeUtxoEvent;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
@@ -74,6 +76,11 @@ public class TransactionDiagram extends GridPane {
|
||||
private Map<BlockTransactionHashIndex, WalletNode> getDisplayedUtxos() {
|
||||
Map<BlockTransactionHashIndex, WalletNode> selectedUtxos = walletTx.getSelectedUtxos();
|
||||
|
||||
if(getPayjoinURI() != null) {
|
||||
selectedUtxos = new LinkedHashMap<>(selectedUtxos);
|
||||
selectedUtxos.put(new PayjoinBlockTransactionHashIndex(), null);
|
||||
}
|
||||
|
||||
if(selectedUtxos.size() > MAX_UTXOS) {
|
||||
Map<BlockTransactionHashIndex, WalletNode> utxos = new LinkedHashMap<>();
|
||||
List<BlockTransactionHashIndex> additional = new ArrayList<>();
|
||||
@@ -92,6 +99,22 @@ public class TransactionDiagram extends GridPane {
|
||||
}
|
||||
}
|
||||
|
||||
private BitcoinURI getPayjoinURI() {
|
||||
for(Payment payment : walletTx.getPayments()) {
|
||||
try {
|
||||
Address address = payment.getAddress();
|
||||
BitcoinURI bitcoinURI = AppController.getPayjoinURI(address);
|
||||
if(bitcoinURI != null) {
|
||||
return bitcoinURI;
|
||||
}
|
||||
} catch(Exception e) {
|
||||
//ignore
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private Pane getInputsType(Map<BlockTransactionHashIndex, WalletNode> displayedUtxos) {
|
||||
StackPane stackPane = new StackPane();
|
||||
|
||||
@@ -166,9 +189,6 @@ public class TransactionDiagram extends GridPane {
|
||||
EventManager.get().post(new ExcludeUtxoEvent(walletTx, input));
|
||||
});
|
||||
|
||||
label.setGraphic(excludeUtxoButton);
|
||||
label.setContentDisplay(ContentDisplay.LEFT);
|
||||
|
||||
Tooltip tooltip = new Tooltip();
|
||||
if(walletNode != null) {
|
||||
tooltip.setText("Spending " + getSatsValue(input.getValue()) + " sats from " + walletNode.getDerivationPath().replace("m", "..") + "\n" + input.getHashAsString() + ":" + input.getIndex() + "\n" + walletTx.getWallet().getAddress(walletNode));
|
||||
@@ -177,13 +197,20 @@ public class TransactionDiagram extends GridPane {
|
||||
if(input.getLabel() == null || input.getLabel().isEmpty()) {
|
||||
label.getStyleClass().add("input-label");
|
||||
}
|
||||
|
||||
label.setGraphic(excludeUtxoButton);
|
||||
label.setContentDisplay(ContentDisplay.LEFT);
|
||||
} else {
|
||||
AdditionalBlockTransactionHashIndex additionalReference = (AdditionalBlockTransactionHashIndex)input;
|
||||
StringJoiner joiner = new StringJoiner("\n");
|
||||
for(BlockTransactionHashIndex additionalInput : additionalReference.getAdditionalInputs()) {
|
||||
joiner.add(getInputDescription(additionalInput));
|
||||
if(input instanceof PayjoinBlockTransactionHashIndex) {
|
||||
tooltip.setText("Added once transaction is signed and sent to the payjoin server");
|
||||
} else {
|
||||
AdditionalBlockTransactionHashIndex additionalReference = (AdditionalBlockTransactionHashIndex) input;
|
||||
StringJoiner joiner = new StringJoiner("\n");
|
||||
for(BlockTransactionHashIndex additionalInput : additionalReference.getAdditionalInputs()) {
|
||||
joiner.add(getInputDescription(additionalInput));
|
||||
}
|
||||
tooltip.setText(joiner.toString());
|
||||
}
|
||||
tooltip.setText(joiner.toString());
|
||||
tooltip.getStyleClass().add("input-label");
|
||||
}
|
||||
tooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
|
||||
@@ -218,11 +245,16 @@ public class TransactionDiagram extends GridPane {
|
||||
group.getChildren().add(yaxisLine);
|
||||
|
||||
double width = 140.0;
|
||||
List<BlockTransactionHashIndex> inputs = new ArrayList<>(displayedUtxos.keySet());
|
||||
int numUtxos = displayedUtxos.size();
|
||||
for(int i = 1; i <= numUtxos; i++) {
|
||||
CubicCurve curve = new CubicCurve();
|
||||
curve.getStyleClass().add("input-line");
|
||||
|
||||
if(inputs.get(numUtxos-i) instanceof PayjoinBlockTransactionHashIndex) {
|
||||
curve.getStyleClass().add("input-dashed-line");
|
||||
}
|
||||
|
||||
curve.setStartX(0);
|
||||
double scaleFactor = (double)i / (numUtxos + 1);
|
||||
int nodeHeight = 17;
|
||||
@@ -399,6 +431,17 @@ public class TransactionDiagram extends GridPane {
|
||||
return consolidationGlyph;
|
||||
}
|
||||
|
||||
public static Glyph getTxoGlyph() {
|
||||
return getChangeGlyph();
|
||||
}
|
||||
|
||||
public static Glyph getPayjoinGlyph() {
|
||||
Glyph payjoinGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.RANDOM);
|
||||
payjoinGlyph.getStyleClass().add("payjoin-icon");
|
||||
payjoinGlyph.setFontSize(12);
|
||||
return payjoinGlyph;
|
||||
}
|
||||
|
||||
public static Glyph getChangeGlyph() {
|
||||
Glyph changeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.COINS);
|
||||
changeGlyph.getStyleClass().add("change-icon");
|
||||
@@ -427,6 +470,17 @@ public class TransactionDiagram extends GridPane {
|
||||
return lockGlyph;
|
||||
}
|
||||
|
||||
private static class PayjoinBlockTransactionHashIndex extends BlockTransactionHashIndex {
|
||||
public PayjoinBlockTransactionHashIndex() {
|
||||
super(Sha256Hash.ZERO_HASH, 0, new Date(), 0L, 0, 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getLabel() {
|
||||
return "Payjoin input";
|
||||
}
|
||||
}
|
||||
|
||||
private static class AdditionalBlockTransactionHashIndex extends BlockTransactionHashIndex {
|
||||
private final List<BlockTransactionHashIndex> additionalInputs;
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ public class UtxosChart extends BarChart<String, Number> {
|
||||
public void update(WalletUtxosEntry walletUtxosEntry) {
|
||||
List<Data<String, Number>> utxoDataList = walletUtxosEntry.getChildren().stream()
|
||||
.map(entry -> new XYChart.Data<>(getCategoryName(entry), (Number)entry.getValue(), entry))
|
||||
.sorted((o1, o2) -> (int) (o2.getYValue().longValue() - o1.getYValue().longValue()))
|
||||
.sorted((o1, o2) -> Long.compare(o2.getYValue().longValue(), o1.getYValue().longValue()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
totalUtxos = utxoDataList.size();
|
||||
|
||||
@@ -38,6 +38,7 @@ public class FontAwesome5 extends GlyphFont {
|
||||
PLUS('\uf067'),
|
||||
QRCODE('\uf029'),
|
||||
QUESTION_CIRCLE('\uf059'),
|
||||
RANDOM('\uf074'),
|
||||
REPLY_ALL('\uf122'),
|
||||
SATELLITE_DISH('\uf7c0'),
|
||||
SD_CARD('\uf7c2'),
|
||||
|
||||
@@ -6,10 +6,7 @@ import com.sparrowwallet.drongo.KeyDerivation;
|
||||
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 com.sparrowwallet.drongo.wallet.*;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.InputStream;
|
||||
@@ -75,8 +72,10 @@ public class CoboVaultSinglesig implements KeystoreFileImport, WalletImport {
|
||||
wallet.getKeystores().add(keystore);
|
||||
wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2WPKH, wallet.getKeystores(), null));
|
||||
|
||||
if(!wallet.isValid()) {
|
||||
throw new ImportException("Wallet is in an inconsistent state.");
|
||||
try {
|
||||
wallet.checkWallet();
|
||||
} catch(InvalidWalletException e) {
|
||||
throw new ImportException("Imported Cobo Vault wallet was invalid: " + e.getMessage());
|
||||
}
|
||||
|
||||
return wallet;
|
||||
|
||||
@@ -88,7 +88,7 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
|
||||
wallet.setPolicyType(PolicyType.MULTI);
|
||||
|
||||
int threshold = 2;
|
||||
ScriptType scriptType = null;
|
||||
ScriptType scriptType = ScriptType.P2SH;
|
||||
String derivation = null;
|
||||
|
||||
try {
|
||||
|
||||
@@ -8,10 +8,7 @@ import com.sparrowwallet.drongo.KeyDerivation;
|
||||
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 com.sparrowwallet.drongo.wallet.*;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.InputStream;
|
||||
@@ -101,8 +98,10 @@ public class ColdcardSinglesig implements KeystoreFileImport, WalletImport {
|
||||
wallet.getKeystores().add(keystore);
|
||||
wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2WPKH, wallet.getKeystores(), null));
|
||||
|
||||
if(!wallet.isValid()) {
|
||||
throw new ImportException("Wallet is in an inconsistent state.");
|
||||
try {
|
||||
wallet.checkWallet();
|
||||
} catch(InvalidWalletException e) {
|
||||
throw new ImportException("Imported Coldcard wallet was invalid: " + e.getMessage());
|
||||
}
|
||||
|
||||
return wallet;
|
||||
|
||||
@@ -138,6 +138,9 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
|
||||
if(keystore.getWalletModel() == null) {
|
||||
throw new ImportException("Wallet has keystore of unknown hardware wallet type \"" + ek.hw_type + "\".");
|
||||
}
|
||||
if(keystore.getWalletModel().equals(WalletModel.TREZOR_1)) {
|
||||
keystore.setWalletModel(WalletModel.TREZOR_T);
|
||||
}
|
||||
} else if("bip32".equals(ek.type)) {
|
||||
if(ek.xprv != null && ek.seed == null) {
|
||||
throw new ImportException("Electrum does not support exporting BIP39 derived seeds, as it does not store the mnemonic words. Only seeds created with its native Electrum Seed Version System are exportable. " +
|
||||
@@ -198,8 +201,10 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
|
||||
|
||||
wallet.updateTransactions(ew.transactions);
|
||||
|
||||
if(!wallet.isValid()) {
|
||||
throw new IllegalStateException("Electrum wallet is in an inconsistent state.");
|
||||
try {
|
||||
wallet.checkWallet();
|
||||
} catch(InvalidWalletException e) {
|
||||
throw new IllegalStateException("Imported Electrum wallet was invalid: " + e.getMessage());
|
||||
}
|
||||
|
||||
return wallet;
|
||||
|
||||
@@ -84,7 +84,7 @@ public class Hwi {
|
||||
if(result.get("xpub") != null) {
|
||||
return result.get("xpub").getAsString();
|
||||
} else {
|
||||
throw new ImportException("Could not retrieve xpub");
|
||||
throw new ImportException("Could not retrieve xpub - reconnect your device and try again.");
|
||||
}
|
||||
} catch(IOException e) {
|
||||
throw new ImportException(e);
|
||||
@@ -535,7 +535,17 @@ public class Hwi {
|
||||
private static class DeviceModelDeserializer implements JsonDeserializer<WalletModel> {
|
||||
@Override
|
||||
public WalletModel deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
|
||||
return WalletModel.valueOf(json.getAsJsonPrimitive().getAsString().toUpperCase());
|
||||
String modelStr = json.getAsJsonPrimitive().getAsString();
|
||||
try {
|
||||
return WalletModel.valueOf(modelStr.toUpperCase());
|
||||
} catch(Exception e) {
|
||||
for(WalletModel model : WalletModel.values()) {
|
||||
if(modelStr.startsWith(model.getType())) {
|
||||
return model;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("Could not determine wallet model for " + modelStr);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.io;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.sparrowwallet.drongo.OutputDescriptor;
|
||||
import com.sparrowwallet.drongo.wallet.InvalidWalletException;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
|
||||
@@ -57,8 +58,10 @@ public class Specter implements WalletImport, WalletExport {
|
||||
Wallet wallet = outputDescriptor.toWallet();
|
||||
wallet.setName(specterWallet.label);
|
||||
|
||||
if(!wallet.isValid()) {
|
||||
throw new ImportException("Specter wallet file did not contain a valid wallet");
|
||||
try {
|
||||
wallet.checkWallet();
|
||||
} catch(InvalidWalletException e) {
|
||||
throw new ImportException("Imported Specter wallet was invalid: " + e.getMessage());
|
||||
}
|
||||
|
||||
return wallet;
|
||||
|
||||
@@ -233,7 +233,19 @@ public class ElectrumServer {
|
||||
if(optionalNode.isPresent()) {
|
||||
WalletNode node = optionalNode.get();
|
||||
|
||||
Set<BlockTransactionHash> references = Arrays.stream(txes).map(ScriptHashTx::getBlockchainTransactionHash).collect(Collectors.toCollection(TreeSet::new));
|
||||
//Some servers can return the same tx as multiple ScriptHashTx entries with different heights. Take the highest height only
|
||||
Set<BlockTransactionHash> references = Arrays.stream(txes).map(ScriptHashTx::getBlockchainTransactionHash)
|
||||
.collect(TreeSet::new, (set, ref) -> {
|
||||
Optional<BlockTransactionHash> optExisting = set.stream().filter(prev -> prev.getHash().equals(ref.getHash())).findFirst();
|
||||
if(optExisting.isPresent()) {
|
||||
if(optExisting.get().getHeight() < ref.getHeight()) {
|
||||
set.remove(optExisting.get());
|
||||
set.add(ref);
|
||||
}
|
||||
} else {
|
||||
set.add(ref);
|
||||
}
|
||||
}, TreeSet::addAll);
|
||||
Set<BlockTransactionHash> existingReferences = nodeTransactionMap.get(node);
|
||||
|
||||
if(existingReferences == null) {
|
||||
@@ -267,7 +279,7 @@ public class ElectrumServer {
|
||||
Map<String, String> pathScriptHashes = new LinkedHashMap<>();
|
||||
for(WalletNode node : nodes) {
|
||||
if(node == null) {
|
||||
log.error("Null node for wallet " + wallet.getName() + " subscribing nodes " + nodes + " startIndex " + startIndex);
|
||||
log.error("Null node for wallet " + wallet.getName() + " subscribing nodes " + nodes + " startIndex " + startIndex, new Throwable());
|
||||
}
|
||||
|
||||
if(node != null && node.getIndex() >= startIndex) {
|
||||
|
||||
@@ -133,7 +133,7 @@ public class TcpTransport implements Transport, Closeable {
|
||||
while(running) {
|
||||
try {
|
||||
String received = readInputStream();
|
||||
if(received.contains("method")) {
|
||||
if(received.contains("method") && !received.contains("error")) {
|
||||
//Handle subscription notification
|
||||
jsonRpcServer.handle(received, subscriptionService);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
package com.sparrowwallet.sparrow.payjoin;
|
||||
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
import com.google.gson.Gson;
|
||||
import com.sparrowwallet.drongo.KeyPurpose;
|
||||
import com.sparrowwallet.drongo.protocol.Script;
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.drongo.protocol.TransactionInput;
|
||||
import com.sparrowwallet.drongo.protocol.TransactionOutput;
|
||||
import com.sparrowwallet.drongo.psbt.PSBT;
|
||||
import com.sparrowwallet.drongo.psbt.PSBTInput;
|
||||
import com.sparrowwallet.drongo.psbt.PSBTOutput;
|
||||
import com.sparrowwallet.drongo.psbt.PSBTParseException;
|
||||
import com.sparrowwallet.drongo.uri.BitcoinURI;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.drongo.wallet.WalletNode;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.*;
|
||||
|
||||
public class Payjoin {
|
||||
private static final Logger log = LoggerFactory.getLogger(Payjoin.class);
|
||||
|
||||
private final BitcoinURI payjoinURI;
|
||||
private final Wallet wallet;
|
||||
private final PSBT psbt;
|
||||
|
||||
public Payjoin(BitcoinURI payjoinURI, Wallet wallet, PSBT psbt) {
|
||||
this.payjoinURI = payjoinURI;
|
||||
this.wallet = wallet;
|
||||
this.psbt = psbt;
|
||||
|
||||
for(PSBTInput psbtInput : psbt.getPsbtInputs()) {
|
||||
if(psbtInput.getUtxo() == null) {
|
||||
throw new IllegalArgumentException("Original PSBT for payjoin transaction must have non_witness_utxo or witness_utxo fields for all inputs");
|
||||
}
|
||||
}
|
||||
|
||||
if(!psbt.isFinalized()) {
|
||||
throw new IllegalArgumentException("Original PSBT for payjoin transaction must be finalized");
|
||||
}
|
||||
}
|
||||
|
||||
public PSBT requestPayjoinPSBT(boolean allowOutputSubstitution) throws PayjoinReceiverException {
|
||||
if(!payjoinURI.isPayjoinOutputSubstitutionAllowed()) {
|
||||
allowOutputSubstitution = false;
|
||||
}
|
||||
|
||||
URI uri = payjoinURI.getPayjoinUrl();
|
||||
if(uri == null) {
|
||||
log.error("No payjoin URL provided");
|
||||
throw new PayjoinReceiverException("No payjoin URL provided");
|
||||
}
|
||||
|
||||
try {
|
||||
String base64Psbt = psbt.getPublicCopy().toBase64String();
|
||||
|
||||
String appendQuery = "v=1";
|
||||
int changeOutputIndex = getChangeOutputIndex();
|
||||
long maxAdditionalFeeContribution = 0;
|
||||
if(changeOutputIndex > -1) {
|
||||
appendQuery += "&additionalfeeoutputindex=" + changeOutputIndex;
|
||||
maxAdditionalFeeContribution = getAdditionalFeeContribution();
|
||||
appendQuery += "&maxadditionalfeecontribution=" + maxAdditionalFeeContribution;
|
||||
}
|
||||
|
||||
URI finalUri = new URI(uri.getScheme(), uri.getAuthority(), uri.getPath(), uri.getQuery() == null ? appendQuery : uri.getQuery() + "&" + appendQuery, uri.getFragment());
|
||||
log.info("Sending PSBT to " + finalUri.toURL());
|
||||
|
||||
HttpClient client = HttpClient.newHttpClient();
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(finalUri)
|
||||
.header("Content-Type", "text/plain")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(base64Psbt))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if(response.statusCode() != 200) {
|
||||
Gson gson = new Gson();
|
||||
PayjoinReceiverError payjoinReceiverError = gson.fromJson(response.body(), PayjoinReceiverError.class);
|
||||
log.warn("Payjoin receiver returned an error of " + payjoinReceiverError.getErrorCode() + " (" + payjoinReceiverError.getMessage() + ")");
|
||||
throw new PayjoinReceiverException(payjoinReceiverError.getSafeMessage());
|
||||
}
|
||||
|
||||
PSBT proposalPsbt = PSBT.fromString(response.body());
|
||||
checkProposal(psbt, proposalPsbt, changeOutputIndex, maxAdditionalFeeContribution, allowOutputSubstitution);
|
||||
|
||||
return proposalPsbt;
|
||||
} catch(URISyntaxException e) {
|
||||
log.error("Invalid payjoin receiver URI", e);
|
||||
throw new PayjoinReceiverException("Invalid payjoin receiver URI", e);
|
||||
} catch(IOException | InterruptedException e) {
|
||||
log.error("Payjoin receiver error", e);
|
||||
throw new PayjoinReceiverException("Payjoin receiver error", e);
|
||||
} catch(PSBTParseException e) {
|
||||
log.error("Error parsing received PSBT", e);
|
||||
throw new PayjoinReceiverException("Payjoin receiver returned invalid PSBT", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void checkProposal(PSBT original, PSBT proposal, int changeOutputIndex, long maxAdditionalFeeContribution, boolean allowOutputSubstitution) throws PayjoinReceiverException {
|
||||
Queue<Map.Entry<TransactionInput, PSBTInput>> originalInputs = new ArrayDeque<>();
|
||||
for(int i = 0; i < original.getPsbtInputs().size(); i++) {
|
||||
originalInputs.add(Map.entry(original.getTransaction().getInputs().get(i), original.getPsbtInputs().get(i)));
|
||||
}
|
||||
|
||||
Queue<Map.Entry<TransactionOutput, PSBTOutput>> originalOutputs = new ArrayDeque<>();
|
||||
for(int i = 0; i < original.getPsbtOutputs().size(); i++) {
|
||||
originalOutputs.add(Map.entry(original.getTransaction().getOutputs().get(i), original.getPsbtOutputs().get(i)));
|
||||
}
|
||||
|
||||
// Checking that the PSBT of the receiver is clean
|
||||
if(!proposal.getExtendedPublicKeys().isEmpty()) {
|
||||
throw new PayjoinReceiverException("Global xpubs should not be included in the receiver's PSBT");
|
||||
}
|
||||
|
||||
Transaction originalTx = original.getTransaction();
|
||||
Transaction proposalTx = proposal.getTransaction();
|
||||
// Verify that the transaction version, and nLockTime are unchanged.
|
||||
if(proposalTx.getVersion() != originalTx.getVersion()) {
|
||||
throw new PayjoinReceiverException("The proposal PSBT changed the transaction version");
|
||||
}
|
||||
if(proposalTx.getLocktime() != originalTx.getLocktime()) {
|
||||
throw new PayjoinReceiverException("The proposal PSBT changed the nLocktime");
|
||||
}
|
||||
|
||||
Set<Long> sequences = new HashSet<>();
|
||||
// For each inputs in the proposal:
|
||||
for(PSBTInput proposedPSBTInput : proposal.getPsbtInputs()) {
|
||||
if(!proposedPSBTInput.getDerivedPublicKeys().isEmpty()) {
|
||||
throw new PayjoinReceiverException("The receiver added keypaths to an input");
|
||||
}
|
||||
if(!proposedPSBTInput.getPartialSignatures().isEmpty()) {
|
||||
throw new PayjoinReceiverException("The receiver added partial signatures to an input");
|
||||
}
|
||||
|
||||
TransactionInput proposedTxIn = proposedPSBTInput.getInput();
|
||||
boolean isOriginalInput = originalInputs.size() > 0 && originalInputs.peek().getKey().getOutpoint().equals(proposedTxIn.getOutpoint());
|
||||
if(isOriginalInput) {
|
||||
Map.Entry<TransactionInput, PSBTInput> originalInput = originalInputs.remove();
|
||||
TransactionInput originalTxIn = originalInput.getKey();
|
||||
|
||||
// Verify that sequence is unchanged.
|
||||
if(originalTxIn.getSequenceNumber() != proposedTxIn.getSequenceNumber()) {
|
||||
throw new PayjoinReceiverException("The proposed transaction input modified the sequence of one of the original inputs");
|
||||
}
|
||||
// Verify the PSBT input is not finalized
|
||||
if(proposedPSBTInput.isFinalized()) {
|
||||
throw new PayjoinReceiverException("The receiver finalized one of the original inputs");
|
||||
}
|
||||
// Verify that non_witness_utxo and witness_utxo are not specified.
|
||||
if(proposedPSBTInput.getNonWitnessUtxo() != null || proposedPSBTInput.getWitnessUtxo() != null) {
|
||||
throw new PayjoinReceiverException("The receiver added non_witness_utxo or witness_utxo to one of the original inputs");
|
||||
}
|
||||
sequences.add(proposedTxIn.getSequenceNumber());
|
||||
|
||||
PSBTInput originalPSBTInput = originalInput.getValue();
|
||||
// Fill up the info from the original PSBT input so we can sign and get fees.
|
||||
proposedPSBTInput.setNonWitnessUtxo(originalPSBTInput.getNonWitnessUtxo());
|
||||
proposedPSBTInput.setWitnessUtxo(originalPSBTInput.getWitnessUtxo());
|
||||
// We fill up information we had on the signed PSBT, so we can sign it.
|
||||
proposedPSBTInput.getDerivedPublicKeys().putAll(originalPSBTInput.getDerivedPublicKeys());
|
||||
proposedPSBTInput.getProprietary().putAll(originalPSBTInput.getProprietary());
|
||||
proposedPSBTInput.setRedeemScript(originalPSBTInput.getFinalScriptSig().getFirstNestedScript());
|
||||
proposedPSBTInput.setWitnessScript(originalPSBTInput.getFinalScriptWitness().getWitnessScript());
|
||||
proposedPSBTInput.setSigHash(originalPSBTInput.getSigHash());
|
||||
} else {
|
||||
// Verify the PSBT input is finalized
|
||||
if(!proposedPSBTInput.isFinalized()) {
|
||||
throw new PayjoinReceiverException("The receiver did not finalize one of their inputs");
|
||||
}
|
||||
// Verify that non_witness_utxo or witness_utxo are filled in.
|
||||
if(proposedPSBTInput.getNonWitnessUtxo() == null && proposedPSBTInput.getWitnessUtxo() == null) {
|
||||
throw new PayjoinReceiverException("The receiver did not specify non_witness_utxo or witness_utxo for one of their inputs");
|
||||
}
|
||||
sequences.add(proposedTxIn.getSequenceNumber());
|
||||
// Verify that the payjoin proposal did not introduced mixed inputs' type.
|
||||
if(wallet.getScriptType() != proposedPSBTInput.getScriptType()) {
|
||||
throw new PayjoinReceiverException("Proposal script type of " + proposedPSBTInput.getScriptType() + " did not match wallet script type of " + wallet.getScriptType());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify that all of sender's inputs from the original PSBT are in the proposal.
|
||||
if(!originalInputs.isEmpty()) {
|
||||
throw new PayjoinReceiverException("Some of the original inputs are not included in the proposal");
|
||||
}
|
||||
|
||||
// Verify that the payjoin proposal did not introduced mixed inputs' sequence.
|
||||
if(sequences.size() != 1) {
|
||||
throw new PayjoinReceiverException("Mixed sequences detected in the proposal");
|
||||
}
|
||||
|
||||
Long newFee = proposal.getFee();
|
||||
long additionalFee = newFee - original.getFee();
|
||||
if(additionalFee < 0) {
|
||||
throw new PayjoinReceiverException("The receiver decreased absolute fee");
|
||||
}
|
||||
|
||||
TransactionOutput changeOutput = (changeOutputIndex > -1 ? originalTx.getOutputs().get(changeOutputIndex) : null);
|
||||
|
||||
// For each outputs in the proposal:
|
||||
for(int i = 0; i < proposal.getPsbtOutputs().size(); i++) {
|
||||
PSBTOutput proposedPSBTOutput = proposal.getPsbtOutputs().get(i);
|
||||
// Verify that no keypaths is in the PSBT output
|
||||
if(!proposedPSBTOutput.getDerivedPublicKeys().isEmpty()) {
|
||||
throw new PayjoinReceiverException("The receiver added keypaths to an output");
|
||||
}
|
||||
|
||||
TransactionOutput proposedTxOut = proposalTx.getOutputs().get(i);
|
||||
boolean isOriginalOutput = originalOutputs.size() > 0 && originalOutputs.peek().getKey().getScript().equals(proposedTxOut.getScript());
|
||||
if(isOriginalOutput) {
|
||||
Map.Entry<TransactionOutput, PSBTOutput> originalOutput = originalOutputs.remove();
|
||||
if(originalOutput.getKey() == changeOutput) {
|
||||
var actualContribution = changeOutput.getValue() - proposedTxOut.getValue();
|
||||
// The amount that was subtracted from the output's value is less than or equal to maxadditionalfeecontribution
|
||||
if(actualContribution > maxAdditionalFeeContribution) {
|
||||
throw new PayjoinReceiverException("The actual contribution is more than maxadditionalfeecontribution");
|
||||
}
|
||||
// Make sure the actual contribution is only paying fee
|
||||
if(actualContribution > additionalFee) {
|
||||
throw new PayjoinReceiverException("The actual contribution is not only paying fee");
|
||||
}
|
||||
// Make sure the actual contribution is only paying for fee incurred by additional inputs
|
||||
int additionalInputsCount = proposalTx.getInputs().size() - originalTx.getInputs().size();
|
||||
if(actualContribution > getSingleInputFee() * additionalInputsCount) {
|
||||
throw new PayjoinReceiverException("The actual contribution is not only paying for additional inputs");
|
||||
}
|
||||
} else if(allowOutputSubstitution && originalOutput.getKey().getScript().equals(payjoinURI.getAddress().getOutputScript())) {
|
||||
// That's the payment output, the receiver may have changed it.
|
||||
} else {
|
||||
if(originalOutput.getKey().getValue() > proposedTxOut.getValue()) {
|
||||
throw new PayjoinReceiverException("The receiver decreased the value of one of the outputs");
|
||||
}
|
||||
}
|
||||
|
||||
PSBTOutput originalPSBTOutput = originalOutput.getValue();
|
||||
// We fill up information we had on the signed PSBT, so we can sign it.
|
||||
proposedPSBTOutput.getDerivedPublicKeys().putAll(originalPSBTOutput.getDerivedPublicKeys());
|
||||
proposedPSBTOutput.getProprietary().putAll(originalPSBTOutput.getProprietary());
|
||||
proposedPSBTOutput.setRedeemScript(originalPSBTOutput.getRedeemScript());
|
||||
proposedPSBTOutput.setWitnessScript(originalPSBTOutput.getWitnessScript());
|
||||
}
|
||||
}
|
||||
|
||||
// Verify that all of sender's outputs from the original PSBT are in the proposal.
|
||||
if(!originalOutputs.isEmpty()) {
|
||||
// The payment output may have been substituted
|
||||
if(!allowOutputSubstitution || originalOutputs.size() != 1 || !originalOutputs.remove().getKey().getScript().equals(payjoinURI.getAddress().getOutputScript())) {
|
||||
throw new PayjoinReceiverException("Some of our outputs are not included in the proposal");
|
||||
}
|
||||
}
|
||||
|
||||
//Add global pubkey map for signing
|
||||
proposal.getExtendedPublicKeys().putAll(psbt.getExtendedPublicKeys());
|
||||
proposal.getGlobalProprietary().putAll(psbt.getGlobalProprietary());
|
||||
}
|
||||
|
||||
private int getChangeOutputIndex() {
|
||||
Map<Script, WalletNode> changeScriptNodes = wallet.getWalletOutputScripts(KeyPurpose.CHANGE);
|
||||
for(int i = 0; i < psbt.getTransaction().getOutputs().size(); i++) {
|
||||
if(changeScriptNodes.containsKey(psbt.getTransaction().getOutputs().get(i).getScript())) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private long getAdditionalFeeContribution() {
|
||||
return getSingleInputFee();
|
||||
}
|
||||
|
||||
private long getSingleInputFee() {
|
||||
Transaction transaction = psbt.extractTransaction();
|
||||
double feeRate = psbt.getFee().doubleValue() / transaction.getVirtualSize();
|
||||
int vSize = 68;
|
||||
|
||||
if(transaction.getInputs().size() > 0) {
|
||||
TransactionInput input = transaction.getInputs().get(0);
|
||||
vSize = input.getLength() * Transaction.WITNESS_SCALE_FACTOR;
|
||||
vSize += input.getWitness() != null ? input.getWitness().getLength() : 0;
|
||||
vSize = (int)Math.ceil((double)vSize / Transaction.WITNESS_SCALE_FACTOR);
|
||||
}
|
||||
|
||||
return (long) (vSize * feeRate);
|
||||
}
|
||||
|
||||
private static class PayjoinReceiverError {
|
||||
Map<String, String> knownErrors = ImmutableMap.of(
|
||||
"unavailable", "The payjoin endpoint is not available for now.",
|
||||
"not-enough-money", "The receiver added some inputs but could not bump the fee of the payjoin proposal.",
|
||||
"version-unsupported", "This version of payjoin is not supported.",
|
||||
"original-psbt-rejected", "The receiver rejected the original PSBT."
|
||||
);
|
||||
|
||||
public String errorCode;
|
||||
public String message;
|
||||
|
||||
public String getErrorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public String getSafeMessage() {
|
||||
String message = knownErrors.get(errorCode);
|
||||
return (message == null ? "Unknown Error" : message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.sparrowwallet.sparrow.payjoin;
|
||||
|
||||
public class PayjoinReceiverException extends Exception {
|
||||
public PayjoinReceiverException() {
|
||||
super();
|
||||
}
|
||||
|
||||
public PayjoinReceiverException(String msg) {
|
||||
super(msg);
|
||||
}
|
||||
|
||||
public PayjoinReceiverException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
|
||||
public PayjoinReceiverException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -109,7 +109,7 @@ public class ServerPreferencesController extends PreferencesDetailController {
|
||||
FileChooser fileChooser = new FileChooser();
|
||||
fileChooser.setTitle("Select Electrum Server certificate");
|
||||
fileChooser.getExtensionFilters().addAll(
|
||||
new FileChooser.ExtensionFilter("All Files", "*"),
|
||||
new FileChooser.ExtensionFilter("All Files", org.controlsfx.tools.Platform.getCurrent().equals(org.controlsfx.tools.Platform.UNIX) ? "*" : "*.*"),
|
||||
new FileChooser.ExtensionFilter("CRT", "*.crt")
|
||||
);
|
||||
|
||||
|
||||
@@ -3,11 +3,14 @@ package com.sparrowwallet.sparrow.transaction;
|
||||
import com.sparrowwallet.drongo.KeyPurpose;
|
||||
import com.sparrowwallet.drongo.SecureString;
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
import com.sparrowwallet.drongo.protocol.*;
|
||||
import com.sparrowwallet.drongo.psbt.PSBT;
|
||||
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.*;
|
||||
@@ -16,6 +19,8 @@ import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands;
|
||||
import com.sparrowwallet.sparrow.io.Device;
|
||||
import com.sparrowwallet.sparrow.net.ElectrumServer;
|
||||
import com.sparrowwallet.sparrow.io.Storage;
|
||||
import com.sparrowwallet.sparrow.payjoin.Payjoin;
|
||||
import com.sparrowwallet.sparrow.payjoin.PayjoinReceiverException;
|
||||
import com.sparrowwallet.sparrow.wallet.HashIndexEntry;
|
||||
import com.sparrowwallet.sparrow.wallet.TransactionEntry;
|
||||
import javafx.application.Platform;
|
||||
@@ -40,10 +45,8 @@ import tornadofx.control.Fieldset;
|
||||
import com.google.common.eventbus.Subscribe;
|
||||
import tornadofx.control.Form;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.net.URL;
|
||||
import java.io.*;
|
||||
import java.net.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.time.*;
|
||||
@@ -196,6 +199,9 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
@FXML
|
||||
private Button saveFinalButton;
|
||||
|
||||
@FXML
|
||||
private Button payjoinButton;
|
||||
|
||||
@Override
|
||||
public void initialize(URL location, ResourceBundle resources) {
|
||||
EventManager.get().register(this);
|
||||
@@ -356,6 +362,12 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
saveFinalButton.visibleProperty().bind(broadcastButton.visibleProperty().not());
|
||||
broadcastButton.visibleProperty().bind(AppController.onlineProperty());
|
||||
|
||||
BitcoinURI payjoinURI = getPayjoinURI();
|
||||
boolean isPayjoinOriginalTx = payjoinURI != null && headersForm.getPsbt() != null && headersForm.getPsbt().getPsbtInputs().stream().noneMatch(PSBTInput::isFinalized);
|
||||
payjoinButton.managedProperty().bind(payjoinButton.visibleProperty());
|
||||
payjoinButton.visibleProperty().set(isPayjoinOriginalTx);
|
||||
broadcastButton.setDefaultButton(!isPayjoinOriginalTx);
|
||||
|
||||
blockchainForm.setVisible(false);
|
||||
signingWalletForm.setVisible(false);
|
||||
sigHashForm.setVisible(false);
|
||||
@@ -365,7 +377,6 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
broadcastButtonBox.setVisible(false);
|
||||
|
||||
if(headersForm.getBlockTransaction() != null) {
|
||||
blockchainForm.setVisible(true);
|
||||
updateBlockchainForm(headersForm.getBlockTransaction(), AppController.getCurrentBlockHeight());
|
||||
} else if(headersForm.getPsbt() != null) {
|
||||
PSBT psbt = headersForm.getPsbt();
|
||||
@@ -470,6 +481,7 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
}
|
||||
|
||||
private void updateBlockchainForm(BlockTransaction blockTransaction, Integer currentHeight) {
|
||||
signaturesForm.setVisible(false);
|
||||
blockchainForm.setVisible(true);
|
||||
|
||||
if(Sha256Hash.ZERO_HASH.equals(blockTransaction.getBlockHash()) && blockTransaction.getHeight() == 0 && headersForm.getSigningWallet() == null) {
|
||||
@@ -541,6 +553,24 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
}
|
||||
}
|
||||
|
||||
private BitcoinURI getPayjoinURI() {
|
||||
if(headersForm.getPsbt() != null) {
|
||||
for(TransactionOutput txOutput : headersForm.getPsbt().getTransaction().getOutputs()) {
|
||||
try {
|
||||
Address address = txOutput.getScript().getToAddresses()[0];
|
||||
BitcoinURI bitcoinURI = AppController.getPayjoinURI(address);
|
||||
if(bitcoinURI != null) {
|
||||
return bitcoinURI;
|
||||
}
|
||||
} catch(Exception e) {
|
||||
//ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static class BlockHeightContextMenu extends ContextMenu {
|
||||
public BlockHeightContextMenu(Sha256Hash blockHash) {
|
||||
MenuItem copyBlockHash = new MenuItem("Copy Block Hash");
|
||||
@@ -556,7 +586,7 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
|
||||
private void updateTxId() {
|
||||
id.setText(headersForm.getTransaction().calculateTxId(false).toString());
|
||||
if(headersForm.getPsbt() != null && !(headersForm.getTransaction().hasScriptSigs() || headersForm.getTransaction().hasWitnesses())) {
|
||||
if(!headersForm.isTransactionFinalized()) {
|
||||
if(!id.getStyleClass().contains(UNFINALIZED_TXID_CLASS)) {
|
||||
id.getStyleClass().add(UNFINALIZED_TXID_CLASS);
|
||||
}
|
||||
@@ -591,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);
|
||||
@@ -788,6 +818,21 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
}
|
||||
}
|
||||
|
||||
public void getPayjoinTransaction(ActionEvent event) {
|
||||
BitcoinURI payjoinURI = getPayjoinURI();
|
||||
if(payjoinURI == null) {
|
||||
throw new IllegalStateException("No valid Payjoin URI");
|
||||
}
|
||||
|
||||
try {
|
||||
Payjoin payjoin = new Payjoin(payjoinURI, headersForm.getSigningWallet(), headersForm.getPsbt());
|
||||
PSBT proposalPsbt = payjoin.requestPayjoinPSBT(true);
|
||||
EventManager.get().post(new ViewPSBTEvent(headersForm.getName() + " Payjoin", proposalPsbt));
|
||||
} catch(PayjoinReceiverException e) {
|
||||
AppController.showErrorDialog("Invalid Payjoin Transaction", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void update() {
|
||||
BlockTransaction blockTransaction = headersForm.getBlockTransaction();
|
||||
@@ -961,7 +1006,7 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
|
||||
@Subscribe
|
||||
public void walletNodeHistoryChanged(WalletNodeHistoryChangedEvent event) {
|
||||
if(headersForm.getSigningWallet() != null && event.getWalletNode(headersForm.getSigningWallet()) != null) {
|
||||
if(headersForm.getSigningWallet() != null && event.getWalletNode(headersForm.getSigningWallet()) != null && headersForm.isTransactionFinalized()) {
|
||||
Sha256Hash txid = headersForm.getTransaction().getTxId();
|
||||
ElectrumServer.TransactionReferenceService transactionReferenceService = new ElectrumServer.TransactionReferenceService(Set.of(txid), event.getScriptHash());
|
||||
transactionReferenceService.setOnSucceeded(successEvent -> {
|
||||
@@ -969,7 +1014,6 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
BlockTransaction blockTransaction = transactionMap.get(txid);
|
||||
if(blockTransaction != null) {
|
||||
headersForm.setBlockTransaction(blockTransaction);
|
||||
signaturesForm.setVisible(false);
|
||||
updateBlockchainForm(blockTransaction, AppController.getCurrentBlockHeight());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.sparrowwallet.drongo.protocol.*;
|
||||
import com.sparrowwallet.drongo.psbt.PSBTInput;
|
||||
import com.sparrowwallet.drongo.wallet.BlockTransaction;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.control.*;
|
||||
import com.sparrowwallet.sparrow.event.*;
|
||||
@@ -120,6 +121,11 @@ public class InputController extends TransactionFormController implements Initia
|
||||
TransactionInput txInput = inputForm.getTransactionInput();
|
||||
PSBTInput psbtInput = inputForm.getPsbtInput();
|
||||
|
||||
inputForm.signingWalletProperty().addListener((observable, oldValue, signingWallet) -> {
|
||||
updateInputLegendFromWallet(txInput, signingWallet);
|
||||
});
|
||||
updateInputLegendFromWallet(txInput, inputForm.getSigningWallet());
|
||||
|
||||
initializeInputFields(txInput, psbtInput);
|
||||
initializeScriptFields(txInput, psbtInput);
|
||||
initializeStatusFields(txInput, psbtInput);
|
||||
@@ -132,9 +138,27 @@ public class InputController extends TransactionFormController implements Initia
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeInputFields(TransactionInput txInput, PSBTInput psbtInput) {
|
||||
inputFieldset.setText("Input #" + txInput.getIndex());
|
||||
private String getLegendText(TransactionInput txInput) {
|
||||
return "Input #" + txInput.getIndex();
|
||||
}
|
||||
|
||||
private void updateInputLegendFromWallet(TransactionInput txInput, Wallet signingWallet) {
|
||||
String baseText = getLegendText(txInput);
|
||||
if(signingWallet != null) {
|
||||
if(inputForm.isWalletTxo()) {
|
||||
inputFieldset.setText(baseText + " - " + signingWallet.getName());
|
||||
inputFieldset.setIcon(TransactionDiagram.getTxoGlyph());
|
||||
} else {
|
||||
inputFieldset.setText(baseText + " - Payjoin");
|
||||
inputFieldset.setIcon(TransactionDiagram.getPayjoinGlyph());
|
||||
}
|
||||
} else {
|
||||
inputFieldset.setText(baseText);
|
||||
inputFieldset.setIcon(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeInputFields(TransactionInput txInput, PSBTInput psbtInput) {
|
||||
outpoint.managedProperty().bind(outpoint.visibleProperty());
|
||||
linkedOutpoint.managedProperty().bind(linkedOutpoint.visibleProperty());
|
||||
|
||||
|
||||
@@ -46,6 +46,11 @@ public class InputForm extends IndexedTransactionForm {
|
||||
return null;
|
||||
}
|
||||
|
||||
public boolean isWalletTxo() {
|
||||
TransactionInput txInput = getTransactionInput();
|
||||
return getSigningWallet() != null && getSigningWallet().getWalletTxos().keySet().stream().anyMatch(ref -> ref.getHash().equals(txInput.getOutpoint().getHash()) && ref.getIndex() == txInput.getOutpoint().getIndex());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Node getContents() throws IOException {
|
||||
FXMLLoader loader = new FXMLLoader(getClass().getResource("input.fxml"));
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.sparrowwallet.sparrow.transaction;
|
||||
|
||||
import com.google.common.eventbus.Subscribe;
|
||||
import com.sparrowwallet.drongo.KeyPurpose;
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
import com.sparrowwallet.drongo.protocol.NonStandardScriptException;
|
||||
import com.sparrowwallet.drongo.protocol.TransactionInput;
|
||||
@@ -106,10 +105,10 @@ public class OutputController extends TransactionFormController implements Initi
|
||||
private void updateOutputLegendFromWallet(TransactionOutput txOutput, Wallet signingWallet) {
|
||||
String baseText = getLegendText(txOutput);
|
||||
if(signingWallet != null) {
|
||||
if(signingWallet.getWalletOutputScripts(KeyPurpose.RECEIVE).containsKey(txOutput.getScript())) {
|
||||
if(outputForm.isWalletConsolidation()) {
|
||||
outputFieldset.setText(baseText + " - Consolidation");
|
||||
outputFieldset.setIcon(TransactionDiagram.getConsolidationGlyph());
|
||||
} else if(signingWallet.getWalletOutputScripts(KeyPurpose.CHANGE).containsKey(txOutput.getScript())) {
|
||||
} else if(outputForm.isWalletChange()) {
|
||||
outputFieldset.setText(baseText + " - Change");
|
||||
outputFieldset.setIcon(TransactionDiagram.getChangeGlyph());
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.sparrowwallet.sparrow.transaction;
|
||||
|
||||
import com.sparrowwallet.drongo.KeyPurpose;
|
||||
import com.sparrowwallet.drongo.protocol.TransactionOutput;
|
||||
import com.sparrowwallet.drongo.psbt.PSBTOutput;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
@@ -30,6 +31,18 @@ public class OutputForm extends IndexedTransactionForm {
|
||||
return psbtOutput;
|
||||
}
|
||||
|
||||
public boolean isWalletConsolidation() {
|
||||
return (getSigningWallet() != null && getSigningWallet().getWalletOutputScripts(KeyPurpose.RECEIVE).containsKey(getTransactionOutput().getScript()));
|
||||
}
|
||||
|
||||
public boolean isWalletChange() {
|
||||
return (getSigningWallet() != null && getSigningWallet().getWalletOutputScripts(KeyPurpose.CHANGE).containsKey(getTransactionOutput().getScript()));
|
||||
}
|
||||
|
||||
public boolean isWalletPayment() {
|
||||
return getSigningWallet() != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Node getContents() throws IOException {
|
||||
FXMLLoader loader = new FXMLLoader(getClass().getResource("output.fxml"));
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.sparrowwallet.drongo.wallet.BlockTransaction;
|
||||
import com.sparrowwallet.sparrow.AppController;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.TransactionTabData;
|
||||
import com.sparrowwallet.sparrow.control.TransactionDiagram;
|
||||
import com.sparrowwallet.sparrow.control.TransactionHexArea;
|
||||
import com.sparrowwallet.sparrow.event.*;
|
||||
import com.sparrowwallet.sparrow.net.ElectrumServer;
|
||||
@@ -17,11 +18,10 @@ import javafx.fxml.FXML;
|
||||
import javafx.fxml.Initializable;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.Parent;
|
||||
import javafx.scene.control.TreeCell;
|
||||
import javafx.scene.control.TreeItem;
|
||||
import javafx.scene.control.TreeView;
|
||||
import javafx.scene.control.cell.TextFieldTreeCell;
|
||||
import javafx.scene.layout.Pane;
|
||||
import javafx.util.StringConverter;
|
||||
import org.controlsfx.control.MasterDetailPane;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -125,17 +125,46 @@ public class TransactionController implements Initializable {
|
||||
rootItem.getChildren().add(outputsItem);
|
||||
txtree.setRoot(rootItem);
|
||||
|
||||
txtree.setCellFactory(p -> new TextFieldTreeCell<>(new StringConverter<TransactionForm>() {
|
||||
txtree.setCellFactory(tc -> new TreeCell<>() {
|
||||
@Override
|
||||
public String toString(TransactionForm transactionForm) {
|
||||
return transactionForm.toString();
|
||||
}
|
||||
protected void updateItem(TransactionForm form, boolean empty) {
|
||||
super.updateItem(form, empty);
|
||||
|
||||
@Override
|
||||
public TransactionForm fromString(String string) {
|
||||
throw new IllegalStateException("No editing");
|
||||
setText(null);
|
||||
setGraphic(null);
|
||||
setTooltip(null);
|
||||
setContextMenu(null);
|
||||
|
||||
if(form != null) {
|
||||
setText(form.toString());
|
||||
|
||||
if(form.getSigningWallet() != null) {
|
||||
if(form instanceof InputForm) {
|
||||
InputForm inputForm = (InputForm)form;
|
||||
if(inputForm.isWalletTxo()) {
|
||||
setGraphic(TransactionDiagram.getTxoGlyph());
|
||||
} else {
|
||||
setGraphic(TransactionDiagram.getPayjoinGlyph());
|
||||
}
|
||||
}
|
||||
if(form instanceof OutputForm) {
|
||||
OutputForm outputForm = (OutputForm)form;
|
||||
if(outputForm.isWalletConsolidation()) {
|
||||
setGraphic(TransactionDiagram.getConsolidationGlyph());
|
||||
} else if(outputForm.isWalletChange()) {
|
||||
setGraphic(TransactionDiagram.getChangeGlyph());
|
||||
} else {
|
||||
setGraphic(TransactionDiagram.getPaymentGlyph());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
txdata.signingWalletProperty().addListener((observable, oldValue, newValue) -> {
|
||||
txtree.refresh();
|
||||
});
|
||||
|
||||
txtree.getSelectionModel().selectedItemProperty().addListener((observable, old_val, selectedItem) -> {
|
||||
TransactionForm transactionForm = selectedItem.getValue();
|
||||
|
||||
@@ -107,6 +107,10 @@ public abstract class TransactionForm {
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean isTransactionFinalized() {
|
||||
return getPsbt() == null || getTransaction().hasScriptSigs() || getTransaction().hasWitnesses();
|
||||
}
|
||||
|
||||
public abstract Node getContents() throws IOException;
|
||||
|
||||
public abstract TransactionView getView();
|
||||
|
||||
@@ -101,6 +101,6 @@ public class HashIndexEntry extends Entry implements Comparable<HashIndexEntry>
|
||||
return o.getHashIndex().getComparisonHeight() - getHashIndex().getComparisonHeight();
|
||||
}
|
||||
|
||||
return (int)o.getHashIndex().getIndex() - (int)getHashIndex().getIndex();
|
||||
return Long.compare(o.getHashIndex().getIndex(), (int)getHashIndex().getIndex());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.sparrowwallet.drongo.address.InvalidAddressException;
|
||||
import com.sparrowwallet.drongo.address.P2PKHAddress;
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.drongo.protocol.TransactionOutput;
|
||||
import com.sparrowwallet.drongo.uri.BitcoinURI;
|
||||
import com.sparrowwallet.drongo.wallet.MaxUtxoSelector;
|
||||
import com.sparrowwallet.drongo.wallet.Payment;
|
||||
import com.sparrowwallet.drongo.wallet.UtxoSelector;
|
||||
@@ -22,9 +23,9 @@ import com.sparrowwallet.sparrow.event.ExchangeRatesUpdatedEvent;
|
||||
import com.sparrowwallet.sparrow.event.FiatCurrencySelectedEvent;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import com.sparrowwallet.sparrow.io.ExchangeSource;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.fxml.Initializable;
|
||||
@@ -95,6 +96,14 @@ public class PaymentController extends WalletFormController implements Initializ
|
||||
@Override
|
||||
public void initializeView() {
|
||||
address.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
try {
|
||||
BitcoinURI bitcoinURI = new BitcoinURI(newValue);
|
||||
Platform.runLater(() -> updateFromURI(bitcoinURI));
|
||||
return;
|
||||
} catch(Exception e) {
|
||||
//ignore, not a URI
|
||||
}
|
||||
|
||||
revalidate(amount, amountListener);
|
||||
maxButton.setDisable(!isValidRecipientAddress());
|
||||
sendController.updateTransaction();
|
||||
@@ -291,20 +300,27 @@ public class PaymentController extends WalletFormController implements Initializ
|
||||
if(optionalResult.isPresent()) {
|
||||
QRScanDialog.Result result = optionalResult.get();
|
||||
if(result.uri != null) {
|
||||
if(result.uri.getAddress() != null) {
|
||||
address.setText(result.uri.getAddress().toString());
|
||||
}
|
||||
if(result.uri.getLabel() != null) {
|
||||
label.setText(result.uri.getLabel());
|
||||
}
|
||||
if(result.uri.getAmount() != null) {
|
||||
setRecipientValueSats(result.uri.getAmount());
|
||||
}
|
||||
sendController.updateTransaction();
|
||||
updateFromURI(result.uri);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void updateFromURI(BitcoinURI bitcoinURI) {
|
||||
if(bitcoinURI.getAddress() != null) {
|
||||
address.setText(bitcoinURI.getAddress().toString());
|
||||
}
|
||||
if(bitcoinURI.getLabel() != null) {
|
||||
label.setText(bitcoinURI.getLabel());
|
||||
}
|
||||
if(bitcoinURI.getAmount() != null) {
|
||||
setRecipientValueSats(bitcoinURI.getAmount());
|
||||
}
|
||||
if(bitcoinURI.getPayjoinUrl() != null) {
|
||||
AppController.addPayjoinURI(bitcoinURI);
|
||||
}
|
||||
sendController.updateTransaction();
|
||||
}
|
||||
|
||||
public void addPayment(ActionEvent event) {
|
||||
sendController.addPaymentTab();
|
||||
}
|
||||
|
||||
@@ -150,15 +150,7 @@ public class SendController extends WalletFormController implements Initializabl
|
||||
addValidation();
|
||||
|
||||
addPaymentTab();
|
||||
Platform.runLater(() -> {
|
||||
StackPane stackPane = (StackPane)paymentTabs.lookup(".tab-header-area");
|
||||
if(stackPane != null) {
|
||||
tabHeader = stackPane;
|
||||
tabHeader.managedProperty().bind(tabHeader.visibleProperty());
|
||||
tabHeader.setVisible(false);
|
||||
paymentTabs.getStyleClass().remove("initial");
|
||||
}
|
||||
});
|
||||
initializeTabHeader(0);
|
||||
|
||||
paymentTabs.getTabs().addListener((ListChangeListener<Tab>) c -> {
|
||||
if(tabHeader != null) {
|
||||
@@ -279,6 +271,21 @@ public class SendController extends WalletFormController implements Initializabl
|
||||
});
|
||||
}
|
||||
|
||||
private void initializeTabHeader(int count) {
|
||||
final int lookupCount = count;
|
||||
Platform.runLater(() -> {
|
||||
StackPane stackPane = (StackPane)paymentTabs.lookup(".tab-header-area");
|
||||
if(stackPane != null) {
|
||||
tabHeader = stackPane;
|
||||
tabHeader.managedProperty().bind(tabHeader.visibleProperty());
|
||||
tabHeader.setVisible(false);
|
||||
paymentTabs.getStyleClass().remove("initial");
|
||||
} else if(lookupCount < 20) {
|
||||
initializeTabHeader(lookupCount+1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public BitcoinUnit getBitcoinUnit(BitcoinUnit bitcoinUnit) {
|
||||
BitcoinUnit unit = bitcoinUnit;
|
||||
if(unit == null || unit.equals(BitcoinUnit.AUTO)) {
|
||||
@@ -598,7 +605,9 @@ public class SendController extends WalletFormController implements Initializabl
|
||||
private void addWalletTransactionNodes() {
|
||||
WalletTransaction walletTransaction = walletTransactionProperty.get();
|
||||
Set<WalletNode> nodes = new LinkedHashSet<>(walletTransaction.getSelectedUtxos().values());
|
||||
nodes.add(walletTransaction.getChangeNode());
|
||||
if(walletTransaction.getChangeNode() != null) {
|
||||
nodes.add(walletTransaction.getChangeNode());
|
||||
}
|
||||
List<WalletNode> consolidationNodes = walletTransaction.getConsolidationSendNodes();
|
||||
nodes.addAll(consolidationNodes);
|
||||
|
||||
|
||||
@@ -13,10 +13,7 @@ import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
import com.sparrowwallet.sparrow.AppController;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.control.CopyableLabel;
|
||||
import com.sparrowwallet.sparrow.control.DescriptorArea;
|
||||
import com.sparrowwallet.sparrow.control.TextAreaDialog;
|
||||
import com.sparrowwallet.sparrow.control.WalletPasswordDialog;
|
||||
import com.sparrowwallet.sparrow.control.*;
|
||||
import com.sparrowwallet.sparrow.event.RequestOpenWalletsEvent;
|
||||
import com.sparrowwallet.sparrow.event.SettingsChangedEvent;
|
||||
import com.sparrowwallet.sparrow.event.StorageEvent;
|
||||
@@ -251,6 +248,21 @@ public class SettingsController extends WalletFormController implements Initiali
|
||||
}
|
||||
}
|
||||
|
||||
public void scanDescriptorQR(ActionEvent event) {
|
||||
QRScanDialog qrScanDialog = new QRScanDialog();
|
||||
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
|
||||
if(optionalResult.isPresent()) {
|
||||
QRScanDialog.Result result = optionalResult.get();
|
||||
if(result.outputDescriptor != null) {
|
||||
setDescriptorText(result.outputDescriptor.toString());
|
||||
} else if(result.payload != null && !result.payload.isEmpty()) {
|
||||
setDescriptorText(result.payload);
|
||||
} else if(result.exception != null) {
|
||||
AppController.showErrorDialog("Error scanning QR", result.exception.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void editDescriptor(ActionEvent event) {
|
||||
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(walletForm.getWallet());
|
||||
String outputDescriptorString = outputDescriptor.toString(walletForm.getWallet().isValid());
|
||||
@@ -260,22 +272,26 @@ public class SettingsController extends WalletFormController implements Initiali
|
||||
dialog.getDialogPane().setHeaderText("Wallet output descriptor:");
|
||||
Optional<String> text = dialog.showAndWait();
|
||||
if(text.isPresent() && !text.get().isEmpty() && !text.get().equals(outputDescriptorString)) {
|
||||
try {
|
||||
OutputDescriptor editedOutputDescriptor = OutputDescriptor.getOutputDescriptor(text.get());
|
||||
Wallet editedWallet = editedOutputDescriptor.toWallet();
|
||||
setDescriptorText(text.get());
|
||||
}
|
||||
}
|
||||
|
||||
editedWallet.setName(getWalletForm().getWallet().getName());
|
||||
keystoreTabs.getTabs().removeAll(keystoreTabs.getTabs());
|
||||
totalKeystores.unbind();
|
||||
totalKeystores.setValue(0);
|
||||
walletForm.setWallet(editedWallet);
|
||||
initialising = true;
|
||||
setFieldsFromWallet(editedWallet);
|
||||
private void setDescriptorText(String text) {
|
||||
try {
|
||||
OutputDescriptor editedOutputDescriptor = OutputDescriptor.getOutputDescriptor(text.trim().replace("\\", ""));
|
||||
Wallet editedWallet = editedOutputDescriptor.toWallet();
|
||||
|
||||
EventManager.get().post(new SettingsChangedEvent(editedWallet, SettingsChangedEvent.Type.POLICY));
|
||||
} catch(Exception e) {
|
||||
AppController.showErrorDialog("Invalid output descriptor", e.getMessage());
|
||||
}
|
||||
editedWallet.setName(getWalletForm().getWallet().getName());
|
||||
keystoreTabs.getTabs().removeAll(keystoreTabs.getTabs());
|
||||
totalKeystores.unbind();
|
||||
totalKeystores.setValue(0);
|
||||
walletForm.setWallet(editedWallet);
|
||||
initialising = true;
|
||||
setFieldsFromWallet(editedWallet);
|
||||
|
||||
EventManager.get().post(new SettingsChangedEvent(editedWallet, SettingsChangedEvent.Type.POLICY));
|
||||
} catch(Exception e) {
|
||||
AppController.showErrorDialog("Invalid output descriptor", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,9 @@ package com.sparrowwallet.sparrow.wallet;
|
||||
|
||||
import com.google.common.eventbus.Subscribe;
|
||||
import com.sparrowwallet.drongo.KeyPurpose;
|
||||
import com.sparrowwallet.drongo.wallet.BlockTransaction;
|
||||
import com.sparrowwallet.drongo.wallet.BlockTransactionHash;
|
||||
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.drongo.protocol.TransactionInput;
|
||||
import com.sparrowwallet.drongo.protocol.TransactionOutput;
|
||||
import com.sparrowwallet.drongo.wallet.*;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.WalletTabData;
|
||||
import com.sparrowwallet.sparrow.event.WalletBlockHeightChangedEvent;
|
||||
@@ -85,6 +84,35 @@ public class TransactionEntry extends Entry implements Comparable<TransactionEnt
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isComplete() {
|
||||
int validEntries = 0;
|
||||
Map<BlockTransactionHashIndex, WalletNode> walletTxos = wallet.getWalletTxos();
|
||||
for(TransactionInput txInput : blockTransaction.getTransaction().getInputs()) {
|
||||
Optional<BlockTransactionHashIndex> optRef = walletTxos.keySet().stream().filter(ref -> ref.getHash().equals(txInput.getOutpoint().getHash()) && ref.getIndex() == txInput.getOutpoint().getIndex()).findFirst();
|
||||
if(optRef.isPresent()) {
|
||||
validEntries++;
|
||||
if(getChildren().stream().noneMatch(entry -> ((HashIndexEntry)entry).getHashIndex().equals(optRef.get().getSpentBy()) && ((HashIndexEntry)entry).getType().equals(HashIndexEntry.Type.INPUT))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
for(TransactionOutput txOutput : blockTransaction.getTransaction().getOutputs()) {
|
||||
Optional<BlockTransactionHashIndex> optRef = walletTxos.keySet().stream().filter(ref -> ref.getHash().equals(txOutput.getHash()) && ref.getIndex() == txOutput.getIndex()).findFirst();
|
||||
if(optRef.isPresent()) {
|
||||
validEntries++;
|
||||
if(getChildren().stream().noneMatch(entry -> ((HashIndexEntry)entry).getHashIndex().equals(optRef.get()) && ((HashIndexEntry)entry).getType().equals(HashIndexEntry.Type.OUTPUT))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(getChildren().size() != validEntries) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static List<Entry> createChildEntries(Wallet wallet, Map<BlockTransactionHashIndex, KeyPurpose> incoming, Map<BlockTransactionHashIndex, KeyPurpose> outgoing) {
|
||||
List<Entry> incomingOutputEntries = incoming.entrySet().stream().map(input -> new TransactionHashIndexEntry(wallet, input.getKey(), HashIndexEntry.Type.OUTPUT, input.getValue())).collect(Collectors.toList());
|
||||
List<Entry> outgoingInputEntries = outgoing.entrySet().stream().map(output -> new TransactionHashIndexEntry(wallet, output.getKey(), HashIndexEntry.Type.INPUT, output.getValue())).collect(Collectors.toList());
|
||||
@@ -97,15 +125,15 @@ public class TransactionEntry extends Entry implements Comparable<TransactionEnt
|
||||
TransactionHashIndexEntry entry1 = (TransactionHashIndexEntry) o1;
|
||||
TransactionHashIndexEntry entry2 = (TransactionHashIndexEntry) o2;
|
||||
|
||||
if (!entry1.getHashIndex().getHash().equals(entry2.getHashIndex().getHash())) {
|
||||
if(!entry1.getHashIndex().getHash().equals(entry2.getHashIndex().getHash())) {
|
||||
return entry1.getHashIndex().getHash().compareTo(entry2.getHashIndex().getHash());
|
||||
}
|
||||
|
||||
if (!entry1.getType().equals(entry2.getType())) {
|
||||
if(!entry1.getType().equals(entry2.getType())) {
|
||||
return entry1.getType().ordinal() - entry2.getType().ordinal();
|
||||
}
|
||||
|
||||
return (int) entry1.getHashIndex().getIndex() - (int) entry2.getHashIndex().getIndex();
|
||||
return Long.compare(entry1.getHashIndex().getIndex(), entry2.getHashIndex().getIndex());
|
||||
});
|
||||
|
||||
return childEntries;
|
||||
|
||||
@@ -9,11 +9,15 @@ import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.NewWalletTransactionsEvent;
|
||||
import javafx.beans.property.LongProperty;
|
||||
import javafx.beans.property.LongPropertyBase;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class WalletTransactionsEntry extends Entry {
|
||||
private static final Logger log = LoggerFactory.getLogger(WalletTransactionsEntry.class);
|
||||
|
||||
private final Wallet wallet;
|
||||
|
||||
public WalletTransactionsEntry(Wallet wallet) {
|
||||
@@ -67,12 +71,21 @@ public class WalletTransactionsEntry extends Entry {
|
||||
|
||||
calculateBalances();
|
||||
|
||||
if(!entriesAdded.isEmpty()) {
|
||||
List<Entry> entriesComplete = entriesAdded.stream().filter(txEntry -> ((TransactionEntry)txEntry).isComplete()).collect(Collectors.toList());
|
||||
if(!entriesComplete.isEmpty()) {
|
||||
List<BlockTransaction> blockTransactions = entriesAdded.stream().map(txEntry -> ((TransactionEntry)txEntry).getBlockTransaction()).collect(Collectors.toList());
|
||||
long totalBlockchainValue = entriesAdded.stream().filter(txEntry -> ((TransactionEntry)txEntry).getConfirmations() > 0).mapToLong(Entry::getValue).sum();
|
||||
long totalMempoolValue = entriesAdded.stream().filter(txEntry -> ((TransactionEntry)txEntry).getConfirmations() == 0).mapToLong(Entry::getValue).sum();
|
||||
EventManager.get().post(new NewWalletTransactionsEvent(wallet, blockTransactions, totalBlockchainValue, totalMempoolValue));
|
||||
}
|
||||
|
||||
if(entriesAdded.size() > entriesComplete.size()) {
|
||||
entriesAdded.removeAll(entriesComplete);
|
||||
for(Entry entry : entriesAdded) {
|
||||
TransactionEntry txEntry = (TransactionEntry)entry;
|
||||
log.warn("Not notifying for incomplete entry " + ((TransactionEntry)entry).getBlockTransaction().getHashAsString() + " value " + txEntry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Collection<WalletTransaction> getWalletTransactions(Wallet wallet) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
open module com.sparrowwallet.sparrow {
|
||||
requires java.desktop;
|
||||
requires java.net.http;
|
||||
requires javafx.controls;
|
||||
requires javafx.fxml;
|
||||
requires javafx.graphics;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
-fx-base: #373e43;
|
||||
-fx-control-inner-background: derive(-fx-base, 35%);
|
||||
-fx-control-inner-background-alt: -fx-control-inner-background ;
|
||||
-fx-default-button: -fx-accent;
|
||||
}
|
||||
|
||||
.label{
|
||||
@@ -41,7 +42,7 @@
|
||||
-fx-base: -fx-accent ;
|
||||
}
|
||||
|
||||
.table-view{
|
||||
.table-view, .tree-view {
|
||||
/*-fx-background-color: derive(-fx-base, 10%);*/
|
||||
-fx-selection-bar-non-focused: derive(-fx-base, 50%);
|
||||
}
|
||||
@@ -97,6 +98,10 @@
|
||||
-fx-fill: #e06c75;
|
||||
}
|
||||
|
||||
.root .invalid-checksum {
|
||||
-fx-text-fill: #e06c75;
|
||||
}
|
||||
|
||||
.root .etched-raised-border {
|
||||
-fx-border-color: #ffffff, #000000;
|
||||
-fx-border-style: solid, solid;
|
||||
|
||||
@@ -248,6 +248,11 @@
|
||||
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="20" icon="ARROW_DOWN" />
|
||||
</graphic>
|
||||
</Button>
|
||||
<Button fx:id="payjoinButton" defaultButton="true" HBox.hgrow="ALWAYS" text="Get Payjoin Transaction" contentDisplay="TOP" wrapText="true" textAlignment="CENTER" onAction="#getPayjoinTransaction">
|
||||
<graphic>
|
||||
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="20" icon="RANDOM" />
|
||||
</graphic>
|
||||
</Button>
|
||||
</HBox>
|
||||
</VBox>
|
||||
</Fieldset>
|
||||
|
||||
@@ -24,4 +24,7 @@
|
||||
|
||||
.locktime { -fx-fill: color-grey }
|
||||
|
||||
|
||||
.legend {
|
||||
-fx-content-display: RIGHT;
|
||||
-fx-graphic-text-gap: 5px;
|
||||
}
|
||||
|
||||
@@ -89,6 +89,10 @@
|
||||
-fx-stroke-width: 1px;
|
||||
}
|
||||
|
||||
#transactionDiagram .input-dashed-line {
|
||||
-fx-stroke-dash-array: 5px 5px;
|
||||
}
|
||||
|
||||
#transactionDiagram .utxo-label .button {
|
||||
-fx-padding: 0;
|
||||
-fx-pref-height: 18;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<?import com.sparrowwallet.drongo.protocol.ScriptType?>
|
||||
<?import com.sparrowwallet.sparrow.control.DescriptorArea?>
|
||||
<?import org.fxmisc.flowless.VirtualizedScrollPane?>
|
||||
<?import org.controlsfx.glyphfont.Glyph?>
|
||||
|
||||
<BorderPane stylesheets="@settings.css, @wallet.css, @../script.css, @../descriptor.css, @../general.css" styleClass="wallet-pane" xmlns="http://javafx.com/javafx/10.0.2-internal" xmlns:fx="http://javafx.com/fxml/1" fx:controller="com.sparrowwallet.sparrow.wallet.SettingsController">
|
||||
<center>
|
||||
@@ -79,6 +80,14 @@
|
||||
<DescriptorArea fx:id="descriptor" editable="false" styleClass="uneditable-codearea" prefHeight="27" maxHeight="27" />
|
||||
</content>
|
||||
</VirtualizedScrollPane>
|
||||
<Button onAction="#scanDescriptorQR">
|
||||
<graphic>
|
||||
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="CAMERA" />
|
||||
</graphic>
|
||||
<tooltip>
|
||||
<Tooltip text="Scan a QR code" />
|
||||
</tooltip>
|
||||
</Button>
|
||||
<Button text="Edit..." onAction="#editDescriptor" />
|
||||
</Field>
|
||||
</Fieldset>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"mode": "OFFLINE",
|
||||
"bitcoinUnit": "AUTO",
|
||||
"groupByAddress": true,
|
||||
"includeMempoolChange": true,
|
||||
"notifyNewTransactions": true,
|
||||
"checkNewVersions": true,
|
||||
"theme": "LIGHT",
|
||||
"useProxy": false
|
||||
}
|
||||
Reference in New Issue
Block a user