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') { implementation('org.slf4j:jul-to-slf4j:1.7.30') {
exclude group: 'org.slf4j' 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:rxjava:2.2.15')
implementation('io.reactivex.rxjava2:rxjavafx:2.2.2') implementation('io.reactivex.rxjava2:rxjavafx:2.2.2')
implementation('org.apache.commons:commons-lang3:3.7') implementation('org.apache.commons:commons-lang3:3.7')
implementation('net.sourceforge.streamsupport:streamsupport:1.7.0')
testImplementation('junit:junit:4.12') testImplementation('junit:junit:4.12')
} }
@ -457,7 +458,7 @@ extraJavaModuleInfo {
module('cbor-0.9.jar', 'co.nstant.in.cbor', '0.9') { module('cbor-0.9.jar', 'co.nstant.in.cbor', '0.9') {
exports('co.nstant.in.cbor') 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('com.google.common')
requires('net.sourceforge.streamsupport') requires('net.sourceforge.streamsupport')
requires('org.slf4j') 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); whirlpool.setHDWallet(storage.getWalletId(wallet), copy);
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId); Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
soroban.setHDWallet(copy); 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(); StandardAccount standardAccount = wallet.getStandardAccountType();
@ -1236,7 +1240,7 @@ public class AppController implements Initializable {
WalletPasswordDialog dlg = new WalletPasswordDialog(copy.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD); WalletPasswordDialog dlg = new WalletPasswordDialog(copy.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
Optional<SecureString> password = dlg.showAndWait(); Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) { if(password.isPresent()) {
Storage storage = AppServices.get().getOpenWallets().get(wallet); Storage storage = selectedWalletForm.getStorage();
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get(), true); Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get(), true);
keyDerivationService.setOnSucceeded(workerStateEvent -> { keyDerivationService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(selectedWalletForm.getWalletId(), TimedEvent.Action.END, "Done")); EventManager.get().post(new StorageEvent(selectedWalletForm.getWalletId(), TimedEvent.Action.END, "Done"));

View file

@ -1,13 +1,19 @@
package com.sparrowwallet.sparrow.control; package com.sparrowwallet.sparrow.control;
import javafx.animation.FadeTransition; import javafx.animation.FadeTransition;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.beans.InvalidationListener; import javafx.beans.InvalidationListener;
import javafx.beans.Observable; import javafx.beans.Observable;
import javafx.beans.property.ObjectProperty; import javafx.beans.property.ObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.event.EventHandler;
import javafx.scene.Cursor; import javafx.scene.Cursor;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.control.Tooltip;
import javafx.scene.input.Clipboard; import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent; import javafx.scene.input.ClipboardContent;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Region; import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane; import javafx.scene.layout.StackPane;
import javafx.util.Duration; import javafx.util.Duration;
@ -16,10 +22,36 @@ import org.controlsfx.control.textfield.CustomTextField;
public class CopyableTextField extends CustomTextField { public class CopyableTextField extends CustomTextField {
private static final Duration FADE_DURATION = Duration.millis(350); 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() { public CopyableTextField() {
super(); super();
getStyleClass().add("copyable-text-field"); getStyleClass().add("copyable-text-field");
setupCopyButtonField(super.rightProperty()); 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) { private void setupCopyButtonField(ObjectProperty<Node> rightProperty) {
@ -31,7 +63,7 @@ public class CopyableTextField extends CustomTextField {
copyButtonPane.setCursor(Cursor.DEFAULT); copyButtonPane.setCursor(Cursor.DEFAULT);
copyButtonPane.setOnMouseReleased(e -> { copyButtonPane.setOnMouseReleased(e -> {
ClipboardContent content = new ClipboardContent(); ClipboardContent content = new ClipboardContent();
content.putString(getText()); content.putString(getCopyText());
Clipboard.getSystemClipboard().setContent(content); 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 File electrumServerCert;
private boolean useProxy; private boolean useProxy;
private String proxyServer; private String proxyServer;
private boolean usePayNym;
private Double appWidth; private Double appWidth;
private Double appHeight; private Double appHeight;
@ -501,6 +502,15 @@ public class Config {
flush(); flush();
} }
public boolean isUsePayNym() {
return usePayNym;
}
public void setUsePayNym(boolean usePayNym) {
this.usePayNym = usePayNym;
flush();
}
public Double getAppWidth() { public Double getAppWidth() {
return appWidth; 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.soroban.client.cahoots.SorobanCahootsService;
import com.samourai.wallet.bip47.rpc.PaymentCode; import com.samourai.wallet.bip47.rpc.PaymentCode;
import com.samourai.wallet.cahoots.Cahoots; 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.protocol.Transaction;
import com.sparrowwallet.drongo.psbt.PSBTParseException; import com.sparrowwallet.drongo.psbt.PSBTParseException;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex; import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode; import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.control.CopyableTextField; import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.control.ProgressTimer; import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.control.TransactionDiagram;
import io.reactivex.rxjavafx.schedulers.JavaFxScheduler; import io.reactivex.rxjavafx.schedulers.JavaFxScheduler;
import io.reactivex.schedulers.Schedulers; import io.reactivex.schedulers.Schedulers;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.property.ObjectProperty; import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections; import javafx.collections.FXCollections;
import javafx.event.ActionEvent;
import javafx.fxml.FXML; import javafx.fxml.FXML;
import javafx.scene.control.ComboBox; import javafx.scene.control.*;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressBar;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
import javafx.util.StringConverter; import javafx.util.StringConverter;
import org.controlsfx.glyphfont.Glyph; import org.controlsfx.glyphfont.Glyph;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.util.ArrayList; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import static com.sparrowwallet.sparrow.soroban.Soroban.TIMEOUT_MS; import static com.sparrowwallet.sparrow.soroban.Soroban.TIMEOUT_MS;
@ -56,7 +53,19 @@ public class CounterpartyController extends SorobanController {
private VBox step4; private VBox step4;
@FXML @FXML
private CopyableTextField paymentCode; private CopyableTextField payNym;
@FXML
private PayNymAvatar payNymAvatar;
@FXML
private Button payNymButton;
@FXML
private PaymentCodeTextField paymentCode;
@FXML
private Button paymentCodeQR;
@FXML @FXML
private ComboBox<Wallet> mixWallet; private ComboBox<Wallet> mixWallet;
@ -144,7 +153,21 @@ public class CounterpartyController extends SorobanController {
throw new IllegalStateException("Soroban HD wallet must be set"); 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()); mixingPartner.managedProperty().bind(mixingPartner.visibleProperty());
meetingFail.managedProperty().bind(meetingFail.visibleProperty()); meetingFail.managedProperty().bind(meetingFail.visibleProperty());
@ -190,18 +213,18 @@ public class CounterpartyController extends SorobanController {
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform()) .observeOn(JavaFxScheduler.platform())
.subscribe(requestMessage -> { .subscribe(requestMessage -> {
PaymentCode paymentCodeInitiator = new PaymentCode(requestMessage.getSender()); String code = requestMessage.getSender();
mixingPartner.setText(requestMessage.getSender()); CahootsType cahootsType = requestMessage.getType();
mixType.setText(requestMessage.getType().getLabel()); PaymentCode paymentCodeInitiator = new PaymentCode(code);
mixDetails.setVisible(true); updateMixPartner(soroban, paymentCodeInitiator, cahootsType);
meetingReceived.set(Boolean.TRUE);
Boolean accepted = (Boolean)Platform.enterNestedEventLoop(meetingAccepted); Boolean accepted = (Boolean)Platform.enterNestedEventLoop(meetingAccepted);
sorobanMeetingService.sendMeetingResponse(paymentCodeInitiator, requestMessage, accepted) sorobanMeetingService.sendMeetingResponse(paymentCodeInitiator, requestMessage, accepted)
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform()) .observeOn(JavaFxScheduler.platform())
.subscribe(responseMessage -> { .subscribe(responseMessage -> {
if(accepted) { if(accepted) {
startCounterpartyStonewall(counterpartyCahootsWallet, paymentCodeInitiator); startCounterpartyCollaboration(counterpartyCahootsWallet, paymentCodeInitiator, cahootsType);
followPaymentCode(soroban, paymentCodeInitiator);
} }
}, error -> { }, error -> {
log.error("Error sending meeting response", 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..."); sorobanProgressLabel.setText("Creating mix transaction...");
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId); Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
@ -227,7 +271,7 @@ public class CounterpartyController extends SorobanController {
try { try {
SorobanCahootsService sorobanCahootsService = soroban.getSorobanCahootsService(counterpartyCahootsWallet); 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) sorobanCahootsService.contributor(counterpartyCahootsWallet.getAccount(), cahootsContext, initiatorPaymentCode, TIMEOUT_MS)
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform()) .observeOn(JavaFxScheduler.platform())
@ -240,16 +284,16 @@ public class CounterpartyController extends SorobanController {
if(cahoots.getStep() == 3) { if(cahoots.getStep() == 3) {
sorobanProgressLabel.setText("Your mix partner is reviewing the transaction..."); sorobanProgressLabel.setText("Your mix partner is reviewing the transaction...");
step3Timer.start(); step3Timer.start();
} else if(cahoots.getStep() >= 4 && cahoots instanceof STONEWALLx2 stonewallx2) { } else if(cahoots.getStep() >= 4) {
try { try {
Transaction transaction = getTransaction(stonewallx2); Transaction transaction = getTransaction(cahoots);
if(transaction != null) { if(transaction != null) {
transactionProperty.set(transaction); transactionProperty.set(transaction);
updateTransactionDiagram(transactionDiagram, wallet, null, transaction); updateTransactionDiagram(transactionDiagram, wallet, null, transaction);
next(); next();
} }
} catch(PSBTParseException e) { } catch(PSBTParseException e) {
log.error("Invalid Stonewallx2 PSBT created", e); log.error("Invalid collaborative PSBT created", e);
step3Desc.setText("Invalid transaction created."); step3Desc.setText("Invalid transaction created.");
sorobanProgressLabel.setVisible(false); 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() { public boolean next() {
if(step1.isVisible()) { if(step1.isVisible()) {
step1.setVisible(false); step1.setVisible(false);
@ -296,6 +353,50 @@ public class CounterpartyController extends SorobanController {
meetingAccepted.set(Boolean.FALSE); 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() { public ObjectProperty<Boolean> meetingReceivedProperty() {
return meetingReceived; 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.bip47.rpc.PaymentCode;
import com.samourai.wallet.cahoots.Cahoots; import com.samourai.wallet.cahoots.Cahoots;
import com.samourai.wallet.cahoots.CahootsType; import com.samourai.wallet.cahoots.CahootsType;
import com.samourai.wallet.cahoots.stonewallx2.STONEWALLx2;
import com.sparrowwallet.drongo.SecureString; import com.sparrowwallet.drongo.SecureString;
import com.sparrowwallet.drongo.crypto.ECKey; import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.crypto.EncryptionType; import com.sparrowwallet.drongo.crypto.EncryptionType;
@ -17,28 +16,35 @@ import com.sparrowwallet.drongo.psbt.PSBTParseException;
import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.PayNymAvatar;
import com.sparrowwallet.sparrow.control.ProgressTimer; import com.sparrowwallet.sparrow.control.ProgressTimer;
import com.sparrowwallet.sparrow.control.TransactionDiagram; import com.sparrowwallet.sparrow.control.TransactionDiagram;
import com.sparrowwallet.sparrow.control.WalletPasswordDialog; import com.sparrowwallet.sparrow.control.WalletPasswordDialog;
import com.sparrowwallet.sparrow.event.StorageEvent; import com.sparrowwallet.sparrow.event.StorageEvent;
import com.sparrowwallet.sparrow.event.TimedEvent; import com.sparrowwallet.sparrow.event.TimedEvent;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage; import com.sparrowwallet.sparrow.io.Storage;
import io.reactivex.Observable;
import io.reactivex.rxjavafx.schedulers.JavaFxScheduler; import io.reactivex.rxjavafx.schedulers.JavaFxScheduler;
import io.reactivex.schedulers.Schedulers; import io.reactivex.schedulers.Schedulers;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.property.ObjectProperty; import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.fxml.FXML; import javafx.fxml.FXML;
import javafx.scene.control.ButtonType; import javafx.scene.control.*;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressBar;
import javafx.scene.control.TextField;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
import javafx.util.StringConverter;
import org.controlsfx.glyphfont.Glyph; 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.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.util.*; 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.AppServices.showErrorDialog;
import static com.sparrowwallet.sparrow.soroban.Soroban.TIMEOUT_MS; 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 { public class InitiatorController extends SorobanController {
private static final Logger log = LoggerFactory.getLogger(InitiatorController.class); 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 String walletId;
private Wallet wallet; private Wallet wallet;
private WalletTransaction walletTransaction; private WalletTransaction walletTransaction;
@ -59,9 +68,15 @@ public class InitiatorController extends SorobanController {
@FXML @FXML
private VBox step3; private VBox step3;
@FXML
private ComboBox<PayNym> payNymFollowers;
@FXML @FXML
private TextField counterparty; private TextField counterparty;
@FXML
private PayNymAvatar payNymAvatar;
@FXML @FXML
private ProgressTimer step2Timer; private ProgressTimer step2Timer;
@ -86,6 +101,8 @@ public class InitiatorController extends SorobanController {
@FXML @FXML
private TransactionDiagram transactionDiagram; private TransactionDiagram transactionDiagram;
private final ObjectProperty<PaymentCode> counterpartyPaymentCode = new SimpleObjectProperty<>(null);
private final ObjectProperty<Step> stepProperty = new SimpleObjectProperty<>(Step.SETUP); private final ObjectProperty<Step> stepProperty = new SimpleObjectProperty<>(Step.SETUP);
private final ObjectProperty<Boolean> transactionAccepted = new SimpleObjectProperty<>(null); private final ObjectProperty<Boolean> transactionAccepted = new SimpleObjectProperty<>(null);
@ -129,6 +146,99 @@ public class InitiatorController extends SorobanController {
step2Timer.start(); 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() { private void startInitiatorMeetingRequest() {
@ -184,8 +294,8 @@ public class InitiatorController extends SorobanController {
private void startInitiatorMeetingRequest(Soroban soroban, Wallet wallet) { private void startInitiatorMeetingRequest(Soroban soroban, Wallet wallet) {
SparrowCahootsWallet initiatorCahootsWallet = soroban.getCahootsWallet(wallet, (long)walletTransaction.getFeeRate()); SparrowCahootsWallet initiatorCahootsWallet = soroban.getCahootsWallet(wallet, (long)walletTransaction.getFeeRate());
PaymentCode paymentCodeCounterparty = new PaymentCode(counterparty.getText());
getPaymentCodeCounterparty(soroban).subscribe(paymentCodeCounterparty -> {
try { try {
SorobanCahootsService sorobanMeetingService = soroban.getSorobanCahootsService(initiatorCahootsWallet); SorobanCahootsService sorobanMeetingService = soroban.getSorobanCahootsService(initiatorCahootsWallet);
sorobanMeetingService.sendMeetingRequest(paymentCodeCounterparty, CahootsType.STONEWALLX2) sorobanMeetingService.sendMeetingRequest(paymentCodeCounterparty, CahootsType.STONEWALLX2)
@ -200,7 +310,7 @@ public class InitiatorController extends SorobanController {
if(sorobanResponse.isAccept()) { if(sorobanResponse.isAccept()) {
sorobanProgressBar.setProgress(0.1); sorobanProgressBar.setProgress(0.1);
sorobanProgressLabel.setText("Mixing partner accepted!"); sorobanProgressLabel.setText("Mixing partner accepted!");
startInitiatorStonewall(initiatorCahootsWallet, paymentCodeCounterparty); startInitiatorCollaborative(initiatorCahootsWallet, paymentCodeCounterparty);
} else { } else {
step2Desc.setText("Mixing partner declined."); step2Desc.setText("Mixing partner declined.");
sorobanProgressLabel.setVisible(false); sorobanProgressLabel.setVisible(false);
@ -209,7 +319,9 @@ public class InitiatorController extends SorobanController {
log.error("Error receiving meeting response", error); log.error("Error receiving meeting response", error);
String cutFrom = "Exception: "; String cutFrom = "Exception: ";
int index = error.getMessage().lastIndexOf(cutFrom); 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); sorobanProgressLabel.setVisible(false);
}); });
}, error -> { }, error -> {
@ -220,9 +332,20 @@ public class InitiatorController extends SorobanController {
} catch(Exception e) { } catch(Exception e) {
log.error("Error sending meeting request", 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); Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
Payment payment = walletTransaction.getPayments().get(0); Payment payment = walletTransaction.getPayments().get(0);
@ -255,9 +378,9 @@ public class InitiatorController extends SorobanController {
Cahoots cahoots = cahootsMessage.getCahoots(); Cahoots cahoots = cahootsMessage.getCahoots();
sorobanProgressBar.setProgress((double)(cahoots.getStep() + 1) / 5); sorobanProgressBar.setProgress((double)(cahoots.getStep() + 1) / 5);
if(cahoots.getStep() >= 3 && cahoots instanceof STONEWALLx2 stonewallx2) { if(cahoots.getStep() >= 3) {
try { try {
Transaction transaction = getTransaction(stonewallx2); Transaction transaction = getTransaction(cahoots);
if(transaction != null) { if(transaction != null) {
transactionProperty.set(transaction); transactionProperty.set(transaction);
if(cahoots.getStep() == 3) { if(cahoots.getStep() == 3) {
@ -273,7 +396,7 @@ public class InitiatorController extends SorobanController {
} }
} }
} catch(PSBTParseException e) { } catch(PSBTParseException e) {
log.error("Invalid Stonewallx2 PSBT created", e); log.error("Invalid collaborative PSBT created", e);
step2Desc.setText("Invalid transaction created."); step2Desc.setText("Invalid transaction created.");
sorobanProgressLabel.setVisible(false); 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() { public void accept() {
transactionAccepted.set(Boolean.TRUE); transactionAccepted.set(Boolean.TRUE);
} }
@ -315,6 +458,10 @@ public class InitiatorController extends SorobanController {
transactionAccepted.set(Boolean.FALSE); transactionAccepted.set(Boolean.FALSE);
} }
public ObjectProperty<PaymentCode> counterpartyPaymentCodeProperty() {
return counterpartyPaymentCode;
}
public ObjectProperty<Step> stepProperty() { public ObjectProperty<Step> stepProperty() {
return stepProperty; return stepProperty;
} }

View file

@ -48,6 +48,7 @@ public class InitiatorDialog extends Dialog<Transaction> {
Button nextButton = (Button)dialogPane.lookupButton(nextButtonType); Button nextButton = (Button)dialogPane.lookupButton(nextButtonType);
Button cancelButton = (Button)dialogPane.lookupButton(cancelButtonType); Button cancelButton = (Button)dialogPane.lookupButton(cancelButtonType);
Button broadcastButton = (Button)dialogPane.lookupButton(broadcastButtonType); Button broadcastButton = (Button)dialogPane.lookupButton(broadcastButtonType);
nextButton.setDisable(true);
broadcastButton.setDisable(true); broadcastButton.setDisable(true);
nextButton.managedProperty().bind(nextButton.visibleProperty()); nextButton.managedProperty().bind(nextButton.visibleProperty());
@ -55,6 +56,10 @@ public class InitiatorDialog extends Dialog<Transaction> {
broadcastButton.visibleProperty().bind(nextButton.visibleProperty().not()); broadcastButton.visibleProperty().bind(nextButton.visibleProperty().not());
initiatorController.counterpartyPaymentCodeProperty().addListener((observable, oldValue, paymentCode) -> {
nextButton.setDisable(paymentCode == null);
});
initiatorController.stepProperty().addListener((observable, oldValue, step) -> { initiatorController.stepProperty().addListener((observable, oldValue, step) -> {
if(step == InitiatorController.Step.SETUP) { if(step == InitiatorController.Step.SETUP) {
nextButton.setDisable(false); 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.samourai.wallet.hd.HD_WalletFactoryGeneric;
import com.sparrowwallet.drongo.Drongo; import com.sparrowwallet.drongo.Drongo;
import com.sparrowwallet.drongo.Network; 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.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore; import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.nightjar.http.JavaHttpClientService; import com.sparrowwallet.nightjar.http.JavaHttpClientService;
import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.AppServices;
import io.reactivex.Observable;
import javafx.concurrent.Service; import javafx.concurrent.Service;
import javafx.concurrent.Task; import javafx.concurrent.Task;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.security.Provider; import java.security.Provider;
import java.util.ArrayList; import java.util.*;
import java.util.List;
public class Soroban { public class Soroban {
private static final Logger log = LoggerFactory.getLogger(Soroban.class); 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 Bip47UtilJava bip47Util = Bip47UtilJava.getInstance();
protected static final Provider PROVIDER_JAVA = Drongo.getProvider(); protected static final Provider PROVIDER_JAVA = Drongo.getProvider();
protected static final int TIMEOUT_MS = 60000; protected static final int TIMEOUT_MS = 60000;
@ -38,13 +42,16 @@ public class Soroban {
private final SorobanServer sorobanServer; private final SorobanServer sorobanServer;
private final JavaHttpClientService httpClientService; private final JavaHttpClientService httpClientService;
private final PayNymService payNymService;
private HD_Wallet hdWallet; private HD_Wallet hdWallet;
private BIP47Wallet bip47Wallet;
private PaymentCode paymentCode; private PaymentCode paymentCode;
public Soroban(Network network, HostAndPort torProxy) { public Soroban(Network network, HostAndPort torProxy) {
this.sorobanServer = SorobanServer.valueOf(network.getName().toUpperCase()); this.sorobanServer = SorobanServer.valueOf(network.getName().toUpperCase());
this.httpClientService = new JavaHttpClientService(torProxy); this.httpClientService = new JavaHttpClientService(torProxy);
this.payNymService = new PayNymService(httpClientService);
} }
public HD_Wallet getHdWallet() { public HD_Wallet getHdWallet() {
@ -55,6 +62,23 @@ public class Soroban {
return paymentCode; 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) { public void setHDWallet(Wallet wallet) {
if(wallet.isEncrypted()) { if(wallet.isEncrypted()) {
throw new IllegalStateException("Wallet cannot be encrypted"); throw new IllegalStateException("Wallet cannot be encrypted");
@ -66,11 +90,10 @@ public class Soroban {
int purpose = scriptType.getDefaultDerivation().get(0).num(); int purpose = scriptType.getDefaultDerivation().get(0).num();
List<String> words = keystore.getSeed().getMnemonicCode(); List<String> words = keystore.getSeed().getMnemonicCode();
String passphrase = keystore.getSeed().getPassphrase().asString(); String passphrase = keystore.getSeed().getPassphrase().asString();
HD_WalletFactoryGeneric hdWalletFactory = HD_WalletFactoryGeneric.getInstance();
byte[] seed = hdWalletFactory.computeSeedFromWords(words); byte[] seed = hdWalletFactory.computeSeedFromWords(words);
hdWallet = new HD_Wallet(purpose, new ArrayList<>(words), sorobanServer.getParams(), seed, passphrase); hdWallet = new HD_Wallet(purpose, new ArrayList<>(words), sorobanServer.getParams(), seed, passphrase);
BIP47Wallet bip47w = hdWalletFactory.getBIP47(hdWallet.getSeedHex(), hdWallet.getPassphrase(), sorobanServer.getParams()); bip47Wallet = hdWalletFactory.getBIP47(hdWallet.getSeedHex(), hdWallet.getPassphrase(), sorobanServer.getParams());
paymentCode = bip47Util.getPaymentCode(bip47w); paymentCode = bip47Util.getPaymentCode(bip47Wallet);
} catch(Exception e) { } catch(Exception e) {
throw new IllegalStateException("Could not create Soroban HD wallet ", e); throw new IllegalStateException("Could not create Soroban HD wallet ", e);
} }
@ -124,6 +147,47 @@ public class Soroban {
httpClientService.shutdown(); 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> { public static class ShutdownService extends Service<Boolean> {
private final Soroban soroban; private final Soroban soroban;

View file

@ -1,6 +1,6 @@
package com.sparrowwallet.sparrow.soroban; 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.KeyPurpose;
import com.sparrowwallet.drongo.address.Address; import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.protocol.Sha256Hash; import com.sparrowwallet.drongo.protocol.Sha256Hash;
@ -21,9 +21,9 @@ import java.util.stream.Collectors;
public class SorobanController { public class SorobanController {
private static final Logger log = LoggerFactory.getLogger(SorobanController.class); private static final Logger log = LoggerFactory.getLogger(SorobanController.class);
protected Transaction getTransaction(STONEWALLx2 stonewallx2) throws PSBTParseException { protected Transaction getTransaction(Cahoots cahoots) throws PSBTParseException {
if(stonewallx2.getPSBT() != null) { if(cahoots.getPSBT() != null) {
PSBT psbt = new PSBT(stonewallx2.getPSBT().toBytes()); PSBT psbt = new PSBT(cahoots.getPSBT().toBytes());
return psbt.getTransaction(); 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.Config;
import com.sparrowwallet.sparrow.io.Storage; import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.TorService; import com.sparrowwallet.sparrow.net.TorService;
import com.sparrowwallet.sparrow.soroban.Soroban;
import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCodeCombination;
import javafx.scene.input.KeyCombination; import javafx.scene.input.KeyCombination;
@ -179,6 +180,9 @@ public class WhirlpoolServices {
whirlpool.setHDWallet(walletId, decryptedWallet); whirlpool.setHDWallet(walletId, decryptedWallet);
whirlpool.setResyncMixesDone(true); whirlpool.setResyncMixesDone(true);
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
soroban.setHDWallet(decryptedWallet);
for(StandardAccount whirlpoolAccount : StandardAccount.WHIRLPOOL_ACCOUNTS) { for(StandardAccount whirlpoolAccount : StandardAccount.WHIRLPOOL_ACCOUNTS) {
if(decryptedWallet.getChildWallet(whirlpoolAccount) == null) { if(decryptedWallet.getChildWallet(whirlpoolAccount) == null) {
Wallet childWallet = decryptedWallet.addChildWallet(whirlpoolAccount); Wallet childWallet = decryptedWallet.addChildWallet(whirlpoolAccount);

View file

@ -42,4 +42,5 @@ open module com.sparrowwallet.sparrow {
requires io.reactivex.rxjava2; requires io.reactivex.rxjava2;
requires io.reactivex.rxjava2fx; requires io.reactivex.rxjava2fx;
requires org.apache.commons.lang3; requires org.apache.commons.lang3;
requires net.sourceforge.streamsupport;
} }

View file

@ -46,5 +46,5 @@
} }
.field-control { .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.TransactionDiagram?>
<?import com.sparrowwallet.sparrow.control.ProgressTimer?> <?import com.sparrowwallet.sparrow.control.ProgressTimer?>
<?import com.sparrowwallet.sparrow.control.CopyableTextField?> <?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"> <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"> <VBox spacing="20">
@ -31,16 +33,34 @@
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="20" icon="RANDOM" styleClass="title-icon" /> <Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="20" icon="RANDOM" styleClass="title-icon" />
</graphic> </graphic>
</Label> </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"> <HBox styleClass="field-box">
<padding> <padding>
<Insets top="20" /> <Insets top="20" />
</padding> </padding>
<Label text="Payment code or PayNym:" styleClass="wide-field-label" /> <Label text="PayNym:" styleClass="field-label" />
<CopyableTextField fx:id="paymentCode" styleClass="field-control" editable="false"/> <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>
<HBox styleClass="field-box"> <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" /> <ComboBox fx:id="mixWallet" />
</HBox> </HBox>
</VBox> </VBox>
@ -60,7 +80,7 @@
<Insets top="20" /> <Insets top="20" />
</padding> </padding>
<Label text="Mixing partner:" styleClass="field-label" /> <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"> <Label fx:id="meetingFail" text="Failed to find mixing partner." styleClass="failure" graphicTextGap="5">
<graphic> <graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="EXCLAMATION_CIRCLE" styleClass="failure" /> <Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="EXCLAMATION_CIRCLE" styleClass="failure" />

View file

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

View file

@ -12,6 +12,8 @@
<?import javafx.geometry.Insets?> <?import javafx.geometry.Insets?>
<?import com.sparrowwallet.sparrow.control.TransactionDiagram?> <?import com.sparrowwallet.sparrow.control.TransactionDiagram?>
<?import com.sparrowwallet.sparrow.control.ProgressTimer?> <?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"> <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"> <VBox spacing="20">
@ -37,7 +39,13 @@
<Insets top="20" /> <Insets top="20" />
</padding> </padding>
<Label text="Payment code or PayNym:" styleClass="field-label" /> <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> </HBox>
</VBox> </VBox>
<VBox fx:id="step2" spacing="15"> <VBox fx:id="step2" spacing="15">