mirror of
https://github.com/sparrowwallet/sparrow.git
synced 2026-07-31 03:56:16 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c804737563 | ||
|
|
d395c7897e | ||
|
|
6e2e5d7a9c | ||
|
|
99a39aa83f | ||
|
|
2ca8b91283 | ||
|
|
94960567b5 | ||
|
|
e14349a645 | ||
|
|
ee7b741a69 | ||
|
|
02c8415a7b | ||
|
|
f305e0bdc2 | ||
|
|
d8b7938a63 | ||
|
|
44ac7f3c0e | ||
|
|
2b55b5feb3 | ||
|
|
43bf6ab265 | ||
|
|
80b7ee803b | ||
|
|
8e23bd64c7 | ||
|
|
bf6fbebd9e | ||
|
|
0b9052dee9 | ||
|
|
6460cb88a5 | ||
|
|
90ac786707 | ||
|
|
c86006335b | ||
|
|
a444e2af2f | ||
|
|
bcd2c966a5 | ||
|
|
f826c2d423 | ||
|
|
37bb1c5d97 | ||
|
|
68da8e6027 | ||
|
|
87fe0accd5 | ||
|
|
8d413e839c | ||
|
|
1392199f5c | ||
|
|
dc33b64a9c | ||
|
|
1b220c72ea | ||
|
|
bcf6f77340 | ||
|
|
0aac8bbea7 | ||
|
|
e1acaa8a78 | ||
|
|
13a486597c | ||
|
|
389ce2180d | ||
|
|
a7a3ad012c | ||
|
|
9f5eb41d90 | ||
|
|
3a4d385560 | ||
|
|
d3881f76b9 | ||
|
|
be5a48ce71 |
+2
-2
@@ -5,7 +5,7 @@ plugins {
|
||||
id 'org.beryx.jlink' version '2.22.0'
|
||||
}
|
||||
|
||||
def sparrowVersion = '0.9.6'
|
||||
def sparrowVersion = '0.9.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
@@ -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")));
|
||||
|
||||
|
||||
+2
-2
@@ -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);
|
||||
|
||||
+20
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
+18
-4
@@ -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 -> {
|
||||
|
||||
+5
-1
@@ -109,7 +109,7 @@ public class ServerPreferencesController extends PreferencesDetailController {
|
||||
FileChooser fileChooser = new FileChooser();
|
||||
fileChooser.setTitle("Select Electrum Server certificate");
|
||||
fileChooser.getExtensionFilters().addAll(
|
||||
new FileChooser.ExtensionFilter("All Files", "*"),
|
||||
new FileChooser.ExtensionFilter("All Files", org.controlsfx.tools.Platform.getCurrent().equals(org.controlsfx.tools.Platform.UNIX) ? "*" : "*.*"),
|
||||
new FileChooser.ExtensionFilter("CRT", "*.crt")
|
||||
);
|
||||
|
||||
@@ -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,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.
BIN
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"mode": "OFFLINE",
|
||||
"bitcoinUnit": "AUTO",
|
||||
"groupByAddress": true,
|
||||
"includeMempoolChange": true,
|
||||
"notifyNewTransactions": true,
|
||||
"checkNewVersions": true,
|
||||
"theme": "LIGHT",
|
||||
"useProxy": false
|
||||
}
|
||||
Reference in New Issue
Block a user