create two person coinjoin transactions using soroban

This commit is contained in:
Craig Raw 2021-11-25 16:15:59 +02:00
parent 72768362a5
commit 0302913c3f
35 changed files with 1939 additions and 40 deletions

View file

@ -91,7 +91,10 @@ dependencies {
implementation('org.slf4j:jul-to-slf4j:1.7.30') {
exclude group: 'org.slf4j'
}
implementation('com.sparrowwallet.nightjar:nightjar:0.2.19')
implementation('com.sparrowwallet.nightjar:nightjar:0.2.21')
implementation('io.reactivex.rxjava2:rxjava:2.2.15')
implementation('io.reactivex.rxjava2:rxjavafx:2.2.2')
implementation('org.apache.commons:commons-lang3:3.7')
testImplementation('junit:junit:4.12')
}
@ -431,6 +434,11 @@ extraJavaModuleInfo {
requires('javafx.graphics')
requires('javafx.controls')
}
module('rxjavafx-2.2.2.jar', 'io.reactivex.rxjava2fx', '2.2.2') {
exports('io.reactivex.rxjavafx.schedulers')
requires('io.reactivex.rxjava2')
requires('javafx.graphics')
}
module('wellbehavedfx-0.3.3.jar', 'org.fxmisc.wellbehaved', '0.3.3') {
requires('javafx.base')
requires('javafx.graphics')
@ -449,7 +457,7 @@ extraJavaModuleInfo {
module('cbor-0.9.jar', 'co.nstant.in.cbor', '0.9') {
exports('co.nstant.in.cbor')
}
module('nightjar-0.2.19.jar', 'com.sparrowwallet.nightjar', '0.2.19') {
module('nightjar-0.2.21.jar', 'com.sparrowwallet.nightjar', '0.2.21') {
requires('com.google.common')
requires('net.sourceforge.streamsupport')
requires('org.slf4j')
@ -459,6 +467,7 @@ extraJavaModuleInfo {
requires('com.fasterxml.jackson.core')
requires('logback.classic')
requires('org.json')
requires('io.reactivex.rxjava2')
exports('com.samourai.http.client')
exports('com.samourai.tor.client')
exports('com.samourai.wallet.api.backend')
@ -466,6 +475,17 @@ extraJavaModuleInfo {
exports('com.samourai.wallet.client.indexHandler')
exports('com.samourai.wallet.hd')
exports('com.samourai.wallet.util')
exports('com.samourai.wallet.bip47.rpc')
exports('com.samourai.wallet.bip47.rpc.java')
exports('com.samourai.wallet.cahoots')
exports('com.samourai.wallet.cahoots.psbt')
exports('com.samourai.wallet.cahoots.stonewallx2')
exports('com.samourai.soroban.cahoots')
exports('com.samourai.soroban.client')
exports('com.samourai.soroban.client.cahoots')
exports('com.samourai.soroban.client.meeting')
exports('com.samourai.soroban.client.rpc')
exports('com.samourai.wallet.send')
exports('com.samourai.whirlpool.client.event')
exports('com.samourai.whirlpool.client.wallet')
exports('com.samourai.whirlpool.client.wallet.beans')

2
drongo

@ -1 +1 @@
Subproject commit 3a061cb73ae318fcbe7ea1dcb0b670e78803d9fa
Subproject commit 4a4a62f239f5de1e25e927ee9996326383ea7f89

View file

@ -28,6 +28,9 @@ import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.net.ServerType;
import com.sparrowwallet.sparrow.preferences.PreferenceGroup;
import com.sparrowwallet.sparrow.preferences.PreferencesDialog;
import com.sparrowwallet.sparrow.soroban.CounterpartyDialog;
import com.sparrowwallet.sparrow.soroban.Soroban;
import com.sparrowwallet.sparrow.soroban.SorobanServices;
import com.sparrowwallet.sparrow.transaction.TransactionController;
import com.sparrowwallet.sparrow.transaction.TransactionData;
import com.sparrowwallet.sparrow.transaction.TransactionView;
@ -58,10 +61,7 @@ import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.*;
import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import javafx.stage.Window;
import javafx.stage.*;
import javafx.util.Duration;
import org.controlsfx.control.Notifications;
import org.controlsfx.control.StatusBar;
@ -165,6 +165,9 @@ public class AppController implements Initializable {
@FXML
private MenuItem sendToMany;
@FXML
private MenuItem findMixingPartner;
@FXML
private CheckMenuItem preventSleep;
private static final BooleanProperty preventSleepProperty = new SimpleBooleanProperty();
@ -324,6 +327,10 @@ public class AppController implements Initializable {
lockWallet.setDisable(true);
refreshWallet.disableProperty().bind(Bindings.or(exportWallet.disableProperty(), Bindings.or(serverToggle.disableProperty(), AppServices.onlineProperty().not())));
sendToMany.disableProperty().bind(exportWallet.disableProperty());
findMixingPartner.setDisable(true);
AppServices.onlineProperty().addListener((observable, oldValue, newValue) -> {
findMixingPartner.setDisable(exportWallet.isDisable() || !SorobanServices.canWalletMix(getSelectedWalletForm().getWallet()) || !newValue);
});
setServerType(Config.get().getServerType());
serverToggle.setSelected(isConnected());
@ -980,6 +987,8 @@ public class AppController implements Initializable {
Whirlpool whirlpool = AppServices.getWhirlpoolServices().getWhirlpool(walletId);
whirlpool.setScode(wallet.getMasterMixConfig().getScode());
whirlpool.setHDWallet(storage.getWalletId(wallet), copy);
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
soroban.setHDWallet(copy);
}
StandardAccount standardAccount = wallet.getStandardAccountType();
@ -1216,6 +1225,70 @@ public class AppController implements Initializable {
}
}
public void findMixingPartner(ActionEvent event) {
WalletForm selectedWalletForm = getSelectedWalletForm();
if(selectedWalletForm != null) {
Wallet wallet = selectedWalletForm.getWallet();
Soroban soroban = AppServices.getSorobanServices().getSoroban(selectedWalletForm.getWalletId());
if(soroban.getHdWallet() == null) {
if(wallet.isEncrypted()) {
Wallet copy = wallet.copy();
WalletPasswordDialog dlg = new WalletPasswordDialog(copy.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
Storage storage = AppServices.get().getOpenWallets().get(wallet);
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get(), true);
keyDerivationService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(selectedWalletForm.getWalletId(), TimedEvent.Action.END, "Done"));
ECKey encryptionFullKey = keyDerivationService.getValue();
Key key = new Key(encryptionFullKey.getPrivKeyBytes(), storage.getKeyDeriver().getSalt(), EncryptionType.Deriver.ARGON2);
copy.decrypt(key);
try {
soroban.setHDWallet(copy);
CounterpartyDialog counterpartyDialog = new CounterpartyDialog(selectedWalletForm.getWalletId(), selectedWalletForm.getWallet());
if(Network.get() == Network.TESTNET) {
counterpartyDialog.initModality(Modality.NONE);
}
counterpartyDialog.showAndWait();
} finally {
key.clear();
encryptionFullKey.clear();
password.get().clear();
}
});
keyDerivationService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(selectedWalletForm.getWalletId(), TimedEvent.Action.END, "Failed"));
if(keyDerivationService.getException() instanceof InvalidPasswordException) {
Optional<ButtonType> optResponse = showErrorDialog("Invalid Password", "The wallet password was invalid. Try again?", ButtonType.CANCEL, ButtonType.OK);
if(optResponse.isPresent() && optResponse.get().equals(ButtonType.OK)) {
Platform.runLater(() -> findMixingPartner(null));
}
} else {
log.error("Error deriving wallet key", keyDerivationService.getException());
}
});
EventManager.get().post(new StorageEvent(selectedWalletForm.getWalletId(), TimedEvent.Action.START, "Decrypting wallet..."));
keyDerivationService.start();
}
} else {
soroban.setHDWallet(wallet);
CounterpartyDialog counterpartyDialog = new CounterpartyDialog(selectedWalletForm.getWalletId(), selectedWalletForm.getWallet());
if(Network.get() == Network.TESTNET) {
counterpartyDialog.initModality(Modality.NONE);
}
counterpartyDialog.showAndWait();
}
} else {
CounterpartyDialog counterpartyDialog = new CounterpartyDialog(selectedWalletForm.getWalletId(), selectedWalletForm.getWallet());
if(Network.get() == Network.TESTNET) {
counterpartyDialog.initModality(Modality.NONE);
}
counterpartyDialog.showAndWait();
}
}
}
public void minimizeToTray(ActionEvent event) {
AppServices.get().minimizeStage((Stage)tabs.getScene().getWindow());
}
@ -1794,6 +1867,7 @@ public class AppController implements Initializable {
showLoadingLog.setDisable(true);
showUtxosChart.setDisable(true);
showTxHex.setDisable(false);
findMixingPartner.setDisable(true);
} else if(event instanceof WalletTabSelectedEvent) {
WalletTabSelectedEvent walletTabEvent = (WalletTabSelectedEvent)event;
WalletTabData walletTabData = walletTabEvent.getWalletTabData();
@ -1804,6 +1878,7 @@ public class AppController implements Initializable {
showLoadingLog.setDisable(false);
showUtxosChart.setDisable(false);
showTxHex.setDisable(true);
findMixingPartner.setDisable(exportWallet.isDisable() || !SorobanServices.canWalletMix(walletTabData.getWallet()) || !AppServices.onlineProperty().get());
}
}
}
@ -1828,6 +1903,7 @@ public class AppController implements Initializable {
if(selectedWalletForm != null) {
if(selectedWalletForm.getWalletId().equals(event.getWalletId())) {
exportWallet.setDisable(!event.getWallet().isValid());
findMixingPartner.setDisable(exportWallet.isDisable() || !SorobanServices.canWalletMix(event.getWallet()) || !AppServices.onlineProperty().get());
}
}
}

View file

@ -16,6 +16,7 @@ import com.sparrowwallet.sparrow.control.TrayManager;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.*;
import com.sparrowwallet.sparrow.net.*;
import com.sparrowwallet.sparrow.soroban.SorobanServices;
import com.sparrowwallet.sparrow.whirlpool.WhirlpoolServices;
import javafx.application.Platform;
import javafx.beans.property.BooleanProperty;
@ -77,6 +78,8 @@ public class AppServices {
private final WhirlpoolServices whirlpoolServices = new WhirlpoolServices();
private final SorobanServices sorobanServices = new SorobanServices();
private final MainApp application;
private final Map<Window, List<WalletTabData>> walletWindows = new LinkedHashMap<>();
@ -151,6 +154,7 @@ public class AppServices {
this.application = application;
EventManager.get().register(this);
EventManager.get().register(whirlpoolServices);
EventManager.get().register(sorobanServices);
}
public void start() {
@ -471,6 +475,10 @@ public class AppServices {
return get().whirlpoolServices;
}
public static SorobanServices getSorobanServices() {
return get().sorobanServices;
}
public static AppController newAppWindow(Stage stage) {
try {
FXMLLoader appLoader = new FXMLLoader(AppServices.class.getResource("app.fxml"));

View file

@ -0,0 +1,53 @@
package com.sparrowwallet.sparrow.control;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.scene.control.ProgressIndicator;
import javafx.util.Duration;
public class ProgressTimer extends ProgressIndicator {
private final IntegerProperty secondsProperty = new SimpleIntegerProperty(60);
private Timeline timeline;
public ProgressTimer() {
super(0);
getStyleClass().add("progress-timer");
}
public void start() {
start(e -> {});
}
public void start(EventHandler<ActionEvent> onFinished) {
timeline = new Timeline(
new KeyFrame(Duration.ZERO, new KeyValue(progressProperty(), 0)),
new KeyFrame(Duration.seconds(getSeconds() * 0.8), e -> getStyleClass().add("warn")),
new KeyFrame(Duration.seconds(getSeconds()), onFinished, new KeyValue(progressProperty(), 1)));
timeline.setCycleCount(1);
timeline.play();
}
public void stop() {
if(timeline != null) {
timeline.stop();
}
}
public int getSeconds() {
return secondsProperty.get();
}
public IntegerProperty secondsProperty() {
return secondsProperty;
}
public void setSeconds(int secondsProperty) {
this.secondsProperty.set(secondsProperty);
}
}

View file

@ -10,9 +10,14 @@ import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.ExcludeUtxoEvent;
import com.sparrowwallet.sparrow.event.ReplaceChangeAddressEvent;
import com.sparrowwallet.sparrow.event.SorobanInitiatedEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.soroban.SorobanServices;
import com.sparrowwallet.sparrow.wallet.OptimizationStrategy;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Group;
@ -42,6 +47,7 @@ public class TransactionDiagram extends GridPane {
private WalletTransaction walletTx;
private final BooleanProperty finalProperty = new SimpleBooleanProperty(false);
private final ObjectProperty<OptimizationStrategy> optimizationStrategyProperty = new SimpleObjectProperty<>(OptimizationStrategy.EFFICIENCY);
public void update(WalletTransaction walletTx) {
setMinHeight(getDiagramHeight());
@ -112,6 +118,14 @@ public class TransactionDiagram extends GridPane {
displayedUtxoSets.add(getDisplayedUtxos(selectedUtxoSet, walletTx.getSelectedUtxoSets().size()));
}
if(getOptimizationStrategy() == OptimizationStrategy.PRIVACY && displayedUtxoSets.size() == 1 && SorobanServices.canWalletMix(walletTx.getWallet())
&& walletTx.getPayments().size() == 1
&& (walletTx.getPayments().get(0).getAddress().getScriptType() == walletTx.getWallet().getAddress(walletTx.getWallet().getFreshNode(KeyPurpose.RECEIVE)).getScriptType())) {
Map<BlockTransactionHashIndex, WalletNode> addUserUtxoSet = new HashMap<>();
addUserUtxoSet.put(new AddUserBlockTransactionHashIndex(), null);
displayedUtxoSets.add(addUserUtxoSet);
}
List<Map<BlockTransactionHashIndex, WalletNode>> paddedUtxoSets = new ArrayList<>();
int maxDisplayedSetSize = displayedUtxoSets.stream().mapToInt(Map::size).max().orElse(0);
for(Map<BlockTransactionHashIndex, WalletNode> selectedUtxoSet : displayedUtxoSets) {
@ -189,8 +203,18 @@ public class TransactionDiagram extends GridPane {
if(numSets > 1) {
double setHeight = (height / numSets) - 5;
for(int set = 0; set < numSets; set++) {
StackPane stackPane = getBracket(width, setHeight, getUserGlyph(), walletTx.getWallet().getFullDisplayName() + "\nClick to replace with an external contributor");
allBrackets.getChildren().add(stackPane);
boolean externalUserSet = displayedUtxoSets.get(set).values().stream().anyMatch(Objects::nonNull);
boolean addUserSet = displayedUtxoSets.get(set).keySet().stream().anyMatch(ref -> ref instanceof AddUserBlockTransactionHashIndex);
if(externalUserSet || addUserSet) {
boolean replace = !isFinal() && set > 0 && SorobanServices.canWalletMix(walletTx.getWallet());
Glyph bracketGlyph = !replace && walletTx.isCoinControlUsed() ? getLockGlyph() : (addUserSet ? getUserAddGlyph() : getCoinsGlyph(replace));
String tooltipText = addUserSet ? "Click to add a mixing partner" : (walletTx.getWallet().getFullDisplayName() + (replace ? "\nClick to replace with a mixing partner" : ""));
StackPane stackPane = getBracket(width, setHeight, bracketGlyph, tooltipText);
allBrackets.getChildren().add(stackPane);
} else {
StackPane stackPane = getBracket(width, setHeight, getUserGlyph(), "Mixing partner");
allBrackets.getChildren().add(stackPane);
}
}
} else if(walletTx.isCoinControlUsed()) {
StackPane stackPane = getBracket(width, height, getLockGlyph(), "Coin control active");
@ -248,6 +272,7 @@ public class TransactionDiagram extends GridPane {
glyph.getStyleClass().add("inputs-type");
Tooltip tooltip = new Tooltip(tooltipText);
tooltip.getStyleClass().add("transaction-tooltip");
tooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
tooltip.setShowDuration(Duration.INDEFINITE);
glyph.setTooltip(tooltip);
@ -299,7 +324,7 @@ public class TransactionDiagram extends GridPane {
joiner.add(getInputDescription(additionalInput));
}
tooltip.setText(joiner.toString());
} else if(input instanceof InvisibleBlockTransactionHashIndex) {
} else if(input instanceof InvisibleBlockTransactionHashIndex || input instanceof AddUserBlockTransactionHashIndex) {
tooltip.setText("");
} else {
if(walletTx.getInputTransactions() != null && walletTx.getInputTransactions().get(input.getHash()) != null) {
@ -359,7 +384,7 @@ public class TransactionDiagram extends GridPane {
CubicCurve curve = new CubicCurve();
curve.getStyleClass().add("input-line");
if(inputs.get(numUtxos-i) instanceof PayjoinBlockTransactionHashIndex) {
if(inputs.get(numUtxos-i) instanceof PayjoinBlockTransactionHashIndex || inputs.get(numUtxos-i) instanceof AddUserBlockTransactionHashIndex) {
curve.getStyleClass().add("input-dashed-line");
} else if(inputs.get(numUtxos-i) instanceof InvisibleBlockTransactionHashIndex) {
continue;
@ -462,7 +487,7 @@ public class TransactionDiagram extends GridPane {
Glyph outputGlyph = getOutputGlyph(payment);
boolean labelledPayment = outputGlyph.getStyleClass().stream().anyMatch(style -> List.of("premix-icon", "badbank-icon", "whirlpoolfee-icon").contains(style)) || payment instanceof AdditionalPayment;
payment.setLabel(getOutputLabel(payment));
Label recipientLabel = new Label(payment.getLabel() == null || payment.getType() == Payment.Type.FAKE_MIX ? payment.getAddress().toString().substring(0, 8) + "..." : payment.getLabel(), outputGlyph);
Label recipientLabel = new Label(payment.getLabel() == null || payment.getType() == Payment.Type.FAKE_MIX || payment.getType() == Payment.Type.MIX ? payment.getAddress().toString().substring(0, 8) + "..." : payment.getLabel(), outputGlyph);
recipientLabel.getStyleClass().add("output-label");
recipientLabel.getStyleClass().add(labelledPayment ? "payment-label" : "recipient-label");
Wallet toWallet = getToWallet(payment);
@ -625,7 +650,9 @@ public class TransactionDiagram extends GridPane {
}
public Glyph getOutputGlyph(Payment payment) {
if(payment.getType().equals(Payment.Type.FAKE_MIX)) {
if(payment.getType().equals(Payment.Type.MIX)) {
return getMixGlyph();
} else if(payment.getType().equals(Payment.Type.FAKE_MIX)) {
return getFakeMixGlyph();
} else if(walletTx.isConsolidationSend(payment)) {
return getConsolidationGlyph();
@ -704,9 +731,9 @@ public class TransactionDiagram extends GridPane {
return getChangeGlyph();
}
public static Glyph getPayjoinGlyph() {
public static Glyph getMixGlyph() {
Glyph payjoinGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.RANDOM);
payjoinGlyph.getStyleClass().add("payjoin-icon");
payjoinGlyph.getStyleClass().add("mix-icon");
payjoinGlyph.setFontSize(12);
return payjoinGlyph;
}
@ -754,14 +781,51 @@ public class TransactionDiagram extends GridPane {
}
private Glyph getUserGlyph() {
Glyph userGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.COINS);
Glyph userGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.USER);
userGlyph.getStyleClass().add("user-icon");
userGlyph.setFontSize(12);
userGlyph.setOnMouseEntered(event -> userGlyph.setIcon(FontAwesome5.Glyph.USER_PLUS));
userGlyph.setOnMouseExited(event -> userGlyph.setIcon(FontAwesome5.Glyph.COINS));
return userGlyph;
}
private Glyph getUserAddGlyph() {
Glyph userAddGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.USER_PLUS);
userAddGlyph.getStyleClass().add("useradd-icon");
userAddGlyph.setFontSize(12);
userAddGlyph.setOnMouseEntered(event -> {
userAddGlyph.setFontSize(18);
});
userAddGlyph.setOnMouseExited(event -> {
userAddGlyph.setFontSize(12);
});
userAddGlyph.setOnMouseClicked(event -> {
EventManager.get().post(new SorobanInitiatedEvent(walletTx.getWallet()));
});
return userAddGlyph;
}
private Glyph getCoinsGlyph(boolean allowReplacement) {
Glyph coinsGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.COINS);
coinsGlyph.setFontSize(12);
if(allowReplacement) {
coinsGlyph.getStyleClass().add("coins-replace-icon");
coinsGlyph.setOnMouseEntered(event -> {
coinsGlyph.setIcon(FontAwesome5.Glyph.USER_PLUS);
coinsGlyph.setFontSize(18);
});
coinsGlyph.setOnMouseExited(event -> {
coinsGlyph.setIcon(FontAwesome5.Glyph.COINS);
coinsGlyph.setFontSize(12);
});
coinsGlyph.setOnMouseClicked(event -> {
EventManager.get().post(new SorobanInitiatedEvent(walletTx.getWallet()));
});
} else {
coinsGlyph.getStyleClass().add("coins-icon");
}
return coinsGlyph;
}
public boolean isFinal() {
return finalProperty.get();
}
@ -774,6 +838,18 @@ public class TransactionDiagram extends GridPane {
this.finalProperty.set(isFinal);
}
public OptimizationStrategy getOptimizationStrategy() {
return optimizationStrategyProperty.get();
}
public ObjectProperty<OptimizationStrategy> optimizationStrategyProperty() {
return optimizationStrategyProperty;
}
public void setOptimizationStrategy(OptimizationStrategy optimizationStrategy) {
this.optimizationStrategyProperty.set(optimizationStrategy);
}
private static class PayjoinBlockTransactionHashIndex extends BlockTransactionHashIndex {
public PayjoinBlockTransactionHashIndex() {
super(Sha256Hash.ZERO_HASH, 0, new Date(), 0L, 0, 0);
@ -814,6 +890,17 @@ public class TransactionDiagram extends GridPane {
}
}
private static class AddUserBlockTransactionHashIndex extends BlockTransactionHashIndex {
public AddUserBlockTransactionHashIndex() {
super(Sha256Hash.ZERO_HASH, 0, new Date(), 0L, 0, 0);
}
@Override
public String getLabel() {
return "Add Mixing Partner?";
}
}
private static class AdditionalPayment extends Payment {
private final List<Payment> additionalPayments;

View file

@ -0,0 +1,15 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
public class SorobanInitiatedEvent {
private Wallet wallet;
public SorobanInitiatedEvent(Wallet wallet) {
this.wallet = wallet;
}
public Wallet getWallet() {
return wallet;
}
}

View file

@ -73,6 +73,7 @@ public class FontAwesome5 extends GlyphFont {
USER('\uf007'),
USER_FRIENDS('\uf500'),
USER_PLUS('\uf234'),
USER_SLASH('\uf506'),
WALLET('\uf555'),
WEIGHT('\uf496');

View file

@ -0,0 +1,310 @@
package com.sparrowwallet.sparrow.soroban;
import com.samourai.soroban.cahoots.CahootsContext;
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.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 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.ComboBox;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressBar;
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.Map;
import static com.sparrowwallet.sparrow.soroban.Soroban.TIMEOUT_MS;
public class CounterpartyController extends SorobanController {
private static final Logger log = LoggerFactory.getLogger(CounterpartyController.class);
private String walletId;
private Wallet wallet;
@FXML
private VBox step1;
@FXML
private VBox step2;
@FXML
private VBox step3;
@FXML
private VBox step4;
@FXML
private CopyableTextField paymentCode;
@FXML
private ComboBox<Wallet> mixWallet;
@FXML
private ProgressTimer step2Timer;
@FXML
private Label step2Desc;
@FXML
private Label mixingPartner;
@FXML
private Label meetingFail;
@FXML
private VBox mixDetails;
@FXML
private Label mixType;
@FXML
private ProgressTimer step3Timer;
@FXML
private Label step3Desc;
@FXML
private ProgressBar sorobanProgressBar;
@FXML
private Label sorobanProgressLabel;
@FXML
private Glyph mixDeclined;
@FXML
private TransactionDiagram transactionDiagram;
private final ObjectProperty<Boolean> meetingReceived = new SimpleObjectProperty<>(null);
private final ObjectProperty<Boolean> meetingAccepted = new SimpleObjectProperty<>(null);
private final ObjectProperty<Transaction> transactionProperty = new SimpleObjectProperty<>(null);
public void initializeView(String walletId, Wallet wallet) {
this.walletId = walletId;
this.wallet = wallet;
step1.managedProperty().bind(step1.visibleProperty());
step2.managedProperty().bind(step2.visibleProperty());
step3.managedProperty().bind(step3.visibleProperty());
step4.managedProperty().bind(step4.visibleProperty());
mixWallet.setConverter(new StringConverter<>() {
@Override
public String toString(Wallet wallet) {
return wallet == null ? "" : wallet.getFullDisplayName();
}
@Override
public Wallet fromString(String string) {
return null;
}
});
mixWallet.setItems(FXCollections.observableList(wallet.getAllWallets()));
mixWallet.setValue(wallet);
mixWallet.valueProperty().addListener((observable, oldValue, selectedWallet) -> setWallet(selectedWallet));
sorobanProgressBar.managedProperty().bind(sorobanProgressBar.visibleProperty());
sorobanProgressLabel.managedProperty().bind(sorobanProgressLabel.visibleProperty());
mixDeclined.managedProperty().bind(mixDeclined.visibleProperty());
sorobanProgressBar.visibleProperty().bind(sorobanProgressLabel.visibleProperty());
mixDeclined.visibleProperty().bind(sorobanProgressLabel.visibleProperty().not());
step2Timer.visibleProperty().bind(mixingPartner.visibleProperty());
step3Timer.visibleProperty().bind(sorobanProgressLabel.visibleProperty());
step2.setVisible(false);
step3.setVisible(false);
step4.setVisible(false);
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
if(soroban.getHdWallet() == null) {
throw new IllegalStateException("Soroban HD wallet must be set");
}
paymentCode.setText(soroban.getPaymentCode().toString());
mixingPartner.managedProperty().bind(mixingPartner.visibleProperty());
meetingFail.managedProperty().bind(meetingFail.visibleProperty());
meetingFail.visibleProperty().bind(mixingPartner.visibleProperty().not());
mixDetails.managedProperty().bind(mixDetails.visibleProperty());
mixDetails.setVisible(false);
meetingAccepted.addListener((observable, oldValue, accepted) -> {
Platform.exitNestedEventLoop(meetingAccepted, accepted);
meetingReceived.set(null);
});
step2.visibleProperty().addListener((observable, oldValue, visible) -> {
if(visible) {
startCounterpartyMeetingReceive();
step2Timer.start(e -> {
step2Desc.setText("Mix declined due to timeout.");
meetingReceived.set(Boolean.FALSE);
});
}
});
step3.visibleProperty().addListener((observable, oldValue, visible) -> {
if(visible) {
meetingAccepted.set(Boolean.TRUE);
}
});
}
private void setWallet(Wallet wallet) {
this.walletId = AppServices.get().getOpenWallets().get(wallet).getWalletId(wallet);
this.wallet = wallet;
}
private void startCounterpartyMeetingReceive() {
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
SparrowCahootsWallet counterpartyCahootsWallet = soroban.getCahootsWallet(wallet, 1);
try {
SorobanCahootsService sorobanMeetingService = soroban.getSorobanCahootsService(counterpartyCahootsWallet);
sorobanMeetingService.receiveMeetingRequest(TIMEOUT_MS)
.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);
Boolean accepted = (Boolean)Platform.enterNestedEventLoop(meetingAccepted);
sorobanMeetingService.sendMeetingResponse(paymentCodeInitiator, requestMessage, accepted)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.subscribe(responseMessage -> {
if(accepted) {
startCounterpartyStonewall(counterpartyCahootsWallet, paymentCodeInitiator);
}
}, error -> {
log.error("Error sending meeting response", error);
mixingPartner.setVisible(false);
});
}, error -> {
log.error("Failed to receive meeting request", error);
mixingPartner.setVisible(false);
});
} catch(Exception e) {
log.error("Error sending meeting response", e);
}
}
private void startCounterpartyStonewall(SparrowCahootsWallet counterpartyCahootsWallet, PaymentCode initiatorPaymentCode) {
sorobanProgressLabel.setText("Creating mix transaction...");
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
Map<BlockTransactionHashIndex, WalletNode> walletUtxos = wallet.getWalletUtxos();
for(Map.Entry<BlockTransactionHashIndex, WalletNode> entry : walletUtxos.entrySet()) {
counterpartyCahootsWallet.addUtxo(wallet, entry.getValue(), wallet.getTransactions().get(entry.getKey().getHash()), (int)entry.getKey().getIndex());
}
try {
SorobanCahootsService sorobanCahootsService = soroban.getSorobanCahootsService(counterpartyCahootsWallet);
CahootsContext cahootsContext = CahootsContext.newCounterpartyStonewallx2();
sorobanCahootsService.contributor(counterpartyCahootsWallet.getAccount(), cahootsContext, initiatorPaymentCode, TIMEOUT_MS)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.subscribe(sorobanMessage -> {
OnlineCahootsMessage cahootsMessage = (OnlineCahootsMessage)sorobanMessage;
if(cahootsMessage != null) {
Cahoots cahoots = cahootsMessage.getCahoots();
sorobanProgressBar.setProgress((double)(cahoots.getStep() + 1) / 5);
if(cahoots.getStep() == 3) {
sorobanProgressLabel.setText("Your mix partner is reviewing the transaction...");
step3Timer.start();
} else if(cahoots.getStep() >= 4 && cahoots instanceof STONEWALLx2 stonewallx2) {
try {
Transaction transaction = getTransaction(stonewallx2);
if(transaction != null) {
transactionProperty.set(transaction);
updateTransactionDiagram(transactionDiagram, wallet, null, transaction);
next();
}
} catch(PSBTParseException e) {
log.error("Invalid Stonewallx2 PSBT created", e);
step3Desc.setText("Invalid transaction created.");
sorobanProgressLabel.setVisible(false);
}
}
}
}, error -> {
log.error("Error creating mix transaction", error);
String cutFrom = "Exception: ";
int index = error.getMessage().lastIndexOf(cutFrom);
String msg = index < 0 ? error.getMessage() : error.getMessage().substring(index + cutFrom.length());
msg = msg.replace("#Cahoots", "mix transaction");
step3Desc.setText(msg);
sorobanProgressLabel.setVisible(false);
});
} catch(Exception e) {
log.error("Error creating mix transaction", e);
sorobanProgressLabel.setText(e.getMessage());
}
}
public boolean next() {
if(step1.isVisible()) {
step1.setVisible(false);
step2.setVisible(true);
return true;
}
if(step2.isVisible()) {
step2.setVisible(false);
step3.setVisible(true);
return true;
}
if(step3.isVisible()) {
step3.setVisible(false);
step4.setVisible(true);
return true;
}
return false;
}
public void cancel() {
meetingAccepted.set(Boolean.FALSE);
}
public ObjectProperty<Boolean> meetingReceivedProperty() {
return meetingReceived;
}
public ObjectProperty<Boolean> meetingAcceptedProperty() {
return meetingAccepted;
}
public ObjectProperty<Transaction> transactionProperty() {
return transactionProperty;
}
}

View file

@ -0,0 +1,73 @@
package com.sparrowwallet.sparrow.soroban;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import javafx.event.ActionEvent;
import javafx.fxml.FXMLLoader;
import javafx.scene.control.*;
import java.io.IOException;
public class CounterpartyDialog extends Dialog<Boolean> {
public CounterpartyDialog(String walletId, Wallet wallet) {
final DialogPane dialogPane = getDialogPane();
AppServices.setStageIcon(dialogPane.getScene().getWindow());
AppServices.onEscapePressed(dialogPane.getScene(), this::close);
try {
FXMLLoader counterpartyLoader = new FXMLLoader(AppServices.class.getResource("soroban/counterparty.fxml"));
dialogPane.setContent(counterpartyLoader.load());
CounterpartyController counterpartyController = counterpartyLoader.getController();
counterpartyController.initializeView(walletId, wallet);
dialogPane.setPrefWidth(730);
dialogPane.setPrefHeight(520);
AppServices.moveToActiveWindowScreen(this);
dialogPane.getStylesheets().add(AppServices.class.getResource("app.css").toExternalForm());
dialogPane.getStylesheets().add(AppServices.class.getResource("soroban/counterparty.css").toExternalForm());
final ButtonType nextButtonType = new javafx.scene.control.ButtonType("Next", ButtonBar.ButtonData.OK_DONE);
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
final ButtonType doneButtonType = new javafx.scene.control.ButtonType("Done", ButtonBar.ButtonData.APPLY);
dialogPane.getButtonTypes().addAll(nextButtonType, cancelButtonType, doneButtonType);
Button nextButton = (Button)dialogPane.lookupButton(nextButtonType);
Button cancelButton = (Button)dialogPane.lookupButton(cancelButtonType);
Button doneButton = (Button)dialogPane.lookupButton(doneButtonType);
doneButton.setDisable(true);
counterpartyController.meetingReceivedProperty().addListener((observable, oldValue, newValue) -> {
nextButton.setDisable(newValue != Boolean.TRUE);
});
counterpartyController.transactionProperty().addListener((observable, oldValue, newValue) -> {
nextButton.setVisible(false);
doneButton.setDisable(newValue == null);
cancelButton.setDisable(newValue != null);
});
nextButton.managedProperty().bind(nextButton.visibleProperty());
doneButton.managedProperty().bind(doneButton.visibleProperty());
doneButton.visibleProperty().bind(nextButton.visibleProperty().not());
nextButton.addEventFilter(ActionEvent.ACTION, event -> {
if(!counterpartyController.next()) {
nextButton.setVisible(false);
doneButton.setDefaultButton(true);
}
nextButton.setDisable(counterpartyController.meetingReceivedProperty().get() != Boolean.TRUE);
event.consume();
});
cancelButton.addEventFilter(ActionEvent.ACTION, event -> {
if(counterpartyController.meetingReceivedProperty().get() == Boolean.TRUE) {
counterpartyController.cancel();
}
});
setResultConverter(dialogButton -> dialogButton.getButtonData().equals(ButtonBar.ButtonData.APPLY));
} catch(IOException e) {
throw new RuntimeException(e);
}
}
}

View file

@ -0,0 +1,333 @@
package com.sparrowwallet.sparrow.soroban;
import com.samourai.soroban.cahoots.CahootsContext;
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.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;
import com.sparrowwallet.drongo.crypto.InvalidPasswordException;
import com.sparrowwallet.drongo.crypto.Key;
import com.sparrowwallet.drongo.protocol.Transaction;
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.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.Storage;
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.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.layout.VBox;
import org.controlsfx.glyphfont.Glyph;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.*;
import static com.sparrowwallet.sparrow.AppServices.showErrorDialog;
import static com.sparrowwallet.sparrow.soroban.Soroban.TIMEOUT_MS;
public class InitiatorController extends SorobanController {
private static final Logger log = LoggerFactory.getLogger(InitiatorController.class);
private String walletId;
private Wallet wallet;
private WalletTransaction walletTransaction;
@FXML
private VBox step1;
@FXML
private VBox step2;
@FXML
private VBox step3;
@FXML
private TextField counterparty;
@FXML
private ProgressTimer step2Timer;
@FXML
private Label step2Desc;
@FXML
private ProgressBar sorobanProgressBar;
@FXML
private Label sorobanProgressLabel;
@FXML
private Glyph mixDeclined;
@FXML
private ProgressTimer step3Timer;
@FXML
private Label step3Desc;
@FXML
private TransactionDiagram transactionDiagram;
private final ObjectProperty<Step> stepProperty = new SimpleObjectProperty<>(Step.SETUP);
private final ObjectProperty<Boolean> transactionAccepted = new SimpleObjectProperty<>(null);
private final ObjectProperty<Transaction> transactionProperty = new SimpleObjectProperty<>(null);
public void initializeView(String walletId, Wallet wallet, WalletTransaction walletTransaction) {
this.walletId = walletId;
this.wallet = wallet;
this.walletTransaction = walletTransaction;
step1.managedProperty().bind(step1.visibleProperty());
step2.managedProperty().bind(step2.visibleProperty());
step3.managedProperty().bind(step3.visibleProperty());
sorobanProgressBar.managedProperty().bind(sorobanProgressBar.visibleProperty());
sorobanProgressLabel.managedProperty().bind(sorobanProgressLabel.visibleProperty());
mixDeclined.managedProperty().bind(mixDeclined.visibleProperty());
sorobanProgressBar.visibleProperty().bind(sorobanProgressLabel.visibleProperty());
mixDeclined.visibleProperty().bind(sorobanProgressLabel.visibleProperty().not());
step2Timer.visibleProperty().bind(sorobanProgressLabel.visibleProperty());
step2.setVisible(false);
step3.setVisible(false);
transactionAccepted.addListener((observable, oldValue, accepted) -> {
if(transactionProperty.get() != null) {
Platform.exitNestedEventLoop(transactionAccepted, accepted);
}
});
transactionProperty.addListener((observable, oldValue, transaction) -> {
if(transaction != null) {
updateTransactionDiagram(transactionDiagram, wallet, walletTransaction, transaction);
}
});
step2.visibleProperty().addListener((observable, oldValue, visible) -> {
if(visible) {
startInitiatorMeetingRequest();
step2Timer.start();
}
});
}
private void startInitiatorMeetingRequest() {
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
if(soroban.getHdWallet() == null) {
if(wallet.isEncrypted()) {
Wallet copy = wallet.copy();
WalletPasswordDialog dlg = new WalletPasswordDialog(copy.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
Storage storage = AppServices.get().getOpenWallets().get(wallet);
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get(), true);
keyDerivationService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Done"));
ECKey encryptionFullKey = keyDerivationService.getValue();
Key key = new Key(encryptionFullKey.getPrivKeyBytes(), storage.getKeyDeriver().getSalt(), EncryptionType.Deriver.ARGON2);
copy.decrypt(key);
try {
soroban.setHDWallet(copy);
startInitiatorMeetingRequest(soroban, wallet);
} finally {
key.clear();
encryptionFullKey.clear();
password.get().clear();
}
});
keyDerivationService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Failed"));
if(keyDerivationService.getException() instanceof InvalidPasswordException) {
Optional<ButtonType> optResponse = showErrorDialog("Invalid Password", "The wallet password was invalid. Try again?", ButtonType.CANCEL, ButtonType.OK);
if(optResponse.isPresent() && optResponse.get().equals(ButtonType.OK)) {
Platform.runLater(this::startInitiatorMeetingRequest);
}
} else {
log.error("Error deriving wallet key", keyDerivationService.getException());
}
});
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.START, "Decrypting wallet..."));
keyDerivationService.start();
} else {
step2.setVisible(false);
step1.setVisible(true);
}
} else {
soroban.setHDWallet(wallet);
startInitiatorMeetingRequest(soroban, wallet);
}
} else {
startInitiatorMeetingRequest(soroban, wallet);
}
}
private void startInitiatorMeetingRequest(Soroban soroban, Wallet wallet) {
SparrowCahootsWallet initiatorCahootsWallet = soroban.getCahootsWallet(wallet, (long)walletTransaction.getFeeRate());
PaymentCode paymentCodeCounterparty = new PaymentCode(counterparty.getText());
try {
SorobanCahootsService sorobanMeetingService = soroban.getSorobanCahootsService(initiatorCahootsWallet);
sorobanMeetingService.sendMeetingRequest(paymentCodeCounterparty, CahootsType.STONEWALLX2)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.subscribe(meetingRequest -> {
sorobanProgressLabel.setText("Waiting for mixing partner...");
sorobanMeetingService.receiveMeetingResponse(paymentCodeCounterparty, meetingRequest, TIMEOUT_MS)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.subscribe(sorobanResponse -> {
if(sorobanResponse.isAccept()) {
sorobanProgressBar.setProgress(0.1);
sorobanProgressLabel.setText("Mixing partner accepted!");
startInitiatorStonewall(initiatorCahootsWallet, paymentCodeCounterparty);
} else {
step2Desc.setText("Mixing partner declined.");
sorobanProgressLabel.setVisible(false);
}
}, error -> {
log.error("Error receiving meeting response", error);
String cutFrom = "Exception: ";
int index = error.getMessage().lastIndexOf(cutFrom);
step2Desc.setText(index < 0 ? error.getMessage() : error.getMessage().substring(index + cutFrom.length()));
sorobanProgressLabel.setVisible(false);
});
}, error -> {
log.error("Error sending meeting request", error);
step2Desc.setText(error.getMessage());
sorobanProgressLabel.setVisible(false);
});
} catch(Exception e) {
log.error("Error sending meeting request", e);
}
}
private void startInitiatorStonewall(SparrowCahootsWallet initiatorCahootsWallet, PaymentCode paymentCodeCounterparty) {
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
Payment payment = walletTransaction.getPayments().get(0);
Map<BlockTransactionHashIndex, WalletNode> firstSetUtxos = walletTransaction.getSelectedUtxoSets().get(0);
for(Map.Entry<BlockTransactionHashIndex, WalletNode> entry : firstSetUtxos.entrySet()) {
initiatorCahootsWallet.addUtxo(wallet, entry.getValue(), wallet.getTransactions().get(entry.getKey().getHash()), (int)entry.getKey().getIndex());
}
SorobanCahootsService sorobanCahootsService = soroban.getSorobanCahootsService(initiatorCahootsWallet);
CahootsContext cahootsContext = CahootsContext.newInitiatorStonewallx2(payment.getAmount(), payment.getAddress().toString());
sorobanCahootsService.getSorobanService().getOnInteraction()
.observeOn(JavaFxScheduler.platform())
.subscribe(interaction -> {
Boolean accepted = (Boolean)Platform.enterNestedEventLoop(transactionAccepted);
if(accepted) {
interaction.sorobanAccept();
} else {
interaction.sorobanReject("Mixing partner declined to broadcast the transaction.");
}
});
try {
sorobanCahootsService.initiator(initiatorCahootsWallet.getAccount(), cahootsContext, paymentCodeCounterparty, TIMEOUT_MS)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.subscribe(sorobanMessage -> {
OnlineCahootsMessage cahootsMessage = (OnlineCahootsMessage)sorobanMessage;
if(cahootsMessage != null) {
Cahoots cahoots = cahootsMessage.getCahoots();
sorobanProgressBar.setProgress((double)(cahoots.getStep() + 1) / 5);
if(cahoots.getStep() >= 3 && cahoots instanceof STONEWALLx2 stonewallx2) {
try {
Transaction transaction = getTransaction(stonewallx2);
if(transaction != null) {
transactionProperty.set(transaction);
if(cahoots.getStep() == 3) {
next();
step3Timer.start(e -> {
if(stepProperty.get() != Step.BROADCAST) {
step3Desc.setText("Transaction declined due to timeout.");
transactionAccepted.set(Boolean.FALSE);
}
});
} else if(cahoots.getStep() == 4) {
stepProperty.set(Step.BROADCAST);
}
}
} catch(PSBTParseException e) {
log.error("Invalid Stonewallx2 PSBT created", e);
step2Desc.setText("Invalid transaction created.");
sorobanProgressLabel.setVisible(false);
}
}
}
},
error -> {
log.error("Error creating mix transaction", error);
String cutFrom = "Exception: ";
int index = error.getMessage().lastIndexOf(cutFrom);
step2Desc.setText(index < 0 ? error.getMessage() : error.getMessage().substring(index + cutFrom.length()));
sorobanProgressLabel.setVisible(false);
});
} catch(Exception e) {
log.error("Soroban communication error", e);
}
}
public void next() {
if(step1.isVisible()) {
step1.setVisible(false);
step2.setVisible(true);
stepProperty.set(Step.COMMUNICATE);
return;
}
if(step2.isVisible()) {
step2.setVisible(false);
step3.setVisible(true);
stepProperty.set(Step.REVIEW);
}
}
public void accept() {
transactionAccepted.set(Boolean.TRUE);
}
public void cancel() {
transactionAccepted.set(Boolean.FALSE);
}
public ObjectProperty<Step> stepProperty() {
return stepProperty;
}
public Transaction getTransaction() {
return transactionProperty.get();
}
public ObjectProperty<Boolean> transactionAcceptedProperty() {
return transactionAccepted;
}
public enum Step {
SETUP, COMMUNICATE, REVIEW, BROADCAST
}
}

View file

@ -0,0 +1,120 @@
package com.sparrowwallet.sparrow.soroban;
import com.sparrowwallet.drongo.SecureString;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletTransaction;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.WalletPasswordDialog;
import com.sparrowwallet.sparrow.event.StorageEvent;
import com.sparrowwallet.sparrow.event.TimedEvent;
import com.sparrowwallet.sparrow.io.Storage;
import javafx.event.ActionEvent;
import javafx.fxml.FXMLLoader;
import javafx.scene.control.*;
import java.io.IOException;
import java.util.Optional;
public class InitiatorDialog extends Dialog<Transaction> {
private final boolean confirmationRequired;
public InitiatorDialog(String walletId, Wallet wallet, WalletTransaction walletTransaction) {
this.confirmationRequired = AppServices.getSorobanServices().getSoroban(walletId).getHdWallet() != null;
final DialogPane dialogPane = getDialogPane();
AppServices.setStageIcon(dialogPane.getScene().getWindow());
AppServices.onEscapePressed(dialogPane.getScene(), this::close);
try {
FXMLLoader initiatorLoader = new FXMLLoader(AppServices.class.getResource("soroban/initiator.fxml"));
dialogPane.setContent(initiatorLoader.load());
InitiatorController initiatorController = initiatorLoader.getController();
initiatorController.initializeView(walletId, wallet, walletTransaction);
dialogPane.setPrefWidth(730);
dialogPane.setPrefHeight(520);
AppServices.moveToActiveWindowScreen(this);
dialogPane.getStylesheets().add(AppServices.class.getResource("app.css").toExternalForm());
dialogPane.getStylesheets().add(AppServices.class.getResource("soroban/initiator.css").toExternalForm());
final ButtonType nextButtonType = new javafx.scene.control.ButtonType("Next", ButtonBar.ButtonData.OK_DONE);
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
final ButtonType broadcastButtonType = new javafx.scene.control.ButtonType("Sign & Broadcast", ButtonBar.ButtonData.APPLY);
dialogPane.getButtonTypes().addAll(nextButtonType, cancelButtonType, broadcastButtonType);
Button nextButton = (Button)dialogPane.lookupButton(nextButtonType);
Button cancelButton = (Button)dialogPane.lookupButton(cancelButtonType);
Button broadcastButton = (Button)dialogPane.lookupButton(broadcastButtonType);
broadcastButton.setDisable(true);
nextButton.managedProperty().bind(nextButton.visibleProperty());
broadcastButton.managedProperty().bind(broadcastButton.visibleProperty());
broadcastButton.visibleProperty().bind(nextButton.visibleProperty().not());
initiatorController.stepProperty().addListener((observable, oldValue, step) -> {
if(step == InitiatorController.Step.SETUP) {
nextButton.setDisable(false);
nextButton.setVisible(true);
} else if(step == InitiatorController.Step.COMMUNICATE) {
nextButton.setDisable(true);
nextButton.setVisible(true);
} else if(step == InitiatorController.Step.REVIEW) {
nextButton.setVisible(false);
broadcastButton.setDefaultButton(true);
broadcastButton.setDisable(false);
} else if(step == InitiatorController.Step.BROADCAST) {
setResult(initiatorController.getTransaction());
}
});
initiatorController.transactionAcceptedProperty().addListener((observable, oldValue, accepted) -> {
broadcastButton.setDisable(accepted != Boolean.TRUE);
});
nextButton.addEventFilter(ActionEvent.ACTION, event -> {
initiatorController.next();
event.consume();
});
cancelButton.addEventFilter(ActionEvent.ACTION, event -> {
initiatorController.cancel();
});
broadcastButton.addEventFilter(ActionEvent.ACTION, event -> {
acceptAndBroadcast(initiatorController, walletId, wallet);
event.consume();
});
setResultConverter(dialogButton -> dialogButton.getButtonData().equals(ButtonBar.ButtonData.APPLY) ? initiatorController.getTransaction() : null);
} catch(IOException e) {
throw new RuntimeException(e);
}
}
private void acceptAndBroadcast(InitiatorController initiatorController, String walletId, Wallet wallet) {
if(confirmationRequired && wallet.isEncrypted()) {
WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
Storage storage = AppServices.get().getOpenWallets().get(wallet);
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get(), true);
keyDerivationService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Done"));
initiatorController.accept();
password.get().clear();
});
keyDerivationService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Failed"));
});
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.START, "Decrypting wallet..."));
keyDerivationService.start();
}
} else {
initiatorController.accept();
}
}
}

View file

@ -0,0 +1,144 @@
package com.sparrowwallet.sparrow.soroban;
import com.google.common.net.HostAndPort;
import com.samourai.http.client.HttpUsage;
import com.samourai.http.client.IHttpClient;
import com.samourai.soroban.client.SorobanServer;
import com.samourai.soroban.client.cahoots.SorobanCahootsService;
import com.samourai.soroban.client.rpc.RpcClient;
import com.samourai.wallet.bip47.rpc.BIP47Wallet;
import com.samourai.wallet.bip47.rpc.PaymentCode;
import com.samourai.wallet.bip47.rpc.java.Bip47UtilJava;
import com.samourai.wallet.cahoots.CahootsWallet;
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.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 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;
public class Soroban {
private static final Logger log = LoggerFactory.getLogger(Soroban.class);
protected static final Bip47UtilJava bip47Util = Bip47UtilJava.getInstance();
protected static final Provider PROVIDER_JAVA = Drongo.getProvider();
protected static final int TIMEOUT_MS = 60000;
public static final List<Network> SOROBAN_NETWORKS = List.of(Network.MAINNET, Network.TESTNET);
private final SorobanServer sorobanServer;
private final JavaHttpClientService httpClientService;
private HD_Wallet hdWallet;
private PaymentCode paymentCode;
public Soroban(Network network, HostAndPort torProxy) {
this.sorobanServer = SorobanServer.valueOf(network.getName().toUpperCase());
this.httpClientService = new JavaHttpClientService(torProxy);
}
public HD_Wallet getHdWallet() {
return hdWallet;
}
public PaymentCode getPaymentCode() {
return paymentCode;
}
public void setHDWallet(Wallet wallet) {
if(wallet.isEncrypted()) {
throw new IllegalStateException("Wallet cannot be encrypted");
}
try {
Keystore keystore = wallet.getKeystores().get(0);
ScriptType scriptType = wallet.getScriptType();
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);
} catch(Exception e) {
throw new IllegalStateException("Could not create Soroban HD wallet ", e);
}
}
public SparrowCahootsWallet getCahootsWallet(Wallet wallet, double feeRate) {
if(wallet.getScriptType() != ScriptType.P2WPKH) {
throw new IllegalArgumentException("Wallet must be P2WPKH");
}
if(hdWallet == null) {
for(Wallet associatedWallet : wallet.getAllWallets()) {
Soroban soroban = AppServices.getSorobanServices().getSoroban(associatedWallet);
if(soroban != null && soroban.getHdWallet() != null) {
hdWallet = soroban.hdWallet;
paymentCode = soroban.paymentCode;
}
}
}
if(hdWallet == null) {
throw new IllegalStateException("HD wallet is not set");
}
try {
return new SparrowCahootsWallet(wallet, hdWallet, sorobanServer, (long)feeRate);
} catch(Exception e) {
log.error("Could not create cahoots wallet", e);
}
return null;
}
public SorobanCahootsService getSorobanCahootsService(CahootsWallet cahootsWallet) {
IHttpClient httpClient = httpClientService.getHttpClient(HttpUsage.COORDINATOR_REST);
RpcClient rpcClient = new RpcClient(httpClient, httpClientService.getTorProxy() != null, sorobanServer.getParams());
return new SorobanCahootsService(bip47Util, PROVIDER_JAVA, cahootsWallet, rpcClient);
}
public HostAndPort getTorProxy() {
return httpClientService.getTorProxy();
}
public void setTorProxy(HostAndPort torProxy) {
//Ensure all http clients are shutdown first
httpClientService.shutdown();
httpClientService.setTorProxy(torProxy);
}
public void shutdown() {
httpClientService.shutdown();
}
public static class ShutdownService extends Service<Boolean> {
private final Soroban soroban;
public ShutdownService(Soroban soroban) {
this.soroban = soroban;
}
@Override
protected Task<Boolean> createTask() {
return new Task<>() {
protected Boolean call() throws Exception {
soroban.shutdown();
return true;
}
};
}
}
}

View file

@ -0,0 +1,122 @@
package com.sparrowwallet.sparrow.soroban;
import com.samourai.wallet.cahoots.stonewallx2.STONEWALLx2;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.protocol.TransactionInput;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTParseException;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.control.TransactionDiagram;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.*;
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());
return psbt.getTransaction();
}
return null;
}
protected void updateTransactionDiagram(TransactionDiagram transactionDiagram, Wallet wallet, WalletTransaction walletTransaction, Transaction transaction) {
WalletTransaction txWalletTransaction = getWalletTransaction(wallet, walletTransaction, transaction, null);
transactionDiagram.update(txWalletTransaction);
if(txWalletTransaction.getSelectedUtxoSets().size() == 2) {
Set<Sha256Hash> references = txWalletTransaction.getSelectedUtxoSets().get(1).keySet().stream().map(BlockTransactionHash::getHash).collect(Collectors.toSet());
ElectrumServer.TransactionReferenceService transactionReferenceService = new ElectrumServer.TransactionReferenceService(references);
transactionReferenceService.setOnSucceeded(successEvent -> {
Map<Sha256Hash, BlockTransaction> transactionMap = transactionReferenceService.getValue();
transactionDiagram.update(getWalletTransaction(wallet, walletTransaction, transaction, transactionMap));
});
transactionReferenceService.setOnFailed(failedEvent -> {
log.error("Failed to retrieve referenced transactions", failedEvent.getSource().getException());
});
transactionReferenceService.start();
}
}
private WalletTransaction getWalletTransaction(Wallet wallet, WalletTransaction walletTransaction, Transaction transaction, Map<Sha256Hash, BlockTransaction> inputTransactions) {
Map<BlockTransactionHashIndex, WalletNode> allWalletUtxos = wallet.getWalletTxos();
Map<BlockTransactionHashIndex, WalletNode> walletUtxos = new LinkedHashMap<>();
Map<BlockTransactionHashIndex, WalletNode> externalUtxos = new LinkedHashMap<>();
for(TransactionInput txInput : transaction.getInputs()) {
Optional<BlockTransactionHashIndex> optWalletUtxo = allWalletUtxos.keySet().stream().filter(txo -> txo.getHash().equals(txInput.getOutpoint().getHash()) && txo.getIndex() == txInput.getOutpoint().getIndex()).findFirst();
if(optWalletUtxo.isPresent()) {
walletUtxos.put(optWalletUtxo.get(), allWalletUtxos.get(optWalletUtxo.get()));
} else {
BlockTransactionHashIndex externalUtxo;
if(inputTransactions != null && inputTransactions.containsKey(txInput.getOutpoint().getHash())) {
BlockTransaction blockTransaction = inputTransactions.get(txInput.getOutpoint().getHash());
TransactionOutput txOutput = blockTransaction.getTransaction().getOutputs().get((int)txInput.getOutpoint().getIndex());
externalUtxo = new BlockTransactionHashIndex(blockTransaction.getHash(), blockTransaction.getHeight(), blockTransaction.getDate(), blockTransaction.getFee(), txInput.getOutpoint().getIndex(), txOutput.getValue());
} else {
externalUtxo = new BlockTransactionHashIndex(txInput.getOutpoint().getHash(), 0, null, null, txInput.getOutpoint().getIndex(), 0);
}
externalUtxos.put(externalUtxo, null);
}
}
List<Map<BlockTransactionHashIndex, WalletNode>> selectedUtxoSets = new ArrayList<>();
selectedUtxoSets.add(walletUtxos);
selectedUtxoSets.add(externalUtxos);
Map<Address, WalletNode> walletAddresses = wallet.getWalletAddresses();
List<Payment> payments = new ArrayList<>();
Map<WalletNode, Long> changeMap = new LinkedHashMap<>();
for(TransactionOutput txOutput : transaction.getOutputs()) {
Address address = txOutput.getScript().getToAddress();
if(address != null) {
Optional<Payment> optPayment = walletTransaction == null ? Optional.empty() :
walletTransaction.getPayments().stream().filter(payment -> payment.getAddress().equals(address) && payment.getAmount() == txOutput.getValue()).findFirst();
if(optPayment.isPresent()) {
payments.add(optPayment.get());
} else if(walletAddresses.containsKey(address) && walletAddresses.get(address).getKeyPurpose() == KeyPurpose.CHANGE) {
changeMap.put(walletAddresses.get(address), txOutput.getValue());
} else {
Payment payment = new Payment(address, null, txOutput.getValue(), false);
if(transaction.getOutputs().stream().anyMatch(txo -> txo != txOutput && txo.getValue() == txOutput.getValue())) {
payment.setType(Payment.Type.MIX);
}
payments.add(payment);
}
}
}
long fee = calculateFee(walletTransaction, selectedUtxoSets, transaction);
return new WalletTransaction(wallet, transaction, Collections.emptyList(), selectedUtxoSets, payments, changeMap, fee, inputTransactions);
}
private long calculateFee(WalletTransaction walletTransaction, List<Map<BlockTransactionHashIndex, WalletNode>> selectedUtxoSets, Transaction transaction) {
Map<BlockTransactionHashIndex, WalletNode> selectedUtxos = new LinkedHashMap<>();
selectedUtxoSets.forEach(selectedUtxos::putAll);
long feeAmt = 0L;
for(BlockTransactionHashIndex utxo : selectedUtxos.keySet()) {
if(utxo.getValue() == 0) {
return walletTransaction == null ? -1 : walletTransaction.getFee();
}
feeAmt += utxo.getValue();
}
for(TransactionOutput txOutput : transaction.getOutputs()) {
feeAmt -= txOutput.getValue();
}
return feeAmt;
}
}

View file

@ -0,0 +1,82 @@
package com.sparrowwallet.sparrow.soroban;
import com.google.common.eventbus.Subscribe;
import com.google.common.net.HostAndPort;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.DeterministicSeed;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.WalletTabData;
import com.sparrowwallet.sparrow.event.WalletTabsClosedEvent;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.TorService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
public class SorobanServices {
private static final Logger log = LoggerFactory.getLogger(SorobanServices.class);
private final Map<String, Soroban> sorobanMap = new HashMap<>();
public Soroban getSoroban(Wallet wallet) {
Wallet masterWallet = wallet.isMasterWallet() ? wallet : wallet.getMasterWallet();
for(Map.Entry<Wallet, Storage> entry : AppServices.get().getOpenWallets().entrySet()) {
if(entry.getKey() == masterWallet) {
return sorobanMap.get(entry.getValue().getWalletId(entry.getKey()));
}
}
return null;
}
public Soroban getSoroban(String walletId) {
Soroban soroban = sorobanMap.get(walletId);
if(soroban == null) {
HostAndPort torProxy = getTorProxy();
soroban = new Soroban(Network.get(), torProxy);
sorobanMap.put(walletId, soroban);
} else {
HostAndPort torProxy = getTorProxy();
if(!Objects.equals(soroban.getTorProxy(), torProxy)) {
soroban.setTorProxy(getTorProxy());
}
}
return soroban;
}
private HostAndPort getTorProxy() {
return AppServices.isTorRunning() ?
HostAndPort.fromParts("localhost", TorService.PROXY_PORT) :
(Config.get().getProxyServer() == null || Config.get().getProxyServer().isEmpty() || !Config.get().isUseProxy() ? null : HostAndPort.fromString(Config.get().getProxyServer()));
}
public static boolean canWalletMix(Wallet wallet) {
return Soroban.SOROBAN_NETWORKS.contains(Network.get())
&& wallet.getKeystores().size() == 1
&& wallet.getKeystores().get(0).hasSeed()
&& wallet.getKeystores().get(0).getSeed().getType() == DeterministicSeed.Type.BIP39
&& wallet.getScriptType() == ScriptType.P2WPKH;
}
@Subscribe
public void walletTabsClosed(WalletTabsClosedEvent event) {
for(WalletTabData walletTabData : event.getClosedWalletTabData()) {
String walletId = walletTabData.getStorage().getWalletId(walletTabData.getWallet());
Soroban soroban = sorobanMap.remove(walletId);
if(soroban != null) {
Soroban.ShutdownService shutdownService = new Soroban.ShutdownService(soroban);
shutdownService.setOnFailed(failedEvent -> {
log.error("Failed to shutdown soroban", failedEvent.getSource().getException());
});
shutdownService.start();
}
}
}
}

View file

@ -0,0 +1,56 @@
package com.sparrowwallet.sparrow.soroban;
import com.samourai.soroban.client.SorobanServer;
import com.samourai.wallet.api.backend.beans.UnspentOutput;
import com.samourai.wallet.cahoots.CahootsUtxo;
import com.samourai.wallet.cahoots.SimpleCahootsWallet;
import com.samourai.wallet.hd.HD_Address;
import com.samourai.wallet.hd.HD_Wallet;
import com.samourai.wallet.send.MyTransactionOutPoint;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import com.sparrowwallet.drongo.wallet.StandardAccount;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.whirlpool.Whirlpool;
import org.apache.commons.lang3.tuple.Pair;
public class SparrowCahootsWallet extends SimpleCahootsWallet {
private final Wallet wallet;
private final int account;
public SparrowCahootsWallet(Wallet wallet, HD_Wallet bip84w, SorobanServer sorobanServer, long feePerB) throws Exception {
super(bip84w, sorobanServer.getParams(), wallet.getFreshNode(KeyPurpose.CHANGE).getIndex(), feePerB);
this.wallet = wallet;
this.account = wallet.getAccountIndex();
bip84w.getAccount(account).getReceive().setAddrIdx(wallet.getFreshNode(KeyPurpose.RECEIVE).getIndex());
bip84w.getAccount(account).getChange().setAddrIdx(wallet.getFreshNode(KeyPurpose.CHANGE).getIndex());
}
public void addUtxo(Wallet wallet, WalletNode node, BlockTransaction blockTransaction, int index) {
UnspentOutput unspentOutput = Whirlpool.getUnspentOutput(wallet, node, blockTransaction, index);
MyTransactionOutPoint myTransactionOutPoint = unspentOutput.computeOutpoint(getParams());
HD_Address hdAddress = getBip84Wallet().getAddressAt(account, unspentOutput);
CahootsUtxo cahootsUtxo = new CahootsUtxo(myTransactionOutPoint, node.getDerivationPath(), hdAddress.getECKey());
addUtxo(account, cahootsUtxo);
}
public int getAccount() {
return account;
}
@Override
public Pair<Integer, Integer> fetchReceiveIndex(int account) throws Exception {
if(account == StandardAccount.WHIRLPOOL_POSTMIX.getAccountNumber()) {
// force change chain
return Pair.of(wallet.getFreshNode(KeyPurpose.CHANGE).getIndex(), 1);
}
return Pair.of(wallet.getFreshNode(KeyPurpose.RECEIVE).getIndex(), 0);
}
@Override
public Pair<Integer, Integer> fetchChangeIndex(int account) throws Exception {
return Pair.of(wallet.getFreshNode(KeyPurpose.CHANGE).getIndex(), 1);
}
}

View file

@ -513,10 +513,10 @@ public class HeadersController extends TransactionFormController implements Init
WalletNode changeNode = changeOutputScripts.get(txOutput.getScript());
if(changeNode != null) {
if(headersForm.getTransaction().getOutputs().size() == 4 && headersForm.getTransaction().getOutputs().stream().anyMatch(txo -> txo != txOutput && txo.getValue() == txOutput.getValue())) {
try {
payments.add(new Payment(txOutput.getScript().getToAddresses()[0], ".." + changeNode + " (Fake Mix)", txOutput.getValue(), false, Payment.Type.FAKE_MIX));
} catch(Exception e) {
//ignore
if(selectedTxos.values().stream().allMatch(Objects::nonNull)) {
payments.add(new Payment(txOutput.getScript().getToAddress(), ".." + changeNode + " (Fake Mix)", txOutput.getValue(), false, Payment.Type.FAKE_MIX));
} else {
payments.add(new Payment(txOutput.getScript().getToAddress(), ".." + changeNode + " (Mix)", txOutput.getValue(), false, Payment.Type.MIX));
}
} else {
changeMap.put(changeNode, txOutput.getValue());

View file

@ -149,8 +149,8 @@ public class InputController extends TransactionFormController implements Initia
inputFieldset.setText(baseText + " from " + signingWallet.getFullDisplayName());
inputFieldset.setIcon(TransactionDiagram.getTxoGlyph());
} else {
inputFieldset.setText(baseText + " - Payjoin");
inputFieldset.setIcon(TransactionDiagram.getPayjoinGlyph());
inputFieldset.setText(baseText + " - External");
inputFieldset.setIcon(TransactionDiagram.getMixGlyph());
}
} else {
inputFieldset.setText(baseText);

View file

@ -152,7 +152,7 @@ public class TransactionController implements Initializable {
if(inputForm.isWalletTxo()) {
setGraphic(TransactionDiagram.getTxoGlyph());
} else {
setGraphic(TransactionDiagram.getPayjoinGlyph());
setGraphic(TransactionDiagram.getMixGlyph());
}
}
if(form instanceof OutputForm) {

View file

@ -4,6 +4,7 @@ import com.google.common.eventbus.Subscribe;
import com.samourai.whirlpool.client.whirlpool.beans.Pool;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.address.InvalidAddressException;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.Transaction;
@ -18,6 +19,8 @@ import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.*;
import com.sparrowwallet.sparrow.soroban.InitiatorDialog;
import com.sparrowwallet.sparrow.soroban.SorobanServices;
import com.sparrowwallet.sparrow.whirlpool.Whirlpool;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
@ -37,6 +40,7 @@ import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.stage.Modality;
import javafx.util.Duration;
import javafx.util.StringConverter;
import org.controlsfx.glyphfont.Glyph;
@ -949,14 +953,14 @@ public class SendController extends WalletFormController implements Initializabl
}
}
private boolean isFakeMixPossible(List<Payment> payments) {
return (utxoSelectorProperty.get() == null
private boolean isMixPossible(List<Payment> payments) {
return (utxoSelectorProperty.get() == null || SorobanServices.canWalletMix(walletForm.getWallet()))
&& payments.size() == 1
&& (payments.get(0).getAddress().getScriptType() == getWalletForm().getWallet().getAddress(getWalletForm().wallet.getFreshNode(KeyPurpose.RECEIVE)).getScriptType()));
&& (payments.get(0).getAddress().getScriptType() == getWalletForm().getWallet().getAddress(getWalletForm().wallet.getFreshNode(KeyPurpose.RECEIVE)).getScriptType());
}
private void updateOptimizationButtons(List<Payment> payments) {
if(isFakeMixPossible(payments)) {
if(isMixPossible(payments)) {
setPreferredOptimizationStrategy();
privacyToggle.setDisable(false);
} else {
@ -975,7 +979,9 @@ public class SendController extends WalletFormController implements Initializabl
}
private void setPreferredOptimizationStrategy() {
optimizationToggleGroup.selectToggle(getPreferredOptimizationStrategy() == OptimizationStrategy.PRIVACY ? privacyToggle : efficiencyToggle);
OptimizationStrategy optimizationStrategy = getPreferredOptimizationStrategy();
optimizationToggleGroup.selectToggle(optimizationStrategy == OptimizationStrategy.PRIVACY ? privacyToggle : efficiencyToggle);
transactionDiagram.setOptimizationStrategy(optimizationStrategy);
}
private void updatePrivacyAnalysis(WalletTransaction walletTransaction) {
@ -1369,6 +1375,38 @@ public class SendController extends WalletFormController implements Initializabl
}
}
@Subscribe
public void sorobanInitiated(SorobanInitiatedEvent event) {
if(event.getWallet().equals(getWalletForm().getWallet())) {
InitiatorDialog initiatorDialog = new InitiatorDialog(getWalletForm().getWalletId(), getWalletForm().getWallet(), walletTransactionProperty.get());
if(Network.get() == Network.TESTNET) {
initiatorDialog.initModality(Modality.NONE);
}
Optional<Transaction> optTransaction = initiatorDialog.showAndWait();
if(optTransaction.isPresent()) {
ElectrumServer.BroadcastTransactionService broadcastTransactionService = new ElectrumServer.BroadcastTransactionService(optTransaction.get());
broadcastTransactionService.setOnRunning(workerStateEvent -> {
createButton.setDisable(true);
addWalletTransactionNodes();
});
broadcastTransactionService.setOnSucceeded(workerStateEvent -> {
createButton.setDisable(false);
clear(null);
});
broadcastTransactionService.setOnFailed(workerStateEvent -> {
createButton.setDisable(false);
Throwable exception = workerStateEvent.getSource().getException();
while(exception.getCause() != null) {
exception = exception.getCause();
}
AppServices.showErrorDialog("Error broadcasting mix transaction", exception.getMessage());
});
broadcastTransactionService.start();
}
}
}
private class PrivacyAnalysisTooltip extends VBox {
private List<Label> analysisLabels = new ArrayList<>();
@ -1386,13 +1424,21 @@ public class SendController extends WalletFormController implements Initializabl
addLabel("Appears as a two person coinjoin", getPlusGlyph());
} else {
if(mixedAddressTypes) {
addLabel("Cannot fake coinjoin due to mixed address types", getWarningGlyph());
} else if(utxoSelectorProperty().get() != null) {
addLabel("Cannot fake coinjoin due to coin control", getWarningGlyph());
addLabel("Cannot coinjoin due to mixed address types", getInfoGlyph());
} else if(userPayments.size() > 1) {
addLabel("Cannot fake coinjoin due to multiple payments", getWarningGlyph());
addLabel("Cannot coinjoin due to multiple payments", getInfoGlyph());
} else {
addLabel("Cannot fake coinjoin due to insufficient funds", getWarningGlyph());
if(utxoSelectorProperty().get() != null) {
addLabel("Cannot fake coinjoin due to coin control", getInfoGlyph());
} else {
addLabel("Cannot fake coinjoin due to insufficient funds", getInfoGlyph());
}
if(!SorobanServices.canWalletMix(getWalletForm().getWallet())) {
addLabel("Can only add mixing partner to Native Segwit software wallets", getInfoGlyph());
} else {
addLabel("Add a mixing partner to create a two person coinjoin", getInfoGlyph());
}
}
}
}
@ -1409,7 +1455,7 @@ public class SendController extends WalletFormController implements Initializabl
addLabel("Address reuse detected", getMinusGlyph());
}
if(analysisLabels.isEmpty() || (analysisLabels.size() == 1 && analysisLabels.get(0).getText().startsWith("Cannot fake coinjoin"))) {
if(!fakeMixPresent && !mixedAddressTypes && !roundPaymentAmounts) {
addLabel("Appears as a possible self transfer", getPlusGlyph());
}
@ -1448,5 +1494,13 @@ public class SendController extends WalletFormController implements Initializabl
minusGlyph.setFontSize(12);
return minusGlyph;
}
private static Glyph getInfoGlyph() {
Glyph infoGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.INFO_CIRCLE);
infoGlyph.setUserData(3);
infoGlyph.setStyle("-fx-text-fill: -fx-accent");
infoGlyph.setFontSize(12);
return infoGlyph;
}
}
}

View file

@ -39,4 +39,7 @@ open module com.sparrowwallet.sparrow {
requires org.reactfx.reactfx;
requires dev.bwt.jni;
requires com.sparrowwallet.nightjar;
requires io.reactivex.rxjava2;
requires io.reactivex.rxjava2fx;
requires org.apache.commons.lang3;
}

View file

@ -109,6 +109,7 @@
<Menu fx:id="toolsMenu" mnemonicParsing="false" text="Tools">
<MenuItem mnemonicParsing="false" text="Sign/Verify Message" accelerator="Shortcut+M" onAction="#signVerifyMessage"/>
<MenuItem fx:id="sendToMany" mnemonicParsing="false" text="Send To Many" onAction="#sendToMany"/>
<MenuItem fx:id="findMixingPartner" mnemonicParsing="false" text="Find Mixing Partner" onAction="#findMixingPartner"/>
<MenuItem styleClass="osxHide,windowsHide" mnemonicParsing="false" text="Install Udev Rules" onAction="#installUdevRules"/>
<SeparatorMenuItem />
<CheckMenuItem fx:id="preventSleep" mnemonicParsing="false" text="Prevent Computer Sleep" onAction="#preventSleep"/>

View file

@ -8,7 +8,7 @@
-fx-box-border: ladder(-fx-color, black 20%, derive(-fx-color,40%) 30%);
}
.label{
.label {
-fx-text-fill: lightgray;
}
@ -204,4 +204,12 @@
.root #loadingLog {
-fx-background-color: transparent;
}
.root #transactionDiagram .coins-icon, #transactionDiagram .user-icon {
-fx-text-fill: lightgray;
}
.root .progress-indicator.progress-timer.warn > .determinate-indicator > .indicator {
-fx-background-color: -fx-box-border, radial-gradient(center 50% 50%, radius 50%, #e06c75 70%, derive(-fx-control-inner-background, -9%) 100%);
}

View file

@ -260,4 +260,29 @@ HorizontalHeaderColumn > TableColumnHeader.column-header.table-column.selected >
CellView > .text-input.text-field {
-fx-text-fill: -fx-text-inner-color;
}
}
.progress-indicator.progress-timer .percentage {
-fx-fill: null;
}
.progress-indicator.progress-timer {
-fx-padding: 0 0 -16 0;
}
.progress-indicator.progress-timer > .determinate-indicator > .tick {
visibility: hidden;
}
.progress-indicator.progress-timer > .determinate-indicator > .indicator {
-fx-background-color: -fx-box-border, radial-gradient(center 50% 50%, radius 50%, -fx-accent 70%, derive(-fx-control-inner-background, -9%) 100%);
}
.progress-indicator.progress-timer.warn > .determinate-indicator > .indicator {
-fx-background-color: -fx-box-border, radial-gradient(center 50% 50%, radius 50%, rgb(202, 18, 67) 70%, derive(-fx-control-inner-background, -9%) 100%);
}
.progress-indicator.progress-timer > .determinate-indicator > .progress {
-fx-background-color: -fx-control-inner-background;
}

View file

@ -0,0 +1,50 @@
.counterparty-pane {
-fx-padding: 0;
}
.title-area {
-fx-background-color: -fx-control-inner-background;
-fx-padding: 10 25 10 25;
-fx-border-width: 0px 0px 1px 0px;
-fx-border-color: #e5e5e6;
}
#counterpartyBox, .button-bar {
-fx-padding: 10 25 25 25;
}
.button-bar .container {
-fx-padding: 0 0 15px 0;
}
.title-label {
-fx-font-size: 24px;
}
.title-text {
-fx-font-size: 20px;
-fx-padding: 0 0 15px 0;
-fx-graphic-text-gap: 10px;
}
.content-text {
-fx-font-size: 16px;
-fx-text-fill: derive(-fx-text-base-color, 15%);
}
.field-box {
-fx-pref-height: 30px;
-fx-alignment: CENTER_LEFT;
}
.wide-field-label {
-fx-pref-width: 180px;
}
.field-label {
-fx-pref-width: 110px;
}
.field-control {
-fx-pref-width: 500px;
}

View file

@ -0,0 +1,119 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import java.lang.*?>
<?import java.util.*?>
<?import javafx.scene.*?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<?import javafx.scene.image.ImageView?>
<?import javafx.scene.image.Image?>
<?import org.controlsfx.glyphfont.Glyph?>
<?import javafx.geometry.Insets?>
<?import com.sparrowwallet.sparrow.control.TransactionDiagram?>
<?import com.sparrowwallet.sparrow.control.ProgressTimer?>
<?import com.sparrowwallet.sparrow.control.CopyableTextField?>
<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">
<HBox styleClass="title-area">
<HBox alignment="CENTER_LEFT">
<Label fx:id="title" text="Find Mixing Partner" styleClass="title-label" />
</HBox>
<Region HBox.hgrow="ALWAYS"/>
<ImageView AnchorPane.rightAnchor="0">
<Image url="/image/useradd.png" requestedWidth="50" requestedHeight="50" smooth="false" />
</ImageView>
</HBox>
<VBox fx:id="counterpartyBox" styleClass="content-area" spacing="20" prefHeight="390">
<VBox fx:id="step1" spacing="15">
<Label text="Mix Preparation" styleClass="title-text">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="20" icon="RANDOM" styleClass="title-icon" />
</graphic>
</Label>
<Label text="Perform a two person coinjoin transaction using the Samourai Soroban service. Your 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" />
<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"/>
</HBox>
<HBox styleClass="field-box">
<Label text="Mix using:" styleClass="wide-field-label" />
<ComboBox fx:id="mixWallet" />
</HBox>
</VBox>
<VBox fx:id="step2" spacing="15">
<HBox>
<Label text="Review Mix Type" styleClass="title-text">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="20" icon="RANDOM" styleClass="title-icon" />
</graphic>
</Label>
<Region HBox.hgrow="ALWAYS" />
<ProgressTimer fx:id="step2Timer" seconds="60" />
</HBox>
<Label fx:id="step2Desc" text="Your mixing partner will now initiate the Soroban communication. Once communication is established, check the details of the mix and click Next if you'd like to proceed." wrapText="true" styleClass="content-text" />
<HBox styleClass="field-box">
<padding>
<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="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" />
</graphic>
</Label>
</HBox>
<VBox fx:id="mixDetails" spacing="7">
<HBox styleClass="field-box">
<Label text="Type:" styleClass="field-label" />
<Label fx:id="mixType" />
</HBox>
<HBox styleClass="field-box">
<Label text="Fee:" styleClass="field-label" />
<Label text="You pay half the miner fee" />
</HBox>
</VBox>
</VBox>
<VBox fx:id="step3" spacing="15">
<HBox>
<Label text="Perform Mix" styleClass="title-text">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="20" icon="RANDOM" styleClass="title-icon" />
</graphic>
</Label>
<Region HBox.hgrow="ALWAYS" />
<ProgressTimer fx:id="step3Timer" seconds="60" />
</HBox>
<Label fx:id="step3Desc" text="The mix transaction is now being created." wrapText="true" styleClass="content-text" />
<HBox>
<padding>
<Insets top="20" />
</padding>
<ProgressBar fx:id="sorobanProgressBar" prefWidth="680" />
</HBox>
<VBox alignment="CENTER">
<Label fx:id="sorobanProgressLabel" text="Waiting for mixing partner..." styleClass="content-text" alignment="CENTER"/>
<Glyph fx:id="mixDeclined" fontFamily="Font Awesome 5 Free Solid" fontSize="80" icon="USER_SLASH" />
</VBox>
</VBox>
<VBox fx:id="step4" spacing="15">
<Label text="Transaction Broadcasted" styleClass="title-text">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="20" icon="CHECK_CIRCLE" styleClass="title-icon,success" />
</graphic>
</Label>
<Label text="The broadcasted transaction is shown below." wrapText="true" styleClass="content-text" />
<HBox>
<padding>
<Insets top="20" />
</padding>
<TransactionDiagram fx:id="transactionDiagram" maxWidth="700" final="true"/>
</HBox>
</VBox>
</VBox>
</VBox>
</StackPane>

View file

@ -0,0 +1,46 @@
.initiator-pane {
-fx-padding: 0;
}
.title-area {
-fx-background-color: -fx-control-inner-background;
-fx-padding: 10 25 10 25;
-fx-border-width: 0px 0px 1px 0px;
-fx-border-color: #e5e5e6;
}
#initiatorBox, .button-bar {
-fx-padding: 10 25 25 25;
}
.button-bar .container {
-fx-padding: 0 0 15px 0;
}
.title-label {
-fx-font-size: 24px;
}
.title-text {
-fx-font-size: 20px;
-fx-padding: 0 0 15px 0;
-fx-graphic-text-gap: 10px;
}
.content-text {
-fx-font-size: 16px;
-fx-text-fill: derive(-fx-text-base-color, 15%);
}
.field-box {
-fx-pref-height: 30px;
-fx-alignment: CENTER_LEFT;
}
.field-label {
-fx-pref-width: 180px;
}
.field-control {
-fx-pref-width: 500px;
}

View file

@ -0,0 +1,85 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import java.lang.*?>
<?import java.util.*?>
<?import javafx.scene.*?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<?import javafx.scene.image.ImageView?>
<?import javafx.scene.image.Image?>
<?import org.controlsfx.glyphfont.Glyph?>
<?import javafx.geometry.Insets?>
<?import com.sparrowwallet.sparrow.control.TransactionDiagram?>
<?import com.sparrowwallet.sparrow.control.ProgressTimer?>
<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">
<HBox styleClass="title-area">
<HBox alignment="CENTER_LEFT">
<Label fx:id="title" text="Add Mixing Partner" styleClass="title-label" />
</HBox>
<Region HBox.hgrow="ALWAYS"/>
<ImageView AnchorPane.rightAnchor="0">
<Image url="/image/useradd.png" requestedWidth="50" requestedHeight="50" smooth="false" />
</ImageView>
</HBox>
<VBox fx:id="initiatorBox" styleClass="content-area" spacing="20" prefHeight="390">
<VBox fx:id="step1" spacing="15">
<Label text="Enter PayNym or Payment Code" styleClass="title-text">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="20" icon="RANDOM" styleClass="title-icon" />
</graphic>
</Label>
<Label text="Add a mixing partner to your two person coinjoin transaction using the Samourai Soroban service. Ask your partner for their PayNym, or use their payment code found in their Sparrow Tools menu → Find Mixing Partner." wrapText="true" styleClass="content-text" />
<HBox styleClass="field-box">
<padding>
<Insets top="20" />
</padding>
<Label text="Payment code or PayNym:" styleClass="field-label" />
<TextField fx:id="counterparty" styleClass="field-control"/>
</HBox>
</VBox>
<VBox fx:id="step2" spacing="15">
<HBox>
<Label text="Request Mix" styleClass="title-text">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="20" icon="RANDOM" styleClass="title-icon" />
</graphic>
</Label>
<Region HBox.hgrow="ALWAYS" />
<ProgressTimer fx:id="step2Timer" seconds="60" />
</HBox>
<Label fx:id="step2Desc" text="Ask your mixing partner to select Find Mixing Partner in the Sparrow Tools menu or Receive Online Cahoots in the Samourai Receive menu." wrapText="true" styleClass="content-text" />
<HBox>
<padding>
<Insets top="20" />
</padding>
<ProgressBar fx:id="sorobanProgressBar" prefWidth="680" />
</HBox>
<VBox alignment="CENTER">
<Label fx:id="sorobanProgressLabel" text="Waiting for mixing partner..." styleClass="content-text" alignment="CENTER"/>
<Glyph fx:id="mixDeclined" fontFamily="Font Awesome 5 Free Solid" fontSize="80" icon="USER_SLASH" />
</VBox>
</VBox>
<VBox fx:id="step3" spacing="15">
<HBox>
<Label text="Review Transaction" styleClass="title-text">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="20" icon="RANDOM" styleClass="title-icon" />
</graphic>
</Label>
<Region HBox.hgrow="ALWAYS" />
<ProgressTimer fx:id="step3Timer" seconds="60" />
</HBox>
<Label fx:id="step3Desc" text="Review the transaction and broadcast when ready." wrapText="true" styleClass="content-text" />
<HBox>
<padding>
<Insets top="20" />
</padding>
<TransactionDiagram fx:id="transactionDiagram" maxWidth="700" final="true"/>
</HBox>
</VBox>
</VBox>
</VBox>
</StackPane>

View file

@ -116,6 +116,14 @@
linear-gradient(to right, #c8416466 0%, #a0a1a766 50%, #a0a1a700 100%);
}
#transactionDiagram .user-icon {
-fx-text-fill: -fx-text-base-color;
#transactionDiagram .coins-icon, #transactionDiagram .user-icon {
-fx-text-fill: -fx-text-background-color;
}
#transactionDiagram .coins-replace-icon {
-fx-text-fill: -fx-accent;
}
#transactionDiagram .useradd-icon {
-fx-text-fill: -fx-accent;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB