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

View file

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

2
drongo

@ -1 +1 @@
Subproject commit da14a9bf34945713494cfbef86f8f44aedbc727f
Subproject commit 16d348a91d4e7583d4a79a89188bfb68ac806be1

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,8 +294,8 @@ 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());
getPaymentCodeCounterparty(soroban).subscribe(paymentCodeCounterparty -> {
try {
SorobanCahootsService sorobanMeetingService = soroban.getSorobanCahootsService(initiatorCahootsWallet);
sorobanMeetingService.sendMeetingRequest(paymentCodeCounterparty, CahootsType.STONEWALLX2)
@ -200,7 +310,7 @@ public class InitiatorController extends SorobanController {
if(sorobanResponse.isAccept()) {
sorobanProgressBar.setProgress(0.1);
sorobanProgressLabel.setText("Mixing partner accepted!");
startInitiatorStonewall(initiatorCahootsWallet, paymentCodeCounterparty);
startInitiatorCollaborative(initiatorCahootsWallet, paymentCodeCounterparty);
} else {
step2Desc.setText("Mixing partner declined.");
sorobanProgressLabel.setVisible(false);
@ -209,7 +319,9 @@ public class InitiatorController extends SorobanController {
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()));
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 -> {
@ -220,9 +332,20 @@ public class InitiatorController extends SorobanController {
} 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;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -46,5 +46,5 @@
}
.field-control {
-fx-pref-width: 500px;
-fx-pref-width: 200px;
}

View file

@ -12,6 +12,8 @@
<?import com.sparrowwallet.sparrow.control.TransactionDiagram?>
<?import com.sparrowwallet.sparrow.control.ProgressTimer?>
<?import com.sparrowwallet.sparrow.control.CopyableTextField?>
<?import com.sparrowwallet.sparrow.control.PaymentCodeTextField?>
<?import com.sparrowwallet.sparrow.control.PayNymAvatar?>
<StackPane prefHeight="460.0" prefWidth="600.0" stylesheets="@counterparty.css, @../wallet/send.css, @../general.css" styleClass="counterparty-pane" fx:controller="com.sparrowwallet.sparrow.soroban.CounterpartyController" xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
<VBox spacing="20">
@ -31,16 +33,34 @@
<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 mixing partner will start the mix, and will need 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 two person coinjoin transaction using the Samourai Soroban service. Your mixing 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" />
<HBox styleClass="field-box">
<padding>
<Insets top="20" />
</padding>
<Label text="Payment code or PayNym:" styleClass="wide-field-label" />
<CopyableTextField fx:id="paymentCode" styleClass="field-control" editable="false"/>
<Label text="PayNym:" styleClass="field-label" />
<HBox spacing="11">
<CopyableTextField fx:id="payNym" promptText="Retrieving..." styleClass="field-control" editable="false"/>
<PayNymAvatar fx:id="payNymAvatar" />
</HBox>
<Button fx:id="payNymButton" text="Retrieve PayNym" onAction="#retrievePayNym" />
</HBox>
<HBox styleClass="field-box">
<Label text="Mix using:" styleClass="wide-field-label" />
<Label text="Payment code:" styleClass="field-label" />
<HBox spacing="10">
<PaymentCodeTextField fx:id="paymentCode" styleClass="field-control" editable="false"/>
<Button fx:id="paymentCodeQR" onAction="#showPayNymQR">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="QRCODE" />
</graphic>
<tooltip>
<Tooltip text="Show as QR code" />
</tooltip>
</Button>
</HBox>
</HBox>
<HBox styleClass="field-box">
<Label text="Mix using:" styleClass="field-label" />
<ComboBox fx:id="mixWallet" />
</HBox>
</VBox>
@ -60,7 +80,7 @@
<Insets top="20" />
</padding>
<Label text="Mixing partner:" styleClass="field-label" />
<Label fx:id="mixingPartner" text="Waiting for mixing partner..." styleClass="field-control"/>
<Label fx:id="mixingPartner" text="Waiting for mixing partner..." graphicTextGap="10" contentDisplay="RIGHT" />
<Label fx:id="meetingFail" text="Failed to find mixing partner." styleClass="failure" graphicTextGap="5">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="EXCLAMATION_CIRCLE" styleClass="failure" />

View file

@ -42,5 +42,5 @@
}
.field-control {
-fx-pref-width: 500px;
-fx-pref-width: 200px;
}

View file

@ -12,6 +12,8 @@
<?import javafx.geometry.Insets?>
<?import com.sparrowwallet.sparrow.control.TransactionDiagram?>
<?import com.sparrowwallet.sparrow.control.ProgressTimer?>
<?import com.sparrowwallet.sparrow.control.ComboBoxTextField?>
<?import com.sparrowwallet.sparrow.control.PayNymAvatar?>
<StackPane prefHeight="460.0" prefWidth="600.0" stylesheets="@initiator.css, @../wallet/send.css, @../general.css" styleClass="initiator-pane" fx:controller="com.sparrowwallet.sparrow.soroban.InitiatorController" xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
<VBox spacing="20">
@ -37,7 +39,13 @@
<Insets top="20" />
</padding>
<Label text="Payment code or PayNym:" styleClass="field-label" />
<TextField fx:id="counterparty" styleClass="field-control"/>
<HBox spacing="10">
<StackPane>
<ComboBox fx:id="payNymFollowers" />
<ComboBoxTextField fx:id="counterparty" styleClass="field-control" comboProperty="$payNymFollowers" />
</StackPane>
<PayNymAvatar fx:id="payNymAvatar" />
</HBox>
</HBox>
</VBox>
<VBox fx:id="step2" spacing="15">