From 2055bde2a49f3982b2a80c3547ce1d2e9b6ae4eb Mon Sep 17 00:00:00 2001 From: Steven Barclay Date: Sat, 31 Aug 2024 03:58:28 +0800 Subject: [PATCH] Add front end for redirect tx recovery & broadcast Provide an overlay for calling 'RedirectionTransactionRecoveryService', activated by the keyboard shortcut ALT/CMD/CTRL-K. The user must supply the deposit tx ID and the peer's warning tx hex. The latter can be found with aid of a suitable block explorer, but cannot generally be retrieved by bitcoinj, since a wallet restored from the user's seed phrase won't see txs spending the deposit tx output, if there are no incoming UTXOs. Also improve the error handling of the backend slightly. --- .../RedirectTxRecoveryException.java | 28 ++ ...RedirectionTransactionRecoveryService.java | 66 ++-- .../DelayedPayoutTxReceiverService.java | 25 ++ .../util/validation/HexStringValidator.java | 4 +- .../resources/i18n/displayStrings.properties | 2 + ...rectionTransactionRecoveryServiceTest.java | 4 +- .../main/java/bisq/desktop/app/BisqApp.java | 6 + .../windows/RedirectTxRecoveryWindow.java | 288 ++++++++++++++++++ .../main/settings/about/AboutView.java | 3 + 9 files changed, 401 insertions(+), 25 deletions(-) create mode 100644 core/src/main/java/bisq/core/btc/exceptions/RedirectTxRecoveryException.java create mode 100644 desktop/src/main/java/bisq/desktop/main/overlays/windows/RedirectTxRecoveryWindow.java diff --git a/core/src/main/java/bisq/core/btc/exceptions/RedirectTxRecoveryException.java b/core/src/main/java/bisq/core/btc/exceptions/RedirectTxRecoveryException.java new file mode 100644 index 00000000000..f39c5d113ca --- /dev/null +++ b/core/src/main/java/bisq/core/btc/exceptions/RedirectTxRecoveryException.java @@ -0,0 +1,28 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.btc.exceptions; + +public class RedirectTxRecoveryException extends RuntimeException { + public RedirectTxRecoveryException(String message) { + super(message); + } + + public RedirectTxRecoveryException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/core/src/main/java/bisq/core/btc/wallet/RedirectionTransactionRecoveryService.java b/core/src/main/java/bisq/core/btc/wallet/RedirectionTransactionRecoveryService.java index 2789ad7b007..115d5f2b94e 100644 --- a/core/src/main/java/bisq/core/btc/wallet/RedirectionTransactionRecoveryService.java +++ b/core/src/main/java/bisq/core/btc/wallet/RedirectionTransactionRecoveryService.java @@ -17,11 +17,14 @@ package bisq.core.btc.wallet; +import bisq.core.btc.exceptions.RedirectTxRecoveryException; import bisq.core.btc.exceptions.TransactionVerificationException; import bisq.core.crypto.LowRSigningKey; import bisq.core.dao.burningman.DelayedPayoutTxReceiverService; +import bisq.core.dao.burningman.DelayedPayoutTxReceiverService.ReceiverFlag; import bisq.core.trade.protocol.bisq_v5.model.StagedPayoutTxParameters; +import bisq.common.app.Version; import bisq.common.util.Tuple2; import org.bitcoinj.core.Address; @@ -43,12 +46,15 @@ import org.bitcoinj.script.Script; import org.bitcoinj.script.ScriptBuilder; import org.bitcoinj.script.ScriptChunk; +import org.bitcoinj.script.ScriptException; import javax.inject.Inject; import javax.inject.Singleton; import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.BoundType; import com.google.common.collect.ImmutableSetMultimap; +import com.google.common.collect.Range; import com.google.common.collect.SetMultimap; import org.bouncycastle.crypto.params.KeyParameter; @@ -56,7 +62,6 @@ import java.math.BigInteger; -import java.util.Date; import java.util.List; import java.util.Map; import java.util.Objects; @@ -95,15 +100,14 @@ public RedirectionTransactionRecoveryService(Supplier params, public Transaction recoverRedirectTx(Sha256Hash depositTxId, Transaction peersWarningTx, @Nullable KeyParameter aesKey) - throws SignatureDecodeException, TransactionVerificationException { + throws RedirectTxRecoveryException { - // TODO: Throw something more useful & specific here... Transaction depositTx = Optional.ofNullable(btcWalletService.getTransaction(depositTxId)) - .orElseThrow(); + .orElseThrow(() -> new RedirectTxRecoveryException("Could not find depositTx in our wallet")); Tuple2 myDepositInputIndexAndKey = findMyDepositInputIndexAndKey(depositTx) - .orElseThrow(); + .orElseThrow(() -> new RedirectTxRecoveryException("Invalid depositTx: missing input from our wallet")); DeterministicKey myMultiSigKeyPair = findMyMultiSigKeyPair(peersWarningTx.getInput(0)) - .orElseThrow(); + .orElseThrow(() -> new RedirectTxRecoveryException("Invalid peersWarningTx: missing multisig key from our wallet")); int myDepositInputIndex = myDepositInputIndexAndKey.first; boolean amBuyer = myDepositInputIndex == 0; @@ -122,15 +126,19 @@ public Transaction recoverRedirectTx(Sha256Hash depositTxId, .flatMap(tx -> matcher.getMatchingSignatures(redirectTxSigHash(peersWarningTx, tx, redeemScript)) .map(signature -> new Tuple2<>(tx, signature))) .findFirst() - .orElseThrow(); + .orElseThrow(() -> new RedirectTxRecoveryException("Unable to find redirectTx matching any peer signature candidate")); Transaction redirectTx = redirectTxAndPeerSignature.first; Sha256Hash sigHash = redirectTxSigHash(peersWarningTx, redirectTx, redeemScript); ECKey.ECDSASignature mySignature = LowRSigningKey.from(myMultiSigKeyPair).sign(sigHash, aesKey); ECKey.ECDSASignature buyerSignature = amBuyer ? mySignature : redirectTxAndPeerSignature.second; ECKey.ECDSASignature sellerSignature = amBuyer ? redirectTxAndPeerSignature.second : mySignature; - return redirectionTransactionFactory.finalizeRedirectionTransaction(peersWarningTx.getOutput(0), redirectTx, - redeemScript, buyerSignature, sellerSignature); + try { + return redirectionTransactionFactory.finalizeRedirectionTransaction(peersWarningTx.getOutput(0), redirectTx, + redeemScript, buyerSignature, sellerSignature); + } catch (TransactionVerificationException | ScriptException | IllegalArgumentException e) { + throw new RedirectTxRecoveryException("Recovered redirectTx failed to verify", e); + } } private Optional> findMyDepositInputIndexAndKey(Transaction depositTx) { @@ -194,8 +202,13 @@ private Stream unsignedRedirectTxCandidates(TransactionOutput depos private Stream unsignedRedirectTxTemplates(TransactionOutput depositTxOutput, Transaction peersWarningTx) { - return lockTimeDelaysPlusErrorsToTry() - .mapToObj(delay -> unsignedRedirectTxTemplate(depositTxOutput, peersWarningTx, delay)); + return receiverFlagSetsToTry() + .flatMap(flags -> lockTimeDelaysPlusErrorsToTry() + .mapToObj(delay -> unsignedRedirectTxTemplate(depositTxOutput, peersWarningTx, flags, delay))); + } + + private Stream> receiverFlagSetsToTry() { + return ReceiverFlag.flagsActivatedBy(Range.downTo(Version.PROTOCOL_5_ACTIVATION_DATE, BoundType.OPEN)).stream(); } private IntStream lockTimeDelaysPlusErrorsToTry() { @@ -206,10 +219,9 @@ private IntStream lockTimeDelaysPlusErrorsToTry() { private Transaction unsignedRedirectTxTemplate(TransactionOutput depositTxOutput, Transaction peersWarningTx, + Set receiverFlags, int lockTimeDelayPlusError) { - long warningTxFee = depositTxOutput.getValue().value - - peersWarningTx.getOutput(0).getValue().value - - peersWarningTx.getOutput(1).getValue().value; + long warningTxFee = depositTxOutput.getValue().value - peersWarningTx.getOutputSum().value; long depositTxFee = StagedPayoutTxParameters.recoverDepositTxFeeRate(warningTxFee) * 278; long inputAmount = peersWarningTx.getOutput(0).getValue().value; long inputAmountMinusFeeBumpAmount = inputAmount - StagedPayoutTxParameters.REDIRECT_TX_FEE_BUMP_OUTPUT_VALUE; @@ -221,7 +233,7 @@ private Transaction unsignedRedirectTxTemplate(TransactionOutput depositTxOutput inputAmountMinusFeeBumpAmount, depositTxFee, StagedPayoutTxParameters.REDIRECT_TX_MIN_WEIGHT, - DelayedPayoutTxReceiverService.ReceiverFlag.flagsActivatedBy(new Date())); // FIXME: This will break if we add any more flags. + receiverFlags); String feeBumpAddress = SegwitAddress.fromHash(params, new byte[20]).toString(); var feeBumpOutputAmountAndAddress = new Tuple2<>(StagedPayoutTxParameters.REDIRECT_TX_FEE_BUMP_OUTPUT_VALUE, feeBumpAddress); @@ -260,24 +272,34 @@ static Stream recoveredSignatureCandidates(Transaction dep DeterministicKey myDepositInputKeyPair, DeterministicKey myMultiSigKeyPair, @Nullable KeyParameter aesKey) - throws SignatureDecodeException { + throws RedirectTxRecoveryException { // TODO: We can do a bit more validation of the witness stacks against the supplied args here... Script scriptCode = ScriptBuilder.createP2PKHOutputScript(myDepositInputKeyPair); Sha256Hash depositSigHash = depositTx.hashForWitnessSignature(myDepositInputIndex, scriptCode, myDepositInputValue, Transaction.SigHash.ALL, false); - TransactionSignature depositSignature = TransactionSignature.decodeFromBitcoin( - depositTx.getInput(myDepositInputIndex).getWitness().getPush(0), true, true); - checkArgument(myDepositInputKeyPair.verify(depositSigHash, depositSignature)); + TransactionSignature depositSignature; + try { + depositSignature = TransactionSignature.decodeFromBitcoin( + depositTx.getInput(myDepositInputIndex).getWitness().getPush(0), true, true); + checkArgument(myDepositInputKeyPair.verify(depositSigHash, depositSignature)); + } catch (SignatureDecodeException | IllegalArgumentException e) { + throw new RedirectTxRecoveryException("Invalid depositTx: could not extract signature for our input", e); + } boolean amBuyer = myDepositInputIndex == 0; Script redeemScript = new Script(peersWarningTx.getInput(0).getWitness().getPush(3)); Coin warningTxInputValue = depositTx.getOutput(0).getValue(); Sha256Hash warningSigHash = peersWarningTx.hashForWitnessSignature(0, redeemScript, warningTxInputValue, Transaction.SigHash.ALL, false); - TransactionSignature myWarningSignature = TransactionSignature.decodeFromBitcoin( - peersWarningTx.getInput(0).getWitness().getPush(amBuyer ? 2 : 1), true, true); - checkArgument(myMultiSigKeyPair.verify(warningSigHash, myWarningSignature)); + TransactionSignature myWarningSignature; + try { + myWarningSignature = TransactionSignature.decodeFromBitcoin( + peersWarningTx.getInput(0).getWitness().getPush(amBuyer ? 2 : 1), true, true); + checkArgument(myMultiSigKeyPair.verify(warningSigHash, myWarningSignature)); + } catch (SignatureDecodeException | IllegalArgumentException e) { + throw new RedirectTxRecoveryException("Invalid peersWarningTx: could not extract our signature", e); + } return recoveredSignatureCandidates( LowRSigningKey.from(myMultiSigKeyPair), diff --git a/core/src/main/java/bisq/core/dao/burningman/DelayedPayoutTxReceiverService.java b/core/src/main/java/bisq/core/dao/burningman/DelayedPayoutTxReceiverService.java index 7ada5e71b8c..473806c916a 100644 --- a/core/src/main/java/bisq/core/dao/burningman/DelayedPayoutTxReceiverService.java +++ b/core/src/main/java/bisq/core/dao/burningman/DelayedPayoutTxReceiverService.java @@ -36,12 +36,16 @@ import javax.inject.Singleton; import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.BoundType; +import com.google.common.collect.Range; +import java.util.Arrays; import java.util.Collection; import java.util.Comparator; import java.util.Date; import java.util.EnumSet; import java.util.GregorianCalendar; +import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -141,6 +145,27 @@ public static Set flagsActivatedBy(Date date) { flags.removeIf(flag -> !date.after(flag.activationDate)); return flags; } + + public static Set> flagsActivatedBy(Range dateRange) { + Set> flagsSet = new LinkedHashSet<>(); + Date[] activationDates = Arrays.stream(values()) + .map(f -> f.activationDate) + .toArray(Date[]::new); + Arrays.sort(activationDates, Comparator.naturalOrder()); + Date lastDate = null; + for (Date date : activationDates) { + Range segment = lastDate != null ? Range.openClosed(lastDate, date) : Range.upTo(date, BoundType.CLOSED); + if (dateRange.isConnected(segment) && !dateRange.intersection(segment).isEmpty()) { + flagsSet.add(flagsActivatedBy(date)); + } + lastDate = date; + } + Range lastSegment = lastDate != null ? Range.downTo(lastDate, BoundType.OPEN) : dateRange; + if (dateRange.isConnected(lastSegment) && !dateRange.intersection(lastSegment).isEmpty()) { + flagsSet.add(EnumSet.allOf(ReceiverFlag.class)); + } + return flagsSet; + } } // We use a snapshot blockHeight to avoid failed trades in case maker and taker have different block heights. diff --git a/core/src/main/java/bisq/core/util/validation/HexStringValidator.java b/core/src/main/java/bisq/core/util/validation/HexStringValidator.java index 32067b76e9e..0bb63286082 100644 --- a/core/src/main/java/bisq/core/util/validation/HexStringValidator.java +++ b/core/src/main/java/bisq/core/util/validation/HexStringValidator.java @@ -41,8 +41,10 @@ public ValidationResult validate(String input) { if (!validationResult.isValid) return validationResult; + if (minLength == maxLength && input.length() != minLength) + return new ValidationResult(false, Res.get("validation.fixedLength", this.minLength)); if (input.length() > maxLength || input.length() < minLength) - new ValidationResult(false, Res.get("validation.length", minLength, maxLength)); + return new ValidationResult(false, Res.get("validation.length", minLength, maxLength)); try { Utilities.decodeFromHex(input); diff --git a/core/src/main/resources/i18n/displayStrings.properties b/core/src/main/resources/i18n/displayStrings.properties index 36d6cd1d5be..aa5b630c9b3 100644 --- a/core/src/main/resources/i18n/displayStrings.properties +++ b/core/src/main/resources/i18n/displayStrings.properties @@ -1682,6 +1682,8 @@ setting.about.shortcuts.showTorLogs=Toggle log level for Tor messages between DE setting.about.shortcuts.manualPayoutTxWindow=Open window for manual payout from 2of2 Multisig deposit tx +setting.about.shortcuts.redirectTxRecoveryWindow=Open window for recovery of the redirection tx for a missing trade + setting.about.shortcuts.reRepublishAllGovernanceData=Republish DAO governance data (proposals, votes) setting.about.shortcuts.removeStuckTrade=Open popup to move failed trade to open trades tab again diff --git a/core/src/test/java/bisq/core/btc/wallet/RedirectionTransactionRecoveryServiceTest.java b/core/src/test/java/bisq/core/btc/wallet/RedirectionTransactionRecoveryServiceTest.java index 75c7741eea3..3675815134a 100644 --- a/core/src/test/java/bisq/core/btc/wallet/RedirectionTransactionRecoveryServiceTest.java +++ b/core/src/test/java/bisq/core/btc/wallet/RedirectionTransactionRecoveryServiceTest.java @@ -235,7 +235,7 @@ public void testUnsignedRedirectTxCandidates_amSeller() { } @Test - public void testRecoverRedirectTx_amBuyer() throws Exception { + public void testRecoverRedirectTx_amBuyer() { setUpWalletServiceStubs(BUYERS_DEPOSIT_INPUT_KEY_PAIR, BUYERS_MULTISIG_KEY_PAIR); var redirectTx = redirectionTransactionRecoveryService.recoverRedirectTx(DEPOSIT_TX.getTxId(), @@ -245,7 +245,7 @@ public void testRecoverRedirectTx_amBuyer() throws Exception { } @Test - public void testRecoverRedirectTx_amSeller() throws Exception { + public void testRecoverRedirectTx_amSeller() { setUpWalletServiceStubs(SELLERS_DEPOSIT_INPUT_KEY_PAIR, SELLERS_MULTISIG_KEY_PAIR); var redirectTx = redirectionTransactionRecoveryService.recoverRedirectTx(DEPOSIT_TX.getTxId(), diff --git a/desktop/src/main/java/bisq/desktop/app/BisqApp.java b/desktop/src/main/java/bisq/desktop/app/BisqApp.java index fe2d71b1433..7eff7384d14 100644 --- a/desktop/src/main/java/bisq/desktop/app/BisqApp.java +++ b/desktop/src/main/java/bisq/desktop/app/BisqApp.java @@ -26,6 +26,7 @@ import bisq.desktop.main.overlays.windows.BsqEmptyWalletWindow; import bisq.desktop.main.overlays.windows.BtcEmptyWalletWindow; import bisq.desktop.main.overlays.windows.FilterWindow; +import bisq.desktop.main.overlays.windows.RedirectTxRecoveryWindow; import bisq.desktop.main.overlays.windows.supporttool.SupportToolWindow; import bisq.desktop.main.overlays.windows.SendAlertMessageWindow; import bisq.desktop.main.overlays.windows.ShowWalletDataWindow; @@ -359,6 +360,11 @@ private void addSceneKeyEventHandler(Scene scene, Injector injector) { injector.getInstance(SupportToolWindow.class).show(); else new Popup().warning(Res.get("popup.warning.walletNotInitialized")).show(); + } else if (Utilities.isAltOrCtrlPressed(KeyCode.K, keyEvent)) { + if (injector.getInstance(BtcWalletService.class).isWalletReady()) + injector.getInstance(RedirectTxRecoveryWindow.class).show(); + else + new Popup().warning(Res.get("popup.warning.walletNotInitialized")).show(); } else if (DevEnv.isDevMode()) { if (Utilities.isAltOrCtrlPressed(KeyCode.Z, keyEvent)) showDebugWindow(scene, injector); diff --git a/desktop/src/main/java/bisq/desktop/main/overlays/windows/RedirectTxRecoveryWindow.java b/desktop/src/main/java/bisq/desktop/main/overlays/windows/RedirectTxRecoveryWindow.java new file mode 100644 index 00000000000..3e9cb4e40d2 --- /dev/null +++ b/desktop/src/main/java/bisq/desktop/main/overlays/windows/RedirectTxRecoveryWindow.java @@ -0,0 +1,288 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.desktop.main.overlays.windows; + +import bisq.desktop.components.AutoTooltipButton; +import bisq.desktop.components.AutoTooltipLabel; +import bisq.desktop.components.BusyAnimation; +import bisq.desktop.components.InputTextField; +import bisq.desktop.main.overlays.Overlay; +import bisq.desktop.main.overlays.popups.Popup; +import bisq.desktop.util.FormBuilder; +import bisq.desktop.util.GUIUtil; + +import bisq.core.btc.exceptions.RedirectTxRecoveryException; +import bisq.core.btc.exceptions.TxBroadcastException; +import bisq.core.btc.wallet.BtcWalletService; +import bisq.core.btc.wallet.RedirectionTransactionRecoveryService; +import bisq.core.btc.wallet.TxBroadcaster; +import bisq.core.locale.Res; +import bisq.core.util.validation.HexStringValidator; + +import bisq.common.UserThread; +import bisq.common.config.Config; +import bisq.common.util.Utilities; + +import org.bitcoinj.core.ProtocolException; +import org.bitcoinj.core.Sha256Hash; +import org.bitcoinj.core.Transaction; + +import javax.inject.Inject; + +import de.jensd.fx.fontawesome.AwesomeDude; +import de.jensd.fx.fontawesome.AwesomeIcon; + +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.TextArea; +import javafx.scene.control.Tooltip; +import javafx.scene.layout.ColumnConstraints; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; + +import javafx.geometry.HPos; +import javafx.geometry.Insets; +import javafx.geometry.Pos; + +import javafx.beans.value.ChangeListener; + +import org.bouncycastle.crypto.params.KeyParameter; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +import lombok.extern.slf4j.Slf4j; + +import javax.annotation.Nullable; + +@Slf4j +public class RedirectTxRecoveryWindow extends Overlay { + private static final int HEX_HASH_LENGTH = 32 * 2; + private static final int HEX_WARNING_TX_MIN_LENGTH = 300 * 2; + private static final int HEX_WARNING_TX_MAX_LENGTH = 350 * 2; + + private final BtcWalletService btcWalletService; + private final RedirectionTransactionRecoveryService redirectionTransactionRecoveryService; + private final HexStringValidator depositTxIdValidator = new HexStringValidator(); + private final HexStringValidator peersWarningTxHexValidator = new HexStringValidator(); + private BusyAnimation busyAnimation; + private InputTextField depositTxId; + private TextArea peersWarningTxHex; + private TextArea redirectTxHex; + private Button recoverButton; + private Button publishButton; + @Nullable + private CompletableFuture recoveryFuture; + private final ChangeListener changeListener; + + @Inject + public RedirectTxRecoveryWindow(BtcWalletService btcWalletService, + RedirectionTransactionRecoveryService redirectionTransactionRecoveryService) { + this.btcWalletService = btcWalletService; + this.redirectionTransactionRecoveryService = redirectionTransactionRecoveryService; + type = Type.Attention; + // We don't translate here as it is for support only purpose... + headLine("Redirection Transaction Recovery Tool"); + width(1068); + changeListener = (observable, oldValue, newValue) -> onChange(); + depositTxIdValidator.setMinLength(HEX_HASH_LENGTH); + depositTxIdValidator.setMaxLength(HEX_HASH_LENGTH); + peersWarningTxHexValidator.setMinLength(HEX_WARNING_TX_MIN_LENGTH); + peersWarningTxHexValidator.setMaxLength(HEX_WARNING_TX_MAX_LENGTH); + } + + @Override + public void show() { + if (gridPane != null) { + rowIndex = -1; + gridPane.getChildren().clear(); + } + createGridPane(); + addHeadLine(); + addContent(); + addButtons(); + applyStyles(); + display(); + } + + private void addContent() { + depositTxId = FormBuilder.addInputTextField(gridPane, ++rowIndex, "depositTxId"); + depositTxId.setValidator(depositTxIdValidator); + depositTxId.setPrefWidth(800); + depositTxId.textProperty().addListener(changeListener); + Tooltip tooltip = new Tooltip(Res.get("txIdTextField.blockExplorerIcon.tooltip")); + Label blockExplorerIcon = new Label(); + blockExplorerIcon.getStyleClass().addAll("icon", "highlight"); + blockExplorerIcon.setTooltip(tooltip); + AwesomeDude.setIcon(blockExplorerIcon, AwesomeIcon.EXTERNAL_LINK); + blockExplorerIcon.setMinWidth(20); + blockExplorerIcon.setOnMouseClicked(mouseEvent -> { + if (depositTxId.validationResultProperty().get().isValid) { + GUIUtil.openTxInBlockExplorer(depositTxId.getText()); + } + }); + HBox hBoxTx = new HBox(12, depositTxId, blockExplorerIcon); + hBoxTx.setAlignment(Pos.BASELINE_LEFT); + hBoxTx.setPrefWidth(800); + gridPane.add(new Label(""), 0, ++rowIndex); // spacer + gridPane.add(hBoxTx, 0, ++rowIndex); + + peersWarningTxHex = FormBuilder.addTextArea(gridPane, ++rowIndex, "peersWarningTxHex"); + peersWarningTxHex.setEditable(true); + peersWarningTxHex.setPrefSize(800, 150); + peersWarningTxHex.textProperty().addListener(changeListener); + + redirectTxHex = FormBuilder.addTextArea(gridPane, ++rowIndex, "redirectTxHex"); + redirectTxHex.setEditable(false); + redirectTxHex.setPrefSize(800, 150); + } + + @Override + protected void addButtons() { + busyAnimation = new BusyAnimation(false); + Label recoverStatusLabel = new AutoTooltipLabel(); + + recoverButton = new AutoTooltipButton("Recover"); + recoverButton.setDefaultButton(true); + recoverButton.getStyleClass().add("action-button"); + recoverButton.setDisable(true); + recoverButton.setOnAction(e -> { + busyAnimation.play(); + recoverButton.setDisable(true); + publishButton.setDisable(true); + recoverButton.setDefaultButton(true); + if (!recoverButton.getStyleClass().contains("action-button")) { + recoverButton.getStyleClass().add("action-button"); + } + publishButton.setDefaultButton(false); + publishButton.getStyleClass().remove("action-button"); + redirectTxHex.setText(""); + recoverStatusLabel.getStyleClass().remove("error-text"); + recoverStatusLabel.setText("Recover redirect tx"); + (recoveryFuture = recoverRedirectTxAsync()).whenComplete((tx, throwable) -> UserThread.execute(() -> { + if (throwable != null) { + log.error("Could not recover redirect tx:", throwable); + if (throwable instanceof CompletionException && throwable.getCause() instanceof RedirectTxRecoveryException) { + recoverStatusLabel.getStyleClass().add("error-text"); + recoverStatusLabel.setText(throwable.getCause().getMessage()); + } + } else { + redirectTxHex.setText(Utilities.encodeToHex(tx.bitcoinSerialize())); + recoverButton.setDefaultButton(false); + recoverButton.getStyleClass().remove("action-button"); + publishButton.setDefaultButton(true); + if (!publishButton.getStyleClass().contains("action-button")) { + publishButton.getStyleClass().add("action-button"); + } + publishButton.setDisable(false); + recoverStatusLabel.setText(""); + } + recoveryFuture = null; + busyAnimation.stop(); + onChange(); + })); + }); + publishButton = new AutoTooltipButton("Publish"); + publishButton.setDisable(true); + publishButton.setOnAction(e -> { + busyAnimation.play(); + recoverButton.setDisable(true); + publishButton.setDisable(true); + recoverStatusLabel.setText("Publish redirect tx"); + byte[] txBytes = Utilities.decodeFromHex(redirectTxHex.getText()); + Transaction redirectTx = new Transaction(Config.baseCurrencyNetworkParameters(), txBytes); + btcWalletService.broadcastTx(redirectTx, new TxBroadcaster.Callback() { + @Override + public void onSuccess(Transaction transaction) { + // TODO: Add details window with the redirect txId and confirmation status. + log.info("Published redirect tx with txId: {}", transaction.getTxId()); + doClose(); + } + + @Override + public void onFailure(TxBroadcastException exception) { + busyAnimation.stop(); + publishButton.setDisable(false); + recoverStatusLabel.setText(""); + onChange(); + new Popup().warning(exception.getMessage()).show(); + } + }); + }); + if (!hideCloseButton) { + closeButton = new AutoTooltipButton(Res.get("shared.close")); + closeButton.setOnAction(event -> doClose()); + } + + HBox hBox = new HBox(); + hBox.setMinWidth(560); + hBox.setPadding(new Insets(0, 0, 0, 0)); + hBox.setSpacing(10); + GridPane.setRowIndex(hBox, ++rowIndex); + hBox.setAlignment(Pos.CENTER_LEFT); + hBox.getChildren().add(recoverButton); + hBox.getChildren().add(publishButton); + if (!hideCloseButton) { + hBox.getChildren().add(closeButton); + } + hBox.getChildren().addAll(busyAnimation, recoverStatusLabel); + gridPane.getChildren().add(hBox); + + ColumnConstraints columnConstraints = new ColumnConstraints(); + columnConstraints.setHalignment(HPos.LEFT); + columnConstraints.setHgrow(Priority.ALWAYS); + gridPane.getColumnConstraints().addAll(columnConstraints); + } + + @Override + protected void cleanup() { + if (recoveryFuture != null) { + recoveryFuture.cancel(true); + recoveryFuture = null; + } + super.cleanup(); + } + + private void onChange() { + recoverButton.setDisable(busyAnimation.isRunning() || !depositTxId.validationResultProperty().get().isValid || + !peersWarningTxHexValidator.validate(peersWarningTxHex.getText()).isValid); + } + + private CompletableFuture recoverRedirectTxAsync() { + return recoverRedirectTxAsync(depositTxId.getText(), peersWarningTxHex.getText()); + } + + private CompletableFuture recoverRedirectTxAsync(String depositTxId, String peersWarningTxHex) { + KeyParameter aesKey = btcWalletService.getAesKey(); + return CompletableFuture.supplyAsync(() -> { + try { + Sha256Hash depositTxHash = Sha256Hash.wrap(depositTxId); + byte[] txBytes = Utilities.decodeFromHex(peersWarningTxHex); + Transaction peersWarningTx = new Transaction(Config.baseCurrencyNetworkParameters(), txBytes); + return redirectionTransactionRecoveryService.recoverRedirectTx(depositTxHash, peersWarningTx, aesKey); + } catch (RedirectTxRecoveryException e) { + throw e; + } catch (ProtocolException e) { + throw new RedirectTxRecoveryException("Could not parse peersWarningTxHex", e); + } catch (RuntimeException e) { + throw new RedirectTxRecoveryException("Unexpected error", e); + } + }); + } +} diff --git a/desktop/src/main/java/bisq/desktop/main/settings/about/AboutView.java b/desktop/src/main/java/bisq/desktop/main/settings/about/AboutView.java index 113df7769cb..2df88b8b9f4 100644 --- a/desktop/src/main/java/bisq/desktop/main/settings/about/AboutView.java +++ b/desktop/src/main/java/bisq/desktop/main/settings/about/AboutView.java @@ -133,6 +133,9 @@ public void initialize() { addCompactTopLabelTextField(root, ++gridRow, Res.get("setting.about.shortcuts.manualPayoutTxWindow"), Res.get("setting.about.shortcuts.ctrlOrAltOrCmd", "g")); + addCompactTopLabelTextField(root, ++gridRow, Res.get("setting.about.shortcuts.redirectTxRecoveryWindow"), + Res.get("setting.about.shortcuts.ctrlOrAltOrCmd", "k")); + addCompactTopLabelTextField(root, ++gridRow, Res.get("setting.about.shortcuts.reRepublishAllGovernanceData"), Res.get("setting.about.shortcuts.ctrlOrAltOrCmd", "h"));