integrate paynyms to collaborative mixing

This commit is contained in:
Craig Raw
2021-11-29 15:31:33 +02:00
parent 3013688447
commit 0956c96046
20 changed files with 737 additions and 87 deletions
@@ -989,6 +989,10 @@ public class AppController implements Initializable {
whirlpool.setHDWallet(storage.getWalletId(wallet), copy);
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
soroban.setHDWallet(copy);
} else if(Config.get().isUsePayNym() && SorobanServices.canWalletMix(wallet)) {
String walletId = storage.getWalletId(wallet);
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
soroban.setPaymentCode(copy);
}
StandardAccount standardAccount = wallet.getStandardAccountType();
@@ -1236,7 +1240,7 @@ public class AppController implements Initializable {
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 storage = selectedWalletForm.getStorage();
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get(), true);
keyDerivationService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(selectedWalletForm.getWalletId(), TimedEvent.Action.END, "Done"));
@@ -1,13 +1,19 @@
package com.sparrowwallet.sparrow.control;
import javafx.animation.FadeTransition;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.property.ObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.event.EventHandler;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.control.Tooltip;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.util.Duration;
@@ -16,10 +22,36 @@ import org.controlsfx.control.textfield.CustomTextField;
public class CopyableTextField extends CustomTextField {
private static final Duration FADE_DURATION = Duration.millis(350);
private final ChangeListener<String> selectionListener = (textObservable, textOldValue, textNewValue) -> {
if(!textNewValue.isEmpty()) {
deselect();
}
};
private final EventHandler<MouseEvent> copyHandler = event -> {
ClipboardContent content = new ClipboardContent();
content.putString(getCopyText());
Clipboard.getSystemClipboard().setContent(content);
Tooltip tooltip = new Tooltip("Copied!");
tooltip.show(this, event.getScreenX(), event.getScreenY());
Timeline timeline = new Timeline(new KeyFrame(Duration.seconds(1), e -> tooltip.hide()));
timeline.play();
};
public CopyableTextField() {
super();
getStyleClass().add("copyable-text-field");
setupCopyButtonField(super.rightProperty());
editableProperty().addListener((observable, oldValue, editable) -> {
if(!editable) {
setOnMouseClicked(copyHandler);
selectedTextProperty().addListener(selectionListener);
} else {
setOnMouseClicked(null);
selectedTextProperty().removeListener(selectionListener);
}
});
}
private void setupCopyButtonField(ObjectProperty<Node> rightProperty) {
@@ -31,7 +63,7 @@ public class CopyableTextField extends CustomTextField {
copyButtonPane.setCursor(Cursor.DEFAULT);
copyButtonPane.setOnMouseReleased(e -> {
ClipboardContent content = new ClipboardContent();
content.putString(getText());
content.putString(getCopyText());
Clipboard.getSystemClipboard().setContent(content);
});
@@ -61,4 +93,8 @@ public class CopyableTextField extends CustomTextField {
}
});
}
protected String getCopyText() {
return getText();
}
}
@@ -0,0 +1,87 @@
package com.sparrowwallet.sparrow.control;
import com.samourai.wallet.bip47.rpc.PaymentCode;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.io.Config;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import javafx.scene.image.Image;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.ImagePattern;
import javafx.scene.shape.Circle;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.InputStream;
import java.net.Proxy;
import java.net.URL;
public class PayNymAvatar extends StackPane {
private static final Logger log = LoggerFactory.getLogger(PayNymAvatar.class);
private final ObjectProperty<PaymentCode> paymentCodeProperty = new SimpleObjectProperty<>(null);
public PayNymAvatar() {
super();
paymentCodeProperty.addListener((observable, oldValue, newValue) -> {
if(Config.get().isUsePayNym()) {
PayNymAvatarService payNymAvatarService = new PayNymAvatarService(newValue);
payNymAvatarService.setOnRunning(runningEvent -> {
getChildren().clear();
});
payNymAvatarService.setOnSucceeded(successEvent -> {
Circle circle = new Circle(getWidth() / 2,getHeight() / 2,getWidth() / 2);
circle.setFill(new ImagePattern(payNymAvatarService.getValue()));
getChildren().add(circle);
});
payNymAvatarService.start();
}
});
}
public PaymentCode getPaymentCode() {
return paymentCodeProperty.get();
}
public ObjectProperty<PaymentCode> paymentCodeProperty() {
return paymentCodeProperty;
}
public void setPaymentCode(PaymentCode paymentCode) {
this.paymentCodeProperty.set(paymentCode);
}
public void setPayNymAvatarUri(String uri) {
setPaymentCode(new PaymentCode(uri.replace("/", "").replace("avatar", "")));
}
private class PayNymAvatarService extends Service<Image> {
private final PaymentCode paymentCode;
public PayNymAvatarService(PaymentCode paymentCode) {
this.paymentCode = paymentCode;
}
@Override
protected Task<Image> createTask() {
return new Task<>() {
@Override
protected Image call() throws Exception {
String paymentCodeStr = paymentCode.toString();
String url = "https://paynym.is/" + paymentCodeStr + "/avatar";
Proxy proxy = AppServices.getProxy();
try(InputStream is = (proxy == null ? new URL(url).openStream() : new URL(url).openConnection(proxy).getInputStream())) {
return new Image(is, getWidth(), getHeight(), true, false);
} catch(Exception e) {
log.warn("Error loading PayNym avatar", e);
throw e;
}
}
};
}
}
}
@@ -0,0 +1,18 @@
package com.sparrowwallet.sparrow.control;
import com.samourai.wallet.bip47.rpc.PaymentCode;
public class PaymentCodeTextField extends CopyableTextField {
private String paymentCodeStr;
public void setPaymentCode(PaymentCode paymentCode) {
this.paymentCodeStr = paymentCode.toString();
String abbrevPcode = paymentCodeStr.substring(0, 12) + "..." + paymentCodeStr.substring(paymentCodeStr.length() - 5);
setText(abbrevPcode);
}
@Override
protected String getCopyText() {
return paymentCodeStr;
}
}
@@ -58,6 +58,7 @@ public class Config {
private File electrumServerCert;
private boolean useProxy;
private String proxyServer;
private boolean usePayNym;
private Double appWidth;
private Double appHeight;
@@ -501,6 +502,15 @@ public class Config {
flush();
}
public boolean isUsePayNym() {
return usePayNym;
}
public void setUsePayNym(boolean usePayNym) {
this.usePayNym = usePayNym;
flush();
}
public Double getAppWidth() {
return appWidth;
}
@@ -5,34 +5,31 @@ import com.samourai.soroban.client.cahoots.OnlineCahootsMessage;
import com.samourai.soroban.client.cahoots.SorobanCahootsService;
import com.samourai.wallet.bip47.rpc.PaymentCode;
import com.samourai.wallet.cahoots.Cahoots;
import com.samourai.wallet.cahoots.stonewallx2.STONEWALLx2;
import com.samourai.wallet.cahoots.CahootsType;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.psbt.PSBTParseException;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.control.CopyableTextField;
import com.sparrowwallet.sparrow.control.ProgressTimer;
import com.sparrowwallet.sparrow.control.TransactionDiagram;
import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.io.Config;
import io.reactivex.rxjavafx.schedulers.JavaFxScheduler;
import io.reactivex.schedulers.Schedulers;
import javafx.application.Platform;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressBar;
import javafx.scene.control.*;
import javafx.scene.layout.VBox;
import javafx.util.StringConverter;
import org.controlsfx.glyphfont.Glyph;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
import java.util.HashMap;
import java.util.Map;
import static com.sparrowwallet.sparrow.soroban.Soroban.TIMEOUT_MS;
@@ -56,7 +53,19 @@ public class CounterpartyController extends SorobanController {
private VBox step4;
@FXML
private CopyableTextField paymentCode;
private CopyableTextField payNym;
@FXML
private PayNymAvatar payNymAvatar;
@FXML
private Button payNymButton;
@FXML
private PaymentCodeTextField paymentCode;
@FXML
private Button paymentCodeQR;
@FXML
private ComboBox<Wallet> mixWallet;
@@ -144,7 +153,21 @@ public class CounterpartyController extends SorobanController {
throw new IllegalStateException("Soroban HD wallet must be set");
}
paymentCode.setText(soroban.getPaymentCode().toString());
payNym.managedProperty().bind(payNym.visibleProperty());
payNymAvatar.managedProperty().bind(payNymAvatar.visibleProperty());
payNymAvatar.visibleProperty().bind(payNym.visibleProperty());
payNymAvatar.prefWidthProperty().bind(payNym.heightProperty());
payNymAvatar.prefHeightProperty().bind(payNym.heightProperty());
payNymButton.managedProperty().bind(payNymButton.visibleProperty());
payNymButton.visibleProperty().bind(payNym.visibleProperty().not());
if(Config.get().isUsePayNym()) {
retrievePayNym(null);
} else {
payNym.setVisible(false);
}
paymentCode.setPaymentCode(soroban.getPaymentCode());
paymentCodeQR.prefHeightProperty().bind(paymentCode.heightProperty());
mixingPartner.managedProperty().bind(mixingPartner.visibleProperty());
meetingFail.managedProperty().bind(meetingFail.visibleProperty());
@@ -190,18 +213,18 @@ public class CounterpartyController extends SorobanController {
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.subscribe(requestMessage -> {
PaymentCode paymentCodeInitiator = new PaymentCode(requestMessage.getSender());
mixingPartner.setText(requestMessage.getSender());
mixType.setText(requestMessage.getType().getLabel());
mixDetails.setVisible(true);
meetingReceived.set(Boolean.TRUE);
String code = requestMessage.getSender();
CahootsType cahootsType = requestMessage.getType();
PaymentCode paymentCodeInitiator = new PaymentCode(code);
updateMixPartner(soroban, paymentCodeInitiator, cahootsType);
Boolean accepted = (Boolean)Platform.enterNestedEventLoop(meetingAccepted);
sorobanMeetingService.sendMeetingResponse(paymentCodeInitiator, requestMessage, accepted)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.subscribe(responseMessage -> {
if(accepted) {
startCounterpartyStonewall(counterpartyCahootsWallet, paymentCodeInitiator);
startCounterpartyCollaboration(counterpartyCahootsWallet, paymentCodeInitiator, cahootsType);
followPaymentCode(soroban, paymentCodeInitiator);
}
}, error -> {
log.error("Error sending meeting response", error);
@@ -216,7 +239,28 @@ public class CounterpartyController extends SorobanController {
}
}
private void startCounterpartyStonewall(SparrowCahootsWallet counterpartyCahootsWallet, PaymentCode initiatorPaymentCode) {
private void updateMixPartner(Soroban soroban, PaymentCode paymentCodeInitiator, CahootsType cahootsType) {
String code = paymentCodeInitiator.toString();
mixingPartner.setText(code.substring(0, 12) + "..." + code.substring(code.length() - 5));
PayNymAvatar payNymAvatar = new PayNymAvatar();
payNymAvatar.setPrefHeight(mixingPartner.getHeight());
payNymAvatar.setPrefWidth(mixingPartner.getHeight());
payNymAvatar.setPaymentCode(paymentCodeInitiator);
mixingPartner.setGraphic(payNymAvatar);
if(Config.get().isUsePayNym()) {
soroban.getPayNym(paymentCodeInitiator.toString()).subscribe(payNym -> {
mixingPartner.setText(payNym.nymName());
}, error -> {
//ignore, may not be a PayNym
});
}
mixType.setText(cahootsType.getLabel());
mixDetails.setVisible(true);
meetingReceived.set(Boolean.TRUE);
}
private void startCounterpartyCollaboration(SparrowCahootsWallet counterpartyCahootsWallet, PaymentCode initiatorPaymentCode, CahootsType cahootsType) {
sorobanProgressLabel.setText("Creating mix transaction...");
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
@@ -227,7 +271,7 @@ public class CounterpartyController extends SorobanController {
try {
SorobanCahootsService sorobanCahootsService = soroban.getSorobanCahootsService(counterpartyCahootsWallet);
CahootsContext cahootsContext = CahootsContext.newCounterpartyStonewallx2();
CahootsContext cahootsContext = cahootsType == CahootsType.STONEWALLX2 ? CahootsContext.newCounterpartyStonewallx2() : CahootsContext.newCounterpartyStowaway();
sorobanCahootsService.contributor(counterpartyCahootsWallet.getAccount(), cahootsContext, initiatorPaymentCode, TIMEOUT_MS)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
@@ -240,16 +284,16 @@ public class CounterpartyController extends SorobanController {
if(cahoots.getStep() == 3) {
sorobanProgressLabel.setText("Your mix partner is reviewing the transaction...");
step3Timer.start();
} else if(cahoots.getStep() >= 4 && cahoots instanceof STONEWALLx2 stonewallx2) {
} else if(cahoots.getStep() >= 4) {
try {
Transaction transaction = getTransaction(stonewallx2);
Transaction transaction = getTransaction(cahoots);
if(transaction != null) {
transactionProperty.set(transaction);
updateTransactionDiagram(transactionDiagram, wallet, null, transaction);
next();
}
} catch(PSBTParseException e) {
log.error("Invalid Stonewallx2 PSBT created", e);
log.error("Invalid collaborative PSBT created", e);
step3Desc.setText("Invalid transaction created.");
sorobanProgressLabel.setVisible(false);
}
@@ -270,6 +314,19 @@ public class CounterpartyController extends SorobanController {
}
}
private void followPaymentCode(Soroban soroban, PaymentCode paymentCodeInitiator) {
if(Config.get().isUsePayNym()) {
soroban.getAuthToken(new HashMap<>()).subscribe(authToken -> {
String signature = soroban.getSignature(authToken);
soroban.followPaymentCode(paymentCodeInitiator, authToken, signature).subscribe(followMap -> {
log.debug("Followed payment code " + followMap.get("following"));
}, error -> {
log.warn("Could not follow payment code", error);
});
});
}
}
public boolean next() {
if(step1.isVisible()) {
step1.setVisible(false);
@@ -296,6 +353,50 @@ public class CounterpartyController extends SorobanController {
meetingAccepted.set(Boolean.FALSE);
}
public void retrievePayNym(ActionEvent event) {
Config.get().setUsePayNym(true);
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
soroban.createPayNym().subscribe(createMap -> {
payNym.setText((String)createMap.get("nymName"));
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);
});
});
});
}
}, error -> {
log.error("Error retrieving PayNym", error);
AppServices.showErrorDialog("Error retrieving PayNym", error.getMessage());
});
}
public void showPayNymQR(ActionEvent event) {
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(soroban.getPaymentCode().toString());
qrDisplayDialog.showAndWait();
}
public ObjectProperty<Boolean> meetingReceivedProperty() {
return meetingReceived;
}
@@ -6,7 +6,6 @@ import com.samourai.soroban.client.cahoots.SorobanCahootsService;
import com.samourai.wallet.bip47.rpc.PaymentCode;
import com.samourai.wallet.cahoots.Cahoots;
import com.samourai.wallet.cahoots.CahootsType;
import com.samourai.wallet.cahoots.stonewallx2.STONEWALLx2;
import com.sparrowwallet.drongo.SecureString;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.crypto.EncryptionType;
@@ -17,28 +16,35 @@ import com.sparrowwallet.drongo.psbt.PSBTParseException;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.PayNymAvatar;
import com.sparrowwallet.sparrow.control.ProgressTimer;
import com.sparrowwallet.sparrow.control.TransactionDiagram;
import com.sparrowwallet.sparrow.control.WalletPasswordDialog;
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 io.reactivex.Observable;
import io.reactivex.rxjavafx.schedulers.JavaFxScheduler;
import io.reactivex.schedulers.Schedulers;
import javafx.application.Platform;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.fxml.FXML;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressBar;
import javafx.scene.control.TextField;
import javafx.scene.control.*;
import javafx.scene.layout.VBox;
import javafx.util.StringConverter;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.*;
import java.util.function.UnaryOperator;
import java.util.regex.Pattern;
import static com.sparrowwallet.sparrow.AppServices.showErrorDialog;
import static com.sparrowwallet.sparrow.soroban.Soroban.TIMEOUT_MS;
@@ -46,6 +52,9 @@ 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 Pattern PAYNYM_REGEX = Pattern.compile("\\+[a-z]+[0-9][0-9a-fA-F][0-9a-fA-F]");
private static final PayNym FIND_FOLLOWERS = new PayNym(null, null, "Retrieve PayNyms...", false);
private String walletId;
private Wallet wallet;
private WalletTransaction walletTransaction;
@@ -59,9 +68,15 @@ public class InitiatorController extends SorobanController {
@FXML
private VBox step3;
@FXML
private ComboBox<PayNym> payNymFollowers;
@FXML
private TextField counterparty;
@FXML
private PayNymAvatar payNymAvatar;
@FXML
private ProgressTimer step2Timer;
@@ -86,6 +101,8 @@ public class InitiatorController extends SorobanController {
@FXML
private TransactionDiagram transactionDiagram;
private final ObjectProperty<PaymentCode> counterpartyPaymentCode = new SimpleObjectProperty<>(null);
private final ObjectProperty<Step> stepProperty = new SimpleObjectProperty<>(Step.SETUP);
private final ObjectProperty<Boolean> transactionAccepted = new SimpleObjectProperty<>(null);
@@ -129,6 +146,99 @@ public class InitiatorController extends SorobanController {
step2Timer.start();
}
});
payNymAvatar.managedProperty().bind(payNymAvatar.visibleProperty());
payNymAvatar.prefWidthProperty().bind(counterparty.heightProperty());
payNymAvatar.prefHeightProperty().bind(counterparty.heightProperty());
payNymFollowers.prefWidthProperty().bind(counterparty.widthProperty());
payNymFollowers.valueProperty().addListener((observable, oldValue, payNym) -> {
if(payNym == FIND_FOLLOWERS) {
Config.get().setUsePayNym(true);
setPayNymFollowers();
} else if(payNym != null) {
counterparty.setText(payNym.nymName());
payNymAvatar.setPaymentCode(payNym.paymentCode());
}
});
payNymFollowers.setConverter(new StringConverter<>() {
@Override
public String toString(PayNym payNym) {
return payNym == null ? "" : payNym.nymName();
}
@Override
public PayNym fromString(String string) {
return null;
}
});
UnaryOperator<TextFormatter.Change> paymentCodeFilter = change -> {
String input = change.getControlNewText();
if(input.startsWith("P") && !input.contains("...")) {
try {
PaymentCode paymentCode = new PaymentCode(input);
if(paymentCode.isValid()) {
counterpartyPaymentCode.set(paymentCode);
payNymAvatar.setPaymentCode(paymentCode);
TextInputControl control = (TextInputControl)change.getControl();
change.setText(input.substring(0, 12) + "..." + input.substring(input.length() - 5));
change.setRange(0, control.getLength());
change.setAnchor(change.getText().length());
change.setCaretPosition(change.getText().length());
}
} catch(Exception e) {
//ignore
}
}
return change;
};
counterparty.setTextFormatter(new TextFormatter<>(paymentCodeFilter));
counterparty.textProperty().addListener((observable, oldValue, newValue) -> {
if(newValue != null) {
if(newValue.startsWith("P") && newValue.contains("...") && newValue.length() == 20 && counterpartyPaymentCode.get() != null) {
//Assumed valid payment code
} else if(Config.get().isUsePayNym() && PAYNYM_REGEX.matcher(newValue).matches()) {
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
soroban.getPayNym(newValue).subscribe(payNym -> {
counterpartyPaymentCode.set(payNym.paymentCode());
payNymAvatar.setPaymentCode(payNym.paymentCode());
}, error -> {
//ignore, probably doesn't exist but will try again on meeting request
});
} else {
counterpartyPaymentCode.set(null);
payNymAvatar.getChildren().clear();
}
}
});
if(Config.get().isUsePayNym()) {
setPayNymFollowers();
} else {
List<PayNym> defaultList = new ArrayList<>();
defaultList.add(FIND_FOLLOWERS);
payNymFollowers.setItems(FXCollections.observableList(defaultList));
}
ValidationSupport validationSupport = new ValidationSupport();
Platform.runLater(() -> {
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(counterparty, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Invalid counterparty", !isValidCounterparty()));
});
}
private void setPayNymFollowers() {
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
if(soroban.getPaymentCode() != null) {
soroban.getFollowers().subscribe(followerPayNyms -> {
payNymFollowers.setItems(FXCollections.observableList(followerPayNyms));
}, error -> {
log.warn("Could not retrieve followers: ", error);
});
}
}
private void startInitiatorMeetingRequest() {
@@ -184,45 +294,58 @@ public class InitiatorController extends SorobanController {
private void startInitiatorMeetingRequest(Soroban soroban, Wallet wallet) {
SparrowCahootsWallet initiatorCahootsWallet = soroban.getCahootsWallet(wallet, (long)walletTransaction.getFeeRate());
PaymentCode paymentCodeCounterparty = new PaymentCode(counterparty.getText());
try {
SorobanCahootsService sorobanMeetingService = soroban.getSorobanCahootsService(initiatorCahootsWallet);
sorobanMeetingService.sendMeetingRequest(paymentCodeCounterparty, CahootsType.STONEWALLX2)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.subscribe(meetingRequest -> {
sorobanProgressLabel.setText("Waiting for mixing partner...");
sorobanMeetingService.receiveMeetingResponse(paymentCodeCounterparty, meetingRequest, TIMEOUT_MS)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.subscribe(sorobanResponse -> {
if(sorobanResponse.isAccept()) {
sorobanProgressBar.setProgress(0.1);
sorobanProgressLabel.setText("Mixing partner accepted!");
startInitiatorStonewall(initiatorCahootsWallet, paymentCodeCounterparty);
} else {
step2Desc.setText("Mixing partner declined.");
getPaymentCodeCounterparty(soroban).subscribe(paymentCodeCounterparty -> {
try {
SorobanCahootsService sorobanMeetingService = soroban.getSorobanCahootsService(initiatorCahootsWallet);
sorobanMeetingService.sendMeetingRequest(paymentCodeCounterparty, CahootsType.STONEWALLX2)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.subscribe(meetingRequest -> {
sorobanProgressLabel.setText("Waiting for mixing partner...");
sorobanMeetingService.receiveMeetingResponse(paymentCodeCounterparty, meetingRequest, TIMEOUT_MS)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.subscribe(sorobanResponse -> {
if(sorobanResponse.isAccept()) {
sorobanProgressBar.setProgress(0.1);
sorobanProgressLabel.setText("Mixing partner accepted!");
startInitiatorCollaborative(initiatorCahootsWallet, paymentCodeCounterparty);
} else {
step2Desc.setText("Mixing partner declined.");
sorobanProgressLabel.setVisible(false);
}
}, error -> {
log.error("Error receiving meeting response", error);
String cutFrom = "Exception: ";
int index = error.getMessage().lastIndexOf(cutFrom);
String msg = index < 0 ? error.getMessage() : error.getMessage().substring(index + cutFrom.length());
msg = msg.replace("#Cahoots", "mix transaction");
step2Desc.setText(msg);
sorobanProgressLabel.setVisible(false);
}
}, error -> {
log.error("Error receiving meeting response", error);
String cutFrom = "Exception: ";
int index = error.getMessage().lastIndexOf(cutFrom);
step2Desc.setText(index < 0 ? error.getMessage() : error.getMessage().substring(index + cutFrom.length()));
sorobanProgressLabel.setVisible(false);
});
}, error -> {
log.error("Error sending meeting request", error);
step2Desc.setText(error.getMessage());
sorobanProgressLabel.setVisible(false);
});
} catch(Exception e) {
log.error("Error sending meeting request", e);
}
});
}, error -> {
log.error("Error sending meeting request", error);
step2Desc.setText(error.getMessage());
sorobanProgressLabel.setVisible(false);
});
} catch(Exception e) {
log.error("Error sending meeting request", e);
}
}, error -> {
log.error("Could not retrieve payment code", error);
if(error.getMessage().endsWith("404")) {
step2Desc.setText("PayNym not found");
} else if(error.getMessage().endsWith("400")) {
step2Desc.setText("Could not retrieve PayNym");
} else {
step2Desc.setText(error.getMessage());
}
sorobanProgressLabel.setVisible(false);
});
}
private void startInitiatorStonewall(SparrowCahootsWallet initiatorCahootsWallet, PaymentCode paymentCodeCounterparty) {
private void startInitiatorCollaborative(SparrowCahootsWallet initiatorCahootsWallet, PaymentCode paymentCodeCounterparty) {
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
Payment payment = walletTransaction.getPayments().get(0);
@@ -255,9 +378,9 @@ public class InitiatorController extends SorobanController {
Cahoots cahoots = cahootsMessage.getCahoots();
sorobanProgressBar.setProgress((double)(cahoots.getStep() + 1) / 5);
if(cahoots.getStep() >= 3 && cahoots instanceof STONEWALLx2 stonewallx2) {
if(cahoots.getStep() >= 3) {
try {
Transaction transaction = getTransaction(stonewallx2);
Transaction transaction = getTransaction(cahoots);
if(transaction != null) {
transactionProperty.set(transaction);
if(cahoots.getStep() == 3) {
@@ -273,7 +396,7 @@ public class InitiatorController extends SorobanController {
}
}
} catch(PSBTParseException e) {
log.error("Invalid Stonewallx2 PSBT created", e);
log.error("Invalid collaborative PSBT created", e);
step2Desc.setText("Invalid transaction created.");
sorobanProgressLabel.setVisible(false);
}
@@ -307,6 +430,26 @@ public class InitiatorController extends SorobanController {
}
}
private Observable<PaymentCode> getPaymentCodeCounterparty(Soroban soroban) {
if(counterpartyPaymentCode.get() != null) {
return Observable.just(counterpartyPaymentCode.get());
} else {
return soroban.getPayNym(counterparty.getText()).map(PayNym::paymentCode);
}
}
private boolean isValidCounterparty() {
if(counterpartyPaymentCode.get() != null) {
return true;
}
if(counterparty.getText().startsWith("P") && counterparty.getText().contains("...") && counterparty.getText().length() == 20) {
return true;
}
return PAYNYM_REGEX.matcher(counterparty.getText()).matches();
}
public void accept() {
transactionAccepted.set(Boolean.TRUE);
}
@@ -315,6 +458,10 @@ public class InitiatorController extends SorobanController {
transactionAccepted.set(Boolean.FALSE);
}
public ObjectProperty<PaymentCode> counterpartyPaymentCodeProperty() {
return counterpartyPaymentCode;
}
public ObjectProperty<Step> stepProperty() {
return stepProperty;
}
@@ -48,6 +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);
broadcastButton.setDisable(true);
nextButton.managedProperty().bind(nextButton.visibleProperty());
@@ -55,6 +56,10 @@ public class InitiatorDialog extends Dialog<Transaction> {
broadcastButton.visibleProperty().bind(nextButton.visibleProperty().not());
initiatorController.counterpartyPaymentCodeProperty().addListener((observable, oldValue, paymentCode) -> {
nextButton.setDisable(paymentCode == null);
});
initiatorController.stepProperty().addListener((observable, oldValue, step) -> {
if(step == InitiatorController.Step.SETUP) {
nextButton.setDisable(false);
@@ -0,0 +1,5 @@
package com.sparrowwallet.sparrow.soroban;
import com.samourai.wallet.bip47.rpc.PaymentCode;
public record PayNym(PaymentCode paymentCode, String nymId, String nymName, boolean segwit) {}
@@ -0,0 +1,139 @@
package com.sparrowwallet.sparrow.soroban;
import com.samourai.http.client.HttpUsage;
import com.samourai.http.client.IHttpClient;
import com.samourai.wallet.bip47.rpc.PaymentCode;
import com.sparrowwallet.nightjar.http.JavaHttpClientService;
import io.reactivex.Observable;
import io.reactivex.rxjavafx.schedulers.JavaFxScheduler;
import io.reactivex.schedulers.Schedulers;
import java8.util.Optional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@SuppressWarnings("unchecked")
public class PayNymService {
private final JavaHttpClientService httpClientService;
public PayNymService(JavaHttpClientService httpClientService) {
this.httpClientService = httpClientService;
}
public Observable<Map<String, Object>> createPayNym(PaymentCode paymentCode) {
if(paymentCode == null) {
throw new IllegalStateException("Payment code is null");
}
Map<String, String> headers = new HashMap<>();
headers.put("content-type", "application/json");
HashMap<String, Object> body = new HashMap<>();
body.put("code", paymentCode.toString());
IHttpClient httpClient = httpClientService.getHttpClient(HttpUsage.COORDINATOR_REST);
return httpClient.postJson("https://paynym.is/api/v1/create", Map.class, headers, body)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.map(Optional::get);
}
public Observable<Map<String, Object>> updateToken(PaymentCode paymentCode) {
if(paymentCode == null) {
throw new IllegalStateException("Payment code is null");
}
Map<String, String> headers = new HashMap<>();
headers.put("content-type", "application/json");
HashMap<String, Object> body = new HashMap<>();
body.put("code", paymentCode.toString());
IHttpClient httpClient = httpClientService.getHttpClient(HttpUsage.COORDINATOR_REST);
return httpClient.postJson("https://paynym.is/api/v1/token", Map.class, headers, body)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.map(Optional::get);
}
public Observable<Map<String, Object>> claimPayNym(String authToken, String signature) {
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("signature", signature);
IHttpClient httpClient = httpClientService.getHttpClient(HttpUsage.COORDINATOR_REST);
return httpClient.postJson("https://paynym.is/api/v1/claim", Map.class, headers, body)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.map(Optional::get);
}
public Observable<Map<String, Object>> addSamouraiPaymentCode(PaymentCode paymentCode, String authToken, String signature) {
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("signature", signature);
IHttpClient httpClient = httpClientService.getHttpClient(HttpUsage.COORDINATOR_REST);
return httpClient.postJson("https://paynym.is/api/v1/nym/add", Map.class, headers, body)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.map(Optional::get);
}
public Observable<Map<String, Object>> followPaymentCode(PaymentCode paymentCode, String authToken, String signature) {
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("signature", signature);
body.put("target", paymentCode.toString());
IHttpClient httpClient = httpClientService.getHttpClient(HttpUsage.COORDINATOR_REST);
return httpClient.postJson("https://paynym.is/api/v1/follow", Map.class, headers, body)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.map(Optional::get);
}
public Observable<Map<String, Object>> fetchPayNym(String nymIdentifier) {
Map<String, String> headers = new HashMap<>();
headers.put("content-type", "application/json");
HashMap<String, Object> body = new HashMap<>();
body.put("nym", nymIdentifier);
IHttpClient httpClient = httpClientService.getHttpClient(HttpUsage.COORDINATOR_REST);
return httpClient.postJson("https://paynym.is/api/v1/nym", Map.class, headers, body)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.map(Optional::get);
}
public Observable<PayNym> getPayNym(String nymIdentifier) {
return fetchPayNym(nymIdentifier).map(nymMap -> {
List<Map<String, Object>> codes = (List<Map<String, Object>>)nymMap.get("codes");
PaymentCode code = new PaymentCode((String)codes.get(0).get("code"));
return new PayNym(code, (String)nymMap.get("nymID"), (String)nymMap.get("nymName"), (Boolean)nymMap.get("segwit"));
});
}
public Observable<List<PayNym>> getFollowers(String nymIdentifier) {
return fetchPayNym(nymIdentifier).flatMap(nymMap -> {
List<Map<String, Object>> followers = (List<Map<String, Object>>)nymMap.get("following");
return Observable.fromArray(followers.stream().map(followerMap -> {
return new PayNym(new PaymentCode((String)followerMap.get("code")), (String)followerMap.get("nymId"), (String)followerMap.get("nymName"), (Boolean)followerMap.get("segwit"));
}).collect(Collectors.toList()));
});
}
}
@@ -14,23 +14,27 @@ import com.samourai.wallet.hd.HD_Wallet;
import com.samourai.wallet.hd.HD_WalletFactoryGeneric;
import com.sparrowwallet.drongo.Drongo;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.crypto.DumpedPrivateKey;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.nightjar.http.JavaHttpClientService;
import com.sparrowwallet.sparrow.AppServices;
import io.reactivex.Observable;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.security.Provider;
import java.util.ArrayList;
import java.util.List;
import java.util.*;
public class Soroban {
private static final Logger log = LoggerFactory.getLogger(Soroban.class);
protected static final HD_WalletFactoryGeneric hdWalletFactory = HD_WalletFactoryGeneric.getInstance();
protected static final Bip47UtilJava bip47Util = Bip47UtilJava.getInstance();
protected static final Provider PROVIDER_JAVA = Drongo.getProvider();
protected static final int TIMEOUT_MS = 60000;
@@ -38,13 +42,16 @@ public class Soroban {
private final SorobanServer sorobanServer;
private final JavaHttpClientService httpClientService;
private final PayNymService payNymService;
private HD_Wallet hdWallet;
private BIP47Wallet bip47Wallet;
private PaymentCode paymentCode;
public Soroban(Network network, HostAndPort torProxy) {
this.sorobanServer = SorobanServer.valueOf(network.getName().toUpperCase());
this.httpClientService = new JavaHttpClientService(torProxy);
this.payNymService = new PayNymService(httpClientService);
}
public HD_Wallet getHdWallet() {
@@ -55,6 +62,23 @@ public class Soroban {
return paymentCode;
}
public void setPaymentCode(Wallet wallet) {
if(wallet.isEncrypted()) {
throw new IllegalStateException("Wallet cannot be encrypted");
}
try {
Keystore keystore = wallet.getKeystores().get(0);
List<String> words = keystore.getSeed().getMnemonicCode();
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);
} catch(Exception e) {
throw new IllegalStateException("Could not create payment code", e);
}
}
public void setHDWallet(Wallet wallet) {
if(wallet.isEncrypted()) {
throw new IllegalStateException("Wallet cannot be encrypted");
@@ -66,11 +90,10 @@ public class Soroban {
int purpose = scriptType.getDefaultDerivation().get(0).num();
List<String> words = keystore.getSeed().getMnemonicCode();
String passphrase = keystore.getSeed().getPassphrase().asString();
HD_WalletFactoryGeneric hdWalletFactory = HD_WalletFactoryGeneric.getInstance();
byte[] seed = hdWalletFactory.computeSeedFromWords(words);
hdWallet = new HD_Wallet(purpose, new ArrayList<>(words), sorobanServer.getParams(), seed, passphrase);
BIP47Wallet bip47w = hdWalletFactory.getBIP47(hdWallet.getSeedHex(), hdWallet.getPassphrase(), sorobanServer.getParams());
paymentCode = bip47Util.getPaymentCode(bip47w);
bip47Wallet = hdWalletFactory.getBIP47(hdWallet.getSeedHex(), hdWallet.getPassphrase(), sorobanServer.getParams());
paymentCode = bip47Util.getPaymentCode(bip47Wallet);
} catch(Exception e) {
throw new IllegalStateException("Could not create Soroban HD wallet ", e);
}
@@ -124,6 +147,47 @@ public class Soroban {
httpClientService.shutdown();
}
public Observable<Map<String, Object>> createPayNym() {
return payNymService.createPayNym(paymentCode);
}
public Observable<Map<String, Object>> updateToken() {
return payNymService.updateToken(paymentCode);
}
public Observable<Map<String, Object>> claimPayNym(String authToken, String signature) {
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>> followPaymentCode(PaymentCode paymentCode, String authToken, String signature) {
return payNymService.followPaymentCode(paymentCode, authToken, signature);
}
public Observable<PayNym> getPayNym(String nymIdentifier) {
return payNymService.getPayNym(nymIdentifier);
}
public Observable<List<PayNym>> getFollowers() {
return payNymService.getFollowers(paymentCode.toString());
}
public Observable<String> getAuthToken(Map<String, Object> map) {
if(map.containsKey("token")) {
return Observable.just((String)map.get("token"));
}
return updateToken().map(tokenMap -> (String)tokenMap.get("token"));
}
public String getSignature(String authToken) {
ECKey notificationAddressKey = DumpedPrivateKey.fromBase58(bip47Wallet.getAccount(0).addressAt(0).getPrivateKeyString()).getKey();
return notificationAddressKey.signMessage(authToken, ScriptType.P2PKH);
}
public static class ShutdownService extends Service<Boolean> {
private final Soroban soroban;
@@ -1,6 +1,6 @@
package com.sparrowwallet.sparrow.soroban;
import com.samourai.wallet.cahoots.stonewallx2.STONEWALLx2;
import com.samourai.wallet.cahoots.Cahoots;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
@@ -21,9 +21,9 @@ import java.util.stream.Collectors;
public class SorobanController {
private static final Logger log = LoggerFactory.getLogger(SorobanController.class);
protected Transaction getTransaction(STONEWALLx2 stonewallx2) throws PSBTParseException {
if(stonewallx2.getPSBT() != null) {
PSBT psbt = new PSBT(stonewallx2.getPSBT().toBytes());
protected Transaction getTransaction(Cahoots cahoots) throws PSBTParseException {
if(cahoots.getPSBT() != null) {
PSBT psbt = new PSBT(cahoots.getPSBT().toBytes());
return psbt.getTransaction();
}
@@ -15,6 +15,7 @@ import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.TorService;
import com.sparrowwallet.sparrow.soroban.Soroban;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyCodeCombination;
import javafx.scene.input.KeyCombination;
@@ -179,6 +180,9 @@ public class WhirlpoolServices {
whirlpool.setHDWallet(walletId, decryptedWallet);
whirlpool.setResyncMixesDone(true);
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
soroban.setHDWallet(decryptedWallet);
for(StandardAccount whirlpoolAccount : StandardAccount.WHIRLPOOL_ACCOUNTS) {
if(decryptedWallet.getChildWallet(whirlpoolAccount) == null) {
Wallet childWallet = decryptedWallet.addChildWallet(whirlpoolAccount);
+1
View File
@@ -42,4 +42,5 @@ open module com.sparrowwallet.sparrow {
requires io.reactivex.rxjava2;
requires io.reactivex.rxjava2fx;
requires org.apache.commons.lang3;
requires net.sourceforge.streamsupport;
}