diff --git a/src/main/java/com/sparrowwallet/sparrow/soroban/InitiatorController.java b/src/main/java/com/sparrowwallet/sparrow/soroban/InitiatorController.java index 7447c0ba..7ce50e57 100644 --- a/src/main/java/com/sparrowwallet/sparrow/soroban/InitiatorController.java +++ b/src/main/java/com/sparrowwallet/sparrow/soroban/InitiatorController.java @@ -1,5 +1,6 @@ package com.sparrowwallet.sparrow.soroban; +import com.google.common.eventbus.Subscribe; import com.samourai.soroban.cahoots.CahootsContext; import com.samourai.soroban.client.cahoots.OnlineCahootsMessage; import com.samourai.soroban.client.cahoots.SorobanCahootsService; @@ -12,6 +13,7 @@ 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.protocol.TransactionInput; import com.sparrowwallet.drongo.psbt.PSBTParseException; import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.sparrow.AppServices; @@ -22,8 +24,10 @@ 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.event.WalletNodeHistoryChangedEvent; import com.sparrowwallet.sparrow.io.Config; import com.sparrowwallet.sparrow.io.Storage; +import com.sparrowwallet.sparrow.net.ElectrumServer; import io.reactivex.Observable; import io.reactivex.rxjavafx.schedulers.JavaFxScheduler; import io.reactivex.schedulers.Schedulers; @@ -37,6 +41,7 @@ import javafx.event.ActionEvent; import javafx.fxml.FXML; import javafx.scene.control.*; import javafx.scene.layout.VBox; +import javafx.util.Duration; import javafx.util.StringConverter; import org.controlsfx.glyphfont.Glyph; import org.controlsfx.validation.ValidationResult; @@ -69,6 +74,9 @@ public class InitiatorController extends SorobanController { @FXML private VBox step3; + @FXML + private VBox step4; + @FXML private ComboBox payNymFollowers; @@ -108,6 +116,18 @@ public class InitiatorController extends SorobanController { @FXML private TransactionDiagram transactionDiagram; + @FXML + private Label step4Desc; + + @FXML + private ProgressBar broadcastProgressBar; + + @FXML + private Label broadcastProgressLabel; + + @FXML + private Glyph broadcastSuccessful; + private final StringProperty counterpartyPayNymName = new SimpleStringProperty(); private final ObjectProperty counterpartyPaymentCode = new SimpleObjectProperty<>(null); @@ -120,6 +140,10 @@ public class InitiatorController extends SorobanController { private CahootsType cahootsType = CahootsType.STONEWALLX2; + private ElectrumServer.TransactionMempoolService transactionMempoolService; + + private boolean closed; + public void initializeView(String walletId, Wallet wallet, WalletTransaction walletTransaction) { this.walletId = walletId; this.wallet = wallet; @@ -128,6 +152,7 @@ public class InitiatorController extends SorobanController { step1.managedProperty().bind(step1.visibleProperty()); step2.managedProperty().bind(step2.visibleProperty()); step3.managedProperty().bind(step3.visibleProperty()); + step4.managedProperty().bind(step4.visibleProperty()); sorobanProgressBar.managedProperty().bind(sorobanProgressBar.visibleProperty()); sorobanProgressLabel.managedProperty().bind(sorobanProgressLabel.visibleProperty()); @@ -135,12 +160,17 @@ public class InitiatorController extends SorobanController { sorobanProgressBar.visibleProperty().bind(sorobanProgressLabel.visibleProperty()); mixDeclined.visibleProperty().bind(sorobanProgressLabel.visibleProperty().not()); step2Timer.visibleProperty().bind(sorobanProgressLabel.visibleProperty()); + broadcastProgressBar.managedProperty().bind(broadcastProgressBar.visibleProperty()); + broadcastProgressLabel.managedProperty().bind(broadcastProgressLabel.visibleProperty()); + broadcastSuccessful.managedProperty().bind(broadcastSuccessful.visibleProperty()); + broadcastSuccessful.setVisible(false); step2.setVisible(false); step3.setVisible(false); + step4.setVisible(false); transactionAccepted.addListener((observable, oldValue, accepted) -> { - if(transactionProperty.get() != null) { + if(transactionProperty.get() != null && stepProperty.get() != Step.REBROADCAST) { Platform.exitNestedEventLoop(transactionAccepted, accepted); } }); @@ -240,6 +270,16 @@ public class InitiatorController extends SorobanController { } }); + stepProperty.addListener((observable, oldValue, step) -> { + if(step == Step.BROADCAST) { + step4Desc.setText("Broadcasting the mix transaction..."); + broadcastProgressLabel.setVisible(true); + } else if(step == Step.REBROADCAST) { + step4Desc.setText("Rebroadcast the mix transaction."); + broadcastProgressLabel.setVisible(false); + } + }); + Payment payment = walletTransaction.getPayments().get(0); if(payment.getAddress() instanceof PayNymAddress payNymAddress) { PayNym payNym = payNymAddress.getPayNym(); @@ -429,13 +469,14 @@ public class InitiatorController extends SorobanController { if(cahoots.getStep() == 3) { next(); step3Timer.start(e -> { - if(stepProperty.get() != Step.BROADCAST) { + if(stepProperty.get() != Step.BROADCAST && stepProperty.get() != Step.REBROADCAST) { step3Desc.setText("Transaction declined due to timeout."); transactionAccepted.set(Boolean.FALSE); } }); } else if(cahoots.getStep() == 4) { - stepProperty.set(Step.BROADCAST); + next(); + broadcastTransaction(); } } } catch(PSBTParseException e) { @@ -458,6 +499,70 @@ public class InitiatorController extends SorobanController { } } + public void broadcastTransaction() { + stepProperty.set(Step.BROADCAST); + + ElectrumServer.BroadcastTransactionService broadcastTransactionService = new ElectrumServer.BroadcastTransactionService(getTransaction()); + broadcastTransactionService.setOnRunning(workerStateEvent -> { + broadcastProgressBar.setProgress(-1); + }); + broadcastTransactionService.setOnSucceeded(workerStateEvent -> { + Map selectedUtxos = new HashMap<>(); + Map walletTxos = wallet.getWalletTxos(); + for(TransactionInput txInput : getTransaction().getInputs()) { + Optional optSelectedUtxo = walletTxos.keySet().stream().filter(txo -> txInput.getOutpoint().getHash().equals(txo.getHash()) && txInput.getOutpoint().getIndex() == txo.getIndex()) + .findFirst(); + optSelectedUtxo.ifPresent(blockTransactionHashIndex -> selectedUtxos.put(blockTransactionHashIndex, walletTxos.get(blockTransactionHashIndex))); + } + + if(transactionMempoolService != null) { + transactionMempoolService.cancel(); + } + + transactionMempoolService = new ElectrumServer.TransactionMempoolService(wallet, getTransaction().getTxId(), new HashSet<>(selectedUtxos.values())); + transactionMempoolService.setDelay(Duration.seconds(3)); + transactionMempoolService.setPeriod(Duration.seconds(10)); + transactionMempoolService.setRestartOnFailure(false); + transactionMempoolService.setOnSucceeded(mempoolWorkerStateEvent -> { + Set scriptHashes = transactionMempoolService.getValue(); + if(!scriptHashes.isEmpty()) { + Platform.runLater(() -> EventManager.get().post(new WalletNodeHistoryChangedEvent(scriptHashes.iterator().next()))); + } + + if(transactionMempoolService.getIterationCount() > 3 && transactionMempoolService.isRunning()) { + transactionMempoolService.cancel(); + broadcastProgressBar.setProgress(0); + log.error("Timeout searching for broadcasted transaction"); + AppServices.showErrorDialog("Timeout searching for broadcasted transaction", "The transaction was broadcast but the server did not register it in the mempool. It is safe to try broadcasting again."); + stepProperty.set(Step.REBROADCAST); + } + }); + transactionMempoolService.setOnFailed(mempoolWorkerStateEvent -> { + transactionMempoolService.cancel(); + broadcastProgressBar.setProgress(0); + log.error("Timeout searching for broadcasted transaction"); + AppServices.showErrorDialog("Timeout searching for broadcasted transaction", "The transaction was broadcast but the server did not indicate it had entered the mempool. It is safe to try broadcasting again."); + stepProperty.set(Step.REBROADCAST); + }); + + if(!closed) { + transactionMempoolService.start(); + } + }); + broadcastTransactionService.setOnFailed(workerStateEvent -> { + broadcastProgressBar.setProgress(0); + Throwable exception = workerStateEvent.getSource().getException(); + while(exception.getCause() != null) { + exception = exception.getCause(); + } + + log.error("Error broadcasting transaction", exception); + AppServices.showErrorDialog("Error broadcasting transaction", exception.getMessage()); + stepProperty.set(Step.REBROADCAST); + }); + broadcastTransactionService.start(); + } + public void next() { if(step1.isVisible()) { step1.setVisible(false); @@ -470,6 +575,13 @@ public class InitiatorController extends SorobanController { step2.setVisible(false); step3.setVisible(true); stepProperty.set(Step.REVIEW); + return; + } + + if(step3.isVisible()) { + step3.setVisible(false); + step4.setVisible(true); + stepProperty.set(Step.BROADCAST); } } @@ -525,11 +637,36 @@ public class InitiatorController extends SorobanController { return transactionProperty.get(); } + public void close() { + closed = true; + if(transactionMempoolService != null) { + transactionMempoolService.cancel(); + } + } + + public boolean isTransactionAccepted() { + return transactionAccepted.get() == Boolean.TRUE; + } + public ObjectProperty transactionAcceptedProperty() { return transactionAccepted; } + @Subscribe + public void walletNodeHistoryChanged(WalletNodeHistoryChangedEvent event) { + if(event.getWalletNode(wallet) != null) { + if(transactionMempoolService != null) { + transactionMempoolService.cancel(); + } + + broadcastProgressBar.setVisible(false); + broadcastProgressLabel.setVisible(false); + step4Desc.setText("Transaction broadcasted."); + broadcastSuccessful.setVisible(true); + } + } + public enum Step { - SETUP, COMMUNICATE, REVIEW, BROADCAST + SETUP, COMMUNICATE, REVIEW, BROADCAST, REBROADCAST } } diff --git a/src/main/java/com/sparrowwallet/sparrow/soroban/InitiatorDialog.java b/src/main/java/com/sparrowwallet/sparrow/soroban/InitiatorDialog.java index 986e081d..ead9fea5 100644 --- a/src/main/java/com/sparrowwallet/sparrow/soroban/InitiatorDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/soroban/InitiatorDialog.java @@ -41,6 +41,8 @@ public class InitiatorDialog extends Dialog { InitiatorController initiatorController = initiatorLoader.getController(); initiatorController.initializeView(walletId, wallet, walletTransaction); + EventManager.get().register(initiatorController); + dialogPane.setPrefWidth(730); dialogPane.setPrefHeight(530); AppServices.moveToActiveWindowScreen(this); @@ -51,18 +53,23 @@ public class InitiatorDialog extends Dialog { 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); + final ButtonType doneButtonType = new javafx.scene.control.ButtonType("Done", ButtonBar.ButtonData.APPLY); + dialogPane.getButtonTypes().addAll(nextButtonType, cancelButtonType, broadcastButtonType, doneButtonType); Button nextButton = (Button)dialogPane.lookupButton(nextButtonType); Button cancelButton = (Button)dialogPane.lookupButton(cancelButtonType); Button broadcastButton = (Button)dialogPane.lookupButton(broadcastButtonType); + Button doneButton = (Button)dialogPane.lookupButton(doneButtonType); nextButton.setDisable(initiatorController.counterpartyPaymentCodeProperty().get() == null); broadcastButton.setDisable(true); nextButton.managedProperty().bind(nextButton.visibleProperty()); + cancelButton.managedProperty().bind(cancelButton.visibleProperty()); broadcastButton.managedProperty().bind(broadcastButton.visibleProperty()); + doneButton.managedProperty().bind(doneButton.visibleProperty()); - broadcastButton.visibleProperty().bind(nextButton.visibleProperty().not()); + broadcastButton.setVisible(false); + doneButton.setVisible(false); initiatorController.counterpartyPaymentCodeProperty().addListener((observable, oldValue, paymentCode) -> { nextButton.setDisable(paymentCode == null || !AppServices.isConnected()); @@ -77,10 +84,19 @@ public class InitiatorDialog extends Dialog { nextButton.setVisible(true); } else if(step == InitiatorController.Step.REVIEW) { nextButton.setVisible(false); + broadcastButton.setVisible(true); broadcastButton.setDefaultButton(true); broadcastButton.setDisable(false); } else if(step == InitiatorController.Step.BROADCAST) { - setResult(initiatorController.getTransaction()); + cancelButton.setVisible(false); + broadcastButton.setVisible(false); + doneButton.setVisible(true); + doneButton.setDefaultButton(true); + } else if(step == InitiatorController.Step.REBROADCAST) { + cancelButton.setVisible(true); + broadcastButton.setVisible(true); + broadcastButton.setDisable(false); + doneButton.setVisible(false); } }); @@ -98,10 +114,19 @@ public class InitiatorDialog extends Dialog { }); broadcastButton.addEventFilter(ActionEvent.ACTION, event -> { - acceptAndBroadcast(initiatorController, walletId, wallet); + if(initiatorController.isTransactionAccepted()) { + initiatorController.broadcastTransaction(); + } else { + acceptAndBroadcast(initiatorController, walletId, wallet); + } event.consume(); }); + setOnCloseRequest(event -> { + initiatorController.close(); + EventManager.get().unregister(initiatorController); + }); + setResultConverter(dialogButton -> dialogButton.getButtonData().equals(ButtonBar.ButtonData.APPLY) ? initiatorController.getTransaction() : null); } catch(IOException e) { throw new RuntimeException(e); diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java index a7e0601d..519a8534 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java @@ -1406,32 +1406,16 @@ public class SendController extends WalletFormController implements Initializabl return; } - InitiatorDialog initiatorDialog = new InitiatorDialog(getWalletForm().getWalletId(), getWalletForm().getWallet(), walletTransactionProperty.get()); - if(Config.get().isSameAppMixing()) { - initiatorDialog.initModality(Modality.NONE); - } - Optional 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); + Platform.runLater(() -> { + InitiatorDialog initiatorDialog = new InitiatorDialog(getWalletForm().getWalletId(), getWalletForm().getWallet(), walletTransactionProperty.get()); + if(Config.get().isSameAppMixing()) { + initiatorDialog.initModality(Modality.NONE); + } + Optional optTransaction = initiatorDialog.showAndWait(); + if(optTransaction.isPresent()) { 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(); - } + } + }); } } diff --git a/src/main/resources/com/sparrowwallet/sparrow/soroban/initiator.fxml b/src/main/resources/com/sparrowwallet/sparrow/soroban/initiator.fxml index 0f8e68c5..57c2c479 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/soroban/initiator.fxml +++ b/src/main/resources/com/sparrowwallet/sparrow/soroban/initiator.fxml @@ -106,6 +106,26 @@ + + + + +