diff --git a/drongo b/drongo index 74d2bfec..30aff119 160000 --- a/drongo +++ b/drongo @@ -1 +1 @@ -Subproject commit 74d2bfec24204300392d7a750b6b010038fb9727 +Subproject commit 30aff119081a4a13f931ea6625f69d7974addb04 diff --git a/src/main/java/com/sparrowwallet/sparrow/SparrowDesktop.java b/src/main/java/com/sparrowwallet/sparrow/SparrowDesktop.java index a5a3cd20..70db8fe3 100644 --- a/src/main/java/com/sparrowwallet/sparrow/SparrowDesktop.java +++ b/src/main/java/com/sparrowwallet/sparrow/SparrowDesktop.java @@ -45,6 +45,7 @@ public class SparrowDesktop extends Application { GlyphFontRegistry.register(new FontAwesome5()); GlyphFontRegistry.register(new FontAwesome5Brands()); Font.loadFont(AppServices.class.getResourceAsStream("/font/RobotoMono-Regular.ttf"), 13); + Font.loadFont(AppServices.class.getResourceAsStream("/font/RobotoMono-Italic.ttf"), 11); URL.setURLStreamHandlerFactory(protocol -> WalletIcon.PROTOCOL.equals(protocol) ? new WalletIcon.WalletIconStreamHandler() : null); AppServices.initialize(this); diff --git a/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java b/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java index 2c6035f6..693efb61 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java @@ -72,6 +72,7 @@ public class TransactionDiagram extends GridPane { private WalletTransaction walletTx; private final BooleanProperty finalProperty = new SimpleBooleanProperty(false); + private final ObjectProperty labelProperty = new SimpleObjectProperty<>(null); private final ObjectProperty optimizationStrategyProperty = new SimpleObjectProperty<>(OptimizationStrategy.EFFICIENCY); private boolean expanded; private TransactionDiagram expandedDiagram; @@ -154,6 +155,10 @@ public class TransactionDiagram extends GridPane { updateDerivedDiagram(expandedDiagram); } } + + if(getLabel() != null) { + getLabel().update(this); + } } public void update(String message) { @@ -534,7 +539,7 @@ public class TransactionDiagram extends GridPane { return input.getLabel() != null && !input.getLabel().isEmpty() ? input.getLabel() : input.getHashAsString().substring(0, 8) + "..:" + input.getIndex(); } - private String getSatsValue(long amount) { + String getSatsValue(long amount) { UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat(); return format.formatSatsValue(amount); } @@ -923,12 +928,12 @@ public class TransactionDiagram extends GridPane { } if(payment.getType() == Payment.Type.WHIRLPOOL_FEE) { - return "Whirlpool Fee"; + return "Whirlpool fee"; } else if(walletTx.isPremixSend(payment)) { int premixIndex = getOutputIndex(payment.getAddress(), payment.getAmount()) - 2; return "Premix #" + premixIndex; } else if(walletTx.isBadbankSend(payment)) { - return "Badbank Change"; + return "Badbank change"; } return null; @@ -938,7 +943,7 @@ public class TransactionDiagram extends GridPane { return walletTx.getTransaction().getOutputs().stream().filter(txOutput -> address.equals(txOutput.getScript().getToAddress()) && txOutput.getValue() == amount).mapToInt(TransactionOutput::getIndex).findFirst().orElseThrow(); } - private Wallet getToWallet(Payment payment) { + Wallet getToWallet(Payment payment) { for(Wallet openWallet : AppServices.get().getOpenWallets().keySet()) { if(openWallet != walletTx.getWallet() && openWallet.isValid()) { WalletNode addressNode = openWallet.getWalletAddresses().get(payment.getAddress()); @@ -1078,7 +1083,7 @@ public class TransactionDiagram extends GridPane { return changeReplaceGlyph; } - private Glyph getFeeGlyph() { + public Glyph getFeeGlyph() { Glyph feeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.HAND_HOLDING); feeGlyph.getStyleClass().add("fee-icon"); feeGlyph.setFontSize(12); @@ -1162,6 +1167,10 @@ public class TransactionDiagram extends GridPane { } } + public WalletTransaction getWalletTransaction() { + return walletTx; + } + public boolean isFinal() { return finalProperty.get(); } @@ -1174,6 +1183,18 @@ public class TransactionDiagram extends GridPane { this.finalProperty.set(isFinal); } + public TransactionDiagramLabel getLabel() { + return labelProperty.get(); + } + + public ObjectProperty labelProperty() { + return labelProperty; + } + + public void setLabelProperty(TransactionDiagramLabel label) { + this.labelProperty.set(label); + } + public OptimizationStrategy getOptimizationStrategy() { return optimizationStrategyProperty.get(); } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagramLabel.java b/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagramLabel.java new file mode 100644 index 00000000..e3f964bf --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagramLabel.java @@ -0,0 +1,268 @@ +package com.sparrowwallet.sparrow.control; + +import com.sparrowwallet.drongo.wallet.*; +import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; +import javafx.beans.property.IntegerProperty; +import javafx.beans.property.SimpleIntegerProperty; +import javafx.geometry.Pos; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.layout.HBox; +import javafx.scene.text.Font; +import org.controlsfx.glyphfont.Glyph; + +import java.util.*; +import java.util.stream.Collectors; + +public class TransactionDiagramLabel extends HBox { + private final List outputs = new ArrayList<>(); + private final Button left; + private final Button right; + private final IntegerProperty displayedIndex = new SimpleIntegerProperty(-1); + + public TransactionDiagramLabel() { + setSpacing(5); + setAlignment(Pos.CENTER_RIGHT); + + left = new Button(""); + left.setGraphic(getLeftGlyph()); + left.setOnAction(event -> { + int index = displayedIndex.get(); + if(index > 0) { + index--; + } + displayedIndex.set(index); + }); + + right = new Button(""); + right.setGraphic(getRightGlyph()); + right.setOnAction(event -> { + int index = displayedIndex.get(); + if(index < outputs.size() - 1) { + index++; + } + displayedIndex.set(index); + }); + + displayedIndex.addListener((observable, oldValue, newValue) -> { + left.setDisable(newValue.intValue() <= 0); + right.setDisable(newValue.intValue() < 0 || newValue.intValue() >= outputs.size() - 1); + if(oldValue.intValue() >= 0 && oldValue.intValue() < outputs.size()) { + outputs.get(oldValue.intValue()).setVisible(false); + } + if(newValue.intValue() >= 0 && newValue.intValue() < outputs.size()) { + outputs.get(newValue.intValue()).setVisible(true); + } + }); + } + + public void update(TransactionDiagram transactionDiagram) { + getChildren().clear(); + outputs.clear(); + displayedIndex.set(-1); + double maxWidth = getMaxWidth(); + + WalletTransaction walletTx = transactionDiagram.getWalletTransaction(); + List outputLabels = new ArrayList<>(); + + List premixOutputs = walletTx.getPayments().stream().filter(walletTx::isPremixSend).collect(Collectors.toList()); + if(!premixOutputs.isEmpty()) { + OutputLabel premixOutputLabel = getPremixOutputLabel(transactionDiagram, premixOutputs); + if(premixOutputLabel != null) { + outputLabels.add(premixOutputLabel); + } + + Optional optWhirlpoolFee = walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.WHIRLPOOL_FEE).findFirst(); + if(optWhirlpoolFee.isPresent()) { + OutputLabel whirlpoolFeeOutputLabel = getWhirlpoolFeeOutputLabel(transactionDiagram, optWhirlpoolFee.get(), premixOutputs); + outputLabels.add(whirlpoolFeeOutputLabel); + } + + List badbankOutputs = walletTx.getPayments().stream().filter(walletTx::isBadbankSend).collect(Collectors.toList()); + List badbankOutputLabels = badbankOutputs.stream().map(payment -> getBadbankOutputLabel(transactionDiagram, payment)).collect(Collectors.toList()); + outputLabels.addAll(badbankOutputLabels); + } else if(walletTx.getPayments().size() >= 5 && walletTx.getPayments().stream().mapToLong(Payment::getAmount).distinct().count() <= 1 + && walletTx.getWallet().getStandardAccountType() == StandardAccount.WHIRLPOOL_PREMIX && walletTx.getPayments().stream().anyMatch(walletTx::isPostmixSend)) { + OutputLabel mixOutputLabel = getMixOutputLabel(transactionDiagram, walletTx.getPayments()); + if(mixOutputLabel != null) { + outputLabels.add(mixOutputLabel); + } + } else if(walletTx.getPayments().size() >= 5 && walletTx.getPayments().stream().mapToLong(Payment::getAmount).distinct().count() <= 1 + && walletTx.getWallet().getStandardAccountType() == StandardAccount.WHIRLPOOL_POSTMIX && walletTx.getPayments().stream().anyMatch(walletTx::isConsolidationSend)) { + OutputLabel remixOutputLabel = getRemixOutputLabel(transactionDiagram, walletTx.getPayments()); + if(remixOutputLabel != null) { + outputLabels.add(remixOutputLabel); + } + } else { + List payments = walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.DEFAULT && !walletTx.isConsolidationSend(payment)).collect(Collectors.toList()); + List paymentLabels = payments.stream().map(payment -> getOutputLabel(transactionDiagram, payment)).collect(Collectors.toList()); + if(walletTx.getSelectedUtxos().values().stream().allMatch(Objects::isNull)) { + paymentLabels.sort(Comparator.comparingInt(paymentLabel -> (paymentLabel.text.startsWith("Receive") ? 0 : 1))); + } + outputLabels.addAll(paymentLabels); + + List consolidations = walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.DEFAULT && walletTx.isConsolidationSend(payment)).collect(Collectors.toList()); + outputLabels.addAll(consolidations.stream().map(consolidation -> getOutputLabel(transactionDiagram, consolidation)).collect(Collectors.toList())); + + List mixes = walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.MIX || payment.getType() == Payment.Type.FAKE_MIX).collect(Collectors.toList()); + outputLabels.addAll(mixes.stream().map(payment -> getOutputLabel(transactionDiagram, payment)).collect(Collectors.toList())); + } + + Map changeMap = walletTx.getChangeMap(); + outputLabels.addAll(changeMap.entrySet().stream().map(changeEntry -> getOutputLabel(transactionDiagram, changeEntry)).collect(Collectors.toList())); + + OutputLabel feeOutputLabel = getFeeOutputLabel(transactionDiagram); + if(feeOutputLabel != null) { + outputLabels.add(feeOutputLabel); + } + + for(OutputLabel outputLabel : outputLabels) { + maxWidth = Math.max(maxWidth, outputLabel.width); + outputs.add(outputLabel.hBox); + getChildren().add(outputLabel.hBox); + } + + HBox buttonBox = new HBox(); + buttonBox.setAlignment(Pos.CENTER_RIGHT); + buttonBox.getChildren().addAll(left, right); + getChildren().add(buttonBox); + + setMaxWidth(maxWidth); + setPrefWidth(maxWidth); + + if(outputLabels.size() > 0) { + displayedIndex.set(0); + } + } + + private OutputLabel getPremixOutputLabel(TransactionDiagram transactionDiagram, List premixOutputs) { + if(premixOutputs.isEmpty()) { + return null; + } + + Payment premixOutput = premixOutputs.get(0); + long total = premixOutputs.stream().mapToLong(Payment::getAmount).sum(); + Glyph glyph = transactionDiagram.getOutputGlyph(premixOutput); + String text; + if(premixOutputs.size() == 1) { + text = "Premix transaction with 1 output of " + transactionDiagram.getSatsValue(premixOutput.getAmount()) + " sats"; + } else { + text = "Premix transaction with " + premixOutputs.size() + " outputs of " + transactionDiagram.getSatsValue(premixOutput.getAmount()) + " sats each (" + + transactionDiagram.getSatsValue(total) + " sats)"; + } + + return getOutputLabel(glyph, text); + } + + private OutputLabel getBadbankOutputLabel(TransactionDiagram transactionDiagram, Payment payment) { + Glyph glyph = transactionDiagram.getOutputGlyph(payment); + String text = "Badbank change of " + transactionDiagram.getSatsValue(payment.getAmount()) + " sats to " + payment.getAddress().toString(); + + return getOutputLabel(glyph, text); + } + + private OutputLabel getWhirlpoolFeeOutputLabel(TransactionDiagram transactionDiagram, Payment whirlpoolFee, List premixOutputs) { + long total = premixOutputs.stream().mapToLong(Payment::getAmount).sum(); + double feePercentage = (double)whirlpoolFee.getAmount() / (total - whirlpoolFee.getAmount()); + Glyph glyph = transactionDiagram.getOutputGlyph(whirlpoolFee); + String text = "Whirlpool fee of " + transactionDiagram.getSatsValue(whirlpoolFee.getAmount()) + " sats (" + String.format("%.2f", feePercentage * 100.0) + "% of total premix value)"; + + return getOutputLabel(glyph, text); + } + + private OutputLabel getMixOutputLabel(TransactionDiagram transactionDiagram, List mixOutputs) { + if(mixOutputs.isEmpty()) { + return null; + } + + Payment remixOutput = mixOutputs.get(0); + long total = mixOutputs.stream().mapToLong(Payment::getAmount).sum(); + Glyph glyph = TransactionDiagram.getPremixGlyph(); + String text = "Mix transaction with " + mixOutputs.size() + " outputs of " + transactionDiagram.getSatsValue(remixOutput.getAmount()) + " sats each (" + + transactionDiagram.getSatsValue(total) + " sats)"; + + return getOutputLabel(glyph, text); + } + + private OutputLabel getRemixOutputLabel(TransactionDiagram transactionDiagram, List remixOutputs) { + if(remixOutputs.isEmpty()) { + return null; + } + + Payment remixOutput = remixOutputs.get(0); + long total = remixOutputs.stream().mapToLong(Payment::getAmount).sum(); + Glyph glyph = TransactionDiagram.getPremixGlyph(); + String text = "Remix transaction with " + remixOutputs.size() + " outputs of " + transactionDiagram.getSatsValue(remixOutput.getAmount()) + " sats each (" + + transactionDiagram.getSatsValue(total) + " sats)"; + + return getOutputLabel(glyph, text); + } + + private OutputLabel getOutputLabel(TransactionDiagram transactionDiagram, Payment payment) { + WalletTransaction walletTx = transactionDiagram.getWalletTransaction(); + Wallet toWallet = transactionDiagram.getToWallet(payment); + WalletNode toNode = walletTx.getWallet() != null && !walletTx.getWallet().isBip47() ? walletTx.getAddressNodeMap().get(payment.getAddress()) : null; + + Glyph glyph = transactionDiagram.getOutputGlyph(payment); + String text = (toWallet == null ? (toNode != null ? "Consolidate " : "Pay ") : "Receive ") + transactionDiagram.getSatsValue(payment.getAmount()) + " sats to " + payment.getAddress().toString(); + + return getOutputLabel(glyph, text); + } + + private OutputLabel getOutputLabel(TransactionDiagram transactionDiagram, Map.Entry changeEntry) { + WalletTransaction walletTx = transactionDiagram.getWalletTransaction(); + + Glyph glyph = TransactionDiagram.getChangeGlyph(); + String text = "Change of " + transactionDiagram.getSatsValue(changeEntry.getValue()) + " sats to " + walletTx.getChangeAddress(changeEntry.getKey()).toString(); + + return getOutputLabel(glyph, text); + } + + private OutputLabel getFeeOutputLabel(TransactionDiagram transactionDiagram) { + WalletTransaction walletTx = transactionDiagram.getWalletTransaction(); + if(walletTx.getFee() < 0) { + return null; + } + + Glyph glyph = transactionDiagram.getFeeGlyph(); + String text = "Fee of " + transactionDiagram.getSatsValue(walletTx.getFee()) + " sats (" + String.format("%.2f", walletTx.getFeePercentage() * 100.0) + "%)"; + + return getOutputLabel(glyph, text); + } + + private OutputLabel getOutputLabel(Glyph glyph, String text) { + Label icon = new Label(); + icon.setMinWidth(15); + glyph.setFontSize(12); + icon.setGraphic(glyph); + + CopyableLabel label = new CopyableLabel(); + label.setFont(Font.font("Roboto Mono Italic", 13)); + label.setText(text); + + HBox output = new HBox(5); + output.setAlignment(Pos.CENTER); + output.managedProperty().bind(output.visibleProperty()); + output.setVisible(false); + output.getChildren().addAll(icon, label); + + double lineWidth = TextUtils.computeTextWidth(label.getFont(), label.getText(), 0.0D) + 2 + getSpacing() + icon.getMinWidth() + 60; + return new OutputLabel(output, lineWidth, text); + } + + public static Glyph getLeftGlyph() { + Glyph caretLeftGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CARET_LEFT); + caretLeftGlyph.getStyleClass().add("label-left-icon"); + caretLeftGlyph.setFontSize(15); + return caretLeftGlyph; + } + + public static Glyph getRightGlyph() { + Glyph caretRightGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CARET_RIGHT); + caretRightGlyph.getStyleClass().add("label-right-icon"); + caretRightGlyph.setFontSize(15); + return caretRightGlyph; + } + + private record OutputLabel(HBox hBox, double width, String text) {} +} diff --git a/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java b/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java index ae33d192..2b83a2b9 100644 --- a/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java +++ b/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java @@ -25,6 +25,8 @@ public class FontAwesome5 extends GlyphFont { BTC('\uf15a'), BULLSEYE('\uf140'), CAMERA('\uf030'), + CARET_LEFT('\uf0d9'), + CARET_RIGHT('\uf0da'), CHECK_CIRCLE('\uf058'), CIRCLE('\uf111'), COINS('\uf51e'), diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java index 9a52641b..532dacb7 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java @@ -95,6 +95,9 @@ public class HeadersController extends TransactionFormController implements Init @FXML private TransactionDiagram transactionDiagram; + @FXML + private TransactionDiagramLabel transactionDiagramLabel; + @FXML private IntegerSpinner version; @@ -440,6 +443,7 @@ public class HeadersController extends TransactionFormController implements Init updateFee(feeAmt); } + transactionDiagram.labelProperty().set(transactionDiagramLabel); transactionDiagram.update(getWalletTransaction(headersForm.getInputTransactions())); blockchainForm.managedProperty().bind(blockchainForm.visibleProperty()); @@ -628,7 +632,11 @@ public class HeadersController extends TransactionFormController implements Init payments.add(new Payment(txOutput.getScript().getToAddress(), ".." + changeNode + " (Mix)", txOutput.getValue(), false, Payment.Type.MIX)); } } else { - changeMap.put(changeNode, txOutput.getValue()); + if(changeMap.containsKey(changeNode)) { + payments.add(new Payment(txOutput.getScript().getToAddress(), headersForm.getName(), txOutput.getValue(), false, Payment.Type.DEFAULT)); + } else { + changeMap.put(changeNode, txOutput.getValue()); + } } } else { Payment.Type paymentType = Payment.Type.DEFAULT; diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionController.java index a3a53fce..0ad686dd 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionController.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionController.java @@ -70,14 +70,14 @@ public class TransactionController implements Initializable { public void initializeView() { fetchTransactions(); initializeTxTree(); - transactionMasterDetail.setDividerPosition(0.82); + transactionMasterDetail.setDividerPosition(0.85); transactionMasterDetail.setShowDetailNode(Config.get().isShowTransactionHex()); txhex.setTransaction(getTransaction()); highlightTxHex(); transactionMasterDetail.sceneProperty().addListener((observable, oldScene, newScene) -> { if(oldScene == null && newScene != null) { - transactionMasterDetail.setDividerPosition(AppServices.isReducedWindowHeight(transactionMasterDetail) ? 0.9 : 0.82); + transactionMasterDetail.setDividerPosition(AppServices.isReducedWindowHeight(transactionMasterDetail) ? 0.9 : 0.85); } }); } diff --git a/src/main/resources/com/sparrowwallet/sparrow/transaction/headers.css b/src/main/resources/com/sparrowwallet/sparrow/transaction/headers.css index bc1ae3be..cc2dc6b5 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/transaction/headers.css +++ b/src/main/resources/com/sparrowwallet/sparrow/transaction/headers.css @@ -82,6 +82,22 @@ -fx-text-fill: rgb(238, 210, 2); } +#transactionDiagramLabel .button { + -fx-padding: 0; + -fx-pref-height: 18; + -fx-pref-width: 18; + -fx-border-width: 0; + -fx-background-color: -fx-background; +} + +#transactionDiagramLabel .button .glyph-font { + -fx-text-fill: #0184bc; +} + +#transactionDiagramLabel .button:hover .glyph-font { + -fx-text-fill: #259cf5; +} + .details-lower .fieldset { -fx-padding: 0 0 0 0; } diff --git a/src/main/resources/com/sparrowwallet/sparrow/transaction/headers.fxml b/src/main/resources/com/sparrowwallet/sparrow/transaction/headers.fxml index ab07619b..30d074a2 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/transaction/headers.fxml +++ b/src/main/resources/com/sparrowwallet/sparrow/transaction/headers.fxml @@ -29,6 +29,7 @@ + @@ -73,7 +74,10 @@ - + + + + diff --git a/src/main/resources/font/RobotoMono-Italic.ttf b/src/main/resources/font/RobotoMono-Italic.ttf new file mode 100644 index 00000000..61e53033 Binary files /dev/null and b/src/main/resources/font/RobotoMono-Italic.ttf differ