diff --git a/core/src/main/java/bisq/core/offer/OfferUtil.java b/core/src/main/java/bisq/core/offer/OfferUtil.java index 53a9338161..a3ba69534d 100644 --- a/core/src/main/java/bisq/core/offer/OfferUtil.java +++ b/core/src/main/java/bisq/core/offer/OfferUtil.java @@ -19,6 +19,7 @@ package bisq.core.offer; import bisq.core.account.witness.AccountAgeWitnessService; import bisq.core.btc.wallet.BsqWalletService; +import bisq.core.btc.wallet.BtcWalletService; import bisq.core.filter.FilterManager; import bisq.core.locale.CurrencyUtil; import bisq.core.locale.Res; @@ -44,6 +45,9 @@ import bisq.common.util.MathUtils; import bisq.common.util.Tuple2; import org.bitcoinj.core.Coin; +import org.bitcoinj.core.Transaction; +import org.bitcoinj.core.TransactionInput; +import org.bitcoinj.core.TransactionOutput; import org.bitcoinj.utils.Fiat; import javax.inject.Inject; @@ -383,4 +387,53 @@ public class OfferUtil { return Optional.empty(); } } + + public static Optional getInvalidMakerFeeTxErrorMessage(Offer offer, BtcWalletService btcWalletService) { + Transaction makerFeeTx = btcWalletService.getTransaction(offer.getOfferFeePaymentTxId()); + if (makerFeeTx == null) { + return Optional.empty(); + } + + String errorMsg = null; + String header = "The offer with offer ID '" + offer.getShortId() + + "' has an invalid maker fee transaction.\n\n"; + String spendingTransaction = null; + String extraString = "\nYou have to remove that offer to avoid failed trades.\n" + + "If this happened because of a bug please contact the Bisq developers " + + "and you can request reimbursement for the lost maker fee."; + if (makerFeeTx.getOutputs().size() > 1) { + // Our output to fund the deposit tx is at index 1 + TransactionOutput output = makerFeeTx.getOutput(1); + TransactionInput spentByTransactionInput = output.getSpentBy(); + if (spentByTransactionInput != null) { + spendingTransaction = spentByTransactionInput.getConnectedTransaction() != null ? + spentByTransactionInput.getConnectedTransaction().toString() : + "null"; + // We this is an exceptional case we do not translate that error msg. + errorMsg = "The output of the maker fee tx is already spent.\n" + + extraString + + "\n\nTransaction input which spent the reserved funds for that offer: '" + + spentByTransactionInput.getConnectedTransaction().getTxId().toString() + ":" + + (spentByTransactionInput.getConnectedOutput() != null ? + spentByTransactionInput.getConnectedOutput().getIndex() + "'" : + "null'"); + log.error("spentByTransactionInput {}", spentByTransactionInput); + } + } else { + errorMsg = "The maker fee tx is invalid as it does not has at least 2 outputs." + extraString + + "\nMakerFeeTx=" + makerFeeTx.toString(); + } + + if (errorMsg == null) { + return Optional.empty(); + } + + errorMsg = header + errorMsg; + log.error(errorMsg); + if (spendingTransaction != null) { + log.error("Spending transaction: {}", spendingTransaction); + } + + return Optional.of(errorMsg); + } } diff --git a/core/src/main/java/bisq/core/offer/OpenOfferManager.java b/core/src/main/java/bisq/core/offer/OpenOfferManager.java index 8c8ec4855d..496a0ac2f2 100644 --- a/core/src/main/java/bisq/core/offer/OpenOfferManager.java +++ b/core/src/main/java/bisq/core/offer/OpenOfferManager.java @@ -63,11 +63,13 @@ import bisq.common.handlers.ResultHandler; import bisq.common.persistence.PersistenceManager; import bisq.common.proto.network.NetworkEnvelope; import bisq.common.proto.persistable.PersistedDataHost; +import bisq.common.util.Tuple2; import org.bitcoinj.core.Coin; import javax.inject.Inject; +import javafx.collections.FXCollections; import javafx.collections.ObservableList; import java.util.ArrayList; @@ -82,6 +84,8 @@ import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import lombok.Getter; + import org.jetbrains.annotations.NotNull; import javax.annotation.Nullable; @@ -118,6 +122,8 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe private final TradableList openOffers = new TradableList<>(); private boolean stopped; private Timer periodicRepublishOffersTimer, periodicRefreshOffersTimer, retryRepublishOffersTimer; + @Getter + private final ObservableList> invalidOffers = FXCollections.observableArrayList(); /////////////////////////////////////////////////////////////////////////////////////////// @@ -190,6 +196,10 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe } cleanUpAddressEntries(); + + openOffers.stream() + .forEach(openOffer -> OfferUtil.getInvalidMakerFeeTxErrorMessage(openOffer.getOffer(), btcWalletService) + .ifPresent(errorMsg -> invalidOffers.add(new Tuple2<>(openOffer, errorMsg)))); } private void cleanUpAddressEntries() { diff --git a/desktop/src/main/java/bisq/desktop/main/MainViewModel.java b/desktop/src/main/java/bisq/desktop/main/MainViewModel.java index 75c724ec1d..bf81fff2dc 100644 --- a/desktop/src/main/java/bisq/desktop/main/MainViewModel.java +++ b/desktop/src/main/java/bisq/desktop/main/MainViewModel.java @@ -48,6 +48,8 @@ import bisq.core.btc.wallet.BtcWalletService; import bisq.core.locale.CryptoCurrency; import bisq.core.locale.CurrencyUtil; import bisq.core.locale.Res; +import bisq.core.offer.OpenOffer; +import bisq.core.offer.OpenOfferManager; import bisq.core.payment.AliPayAccount; import bisq.core.payment.CryptoCurrencyAccount; import bisq.core.payment.RevolutAccount; @@ -70,6 +72,7 @@ import bisq.common.app.DevEnv; import bisq.common.app.Version; import bisq.common.config.Config; import bisq.common.file.CorruptedStorageFileHandler; +import bisq.common.util.Tuple2; import com.google.inject.Inject; @@ -86,6 +89,7 @@ import javafx.beans.property.SimpleDoubleProperty; import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; +import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; import java.util.ArrayList; @@ -115,6 +119,7 @@ public class MainViewModel implements ViewModel, BisqSetup.BisqSetupListener { private final SettingsPresentation settingsPresentation; private final P2PService p2PService; private final TradeManager tradeManager; + private final OpenOfferManager openOfferManager; @Getter private final Preferences preferences; private final PrivateNotificationManager privateNotificationManager; @@ -160,6 +165,7 @@ public class MainViewModel implements ViewModel, BisqSetup.BisqSetupListener { SettingsPresentation settingsPresentation, P2PService p2PService, TradeManager tradeManager, + OpenOfferManager openOfferManager, Preferences preferences, PrivateNotificationManager privateNotificationManager, WalletPasswordWindow walletPasswordWindow, @@ -184,6 +190,7 @@ public class MainViewModel implements ViewModel, BisqSetup.BisqSetupListener { this.settingsPresentation = settingsPresentation; this.p2PService = p2PService; this.tradeManager = tradeManager; + this.openOfferManager = openOfferManager; this.preferences = preferences; this.privateNotificationManager = privateNotificationManager; this.walletPasswordWindow = walletPasswordWindow; @@ -432,6 +439,17 @@ public class MainViewModel implements ViewModel, BisqSetup.BisqSetupListener { this.footerVersionInfo.setValue("v" + Version.VERSION); } }); + + if (p2PService.isBootstrapped()) { + setupInvalidOpenOffersHandler(); + } else { + p2PService.addP2PServiceListener(new BootstrapListener() { + @Override + public void onUpdatedDataReceived() { + setupInvalidOpenOffersHandler(); + } + }); + } } private void showRevolutAccountUpdateWindow(List revolutAccountList) { @@ -573,6 +591,34 @@ public class MainViewModel implements ViewModel, BisqSetup.BisqSetupListener { } } + private void setupInvalidOpenOffersHandler() { + openOfferManager.getInvalidOffers().addListener((ListChangeListener>) c -> { + c.next(); + if (c.wasAdded()) { + handleInvalidOpenOffers(c.getAddedSubList()); + } + }); + handleInvalidOpenOffers(openOfferManager.getInvalidOffers()); + } + + private void handleInvalidOpenOffers(List> list) { + list.forEach(tuple2 -> { + String errorMsg = tuple2.second; + OpenOffer openOffer = tuple2.first; + new Popup().warning(errorMsg) + .width(1000) + .actionButtonText(Res.get("shared.removeOffer")) + .onAction(() -> { + openOfferManager.removeOpenOffer(openOffer, () -> { + log.info("Invalid open offer with ID {} was successfully removed.", openOffer.getId()); + }, log::error); + + }) + .hideCloseButton() + .show(); + }); + } + /////////////////////////////////////////////////////////////////////////////////////////// // MainView delegate getters diff --git a/desktop/src/main/java/bisq/desktop/main/overlays/windows/OfferDetailsWindow.java b/desktop/src/main/java/bisq/desktop/main/overlays/windows/OfferDetailsWindow.java index 872a149cf1..360e1e8f37 100644 --- a/desktop/src/main/java/bisq/desktop/main/overlays/windows/OfferDetailsWindow.java +++ b/desktop/src/main/java/bisq/desktop/main/overlays/windows/OfferDetailsWindow.java @@ -20,17 +20,21 @@ package bisq.desktop.main.overlays.windows; import bisq.desktop.Navigation; import bisq.desktop.components.AutoTooltipButton; import bisq.desktop.components.BusyAnimation; +import bisq.desktop.components.TitledGroupBg; +import bisq.desktop.components.TxIdTextField; import bisq.desktop.main.overlays.Overlay; import bisq.desktop.util.DisplayUtils; import bisq.desktop.util.GUIUtil; import bisq.desktop.util.Layout; +import bisq.core.btc.wallet.BtcWalletService; import bisq.core.locale.BankUtil; import bisq.core.locale.CountryUtil; import bisq.core.locale.Res; import bisq.core.monetary.Price; import bisq.core.offer.Offer; import bisq.core.offer.OfferPayload; +import bisq.core.offer.OfferUtil; import bisq.core.payment.PaymentAccount; import bisq.core.payment.payload.PaymentMethod; import bisq.core.user.User; @@ -74,6 +78,7 @@ public class OfferDetailsWindow extends Overlay { private final User user; private final KeyRing keyRing; private final Navigation navigation; + private final BtcWalletService btcWalletService; private Offer offer; private Coin tradeAmount; private Price tradePrice; @@ -90,11 +95,13 @@ public class OfferDetailsWindow extends Overlay { public OfferDetailsWindow(@Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter formatter, User user, KeyRing keyRing, - Navigation navigation) { + Navigation navigation, + BtcWalletService btcWalletService) { this.formatter = formatter; this.user = user; this.keyRing = keyRing; this.navigation = navigation; + this.btcWalletService = btcWalletService; type = Type.Confirmation; } @@ -313,13 +320,13 @@ public class OfferDetailsWindow extends Overlay { textArea.setEditable(false); } - rows = 3; + rows = 4; if (countryCode != null) rows++; if (!isF2F) rows++; - addTitledGroupBg(gridPane, ++rowIndex, rows, Res.get("shared.details"), Layout.GROUP_DISTANCE); + TitledGroupBg titledGroupBg = addTitledGroupBg(gridPane, ++rowIndex, rows, Res.get("shared.details"), Layout.GROUP_DISTANCE); addConfirmationLabelTextFieldWithCopyIcon(gridPane, rowIndex, Res.get("shared.offerId"), offer.getId(), Layout.TWICE_FIRST_ROW_AND_GROUP_DISTANCE); addConfirmationLabelTextFieldWithCopyIcon(gridPane, ++rowIndex, Res.get("offerDetailsWindow.makersOnion"), @@ -335,6 +342,18 @@ public class OfferDetailsWindow extends Overlay { formatter.formatCoinWithCode(offer.getSellerSecurityDeposit()); addConfirmationLabelLabel(gridPane, ++rowIndex, Res.get("shared.securityDeposit"), value); + TxIdTextField makerFeeTxIdTextField = addLabelTxIdTextField(gridPane, ++rowIndex, + Res.get("shared.makerFeeTxId"), offer.getOfferFeePaymentTxId()).second; + + int finalRows = rows; + OfferUtil.getInvalidMakerFeeTxErrorMessage(offer, btcWalletService) + .ifPresent(errorMsg -> { + makerFeeTxIdTextField.getTextField().setId("address-text-field-error"); + GridPane.setRowSpan(titledGroupBg, finalRows + 1); + addConfirmationLabelLabel(gridPane, ++rowIndex, Res.get("shared.errorMessage"), + errorMsg.replace("\n\n", "\n")); + }); + if (countryCode != null && !isF2F) addConfirmationLabelLabel(gridPane, ++rowIndex, Res.get("offerDetailsWindow.countryBank"), CountryUtil.getNameAndCode(countryCode));