add initial sending to silent payments support

This commit is contained in:
Craig Raw 2025-09-29 08:37:07 +02:00
parent 6240667478
commit efb1eb1051
20 changed files with 439 additions and 149 deletions

2
drongo

@ -1 +1 @@
Subproject commit d30cc4432cec39ff44ad6c11ab324200a9629a8c
Subproject commit a896809286f6f4393202110a15a4dd525f14cf73

View file

@ -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<DnsPayment> optDnsPayment = psbtOutput.getDnsPayment();
optDnsPayment.ifPresent(dnsPayment -> DnsPaymentCache.putDnsPayment(address, dnsPayment));
} catch(Exception e) {
log.debug("Error resolving DNS payment", e);
}
if(address != null) {
Optional<SilentPaymentAddress> 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<DnsPayment> 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<DnsPayment> optDnsPayment = psbtOutput.getDnsPayment();
if(optDnsPayment.isPresent() && optDnsPayment.get().hasAddress()) {
DnsPaymentCache.putDnsPayment(address, optDnsPayment.get());
}
} catch(Exception e) {
log.debug("Error resolving DNS payment", e);
}
}
});
}
}
}

View file

@ -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);
}

View file

@ -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<Entry, Entry> 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<Entry, Entry> 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<Entry, Entry> 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<Entry, Entry> 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<Entry, Entry> implements Confirmati
private static void increaseFee(TransactionEntry transactionEntry, boolean cancelTransaction) {
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
boolean silentPaymentTransaction = transactionEntry.getWallet().isSilentPaymentsTransaction(blockTransaction);
Map<BlockTransactionHashIndex, WalletNode> walletTxos = transactionEntry.getWallet().getWalletTxos();
List<BlockTransactionHashIndex> utxos = transactionEntry.getChildren().stream()
.filter(e -> e instanceof HashIndexEntry)
.map(e -> (HashIndexEntry)e)
.filter(e -> e.getType().equals(HashIndexEntry.Type.INPUT) && e.isSpendable())
.map(e -> blockTransaction.getTransaction().getInputs().get((int)e.getHashIndex().getIndex()))
.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<Entry, Entry> 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<Entry, Entry> implements Confirmati
List<OutputGroup> 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<Entry, Entry> 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<Entry, Entry> 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<Entry, Entry> 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<Entry, Entry> 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<Entry, Entry> 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<Entry, Entry> 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<Entry, Entry> 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 -> {

View file

@ -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<List<Payment>> {
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<Payment> payments) {
this.bitcoinUnit = bitcoinUnit;
@ -92,7 +94,8 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
for(int row = 0; row < grid.getRowCount(); ++row) {
final ObservableList<SpreadsheetCell> 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<List<Payment>> {
String firstLabel = null;
for(int row = 0; row < grid.getRowCount(); row++) {
ObservableList<SpreadsheetCell> 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<List<Payment>> {
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<List<Payment>> {
} 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<List<Payment>> {
}
}
public static class AddressCellType extends SpreadsheetCellType<Address> {
public AddressCellType() {
this(new StringConverterWithFormat<>(new AddressStringConverter()) {
public static class SendToAddressCellType extends SpreadsheetCellType<SendToAddress> {
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<List<Payment>> {
}
@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<List<Payment>> {
});
}
public AddressCellType(StringConverter<Address> converter) {
public SendToAddressCellType(StringConverter<SendToAddress> converter) {
super(converter);
}
@ -263,7 +271,7 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
}
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<List<Payment>> {
@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<List<Payment>> {
}
@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<List<Payment>> {
}
@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<Address>)converter).toStringFormat(item, format);
public String toString(SendToAddress item, String format) {
return ((StringConverterWithFormat<SendToAddress>)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<SendToAddress> {
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();
}
}
}

View file

@ -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<Long> values = walletTx.getTransaction().getOutputs().stream().filter(txo -> txo.getScript().getToAddress() != null).map(TransactionOutput::getValue).collect(Collectors.toList());
List<Long> 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<Integer> 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<Integer> seenIndexes) {
List<TransactionOutput> 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<TransactionOutput> 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);
}
}
}
}

View file

@ -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);
}

View file

@ -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<BlockTransactionHashIndex> utxos) {
this(wallet, utxos, null, null, null, false, null);
this(wallet, utxos, null, null, null, false, null, true);
}
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos, List<Payment> payments, List<byte[]> opReturns, Long fee, boolean requireAllUtxos, BlockTransaction replacedTransaction) {
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos, List<Payment> payments, List<byte[]> 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<Payment> payments, List<byte[]> 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;
}
}

View file

@ -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);
}
}

View file

@ -624,7 +624,8 @@ public class PayNymController {
List<UtxoSelector> utxoSelectors = List.of(utxos == null ? new KnapsackUtxoSelector(noInputsFee) : new PresetUtxoSelector(utxos, true, false));
List<TxoFilter> 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<BlockTransaction, WalletNode> getNotificationTransaction(PaymentCode externalPaymentCode) {

View file

@ -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<Payment> payments = new ArrayList<>();
List<WalletTransaction.Output> outputs = new ArrayList<>();
Map<WalletNode, Long> changeMap = new LinkedHashMap<>();
Map<Script, WalletNode> 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<String> optLabel = createdTx.getPayments().stream().filter(pymt -> pymt.getAddress().equals(payment.getAddress()) && pymt.getAmount() == payment.getAmount()).map(Payment::getLabel).findFirst();
Optional<String> 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<BlockTransactionHashIndex, WalletNode> 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<Payment> payments = new ArrayList<>();
List<WalletTransaction.Output> 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<PSBTInput, WalletNode> signingNodes = unencryptedWallet.getSigningNodes(headersForm.getPsbt());
List<SilentPayment> 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())) {

View file

@ -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));

View file

@ -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());
}
}
}

View file

@ -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());

View file

@ -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());
}
}
}

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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<TransactionOutput> 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<PieChart.Data> outputsPieData = FXCollections.observableList(List.of(new PieChart.Data("Coinbase", value)));
addPieData(pie, outputsPieData);

View file

@ -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<PayNym> payNymProperty = new SimpleObjectProperty<>();
private final ObjectProperty<SilentPaymentAddress> silentPaymentAddressProperty = new SimpleObjectProperty<>();
private final ObjectProperty<DnsPayment> 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) {

View file

@ -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<UtxoSelector> getUtxoSelectors(List<Payment> payments) throws InvalidAddressException {
private List<UtxoSelector> getUtxoSelectors(List<Payment> 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<Wallet, Map<Address, WalletNode>> addressNodeMap,
Wallet wallet, List<UtxoSelector> utxoSelectors, List<TxoFilter> txoFilters,
List<Payment> payments, List<byte[]> opReturns, Set<WalletNode> 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<TxoFilter> 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);
}
}