Compare commits

..
42 Commits
Author SHA1 Message Date
Craig Raw f8fa7f4cf2 add separate button to backup tapsigner without changing pin 2023-06-22 10:41:59 +02:00
Craig Raw bcbb531414 fix ui button update when stopping mixing on terminal 2023-06-22 09:10:52 +02:00
Craig Raw 2deab05c45 remove path from non-batched electrum server requests 2023-06-22 08:21:57 +02:00
Craig Raw c7923300c6 update kmp-tor to v1.4.3, fix storage tests 2023-06-22 07:54:48 +02:00
Craig Raw c4651025be format fee rates in broadcast errors using unit format 2023-06-21 16:42:57 +02:00
Craig Raw 2897f88c8b fix unit format switching on send tab 2023-06-21 15:47:21 +02:00
Craig Raw ee20a6980b fix incorrect hours check for 24h mempool fee rates toggle 2023-06-21 11:40:04 +02:00
Craig Raw 721c446fa8 update seedsigner help text 2023-06-21 10:36:34 +02:00
Craig Raw 7c43ee7208 add context menu options to date column in utxos table to freeze utxo and view transaction 2023-06-21 09:33:42 +02:00
Craig Raw c6ea37e081 add 24h mempool fees chart toggle 2023-06-21 08:33:05 +02:00
Craig Raw 171bf24133 optimize fetching mempool entries for fee histogram when connected to bitcoin core, fix and improve mempool fee rates chart 2023-06-20 16:15:19 +02:00
Craig Raw 3242f00812 improve performance on large wallets with high address reuse 2023-06-15 12:19:09 +02:00
Craig Raw 15500b6535 highlight csv download buttons 2023-06-13 14:13:44 +02:00
Craig Raw 700c880b92 improve error message on broadcasting a tx with fee rate below the purge rate for the connected mempool 2023-06-11 10:50:19 +02:00
Craig Raw 7f2c07c918 set all witness utxos on psbt inputs before attempting to sign sweep tx 2023-06-08 11:07:32 +02:00
Craig Raw 0745d21761 fix export to specter desktop without any wallet history 2023-06-08 08:58:43 +02:00
Craig Raw 50aa9b4dcb ensure canonical ordering of key expressions in multisig descriptor qr 2023-06-07 10:21:29 +02:00
Craig Raw fe50cb845e log batch errors getting fee estimates from server 2023-06-05 11:49:10 +02:00
Craig Raw f4b9807285 widen mnemonic grid dialog to accommodate word numbers 2023-06-04 10:02:55 +02:00
Craig Raw f534beb624 add additional rbf tx inputs if needed as required fee is increased 2023-06-01 15:31:15 +02:00
Craig Raw 719cfaa906 support airgapped keystore import of a tapsigner with a custom derivation path 2023-05-31 15:50:06 +02:00
Craig Raw 98d9a6882b look for supported cards across all connected card terminals 2023-05-31 14:25:45 +02:00
Craig Raw dbbeaf67b6 support spendable property on utxos in bip329 wallet label imports and exports 2023-05-31 10:42:32 +02:00
Craig Raw 2a542bb8b9 avoid multiple selection on border wallets grid, display word number, clarify recovery phrase purpose on pdf 2023-05-30 14:01:45 +02:00
Craig Raw 29b630f6bf update to hummingbird v1.6.7 to support pair path components and unique part progress indicator 2023-05-29 17:20:43 +02:00
Craig Raw 3aa00076c6 trim leading and trailing whitespace from pay to address field 2023-05-25 16:15:38 +02:00
Craig Raw 3cf99961d3 improve error message when broadcasting an rbf transaction with insufficient fee, indicate minimum required fee if available 2023-05-25 14:48:32 +02:00
Craig Raw 742727d6f2 add ctrl/cmd+b keyboard shortcut for quickly switching from btc to satoshis and back 2023-05-25 13:22:44 +02:00
Craig Raw 5d99eee89a invert cropped frame and scan for inverted qrs 2023-05-25 12:29:43 +02:00
Craig Raw b52be27a99 support scanning text qr containing seed words 2023-05-15 15:49:39 -05:00
Craig Raw e68f177e4a bump to v1.7.7 2023-04-14 18:31:10 -06:00
Craig Raw e0a6626650 add further guidance when regenerating a border wallets grid 2023-04-14 16:41:47 -06:00
Craig Raw 1d8888bb14 fix button placement on border wallets dialog in windows 2023-04-14 15:59:24 -06:00
Craig Raw d0958b7936 clear border wallets selected cells on grid initialization 2023-04-14 13:26:29 -06:00
Craig Raw d9d316a627 bump to v1.7.6 2023-04-14 10:25:08 -06:00
Craig Raw 0270910b74 update specter desktop wallet import 2023-04-14 08:29:24 -06:00
Craig Raw 432e0642ca cormorant: improve error message when core wallet support is disabled 2023-04-14 07:50:06 -06:00
Craig Raw c7ab8e4601 support broadcasting via mempool.space when using signet 2023-04-14 07:41:00 -06:00
Craig Raw 1a46f8a643 preserve order of cell selection in border wallets grid 2023-04-14 06:36:34 -06:00
Craig Raw 04145bde74 add border wallets number grid support 2023-04-06 14:57:56 +02:00
Craig Raw 483e4c8f38 always show tapsigner in airgapped import options 2023-04-05 08:24:18 +02:00
Craig Raw fe4468d49d bump to v1.7.5 2023-04-05 08:10:36 +02:00
55 changed files with 921 additions and 221 deletions
+14 -27
View File
@@ -7,7 +7,7 @@ plugins {
id 'org.beryx.jlink' version '2.26.0'
}
def sparrowVersion = '1.7.4'
def sparrowVersion = '1.7.7'
def os = org.gradle.internal.os.OperatingSystem.current()
def osName = os.getFamilyName()
if(os.macOsX) {
@@ -24,7 +24,7 @@ if(System.getProperty("os.arch") == "aarch64") {
def headless = "true".equals(System.getProperty("java.awt.headless"))
def vTor = '4.7.13-4'
def vKmpTor = '1.4.2'
def vKmpTor = '1.4.3'
def kmpOs = osName
if(os.macOsX) {
kmpOs = "macos"
@@ -89,7 +89,7 @@ dependencies {
implementation('com.github.arteam:simple-json-rpc-server:1.3') {
exclude group: 'org.slf4j'
}
implementation('com.sparrowwallet:hummingbird:1.6.4')
implementation('com.sparrowwallet:hummingbird:1.6.7')
implementation('co.nstant.in:cbor:0.9')
implementation("com.nativelibs4java:bridj${targetName}:0.7-20140918-3") {
exclude group: 'com.google.android.tools', module: 'dx'
@@ -105,7 +105,7 @@ dependencies {
}
implementation("io.matthewnelson.kotlin-components:kmp-tor-binary-extract:${vTor}")
implementation("io.matthewnelson.kotlin-components:kmp-tor-ext-callback-manager:${vKmpTor}")
implementation('org.jetbrains.kotlinx:kotlinx-coroutines-javafx:1.6.4')
implementation('org.jetbrains.kotlinx:kotlinx-coroutines-javafx:1.7.1')
implementation('de.codecentric.centerdevice:centerdevice-nsmenufx:2.1.7')
implementation('org.controlsfx:controlsfx:11.1.0' ) {
exclude group: 'org.openjfx', module: 'javafx-base'
@@ -182,7 +182,7 @@ run {
"--add-opens=java.base/java.net=com.sparrowwallet.sparrow",
"--add-opens=java.base/java.io=com.google.gson",
"--add-opens=java.smartcardio/sun.security.smartcardio=com.sparrowwallet.sparrow",
"--add-reads=kotlin.stdlib=kotlinx.coroutines.core.jvm"]
"--add-reads=kotlin.stdlib=kotlinx.coroutines.core"]
if(os.macOsX) {
applicationDefaultJvmArgs += ["-Dprism.lcdtext=false", "-Xdock:name=Sparrow", "-Xdock:icon=/Users/scy/git/sparrow/src/main/resources/sparrow-large.png",
@@ -241,7 +241,7 @@ jlink {
"--add-reads=com.sparrowwallet.merged.module=com.fasterxml.jackson.annotation",
"--add-reads=com.sparrowwallet.merged.module=com.fasterxml.jackson.core",
"--add-reads=com.sparrowwallet.merged.module=co.nstant.in.cbor",
"--add-reads=kotlin.stdlib=kotlinx.coroutines.core.jvm"]
"--add-reads=kotlin.stdlib=kotlinx.coroutines.core"]
if(os.windows) {
jvmArgs += ["-Djavax.accessibility.assistive_technologies", "-Djavax.accessibility.screen_magnifier_present=false"]
@@ -605,19 +605,6 @@ extraJavaModuleInfo {
requires('javafx.graphics')
requires('java.xml')
}
module('kotlinx-coroutines-core-jvm-1.6.4.jar', 'kotlinx.coroutines.core.jvm', '1.6.4') {
exports('kotlinx.coroutines')
requires('kotlin.stdlib')
requires('java.instrument')
uses('kotlinx.coroutines.CoroutineExceptionHandler')
uses('kotlinx.coroutines.internal.MainDispatcherFactory')
}
module('kotlinx-coroutines-javafx-1.6.4.jar', 'kotlinx.coroutines.javafx', '1.6.4') {
exports('kotlinx.coroutines.javafx')
requires('kotlinx.coroutines.core.jvm')
requires('kotlin.stdlib')
requires('javafx.graphics')
}
module("kmp-tor-jvm-${vKmpTor}.jar", 'kmp.tor.jvm', "${vTor}-${vKmpTor}") {
exports('io.matthewnelson.kmp.tor')
requires('kmp.tor.binary.extract.jvm')
@@ -625,7 +612,7 @@ extraJavaModuleInfo {
requires('kmp.tor.manager.common.jvm')
requires('kmp.tor.controller.common.jvm')
requires('kotlin.stdlib')
requires('kotlinx.coroutines.core.jvm')
requires('kotlinx.coroutines.core')
requires('java.management')
}
if(kmpOs == "linux" && kmpArch == "arm64") {
@@ -652,7 +639,7 @@ extraJavaModuleInfo {
requires('kmp.tor.controller.common.jvm')
requires('kmp.tor.manager.common.jvm')
requires('kotlin.stdlib')
requires('kotlinx.coroutines.core.jvm')
requires('kotlinx.coroutines.core')
requires('kotlinx.atomicfu')
requires('kmp.tor.controller.jvm')
requires('kmp.tor.common.jvm')
@@ -685,7 +672,7 @@ extraJavaModuleInfo {
exports('io.matthewnelson.kmp.tor.controller.internal.controller')
requires('kmp.tor.common.jvm')
requires('kmp.tor.controller.common.jvm')
requires('kotlinx.coroutines.core.jvm')
requires('kotlinx.coroutines.core')
requires('kotlin.stdlib')
requires('kotlinx.atomicfu')
requires('encoding.core.jvm')
@@ -703,7 +690,7 @@ extraJavaModuleInfo {
requires('kmp.tor.manager.common.jvm')
requires('kmp.tor.controller.common.jvm')
requires('kotlin.stdlib')
requires('kotlinx.coroutines.core.jvm')
requires('kotlinx.coroutines.core')
}
module("kmp-tor-ext-callback-manager-common-jvm-${vKmpTor}.jar", 'kmp.tor.ext.callback.manager.common.jvm', "${vKmpTor}") {
exports('io.matthewnelson.kmp.tor.ext.callback.manager.common')
@@ -717,14 +704,14 @@ extraJavaModuleInfo {
exports('io.matthewnelson.kmp.tor.binary.geoip')
exports('kmptor')
}
module("encoding-base16-jvm-1.2.1.jar", 'encoding.base16.jvm', "1.2.1") {
module("base16-jvm-2.0.0.jar", 'encoding.base16.jvm', "2.0.0") {
exports('io.matthewnelson.encoding.base16')
requires('encoding.core.jvm')
requires('kotlin.stdlib')
}
module("encoding-base32-jvm-1.2.1.jar", 'encoding.base32.jvm', "1.2.1")
module("encoding-base64-jvm-1.2.1.jar", 'encoding.base64.jvm', "1.2.1")
module("encoding-core-jvm-1.2.1.jar", 'encoding.core.jvm', "1.2.1") {
module("base32-jvm-2.0.0.jar", 'encoding.base32.jvm', "2.0.0")
module("base64-jvm-2.0.0.jar", 'encoding.base64.jvm', "2.0.0")
module("core-jvm-2.0.0.jar", 'encoding.core.jvm', "2.0.0") {
exports('io.matthewnelson.encoding.core')
requires('kotlin.stdlib')
}
+1 -1
View File
@@ -82,7 +82,7 @@ sudo apt install -y rpm fakeroot binutils
First, assign a temporary variable in your shell for the specific release you want to build. For the current one specify:
```shell
GIT_TAG="1.7.3"
GIT_TAG="1.7.6"
```
The project can then be initially cloned as follows:
+1 -1
Submodule drongo updated: fe19c86544...a8df17ff5e
+1 -1
View File
@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.7.4</string>
<string>1.7.7</string>
<key>CFBundleSignature</key>
<string>????</string>
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
@@ -328,6 +328,9 @@ public class AppController implements Initializable {
final BitcoinUnit selectedUnit = unit;
Optional<Toggle> selectedUnitToggle = bitcoinUnit.getToggles().stream().filter(toggle -> selectedUnit.equals(toggle.getUserData())).findFirst();
selectedUnitToggle.ifPresent(toggle -> bitcoinUnit.selectToggle(toggle));
Optional<Toggle> otherUnitToggle = bitcoinUnit.getToggles().stream().filter(toggle ->
(List.of(BitcoinUnit.AUTO, BitcoinUnit.SATOSHIS).contains(selectedUnit) && BitcoinUnit.BTC.equals(toggle.getUserData()) || (selectedUnit == BitcoinUnit.BTC && BitcoinUnit.SATOSHIS.equals(toggle.getUserData())))).findFirst();
otherUnitToggle.ifPresent(toggle -> ((RadioMenuItem)toggle).setAccelerator(new KeyCodeCombination(KeyCode.B, KeyCombination.SHORTCUT_DOWN)));
UnitFormat format = Config.get().getUnitFormat();
if(format == null) {
@@ -2797,6 +2800,14 @@ public class AppController implements Initializable {
public void bitcoinUnitChanged(BitcoinUnitChangedEvent event) {
Optional<Toggle> selectedToggle = bitcoinUnit.getToggles().stream().filter(toggle -> event.getBitcoinUnit().equals(toggle.getUserData())).findFirst();
selectedToggle.ifPresent(toggle -> bitcoinUnit.selectToggle(toggle));
bitcoinUnit.getToggles().forEach(toggle -> {
RadioMenuItem menuItem = (RadioMenuItem)toggle;
if(List.of(BitcoinUnit.AUTO, BitcoinUnit.SATOSHIS).contains(event.getBitcoinUnit()) && BitcoinUnit.BTC.equals(toggle.getUserData()) || (event.getBitcoinUnit() == BitcoinUnit.BTC && BitcoinUnit.SATOSHIS.equals(toggle.getUserData()))) {
menuItem.setAccelerator(new KeyCodeCombination(KeyCode.B, KeyCombination.SHORTCUT_DOWN));
} else {
menuItem.setAccelerator(null);
}
});
}
@Subscribe
@@ -669,6 +669,10 @@ public class AppServices {
}
private void addMempoolRateSizes(Set<MempoolRateSize> rateSizes) {
if(rateSizes.isEmpty()) {
return;
}
LocalDateTime dateMinute = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES);
if(mempoolHistogram.isEmpty()) {
mempoolHistogram.put(Date.from(dateMinute.minusMinutes(1).atZone(ZoneId.systemDefault()).toInstant()), rateSizes);
@@ -1022,8 +1026,6 @@ public class AppServices {
public void newConnection(ConnectionEvent event) {
currentBlockHeight = event.getBlockHeight();
System.setProperty(Network.BLOCK_HEIGHT_PROPERTY, Integer.toString(currentBlockHeight));
targetBlockFeeRates = event.getTargetBlockFeeRates();
addMempoolRateSizes(event.getMempoolRateSizes());
minimumRelayFeeRate = Math.max(event.getMinimumRelayFeeRate(), Transaction.DEFAULT_MIN_RELAY_FEE);
latestBlockHeader = event.getBlockHeader();
Config.get().addRecentServer();
@@ -1046,6 +1048,10 @@ public class AppServices {
@Subscribe
public void feesUpdated(FeeRatesUpdatedEvent event) {
targetBlockFeeRates = event.getTargetBlockFeeRates();
}
@Subscribe
public void mempoolRateSizes(MempoolRateSizesUpdatedEvent event) {
addMempoolRateSizes(event.getMempoolRateSizes());
}
@@ -18,7 +18,7 @@ import java.util.*;
public class SparrowWallet {
public static final String APP_ID = "com.sparrowwallet.sparrow";
public static final String APP_NAME = "Sparrow";
public static final String APP_VERSION = "1.7.4";
public static final String APP_VERSION = "1.7.7";
public static final String APP_VERSION_SUFFIX = "";
public static final String APP_HOME_PROPERTY = "sparrow.home";
public static final String NETWORK_ENV_PROPERTY = "SPARROW_NETWORK";
@@ -26,6 +26,10 @@ import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.controlsfx.control.textfield.CustomPasswordField;
import org.controlsfx.glyphfont.Glyph;
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;
@@ -33,11 +37,13 @@ import javax.smartcardio.CardException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import static com.sparrowwallet.sparrow.io.CardApi.isReaderAvailable;
public class CardImportPane extends TitledDescriptionPane {
private static final Logger log = LoggerFactory.getLogger(CardImportPane.class);
private final KeystoreCardImport importer;
private final List<ChildNumber> derivation;
private List<ChildNumber> derivation;
protected Button importButton;
private final SimpleStringProperty pin = new SimpleStringProperty("");
@@ -62,9 +68,15 @@ public class CardImportPane extends TitledDescriptionPane {
}
private void importCard() {
if(!isReaderAvailable()) {
setError("No reader", "No card reader was detected.");
importButton.setDisable(false);
return;
}
if(pin.get().length() < 6) {
setDescription(pin.get().isEmpty() ? "Enter PIN code" : "PIN code too short");
setContent(getPinEntry());
setContent(getPinAndDerivationEntry());
showHideLink.setVisible(false);
setExpanded(true);
importButton.setDisable(false);
@@ -98,7 +110,7 @@ public class CardImportPane extends TitledDescriptionPane {
Throwable rootCause = Throwables.getRootCause(event.getSource().getException());
if(rootCause instanceof CardAuthorizationException) {
setError(rootCause.getMessage(), null);
setContent(getPinEntry());
setContent(getPinAndDerivationEntry());
} else {
log.error("Error importing keystore from card", event.getSource().getException());
setError("Import Error", rootCause.getMessage());
@@ -161,6 +173,13 @@ public class CardImportPane extends TitledDescriptionPane {
return contentBox;
}
private Node getPinAndDerivationEntry() {
VBox vBox = new VBox();
vBox.getChildren().add(getPinEntry());
vBox.getChildren().add(getDerivationEntry());
return vBox;
}
private Node getPinEntry() {
VBox vBox = new VBox();
@@ -175,7 +194,7 @@ public class CardImportPane extends TitledDescriptionPane {
contentBox.setAlignment(Pos.TOP_RIGHT);
contentBox.setSpacing(20);
contentBox.getChildren().add(pinField);
contentBox.setPadding(new Insets(10, 30, 10, 30));
contentBox.setPadding(new Insets(10, 30, 0, 30));
contentBox.setPrefHeight(50);
vBox.getChildren().add(contentBox);
@@ -183,6 +202,51 @@ public class CardImportPane extends TitledDescriptionPane {
return vBox;
}
private Node getDerivationEntry() {
VBox vBox = new VBox();
CheckBox checkBox = new CheckBox("Use Custom Derivation");
Label customLabel = new Label("Derivation:");
TextField customDerivation = new TextField(KeyDerivation.writePath(derivation));
ValidationSupport validationSupport = new ValidationSupport();
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(customDerivation, Validator.combine(
Validator.createEmptyValidator("Derivation is required"),
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid derivation", !KeyDerivation.isValid(newValue))
));
customDerivation.textProperty().addListener((observable, oldValue, newValue) -> {
if(newValue.isEmpty() || !KeyDerivation.isValid(newValue)) {
importButton.setDisable(true);
} else {
importButton.setDisable(false);
derivation = KeyDerivation.parsePath(newValue);
}
});
checkBox.managedProperty().bind(checkBox.visibleProperty());
customLabel.managedProperty().bind(customLabel.visibleProperty());
customDerivation.managedProperty().bind(customDerivation.visibleProperty());
customLabel.visibleProperty().bind(checkBox.visibleProperty().not());
customDerivation.visibleProperty().bind(checkBox.visibleProperty().not());
checkBox.selectedProperty().addListener((observable, oldValue, newValue) -> {
checkBox.setVisible(false);
});
HBox derivationBox = new HBox();
derivationBox.setAlignment(Pos.CENTER_LEFT);
derivationBox.setSpacing(20);
derivationBox.getChildren().addAll(checkBox, customLabel, customDerivation);
derivationBox.setPadding(new Insets(10, 30, 10, 30));
derivationBox.setPrefHeight(50);
vBox.getChildren().addAll(derivationBox);
return vBox;
}
public static class CardInitializationService extends Service<Void> {
private final KeystoreCardImport cardImport;
private final String pin;
@@ -22,20 +22,25 @@ public class CardPinDialog extends Dialog<CardPinDialog.CardPinChange> {
private final CheckBox backupFirst;
private final ButtonType okButtonType;
public CardPinDialog() {
public CardPinDialog(boolean backupOnly) {
this.existingPin = new ViewPasswordField();
this.newPin = new ViewPasswordField();
this.newPinConfirm = new ViewPasswordField();
this.backupFirst = new CheckBox();
if(backupOnly) {
newPin.textProperty().bind(existingPin.textProperty());
newPinConfirm.textProperty().bind(existingPin.textProperty());
}
final DialogPane dialogPane = getDialogPane();
setTitle("Change Card PIN");
dialogPane.setHeaderText("Enter the current PIN, and then the new PIN twice. PIN must be between 6 and 32 digits.");
setTitle(backupOnly ? "Backup Card" : "Change Card PIN");
dialogPane.setHeaderText(backupOnly ? "Enter the current card PIN." : "Enter the current PIN, and then the new PIN twice. PIN must be between 6 and 32 digits.");
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
AppServices.setStageIcon(dialogPane.getScene().getWindow());
dialogPane.getButtonTypes().addAll(ButtonType.CANCEL);
dialogPane.setPrefWidth(380);
dialogPane.setPrefHeight(260);
dialogPane.setPrefHeight(backupOnly ? 135 : 260);
AppServices.moveToActiveWindowScreen(this);
Glyph lock = new Glyph("FontAwesome", FontAwesome.Glyph.LOCK);
@@ -63,7 +68,12 @@ public class CardPinDialog extends Dialog<CardPinDialog.CardPinChange> {
backupField.setText("Backup First:");
backupField.getInputs().add(backupFirst);
fieldset.getChildren().addAll(currentField, newField, confirmField, backupField);
if(backupOnly) {
fieldset.getChildren().addAll(currentField);
} else {
fieldset.getChildren().addAll(currentField, newField, confirmField, backupField);
}
form.getChildren().add(fieldset);
dialogPane.setContent(form);
@@ -75,7 +85,7 @@ public class CardPinDialog extends Dialog<CardPinDialog.CardPinChange> {
validationSupport.registerValidator(newPinConfirm, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "PIN confirmation does not match", !newPinConfirm.getText().equals(newPin.getText())));
});
okButtonType = new javafx.scene.control.ButtonType("Change", ButtonBar.ButtonData.OK_DONE);
okButtonType = new javafx.scene.control.ButtonType(backupOnly ? "Backup" : "Change", ButtonBar.ButtonData.OK_DONE);
dialogPane.getButtonTypes().addAll(okButtonType);
Button okButton = (Button) dialogPane.lookupButton(okButtonType);
okButton.setPrefWidth(130);
@@ -12,7 +12,7 @@ import java.util.regex.Pattern;
public class CoinTextFormatter extends TextFormatter<String> {
public CoinTextFormatter(UnitFormat unitFormat) {
super(new CoinFilter(unitFormat));
super(new CoinFilter(unitFormat == null ? UnitFormat.DOT : unitFormat));
}
public UnitFormat getUnitFormat() {
@@ -29,7 +29,7 @@ public class CoinTextFormatter extends TextFormatter<String> {
private final Pattern coinValidation;
public CoinFilter(UnitFormat unitFormat) {
this.unitFormat = unitFormat == null ? UnitFormat.DOT : unitFormat;
this.unitFormat = unitFormat;
this.coinFormat = new DecimalFormat("###,###.########", unitFormat.getDecimalFormatSymbols());
this.coinValidation = Pattern.compile("[\\d" + Pattern.quote(unitFormat.getGroupingSeparator()) + "]*(" + Pattern.quote(unitFormat.getDecimalSeparator()) + "\\d{0,8})?");
}
@@ -1,6 +1,5 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.UtxoEntry;
import javafx.geometry.Pos;
@@ -12,6 +11,8 @@ import javafx.util.Duration;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import static com.sparrowwallet.sparrow.control.EntryCell.HashIndexEntryContextMenu;
public class DateCell extends TreeTableCell<Entry, Entry> {
private static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm");
@@ -36,11 +37,11 @@ public class DateCell extends TreeTableCell<Entry, Entry> {
UtxoEntry utxoEntry = (UtxoEntry)entry;
if(utxoEntry.getHashIndex().getHeight() <= 0) {
setText("Unconfirmed " + (utxoEntry.getHashIndex().getHeight() < 0 ? "Parent " : "") + (utxoEntry.getWallet().isWhirlpoolMixWallet() ? "(Not yet mixable)" : (utxoEntry.isSpendable() ? "(Spendable)" : "(Not yet spendable)")));
setContextMenu(null);
setContextMenu(new HashIndexEntryContextMenu(getTreeTableView(), utxoEntry));
} else if(utxoEntry.getHashIndex().getDate() != null) {
String date = DATE_FORMAT.format(utxoEntry.getHashIndex().getDate());
setText(date);
setContextMenu(new DateContextMenu(date, utxoEntry.getHashIndex()));
setContextMenu(new DateContextMenu(getTreeTableView(), utxoEntry, date));
} else {
setText("Unknown");
setContextMenu(null);
@@ -56,8 +57,10 @@ public class DateCell extends TreeTableCell<Entry, Entry> {
}
}
private static class DateContextMenu extends ContextMenu {
public DateContextMenu(String date, BlockTransactionHashIndex reference) {
private static class DateContextMenu extends HashIndexEntryContextMenu {
public DateContextMenu(TreeTableView<Entry> treeTableView, UtxoEntry utxoEntry, String date) {
super(treeTableView, utxoEntry);
MenuItem copyDate = new MenuItem("Copy Date");
copyDate.setOnAction(AE -> {
hide();
@@ -70,7 +73,7 @@ public class DateCell extends TreeTableCell<Entry, Entry> {
copyHeight.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(reference.getHeight() > 0 ? Integer.toString(reference.getHeight()) : "Mempool");
content.putString(utxoEntry.getHashIndex().getHeight() > 0 ? Integer.toString(utxoEntry.getHashIndex().getHeight()) : "Mempool");
Clipboard.getSystemClipboard().setContent(content);
});
@@ -245,8 +245,8 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
double vSize = tx.getVirtualSize();
int inputSize = tx.getInputs().get(0).getLength() + (tx.getInputs().get(0).hasWitness() ? tx.getInputs().get(0).getWitness().getLength() / Transaction.WITNESS_SCALE_FACTOR : 0);
List<BlockTransactionHashIndex> walletUtxos = new ArrayList<>(transactionEntry.getWallet().getWalletUtxos().keySet());
//Remove any UTXOs created by the transaction that is to be replaced
walletUtxos.removeIf(utxo -> ourOutputs.stream().anyMatch(output -> output.getHash().equals(utxo.getHash()) && output.getIndex() == utxo.getIndex()));
//Remove any UTXOs that are frozen or created by the transaction that is to be replaced
walletUtxos.removeIf(utxo -> utxo.getStatus() == Status.FROZEN || ourOutputs.stream().anyMatch(output -> output.getHash().equals(utxo.getHash()) && output.getIndex() == utxo.getIndex()));
Collections.shuffle(walletUtxos);
while((double)changeTotal / vSize < getMaxFeeRate() && !walletUtxos.isEmpty() && !cancelTransaction) {
//If there is insufficient change output, include another random UTXO so the fee can be increased
@@ -691,7 +691,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
}
}
private static class HashIndexEntryContextMenu extends ContextMenu {
static class HashIndexEntryContextMenu extends ContextMenu {
public HashIndexEntryContextMenu(TreeTableView<Entry> treeTableView, HashIndexEntry hashIndexEntry) {
MenuItem viewTransaction = new MenuItem("View Transaction");
viewTransaction.setGraphic(getViewTransactionGlyph());
@@ -1,21 +1,32 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.Theme;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.MempoolRateSize;
import javafx.application.Platform;
import javafx.beans.NamedArg;
import javafx.collections.FXCollections;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Point2D;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.chart.*;
import javafx.scene.control.Label;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.scene.control.*;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.stage.Modality;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import javafx.util.Duration;
import javafx.util.StringConverter;
import org.controlsfx.control.SegmentedButton;
import org.controlsfx.glyphfont.Glyph;
import java.text.DateFormat;
@@ -27,16 +38,100 @@ import java.util.stream.Collectors;
public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
private static final DateFormat dateFormatter = new SimpleDateFormat("HH:mm");
public static final int MAX_PERIOD_HOURS = 2;
public static final int DEFAULT_MAX_PERIOD_HOURS = 2;
private static final double Y_VALUE_BREAK_MVB = 3.0;
private static final List<Integer> FEE_RATES_INTERVALS = List.of(1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100, 125, 150, 175, 200, 250, 300, 350, 400, 500, 600, 700, 800);
private int maxPeriodHours = DEFAULT_MAX_PERIOD_HOURS;
private Tooltip tooltip;
private MempoolSizeFeeRatesChart expandedChart;
private final EventHandler<MouseEvent> expandedChartHandler = new EventHandler<>() {
@Override
public void handle(MouseEvent event) {
if(!event.isConsumed() && event.getButton() != MouseButton.SECONDARY) {
Stage stage = new Stage(StageStyle.UNDECORATED);
stage.setTitle("Mempool by vBytes");
stage.initOwner(MempoolSizeFeeRatesChart.this.getScene().getWindow());
stage.initModality(Modality.WINDOW_MODAL);
stage.setResizable(false);
StackPane scenePane = new StackPane();
if(org.controlsfx.tools.Platform.getCurrent() == org.controlsfx.tools.Platform.WINDOWS) {
scenePane.setBorder(new Border(new BorderStroke(Color.DARKGRAY, BorderStrokeStyle.SOLID, CornerRadii.EMPTY, BorderWidths.DEFAULT)));
}
scenePane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
if(Config.get().getTheme() == Theme.DARK) {
scenePane.getStylesheets().add(AppServices.class.getResource("darktheme.css").toExternalForm());
}
scenePane.getStylesheets().add(AppServices.class.getResource("wallet/wallet.css").toExternalForm());
scenePane.getStylesheets().add(AppServices.class.getResource("wallet/send.css").toExternalForm());
VBox vBox = new VBox(20);
vBox.setPadding(new Insets(20, 20, 20, 20));
expandedChart = new MempoolSizeFeeRatesChart();
expandedChart.initialize();
expandedChart.getStyleClass().add("vsizeChart");
expandedChart.update(AppServices.getMempoolHistogram());
expandedChart.setLegendVisible(false);
expandedChart.setAnimated(false);
expandedChart.setPrefWidth(700);
HBox buttonBox = new HBox();
buttonBox.setAlignment(Pos.CENTER);
ToggleGroup periodGroup = new ToggleGroup();
ToggleButton period2 = new ToggleButton("2H");
ToggleButton period24 = new ToggleButton("24H");
SegmentedButton periodButtons = new SegmentedButton(period2, period24);
periodButtons.setToggleGroup(periodGroup);
periodGroup.selectedToggleProperty().addListener((observable, oldValue, newValue) -> {
expandedChart.maxPeriodHours = (newValue == period2 ? 2 : 24);
expandedChart.update(AppServices.getMempoolHistogram());
});
Optional<Date> optEarliest = AppServices.getMempoolHistogram().keySet().stream().findFirst();
period24.setDisable(optEarliest.isEmpty() || optEarliest.get().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime().isAfter(LocalDateTime.now().minusHours(2)));
Region region = new Region();
HBox.setHgrow(region, Priority.SOMETIMES);
Button button = new Button("Close");
button.setOnAction(e -> {
stage.close();
});
buttonBox.getChildren().addAll(periodButtons, region, button);
vBox.getChildren().addAll(expandedChart, buttonBox);
scenePane.getChildren().add(vBox);
Scene scene = new Scene(scenePane);
AppServices.onEscapePressed(scene, stage::close);
AppServices.setStageIcon(stage);
stage.setScene(scene);
stage.setOnShowing(e -> {
AppServices.moveToActiveWindowScreen(stage, 800, 460);
});
stage.setOnHidden(e -> {
expandedChart = null;
});
stage.show();
}
}
};
public MempoolSizeFeeRatesChart() {
super(new CategoryAxis(), new NumberAxis());
}
public MempoolSizeFeeRatesChart(@NamedArg("xAxis") Axis<String> xAxis, @NamedArg("yAxis") Axis<Number> yAxis) {
super(xAxis, yAxis);
setOnMouseClicked(expandedChartHandler);
}
public void initialize() {
getStyleClass().add("vsizeChart");
setCreateSymbols(false);
setCursor(Cursor.CROSSHAIR);
setVerticalGridLinesVisible(false);
@@ -78,17 +173,18 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
}
});
long previousFeeRate = 0;
for(Long feeRate : AppServices.FEE_RATES_RANGE) {
for(int i = 0; i < FEE_RATES_INTERVALS.size(); i++) {
int feeRate = FEE_RATES_INTERVALS.get(i);
int nextFeeRate = (i == FEE_RATES_INTERVALS.size() - 1 ? Integer.MAX_VALUE : FEE_RATES_INTERVALS.get(i+1));
XYChart.Series<String, Number> series = new XYChart.Series<>();
series.setName(feeRate + "+ sats/vB");
series.setName(feeRate + "-" + (nextFeeRate == Integer.MAX_VALUE ? 900 : nextFeeRate));
long seriesTotalVSize = 0;
for(Date date : periodRateSizes.keySet()) {
Set<MempoolRateSize> rateSizes = periodRateSizes.get(date);
long totalVSize = 0;
for(MempoolRateSize rateSize : rateSizes) {
if(rateSize.getFee() > previousFeeRate && rateSize.getFee() <= feeRate) {
if(rateSize.getFee() >= feeRate && rateSize.getFee() < nextFeeRate) {
totalVSize += rateSize.getVSize();
}
}
@@ -100,8 +196,19 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
if(seriesTotalVSize > 0) {
getData().add(series);
}
}
previousFeeRate = feeRate;
for(int i = 0; i < getData().size(); i++) {
Series<String, Number> series = getData().get(i);
Set<Node> nodes = lookupAll(".series" + i);
for(Node node : nodes) {
if(node.getStyleClass().contains("chart-series-area-line")) {
node.setStyle("-fx-stroke: VSIZE" + series.getName() + "_COLOR; -fx-opacity: 0.2;");
} else {
node.setStyle("-fx-fill: VSIZE" + series.getName() + "_COLOR; -fx-opacity: 0.5;");
}
node.getStyleClass().remove("default-color" + i);
}
}
final double maxMvB = getMaxMvB(getData());
@@ -131,6 +238,10 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
numberAxis.setTickLabelsVisible(false);
numberAxis.setOpacity(0);
}
if(expandedChart != null) {
expandedChart.update(mempoolRateSizes);
}
}
private Map<Date, Set<MempoolRateSize>> getPeriodRateSizes(Map<Date, Set<MempoolRateSize>> mempoolRateSizes) {
@@ -138,7 +249,7 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
return mempoolRateSizes;
}
LocalDateTime period = LocalDateTime.now().minusHours(MAX_PERIOD_HOURS);
LocalDateTime period = LocalDateTime.now().minusHours(maxPeriodHours);
return mempoolRateSizes.entrySet().stream().filter(entry -> {
LocalDateTime dateTime = entry.getKey().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
return dateTime.isAfter(period);
@@ -200,11 +311,9 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
double mvb = kvb / 1000;
if(mvb >= 0.01 || (maxMvB < Y_VALUE_BREAK_MVB && mvb > 0.001)) {
String amount = (maxMvB < Y_VALUE_BREAK_MVB ? (int)kvb + " kvB" : String.format("%.2f", mvb) + " MvB");
Label label = new Label(series.getName() + ": " + amount);
Label label = new Label(series.getName() + " sats/vB: " + amount);
Glyph circle = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CIRCLE);
if(i < 8) {
circle.setStyle("-fx-text-fill: CHART_COLOR_" + (i+1));
}
circle.setStyle("-fx-text-fill: VSIZE" + series.getName() + "_COLOR; -fx-opacity: 0.7;");
label.setGraphic(circle);
getChildren().add(label);
}
@@ -14,17 +14,16 @@ import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.StackPane;
import javafx.scene.text.Font;
import javafx.scene.text.Text;
import javafx.stage.FileChooser;
import org.controlsfx.control.spreadsheet.*;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.tools.Platform;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@@ -40,13 +39,15 @@ public class MnemonicGridDialog extends Dialog<List<String>> {
private final BooleanProperty initializedProperty = new SimpleBooleanProperty(false);
private final BooleanProperty wordsSelectedProperty = new SimpleBooleanProperty(false);
private final ObservableList<TablePosition> selectedCells = FXCollections.observableArrayList();
public MnemonicGridDialog() {
DialogPane dialogPane = new MnemonicGridDialogPane();
setDialogPane(dialogPane);
setTitle("Border Wallets Grid");
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
dialogPane.getStylesheets().add(AppServices.class.getResource("grid.css").toExternalForm());
dialogPane.setHeaderText("Load a Border Wallets PDF, or generate a grid from a BIP39 seed.\nThen select 11 or 23 words in a pattern on the grid. Note the order of selection is important!");
dialogPane.setHeaderText("Load a Border Wallets PDF, or generate a grid from a BIP39 seed.\nThen select 11 or 23 words in a pattern on the grid.\nThe order of selection is important!");
javafx.scene.image.Image image = new Image("/image/border-wallets.png");
dialogPane.setGraphic(new ImageView(image));
@@ -54,28 +55,56 @@ public class MnemonicGridDialog extends Dialog<List<String>> {
Grid grid = getGrid(emptyWordGrid);
spreadsheetView = new SpreadsheetView(grid);
spreadsheetView.addEventFilter(MouseEvent.MOUSE_PRESSED, event -> {
try {
Field f = event.getClass().getDeclaredField(Platform.getCurrent() == Platform.OSX ? "metaDown" : "controlDown");
f.setAccessible(true);
f.set(event, true);
} catch(IllegalAccessException | NoSuchFieldException e) {
//ignore
}
});
spreadsheetView.setId("grid");
spreadsheetView.setEditable(false);
spreadsheetView.setFixingColumnsAllowed(false);
spreadsheetView.setFixingRowsAllowed(false);
spreadsheetView.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
spreadsheetView.getSelectionModel().getSelectedCells().addListener(new ListChangeListener<>() {
@Override
public void onChanged(Change<? extends TablePosition> c) {
int numWords = c.getList().size();
while(c.next()) {
if(c.wasAdded()) {
for(TablePosition<?, ?> pos : c.getAddedSubList()) {
if(selectedCells.contains(pos)) {
selectedCells.remove(pos);
} else {
selectedCells.add(pos);
}
}
}
}
int numWords = selectedCells.size();
wordsSelectedProperty.set(numWords == 11 || numWords == 23);
}
});
selectedCells.addListener((ListChangeListener<? super TablePosition>) c -> {
while(c.next()) {
if(c.wasRemoved()) {
for(TablePosition<?,?> pos : c.getRemoved()) {
SpreadsheetCell cell = spreadsheetView.getGrid().getRows().get(pos.getRow()).get(pos.getColumn());
cell.getStyleClass().remove("selection");
cell.setGraphic(null);
}
}
if(c.wasAdded()) {
for(TablePosition<?,?> pos : c.getAddedSubList()) {
SpreadsheetCell cell = spreadsheetView.getGrid().getRows().get(pos.getRow()).get(pos.getColumn());
cell.getStyleClass().add("selection");
}
}
for(int i = 0; i < selectedCells.size(); i++) {
Text index = new Text(Integer.toString(i+1));
index.setFont(Font.font(8));
SpreadsheetCell cell = spreadsheetView.getGrid().getRows().get(selectedCells.get(i).getRow()).get(selectedCells.get(i).getColumn());
cell.setGraphic(index);
}
}
});
StackPane stackPane = new StackPane();
stackPane.getChildren().add(spreadsheetView);
dialogPane.setContent(stackPane);
@@ -93,7 +122,7 @@ public class MnemonicGridDialog extends Dialog<List<String>> {
final ButtonType loadCsvButtonType = new javafx.scene.control.ButtonType("Load PDF...", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().add(loadCsvButtonType);
final ButtonType generateButtonType = new javafx.scene.control.ButtonType("Generate Grid...", ButtonBar.ButtonData.HELP);
final ButtonType generateButtonType = new javafx.scene.control.ButtonType("Generate Grid...", ButtonBar.ButtonData.HELP_2);
dialogPane.getButtonTypes().add(generateButtonType);
final ButtonType clearButtonType = new javafx.scene.control.ButtonType("Clear Selection", ButtonBar.ButtonData.OTHER);
@@ -107,7 +136,7 @@ public class MnemonicGridDialog extends Dialog<List<String>> {
return data == ButtonBar.ButtonData.OK_DONE ? getSelectedWords() : null;
});
dialogPane.setPrefWidth(850);
dialogPane.setPrefWidth(952);
dialogPane.setPrefHeight(500);
AppServices.setStageIcon(dialogPane.getScene().getWindow());
AppServices.moveToActiveWindowScreen(this);
@@ -137,7 +166,7 @@ public class MnemonicGridDialog extends Dialog<List<String>> {
}
private List<String> getSelectedWords() {
List<String> abbreviations = spreadsheetView.getSelectionModel().getSelectedCells().stream()
List<String> abbreviations = selectedCells.stream()
.map(position -> (String)spreadsheetView.getGrid().getRows().get(position.getRow()).get(position.getColumn()).getItem()).collect(Collectors.toList());
List<String> words = new ArrayList<>();
@@ -148,6 +177,13 @@ public class MnemonicGridDialog extends Dialog<List<String>> {
break;
}
}
try {
int index = Integer.parseInt(abbreviation);
words.add(Bip39MnemonicCode.INSTANCE.getWordList().get(index - 1));
} catch(NumberFormatException e) {
//ignore
}
}
if(words.size() != abbreviations.size()) {
@@ -217,6 +253,8 @@ public class MnemonicGridDialog extends Dialog<List<String>> {
try(BufferedInputStream inputStream = new BufferedInputStream(new FileInputStream(file))) {
String[][] wordGrid = PdfUtils.getWordGrid(inputStream);
spreadsheetView.setGrid(getGrid(wordGrid));
selectedCells.clear();
spreadsheetView.getSelectionModel().clearSelection();
initializedProperty.set(true);
} catch(Exception e) {
AppServices.showErrorDialog("Cannot load PDF", e.getMessage());
@@ -225,18 +263,20 @@ public class MnemonicGridDialog extends Dialog<List<String>> {
});
button = loadButton;
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.HELP) {
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.HELP_2) {
Button generateButton = new Button(buttonType.getText());
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(generateButton, buttonData);
generateButton.setOnAction(event -> {
SeedEntryDialog seedEntryDialog = new SeedEntryDialog(12);
SeedEntryDialog seedEntryDialog = new SeedEntryDialog("Border Wallets Entropy Grid Recovery Seed", 12);
Optional<List<String>> optWords = seedEntryDialog.showAndWait();
if(optWords.isPresent()) {
List<String> mnemonicWords = optWords.get();
List<String> shuffledWordList = shuffle(mnemonicWords);
String[][] wordGrid = toGrid(shuffledWordList);
spreadsheetView.setGrid(getGrid(wordGrid));
selectedCells.clear();
spreadsheetView.getSelectionModel().clearSelection();
initializedProperty.set(true);
if(seedEntryDialog.isGenerated()) {
@@ -251,6 +291,7 @@ public class MnemonicGridDialog extends Dialog<List<String>> {
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(clearButton, buttonData);
clearButton.setOnAction(event -> {
selectedCells.clear();
spreadsheetView.getSelectionModel().clearSelection();
});
@@ -18,8 +18,8 @@ public class MnemonicKeystoreEntryPane extends MnemonicKeystorePane {
private boolean generated;
public MnemonicKeystoreEntryPane(int numWords) {
super(DeterministicSeed.Type.BIP39.getName(), "Enter seed words", "", "image/" + WalletModel.SEED.getType() + ".png");
public MnemonicKeystoreEntryPane(String name, int numWords) {
super(name, "Enter seed words", "", "image/" + WalletModel.SEED.getType() + ".png");
showHideLink.setVisible(false);
buttonBox.getChildren().clear();
@@ -100,6 +100,7 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
Optional<List<String>> optWords = mnemonicGridDialog.showAndWait();
if(optWords.isPresent()) {
List<String> words = optWords.get();
defaultWordSizeProperty.set(words.size() + 1);
setContent(getMnemonicWordsEntry(words.size() + 1, true, true));
setExpanded(true);
@@ -378,11 +378,17 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
transaction.addOutput(new TransactionOutput(transaction, total - fee, destAddress.getOutputScript()));
PSBT psbt = new PSBT(transaction);
//Set witness UTXOs on PSBT inputs first - they are all required when hashing for a Taproot signature
for(int i = 0; i < txOutputs.size(); i++) {
TransactionOutput utxoOutput = txOutputs.get(i);
PSBTInput psbtInput = psbt.getPsbtInputs().get(i);
psbtInput.setWitnessUtxo(utxoOutput);
}
for(int i = 0; i < txOutputs.size(); i++) {
TransactionOutput utxoOutput = txOutputs.get(i);
TransactionInput txInput = transaction.getInputs().get(i);
PSBTInput psbtInput = psbt.getPsbtInputs().get(i);
psbtInput.setWitnessUtxo(utxoOutput);
if(ScriptType.P2SH.isScriptType(utxoOutput.getScript())) {
psbtInput.setRedeemScript(txInput.getScriptSig().getFirstNestedScript());
@@ -16,6 +16,7 @@ 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.Bip39MnemonicCode;
import com.sparrowwallet.drongo.wallet.DeterministicSeed;
import com.sparrowwallet.drongo.wallet.SeedQR;
import com.sparrowwallet.drongo.wallet.Wallet;
@@ -24,6 +25,8 @@ import com.sparrowwallet.hummingbird.registry.*;
import com.sparrowwallet.hummingbird.ResultType;
import com.sparrowwallet.hummingbird.UR;
import com.sparrowwallet.hummingbird.URDecoder;
import com.sparrowwallet.hummingbird.registry.pathcomponent.IndexPathComponent;
import com.sparrowwallet.hummingbird.registry.pathcomponent.PathComponent;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
@@ -207,6 +210,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
URDecoder.Result urResult = decoder.getResult();
if(urResult.type == ResultType.SUCCESS) {
result = extractResultFromUR(urResult.ur);
Platform.runLater(() -> setResult(result));
} else {
result = new Result(new URException(urResult.error));
}
@@ -351,6 +355,17 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
//Ignore, not parseable as a CompactSeedQR
}
try {
List<String> words = Arrays.asList(qrtext.split(" "));
if(words.size() == 12 || words.size() == 15 || words.size() == 18 || words.size() == 21 || words.size() == 24) {
Bip39MnemonicCode.INSTANCE.check(words);
result = new Result(new DeterministicSeed(words, null, System.currentTimeMillis(), DeterministicSeed.Type.BIP39));
return;
}
} catch(Exception e) {
//Ignore, not parseable as BIP39 seed words
}
result = new Result(qrtext);
}
}
@@ -467,7 +482,9 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
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());
if(lastComponent instanceof IndexPathComponent indexPathComponent) {
lastChild = new ChildNumber(indexPathComponent.getIndex(), indexPathComponent.isHardened());
}
depth = cryptoHDKey.getOrigin().getComponents().size();
}
if(cryptoHDKey.getParentFingerprint() != null) {
@@ -543,11 +560,12 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
private KeyDerivation getKeyDerivation(CryptoKeypath cryptoKeypath) {
if(cryptoKeypath != null) {
if(cryptoKeypath.getComponents().stream().anyMatch(PathComponent::isWildcard)) {
throw new IllegalArgumentException("Wildcard derivation paths are not supported");
if(!cryptoKeypath.getComponents().stream().allMatch(pathComponent -> pathComponent instanceof IndexPathComponent)) {
throw new IllegalArgumentException("Only indexed derivation path components are supported");
}
List<ChildNumber> path = cryptoKeypath.getComponents().stream().map(comp -> new ChildNumber(comp.getIndex(), comp.isHardened())).collect(Collectors.toList());
List<ChildNumber> path = cryptoKeypath.getComponents().stream().map(comp -> (IndexPathComponent)comp)
.map(comp -> new ChildNumber(comp.getIndex(), comp.isHardened())).collect(Collectors.toList());
return new KeyDerivation(Utils.bytesToHex(cryptoKeypath.getSourceFingerprint()), KeyDerivation.writePath(path));
}
@@ -12,7 +12,7 @@ import java.util.List;
public class SeedEntryDialog extends Dialog<List<String>> {
private final MnemonicKeystoreEntryPane keystorePane;
public SeedEntryDialog(int numWords) {
public SeedEntryDialog(String name, int numWords) {
final DialogPane dialogPane = new MnemonicGridDialogPane();
setDialogPane(dialogPane);
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
@@ -38,7 +38,7 @@ public class SeedEntryDialog extends Dialog<List<String>> {
Accordion keystoreAccordion = new Accordion();
scrollPane.setContent(keystoreAccordion);
keystorePane = new MnemonicKeystoreEntryPane(numWords);
keystorePane = new MnemonicKeystoreEntryPane(name, numWords);
keystorePane.setAnimated(false);
keystoreAccordion.getPanes().add(keystorePane);
@@ -535,7 +535,8 @@ public class TransactionDiagram extends GridPane {
}
private String getSatsValue(long amount) {
return String.format(Locale.ENGLISH, "%,d", amount);
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
return format.formatSatsValue(amount);
}
private Pane getInputsLines(List<Map<BlockTransactionHashIndex, WalletNode>> displayedUtxoSets) {
@@ -14,16 +14,21 @@ import javafx.concurrent.ScheduledService;
import javafx.concurrent.Task;
import javafx.embed.swing.SwingFXUtils;
import javafx.scene.image.Image;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.awt.*;
import java.awt.geom.RoundRectangle2D;
import java.awt.image.BufferedImage;
import java.awt.image.WritableRaster;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
public class WebcamService extends ScheduledService<Image> {
private static final Logger log = LoggerFactory.getLogger(WebcamService.class);
private WebcamResolution resolution;
private WebcamDevice device;
private final WebcamListener listener;
@@ -137,6 +142,9 @@ public class WebcamService extends ScheduledService<Image> {
if(result == null) {
result = readQR(croppedImage);
}
if(result == null) {
result = readQR(invert(croppedImage));
}
if(result != null) {
resultProperty.set(result);
@@ -175,6 +183,29 @@ public class WebcamService extends ScheduledService<Image> {
return new CroppedDimension(x, y, squareSize);
}
public BufferedImage invert(BufferedImage inImg) {
try {
int width = inImg.getWidth();
int height = inImg.getHeight();
BufferedImage outImg = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
WritableRaster outRaster = outImg.getRaster();
WritableRaster inRaster = inImg.getRaster();
for(int y = 0; y < height; y++) {
for(int x = 0; x < width; x++) {
for(int i = 0; i < outRaster.getNumBands(); i++) {
outRaster.setSample(x, y, i, 255 - inRaster.getSample(x, y, i));
}
}
}
return outImg;
} catch(Exception e) {
log.warn("Error inverting image", e);
return inImg;
}
}
public Result getResult() {
return resultProperty.get();
}
@@ -5,20 +5,15 @@ import com.sparrowwallet.sparrow.net.MempoolRateSize;
import java.util.Map;
import java.util.Set;
public class FeeRatesUpdatedEvent {
public class FeeRatesUpdatedEvent extends MempoolRateSizesUpdatedEvent {
private final Map<Integer, Double> targetBlockFeeRates;
private final Set<MempoolRateSize> mempoolRateSizes;
public FeeRatesUpdatedEvent(Map<Integer, Double> targetBlockFeeRates, Set<MempoolRateSize> mempoolRateSizes) {
super(mempoolRateSizes);
this.targetBlockFeeRates = targetBlockFeeRates;
this.mempoolRateSizes = mempoolRateSizes;
}
public Map<Integer, Double> getTargetBlockFeeRates() {
return targetBlockFeeRates;
}
public Set<MempoolRateSize> getMempoolRateSizes() {
return mempoolRateSizes;
}
}
@@ -0,0 +1,8 @@
package com.sparrowwallet.sparrow.event;
/**
* The event is posted when the first set of mempool entries (txid and vsizes) have been retrieved from the node.
* Cormorant only.
*/
public class MempoolEntriesInitializedEvent {
}
@@ -0,0 +1,17 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.sparrow.net.MempoolRateSize;
import java.util.Set;
public class MempoolRateSizesUpdatedEvent {
private final Set<MempoolRateSize> mempoolRateSizes;
public MempoolRateSizesUpdatedEvent(Set<MempoolRateSize> mempoolRateSizes) {
this.mempoolRateSizes = mempoolRateSizes;
}
public Set<MempoolRateSize> getMempoolRateSizes() {
return mempoolRateSizes;
}
}
@@ -17,6 +17,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.smartcardio.CardException;
import javax.smartcardio.CardTerminal;
import javax.smartcardio.CardTerminals;
import javax.smartcardio.TerminalFactory;
import java.io.File;
@@ -90,11 +91,15 @@ public abstract class CardApi {
public abstract void disconnect();
public static boolean isReaderAvailable() {
return !getAvailableTerminals().isEmpty();
}
public static List<CardTerminal> getAvailableTerminals() {
setLibrary();
try {
TerminalFactory tf = TerminalFactory.getDefault();
return !tf.terminals().list().isEmpty();
return tf.terminals().list();
} catch(Exception e) {
Throwable cause = Throwables.getRootCause(e);
if(cause.getMessage().equals("SCARD_E_NO_SERVICE")) {
@@ -106,7 +111,7 @@ public abstract class CardApi {
}
}
return false;
return Collections.emptyList();
}
private static void recoverNoService() {
@@ -157,7 +157,7 @@ public class PdfUtils {
Font headerFont = new Font(Font.HELVETICA, 8, Font.BOLD, Color.DARK_GRAY);
Font font = new Font(Font.HELVETICA, 8, Font.NORMAL, Color.DARK_GRAY);
HeaderFooter footer = new HeaderFooter(false, new Phrase("Recovery Phrase: " + String.join(" ", mnemonicWords), font));
HeaderFooter footer = new HeaderFooter(false, new Phrase("Recovery Phrase (to regenerate grid): " + String.join(" ", mnemonicWords), font));
footer.setAlignment(Element.ALIGN_CENTER);
footer.setBorder(Rectangle.NO_BORDER);
footer.setBorderWidth(0);
@@ -210,6 +210,8 @@ public class PdfUtils {
log.error("Error creating word grid PDF", e);
AppServices.showErrorDialog("Error creating word grid PDF", e.getMessage());
}
} else {
AppServices.showWarningDialog("Entropy Grid PDF not saved", "You have chosen to not save the entropy grid PDF.\n\nDo not store funds on a seed selected from this grid - you will not be able to regenerate it!");
}
}
}
@@ -10,7 +10,7 @@ public class SeedSigner extends SpecterDIY {
@Override
public String getKeystoreImportDescription(int account) {
return "Import QR created on your SeedSigner by selecting xPub from Seed in the Seed Tools menu once you have entered your seed.";
return "Import QR created on your SeedSigner by selecting Export Xpub in the Seeds menu once you have entered your seed.";
}
@Override
@@ -1,7 +1,6 @@
package com.sparrowwallet.sparrow.io;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.*;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.wallet.*;
import org.slf4j.Logger;
@@ -12,6 +11,7 @@ import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
public class SpecterDesktop implements WalletImport, WalletExport {
@@ -22,7 +22,7 @@ public class SpecterDesktop implements WalletImport, WalletExport {
try {
SpecterWallet specterWallet = new SpecterWallet();
specterWallet.label = wallet.getFullName();
specterWallet.blockheight = wallet.getTransactions().values().stream().mapToInt(BlockTransactionHash::getHeight).min().orElse(wallet.getStoredBlockHeight());
specterWallet.blockheight = wallet.getTransactions().values().stream().mapToInt(BlockTransactionHash::getHeight).min().orElse(wallet.getStoredBlockHeight() == null ? 0 : wallet.getStoredBlockHeight());
specterWallet.descriptor = OutputDescriptor.getOutputDescriptor(wallet).toString(true);
Gson gson = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
@@ -54,7 +54,44 @@ public class SpecterDesktop implements WalletImport, WalletExport {
public Wallet importWallet(InputStream inputStream, String password) throws ImportException {
try {
Gson gson = new Gson();
SpecterWallet specterWallet = gson.fromJson(new InputStreamReader(inputStream, StandardCharsets.UTF_8), SpecterWallet.class);
JsonObject jsonObj = gson.fromJson(new InputStreamReader(inputStream, StandardCharsets.UTF_8), JsonElement.class).getAsJsonObject();
SpecterWallet specterWallet = new SpecterWallet();
if(jsonObj.get("descriptor") != null) {
specterWallet.descriptor = jsonObj.get("descriptor").getAsString();
} else if(jsonObj.get("recv_descriptor") != null) {
specterWallet.descriptor = jsonObj.get("recv_descriptor").getAsString();
}
if(jsonObj.get("label") != null) {
specterWallet.label = jsonObj.get("label").getAsString();
} else if(jsonObj.get("name") != null) {
specterWallet.label = jsonObj.get("name").getAsString();
}
if(jsonObj.get("blockheight") != null) {
specterWallet.blockheight = jsonObj.get("blockheight").getAsInt();
}
if(jsonObj.get("devices") != null) {
JsonArray jsonDevices = jsonObj.get("devices").getAsJsonArray();
specterWallet.devices = new ArrayList<>();
for(JsonElement jsonDevice : jsonDevices) {
SpecterWalletDevice specterWalletDevice = new SpecterWalletDevice();
if(jsonDevice.isJsonObject()) {
JsonObject jsonDeviceObj = (JsonObject)jsonDevice;
if(jsonDeviceObj.get("label") != null) {
specterWalletDevice.label = jsonDeviceObj.get("label").getAsString();
}
if(jsonDeviceObj.get("type") != null) {
specterWalletDevice.type = jsonDeviceObj.get("type").getAsString();
}
} else if(jsonDevice.isJsonPrimitive()) {
specterWalletDevice.label = jsonDevice.getAsString();
}
specterWallet.devices.add(specterWalletDevice);
}
}
if(specterWallet.descriptor != null) {
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(specterWallet.descriptor);
@@ -80,6 +117,9 @@ public class SpecterDesktop implements WalletImport, WalletExport {
} else {
keystore.setSource(KeystoreSource.HW_AIRGAPPED);
}
} else {
keystore.setWalletModel(WalletModel.SPARROW);
keystore.setSource(KeystoreSource.SW_WATCH);
}
}
}
@@ -8,6 +8,7 @@ import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.KeystoreLabelsChangedEvent;
import com.sparrowwallet.sparrow.event.WalletEntryLabelsChangedEvent;
import com.sparrowwallet.sparrow.event.WalletUtxoStatusChangedEvent;
import com.sparrowwallet.sparrow.wallet.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -54,29 +55,32 @@ public class WalletLabels implements WalletImport, WalletExport {
for(Keystore keystore : exportWallet.getKeystores()) {
if(keystore.getLabel() != null && !keystore.getLabel().isEmpty()) {
labels.add(new Label(Type.xpub, keystore.getExtendedPublicKey().toString(), keystore.getLabel(), null));
labels.add(new Label(Type.xpub, keystore.getExtendedPublicKey().toString(), keystore.getLabel(), null, null));
}
}
for(BlockTransaction blkTx : exportWallet.getWalletTransactions().values()) {
if(blkTx.getLabel() != null && !blkTx.getLabel().isEmpty()) {
labels.add(new Label(Type.tx, blkTx.getHashAsString(), blkTx.getLabel(), origin));
labels.add(new Label(Type.tx, blkTx.getHashAsString(), blkTx.getLabel(), origin, null));
}
}
for(WalletNode addressNode : exportWallet.getWalletAddresses().values()) {
if(addressNode.getLabel() != null && !addressNode.getLabel().isEmpty()) {
labels.add(new Label(Type.addr, addressNode.getAddress().toString(), addressNode.getLabel(), null));
labels.add(new Label(Type.addr, addressNode.getAddress().toString(), addressNode.getLabel(), null, null));
}
}
for(BlockTransactionHashIndex txo : exportWallet.getWalletTxos().keySet()) {
String spendable = (txo.isSpent() ? null : txo.getStatus() == Status.FROZEN ? "false" : "true");
if(txo.getLabel() != null && !txo.getLabel().isEmpty()) {
labels.add(new Label(Type.output, txo.toString(), txo.getLabel(), null));
labels.add(new Label(Type.output, txo.toString(), txo.getLabel(), null, spendable));
} else if(!txo.isSpent()) {
labels.add(new Label(Type.output, txo.toString(), null, null, spendable));
}
if(txo.isSpent() && txo.getSpentBy().getLabel() != null && !txo.getSpentBy().getLabel().isEmpty()) {
labels.add(new Label(Type.input, txo.getSpentBy().toString(), txo.getSpentBy().getLabel(), null));
labels.add(new Label(Type.input, txo.getSpentBy().toString(), txo.getSpentBy().getLabel(), null, null));
}
}
}
@@ -140,7 +144,15 @@ public class WalletLabels implements WalletImport, WalletExport {
continue;
}
if(label == null || label.type == null || label.ref == null || label.label == null || label.label.isEmpty()) {
if(label == null || label.type == null || label.ref == null) {
continue;
}
if(label.type == Type.output) {
if((label.label == null || label.label.isEmpty()) && label.spendable == null) {
continue;
}
} else if(label.label == null || label.label.isEmpty()) {
continue;
}
@@ -152,6 +164,7 @@ public class WalletLabels implements WalletImport, WalletExport {
Map<Wallet, List<Keystore>> changedWalletKeystores = new LinkedHashMap<>();
Map<Wallet, List<Entry>> changedWalletEntries = new LinkedHashMap<>();
Map<Wallet, List<BlockTransactionHashIndex>> changedWalletUtxoStatuses = new LinkedHashMap<>();
for(WalletForm walletForm : walletForms) {
Wallet wallet = walletForm.getWallet();
@@ -216,9 +229,21 @@ public class WalletLabels implements WalletImport, WalletExport {
BlockTransactionHashIndex reference = txioEntry.getHashIndex();
if((label.type == Type.output && txioEntry.getType() == HashIndexEntry.Type.OUTPUT && reference.toString().equals(label.ref))
|| (label.type == Type.input && txioEntry.getType() == HashIndexEntry.Type.INPUT && reference.toString().equals(label.ref))) {
txioEntry.getHashIndex().setLabel(label.label);
txioEntry.labelProperty().set(label.label);
addChangedEntry(changedWalletEntries, txioEntry);
if(label.label != null && !label.label.isEmpty()) {
reference.setLabel(label.label);
txioEntry.labelProperty().set(label.label);
addChangedEntry(changedWalletEntries, txioEntry);
}
if(label.type == Type.output && !reference.isSpent()) {
if("false".equalsIgnoreCase(label.spendable) && reference.getStatus() != Status.FROZEN) {
reference.setStatus(Status.FROZEN);
addChangedUtxo(changedWalletUtxoStatuses, txioEntry);
} else if("true".equalsIgnoreCase(label.spendable) && reference.getStatus() == Status.FROZEN) {
reference.setStatus(null);
addChangedUtxo(changedWalletUtxoStatuses, txioEntry);
}
}
}
}
}
@@ -248,6 +273,10 @@ public class WalletLabels implements WalletImport, WalletExport {
EventManager.get().post(new WalletEntryLabelsChangedEvent(walletEntries.getKey(), walletEntries.getValue(), false));
}
for(Map.Entry<Wallet, List<BlockTransactionHashIndex>> walletUtxos : changedWalletUtxoStatuses.entrySet()) {
EventManager.get().post(new WalletUtxoStatusChangedEvent(walletUtxos.getKey(), walletUtxos.getValue()));
}
return walletForms.get(0).getWallet();
}
@@ -256,7 +285,9 @@ public class WalletLabels implements WalletImport, WalletExport {
BlockTransactionHashIndex reference = hashIndexEntry.getHashIndex();
if((label.type == Type.output && hashIndexEntry.getType() == HashIndexEntry.Type.OUTPUT && reference.toString().equals(label.ref))
|| (label.type == Type.input && hashIndexEntry.getType() == HashIndexEntry.Type.INPUT && reference.toString().equals(label.ref))) {
hashIndexEntry.labelProperty().set(label.label);
if(label.label != null && !label.label.isEmpty()) {
hashIndexEntry.labelProperty().set(label.label);
}
}
}
}
@@ -266,6 +297,11 @@ public class WalletLabels implements WalletImport, WalletExport {
entries.add(entry);
}
private static void addChangedUtxo(Map<Wallet, List<BlockTransactionHashIndex>> changedUtxos, TransactionHashIndexEntry utxoEntry) {
List<BlockTransactionHashIndex> utxos = changedUtxos.computeIfAbsent(utxoEntry.getWallet(), w -> new ArrayList<>());
utxos.add(utxoEntry.getHashIndex());
}
@Override
public boolean isWalletImportScannable() {
return false;
@@ -281,16 +317,18 @@ public class WalletLabels implements WalletImport, WalletExport {
}
private static class Label {
public Label(Type type, String ref, String label, String origin) {
public Label(Type type, String ref, String label, String origin, String spendable) {
this.type = type;
this.ref = ref;
this.label = label;
this.origin = origin;
this.spendable = spendable;
}
Type type;
String ref;
String label;
String origin;
String spendable;
}
}
@@ -20,8 +20,7 @@ import org.slf4j.LoggerFactory;
import javax.smartcardio.*;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.*;
import java.util.Map;
public class CardTransport {
@@ -41,15 +40,32 @@ public class CardTransport {
throw new IllegalStateException("No reader connected");
}
CardTerminal cardTerminal = (CardTerminal)terminals.get(0);
connection = cardTerminal.connect("*");
Card connection = null;
for(Iterator<CardTerminal> iter = terminals.iterator(); iter.hasNext(); ) {
try {
connection = getConnection(iter.next());
break;
} catch(CardException e) {
if(!iter.hasNext()) {
log.error(e.getMessage());
throw e;
}
}
}
this.connection = connection;
}
private Card getConnection(CardTerminal cardTerminal) throws CardException {
Card connection = cardTerminal.connect("*");
CardChannel cardChannel = connection.getBasicChannel();
ResponseAPDU resp = cardChannel.transmit(new CommandAPDU(0, 0xA4, 4, 0, Utils.hexToBytes(APPID.toUpperCase())));
if(resp.getSW() != SW_OKAY) {
log.error("Card initialization error, response was 0x" + Integer.toHexString(resp.getSW()));
throw new CardException("Card initialization error, response was 0x" + Integer.toHexString(resp.getSW()) + ". Note that only the Tapsigner is currently supported.");
}
return connection;
}
JsonObject send(String cmd, Map<String, Object> args) throws CardException {
@@ -15,8 +15,6 @@ import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import static com.sparrowwallet.sparrow.io.CardApi.isReaderAvailable;
public class HwAirgappedController extends KeystoreImportDetailController {
private static final Logger log = LoggerFactory.getLogger(HwAirgappedController.class);
@@ -40,10 +38,7 @@ public class HwAirgappedController extends KeystoreImportDetailController {
}
}
List<KeystoreCardImport> cardImporters = Collections.emptyList();
if(isReaderAvailable()) {
cardImporters = List.of(new Tapsigner());
}
List<KeystoreCardImport> cardImporters = List.of(new Tapsigner());
for(KeystoreCardImport importer : cardImporters) {
if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
CardImportPane importPane = new CardImportPane(getMasterController().getWallet(), importer, getMasterController().getRequiredDerivation());
@@ -4,6 +4,7 @@ import com.github.arteam.simplejsonrpc.client.JsonRpcClient;
import com.github.arteam.simplejsonrpc.client.Transport;
import com.github.arteam.simplejsonrpc.client.exception.JsonRpcBatchException;
import com.github.arteam.simplejsonrpc.client.exception.JsonRpcException;
import com.github.arteam.simplejsonrpc.core.domain.ErrorMessage;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.EventManager;
@@ -11,7 +12,7 @@ import com.sparrowwallet.sparrow.event.WalletHistoryStatusEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.math.BigInteger;
import java.math.BigDecimal;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Collectors;
@@ -227,23 +228,23 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
try {
return batchRequest.execute();
} catch(JsonRpcBatchException e) {
throw new ElectrumServerRpcException("Error getting fee estimates", e);
throw new ElectrumServerRpcException("Error getting fee estimates: " + e.getErrors(), e);
} catch(Exception e) {
throw new ElectrumServerRpcException("Error getting fee estimates for target blocks: " + targetBlocks, e);
}
}
@Override
public Map<Long, Long> getFeeRateHistogram(Transport transport) {
public Map<Double, Long> getFeeRateHistogram(Transport transport) {
try {
JsonRpcClient client = new JsonRpcClient(transport);
BigInteger[][] feesArray = new RetryLogic<BigInteger[][]>(DEFAULT_MAX_ATTEMPTS, RETRY_DELAY_SECS, IllegalStateException.class).getResult(() ->
client.createRequest().returnAs(BigInteger[][].class).method("mempool.get_fee_histogram").id(idCounter.incrementAndGet()).execute());
BigDecimal[][] feesArray = new RetryLogic<BigDecimal[][]>(DEFAULT_MAX_ATTEMPTS, RETRY_DELAY_SECS, IllegalStateException.class).getResult(() ->
client.createRequest().returnAs(BigDecimal[][].class).method("mempool.get_fee_histogram").id(idCounter.incrementAndGet()).execute());
Map<Long, Long> feeRateHistogram = new TreeMap<>();
for(BigInteger[] feePair : feesArray) {
Map<Double, Long> feeRateHistogram = new TreeMap<>();
for(BigDecimal[] feePair : feesArray) {
if(feePair[0].longValue() > 0) {
feeRateHistogram.put(feePair[0].longValue(), feePair[1].longValue());
feeRateHistogram.put(feePair[0].doubleValue(), feePair[1].longValue());
}
}
@@ -56,6 +56,8 @@ public enum BroadcastSource {
return new URL(getBaseUrl(proxy) + "/api/tx");
} else if(Network.get() == Network.TESTNET) {
return new URL(getBaseUrl(proxy) + "/testnet/api/tx");
} else if(Network.get() == Network.SIGNET) {
return new URL(getBaseUrl(proxy) + "/signet/api/tx");
} else {
throw new IllegalStateException("Cannot broadcast transaction to " + getName() + " on network " + Network.get());
}
@@ -692,7 +692,9 @@ public class ElectrumServer {
//First check all provided txes that pay to this node
Script nodeScript = node.getOutputScript();
Set<BlockTransactionHash> history = nodeTransactionMap.get(node);
Map<Sha256Hash, BlockTransactionHash> txHashHistory = new HashMap<>();
for(BlockTransactionHash reference : history) {
txHashHistory.put(reference.getHash(), reference);
BlockTransaction blockTransaction = wallet.getTransactions().get(reference.getHash());
if(blockTransaction == null) {
throw new IllegalStateException("Did not retrieve transaction for hash " + reference.getHashAsString());
@@ -731,14 +733,13 @@ public class ElectrumServer {
throw new IllegalStateException("Could not retrieve transaction for hash " + reference.getHashAsString());
}
Optional<BlockTransactionHash> optionalTxHash = history.stream().filter(txHash -> txHash.getHash().equals(previousHash)).findFirst();
if(optionalTxHash.isEmpty()) {
BlockTransactionHash spentTxHash = txHashHistory.get(previousHash);
if(spentTxHash == null) {
//No previous transaction history found, cannot check if spends from wallet
//This is fine so long as all referenced transactions have been returned, in which case this refers to a transaction that does not affect this wallet node
continue;
}
BlockTransactionHash spentTxHash = optionalTxHash.get();
TransactionOutput spentOutput = previousTransaction.getTransaction().getOutputs().get((int)input.getOutpoint().getIndex());
if(spentOutput.getScript().equals(nodeScript)) {
BlockTransactionHashIndex spendingTXI = new BlockTransactionHashIndex(reference.getHash(), reference.getHeight(), blockTransaction.getDate(), reference.getFee(), inputIndex, spentOutput.getValue());
@@ -835,9 +836,9 @@ public class ElectrumServer {
}
public Set<MempoolRateSize> getMempoolRateSizes() throws ServerException {
Map<Long, Long> feeRateHistogram = electrumServerRpc.getFeeRateHistogram(getTransport());
Map<Double, Long> feeRateHistogram = electrumServerRpc.getFeeRateHistogram(getTransport());
Set<MempoolRateSize> mempoolRateSizes = new TreeSet<>();
for(Long fee : feeRateHistogram.keySet()) {
for(Double fee : feeRateHistogram.keySet()) {
mempoolRateSizes.add(new MempoolRateSize(fee, feeRateHistogram.get(fee)));
}
@@ -1330,6 +1331,13 @@ public class ElectrumServer {
bwtStartLock.unlock();
}
}
@Subscribe
public void mempoolEntriesInitialized(MempoolEntriesInitializedEvent event) throws ServerException {
ElectrumServer electrumServer = new ElectrumServer();
Set<MempoolRateSize> mempoolRateSizes = electrumServer.getMempoolRateSizes();
EventManager.get().post(new MempoolRateSizesUpdatedEvent(mempoolRateSizes));
}
}
public static class ReadRunnable implements Runnable {
@@ -30,7 +30,7 @@ public interface ElectrumServerRpc {
Map<Integer, Double> getFeeEstimates(Transport transport, List<Integer> targetBlocks);
Map<Long, Long> getFeeRateHistogram(Transport transport);
Map<Double, Long> getFeeRateHistogram(Transport transport);
Double getMinimumRelayFee(Transport transport);
@@ -3,15 +3,15 @@ package com.sparrowwallet.sparrow.net;
import java.util.Objects;
public class MempoolRateSize implements Comparable<MempoolRateSize> {
private final long fee;
private final double fee;
private final long vSize;
public MempoolRateSize(long fee, long vSize) {
public MempoolRateSize(double fee, long vSize) {
this.fee = fee;
this.vSize = vSize;
}
public long getFee() {
public double getFee() {
return fee;
}
@@ -38,7 +38,7 @@ public class MempoolRateSize implements Comparable<MempoolRateSize> {
@Override
public int compareTo(MempoolRateSize other) {
return Long.compare(fee, other.fee);
return Double.compare(fee, other.fee);
}
@Override
@@ -13,7 +13,7 @@ import com.sparrowwallet.sparrow.event.WalletHistoryStatusEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.math.BigInteger;
import java.math.BigDecimal;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
@@ -79,7 +79,7 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Loading transactions for " + path));
try {
ScriptHashTx[] scriptHashTxes = new RetryLogic<ScriptHashTx[]>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() ->
client.createRequest().returnAs(ScriptHashTx[].class).method("blockchain.scripthash.get_history").id(path + "-" + idCounter.incrementAndGet()).params(pathScriptHashes.get(path)).execute());
client.createRequest().returnAs(ScriptHashTx[].class).method("blockchain.scripthash.get_history").id(idCounter.incrementAndGet()).params(pathScriptHashes.get(path)).execute());
result.put(path, scriptHashTxes);
} catch(Exception e) {
if(failOnError) {
@@ -101,7 +101,7 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
for(String path : pathScriptHashes.keySet()) {
try {
ScriptHashTx[] scriptHashTxes = new RetryLogic<ScriptHashTx[]>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() ->
client.createRequest().returnAs(ScriptHashTx[].class).method("blockchain.scripthash.get_mempool").id(path + "-" + idCounter.incrementAndGet()).params(pathScriptHashes.get(path)).execute());
client.createRequest().returnAs(ScriptHashTx[].class).method("blockchain.scripthash.get_mempool").id(idCounter.incrementAndGet()).params(pathScriptHashes.get(path)).execute());
result.put(path, scriptHashTxes);
} catch(Exception e) {
if(failOnError) {
@@ -124,7 +124,7 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Finding transactions for " + path));
try {
String scriptHash = new RetryLogic<String>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() ->
client.createRequest().returnAs(String.class).method("blockchain.scripthash.subscribe").id(path + "-" + idCounter.incrementAndGet()).params(pathScriptHashes.get(path)).executeNullable());
client.createRequest().returnAs(String.class).method("blockchain.scripthash.subscribe").id(idCounter.incrementAndGet()).params(pathScriptHashes.get(path)).executeNullable());
result.put(path, scriptHash);
} catch(Exception e) {
//Even if we have some successes, failure to subscribe for all script hashes will result in outdated wallet view. Don't proceed.
@@ -260,16 +260,16 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
}
@Override
public Map<Long, Long> getFeeRateHistogram(Transport transport) {
public Map<Double, Long> getFeeRateHistogram(Transport transport) {
try {
JsonRpcClient client = new JsonRpcClient(transport);
BigInteger[][] feesArray = new RetryLogic<BigInteger[][]>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(() ->
client.createRequest().returnAs(BigInteger[][].class).method("mempool.get_fee_histogram").id(idCounter.incrementAndGet()).execute());
BigDecimal[][] feesArray = new RetryLogic<BigDecimal[][]>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(() ->
client.createRequest().returnAs(BigDecimal[][].class).method("mempool.get_fee_histogram").id(idCounter.incrementAndGet()).execute());
Map<Long, Long> feeRateHistogram = new TreeMap<>();
for(BigInteger[] feePair : feesArray) {
Map<Double, Long> feeRateHistogram = new TreeMap<>();
for(BigDecimal[] feePair : feesArray) {
if(feePair[0].longValue() > 0) {
feeRateHistogram.put(feePair[0].longValue(), feePair[1].longValue());
feeRateHistogram.put(feePair[0].doubleValue(), feePair[1].longValue());
}
}
@@ -35,7 +35,7 @@ public class Cormorant {
}
public Server start() throws CormorantBitcoindException {
bitcoindClient = new BitcoindClient();
bitcoindClient = new BitcoindClient(useWallets);
bitcoindClient.initialize();
Thread importThread = new Thread(() -> {
@@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
import com.github.arteam.simplejsonrpc.client.JsonRpcClient;
import com.github.arteam.simplejsonrpc.client.exception.JsonRpcException;
import com.google.common.collect.Sets;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.Utils;
@@ -25,11 +26,14 @@ import com.sparrowwallet.sparrow.net.cormorant.electrum.ScriptHashStatus;
import com.sparrowwallet.sparrow.net.cormorant.index.Store;
import com.sparrowwallet.drongo.protocol.*;
import javafx.application.Platform;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
@@ -60,6 +64,7 @@ public class BitcoindClient {
private Exception lastPollException;
private final boolean useWallets;
private boolean pruned;
private boolean legacyWalletExists;
@@ -76,7 +81,11 @@ public class BitcoindClient {
private final List<String> pruneWarnedDescriptors = new ArrayList<>();
public BitcoindClient() {
private final Map<String, MempoolEntry> mempoolEntries = new ConcurrentHashMap<>();
private MempoolEntriesState mempoolEntriesState = MempoolEntriesState.UNINITIALIZED;
private long timerTaskCount;
public BitcoindClient(boolean useWallets) {
BitcoindTransport bitcoindTransport;
Config config = Config.get();
@@ -87,6 +96,7 @@ public class BitcoindClient {
}
this.jsonRpcClient = new JsonRpcClient(bitcoindTransport);
this.useWallets = useWallets;
}
public void initialize() throws CormorantBitcoindException {
@@ -125,6 +135,9 @@ public class BitcoindClient {
}
ListWalletDirResult listWalletDirResult = getBitcoindService().listWalletDir();
if(listWalletDirResult == null) {
throw new RuntimeException("Wallet support must be enabled in Bitcoin Core");
}
boolean exists = listWalletDirResult.wallets().stream().anyMatch(walletDirResult -> walletDirResult.name().equals(CORE_WALLET_NAME));
legacyWalletExists = listWalletDirResult.wallets().stream().anyMatch(walletDirResult -> walletDirResult.name().equals(Bwt.DEFAULT_CORE_WALLET));
@@ -513,6 +526,64 @@ public class BitcoindClient {
}
}
public void initializeMempoolEntries() {
mempoolEntriesState = MempoolEntriesState.INITIALIZING;
long start = System.currentTimeMillis();
Set<String> txids = getBitcoindService().getRawMempool();
long end = System.currentTimeMillis();
if(end - start < 1000) {
//Fast system, fetch all mempool data at once
mempoolEntries.putAll(getBitcoindService().getRawMempool(true));
} else {
//Slow system, fetch mempool entries one-by-one to avoid risking a node crash
for(String txid : txids) {
try {
MempoolEntry mempoolEntry = getBitcoindService().getMempoolEntry(txid);
mempoolEntries.put(txid, mempoolEntry);
} catch(JsonRpcException e) {
//ignore, probably tx has been removed from mempool
}
}
}
mempoolEntriesState = MempoolEntriesState.INITIALIZED;
}
public void updateMempoolEntries() {
Set<String> txids = getBitcoindService().getRawMempool();
Set<String> removed = new HashSet<>(Sets.difference(mempoolEntries.keySet(), txids));
mempoolEntries.keySet().removeAll(removed);
Set<String> added = Sets.difference(txids, mempoolEntries.keySet());
for(String txid : added) {
try {
MempoolEntry mempoolEntry = getBitcoindService().getMempoolEntry(txid);
mempoolEntries.put(txid, mempoolEntry);
} catch(JsonRpcException e) {
//ignore, probably tx has been removed from mempool
}
}
}
public Map<String, MempoolEntry> getMempoolEntries() {
return mempoolEntries;
}
public MempoolEntriesState getMempoolEntriesState() {
return mempoolEntriesState;
}
public InitializeMempoolEntriesService getInitializeMempoolEntriesService() {
return new InitializeMempoolEntriesService();
}
public boolean isUseWallets() {
return useWallets;
}
public Store getStore() {
return store;
}
@@ -563,6 +634,10 @@ public class BitcoindClient {
}
}
if(mempoolEntriesState == MempoolEntriesState.INITIALIZED && (++timerTaskCount+1) % 12 == 0) {
updateMempoolEntries();
}
ListSinceBlock listSinceBlock = getListSinceBlock(lastBlock);
String currentBlock = lastBlock;
updateStore(listSinceBlock);
@@ -588,7 +663,7 @@ public class BitcoindClient {
}
} catch(Exception e) {
lastPollException = e;
log.warn("Error polling Bitcoin Core: " + e.getMessage());
log.warn("Error polling Bitcoin Core", e);
if(syncing) {
syncingLock.lock();
@@ -624,4 +699,21 @@ public class BitcoindClient {
return rescanSince == null ? "now" : rescanSince.getTime() / 1000;
}
}
public class InitializeMempoolEntriesService extends Service<Void> {
@Override
protected Task<Void> createTask() {
return new Task<>() {
@Override
protected Void call() {
initializeMempoolEntries();
return null;
}
};
}
}
public enum MempoolEntriesState {
UNINITIALIZED, INITIALIZING, INITIALIZED
}
}
@@ -9,6 +9,7 @@ import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcService;
import java.util.List;
import java.util.Map;
import java.util.Set;
@JsonRpcService
@JsonRpcParams(ParamsType.ARRAY)
@@ -22,6 +23,9 @@ public interface BitcoindClientService {
@JsonRpcMethod("estimatesmartfee")
FeeInfo estimateSmartFee(@JsonRpcParam("conf_target") int blocks);
@JsonRpcMethod("getrawmempool")
Set<String> getRawMempool();
@JsonRpcMethod("getrawmempool")
Map<String, MempoolEntry> getRawMempool(@JsonRpcParam("verbose") boolean verbose);
@@ -5,7 +5,10 @@ import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcMethod;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcOptional;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcParam;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcService;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.SparrowWallet;
import com.sparrowwallet.sparrow.event.MempoolEntriesInitializedEvent;
import com.sparrowwallet.sparrow.net.Version;
import com.sparrowwallet.sparrow.net.cormorant.Cormorant;
import com.sparrowwallet.sparrow.net.cormorant.bitcoind.*;
@@ -60,9 +63,23 @@ public class ElectrumServerService {
}
@JsonRpcMethod("mempool.get_fee_histogram")
public List<List<Number>> getFeeHistogram() throws BitcoindIOException {
try {
Map<String, MempoolEntry> mempoolEntries = bitcoindClient.getBitcoindService().getRawMempool(true);
public List<List<Number>> getFeeHistogram() {
BitcoindClient.MempoolEntriesState mempoolEntriesState = bitcoindClient.getMempoolEntriesState();
if(mempoolEntriesState != BitcoindClient.MempoolEntriesState.INITIALIZED) {
if(bitcoindClient.isUseWallets() && mempoolEntriesState == BitcoindClient.MempoolEntriesState.UNINITIALIZED) {
BitcoindClient.InitializeMempoolEntriesService initializeMempoolEntriesService = bitcoindClient.getInitializeMempoolEntriesService();
initializeMempoolEntriesService.setOnSucceeded(successEvent -> {
EventManager.get().post(new MempoolEntriesInitializedEvent());
});
initializeMempoolEntriesService.setOnFailed(failedEvent -> {
log.error("Failed to initialize mempool entries", failedEvent.getSource().getException());
});
initializeMempoolEntriesService.start();
}
return Collections.emptyList();
} else {
Map<String, MempoolEntry> mempoolEntries = bitcoindClient.getMempoolEntries();
List<VsizeFeerate> vsizeFeerates = mempoolEntries.values().stream().map(entry -> new VsizeFeerate(entry.vsize(), entry.fees().base())).sorted().toList();
@@ -85,8 +102,6 @@ public class ElectrumServerService {
}
return histogram;
} catch(IllegalStateException e) {
throw new BitcoindIOException(e);
}
}
@@ -204,7 +219,9 @@ public class ElectrumServerService {
public VsizeFeerate(int vsize, double fee) {
this.vsize = vsize;
this.feerate = fee / vsize * 100000000;
double feeRate = fee / vsize * Transaction.SATOSHIS_PER_BITCOIN;
//Round down to 0.1 sats/vb precision
this.feerate = Math.floor(10 * feeRate) / 10;
}
@Override
@@ -81,7 +81,12 @@ public class UtxosDialog extends WalletDialog {
}
}
startMix.setLabel(newValue ? "Stop Mixing" : "Start Mixing");
Whirlpool whirlpool = AppServices.getWhirlpoolServices().getWhirlpool(getWalletForm().getWallet());
if(newValue) {
startMix.setLabel("Stop Mixing");
} else if(whirlpool == null || !whirlpool.stoppingProperty().get()) {
startMix.setLabel("Start Mixing");
}
};
public UtxosDialog(WalletForm walletForm) {
@@ -12,6 +12,7 @@ import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.hummingbird.registry.CryptoPSBT;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
@@ -60,6 +61,8 @@ import java.text.SimpleDateFormat;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@@ -74,6 +77,10 @@ public class HeadersController extends TransactionFormController implements Init
public static final String MAX_LOCKTIME_DATE = "2106-02-07T06:28:15Z";
public static final String MIN_LOCKTIME_DATE = "1985-11-05T00:53:20Z";
private static final Pattern MIN_MEMPOOL_FEE = Pattern.compile("the transaction was rejected by network rules.*mempool min fee not met, (\\d+) < (\\d+).*", Pattern.DOTALL | Pattern.MULTILINE);
private static final Pattern RBF_INSUFFICIENT_FEE = Pattern.compile("insufficient fee, rejecting replacement.*?(\\d+\\.?\\d*) < (\\d+\\.?\\d*)");
private static final Pattern RBF_INSUFFICIENT_FEE_RATE = Pattern.compile("insufficient fee, rejecting replacement.*new feerate (\\d+\\.?\\d*)[^\\d]*(\\d+\\.?\\d*)[^\\d]*");
private HeadersForm headersForm;
@FXML
@@ -1138,12 +1145,35 @@ public class HeadersController extends TransactionFormController implements Init
failMessage = workerStateEvent.getSource().getException().getMessage();
}
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
if(failMessage.startsWith("min relay fee not met")) {
AppServices.showErrorDialog("Error broadcasting transaction", "The fee rate for the signed transaction is below the minimum " + AppServices.getMinimumRelayFeeRate() + " sats/vB. " +
AppServices.showErrorDialog("Error broadcasting transaction", "The fee rate for the signed transaction is below the minimum " + format.getCurrencyFormat().format(AppServices.getMinimumRelayFeeRate()) + " sats/vB. " +
"This usually happens because a keystore has created a signature that is larger than necessary.\n\n" +
"You can solve this by recreating the transaction with a slightly increased fee rate.");
} else if(failMessage.startsWith("bad-txns-inputs-missingorspent")) {
AppServices.showErrorDialog("Error broadcasting transaction", "The server returned an error indicating some or all of the UTXOs this transaction is spending are missing or have already been spent.");
} else if(failMessage.contains("mempool min fee not met")) {
Matcher minMempoolMatcher = MIN_MEMPOOL_FEE.matcher(failMessage);
if(minMempoolMatcher.matches()) {
long requiredFee = Long.parseLong(minMempoolMatcher.group(2));
AppServices.showErrorDialog("Error broadcasting transaction", "The fee for the transaction was insufficient for relay by your connected server. Increase the fee to at least " + requiredFee + " sats to try again.");
} else {
AppServices.showErrorDialog("Error broadcasting transaction", "The fee for the transaction was insufficient for relay by your connected server. Increase the fee to try again.");
}
} else if(failMessage.startsWith("insufficient fee, rejecting replacement")) {
Matcher feeMatcher = RBF_INSUFFICIENT_FEE.matcher(failMessage);
Matcher feeRateMatcher = RBF_INSUFFICIENT_FEE_RATE.matcher(failMessage);
if(feeMatcher.matches() && fee.getValue() > 0) {
long currentAdditionalFee = (long)(Double.parseDouble(feeMatcher.group(1)) * Transaction.SATOSHIS_PER_BITCOIN);
long requiredAdditionalFee = (long)(Double.parseDouble(feeMatcher.group(2)) * Transaction.SATOSHIS_PER_BITCOIN);
long requiredFee = fee.getValue() - currentAdditionalFee + requiredAdditionalFee;
AppServices.showErrorDialog("Error broadcasting transaction", "The fee for the replacement transaction was insufficient. Increase the fee to at least " + requiredFee + " sats to try again.");
} else if(feeRateMatcher.matches()) {
double requiredFeeRate = Double.parseDouble(feeRateMatcher.group(2)) * Transaction.SATOSHIS_PER_BITCOIN / 1000;
AppServices.showErrorDialog("Error broadcasting transaction", "The fee rate for the replacement transaction was insufficient. Increase the fee rate to at least " + format.getCurrencyFormat().format(requiredFeeRate) + " sats/vB to try again.");
} else {
AppServices.showErrorDialog("Error broadcasting transaction", "The fee for the replacement transaction was insufficient. Increase the fee to try again.");
}
} else {
AppServices.showErrorDialog("Error broadcasting transaction", "The server returned an error when broadcasting the transaction. The server response is contained in the log (See Help > Show Log File).");
}
@@ -24,6 +24,7 @@ import javafx.scene.control.*;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.scene.layout.StackPane;
import org.controlsfx.control.SegmentedButton;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
@@ -68,7 +69,10 @@ public class KeystoreController extends WalletFormController implements Initiali
private Button viewKeyButton;
@FXML
private Button changePinButton;
private ToggleGroup cardServiceToggleGroup;
@FXML
private SegmentedButton cardServiceButtons;
@FXML
private Button importButton;
@@ -137,7 +141,7 @@ public class KeystoreController extends WalletFormController implements Initiali
exportButton.managedProperty().bind(exportButton.visibleProperty());
viewSeedButton.managedProperty().bind(viewSeedButton.visibleProperty());
viewKeyButton.managedProperty().bind(viewKeyButton.visibleProperty());
changePinButton.managedProperty().bind(changePinButton.visibleProperty());
cardServiceButtons.managedProperty().bind(cardServiceButtons.visibleProperty());
scanXpubQR.managedProperty().bind(scanXpubQR.visibleProperty());
displayXpubQR.managedProperty().bind(displayXpubQR.visibleProperty());
displayXpubQR.visibleProperty().bind(scanXpubQR.visibleProperty().not());
@@ -294,7 +298,7 @@ public class KeystoreController extends WalletFormController implements Initiali
exportButton.setVisible(showExport && getWalletForm().getWallet().getPolicyType() == PolicyType.MULTI);
viewSeedButton.setVisible(keystore.getSource() == KeystoreSource.SW_SEED && keystore.hasSeed());
viewKeyButton.setVisible(keystore.getSource() == KeystoreSource.SW_SEED && keystore.hasMasterPrivateExtendedKey());
changePinButton.setVisible(keystore.getWalletModel().isCard());
cardServiceButtons.setVisible(keystore.getWalletModel().isCard());
importButton.setText(keystore.getSource() == KeystoreSource.SW_WATCH ? "Import..." : "Replace...");
importButton.setTooltip(new Tooltip(keystore.getSource() == KeystoreSource.SW_WATCH ? "Import a keystore from an external source" : "Replace this keystore with another source"));
@@ -432,12 +436,22 @@ public class KeystoreController extends WalletFormController implements Initiali
}
public void changeCardPin(ActionEvent event) {
cardServiceToggleGroup.selectToggle(null);
changeCardPinOrBackup(false);
}
public void backupCard(ActionEvent event) {
cardServiceToggleGroup.selectToggle(null);
changeCardPinOrBackup(true);
}
public void changeCardPinOrBackup(boolean backupOnly) {
if(!isReaderAvailable()) {
AppServices.showErrorDialog("No card reader", "Connect a card reader to change the card PIN.");
return;
}
CardPinDialog cardPinDialog = new CardPinDialog();
CardPinDialog cardPinDialog = new CardPinDialog(backupOnly);
Optional<CardPinDialog.CardPinChange> optPinChange = cardPinDialog.showAndWait();
if(optPinChange.isPresent()) {
String currentPin = optPinChange.get().currentPin();
@@ -449,7 +463,7 @@ public class KeystoreController extends WalletFormController implements Initiali
if(authDelayService != null) {
authDelayService.setOnSucceeded(event1 -> {
try {
changeCardPin(newPin, backupFirst, cardApi);
changeCardPin(newPin, backupFirst, backupOnly, cardApi);
} catch(CardException e) {
log.error("Error communicating with card", e);
AppServices.showErrorDialog("Error communicating with card", e.getMessage());
@@ -464,7 +478,7 @@ public class KeystoreController extends WalletFormController implements Initiali
AppServices.moveToActiveWindowScreen(serviceProgressDialog);
authDelayService.start();
} else {
changeCardPin(newPin, backupFirst, cardApi);
changeCardPin(newPin, backupFirst, backupOnly, cardApi);
}
} catch(CardException e) {
log.error("Error communicating with card", e);
@@ -473,22 +487,24 @@ public class KeystoreController extends WalletFormController implements Initiali
}
}
private void changeCardPin(String newPin, boolean backupFirst, CardApi cardApi) throws CardException {
private void changeCardPin(String newPin, boolean backupFirst, boolean backupOnly, CardApi cardApi) throws CardException {
boolean requiresBackup = cardApi.requiresBackup();
if(backupFirst || requiresBackup) {
if(backupOnly || backupFirst || requiresBackup) {
Service<String> backupService = cardApi.getBackupService();
backupService.setOnSucceeded(event -> {
String backup = backupService.getValue();
String filename = fingerprint.getText() + ".aes";
TextAreaDialog backupDialog = new TextAreaDialog(backup, false, filename, Base64.getDecoder().decode(backup));
backupDialog.setTitle("Backup Private Key");
backupDialog.getDialogPane().setHeaderText((requiresBackup ? "Please backup first by saving" : "Save") + " the following text in a safe place. It contains an encrypted copy of the card's private key, and can be decrypted using the backup key written on the back of the card.");
backupDialog.getDialogPane().setHeaderText((requiresBackup && !backupOnly ? "Please backup first by saving" : "Save") + " the following text in a safe place. It contains an encrypted copy of the card's private key, and can be decrypted using the backup key written on the back of the card.");
backupDialog.showAndWait();
try {
changePin(newPin, cardApi);
} catch(Exception e) {
log.error("Error communicating with card", e);
AppServices.showErrorDialog("Error communicating with card", e.getMessage());
if(!backupOnly) {
try {
changePin(newPin, cardApi);
} catch(Exception e) {
log.error("Error communicating with card", e);
AppServices.showErrorDialog("Error communicating with card", e.getMessage());
}
}
});
backupService.setOnFailed(event -> {
@@ -210,6 +210,18 @@ public class PaymentController extends WalletFormController implements Initializ
revalidateAmount();
});
address.setTextFormatter(new TextFormatter<>(change -> {
String controlNewText = change.getControlNewText();
if(!controlNewText.equals(controlNewText.trim())) {
String text = change.getText();
String newText = text.trim();
int caretPos = change.getCaretPosition() - text.length() + newText.length();
change.setText(newText);
change.selectRange(caretPos, caretPos);
}
return change;
}));
address.textProperty().addListener((observable, oldValue, newValue) -> {
address.leftProperty().set(null);
@@ -694,7 +706,7 @@ public class PaymentController extends WalletFormController implements Initializ
@Subscribe
public void unitFormatChanged(UnitFormatChangedEvent event) {
if(amount.getTextFormatter() instanceof CoinTextFormatter coinTextFormatter && coinTextFormatter.getUnitFormat() != event.getUnitFormat()) {
Long value = getRecipientValueSats(coinTextFormatter.getUnitFormat(), event.getBitcoinUnit());
Long value = getRecipientValueSats(coinTextFormatter.getUnitFormat(), amountUnit.getSelectionModel().getSelectedItem());
amount.setTextFormatter(new CoinTextFormatter(event.getUnitFormat()));
if(value != null) {
@@ -697,6 +697,41 @@ public class SendController extends WalletFormController implements Initializabl
protected Task<WalletTransaction> createTask() {
return new Task<>() {
protected WalletTransaction call() throws InsufficientFundsException {
try {
return getWalletTransaction();
} catch(InsufficientFundsException e) {
if(e.getTargetValue() != null && includeSpentMempoolOutputs && utxoSelectors.size() == 1 && utxoSelectors.get(0) instanceof PresetUtxoSelector presetUtxoSelector) {
Optional<BlockTransaction> optBlkTx = wallet.getWalletTransactions().values().stream().filter(blkTx -> {
return blkTx.getTransaction().getInputs().stream().anyMatch(txInput -> {
return presetUtxoSelector.getPresetUtxos().stream().anyMatch(ref -> {
return txInput.getOutpoint().getHash().equals(ref.getHash()) && txInput.getOutpoint().getIndex() == ref.getIndex();
});
});
}).findFirst();
if(optBlkTx.isPresent()) {
//Creating RBF transaction - include additional UTXOs if available to pay desired fee
Transaction rbfTx = optBlkTx.get().getTransaction();
List<BlockTransactionHashIndex> walletUtxos = new ArrayList<>(wallet.getWalletUtxos().keySet());
//Remove any UTXOs that are frozen or created by the transaction that is to be replaced
walletUtxos.removeIf(utxo -> utxo.getStatus() == Status.FROZEN || rbfTx.getOutputs().stream().anyMatch(output -> output.getHash().equals(utxo.getHash()) && output.getIndex() == utxo.getIndex()));
//Remove any UTXOs that have already been added or previously excluded
walletUtxos.removeAll(presetUtxoSelector.getPresetUtxos());
walletUtxos.removeAll(presetUtxoSelector.getExcludedUtxos());
Collections.shuffle(walletUtxos);
while(!walletUtxos.isEmpty() && presetUtxoSelector.getPresetUtxos().stream().mapToLong(BlockTransactionHashIndex::getValue).sum() < e.getTargetValue()) {
presetUtxoSelector.getPresetUtxos().add(walletUtxos.remove(0));
}
return getWalletTransaction();
}
}
throw e;
}
}
private WalletTransaction getWalletTransaction() throws InsufficientFundsException {
updateMessage("Selecting UTXOs...");
WalletTransaction walletTransaction = wallet.createWalletTransaction(utxoSelectors, utxoFilters, payments, opReturns, excludedChangeNodes,
feeRate, longTermFeeRate, fee, currentBlockHeight, groupByAddress, includeMempoolOutputs, includeSpentMempoolOutputs);
@@ -861,7 +896,8 @@ public class SendController extends WalletFormController implements Initializabl
}
private void setFeeRate(Double feeRateAmt) {
feeRate.setText(String.format("%.2f", feeRateAmt) + " sats/vB");
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
feeRate.setText(format.getCurrencyFormat().format(feeRateAmt) + " sats/vB");
setFeeRatePriority(feeRateAmt);
}
@@ -1473,7 +1509,6 @@ public class SendController extends WalletFormController implements Initializabl
public void feeRatesUpdated(FeeRatesUpdatedEvent event) {
blockTargetFeeRatesChart.update(event.getTargetBlockFeeRates());
blockTargetFeeRatesChart.select(getTargetBlocks());
mempoolSizeFeeRatesChart.update(getMempoolHistogram());
if(targetBlocksField.isVisible()) {
setFeeRate(event.getTargetBlockFeeRates().get(getTargetBlocks()));
} else {
@@ -1482,6 +1517,11 @@ public class SendController extends WalletFormController implements Initializabl
addFeeRangeTrackHighlight(0);
}
@Subscribe
public void mempoolRateSizesUpdated(MempoolRateSizesUpdatedEvent event) {
mempoolSizeFeeRatesChart.update(getMempoolHistogram());
}
@Subscribe
public void feeRateSelectionChanged(FeeRatesSelectionChangedEvent event) {
if(event.getWallet() == getWalletForm().getWallet()) {
@@ -1558,8 +1598,9 @@ public class SendController extends WalletFormController implements Initializabl
@Subscribe
public void unitFormatChanged(UnitFormatChangedEvent event) {
setFeeRate(getFeeRate());
if(fee.getTextFormatter() instanceof CoinTextFormatter coinTextFormatter && coinTextFormatter.getUnitFormat() != event.getUnitFormat()) {
Long value = getFeeValueSats(coinTextFormatter.getUnitFormat(), event.getBitcoinUnit());
Long value = getFeeValueSats(coinTextFormatter.getUnitFormat(), feeAmountUnit.getSelectionModel().getSelectedItem());
fee.setTextFormatter(new CoinTextFormatter(event.getUnitFormat()));
if(value != null) {
@@ -1589,22 +1630,22 @@ public class SendController extends WalletFormController implements Initializabl
if(utxoSelector instanceof MaxUtxoSelector) {
Collection<BlockTransactionHashIndex> utxos = walletForm.getWallet().getWalletUtxos().keySet();
utxos.remove(event.getUtxo());
if(utxoFilterProperty.get() instanceof ExcludeUtxoFilter) {
ExcludeUtxoFilter existingUtxoFilter = (ExcludeUtxoFilter)utxoFilterProperty.get();
if(utxoFilterProperty.get() instanceof ExcludeUtxoFilter existingUtxoFilter) {
utxos.removeAll(existingUtxoFilter.getExcludedUtxos());
}
PresetUtxoSelector presetUtxoSelector = new PresetUtxoSelector(utxos);
presetUtxoSelector.getExcludedUtxos().add(event.getUtxo());
utxoSelectorProperty.set(presetUtxoSelector);
updateTransaction(true);
} else if(utxoSelector instanceof PresetUtxoSelector) {
PresetUtxoSelector presetUtxoSelector = new PresetUtxoSelector(((PresetUtxoSelector)utxoSelector).getPresetUtxos());
} else if(utxoSelector instanceof PresetUtxoSelector existingUtxoSelector) {
PresetUtxoSelector presetUtxoSelector = new PresetUtxoSelector(existingUtxoSelector.getPresetUtxos(), existingUtxoSelector.getExcludedUtxos());
presetUtxoSelector.getPresetUtxos().remove(event.getUtxo());
presetUtxoSelector.getExcludedUtxos().add(event.getUtxo());
utxoSelectorProperty.set(presetUtxoSelector);
updateTransaction(true);
updateTransaction(!includeSpentMempoolOutputsProperty.get());
} else {
ExcludeUtxoFilter utxoFilter = new ExcludeUtxoFilter();
if(utxoFilterProperty.get() instanceof ExcludeUtxoFilter) {
ExcludeUtxoFilter existingUtxoFilter = (ExcludeUtxoFilter)utxoFilterProperty.get();
if(utxoFilterProperty.get() instanceof ExcludeUtxoFilter existingUtxoFilter) {
utxoFilter.getExcludedUtxos().addAll(existingUtxoFilter.getExcludedUtxos());
}
@@ -9,6 +9,8 @@ import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.hummingbird.UR;
import com.sparrowwallet.hummingbird.registry.*;
import com.sparrowwallet.hummingbird.registry.pathcomponent.IndexPathComponent;
import com.sparrowwallet.hummingbird.registry.pathcomponent.PathComponent;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.*;
@@ -354,7 +356,11 @@ public class SettingsController extends WalletFormController implements Initiali
if(wallet.getPolicyType() == PolicyType.SINGLE) {
cryptoOutput = new CryptoOutput(scriptExpressions, getCryptoHDKey(wallet.getKeystores().get(0)));
} else if(wallet.getPolicyType() == PolicyType.MULTI) {
List<CryptoHDKey> cryptoHDKeys = wallet.getKeystores().stream().map(this::getCryptoHDKey).collect(Collectors.toList());
WalletNode firstReceive = new WalletNode(wallet, KeyPurpose.RECEIVE, 0);
Utils.LexicographicByteArrayComparator lexicographicByteArrayComparator = new Utils.LexicographicByteArrayComparator();
List<CryptoHDKey> cryptoHDKeys = wallet.getKeystores().stream().sorted((keystore1, keystore2) -> {
return lexicographicByteArrayComparator.compare(keystore1.getPubKey(firstReceive).getPubKey(), keystore2.getPubKey(firstReceive).getPubKey());
}).map(this::getCryptoHDKey).collect(Collectors.toList());
MultiKey multiKey = new MultiKey(wallet.getDefaultPolicy().getNumSignaturesRequired(), null, cryptoHDKeys);
List<ScriptExpression> multiScriptExpressions = new ArrayList<>(scriptExpressions);
multiScriptExpressions.add(ScriptExpression.SORTED_MULTISIG);
@@ -389,7 +395,7 @@ public class SettingsController extends WalletFormController implements Initiali
private CryptoHDKey getCryptoHDKey(Keystore keystore) {
ExtendedKey extendedKey = keystore.getExtendedPublicKey();
CryptoCoinInfo cryptoCoinInfo = new CryptoCoinInfo(CryptoCoinInfo.Type.BITCOIN.ordinal(), Network.get() == Network.MAINNET ? CryptoCoinInfo.Network.MAINNET.ordinal() : CryptoCoinInfo.Network.TESTNET.ordinal());
List<PathComponent> pathComponents = keystore.getKeyDerivation().getDerivation().stream().map(cNum -> new PathComponent(cNum.num(), cNum.isHardened())).collect(Collectors.toList());
List<PathComponent> pathComponents = keystore.getKeyDerivation().getDerivation().stream().map(cNum -> new IndexPathComponent(cNum.num(), cNum.isHardened())).collect(Collectors.toList());
CryptoKeypath cryptoKeypath = new CryptoKeypath(pathComponents, Utils.hexToBytes(keystore.getKeyDerivation().getMasterFingerprint()), pathComponents.size());
return new CryptoHDKey(false, extendedKey.getKey().getPubKey(), extendedKey.getKey().getChainCode(), cryptoCoinInfo, cryptoKeypath, null, extendedKey.getParentFingerprint());
}
@@ -1,5 +1,6 @@
package com.sparrowwallet.sparrow.wallet;
import com.google.common.collect.Sets;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.protocol.HashIndex;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
@@ -72,15 +73,14 @@ public class WalletTransactionsEntry extends Entry {
.collect(Collectors.toUnmodifiableMap(entry -> new HashIndex(entry.getKey().getHash(), entry.getKey().getIndex()), Map.Entry::getKey,
BinaryOperator.maxBy(BlockTransactionHashIndex::compareTo)));
List<Entry> current = getWalletTransactions(getWallet()).stream().map(WalletTransaction::getTransactionEntry).collect(Collectors.toList());
List<Entry> previous = new ArrayList<>(getChildren());
Collection<WalletTransactionsEntry.WalletTransaction> entries = getWalletTransactions(getWallet());
Set<Entry> current = entries.stream().map(WalletTransaction::getTransactionEntry).collect(Collectors.toCollection(LinkedHashSet::new));
Set<Entry> previous = new LinkedHashSet<>(getChildren());
List<Entry> entriesAdded = new ArrayList<>(current);
entriesAdded.removeAll(previous);
Set<Entry> entriesAdded = Sets.difference(current, previous);
getChildren().addAll(entriesAdded);
List<Entry> entriesRemoved = new ArrayList<>(previous);
entriesRemoved.removeAll(current);
Set<Entry> entriesRemoved = Sets.difference(previous, current);
getChildren().removeAll(entriesRemoved);
calculateBalances(true);
@@ -16,6 +16,7 @@ import org.slf4j.LoggerFactory;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
public class SparrowUtxoConfigPersister extends UtxoConfigPersister {
@@ -38,7 +39,7 @@ public class SparrowUtxoConfigPersister extends UtxoConfigPersister {
Map<String, UtxoConfigPersisted> utxoConfigs = wallet.getUtxoMixes().entrySet().stream()
.collect(Collectors.toMap(entry -> entry.getKey().toString(), entry -> new UtxoConfigPersisted(entry.getValue().getMixesDone(), entry.getValue().getExpired()),
(u, v) -> { throw new IllegalStateException("Duplicate utxo config hashes"); },
HashMap::new));
ConcurrentHashMap::new));
return new UtxoConfigData(utxoConfigs);
}
@@ -331,3 +331,7 @@ HorizontalHeaderColumn > TableColumnHeader.column-header.table-column{
.root .hyperlink:visited {
-fx-text-fill: #229df5;
}
#grid .spreadsheet-cell.selection {
-fx-text-fill: -fx-base;
}
@@ -236,11 +236,14 @@
-fx-border-width: 0;
-fx-border-insets: 0;
-fx-background-color: transparent;
-fx-opacity: 0.7;
}
.icon-button:hover {
-fx-opacity: 1.0;
.icon-button .glyph-font {
-fx-text-fill: #1e88cf;
}
.icon-button:hover .glyph-font {
-fx-text-fill: #259cf5;
}
.number-field {
@@ -1,5 +1,13 @@
#grid .spreadsheet-cell:selected,
#grid .spreadsheet-cell:focused:selected,
#grid .spreadsheet-cell:focused:selected:hover {
-fx-background-color: transparent;
}
#grid .spreadsheet-cell.selection:selected,
#grid .spreadsheet-cell.selection:focused:selected,
#grid .spreadsheet-cell.selection:focused:selected:hover,
#grid .spreadsheet-cell.selection {
-fx-background-color: rgb(238, 210, 2);
-fx-content-display: RIGHT;
}
@@ -41,14 +41,26 @@
<Tooltip text="View master private key"/>
</tooltip>
</Button>
<Button fx:id="changePinButton" text="Change Pin..." graphicTextGap="5" onAction="#changeCardPin">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="WIFI" />
</graphic>
<tooltip>
<Tooltip text="Change the PIN of current card"/>
</tooltip>
</Button>
<SegmentedButton fx:id="cardServiceButtons">
<toggleGroup>
<ToggleGroup fx:id="cardServiceToggleGroup" />
</toggleGroup>
<buttons>
<ToggleButton toggleGroup="$cardServiceToggleGroup" text="Change Pin" graphicTextGap="5" onAction="#changeCardPin">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="WIFI" />
</graphic>
<tooltip>
<Tooltip text="Change the PIN of the current card"/>
</tooltip>
</ToggleButton>
<ToggleButton toggleGroup="$cardServiceToggleGroup" text="Backup" onAction="#backupCard">
<tooltip>
<Tooltip text="Backup the current card"/>
</tooltip>
</ToggleButton>
</buttons>
</SegmentedButton>
<Button fx:id="exportButton" text="Export..." onAction="#export">
<tooltip>
<Tooltip text="Export this keystore as a signer for a multisig wallet"/>
@@ -130,4 +130,37 @@
#transactionDiagram .useradd-icon {
-fx-text-fill: -fx-accent;
}
}
.vsizeChart {
VSIZE1-2_COLOR: rgb(216, 27, 96);
VSIZE2-3_COLOR: rgb(142, 36, 170);
VSIZE3-4_COLOR: rgb(94, 53, 177);
VSIZE4-5_COLOR: rgb(57, 73, 171);
VSIZE5-6_COLOR: rgb(30, 136, 229);
VSIZE6-8_COLOR: rgb(3, 155, 229);
VSIZE8-10_COLOR: rgb(0, 172, 193);
VSIZE10-12_COLOR: rgb(0, 137, 123);
VSIZE12-15_COLOR: rgb(67, 160, 71);
VSIZE15-20_COLOR: rgb(124, 179, 66);
VSIZE20-30_COLOR: rgb(192, 202, 51);
VSIZE30-40_COLOR: rgb(253, 216, 53);
VSIZE40-50_COLOR: rgb(255, 179, 0);
VSIZE50-60_COLOR: rgb(251, 140, 0);
VSIZE60-70_COLOR: rgb(244, 81, 30);
VSIZE70-80_COLOR: rgb(109, 76, 65);
VSIZE80-90_COLOR: rgb(117, 117, 117);
VSIZE90-100_COLOR: rgb(84, 110, 122);
VSIZE100-125_COLOR: rgb(183, 28, 28);
VSIZE125-150_COLOR: rgb(136, 14, 79);
VSIZE150-175_COLOR: rgb(74, 20, 140);
VSIZE175-200_COLOR: rgb(49, 27, 146);
VSIZE200-250_COLOR: rgb(26, 35, 126);
VSIZE250-300_COLOR: rgb(13, 71, 161);
VSIZE300-350_COLOR: rgb(1, 87, 155);
VSIZE350-400_COLOR: rgb(0, 96, 100);
VSIZE400-500_COLOR: rgb(0, 77, 64);
VSIZE500-600_COLOR: rgb(27, 94, 32);
VSIZE600-700_COLOR: rgb(51, 105, 30);
VSIZE700-800_COLOR: rgb(130, 119, 23);
}
@@ -12,7 +12,8 @@ public class IoTest {
protected File getFile(String filename) {
try {
Path tempFile = Files.createTempFile(filename, null);
Path tempDir = Files.createTempDirectory(null);
Path tempFile = Files.createTempFile(tempDir, filename, null);
Files.copy(getInputStream(filename), tempFile, StandardCopyOption.REPLACE_EXISTING);
return tempFile.toFile();
} catch(IOException e) {