mirror of
https://github.com/sparrowwallet/sparrow.git
synced 2026-08-09 00:04:55 +00:00
1158 lines
48 KiB
Java
1158 lines
48 KiB
Java
package com.sparrowwallet.sparrow.wallet;
|
|
|
|
import com.google.common.eventbus.Subscribe;
|
|
import com.sparrowwallet.drongo.BitcoinUnit;
|
|
import com.sparrowwallet.drongo.address.InvalidAddressException;
|
|
import com.sparrowwallet.drongo.protocol.Transaction;
|
|
import com.sparrowwallet.drongo.psbt.PSBT;
|
|
import com.sparrowwallet.drongo.wallet.*;
|
|
import com.sparrowwallet.sparrow.AppServices;
|
|
import com.sparrowwallet.sparrow.CurrencyRate;
|
|
import com.sparrowwallet.sparrow.EventManager;
|
|
import com.sparrowwallet.sparrow.control.*;
|
|
import com.sparrowwallet.sparrow.event.*;
|
|
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
|
import com.sparrowwallet.sparrow.io.Config;
|
|
import com.sparrowwallet.sparrow.net.*;
|
|
import javafx.animation.KeyFrame;
|
|
import javafx.animation.Timeline;
|
|
import javafx.application.Platform;
|
|
import javafx.beans.property.*;
|
|
import javafx.beans.value.ChangeListener;
|
|
import javafx.beans.value.ObservableValue;
|
|
import javafx.collections.ListChangeListener;
|
|
import javafx.concurrent.Service;
|
|
import javafx.concurrent.Task;
|
|
import javafx.event.ActionEvent;
|
|
import javafx.fxml.FXML;
|
|
import javafx.fxml.FXMLLoader;
|
|
import javafx.fxml.Initializable;
|
|
import javafx.scene.Node;
|
|
import javafx.scene.control.*;
|
|
import javafx.scene.layout.StackPane;
|
|
import javafx.util.Duration;
|
|
import javafx.util.StringConverter;
|
|
import org.controlsfx.glyphfont.Glyph;
|
|
import org.controlsfx.validation.ValidationResult;
|
|
import org.controlsfx.validation.ValidationSupport;
|
|
import org.controlsfx.validation.Validator;
|
|
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
|
|
import org.slf4j.Logger;
|
|
import org.slf4j.LoggerFactory;
|
|
import tornadofx.control.Field;
|
|
|
|
import java.io.IOException;
|
|
import java.net.URL;
|
|
import java.text.DecimalFormat;
|
|
import java.text.DecimalFormatSymbols;
|
|
import java.util.*;
|
|
import java.util.stream.Collectors;
|
|
|
|
public class SendController extends WalletFormController implements Initializable {
|
|
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);
|
|
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;
|
|
|
|
@FXML
|
|
private Label cpfpFeeRate;
|
|
|
|
@FXML
|
|
private Label feeRatePriority;
|
|
|
|
@FXML
|
|
private Glyph feeRatePriorityGlyph;
|
|
|
|
@FXML
|
|
private TextField fee;
|
|
|
|
@FXML
|
|
private ComboBox<BitcoinUnit> feeAmountUnit;
|
|
|
|
@FXML
|
|
private FiatLabel fiatFeeAmount;
|
|
|
|
@FXML
|
|
private BlockTargetFeeRatesChart blockTargetFeeRatesChart;
|
|
|
|
@FXML
|
|
private MempoolSizeFeeRatesChart mempoolSizeFeeRatesChart;
|
|
|
|
@FXML
|
|
private TransactionDiagram transactionDiagram;
|
|
|
|
@FXML
|
|
private Button clearButton;
|
|
|
|
@FXML
|
|
private Button createButton;
|
|
|
|
private StackPane tabHeader;
|
|
|
|
private final BooleanProperty userFeeSet = new SimpleBooleanProperty(false);
|
|
|
|
private final ObjectProperty<UtxoSelector> utxoSelectorProperty = new SimpleObjectProperty<>(null);
|
|
|
|
private final ObjectProperty<UtxoFilter> utxoFilterProperty = new SimpleObjectProperty<>(null);
|
|
|
|
private final ObjectProperty<WalletTransaction> walletTransactionProperty = new SimpleObjectProperty<>(null);
|
|
|
|
private final ObjectProperty<WalletTransaction> createdWalletTransactionProperty = new SimpleObjectProperty<>(null);
|
|
|
|
private final BooleanProperty insufficientInputsProperty = new SimpleBooleanProperty(false);
|
|
|
|
private final StringProperty utxoLabelSelectionProperty = new SimpleStringProperty("");
|
|
|
|
private final BooleanProperty includeSpentMempoolOutputsProperty = new SimpleBooleanProperty(false);
|
|
|
|
private final ChangeListener<String> feeListener = new ChangeListener<>() {
|
|
@Override
|
|
public void changed(ObservableValue<? extends String> observable, String oldValue, String newValue) {
|
|
userFeeSet.set(true);
|
|
if(newValue.isEmpty()) {
|
|
fiatFeeAmount.setText("");
|
|
} else {
|
|
setFiatFeeAmount(AppServices.getFiatCurrencyExchangeRate(), getFeeValueSats());
|
|
}
|
|
|
|
setTargetBlocks(getTargetBlocks());
|
|
updateTransaction();
|
|
}
|
|
};
|
|
|
|
private final ChangeListener<Number> targetBlocksListener = new ChangeListener<>() {
|
|
@Override
|
|
public void changed(ObservableValue<? extends Number> observable, Number oldValue, Number newValue) {
|
|
Map<Integer, Double> targetBlocksFeeRates = getTargetBlocksFeeRates();
|
|
Integer target = getTargetBlocks();
|
|
|
|
if(targetBlocksFeeRates != null) {
|
|
setFeeRate(targetBlocksFeeRates.get(target));
|
|
blockTargetFeeRatesChart.select(target);
|
|
} else {
|
|
feeRate.setText("Unknown");
|
|
}
|
|
|
|
Tooltip tooltip = new Tooltip("Target inclusion within " + target + " blocks");
|
|
targetBlocks.setTooltip(tooltip);
|
|
|
|
userFeeSet.set(false);
|
|
for(Tab tab : paymentTabs.getTabs()) {
|
|
PaymentController controller = (PaymentController)tab.getUserData();
|
|
controller.revalidate();
|
|
}
|
|
updateTransaction();
|
|
}
|
|
};
|
|
|
|
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;
|
|
|
|
private WalletTransactionService walletTransactionService;
|
|
|
|
@Override
|
|
public void initialize(URL location, ResourceBundle resources) {
|
|
EventManager.get().register(this);
|
|
}
|
|
|
|
@Override
|
|
public void initializeView() {
|
|
addValidation();
|
|
|
|
addPaymentTab();
|
|
initializeTabHeader(0);
|
|
|
|
paymentTabs.getTabs().addListener((ListChangeListener<Tab>) c -> {
|
|
if(tabHeader != null) {
|
|
tabHeader.setVisible(c.getList().size() > 1);
|
|
}
|
|
|
|
if(c.getList().size() > 1) {
|
|
if(!paymentTabs.getStyleClass().contains("multiple-tabs")) {
|
|
paymentTabs.getStyleClass().add("multiple-tabs");
|
|
}
|
|
paymentTabs.getTabs().forEach(tab -> tab.setClosable(true));
|
|
} else {
|
|
paymentTabs.getStyleClass().remove("multiple-tabs");
|
|
Tab remainingTab = paymentTabs.getTabs().get(0);
|
|
remainingTab.setClosable(false);
|
|
remainingTab.setText("1");
|
|
}
|
|
|
|
updateTransaction();
|
|
});
|
|
|
|
insufficientInputsProperty.addListener((observable, oldValue, newValue) -> {
|
|
for(Tab tab : paymentTabs.getTabs()) {
|
|
PaymentController controller = (PaymentController)tab.getUserData();
|
|
controller.revalidate();
|
|
}
|
|
revalidate(fee, feeListener);
|
|
});
|
|
|
|
targetBlocksField.managedProperty().bind(targetBlocksField.visibleProperty());
|
|
targetBlocks.setMin(0);
|
|
targetBlocks.setMax(TARGET_BLOCKS_RANGE.size() - 1);
|
|
targetBlocks.setMajorTickUnit(1);
|
|
targetBlocks.setMinorTickCount(0);
|
|
targetBlocks.setLabelFormatter(new StringConverter<Double>() {
|
|
@Override
|
|
public String toString(Double object) {
|
|
String blocks = Integer.toString(TARGET_BLOCKS_RANGE.get(object.intValue()));
|
|
return (object.intValue() == TARGET_BLOCKS_RANGE.size() - 1) ? blocks + "+" : blocks;
|
|
}
|
|
|
|
@Override
|
|
public Double fromString(String string) {
|
|
return (double)TARGET_BLOCKS_RANGE.indexOf(Integer.valueOf(string.replace("+", "")));
|
|
}
|
|
});
|
|
targetBlocks.valueProperty().addListener(targetBlocksListener);
|
|
|
|
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) {
|
|
blockTargetFeeRatesChart.update(targetBlocksFeeRates);
|
|
} else {
|
|
feeRate.setText("Unknown");
|
|
}
|
|
|
|
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.MEMPOOL_SIZE : feeRatesSelection);
|
|
cpfpFeeRate.managedProperty().bind(cpfpFeeRate.visibleProperty());
|
|
cpfpFeeRate.setVisible(false);
|
|
setDefaultFeeRate();
|
|
updateFeeRateSelection(feeRatesSelection);
|
|
feeSelectionToggleGroup.selectToggle(feeRatesSelection == FeeRatesSelection.BLOCK_TARGET ? targetBlocksToggle : mempoolSizeToggle);
|
|
feeSelectionToggleGroup.selectedToggleProperty().addListener((observable, oldValue, newValue) -> {
|
|
if(newValue != null) {
|
|
FeeRatesSelection newFeeRatesSelection = (FeeRatesSelection)newValue.getUserData();
|
|
Config.get().setFeeRatesSelection(newFeeRatesSelection);
|
|
EventManager.get().post(new FeeRatesSelectionChangedEvent(getWalletForm().getWallet(), newFeeRatesSelection));
|
|
};
|
|
});
|
|
|
|
fee.setTextFormatter(new CoinTextFormatter());
|
|
fee.textProperty().addListener(feeListener);
|
|
|
|
BitcoinUnit unit = getBitcoinUnit(Config.get().getBitcoinUnit());
|
|
feeAmountUnit.getSelectionModel().select(BitcoinUnit.BTC.equals(unit) ? 0 : 1);
|
|
feeAmountUnit.valueProperty().addListener((observable, oldValue, newValue) -> {
|
|
Long value = getFeeValueSats(oldValue);
|
|
if(value != null) {
|
|
setFeeValueSats(value);
|
|
}
|
|
});
|
|
|
|
userFeeSet.addListener((observable, oldValue, newValue) -> {
|
|
blockTargetFeeRatesChart.select(0);
|
|
|
|
Node thumb = getSliderThumb();
|
|
if(thumb != null) {
|
|
if(newValue) {
|
|
thumb.getStyleClass().add("inactive");
|
|
} else {
|
|
thumb.getStyleClass().remove("inactive");
|
|
}
|
|
}
|
|
});
|
|
|
|
utxoLabelSelectionProperty.addListener((observable, oldValue, newValue) -> {
|
|
clearButton.setText("Clear" + newValue);
|
|
});
|
|
|
|
utxoSelectorProperty.addListener((observable, oldValue, utxoSelector) -> {
|
|
updateMaxClearButtons(utxoSelector, utxoFilterProperty.get());
|
|
});
|
|
|
|
utxoFilterProperty.addListener((observable, oldValue, utxoFilter) -> {
|
|
updateMaxClearButtons(utxoSelectorProperty.get(), utxoFilter);
|
|
});
|
|
|
|
walletTransactionProperty.addListener((observable, oldValue, walletTransaction) -> {
|
|
if(walletTransaction != null) {
|
|
setPayments(walletTransaction.getPayments());
|
|
|
|
double feeRate = walletTransaction.getFeeRate();
|
|
if(userFeeSet.get()) {
|
|
setTargetBlocks(getTargetBlocks(feeRate));
|
|
setFeeRangeRate(feeRate);
|
|
} else {
|
|
setFeeValueSats(walletTransaction.getFee());
|
|
}
|
|
|
|
setFeeRate(feeRate);
|
|
setEffectiveFeeRate(walletTransaction);
|
|
}
|
|
|
|
transactionDiagram.update(walletTransaction);
|
|
createButton.setDisable(walletTransaction == null || isInsufficientFeeRate());
|
|
});
|
|
|
|
transactionDiagram.sceneProperty().addListener((observable, oldScene, newScene) -> {
|
|
if(oldScene == null && newScene != null) {
|
|
transactionDiagram.update(walletTransactionProperty.get());
|
|
newScene.getWindow().heightProperty().addListener((observable1, oldValue, newValue) -> {
|
|
transactionDiagram.update(walletTransactionProperty.get());
|
|
});
|
|
}
|
|
});
|
|
|
|
addFeeRangeTrackHighlight(0);
|
|
}
|
|
|
|
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)) {
|
|
unit = getWalletForm().getWallet().getAutoUnit();
|
|
}
|
|
return unit;
|
|
}
|
|
|
|
public ValidationSupport getValidationSupport() {
|
|
return validationSupport;
|
|
}
|
|
|
|
private void addValidation() {
|
|
validationSupport = new ValidationSupport();
|
|
validationSupport.registerValidator(fee, Validator.combine(
|
|
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Inputs", userFeeSet.get() && insufficientInputsProperty.get()),
|
|
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Fee", getFeeValueSats() != null && getFeeValueSats() == 0),
|
|
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Fee Rate", isInsufficientFeeRate())
|
|
));
|
|
|
|
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
|
|
validationSupport.setErrorDecorationEnabled(false);
|
|
}
|
|
|
|
public Tab addPaymentTab() {
|
|
Tab tab = getPaymentTab();
|
|
paymentTabs.getTabs().add(tab);
|
|
paymentTabs.getSelectionModel().select(tab);
|
|
return tab;
|
|
}
|
|
|
|
public Tab getPaymentTab() {
|
|
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(AppServices.class.getResource("wallet/payment.fxml"));
|
|
tab.setContent(paymentLoader.load());
|
|
PaymentController controller = paymentLoader.getController();
|
|
controller.setSendController(this);
|
|
controller.initializeView();
|
|
tab.setUserData(controller);
|
|
return tab;
|
|
} catch (IOException e) {
|
|
throw new RuntimeException(e);
|
|
}
|
|
}
|
|
|
|
public List<Payment> getPayments() {
|
|
List<Payment> payments = new ArrayList<>();
|
|
for(Tab tab : paymentTabs.getTabs()) {
|
|
PaymentController controller = (PaymentController)tab.getUserData();
|
|
payments.add(controller.getPayment());
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
public void updateTransaction(boolean sendAll) {
|
|
try {
|
|
if(paymentTabs.getTabs().size() == 1) {
|
|
PaymentController controller = (PaymentController)paymentTabs.getTabs().get(0).getUserData();
|
|
controller.setSendMax(sendAll);
|
|
updateTransaction(List.of(controller.getPayment()));
|
|
} else {
|
|
updateTransaction(null);
|
|
}
|
|
} catch(IllegalStateException e) {
|
|
//ignore
|
|
}
|
|
}
|
|
|
|
public void updateTransaction(List<Payment> transactionPayments) {
|
|
if(walletTransactionService != null && walletTransactionService.isRunning()) {
|
|
walletTransactionService.setIgnoreResult(true);
|
|
walletTransactionService.cancel();
|
|
}
|
|
|
|
try {
|
|
List<Payment> payments = transactionPayments != null ? transactionPayments : getPayments();
|
|
if(!userFeeSet.get() || (getFeeValueSats() != null && getFeeValueSats() > 0)) {
|
|
Wallet wallet = getWalletForm().getWallet();
|
|
Long userFee = userFeeSet.get() ? getFeeValueSats() : null;
|
|
double feeRate = getUserFeeRate();
|
|
Integer currentBlockHeight = AppServices.getCurrentBlockHeight();
|
|
boolean groupByAddress = Config.get().isGroupByAddress();
|
|
boolean includeMempoolOutputs = Config.get().isIncludeMempoolOutputs();
|
|
boolean includeSpentMempoolOutputs = includeSpentMempoolOutputsProperty.get();
|
|
|
|
walletTransactionService = new WalletTransactionService(wallet, getUtxoSelectors(), getUtxoFilters(), payments, feeRate, getMinimumFeeRate(), userFee, currentBlockHeight, groupByAddress, includeMempoolOutputs, includeSpentMempoolOutputs);
|
|
walletTransactionService.setOnSucceeded(event -> {
|
|
if(!walletTransactionService.isIgnoreResult()) {
|
|
walletTransactionProperty.setValue(walletTransactionService.getValue());
|
|
insufficientInputsProperty.set(false);
|
|
}
|
|
});
|
|
walletTransactionService.setOnFailed(event -> {
|
|
if(!walletTransactionService.isIgnoreResult()) {
|
|
transactionDiagram.clear();
|
|
walletTransactionProperty.setValue(null);
|
|
if(event.getSource().getException() instanceof InsufficientFundsException) {
|
|
insufficientInputsProperty.set(true);
|
|
}
|
|
}
|
|
});
|
|
|
|
final WalletTransactionService currentWalletTransactionService = walletTransactionService;
|
|
final KeyFrame delay = new KeyFrame(Duration.millis(200), e -> {
|
|
if(currentWalletTransactionService.isRunning()) {
|
|
transactionDiagram.update("Selecting UTXOs...");
|
|
createButton.setDisable(true);
|
|
}
|
|
});
|
|
final Timeline timeline = new Timeline(delay);
|
|
timeline.play();
|
|
|
|
walletTransactionService.start();
|
|
}
|
|
} catch(InvalidAddressException | IllegalStateException e) {
|
|
walletTransactionProperty.setValue(null);
|
|
}
|
|
}
|
|
|
|
private List<UtxoSelector> getUtxoSelectors() throws InvalidAddressException {
|
|
if(utxoSelectorProperty.get() != null) {
|
|
return List.of(utxoSelectorProperty.get());
|
|
}
|
|
|
|
Wallet wallet = getWalletForm().getWallet();
|
|
long noInputsFee = wallet.getNoInputsFee(getPayments(), getUserFeeRate());
|
|
long costOfChange = wallet.getCostOfChange(getUserFeeRate(), getMinimumFeeRate());
|
|
|
|
return List.of(new BnBUtxoSelector(noInputsFee, costOfChange), new KnapsackUtxoSelector(noInputsFee));
|
|
}
|
|
|
|
private static class WalletTransactionService extends Service<WalletTransaction> {
|
|
private final Wallet wallet;
|
|
private final List<UtxoSelector> utxoSelectors;
|
|
private final List<UtxoFilter> utxoFilters;
|
|
private final List<Payment> payments;
|
|
private final double feeRate;
|
|
private final double longTermFeeRate;
|
|
private final Long fee;
|
|
private final Integer currentBlockHeight;
|
|
private final boolean groupByAddress;
|
|
private final boolean includeMempoolOutputs;
|
|
private final boolean includeSpentMempoolOutputs;
|
|
private boolean ignoreResult;
|
|
|
|
public WalletTransactionService(Wallet wallet, List<UtxoSelector> utxoSelectors, List<UtxoFilter> utxoFilters, List<Payment> payments, double feeRate, double longTermFeeRate, Long fee, Integer currentBlockHeight, boolean groupByAddress, boolean includeMempoolOutputs, boolean includeSpentMempoolOutputs) {
|
|
this.wallet = wallet;
|
|
this.utxoSelectors = utxoSelectors;
|
|
this.utxoFilters = utxoFilters;
|
|
this.payments = payments;
|
|
this.feeRate = feeRate;
|
|
this.longTermFeeRate = longTermFeeRate;
|
|
this.fee = fee;
|
|
this.currentBlockHeight = currentBlockHeight;
|
|
this.groupByAddress = groupByAddress;
|
|
this.includeMempoolOutputs = includeMempoolOutputs;
|
|
this.includeSpentMempoolOutputs = includeSpentMempoolOutputs;
|
|
}
|
|
|
|
@Override
|
|
protected Task<WalletTransaction> createTask() {
|
|
return new Task<>() {
|
|
protected WalletTransaction call() throws InsufficientFundsException {
|
|
return wallet.createWalletTransaction(utxoSelectors, utxoFilters, payments, feeRate, longTermFeeRate, fee, currentBlockHeight, groupByAddress, includeMempoolOutputs, includeSpentMempoolOutputs);
|
|
}
|
|
};
|
|
}
|
|
|
|
public boolean isIgnoreResult() {
|
|
return ignoreResult;
|
|
}
|
|
|
|
public void setIgnoreResult(boolean ignoreResult) {
|
|
this.ignoreResult = ignoreResult;
|
|
}
|
|
}
|
|
|
|
private List<UtxoFilter> getUtxoFilters() {
|
|
UtxoFilter utxoFilter = utxoFilterProperty.get();
|
|
if(utxoFilter != null) {
|
|
return List.of(utxoFilter, new FrozenUtxoFilter());
|
|
}
|
|
|
|
return List.of(new FrozenUtxoFilter());
|
|
}
|
|
|
|
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());
|
|
}
|
|
|
|
private Long getFeeValueSats(BitcoinUnit bitcoinUnit) {
|
|
if(fee.getText() != null && !fee.getText().isEmpty()) {
|
|
double fieldValue = Double.parseDouble(fee.getText().replaceAll(",", ""));
|
|
return bitcoinUnit.getSatsValue(fieldValue);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private void setFeeValueSats(long feeValue) {
|
|
fee.textProperty().removeListener(feeListener);
|
|
DecimalFormat df = new DecimalFormat("#.#", DecimalFormatSymbols.getInstance(Locale.ENGLISH));
|
|
df.setMaximumFractionDigits(8);
|
|
fee.setText(df.format(feeAmountUnit.getValue().getValue(feeValue)));
|
|
fee.textProperty().addListener(feeListener);
|
|
setFiatFeeAmount(AppServices.getFiatCurrencyExchangeRate(), feeValue);
|
|
}
|
|
|
|
private Integer getTargetBlocks() {
|
|
int index = (int)targetBlocks.getValue();
|
|
return TARGET_BLOCKS_RANGE.get(index);
|
|
}
|
|
|
|
private Integer getTargetBlocks(double feeRate) {
|
|
Map<Integer, Double> targetBlocksFeeRates = getTargetBlocksFeeRates();
|
|
int maxTargetBlocks = 1;
|
|
for(Integer targetBlocks : targetBlocksFeeRates.keySet()) {
|
|
if(TARGET_BLOCKS_RANGE.contains(targetBlocks)) {
|
|
maxTargetBlocks = Math.max(maxTargetBlocks, targetBlocks);
|
|
Double candidate = targetBlocksFeeRates.get(targetBlocks);
|
|
if(Math.round(feeRate) >= Math.round(candidate)) {
|
|
return targetBlocks;
|
|
}
|
|
}
|
|
}
|
|
|
|
return maxTargetBlocks;
|
|
}
|
|
|
|
private void setTargetBlocks(Integer target) {
|
|
targetBlocks.valueProperty().removeListener(targetBlocksListener);
|
|
int index = TARGET_BLOCKS_RANGE.indexOf(target);
|
|
targetBlocks.setValue(index);
|
|
blockTargetFeeRatesChart.select(target);
|
|
Tooltip tooltip = new Tooltip("Target inclusion within " + target + " blocks");
|
|
targetBlocks.setTooltip(tooltip);
|
|
targetBlocks.valueProperty().addListener(targetBlocksListener);
|
|
}
|
|
|
|
private Map<Integer, Double> getTargetBlocksFeeRates() {
|
|
Map<Integer, Double> retrievedFeeRates = AppServices.getTargetBlockFeeRates();
|
|
if(retrievedFeeRates == null) {
|
|
retrievedFeeRates = TARGET_BLOCKS_RANGE.stream().collect(Collectors.toMap(java.util.function.Function.identity(), v -> FALLBACK_FEE_RATE,
|
|
(u, v) -> { throw new IllegalStateException("Duplicate target blocks"); },
|
|
LinkedHashMap::new));
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* This method retrieves the fee rate used as input to constructing the transaction.
|
|
* Where the user has set a custom fee amount, using the slider fee rate can mean the UTXO selectors underestimate the UTXO effective values and fail to find a solution
|
|
* In this case, use a fee rate of 1 sat/VB for maximum flexibility
|
|
*
|
|
* @return the fee rate to use when constructing a transaction
|
|
*/
|
|
public Double getUserFeeRate() {
|
|
return (userFeeSet.get() ? Transaction.DEFAULT_MIN_RELAY_FEE : getFeeRate());
|
|
}
|
|
|
|
public Double getFeeRate() {
|
|
if(targetBlocksField.isVisible()) {
|
|
return getTargetBlocksFeeRates().get(getTargetBlocks());
|
|
} else {
|
|
return getFeeRangeRate();
|
|
}
|
|
}
|
|
|
|
private Double getMinimumFeeRate() {
|
|
Optional<Double> optMinFeeRate = getTargetBlocksFeeRates().values().stream().min(Double::compareTo);
|
|
Double minRate = optMinFeeRate.orElse(FALLBACK_FEE_RATE);
|
|
return Math.max(minRate, Transaction.DUST_RELAY_TX_FEE);
|
|
}
|
|
|
|
private Map<Date, Set<MempoolRateSize>> getMempoolHistogram() {
|
|
return AppServices.getMempoolHistogram();
|
|
}
|
|
|
|
public boolean isInsufficientFeeRate() {
|
|
return walletTransactionProperty.get() != null && walletTransactionProperty.get().getFeeRate() < AppServices.getMinimumRelayFeeRate();
|
|
}
|
|
|
|
private void setFeeRate(Double feeRateAmt) {
|
|
feeRate.setText(String.format("%.2f", feeRateAmt) + " sats/vB");
|
|
setFeeRatePriority(feeRateAmt);
|
|
}
|
|
|
|
private void setEffectiveFeeRate(WalletTransaction walletTransaction) {
|
|
List<BlockTransaction> unconfirmedUtxoTxs = walletTransaction.getSelectedUtxos().keySet().stream().filter(ref -> ref.getHeight() <= 0)
|
|
.map(ref -> getWalletForm().getWallet().getTransactions().get(ref.getHash())).filter(Objects::nonNull).distinct().collect(Collectors.toList());
|
|
if(!unconfirmedUtxoTxs.isEmpty()) {
|
|
long utxoTxFee = unconfirmedUtxoTxs.stream().mapToLong(BlockTransaction::getFee).sum();
|
|
double utxoTxSize = unconfirmedUtxoTxs.stream().mapToDouble(blkTx -> blkTx.getTransaction().getVirtualSize()).sum();
|
|
long thisFee = walletTransaction.getFee();
|
|
double thisSize = walletTransaction.getTransaction().getVirtualSize();
|
|
double effectiveRate = (utxoTxFee + thisFee) / (utxoTxSize + thisSize);
|
|
Tooltip tooltip = new Tooltip("Child Pays For Parent\n" + String.format("%.2f", effectiveRate) + " sats/vB effective rate");
|
|
cpfpFeeRate.setTooltip(tooltip);
|
|
cpfpFeeRate.setVisible(true);
|
|
} else {
|
|
cpfpFeeRate.setVisible(false);
|
|
}
|
|
}
|
|
|
|
private void setFeeRatePriority(Double feeRateAmt) {
|
|
Map<Integer, Double> targetBlocksFeeRates = getTargetBlocksFeeRates();
|
|
Integer targetBlocks = getTargetBlocks(feeRateAmt);
|
|
if(targetBlocksFeeRates.get(Integer.MAX_VALUE) != null) {
|
|
Double minFeeRate = targetBlocksFeeRates.get(Integer.MAX_VALUE);
|
|
if(minFeeRate > 1.0 && feeRateAmt < minFeeRate) {
|
|
feeRatePriority.setText("Below Minimum");
|
|
feeRatePriority.setTooltip(new Tooltip("Transactions at this fee rate are currently being purged from the default sized mempool"));
|
|
feeRatePriorityGlyph.setStyle("-fx-text-fill: #a0a1a7cc");
|
|
feeRatePriorityGlyph.setIcon(FontAwesome5.Glyph.EXCLAMATION_CIRCLE);
|
|
return;
|
|
}
|
|
|
|
Double lowestBlocksRate = targetBlocksFeeRates.get(TARGET_BLOCKS_RANGE.get(TARGET_BLOCKS_RANGE.size() - 1));
|
|
if(lowestBlocksRate >= minFeeRate && feeRateAmt < (minFeeRate + ((lowestBlocksRate - minFeeRate) / 2)) && !isPayjoinTx()) {
|
|
feeRatePriority.setText("Try Then Replace");
|
|
feeRatePriority.setTooltip(new Tooltip("Send a transaction, verify it appears in the destination wallet, then RBF to get it confirmed or sent to another address"));
|
|
feeRatePriorityGlyph.setStyle("-fx-text-fill: #7eb7c9cc");
|
|
feeRatePriorityGlyph.setIcon(FontAwesome5.Glyph.PLUS_CIRCLE);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if(targetBlocks != null) {
|
|
if(targetBlocks < FeeRatesSource.BLOCKS_IN_HALF_HOUR) {
|
|
Double maxFeeRate = FEE_RATES_RANGE.get(FEE_RATES_RANGE.size() - 1).doubleValue();
|
|
Double highestBlocksRate = targetBlocksFeeRates.get(TARGET_BLOCKS_RANGE.get(0));
|
|
if(highestBlocksRate < maxFeeRate && feeRateAmt > (highestBlocksRate + ((maxFeeRate - highestBlocksRate) / 10))) {
|
|
feeRatePriority.setText("Overpaid");
|
|
feeRatePriority.setTooltip(new Tooltip("Transaction fees at this rate are likely higher than necessary"));
|
|
feeRatePriorityGlyph.setStyle("-fx-text-fill: #c8416499");
|
|
feeRatePriorityGlyph.setIcon(FontAwesome5.Glyph.EXCLAMATION_CIRCLE);
|
|
} else {
|
|
feeRatePriority.setText("High Priority");
|
|
feeRatePriority.setTooltip(new Tooltip("Typically confirms within minutes"));
|
|
feeRatePriorityGlyph.setStyle("-fx-text-fill: #c8416499");
|
|
feeRatePriorityGlyph.setIcon(FontAwesome5.Glyph.CIRCLE);
|
|
}
|
|
} else if(targetBlocks < FeeRatesSource.BLOCKS_IN_HOUR) {
|
|
feeRatePriority.setText("Medium Priority");
|
|
feeRatePriority.setTooltip(new Tooltip("Typically confirms within an hour or two"));
|
|
feeRatePriorityGlyph.setStyle("-fx-text-fill: #fba71b99");
|
|
feeRatePriorityGlyph.setIcon(FontAwesome5.Glyph.CIRCLE);
|
|
} else {
|
|
feeRatePriority.setText("Low Priority");
|
|
feeRatePriority.setTooltip(new Tooltip("Typically confirms in a day or longer"));
|
|
feeRatePriorityGlyph.setStyle("-fx-text-fill: #41a9c999");
|
|
feeRatePriorityGlyph.setIcon(FontAwesome5.Glyph.CIRCLE);
|
|
}
|
|
}
|
|
}
|
|
|
|
private boolean isPayjoinTx() {
|
|
if(walletTransactionProperty.get() != null) {
|
|
return walletTransactionProperty.get().getPayments().stream().anyMatch(payment -> AppServices.getPayjoinURI(payment.getAddress()) != null);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private Node getSliderThumb() {
|
|
return targetBlocks.lookup(".thumb");
|
|
}
|
|
|
|
private void setFiatFeeAmount(CurrencyRate currencyRate, Long amount) {
|
|
if(amount != null && currencyRate != null && currencyRate.isAvailable()) {
|
|
fiatFeeAmount.set(currencyRate, amount);
|
|
}
|
|
}
|
|
|
|
private void addFeeRangeTrackHighlight(int count) {
|
|
Platform.runLater(() -> {
|
|
Node track = feeRange.lookup(".track");
|
|
if(track != null) {
|
|
Map<Integer, Double> targetBlocksFeeRates = getTargetBlocksFeeRates();
|
|
String highlight = "";
|
|
if(targetBlocksFeeRates.get(Integer.MAX_VALUE) != null) {
|
|
highlight += "#a0a1a766 " + getPercentageOfFeeRange(targetBlocksFeeRates.get(Integer.MAX_VALUE)) + "%, ";
|
|
}
|
|
highlight += "#41a9c966 " + getPercentageOfFeeRange(targetBlocksFeeRates, FeeRatesSource.BLOCKS_IN_TWO_HOURS - 1) + "%, ";
|
|
highlight += "#fba71b66 " + getPercentageOfFeeRange(targetBlocksFeeRates, FeeRatesSource.BLOCKS_IN_HOUR - 1) + "%, ";
|
|
highlight += "#c8416466 " + getPercentageOfFeeRange(targetBlocksFeeRates, FeeRatesSource.BLOCKS_IN_HALF_HOUR - 1) + "%";
|
|
|
|
track.setStyle("-fx-background-color: " +
|
|
"-fx-shadow-highlight-color, " +
|
|
"linear-gradient(to bottom, derive(-fx-text-box-border, -10%), -fx-text-box-border), " +
|
|
"linear-gradient(to bottom, derive(-fx-control-inner-background, -9%), derive(-fx-control-inner-background, 0%), derive(-fx-control-inner-background, -5%), derive(-fx-control-inner-background, -12%)), " +
|
|
"linear-gradient(to right, " + highlight + ")");
|
|
} else if(count < 20) {
|
|
addFeeRangeTrackHighlight(count+1);
|
|
}
|
|
});
|
|
}
|
|
|
|
private int getPercentageOfFeeRange(Map<Integer, Double> targetBlocksFeeRates, Integer minTargetBlocks) {
|
|
List<Integer> rates = new ArrayList<>(targetBlocksFeeRates.keySet());
|
|
Collections.reverse(rates);
|
|
for(Integer targetBlocks : rates) {
|
|
if(targetBlocks < minTargetBlocks) {
|
|
return getPercentageOfFeeRange(targetBlocksFeeRates.get(targetBlocks));
|
|
}
|
|
}
|
|
|
|
return 100;
|
|
}
|
|
|
|
private int getPercentageOfFeeRange(Double feeRate) {
|
|
double index = Math.log(feeRate) / Math.log(2);
|
|
return (int)Math.round(index * 10.0);
|
|
}
|
|
|
|
private void updateMaxClearButtons(UtxoSelector utxoSelector, UtxoFilter utxoFilter) {
|
|
if(utxoSelector instanceof PresetUtxoSelector) {
|
|
PresetUtxoSelector presetUtxoSelector = (PresetUtxoSelector)utxoSelector;
|
|
int num = presetUtxoSelector.getPresetUtxos().size();
|
|
String selection = " (" + num + " UTXO" + (num != 1 ? "s" : "") + " selected)";
|
|
utxoLabelSelectionProperty.set(selection);
|
|
} else if(utxoFilter instanceof ExcludeUtxoFilter) {
|
|
ExcludeUtxoFilter excludeUtxoFilter = (ExcludeUtxoFilter)utxoFilter;
|
|
int num = excludeUtxoFilter.getExcludedUtxos().size();
|
|
String exclusion = " (" + num + " UTXO" + (num != 1 ? "s" : "") + " excluded)";
|
|
utxoLabelSelectionProperty.set(exclusion);
|
|
} else {
|
|
utxoLabelSelectionProperty.set("");
|
|
}
|
|
}
|
|
|
|
public void clear(ActionEvent event) {
|
|
boolean firstTab = true;
|
|
for(Iterator<Tab> iterator = paymentTabs.getTabs().iterator(); iterator.hasNext(); ) {
|
|
PaymentController controller = (PaymentController)iterator.next().getUserData();
|
|
if(firstTab) {
|
|
controller.clear();
|
|
firstTab = false;
|
|
} else {
|
|
EventManager.get().unregister(controller);
|
|
iterator.remove();
|
|
}
|
|
}
|
|
|
|
fee.textProperty().removeListener(feeListener);
|
|
fee.setText("");
|
|
fee.textProperty().addListener(feeListener);
|
|
|
|
cpfpFeeRate.setVisible(false);
|
|
fiatFeeAmount.setText("");
|
|
|
|
userFeeSet.set(false);
|
|
setDefaultFeeRate();
|
|
utxoSelectorProperty.setValue(null);
|
|
utxoFilterProperty.setValue(null);
|
|
includeSpentMempoolOutputsProperty.set(false);
|
|
walletTransactionProperty.setValue(null);
|
|
createdWalletTransactionProperty.set(null);
|
|
|
|
validationSupport.setErrorDecorationEnabled(false);
|
|
}
|
|
|
|
public UtxoSelector getUtxoSelector() {
|
|
return utxoSelectorProperty.get();
|
|
}
|
|
|
|
public ObjectProperty<UtxoSelector> utxoSelectorProperty() {
|
|
return utxoSelectorProperty;
|
|
}
|
|
|
|
public boolean isInsufficientInputs() {
|
|
return insufficientInputsProperty.get();
|
|
}
|
|
|
|
public BooleanProperty insufficientInputsProperty() {
|
|
return insufficientInputsProperty;
|
|
}
|
|
|
|
public WalletTransaction getWalletTransaction() {
|
|
return walletTransactionProperty.get();
|
|
}
|
|
|
|
public ObjectProperty<WalletTransaction> walletTransactionProperty() {
|
|
return walletTransactionProperty;
|
|
}
|
|
|
|
public String getUtxoLabelSelection() {
|
|
return utxoLabelSelectionProperty.get();
|
|
}
|
|
|
|
public StringProperty utxoLabelSelectionProperty() {
|
|
return utxoLabelSelectionProperty;
|
|
}
|
|
|
|
public TabPane getPaymentTabs() {
|
|
return paymentTabs;
|
|
}
|
|
|
|
public Button getCreateButton() {
|
|
return createButton;
|
|
}
|
|
|
|
private void revalidate(TextField field, ChangeListener<String> listener) {
|
|
field.textProperty().removeListener(listener);
|
|
String amt = field.getText();
|
|
int caret = field.getCaretPosition();
|
|
field.setText(amt + "0");
|
|
field.setText(amt);
|
|
field.positionCaret(caret);
|
|
field.textProperty().addListener(listener);
|
|
}
|
|
|
|
public void createTransaction(ActionEvent event) {
|
|
if(log.isDebugEnabled()) {
|
|
Map<WalletNode, List<String>> inputHashes = new LinkedHashMap<>();
|
|
for(WalletNode node : walletTransactionProperty.get().getSelectedUtxos().values()) {
|
|
List<String> nodeHashes = inputHashes.computeIfAbsent(node, k -> new ArrayList<>());
|
|
nodeHashes.add(ElectrumServer.getScriptHash(walletForm.getWallet(), node));
|
|
}
|
|
Map<WalletNode, List<String>> changeHash = Collections.emptyMap();
|
|
if(walletTransactionProperty.get().getChangeNode() != null) {
|
|
changeHash = Map.of(walletTransactionProperty.get().getChangeNode(), List.of(ElectrumServer.getScriptHash(walletForm.getWallet(), walletTransactionProperty.get().getChangeNode())));
|
|
}
|
|
log.debug("Creating tx " + walletTransactionProperty.get().getTransaction().getTxId() + ", expecting notifications for \ninputs \n" + inputHashes + " and \nchange \n" + changeHash);
|
|
}
|
|
|
|
addWalletTransactionNodes();
|
|
createdWalletTransactionProperty.set(walletTransactionProperty.get());
|
|
PSBT psbt = walletTransactionProperty.get().createPSBT();
|
|
EventManager.get().post(new ViewPSBTEvent(createButton.getScene().getWindow(), walletTransactionProperty.get().getPayments().get(0).getLabel(), null, psbt));
|
|
}
|
|
|
|
private void addWalletTransactionNodes() {
|
|
WalletTransaction walletTransaction = walletTransactionProperty.get();
|
|
Set<WalletNode> nodes = new LinkedHashSet<>(walletTransaction.getSelectedUtxos().values());
|
|
if(walletTransaction.getChangeNode() != null) {
|
|
nodes.add(walletTransaction.getChangeNode());
|
|
}
|
|
List<WalletNode> consolidationNodes = walletTransaction.getConsolidationSendNodes();
|
|
nodes.addAll(consolidationNodes);
|
|
|
|
//All wallet nodes applicable to this transaction are stored so when the subscription status for one is updated, the history for all can be fetched in one atomic update
|
|
walletForm.addWalletTransactionNodes(nodes);
|
|
}
|
|
|
|
@Subscribe
|
|
public void walletNodesChanged(WalletNodesChangedEvent event) {
|
|
if(event.getWallet().equals(walletForm.getWallet())) {
|
|
clear(null);
|
|
}
|
|
}
|
|
|
|
@Subscribe
|
|
public void walletHistoryChanged(WalletHistoryChangedEvent event) {
|
|
if(event.getWallet().equals(walletForm.getWallet()) && createdWalletTransactionProperty.get() != null) {
|
|
if(createdWalletTransactionProperty.get().getSelectedUtxos() != null && allSelectedUtxosSpent(event.getHistoryChangedNodes())) {
|
|
clear(null);
|
|
} else {
|
|
updateTransaction();
|
|
}
|
|
}
|
|
}
|
|
|
|
private boolean allSelectedUtxosSpent(List<WalletNode> historyChangedNodes) {
|
|
Set<BlockTransactionHashIndex> unspentUtxos = new HashSet<>(createdWalletTransactionProperty.get().getSelectedUtxos().keySet());
|
|
|
|
for(Map.Entry<BlockTransactionHashIndex, WalletNode> selectedUtxoEntry : createdWalletTransactionProperty.get().getSelectedUtxos().entrySet()) {
|
|
BlockTransactionHashIndex utxo = selectedUtxoEntry.getKey();
|
|
WalletNode utxoWalletNode = selectedUtxoEntry.getValue();
|
|
|
|
for(WalletNode changedNode : historyChangedNodes) {
|
|
if(utxoWalletNode.equals(changedNode)) {
|
|
Optional<BlockTransactionHashIndex> spentTxo = changedNode.getTransactionOutputs().stream().filter(txo -> txo.getHash().equals(utxo.getHash()) && txo.getIndex() == utxo.getIndex() && txo.isSpent()).findAny();
|
|
if(spentTxo.isPresent()) {
|
|
unspentUtxos.remove(utxo);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return unspentUtxos.isEmpty();
|
|
}
|
|
|
|
@Subscribe
|
|
public void walletEntryLabelChanged(WalletEntryLabelChangedEvent event) {
|
|
if(event.getWallet().equals(walletForm.getWallet())) {
|
|
updateTransaction();
|
|
}
|
|
}
|
|
|
|
@Subscribe
|
|
public void feeRatesUpdated(FeeRatesUpdatedEvent event) {
|
|
blockTargetFeeRatesChart.update(event.getTargetBlockFeeRates());
|
|
blockTargetFeeRatesChart.select(getTargetBlocks());
|
|
mempoolSizeFeeRatesChart.update(getMempoolHistogram());
|
|
if(targetBlocksField.isVisible()) {
|
|
setFeeRate(event.getTargetBlockFeeRates().get(getTargetBlocks()));
|
|
} else {
|
|
setFeeRatePriority(getFeeRangeRate());
|
|
}
|
|
addFeeRangeTrackHighlight(0);
|
|
}
|
|
|
|
@Subscribe
|
|
public void feeRateSelectionChanged(FeeRatesSelectionChangedEvent event) {
|
|
if(event.getWallet() == getWalletForm().getWallet()) {
|
|
updateFeeRateSelection(event.getFeeRateSelection());
|
|
}
|
|
}
|
|
|
|
@Subscribe
|
|
public void spendUtxos(SpendUtxoEvent event) {
|
|
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);
|
|
}
|
|
|
|
includeSpentMempoolOutputsProperty.set(event.isIncludeSpentMempoolOutputs());
|
|
|
|
List<BlockTransactionHashIndex> utxos = event.getUtxos();
|
|
utxoSelectorProperty.set(new PresetUtxoSelector(utxos));
|
|
utxoFilterProperty.set(null);
|
|
updateTransaction(event.getPayments() == null || event.getPayments().stream().anyMatch(Payment::isSendMax));
|
|
}
|
|
}
|
|
|
|
@Subscribe
|
|
public void bitcoinUnitChanged(BitcoinUnitChangedEvent event) {
|
|
BitcoinUnit unit = getBitcoinUnit(event.getBitcoinUnit());
|
|
feeAmountUnit.getSelectionModel().select(BitcoinUnit.BTC.equals(unit) ? 0 : 1);
|
|
}
|
|
|
|
@Subscribe
|
|
public void fiatCurrencySelected(FiatCurrencySelectedEvent event) {
|
|
if(event.getExchangeSource() == ExchangeSource.NONE) {
|
|
fiatFeeAmount.setCurrency(null);
|
|
fiatFeeAmount.setBtcRate(0.0);
|
|
}
|
|
}
|
|
|
|
@Subscribe
|
|
public void exchangeRatesUpdated(ExchangeRatesUpdatedEvent event) {
|
|
setFiatFeeAmount(event.getCurrencyRate(), getFeeValueSats());
|
|
}
|
|
|
|
@Subscribe
|
|
public void excludeUtxo(ExcludeUtxoEvent event) {
|
|
if(event.getWalletTransaction() == walletTransactionProperty.get()) {
|
|
UtxoSelector utxoSelector = utxoSelectorProperty.get();
|
|
if(utxoSelector instanceof MaxUtxoSelector) {
|
|
Collection<BlockTransactionHashIndex> utxos = walletForm.getWallet().getWalletUtxos().keySet();
|
|
utxos.remove(event.getUtxo());
|
|
if(utxoFilterProperty.get() instanceof ExcludeUtxoFilter) {
|
|
ExcludeUtxoFilter existingUtxoFilter = (ExcludeUtxoFilter)utxoFilterProperty.get();
|
|
utxos.removeAll(existingUtxoFilter.getExcludedUtxos());
|
|
}
|
|
PresetUtxoSelector presetUtxoSelector = new PresetUtxoSelector(utxos);
|
|
utxoSelectorProperty.set(presetUtxoSelector);
|
|
updateTransaction(true);
|
|
} else if(utxoSelector instanceof PresetUtxoSelector) {
|
|
PresetUtxoSelector presetUtxoSelector = new PresetUtxoSelector(((PresetUtxoSelector)utxoSelector).getPresetUtxos());
|
|
presetUtxoSelector.getPresetUtxos().remove(event.getUtxo());
|
|
utxoSelectorProperty.set(presetUtxoSelector);
|
|
updateTransaction(true);
|
|
} else {
|
|
ExcludeUtxoFilter utxoFilter = new ExcludeUtxoFilter();
|
|
if(utxoFilterProperty.get() instanceof ExcludeUtxoFilter) {
|
|
ExcludeUtxoFilter existingUtxoFilter = (ExcludeUtxoFilter)utxoFilterProperty.get();
|
|
utxoFilter.getExcludedUtxos().addAll(existingUtxoFilter.getExcludedUtxos());
|
|
}
|
|
|
|
utxoFilter.getExcludedUtxos().add(event.getUtxo());
|
|
utxoFilterProperty.set(utxoFilter);
|
|
updateTransaction();
|
|
}
|
|
}
|
|
}
|
|
|
|
@Subscribe
|
|
public void walletUtxoStatusChanged(WalletUtxoStatusChangedEvent event) {
|
|
if(event.getWallet().equals(getWalletForm().getWallet())) {
|
|
UtxoSelector utxoSelector = utxoSelectorProperty.get();
|
|
if(utxoSelector instanceof MaxUtxoSelector) {
|
|
updateTransaction(true);
|
|
} else if(utxoSelectorProperty().get() instanceof PresetUtxoSelector) {
|
|
PresetUtxoSelector presetUtxoSelector = new PresetUtxoSelector(((PresetUtxoSelector)utxoSelector).getPresetUtxos());
|
|
presetUtxoSelector.getPresetUtxos().remove(event.getUtxo());
|
|
utxoSelectorProperty.set(presetUtxoSelector);
|
|
updateTransaction(true);
|
|
} else {
|
|
updateTransaction();
|
|
}
|
|
}
|
|
}
|
|
|
|
@Subscribe
|
|
public void includeMempoolOutputsChangedEvent(IncludeMempoolOutputsChangedEvent event) {
|
|
updateTransaction();
|
|
}
|
|
|
|
@Subscribe
|
|
public void newBlock(NewBlockEvent event) {
|
|
if(cpfpFeeRate.isVisible()) {
|
|
updateTransaction();
|
|
}
|
|
}
|
|
}
|