Craig Raw
3 years ago
35 changed files with 1939 additions and 40 deletions
@ -1 +1 @@ |
|||
Subproject commit 3a061cb73ae318fcbe7ea1dcb0b670e78803d9fa |
|||
Subproject commit 4a4a62f239f5de1e25e927ee9996326383ea7f89 |
@ -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); |
|||
} |
|||
} |
@ -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; |
|||
} |
|||
} |
@ -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; |
|||
} |
|||
} |
@ -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); |
|||
} |
|||
} |
|||
} |
@ -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 |
|||
} |
|||
} |
@ -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(); |
|||
} |
|||
} |
|||
} |
@ -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; |
|||
} |
|||
}; |
|||
} |
|||
} |
|||
} |
@ -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; |
|||
} |
|||
} |
@ -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(); |
|||
} |
|||
} |
|||
} |
|||
} |
@ -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); |
|||
} |
|||
} |
@ -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; |
|||
} |
@ -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> |
@ -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; |
|||
} |
@ -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> |
After Width: | Height: | Size: 3.7 KiB |
After Width: | Height: | Size: 6.0 KiB |
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 2.2 KiB |
After Width: | Height: | Size: 3.1 KiB |
After Width: | Height: | Size: 5.1 KiB |
Loading…
Reference in new issue