Compare commits

...
41 Commits
Author SHA1 Message Date
Craig Raw c804737563 upgrade to hwi 1.2.1 2020-11-26 07:56:40 +02:00
Craig Raw d395c7897e v0.9.8 2020-11-26 07:19:47 +02:00
Craig Raw 6e2e5d7a9c fix cross-platform issues 2020-11-25 11:45:26 +02:00
Craig Raw 99a39aa83f reduce unconfirmed opacity 2020-11-25 09:56:31 +02:00
Craig Raw 2ca8b91283 add increase fee functionality for rbf transactions 2020-11-24 20:34:51 +02:00
Craig Raw 94960567b5 default to mempool.space, improve mempool size tooltip display 2020-11-24 10:05:27 +02:00
Craig Raw e14349a645 only fetch external fee rates on mainnet 2020-11-23 17:34:19 +02:00
Craig Raw ee7b741a69 fetch fee rates from configurable external sources 2020-11-23 17:26:51 +02:00
Craig Raw 02c8415a7b use proxy for exchange rate if available, move to exchange rate to net package 2020-11-23 14:15:39 +02:00
Craig Raw f305e0bdc2 reduce mempool size x axis labelling 2020-11-23 12:23:45 +02:00
Craig Raw d8b7938a63 show returned errors on hwi enumerate 2020-11-19 12:54:12 +02:00
Craig Raw 44ac7f3c0e toggle fee rate selection on send, dont show zero total rate series 2020-11-19 12:33:33 +02:00
Craig Raw 2b55b5feb3 add mempool size fee selection 2020-11-19 10:46:19 +02:00
Craig Raw 43bf6ab265 get and store fee rate histogram 2020-11-18 09:54:34 +02:00
Craig Raw 80b7ee803b separate keystore and wallet scannable, improve cobo importing 2020-11-18 08:37:51 +02:00
Craig Raw 8e23bd64c7 add cobo vault support with legacy qr scanning 2020-11-17 13:32:07 +02:00
Craig Raw bf6fbebd9e add wallet to wallethistorystatusevent and filter updates 2020-11-13 12:44:27 +02:00
Craig Raw 0b9052dee9 refactor wallet field push down to entry 2020-11-13 10:12:19 +02:00
Craig Raw 6460cb88a5 v0.9.7 2020-11-12 08:40:20 +02:00
Craig Raw 90ac786707 slow down qr animation 2020-11-12 08:37:33 +02:00
Craig Raw c86006335b ensure wallet tab name and wallet name are synced on wallet open 2020-11-11 17:10:46 +02:00
Craig Raw a444e2af2f ensure wallet tab name and wallet name are synced on wallet open 2020-11-11 16:31:58 +02:00
Craig Raw bcd2c966a5 add recursive lookup for tab header 2020-11-11 14:48:10 +02:00
Craig Raw f826c2d423 upgrade to hummingbird 1.4 to fix crypto-account 2020-11-11 14:04:08 +02:00
Craig Raw 37bb1c5d97 import descriptor from qr 2020-11-11 12:26:36 +02:00
Craig Raw 68da8e6027 add support for UR registry types crypto-address, crypto-hdkey, crypto-output and crypto-account 2020-11-11 09:51:50 +02:00
Craig Raw 87fe0accd5 Use broader matching on HWI wallet types (eg bitbox02_btconly) 2020-11-05 16:46:01 +02:00
Craig Raw 8d413e839c handle server returning history with the same tx at multiple heights (electrs issue #316) 2020-11-05 16:29:15 +02:00
Craig Raw 1392199f5c add tx tree icons and input descriptions when signing 2020-11-05 10:31:54 +02:00
Craig Raw dc33b64a9c further payjoin tweaks #2 2020-11-04 16:33:05 +02:00
Craig Raw 1b220c72ea further payjoin tweaks 2020-11-04 16:32:17 +02:00
Craig Raw bcf6f77340 improve wallet import error messaging, handle p2sh-p2wpkh payjoin 2020-11-04 12:10:40 +02:00
Craig Raw 0aac8bbea7 indicate payjoin input on transaction diagram 2020-11-03 15:00:34 +02:00
Craig Raw e1acaa8a78 add payjoin (bip78) support 2020-11-03 13:54:18 +02:00
Craig Raw 13a486597c Remove illegal / characters from output descriptors 2020-11-02 07:49:55 +02:00
Craig Raw 389ce2180d accept bip21 uri in pay to field 2020-10-29 11:45:10 +02:00
Craig Raw a7a3ad012c add stacktrace on null node 2020-10-29 11:24:56 +02:00
Craig Raw 9f5eb41d90 dont add change node if no change present 2020-10-29 11:23:50 +02:00
Craig Raw 3a4d385560 avoid casting to int when comparing longs 2020-10-27 11:11:35 +02:00
Craig Raw d3881f76b9 default to trezor t on import 2020-10-27 11:09:31 +02:00
Craig Raw be5a48ce71 handle p2sh default derivation change and other small fixes 2020-10-26 15:12:10 +02:00
90 changed files with 2454 additions and 388 deletions
+2 -2
View File
@@ -5,7 +5,7 @@ plugins {
id 'org.beryx.jlink' version '2.22.0'
}
def sparrowVersion = '0.9.6'
def sparrowVersion = '0.9.8'
def os = org.gradle.internal.os.OperatingSystem.current()
def osName = os.getFamilyName()
if(os.macOsX) {
@@ -51,7 +51,7 @@ dependencies {
implementation('com.github.arteam:simple-json-rpc-server:1.0') {
exclude group: 'org.slf4j'
}
implementation('com.sparrowwallet:hummingbird:1.2')
implementation('com.sparrowwallet:hummingbird:1.5.2')
implementation('com.nativelibs4java:bridj:0.7-20140918-3') {
exclude group: 'com.google.android.tools', module: 'dx'
}
+1 -1
Submodule drongo updated: c4f5218f29...6b20c6558a
+1 -1
View File
@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.9.6</string>
<string>0.9.8</string>
<key>CFBundleSignature</key>
<string>????</string>
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
@@ -7,6 +7,7 @@ import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.SecureString;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.crypto.EncryptionType;
import com.sparrowwallet.drongo.crypto.InvalidPasswordException;
@@ -16,12 +17,16 @@ import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTInput;
import com.sparrowwallet.drongo.psbt.PSBTParseException;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.*;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.net.ExchangeSource;
import com.sparrowwallet.sparrow.net.MempoolRateSize;
import com.sparrowwallet.sparrow.net.VersionCheckService;
import com.sparrowwallet.sparrow.preferences.PreferencesDialog;
import com.sparrowwallet.sparrow.transaction.TransactionController;
@@ -65,6 +70,9 @@ import java.io.*;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.text.ParseException;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.List;
import java.util.stream.Collectors;
@@ -72,7 +80,7 @@ import java.util.stream.Collectors;
public class AppController implements Initializable {
private static final Logger log = LoggerFactory.getLogger(AppController.class);
private static final int SERVER_PING_PERIOD = 8 * 60 * 1000;
private static final int SERVER_PING_PERIOD = 1 * 60 * 1000;
private static final int ENUMERATE_HW_PERIOD = 30 * 1000;
private static final int RATES_PERIOD = 5 * 60 * 1000;
private static final int VERSION_CHECK_PERIOD_HOURS = 24;
@@ -140,12 +148,16 @@ public class AppController implements Initializable {
private static Map<Integer, Double> targetBlockFeeRates;
private static final Map<Date, Set<MempoolRateSize>> mempoolHistogram = new TreeMap<>();
private static Double minimumRelayFeeRate;
private static CurrencyRate fiatCurrencyExchangeRate;
private static List<Device> devices;
private static final Map<Address, BitcoinURI> payjoinURIs = new HashMap<>();
@Override
public void initialize(URL location, ResourceBundle resources) {
EventManager.get().register(this);
@@ -447,7 +459,7 @@ public class AppController implements Initializable {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Open Transaction");
fileChooser.getExtensionFilters().addAll(
new FileChooser.ExtensionFilter("All Files", "*"),
new FileChooser.ExtensionFilter("All Files", org.controlsfx.tools.Platform.getCurrent().equals(org.controlsfx.tools.Platform.UNIX) ? "*" : "*.*"),
new FileChooser.ExtensionFilter("PSBT", "*.psbt"),
new FileChooser.ExtensionFilter("TXN", "*.txn")
);
@@ -565,8 +577,8 @@ public class AppController implements Initializable {
Tab tab = addTransactionTab(null, result.psbt);
tabs.getSelectionModel().select(tab);
} else if(result.exception != null) {
log.error("Error opening webcam", result.exception);
showErrorDialog("Error opening webcam", result.exception.getMessage());
log.error("Error scanning QR", result.exception);
showErrorDialog("Error scanning QR", result.exception.getMessage());
} else {
AppController.showErrorDialog("Invalid QR Code", "Cannot parse QR code into a transaction or PSBT");
}
@@ -645,6 +657,19 @@ public class AppController implements Initializable {
return targetBlockFeeRates;
}
public static Map<Date, Set<MempoolRateSize>> getMempoolHistogram() {
return mempoolHistogram;
}
private void addMempoolRateSizes(Set<MempoolRateSize> rateSizes) {
LocalDateTime dateMinute = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES);
if(mempoolHistogram.isEmpty()) {
mempoolHistogram.put(Date.from(dateMinute.minusMinutes(1).atZone(ZoneId.systemDefault()).toInstant()), rateSizes);
}
mempoolHistogram.put(Date.from(dateMinute.atZone(ZoneId.systemDefault()).toInstant()), rateSizes);
}
public static Double getMinimumRelayFeeRate() {
return minimumRelayFeeRate == null ? Transaction.DEFAULT_MIN_RELAY_FEE : minimumRelayFeeRate;
}
@@ -657,6 +682,17 @@ public class AppController implements Initializable {
return devices == null ? new ArrayList<>() : devices;
}
public static BitcoinURI getPayjoinURI(Address address) {
return payjoinURIs.get(address);
}
public static void addPayjoinURI(BitcoinURI bitcoinURI) {
if(bitcoinURI.getPayjoinUrl() == null) {
throw new IllegalArgumentException("Not a payjoin URI");
}
payjoinURIs.put(bitcoinURI.getAddress(), bitcoinURI);
}
public Map<Wallet, Storage> getOpenWallets() {
Map<Wallet, Storage> openWallets = new LinkedHashMap<>();
@@ -1031,6 +1067,9 @@ public class AppController implements Initializable {
if(name.endsWith(".json")) {
name = name.substring(0, name.lastIndexOf('.'));
}
if(!name.equals(wallet.getName())) {
wallet.setName(name);
}
Tab tab = new Tab(name);
tab.setContextMenu(getTabContextMenu(tab));
tab.setClosable(true);
@@ -1119,14 +1158,31 @@ public class AppController implements Initializable {
//If an exact match bytewise of an existing tab, return that tab
if(Arrays.equals(transactionTabData.getTransaction().bitcoinSerialize(), transaction.bitcoinSerialize())) {
//As per BIP174, combine PSBTs with matching transactions so long as they are not yet finalized
if(transactionTabData.getPsbt() != null && psbt != null && !transactionTabData.getPsbt().isFinalized() && !psbt.isFinalized()) {
transactionTabData.getPsbt().combine(psbt);
if(name != null && !name.isEmpty()) {
tab.setText(name);
}
if(transactionTabData.getPsbt() != null && psbt != null && !transactionTabData.getPsbt().isFinalized()) {
if(!psbt.isFinalized()) {
//As per BIP174, combine PSBTs with matching transactions so long as they are not yet finalized
transactionTabData.getPsbt().combine(psbt);
if(name != null && !name.isEmpty()) {
tab.setText(name);
}
EventManager.get().post(new PSBTCombinedEvent(transactionTabData.getPsbt()));
EventManager.get().post(new PSBTCombinedEvent(transactionTabData.getPsbt()));
} else {
//If the new PSBT is finalized, copy the finalized fields to the existing unfinalized PSBT
for(int i = 0; i < transactionTabData.getPsbt().getPsbtInputs().size(); i++) {
PSBTInput existingInput = transactionTabData.getPsbt().getPsbtInputs().get(i);
PSBTInput finalizedInput = psbt.getPsbtInputs().get(i);
existingInput.setFinalScriptSig(finalizedInput.getFinalScriptSig());
existingInput.setFinalScriptWitness(finalizedInput.getFinalScriptWitness());
existingInput.clearNonFinalFields();
}
if(name != null && !name.isEmpty()) {
tab.setText(name);
}
EventManager.get().post(new PSBTFinalizedEvent(transactionTabData.getPsbt()));
}
}
return tab;
@@ -1393,6 +1449,7 @@ public class AppController implements Initializable {
public void newConnection(ConnectionEvent event) {
currentBlockHeight = event.getBlockHeight();
targetBlockFeeRates = event.getTargetBlockFeeRates();
addMempoolRateSizes(event.getMempoolRateSizes());
minimumRelayFeeRate = event.getMinimumRelayFeeRate();
String banner = event.getServerBanner();
String status = "Connected to " + Config.get().getElectrumServer() + " at height " + event.getBlockHeight();
@@ -1417,6 +1474,7 @@ public class AppController implements Initializable {
@Subscribe
public void feesUpdated(FeeRatesUpdatedEvent event) {
targetBlockFeeRates = event.getTargetBlockFeeRates();
addMempoolRateSizes(event.getMempoolRateSizes());
}
@Subscribe
@@ -1437,6 +1495,15 @@ public class AppController implements Initializable {
selectedToggle.ifPresent(toggle -> bitcoinUnit.selectToggle(toggle));
}
@Subscribe
public void feeRateSourceChanged(FeeRatesSourceChangedEvent event) {
ElectrumServer.FeeRatesService feeRatesService = new ElectrumServer.FeeRatesService();
feeRatesService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(feeRatesService.getValue());
});
feeRatesService.start();
}
@Subscribe
public void fiatCurrencySelected(FiatCurrencySelectedEvent event) {
ratesService.cancel();
@@ -31,7 +31,7 @@ public class MainApp extends Application {
private static final Logger log = LoggerFactory.getLogger(MainApp.class);
public static final String APP_NAME = "Sparrow";
public static final String APP_VERSION = "0.9.6";
public static final String APP_VERSION = "0.9.8";
public static final String APP_HOME_PROPERTY = "sparrow.home";
public static final String NETWORK_ENV_PROPERTY = "SPARROW_NETWORK";
@@ -78,7 +78,7 @@ public class MainApp extends Application {
stage.setTitle("Sparrow");
stage.setMinWidth(650);
stage.setMinHeight(700);
stage.setMinHeight(830);
stage.setScene(scene);
stage.getIcons().add(new Image(MainApp.class.getResourceAsStream("/image/sparrow.png")));
@@ -9,11 +9,11 @@ import javafx.scene.chart.XYChart;
import java.util.Iterator;
import java.util.Map;
public class FeeRatesChart extends LineChart<String, Number> {
public class BlockTargetFeeRatesChart extends LineChart<String, Number> {
private XYChart.Series<String, Number> feeRateSeries;
private Integer selectedTargetBlocks;
public FeeRatesChart(@NamedArg("xAxis") Axis<String> xAxis, @NamedArg("yAxis") Axis<Number> yAxis) {
public BlockTargetFeeRatesChart(@NamedArg("xAxis") Axis<String> xAxis, @NamedArg("yAxis") Axis<Number> yAxis) {
super(xAxis, yAxis);
}
@@ -38,18 +38,23 @@ public class CoinTreeTable extends TreeTableView<Entry> {
}
public void updateHistoryStatus(WalletHistoryStatusEvent event) {
Platform.runLater(() -> {
if(event.getErrorMessage() != null) {
setPlaceholder(new Label("Error loading transactions: " + event.getErrorMessage()));
} else if(event.isLoading()) {
if(event.getStatusMessage() != null) {
setPlaceholder(new Label(event.getStatusMessage() + "..."));
} else {
setPlaceholder(new Label("Loading transactions..."));
}
} else {
setPlaceholder(new Label("No transactions"));
if(getRoot() != null) {
Entry entry = getRoot().getValue();
if(entry != null && event.getWallet() != null && entry.getWallet() == event.getWallet()) {
Platform.runLater(() -> {
if(event.getErrorMessage() != null) {
setPlaceholder(new Label("Error loading transactions: " + event.getErrorMessage()));
} else if(event.isLoading()) {
if(event.getStatusMessage() != null) {
setPlaceholder(new Label(event.getStatusMessage() + "..."));
} else {
setPlaceholder(new Label("Loading transactions..."));
}
} else {
setPlaceholder(new Label("No transactions"));
}
});
}
});
}
}
}
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.crypto.ChildNumber;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
@@ -31,10 +32,14 @@ import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.Validator;
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
public class DevicePane extends TitledDescriptionPane {
private static final Logger log = LoggerFactory.getLogger(DevicePane.class);
private final DeviceOperation deviceOperation;
private final Wallet wallet;
private final PSBT psbt;
@@ -68,7 +73,12 @@ public class DevicePane extends TitledDescriptionPane {
createSetPassphraseButton();
createImportButton();
if (device.getNeedsPinSent() != null && device.getNeedsPinSent()) {
if(device.getError() != null) {
setError("Error", device.getError());
Platform.runLater(() -> {
setExpanded(true);
});
} else if(device.getNeedsPinSent() != null && device.getNeedsPinSent()) {
unlockButton.setVisible(true);
} else if(device.getNeedsPassphraseSent() != null && device.getNeedsPassphraseSent()) {
setPassphraseButton.setVisible(true);
@@ -199,7 +209,8 @@ public class DevicePane extends TitledDescriptionPane {
importKeystore(wallet.getScriptType().getDefaultDerivation());
});
String[] accounts = new String[] {"Default Account #0", "Account #1", "Account #2", "Account #3", "Account #4", "Account #5", "Account #6", "Account #7", "Account #8", "Account #9"};
for(int i = 0; i < accounts.length; i++) {
int scriptAccountsLength = ScriptType.P2SH.equals(wallet.getScriptType()) ? 1 : accounts.length;
for(int i = 0; i < scriptAccountsLength; i++) {
MenuItem item = new MenuItem(accounts[i]);
final List<ChildNumber> derivation = wallet.getScriptType().getDefaultDerivation(i);
item.setOnAction(event -> {
@@ -465,6 +476,7 @@ public class DevicePane extends TitledDescriptionPane {
});
signPSBTService.setOnFailed(workerStateEvent -> {
setError("Signing Error", signPSBTService.getException().getMessage());
log.error("Signing Error: " + signPSBTService.getException().getMessage());
signButton.setDisable(false);
});
setDescription("Signing...");
@@ -2,8 +2,11 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.protocol.NonStandardScriptException;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppController;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
@@ -19,7 +22,7 @@ import org.controlsfx.glyphfont.Glyph;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.List;
import java.util.*;
import java.util.stream.Collectors;
class EntryCell extends TreeTableCell<Entry, Entry> {
@@ -46,10 +49,10 @@ class EntryCell extends TreeTableCell<Entry, Entry> {
TransactionEntry transactionEntry = (TransactionEntry)entry;
if(transactionEntry.getBlockTransaction().getHeight() == -1) {
setText("Unconfirmed Parent");
setContextMenu(new UnconfirmedTransactionContextMenu(transactionEntry.getBlockTransaction()));
setContextMenu(new UnconfirmedTransactionContextMenu(transactionEntry));
} else if(transactionEntry.getBlockTransaction().getHeight() == 0) {
setText("Unconfirmed");
setContextMenu(new UnconfirmedTransactionContextMenu(transactionEntry.getBlockTransaction()));
setContextMenu(new UnconfirmedTransactionContextMenu(transactionEntry));
} else {
String date = DATE_FORMAT.format(transactionEntry.getBlockTransaction().getDate());
setText(date);
@@ -60,6 +63,7 @@ class EntryCell extends TreeTableCell<Entry, Entry> {
tooltip.setText(transactionEntry.getBlockTransaction().getHash().toString());
setTooltip(tooltip);
HBox actionBox = new HBox();
Button viewTransactionButton = new Button("");
Glyph searchGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.SEARCH);
searchGlyph.setFontSize(12);
@@ -67,7 +71,21 @@ class EntryCell extends TreeTableCell<Entry, Entry> {
viewTransactionButton.setOnAction(event -> {
EventManager.get().post(new ViewTransactionEvent(transactionEntry.getBlockTransaction()));
});
setGraphic(viewTransactionButton);
actionBox.getChildren().add(viewTransactionButton);
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
if(blockTransaction.getHeight() <= 0 && blockTransaction.getTransaction().isReplaceByFee() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
Button increaseFeeButton = new Button("");
Glyph increaseFeeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.HAND_HOLDING_MEDICAL);
increaseFeeGlyph.setFontSize(12);
increaseFeeButton.setGraphic(increaseFeeGlyph);
increaseFeeButton.setOnAction(event -> {
increaseFee(transactionEntry);
});
actionBox.getChildren().add(increaseFeeButton);
}
setGraphic(actionBox);
} else if(entry instanceof NodeEntry) {
NodeEntry nodeEntry = (NodeEntry)entry;
Address address = nodeEntry.getAddress();
@@ -139,9 +157,9 @@ class EntryCell extends TreeTableCell<Entry, Entry> {
utxoEntries = List.of(hashIndexEntry);
}
final List<HashIndexEntry> spendingUtxoEntries = utxoEntries;
EventManager.get().post(new SendActionEvent(utxoEntries));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(spendingUtxoEntries)));
final List<BlockTransactionHashIndex> spendingUtxos = utxoEntries.stream().map(HashIndexEntry::getHashIndex).collect(Collectors.toList());
EventManager.get().post(new SendActionEvent(hashIndexEntry.getWallet(), spendingUtxos));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(hashIndexEntry.getWallet(), spendingUtxos)));
});
actionBox.getChildren().add(spendUtxoButton);
}
@@ -151,8 +169,63 @@ class EntryCell extends TreeTableCell<Entry, Entry> {
}
}
private static void increaseFee(TransactionEntry transactionEntry) {
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
Map<BlockTransactionHashIndex, WalletNode> walletTxos = transactionEntry.getWallet().getWalletTxos();
List<BlockTransactionHashIndex> utxos = transactionEntry.getChildren().stream()
.filter(e -> e instanceof HashIndexEntry)
.map(e -> (HashIndexEntry)e)
.filter(e -> e.getType().equals(HashIndexEntry.Type.INPUT) && e.isSpendable())
.map(e -> blockTransaction.getTransaction().getInputs().get((int)e.getHashIndex().getIndex()))
.map(txInput -> walletTxos.keySet().stream().filter(txo -> txo.getHash().equals(txInput.getOutpoint().getHash()) && txo.getIndex() == txInput.getOutpoint().getIndex()).findFirst().get())
.collect(Collectors.toList());
List<TransactionOutput> ourOutputs = transactionEntry.getChildren().stream()
.filter(e -> e instanceof HashIndexEntry)
.map(e -> (HashIndexEntry)e)
.filter(e -> e.getType().equals(HashIndexEntry.Type.OUTPUT))
.map(e -> e.getBlockTransaction().getTransaction().getOutputs().get((int)e.getHashIndex().getIndex()))
.collect(Collectors.toList());
long changeTotal = ourOutputs.stream().mapToLong(TransactionOutput::getValue).sum();
Transaction tx = blockTransaction.getTransaction();
int 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());
Collections.shuffle(walletUtxos);
while((double)changeTotal / vSize < getMaxFeeRate() && !walletUtxos.isEmpty()) {
//If there is insufficent change output, include another random UTXO so the fee can be increased
BlockTransactionHashIndex utxo = walletUtxos.remove(0);
utxos.add(utxo);
changeTotal += utxo.getValue();
vSize += inputSize;
}
List<TransactionOutput> externalOutputs = new ArrayList<>(blockTransaction.getTransaction().getOutputs());
externalOutputs.removeAll(ourOutputs);
List<Payment> payments = externalOutputs.stream().map(txOutput -> {
try {
return new Payment(txOutput.getScript().getToAddresses()[0], transactionEntry.getLabel(), txOutput.getValue(), false);
} catch(Exception e) {
return null;
}
}).filter(Objects::nonNull).collect(Collectors.toList());
EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), utxos));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, payments, blockTransaction.getFee(), true)));
}
private static Double getMaxFeeRate() {
if(AppController.getTargetBlockFeeRates().isEmpty()) {
return 100.0;
}
return AppController.getTargetBlockFeeRates().values().iterator().next();
}
private static class UnconfirmedTransactionContextMenu extends ContextMenu {
public UnconfirmedTransactionContextMenu(BlockTransaction blockTransaction) {
public UnconfirmedTransactionContextMenu(TransactionEntry transactionEntry) {
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
MenuItem copyTxid = new MenuItem("Copy Transaction ID");
copyTxid.setOnAction(AE -> {
hide();
@@ -161,7 +234,17 @@ class EntryCell extends TreeTableCell<Entry, Entry> {
Clipboard.getSystemClipboard().setContent(content);
});
getItems().addAll(copyTxid);
getItems().add(copyTxid);
if(blockTransaction.getTransaction().isReplaceByFee() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
MenuItem increaseFee = new MenuItem("Increase Fee");
increaseFee.setOnAction(AE -> {
hide();
increaseFee(transactionEntry);
});
getItems().add(increaseFee);
}
}
}
@@ -2,6 +2,9 @@ package com.sparrowwallet.sparrow.control;
import com.google.gson.JsonParseException;
import com.sparrowwallet.drongo.crypto.InvalidPasswordException;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.FileImport;
import com.sparrowwallet.sparrow.io.ImportException;
@@ -21,11 +24,13 @@ import org.controlsfx.control.SegmentedButton;
import org.controlsfx.control.textfield.CustomPasswordField;
import org.controlsfx.control.textfield.TextFields;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.tools.Platform;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Optional;
public abstract class FileImportPane extends TitledDescriptionPane {
@@ -35,6 +40,7 @@ public abstract class FileImportPane extends TitledDescriptionPane {
protected ButtonBase importButton;
private final SimpleStringProperty password = new SimpleStringProperty("");
private final boolean scannable;
protected List<Wallet> wallets;
public FileImportPane(FileImport importer, String title, String description, String content, String imageUrl, boolean scannable) {
super(title, description, content, imageUrl);
@@ -84,7 +90,7 @@ public abstract class FileImportPane extends TitledDescriptionPane {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Open " + importer.getWalletModel().toDisplayString() + " File");
fileChooser.getExtensionFilters().addAll(
new FileChooser.ExtensionFilter("All Files", "*"),
new FileChooser.ExtensionFilter("All Files", Platform.getCurrent().equals(Platform.UNIX) ? "*" : "*.*"),
new FileChooser.ExtensionFilter("JSON", "*.json"),
new FileChooser.ExtensionFilter("TXT", "*.txt")
);
@@ -131,7 +137,14 @@ public abstract class FileImportPane extends TitledDescriptionPane {
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
if(optionalResult.isPresent()) {
QRScanDialog.Result result = optionalResult.get();
if(result.payload != null) {
if(result.wallets != null) {
wallets = result.wallets;
try {
importFile(importer.getName(), null, null);
} catch(ImportException e) {
setError("Import Error", e.getMessage());
}
} else if(result.payload != null) {
try {
importFile(importer.getName(), new ByteArrayInputStream(result.payload.getBytes(StandardCharsets.UTF_8)), null);
} catch(Exception e) {
@@ -145,10 +158,27 @@ public abstract class FileImportPane extends TitledDescriptionPane {
}
setError("Import Error", errorMessage);
}
} else if(result.exception != null) {
log.error("Error importing QR", result.exception);
setError("Import Error", result.exception.getMessage());
}
}
}
protected Keystore getScannedKeystore(ScriptType scriptType) throws ImportException {
if(wallets != null) {
for(Wallet wallet : wallets) {
if(scriptType.equals(wallet.getScriptType()) && !wallet.getKeystores().isEmpty()) {
return wallet.getKeystores().get(0);
}
}
throw new ImportException("Script type " + scriptType + " is not supported");
}
return null;
}
protected abstract void importFile(String fileName, InputStream inputStream, String password) throws ImportException;
private Node getPasswordEntry(File file) {
@@ -14,13 +14,17 @@ public class FileKeystoreImportPane extends FileImportPane {
private final KeystoreFileImport importer;
public FileKeystoreImportPane(Wallet wallet, KeystoreFileImport importer) {
super(importer, importer.getName(), "Keystore import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isScannable());
super(importer, importer.getName(), "Keystore import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isKeystoreImportScannable());
this.wallet = wallet;
this.importer = importer;
}
protected void importFile(String fileName, InputStream inputStream, String password) throws ImportException {
Keystore keystore = importer.getKeystore(wallet.getScriptType(), inputStream, password);
Keystore keystore = getScannedKeystore(wallet.getScriptType());
if(keystore == null) {
keystore = importer.getKeystore(wallet.getScriptType(), inputStream, password);
}
EventManager.get().post(new KeystoreImportEvent(keystore));
}
}
@@ -12,7 +12,7 @@ public class FileWalletImportPane extends FileImportPane {
private final WalletImport importer;
public FileWalletImportPane(WalletImport importer) {
super(importer, importer.getName(), "Wallet import", importer.getWalletImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isScannable());
super(importer, importer.getName(), "Wallet import", importer.getWalletImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isWalletImportScannable());
this.importer = importer;
}
@@ -36,7 +36,7 @@ public class FileWalletKeystoreImportPane extends FileImportPane {
private byte[] fileBytes;
public FileWalletKeystoreImportPane(KeystoreFileImport importer) {
super(importer, importer.getName(), "Wallet import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isScannable());
super(importer, importer.getName(), "Wallet import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isKeystoreImportScannable());
this.importer = importer;
}
@@ -0,0 +1,188 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.net.MempoolRateSize;
import com.sparrowwallet.sparrow.wallet.SendController;
import javafx.application.Platform;
import javafx.beans.NamedArg;
import javafx.collections.FXCollections;
import javafx.geometry.Point2D;
import javafx.scene.Cursor;
import javafx.scene.Node;
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.util.Duration;
import javafx.util.StringConverter;
import org.controlsfx.glyphfont.Glyph;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.*;
import java.util.stream.Collectors;
public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
private static final DateFormat dateFormatter = new SimpleDateFormat("HH:mm");
public static final int MAX_PERIOD_HOURS = 2;
private Tooltip tooltip;
public MempoolSizeFeeRatesChart(@NamedArg("xAxis") Axis<String> xAxis, @NamedArg("yAxis") Axis<Number> yAxis) {
super(xAxis, yAxis);
}
public void initialize() {
setCreateSymbols(false);
setCursor(Cursor.CROSSHAIR);
setVerticalGridLinesVisible(false);
tooltip = new Tooltip();
tooltip.setShowDelay(Duration.ZERO);
tooltip.setHideDelay(Duration.ZERO);
tooltip.setShowDuration(Duration.INDEFINITE);
Platform.runLater(() -> {
Node node = this.lookup(".plot-content");
Tooltip.install(node, tooltip);
});
}
public void update(Map<Date, Set<MempoolRateSize>> mempoolRateSizes) {
getData().clear();
if(tooltip.isShowing()) {
tooltip.hide();
}
Map<Date, Set<MempoolRateSize>> periodRateSizes = getPeriodRateSizes(mempoolRateSizes);
Map<Date, String> categories = getCategories(periodRateSizes);
CategoryAxis categoryAxis = (CategoryAxis)getXAxis();
categoryAxis.setTickMarkVisible(false);
categoryAxis.setTickLabelGap(10);
categoryAxis.setAutoRanging(false);
categoryAxis.setCategories(FXCollections.observableArrayList(categories.values()));
categoryAxis.invalidateRange(new ArrayList<>(categories.values()));
categoryAxis.setGapStartAndEnd(false);
categoryAxis.setTickLabelRotation(0);
NumberAxis numberAxis = (NumberAxis)getYAxis();
numberAxis.setTickLabelFormatter(new StringConverter<Number>() {
@Override
public String toString(Number object) {
long vSizeBytes = object.longValue();
return (vSizeBytes / (1000 * 1000)) + " MvB";
}
@Override
public Number fromString(String string) {
return null;
}
});
this.setOnMouseMoved(mouseEvent -> {
Point2D sceneCoords = this.localToScene(mouseEvent.getX(), mouseEvent.getY());
String category = categoryAxis.getValueForDisplay(categoryAxis.sceneToLocal(sceneCoords).getX());
if(category != null) {
Optional<String> time = categories.entrySet().stream().filter(entry -> entry.getValue().equals(category)).map(entry -> dateFormatter.format(entry.getKey())).findFirst();
time.ifPresent(s -> tooltip.setGraphic(new ChartTooltip(category, s, getData())));
}
});
long previousFeeRate = 0;
for(Long feeRate : SendController.FEE_RATES_RANGE) {
XYChart.Series<String, Number> series = new XYChart.Series<>();
series.setName(feeRate + "+ vB");
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) {
totalVSize += rateSize.getVSize();
}
}
series.getData().add(new XYChart.Data<>(categories.get(date), totalVSize));
seriesTotalVSize += totalVSize;
}
if(seriesTotalVSize > 0) {
getData().add(series);
}
previousFeeRate = feeRate;
}
if(categories.keySet().iterator().hasNext()) {
String time = categories.values().iterator().next();
tooltip.setGraphic(new ChartTooltip(time, time, getData()));
numberAxis.setTickLabelsVisible(true);
numberAxis.setOpacity(1);
} else {
numberAxis.setTickLabelsVisible(false);
numberAxis.setOpacity(0);
}
}
private Map<Date, Set<MempoolRateSize>> getPeriodRateSizes(Map<Date, Set<MempoolRateSize>> mempoolRateSizes) {
if(mempoolRateSizes.size() == 1) {
return mempoolRateSizes;
}
LocalDateTime period = LocalDateTime.now().minusHours(MAX_PERIOD_HOURS);
return mempoolRateSizes.entrySet().stream().filter(entry -> {
LocalDateTime dateTime = entry.getKey().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
return dateTime.isAfter(period);
}).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue,
(u, v) -> { throw new IllegalStateException("Duplicate dates"); },
TreeMap::new));
}
private Map<Date, String> getCategories(Map<Date, Set<MempoolRateSize>> mempoolHistogram) {
Map<Date, String> categories = new LinkedHashMap<>();
String invisible = "" + '\ufeff';
for(Iterator<Date> iter = mempoolHistogram.keySet().iterator(); iter.hasNext(); ) {
Date date = iter.next();
String label = dateFormatter.format(date);
if(!categories.isEmpty() && iter.hasNext()) {
label = invisible;
invisible += '\ufeff';
}
categories.put(date, label);
}
return categories;
}
private static class ChartTooltip extends VBox {
public ChartTooltip(String category, String time, List<Series<String, Number>> seriesList) {
Label title = new Label("At " + time);
HBox titleBox = new HBox(title);
title.setStyle("-fx-alignment: center; -fx-font-size: 12px; -fx-padding: 0 0 5 0;");
getChildren().add(titleBox);
for(int i = seriesList.size() - 1; i >= 0; i--) {
Series<String, Number> series = seriesList.get(i);
for(XYChart.Data<String, Number> data : series.getData()) {
if(data.getXValue().equals(category)) {
double mvb = data.getYValue().doubleValue() / (1000 * 1000);
if(mvb >= 0.01) {
Label label = new Label(series.getName() + ": " + String.format("%.2f", mvb) + " MvB");
Glyph circle = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CIRCLE);
if(i < 8) {
circle.setStyle("-fx-text-fill: CHART_COLOR_" + (i+1));
}
label.setGraphic(circle);
getChildren().add(label);
}
}
}
}
}
}
}
@@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.crypto.ChildNumber;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.KeystoreImportEvent;
@@ -107,7 +108,8 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
importKeystore(wallet.getScriptType().getDefaultDerivation(), false);
});
String[] accounts = new String[] {"Default Account #0", "Account #1", "Account #2", "Account #3", "Account #4", "Account #5", "Account #6", "Account #7", "Account #8", "Account #9"};
for(int i = 0; i < accounts.length; i++) {
int scriptAccountsLength = ScriptType.P2SH.equals(wallet.getScriptType()) ? 1 : accounts.length;
for(int i = 0; i < scriptAccountsLength; i++) {
MenuItem item = new MenuItem(accounts[i]);
final List<ChildNumber> derivation = wallet.getScriptType().getDefaultDerivation(i);
item.setOnAction(event -> {
@@ -5,20 +5,22 @@ import com.google.zxing.client.j2se.MatrixToImageConfig;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import com.sparrowwallet.hummingbird.LegacyUREncoder;
import com.sparrowwallet.hummingbird.registry.RegistryType;
import com.sparrowwallet.sparrow.AppController;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.ImportException;
import com.sparrowwallet.hummingbird.UR;
import com.sparrowwallet.hummingbird.UREncoder;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Task;
import javafx.scene.control.ButtonBar;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Dialog;
import javafx.scene.control.DialogPane;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.StackPane;
import javafx.util.Duration;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.tools.Borders;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -26,28 +28,41 @@ import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
@SuppressWarnings("deprecation")
public class QRDisplayDialog extends Dialog<UR> {
private static final Logger log = LoggerFactory.getLogger(QRDisplayDialog.class);
private static final int MIN_FRAGMENT_LENGTH = 10;
private static final int MAX_FRAGMENT_LENGTH = 100;
private static final int ANIMATION_PERIOD_MILLIS = 400;
private final UR ur;
private final UREncoder encoder;
private final ImageView qrImageView;
private AnimateQRService animateQRService;
private String currentPart;
public QRDisplayDialog(String type, byte[] data) throws UR.URException {
this(UR.fromBytes(type, data));
private boolean useLegacyEncoding;
private String[] legacyParts;
private int legacyPartIndex;
public QRDisplayDialog(String type, byte[] data, boolean addLegacyEncodingOption) throws UR.URException {
this(UR.fromBytes(type, data), addLegacyEncodingOption);
}
public QRDisplayDialog(UR ur) {
this(ur, false);
}
public QRDisplayDialog(UR ur, boolean addLegacyEncodingOption) {
this.ur = ur;
this.encoder = new UREncoder(ur, MAX_FRAGMENT_LENGTH, MIN_FRAGMENT_LENGTH, 0);
final DialogPane dialogPane = getDialogPane();
final DialogPane dialogPane = new QRDisplayDialogPane();
setDialogPane(dialogPane);
AppController.setStageIcon(dialogPane.getScene().getWindow());
StackPane stackPane = new StackPane();
@@ -60,8 +75,8 @@ public class QRDisplayDialog extends Dialog<UR> {
if(encoder.isSinglePart()) {
qrImageView.setImage(getQrCode(currentPart));
} else {
AnimateQRService animateQRService = new AnimateQRService();
animateQRService.setPeriod(Duration.millis(100));
animateQRService = new AnimateQRService();
animateQRService.setPeriod(Duration.millis(ANIMATION_PERIOD_MILLIS));
animateQRService.start();
setOnCloseRequest(event -> {
animateQRService.cancel();
@@ -69,7 +84,13 @@ public class QRDisplayDialog extends Dialog<UR> {
}
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
dialogPane.getButtonTypes().addAll(cancelButtonType);
dialogPane.getButtonTypes().add(cancelButtonType);
if(addLegacyEncodingOption) {
final ButtonType legacyEncodingButtonType = new javafx.scene.control.ButtonType("Use Legacy Encoding (Cobo Vault)", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().add(legacyEncodingButtonType);
}
dialogPane.setPrefWidth(500);
dialogPane.setPrefHeight(550);
@@ -99,8 +120,16 @@ public class QRDisplayDialog extends Dialog<UR> {
}
private void nextPart() {
String fragment = encoder.nextPart();
currentPart = fragment.toUpperCase();
if(!useLegacyEncoding) {
String fragment = encoder.nextPart();
currentPart = fragment.toUpperCase();
} else {
currentPart = legacyParts[legacyPartIndex];
legacyPartIndex++;
if(legacyPartIndex > legacyParts.length - 1) {
legacyPartIndex = 0;
}
}
}
private Image getQrCode(String fragment) {
@@ -120,6 +149,44 @@ public class QRDisplayDialog extends Dialog<UR> {
return null;
}
private void setUseLegacyEncoding(boolean useLegacyEncoding) {
if(useLegacyEncoding) {
try {
//Force to be bytes type for legacy encoding
LegacyUREncoder legacyEncoder = new LegacyUREncoder(new UR(RegistryType.BYTES.toString(), ur.getCborBytes()));
this.legacyParts = legacyEncoder.encode();
this.useLegacyEncoding = true;
if(legacyParts.length == 1) {
if(animateQRService != null) {
animateQRService.cancel();
}
nextPart();
qrImageView.setImage(getQrCode(currentPart));
} else if(!animateQRService.isRunning()) {
animateQRService.reset();
animateQRService.start();
}
} catch(UR.InvalidTypeException e) {
//Can't happen
}
} else {
this.useLegacyEncoding = false;
if(encoder.isSinglePart()) {
if(animateQRService != null) {
animateQRService.cancel();
}
qrImageView.setImage(getQrCode(currentPart));
} else if(!animateQRService.isRunning()) {
animateQRService.reset();
animateQRService.start();
}
}
}
private class AnimateQRService extends ScheduledService<Boolean> {
@Override
protected Task<Boolean> createTask() {
@@ -134,4 +201,44 @@ public class QRDisplayDialog extends Dialog<UR> {
};
}
}
private class QRDisplayDialogPane extends DialogPane {
@Override
protected Node createButton(ButtonType buttonType) {
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) {
ToggleButton legacy = new ToggleButton(buttonType.getText());
legacy.setGraphicTextGap(5);
setLegacyGraphic(legacy, false);
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(legacy, buttonData);
legacy.selectedProperty().addListener((observable, oldValue, newValue) -> {
setUseLegacyEncoding(newValue);
setLegacyGraphic(legacy, newValue);
});
return legacy;
}
return super.createButton(buttonType);
}
private void setLegacyGraphic(ToggleButton legacy, boolean useLegacyEncoding) {
if(useLegacyEncoding) {
legacy.setGraphic(getGlyph(FontAwesome5.Glyph.CHECK_CIRCLE, "success"));
} else {
legacy.setGraphic(getGlyph(FontAwesome5.Glyph.QUESTION_CIRCLE, null));
}
}
private Glyph getGlyph(FontAwesome5.Glyph glyphName, String styleClass) {
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, glyphName);
glyph.setFontSize(12);
if(styleClass != null) {
glyph.getStyleClass().add(styleClass);
}
return glyph;
}
}
}
@@ -2,12 +2,23 @@ package com.sparrowwallet.sparrow.control;
import com.github.sarxos.webcam.WebcamResolution;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.P2PKHAddress;
import com.sparrowwallet.drongo.address.P2SHAddress;
import com.sparrowwallet.drongo.address.P2WPKHAddress;
import com.sparrowwallet.drongo.crypto.*;
import com.sparrowwallet.drongo.protocol.Base43;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.hummingbird.LegacyURDecoder;
import com.sparrowwallet.hummingbird.registry.*;
import com.sparrowwallet.sparrow.AppController;
import com.sparrowwallet.hummingbird.ResultType;
import com.sparrowwallet.hummingbird.UR;
@@ -21,16 +32,24 @@ import javafx.scene.control.Dialog;
import javafx.scene.control.DialogPane;
import javafx.scene.layout.StackPane;
import org.controlsfx.tools.Borders;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.IntStream;
@SuppressWarnings("deprecation")
public class QRScanDialog extends Dialog<QRScanDialog.Result> {
private static final Logger log = LoggerFactory.getLogger(QRScanDialog.class);
private final URDecoder decoder;
private final LegacyURDecoder legacyDecoder;
private final WebcamService webcamService;
private List<String> parts;
@@ -41,6 +60,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
public QRScanDialog() {
this.decoder = new URDecoder();
this.legacyDecoder = new LegacyURDecoder();
this.webcamService = new WebcamService(WebcamResolution.VGA);
WebcamView webcamView = new WebcamView(webcamService);
@@ -83,35 +103,28 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
if(isUr || qrtext.toLowerCase().startsWith(UR.UR_PREFIX)) {
isUr = true;
decoder.receivePart(qrtext);
if(decoder.getResult() != null) {
URDecoder.Result urResult = decoder.getResult();
if(urResult.type == ResultType.SUCCESS) {
//TODO: Confirm once UR type registry is updated
if(urResult.ur.getType().contains(UR.BYTES_TYPE) || urResult.ur.getType().equals(UR.CRYPTO_PSBT_TYPE)) {
try {
PSBT psbt = new PSBT(urResult.ur.toBytes());
result = new Result(psbt);
return;
} catch(Exception e) {
//ignore, bytes not parsable as PSBT
}
if(LegacyURDecoder.isLegacyURFragment(qrtext)) {
legacyDecoder.receivePart(qrtext.toLowerCase());
try {
Transaction transaction = new Transaction(urResult.ur.toBytes());
result = new Result(transaction);
return;
} catch(Exception e) {
//ignore, bytes not parsable as tx
}
result = new Result("Parsed UR of type " + urResult.ur.getType() + " was not a PSBT or transaction");
} else {
result = new Result("Cannot parse UR type of " + urResult.ur.getType());
if(legacyDecoder.isComplete()) {
try {
UR ur = legacyDecoder.decode();
result = extractResultFromUR(ur);
} catch(Exception e) {
result = new Result(new URException(e.getMessage()));
}
}
} else {
decoder.receivePart(qrtext);
if(decoder.getResult() != null) {
URDecoder.Result urResult = decoder.getResult();
if(urResult.type == ResultType.SUCCESS) {
result = extractResultFromUR(urResult.ur);
} else {
result = new Result(new URException(urResult.error));
}
} else {
result = new Result(urResult.error);
}
}
} else if(partMatcher.matches()) {
@@ -143,7 +156,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
//ignore, bytes not parsable as tx
}
result = new Result("Parsed QR parts were not a PSBT or transaction");
result = new Result(new ScanException("Parsed QR parts were not a PSBT or transaction"));
}
} else {
PSBT psbt;
@@ -227,6 +240,189 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
result = new Result(qrtext);
}
}
private Result extractResultFromUR(UR ur) {
try {
RegistryType urRegistryType = ur.getRegistryType();
if(urRegistryType.equals(RegistryType.BYTES)) {
byte[] urBytes = (byte[])ur.decodeFromRegistry();
try {
PSBT psbt = new PSBT(urBytes);
return new Result(psbt);
} catch(Exception e) {
//ignore, bytes not parsable as PSBT
}
try {
Transaction transaction = new Transaction(urBytes);
return new Result(transaction);
} catch(Exception e) {
//ignore, bytes not parsable as tx
}
try {
CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder();
ByteBuffer buf = ByteBuffer.wrap(urBytes);
CharBuffer charBuffer = decoder.decode(buf);
return new Result(charBuffer.toString());
} catch(Exception e) {
//ignore, bytes not parsable as utf-8
}
result = new Result(new URException("Parsed UR of type " + urRegistryType + " was not a PSBT, transaction or UTF-8 text"));
} else if(urRegistryType.equals(RegistryType.CRYPTO_PSBT)) {
CryptoPSBT cryptoPSBT = (CryptoPSBT)ur.decodeFromRegistry();
try {
PSBT psbt = new PSBT(cryptoPSBT.getPsbt());
return new Result(psbt);
} catch(Exception e) {
log.error("Error parsing PSBT from UR type " + urRegistryType, e);
return new Result(new URException("Error parsing PSBT from UR type " + urRegistryType, e));
}
} else if(urRegistryType.equals(RegistryType.CRYPTO_ADDRESS)) {
CryptoAddress cryptoAddress = (CryptoAddress)ur.decodeFromRegistry();
Address address = getAddress(cryptoAddress);
if(address != null) {
return new Result(BitcoinURI.fromAddress(address));
} else {
return new Result(new URException("Unknown " + urRegistryType + " type of " + cryptoAddress.getType()));
}
} else if(urRegistryType.equals(RegistryType.CRYPTO_HDKEY)) {
CryptoHDKey cryptoHDKey = (CryptoHDKey)ur.decodeFromRegistry();
ExtendedKey extendedKey = getExtendedKey(cryptoHDKey);
return new Result(extendedKey);
} else if(urRegistryType.equals(RegistryType.CRYPTO_OUTPUT)) {
CryptoOutput cryptoOutput = (CryptoOutput)ur.decodeFromRegistry();
OutputDescriptor outputDescriptor = getOutputDescriptor(cryptoOutput);
return new Result(outputDescriptor);
} else if(urRegistryType.equals(RegistryType.CRYPTO_ACCOUNT)) {
CryptoAccount cryptoAccount = (CryptoAccount)ur.decodeFromRegistry();
List<Wallet> wallets = getWallets(cryptoAccount);
return new Result(wallets);
} else {
log.error("Unsupported UR type " + urRegistryType);
return new Result(new URException("UR type " + urRegistryType + " is not supported"));
}
} catch(IllegalArgumentException e) {
log.error("Unknown UR type of " + ur.getType(), e);
return new Result(new URException("Unknown UR type of " + ur.getType(), e));
} catch(UR.InvalidCBORException e) {
log.error("Invalid CBOR in UR", e);
return new Result(new URException("Invalid CBOR in UR", e));
} catch(Exception e) {
log.error("Error parsing UR CBOR", e);
return new Result(new URException("Error parsing UR CBOR", e));
}
return null;
}
private Address getAddress(CryptoAddress cryptoAddress) {
Address address = null;
if(cryptoAddress.getType() == null || cryptoAddress.getType() == CryptoAddress.Type.P2PKH) {
address = new P2PKHAddress(cryptoAddress.getData());
} else if(cryptoAddress.getType() == CryptoAddress.Type.P2SH) {
address = new P2SHAddress(cryptoAddress.getData());
} else if(cryptoAddress.getType() == CryptoAddress.Type.P2WPKH) {
address = new P2WPKHAddress(cryptoAddress.getData());
}
return address;
}
private ExtendedKey getExtendedKey(CryptoHDKey cryptoHDKey) {
if(cryptoHDKey.isPrivateKey()) {
DeterministicKey prvKey = HDKeyDerivation.createMasterPrivKeyFromBytes(Arrays.copyOfRange(cryptoHDKey.getKey(), 1, 33), cryptoHDKey.getChainCode(), List.of(ChildNumber.ZERO));
return new ExtendedKey(prvKey, new byte[4], ChildNumber.ZERO);
} else {
ChildNumber lastChild = ChildNumber.ZERO;
int depth = 1;
byte[] parentFingerprint = new byte[4];
if(cryptoHDKey.getOrigin() != null) {
if(!cryptoHDKey.getOrigin().getComponents().isEmpty()) {
PathComponent lastComponent = cryptoHDKey.getOrigin().getComponents().get(cryptoHDKey.getOrigin().getComponents().size() - 1);
lastChild = new ChildNumber(lastComponent.getIndex(), lastComponent.isHardened());
depth = cryptoHDKey.getOrigin().getComponents().size();
}
if(cryptoHDKey.getParentFingerprint() != null) {
parentFingerprint = cryptoHDKey.getParentFingerprint();
}
}
DeterministicKey pubKey = new DeterministicKey(List.of(lastChild), cryptoHDKey.getChainCode(), cryptoHDKey.getKey(), depth, parentFingerprint);
return new ExtendedKey(pubKey, parentFingerprint, lastChild);
}
}
private OutputDescriptor getOutputDescriptor(CryptoOutput cryptoOutput) {
ScriptType scriptType = getScriptType(cryptoOutput.getScriptExpressions());
if(cryptoOutput.getMultiKey() != null) {
MultiKey multiKey = cryptoOutput.getMultiKey();
Map<ExtendedKey, KeyDerivation> extendedPublicKeys = new LinkedHashMap<>();
for(CryptoHDKey cryptoHDKey : multiKey.getHdKeys()) {
ExtendedKey extendedKey = getExtendedKey(cryptoHDKey);
KeyDerivation keyDerivation = getKeyDerivation(cryptoHDKey.getOrigin());
extendedPublicKeys.put(extendedKey, keyDerivation);
}
return new OutputDescriptor(scriptType, multiKey.getThreshold(), extendedPublicKeys);
} else if(cryptoOutput.getEcKey() != null) {
throw new IllegalArgumentException("EC keys are currently unsupported");
} else if(cryptoOutput.getHdKey() != null) {
ExtendedKey extendedKey = getExtendedKey(cryptoOutput.getHdKey());
KeyDerivation keyDerivation = getKeyDerivation(cryptoOutput.getHdKey().getOrigin());
return new OutputDescriptor(scriptType, extendedKey, keyDerivation);
}
throw new IllegalStateException("CryptoOutput did not contain sufficient information");
}
private List<Wallet> getWallets(CryptoAccount cryptoAccount) {
List<Wallet> wallets = new ArrayList<>();
String masterFingerprint = Utils.bytesToHex(cryptoAccount.getMasterFingerprint());
for(CryptoOutput cryptoOutput : cryptoAccount.getOutputDescriptors()) {
Wallet wallet = new Wallet();
OutputDescriptor outputDescriptor = getOutputDescriptor(cryptoOutput);
if(outputDescriptor.isMultisig()) {
throw new IllegalStateException("Multisig output descriptors are unsupported in CryptoAccount");
}
ExtendedKey extendedKey = outputDescriptor.getSingletonExtendedPublicKey();
wallet.setScriptType(outputDescriptor.getScriptType());
Keystore keystore = new Keystore();
keystore.setKeyDerivation(new KeyDerivation(masterFingerprint, outputDescriptor.getKeyDerivation(extendedKey).getDerivationPath()));
keystore.setExtendedPublicKey(extendedKey);
wallet.getKeystores().add(keystore);
wallets.add(wallet);
}
return wallets;
}
private ScriptType getScriptType(List<ScriptExpression> expressions) {
if(List.of(ScriptExpression.PUBLIC_KEY_HASH).equals(expressions)) {
return ScriptType.P2PKH;
} else if(List.of(ScriptExpression.SCRIPT_HASH, ScriptExpression.WITNESS_PUBLIC_KEY_HASH).equals(expressions)) {
return ScriptType.P2SH_P2WPKH;
} else if(List.of(ScriptExpression.WITNESS_PUBLIC_KEY_HASH).equals(expressions)) {
return ScriptType.P2WPKH;
} else if(List.of(ScriptExpression.SCRIPT_HASH).equals(expressions)) {
return ScriptType.P2SH;
} else if(List.of(ScriptExpression.SCRIPT_HASH, ScriptExpression.WITNESS_SCRIPT_HASH).equals(expressions)) {
return ScriptType.P2SH_P2WSH;
} else if(List.of(ScriptExpression.WITNESS_SCRIPT_HASH).equals(expressions)) {
return ScriptType.P2WSH;
}
throw new IllegalArgumentException("Unknown script of " + expressions);
}
private KeyDerivation getKeyDerivation(CryptoKeypath cryptoKeypath) {
if(cryptoKeypath != null) {
return new KeyDerivation(Utils.bytesToHex(cryptoKeypath.getSourceFingerprint()), cryptoKeypath.getPath());
}
return null;
}
}
public static class Result {
@@ -234,6 +430,8 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
public final PSBT psbt;
public final BitcoinURI uri;
public final ExtendedKey extendedKey;
public final OutputDescriptor outputDescriptor;
public final List<Wallet> wallets;
public final String payload;
public final Throwable exception;
@@ -242,6 +440,8 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null;
this.uri = null;
this.extendedKey = null;
this.outputDescriptor = null;
this.wallets = null;
this.payload = null;
this.exception = null;
}
@@ -251,6 +451,8 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = psbt;
this.uri = null;
this.extendedKey = null;
this.outputDescriptor = null;
this.wallets = null;
this.payload = null;
this.exception = null;
}
@@ -260,6 +462,8 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null;
this.uri = uri;
this.extendedKey = null;
this.outputDescriptor = null;
this.wallets = null;
this.payload = null;
this.exception = null;
}
@@ -269,6 +473,8 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null;
this.uri = BitcoinURI.fromAddress(address);
this.extendedKey = null;
this.outputDescriptor = null;
this.wallets = null;
this.payload = null;
this.exception = null;
}
@@ -278,6 +484,30 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null;
this.uri = null;
this.extendedKey = extendedKey;
this.outputDescriptor = null;
this.wallets = null;
this.payload = null;
this.exception = null;
}
public Result(OutputDescriptor outputDescriptor) {
this.transaction = null;
this.psbt = null;
this.uri = null;
this.extendedKey = null;
this.outputDescriptor = outputDescriptor;
this.wallets = null;
this.payload = null;
this.exception = null;
}
public Result(List<Wallet> wallets) {
this.transaction = null;
this.psbt = null;
this.uri = null;
this.extendedKey = null;
this.outputDescriptor = null;
this.wallets = wallets;
this.payload = null;
this.exception = null;
}
@@ -287,6 +517,8 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null;
this.uri = null;
this.extendedKey = null;
this.outputDescriptor = null;
this.wallets = null;
this.payload = payload;
this.exception = null;
}
@@ -296,8 +528,46 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null;
this.uri = null;
this.extendedKey = null;
this.outputDescriptor = null;
this.wallets = null;
this.payload = null;
this.exception = exception;
}
}
public static class ScanException extends Exception {
public ScanException() {
super();
}
public ScanException(String message) {
super(message);
}
public ScanException(Throwable cause) {
super(cause);
}
public ScanException(String message, Throwable cause) {
super(message, cause);
}
}
public static class URException extends ScanException {
public URException() {
super();
}
public URException(String message) {
super(message);
}
public URException(Throwable cause) {
super(cause);
}
public URException(String message, Throwable cause) {
super(message, cause);
}
}
}
@@ -1,11 +1,13 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.Payment;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.drongo.wallet.WalletTransaction;
import com.sparrowwallet.sparrow.AppController;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.ExcludeUtxoEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
@@ -74,6 +76,11 @@ public class TransactionDiagram extends GridPane {
private Map<BlockTransactionHashIndex, WalletNode> getDisplayedUtxos() {
Map<BlockTransactionHashIndex, WalletNode> selectedUtxos = walletTx.getSelectedUtxos();
if(getPayjoinURI() != null) {
selectedUtxos = new LinkedHashMap<>(selectedUtxos);
selectedUtxos.put(new PayjoinBlockTransactionHashIndex(), null);
}
if(selectedUtxos.size() > MAX_UTXOS) {
Map<BlockTransactionHashIndex, WalletNode> utxos = new LinkedHashMap<>();
List<BlockTransactionHashIndex> additional = new ArrayList<>();
@@ -92,6 +99,22 @@ public class TransactionDiagram extends GridPane {
}
}
private BitcoinURI getPayjoinURI() {
for(Payment payment : walletTx.getPayments()) {
try {
Address address = payment.getAddress();
BitcoinURI bitcoinURI = AppController.getPayjoinURI(address);
if(bitcoinURI != null) {
return bitcoinURI;
}
} catch(Exception e) {
//ignore
}
}
return null;
}
private Pane getInputsType(Map<BlockTransactionHashIndex, WalletNode> displayedUtxos) {
StackPane stackPane = new StackPane();
@@ -166,9 +189,6 @@ public class TransactionDiagram extends GridPane {
EventManager.get().post(new ExcludeUtxoEvent(walletTx, input));
});
label.setGraphic(excludeUtxoButton);
label.setContentDisplay(ContentDisplay.LEFT);
Tooltip tooltip = new Tooltip();
if(walletNode != null) {
tooltip.setText("Spending " + getSatsValue(input.getValue()) + " sats from " + walletNode.getDerivationPath().replace("m", "..") + "\n" + input.getHashAsString() + ":" + input.getIndex() + "\n" + walletTx.getWallet().getAddress(walletNode));
@@ -177,13 +197,20 @@ public class TransactionDiagram extends GridPane {
if(input.getLabel() == null || input.getLabel().isEmpty()) {
label.getStyleClass().add("input-label");
}
label.setGraphic(excludeUtxoButton);
label.setContentDisplay(ContentDisplay.LEFT);
} else {
AdditionalBlockTransactionHashIndex additionalReference = (AdditionalBlockTransactionHashIndex)input;
StringJoiner joiner = new StringJoiner("\n");
for(BlockTransactionHashIndex additionalInput : additionalReference.getAdditionalInputs()) {
joiner.add(getInputDescription(additionalInput));
if(input instanceof PayjoinBlockTransactionHashIndex) {
tooltip.setText("Added once transaction is signed and sent to the payjoin server");
} else {
AdditionalBlockTransactionHashIndex additionalReference = (AdditionalBlockTransactionHashIndex) input;
StringJoiner joiner = new StringJoiner("\n");
for(BlockTransactionHashIndex additionalInput : additionalReference.getAdditionalInputs()) {
joiner.add(getInputDescription(additionalInput));
}
tooltip.setText(joiner.toString());
}
tooltip.setText(joiner.toString());
tooltip.getStyleClass().add("input-label");
}
tooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
@@ -218,11 +245,16 @@ public class TransactionDiagram extends GridPane {
group.getChildren().add(yaxisLine);
double width = 140.0;
List<BlockTransactionHashIndex> inputs = new ArrayList<>(displayedUtxos.keySet());
int numUtxos = displayedUtxos.size();
for(int i = 1; i <= numUtxos; i++) {
CubicCurve curve = new CubicCurve();
curve.getStyleClass().add("input-line");
if(inputs.get(numUtxos-i) instanceof PayjoinBlockTransactionHashIndex) {
curve.getStyleClass().add("input-dashed-line");
}
curve.setStartX(0);
double scaleFactor = (double)i / (numUtxos + 1);
int nodeHeight = 17;
@@ -399,6 +431,17 @@ public class TransactionDiagram extends GridPane {
return consolidationGlyph;
}
public static Glyph getTxoGlyph() {
return getChangeGlyph();
}
public static Glyph getPayjoinGlyph() {
Glyph payjoinGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.RANDOM);
payjoinGlyph.getStyleClass().add("payjoin-icon");
payjoinGlyph.setFontSize(12);
return payjoinGlyph;
}
public static Glyph getChangeGlyph() {
Glyph changeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.COINS);
changeGlyph.getStyleClass().add("change-icon");
@@ -427,6 +470,17 @@ public class TransactionDiagram extends GridPane {
return lockGlyph;
}
private static class PayjoinBlockTransactionHashIndex extends BlockTransactionHashIndex {
public PayjoinBlockTransactionHashIndex() {
super(Sha256Hash.ZERO_HASH, 0, new Date(), 0L, 0, 0);
}
@Override
public String getLabel() {
return "Payjoin input";
}
}
private static class AdditionalBlockTransactionHashIndex extends BlockTransactionHashIndex {
private final List<BlockTransactionHashIndex> additionalInputs;
@@ -39,7 +39,7 @@ public class UtxosChart extends BarChart<String, Number> {
public void update(WalletUtxosEntry walletUtxosEntry) {
List<Data<String, Number>> utxoDataList = walletUtxosEntry.getChildren().stream()
.map(entry -> new XYChart.Data<>(getCategoryName(entry), (Number)entry.getValue(), entry))
.sorted((o1, o2) -> (int) (o2.getYValue().longValue() - o1.getYValue().longValue()))
.sorted((o1, o2) -> Long.compare(o2.getYValue().longValue(), o1.getYValue().longValue()))
.collect(Collectors.toList());
totalUtxos = utxoDataList.size();
@@ -1,9 +1,11 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.protocol.BlockHeader;
import com.sparrowwallet.sparrow.net.MempoolRateSize;
import java.util.List;
import java.util.Map;
import java.util.Set;
public class ConnectionEvent extends FeeRatesUpdatedEvent {
private final List<String> serverVersion;
@@ -12,8 +14,8 @@ public class ConnectionEvent extends FeeRatesUpdatedEvent {
private final BlockHeader blockHeader;
private final Double minimumRelayFeeRate;
public ConnectionEvent(List<String> serverVersion, String serverBanner, int blockHeight, BlockHeader blockHeader, Map<Integer, Double> targetBlockFeeRates, Double minimumRelayFeeRate) {
super(targetBlockFeeRates);
public ConnectionEvent(List<String> serverVersion, String serverBanner, int blockHeight, BlockHeader blockHeader, Map<Integer, Double> targetBlockFeeRates, Set<MempoolRateSize> mempoolRateSizes, Double minimumRelayFeeRate) {
super(targetBlockFeeRates, mempoolRateSizes);
this.serverVersion = serverVersion;
this.serverBanner = serverBanner;
this.blockHeight = blockHeight;
@@ -0,0 +1,15 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.sparrow.wallet.FeeRatesSelection;
public class FeeRatesSelectionChangedEvent {
private final FeeRatesSelection feeRatesSelection;
public FeeRatesSelectionChangedEvent(FeeRatesSelection feeRatesSelection) {
this.feeRatesSelection = feeRatesSelection;
}
public FeeRatesSelection getFeeRateSelection() {
return feeRatesSelection;
}
}
@@ -0,0 +1,15 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.sparrow.net.FeeRatesSource;
public class FeeRatesSourceChangedEvent {
private final FeeRatesSource feeRatesSource;
public FeeRatesSourceChangedEvent(FeeRatesSource feeRatesSource) {
this.feeRatesSource = feeRatesSource;
}
public FeeRatesSource getFeeRateSource() {
return feeRatesSource;
}
}
@@ -1,15 +1,24 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.sparrow.net.MempoolRateSize;
import java.util.Map;
import java.util.Set;
public class FeeRatesUpdatedEvent {
private final Map<Integer, Double> targetBlockFeeRates;
private final Set<MempoolRateSize> mempoolRateSizes;
public FeeRatesUpdatedEvent(Map<Integer, Double> targetBlockFeeRates) {
public FeeRatesUpdatedEvent(Map<Integer, Double> targetBlockFeeRates, Set<MempoolRateSize> mempoolRateSizes) {
this.targetBlockFeeRates = targetBlockFeeRates;
this.mempoolRateSizes = mempoolRateSizes;
}
public Map<Integer, Double> getTargetBlockFeeRates() {
return targetBlockFeeRates;
}
public Set<MempoolRateSize> getMempoolRateSizes() {
return mempoolRateSizes;
}
}
@@ -1,6 +1,6 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.sparrow.io.ExchangeSource;
import com.sparrowwallet.sparrow.net.ExchangeSource;
import java.util.Currency;
@@ -1,17 +1,24 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.sparrow.wallet.HashIndexEntry;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.Wallet;
import java.util.List;
public class SendActionEvent {
private final List<HashIndexEntry> utxoEntries;
private final Wallet wallet;
private final List<BlockTransactionHashIndex> utxos;
public SendActionEvent(List<HashIndexEntry> utxoEntries) {
this.utxoEntries = utxoEntries;
public SendActionEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos) {
this.wallet = wallet;
this.utxos = utxos;
}
public List<HashIndexEntry> getUtxoEntries() {
return utxoEntries;
public Wallet getWallet() {
return wallet;
}
public List<BlockTransactionHashIndex> getUtxos() {
return utxos;
}
}
@@ -1,17 +1,47 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.sparrow.wallet.HashIndexEntry;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.Payment;
import com.sparrowwallet.drongo.wallet.Wallet;
import java.util.List;
public class SpendUtxoEvent {
private final List<HashIndexEntry> utxoEntries;
private final Wallet wallet;
private final List<BlockTransactionHashIndex> utxos;
private final List<Payment> payments;
private final Long fee;
private final boolean includeMempoolInputs;
public SpendUtxoEvent(List<HashIndexEntry> utxoEntries) {
this.utxoEntries = utxoEntries;
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos) {
this(wallet, utxos, null, null, false);
}
public List<HashIndexEntry> getUtxoEntries() {
return utxoEntries;
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos, List<Payment> payments, Long fee, boolean includeMempoolInputs) {
this.wallet = wallet;
this.utxos = utxos;
this.payments = payments;
this.fee = fee;
this.includeMempoolInputs = includeMempoolInputs;
}
public Wallet getWallet() {
return wallet;
}
public List<BlockTransactionHashIndex> getUtxos() {
return utxos;
}
public List<Payment> getPayments() {
return payments;
}
public Long getFee() {
return fee;
}
public boolean isIncludeMempoolInputs() {
return includeMempoolInputs;
}
}
@@ -1,28 +1,38 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
public class WalletHistoryStatusEvent {
private final Wallet wallet;
private final boolean loaded;
private final String statusMessage;
private final String errorMessage;
public WalletHistoryStatusEvent(boolean loaded) {
public WalletHistoryStatusEvent(Wallet wallet, boolean loaded) {
this.wallet = wallet;
this.loaded = loaded;
this.statusMessage = null;
this.errorMessage = null;
}
public WalletHistoryStatusEvent(boolean loaded, String statusMessage) {
public WalletHistoryStatusEvent(Wallet wallet,boolean loaded, String statusMessage) {
this.wallet = wallet;
this.loaded = false;
this.statusMessage = statusMessage;
this.errorMessage = null;
}
public WalletHistoryStatusEvent(String errorMessage) {
public WalletHistoryStatusEvent(Wallet wallet,String errorMessage) {
this.wallet = wallet;
this.loaded = false;
this.statusMessage = null;
this.errorMessage = errorMessage;
}
public Wallet getWallet() {
return wallet;
}
public boolean isLoading() {
return !loaded;
}
@@ -30,6 +30,7 @@ public class FontAwesome5 extends GlyphFont {
ELLIPSIS_H('\uf141'),
EYE('\uf06e'),
HAND_HOLDING('\uf4bd'),
HAND_HOLDING_MEDICAL('\ue05c'),
KEY('\uf084'),
LAPTOP('\uf109'),
LOCK('\uf023'),
@@ -38,6 +39,7 @@ public class FontAwesome5 extends GlyphFont {
PLUS('\uf067'),
QRCODE('\uf029'),
QUESTION_CIRCLE('\uf059'),
RANDOM('\uf074'),
REPLY_ALL('\uf122'),
SATELLITE_DISH('\uf7c0'),
SD_CARD('\uf7c2'),
@@ -29,7 +29,7 @@ public class CoboVaultMultisig extends ColdcardMultisig {
@Override
public String getKeystoreImportDescription() {
return "Import file or QR created by using the Multisig Wallet > Show/Export XPUB > Export All > Export feature on your Cobo Vault.";
return "Import file or QR created by using the Multisig Wallet > Show/Export XPUB feature on your Cobo Vault.";
}
@Override
@@ -54,7 +54,12 @@ public class CoboVaultMultisig extends ColdcardMultisig {
}
@Override
public boolean isScannable() {
public boolean isWalletImportScannable() {
return true;
}
@Override
public boolean isKeystoreImportScannable() {
return true;
}
}
@@ -6,10 +6,7 @@ import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.policy.Policy;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.drongo.wallet.*;
import java.io.File;
import java.io.InputStream;
@@ -23,7 +20,7 @@ public class CoboVaultSinglesig implements KeystoreFileImport, WalletImport {
@Override
public String getKeystoreImportDescription() {
return "Import file or QR created by using the Watch-Only Wallet > Generic Wallet > Export Wallet feature on your Cobo Vault.";
return "Import file or QR created by using the Settings > Watch-Only Wallet > Generic Wallet > Export Wallet feature on your Cobo Vault.";
}
@Override
@@ -75,8 +72,10 @@ public class CoboVaultSinglesig implements KeystoreFileImport, WalletImport {
wallet.getKeystores().add(keystore);
wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2WPKH, wallet.getKeystores(), null));
if(!wallet.isValid()) {
throw new ImportException("Wallet is in an inconsistent state.");
try {
wallet.checkWallet();
} catch(InvalidWalletException e) {
throw new ImportException("Imported Cobo Vault wallet was invalid: " + e.getMessage());
}
return wallet;
@@ -88,7 +87,12 @@ public class CoboVaultSinglesig implements KeystoreFileImport, WalletImport {
}
@Override
public boolean isScannable() {
public boolean isWalletImportScannable() {
return true;
}
@Override
public boolean isKeystoreImportScannable() {
return true;
}
@@ -88,7 +88,7 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
wallet.setPolicyType(PolicyType.MULTI);
int threshold = 2;
ScriptType scriptType = null;
ScriptType scriptType = ScriptType.P2SH;
String derivation = null;
try {
@@ -211,7 +211,12 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
}
@Override
public boolean isScannable() {
public boolean isWalletImportScannable() {
return false;
}
@Override
public boolean isKeystoreImportScannable() {
return false;
}
}
@@ -8,10 +8,7 @@ import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.policy.Policy;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.drongo.wallet.*;
import java.io.File;
import java.io.InputStream;
@@ -41,7 +38,12 @@ public class ColdcardSinglesig implements KeystoreFileImport, WalletImport {
}
@Override
public boolean isScannable() {
public boolean isWalletImportScannable() {
return false;
}
@Override
public boolean isKeystoreImportScannable() {
return false;
}
@@ -101,8 +103,10 @@ public class ColdcardSinglesig implements KeystoreFileImport, WalletImport {
wallet.getKeystores().add(keystore);
wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2WPKH, wallet.getKeystores(), null));
if(!wallet.isValid()) {
throw new ImportException("Wallet is in an inconsistent state.");
try {
wallet.checkWallet();
} catch(InvalidWalletException e) {
throw new ImportException("Imported Coldcard wallet was invalid: " + e.getMessage());
}
return wallet;
@@ -4,6 +4,9 @@ import com.google.gson.*;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.sparrow.Mode;
import com.sparrowwallet.sparrow.Theme;
import com.sparrowwallet.sparrow.net.ExchangeSource;
import com.sparrowwallet.sparrow.net.FeeRatesSource;
import com.sparrowwallet.sparrow.wallet.FeeRatesSelection;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -19,6 +22,8 @@ public class Config {
private Mode mode;
private BitcoinUnit bitcoinUnit;
private FeeRatesSource feeRatesSource;
private FeeRatesSelection feeRatesSelection;
private Currency fiatCurrency;
private ExchangeSource exchangeSource;
private boolean groupByAddress = true;
@@ -98,6 +103,24 @@ public class Config {
flush();
}
public FeeRatesSource getFeeRatesSource() {
return feeRatesSource;
}
public void setFeeRatesSource(FeeRatesSource feeRatesSource) {
this.feeRatesSource = feeRatesSource;
flush();
}
public FeeRatesSelection getFeeRatesSelection() {
return feeRatesSelection;
}
public void setFeeRatesSelection(FeeRatesSelection feeRatesSelection) {
this.feeRatesSelection = feeRatesSelection;
flush();
}
public Currency getFiatCurrency() {
return fiatCurrency;
}
@@ -11,6 +11,7 @@ public class Device {
private Boolean needsPinSent;
private Boolean needsPassphraseSent;
private String fingerprint;
private String error;
public String getType() {
return type;
@@ -60,6 +61,14 @@ public class Device {
this.fingerprint = fingerprint;
}
public String getError() {
return error;
}
public void setError(String error) {
this.error = error;
}
public String toString() {
return getModel() + ":" + getPath();
}
@@ -138,6 +138,9 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
if(keystore.getWalletModel() == null) {
throw new ImportException("Wallet has keystore of unknown hardware wallet type \"" + ek.hw_type + "\".");
}
if(keystore.getWalletModel().equals(WalletModel.TREZOR_1)) {
keystore.setWalletModel(WalletModel.TREZOR_T);
}
} else if("bip32".equals(ek.type)) {
if(ek.xprv != null && ek.seed == null) {
throw new ImportException("Electrum does not support exporting BIP39 derived seeds, as it does not store the mnemonic words. Only seeds created with its native Electrum Seed Version System are exportable. " +
@@ -198,8 +201,10 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
wallet.updateTransactions(ew.transactions);
if(!wallet.isValid()) {
throw new IllegalStateException("Electrum wallet is in an inconsistent state.");
try {
wallet.checkWallet();
} catch(InvalidWalletException e) {
throw new IllegalStateException("Imported Electrum wallet was invalid: " + e.getMessage());
}
return wallet;
@@ -318,7 +323,12 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
}
@Override
public boolean isScannable() {
public boolean isWalletImportScannable() {
return false;
}
@Override
public boolean isKeystoreImportScannable() {
return false;
}
@@ -4,5 +4,4 @@ import java.io.File;
public interface FileImport extends Import {
boolean isEncrypted(File file);
boolean isScannable();
}
@@ -29,7 +29,7 @@ import java.util.zip.ZipInputStream;
public class Hwi {
private static final Logger log = LoggerFactory.getLogger(Hwi.class);
private static final String TEMP_FILE_PREFIX = "hwi-1.2.0-";
private static final String TEMP_FILE_PREFIX = "hwi-1.2.1-";
private static boolean isPromptActive = false;
@@ -84,7 +84,7 @@ public class Hwi {
if(result.get("xpub") != null) {
return result.get("xpub").getAsString();
} else {
throw new ImportException("Could not retrieve xpub");
throw new ImportException("Could not retrieve xpub - reconnect your device and try again.");
}
} catch(IOException e) {
throw new ImportException(e);
@@ -218,7 +218,7 @@ public class Hwi {
//The check will still happen on first invocation, but will not thereafter
//See https://github.com/bitcoin-core/HWI/issues/327 for details
if(platform == Platform.OSX) {
InputStream inputStream = Hwi.class.getResourceAsStream("/native/osx/x64/hwi-1.2.0-mac-amd64-signed.zip");
InputStream inputStream = Hwi.class.getResourceAsStream("/native/osx/x64/hwi-1.2.1-mac-amd64-signed.zip");
Path tempHwiDirPath = Files.createTempDirectory(TEMP_FILE_PREFIX, PosixFilePermissions.asFileAttribute(ownerExecutableWritable));
File tempHwiDir = tempHwiDirPath.toFile();
//tempHwiDir.deleteOnExit();
@@ -535,7 +535,17 @@ public class Hwi {
private static class DeviceModelDeserializer implements JsonDeserializer<WalletModel> {
@Override
public WalletModel deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
return WalletModel.valueOf(json.getAsJsonPrimitive().getAsString().toUpperCase());
String modelStr = json.getAsJsonPrimitive().getAsString();
try {
return WalletModel.valueOf(modelStr.toUpperCase());
} catch(Exception e) {
for(WalletModel model : WalletModel.values()) {
if(modelStr.startsWith(model.getType())) {
return model;
}
}
}
throw new IllegalArgumentException("Could not determine wallet model for " + modelStr);
}
}
@@ -3,9 +3,9 @@ package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import java.io.File;
import java.io.InputStream;
public interface KeystoreFileImport extends KeystoreImport, FileImport {
Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException;
boolean isKeystoreImportScannable();
}
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.io;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.wallet.InvalidWalletException;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
@@ -57,8 +58,10 @@ public class Specter implements WalletImport, WalletExport {
Wallet wallet = outputDescriptor.toWallet();
wallet.setName(specterWallet.label);
if(!wallet.isValid()) {
throw new ImportException("Specter wallet file did not contain a valid wallet");
try {
wallet.checkWallet();
} catch(InvalidWalletException e) {
throw new ImportException("Imported Specter wallet was invalid: " + e.getMessage());
}
return wallet;
@@ -76,7 +79,7 @@ public class Specter implements WalletImport, WalletExport {
}
@Override
public boolean isScannable() {
public boolean isWalletImportScannable() {
return true;
}
@@ -2,10 +2,10 @@ package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.wallet.Wallet;
import java.io.File;
import java.io.InputStream;
public interface WalletImport extends Import, FileImport {
String getWalletImportDescription();
Wallet importWallet(InputStream inputStream, String password) throws ImportException;
boolean isWalletImportScannable();
}
@@ -6,6 +6,7 @@ import com.github.arteam.simplejsonrpc.client.builder.BatchRequestBuilder;
import com.github.arteam.simplejsonrpc.client.exception.JsonRpcBatchException;
import com.github.arteam.simplejsonrpc.client.exception.JsonRpcException;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletHistoryStatusEvent;
import org.slf4j.Logger;
@@ -14,6 +15,7 @@ import org.slf4j.LoggerFactory;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.concurrent.atomic.AtomicLong;
public class BatchedElectrumServerRpc implements ElectrumServerRpc {
@@ -69,10 +71,10 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
@Override
@SuppressWarnings("unchecked")
public Map<String, ScriptHashTx[]> getScriptHashHistory(Transport transport, Map<String, String> pathScriptHashes, boolean failOnError) {
public Map<String, ScriptHashTx[]> getScriptHashHistory(Transport transport, Wallet wallet, Map<String, String> pathScriptHashes, boolean failOnError) {
JsonRpcClient client = new JsonRpcClient(transport);
BatchRequestBuilder<String, ScriptHashTx[]> batchRequest = client.createBatchRequest().keysType(String.class).returnType(ScriptHashTx[].class);
EventManager.get().post(new WalletHistoryStatusEvent(false, "Loading transactions"));
EventManager.get().post(new WalletHistoryStatusEvent(wallet, false, "Loading transactions"));
for(String path : pathScriptHashes.keySet()) {
batchRequest.add(path, "blockchain.scripthash.get_history", pathScriptHashes.get(path));
@@ -98,7 +100,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
@Override
@SuppressWarnings("unchecked")
public Map<String, ScriptHashTx[]> getScriptHashMempool(Transport transport, Map<String, String> pathScriptHashes, boolean failOnError) {
public Map<String, ScriptHashTx[]> getScriptHashMempool(Transport transport, Wallet wallet, Map<String, String> pathScriptHashes, boolean failOnError) {
JsonRpcClient client = new JsonRpcClient(transport);
BatchRequestBuilder<String, ScriptHashTx[]> batchRequest = client.createBatchRequest().keysType(String.class).returnType(ScriptHashTx[].class);
@@ -125,10 +127,10 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
}
@Override
public Map<String, String> subscribeScriptHashes(Transport transport, Map<String, String> pathScriptHashes) {
public Map<String, String> subscribeScriptHashes(Transport transport, Wallet wallet, Map<String, String> pathScriptHashes) {
JsonRpcClient client = new JsonRpcClient(transport);
BatchRequestBuilder<String, String> batchRequest = client.createBatchRequest().keysType(String.class).returnType(String.class);
EventManager.get().post(new WalletHistoryStatusEvent(false, "Finding transactions"));
EventManager.get().post(new WalletHistoryStatusEvent(wallet, false, "Finding transactions"));
for(String path : pathScriptHashes.keySet()) {
batchRequest.add(path, "blockchain.scripthash.subscribe", pathScriptHashes.get(path));
@@ -146,10 +148,10 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
@Override
@SuppressWarnings("unchecked")
public Map<Integer, String> getBlockHeaders(Transport transport, Set<Integer> blockHeights) {
public Map<Integer, String> getBlockHeaders(Transport transport, Wallet wallet, Set<Integer> blockHeights) {
JsonRpcClient client = new JsonRpcClient(transport);
BatchRequestBuilder<Integer, String> batchRequest = client.createBatchRequest().keysType(Integer.class).returnType(String.class);
EventManager.get().post(new WalletHistoryStatusEvent(false, "Retrieving blocks"));
EventManager.get().post(new WalletHistoryStatusEvent(wallet, false, "Retrieving blocks"));
for(Integer height : blockHeights) {
batchRequest.add(height, "blockchain.block.header", height);
@@ -166,10 +168,10 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
@Override
@SuppressWarnings("unchecked")
public Map<String, String> getTransactions(Transport transport, Set<String> txids) {
public Map<String, String> getTransactions(Transport transport, Wallet wallet, Set<String> txids) {
JsonRpcClient client = new JsonRpcClient(transport);
BatchRequestBuilder<String, String> batchRequest = client.createBatchRequest().keysType(String.class).returnType(String.class);
EventManager.get().post(new WalletHistoryStatusEvent(false, "Retrieving transactions"));
EventManager.get().post(new WalletHistoryStatusEvent(wallet, false, "Retrieving transactions"));
for(String txid : txids) {
batchRequest.add(txid, "blockchain.transaction.get", txid);
@@ -228,6 +230,24 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
}
}
@Override
public Map<Long, Long> getFeeRateHistogram(Transport transport) {
try {
JsonRpcClient client = new JsonRpcClient(transport);
Long[][] feesArray = new RetryLogic<Long[][]>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(() ->
client.createRequest().returnAs(Long[][].class).method("mempool.get_fee_histogram").id(idCounter.incrementAndGet()).execute());
Map<Long, Long> feeRateHistogram = new TreeMap<>();
for(Long[] feePair : feesArray) {
feeRateHistogram.put(feePair[0], feePair[1]);
}
return feeRateHistogram;
} catch(Exception e) {
throw new ElectrumServerRpcException("Error getting fee rate histogram", e);
}
}
@Override
public Double getMinimumRelayFee(Transport transport) {
try {
@@ -5,6 +5,7 @@ import com.google.common.collect.Iterables;
import com.google.common.eventbus.Subscribe;
import com.google.common.net.HostAndPort;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.wallet.*;
@@ -137,7 +138,16 @@ public class ElectrumServer {
public Map<WalletNode, Set<BlockTransactionHash>> getHistory(Wallet wallet, Collection<WalletNode> nodes) throws ServerException {
Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap = new TreeMap<>();
subscribeWalletNodes(wallet, nodes, nodeTransactionMap, 0);
Set<WalletNode> historyNodes = new HashSet<>(nodes);
//Add any nodes with mempool transactions in case these have been replaced
Set<WalletNode> mempoolNodes = wallet.getWalletTxos().entrySet().stream()
.filter(entry -> entry.getKey().getHeight() <= 0 || (entry.getKey().getSpentBy() != null && entry.getKey().getSpentBy().getHeight() <= 0))
.map(Map.Entry::getValue)
.collect(Collectors.toSet());
historyNodes.addAll(mempoolNodes);
subscribeWalletNodes(wallet, historyNodes, nodeTransactionMap, 0);
getReferences(wallet, nodeTransactionMap.keySet(), nodeTransactionMap, 0);
Set<BlockTransactionHash> newReferences = nodeTransactionMap.values().stream().flatMap(Collection::stream).filter(ref -> !wallet.getTransactions().containsKey(ref.getHash())).collect(Collectors.toSet());
getReferencedTransactions(wallet, nodeTransactionMap);
@@ -151,7 +161,7 @@ public class ElectrumServer {
BlockTransaction blockTransaction = wallet.getTransactions().get(reference.getHash());
for(TransactionOutput txOutput : blockTransaction.getTransaction().getOutputs()) {
WalletNode node = walletScriptHashes.get(getScriptHash(txOutput));
if(node != null && !nodes.contains(node)) {
if(node != null && !historyNodes.contains(node)) {
additionalNodes.add(node);
}
}
@@ -161,7 +171,7 @@ public class ElectrumServer {
if(inputBlockTransaction != null) {
TransactionOutput txOutput = inputBlockTransaction.getTransaction().getOutputs().get((int)txInput.getOutpoint().getIndex());
WalletNode node = walletScriptHashes.get(getScriptHash(txOutput));
if(node != null && !nodes.contains(node)) {
if(node != null && !historyNodes.contains(node)) {
additionalNodes.add(node);
}
}
@@ -224,7 +234,7 @@ public class ElectrumServer {
}
//Even if we have some successes, failure to retrieve all references will result in an incomplete wallet history. Don't proceed if that's the case.
Map<String, ScriptHashTx[]> result = electrumServerRpc.getScriptHashHistory(getTransport(), pathScriptHashes, true);
Map<String, ScriptHashTx[]> result = electrumServerRpc.getScriptHashHistory(getTransport(), wallet, pathScriptHashes, true);
for(String path : result.keySet()) {
ScriptHashTx[] txes = result.get(path);
@@ -233,7 +243,19 @@ public class ElectrumServer {
if(optionalNode.isPresent()) {
WalletNode node = optionalNode.get();
Set<BlockTransactionHash> references = Arrays.stream(txes).map(ScriptHashTx::getBlockchainTransactionHash).collect(Collectors.toCollection(TreeSet::new));
//Some servers can return the same tx as multiple ScriptHashTx entries with different heights. Take the highest height only
Set<BlockTransactionHash> references = Arrays.stream(txes).map(ScriptHashTx::getBlockchainTransactionHash)
.collect(TreeSet::new, (set, ref) -> {
Optional<BlockTransactionHash> optExisting = set.stream().filter(prev -> prev.getHash().equals(ref.getHash())).findFirst();
if(optExisting.isPresent()) {
if(optExisting.get().getHeight() < ref.getHeight()) {
set.remove(optExisting.get());
set.add(ref);
}
} else {
set.add(ref);
}
}, TreeSet::addAll);
Set<BlockTransactionHash> existingReferences = nodeTransactionMap.get(node);
if(existingReferences == null) {
@@ -267,7 +289,7 @@ public class ElectrumServer {
Map<String, String> pathScriptHashes = new LinkedHashMap<>();
for(WalletNode node : nodes) {
if(node == null) {
log.error("Null node for wallet " + wallet.getName() + " subscribing nodes " + nodes + " startIndex " + startIndex);
log.error("Null node for wallet " + wallet.getName() + " subscribing nodes " + nodes + " startIndex " + startIndex, new Throwable());
}
if(node != null && node.getIndex() >= startIndex) {
@@ -286,7 +308,7 @@ public class ElectrumServer {
return;
}
Map<String, String> result = electrumServerRpc.subscribeScriptHashes(getTransport(), pathScriptHashes);
Map<String, String> result = electrumServerRpc.subscribeScriptHashes(getTransport(), wallet, pathScriptHashes);
for(String path : result.keySet()) {
String status = result.get(path);
@@ -320,7 +342,7 @@ public class ElectrumServer {
pathScriptHashes.put(Integer.toString(i), getScriptHash(output));
}
Map<String, ScriptHashTx[]> result = electrumServerRpc.getScriptHashHistory(getTransport(), pathScriptHashes, false);
Map<String, ScriptHashTx[]> result = electrumServerRpc.getScriptHashHistory(getTransport(), null, pathScriptHashes, false);
List<Set<BlockTransactionHash>> blockTransactionHashes = new ArrayList<>(transaction.getOutputs().size());
for(int i = 0; i < transaction.getOutputs().size(); i++) {
@@ -372,8 +394,8 @@ public class ElectrumServer {
Map<Sha256Hash, BlockTransaction> transactionMap = new HashMap<>();
if(!references.isEmpty()) {
Map<Integer, BlockHeader> blockHeaderMap = getBlockHeaders(references);
transactionMap = getTransactions(references, blockHeaderMap);
Map<Integer, BlockHeader> blockHeaderMap = getBlockHeaders(wallet, references);
transactionMap = getTransactions(wallet, references, blockHeaderMap);
}
if(!transactionMap.equals(wallet.getTransactions())) {
@@ -381,7 +403,7 @@ public class ElectrumServer {
}
}
public Map<Integer, BlockHeader> getBlockHeaders(Set<BlockTransactionHash> references) throws ServerException {
public Map<Integer, BlockHeader> getBlockHeaders(Wallet wallet, Set<BlockTransactionHash> references) throws ServerException {
try {
Set<Integer> blockHeights = new TreeSet<>();
for(BlockTransactionHash reference : references) {
@@ -394,7 +416,7 @@ public class ElectrumServer {
return Collections.emptyMap();
}
Map<Integer, String> result = electrumServerRpc.getBlockHeaders(getTransport(), blockHeights);
Map<Integer, String> result = electrumServerRpc.getBlockHeaders(getTransport(), wallet, blockHeights);
Map<Integer, BlockHeader> blockHeaderMap = new TreeMap<>();
for(Integer height : result.keySet()) {
@@ -416,7 +438,7 @@ public class ElectrumServer {
}
}
public Map<Sha256Hash, BlockTransaction> getTransactions(Set<BlockTransactionHash> references, Map<Integer, BlockHeader> blockHeaderMap) throws ServerException {
public Map<Sha256Hash, BlockTransaction> getTransactions(Wallet wallet, Set<BlockTransactionHash> references, Map<Integer, BlockHeader> blockHeaderMap) throws ServerException {
try {
Set<BlockTransactionHash> checkReferences = new TreeSet<>(references);
@@ -425,7 +447,7 @@ public class ElectrumServer {
txids.add(reference.getHashAsString());
}
Map<String, String> result = electrumServerRpc.getTransactions(getTransport(), txids);
Map<String, String> result = electrumServerRpc.getTransactions(getTransport(), wallet, txids);
String strErrorTx = Sha256Hash.ZERO_HASH.toString();
Map<Sha256Hash, BlockTransaction> transactionMap = new HashMap<>();
@@ -592,12 +614,28 @@ public class ElectrumServer {
targetBlocksFeeRatesSats.put(target, minFeeRateSatsKb / 1000d);
}
FeeRatesSource feeRatesSource = Config.get().getFeeRatesSource();
feeRatesSource = (feeRatesSource == null ? FeeRatesSource.MEMPOOL_SPACE : feeRatesSource);
if(Network.get().equals(Network.MAINNET)) {
targetBlocksFeeRatesSats.putAll(feeRatesSource.getBlockTargetFeeRates(targetBlocksFeeRatesSats));
}
return targetBlocksFeeRatesSats;
} catch(ElectrumServerRpcException e) {
throw new ServerException(e.getMessage(), e);
}
}
public Set<MempoolRateSize> getMempoolRateSizes() throws ServerException {
Map<Long, Long> feeRateHistogram = electrumServerRpc.getFeeRateHistogram(getTransport());
Set<MempoolRateSize> mempoolRateSizes = new TreeSet<>();
for(Long fee : feeRateHistogram.keySet()) {
mempoolRateSizes.add(new MempoolRateSize(fee, feeRateHistogram.get(fee)));
}
return mempoolRateSizes;
}
public Double getMinimumRelayFee() throws ServerException {
Double minFeeRateBtcKb = electrumServerRpc.getMinimumRelayFee(getTransport());
if(minFeeRateBtcKb != null) {
@@ -696,7 +734,7 @@ public class ElectrumServer {
}
public static class ConnectionService extends ScheduledService<FeeRatesUpdatedEvent> implements Thread.UncaughtExceptionHandler {
private static final int FEE_RATES_PERIOD = 10 * 60 * 1000;
private static final int FEE_RATES_PERIOD = 30 * 1000;
private final boolean subscribe;
private boolean firstCall = true;
@@ -748,6 +786,7 @@ public class ElectrumServer {
String banner = electrumServer.getServerBanner();
Map<Integer, Double> blockTargetFeeRates = electrumServer.getFeeEstimates(SendController.TARGET_BLOCKS_RANGE);
Set<MempoolRateSize> mempoolRateSizes = electrumServer.getMempoolRateSizes();
feeRatesRetrievedAt = System.currentTimeMillis();
Double minimumRelayFeeRate = electrumServer.getMinimumRelayFee();
@@ -755,7 +794,7 @@ public class ElectrumServer {
blockTargetFeeRates.computeIfPresent(blockTarget, (blocks, feeRate) -> feeRate < minimumRelayFeeRate ? minimumRelayFeeRate : feeRate);
}
return new ConnectionEvent(serverVersion, banner, tip.height, tip.getBlockHeader(), blockTargetFeeRates, minimumRelayFeeRate);
return new ConnectionEvent(serverVersion, banner, tip.height, tip.getBlockHeader(), blockTargetFeeRates, mempoolRateSizes, minimumRelayFeeRate);
} else {
if(reader.isAlive()) {
electrumServer.ping();
@@ -763,8 +802,9 @@ public class ElectrumServer {
long elapsed = System.currentTimeMillis() - feeRatesRetrievedAt;
if(elapsed > FEE_RATES_PERIOD) {
Map<Integer, Double> blockTargetFeeRates = electrumServer.getFeeEstimates(SendController.TARGET_BLOCKS_RANGE);
Set<MempoolRateSize> mempoolRateSizes = electrumServer.getMempoolRateSizes();
feeRatesRetrievedAt = System.currentTimeMillis();
return new FeeRatesUpdatedEvent(blockTargetFeeRates);
return new FeeRatesUpdatedEvent(blockTargetFeeRates, mempoolRateSizes);
}
} else {
resetConnection();
@@ -934,8 +974,8 @@ public class ElectrumServer {
Map<Sha256Hash, BlockTransaction> transactionMap = new HashMap<>();
if(!setReferences.isEmpty()) {
Map<Integer, BlockHeader> blockHeaderMap = electrumServer.getBlockHeaders(setReferences);
transactionMap = electrumServer.getTransactions(setReferences, blockHeaderMap);
Map<Integer, BlockHeader> blockHeaderMap = electrumServer.getBlockHeaders(null, setReferences);
transactionMap = electrumServer.getTransactions(null, setReferences, blockHeaderMap);
}
for(int i = 0; i < outputTransactionReferences.size(); i++) {
@@ -990,4 +1030,18 @@ public class ElectrumServer {
};
}
}
public static class FeeRatesService extends Service<FeeRatesUpdatedEvent> {
@Override
protected Task<FeeRatesUpdatedEvent> createTask() {
return new Task<>() {
protected FeeRatesUpdatedEvent call() throws ServerException {
ElectrumServer electrumServer = new ElectrumServer();
Map<Integer, Double> blockTargetFeeRates = electrumServer.getFeeEstimates(SendController.TARGET_BLOCKS_RANGE);
Set<MempoolRateSize> mempoolRateSizes = electrumServer.getMempoolRateSizes();
return new FeeRatesUpdatedEvent(blockTargetFeeRates, mempoolRateSizes);
}
};
}
}
}
@@ -1,6 +1,7 @@
package com.sparrowwallet.sparrow.net;
import com.github.arteam.simplejsonrpc.client.Transport;
import com.sparrowwallet.drongo.wallet.Wallet;
import java.util.List;
import java.util.Map;
@@ -15,20 +16,22 @@ public interface ElectrumServerRpc {
BlockHeaderTip subscribeBlockHeaders(Transport transport);
Map<String, ScriptHashTx[]> getScriptHashHistory(Transport transport, Map<String, String> pathScriptHashes, boolean failOnError);
Map<String, ScriptHashTx[]> getScriptHashHistory(Transport transport, Wallet wallet, Map<String, String> pathScriptHashes, boolean failOnError);
Map<String, ScriptHashTx[]> getScriptHashMempool(Transport transport, Map<String, String> pathScriptHashes, boolean failOnError);
Map<String, ScriptHashTx[]> getScriptHashMempool(Transport transport, Wallet wallet, Map<String, String> pathScriptHashes, boolean failOnError);
Map<String, String> subscribeScriptHashes(Transport transport, Map<String, String> pathScriptHashes);
Map<String, String> subscribeScriptHashes(Transport transport, Wallet wallet, Map<String, String> pathScriptHashes);
Map<Integer, String> getBlockHeaders(Transport transport, Set<Integer> blockHeights);
Map<Integer, String> getBlockHeaders(Transport transport, Wallet wallet, Set<Integer> blockHeights);
Map<String, String> getTransactions(Transport transport, Set<String> txids);
Map<String, String> getTransactions(Transport transport, Wallet wallet, Set<String> txids);
Map<String, VerboseTransaction> getVerboseTransactions(Transport transport, Set<String> txids, String scriptHash);
Map<Integer, Double> getFeeEstimates(Transport transport, List<Integer> targetBlocks);
Map<Long, Long> getFeeRateHistogram(Transport transport);
Double getMinimumRelayFee(Transport transport);
String broadcastTransaction(Transport transport, String txHex);
@@ -1,7 +1,9 @@
package com.sparrowwallet.sparrow.io;
package com.sparrowwallet.sparrow.net;
import com.google.common.net.HostAndPort;
import com.google.gson.Gson;
import com.sparrowwallet.sparrow.event.ExchangeRatesUpdatedEvent;
import com.sparrowwallet.sparrow.io.Config;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
@@ -11,6 +13,8 @@ import org.slf4j.LoggerFactory;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.*;
@@ -48,8 +52,9 @@ public enum ExchangeSource {
private CoinbaseRates getRates() {
String url = "https://api.coinbase.com/v2/exchange-rates?currency=BTC";
Proxy proxy = getProxy();
try(InputStream is = new URL(url).openStream(); Reader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) {
try(InputStream is = (proxy == null ? new URL(url).openStream() : new URL(url).openConnection(proxy).getInputStream()); Reader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) {
Gson gson = new Gson();
return gson.fromJson(reader, CoinbaseRates.class);
} catch (Exception e) {
@@ -78,8 +83,9 @@ public enum ExchangeSource {
private CoinGeckoRates getRates() {
String url = "https://api.coingecko.com/api/v3/exchange_rates";
Proxy proxy = getProxy();
try(InputStream is = new URL(url).openStream(); Reader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) {
try(InputStream is = (proxy == null ? new URL(url).openStream() : new URL(url).openConnection(proxy).getInputStream()); Reader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) {
Gson gson = new Gson();
return gson.fromJson(reader, CoinGeckoRates.class);
} catch (Exception e) {
@@ -110,6 +116,17 @@ public enum ExchangeSource {
}
}
private static Proxy getProxy() {
Config config = Config.get();
if(config.isUseProxy()) {
HostAndPort proxy = HostAndPort.fromString(config.getProxyServer());
InetSocketAddress proxyAddress = new InetSocketAddress(proxy.getHost(), proxy.getPortOrDefault(ProxyTcpOverTlsTransport.DEFAULT_PROXY_PORT));
return new Proxy(Proxy.Type.SOCKS, proxyAddress);
}
return null;
}
@Override
public String toString() {
return name;
@@ -0,0 +1,102 @@
package com.sparrowwallet.sparrow.net;
import com.google.common.net.HostAndPort;
import com.google.gson.Gson;
import com.sparrowwallet.sparrow.io.Config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
public enum FeeRatesSource {
ELECTRUM_SERVER("Electrum Server") {
@Override
public Map<Integer, Double> getBlockTargetFeeRates(Map<Integer, Double> defaultblockTargetFeeRates) {
return Collections.emptyMap();
}
},
MEMPOOL_SPACE("mempool.space") {
@Override
public Map<Integer, Double> getBlockTargetFeeRates(Map<Integer, Double> defaultblockTargetFeeRates) {
String url = "https://mempool.space/api/v1/fees/recommended";
return getThreeTierFeeRates(defaultblockTargetFeeRates, url);
}
},
BITCOINFEES_EARN_COM("bitcoinfees.earn.com") {
@Override
public Map<Integer, Double> getBlockTargetFeeRates(Map<Integer, Double> defaultblockTargetFeeRates) {
String url = "https://bitcoinfees.earn.com/api/v1/fees/recommended";
return getThreeTierFeeRates(defaultblockTargetFeeRates, url);
}
};
private static final Logger log = LoggerFactory.getLogger(FeeRatesSource.class);
private final String name;
FeeRatesSource(String name) {
this.name = name;
}
public abstract Map<Integer, Double> getBlockTargetFeeRates(Map<Integer, Double> defaultblockTargetFeeRates);
public String getName() {
return name;
}
private static Map<Integer, Double> getThreeTierFeeRates(Map<Integer, Double> defaultblockTargetFeeRates, String url) {
Proxy proxy = getProxy();
Map<Integer, Double> blockTargetFeeRates = new LinkedHashMap<>();
try(InputStream is = (proxy == null ? new URL(url).openStream() : new URL(url).openConnection(proxy).getInputStream()); Reader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) {
Gson gson = new Gson();
ThreeTierRates threeTierRates = gson.fromJson(reader, ThreeTierRates.class);
for(Integer blockTarget : defaultblockTargetFeeRates.keySet()) {
if(blockTarget < 3) {
blockTargetFeeRates.put(blockTarget, threeTierRates.fastestFee);
} else if(blockTarget < 6) {
blockTargetFeeRates.put(blockTarget, threeTierRates.halfHourFee);
} else if(blockTarget <= 10 || defaultblockTargetFeeRates.get(blockTarget) > threeTierRates.hourFee) {
blockTargetFeeRates.put(blockTarget, threeTierRates.hourFee);
} else {
blockTargetFeeRates.put(blockTarget, defaultblockTargetFeeRates.get(blockTarget));
}
}
} catch (Exception e) {
log.warn("Error retrieving recommended fee rates from " + url, e);
}
return blockTargetFeeRates;
}
private static Proxy getProxy() {
Config config = Config.get();
if(config.isUseProxy()) {
HostAndPort proxy = HostAndPort.fromString(config.getProxyServer());
InetSocketAddress proxyAddress = new InetSocketAddress(proxy.getHost(), proxy.getPortOrDefault(ProxyTcpOverTlsTransport.DEFAULT_PROXY_PORT));
return new Proxy(Proxy.Type.SOCKS, proxyAddress);
}
return null;
}
@Override
public String toString() {
return name;
}
private static class ThreeTierRates {
Double fastestFee;
Double halfHourFee;
Double hourFee;
}
}
@@ -0,0 +1,51 @@
package com.sparrowwallet.sparrow.net;
import java.util.Objects;
public class MempoolRateSize implements Comparable<MempoolRateSize> {
private final long fee;
private final long vSize;
public MempoolRateSize(long fee, long vSize) {
this.fee = fee;
this.vSize = vSize;
}
public long getFee() {
return fee;
}
public long getVSize() {
return vSize;
}
@Override
public boolean equals(Object o) {
if(this == o) {
return true;
}
if(o == null || getClass() != o.getClass()) {
return false;
}
MempoolRateSize that = (MempoolRateSize) o;
return fee == that.fee;
}
@Override
public int hashCode() {
return Objects.hash(fee);
}
@Override
public int compareTo(MempoolRateSize other) {
return Long.compare(fee, other.fee);
}
@Override
public String toString() {
return "MempoolRateSize{" +
"fee=" + fee +
", vSize=" + vSize +
'}';
}
}
@@ -6,6 +6,7 @@ import com.github.arteam.simplejsonrpc.client.exception.JsonRpcException;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppController;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletHistoryStatusEvent;
@@ -69,12 +70,12 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
}
@Override
public Map<String, ScriptHashTx[]> getScriptHashHistory(Transport transport, Map<String, String> pathScriptHashes, boolean failOnError) {
public Map<String, ScriptHashTx[]> getScriptHashHistory(Transport transport, Wallet wallet, Map<String, String> pathScriptHashes, boolean failOnError) {
JsonRpcClient client = new JsonRpcClient(transport);
Map<String, ScriptHashTx[]> result = new LinkedHashMap<>();
for(String path : pathScriptHashes.keySet()) {
EventManager.get().post(new WalletHistoryStatusEvent(false, "Loading transactions for " + path));
EventManager.get().post(new WalletHistoryStatusEvent(wallet, false, "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());
@@ -92,7 +93,7 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
}
@Override
public Map<String, ScriptHashTx[]> getScriptHashMempool(Transport transport, Map<String, String> pathScriptHashes, boolean failOnError) {
public Map<String, ScriptHashTx[]> getScriptHashMempool(Transport transport, Wallet wallet, Map<String, String> pathScriptHashes, boolean failOnError) {
JsonRpcClient client = new JsonRpcClient(transport);
Map<String, ScriptHashTx[]> result = new LinkedHashMap<>();
@@ -114,12 +115,12 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
}
@Override
public Map<String, String> subscribeScriptHashes(Transport transport, Map<String, String> pathScriptHashes) {
public Map<String, String> subscribeScriptHashes(Transport transport, Wallet wallet, Map<String, String> pathScriptHashes) {
JsonRpcClient client = new JsonRpcClient(transport);
Map<String, String> result = new LinkedHashMap<>();
for(String path : pathScriptHashes.keySet()) {
EventManager.get().post(new WalletHistoryStatusEvent(false, "Finding transactions for " + path));
EventManager.get().post(new WalletHistoryStatusEvent(wallet, false, "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());
@@ -134,12 +135,12 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
}
@Override
public Map<Integer, String> getBlockHeaders(Transport transport, Set<Integer> blockHeights) {
public Map<Integer, String> getBlockHeaders(Transport transport, Wallet wallet, Set<Integer> blockHeights) {
JsonRpcClient client = new JsonRpcClient(transport);
Map<Integer, String> result = new LinkedHashMap<>();
for(Integer blockHeight : blockHeights) {
EventManager.get().post(new WalletHistoryStatusEvent(false, "Retrieving block at height " + blockHeight));
EventManager.get().post(new WalletHistoryStatusEvent(wallet, false, "Retrieving block at height " + blockHeight));
try {
String blockHeader = new RetryLogic<String>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() ->
client.createRequest().returnAs(String.class).method("blockchain.block.header").id(idCounter.incrementAndGet()).params(blockHeight).execute());
@@ -155,12 +156,12 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
}
@Override
public Map<String, String> getTransactions(Transport transport, Set<String> txids) {
public Map<String, String> getTransactions(Transport transport, Wallet wallet, Set<String> txids) {
JsonRpcClient client = new JsonRpcClient(transport);
Map<String, String> result = new LinkedHashMap<>();
for(String txid : txids) {
EventManager.get().post(new WalletHistoryStatusEvent(false, "Retrieving transaction [" + txid.substring(0, 6) + "]"));
EventManager.get().post(new WalletHistoryStatusEvent(wallet, false, "Retrieving transaction [" + txid.substring(0, 6) + "]"));
try {
String rawTxHex = new RetryLogic<String>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() ->
client.createRequest().returnAs(String.class).method("blockchain.transaction.get").id(idCounter.incrementAndGet()).params(txid).execute());
@@ -245,6 +246,24 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
return result;
}
@Override
public Map<Long, Long> getFeeRateHistogram(Transport transport) {
try {
JsonRpcClient client = new JsonRpcClient(transport);
Long[][] feesArray = new RetryLogic<Long[][]>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(() ->
client.createRequest().returnAs(Long[][].class).method("mempool.get_fee_histogram").id(idCounter.incrementAndGet()).execute());
Map<Long, Long> feeRateHistogram = new TreeMap<>();
for(Long[] feePair : feesArray) {
feeRateHistogram.put(feePair[0], feePair[1]);
}
return feeRateHistogram;
} catch(Exception e) {
throw new ElectrumServerRpcException("Error getting fee rate histogram", e);
}
}
@Override
public Double getMinimumRelayFee(Transport transport) {
try {
@@ -1,6 +1,7 @@
package com.sparrowwallet.sparrow.net;
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.google.common.collect.Iterables;
@@ -23,7 +24,12 @@ public class SubscriptionService {
}
@JsonRpcMethod("blockchain.scripthash.subscribe")
public void scriptHashStatusUpdated(@JsonRpcParam("scripthash") final String scriptHash, @JsonRpcParam("status") final String status) {
public void scriptHashStatusUpdated(@JsonRpcParam("scripthash") final String scriptHash, @JsonRpcOptional @JsonRpcParam("status") final String status) {
if(status == null) {
//Mempool transaction was replaced returning change/consolidation script hash status to null, ignore this update
return;
}
Set<String> existingStatuses = ElectrumServer.getSubscribedScriptHashes().get(scriptHash);
if(existingStatuses == null) {
log.warn("Received script hash status update for unsubscribed script hash: " + scriptHash);
@@ -133,7 +133,7 @@ public class TcpTransport implements Transport, Closeable {
while(running) {
try {
String received = readInputStream();
if(received.contains("method")) {
if(received.contains("method") && !received.contains("error")) {
//Handle subscription notification
jsonRpcServer.handle(received, subscriptionService);
} else {
@@ -0,0 +1,321 @@
package com.sparrowwallet.sparrow.payjoin;
import com.google.common.collect.ImmutableMap;
import com.google.gson.Gson;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.protocol.Script;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.protocol.TransactionInput;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTInput;
import com.sparrowwallet.drongo.psbt.PSBTOutput;
import com.sparrowwallet.drongo.psbt.PSBTParseException;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.*;
public class Payjoin {
private static final Logger log = LoggerFactory.getLogger(Payjoin.class);
private final BitcoinURI payjoinURI;
private final Wallet wallet;
private final PSBT psbt;
public Payjoin(BitcoinURI payjoinURI, Wallet wallet, PSBT psbt) {
this.payjoinURI = payjoinURI;
this.wallet = wallet;
this.psbt = psbt;
for(PSBTInput psbtInput : psbt.getPsbtInputs()) {
if(psbtInput.getUtxo() == null) {
throw new IllegalArgumentException("Original PSBT for payjoin transaction must have non_witness_utxo or witness_utxo fields for all inputs");
}
}
if(!psbt.isFinalized()) {
throw new IllegalArgumentException("Original PSBT for payjoin transaction must be finalized");
}
}
public PSBT requestPayjoinPSBT(boolean allowOutputSubstitution) throws PayjoinReceiverException {
if(!payjoinURI.isPayjoinOutputSubstitutionAllowed()) {
allowOutputSubstitution = false;
}
URI uri = payjoinURI.getPayjoinUrl();
if(uri == null) {
log.error("No payjoin URL provided");
throw new PayjoinReceiverException("No payjoin URL provided");
}
try {
String base64Psbt = psbt.getPublicCopy().toBase64String();
String appendQuery = "v=1";
int changeOutputIndex = getChangeOutputIndex();
long maxAdditionalFeeContribution = 0;
if(changeOutputIndex > -1) {
appendQuery += "&additionalfeeoutputindex=" + changeOutputIndex;
maxAdditionalFeeContribution = getAdditionalFeeContribution();
appendQuery += "&maxadditionalfeecontribution=" + maxAdditionalFeeContribution;
}
URI finalUri = new URI(uri.getScheme(), uri.getAuthority(), uri.getPath(), uri.getQuery() == null ? appendQuery : uri.getQuery() + "&" + appendQuery, uri.getFragment());
log.info("Sending PSBT to " + finalUri.toURL());
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(finalUri)
.header("Content-Type", "text/plain")
.POST(HttpRequest.BodyPublishers.ofString(base64Psbt))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if(response.statusCode() != 200) {
Gson gson = new Gson();
PayjoinReceiverError payjoinReceiverError = gson.fromJson(response.body(), PayjoinReceiverError.class);
log.warn("Payjoin receiver returned an error of " + payjoinReceiverError.getErrorCode() + " (" + payjoinReceiverError.getMessage() + ")");
throw new PayjoinReceiverException(payjoinReceiverError.getSafeMessage());
}
PSBT proposalPsbt = PSBT.fromString(response.body());
checkProposal(psbt, proposalPsbt, changeOutputIndex, maxAdditionalFeeContribution, allowOutputSubstitution);
return proposalPsbt;
} catch(URISyntaxException e) {
log.error("Invalid payjoin receiver URI", e);
throw new PayjoinReceiverException("Invalid payjoin receiver URI", e);
} catch(IOException | InterruptedException e) {
log.error("Payjoin receiver error", e);
throw new PayjoinReceiverException("Payjoin receiver error", e);
} catch(PSBTParseException e) {
log.error("Error parsing received PSBT", e);
throw new PayjoinReceiverException("Payjoin receiver returned invalid PSBT", e);
}
}
private void checkProposal(PSBT original, PSBT proposal, int changeOutputIndex, long maxAdditionalFeeContribution, boolean allowOutputSubstitution) throws PayjoinReceiverException {
Queue<Map.Entry<TransactionInput, PSBTInput>> originalInputs = new ArrayDeque<>();
for(int i = 0; i < original.getPsbtInputs().size(); i++) {
originalInputs.add(Map.entry(original.getTransaction().getInputs().get(i), original.getPsbtInputs().get(i)));
}
Queue<Map.Entry<TransactionOutput, PSBTOutput>> originalOutputs = new ArrayDeque<>();
for(int i = 0; i < original.getPsbtOutputs().size(); i++) {
originalOutputs.add(Map.entry(original.getTransaction().getOutputs().get(i), original.getPsbtOutputs().get(i)));
}
// Checking that the PSBT of the receiver is clean
if(!proposal.getExtendedPublicKeys().isEmpty()) {
throw new PayjoinReceiverException("Global xpubs should not be included in the receiver's PSBT");
}
Transaction originalTx = original.getTransaction();
Transaction proposalTx = proposal.getTransaction();
// Verify that the transaction version, and nLockTime are unchanged.
if(proposalTx.getVersion() != originalTx.getVersion()) {
throw new PayjoinReceiverException("The proposal PSBT changed the transaction version");
}
if(proposalTx.getLocktime() != originalTx.getLocktime()) {
throw new PayjoinReceiverException("The proposal PSBT changed the nLocktime");
}
Set<Long> sequences = new HashSet<>();
// For each inputs in the proposal:
for(PSBTInput proposedPSBTInput : proposal.getPsbtInputs()) {
if(!proposedPSBTInput.getDerivedPublicKeys().isEmpty()) {
throw new PayjoinReceiverException("The receiver added keypaths to an input");
}
if(!proposedPSBTInput.getPartialSignatures().isEmpty()) {
throw new PayjoinReceiverException("The receiver added partial signatures to an input");
}
TransactionInput proposedTxIn = proposedPSBTInput.getInput();
boolean isOriginalInput = originalInputs.size() > 0 && originalInputs.peek().getKey().getOutpoint().equals(proposedTxIn.getOutpoint());
if(isOriginalInput) {
Map.Entry<TransactionInput, PSBTInput> originalInput = originalInputs.remove();
TransactionInput originalTxIn = originalInput.getKey();
// Verify that sequence is unchanged.
if(originalTxIn.getSequenceNumber() != proposedTxIn.getSequenceNumber()) {
throw new PayjoinReceiverException("The proposed transaction input modified the sequence of one of the original inputs");
}
// Verify the PSBT input is not finalized
if(proposedPSBTInput.isFinalized()) {
throw new PayjoinReceiverException("The receiver finalized one of the original inputs");
}
// Verify that non_witness_utxo and witness_utxo are not specified.
if(proposedPSBTInput.getNonWitnessUtxo() != null || proposedPSBTInput.getWitnessUtxo() != null) {
throw new PayjoinReceiverException("The receiver added non_witness_utxo or witness_utxo to one of the original inputs");
}
sequences.add(proposedTxIn.getSequenceNumber());
PSBTInput originalPSBTInput = originalInput.getValue();
// Fill up the info from the original PSBT input so we can sign and get fees.
proposedPSBTInput.setNonWitnessUtxo(originalPSBTInput.getNonWitnessUtxo());
proposedPSBTInput.setWitnessUtxo(originalPSBTInput.getWitnessUtxo());
// We fill up information we had on the signed PSBT, so we can sign it.
proposedPSBTInput.getDerivedPublicKeys().putAll(originalPSBTInput.getDerivedPublicKeys());
proposedPSBTInput.getProprietary().putAll(originalPSBTInput.getProprietary());
proposedPSBTInput.setRedeemScript(originalPSBTInput.getFinalScriptSig().getFirstNestedScript());
proposedPSBTInput.setWitnessScript(originalPSBTInput.getFinalScriptWitness().getWitnessScript());
proposedPSBTInput.setSigHash(originalPSBTInput.getSigHash());
} else {
// Verify the PSBT input is finalized
if(!proposedPSBTInput.isFinalized()) {
throw new PayjoinReceiverException("The receiver did not finalize one of their inputs");
}
// Verify that non_witness_utxo or witness_utxo are filled in.
if(proposedPSBTInput.getNonWitnessUtxo() == null && proposedPSBTInput.getWitnessUtxo() == null) {
throw new PayjoinReceiverException("The receiver did not specify non_witness_utxo or witness_utxo for one of their inputs");
}
sequences.add(proposedTxIn.getSequenceNumber());
// Verify that the payjoin proposal did not introduced mixed inputs' type.
if(wallet.getScriptType() != proposedPSBTInput.getScriptType()) {
throw new PayjoinReceiverException("Proposal script type of " + proposedPSBTInput.getScriptType() + " did not match wallet script type of " + wallet.getScriptType());
}
}
}
// Verify that all of sender's inputs from the original PSBT are in the proposal.
if(!originalInputs.isEmpty()) {
throw new PayjoinReceiverException("Some of the original inputs are not included in the proposal");
}
// Verify that the payjoin proposal did not introduced mixed inputs' sequence.
if(sequences.size() != 1) {
throw new PayjoinReceiverException("Mixed sequences detected in the proposal");
}
Long newFee = proposal.getFee();
long additionalFee = newFee - original.getFee();
if(additionalFee < 0) {
throw new PayjoinReceiverException("The receiver decreased absolute fee");
}
TransactionOutput changeOutput = (changeOutputIndex > -1 ? originalTx.getOutputs().get(changeOutputIndex) : null);
// For each outputs in the proposal:
for(int i = 0; i < proposal.getPsbtOutputs().size(); i++) {
PSBTOutput proposedPSBTOutput = proposal.getPsbtOutputs().get(i);
// Verify that no keypaths is in the PSBT output
if(!proposedPSBTOutput.getDerivedPublicKeys().isEmpty()) {
throw new PayjoinReceiverException("The receiver added keypaths to an output");
}
TransactionOutput proposedTxOut = proposalTx.getOutputs().get(i);
boolean isOriginalOutput = originalOutputs.size() > 0 && originalOutputs.peek().getKey().getScript().equals(proposedTxOut.getScript());
if(isOriginalOutput) {
Map.Entry<TransactionOutput, PSBTOutput> originalOutput = originalOutputs.remove();
if(originalOutput.getKey() == changeOutput) {
var actualContribution = changeOutput.getValue() - proposedTxOut.getValue();
// The amount that was subtracted from the output's value is less than or equal to maxadditionalfeecontribution
if(actualContribution > maxAdditionalFeeContribution) {
throw new PayjoinReceiverException("The actual contribution is more than maxadditionalfeecontribution");
}
// Make sure the actual contribution is only paying fee
if(actualContribution > additionalFee) {
throw new PayjoinReceiverException("The actual contribution is not only paying fee");
}
// Make sure the actual contribution is only paying for fee incurred by additional inputs
int additionalInputsCount = proposalTx.getInputs().size() - originalTx.getInputs().size();
if(actualContribution > getSingleInputFee() * additionalInputsCount) {
throw new PayjoinReceiverException("The actual contribution is not only paying for additional inputs");
}
} else if(allowOutputSubstitution && originalOutput.getKey().getScript().equals(payjoinURI.getAddress().getOutputScript())) {
// That's the payment output, the receiver may have changed it.
} else {
if(originalOutput.getKey().getValue() > proposedTxOut.getValue()) {
throw new PayjoinReceiverException("The receiver decreased the value of one of the outputs");
}
}
PSBTOutput originalPSBTOutput = originalOutput.getValue();
// We fill up information we had on the signed PSBT, so we can sign it.
proposedPSBTOutput.getDerivedPublicKeys().putAll(originalPSBTOutput.getDerivedPublicKeys());
proposedPSBTOutput.getProprietary().putAll(originalPSBTOutput.getProprietary());
proposedPSBTOutput.setRedeemScript(originalPSBTOutput.getRedeemScript());
proposedPSBTOutput.setWitnessScript(originalPSBTOutput.getWitnessScript());
}
}
// Verify that all of sender's outputs from the original PSBT are in the proposal.
if(!originalOutputs.isEmpty()) {
// The payment output may have been substituted
if(!allowOutputSubstitution || originalOutputs.size() != 1 || !originalOutputs.remove().getKey().getScript().equals(payjoinURI.getAddress().getOutputScript())) {
throw new PayjoinReceiverException("Some of our outputs are not included in the proposal");
}
}
//Add global pubkey map for signing
proposal.getExtendedPublicKeys().putAll(psbt.getExtendedPublicKeys());
proposal.getGlobalProprietary().putAll(psbt.getGlobalProprietary());
}
private int getChangeOutputIndex() {
Map<Script, WalletNode> changeScriptNodes = wallet.getWalletOutputScripts(KeyPurpose.CHANGE);
for(int i = 0; i < psbt.getTransaction().getOutputs().size(); i++) {
if(changeScriptNodes.containsKey(psbt.getTransaction().getOutputs().get(i).getScript())) {
return i;
}
}
return -1;
}
private long getAdditionalFeeContribution() {
return getSingleInputFee();
}
private long getSingleInputFee() {
Transaction transaction = psbt.extractTransaction();
double feeRate = psbt.getFee().doubleValue() / transaction.getVirtualSize();
int vSize = 68;
if(transaction.getInputs().size() > 0) {
TransactionInput input = transaction.getInputs().get(0);
vSize = input.getLength() * Transaction.WITNESS_SCALE_FACTOR;
vSize += input.getWitness() != null ? input.getWitness().getLength() : 0;
vSize = (int)Math.ceil((double)vSize / Transaction.WITNESS_SCALE_FACTOR);
}
return (long) (vSize * feeRate);
}
private static class PayjoinReceiverError {
Map<String, String> knownErrors = ImmutableMap.of(
"unavailable", "The payjoin endpoint is not available for now.",
"not-enough-money", "The receiver added some inputs but could not bump the fee of the payjoin proposal.",
"version-unsupported", "This version of payjoin is not supported.",
"original-psbt-rejected", "The receiver rejected the original PSBT."
);
public String errorCode;
public String message;
public String getErrorCode() {
return errorCode;
}
public String getMessage() {
return message;
}
public String getSafeMessage() {
String message = knownErrors.get(errorCode);
return (message == null ? "Unknown Error" : message);
}
}
}
@@ -0,0 +1,19 @@
package com.sparrowwallet.sparrow.payjoin;
public class PayjoinReceiverException extends Exception {
public PayjoinReceiverException() {
super();
}
public PayjoinReceiverException(String msg) {
super(msg);
}
public PayjoinReceiverException(Throwable cause) {
super(cause);
}
public PayjoinReceiverException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -3,11 +3,10 @@ package com.sparrowwallet.sparrow.preferences;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.UnlabeledToggleSwitch;
import com.sparrowwallet.sparrow.event.BitcoinUnitChangedEvent;
import com.sparrowwallet.sparrow.event.FiatCurrencySelectedEvent;
import com.sparrowwallet.sparrow.event.VersionCheckStatusEvent;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.ExchangeSource;
import com.sparrowwallet.sparrow.net.ExchangeSource;
import com.sparrowwallet.sparrow.net.FeeRatesSource;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.fxml.FXML;
@@ -24,6 +23,9 @@ public class GeneralPreferencesController extends PreferencesDetailController {
@FXML
private ComboBox<BitcoinUnit> bitcoinUnit;
@FXML
private ComboBox<FeeRatesSource> feeRatesSource;
@FXML
private ComboBox<Currency> fiatCurrency;
@@ -63,6 +65,18 @@ public class GeneralPreferencesController extends PreferencesDetailController {
EventManager.get().post(new BitcoinUnitChangedEvent(newValue));
});
if(config.getFeeRatesSource() != null) {
feeRatesSource.setValue(config.getFeeRatesSource());
} else {
feeRatesSource.getSelectionModel().select(1);
config.setFeeRatesSource(feeRatesSource.getValue());
}
feeRatesSource.valueProperty().addListener((observable, oldValue, newValue) -> {
config.setFeeRatesSource(newValue);
EventManager.get().post(new FeeRatesSourceChangedEvent(newValue));
});
if(config.getExchangeSource() != null) {
exchangeSource.setValue(config.getExchangeSource());
} else {
@@ -49,7 +49,7 @@ public class PreferencesDialog extends Dialog<Boolean> {
}
dialogPane.setPrefWidth(650);
dialogPane.setPrefHeight(500);
dialogPane.setPrefHeight(550);
existingConnection = ElectrumServer.isConnected();
setOnCloseRequest(event -> {
@@ -109,7 +109,7 @@ public class ServerPreferencesController extends PreferencesDetailController {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Select Electrum Server certificate");
fileChooser.getExtensionFilters().addAll(
new FileChooser.ExtensionFilter("All Files", "*"),
new FileChooser.ExtensionFilter("All Files", org.controlsfx.tools.Platform.getCurrent().equals(org.controlsfx.tools.Platform.UNIX) ? "*" : "*.*"),
new FileChooser.ExtensionFilter("CRT", "*.crt")
);
@@ -296,6 +296,10 @@ public class ServerPreferencesController extends PreferencesDetailController {
@NotNull
private ChangeListener<String> getProxyListener(Config config) {
return (observable, oldValue, newValue) -> {
if(oldValue.trim().equals(newValue.trim())) {
return;
}
String hostAsString = getHost(proxyHost.getText());
Integer portAsInteger = getPort(proxyPort.getText());
if(hostAsString != null && portAsInteger != null && isValidPort(portAsInteger)) {
@@ -3,11 +3,14 @@ package com.sparrowwallet.sparrow.transaction;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.SecureString;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTInput;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.hummingbird.UR;
import com.sparrowwallet.hummingbird.registry.RegistryType;
import com.sparrowwallet.sparrow.AppController;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.*;
@@ -16,6 +19,8 @@ import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands;
import com.sparrowwallet.sparrow.io.Device;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.payjoin.Payjoin;
import com.sparrowwallet.sparrow.payjoin.PayjoinReceiverException;
import com.sparrowwallet.sparrow.wallet.HashIndexEntry;
import com.sparrowwallet.sparrow.wallet.TransactionEntry;
import javafx.application.Platform;
@@ -40,10 +45,8 @@ import tornadofx.control.Fieldset;
import com.google.common.eventbus.Subscribe;
import tornadofx.control.Form;
import java.io.File;
import java.io.IOException;
import java.io.PrintWriter;
import java.net.URL;
import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.time.*;
@@ -196,6 +199,9 @@ public class HeadersController extends TransactionFormController implements Init
@FXML
private Button saveFinalButton;
@FXML
private Button payjoinButton;
@Override
public void initialize(URL location, ResourceBundle resources) {
EventManager.get().register(this);
@@ -356,6 +362,12 @@ public class HeadersController extends TransactionFormController implements Init
saveFinalButton.visibleProperty().bind(broadcastButton.visibleProperty().not());
broadcastButton.visibleProperty().bind(AppController.onlineProperty());
BitcoinURI payjoinURI = getPayjoinURI();
boolean isPayjoinOriginalTx = payjoinURI != null && headersForm.getPsbt() != null && headersForm.getPsbt().getPsbtInputs().stream().noneMatch(PSBTInput::isFinalized);
payjoinButton.managedProperty().bind(payjoinButton.visibleProperty());
payjoinButton.visibleProperty().set(isPayjoinOriginalTx);
broadcastButton.setDefaultButton(!isPayjoinOriginalTx);
blockchainForm.setVisible(false);
signingWalletForm.setVisible(false);
sigHashForm.setVisible(false);
@@ -365,7 +377,6 @@ public class HeadersController extends TransactionFormController implements Init
broadcastButtonBox.setVisible(false);
if(headersForm.getBlockTransaction() != null) {
blockchainForm.setVisible(true);
updateBlockchainForm(headersForm.getBlockTransaction(), AppController.getCurrentBlockHeight());
} else if(headersForm.getPsbt() != null) {
PSBT psbt = headersForm.getPsbt();
@@ -470,6 +481,7 @@ public class HeadersController extends TransactionFormController implements Init
}
private void updateBlockchainForm(BlockTransaction blockTransaction, Integer currentHeight) {
signaturesForm.setVisible(false);
blockchainForm.setVisible(true);
if(Sha256Hash.ZERO_HASH.equals(blockTransaction.getBlockHash()) && blockTransaction.getHeight() == 0 && headersForm.getSigningWallet() == null) {
@@ -541,6 +553,24 @@ public class HeadersController extends TransactionFormController implements Init
}
}
private BitcoinURI getPayjoinURI() {
if(headersForm.getPsbt() != null) {
for(TransactionOutput txOutput : headersForm.getPsbt().getTransaction().getOutputs()) {
try {
Address address = txOutput.getScript().getToAddresses()[0];
BitcoinURI bitcoinURI = AppController.getPayjoinURI(address);
if(bitcoinURI != null) {
return bitcoinURI;
}
} catch(Exception e) {
//ignore
}
}
}
return null;
}
private static class BlockHeightContextMenu extends ContextMenu {
public BlockHeightContextMenu(Sha256Hash blockHash) {
MenuItem copyBlockHash = new MenuItem("Copy Block Hash");
@@ -556,7 +586,7 @@ public class HeadersController extends TransactionFormController implements Init
private void updateTxId() {
id.setText(headersForm.getTransaction().calculateTxId(false).toString());
if(headersForm.getPsbt() != null && !(headersForm.getTransaction().hasScriptSigs() || headersForm.getTransaction().hasWitnesses())) {
if(!headersForm.isTransactionFinalized()) {
if(!id.getStyleClass().contains(UNFINALIZED_TXID_CLASS)) {
id.getStyleClass().add(UNFINALIZED_TXID_CLASS);
}
@@ -590,8 +620,11 @@ public class HeadersController extends TransactionFormController implements Init
ToggleButton toggleButton = (ToggleButton)event.getSource();
toggleButton.setSelected(false);
//TODO: Remove once Cobo Vault has upgraded to UR2.0
boolean addLegacyEncodingOption = headersForm.getSigningWallet().getKeystores().stream().anyMatch(keystore -> keystore.getWalletModel().equals(WalletModel.COBO_VAULT));
try {
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(UR.CRYPTO_PSBT_TYPE, headersForm.getPsbt().serialize());
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(RegistryType.CRYPTO_PSBT.toString(), headersForm.getPsbt().serialize(), addLegacyEncodingOption);
qrDisplayDialog.show();
} catch(UR.URException e) {
log.error("Error creating PSBT UR", e);
@@ -788,6 +821,21 @@ public class HeadersController extends TransactionFormController implements Init
}
}
public void getPayjoinTransaction(ActionEvent event) {
BitcoinURI payjoinURI = getPayjoinURI();
if(payjoinURI == null) {
throw new IllegalStateException("No valid Payjoin URI");
}
try {
Payjoin payjoin = new Payjoin(payjoinURI, headersForm.getSigningWallet(), headersForm.getPsbt());
PSBT proposalPsbt = payjoin.requestPayjoinPSBT(true);
EventManager.get().post(new ViewPSBTEvent(headersForm.getName() + " Payjoin", proposalPsbt));
} catch(PayjoinReceiverException e) {
AppController.showErrorDialog("Invalid Payjoin Transaction", e.getMessage());
}
}
@Override
public void update() {
BlockTransaction blockTransaction = headersForm.getBlockTransaction();
@@ -936,6 +984,10 @@ public class HeadersController extends TransactionFormController implements Init
@Subscribe
public void psbtFinalized(PSBTFinalizedEvent event) {
if(event.getPsbt().equals(headersForm.getPsbt())) {
if(headersForm.getSigningWallet() != null) {
updateSignedKeystores(headersForm.getSigningWallet());
}
signButtonBox.setVisible(false);
broadcastButtonBox.setVisible(true);
}
@@ -961,7 +1013,7 @@ public class HeadersController extends TransactionFormController implements Init
@Subscribe
public void walletNodeHistoryChanged(WalletNodeHistoryChangedEvent event) {
if(headersForm.getSigningWallet() != null && event.getWalletNode(headersForm.getSigningWallet()) != null) {
if(headersForm.getSigningWallet() != null && event.getWalletNode(headersForm.getSigningWallet()) != null && headersForm.isTransactionFinalized()) {
Sha256Hash txid = headersForm.getTransaction().getTxId();
ElectrumServer.TransactionReferenceService transactionReferenceService = new ElectrumServer.TransactionReferenceService(Set.of(txid), event.getScriptHash());
transactionReferenceService.setOnSucceeded(successEvent -> {
@@ -969,7 +1021,6 @@ public class HeadersController extends TransactionFormController implements Init
BlockTransaction blockTransaction = transactionMap.get(txid);
if(blockTransaction != null) {
headersForm.setBlockTransaction(blockTransaction);
signaturesForm.setVisible(false);
updateBlockchainForm(blockTransaction, AppController.getCurrentBlockHeight());
}
});
@@ -8,6 +8,7 @@ import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.psbt.PSBTInput;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.*;
@@ -120,6 +121,11 @@ public class InputController extends TransactionFormController implements Initia
TransactionInput txInput = inputForm.getTransactionInput();
PSBTInput psbtInput = inputForm.getPsbtInput();
inputForm.signingWalletProperty().addListener((observable, oldValue, signingWallet) -> {
updateInputLegendFromWallet(txInput, signingWallet);
});
updateInputLegendFromWallet(txInput, inputForm.getSigningWallet());
initializeInputFields(txInput, psbtInput);
initializeScriptFields(txInput, psbtInput);
initializeStatusFields(txInput, psbtInput);
@@ -132,9 +138,27 @@ public class InputController extends TransactionFormController implements Initia
}
}
private void initializeInputFields(TransactionInput txInput, PSBTInput psbtInput) {
inputFieldset.setText("Input #" + txInput.getIndex());
private String getLegendText(TransactionInput txInput) {
return "Input #" + txInput.getIndex();
}
private void updateInputLegendFromWallet(TransactionInput txInput, Wallet signingWallet) {
String baseText = getLegendText(txInput);
if(signingWallet != null) {
if(inputForm.isWalletTxo()) {
inputFieldset.setText(baseText + " - " + signingWallet.getName());
inputFieldset.setIcon(TransactionDiagram.getTxoGlyph());
} else {
inputFieldset.setText(baseText + " - Payjoin");
inputFieldset.setIcon(TransactionDiagram.getPayjoinGlyph());
}
} else {
inputFieldset.setText(baseText);
inputFieldset.setIcon(null);
}
}
private void initializeInputFields(TransactionInput txInput, PSBTInput psbtInput) {
outpoint.managedProperty().bind(outpoint.visibleProperty());
linkedOutpoint.managedProperty().bind(linkedOutpoint.visibleProperty());
@@ -46,6 +46,11 @@ public class InputForm extends IndexedTransactionForm {
return null;
}
public boolean isWalletTxo() {
TransactionInput txInput = getTransactionInput();
return getSigningWallet() != null && getSigningWallet().getWalletTxos().keySet().stream().anyMatch(ref -> ref.getHash().equals(txInput.getOutpoint().getHash()) && ref.getIndex() == txInput.getOutpoint().getIndex());
}
@Override
public Node getContents() throws IOException {
FXMLLoader loader = new FXMLLoader(getClass().getResource("input.fxml"));
@@ -1,7 +1,6 @@
package com.sparrowwallet.sparrow.transaction;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.protocol.NonStandardScriptException;
import com.sparrowwallet.drongo.protocol.TransactionInput;
@@ -106,10 +105,10 @@ public class OutputController extends TransactionFormController implements Initi
private void updateOutputLegendFromWallet(TransactionOutput txOutput, Wallet signingWallet) {
String baseText = getLegendText(txOutput);
if(signingWallet != null) {
if(signingWallet.getWalletOutputScripts(KeyPurpose.RECEIVE).containsKey(txOutput.getScript())) {
if(outputForm.isWalletConsolidation()) {
outputFieldset.setText(baseText + " - Consolidation");
outputFieldset.setIcon(TransactionDiagram.getConsolidationGlyph());
} else if(signingWallet.getWalletOutputScripts(KeyPurpose.CHANGE).containsKey(txOutput.getScript())) {
} else if(outputForm.isWalletChange()) {
outputFieldset.setText(baseText + " - Change");
outputFieldset.setIcon(TransactionDiagram.getChangeGlyph());
} else {
@@ -1,5 +1,6 @@
package com.sparrowwallet.sparrow.transaction;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.psbt.PSBTOutput;
import javafx.fxml.FXMLLoader;
@@ -30,6 +31,18 @@ public class OutputForm extends IndexedTransactionForm {
return psbtOutput;
}
public boolean isWalletConsolidation() {
return (getSigningWallet() != null && getSigningWallet().getWalletOutputScripts(KeyPurpose.RECEIVE).containsKey(getTransactionOutput().getScript()));
}
public boolean isWalletChange() {
return (getSigningWallet() != null && getSigningWallet().getWalletOutputScripts(KeyPurpose.CHANGE).containsKey(getTransactionOutput().getScript()));
}
public boolean isWalletPayment() {
return getSigningWallet() != null;
}
@Override
public Node getContents() throws IOException {
FXMLLoader loader = new FXMLLoader(getClass().getResource("output.fxml"));
@@ -9,6 +9,7 @@ import com.sparrowwallet.drongo.wallet.BlockTransaction;
import com.sparrowwallet.sparrow.AppController;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.TransactionTabData;
import com.sparrowwallet.sparrow.control.TransactionDiagram;
import com.sparrowwallet.sparrow.control.TransactionHexArea;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.net.ElectrumServer;
@@ -17,11 +18,10 @@ import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.control.TreeCell;
import javafx.scene.control.TreeItem;
import javafx.scene.control.TreeView;
import javafx.scene.control.cell.TextFieldTreeCell;
import javafx.scene.layout.Pane;
import javafx.util.StringConverter;
import org.controlsfx.control.MasterDetailPane;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -125,17 +125,46 @@ public class TransactionController implements Initializable {
rootItem.getChildren().add(outputsItem);
txtree.setRoot(rootItem);
txtree.setCellFactory(p -> new TextFieldTreeCell<>(new StringConverter<TransactionForm>() {
txtree.setCellFactory(tc -> new TreeCell<>() {
@Override
public String toString(TransactionForm transactionForm) {
return transactionForm.toString();
}
protected void updateItem(TransactionForm form, boolean empty) {
super.updateItem(form, empty);
@Override
public TransactionForm fromString(String string) {
throw new IllegalStateException("No editing");
setText(null);
setGraphic(null);
setTooltip(null);
setContextMenu(null);
if(form != null) {
setText(form.toString());
if(form.getSigningWallet() != null) {
if(form instanceof InputForm) {
InputForm inputForm = (InputForm)form;
if(inputForm.isWalletTxo()) {
setGraphic(TransactionDiagram.getTxoGlyph());
} else {
setGraphic(TransactionDiagram.getPayjoinGlyph());
}
}
if(form instanceof OutputForm) {
OutputForm outputForm = (OutputForm)form;
if(outputForm.isWalletConsolidation()) {
setGraphic(TransactionDiagram.getConsolidationGlyph());
} else if(outputForm.isWalletChange()) {
setGraphic(TransactionDiagram.getChangeGlyph());
} else {
setGraphic(TransactionDiagram.getPaymentGlyph());
}
}
}
}
}
}));
});
txdata.signingWalletProperty().addListener((observable, oldValue, newValue) -> {
txtree.refresh();
});
txtree.getSelectionModel().selectedItemProperty().addListener((observable, old_val, selectedItem) -> {
TransactionForm transactionForm = selectedItem.getValue();
@@ -107,6 +107,10 @@ public abstract class TransactionForm {
return true;
}
public boolean isTransactionFinalized() {
return getPsbt() == null || getTransaction().hasScriptSigs() || getTransaction().hasWitnesses();
}
public abstract Node getContents() throws IOException;
public abstract TransactionView getView();
@@ -1,5 +1,6 @@
package com.sparrowwallet.sparrow.wallet;
import com.sparrowwallet.drongo.wallet.Wallet;
import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
@@ -7,19 +8,26 @@ import javafx.collections.ObservableList;
import java.util.List;
public abstract class Entry {
private final Wallet wallet;
private final SimpleStringProperty labelProperty;
private final ObservableList<Entry> children;
public Entry(String label, List<Entry> entries) {
public Entry(Wallet wallet, String label, List<Entry> entries) {
this.wallet = wallet;
this.labelProperty = new SimpleStringProperty(this, "label", label);
this.children = FXCollections.observableList(entries);
}
public Entry(SimpleStringProperty labelProperty, ObservableList<Entry> children) {
public Entry(Wallet wallet, SimpleStringProperty labelProperty, ObservableList<Entry> children) {
this.wallet = wallet;
this.labelProperty = labelProperty;
this.children = children;
}
public Wallet getWallet() {
return wallet;
}
public String getLabel() {
return labelProperty.get();
}
@@ -0,0 +1,20 @@
package com.sparrowwallet.sparrow.wallet;
public enum FeeRatesSelection {
BLOCK_TARGET("Block Target"), MEMPOOL_SIZE("Mempool Size");
private final String name;
private FeeRatesSelection(String name) {
this.name = name;
}
public String getName() {
return name;
}
@Override
public String toString() {
return name;
}
}
@@ -13,14 +13,12 @@ import java.util.List;
import java.util.Objects;
public class HashIndexEntry extends Entry implements Comparable<HashIndexEntry> {
private final Wallet wallet;
private final BlockTransactionHashIndex hashIndex;
private final Type type;
private final KeyPurpose keyPurpose;
public HashIndexEntry(Wallet wallet, BlockTransactionHashIndex hashIndex, Type type, KeyPurpose keyPurpose) {
super(hashIndex.getLabel(), hashIndex.getSpentBy() != null ? List.of(new HashIndexEntry(wallet, hashIndex.getSpentBy(), Type.INPUT, keyPurpose)) : Collections.emptyList());
this.wallet = wallet;
super(wallet, hashIndex.getLabel(), hashIndex.getSpentBy() != null ? List.of(new HashIndexEntry(wallet, hashIndex.getSpentBy(), Type.INPUT, keyPurpose)) : Collections.emptyList());
this.hashIndex = hashIndex;
this.type = type;
this.keyPurpose = keyPurpose;
@@ -31,10 +29,6 @@ public class HashIndexEntry extends Entry implements Comparable<HashIndexEntry>
});
}
public Wallet getWallet() {
return wallet;
}
public BlockTransactionHashIndex getHashIndex() {
return hashIndex;
}
@@ -48,7 +42,7 @@ public class HashIndexEntry extends Entry implements Comparable<HashIndexEntry>
}
public BlockTransaction getBlockTransaction() {
return wallet.getTransactions().get(hashIndex.getHash());
return getWallet().getTransactions().get(hashIndex.getHash());
}
public String getDescription() {
@@ -63,7 +57,7 @@ public class HashIndexEntry extends Entry implements Comparable<HashIndexEntry>
}
public boolean isSpendable() {
return !isSpent() && (hashIndex.getHeight() > 0 || wallet.allInputsFromWallet(hashIndex.getHash()));
return !isSpent() && (hashIndex.getHeight() > 0 || getWallet().allInputsFromWallet(hashIndex.getHash()));
}
@Override
@@ -80,7 +74,7 @@ public class HashIndexEntry extends Entry implements Comparable<HashIndexEntry>
if (this == o) return true;
if (!(o instanceof HashIndexEntry)) return false;
HashIndexEntry that = (HashIndexEntry) o;
return wallet.equals(that.wallet) &&
return getWallet().equals(that.getWallet()) &&
hashIndex.equals(that.hashIndex) &&
type == that.type &&
keyPurpose == that.keyPurpose;
@@ -88,7 +82,7 @@ public class HashIndexEntry extends Entry implements Comparable<HashIndexEntry>
@Override
public int hashCode() {
return Objects.hash(wallet, hashIndex, type, keyPurpose);
return Objects.hash(getWallet(), hashIndex, type, keyPurpose);
}
@Override
@@ -101,6 +95,6 @@ public class HashIndexEntry extends Entry implements Comparable<HashIndexEntry>
return o.getHashIndex().getComparisonHeight() - getHashIndex().getComparisonHeight();
}
return (int)o.getHashIndex().getIndex() - (int)getHashIndex().getIndex();
return Long.compare(o.getHashIndex().getIndex(), (int)getHashIndex().getIndex());
}
}
@@ -363,8 +363,8 @@ public class KeystoreController extends WalletFormController implements Initiali
if(result.extendedKey != null && result.extendedKey.getKey().isPubKeyOnly()) {
xpub.setText(result.extendedKey.getExtendedKey());
} else if(result.exception != null) {
log.error("Error opening webcam", result.exception);
AppController.showErrorDialog("Error opening webcam", result.exception.getMessage());
log.error("Error scanning QR", result.exception);
AppController.showErrorDialog("Error scanning QR", result.exception.getMessage());
} else {
AppController.showErrorDialog("Invalid QR Code", "QR Code did not contain a valid " + Network.get().getXpubHeader().getDisplayName());
}
@@ -10,16 +10,14 @@ import com.sparrowwallet.sparrow.event.WalletEntryLabelChangedEvent;
import java.util.stream.Collectors;
public class NodeEntry extends Entry implements Comparable<NodeEntry> {
private final Wallet wallet;
private final WalletNode node;
public NodeEntry(Wallet wallet, WalletNode node) {
super(node.getLabel(),
super(wallet, node.getLabel(),
!node.getChildren().isEmpty() ?
node.getChildren().stream().map(childNode -> new NodeEntry(wallet, childNode)).collect(Collectors.toList()) :
node.getTransactionOutputs().stream().map(txo -> new HashIndexEntry(wallet, txo, HashIndexEntry.Type.OUTPUT, node.getKeyPurpose())).collect(Collectors.toList()));
this.wallet = wallet;
this.node = node;
labelProperty().addListener((observable, oldValue, newValue) -> {
@@ -28,24 +26,20 @@ public class NodeEntry extends Entry implements Comparable<NodeEntry> {
});
}
public Wallet getWallet() {
return wallet;
}
public WalletNode getNode() {
return node;
}
public Address getAddress() {
return wallet.getAddress(node);
return getWallet().getAddress(node);
}
public Script getOutputScript() {
return wallet.getOutputScript(node);
return getWallet().getOutputScript(node);
}
public String getOutputDescriptor() {
return wallet.getOutputDescriptor(node);
return getWallet().getOutputDescriptor(node);
}
@Override
@@ -7,6 +7,7 @@ import com.sparrowwallet.drongo.address.InvalidAddressException;
import com.sparrowwallet.drongo.address.P2PKHAddress;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.wallet.MaxUtxoSelector;
import com.sparrowwallet.drongo.wallet.Payment;
import com.sparrowwallet.drongo.wallet.UtxoSelector;
@@ -21,10 +22,10 @@ import com.sparrowwallet.sparrow.event.BitcoinUnitChangedEvent;
import com.sparrowwallet.sparrow.event.ExchangeRatesUpdatedEvent;
import com.sparrowwallet.sparrow.event.FiatCurrencySelectedEvent;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.ExchangeSource;
import com.sparrowwallet.sparrow.net.ExchangeSource;
import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.ListChangeListener;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
@@ -95,6 +96,14 @@ public class PaymentController extends WalletFormController implements Initializ
@Override
public void initializeView() {
address.textProperty().addListener((observable, oldValue, newValue) -> {
try {
BitcoinURI bitcoinURI = new BitcoinURI(newValue);
Platform.runLater(() -> updateFromURI(bitcoinURI));
return;
} catch(Exception e) {
//ignore, not a URI
}
revalidate(amount, amountListener);
maxButton.setDisable(!isValidRecipientAddress());
sendController.updateTransaction();
@@ -246,6 +255,10 @@ public class PaymentController extends WalletFormController implements Initializ
public void setPayment(Payment payment) {
if(getRecipientValueSats() == null || payment.getAmount() != getRecipientValueSats()) {
address.setText(payment.getAddress().toString());
if(payment.getLabel() != null) {
label.setText(payment.getLabel());
}
setRecipientValueSats(payment.getAmount());
setFiatAmount(AppController.getFiatCurrencyExchangeRate(), payment.getAmount());
}
@@ -291,20 +304,27 @@ public class PaymentController extends WalletFormController implements Initializ
if(optionalResult.isPresent()) {
QRScanDialog.Result result = optionalResult.get();
if(result.uri != null) {
if(result.uri.getAddress() != null) {
address.setText(result.uri.getAddress().toString());
}
if(result.uri.getLabel() != null) {
label.setText(result.uri.getLabel());
}
if(result.uri.getAmount() != null) {
setRecipientValueSats(result.uri.getAmount());
}
sendController.updateTransaction();
updateFromURI(result.uri);
}
}
}
private void updateFromURI(BitcoinURI bitcoinURI) {
if(bitcoinURI.getAddress() != null) {
address.setText(bitcoinURI.getAddress().toString());
}
if(bitcoinURI.getLabel() != null) {
label.setText(bitcoinURI.getLabel());
}
if(bitcoinURI.getAmount() != null) {
setRecipientValueSats(bitcoinURI.getAmount());
}
if(bitcoinURI.getPayjoinUrl() != null) {
AppController.addPayjoinURI(bitcoinURI);
}
sendController.updateTransaction();
}
public void addPayment(ActionEvent event) {
sendController.addPaymentTab();
}
@@ -2,11 +2,8 @@ package com.sparrowwallet.sparrow.wallet;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.InvalidAddressException;
import com.sparrowwallet.drongo.address.P2PKHAddress;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppController;
@@ -15,8 +12,9 @@ import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.ExchangeSource;
import com.sparrowwallet.sparrow.net.ExchangeSource;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.net.MempoolRateSize;
import javafx.application.Platform;
import javafx.beans.property.*;
import javafx.beans.value.ChangeListener;
@@ -36,6 +34,7 @@ import org.controlsfx.validation.Validator;
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import tornadofx.control.Field;
import java.io.IOException;
import java.net.URL;
@@ -49,15 +48,34 @@ public class SendController extends WalletFormController implements Initializabl
private static final Logger log = LoggerFactory.getLogger(SendController.class);
public static final List<Integer> TARGET_BLOCKS_RANGE = List.of(1, 2, 3, 4, 5, 10, 25, 50, 100, 500);
public static final List<Long> FEE_RATES_RANGE = List.of(1L, 2L, 4L, 8L, 16L, 32L, 64L, 128L, 256L, 512L, 1024L);
public static final double FALLBACK_FEE_RATE = 20000d / 1000;
@FXML
private TabPane paymentTabs;
@FXML
private ToggleGroup feeSelectionToggleGroup;
@FXML
private ToggleButton targetBlocksToggle;
@FXML
private ToggleButton mempoolSizeToggle;
@FXML
private Field targetBlocksField;
@FXML
private Slider targetBlocks;
@FXML
private Field feeRangeField;
@FXML
private Slider feeRange;
@FXML
private CopyableLabel feeRate;
@@ -71,7 +89,10 @@ public class SendController extends WalletFormController implements Initializabl
private FiatLabel fiatFeeAmount;
@FXML
private FeeRatesChart feeRatesChart;
private BlockTargetFeeRatesChart blockTargetFeeRatesChart;
@FXML
private MempoolSizeFeeRatesChart mempoolSizeFeeRatesChart;
@FXML
private TransactionDiagram transactionDiagram;
@@ -98,6 +119,8 @@ public class SendController extends WalletFormController implements Initializabl
private final StringProperty utxoLabelSelectionProperty = new SimpleStringProperty("");
private final BooleanProperty includeMempoolInputsProperty = new SimpleBooleanProperty(false);
private final ChangeListener<String> feeListener = new ChangeListener<>() {
@Override
public void changed(ObservableValue<? extends String> observable, String oldValue, String newValue) {
@@ -121,12 +144,12 @@ public class SendController extends WalletFormController implements Initializabl
if(targetBlocksFeeRates != null) {
setFeeRate(targetBlocksFeeRates.get(target));
feeRatesChart.select(target);
blockTargetFeeRatesChart.select(target);
} else {
feeRate.setText("Unknown");
}
Tooltip tooltip = new Tooltip("Target confirmation within " + target + " blocks");
Tooltip tooltip = new Tooltip("Target inclusion within " + target + " blocks");
targetBlocks.setTooltip(tooltip);
userFeeSet.set(false);
@@ -138,6 +161,19 @@ public class SendController extends WalletFormController implements Initializabl
}
};
private final ChangeListener<Number> feeRangeListener = new ChangeListener<>() {
@Override
public void changed(ObservableValue<? extends Number> observable, Number oldValue, Number newValue) {
setFeeRate(getFeeRangeRate());
userFeeSet.set(false);
for(Tab tab : paymentTabs.getTabs()) {
PaymentController controller = (PaymentController)tab.getUserData();
controller.revalidate();
}
updateTransaction();
}
};
private ValidationSupport validationSupport;
@Override
@@ -150,15 +186,7 @@ public class SendController extends WalletFormController implements Initializabl
addValidation();
addPaymentTab();
Platform.runLater(() -> {
StackPane stackPane = (StackPane)paymentTabs.lookup(".tab-header-area");
if(stackPane != null) {
tabHeader = stackPane;
tabHeader.managedProperty().bind(tabHeader.visibleProperty());
tabHeader.setVisible(false);
paymentTabs.getStyleClass().remove("initial");
}
});
initializeTabHeader(0);
paymentTabs.getTabs().addListener((ListChangeListener<Tab>) c -> {
if(tabHeader != null) {
@@ -188,6 +216,7 @@ public class SendController extends WalletFormController implements Initializabl
revalidate(fee, feeListener);
});
targetBlocksField.managedProperty().bind(targetBlocksField.visibleProperty());
targetBlocks.setMin(0);
targetBlocks.setMax(TARGET_BLOCKS_RANGE.size() - 1);
targetBlocks.setMajorTickUnit(1);
@@ -206,18 +235,52 @@ public class SendController extends WalletFormController implements Initializabl
});
targetBlocks.valueProperty().addListener(targetBlocksListener);
feeRatesChart.initialize();
feeRangeField.managedProperty().bind(feeRangeField.visibleProperty());
feeRangeField.visibleProperty().bind(targetBlocksField.visibleProperty().not());
feeRange.setMin(0);
feeRange.setMax(FEE_RATES_RANGE.size() - 1);
feeRange.setMajorTickUnit(1);
feeRange.setMinorTickCount(0);
feeRange.setLabelFormatter(new StringConverter<Double>() {
@Override
public String toString(Double object) {
return Long.toString(FEE_RATES_RANGE.get(object.intValue()));
}
@Override
public Double fromString(String string) {
return null;
}
});
feeRange.valueProperty().addListener(feeRangeListener);
blockTargetFeeRatesChart.managedProperty().bind(blockTargetFeeRatesChart.visibleProperty());
blockTargetFeeRatesChart.initialize();
Map<Integer, Double> targetBlocksFeeRates = getTargetBlocksFeeRates();
if(targetBlocksFeeRates != null) {
feeRatesChart.update(targetBlocksFeeRates);
blockTargetFeeRatesChart.update(targetBlocksFeeRates);
} else {
feeRate.setText("Unknown");
}
int defaultTarget = TARGET_BLOCKS_RANGE.get((TARGET_BLOCKS_RANGE.size() / 2) - 1);
int index = TARGET_BLOCKS_RANGE.indexOf(defaultTarget);
targetBlocks.setValue(index);
feeRatesChart.select(defaultTarget);
mempoolSizeFeeRatesChart.managedProperty().bind(mempoolSizeFeeRatesChart.visibleProperty());
mempoolSizeFeeRatesChart.visibleProperty().bind(blockTargetFeeRatesChart.visibleProperty().not());
mempoolSizeFeeRatesChart.initialize();
Map<Date, Set<MempoolRateSize>> mempoolHistogram = getMempoolHistogram();
if(mempoolHistogram != null) {
mempoolSizeFeeRatesChart.update(mempoolHistogram);
}
FeeRatesSelection feeRatesSelection = Config.get().getFeeRatesSelection();
feeRatesSelection = (feeRatesSelection == null ? FeeRatesSelection.BLOCK_TARGET : feeRatesSelection);
setDefaultFeeRate();
updateFeeRateSelection(feeRatesSelection);
feeSelectionToggleGroup.selectToggle(feeRatesSelection == FeeRatesSelection.BLOCK_TARGET ? targetBlocksToggle : mempoolSizeToggle);
feeSelectionToggleGroup.selectedToggleProperty().addListener((observable, oldValue, newValue) -> {
FeeRatesSelection newFeeRatesSelection = (FeeRatesSelection)newValue.getUserData();
Config.get().setFeeRatesSelection(newFeeRatesSelection);
EventManager.get().post(new FeeRatesSelectionChangedEvent(newFeeRatesSelection));
});
fee.setTextFormatter(new CoinTextFormatter());
fee.textProperty().addListener(feeListener);
@@ -232,7 +295,7 @@ public class SendController extends WalletFormController implements Initializabl
});
userFeeSet.addListener((observable, oldValue, newValue) -> {
feeRatesChart.select(0);
blockTargetFeeRatesChart.select(0);
Node thumb = getSliderThumb();
if(thumb != null) {
@@ -258,15 +321,12 @@ public class SendController extends WalletFormController implements Initializabl
walletTransactionProperty.addListener((observable, oldValue, walletTransaction) -> {
if(walletTransaction != null) {
for(int i = 0; i < paymentTabs.getTabs().size(); i++) {
Payment payment = walletTransaction.getPayments().get(i);
PaymentController controller = (PaymentController)paymentTabs.getTabs().get(i).getUserData();
controller.setPayment(payment);
}
setPayments(walletTransaction.getPayments());
double feeRate = walletTransaction.getFeeRate();
if(userFeeSet.get()) {
setTargetBlocks(getTargetBlocks(feeRate));
setFeeRangeRate(feeRate);
} else {
setFeeValueSats(walletTransaction.getFee());
}
@@ -279,6 +339,21 @@ public class SendController extends WalletFormController implements Initializabl
});
}
private void initializeTabHeader(int count) {
final int lookupCount = count;
Platform.runLater(() -> {
StackPane stackPane = (StackPane)paymentTabs.lookup(".tab-header-area");
if(stackPane != null) {
tabHeader = stackPane;
tabHeader.managedProperty().bind(tabHeader.visibleProperty());
tabHeader.setVisible(false);
paymentTabs.getStyleClass().remove("initial");
} else if(lookupCount < 20) {
initializeTabHeader(lookupCount+1);
}
});
}
public BitcoinUnit getBitcoinUnit(BitcoinUnit bitcoinUnit) {
BitcoinUnit unit = bitcoinUnit;
if(unit == null || unit.equals(BitcoinUnit.AUTO)) {
@@ -311,7 +386,8 @@ public class SendController extends WalletFormController implements Initializabl
}
public Tab getPaymentTab() {
Tab tab = new Tab(Integer.toString(paymentTabs.getTabs().size() + 1));
OptionalInt highestTabNo = paymentTabs.getTabs().stream().mapToInt(tab -> Integer.parseInt(tab.getText())).max();
Tab tab = new Tab(Integer.toString(highestTabNo.isPresent() ? highestTabNo.getAsInt() + 1 : 1));
try {
FXMLLoader paymentLoader = new FXMLLoader(AppController.class.getResource("wallet/payment.fxml"));
@@ -336,6 +412,22 @@ public class SendController extends WalletFormController implements Initializabl
return payments;
}
public void setPayments(List<Payment> payments) {
while(paymentTabs.getTabs().size() < payments.size()) {
addPaymentTab();
}
while(paymentTabs.getTabs().size() > payments.size()) {
paymentTabs.getTabs().remove(paymentTabs.getTabs().size() - 1);
}
for(int i = 0; i < paymentTabs.getTabs().size(); i++) {
Payment payment = payments.get(i);
PaymentController controller = (PaymentController)paymentTabs.getTabs().get(i).getUserData();
controller.setPayment(payment);
}
}
public void updateTransaction() {
updateTransaction(null);
}
@@ -362,7 +454,8 @@ public class SendController extends WalletFormController implements Initializabl
Integer currentBlockHeight = AppController.getCurrentBlockHeight();
boolean groupByAddress = Config.get().isGroupByAddress();
boolean includeMempoolChange = Config.get().isIncludeMempoolChange();
WalletTransaction walletTransaction = wallet.createWalletTransaction(getUtxoSelectors(), getUtxoFilters(), payments, getFeeRate(), getMinimumFeeRate(), userFee, currentBlockHeight, groupByAddress, includeMempoolChange);
boolean includeMempoolInputs = includeMempoolInputsProperty.get();
WalletTransaction walletTransaction = wallet.createWalletTransaction(getUtxoSelectors(), getUtxoFilters(), payments, getFeeRate(), getMinimumFeeRate(), userFee, currentBlockHeight, groupByAddress, includeMempoolChange, includeMempoolInputs);
walletTransactionProperty.setValue(walletTransaction);
insufficientInputsProperty.set(false);
@@ -398,6 +491,28 @@ public class SendController extends WalletFormController implements Initializabl
return Collections.emptyList();
}
private void updateFeeRateSelection(FeeRatesSelection feeRatesSelection) {
boolean blockTargetSelection = (feeRatesSelection == FeeRatesSelection.BLOCK_TARGET);
targetBlocksField.setVisible(blockTargetSelection);
blockTargetFeeRatesChart.setVisible(blockTargetSelection);
if(blockTargetSelection) {
setTargetBlocks(getTargetBlocks(getFeeRangeRate()));
} else {
setFeeRangeRate(getTargetBlocksFeeRates().get(getTargetBlocks()));
}
updateTransaction();
}
private void setDefaultFeeRate() {
int defaultTarget = TARGET_BLOCKS_RANGE.get((TARGET_BLOCKS_RANGE.size() / 2) - 1);
int index = TARGET_BLOCKS_RANGE.indexOf(defaultTarget);
Double defaultRate = getTargetBlocksFeeRates().get(defaultTarget);
targetBlocks.setValue(index);
blockTargetFeeRatesChart.select(defaultTarget);
setFeeRangeRate(defaultRate);
setFeeRate(getFeeRangeRate());
}
private Long getFeeValueSats() {
return getFeeValueSats(feeAmountUnit.getSelectionModel().getSelectedItem());
}
@@ -431,7 +546,7 @@ public class SendController extends WalletFormController implements Initializabl
for(Integer targetBlocks : targetBlocksFeeRates.keySet()) {
maxTargetBlocks = Math.max(maxTargetBlocks, targetBlocks);
Double candidate = targetBlocksFeeRates.get(targetBlocks);
if(feeRate > candidate) {
if(Math.round(feeRate) >= Math.round(candidate)) {
return targetBlocks;
}
}
@@ -443,7 +558,9 @@ public class SendController extends WalletFormController implements Initializabl
targetBlocks.valueProperty().removeListener(targetBlocksListener);
int index = TARGET_BLOCKS_RANGE.indexOf(target);
targetBlocks.setValue(index);
feeRatesChart.select(target);
blockTargetFeeRatesChart.select(target);
Tooltip tooltip = new Tooltip("Target inclusion within " + target + " blocks");
targetBlocks.setTooltip(tooltip);
targetBlocks.valueProperty().addListener(targetBlocksListener);
}
@@ -458,8 +575,22 @@ public class SendController extends WalletFormController implements Initializabl
return retrievedFeeRates;
}
private Double getFeeRangeRate() {
return Math.pow(2.0, feeRange.getValue());
}
private void setFeeRangeRate(Double feeRate) {
feeRange.valueProperty().removeListener(feeRangeListener);
feeRange.setValue(Math.log(feeRate) / Math.log(2));
feeRange.valueProperty().addListener(feeRangeListener);
}
public Double getFeeRate() {
return getTargetBlocksFeeRates().get(getTargetBlocks());
if(targetBlocksField.isVisible()) {
return getTargetBlocksFeeRates().get(getTargetBlocks());
} else {
return getFeeRangeRate();
}
}
private Double getMinimumFeeRate() {
@@ -468,6 +599,10 @@ public class SendController extends WalletFormController implements Initializabl
return Math.max(minRate, Transaction.DUST_RELAY_TX_FEE);
}
private Map<Date, Set<MempoolRateSize>> getMempoolHistogram() {
return AppController.getMempoolHistogram();
}
public boolean isInsufficientFeeRate() {
return walletTransactionProperty.get() != null && walletTransactionProperty.get().getFeeRate() < AppController.getMinimumRelayFeeRate();
}
@@ -522,9 +657,10 @@ public class SendController extends WalletFormController implements Initializabl
fiatFeeAmount.setText("");
userFeeSet.set(false);
targetBlocks.setValue(4);
setDefaultFeeRate();
utxoSelectorProperty.setValue(null);
utxoFilterProperty.setValue(null);
includeMempoolInputsProperty.set(false);
walletTransactionProperty.setValue(null);
createdWalletTransactionProperty.set(null);
@@ -598,7 +734,9 @@ public class SendController extends WalletFormController implements Initializabl
private void addWalletTransactionNodes() {
WalletTransaction walletTransaction = walletTransactionProperty.get();
Set<WalletNode> nodes = new LinkedHashSet<>(walletTransaction.getSelectedUtxos().values());
nodes.add(walletTransaction.getChangeNode());
if(walletTransaction.getChangeNode() != null) {
nodes.add(walletTransaction.getChangeNode());
}
List<WalletNode> consolidationNodes = walletTransaction.getConsolidationSendNodes();
nodes.addAll(consolidationNodes);
@@ -653,18 +791,38 @@ public class SendController extends WalletFormController implements Initializabl
@Subscribe
public void feeRatesUpdated(FeeRatesUpdatedEvent event) {
feeRatesChart.update(event.getTargetBlockFeeRates());
feeRatesChart.select(getTargetBlocks());
setFeeRate(event.getTargetBlockFeeRates().get(getTargetBlocks()));
blockTargetFeeRatesChart.update(event.getTargetBlockFeeRates());
blockTargetFeeRatesChart.select(getTargetBlocks());
mempoolSizeFeeRatesChart.update(getMempoolHistogram());
if(targetBlocksField.isVisible()) {
setFeeRate(event.getTargetBlockFeeRates().get(getTargetBlocks()));
}
}
@Subscribe
public void feeRateSelectionChanged(FeeRatesSelectionChangedEvent event) {
updateFeeRateSelection(event.getFeeRateSelection());
}
@Subscribe
public void spendUtxos(SpendUtxoEvent event) {
if(!event.getUtxoEntries().isEmpty() && event.getUtxoEntries().get(0).getWallet().equals(getWalletForm().getWallet())) {
List<BlockTransactionHashIndex> utxos = event.getUtxoEntries().stream().map(HashIndexEntry::getHashIndex).collect(Collectors.toList());
if(!event.getUtxos().isEmpty() && event.getWallet().equals(getWalletForm().getWallet())) {
if(event.getPayments() != null) {
clear(null);
setPayments(event.getPayments());
}
if(event.getFee() != null) {
setFeeValueSats(event.getFee());
userFeeSet.set(true);
}
includeMempoolInputsProperty.set(event.isIncludeMempoolInputs());
List<BlockTransactionHashIndex> utxos = event.getUtxos();
utxoSelectorProperty.set(new PresetUtxoSelector(utxos));
utxoFilterProperty.set(null);
updateTransaction(true);
updateTransaction(event.getPayments() == null);
}
}
@@ -13,10 +13,7 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.AppController;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.CopyableLabel;
import com.sparrowwallet.sparrow.control.DescriptorArea;
import com.sparrowwallet.sparrow.control.TextAreaDialog;
import com.sparrowwallet.sparrow.control.WalletPasswordDialog;
import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.RequestOpenWalletsEvent;
import com.sparrowwallet.sparrow.event.SettingsChangedEvent;
import com.sparrowwallet.sparrow.event.StorageEvent;
@@ -251,6 +248,21 @@ public class SettingsController extends WalletFormController implements Initiali
}
}
public void scanDescriptorQR(ActionEvent event) {
QRScanDialog qrScanDialog = new QRScanDialog();
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
if(optionalResult.isPresent()) {
QRScanDialog.Result result = optionalResult.get();
if(result.outputDescriptor != null) {
setDescriptorText(result.outputDescriptor.toString());
} else if(result.payload != null && !result.payload.isEmpty()) {
setDescriptorText(result.payload);
} else if(result.exception != null) {
AppController.showErrorDialog("Error scanning QR", result.exception.getMessage());
}
}
}
public void editDescriptor(ActionEvent event) {
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(walletForm.getWallet());
String outputDescriptorString = outputDescriptor.toString(walletForm.getWallet().isValid());
@@ -260,22 +272,26 @@ public class SettingsController extends WalletFormController implements Initiali
dialog.getDialogPane().setHeaderText("Wallet output descriptor:");
Optional<String> text = dialog.showAndWait();
if(text.isPresent() && !text.get().isEmpty() && !text.get().equals(outputDescriptorString)) {
try {
OutputDescriptor editedOutputDescriptor = OutputDescriptor.getOutputDescriptor(text.get());
Wallet editedWallet = editedOutputDescriptor.toWallet();
setDescriptorText(text.get());
}
}
editedWallet.setName(getWalletForm().getWallet().getName());
keystoreTabs.getTabs().removeAll(keystoreTabs.getTabs());
totalKeystores.unbind();
totalKeystores.setValue(0);
walletForm.setWallet(editedWallet);
initialising = true;
setFieldsFromWallet(editedWallet);
private void setDescriptorText(String text) {
try {
OutputDescriptor editedOutputDescriptor = OutputDescriptor.getOutputDescriptor(text.trim().replace("\\", ""));
Wallet editedWallet = editedOutputDescriptor.toWallet();
EventManager.get().post(new SettingsChangedEvent(editedWallet, SettingsChangedEvent.Type.POLICY));
} catch(Exception e) {
AppController.showErrorDialog("Invalid output descriptor", e.getMessage());
}
editedWallet.setName(getWalletForm().getWallet().getName());
keystoreTabs.getTabs().removeAll(keystoreTabs.getTabs());
totalKeystores.unbind();
totalKeystores.setValue(0);
walletForm.setWallet(editedWallet);
initialising = true;
setFieldsFromWallet(editedWallet);
EventManager.get().post(new SettingsChangedEvent(editedWallet, SettingsChangedEvent.Type.POLICY));
} catch(Exception e) {
AppController.showErrorDialog("Invalid output descriptor", e.getMessage());
}
}
@@ -2,10 +2,9 @@ package com.sparrowwallet.sparrow.wallet;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import com.sparrowwallet.drongo.wallet.BlockTransactionHash;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.protocol.TransactionInput;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.WalletTabData;
import com.sparrowwallet.sparrow.event.WalletBlockHeightChangedEvent;
@@ -20,12 +19,10 @@ import java.util.*;
import java.util.stream.Collectors;
public class TransactionEntry extends Entry implements Comparable<TransactionEntry> {
private final Wallet wallet;
private final BlockTransaction blockTransaction;
public TransactionEntry(Wallet wallet, BlockTransaction blockTransaction, Map<BlockTransactionHashIndex, KeyPurpose> inputs, Map<BlockTransactionHashIndex, KeyPurpose> outputs) {
super(blockTransaction.getLabel(), createChildEntries(wallet, inputs, outputs));
this.wallet = wallet;
super(wallet, blockTransaction.getLabel(), createChildEntries(wallet, inputs, outputs));
this.blockTransaction = blockTransaction;
labelProperty().addListener((observable, oldValue, newValue) -> {
@@ -39,10 +36,6 @@ public class TransactionEntry extends Entry implements Comparable<TransactionEnt
}
}
public Wallet getWallet() {
return wallet;
}
public BlockTransaction getBlockTransaction() {
return blockTransaction;
}
@@ -71,7 +64,7 @@ public class TransactionEntry extends Entry implements Comparable<TransactionEnt
}
public int calculateConfirmations() {
return blockTransaction.getConfirmations(wallet.getStoredBlockHeight());
return blockTransaction.getConfirmations(getWallet().getStoredBlockHeight());
}
public String getConfirmationsDescription() {
@@ -85,6 +78,35 @@ public class TransactionEntry extends Entry implements Comparable<TransactionEnt
}
}
public boolean isComplete() {
int validEntries = 0;
Map<BlockTransactionHashIndex, WalletNode> walletTxos = getWallet().getWalletTxos();
for(TransactionInput txInput : blockTransaction.getTransaction().getInputs()) {
Optional<BlockTransactionHashIndex> optRef = walletTxos.keySet().stream().filter(ref -> ref.getHash().equals(txInput.getOutpoint().getHash()) && ref.getIndex() == txInput.getOutpoint().getIndex()).findFirst();
if(optRef.isPresent()) {
validEntries++;
if(getChildren().stream().noneMatch(entry -> ((HashIndexEntry)entry).getHashIndex().equals(optRef.get().getSpentBy()) && ((HashIndexEntry)entry).getType().equals(HashIndexEntry.Type.INPUT))) {
return false;
}
}
}
for(TransactionOutput txOutput : blockTransaction.getTransaction().getOutputs()) {
Optional<BlockTransactionHashIndex> optRef = walletTxos.keySet().stream().filter(ref -> ref.getHash().equals(txOutput.getHash()) && ref.getIndex() == txOutput.getIndex()).findFirst();
if(optRef.isPresent()) {
validEntries++;
if(getChildren().stream().noneMatch(entry -> ((HashIndexEntry)entry).getHashIndex().equals(optRef.get()) && ((HashIndexEntry)entry).getType().equals(HashIndexEntry.Type.OUTPUT))) {
return false;
}
}
}
if(getChildren().size() != validEntries) {
return false;
}
return true;
}
private static List<Entry> createChildEntries(Wallet wallet, Map<BlockTransactionHashIndex, KeyPurpose> incoming, Map<BlockTransactionHashIndex, KeyPurpose> outgoing) {
List<Entry> incomingOutputEntries = incoming.entrySet().stream().map(input -> new TransactionHashIndexEntry(wallet, input.getKey(), HashIndexEntry.Type.OUTPUT, input.getValue())).collect(Collectors.toList());
List<Entry> outgoingInputEntries = outgoing.entrySet().stream().map(output -> new TransactionHashIndexEntry(wallet, output.getKey(), HashIndexEntry.Type.INPUT, output.getValue())).collect(Collectors.toList());
@@ -97,15 +119,15 @@ public class TransactionEntry extends Entry implements Comparable<TransactionEnt
TransactionHashIndexEntry entry1 = (TransactionHashIndexEntry) o1;
TransactionHashIndexEntry entry2 = (TransactionHashIndexEntry) o2;
if (!entry1.getHashIndex().getHash().equals(entry2.getHashIndex().getHash())) {
if(!entry1.getHashIndex().getHash().equals(entry2.getHashIndex().getHash())) {
return entry1.getHashIndex().getHash().compareTo(entry2.getHashIndex().getHash());
}
if (!entry1.getType().equals(entry2.getType())) {
if(!entry1.getType().equals(entry2.getType())) {
return entry1.getType().ordinal() - entry2.getType().ordinal();
}
return (int) entry1.getHashIndex().getIndex() - (int) entry2.getHashIndex().getIndex();
return Long.compare(entry1.getHashIndex().getIndex(), entry2.getHashIndex().getIndex());
});
return childEntries;
@@ -116,14 +138,12 @@ public class TransactionEntry extends Entry implements Comparable<TransactionEnt
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
TransactionEntry that = (TransactionEntry) o;
// Even though the txid identifies a transaction, receiving an incomplete set of script hash notifications can result in some inputs/outputs for a tx being missing.
// To resolve this we check the number of children, but not the children themselves (since we don't care here when they are spent)
return wallet.equals(that.wallet) && blockTransaction.equals(that.blockTransaction) && getChildren().size() == that.getChildren().size();
return getWallet().equals(that.getWallet()) && blockTransaction.equals(that.blockTransaction);
}
@Override
public int hashCode() {
return Objects.hash(wallet, blockTransaction, getChildren().size());
return Objects.hash(getWallet(), blockTransaction, getChildren().size());
}
@Override
@@ -211,7 +231,7 @@ public class TransactionEntry extends Entry implements Comparable<TransactionEnt
@Subscribe
public void walletTabsClosed(WalletTabsClosedEvent event) {
for(WalletTabData tabData : event.getClosedWalletTabData()) {
if(tabData.getWalletForm().getWallet() == wallet) {
if(tabData.getWalletForm().getWallet() == getWallet()) {
try {
EventManager.get().unregister(this);
} catch(IllegalArgumentException e) {
@@ -9,7 +9,7 @@ import com.sparrowwallet.sparrow.control.CoinLabel;
import com.sparrowwallet.sparrow.control.FiatLabel;
import com.sparrowwallet.sparrow.control.TransactionsTreeTable;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.ExchangeSource;
import com.sparrowwallet.sparrow.net.ExchangeSource;
import javafx.collections.ListChangeListener;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.wallet;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.CoinLabel;
import com.sparrowwallet.sparrow.control.UtxosChart;
@@ -81,8 +82,9 @@ public class UtxosController extends WalletFormController implements Initializab
.filter(e -> e.getType().equals(HashIndexEntry.Type.OUTPUT) && e.isSpendable())
.collect(Collectors.toList());
EventManager.get().post(new SendActionEvent(utxoEntries));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(utxoEntries)));
final List<BlockTransactionHashIndex> spendingUtxos = utxoEntries.stream().map(HashIndexEntry::getHashIndex).collect(Collectors.toList());
EventManager.get().post(new SendActionEvent(getWalletForm().getWallet(), spendingUtxos));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(getWalletForm().getWallet(), spendingUtxos)));
}
public void clear(ActionEvent event) {
@@ -121,7 +121,7 @@ public class WalletController extends WalletFormController implements Initializa
@Subscribe
public void sendAction(SendActionEvent event) {
if(!event.getUtxoEntries().isEmpty() && event.getUtxoEntries().get(0).getWallet().equals(walletForm.getWallet())) {
if(!event.getUtxos().isEmpty() && event.getWallet().equals(walletForm.getWallet())) {
selectFunction(Function.SEND);
}
}
@@ -79,14 +79,14 @@ public class WalletForm {
log.debug(node == null ? "Refreshing full wallet history" : "Requesting node wallet history for " + node.getDerivationPath());
ElectrumServer.TransactionHistoryService historyService = new ElectrumServer.TransactionHistoryService(wallet, getWalletTransactionNodes(node));
historyService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new WalletHistoryStatusEvent(true));
EventManager.get().post(new WalletHistoryStatusEvent(wallet, true));
updateWallet(previousWallet, blockHeight);
});
historyService.setOnFailed(workerStateEvent -> {
log.error("Error retrieving wallet history", workerStateEvent.getSource().getException());
EventManager.get().post(new WalletHistoryStatusEvent(workerStateEvent.getSource().getException().getMessage()));
EventManager.get().post(new WalletHistoryStatusEvent(wallet, workerStateEvent.getSource().getException().getMessage()));
});
EventManager.get().post(new WalletHistoryStatusEvent(false));
EventManager.get().post(new WalletHistoryStatusEvent(wallet, false));
historyService.start();
}
}
@@ -9,23 +9,20 @@ import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.NewWalletTransactionsEvent;
import javafx.beans.property.LongProperty;
import javafx.beans.property.LongPropertyBase;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.*;
import java.util.stream.Collectors;
public class WalletTransactionsEntry extends Entry {
private final Wallet wallet;
private static final Logger log = LoggerFactory.getLogger(WalletTransactionsEntry.class);
public WalletTransactionsEntry(Wallet wallet) {
super(wallet.getName(), getWalletTransactions(wallet).stream().map(WalletTransaction::getTransactionEntry).collect(Collectors.toList()));
this.wallet = wallet;
super(wallet, wallet.getName(), getWalletTransactions(wallet).stream().map(WalletTransaction::getTransactionEntry).collect(Collectors.toList()));
calculateBalances();
}
public Wallet getWallet() {
return wallet;
}
@Override
public Long getValue() {
return getBalance();
@@ -54,7 +51,7 @@ public class WalletTransactionsEntry extends Entry {
}
public void updateTransactions() {
List<Entry> current = getWalletTransactions(wallet).stream().map(WalletTransaction::getTransactionEntry).collect(Collectors.toList());
List<Entry> current = getWalletTransactions(getWallet()).stream().map(WalletTransaction::getTransactionEntry).collect(Collectors.toList());
List<Entry> previous = new ArrayList<>(getChildren());
List<Entry> entriesAdded = new ArrayList<>(current);
@@ -67,11 +64,21 @@ public class WalletTransactionsEntry extends Entry {
calculateBalances();
if(!entriesAdded.isEmpty()) {
List<Entry> entriesComplete = entriesAdded.stream().filter(txEntry -> ((TransactionEntry)txEntry).isComplete()).collect(Collectors.toList());
if(!entriesComplete.isEmpty()) {
List<BlockTransaction> blockTransactions = entriesAdded.stream().map(txEntry -> ((TransactionEntry)txEntry).getBlockTransaction()).collect(Collectors.toList());
long totalBlockchainValue = entriesAdded.stream().filter(txEntry -> ((TransactionEntry)txEntry).getConfirmations() > 0).mapToLong(Entry::getValue).sum();
long totalMempoolValue = entriesAdded.stream().filter(txEntry -> ((TransactionEntry)txEntry).getConfirmations() == 0).mapToLong(Entry::getValue).sum();
EventManager.get().post(new NewWalletTransactionsEvent(wallet, blockTransactions, totalBlockchainValue, totalMempoolValue));
EventManager.get().post(new NewWalletTransactionsEvent(getWallet(), blockTransactions, totalBlockchainValue, totalMempoolValue));
}
if(entriesAdded.size() > entriesComplete.size()) {
entriesAdded.removeAll(entriesComplete);
for(Entry entry : entriesAdded) {
TransactionEntry txEntry = (TransactionEntry)entry;
getChildren().remove(txEntry);
log.warn("Removing and not notifying incomplete entry " + ((TransactionEntry)entry).getBlockTransaction().getHashAsString() + " value " + txEntry.getValue());
}
}
}
@@ -6,18 +6,11 @@ import java.util.*;
import java.util.stream.Collectors;
public class WalletUtxosEntry extends Entry {
private final Wallet wallet;
public WalletUtxosEntry(Wallet wallet) {
super(wallet.getName(), wallet.getWalletUtxos().entrySet().stream().map(entry -> new UtxoEntry(wallet, entry.getKey(), HashIndexEntry.Type.OUTPUT, entry.getValue())).collect(Collectors.toList()));
this.wallet = wallet;
super(wallet, wallet.getName(), wallet.getWalletUtxos().entrySet().stream().map(entry -> new UtxoEntry(wallet, entry.getKey(), HashIndexEntry.Type.OUTPUT, entry.getValue())).collect(Collectors.toList()));
calculateDuplicates();
}
public Wallet getWallet() {
return wallet;
}
@Override
public Long getValue() {
return 0L;
@@ -42,7 +35,7 @@ public class WalletUtxosEntry extends Entry {
}
public void updateUtxos() {
List<Entry> current = wallet.getWalletUtxos().entrySet().stream().map(entry -> new UtxoEntry(wallet, entry.getKey(), HashIndexEntry.Type.OUTPUT, entry.getValue())).collect(Collectors.toList());
List<Entry> current = getWallet().getWalletUtxos().entrySet().stream().map(entry -> new UtxoEntry(getWallet(), entry.getKey(), HashIndexEntry.Type.OUTPUT, entry.getValue())).collect(Collectors.toList());
List<Entry> previous = new ArrayList<>(getChildren());
List<Entry> entriesAdded = new ArrayList<>(current);
+1
View File
@@ -1,5 +1,6 @@
open module com.sparrowwallet.sparrow {
requires java.desktop;
requires java.net.http;
requires javafx.controls;
requires javafx.fxml;
requires javafx.graphics;
@@ -4,6 +4,7 @@
-fx-base: #373e43;
-fx-control-inner-background: derive(-fx-base, 35%);
-fx-control-inner-background-alt: -fx-control-inner-background ;
-fx-default-button: -fx-accent;
}
.label{
@@ -41,7 +42,7 @@
-fx-base: -fx-accent ;
}
.table-view{
.table-view, .tree-view {
/*-fx-background-color: derive(-fx-base, 10%);*/
-fx-selection-bar-non-focused: derive(-fx-base, 50%);
}
@@ -97,6 +98,10 @@
-fx-fill: #e06c75;
}
.root .invalid-checksum {
-fx-text-fill: #e06c75;
}
.root .etched-raised-border {
-fx-border-color: #ffffff, #000000;
-fx-border-style: solid, solid;
@@ -12,9 +12,10 @@
<?import javafx.collections.FXCollections?>
<?import javafx.geometry.Insets?>
<?import com.sparrowwallet.drongo.BitcoinUnit?>
<?import com.sparrowwallet.sparrow.io.ExchangeSource?>
<?import com.sparrowwallet.sparrow.net.ExchangeSource?>
<?import com.sparrowwallet.sparrow.control.UnlabeledToggleSwitch?>
<?import com.sparrowwallet.sparrow.control.HelpLabel?>
<?import com.sparrowwallet.sparrow.net.FeeRatesSource?>
<GridPane hgap="10.0" vgap="10.0" stylesheets="@preferences.css, @../general.css" xmlns="http://javafx.com/javafx/10.0.2-internal" xmlns:fx="http://javafx.com/fxml/1" fx:controller="com.sparrowwallet.sparrow.preferences.GeneralPreferencesController">
<padding>
@@ -41,6 +42,18 @@
</ComboBox>
<HelpLabel helpText="Display unit for bitcoin amounts.\nAuto displays amounts over 1 BTC in BTC, and amounts under that in satoshis."/>
</Field>
<Field text="Fee rates source:">
<ComboBox fx:id="feeRatesSource">
<items>
<FXCollections fx:factory="observableArrayList">
<FeeRatesSource fx:constant="ELECTRUM_SERVER" />
<FeeRatesSource fx:constant="MEMPOOL_SPACE" />
<FeeRatesSource fx:constant="BITCOINFEES_EARN_COM" />
</FXCollections>
</items>
</ComboBox>
<HelpLabel helpText="Recommended fee rates can be sourced from your connected Electrum server, or an external source."/>
</Field>
</Fieldset>
<Fieldset inputGrow="SOMETIMES" text="Fiat" styleClass="wideLabelFieldSet">
<Field text="Currency:">
@@ -248,6 +248,11 @@
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="20" icon="ARROW_DOWN" />
</graphic>
</Button>
<Button fx:id="payjoinButton" defaultButton="true" HBox.hgrow="ALWAYS" text="Get Payjoin Transaction" contentDisplay="TOP" wrapText="true" textAlignment="CENTER" onAction="#getPayjoinTransaction">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="20" icon="RANDOM" />
</graphic>
</Button>
</HBox>
</VBox>
</Fieldset>
@@ -24,4 +24,7 @@
.locktime { -fx-fill: color-grey }
.legend {
-fx-content-display: RIGHT;
-fx-graphic-text-gap: 5px;
}
@@ -39,7 +39,7 @@
-fx-max-width: 76px;
}
#feeRatesChart {
.feeRatesChart {
-fx-max-width: 335px;
-fx-max-height: 130px;
}
@@ -89,6 +89,10 @@
-fx-stroke-width: 1px;
}
#transactionDiagram .input-dashed-line {
-fx-stroke-dash-array: 5px 5px;
}
#transactionDiagram .utxo-label .button {
-fx-padding: 0;
-fx-pref-height: 18;
@@ -103,4 +107,4 @@
#transactionDiagram .utxo-label:hover .button .label .text {
-fx-fill: -fx-text-base-color;
}
}
@@ -14,17 +14,20 @@
<?import javafx.geometry.Insets?>
<?import com.sparrowwallet.sparrow.control.CopyableLabel?>
<?import javafx.collections.FXCollections?>
<?import com.sparrowwallet.sparrow.control.FeeRatesChart?>
<?import com.sparrowwallet.sparrow.control.BlockTargetFeeRatesChart?>
<?import javafx.scene.chart.CategoryAxis?>
<?import javafx.scene.chart.NumberAxis?>
<?import com.sparrowwallet.sparrow.control.TransactionDiagram?>
<?import com.sparrowwallet.drongo.BitcoinUnit?>
<?import com.sparrowwallet.sparrow.control.FiatLabel?>
<?import org.controlsfx.glyphfont.Glyph?>
<?import com.sparrowwallet.sparrow.control.MempoolSizeFeeRatesChart?>
<?import org.controlsfx.control.SegmentedButton?>
<?import com.sparrowwallet.sparrow.wallet.FeeRatesSelection?>
<BorderPane stylesheets="@send.css, @wallet.css, @../script.css, @../general.css" styleClass="wallet-pane" xmlns="http://javafx.com/javafx/10.0.2-internal" xmlns:fx="http://javafx.com/fxml/1" fx:controller="com.sparrowwallet.sparrow.wallet.SendController">
<center>
<VBox spacing="10">
<VBox>
<GridPane styleClass="send-form" hgap="10.0" vgap="10.0">
<padding>
<Insets left="25.0" right="25.0" top="25.0" />
@@ -47,11 +50,45 @@
<TabPane fx:id="paymentTabs" side="RIGHT" styleClass="initial" />
</center>
</BorderPane>
<Form GridPane.columnIndex="0" GridPane.rowIndex="1">
<Fieldset inputGrow="SOMETIMES" text="Fee">
<Field text="Block target">
<Form styleClass="title-form" GridPane.columnIndex="0" GridPane.rowIndex="1">
<Fieldset inputGrow="ALWAYS" text="Fee"/>
</Form>
<HBox GridPane.columnIndex="1" GridPane.rowIndex="1" GridPane.columnSpan="2" GridPane.halignment="RIGHT" alignment="CENTER_RIGHT">
<padding>
<Insets right="10.0" />
</padding>
<SegmentedButton>
<toggleGroup>
<ToggleGroup fx:id="feeSelectionToggleGroup" />
</toggleGroup>
<buttons>
<ToggleButton fx:id="targetBlocksToggle" text="Target Blocks" toggleGroup="$feeSelectionToggleGroup">
<tooltip>
<Tooltip text="Determine fee via estimated number of blocks"/>
</tooltip>
<userData>
<FeeRatesSelection fx:constant="BLOCK_TARGET"/>
</userData>
</ToggleButton>
<ToggleButton fx:id="mempoolSizeToggle" text="Mempool Size" toggleGroup="$feeSelectionToggleGroup">
<tooltip>
<Tooltip text="Determine fee via current mempool size"/>
</tooltip>
<userData>
<FeeRatesSelection fx:constant="MEMPOOL_SIZE"/>
</userData>
</ToggleButton>
</buttons>
</SegmentedButton>
</HBox>
<Form GridPane.columnIndex="0" GridPane.rowIndex="2">
<Fieldset inputGrow="SOMETIMES">
<Field fx:id="targetBlocksField" text="Block target">
<Slider fx:id="targetBlocks" snapToTicks="true" showTickLabels="true" showTickMarks="true" />
</Field>
<Field fx:id="feeRangeField" text="Rate Range:">
<Slider fx:id="feeRange" snapToTicks="false" showTickLabels="true" showTickMarks="true" />
</Field>
<Field fx:id="feeRateField" text="Rate:">
<CopyableLabel fx:id="feeRate" />
</Field>
@@ -70,15 +107,23 @@
</Field>
</Fieldset>
</Form>
<AnchorPane GridPane.columnIndex="1" GridPane.rowIndex="1" GridPane.columnSpan="2">
<FeeRatesChart fx:id="feeRatesChart" legendVisible="false" AnchorPane.topAnchor="10" AnchorPane.leftAnchor="20" animated="false">
<AnchorPane GridPane.columnIndex="1" GridPane.rowIndex="2" GridPane.columnSpan="2">
<BlockTargetFeeRatesChart fx:id="blockTargetFeeRatesChart" styleClass="feeRatesChart" legendVisible="false" AnchorPane.topAnchor="10" AnchorPane.leftAnchor="20" animated="false">
<xAxis>
<CategoryAxis side="BOTTOM" />
</xAxis>
<yAxis>
<NumberAxis side="LEFT" />
</yAxis>
</FeeRatesChart>
</BlockTargetFeeRatesChart>
<MempoolSizeFeeRatesChart fx:id="mempoolSizeFeeRatesChart" styleClass="feeRatesChart" legendVisible="false" AnchorPane.topAnchor="10" AnchorPane.leftAnchor="20" animated="false">
<xAxis>
<CategoryAxis side="BOTTOM" />
</xAxis>
<yAxis>
<NumberAxis side="LEFT" />
</yAxis>
</MempoolSizeFeeRatesChart>
</AnchorPane>
</GridPane>
<AnchorPane>
@@ -11,6 +11,7 @@
<?import com.sparrowwallet.drongo.protocol.ScriptType?>
<?import com.sparrowwallet.sparrow.control.DescriptorArea?>
<?import org.fxmisc.flowless.VirtualizedScrollPane?>
<?import org.controlsfx.glyphfont.Glyph?>
<BorderPane stylesheets="@settings.css, @wallet.css, @../script.css, @../descriptor.css, @../general.css" styleClass="wallet-pane" xmlns="http://javafx.com/javafx/10.0.2-internal" xmlns:fx="http://javafx.com/fxml/1" fx:controller="com.sparrowwallet.sparrow.wallet.SettingsController">
<center>
@@ -79,6 +80,14 @@
<DescriptorArea fx:id="descriptor" editable="false" styleClass="uneditable-codearea" prefHeight="27" maxHeight="27" />
</content>
</VirtualizedScrollPane>
<Button onAction="#scanDescriptorQR">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="CAMERA" />
</graphic>
<tooltip>
<Tooltip text="Scan a QR code" />
</tooltip>
</Button>
<Button text="Edit..." onAction="#editDescriptor" />
</Field>
</Fieldset>
@@ -43,7 +43,7 @@
}
.transaction-row.confirming {
-fx-opacity: 0.7;
-fx-opacity: 0.8;
}
.utxo-row.unspendable {
Binary file not shown.
Binary file not shown.
Binary file not shown.
+10
View File
@@ -0,0 +1,10 @@
{
"mode": "OFFLINE",
"bitcoinUnit": "AUTO",
"groupByAddress": true,
"includeMempoolChange": true,
"notifyNewTransactions": true,
"checkNewVersions": true,
"theme": "LIGHT",
"useProxy": false
}