diff --git a/drongo b/drongo index d30cc443..a8968092 160000 --- a/drongo +++ b/drongo @@ -1 +1 @@ -Subproject commit d30cc4432cec39ff44ad6c11ab324200a9629a8c +Subproject commit a896809286f6f4393202110a15a4dd525f14cf73 diff --git a/src/main/java/com/sparrowwallet/sparrow/AppController.java b/src/main/java/com/sparrowwallet/sparrow/AppController.java index 84aa0060..4dbe740f 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppController.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppController.java @@ -10,6 +10,7 @@ import com.sparrowwallet.drongo.dns.DnsPaymentCache; import com.sparrowwallet.drongo.policy.PolicyType; import com.sparrowwallet.drongo.protocol.*; import com.sparrowwallet.drongo.psbt.*; +import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress; import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.hummingbird.UR; import com.sparrowwallet.hummingbird.registry.CryptoPSBT; @@ -822,10 +823,10 @@ public class AppController implements Initializable { try(FileOutputStream outputStream = new FileOutputStream(file)) { if(asText) { PrintWriter writer = new PrintWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)); - writer.print(transactionTabData.getPsbt().toBase64String(includeXpubs)); + writer.print(transactionTabData.getPsbt().getForExport().toBase64String(includeXpubs)); writer.flush(); } else { - outputStream.write(transactionTabData.getPsbt().serialize(includeXpubs, true)); + outputStream.write(transactionTabData.getPsbt().getForExport().serialize(includeXpubs, true)); } } catch(IOException e) { log.error("Error saving PSBT", e); @@ -848,7 +849,7 @@ public class AppController implements Initializable { TabData tabData = (TabData)selectedTab.getUserData(); if(tabData.getType() == TabData.TabType.TRANSACTION) { TransactionTabData transactionTabData = (TransactionTabData)tabData; - String data = asBase64 ? transactionTabData.getPsbt().toBase64String() : transactionTabData.getPsbt().toString(); + String data = asBase64 ? transactionTabData.getPsbt().getForExport().toBase64String() : transactionTabData.getPsbt().getForExport().toString(); ClipboardContent content = new ClipboardContent(); content.putString(data); @@ -862,7 +863,7 @@ public class AppController implements Initializable { if(tabData.getType() == TabData.TabType.TRANSACTION) { TransactionTabData transactionTabData = (TransactionTabData)tabData; - byte[] psbtBytes = transactionTabData.getPsbt().serialize(); + byte[] psbtBytes = transactionTabData.getPsbt().getForExport().serialize(); CryptoPSBT cryptoPSBT = new CryptoPSBT(psbtBytes); BBQR bbqr = new BBQR(BBQRType.PSBT, psbtBytes); QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(cryptoPSBT.toUR(), bbqr, false, true, false); @@ -1897,6 +1898,11 @@ public class AppController implements Initializable { } private void addTransactionTab(String name, File file, PSBT psbt) { + //Convert to PSBTv0 first + if(psbt.getVersion() != null && psbt.getVersion() >= 2) { + psbt.convertVersion(0); + } + //Add any missing previous outputs if available in open wallets for(PSBTInput psbtInput : psbt.getPsbtInputs()) { if(psbtInput.getUtxo() == null) { @@ -1920,13 +1926,32 @@ public class AppController implements Initializable { for(PSBTOutput psbtOutput : psbt.getPsbtOutputs()) { if(psbtOutput.getDnssecProof() != null && !psbtOutput.getDnssecProof().isEmpty() && psbtOutput.getScript() != null) { Address address = psbtOutput.getScript().getToAddress(); - if(address != null && DnsPaymentCache.getDnsPayment(address) == null) { - try { - Optional optDnsPayment = psbtOutput.getDnsPayment(); - optDnsPayment.ifPresent(dnsPayment -> DnsPaymentCache.putDnsPayment(address, dnsPayment)); - } catch(Exception e) { - log.debug("Error resolving DNS payment", e); - } + if(address != null) { + Optional optSilentPaymentAddress = AppServices.get().getOpenWallets().keySet().stream() + .map(wallet -> wallet.getSilentPaymentAddress(address)).filter(Objects::nonNull).findFirst(); + optSilentPaymentAddress.ifPresentOrElse(silentPaymentAddress -> { + if(DnsPaymentCache.getDnsPayment(silentPaymentAddress) == null) { + try { + Optional optDnsPayment = psbtOutput.getDnsPayment(); + if(optDnsPayment.isPresent() && optDnsPayment.get().hasSilentPaymentAddress()) { + DnsPaymentCache.putDnsPayment(silentPaymentAddress, optDnsPayment.get()); + } + } catch(Exception e) { + log.debug("Error resolving DNS payment", e); + } + } + }, () -> { + if(DnsPaymentCache.getDnsPayment(address) == null) { + try { + Optional optDnsPayment = psbtOutput.getDnsPayment(); + if(optDnsPayment.isPresent() && optDnsPayment.get().hasAddress()) { + DnsPaymentCache.putDnsPayment(address, optDnsPayment.get()); + } + } catch(Exception e) { + log.debug("Error resolving DNS payment", e); + } + } + }); } } } diff --git a/src/main/java/com/sparrowwallet/sparrow/AppServices.java b/src/main/java/com/sparrowwallet/sparrow/AppServices.java index 2e79d8f9..8c9ff60d 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppServices.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppServices.java @@ -835,8 +835,8 @@ public class AppServices { } public static void addPayjoinURI(BitcoinURI bitcoinURI) { - if(bitcoinURI.getPayjoinUrl() == null) { - throw new IllegalArgumentException("Not a payjoin URI"); + if(bitcoinURI.getPayjoinUrl() == null || bitcoinURI.getAddress() == null) { + throw new IllegalArgumentException("Not a valid payjoin URI"); } payjoinURIs.put(bitcoinURI.getAddress(), bitcoinURI); } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java b/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java index 0c925cec..7df648a3 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java @@ -5,6 +5,8 @@ import com.sparrowwallet.drongo.OsType; import com.sparrowwallet.drongo.Utils; import com.sparrowwallet.drongo.address.Address; import com.sparrowwallet.drongo.protocol.*; +import com.sparrowwallet.drongo.silentpayments.SilentPayment; +import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress; import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.EventManager; @@ -66,8 +68,7 @@ public class EntryCell extends TreeTableCell implements Confirmati setText(null); setGraphic(null); } else { - if(entry instanceof TransactionEntry) { - TransactionEntry transactionEntry = (TransactionEntry)entry; + if(entry instanceof TransactionEntry transactionEntry) { if(transactionEntry.getBlockTransaction().getHeight() == -1) { setText("Unconfirmed Parent"); setContextMenu(new UnconfirmedTransactionContextMenu(transactionEntry)); @@ -101,7 +102,7 @@ public class EntryCell extends TreeTableCell implements Confirmati actionBox.getChildren().add(viewTransactionButton); BlockTransaction blockTransaction = transactionEntry.getBlockTransaction(); - if(blockTransaction.getHeight() <= 0 && canRBF(blockTransaction) && + if(blockTransaction.getHeight() <= 0 && canRBF(blockTransaction, transactionEntry.getWallet()) && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) { Button increaseFeeButton = new Button(""); increaseFeeButton.setGraphic(getIncreaseFeeRBFGlyph()); @@ -121,8 +122,7 @@ public class EntryCell extends TreeTableCell implements Confirmati } setGraphic(actionBox); - } else if(entry instanceof NodeEntry) { - NodeEntry nodeEntry = (NodeEntry)entry; + } else if(entry instanceof NodeEntry nodeEntry) { Address address = nodeEntry.getAddress(); setText(address.toString()); setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor(), nodeEntry, true, getTreeTableView())); @@ -163,8 +163,7 @@ public class EntryCell extends TreeTableCell implements Confirmati setContextMenu(null); setGraphic(new HBox()); } - } else if(entry instanceof HashIndexEntry) { - HashIndexEntry hashIndexEntry = (HashIndexEntry)entry; + } else if(entry instanceof HashIndexEntry hashIndexEntry) { setText(hashIndexEntry.getDescription()); setContextMenu(getTreeTableView().getStyleClass().contains("bip47") ? null : new HashIndexEntryContextMenu(getTreeTableView(), hashIndexEntry)); Tooltip tooltip = new Tooltip(); @@ -212,13 +211,14 @@ public class EntryCell extends TreeTableCell implements Confirmati private static void increaseFee(TransactionEntry transactionEntry, boolean cancelTransaction) { BlockTransaction blockTransaction = transactionEntry.getBlockTransaction(); + boolean silentPaymentTransaction = transactionEntry.getWallet().isSilentPaymentsTransaction(blockTransaction); Map walletTxos = transactionEntry.getWallet().getWalletTxos(); List 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())) - .filter(i -> Config.get().isMempoolFullRbf() || i.isReplaceByFeeEnabled()) + .filter(i -> Config.get().isMempoolFullRbf() || i.isReplaceByFeeEnabled() || silentPaymentTransaction) .map(txInput -> walletTxos.keySet().stream().filter(txo -> txo.getHash().equals(txInput.getOutpoint().getHash()) && txo.getIndex() == txInput.getOutpoint().getIndex()).findFirst().get()) .collect(Collectors.toList()); @@ -243,6 +243,7 @@ public class EntryCell extends TreeTableCell implements Confirmati .collect(Collectors.toList()); boolean consolidationTransaction = consolidationOutputs.size() == blockTransaction.getTransaction().getOutputs().size() && consolidationOutputs.size() == 1; + boolean safeToAddInputsOrOutputs = transactionEntry.getWallet().isSafeToAddInputsOrOutputs(blockTransaction); long changeTotal = ourOutputs.stream().mapToLong(TransactionOutput::getValue).sum() - consolidationOutputs.stream().mapToLong(TransactionOutput::getValue).sum(); Transaction tx = blockTransaction.getTransaction(); double vSize = tx.getVirtualSize(); @@ -257,7 +258,7 @@ public class EntryCell extends TreeTableCell implements Confirmati List outputGroups = transactionEntry.getWallet().getGroupedUtxos(txoFilters, feeRate, AppServices.getMinimumRelayFeeRate(), Config.get().isGroupByAddress()) .stream().filter(outputGroup -> outputGroup.getEffectiveValue() >= 0).collect(Collectors.toList()); Collections.shuffle(outputGroups); - while((double)changeTotal / vSize < getMaxFeeRate() && !outputGroups.isEmpty() && !cancelTransaction && !consolidationTransaction) { + while((double)changeTotal / vSize < getMaxFeeRate() && !outputGroups.isEmpty() && !cancelTransaction && !consolidationTransaction && safeToAddInputsOrOutputs) { //If there is insufficient change output, include another random output group so the fee can be increased OutputGroup outputGroup = outputGroups.remove(0); for(BlockTransactionHashIndex utxo : outputGroup.getUtxos()) { @@ -298,9 +299,13 @@ public class EntryCell extends TreeTableCell implements Confirmati label += " (Replaced By Fee)"; } - if(txOutput.getScript().getToAddress() != null) { + Address address = txOutput.getScript().getToAddress(); + if(address != null) { + long value = txOutput.getValue(); //Disable change creation by enabling max payment when there is only one output and no additional UTXOs included - return new Payment(txOutput.getScript().getToAddress(), label, txOutput.getValue(), blockTransaction.getTransaction().getOutputs().size() == 1 && rbfChange == 0); + boolean sendMax = blockTransaction.getTransaction().getOutputs().size() == 1 && rbfChange == 0; + SilentPaymentAddress silentPaymentAddress = transactionEntry.getWallet().getSilentPaymentAddress(address); + return silentPaymentAddress == null ? new Payment(address, label, value, sendMax) : new SilentPayment(silentPaymentAddress, label, value, sendMax); } return null; @@ -337,7 +342,7 @@ public class EntryCell extends TreeTableCell implements Confirmati } EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), utxos)); - Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, payments, opReturns.isEmpty() ? null : opReturns, rbfFee, true, blockTransaction))); + Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, payments, opReturns.isEmpty() ? null : opReturns, rbfFee, true, blockTransaction, safeToAddInputsOrOutputs))); } private static Double getMaxFeeRate() { @@ -394,11 +399,11 @@ public class EntryCell extends TreeTableCell implements Confirmati Payment payment = new Payment(freshAddress, label, inputTotal, true); EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), utxos)); - Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, List.of(payment), null, blockTransaction.getFee(), true, null))); + Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, List.of(payment), null, blockTransaction.getFee(), true, null, true))); } - private static boolean canRBF(BlockTransaction blockTransaction) { - return Config.get().isMempoolFullRbf() || blockTransaction.getTransaction().isReplaceByFee(); + private static boolean canRBF(BlockTransaction blockTransaction, Wallet wallet) { + return Config.get().isMempoolFullRbf() || blockTransaction.getTransaction().isReplaceByFee() || wallet.isSilentPaymentsTransaction(blockTransaction); } private static boolean canSignMessage(WalletNode walletNode) { @@ -476,7 +481,7 @@ public class EntryCell extends TreeTableCell implements Confirmati tooltip += "\nFee rate: " + String.format("%.2f", feeRate) + " sats/vB"; } - tooltip += "\nRBF: " + (canRBF(transactionEntry.getBlockTransaction()) ? "Enabled" : "Disabled"); + tooltip += "\nRBF: " + (canRBF(transactionEntry.getBlockTransaction(), transactionEntry.getWallet()) ? "Enabled" : "Disabled"); } return tooltip; @@ -544,6 +549,7 @@ public class EntryCell extends TreeTableCell implements Confirmati private static class UnconfirmedTransactionContextMenu extends ContextMenu { public UnconfirmedTransactionContextMenu(TransactionEntry transactionEntry) { + Wallet wallet = transactionEntry.getWallet(); BlockTransaction blockTransaction = transactionEntry.getBlockTransaction(); MenuItem viewTransaction = new MenuItem("View Transaction"); viewTransaction.setGraphic(getViewTransactionGlyph()); @@ -553,7 +559,7 @@ public class EntryCell extends TreeTableCell implements Confirmati }); getItems().add(viewTransaction); - if(canRBF(blockTransaction) && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) { + if(canRBF(blockTransaction, wallet) && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) { MenuItem increaseFee = new MenuItem("Increase Fee (RBF)"); increaseFee.setGraphic(getIncreaseFeeRBFGlyph()); increaseFee.setOnAction(AE -> { @@ -564,7 +570,7 @@ public class EntryCell extends TreeTableCell implements Confirmati getItems().add(increaseFee); } - if(canRBF(blockTransaction) && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) { + if(canRBF(blockTransaction, wallet) && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) { MenuItem cancelTx = new MenuItem("Cancel Transaction (RBF)"); cancelTx.setGraphic(getCancelTransactionRBFGlyph()); cancelTx.setOnAction(AE -> { diff --git a/src/main/java/com/sparrowwallet/sparrow/control/SendToManyDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/SendToManyDialog.java index bad10169..d1cd0d7b 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/SendToManyDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/SendToManyDialog.java @@ -6,6 +6,8 @@ import com.sparrowwallet.drongo.OsType; import com.sparrowwallet.drongo.address.Address; import com.sparrowwallet.drongo.address.InvalidAddressException; import com.sparrowwallet.drongo.protocol.Transaction; +import com.sparrowwallet.drongo.silentpayments.SilentPayment; +import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress; import com.sparrowwallet.drongo.wallet.Payment; import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; @@ -30,7 +32,7 @@ import java.util.stream.IntStream; public class SendToManyDialog extends Dialog> { private final BitcoinUnit bitcoinUnit; private final SpreadsheetView spreadsheetView; - public static final AddressCellType ADDRESS = new AddressCellType(); + public static final SendToAddressCellType SEND_TO_ADDRESS = new SendToAddressCellType(); public SendToManyDialog(BitcoinUnit bitcoinUnit, List payments) { this.bitcoinUnit = bitcoinUnit; @@ -92,7 +94,8 @@ public class SendToManyDialog extends Dialog> { for(int row = 0; row < grid.getRowCount(); ++row) { final ObservableList list = FXCollections.observableArrayList(); - SpreadsheetCell addressCell = ADDRESS.createCell(row, 0, 1, 1, payments.get(row).getAddress()); + SendToAddress sendToAddress = SendToAddress.fromPayment(payments.get(row)); + SpreadsheetCell addressCell = SEND_TO_ADDRESS.createCell(row, 0, 1, 1, sendToAddress); addressCell.getStyleClass().add("fixed-width"); list.add(addressCell); @@ -123,7 +126,7 @@ public class SendToManyDialog extends Dialog> { String firstLabel = null; for(int row = 0; row < grid.getRowCount(); row++) { ObservableList rowCells = spreadsheetView.getItems().get(row); - Address address = (Address)rowCells.get(0).getItem(); + SendToAddress sendToAddress = (SendToAddress)rowCells.get(0).getItem(); Double value = (Double)rowCells.get(1).getItem(); String label = (String)rowCells.get(2).getItem(); if(firstLabel == null) { @@ -133,12 +136,12 @@ public class SendToManyDialog extends Dialog> { label = firstLabel; } - if(address != null && value != null) { + if(sendToAddress != null && value != null) { if(bitcoinUnit == BitcoinUnit.BTC) { value = value * Transaction.SATOSHIS_PER_BITCOIN; } - payments.add(new Payment(address, label, value.longValue(), false)); + payments.add(sendToAddress.toPayment(label, value.longValue(), false)); } } @@ -183,9 +186,14 @@ public class SendToManyDialog extends Dialog> { } else { amount = Long.parseLong(csvReader.get(1).replace(",", "")); } - Address address = Address.fromString(csvReader.get(0)); String label = csvReader.get(2); - csvPayments.add(new Payment(address, label, amount, false)); + try { + SilentPaymentAddress silentPaymentAddress = SilentPaymentAddress.from(csvReader.get(0)); + csvPayments.add(new SilentPayment(silentPaymentAddress, label, amount, false)); + } catch(Exception e) { + Address address = Address.fromString(csvReader.get(0)); + csvPayments.add(new Payment(address, label, amount, false)); + } } catch(NumberFormatException e) { //ignore and continue - probably a header line } catch(InvalidAddressException e) { @@ -221,16 +229,16 @@ public class SendToManyDialog extends Dialog> { } } - public static class AddressCellType extends SpreadsheetCellType
{ - public AddressCellType() { - this(new StringConverterWithFormat<>(new AddressStringConverter()) { + public static class SendToAddressCellType extends SpreadsheetCellType { + public SendToAddressCellType() { + this(new StringConverterWithFormat<>(new SendToAddressStringConverter()) { @Override - public String toString(Address item) { + public String toString(SendToAddress item) { return toStringFormat(item, ""); //$NON-NLS-1$ } @Override - public Address fromString(String str) { + public SendToAddress fromString(String str) { if(str == null || str.isEmpty()) { //$NON-NLS-1$ return null; } else { @@ -239,7 +247,7 @@ public class SendToManyDialog extends Dialog> { } @Override - public String toStringFormat(Address item, String format) { + public String toStringFormat(SendToAddress item, String format) { try { if(item == null) { return ""; //$NON-NLS-1$ @@ -253,7 +261,7 @@ public class SendToManyDialog extends Dialog> { }); } - public AddressCellType(StringConverter
converter) { + public SendToAddressCellType(StringConverter converter) { super(converter); } @@ -263,7 +271,7 @@ public class SendToManyDialog extends Dialog> { } public SpreadsheetCell createCell(final int row, final int column, final int rowSpan, final int columnSpan, - final Address value) { + final SendToAddress value) { SpreadsheetCell cell = new SpreadsheetCellBase(row, column, rowSpan, columnSpan, this); cell.setItem(value); return cell; @@ -276,7 +284,7 @@ public class SendToManyDialog extends Dialog> { @Override public boolean match(Object value, Object... options) { - if(value instanceof Address) + if(value instanceof SendToAddress) return true; else { try { @@ -289,9 +297,9 @@ public class SendToManyDialog extends Dialog> { } @Override - public Address convertValue(Object value) { - if(value instanceof Address) - return (Address)value; + public SendToAddress convertValue(Object value) { + if(value instanceof SendToAddress) + return (SendToAddress)value; else { try { return converter.fromString(value == null ? null : value.toString()); @@ -302,13 +310,64 @@ public class SendToManyDialog extends Dialog> { } @Override - public String toString(Address item) { + public String toString(SendToAddress item) { return converter.toString(item); } @Override - public String toString(Address item, String format) { - return ((StringConverterWithFormat
)converter).toStringFormat(item, format); + public String toString(SendToAddress item, String format) { + return ((StringConverterWithFormat)converter).toStringFormat(item, format); } }; + + public static class SendToAddress { + private final Address address; + private final SilentPaymentAddress silentPaymentAddress; + + public SendToAddress(Address address) { + this.address = address; + this.silentPaymentAddress = null; + } + + public SendToAddress(SilentPaymentAddress silentPaymentAddress) { + this.address = null; + this.silentPaymentAddress = silentPaymentAddress; + } + + public String toString() { + return silentPaymentAddress == null ? (address == null ? null : address.toString()) : silentPaymentAddress.toString(); + } + + public static SendToAddress fromPayment(Payment payment) { + return payment instanceof SilentPayment ? new SendToAddress(((SilentPayment)payment).getSilentPaymentAddress()) : new SendToAddress(payment.getAddress()); + } + + public Payment toPayment(String label, long value, boolean sendMax) { + if(silentPaymentAddress != null) { + return new SilentPayment(silentPaymentAddress, label, value, sendMax); + } else { + return new Payment(address, label, value, sendMax); + } + } + } + + private static class SendToAddressStringConverter extends StringConverter { + private final AddressStringConverter addressStringConverter = new AddressStringConverter(); + + @Override + public SendToAddress fromString(String value) { + try { + SilentPaymentAddress silentPaymentAddress = SilentPaymentAddress.from(value); + return new SendToAddress(silentPaymentAddress); + } catch(Exception e) { + Address address = addressStringConverter.fromString(value); + return address == null ? null : new SendToAddress(address); + } + } + + @Override + public String toString(SendToAddress value) { + return value.toString(); + } + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java b/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java index e19b37c3..bddf0748 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java @@ -8,6 +8,8 @@ import com.sparrowwallet.drongo.dns.DnsPayment; import com.sparrowwallet.drongo.dns.DnsPaymentCache; import com.sparrowwallet.drongo.protocol.Sha256Hash; import com.sparrowwallet.drongo.protocol.TransactionOutput; +import com.sparrowwallet.drongo.silentpayments.SilentPayment; +import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress; import com.sparrowwallet.drongo.uri.BitcoinURI; import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.sparrow.*; @@ -202,7 +204,7 @@ public class TransactionDiagram extends GridPane { VBox messagePane = new VBox(); messagePane.setPrefHeight(getDiagramHeight()); - messagePane.setPadding(new Insets(0, 10, 0, 280)); + messagePane.setPadding(new Insets(0, 10, 0, 10)); messagePane.setAlignment(Pos.CENTER); messagePane.getChildren().add(createSpacer()); @@ -676,7 +678,8 @@ public class TransactionDiagram extends GridPane { double width = 140.0; long sum = walletTx.getTotal(); - List values = walletTx.getTransaction().getOutputs().stream().filter(txo -> txo.getScript().getToAddress() != null).map(TransactionOutput::getValue).collect(Collectors.toList()); + List values = walletTx.getOutputs().stream().filter(output -> !(output instanceof WalletTransaction.NonAddressOutput)) + .map(output -> output.getTransactionOutput().getValue()).collect(Collectors.toList()); values.add(walletTx.getFee()); int numOutputs = displayedPayments.size() + walletTx.getChangeMap().size() + 1; for(int i = 1; i <= numOutputs; i++) { @@ -720,16 +723,16 @@ public class TransactionDiagram extends GridPane { for(Payment payment : displayedPayments) { Glyph outputGlyph = GlyphUtils.getOutputGlyph(walletTx, payment); boolean labelledPayment = outputGlyph.getStyleClass().stream().anyMatch(style -> List.of("premix-icon", "badbank-icon", "whirlpoolfee-icon", "anchor-icon").contains(style)) || payment instanceof AdditionalPayment || payment.getLabel() != null; - Label recipientLabel = new Label(payment.getLabel() == null || payment.getType() == Payment.Type.FAKE_MIX || payment.getType() == Payment.Type.MIX ? payment.getAddress().toString().substring(0, 8) + "..." : payment.getLabel(), outputGlyph); + Label recipientLabel = new Label(payment.getLabel() == null || payment.getType() == Payment.Type.FAKE_MIX || payment.getType() == Payment.Type.MIX ? payment.toString().substring(0, 8) + "..." : payment.getLabel(), outputGlyph); recipientLabel.getStyleClass().add("output-label"); recipientLabel.getStyleClass().add(labelledPayment ? "payment-label" : "recipient-label"); Wallet toWallet = walletTx.getToWallet(AppServices.get().getOpenWallets().keySet(), payment); WalletNode toNode = walletTx.getWallet() != null && !walletTx.getWallet().isBip47() ? walletTx.getAddressNodeMap().get(payment.getAddress()) : null; Wallet toBip47Wallet = getBip47SendWallet(payment); - DnsPayment dnsPayment = DnsPaymentCache.getDnsPayment(payment.getAddress()); + DnsPayment dnsPayment = DnsPaymentCache.getDnsPayment(payment); Tooltip recipientTooltip = new Tooltip((toWallet == null ? (toNode != null ? "Consolidate " : "Pay ") : "Receive ") + getSatsValue(payment.getAmount()) + " sats to " - + (payment instanceof AdditionalPayment ? (isExpanded() ? "\n" : "(click to expand)\n") + payment : (toWallet == null ? (dnsPayment == null ? (payment.getLabel() == null ? (toNode != null ? toNode : (toBip47Wallet == null ? "external address" : toBip47Wallet.getDisplayName())) : payment.getLabel()) : dnsPayment.toString()) : toWallet.getFullDisplayName()) + "\n" + payment.getAddress().toString()) + + (payment instanceof AdditionalPayment ? (isExpanded() ? "\n" : "(click to expand)\n") + payment : (toWallet == null ? (dnsPayment == null ? (payment.getLabel() == null ? (toNode != null ? toNode : (toBip47Wallet == null ? "external address" : toBip47Wallet.getDisplayName())) : payment.getLabel()) : dnsPayment.toString()) : toWallet.getFullDisplayName()) + "\n" + payment.getDisplayAddress()) + (walletTx.isDuplicateAddress(payment) ? " (Duplicate)" : "")); recipientTooltip.getStyleClass().add("recipient-label"); recipientTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY)); @@ -754,9 +757,13 @@ public class TransactionDiagram extends GridPane { paymentBox.getChildren().addAll(region, amountLabel); } - Wallet bip47Wallet = toWallet != null && toWallet.isBip47() ? toWallet : (toBip47Wallet != null && toBip47Wallet.isBip47() ? toBip47Wallet : null); - PaymentCode paymentCode = bip47Wallet == null ? null : bip47Wallet.getKeystores().getFirst().getExternalPaymentCode(); - outputNodes.add(new OutputNode(paymentBox, payment.getAddress(), payment.getAmount(), paymentCode)); + if(payment instanceof SilentPayment silentPayment) { + outputNodes.add(new OutputNode(paymentBox, silentPayment.isAddressComputed() ? silentPayment.getAddress() : null, payment.getAmount(), null, silentPayment.getSilentPaymentAddress())); + } else { + Wallet bip47Wallet = toWallet != null && toWallet.isBip47() ? toWallet : (toBip47Wallet != null && toBip47Wallet.isBip47() ? toBip47Wallet : null); + PaymentCode paymentCode = bip47Wallet == null ? null : bip47Wallet.getKeystores().getFirst().getExternalPaymentCode(); + outputNodes.add(new OutputNode(paymentBox, payment.getAddress(), payment.getAmount(), paymentCode, null)); + } } Set seenIndexes = new HashSet<>(); @@ -820,7 +827,7 @@ public class TransactionDiagram extends GridPane { outputsBox.getChildren().add(outputNode.outputLabel); outputsBox.getChildren().add(createSpacer()); - ContextMenu contextMenu = new LabelContextMenu(outputNode.address, outputNode.amount, outputNode.paymentCode); + ContextMenu contextMenu = new LabelContextMenu(outputNode.address, outputNode.amount, outputNode.paymentCode, outputNode.silentPaymentAddress); if(!outputNode.outputLabel.getChildren().isEmpty() && outputNode.outputLabel.getChildren().get(0) instanceof Label outputLabelControl) { outputLabelControl.setContextMenu(contextMenu); } @@ -995,8 +1002,11 @@ public class TransactionDiagram extends GridPane { } private int getOutputIndex(Address address, long amount, Collection seenIndexes) { - List addressOutputs = walletTx.getTransaction().getOutputs().stream().filter(txOutput -> txOutput.getScript().getToAddress() != null).collect(Collectors.toList()); - TransactionOutput output = addressOutputs.stream().filter(txOutput -> address.equals(txOutput.getScript().getToAddress()) && txOutput.getValue() == amount && !seenIndexes.contains(txOutput.getIndex())).findFirst().orElseThrow(); + List addressOutputs = walletTx.getOutputs().stream().filter(output -> !(output instanceof WalletTransaction.NonAddressOutput)) + .map(WalletTransaction.Output::getTransactionOutput).collect(Collectors.toList()); + TransactionOutput output = addressOutputs.stream() + .filter(txOutput -> address.equals(txOutput.getScript().getToAddress()) && txOutput.getValue() == amount && !seenIndexes.contains(txOutput.getIndex())) + .findFirst().orElseThrow(); return addressOutputs.indexOf(output); } @@ -1146,7 +1156,7 @@ public class TransactionDiagram extends GridPane { } public String toString() { - return additionalPayments.stream().map(payment -> payment.getAddress().toString()).collect(Collectors.joining("\n")); + return additionalPayments.stream().map(Payment::toString).collect(Collectors.joining("\n")); } } @@ -1155,25 +1165,27 @@ public class TransactionDiagram extends GridPane { public Address address; public long amount; public PaymentCode paymentCode; + public SilentPaymentAddress silentPaymentAddress; public OutputNode(Pane outputLabel, Address address, long amount) { - this(outputLabel, address, amount, null); + this(outputLabel, address, amount, null, null); } - public OutputNode(Pane outputLabel, Address address, long amount, PaymentCode paymentCode) { + public OutputNode(Pane outputLabel, Address address, long amount, PaymentCode paymentCode, SilentPaymentAddress silentPaymentAddress) { this.outputLabel = outputLabel; this.address = address; this.amount = amount; this.paymentCode = paymentCode; + this.silentPaymentAddress = silentPaymentAddress; } } private class LabelContextMenu extends ContextMenu { public LabelContextMenu(Address address, long value) { - this(address, value, null); + this(address, value, null, null); } - public LabelContextMenu(Address address, long value, PaymentCode paymentCode) { + public LabelContextMenu(Address address, long value, PaymentCode paymentCode, SilentPaymentAddress silentPaymentAddress) { if(address != null) { MenuItem copyAddress = new MenuItem("Copy Address"); copyAddress.setOnAction(event -> { @@ -1221,6 +1233,17 @@ public class TransactionDiagram extends GridPane { }); getItems().add(copyPaymentCode); } + + if(silentPaymentAddress != null) { + MenuItem copySilentPaymentAddress = new MenuItem("Copy Silent Payment Address"); + copySilentPaymentAddress.setOnAction(AE -> { + hide(); + ClipboardContent content = new ClipboardContent(); + content.putString(silentPaymentAddress.toString()); + Clipboard.getSystemClipboard().setContent(content); + }); + getItems().add(copySilentPaymentAddress); + } } } } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagramLabel.java b/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagramLabel.java index c87acb56..76432d7b 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagramLabel.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagramLabel.java @@ -1,7 +1,5 @@ package com.sparrowwallet.sparrow.control; -import com.sparrowwallet.drongo.dns.DnsPayment; -import com.sparrowwallet.drongo.dns.DnsPaymentCache; import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; @@ -208,9 +206,7 @@ public class TransactionDiagramLabel extends HBox { WalletNode toNode = walletTx.getWallet() != null && !walletTx.getWallet().isBip47() ? walletTx.getAddressNodeMap().get(payment.getAddress()) : null; Glyph glyph = GlyphUtils.getOutputGlyph(transactionDiagram.getWalletTransaction(), payment); - DnsPayment dnsPayment = DnsPaymentCache.getDnsPayment(payment.getAddress()); - String recipient = dnsPayment == null ? payment.getAddress().toString() : dnsPayment.toString(); - String text = (toWallet == null ? (toNode != null ? "Consolidate " : "Pay ") : "Receive ") + transactionDiagram.getSatsValue(payment.getAmount()) + " sats to " + recipient; + String text = (toWallet == null ? (toNode != null ? "Consolidate " : "Pay ") : "Receive ") + transactionDiagram.getSatsValue(payment.getAmount()) + " sats to " + payment; return getOutputLabel(glyph, text); } diff --git a/src/main/java/com/sparrowwallet/sparrow/event/SpendUtxoEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/SpendUtxoEvent.java index 4816820e..e67211a6 100644 --- a/src/main/java/com/sparrowwallet/sparrow/event/SpendUtxoEvent.java +++ b/src/main/java/com/sparrowwallet/sparrow/event/SpendUtxoEvent.java @@ -17,12 +17,13 @@ public class SpendUtxoEvent { private final boolean requireAllUtxos; private final BlockTransaction replacedTransaction; private final PaymentCode paymentCode; + private final boolean allowPaymentChanges; public SpendUtxoEvent(Wallet wallet, List utxos) { - this(wallet, utxos, null, null, null, false, null); + this(wallet, utxos, null, null, null, false, null, true); } - public SpendUtxoEvent(Wallet wallet, List utxos, List payments, List opReturns, Long fee, boolean requireAllUtxos, BlockTransaction replacedTransaction) { + public SpendUtxoEvent(Wallet wallet, List utxos, List payments, List opReturns, Long fee, boolean requireAllUtxos, BlockTransaction replacedTransaction, boolean allowPaymentChanges) { this.wallet = wallet; this.utxos = utxos; this.payments = payments; @@ -31,6 +32,7 @@ public class SpendUtxoEvent { this.requireAllUtxos = requireAllUtxos; this.replacedTransaction = replacedTransaction; this.paymentCode = null; + this.allowPaymentChanges = allowPaymentChanges; } public SpendUtxoEvent(Wallet wallet, List payments, List opReturns, PaymentCode paymentCode) { @@ -42,6 +44,7 @@ public class SpendUtxoEvent { this.requireAllUtxos = false; this.replacedTransaction = null; this.paymentCode = paymentCode; + this.allowPaymentChanges = false; } public Wallet getWallet() { @@ -75,4 +78,8 @@ public class SpendUtxoEvent { public PaymentCode getPaymentCode() { return paymentCode; } + + public boolean allowPaymentChanges() { + return allowPaymentChanges; + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/event/TransactionOutputsChangedEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/TransactionOutputsChangedEvent.java new file mode 100644 index 00000000..acda27bb --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/event/TransactionOutputsChangedEvent.java @@ -0,0 +1,9 @@ +package com.sparrowwallet.sparrow.event; + +import com.sparrowwallet.drongo.protocol.Transaction; + +public class TransactionOutputsChangedEvent extends TransactionChangedEvent { + public TransactionOutputsChangedEvent(Transaction transaction) { + super(transaction); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/paynym/PayNymController.java b/src/main/java/com/sparrowwallet/sparrow/paynym/PayNymController.java index 141a8994..76f58b15 100644 --- a/src/main/java/com/sparrowwallet/sparrow/paynym/PayNymController.java +++ b/src/main/java/com/sparrowwallet/sparrow/paynym/PayNymController.java @@ -624,7 +624,8 @@ public class PayNymController { List utxoSelectors = List.of(utxos == null ? new KnapsackUtxoSelector(noInputsFee) : new PresetUtxoSelector(utxos, true, false)); List txoFilters = List.of(new SpentTxoFilter(), new FrozenTxoFilter(), new CoinbaseTxoFilter(wallet)); - return wallet.createWalletTransaction(utxoSelectors, txoFilters, payments, opReturns, Collections.emptySet(), feeRate, minimumFeeRate, minRelayFeeRate, null, AppServices.getCurrentBlockHeight(), groupByAddress, includeMempoolOutputs); + return wallet.createWalletTransaction(utxoSelectors, txoFilters, payments, opReturns, Collections.emptySet(), feeRate, minimumFeeRate, minRelayFeeRate, null, + AppServices.getCurrentBlockHeight(), groupByAddress, includeMempoolOutputs, true); } private Map getNotificationTransaction(PaymentCode externalPaymentCode) { diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java index 979649d6..5fa7e9c4 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java @@ -7,6 +7,8 @@ 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.silentpayments.SilentPayment; +import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress; import com.sparrowwallet.drongo.uri.BitcoinURI; import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.hummingbird.UR; @@ -57,7 +59,6 @@ import tornadofx.control.Fieldset; import com.google.common.eventbus.Subscribe; import tornadofx.control.Form; -import javax.swing.text.html.Option; import java.io.*; import java.net.*; import java.nio.charset.StandardCharsets; @@ -640,6 +641,7 @@ public class HeadersController extends TransactionFormController implements Init } List payments = new ArrayList<>(); + List outputs = new ArrayList<>(); Map changeMap = new LinkedHashMap<>(); Map changeOutputScripts = wallet.getWalletOutputScripts(wallet.getChangeKeyPurpose()); for(TransactionOutput txOutput : headersForm.getTransaction().getOutputs()) { @@ -658,6 +660,7 @@ public class HeadersController extends TransactionFormController implements Init changeMap.put(changeNode, txOutput.getValue()); } } + outputs.add(new WalletTransaction.ChangeOutput(txOutput, changeNode, txOutput.getValue())); } else { Payment.Type paymentType = Payment.Type.DEFAULT; Wallet masterWallet = wallet.isMasterWallet() ? wallet : wallet.getMasterWallet(); @@ -668,24 +671,33 @@ public class HeadersController extends TransactionFormController implements Init BlockTransactionHashIndex receivedTxo = walletTxos.keySet().stream().filter(txo -> txo.getHash().equals(txOutput.getHash()) && txo.getIndex() == txOutput.getIndex()).findFirst().orElse(null); String label = headersForm.getName() == null || (headersForm.getName().startsWith("[") && headersForm.getName().endsWith("]") && headersForm.getName().length() == 8) ? null : headersForm.getName(); - try { - Payment payment = new Payment(txOutput.getScript().getToAddresses()[0], receivedTxo != null ? receivedTxo.getLabel() : label, txOutput.getValue(), false, paymentType); + Address address = txOutput.getScript().getToAddress(); + SilentPaymentAddress silentPaymentAddress = headersForm.getSilentPaymentAddress(txOutput); + label = receivedTxo != null ? receivedTxo.getLabel() : label; + if(address != null || silentPaymentAddress != null) { + Payment payment = (silentPaymentAddress == null ? + new Payment(address, label, txOutput.getValue(), false, paymentType) : + new SilentPayment(silentPaymentAddress, address, label, txOutput.getValue(), false)); WalletTransaction createdTx = AppServices.get().getCreatedTransaction(selectedTxos.keySet()); if(createdTx != null) { - Optional optLabel = createdTx.getPayments().stream().filter(pymt -> pymt.getAddress().equals(payment.getAddress()) && pymt.getAmount() == payment.getAmount()).map(Payment::getLabel).findFirst(); + Optional optLabel = createdTx.getPayments().stream() + .filter(pymt -> (pymt instanceof SilentPayment silentPayment ? silentPayment.getSilentPaymentAddress().equals(silentPaymentAddress) : + pymt.getAddress().equals(payment.getAddress())) && pymt.getAmount() == payment.getAmount()).map(Payment::getLabel).findFirst(); if(optLabel.isPresent()) { payment.setLabel(optLabel.get()); outputIndexLabels.put(txOutput.getIndex(), optLabel.get()); } } payments.add(payment); - } catch(Exception e) { - //ignore + outputs.add(payment instanceof SilentPayment silentPayment ? new WalletTransaction.SilentPaymentOutput(txOutput, silentPayment) : + new WalletTransaction.PaymentOutput(txOutput, payment)); + } else { + outputs.add(new WalletTransaction.NonAddressOutput(txOutput)); } } } - return new WalletTransaction(wallet, headersForm.getTransaction(), Collections.emptyList(), List.of(selectedTxos), payments, changeMap, fee.getValue(), walletInputTransactions); + return new WalletTransaction(wallet, headersForm.getTransaction(), Collections.emptyList(), List.of(selectedTxos), payments, outputs, changeMap, fee.getValue(), walletInputTransactions); } else { Map selectedTxos = headersForm.getTransaction().getInputs().stream() .collect(Collectors.toMap(txInput -> getBlockTransactionInput(inputTransactions, txInput), @@ -695,16 +707,25 @@ public class HeadersController extends TransactionFormController implements Init selectedTxos.entrySet().forEach(entry -> entry.setValue(null)); List payments = new ArrayList<>(); + List outputs = new ArrayList<>(); for(TransactionOutput txOutput : headersForm.getTransaction().getOutputs()) { - try { - BlockTransactionHashIndex receivedTxo = getBlockTransactionOutput(txOutput); - payments.add(new Payment(txOutput.getScript().getToAddresses()[0], receivedTxo != null ? receivedTxo.getLabel() : null, txOutput.getValue(), false)); - } catch(Exception e) { - //ignore + Address address = txOutput.getScript().getToAddress(); + SilentPaymentAddress silentPaymentAddress = headersForm.getSilentPaymentAddress(txOutput); + BlockTransactionHashIndex receivedTxo = getBlockTransactionOutput(txOutput); + String label = receivedTxo != null ? receivedTxo.getLabel() : null; + if(address != null || silentPaymentAddress != null) { + Payment payment = (silentPaymentAddress == null ? + new Payment(address, label, txOutput.getValue(), false) : + new SilentPayment(silentPaymentAddress, address, label, txOutput.getValue(), false)); + payments.add(payment); + outputs.add(payment instanceof SilentPayment silentPayment ? new WalletTransaction.SilentPaymentOutput(txOutput, silentPayment) : + new WalletTransaction.PaymentOutput(txOutput, payment)); + } else { + outputs.add(new WalletTransaction.NonAddressOutput(txOutput)); } } - return new WalletTransaction(null, headersForm.getTransaction(), Collections.emptyList(), List.of(selectedTxos), payments, Collections.emptyMap(), fee.getValue(), inputTransactions); + return new WalletTransaction(null, headersForm.getTransaction(), Collections.emptyList(), List.of(selectedTxos), payments, outputs, Collections.emptyMap(), fee.getValue(), inputTransactions); } } @@ -931,7 +952,7 @@ public class HeadersController extends TransactionFormController implements Init //Don't include non witness utxo fields for segwit wallets when displaying the PSBT as a QR - it can add greatly to the time required for scanning boolean includeNonWitnessUtxos = !Arrays.asList(ScriptType.WITNESS_TYPES).contains(headersForm.getSigningWallet().getScriptType()); - byte[] psbtBytes = headersForm.getPsbt().serialize(true, includeNonWitnessUtxos); + byte[] psbtBytes = headersForm.getPsbt().getForExport().serialize(true, includeNonWitnessUtxos); CryptoPSBT cryptoPSBT = new CryptoPSBT(psbtBytes); BBQR bbqr = addBbqrOption ? new BBQR(BBQRType.PSBT, psbtBytes) : null; @@ -1014,7 +1035,7 @@ public class HeadersController extends TransactionFormController implements Init } try(FileOutputStream outputStream = new FileOutputStream(file)) { - outputStream.write(headersForm.getPsbt().serialize()); + outputStream.write(headersForm.getPsbt().getForExport().serialize()); } catch(IOException e) { log.error("Error saving PSBT", e); AppServices.showErrorDialog("Error saving PSBT", "Cannot write to " + file.getAbsolutePath()); @@ -1071,7 +1092,12 @@ public class HeadersController extends TransactionFormController implements Init private void signUnencryptedKeystores(Wallet unencryptedWallet) { try { - unencryptedWallet.sign(headersForm.getPsbt()); + Map signingNodes = unencryptedWallet.getSigningNodes(headersForm.getPsbt()); + List silentPayments = unencryptedWallet.computeSilentPaymentOutputs(headersForm.getPsbt(), signingNodes); + if(!silentPayments.isEmpty()) { + EventManager.get().post(new TransactionOutputsChangedEvent(headersForm.getTransaction())); + } + unencryptedWallet.sign(signingNodes); updateSignedKeystores(headersForm.getSigningWallet()); } catch(Exception e) { log.warn("Failed to Sign", e); @@ -1599,6 +1625,13 @@ public class HeadersController extends TransactionFormController implements Init } } + @Subscribe + public void transactionOutputsChanged(TransactionOutputsChangedEvent event) { + if(event.getTransaction().equals(headersForm.getTransaction())) { + headersForm.setWalletTransaction(getWalletTransaction(headersForm.getInputTransactions())); + } + } + @Subscribe public void transactionExtracted(TransactionExtractedEvent event) { if(event.getPsbt().equals(headersForm.getPsbt())) { diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/InputController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/InputController.java index 6ba500dc..122aa018 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/InputController.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/InputController.java @@ -337,7 +337,7 @@ public class InputController extends TransactionFormController implements Initia } } else { if(txInput.isAbsoluteTimeLocked()) { - txInput.setSequenceNumber(TransactionInput.SEQUENCE_LOCKTIME_DISABLED - 1); + txInput.setSequenceNumber(TransactionInput.SEQUENCE_RBF_DISABLED); if(oldValue != null) { EventManager.get().post(new TransactionChangedEvent(transaction)); } @@ -389,7 +389,7 @@ public class InputController extends TransactionFormController implements Initia if(rbf.selectedProperty().getValue()) { txInput.setSequenceNumber(TransactionInput.SEQUENCE_RBF_ENABLED); } else { - txInput.setSequenceNumber(TransactionInput.SEQUENCE_LOCKTIME_DISABLED - 1); + txInput.setSequenceNumber(TransactionInput.SEQUENCE_RBF_DISABLED); } if(old_toggle != null) { EventManager.get().post(new TransactionChangedEvent(transaction)); diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/OutputController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/OutputController.java index b025d9bd..b4cabac2 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/OutputController.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/OutputController.java @@ -5,14 +5,12 @@ import com.sparrowwallet.drongo.address.Address; import com.sparrowwallet.drongo.protocol.NonStandardScriptException; import com.sparrowwallet.drongo.protocol.TransactionInput; import com.sparrowwallet.drongo.protocol.TransactionOutput; +import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress; import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.control.*; -import com.sparrowwallet.sparrow.event.PSBTReorderedEvent; -import com.sparrowwallet.sparrow.event.UnitFormatChangedEvent; -import com.sparrowwallet.sparrow.event.BlockTransactionOutputsFetchedEvent; -import com.sparrowwallet.sparrow.event.ViewTransactionEvent; +import com.sparrowwallet.sparrow.event.*; import com.sparrowwallet.sparrow.net.ElectrumServer; import javafx.fxml.FXML; import javafx.fxml.Initializable; @@ -70,20 +68,7 @@ public class OutputController extends TransactionFormController implements Initi updateOutputLegendFromWallet(txOutput, walletTransaction != null ? walletTransaction.getWallet() : null); }); updateOutputLegendFromWallet(txOutput, outputForm.getWallet()); - - value.setValue(txOutput.getValue()); - to.setVisible(false); - try { - Address[] addresses = txOutput.getScript().getToAddresses(); - to.setVisible(true); - if(addresses.length == 1) { - address.setAddress(addresses[0]); - } else { - address.setText("multiple addresses"); - } - } catch(NonStandardScriptException e) { - //ignore - } + updateSends(txOutput); spentField.managedProperty().bind(spentField.visibleProperty()); spentByField.managedProperty().bind(spentByField.visibleProperty()); @@ -98,6 +83,32 @@ public class OutputController extends TransactionFormController implements Initi } initializeScriptField(scriptPubKeyArea); + updateScriptPubKey(txOutput); + } + + private void updateSends(TransactionOutput txOutput) { + value.setValue(txOutput.getValue()); + to.setVisible(false); + Address toAddress = txOutput.getScript().getToAddress(); + SilentPaymentAddress silentPaymentAddress = outputForm.getSilentPaymentAddress(txOutput); + if(toAddress != null) { + to.setVisible(true); + address.setAddress(toAddress); + } else if(silentPaymentAddress != null) { + to.setVisible(true); + address.setText(silentPaymentAddress.toAbbreviatedString()); + } else { + try { + txOutput.getScript().getToAddresses(); + to.setVisible(true); + address.setText("multiple addresses"); + } catch(NonStandardScriptException e) { + //ignore + } + } + } + + private void updateScriptPubKey(TransactionOutput txOutput) { scriptPubKeyArea.clear(); scriptPubKeyArea.appendScript(txOutput.getScript(), null, null); } @@ -115,6 +126,8 @@ public class OutputController extends TransactionFormController implements Initi WalletTransaction.Output output = outputs.get(outputForm.getIndex()); if(output instanceof WalletTransaction.NonAddressOutput) { outputFieldset.setText(baseText); + } else if(output instanceof WalletTransaction.SilentPaymentOutput silentPaymentOutput) { + outputFieldset.setText(baseText + " - Silent Payment"); } else if(output instanceof WalletTransaction.PaymentOutput paymentOutput) { Payment payment = paymentOutput.getPayment(); Wallet toWallet = walletTx.getToWallet(AppServices.get().getOpenWallets().keySet(), payment); @@ -206,4 +219,12 @@ public class OutputController extends TransactionFormController implements Initi updateOutputLegendFromWallet(outputForm.getTransactionOutput(), null); } } + + @Subscribe + public void transactionOutputsChanged(TransactionOutputsChangedEvent event) { + if(event.getTransaction().equals(outputForm.getTransaction())) { + updateSends(outputForm.getTransactionOutput()); + updateScriptPubKey(outputForm.getTransactionOutput()); + } + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/OutputForm.java b/src/main/java/com/sparrowwallet/sparrow/transaction/OutputForm.java index ed0d3b9a..fee07185 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/OutputForm.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/OutputForm.java @@ -89,7 +89,7 @@ public class OutputForm extends IndexedTransactionForm { } } else if(output instanceof WalletTransaction.PaymentOutput paymentOutput) { Payment payment = paymentOutput.getPayment(); - return new Label(payment.getLabel() != null && payment.getType() != Payment.Type.FAKE_MIX && payment.getType() != Payment.Type.MIX ? payment.getLabel() : payment.getAddress().toString(), + return new Label(payment.getLabel() != null && payment.getType() != Payment.Type.FAKE_MIX && payment.getType() != Payment.Type.MIX ? payment.getLabel() : payment.toString(), GlyphUtils.getOutputGlyph(getWalletTransaction(), payment)); } else if(output instanceof WalletTransaction.ChangeOutput changeOutput) { return new Label("Change", GlyphUtils.getChangeGlyph()); diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/OutputsController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/OutputsController.java index 8efdc8ff..24e2451a 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/OutputsController.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/OutputsController.java @@ -6,6 +6,7 @@ import com.sparrowwallet.drongo.protocol.TransactionOutput; import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.control.CopyableCoinLabel; import com.sparrowwallet.sparrow.control.CopyableLabel; +import com.sparrowwallet.sparrow.event.TransactionOutputsChangedEvent; import com.sparrowwallet.sparrow.event.UnitFormatChangedEvent; import javafx.fxml.FXML; import javafx.fxml.Initializable; @@ -60,4 +61,11 @@ public class OutputsController extends TransactionFormController implements Init public void unitFormatChanged(UnitFormatChangedEvent event) { total.refresh(event.getUnitFormat(), event.getBitcoinUnit()); } + + @Subscribe + public void transactionOutputsChanged(TransactionOutputsChangedEvent event) { + if(event.getTransaction().equals(outputsForm.getTransaction())) { + updatePieData(outputsPie, outputsForm.getTransaction().getOutputs()); + } + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionData.java b/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionData.java index 71044841..59555c93 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionData.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionData.java @@ -3,6 +3,8 @@ package com.sparrowwallet.sparrow.transaction; import com.sparrowwallet.drongo.KeyPurpose; import com.sparrowwallet.drongo.protocol.*; import com.sparrowwallet.drongo.psbt.PSBT; +import com.sparrowwallet.drongo.psbt.PSBTOutput; +import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress; import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.sparrow.io.Storage; import javafx.beans.property.SimpleObjectProperty; @@ -193,4 +195,16 @@ public class TransactionData { public Wallet getWallet() { return getSigningWallet() != null ? getSigningWallet() : (getWalletTransaction() != null ? getWalletTransaction().getWallet() : null); } + + protected SilentPaymentAddress getSilentPaymentAddress(TransactionOutput txOutput) { + if(getPsbt() != null && txOutput.getParent() != null) { + for(PSBTOutput psbtOutput : getPsbt().getPsbtOutputs()) { + if(psbtOutput.getOutput().getIndex() == txOutput.getIndex() && psbtOutput.getSilentPaymentAddress() != null) { + return psbtOutput.getSilentPaymentAddress(); + } + } + } + + return null; + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionForm.java b/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionForm.java index d8cdb422..17fc23e8 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionForm.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionForm.java @@ -2,8 +2,10 @@ package com.sparrowwallet.sparrow.transaction; import com.sparrowwallet.drongo.protocol.Sha256Hash; import com.sparrowwallet.drongo.protocol.Transaction; +import com.sparrowwallet.drongo.protocol.TransactionOutput; import com.sparrowwallet.drongo.protocol.TransactionSignature; import com.sparrowwallet.drongo.psbt.PSBT; +import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress; import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.sparrow.io.Storage; import javafx.beans.property.SimpleObjectProperty; @@ -112,6 +114,10 @@ public abstract class TransactionForm { return txdata.getWallet(); } + public SilentPaymentAddress getSilentPaymentAddress(TransactionOutput output) { + return txdata.getSilentPaymentAddress(output); + } + public boolean isEditable() { if(getBlockTransaction() != null) { return false; diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionFormController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionFormController.java index 71a418da..cfe2b81a 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionFormController.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionFormController.java @@ -5,6 +5,7 @@ import com.sparrowwallet.drongo.BitcoinUnit; import com.sparrowwallet.drongo.address.Address; import com.sparrowwallet.drongo.protocol.NonStandardScriptException; import com.sparrowwallet.drongo.protocol.TransactionOutput; +import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress; import com.sparrowwallet.sparrow.UnitFormat; import com.sparrowwallet.sparrow.BaseController; import com.sparrowwallet.sparrow.EventManager; @@ -33,17 +34,7 @@ public abstract class TransactionFormController extends BaseController { long totalAmt = 0; for(int i = 0; i < outputs.size(); i++) { TransactionOutput output = outputs.get(i); - String name = "#" + i; - try { - Address[] addresses = output.getScript().getToAddresses(); - if(addresses.length == 1) { - name = name + " " + addresses[0].getAddress(); - } else { - name = name + " [" + addresses[0].getAddress() + ",...]"; - } - } catch(NonStandardScriptException e) { - //ignore - } + String name = getPieDataName(i, output); totalAmt += output.getValue(); outputsPieData.add(new PieChart.Data(name, output.getValue())); @@ -52,6 +43,34 @@ public abstract class TransactionFormController extends BaseController { addPieData(pie, outputsPieData); } + protected void updatePieData(PieChart pie, List outputs) { + for(int i = 0; i < outputs.size(); i++) { + TransactionOutput output = outputs.get(i); + String name = getPieDataName(i, output); + pie.getData().get(i).setName(name); + } + } + + private String getPieDataName(int i, TransactionOutput output) { + String name = "#" + i; + Address address = output.getScript().getToAddress(); + SilentPaymentAddress silentPaymentAddress = getTransactionForm().getSilentPaymentAddress(output); + if(address != null) { + name = name + " " + address.getAddress(); + } else if(silentPaymentAddress != null) { + name = name + " " + silentPaymentAddress.toAbbreviatedString(); + } else { + try { + Address[] addresses = output.getScript().getToAddresses(); + name = name + " [" + addresses[0].getAddress() + ",...]"; + } catch(NonStandardScriptException e) { + //ignore + } + } + + return name; + } + protected void addCoinbasePieData(PieChart pie, long value) { ObservableList outputsPieData = FXCollections.observableList(List.of(new PieChart.Data("Coinbase", value))); addPieData(pie, outputsPieData); diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java index 6e196c9b..01229b87 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java @@ -14,6 +14,8 @@ import com.sparrowwallet.drongo.dns.DnsPaymentCache; import com.sparrowwallet.drongo.protocol.ScriptType; import com.sparrowwallet.drongo.protocol.Transaction; import com.sparrowwallet.drongo.protocol.TransactionOutput; +import com.sparrowwallet.drongo.silentpayments.SilentPayment; +import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress; import com.sparrowwallet.drongo.uri.BitcoinURI; import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.sparrow.*; @@ -143,6 +145,8 @@ public class PaymentController extends WalletFormController implements Initializ private final ObjectProperty payNymProperty = new SimpleObjectProperty<>(); + private final ObjectProperty silentPaymentAddressProperty = new SimpleObjectProperty<>(); + private final ObjectProperty dnsPaymentProperty = new SimpleObjectProperty<>(); private static final Wallet payNymWallet = new Wallet() { @@ -172,6 +176,10 @@ public class PaymentController extends WalletFormController implements Initializ dnsPaymentProperty.set(null); } + if(silentPaymentAddressProperty.get() != null && !newValue.equals(silentPaymentAddressProperty.get().getAddress())) { + silentPaymentAddressProperty.set(null); + } + try { BitcoinURI bitcoinURI = new BitcoinURI(newValue); Platform.runLater(() -> updateFromURI(bitcoinURI)); @@ -237,6 +245,13 @@ public class PaymentController extends WalletFormController implements Initializ } } + try { + SilentPaymentAddress silentPaymentAddress = SilentPaymentAddress.from(newValue); + setSilentPaymentAddress(silentPaymentAddress); + } catch(Exception e) { + //ignore, not a silent payment address + } + revalidateAmount(); maxButton.setDisable(!isMaxButtonEnabled()); sendController.updateTransaction(); @@ -312,6 +327,10 @@ public class PaymentController extends WalletFormController implements Initializ revalidateAmount(); }); + silentPaymentAddressProperty.addListener((observable, oldValue, silentPaymentAddress) -> { + revalidateAmount(); + }); + dnsPaymentProperty.addListener((observable, oldValue, dnsPayment) -> { if(dnsPayment != null) { MenuItem copyMenuItem = new MenuItem("Copy URI"); @@ -402,22 +421,36 @@ public class PaymentController extends WalletFormController implements Initializ } public void setDnsPayment(DnsPayment dnsPayment) { - if(dnsPayment.bitcoinURI().getAddress() == null) { - AppServices.showWarningDialog("No Address Provided", "The DNS payment instruction for " + dnsPayment.hrn() + " resolved correctly but did not contain a Bitcoin address."); + if(dnsPayment.hasAddress()) { + DnsPaymentCache.putDnsPayment(dnsPayment.bitcoinURI().getAddress(), dnsPayment); + } else if(dnsPayment.hasSilentPaymentAddress()) { + DnsPaymentCache.putDnsPayment(dnsPayment.bitcoinURI().getSilentPaymentAddress(), dnsPayment); + setSilentPaymentAddress(dnsPayment.bitcoinURI().getSilentPaymentAddress()); + } else { + AppServices.showWarningDialog("No Address Provided", "The DNS payment instruction for " + dnsPayment.hrn() + " resolved correctly but did not contain a bitcoin address."); return; } - DnsPaymentCache.putDnsPayment(dnsPayment.bitcoinURI().getAddress(), dnsPayment); dnsPaymentProperty.set(dnsPayment); address.setText(dnsPayment.hrn()); revalidate(address, addressListener); address.leftProperty().set(getBitcoinCharacter()); - if(label.getText().isEmpty() || label.getText().startsWith("To ₿")) { - label.setText("To " + dnsPayment); + if(label.getText().isEmpty() || (label.getText().startsWith("₿") && !label.getText().contains(" "))) { + label.setText(dnsPayment.toString()); } label.requestFocus(); } + private void setSilentPaymentAddress(SilentPaymentAddress silentPaymentAddress) { + if(!sendController.getWalletForm().getWallet().canSendSilentPayments()) { + Platform.runLater(() -> AppServices.showErrorDialog("Silent Payments Unsupported", "This wallet does not support sending silent payments. Use a single signature software wallet.")); + return; + } + + silentPaymentAddressProperty.set(silentPaymentAddress); + label.requestFocus(); + } + private void updateOpenWallets() { updateOpenWallets(AppServices.get().getOpenWallets().keySet()); } @@ -489,8 +522,13 @@ public class PaymentController extends WalletFormController implements Initializ } private Address getRecipientAddress() throws InvalidAddressException { + SilentPaymentAddress silentPaymentAddress = silentPaymentAddressProperty.get(); + if(silentPaymentAddress != null) { + return SilentPayment.getDummyAddress(); + } + DnsPayment dnsPayment = dnsPaymentProperty.get(); - if(dnsPayment != null) { + if(dnsPayment != null && dnsPayment.hasAddress()) { return dnsPayment.bitcoinURI().getAddress(); } @@ -630,7 +668,14 @@ public class PaymentController extends WalletFormController implements Initializ Long value = sendAll ? Long.valueOf(getRecipientDustThreshold() + 1) : getRecipientValueSats(); if(!label.getText().isEmpty() && value != null && value >= getRecipientDustThreshold()) { - Payment payment = new Payment(recipientAddress, label.getText(), value, sendAll); + Payment payment; + SilentPaymentAddress silentPaymentAddress = silentPaymentAddressProperty.get(); + if(silentPaymentAddress != null) { + payment = new SilentPayment(silentPaymentAddress, label.getText(), value, sendAll); + } else { + payment = new Payment(recipientAddress, label.getText(), value, sendAll); + } + if(address.getUserData() != null) { payment.setType((Payment.Type)address.getUserData()); } @@ -647,7 +692,11 @@ public class PaymentController extends WalletFormController implements Initializ public void setPayment(Payment payment) { if(getRecipientValueSats() == null || payment.getAmount() != getRecipientValueSats()) { if(payment.getAddress() != null) { - address.setText(payment.getAddress().toString()); + if(payment instanceof SilentPayment silentPayment) { + address.setText(silentPayment.getSilentPaymentAddress().getAddress()); + } else { + address.setText(payment.getAddress().toString()); + } address.setUserData(payment.getType()); } if(payment.getLabel() != null && !label.getText().equals(payment.getLabel())) { @@ -680,6 +729,7 @@ public class PaymentController extends WalletFormController implements Initializ dustAmountProperty.set(false); payNymProperty.set(null); dnsPaymentProperty.set(null); + silentPaymentAddressProperty.set(null); } public void setMaxInput(ActionEvent event) { diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java index 0d7ddeb1..21720f17 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java @@ -6,12 +6,12 @@ import com.sparrowwallet.drongo.KeyPurpose; import com.sparrowwallet.drongo.Network; import com.sparrowwallet.drongo.SecureString; import com.sparrowwallet.drongo.address.Address; -import com.sparrowwallet.drongo.address.InvalidAddressException; import com.sparrowwallet.drongo.bip47.PaymentCode; import com.sparrowwallet.drongo.bip47.SecretPoint; import com.sparrowwallet.drongo.crypto.ECKey; import com.sparrowwallet.drongo.protocol.*; import com.sparrowwallet.drongo.psbt.PSBT; +import com.sparrowwallet.drongo.silentpayments.SilentPayment; import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.sparrow.*; import com.sparrowwallet.sparrow.control.*; @@ -615,9 +615,14 @@ public class SendController extends WalletFormController implements Initializabl boolean includeMempoolOutputs = Config.get().isIncludeMempoolOutputs(); BlockTransaction replacedTransaction = replacedTransactionProperty.get(); + //Disable RBF for silent payments, as we can't guarantee RBF won't be attempted on another device without knowledge to recompute the address if necessary + boolean allowRbf = (replacedTransaction == null || replacedTransaction.getTransaction().isReplaceByFee()) + && payments.stream().noneMatch(payment -> payment instanceof SilentPayment); + walletTransactionService = new WalletTransactionService(addressNodeMap, wallet, getUtxoSelectors(payments), getTxoFilters(), payments, opReturnsList, excludedChangeNodes, - feeRate, getMinimumFeeRate(), minRelayFeeRate, userFee, currentBlockHeight, groupByAddress, includeMempoolOutputs, replacedTransaction); + feeRate, getMinimumFeeRate(), minRelayFeeRate, userFee, + currentBlockHeight, groupByAddress, includeMempoolOutputs, replacedTransaction, allowRbf); walletTransactionService.setOnSucceeded(event -> { if(!walletTransactionService.isIgnoreResult()) { walletTransactionProperty.setValue(walletTransactionService.getValue()); @@ -652,12 +657,12 @@ public class SendController extends WalletFormController implements Initializabl walletTransactionService.start(); } - } catch(InvalidAddressException | IllegalStateException e) { + } catch(IllegalStateException e) { walletTransactionProperty.setValue(null); } } - private List getUtxoSelectors(List payments) throws InvalidAddressException { + private List getUtxoSelectors(List payments) { if(utxoSelectorProperty.get() != null) { return List.of(utxoSelectorProperty.get()); } @@ -694,13 +699,15 @@ public class SendController extends WalletFormController implements Initializabl private final boolean groupByAddress; private final boolean includeMempoolOutputs; private final BlockTransaction replacedTransaction; + private final boolean allowRbf; private boolean ignoreResult; public WalletTransactionService(Map> addressNodeMap, Wallet wallet, List utxoSelectors, List txoFilters, List payments, List opReturns, Set excludedChangeNodes, double feeRate, double longTermFeeRate, double minRelayFeeRate, Long fee, - Integer currentBlockHeight, boolean groupByAddress, boolean includeMempoolOutputs, BlockTransaction replacedTransaction) { + Integer currentBlockHeight, boolean groupByAddress, boolean includeMempoolOutputs, + BlockTransaction replacedTransaction, boolean allowRbf) { this.addressNodeMap = addressNodeMap; this.wallet = wallet; this.utxoSelectors = utxoSelectors; @@ -716,6 +723,7 @@ public class SendController extends WalletFormController implements Initializabl this.groupByAddress = groupByAddress; this.includeMempoolOutputs = includeMempoolOutputs; this.replacedTransaction = replacedTransaction; + this.allowRbf = allowRbf; } @Override @@ -725,7 +733,8 @@ public class SendController extends WalletFormController implements Initializabl try { return getWalletTransaction(); } catch(InsufficientFundsException e) { - if(e.getTargetValue() != null && replacedTransaction != null && utxoSelectors.size() == 1 && utxoSelectors.get(0) instanceof PresetUtxoSelector presetUtxoSelector) { + if(e.getTargetValue() != null && replacedTransaction != null && wallet.isSafeToAddInputsOrOutputs(replacedTransaction) + && utxoSelectors.size() == 1 && utxoSelectors.getFirst() instanceof PresetUtxoSelector presetUtxoSelector) { //Creating RBF transaction - include additional UTXOs if available to pay desired fee List filters = new ArrayList<>(txoFilters); filters.add(presetUtxoSelector.asExcludeTxoFilter()); @@ -734,7 +743,7 @@ public class SendController extends WalletFormController implements Initializabl Collections.shuffle(outputGroups); while(!outputGroups.isEmpty() && presetUtxoSelector.getPresetUtxos().stream().mapToLong(BlockTransactionHashIndex::getValue).sum() < e.getTargetValue()) { - OutputGroup outputGroup = outputGroups.remove(0); + OutputGroup outputGroup = outputGroups.removeFirst(); for(BlockTransactionHashIndex utxo : outputGroup.getUtxos()) { presetUtxoSelector.getPresetUtxos().add(utxo); } @@ -748,12 +757,16 @@ public class SendController extends WalletFormController implements Initializabl } private WalletTransaction getWalletTransaction() throws InsufficientFundsException { - updateMessage("Selecting UTXOs..."); - WalletTransaction walletTransaction = wallet.createWalletTransaction(utxoSelectors, txoFilters, payments, opReturns, excludedChangeNodes, - feeRate, longTermFeeRate, minRelayFeeRate, fee, currentBlockHeight, groupByAddress, includeMempoolOutputs); - updateMessage("Deriving keys..."); - walletTransaction.updateAddressNodeMap(addressNodeMap, walletTransaction.getWallet()); - return walletTransaction; + try { + updateMessage("Selecting UTXOs..."); + WalletTransaction walletTransaction = wallet.createWalletTransaction(utxoSelectors, txoFilters, payments, opReturns, excludedChangeNodes, + feeRate, longTermFeeRate, minRelayFeeRate, fee, currentBlockHeight, groupByAddress, includeMempoolOutputs, allowRbf); + updateMessage("Deriving keys..."); + walletTransaction.updateAddressNodeMap(addressNodeMap, walletTransaction.getWallet()); + return walletTransaction; + } finally { + updateMessage(""); + } } }; } @@ -1252,7 +1265,7 @@ public class SendController extends WalletFormController implements Initializabl boolean includeMempoolOutputs = Config.get().isIncludeMempoolOutputs(); WalletTransaction finalWalletTx = decryptedWallet.createWalletTransaction(utxoSelectors, getTxoFilters(), walletTransaction.getPayments(), List.of(blindedPaymentCode), - excludedChangeNodes, feeRate, getMinimumFeeRate(), minRelayFeeRate, userFee, currentBlockHeight, groupByAddress, includeMempoolOutputs); + excludedChangeNodes, feeRate, getMinimumFeeRate(), minRelayFeeRate, userFee, currentBlockHeight, groupByAddress, includeMempoolOutputs, true); PSBT psbt = finalWalletTx.createPSBT(); decryptedWallet.sign(psbt); decryptedWallet.finalise(psbt); @@ -1511,7 +1524,7 @@ public class SendController extends WalletFormController implements Initializabl notificationButton.setVisible(isNotificationTransaction); notificationButton.setDefaultButton(isNotificationTransaction); - setInputFieldsDisabled(isNotificationTransaction, false); + setInputFieldsDisabled(!event.allowPaymentChanges(), false); } }