Browse Source

implement bip47 (linking, sending to and receiving from paynyms)

terminal
Craig Raw 3 years ago
parent
commit
e83c02653c
  1. 2
      drongo
  2. 30
      src/main/java/com/sparrowwallet/sparrow/AppController.java
  3. 6
      src/main/java/com/sparrowwallet/sparrow/AppServices.java
  4. 6
      src/main/java/com/sparrowwallet/sparrow/control/MessageSignDialog.java
  5. 32
      src/main/java/com/sparrowwallet/sparrow/control/PayNymCell.java
  6. 3
      src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java
  7. 4
      src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java
  8. 13
      src/main/java/com/sparrowwallet/sparrow/event/WalletNodeHistoryChangedEvent.java
  9. 1
      src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java
  10. 2
      src/main/java/com/sparrowwallet/sparrow/io/JsonPersistence.java
  11. 11
      src/main/java/com/sparrowwallet/sparrow/io/db/KeystoreDao.java
  12. 2
      src/main/java/com/sparrowwallet/sparrow/io/db/KeystoreMapper.java
  13. 1
      src/main/java/com/sparrowwallet/sparrow/io/db/WalletNodeDao.java
  14. 168
      src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java
  15. 2
      src/main/java/com/sparrowwallet/sparrow/payjoin/Payjoin.java
  16. 4
      src/main/java/com/sparrowwallet/sparrow/soroban/CounterpartyController.java
  17. 2
      src/main/java/com/sparrowwallet/sparrow/soroban/InitiatorController.java
  18. 55
      src/main/java/com/sparrowwallet/sparrow/soroban/PayNym.java
  19. 315
      src/main/java/com/sparrowwallet/sparrow/soroban/PayNymController.java
  20. 17
      src/main/java/com/sparrowwallet/sparrow/soroban/PayNymDialog.java
  21. 4
      src/main/java/com/sparrowwallet/sparrow/soroban/PayNymService.java
  22. 8
      src/main/java/com/sparrowwallet/sparrow/soroban/Soroban.java
  23. 6
      src/main/java/com/sparrowwallet/sparrow/soroban/SorobanController.java
  24. 7
      src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java
  25. 8
      src/main/java/com/sparrowwallet/sparrow/transaction/OutputController.java
  26. 2
      src/main/java/com/sparrowwallet/sparrow/transaction/OutputForm.java
  27. 6
      src/main/java/com/sparrowwallet/sparrow/transaction/TransactionController.java
  28. 1
      src/main/java/com/sparrowwallet/sparrow/wallet/KeystoreController.java
  29. 80
      src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java
  30. 17
      src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java
  31. 91
      src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java
  32. 5
      src/main/java/com/sparrowwallet/sparrow/wallet/WalletTransactionsEntry.java
  33. 3
      src/main/java/com/sparrowwallet/sparrow/whirlpool/dataSource/SparrowPostmixHandler.java
  34. 8
      src/main/resources/com/sparrowwallet/sparrow/soroban/paynym.css
  35. 1
      src/main/resources/com/sparrowwallet/sparrow/sql/V6__PaymentCode.sql

2
drongo

@ -1 +1 @@
Subproject commit f73cabad3c76c1eb28b4f02b17c9beb608ba2aa4
Subproject commit 7bb07ab39eafc0de54d3dc2e19a444d39f9a1fc3

30
src/main/java/com/sparrowwallet/sparrow/AppController.java

@ -343,9 +343,10 @@ public class AppController implements Initializable {
refreshWallet.disableProperty().bind(Bindings.or(exportWallet.disableProperty(), Bindings.or(serverToggle.disableProperty(), AppServices.onlineProperty().not())));
sendToMany.disableProperty().bind(exportWallet.disableProperty());
sweepPrivateKey.disableProperty().bind(Bindings.or(serverToggle.disableProperty(), AppServices.onlineProperty().not()));
showPayNym.disableProperty().bind(findMixingPartner.disableProperty());
showPayNym.setDisable(true);
findMixingPartner.setDisable(true);
AppServices.onlineProperty().addListener((observable, oldValue, newValue) -> {
showPayNym.setDisable(exportWallet.isDisable() || getSelectedWalletForm() == null || !getSelectedWalletForm().getWallet().hasPaymentCode() || !newValue);
findMixingPartner.setDisable(exportWallet.isDisable() || getSelectedWalletForm() == null || !SorobanServices.canWalletMix(getSelectedWalletForm().getWallet()) || !newValue);
});
@ -979,7 +980,7 @@ public class AppController implements Initializable {
}
private void restorePublicKeysFromSeed(Storage storage, Wallet wallet, Key key) throws MnemonicException {
if(wallet.containsPrivateKeys()) {
if(wallet.containsMasterPrivateKeys()) {
//Derive xpub and master fingerprint from seed, potentially with passphrase
Wallet copy = wallet.copy();
for(int i = 0; i < copy.getKeystores().size(); i++) {
@ -1037,16 +1038,27 @@ public class AppController implements Initializable {
keystore.setKeyDerivation(derivedKeystore.getKeyDerivation());
keystore.setExtendedPublicKey(derivedKeystore.getExtendedPublicKey());
keystore.getSeed().setPassphrase(copyKeystore.getSeed().getPassphrase());
keystore.setBip47ExtendedPrivateKey(derivedKeystore.getBip47ExtendedPrivateKey());
copyKeystore.getSeed().clear();
} else if(keystore.hasMasterPrivateExtendedKey()) {
Keystore copyKeystore = copy.getKeystores().get(i);
Keystore derivedKeystore = Keystore.fromMasterPrivateExtendedKey(copyKeystore.getMasterPrivateExtendedKey(), copyKeystore.getKeyDerivation().getDerivation());
keystore.setKeyDerivation(derivedKeystore.getKeyDerivation());
keystore.setExtendedPublicKey(derivedKeystore.getExtendedPublicKey());
keystore.setBip47ExtendedPrivateKey(derivedKeystore.getBip47ExtendedPrivateKey());
copyKeystore.getMasterPrivateKey().clear();
}
}
}
if(wallet.isBip47()) {
try {
Keystore keystore = wallet.getKeystores().get(0);
keystore.setBip47ExtendedPrivateKey(wallet.getMasterWallet().getKeystores().get(0).getBip47ExtendedPrivateKey());
} catch(Exception e) {
log.error("Cannot prepare BIP47 keystore", e);
}
}
}
public void importWallet(ActionEvent event) {
@ -1342,7 +1354,7 @@ public class AppController implements Initializable {
public void showPayNym(ActionEvent event) {
WalletForm selectedWalletForm = getSelectedWalletForm();
if(selectedWalletForm != null) {
PayNymDialog payNymDialog = new PayNymDialog(selectedWalletForm.getWalletId(), false);
PayNymDialog payNymDialog = new PayNymDialog(selectedWalletForm.getWalletId());
payNymDialog.showAndWait();
}
}
@ -1961,6 +1973,7 @@ public class AppController implements Initializable {
exportWallet.setDisable(true);
showLoadingLog.setDisable(true);
showTxHex.setDisable(false);
showPayNym.setDisable(true);
findMixingPartner.setDisable(true);
} else if(event instanceof WalletTabSelectedEvent) {
WalletTabSelectedEvent walletTabEvent = (WalletTabSelectedEvent)event;
@ -1971,6 +1984,7 @@ public class AppController implements Initializable {
exportWallet.setDisable(walletTabData.getWallet() == null || !walletTabData.getWallet().isValid() || walletTabData.getWalletForm().isLocked());
showLoadingLog.setDisable(false);
showTxHex.setDisable(true);
showPayNym.setDisable(exportWallet.isDisable() || !walletTabData.getWallet().hasPaymentCode() || !AppServices.onlineProperty().get());
findMixingPartner.setDisable(exportWallet.isDisable() || !SorobanServices.canWalletMix(walletTabData.getWallet()) || !AppServices.onlineProperty().get());
}
}
@ -1996,6 +2010,7 @@ public class AppController implements Initializable {
if(selectedWalletForm != null) {
if(selectedWalletForm.getWalletId().equals(event.getWalletId())) {
exportWallet.setDisable(!event.getWallet().isValid() || selectedWalletForm.isLocked());
showPayNym.setDisable(exportWallet.isDisable() || !event.getWallet().hasPaymentCode() || !AppServices.onlineProperty().get());
findMixingPartner.setDisable(exportWallet.isDisable() || !SorobanServices.canWalletMix(event.getWallet()) || !AppServices.onlineProperty().get());
}
}
@ -2075,12 +2090,9 @@ public class AppController implements Initializable {
});
Image image = new Image("image/sparrow-small.png", 50, 50, false, false);
String walletName = event.getWallet().getMasterName();
if(walletName.length() > 25) {
walletName = walletName.substring(0, 25) + "...";
}
if(!event.getWallet().isMasterWallet()) {
walletName += " " + event.getWallet().getName();
String walletName = event.getWallet().getFullDisplayName();
if(walletName.length() > 40) {
walletName = walletName.substring(0, 40) + "...";
}
Notifications notificationBuilder = Notifications.create()

6
src/main/java/com/sparrowwallet/sparrow/AppServices.java

@ -610,6 +610,12 @@ public class AppServices {
return getTargetBlockFeeRates() == null ? FALLBACK_FEE_RATE : getTargetBlockFeeRates().get(defaultTarget);
}
public static Double getMinimumFeeRate() {
Optional<Double> optMinFeeRate = getTargetBlockFeeRates().values().stream().min(Double::compareTo);
Double minRate = optMinFeeRate.orElse(FALLBACK_FEE_RATE);
return Math.max(minRate, Transaction.DUST_RELAY_TX_FEE);
}
public static Map<Integer, Double> getTargetBlockFeeRates() {
return targetBlockFeeRates;
}

6
src/main/java/com/sparrowwallet/sparrow/control/MessageSignDialog.java

@ -94,7 +94,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
throw new IllegalArgumentException("Cannot sign messages using a wallet with multiple keystores - a single key is required");
}
if(!wallet.getKeystores().get(0).hasPrivateKey() && wallet.getKeystores().get(0).getSource() != KeystoreSource.HW_USB) {
throw new IllegalArgumentException("Cannot sign messages using a wallet without a seed or USB keystore");
throw new IllegalArgumentException("Cannot sign messages using a wallet without private keys or a USB keystore");
}
}
@ -301,7 +301,8 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
return;
}
if(wallet.containsPrivateKeys()) {
//Note we can expect a single keystore due to the check in the constructor
if(wallet.getKeystores().get(0).hasPrivateKey()) {
if(wallet.isEncrypted()) {
EventManager.get().post(new RequestOpenWalletsEvent());
} else {
@ -314,7 +315,6 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
private void signUnencryptedKeystore(Wallet decryptedWallet) {
try {
//Note we can expect a single keystore due to the check above
Keystore keystore = decryptedWallet.getKeystores().get(0);
ECKey privKey = keystore.getKey(walletNode);
ScriptType scriptType = electrumSignatureFormat ? ScriptType.P2PKH : decryptedWallet.getScriptType();

32
src/main/java/com/sparrowwallet/sparrow/control/PayNymCell.java

@ -1,5 +1,6 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.soroban.PayNym;
import com.sparrowwallet.sparrow.soroban.PayNymController;
import javafx.geometry.Insets;
@ -7,6 +8,7 @@ import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import org.controlsfx.glyphfont.Glyph;
public class PayNymCell extends ListCell<PayNym> {
private final PayNymController payNymController;
@ -24,6 +26,8 @@ public class PayNymCell extends ListCell<PayNym> {
protected void updateItem(PayNym payNym, boolean empty) {
super.updateItem(payNym, empty);
getStyleClass().remove("unlinked");
if(empty || payNym == null) {
setText(null);
setGraphic(null);
@ -53,10 +57,38 @@ public class PayNymCell extends ListCell<PayNym> {
button.setDisable(true);
payNymController.followPayNym(payNym.paymentCode());
});
} else if(payNymController != null) {
HBox hBox = new HBox();
hBox.setAlignment(Pos.CENTER);
pane.setRight(hBox);
if(payNymController.isLinked(payNym)) {
Label linkedLabel = new Label("Linked", getLinkGlyph());
linkedLabel.setTooltip(new Tooltip("You can send non-collaboratively to this contact."));
hBox.getChildren().add(linkedLabel);
} else {
Button linkButton = new Button("Link Contact", getLinkGlyph());
linkButton.setTooltip(new Tooltip("Create a transaction that will enable you to send non-collaboratively to this contact."));
hBox.getChildren().add(linkButton);
linkButton.setOnAction(event -> {
linkButton.setDisable(true);
payNymController.linkPayNym(payNym);
});
if(payNymController.isSelectLinkedOnly()) {
getStyleClass().add("unlinked");
}
}
}
setText(null);
setGraphic(pane);
}
}
public static Glyph getLinkGlyph() {
Glyph failGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.LINK);
failGlyph.setFontSize(12);
return failGlyph;
}
}

3
src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java

@ -123,7 +123,8 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
toAddress = new ComboBoxTextField();
toAddress.getStyleClass().add("fixed-width");
toWallet = new ComboBox<>();
toWallet.setItems(FXCollections.observableList(AppServices.get().getOpenWallets().keySet().stream().filter(w -> !w.isWhirlpoolChildWallet()).collect(Collectors.toList())));
toWallet.setItems(FXCollections.observableList(AppServices.get().getOpenWallets().keySet().stream()
.filter(w -> !w.isWhirlpoolChildWallet() && !w.isBip47()).collect(Collectors.toList())));
toAddress.setComboProperty(toWallet);
toWallet.prefWidthProperty().bind(toAddress.widthProperty());
StackPane stackPane = new StackPane();

4
src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java

@ -647,7 +647,7 @@ public class TransactionDiagram extends GridPane {
recipientLabel.getStyleClass().add("output-label");
recipientLabel.getStyleClass().add(labelledPayment ? "payment-label" : "recipient-label");
Wallet toWallet = getToWallet(payment);
WalletNode toNode = walletTx.getWallet() != null ? walletTx.getWallet().getWalletAddresses().get(payment.getAddress()) : null;
WalletNode toNode = walletTx.getWallet() != null && !walletTx.getWallet().isBip47() ? walletTx.getWallet().getWalletAddresses().get(payment.getAddress()) : null;
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 ? (payment.getLabel() == null ? (toNode != null ? toNode : "external address") : payment.getLabel()) : toWallet.getFullDisplayName()) + "\n" + payment.getAddress().toString()));
@ -849,7 +849,7 @@ public class TransactionDiagram extends GridPane {
private Wallet getToWallet(Payment payment) {
for(Wallet openWallet : AppServices.get().getOpenWallets().keySet()) {
if(openWallet != walletTx.getWallet() && openWallet.isValid() && openWallet.isWalletAddress(payment.getAddress())) {
if(openWallet != walletTx.getWallet() && openWallet.isValid() && !openWallet.isBip47() && openWallet.isWalletAddress(payment.getAddress())) {
return openWallet;
}
}

13
src/main/java/com/sparrowwallet/sparrow/event/WalletNodeHistoryChangedEvent.java

@ -5,6 +5,7 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import java.util.ArrayList;
import java.util.List;
/**
@ -26,12 +27,20 @@ public class WalletNodeHistoryChangedEvent {
}
}
Wallet notificationWallet = wallet.getNotificationWallet();
if(notificationWallet != null) {
WalletNode notificationNode = notificationWallet.getNode(KeyPurpose.NOTIFICATION);
if(ElectrumServer.getScriptHash(notificationWallet, notificationNode).equals(scriptHash)) {
return notificationNode;
}
}
return null;
}
private WalletNode getWalletNode(Wallet wallet, KeyPurpose keyPurpose) {
WalletNode purposeNode = wallet.getNode(keyPurpose);
for(WalletNode addressNode : purposeNode.getChildren()) {
WalletNode purposeNode = wallet.getNode(keyPurpose);
for(WalletNode addressNode : new ArrayList<>(purposeNode.getChildren())) {
if(ElectrumServer.getScriptHash(wallet, addressNode).equals(scriptHash)) {
return addressNode;
}

1
src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java

@ -45,6 +45,7 @@ public class FontAwesome5 extends GlyphFont {
INFO_CIRCLE('\uf05a'),
KEY('\uf084'),
LAPTOP('\uf109'),
LINK('\uf0c1'),
LOCK('\uf023'),
LOCK_OPEN('\uf3c1'),
MINUS_CIRCLE('\uf056'),

2
src/main/java/com/sparrowwallet/sparrow/io/JsonPersistence.java

@ -425,7 +425,7 @@ public class JsonPersistence implements Persistence {
@Override
public JsonElement serialize(Keystore keystore, Type typeOfSrc, JsonSerializationContext context) {
JsonObject jsonObject = (JsonObject)getGson(false).toJsonTree(keystore);
if(keystore.hasPrivateKey()) {
if(keystore.hasMasterPrivateKey()) {
jsonObject.remove("extendedPublicKey");
jsonObject.getAsJsonObject("keyDerivation").remove("masterFingerprint");
}

11
src/main/java/com/sparrowwallet/sparrow/io/db/KeystoreDao.java

@ -13,16 +13,16 @@ import org.jdbi.v3.sqlobject.statement.SqlUpdate;
import java.util.List;
public interface KeystoreDao {
@SqlQuery("select keystore.id, keystore.label, keystore.source, keystore.walletModel, keystore.masterFingerprint, keystore.derivationPath, keystore.extendedPublicKey, " +
@SqlQuery("select keystore.id, keystore.label, keystore.source, keystore.walletModel, keystore.masterFingerprint, keystore.derivationPath, keystore.extendedPublicKey, keystore.externalPaymentCode, " +
"masterPrivateExtendedKey.id, masterPrivateExtendedKey.privateKey, masterPrivateExtendedKey.chainCode, masterPrivateExtendedKey.initialisationVector, masterPrivateExtendedKey.encryptedBytes, masterPrivateExtendedKey.keySalt, masterPrivateExtendedKey.deriver, masterPrivateExtendedKey.crypter, " +
"seed.id, seed.type, seed.mnemonicString, seed.initialisationVector, seed.encryptedBytes, seed.keySalt, seed.deriver, seed.crypter, seed.needsPassphrase, seed.creationTimeSeconds " +
"from keystore left join masterPrivateExtendedKey on keystore.masterPrivateExtendedKey = masterPrivateExtendedKey.id left join seed on keystore.seed = seed.id where keystore.wallet = ? order by keystore.index asc")
@RegisterRowMapper(KeystoreMapper.class)
List<Keystore> getForWalletId(Long id);
@SqlUpdate("insert into keystore (label, source, walletModel, masterFingerprint, derivationPath, extendedPublicKey, masterPrivateExtendedKey, seed, wallet, index) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
@SqlUpdate("insert into keystore (label, source, walletModel, masterFingerprint, derivationPath, extendedPublicKey, externalPaymentCode, masterPrivateExtendedKey, seed, wallet, index) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
@GetGeneratedKeys("id")
long insert(String label, int source, int walletModel, String masterFingerprint, String derivationPath, String extendedPublicKey, Long masterPrivateExtendedKey, Long seed, long wallet, int index);
long insert(String label, int source, int walletModel, String masterFingerprint, String derivationPath, String extendedPublicKey, String externalPaymentCode, Long masterPrivateExtendedKey, Long seed, long wallet, int index);
@SqlUpdate("insert into masterPrivateExtendedKey (privateKey, chainCode, initialisationVector, encryptedBytes, keySalt, deriver, crypter, creationTimeSeconds) values (?, ?, ?, ?, ?, ?, ?, ?)")
@GetGeneratedKeys("id")
@ -69,9 +69,10 @@ public interface KeystoreDao {
}
long id = insert(truncate(keystore.getLabel()), keystore.getSource().ordinal(), keystore.getWalletModel().ordinal(),
keystore.hasPrivateKey() ? null : keystore.getKeyDerivation().getMasterFingerprint(),
keystore.hasMasterPrivateKey() ? null : keystore.getKeyDerivation().getMasterFingerprint(),
keystore.getKeyDerivation().getDerivationPath(),
keystore.hasPrivateKey() ? null : keystore.getExtendedPublicKey().toString(),
keystore.hasMasterPrivateKey() ? null : keystore.getExtendedPublicKey().toString(),
keystore.getExternalPaymentCode() == null ? null : keystore.getExternalPaymentCode().toString(),
keystore.getMasterPrivateExtendedKey() == null ? null : keystore.getMasterPrivateExtendedKey().getId(),
keystore.getSeed() == null ? null : keystore.getSeed().getId(), wallet.getId(), i);
keystore.setId(id);

2
src/main/java/com/sparrowwallet/sparrow/io/db/KeystoreMapper.java

@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.io.db;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.bip47.PaymentCode;
import com.sparrowwallet.drongo.crypto.EncryptedData;
import com.sparrowwallet.drongo.crypto.EncryptionType;
import com.sparrowwallet.drongo.wallet.*;
@ -23,6 +24,7 @@ public class KeystoreMapper implements RowMapper<Keystore> {
keystore.setWalletModel(WalletModel.values()[rs.getInt("keystore.walletModel")]);
keystore.setKeyDerivation(new KeyDerivation(rs.getString("keystore.masterFingerprint"), rs.getString("keystore.derivationPath")));
keystore.setExtendedPublicKey(rs.getString("keystore.extendedPublicKey") == null ? null : ExtendedKey.fromDescriptor(rs.getString("keystore.extendedPublicKey")));
keystore.setExternalPaymentCode(rs.getString("keystore.externalPaymentCode") == null ? null : PaymentCode.fromString(rs.getString("keystore.externalPaymentCode")));
if(rs.getBytes("masterPrivateExtendedKey.privateKey") != null) {
MasterPrivateExtendedKey masterPrivateExtendedKey = new MasterPrivateExtendedKey(rs.getBytes("masterPrivateExtendedKey.privateKey"), rs.getBytes("masterPrivateExtendedKey.chainCode"));

1
src/main/java/com/sparrowwallet/sparrow/io/db/WalletNodeDao.java

@ -61,6 +61,7 @@ public interface WalletNodeDao {
for(WalletNode purposeNode : wallet.getPurposeNodes()) {
long purposeNodeId = insertWalletNode(purposeNode.getDerivationPath(), truncate(purposeNode.getLabel()), wallet.getId(), null);
purposeNode.setId(purposeNodeId);
addTransactionOutputs(purposeNode);
List<WalletNode> childNodes = new ArrayList<>(purposeNode.getChildren());
for(WalletNode addressNode : childNodes) {
long addressNodeId = insertWalletNode(addressNode.getDerivationPath(), truncate(addressNode.getLabel()), wallet.getId(), purposeNodeId);

168
src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java

@ -7,12 +7,16 @@ import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.bip47.InvalidPaymentCodeException;
import com.sparrowwallet.drongo.bip47.PaymentCode;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.soroban.PayNym;
import com.sparrowwallet.sparrow.soroban.Soroban;
import javafx.application.Platform;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
@ -172,6 +176,12 @@ public class ElectrumServer {
getCalculatedScriptHashes(wallet).forEach(retrievedScriptHashes::putIfAbsent);
}
private static void addCalculatedScriptHashes(Wallet wallet, WalletNode walletNode) {
Map<String, String> calculatedScriptHashStatuses = new HashMap<>();
addScriptHashStatus(calculatedScriptHashStatuses, wallet, walletNode);
calculatedScriptHashStatuses.forEach(retrievedScriptHashes::putIfAbsent);
}
private static Map<String, String> getCalculatedScriptHashes(Wallet wallet) {
Map<String, String> storedScriptHashStatuses = new HashMap<>();
storedScriptHashStatuses.putAll(calculateScriptHashes(wallet, KeyPurpose.RECEIVE));
@ -182,41 +192,49 @@ public class ElectrumServer {
private static Map<String, String> calculateScriptHashes(Wallet wallet, KeyPurpose keyPurpose) {
Map<String, String> calculatedScriptHashes = new LinkedHashMap<>();
for(WalletNode walletNode : wallet.getNode(keyPurpose).getChildren()) {
String scriptHash = getScriptHash(wallet, walletNode);
List<BlockTransactionHashIndex> txos = new ArrayList<>(walletNode.getTransactionOutputs());
txos.addAll(walletNode.getTransactionOutputs().stream().filter(BlockTransactionHashIndex::isSpent).map(BlockTransactionHashIndex::getSpentBy).collect(Collectors.toList()));
Set<Sha256Hash> unique = new HashSet<>(txos.size());
txos.removeIf(ref -> !unique.add(ref.getHash()));
txos.sort((txo1, txo2) -> {
if(txo1.getHeight() != txo2.getHeight()) {
return txo1.getComparisonHeight() - txo2.getComparisonHeight();
}
addScriptHashStatus(calculatedScriptHashes, wallet, walletNode);
}
if(txo1.isSpent() && txo1.getSpentBy().equals(txo2)) {
return -1;
}
return calculatedScriptHashes;
}
if(txo2.isSpent() && txo2.getSpentBy().equals(txo1)) {
return 1;
}
private static void addScriptHashStatus(Map<String, String> calculatedScriptHashes, Wallet wallet, WalletNode walletNode) {
String scriptHash = getScriptHash(wallet, walletNode);
String scriptHashStatus = getScriptHashStatus(walletNode);
calculatedScriptHashes.put(scriptHash, scriptHashStatus);
}
//We cannot further sort by order within a block, so sometimes multiple txos to an address will mean an incorrect status
return 0;
});
if(!txos.isEmpty()) {
StringBuilder scriptHashStatus = new StringBuilder();
for(BlockTransactionHashIndex txo : txos) {
scriptHashStatus.append(txo.getHash().toString()).append(":").append(txo.getHeight()).append(":");
}
private static String getScriptHashStatus(WalletNode walletNode) {
List<BlockTransactionHashIndex> txos = new ArrayList<>(walletNode.getTransactionOutputs());
txos.addAll(walletNode.getTransactionOutputs().stream().filter(BlockTransactionHashIndex::isSpent).map(BlockTransactionHashIndex::getSpentBy).collect(Collectors.toList()));
Set<Sha256Hash> unique = new HashSet<>(txos.size());
txos.removeIf(ref -> !unique.add(ref.getHash()));
txos.sort((txo1, txo2) -> {
if(txo1.getHeight() != txo2.getHeight()) {
return txo1.getComparisonHeight() - txo2.getComparisonHeight();
}
calculatedScriptHashes.put(scriptHash, Utils.bytesToHex(Sha256Hash.hash(scriptHashStatus.toString().getBytes(StandardCharsets.UTF_8))));
} else {
calculatedScriptHashes.put(scriptHash, null);
if(txo1.isSpent() && txo1.getSpentBy().equals(txo2)) {
return -1;
}
}
return calculatedScriptHashes;
if(txo2.isSpent() && txo2.getSpentBy().equals(txo1)) {
return 1;
}
//We cannot further sort by order within a block, so sometimes multiple txos to an address will mean an incorrect status
return 0;
});
if(!txos.isEmpty()) {
StringBuilder scriptHashStatus = new StringBuilder();
for(BlockTransactionHashIndex txo : txos) {
scriptHashStatus.append(txo.getHash().toString()).append(":").append(txo.getHeight()).append(":");
}
return Utils.bytesToHex(Sha256Hash.hash(scriptHashStatus.toString().getBytes(StandardCharsets.UTF_8)));
} else {
return null;
}
}
public static void clearRetrievedScriptHashes(Wallet wallet) {
@ -421,8 +439,8 @@ public class ElectrumServer {
String scriptHash = getScriptHash(wallet, node);
String subscribedStatus = getSubscribedScriptHashStatus(scriptHash);
if(subscribedStatus != null) {
//Already subscribed, but still need to fetch history from a used node if not previously fetched
if(!subscribedStatus.equals(retrievedScriptHashes.get(scriptHash))) {
//Already subscribed, but still need to fetch history from a used node if not previously fetched or present
if(!subscribedStatus.equals(retrievedScriptHashes.get(scriptHash)) || !subscribedStatus.equals(getScriptHashStatus(node))) {
nodeTransactionMap.put(node, new TreeSet<>());
}
} else if(!subscribedScriptHashes.containsKey(scriptHash) && scriptHashes.add(scriptHash)) {
@ -1632,4 +1650,92 @@ public class ElectrumServer {
};
}
}
public static class PaymentCodesService extends Service<List<Wallet>> {
private final String walletId;
private final Wallet wallet;
public PaymentCodesService(String walletId, Wallet wallet) {
this.walletId = walletId;
this.wallet = wallet;
}
@Override
protected Task<List<Wallet>> createTask() {
return new Task<>() {
protected List<Wallet> call() throws ServerException {
Wallet notificationWallet = wallet.getNotificationWallet();
WalletNode notificationNode = notificationWallet.getNode(KeyPurpose.NOTIFICATION);
for(Wallet childWallet : wallet.getChildWallets()) {
if(childWallet.isBip47()) {
WalletNode savedNotificationNode = childWallet.getNode(KeyPurpose.NOTIFICATION);
notificationNode.getTransactionOutputs().addAll(savedNotificationNode.getTransactionOutputs());
notificationWallet.updateTransactions(childWallet.getTransactions());
}
}
addCalculatedScriptHashes(notificationWallet, notificationNode);
ElectrumServer electrumServer = new ElectrumServer();
Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap = electrumServer.getHistory(notificationWallet, List.of(notificationNode));
electrumServer.getReferencedTransactions(notificationWallet, nodeTransactionMap);
electrumServer.calculateNodeHistory(notificationWallet, nodeTransactionMap);
List<Wallet> addedWallets = new ArrayList<>();
if(!nodeTransactionMap.isEmpty()) {
Set<PaymentCode> paymentCodes = new LinkedHashSet<>();
for(BlockTransactionHashIndex output : notificationNode.getTransactionOutputs()) {
BlockTransaction blkTx = notificationWallet.getTransactions().get(output.getHash());
try {
PaymentCode paymentCode = PaymentCode.getPaymentCode(blkTx.getTransaction(), notificationWallet.getKeystores().get(0));
if(paymentCodes.add(paymentCode)) {
if(getExistingChildWallet(paymentCode) == null) {
PayNym payNym = Config.get().isUsePayNym() ? getPayNym(paymentCode) : null;
List<ScriptType> scriptTypes = payNym == null || wallet.getScriptType() != ScriptType.P2PKH ? PayNym.getSegwitScriptTypes() : payNym.getScriptTypes();
for(ScriptType childScriptType : scriptTypes) {
Wallet addedWallet = wallet.addChildWallet(paymentCode, childScriptType, output, blkTx);
if(payNym != null) {
addedWallet.setLabel(payNym.nymName() + " " + childScriptType.getName());
}
//Check this is a valid payment code, will throw IllegalArgumentException if not
addedWallet.getPubKey(new WalletNode(KeyPurpose.RECEIVE, 0));
addedWallets.add(addedWallet);
}
}
}
} catch(InvalidPaymentCodeException e) {
log.info("Could not determine payment code for notification transaction", e);
} catch(IllegalArgumentException e) {
log.info("Invalid notification transaction creates illegal payment code", e);
}
}
}
return addedWallets;
}
};
}
private PayNym getPayNym(PaymentCode paymentCode) {
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
try {
return soroban.getPayNym(paymentCode.toString()).blockingFirst();
} catch(Exception e) {
//ignore
}
return null;
}
private Wallet getExistingChildWallet(PaymentCode paymentCode) {
for(Wallet childWallet : wallet.getChildWallets()) {
if(childWallet.isBip47() && paymentCode.equals(childWallet.getKeystores().get(0).getExternalPaymentCode())) {
return childWallet;
}
}
return null;
}
}
}

2
src/main/java/com/sparrowwallet/sparrow/payjoin/Payjoin.java

@ -285,7 +285,7 @@ public class Payjoin {
}
private int getChangeOutputIndex() {
Map<Script, WalletNode> changeScriptNodes = wallet.getWalletOutputScripts(KeyPurpose.CHANGE);
Map<Script, WalletNode> changeScriptNodes = wallet.getWalletOutputScripts(wallet.getChangeKeyPurpose());
for(int i = 0; i < psbt.getTransaction().getOutputs().size(); i++) {
if(changeScriptNodes.containsKey(psbt.getTransaction().getOutputs().get(i).getScript())) {
return i;

4
src/main/java/com/sparrowwallet/sparrow/soroban/CounterpartyController.java

@ -382,7 +382,7 @@ public class CounterpartyController extends SorobanController {
payNymAvatar.setPaymentCode(soroban.getPaymentCode());
payNym.setVisible(true);
claimPayNym(soroban, createMap);
claimPayNym(soroban, createMap, true);
}, error -> {
log.error("Error retrieving PayNym", error);
Optional<ButtonType> optResponse = showErrorDialog("Error retrieving PayNym", "Could not retrieve PayNym. Try again?", ButtonType.CANCEL, ButtonType.OK);
@ -395,7 +395,7 @@ public class CounterpartyController extends SorobanController {
}
public void showPayNym(ActionEvent event) {
PayNymDialog payNymDialog = new PayNymDialog(walletId, false);
PayNymDialog payNymDialog = new PayNymDialog(walletId);
payNymDialog.showAndWait();
}

2
src/main/java/com/sparrowwallet/sparrow/soroban/InitiatorController.java

@ -614,7 +614,7 @@ public class InitiatorController extends SorobanController {
}
public void findPayNym(ActionEvent event) {
PayNymDialog payNymDialog = new PayNymDialog(walletId, true);
PayNymDialog payNymDialog = new PayNymDialog(walletId, true, false);
Optional<PayNym> optPayNym = payNymDialog.showAndWait();
optPayNym.ifPresent(payNym -> {
counterpartyPayNymName.set(payNym.nymName());

55
src/main/java/com/sparrowwallet/sparrow/soroban/PayNym.java

@ -1,7 +1,60 @@
package com.sparrowwallet.sparrow.soroban;
import com.samourai.wallet.bip47.rpc.PaymentCode;
import com.sparrowwallet.drongo.protocol.ScriptType;
import java.util.List;
public record PayNym(PaymentCode paymentCode, String nymId, String nymName, boolean segwit, List<PayNym> following, List<PayNym> followers) {}
public class PayNym {
private final PaymentCode paymentCode;
private final String nymId;
private final String nymName;
private final boolean segwit;
private final List<PayNym> following;
private final List<PayNym> followers;
public PayNym(PaymentCode paymentCode, String nymId, String nymName, boolean segwit, List<PayNym> following, List<PayNym> followers) {
this.paymentCode = paymentCode;
this.nymId = nymId;
this.nymName = nymName;
this.segwit = segwit;
this.following = following;
this.followers = followers;
}
public PaymentCode paymentCode() {
return paymentCode;
}
public String nymId() {
return nymId;
}
public String nymName() {
return nymName;
}
public boolean segwit() {
return segwit;
}
public List<PayNym> following() {
return following;
}
public List<PayNym> followers() {
return followers;
}
public List<ScriptType> getScriptTypes() {
return segwit ? getSegwitScriptTypes() : getV1ScriptTypes();
}
public static List<ScriptType> getSegwitScriptTypes() {
return List.of(ScriptType.P2PKH, ScriptType.P2SH_P2WPKH, ScriptType.P2WPKH);
}
public static List<ScriptType> getV1ScriptTypes() {
return List.of(ScriptType.P2PKH);
}
}

315
src/main/java/com/sparrowwallet/sparrow/soroban/PayNymController.java

@ -1,19 +1,25 @@
package com.sparrowwallet.sparrow.soroban;
import com.google.common.eventbus.Subscribe;
import com.samourai.wallet.bip47.rpc.PaymentCode;
import com.sparrowwallet.drongo.SecureString;
import com.sparrowwallet.drongo.bip47.SecretPoint;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.crypto.EncryptionType;
import com.sparrowwallet.drongo.crypto.InvalidPasswordException;
import com.sparrowwallet.drongo.crypto.Key;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.psbt.PSBT;
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.StorageEvent;
import com.sparrowwallet.sparrow.event.TimedEvent;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.TransactionEntry;
import javafx.application.Platform;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
@ -24,13 +30,13 @@ import javafx.collections.ObservableList;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.scene.control.*;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.util.Duration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Optional;
import java.util.*;
import java.util.function.UnaryOperator;
import static com.sparrowwallet.sparrow.AppServices.showErrorDialog;
@ -38,7 +44,10 @@ import static com.sparrowwallet.sparrow.AppServices.showErrorDialog;
public class PayNymController extends SorobanController {
private static final Logger log = LoggerFactory.getLogger(PayNymController.class);
private static final long MINIMUM_P2PKH_OUTPUT_SATS = 546L;
private String walletId;
private boolean selectLinkedOnly;
private PayNym walletPayNym;
@FXML
@ -72,8 +81,11 @@ public class PayNymController extends SorobanController {
private final StringProperty findNymProperty = new SimpleStringProperty();
public void initializeView(String walletId) {
private final Map<Sha256Hash, PayNym> notificationTransactions = new HashMap<>();
public void initializeView(String walletId, boolean selectLinkedOnly) {
this.walletId = walletId;
this.selectLinkedOnly = selectLinkedOnly;
payNymName.managedProperty().bind(payNymName.visibleProperty());
payNymRetrieve.managedProperty().bind(payNymRetrieve.visibleProperty());
@ -83,9 +95,9 @@ public class PayNymController extends SorobanController {
retrievePayNymProgress.maxHeightProperty().bind(payNymName.heightProperty());
retrievePayNymProgress.setVisible(false);
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
if(soroban.getPaymentCode() != null) {
paymentCode.setPaymentCode(soroban.getPaymentCode());
Wallet masterWallet = getMasterWallet();
if(masterWallet.hasPaymentCode()) {
paymentCode.setPaymentCode(new PaymentCode(masterWallet.getPaymentCode().toString()));
}
findNymProperty.addListener((observable, oldValue, nymIdentifier) -> {
@ -121,6 +133,12 @@ public class PayNymController extends SorobanController {
return change;
};
searchPayNyms.setTextFormatter(new TextFormatter<>(paymentCodeFilter));
searchPayNyms.addEventFilter(KeyEvent.ANY, event -> {
if(event.getCode() == KeyCode.ENTER) {
findNymProperty.set(searchPayNyms.getText());
event.consume();
}
});
findPayNym.managedProperty().bind(findPayNym.visibleProperty());
findPayNym.maxHeightProperty().bind(searchPayNyms.heightProperty());
findPayNym.setVisible(false);
@ -140,7 +158,7 @@ public class PayNymController extends SorobanController {
followersList.setSelectionModel(new NoSelectionModel<>());
followersList.setFocusTraversable(false);
if(Config.get().isUsePayNym() && soroban.getPaymentCode() != null) {
if(Config.get().isUsePayNym() && masterWallet.hasPaymentCode()) {
refresh();
} else {
payNymName.setVisible(false);
@ -149,12 +167,12 @@ public class PayNymController extends SorobanController {
private void refresh() {
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
if(soroban.getPaymentCode() == null) {
throw new IllegalStateException("Payment code has not been set");
if(!getMasterWallet().hasPaymentCode()) {
throw new IllegalStateException("Payment code is not present");
}
retrievePayNymProgress.setVisible(true);
soroban.getPayNym(soroban.getPaymentCode().toString()).subscribe(payNym -> {
soroban.getPayNym(getMasterWallet().getPaymentCode().toString()).subscribe(payNym -> {
retrievePayNymProgress.setVisible(false);
walletPayNym = payNym;
payNymName.setText(payNym.nymName());
@ -165,6 +183,7 @@ public class PayNymController extends SorobanController {
followingList.setItems(FXCollections.observableList(payNym.following()));
followersList.setPlaceholder(new Label("No followers"));
followersList.setItems(FXCollections.observableList(payNym.followers()));
Platform.runLater(() -> addWalletIfNotificationTransactionPresent(payNym.following()));
}, error -> {
retrievePayNymProgress.setVisible(false);
if(error.getMessage().endsWith("404")) {
@ -215,7 +234,7 @@ public class PayNymController extends SorobanController {
public void showQR(ActionEvent event) {
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(soroban.getPaymentCode().toString());
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(getMasterWallet().getPaymentCode().toString());
qrDisplayDialog.showAndWait();
}
@ -244,7 +263,7 @@ public class PayNymController extends SorobanController {
private void makeAuthenticatedCall(PaymentCode contact) {
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
if(soroban.getHdWallet() == null) {
Wallet wallet = AppServices.get().getWallet(walletId);
Wallet wallet = getMasterWallet();
if(wallet.isEncrypted()) {
Wallet copy = wallet.copy();
WalletPasswordDialog dlg = new WalletPasswordDialog(copy.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
@ -301,10 +320,10 @@ public class PayNymController extends SorobanController {
private void retrievePayNym(Soroban soroban) {
soroban.createPayNym().subscribe(createMap -> {
payNymName.setText((String)createMap.get("nymName"));
payNymAvatar.setPaymentCode(soroban.getPaymentCode());
payNymAvatar.setPaymentCode(new PaymentCode(getMasterWallet().getPaymentCode().toString()));
payNymName.setVisible(true);
claimPayNym(soroban, createMap);
claimPayNym(soroban, createMap, getMasterWallet().getScriptType() != ScriptType.P2PKH);
refresh();
}, error -> {
log.error("Error retrieving PayNym", error);
@ -340,6 +359,248 @@ public class PayNymController extends SorobanController {
});
}
public boolean isLinked(PayNym payNym) {
com.sparrowwallet.drongo.bip47.PaymentCode externalPaymentCode = com.sparrowwallet.drongo.bip47.PaymentCode.fromString(payNym.paymentCode().toString());
return getMasterWallet().getChildWallet(externalPaymentCode, payNym.segwit() ? ScriptType.P2WPKH : ScriptType.P2PKH) != null;
}
private void addWalletIfNotificationTransactionPresent(List<PayNym> following) {
Map<BlockTransaction, PayNym> unlinkedPayNyms = new HashMap<>();
Map<BlockTransaction, WalletNode> unlinkedNotifications = new HashMap<>();
for(PayNym payNym : following) {
if(!isLinked(payNym)) {
com.sparrowwallet.drongo.bip47.PaymentCode externalPaymentCode = com.sparrowwallet.drongo.bip47.PaymentCode.fromString(payNym.paymentCode().toString());
Map<BlockTransaction, WalletNode> unlinkedNotification = getMasterWallet().getNotificationTransaction(externalPaymentCode);
if(!unlinkedNotification.isEmpty()) {
unlinkedNotifications.putAll(unlinkedNotification);
unlinkedPayNyms.put(unlinkedNotification.keySet().iterator().next(), payNym);
}
}
}
Wallet wallet = getMasterWallet();
if(!unlinkedNotifications.isEmpty()) {
if(wallet.isEncrypted()) {
Storage storage = AppServices.get().getOpenWallets().get(wallet);
Optional<ButtonType> optButtonType = AppServices.showAlertDialog("Link contacts?", "Some contacts were found that may be already linked. Link these contacts? Your password is required to check.", Alert.AlertType.CONFIRMATION, ButtonType.NO, ButtonType.YES);
if(optButtonType.isPresent() && optButtonType.get() == ButtonType.YES) {
WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(wallet.copy(), password.get());
decryptWalletService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.END, "Done"));
Wallet decryptedWallet = decryptWalletService.getValue();
addWalletIfNotificationTransactionPresent(decryptedWallet, unlinkedPayNyms, unlinkedNotifications);
decryptedWallet.clearPrivate();
});
decryptWalletService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.END, "Failed"));
AppServices.showErrorDialog("Incorrect Password", decryptWalletService.getException().getMessage());
});
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.START, "Decrypting wallet..."));
decryptWalletService.start();
}
}
} else {
addWalletIfNotificationTransactionPresent(wallet, unlinkedPayNyms, unlinkedNotifications);
}
}
}
private void addWalletIfNotificationTransactionPresent(Wallet decryptedWallet, Map<BlockTransaction, PayNym> unlinkedPayNyms, Map<BlockTransaction, WalletNode> unlinkedNotifications) {
for(BlockTransaction blockTransaction : unlinkedNotifications.keySet()) {
try {
PayNym payNym = unlinkedPayNyms.get(blockTransaction);
com.sparrowwallet.drongo.bip47.PaymentCode externalPaymentCode = com.sparrowwallet.drongo.bip47.PaymentCode.fromString(payNym.paymentCode().toString());
ECKey input0Key = decryptedWallet.getKeystores().get(0).getKey(unlinkedNotifications.get(blockTransaction));
TransactionOutPoint input0Outpoint = com.sparrowwallet.drongo.bip47.PaymentCode.getDesignatedInput(blockTransaction.getTransaction()).getOutpoint();
SecretPoint secretPoint = new SecretPoint(input0Key.getPrivKeyBytes(), externalPaymentCode.getNotificationKey().getPubKey());
byte[] blindingMask = com.sparrowwallet.drongo.bip47.PaymentCode.getMask(secretPoint.ECDHSecretAsBytes(), input0Outpoint.bitcoinSerialize());
byte[] blindedPaymentCode = com.sparrowwallet.drongo.bip47.PaymentCode.blind(getMasterWallet().getPaymentCode().getPayload(), blindingMask);
byte[] opReturnData = com.sparrowwallet.drongo.bip47.PaymentCode.getOpReturnData(blockTransaction.getTransaction());
if(Arrays.equals(opReturnData, blindedPaymentCode)) {
addChildWallet(payNym, externalPaymentCode);
followingList.refresh();
}
} catch(Exception e) {
log.error("Error adding linked contact from notification transaction", e);
}
}
}
public void addChildWallet(PayNym payNym, com.sparrowwallet.drongo.bip47.PaymentCode externalPaymentCode) {
Wallet masterWallet = getMasterWallet();
Storage storage = AppServices.get().getOpenWallets().get(masterWallet);
List<ScriptType> scriptTypes = masterWallet.getScriptType() != ScriptType.P2PKH ? PayNym.getSegwitScriptTypes() : payNym.getScriptTypes();
for(ScriptType childScriptType : scriptTypes) {
Wallet addedWallet = masterWallet.addChildWallet(externalPaymentCode, childScriptType);
addedWallet.setLabel(payNym.nymName() + " " + childScriptType.getName());
if(!storage.isPersisted(addedWallet)) {
try {
storage.saveWallet(addedWallet);
} catch(Exception e) {
log.error("Error saving wallet", e);
AppServices.showErrorDialog("Error saving wallet " + addedWallet.getName(), e.getMessage());
}
}
EventManager.get().post(new ChildWalletAddedEvent(storage, masterWallet, addedWallet));
}
}
public void linkPayNym(PayNym payNym) {
Optional<ButtonType> optButtonType = AppServices.showAlertDialog("Link PayNym?",
"Linking to this contact will allow you to send to it non-collaboratively through unique private addresses you can generate independently.\n\n" +
"It will cost " + MINIMUM_P2PKH_OUTPUT_SATS + " sats to create the link, plus the mining fee. Send transaction?", Alert.AlertType.CONFIRMATION, ButtonType.NO, ButtonType.YES);
if(optButtonType.isPresent() && optButtonType.get() == ButtonType.YES) {
broadcastNotificationTransaction(payNym);
} else {
followingList.refresh();
}
}
public void broadcastNotificationTransaction(PayNym payNym) {
Wallet masterWallet = getMasterWallet();
WalletTransaction walletTransaction;
try {
walletTransaction = getWalletTransaction(masterWallet, payNym, new byte[80], null);
} catch(InsufficientFundsException e) {
try {
Wallet wallet = AppServices.get().getWallet(walletId);
walletTransaction = getWalletTransaction(wallet, payNym, new byte[80], null);
} catch(InsufficientFundsException e2) {
AppServices.showErrorDialog("Insufficient Funds", "There are not enough funds in this wallet to broadcast the notification transaction.");
followingList.refresh();
return;
}
}
final WalletTransaction walletTx = walletTransaction;
final com.sparrowwallet.drongo.bip47.PaymentCode paymentCode = masterWallet.getPaymentCode();
Wallet wallet = walletTransaction.getWallet();
Storage storage = AppServices.get().getOpenWallets().get(wallet);
if(wallet.isEncrypted()) {
WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(wallet.copy(), password.get());
decryptWalletService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.END, "Done"));
Wallet decryptedWallet = decryptWalletService.getValue();
broadcastNotificationTransaction(decryptedWallet, walletTx, paymentCode, payNym);
decryptedWallet.clearPrivate();
});
decryptWalletService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.END, "Failed"));
followingList.refresh();
AppServices.showErrorDialog("Incorrect Password", decryptWalletService.getException().getMessage());
});
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.START, "Decrypting wallet..."));
decryptWalletService.start();
}
} else {
broadcastNotificationTransaction(wallet, walletTx, paymentCode, payNym);
}
}
private void broadcastNotificationTransaction(Wallet decryptedWallet, WalletTransaction walletTransaction, com.sparrowwallet.drongo.bip47.PaymentCode paymentCode, PayNym payNym) {
try {
com.sparrowwallet.drongo.bip47.PaymentCode externalPaymentCode = com.sparrowwallet.drongo.bip47.PaymentCode.fromString(payNym.paymentCode().toString());
WalletNode input0Node = walletTransaction.getSelectedUtxos().entrySet().iterator().next().getValue();
ECKey input0Key = decryptedWallet.getKeystores().get(0).getKey(input0Node);
TransactionOutPoint input0Outpoint = walletTransaction.getTransaction().getInputs().iterator().next().getOutpoint();
SecretPoint secretPoint = new SecretPoint(input0Key.getPrivKeyBytes(), externalPaymentCode.getNotificationKey().getPubKey());
byte[] blindingMask = com.sparrowwallet.drongo.bip47.PaymentCode.getMask(secretPoint.ECDHSecretAsBytes(), input0Outpoint.bitcoinSerialize());
byte[] blindedPaymentCode = com.sparrowwallet.drongo.bip47.PaymentCode.blind(paymentCode.getPayload(), blindingMask);
WalletTransaction finalWalletTx = getWalletTransaction(decryptedWallet, payNym, blindedPaymentCode, walletTransaction.getSelectedUtxos().keySet());
PSBT psbt = finalWalletTx.createPSBT();
decryptedWallet.sign(psbt);
decryptedWallet.finalise(psbt);
Transaction transaction = psbt.extractTransaction();
ElectrumServer.BroadcastTransactionService broadcastTransactionService = new ElectrumServer.BroadcastTransactionService(transaction);
broadcastTransactionService.setOnSucceeded(successEvent -> {
ElectrumServer.TransactionMempoolService transactionMempoolService = new ElectrumServer.TransactionMempoolService(walletTransaction.getWallet(), transaction.getTxId(), new HashSet<>(walletTransaction.getSelectedUtxos().values()));
transactionMempoolService.setDelay(Duration.seconds(2));
transactionMempoolService.setPeriod(Duration.seconds(5));
transactionMempoolService.setRestartOnFailure(false);
transactionMempoolService.setOnSucceeded(mempoolWorkerStateEvent -> {
Set<String> scriptHashes = transactionMempoolService.getValue();
if(!scriptHashes.isEmpty()) {
transactionMempoolService.cancel();
addChildWallet(payNym, externalPaymentCode);
retrievePayNymProgress.setVisible(false);
followingList.refresh();
BlockTransaction blockTransaction = walletTransaction.getWallet().getTransactions().get(transaction.getTxId());
if(blockTransaction != null && blockTransaction.getLabel() == null) {
blockTransaction.setLabel("Link " + payNym.nymName());
TransactionEntry transactionEntry = new TransactionEntry(walletTransaction.getWallet(), blockTransaction, Collections.emptyMap(), Collections.emptyMap());
EventManager.get().post(new WalletEntryLabelsChangedEvent(walletTransaction.getWallet(), List.of(transactionEntry)));
}
}
if(transactionMempoolService.getIterationCount() > 5 && transactionMempoolService.isRunning()) {
transactionMempoolService.cancel();
retrievePayNymProgress.setVisible(false);
followingList.refresh();
log.error("Timeout searching for broadcasted notification transaction");
AppServices.showErrorDialog("Timeout searching for broadcasted transaction", "The transaction was broadcast but the server did not register it in the mempool. It is safe to try linking again.");
}
});
transactionMempoolService.setOnFailed(mempoolWorkerStateEvent -> {
transactionMempoolService.cancel();
log.error("Error searching for broadcasted notification transaction", mempoolWorkerStateEvent.getSource().getException());
retrievePayNymProgress.setVisible(false);
followingList.refresh();
AppServices.showErrorDialog("Timeout searching for broadcasted transaction", "The transaction was broadcast but the server did not register it in the mempool. It is safe to try linking again.");
});
transactionMempoolService.start();
});
broadcastTransactionService.setOnFailed(failedEvent -> {
log.error("Error broadcasting notification transaction", failedEvent.getSource().getException());
retrievePayNymProgress.setVisible(false);
followingList.refresh();
AppServices.showErrorDialog("Error broadcasting notification transaction", failedEvent.getSource().getException().getMessage());
});
retrievePayNymProgress.setVisible(true);
notificationTransactions.put(transaction.getTxId(), payNym);
broadcastTransactionService.start();
} catch(Exception e) {
log.error("Error creating notification transaction", e);
retrievePayNymProgress.setVisible(false);
followingList.refresh();
AppServices.showErrorDialog("Error creating notification transaction", e.getMessage());
}
}
private WalletTransaction getWalletTransaction(Wallet wallet, PayNym payNym, byte[] blindedPaymentCode, Collection<BlockTransactionHashIndex> utxos) throws InsufficientFundsException {
com.sparrowwallet.drongo.bip47.PaymentCode externalPaymentCode = com.sparrowwallet.drongo.bip47.PaymentCode.fromString(payNym.paymentCode().toString());
Payment payment = new Payment(externalPaymentCode.getNotificationAddress(), "Link " + payNym.nymName(), MINIMUM_P2PKH_OUTPUT_SATS, false);
List<Payment> payments = List.of(payment);
List<byte[]> opReturns = List.of(blindedPaymentCode);
Double feeRate = AppServices.getDefaultFeeRate();
Double minimumFeeRate = AppServices.getMinimumFeeRate();
boolean groupByAddress = Config.get().isGroupByAddress();
boolean includeMempoolOutputs = Config.get().isIncludeMempoolOutputs();
long noInputsFee = getMasterWallet().getNoInputsFee(payments, feeRate);
List<UtxoSelector> utxoSelectors = List.of(utxos == null ? new KnapsackUtxoSelector(noInputsFee) : new PresetUtxoSelector(utxos, true));
List<UtxoFilter> utxoFilters = List.of(new FrozenUtxoFilter(), new CoinbaseUtxoFilter(wallet));
return wallet.createWalletTransaction(utxoSelectors, utxoFilters, payments, opReturns, Collections.emptySet(), feeRate, minimumFeeRate, null, AppServices.getCurrentBlockHeight(), groupByAddress, includeMempoolOutputs, false);
}
private Wallet getMasterWallet() {
Wallet wallet = AppServices.get().getWallet(walletId);
return wallet.isMasterWallet() ? wallet : wallet.getMasterWallet();
}
public boolean isSelectLinkedOnly() {
return selectLinkedOnly;
}
public PayNym getPayNym() {
return payNymProperty.get();
}
@ -348,6 +609,22 @@ public class PayNymController extends SorobanController {
return payNymProperty;
}
@Subscribe
public void walletHistoryChanged(WalletHistoryChangedEvent event) {
List<Entry> changedLabelEntries = new ArrayList<>();
for(Map.Entry<Sha256Hash, PayNym> notificationTx : notificationTransactions.entrySet()) {
BlockTransaction blockTransaction = event.getWallet().getTransactions().get(notificationTx.getKey());
if(blockTransaction != null && blockTransaction.getLabel() == null) {
blockTransaction.setLabel("Link " + notificationTx.getValue().nymName());
changedLabelEntries.add(new TransactionEntry(event.getWallet(), blockTransaction, Collections.emptyMap(), Collections.emptyMap()));
}
}
if(!changedLabelEntries.isEmpty()) {
Platform.runLater(() -> EventManager.get().post(new WalletEntryLabelsChangedEvent(event.getWallet(), changedLabelEntries)));
}
}
public static class NoSelectionModel<T> extends MultipleSelectionModel<T> {
@Override

17
src/main/java/com/sparrowwallet/sparrow/soroban/PayNymDialog.java

@ -1,13 +1,18 @@
package com.sparrowwallet.sparrow.soroban;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import javafx.fxml.FXMLLoader;
import javafx.scene.control.*;
import java.io.IOException;
public class PayNymDialog extends Dialog<PayNym> {
public PayNymDialog(String walletId, boolean selectPayNym) {
public PayNymDialog(String walletId) {
this(walletId, false, false);
}
public PayNymDialog(String walletId, boolean selectPayNym, boolean selectLinkedOnly) {
final DialogPane dialogPane = getDialogPane();
AppServices.setStageIcon(dialogPane.getScene().getWindow());
AppServices.onEscapePressed(dialogPane.getScene(), this::close);
@ -16,7 +21,9 @@ public class PayNymDialog extends Dialog<PayNym> {
FXMLLoader payNymLoader = new FXMLLoader(AppServices.class.getResource("soroban/paynym.fxml"));
dialogPane.setContent(payNymLoader.load());
PayNymController payNymController = payNymLoader.getController();
payNymController.initializeView(walletId);
payNymController.initializeView(walletId, selectLinkedOnly);
EventManager.get().register(payNymController);
dialogPane.setPrefWidth(730);
dialogPane.setPrefHeight(600);
@ -35,12 +42,16 @@ public class PayNymDialog extends Dialog<PayNym> {
selectButton.setDisable(true);
selectButton.setDefaultButton(true);
payNymController.payNymProperty().addListener((observable, oldValue, payNym) -> {
selectButton.setDisable(payNym == null);
selectButton.setDisable(payNym == null || (selectLinkedOnly && !payNymController.isLinked(payNym)));
});
} else {
dialogPane.getButtonTypes().add(doneButtonType);
}
setOnCloseRequest(event -> {
EventManager.get().unregister(payNymController);
});
setResultConverter(dialogButton -> dialogButton == selectButtonType ? payNymController.getPayNym() : null);
} catch(IOException e) {
throw new RuntimeException(e);

4
src/main/java/com/sparrowwallet/sparrow/soroban/PayNymService.java

@ -74,14 +74,14 @@ public class PayNymService {
.map(Optional::get);
}
public Observable<Map<String, Object>> addSamouraiPaymentCode(PaymentCode paymentCode, String authToken, String signature) {
public Observable<Map<String, Object>> addPaymentCode(PaymentCode paymentCode, String authToken, String signature, boolean segwit) {
Map<String, String> headers = new HashMap<>();
headers.put("content-type", "application/json");
headers.put("auth-token", authToken);
HashMap<String, Object> body = new HashMap<>();
body.put("nym", paymentCode.toString());
body.put("code", paymentCode.makeSamouraiPaymentCode());
body.put("code", segwit ? paymentCode.makeSamouraiPaymentCode() : paymentCode.toString());
body.put("signature", signature);
IHttpClient httpClient = httpClientService.getHttpClient(HttpUsage.COORDINATOR_REST);

8
src/main/java/com/sparrowwallet/sparrow/soroban/Soroban.java

@ -73,7 +73,7 @@ public class Soroban {
String passphrase = keystore.getSeed().getPassphrase().asString();
byte[] seed = hdWalletFactory.computeSeedFromWords(words);
BIP47Wallet bip47Wallet = hdWalletFactory.getBIP47(Utils.bytesToHex(seed), passphrase, sorobanServer.getParams());
paymentCode = bip47Util.getPaymentCode(bip47Wallet);
paymentCode = bip47Util.getPaymentCode(bip47Wallet, wallet.isMasterWallet() ? wallet.getAccountIndex() : wallet.getMasterWallet().getAccountIndex());
} catch(Exception e) {
throw new IllegalStateException("Could not create payment code", e);
}
@ -93,7 +93,7 @@ public class Soroban {
byte[] seed = hdWalletFactory.computeSeedFromWords(words);
hdWallet = new HD_Wallet(purpose, new ArrayList<>(words), sorobanServer.getParams(), seed, passphrase);
bip47Wallet = hdWalletFactory.getBIP47(hdWallet.getSeedHex(), hdWallet.getPassphrase(), sorobanServer.getParams());
paymentCode = bip47Util.getPaymentCode(bip47Wallet);
paymentCode = bip47Util.getPaymentCode(bip47Wallet, wallet.isMasterWallet() ? wallet.getAccountIndex() : wallet.getMasterWallet().getAccountIndex());
} catch(Exception e) {
throw new IllegalStateException("Could not create Soroban HD wallet ", e);
}
@ -160,8 +160,8 @@ public class Soroban {
return payNymService.claimPayNym(authToken, signature);
}
public Observable<Map<String, Object>> addSamouraiPaymentCode(String authToken, String signature) {
return payNymService.addSamouraiPaymentCode(paymentCode, authToken, signature);
public Observable<Map<String, Object>> addPaymentCode(String authToken, String signature, boolean segwit) {
return payNymService.addPaymentCode(paymentCode, authToken, signature, segwit);
}
public Observable<Map<String, Object>> followPaymentCode(PaymentCode paymentCode, String authToken, String signature) {

6
src/main/java/com/sparrowwallet/sparrow/soroban/SorobanController.java

@ -23,13 +23,13 @@ public class SorobanController {
private static final Logger log = LoggerFactory.getLogger(SorobanController.class);
protected static final Pattern PAYNYM_REGEX = Pattern.compile("\\+[a-z]+[0-9][0-9a-fA-F][0-9a-fA-F]");
protected void claimPayNym(Soroban soroban, Map<String, Object> createMap) {
protected void claimPayNym(Soroban soroban, Map<String, Object> createMap, boolean segwit) {
if(createMap.get("claimed") == Boolean.FALSE) {
soroban.getAuthToken(createMap).subscribe(authToken -> {
String signature = soroban.getSignature(authToken);
soroban.claimPayNym(authToken, signature).subscribe(claimMap -> {
log.debug("Claimed payment code " + claimMap.get("claimed"));
soroban.addSamouraiPaymentCode(authToken, signature).subscribe(addMap -> {
soroban.addPaymentCode(authToken, signature, segwit).subscribe(addMap -> {
log.debug("Added payment code " + addMap);
});
}, error -> {
@ -37,7 +37,7 @@ public class SorobanController {
String newSignature = soroban.getSignature(newAuthToken);
soroban.claimPayNym(newAuthToken, newSignature).subscribe(claimMap -> {
log.debug("Claimed payment code " + claimMap.get("claimed"));
soroban.addSamouraiPaymentCode(newAuthToken, newSignature).subscribe(addMap -> {
soroban.addPaymentCode(newAuthToken, newSignature, segwit).subscribe(addMap -> {
log.debug("Added payment code " + addMap);
});
}, newError -> {

7
src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java

@ -579,7 +579,7 @@ public class HeadersController extends TransactionFormController implements Init
List<Payment> payments = new ArrayList<>();
Map<WalletNode, Long> changeMap = new LinkedHashMap<>();
Map<Script, WalletNode> changeOutputScripts = wallet.getWalletOutputScripts(KeyPurpose.CHANGE);
Map<Script, WalletNode> changeOutputScripts = wallet.getWalletOutputScripts(wallet.getChangeKeyPurpose());
for(TransactionOutput txOutput : headersForm.getTransaction().getOutputs()) {
WalletNode changeNode = changeOutputScripts.get(txOutput.getScript());
if(changeNode != null) {
@ -729,9 +729,10 @@ public class HeadersController extends TransactionFormController implements Init
private void initializeSignButton(Wallet signingWallet) {
Optional<Keystore> softwareKeystore = signingWallet.getKeystores().stream().filter(keystore -> keystore.getSource().equals(KeystoreSource.SW_SEED)).findAny();
Optional<Keystore> usbKeystore = signingWallet.getKeystores().stream().filter(keystore -> keystore.getSource().equals(KeystoreSource.HW_USB)).findAny();
if(softwareKeystore.isEmpty() && usbKeystore.isEmpty()) {
Optional<Keystore> bip47Keystore = signingWallet.getKeystores().stream().filter(keystore -> keystore.getSource().equals(KeystoreSource.SW_PAYMENT_CODE)).findAny();
if(softwareKeystore.isEmpty() && usbKeystore.isEmpty() && bip47Keystore.isEmpty()) {
signButton.setDisable(true);
} else if(softwareKeystore.isEmpty()) {
} else if(softwareKeystore.isEmpty() && bip47Keystore.isEmpty()) {
Glyph usbGlyph = new Glyph(FontAwesome5Brands.FONT_NAME, FontAwesome5Brands.Glyph.USB);
usbGlyph.setFontSize(20);
signButton.setGraphic(usbGlyph);

8
src/main/java/com/sparrowwallet/sparrow/transaction/OutputController.java

@ -105,12 +105,12 @@ public class OutputController extends TransactionFormController implements Initi
private void updateOutputLegendFromWallet(TransactionOutput txOutput, Wallet signingWallet) {
String baseText = getLegendText(txOutput);
if(signingWallet != null) {
if(outputForm.isWalletConsolidation()) {
outputFieldset.setText(baseText + " - Consolidation");
outputFieldset.setIcon(TransactionDiagram.getConsolidationGlyph());
} else if(outputForm.isWalletChange()) {
if(outputForm.isWalletChange()) {
outputFieldset.setText(baseText + " - Change");
outputFieldset.setIcon(TransactionDiagram.getChangeGlyph());
} else if(outputForm.isWalletConsolidation()) {
outputFieldset.setText(baseText + " - Consolidation");
outputFieldset.setIcon(TransactionDiagram.getConsolidationGlyph());
} else {
outputFieldset.setText(baseText + " - Payment");
outputFieldset.setIcon(TransactionDiagram.getPaymentGlyph());

2
src/main/java/com/sparrowwallet/sparrow/transaction/OutputForm.java

@ -36,7 +36,7 @@ public class OutputForm extends IndexedTransactionForm {
}
public boolean isWalletChange() {
return (getSigningWallet() != null && getSigningWallet().getWalletOutputScripts(KeyPurpose.CHANGE).containsKey(getTransactionOutput().getScript()));
return (getSigningWallet() != null && getSigningWallet().getWalletOutputScripts(getSigningWallet().getChangeKeyPurpose()).containsKey(getTransactionOutput().getScript()));
}
public boolean isWalletPayment() {

6
src/main/java/com/sparrowwallet/sparrow/transaction/TransactionController.java

@ -157,10 +157,10 @@ public class TransactionController implements Initializable {
}
if(form instanceof OutputForm) {
OutputForm outputForm = (OutputForm)form;
if(outputForm.isWalletConsolidation()) {
setGraphic(TransactionDiagram.getConsolidationGlyph());
} else if(outputForm.isWalletChange()) {
if(outputForm.isWalletChange()) {
setGraphic(TransactionDiagram.getChangeGlyph());
} else if(outputForm.isWalletConsolidation()) {
setGraphic(TransactionDiagram.getConsolidationGlyph());
} else {
setGraphic(TransactionDiagram.getPaymentGlyph());
}

1
src/main/java/com/sparrowwallet/sparrow/wallet/KeystoreController.java

@ -336,6 +336,7 @@ public class KeystoreController extends WalletFormController implements Initiali
keystore.setExtendedPublicKey(importedKeystore.getExtendedPublicKey());
keystore.setMasterPrivateExtendedKey(importedKeystore.getMasterPrivateExtendedKey());
keystore.setSeed(importedKeystore.getSeed());
keystore.setBip47ExtendedPrivateKey(importedKeystore.getBip47ExtendedPrivateKey());
updateType();
label.setText(keystore.getLabel());

80
src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java

@ -6,6 +6,10 @@ import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.InvalidAddressException;
import com.sparrowwallet.drongo.address.P2PKHAddress;
import com.sparrowwallet.drongo.bip47.InvalidPaymentCodeException;
import com.sparrowwallet.drongo.bip47.PaymentCode;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.uri.BitcoinURI;
@ -20,10 +24,7 @@ import com.sparrowwallet.sparrow.event.FiatCurrencySelectedEvent;
import com.sparrowwallet.sparrow.event.OpenWalletsEvent;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.ExchangeSource;
import com.sparrowwallet.sparrow.soroban.PayNym;
import com.sparrowwallet.sparrow.soroban.PayNymAddress;
import com.sparrowwallet.sparrow.soroban.PayNymDialog;
import com.sparrowwallet.sparrow.soroban.SorobanServices;
import com.sparrowwallet.sparrow.soroban.*;
import javafx.application.Platform;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
@ -119,6 +120,8 @@ public class PaymentController extends WalletFormController implements Initializ
emptyAmountProperty.set(true);
}
updateMixOnlyStatus();
sendController.updateTransaction();
}
};
@ -159,7 +162,8 @@ public class PaymentController extends WalletFormController implements Initializ
openWallets.prefWidthProperty().bind(address.widthProperty());
openWallets.valueProperty().addListener((observable, oldValue, newValue) -> {
if(newValue == payNymWallet) {
PayNymDialog payNymDialog = new PayNymDialog(sendController.getWalletForm().getWalletId(), true);
boolean selectLinkedOnly = sendController.getPaymentTabs().getTabs().size() > 1 || !SorobanServices.canWalletMix(sendController.getWalletForm().getWallet());
PayNymDialog payNymDialog = new PayNymDialog(sendController.getWalletForm().getWalletId(), true, selectLinkedOnly);
Optional<PayNym> optPayNym = payNymDialog.showAndWait();
if(optPayNym.isPresent()) {
PayNym payNym = optPayNym.get();
@ -175,11 +179,8 @@ public class PaymentController extends WalletFormController implements Initializ
}
});
payNymProperty.addListener((observable, oldValue, newValue) -> {
addPaymentButton.setDisable(newValue != null);
if(newValue != null) {
sendController.setPayNymPayment();
}
payNymProperty.addListener((observable, oldValue, payNym) -> {
updateMixOnlyStatus(payNym);
revalidateAmount();
});
@ -249,14 +250,32 @@ public class PaymentController extends WalletFormController implements Initializ
addValidation(validationSupport);
}
public void updateMixOnlyStatus() {
updateMixOnlyStatus(payNymProperty.get());
}
public void updateMixOnlyStatus(PayNym payNym) {
boolean mixOnly = false;
try {
mixOnly = payNym != null && getRecipientAddress() instanceof PayNymAddress;
} catch(InvalidAddressException e) {
log.error("Error creating payment code from PayNym", e);
}
addPaymentButton.setDisable(mixOnly);
if(mixOnly) {
sendController.setPayNymMixOnlyPayment();
}
}
private void updateOpenWallets() {
updateOpenWallets(AppServices.get().getOpenWallets().keySet());
}
private void updateOpenWallets(Collection<Wallet> wallets) {
List<Wallet> openWalletList = wallets.stream().filter(wallet -> wallet.isValid() && !wallet.isWhirlpoolChildWallet()).collect(Collectors.toList());
List<Wallet> openWalletList = wallets.stream().filter(wallet -> wallet.isValid() && !wallet.isWhirlpoolChildWallet() && !wallet.isBip47()).collect(Collectors.toList());
if(sendController.getPaymentTabs().getTabs().size() <= 1 && SorobanServices.canWalletMix(sendController.getWalletForm().getWallet())) {
if(sendController.getWalletForm().getWallet().hasPaymentCode()) {
openWalletList.add(payNymWallet);
}
@ -296,7 +315,30 @@ public class PaymentController extends WalletFormController implements Initializ
}
private Address getRecipientAddress() throws InvalidAddressException {
return payNymProperty.get() == null ? Address.fromString(address.getText()) : new PayNymAddress(payNymProperty.get());
if(payNymProperty.get() == null) {
return Address.fromString(address.getText());
}
try {
Wallet recipientBip47Wallet = getWalletForPayNym(payNymProperty.get());
if(recipientBip47Wallet != null) {
WalletNode sendNode = recipientBip47Wallet.getFreshNode(KeyPurpose.SEND);
ECKey pubKey = recipientBip47Wallet.getPubKey(sendNode);
Address address = recipientBip47Wallet.getScriptType().getAddress(pubKey);
if(sendController.getPaymentTabs().getTabs().size() > 1 || (getRecipientValueSats() != null && getRecipientValueSats() > getRecipientDustThreshold(address))) {
return address;
}
}
} catch(InvalidPaymentCodeException e) {
log.error("Error creating payment code from PayNym", e);
}
return new PayNymAddress(payNymProperty.get());
}
private Wallet getWalletForPayNym(PayNym payNym) throws InvalidPaymentCodeException {
Wallet masterWallet = sendController.getWalletForm().getMasterWallet();
return masterWallet.getChildWallet(new PaymentCode(payNym.paymentCode().toString()), payNym.segwit() ? ScriptType.P2WPKH : ScriptType.P2PKH);
}
private Long getRecipientValueSats() {
@ -321,10 +363,6 @@ public class PaymentController extends WalletFormController implements Initializ
}
private long getRecipientDustThreshold() {
if(payNymProperty.get() != null) {
return 0;
}
Address address;
try {
address = getRecipientAddress();
@ -332,6 +370,14 @@ public class PaymentController extends WalletFormController implements Initializ
address = new P2PKHAddress(new byte[20]);
}
if(address instanceof PayNymAddress && SorobanServices.canWalletMix(sendController.getWalletForm().getWallet())) {
return 0;
}
return getRecipientDustThreshold(address);
}
private long getRecipientDustThreshold(Address address) {
TransactionOutput txOutput = new TransactionOutput(new Transaction(), 1L, address.getOutputScript());
return address.getScriptType().getDustThreshold(txOutput, Transaction.DUST_RELAY_TX_FEE);
}

17
src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java

@ -248,7 +248,10 @@ public class SendController extends WalletFormController implements Initializabl
if(!paymentTabs.getStyleClass().contains("multiple-tabs")) {
paymentTabs.getStyleClass().add("multiple-tabs");
}
paymentTabs.getTabs().forEach(tab -> tab.setClosable(true));
paymentTabs.getTabs().forEach(tab -> {
tab.setClosable(true);
((PaymentController)tab.getUserData()).updateMixOnlyStatus();
});
} else {
paymentTabs.getStyleClass().remove("multiple-tabs");
Tab remainingTab = paymentTabs.getTabs().get(0);
@ -392,7 +395,7 @@ public class SendController extends WalletFormController implements Initializabl
transactionDiagram.update(walletTransaction);
updatePrivacyAnalysis(walletTransaction);
createButton.setDisable(walletTransaction == null || isInsufficientFeeRate() || isPayNymPayment(walletTransaction.getPayments()));
createButton.setDisable(walletTransaction == null || isInsufficientFeeRate() || isPayNymMixOnlyPayment(walletTransaction.getPayments()));
});
transactionDiagram.sceneProperty().addListener((observable, oldScene, newScene) -> {
@ -949,11 +952,11 @@ public class SendController extends WalletFormController implements Initializabl
}
}
private boolean isPayNymPayment(List<Payment> payments) {
private boolean isPayNymMixOnlyPayment(List<Payment> payments) {
return payments.size() == 1 && payments.get(0).getAddress() instanceof PayNymAddress;
}
public void setPayNymPayment() {
public void setPayNymMixOnlyPayment() {
optimizationToggleGroup.selectToggle(privacyToggle);
transactionDiagram.setOptimizationStrategy(OptimizationStrategy.PRIVACY);
efficiencyToggle.setDisable(true);
@ -967,8 +970,8 @@ public class SendController extends WalletFormController implements Initializabl
}
private void updateOptimizationButtons(List<Payment> payments) {
if(isPayNymPayment(payments)) {
setPayNymPayment();
if(isPayNymMixOnlyPayment(payments)) {
setPayNymMixOnlyPayment();
} else if(isMixPossible(payments)) {
setPreferredOptimizationStrategy();
efficiencyToggle.setDisable(false);
@ -1422,7 +1425,7 @@ public class SendController extends WalletFormController implements Initializabl
List<Payment> payments = walletTransaction.getPayments();
List<Payment> userPayments = payments.stream().filter(payment -> payment.getType() != Payment.Type.FAKE_MIX).collect(Collectors.toList());
OptimizationStrategy optimizationStrategy = getPreferredOptimizationStrategy();
boolean payNymPresent = isPayNymPayment(payments);
boolean payNymPresent = isPayNymMixOnlyPayment(payments);
boolean fakeMixPresent = payments.stream().anyMatch(payment -> payment.getType() == Payment.Type.FAKE_MIX);
boolean roundPaymentAmounts = userPayments.stream().anyMatch(payment -> payment.getAmount() % 100 == 0);
boolean mixedAddressTypes = userPayments.stream().anyMatch(payment -> payment.getAddress().getScriptType() != getWalletForm().getWallet().getAddress(getWalletForm().wallet.getFreshNode(KeyPurpose.RECEIVE)).getScriptType());

91
src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java

@ -24,6 +24,7 @@ import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.IOException;
import java.util.*;
import java.util.stream.Collectors;
import static com.sparrowwallet.drongo.wallet.WalletNode.nodeRangesToString;
@ -126,37 +127,62 @@ public class WalletForm {
log.debug(nodes == null ? wallet.getFullName() + " refreshing full wallet history" : wallet.getFullName() + " requesting node wallet history for " + nodeRangesToString(nodes));
}
ElectrumServer.TransactionHistoryService historyService = new ElectrumServer.TransactionHistoryService(wallet, getWalletTransactionNodes(nodes));
historyService.setOnSucceeded(workerStateEvent -> {
if(historyService.getValue()) {
EventManager.get().post(new WalletHistoryFinishedEvent(wallet));
updateWallet(blockHeight, previousWallet);
}
});
historyService.setOnFailed(workerStateEvent -> {
if(workerStateEvent.getSource().getException() instanceof AllHistoryChangedException) {
try {
storage.backupWallet();
} catch(IOException e) {
log.error("Error backing up wallet", e);
Set<WalletNode> walletTransactionNodes = getWalletTransactionNodes(nodes);
if(walletTransactionNodes == null || !walletTransactionNodes.isEmpty()) {
ElectrumServer.TransactionHistoryService historyService = new ElectrumServer.TransactionHistoryService(wallet, walletTransactionNodes);
historyService.setOnSucceeded(workerStateEvent -> {
if(historyService.getValue()) {
EventManager.get().post(new WalletHistoryFinishedEvent(wallet));
updateWallet(blockHeight, previousWallet);
}
});
historyService.setOnFailed(workerStateEvent -> {
if(workerStateEvent.getSource().getException() instanceof AllHistoryChangedException) {
try {
storage.backupWallet();
} catch(IOException e) {
log.error("Error backing up wallet", e);
}
wallet.clearHistory();
AppServices.clearTransactionHistoryCache(wallet);
EventManager.get().post(new WalletHistoryClearedEvent(wallet, previousWallet, getWalletId()));
} else {
if(AppServices.isConnected()) {
log.error("Error retrieving wallet history", workerStateEvent.getSource().getException());
wallet.clearHistory();
AppServices.clearTransactionHistoryCache(wallet);
EventManager.get().post(new WalletHistoryClearedEvent(wallet, previousWallet, getWalletId()));
} else {
log.debug("Disconnected while retrieving wallet history", workerStateEvent.getSource().getException());
if(AppServices.isConnected()) {
log.error("Error retrieving wallet history", workerStateEvent.getSource().getException());
} else {
log.debug("Disconnected while retrieving wallet history", workerStateEvent.getSource().getException());
}
EventManager.get().post(new WalletHistoryFailedEvent(wallet, workerStateEvent.getSource().getException()));
}
});
EventManager.get().post(new WalletHistoryFailedEvent(wallet, workerStateEvent.getSource().getException()));
}
});
EventManager.get().post(new WalletHistoryStartedEvent(wallet, nodes));
historyService.start();
}
EventManager.get().post(new WalletHistoryStartedEvent(wallet, nodes));
historyService.start();
if(wallet.isMasterWallet() && wallet.hasPaymentCode() && refreshNotificationNode(nodes)) {
ElectrumServer.PaymentCodesService paymentCodesService = new ElectrumServer.PaymentCodesService(getWalletId(), wallet);
paymentCodesService.setOnSucceeded(successEvent -> {
List<Wallet> addedWallets = paymentCodesService.getValue();
for(Wallet addedWallet : addedWallets) {
if(!storage.isPersisted(addedWallet)) {
try {
storage.saveWallet(addedWallet);
} catch(Exception e) {
log.error("Error saving wallet", e);
AppServices.showErrorDialog("Error saving wallet " + addedWallet.getName(), e.getMessage());
}
}
EventManager.get().post(new ChildWalletAddedEvent(storage, wallet, addedWallet));
}
});
paymentCodesService.setOnFailed(failedEvent -> {
log.error("Could not determine payment codes for wallet " + wallet.getFullName(), failedEvent.getSource().getException());
});
paymentCodesService.start();
}
}
}
@ -226,7 +252,20 @@ public class WalletForm {
}
}
return allNodes.isEmpty() ? walletNodes : allNodes;
Set<WalletNode> nodes = allNodes.isEmpty() ? walletNodes : allNodes;
if(nodes.stream().anyMatch(node -> node.getDerivation().size() == 1)) {
return nodes.stream().filter(node -> node.getDerivation().size() > 1).collect(Collectors.toSet());
}
return nodes;
}
public boolean refreshNotificationNode(Set<WalletNode> walletNodes) {
if(walletNodes == null) {
return true;
}
return walletNodes.stream().anyMatch(node -> node.getDerivation().size() == 1);
}
public WalletTransaction getCreatedWalletTransaction() {

5
src/main/java/com/sparrowwallet/sparrow/wallet/WalletTransactionsEntry.java

@ -100,8 +100,9 @@ public class WalletTransactionsEntry extends Entry {
private static Collection<WalletTransaction> getWalletTransactions(Wallet wallet) {
Map<BlockTransaction, WalletTransaction> walletTransactionMap = new HashMap<>(wallet.getTransactions().size());
getWalletTransactions(wallet, walletTransactionMap, wallet.getNode(KeyPurpose.RECEIVE));
getWalletTransactions(wallet, walletTransactionMap, wallet.getNode(KeyPurpose.CHANGE));
for(KeyPurpose keyPurpose : wallet.getWalletKeyPurposes()) {
getWalletTransactions(wallet, walletTransactionMap, wallet.getNode(keyPurpose));
}
List<WalletTransaction> walletTransactions = new ArrayList<>(walletTransactionMap.values());
Collections.sort(walletTransactions);

3
src/main/java/com/sparrowwallet/sparrow/whirlpool/dataSource/SparrowPostmixHandler.java

@ -9,6 +9,7 @@ import com.samourai.whirlpool.client.wallet.WhirlpoolWalletService;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -38,7 +39,7 @@ public class SparrowPostmixHandler implements IPostmixHandler {
int index = Math.max(getIndexHandler().getAndIncrementUnconfirmed(), startIndex);
// address
Address address = wallet.getAddress(keyPurpose, index);
Address address = wallet.getAddress(new WalletNode(keyPurpose, index));
String path = XPubUtil.getInstance().getPath(index, keyPurpose.getPathIndex().num());
log.info("Mixing to external xPub -> receiveAddress=" + address + ", path=" + path);

8
src/main/resources/com/sparrowwallet/sparrow/soroban/paynym.css

@ -55,6 +55,14 @@
-fx-padding: 10 0 10 0;
}
#followingList .paynym-cell.unlinked .label {
-fx-text-fill: #a0a1a7;
}
#followingList .paynym-cell .button .label.glyph-font {
-fx-text-fill: -fx-text-base-color;
}
#followersList .paynym-cell .label {
-fx-text-fill: #a0a1a7;
}

1
src/main/resources/com/sparrowwallet/sparrow/sql/V6__PaymentCode.sql

@ -0,0 +1 @@
alter table keystore add column externalPaymentCode varchar(255) after extendedPublicKey;
Loading…
Cancel
Save