Browse Source

add pay to paynym via payjoin

terminal
Craig Raw 3 years ago
parent
commit
26fb2b97fb
  1. 4
      build.gradle
  2. 6
      src/main/java/com/sparrowwallet/sparrow/AppController.java
  3. 19
      src/main/java/com/sparrowwallet/sparrow/control/PayNymCell.java
  4. 34
      src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java
  5. 21
      src/main/java/com/sparrowwallet/sparrow/event/FollowPayNymEvent.java
  6. 10
      src/main/java/com/sparrowwallet/sparrow/io/Config.java
  7. 39
      src/main/java/com/sparrowwallet/sparrow/soroban/CounterpartyController.java
  8. 27
      src/main/java/com/sparrowwallet/sparrow/soroban/InitiatorController.java
  9. 2
      src/main/java/com/sparrowwallet/sparrow/soroban/InitiatorDialog.java
  10. 20
      src/main/java/com/sparrowwallet/sparrow/soroban/PayNymAddress.java
  11. 152
      src/main/java/com/sparrowwallet/sparrow/soroban/PayNymController.java
  12. 8
      src/main/java/com/sparrowwallet/sparrow/soroban/PayNymDialog.java
  13. 26
      src/main/java/com/sparrowwallet/sparrow/soroban/SorobanController.java
  14. 59
      src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java
  15. 49
      src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java
  16. 8
      src/main/resources/com/sparrowwallet/sparrow/soroban/counterparty.fxml
  17. 3
      src/main/resources/com/sparrowwallet/sparrow/soroban/initiator.fxml
  18. 4
      src/main/resources/com/sparrowwallet/sparrow/soroban/paynym.css
  19. 12
      src/main/resources/com/sparrowwallet/sparrow/soroban/paynym.fxml
  20. 2
      src/main/resources/com/sparrowwallet/sparrow/wallet/send.css

4
build.gradle

@ -91,7 +91,7 @@ dependencies {
implementation('org.slf4j:jul-to-slf4j:1.7.30') {
exclude group: 'org.slf4j'
}
implementation('com.sparrowwallet.nightjar:nightjar:0.2.23')
implementation('com.sparrowwallet.nightjar:nightjar:0.2.24')
implementation('io.reactivex.rxjava2:rxjava:2.2.15')
implementation('io.reactivex.rxjava2:rxjavafx:2.2.2')
implementation('org.apache.commons:commons-lang3:3.7')
@ -458,7 +458,7 @@ extraJavaModuleInfo {
module('cbor-0.9.jar', 'co.nstant.in.cbor', '0.9') {
exports('co.nstant.in.cbor')
}
module('nightjar-0.2.23.jar', 'com.sparrowwallet.nightjar', '0.2.23') {
module('nightjar-0.2.24.jar', 'com.sparrowwallet.nightjar', '0.2.24') {
requires('com.google.common')
requires('net.sourceforge.streamsupport')
requires('org.slf4j')

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

@ -1251,7 +1251,7 @@ public class AppController implements Initializable {
try {
soroban.setHDWallet(copy);
CounterpartyDialog counterpartyDialog = new CounterpartyDialog(selectedWalletForm.getWalletId(), selectedWalletForm.getWallet());
if(Network.get() == Network.TESTNET) {
if(Config.get().isSameAppMixing()) {
counterpartyDialog.initModality(Modality.NONE);
}
counterpartyDialog.showAndWait();
@ -1278,14 +1278,14 @@ public class AppController implements Initializable {
} else {
soroban.setHDWallet(wallet);
CounterpartyDialog counterpartyDialog = new CounterpartyDialog(selectedWalletForm.getWalletId(), selectedWalletForm.getWallet());
if(Network.get() == Network.TESTNET) {
if(Config.get().isSameAppMixing()) {
counterpartyDialog.initModality(Modality.NONE);
}
counterpartyDialog.showAndWait();
}
} else {
CounterpartyDialog counterpartyDialog = new CounterpartyDialog(selectedWalletForm.getWalletId(), selectedWalletForm.getWallet());
if(Network.get() == Network.TESTNET) {
if(Config.get().isSameAppMixing()) {
counterpartyDialog.initModality(Modality.NONE);
}
counterpartyDialog.showAndWait();

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

@ -1,27 +1,23 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.FollowPayNymEvent;
import com.sparrowwallet.sparrow.soroban.PayNym;
import com.sparrowwallet.sparrow.soroban.PayNymController;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.ContentDisplay;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.*;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
public class PayNymCell extends ListCell<PayNym> {
private final String walletId;
private final PayNymController payNymController;
public PayNymCell(String walletId) {
public PayNymCell(PayNymController payNymController) {
super();
setAlignment(Pos.CENTER_LEFT);
setContentDisplay(ContentDisplay.LEFT);
getStyleClass().add("paynym-cell");
setPrefHeight(50);
this.walletId = walletId;
this.payNymController = payNymController;
}
@Override
@ -50,11 +46,12 @@ public class PayNymCell extends ListCell<PayNym> {
if(getListView().getUserData() == Boolean.TRUE) {
HBox hBox = new HBox();
hBox.setAlignment(Pos.CENTER);
Button button = new Button("Follow");
Button button = new Button("Add Contact");
hBox.getChildren().add(button);
pane.setRight(hBox);
button.setOnAction(event -> {
EventManager.get().post(new FollowPayNymEvent(walletId, payNym.paymentCode()));
button.setDisable(true);
payNymController.followPayNym(payNym.paymentCode());
});
}

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

@ -113,16 +113,18 @@ public class TransactionDiagram extends GridPane {
}
private List<Map<BlockTransactionHashIndex, WalletNode>> getDisplayedUtxoSets() {
boolean addUserSet = getOptimizationStrategy() == OptimizationStrategy.PRIVACY && SorobanServices.canWalletMix(walletTx.getWallet())
&& walletTx.getPayments().size() == 1
&& (walletTx.getPayments().get(0).getAddress().getScriptType() == walletTx.getWallet().getAddress(walletTx.getWallet().getFreshNode(KeyPurpose.RECEIVE)).getScriptType());
List<Map<BlockTransactionHashIndex, WalletNode>> displayedUtxoSets = new ArrayList<>();
for(Map<BlockTransactionHashIndex, WalletNode> selectedUtxoSet : walletTx.getSelectedUtxoSets()) {
displayedUtxoSets.add(getDisplayedUtxos(selectedUtxoSet, walletTx.getSelectedUtxoSets().size()));
displayedUtxoSets.add(getDisplayedUtxos(selectedUtxoSet, addUserSet ? 2 : walletTx.getSelectedUtxoSets().size()));
}
if(getOptimizationStrategy() == OptimizationStrategy.PRIVACY && displayedUtxoSets.size() == 1 && SorobanServices.canWalletMix(walletTx.getWallet())
&& walletTx.getPayments().size() == 1
&& (walletTx.getPayments().get(0).getAddress().getScriptType() == walletTx.getWallet().getAddress(walletTx.getWallet().getFreshNode(KeyPurpose.RECEIVE)).getScriptType())) {
if(addUserSet && displayedUtxoSets.size() == 1) {
Map<BlockTransactionHashIndex, WalletNode> addUserUtxoSet = new HashMap<>();
addUserUtxoSet.put(new AddUserBlockTransactionHashIndex(), null);
addUserUtxoSet.put(new AddUserBlockTransactionHashIndex(!walletTx.isTwoPersonCoinjoin()), null);
displayedUtxoSets.add(addUserUtxoSet);
}
@ -324,8 +326,14 @@ public class TransactionDiagram extends GridPane {
joiner.add(getInputDescription(additionalInput));
}
tooltip.setText(joiner.toString());
} else if(input instanceof InvisibleBlockTransactionHashIndex || input instanceof AddUserBlockTransactionHashIndex) {
} else if(input instanceof InvisibleBlockTransactionHashIndex) {
tooltip.setText("");
} else if(input instanceof AddUserBlockTransactionHashIndex) {
tooltip.setText("");
label.setGraphic(walletTx.isTwoPersonCoinjoin() ? getQuestionGlyph() : getWarningGlyph());
label.setOnMouseClicked(event -> {
EventManager.get().post(new SorobanInitiatedEvent(walletTx.getWallet()));
});
} else {
if(walletTx.getInputTransactions() != null && walletTx.getInputTransactions().get(input.getHash()) != null) {
BlockTransaction blockTransaction = walletTx.getInputTransactions().get(input.getHash());
@ -773,6 +781,13 @@ public class TransactionDiagram extends GridPane {
return feeWarningGlyph;
}
private Glyph getQuestionGlyph() {
Glyph feeWarningGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.QUESTION_CIRCLE);
feeWarningGlyph.getStyleClass().add("question-icon");
feeWarningGlyph.setFontSize(12);
return feeWarningGlyph;
}
private Glyph getLockGlyph() {
Glyph lockGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.LOCK);
lockGlyph.getStyleClass().add("lock-icon");
@ -891,13 +906,16 @@ public class TransactionDiagram extends GridPane {
}
private static class AddUserBlockTransactionHashIndex extends BlockTransactionHashIndex {
public AddUserBlockTransactionHashIndex() {
private final boolean required;
public AddUserBlockTransactionHashIndex(boolean required) {
super(Sha256Hash.ZERO_HASH, 0, new Date(), 0L, 0, 0);
this.required = required;
}
@Override
public String getLabel() {
return "Add Mix Partner?";
return "Add Mix Partner" + (required ? "" : "?");
}
}

21
src/main/java/com/sparrowwallet/sparrow/event/FollowPayNymEvent.java

@ -1,21 +0,0 @@
package com.sparrowwallet.sparrow.event;
import com.samourai.wallet.bip47.rpc.PaymentCode;
public class FollowPayNymEvent {
private final String walletId;
private final PaymentCode paymentCode;
public FollowPayNymEvent(String walletId, PaymentCode paymentCode) {
this.walletId = walletId;
this.paymentCode = paymentCode;
}
public String getWalletId() {
return walletId;
}
public PaymentCode getPaymentCode() {
return paymentCode;
}
}

10
src/main/java/com/sparrowwallet/sparrow/io/Config.java

@ -59,6 +59,7 @@ public class Config {
private boolean useProxy;
private String proxyServer;
private boolean usePayNym;
private boolean sameAppMixing;
private Double appWidth;
private Double appHeight;
@ -511,6 +512,15 @@ public class Config {
flush();
}
public boolean isSameAppMixing() {
return sameAppMixing;
}
public void setSameAppMixing(boolean sameAppMixing) {
this.sameAppMixing = sameAppMixing;
flush();
}
public Double getAppWidth() {
return appWidth;
}

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

@ -94,6 +94,9 @@ public class CounterpartyController extends SorobanController {
@FXML
private Label mixType;
@FXML
private Label mixFee;
@FXML
private ProgressTimer step3Timer;
@ -258,7 +261,17 @@ public class CounterpartyController extends SorobanController {
});
}
mixType.setText(cahootsType.getLabel());
if(cahootsType == CahootsType.STONEWALLX2) {
mixType.setText("Two person coinjoin (" + cahootsType.getLabel() + ")");
mixFee.setText("You pay half the miner fee");
} else if(cahootsType == CahootsType.STOWAWAY) {
mixType.setText("Payjoin (" + cahootsType.getLabel() + ")");
mixFee.setText("None");
} else {
mixType.setText(cahootsType.getLabel());
mixFee.setText("None");
}
mixDetails.setVisible(true);
meetingReceived.set(Boolean.TRUE);
}
@ -365,29 +378,7 @@ public class CounterpartyController extends SorobanController {
payNymAvatar.setPaymentCode(soroban.getPaymentCode());
payNym.setVisible(true);
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 -> {
log.debug("Added payment code " + addMap);
});
}, error -> {
soroban.getAuthToken(new HashMap<>()).subscribe(newAuthToken -> {
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 -> {
log.debug("Added payment code " + addMap);
});
});
}, newError -> {
log.error("Error claiming PayNym", newError);
});
});
});
}
claimPayNym(soroban, createMap);
}, error -> {
log.error("Error retrieving PayNym", error);
AppServices.showErrorDialog("Error retrieving PayNym", error.getMessage());

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

@ -54,7 +54,7 @@ import static com.sparrowwallet.sparrow.soroban.Soroban.TIMEOUT_MS;
public class InitiatorController extends SorobanController {
private static final Logger log = LoggerFactory.getLogger(InitiatorController.class);
private static final PayNym FIND_FOLLOWERS = new PayNym(null, null, "Retrieve PayNyms...", false, Collections.emptyList(), Collections.emptyList());
private static final PayNym FIND_FOLLOWERS = new PayNym(null, null, "Retrieve Contacts...", false, Collections.emptyList(), Collections.emptyList());
private String walletId;
private Wallet wallet;
@ -118,6 +118,8 @@ public class InitiatorController extends SorobanController {
private final ObjectProperty<Transaction> transactionProperty = new SimpleObjectProperty<>(null);
private CahootsType cahootsType = CahootsType.STONEWALLX2;
public void initializeView(String walletId, Wallet wallet, WalletTransaction walletTransaction) {
this.walletId = walletId;
this.wallet = wallet;
@ -160,9 +162,6 @@ public class InitiatorController extends SorobanController {
payNymLoading.maxHeightProperty().bind(counterparty.heightProperty());
payNymLoading.setVisible(false);
findPayNym.managedProperty().bind(findPayNym.visibleProperty());
findPayNym.setVisible(Config.get().isUsePayNym());
payNymAvatar.managedProperty().bind(payNymAvatar.visibleProperty());
payNymFollowers.prefWidthProperty().bind(counterparty.widthProperty());
payNymFollowers.valueProperty().addListener((observable, oldValue, payNym) -> {
@ -241,7 +240,17 @@ public class InitiatorController extends SorobanController {
}
});
if(Config.get().isUsePayNym()) {
Payment payment = walletTransaction.getPayments().get(0);
if(payment.getAddress() instanceof PayNymAddress payNymAddress) {
PayNym payNym = payNymAddress.getPayNym();
counterpartyPayNymName.set(payNym.nymName());
counterpartyPaymentCode.set(payNym.paymentCode());
payNymAvatar.setPaymentCode(payNym.paymentCode());
counterparty.setText(payNym.nymName());
counterparty.setEditable(false);
findPayNym.setVisible(false);
cahootsType = CahootsType.STOWAWAY;
} else if(Config.get().isUsePayNym()) {
setPayNymFollowers();
} else {
List<PayNym> defaultList = new ArrayList<>();
@ -265,7 +274,7 @@ public class InitiatorController extends SorobanController {
}, error -> {
if(error.getMessage().endsWith("404")) {
Config.get().setUsePayNym(false);
AppServices.showErrorDialog("Could not retrieve PayNym", "This wallet does not have an associated PayNym or any followers. You can retrieve the PayNym using the Tools menu → Find Mix Partner.");
AppServices.showErrorDialog("Could not retrieve PayNym", "This wallet does not have an associated PayNym or any followers yet. You can retrieve the PayNym using the Find PayNym button.");
} else {
log.warn("Could not retrieve followers: ", error);
}
@ -330,7 +339,7 @@ public class InitiatorController extends SorobanController {
getPaymentCodeCounterparty(soroban).subscribe(paymentCodeCounterparty -> {
try {
SorobanCahootsService sorobanMeetingService = soroban.getSorobanCahootsService(initiatorCahootsWallet);
sorobanMeetingService.sendMeetingRequest(paymentCodeCounterparty, CahootsType.STONEWALLX2)
sorobanMeetingService.sendMeetingRequest(paymentCodeCounterparty, cahootsType)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.subscribe(meetingRequest -> {
@ -387,7 +396,9 @@ public class InitiatorController extends SorobanController {
}
SorobanCahootsService sorobanCahootsService = soroban.getSorobanCahootsService(initiatorCahootsWallet);
CahootsContext cahootsContext = CahootsContext.newInitiatorStonewallx2(payment.getAmount(), payment.getAddress().toString());
CahootsContext cahootsContext = cahootsType == CahootsType.STONEWALLX2 ?
CahootsContext.newInitiatorStonewallx2(payment.getAmount(), payment.getAddress().toString()) :
CahootsContext.newInitiatorStowaway(payment.getAmount());
sorobanCahootsService.getSorobanService().getOnInteraction()
.observeOn(JavaFxScheduler.platform())

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

@ -48,7 +48,7 @@ public class InitiatorDialog extends Dialog<Transaction> {
Button nextButton = (Button)dialogPane.lookupButton(nextButtonType);
Button cancelButton = (Button)dialogPane.lookupButton(cancelButtonType);
Button broadcastButton = (Button)dialogPane.lookupButton(broadcastButtonType);
nextButton.setDisable(true);
nextButton.setDisable(initiatorController.counterpartyPaymentCodeProperty().get() == null);
broadcastButton.setDisable(true);
nextButton.managedProperty().bind(nextButton.visibleProperty());

20
src/main/java/com/sparrowwallet/sparrow/soroban/PayNymAddress.java

@ -0,0 +1,20 @@
package com.sparrowwallet.sparrow.soroban;
import com.sparrowwallet.drongo.address.P2WPKHAddress;
public final class PayNymAddress extends P2WPKHAddress {
private final PayNym payNym;
public PayNymAddress(PayNym payNym) {
super(new byte[20]);
this.payNym = payNym;
}
public PayNym getPayNym() {
return payNym;
}
public String toString() {
return payNym.nymName();
}
}

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

@ -1,10 +1,20 @@
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.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.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.FollowPayNymEvent;
import com.sparrowwallet.sparrow.event.StorageEvent;
import com.sparrowwallet.sparrow.event.TimedEvent;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage;
import javafx.application.Platform;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
@ -23,6 +33,8 @@ import java.util.List;
import java.util.Optional;
import java.util.function.UnaryOperator;
import static com.sparrowwallet.sparrow.AppServices.showErrorDialog;
public class PayNymController extends SorobanController {
private static final Logger log = LoggerFactory.getLogger(PayNymController.class);
@ -32,6 +44,9 @@ public class PayNymController extends SorobanController {
@FXML
private CopyableTextField payNymName;
@FXML
private Button payNymRetrieve;
@FXML
private PaymentCodeTextField paymentCode;
@ -57,6 +72,15 @@ public class PayNymController extends SorobanController {
public void initializeView(String walletId) {
this.walletId = walletId;
payNymName.managedProperty().bind(payNymName.visibleProperty());
payNymRetrieve.managedProperty().bind(payNymRetrieve.visibleProperty());
payNymRetrieve.visibleProperty().bind(payNymName.visibleProperty().not());
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
if(soroban.getPaymentCode() != null) {
paymentCode.setPaymentCode(soroban.getPaymentCode());
}
findNymProperty.addListener((observable, oldValue, nymIdentifier) -> {
if(nymIdentifier != null) {
searchFollowing(nymIdentifier);
@ -95,7 +119,7 @@ public class PayNymController extends SorobanController {
findPayNym.setVisible(false);
followingList.setCellFactory(param -> {
return new PayNymCell(walletId);
return new PayNymCell(this);
});
followingList.getSelectionModel().selectedItemProperty().addListener((observable, oldValue, payNym) -> {
@ -103,13 +127,17 @@ public class PayNymController extends SorobanController {
});
followersList.setCellFactory(param -> {
return new PayNymCell(walletId);
return new PayNymCell(null);
});
followersList.setSelectionModel(new NoSelectionModel<>());
followersList.setFocusTraversable(false);
refresh();
if(Config.get().isUsePayNym() && soroban.getPaymentCode() != null) {
refresh();
} else {
payNymName.setVisible(false);
}
}
private void refresh() {
@ -124,8 +152,14 @@ public class PayNymController extends SorobanController {
paymentCode.setPaymentCode(payNym.paymentCode());
payNymAvatar.setPaymentCode(payNym.paymentCode());
followingList.setUserData(null);
followingList.setPlaceholder(new Label("No contacts"));
followingList.setItems(FXCollections.observableList(payNym.following()));
followersList.setPlaceholder(new Label("No followers"));
followersList.setItems(FXCollections.observableList(payNym.followers()));
}, error -> {
if(error.getMessage().endsWith("404")) {
payNymName.setVisible(false);
}
});
}
@ -180,6 +214,98 @@ public class PayNymController extends SorobanController {
}
}
public void retrievePayNym(ActionEvent event) {
Config.get().setUsePayNym(true);
makeAuthenticatedCall(null);
}
public void followPayNym(PaymentCode paymentCode) {
makeAuthenticatedCall(paymentCode);
}
private void makeAuthenticatedCall(PaymentCode contact) {
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
if(soroban.getHdWallet() == null) {
Wallet wallet = AppServices.get().getWallet(walletId);
if(wallet.isEncrypted()) {
Wallet copy = wallet.copy();
WalletPasswordDialog dlg = new WalletPasswordDialog(copy.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
Storage storage = AppServices.get().getOpenWallets().get(wallet);
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get(), true);
keyDerivationService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Done"));
ECKey encryptionFullKey = keyDerivationService.getValue();
Key key = new Key(encryptionFullKey.getPrivKeyBytes(), storage.getKeyDeriver().getSalt(), EncryptionType.Deriver.ARGON2);
copy.decrypt(key);
try {
soroban.setHDWallet(copy);
makeAuthenticatedCall(soroban, contact);
} finally {
key.clear();
encryptionFullKey.clear();
password.get().clear();
}
});
keyDerivationService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Failed"));
if(keyDerivationService.getException() instanceof InvalidPasswordException) {
Optional<ButtonType> optResponse = showErrorDialog("Invalid Password", "The wallet password was invalid. Try again?", ButtonType.CANCEL, ButtonType.OK);
if(optResponse.isPresent() && optResponse.get().equals(ButtonType.OK)) {
Platform.runLater(() -> makeAuthenticatedCall(contact));
}
} else {
log.error("Error deriving wallet key", keyDerivationService.getException());
}
});
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.START, "Decrypting wallet..."));
keyDerivationService.start();
}
} else {
soroban.setHDWallet(wallet);
makeAuthenticatedCall(soroban, contact);
}
} else {
makeAuthenticatedCall(soroban, contact);
}
}
private void makeAuthenticatedCall(Soroban soroban, PaymentCode contact) {
if(contact != null) {
followPayNym(soroban, contact);
} else {
retrievePayNym(soroban);
}
}
private void retrievePayNym(Soroban soroban) {
soroban.createPayNym().subscribe(createMap -> {
payNymName.setText((String)createMap.get("nymName"));
payNymAvatar.setPaymentCode(soroban.getPaymentCode());
payNymName.setVisible(true);
claimPayNym(soroban, createMap);
refresh();
}, error -> {
log.error("Error retrieving PayNym", error);
AppServices.showErrorDialog("Error retrieving PayNym", error.getMessage());
});
}
private void followPayNym(Soroban soroban, PaymentCode contact) {
soroban.getAuthToken(new HashMap<>()).subscribe(authToken -> {
String signature = soroban.getSignature(authToken);
soroban.followPaymentCode(contact, authToken, signature).subscribe(followMap -> {
refresh();
}, error -> {
log.error("Could not follow payment code", error);
AppServices.showErrorDialog("Could not follow payment code", error.getMessage());
});
});
}
public PayNym getPayNym() {
return payNymProperty.get();
}
@ -188,22 +314,6 @@ public class PayNymController extends SorobanController {
return payNymProperty;
}
@Subscribe
public void followPayNym(FollowPayNymEvent event) {
if(event.getWalletId().equals(walletId)) {
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
soroban.getAuthToken(new HashMap<>()).subscribe(authToken -> {
String signature = soroban.getSignature(authToken);
soroban.followPaymentCode(event.getPaymentCode(), authToken, signature).subscribe(followMap -> {
refresh();
}, error -> {
log.error("Could not follow payment code", error);
AppServices.showErrorDialog("Could not follow payment code", error.getMessage());
});
});
}
}
public static class NoSelectionModel<T> extends MultipleSelectionModel<T> {
@Override

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

@ -1,7 +1,6 @@
package com.sparrowwallet.sparrow.soroban;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import javafx.fxml.FXMLLoader;
import javafx.scene.control.*;
@ -18,7 +17,6 @@ public class PayNymDialog extends Dialog<PayNym> {
dialogPane.setContent(payNymLoader.load());
PayNymController payNymController = payNymLoader.getController();
payNymController.initializeView(walletId);
EventManager.get().register(payNymController);
dialogPane.setPrefWidth(730);
dialogPane.setPrefHeight(600);
@ -27,7 +25,7 @@ public class PayNymDialog extends Dialog<PayNym> {
dialogPane.getStylesheets().add(AppServices.class.getResource("app.css").toExternalForm());
dialogPane.getStylesheets().add(AppServices.class.getResource("soroban/paynym.css").toExternalForm());
final ButtonType selectButtonType = new javafx.scene.control.ButtonType("Select PayNym", ButtonBar.ButtonData.APPLY);
final ButtonType selectButtonType = new javafx.scene.control.ButtonType("Select Contact", ButtonBar.ButtonData.APPLY);
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
final ButtonType doneButtonType = new javafx.scene.control.ButtonType("Done", ButtonBar.ButtonData.OK_DONE);
@ -43,10 +41,6 @@ public class PayNymDialog extends Dialog<PayNym> {
dialogPane.getButtonTypes().add(doneButtonType);
}
setOnCloseRequest(event -> {
EventManager.get().unregister(payNymController);
});
setResultConverter(dialogButton -> dialogButton == selectButtonType ? payNymController.getPayNym() : null);
} catch(IOException e) {
throw new RuntimeException(e);

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

@ -23,6 +23,32 @@ 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) {
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 -> {
log.debug("Added payment code " + addMap);
});
}, error -> {
soroban.getAuthToken(new HashMap<>()).subscribe(newAuthToken -> {
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 -> {
log.debug("Added payment code " + addMap);
});
});
}, newError -> {
log.error("Error claiming PayNym", newError);
});
});
});
}
}
protected Transaction getTransaction(Cahoots cahoots) throws PSBTParseException {
if(cahoots.getPSBT() != null) {
PSBT psbt = new PSBT(cahoots.getPSBT().toBytes());

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

@ -20,9 +20,15 @@ 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 javafx.application.Platform;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
@ -117,6 +123,15 @@ public class PaymentController extends WalletFormController implements Initializ
}
};
private final ObjectProperty<PayNym> payNymProperty = new SimpleObjectProperty<>();
private static final Wallet payNymWallet = new Wallet() {
@Override
public String getFullDisplayName() {
return "PayNym...";
}
};
@Override
public void initialize(URL location, ResourceBundle resources) {
EventManager.get().register(this);
@ -140,17 +155,38 @@ public class PaymentController extends WalletFormController implements Initializ
return null;
}
});
openWallets.setItems(FXCollections.observableList(AppServices.get().getOpenWallets().keySet().stream().filter(wallet -> wallet.isValid() && !wallet.isWhirlpoolChildWallet()).collect(Collectors.toList())));
updateOpenWallets();
openWallets.prefWidthProperty().bind(address.widthProperty());
openWallets.valueProperty().addListener((observable, oldValue, newValue) -> {
if(newValue != null) {
if(newValue == payNymWallet) {
PayNymDialog payNymDialog = new PayNymDialog(sendController.getWalletForm().getWalletId(), true);
Optional<PayNym> optPayNym = payNymDialog.showAndWait();
if(optPayNym.isPresent()) {
PayNym payNym = optPayNym.get();
payNymProperty.set(payNym);
address.setText(payNym.nymName());
label.requestFocus();
}
} else if(newValue != null) {
WalletNode freshNode = newValue.getFreshNode(KeyPurpose.RECEIVE);
Address freshAddress = newValue.getAddress(freshNode);
address.setText(freshAddress.toString());
label.requestFocus();
}
});
payNymProperty.addListener((observable, oldValue, newValue) -> {
addPaymentButton.setDisable(newValue != null);
if(newValue != null) {
sendController.setPayNymPayment();
}
});
address.textProperty().addListener((observable, oldValue, newValue) -> {
if(payNymProperty.get() != null && !newValue.equals(payNymProperty.get().nymName())) {
payNymProperty.set(null);
}
try {
BitcoinURI bitcoinURI = new BitcoinURI(newValue);
Platform.runLater(() -> updateFromURI(bitcoinURI));
@ -212,6 +248,20 @@ public class PaymentController extends WalletFormController implements Initializ
addValidation(validationSupport);
}
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());
if(sendController.getPaymentTabs().getTabs().size() <= 1 && SorobanServices.canWalletMix(sendController.getWalletForm().getWallet())) {
openWalletList.add(payNymWallet);
}
openWallets.setItems(FXCollections.observableList(openWalletList));
}
private void addValidation(ValidationSupport validationSupport) {
this.validationSupport = validationSupport;
@ -245,7 +295,7 @@ public class PaymentController extends WalletFormController implements Initializ
}
private Address getRecipientAddress() throws InvalidAddressException {
return Address.fromString(address.getText());
return payNymProperty.get() == null ? Address.fromString(address.getText()) : new PayNymAddress(payNymProperty.get());
}
private Long getRecipientValueSats() {
@ -365,6 +415,7 @@ public class PaymentController extends WalletFormController implements Initializ
setSendMax(false);
dustAmountProperty.set(false);
payNymProperty.set(null);
}
public void setMaxInput(ActionEvent event) {
@ -475,6 +526,6 @@ public class PaymentController extends WalletFormController implements Initializ
@Subscribe
public void openWallets(OpenWalletsEvent event) {
openWallets.setItems(FXCollections.observableList(event.getWallets().stream().filter(wallet -> wallet.isValid() && !wallet.isWhirlpoolChildWallet()).collect(Collectors.toList())));
updateOpenWallets(event.getWallets());
}
}

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

@ -20,6 +20,7 @@ import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.*;
import com.sparrowwallet.sparrow.soroban.InitiatorDialog;
import com.sparrowwallet.sparrow.soroban.PayNymAddress;
import com.sparrowwallet.sparrow.soroban.SorobanServices;
import com.sparrowwallet.sparrow.whirlpool.Whirlpool;
import javafx.animation.KeyFrame;
@ -397,7 +398,7 @@ public class SendController extends WalletFormController implements Initializabl
transactionDiagram.update(walletTransaction);
updatePrivacyAnalysis(walletTransaction);
createButton.setDisable(walletTransaction == null || isInsufficientFeeRate());
createButton.setDisable(walletTransaction == null || isInsufficientFeeRate() || isPayNymPayment(walletTransaction.getPayments()));
});
transactionDiagram.sceneProperty().addListener((observable, oldScene, newScene) -> {
@ -608,7 +609,8 @@ public class SendController extends WalletFormController implements Initializabl
OptimizationStrategy optimizationStrategy = (OptimizationStrategy)optimizationToggleGroup.getSelectedToggle().getUserData();
if(optimizationStrategy == OptimizationStrategy.PRIVACY
&& payments.size() == 1
&& (payments.get(0).getAddress().getScriptType() == getWalletForm().getWallet().getAddress(getWalletForm().wallet.getFreshNode(KeyPurpose.RECEIVE)).getScriptType())) {
&& (payments.get(0).getAddress().getScriptType() == getWalletForm().getWallet().getAddress(getWalletForm().wallet.getFreshNode(KeyPurpose.RECEIVE)).getScriptType())
&& !(payments.get(0).getAddress() instanceof PayNymAddress)) {
selectors.add(new StonewallUtxoSelector(noInputsFee));
}
@ -953,6 +955,17 @@ public class SendController extends WalletFormController implements Initializabl
}
}
private boolean isPayNymPayment(List<Payment> payments) {
return payments.size() == 1 && payments.get(0).getAddress() instanceof PayNymAddress;
}
public void setPayNymPayment() {
optimizationToggleGroup.selectToggle(privacyToggle);
transactionDiagram.setOptimizationStrategy(OptimizationStrategy.PRIVACY);
efficiencyToggle.setDisable(true);
privacyToggle.setDisable(false);
}
private boolean isMixPossible(List<Payment> payments) {
return (utxoSelectorProperty.get() == null || SorobanServices.canWalletMix(walletForm.getWallet()))
&& payments.size() == 1
@ -960,11 +973,16 @@ public class SendController extends WalletFormController implements Initializabl
}
private void updateOptimizationButtons(List<Payment> payments) {
if(isMixPossible(payments)) {
if(isPayNymPayment(payments)) {
setPayNymPayment();
} else if(isMixPossible(payments)) {
setPreferredOptimizationStrategy();
efficiencyToggle.setDisable(false);
privacyToggle.setDisable(false);
} else {
optimizationToggleGroup.selectToggle(efficiencyToggle);
transactionDiagram.setOptimizationStrategy(OptimizationStrategy.EFFICIENCY);
efficiencyToggle.setDisable(false);
privacyToggle.setDisable(true);
}
}
@ -1033,6 +1051,9 @@ public class SendController extends WalletFormController implements Initializabl
setInputFieldsDisabled(false);
efficiencyToggle.setDisable(false);
privacyToggle.setDisable(false);
premixButton.setVisible(false);
createButton.setDefaultButton(true);
}
@ -1088,23 +1109,24 @@ public class SendController extends WalletFormController implements Initializabl
}
public void createTransaction(ActionEvent event) {
WalletTransaction walletTransaction = walletTransactionProperty.get();
if(log.isDebugEnabled()) {
Map<WalletNode, List<String>> inputHashes = new LinkedHashMap<>();
for(WalletNode node : walletTransactionProperty.get().getSelectedUtxos().values()) {
for(WalletNode node : walletTransaction.getSelectedUtxos().values()) {
List<String> nodeHashes = inputHashes.computeIfAbsent(node, k -> new ArrayList<>());
nodeHashes.add(ElectrumServer.getScriptHash(walletForm.getWallet(), node));
}
Map<WalletNode, List<String>> changeHash = new LinkedHashMap<>();
for(WalletNode changeNode : walletTransactionProperty.get().getChangeMap().keySet()) {
for(WalletNode changeNode : walletTransaction.getChangeMap().keySet()) {
changeHash.put(changeNode, List.of(ElectrumServer.getScriptHash(walletForm.getWallet(), changeNode)));
}
log.debug("Creating tx " + walletTransactionProperty.get().getTransaction().getTxId() + ", expecting notifications for \ninputs \n" + inputHashes + " and \nchange \n" + changeHash);
log.debug("Creating tx " + walletTransaction.getTransaction().getTxId() + ", expecting notifications for \ninputs \n" + inputHashes + " and \nchange \n" + changeHash);
}
addWalletTransactionNodes();
createdWalletTransactionProperty.set(walletTransactionProperty.get());
PSBT psbt = walletTransactionProperty.get().createPSBT();
EventManager.get().post(new ViewPSBTEvent(createButton.getScene().getWindow(), walletTransactionProperty.get().getPayments().get(0).getLabel(), null, psbt));
createdWalletTransactionProperty.set(walletTransaction);
PSBT psbt = walletTransaction.createPSBT();
EventManager.get().post(new ViewPSBTEvent(createButton.getScene().getWindow(), walletTransaction.getPayments().get(0).getLabel(), null, psbt));
}
private void addWalletTransactionNodes() {
@ -1379,7 +1401,7 @@ public class SendController extends WalletFormController implements Initializabl
public void sorobanInitiated(SorobanInitiatedEvent event) {
if(event.getWallet().equals(getWalletForm().getWallet())) {
InitiatorDialog initiatorDialog = new InitiatorDialog(getWalletForm().getWalletId(), getWalletForm().getWallet(), walletTransactionProperty.get());
if(Network.get() == Network.TESTNET) {
if(Config.get().isSameAppMixing()) {
initiatorDialog.initModality(Modality.NONE);
}
Optional<Transaction> optTransaction = initiatorDialog.showAndWait();
@ -1414,13 +1436,16 @@ 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 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());
boolean addressReuse = userPayments.stream().anyMatch(payment -> getWalletForm().getWallet().getWalletAddresses().get(payment.getAddress()) != null && !getWalletForm().getWallet().getWalletAddresses().get(payment.getAddress()).getTransactionOutputs().isEmpty());
if(optimizationStrategy == OptimizationStrategy.PRIVACY) {
if(fakeMixPresent) {
if(payNymPresent) {
addLabel("Appears as a normal transaction, but actual value transferred is hidden", getPlusGlyph());
} else if(fakeMixPresent) {
addLabel("Appears as a two person coinjoin", getPlusGlyph());
} else {
if(mixedAddressTypes) {
@ -1447,7 +1472,7 @@ public class SendController extends WalletFormController implements Initializabl
addLabel("Address types different to the wallet indicate external payments", getMinusGlyph());
}
if(roundPaymentAmounts && !fakeMixPresent) {
if(roundPaymentAmounts && !fakeMixPresent && !payNymPresent) {
addLabel("Rounded payment amounts indicate external payments", getMinusGlyph());
}

8
src/main/resources/com/sparrowwallet/sparrow/soroban/counterparty.fxml

@ -28,12 +28,12 @@
</HBox>
<VBox fx:id="counterpartyBox" styleClass="content-area" spacing="20" prefHeight="390">
<VBox fx:id="step1" spacing="15">
<Label text="Mix Preparation" styleClass="title-text">
<Label text="Share your PayNym or Payment code" styleClass="title-text">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="20" icon="RANDOM" styleClass="title-icon" />
</graphic>
</Label>
<Label text="Perform a two person coinjoin transaction using the Samourai Soroban service. Your mix partner will start the mix, and will need either your PayNym or the Payment code shown below. Click Next once they have indicated they are ready." wrapText="true" styleClass="content-text" />
<Label text="Perform a collaborative transaction using the Samourai Soroban service. Your mix partner will start the mix, and will need either your PayNym or the Payment code shown below. Click Next once they have indicated they are ready." wrapText="true" styleClass="content-text" />
<BorderPane>
<padding>
<Insets top="20" right="70" />
@ -97,7 +97,7 @@
<Region HBox.hgrow="ALWAYS" />
<ProgressTimer fx:id="step2Timer" seconds="60" />
</HBox>
<Label fx:id="step2Desc" text="Your mix partner will now initiate the Soroban communication. Once communication is established, check the details of the mix and click Next if you'd like to proceed." wrapText="true" styleClass="content-text" />
<Label fx:id="step2Desc" text="Your mix partner will now initiate the Soroban communication. Once communication is established, check the details of the mix transaction and click Next if you'd like to proceed." wrapText="true" styleClass="content-text" />
<BorderPane>
<padding>
<Insets top="20" right="70" />
@ -120,7 +120,7 @@
</HBox>
<HBox styleClass="field-box">
<Label text="Fee:" styleClass="field-label" />
<Label text="You pay half the miner fee" />
<Label fx:id="mixFee" text="You pay half the miner fee" />
</HBox>
</VBox>
</VBox>

3
src/main/resources/com/sparrowwallet/sparrow/soroban/initiator.fxml

@ -33,7 +33,8 @@
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="20" icon="RANDOM" styleClass="title-icon" />
</graphic>
</Label>
<Label text="Add a mix partner to your two person coinjoin transaction using the Samourai Soroban service. Ask your partner for their PayNym, or use their payment code found in their Sparrow Tools menu → Find Mix Partner." wrapText="true" styleClass="content-text" />
<Label text="Add a mix partner to your transaction using the Samourai Soroban service, breaking the common input ownership heuristic and obfuscating payment amounts." wrapText="true" styleClass="content-text"/>
<Label text="Ask your partner for their PayNym, or use their payment code found in their Sparrow Tools menu → Find Mix Partner. They will need a Native Segwit software wallet like this one." wrapText="true" styleClass="content-text" />
<BorderPane>
<padding>
<Insets top="20" right="70" />

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

@ -53,4 +53,8 @@
-fx-font-weight: bold;
-fx-font-size: 1.2em;
-fx-padding: 10 0 10 0;
}
#followersList .paynym-cell .label {
-fx-text-fill: #a0a1a7;
}

12
src/main/resources/com/sparrowwallet/sparrow/soroban/paynym.fxml

@ -33,6 +33,14 @@
<HBox styleClass="field-box">
<Label text="PayNym:" styleClass="field-label" />
<CopyableTextField fx:id="payNymName" promptText="Retrieving..." styleClass="field-control" editable="false"/>
<Button fx:id="payNymRetrieve" text="Retrieve PayNym" graphicTextGap="8" onAction="#retrievePayNym">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="ROBOT" />
</graphic>
<tooltip>
<Tooltip text="Retrieves and claims the PayNym for this wallet" />
</tooltip>
</Button>
</HBox>
<HBox styleClass="field-box">
<Label text="Payment code:" styleClass="field-label" />
@ -52,7 +60,7 @@
<padding>
<Insets top="35" />
</padding>
<Label text="Find:" styleClass="field-label" />
<Label text="Find Contact:" styleClass="field-label" />
<HBox spacing="10">
<CopyableTextField fx:id="searchPayNyms" promptText="PayNym or Payment code" styleClass="field-control"/>
<Button onAction="#scanQR">
@ -86,7 +94,7 @@
<BorderPane GridPane.columnIndex="0" GridPane.rowIndex="0">
<top>
<HBox alignment="CENTER_LEFT">
<Label styleClass="listview-label" text="Following"/>
<Label styleClass="listview-label" text="Contacts"/>
</HBox>
</top>
<center>

2
src/main/resources/com/sparrowwallet/sparrow/wallet/send.css

@ -120,7 +120,7 @@
-fx-text-fill: -fx-text-background-color;
}
#transactionDiagram .coins-replace-icon {
#transactionDiagram .coins-replace-icon, #transactionDiagram .question-icon {
-fx-text-fill: -fx-accent;
}

Loading…
Cancel
Save