diff --git a/core/src/main/java/bisq/core/app/BisqHeadlessApp.java b/core/src/main/java/bisq/core/app/BisqHeadlessApp.java index c77acd6d7c5..f721cb2e303 100644 --- a/core/src/main/java/bisq/core/app/BisqHeadlessApp.java +++ b/core/src/main/java/bisq/core/app/BisqHeadlessApp.java @@ -93,7 +93,8 @@ protected void setupHandlers() { bisqSetup.setDisplaySecurityRecommendationHandler(key -> log.info("onDisplaySecurityRecommendationHandler")); bisqSetup.setDisplayLocalhostHandler(key -> log.info("onDisplayLocalhostHandler")); bisqSetup.setWrongOSArchitectureHandler(msg -> log.error("onWrongOSArchitectureHandler. msg={}", msg)); - bisqSetup.setVoteResultExceptionHandler(voteResultException -> log.warn("voteResultException={}", voteResultException)); + bisqSetup.setVoteResultExceptionHandler(voteResultException -> log.warn("voteResultException={}", voteResultException.toString())); + bisqSetup.setRejectedTxErrorMessageHandler(errorMessage -> log.warn("setRejectedTxErrorMessageHandler. errorMessage={}", errorMessage)); //TODO move to bisqSetup corruptedDatabaseFilesHandler.getCorruptedDatabaseFiles().ifPresent(files -> log.warn("getCorruptedDatabaseFiles. files={}", files)); diff --git a/core/src/main/java/bisq/core/app/BisqSetup.java b/core/src/main/java/bisq/core/app/BisqSetup.java index 6a52ace148c..4b72f65ede7 100644 --- a/core/src/main/java/bisq/core/app/BisqSetup.java +++ b/core/src/main/java/bisq/core/app/BisqSetup.java @@ -55,6 +55,7 @@ import bisq.core.support.dispute.refund.refundagent.RefundAgentManager; import bisq.core.support.traderchat.TraderChatManager; import bisq.core.trade.TradeManager; +import bisq.core.trade.TradeTxException; import bisq.core.trade.statistics.AssetTradeActivityCheck; import bisq.core.trade.statistics.TradeStatisticsManager; import bisq.core.user.Preferences; @@ -107,6 +108,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.function.BiConsumer; import java.util.function.Consumer; @@ -192,7 +194,8 @@ default void onRequestWalletPassword() { spvFileCorruptedHandler, lockedUpFundsHandler, daoErrorMessageHandler, daoWarnMessageHandler, filterWarningHandler, displaySecurityRecommendationHandler, displayLocalhostHandler, wrongOSArchitectureHandler, displaySignedByArbitratorHandler, - displaySignedByPeerHandler, displayPeerLimitLiftedHandler, displayPeerSignerHandler; + displaySignedByPeerHandler, displayPeerLimitLiftedHandler, displayPeerSignerHandler, + rejectedTxErrorMessageHandler; @Setter @Nullable private Consumer displayTorNetworkSettingsHandler; @@ -601,28 +604,56 @@ private void initWallet() { showFirstPopupIfResyncSPVRequestedHandler, walletPasswordHandler, () -> { - if (allBasicServicesInitialized) + if (allBasicServicesInitialized) { checkForLockedUpFunds(); + checkForInvalidMakerFeeTxs(); + } }, () -> walletInitialized.set(true)); } private void checkForLockedUpFunds() { - btcWalletService.getAddressEntriesForTrade().stream() - .filter(e -> tradeManager.getSetOfAllTradeIds().contains(e.getOfferId()) && - e.getContext() == AddressEntry.Context.MULTI_SIG) - .forEach(e -> { - Coin balance = e.getCoinLockedInMultiSig(); - if (balance.isPositive()) { - String message = Res.get("popup.warning.lockedUpFunds", - formatter.formatCoinWithCode(balance), e.getAddressString(), e.getOfferId()); - log.warn(message); - if (lockedUpFundsHandler != null) { - lockedUpFundsHandler.accept(message); + // We check if there are locked up funds in failed or closed trades + try { + Set setOfAllTradeIds = tradeManager.getSetOfFailedOrClosedTradeIdsFromLockedInFunds(); + btcWalletService.getAddressEntriesForTrade().stream() + .filter(e -> setOfAllTradeIds.contains(e.getOfferId()) && + e.getContext() == AddressEntry.Context.MULTI_SIG) + .forEach(e -> { + Coin balance = e.getCoinLockedInMultiSig(); + if (balance.isPositive()) { + String message = Res.get("popup.warning.lockedUpFunds", + formatter.formatCoinWithCode(balance), e.getAddressString(), e.getOfferId()); + log.warn(message); + if (lockedUpFundsHandler != null) { + lockedUpFundsHandler.accept(message); + } } - } - }); + }); + } catch (TradeTxException e) { + log.warn(e.getMessage()); + if (lockedUpFundsHandler != null) { + lockedUpFundsHandler.accept(e.getMessage()); + } + } + } + + private void checkForInvalidMakerFeeTxs() { + // We check if we have open offers with no confidence object at the maker fee tx. That can happen if the + // miner fee was too low and the transaction got removed from mempool and got out from our wallet after a + // resync. + openOfferManager.getObservableList().forEach(e -> { + String offerFeePaymentTxId = e.getOffer().getOfferFeePaymentTxId(); + if (btcWalletService.getConfidenceForTxId(offerFeePaymentTxId) == null) { + String message = Res.get("popup.warning.openOfferWithInvalidMakerFeeTx", + e.getOffer().getShortId(), offerFeePaymentTxId); + log.warn(message); + if (lockedUpFundsHandler != null) { + lockedUpFundsHandler.accept(message); + } + } + }); } private void checkForCorrectOSArchitecture() { @@ -651,13 +682,60 @@ private void initDomainServices() { tradeManager.onAllServicesInitialized(); - if (walletsSetup.downloadPercentageProperty().get() == 1) + if (walletsSetup.downloadPercentageProperty().get() == 1) { checkForLockedUpFunds(); + checkForInvalidMakerFeeTxs(); + } openOfferManager.onAllServicesInitialized(); balances.onAllServicesInitialized(); + walletAppSetup.getRejectedTxException().addListener((observable, oldValue, newValue) -> { + // We delay as we might get the rejected tx error before we have completed the create offer protocol + UserThread.runAfter(() -> { + if (rejectedTxErrorMessageHandler != null && newValue != null && newValue.getTxId() != null) { + String txId = newValue.getTxId(); + openOfferManager.getObservableList().stream() + .filter(openOffer -> txId.equals(openOffer.getOffer().getOfferFeePaymentTxId())) + .forEach(openOffer -> { + // We delay to avoid concurrent modification exceptions + UserThread.runAfter(() -> { + openOffer.getOffer().setErrorMessage(newValue.getMessage()); + rejectedTxErrorMessageHandler.accept(Res.get("popup.warning.openOffer.makerFeeTxRejected", openOffer.getId(), txId)); + openOfferManager.removeOpenOffer(openOffer, () -> { + log.warn("We removed an open offer because the maker fee was rejected by the Bitcoin " + + "network. OfferId={}, txId={}", openOffer.getShortId(), txId); + }, log::warn); + }, 1); + }); + + tradeManager.getTradableList().stream() + .filter(trade -> trade.getOffer() != null) + .forEach(trade -> { + String details = null; + if (txId.equals(trade.getDepositTxId())) { + details = Res.get("popup.warning.trade.txRejected.deposit"); + } + if (txId.equals(trade.getOffer().getOfferFeePaymentTxId()) || txId.equals(trade.getTakerFeeTxId())) { + details = Res.get("popup.warning.trade.txRejected.tradeFee"); + } + + if (details != null) { + // We delay to avoid concurrent modification exceptions + String finalDetails = details; + UserThread.runAfter(() -> { + trade.setErrorMessage(newValue.getMessage()); + rejectedTxErrorMessageHandler.accept(Res.get("popup.warning.trade.txRejected", + finalDetails, trade.getShortId(), txId)); + tradeManager.addTradeToFailedTrades(trade); + }, 1); + } + }); + } + }, 3); + }); + arbitratorManager.onAllServicesInitialized(); mediatorManager.onAllServicesInitialized(); refundAgentManager.onAllServicesInitialized(); diff --git a/core/src/main/java/bisq/core/app/WalletAppSetup.java b/core/src/main/java/bisq/core/app/WalletAppSetup.java index 5ba055fe574..4c2406c7536 100644 --- a/core/src/main/java/bisq/core/app/WalletAppSetup.java +++ b/core/src/main/java/bisq/core/app/WalletAppSetup.java @@ -17,6 +17,7 @@ package bisq.core.app; +import bisq.core.btc.exceptions.RejectedTxException; import bisq.core.btc.setup.WalletsSetup; import bisq.core.btc.wallet.WalletsManager; import bisq.core.locale.Res; @@ -71,6 +72,8 @@ public class WalletAppSetup { @Getter private final StringProperty btcInfo = new SimpleStringProperty(Res.get("mainView.footer.btcInfo.initializing")); @Getter + private final ObjectProperty rejectedTxException = new SimpleObjectProperty<>(); + @Getter private int numBtcPeers = 0; @Getter private final BooleanProperty useTorForBTC = new SimpleBooleanProperty(); @@ -132,7 +135,7 @@ void init(@Nullable Consumer chainFileLockedExceptionHandler, getNumBtcPeers(), Res.get("mainView.footer.btcInfo.connectionFailed"), getBtcNetworkAsString()); - log.error(exception.getMessage()); + log.error(exception.toString()); if (exception instanceof TimeoutException) { getWalletServiceErrorMsg().set(Res.get("mainView.walletServiceErrorMsg.timeout")); } else if (exception.getCause() instanceof BlockStoreException) { @@ -141,6 +144,9 @@ void init(@Nullable Consumer chainFileLockedExceptionHandler, } else if (spvFileCorruptedHandler != null) { spvFileCorruptedHandler.accept(Res.get("error.spvFileCorrupted", exception.getMessage())); } + } else if (exception instanceof RejectedTxException) { + rejectedTxException.set((RejectedTxException) exception); + getWalletServiceErrorMsg().set(Res.get("mainView.walletServiceErrorMsg.rejectedTxException", exception.getMessage())); } else { getWalletServiceErrorMsg().set(Res.get("mainView.walletServiceErrorMsg.connectionError", exception.toString())); } diff --git a/core/src/main/java/bisq/core/btc/Balances.java b/core/src/main/java/bisq/core/btc/Balances.java index cf85da0b98d..a38904bdb15 100644 --- a/core/src/main/java/bisq/core/btc/Balances.java +++ b/core/src/main/java/bisq/core/btc/Balances.java @@ -119,8 +119,8 @@ private void updateReservedBalance() { } private void updateLockedBalance() { - Stream lockedTrades = Stream.concat(closedTradableManager.getLockedTradesStream(), failedTradesManager.getLockedTradesStream()); - lockedTrades = Stream.concat(lockedTrades, tradeManager.getLockedTradesStream()); + Stream lockedTrades = Stream.concat(closedTradableManager.getTradesStreamWithFundsLockedIn(), failedTradesManager.getTradesStreamWithFundsLockedIn()); + lockedTrades = Stream.concat(lockedTrades, tradeManager.getTradesStreamWithFundsLockedIn()); long sum = lockedTrades.map(trade -> btcWalletService.getAddressEntry(trade.getId(), AddressEntry.Context.MULTI_SIG) .orElse(null)) .filter(Objects::nonNull) diff --git a/core/src/main/java/bisq/core/btc/exceptions/RejectedTxException.java b/core/src/main/java/bisq/core/btc/exceptions/RejectedTxException.java new file mode 100644 index 00000000000..a45c62c9dc2 --- /dev/null +++ b/core/src/main/java/bisq/core/btc/exceptions/RejectedTxException.java @@ -0,0 +1,46 @@ +/* + * 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; + +import org.bitcoinj.core.RejectMessage; + +import lombok.Getter; + +import javax.annotation.Nullable; + +public class RejectedTxException extends RuntimeException { + @Getter + private final RejectMessage rejectMessage; + @Getter + @Nullable + private final String txId; + + public RejectedTxException(String message, RejectMessage rejectMessage) { + super(message); + this.rejectMessage = rejectMessage; + txId = rejectMessage.getRejectedObjectHash() != null ? rejectMessage.getRejectedObjectHash().toString() : null; + } + + @Override + public String toString() { + return "RejectedTxException{" + + "\n rejectMessage=" + rejectMessage + + ",\n txId='" + txId + '\'' + + "\n} " + super.toString(); + } +} diff --git a/core/src/main/java/bisq/core/btc/setup/WalletsSetup.java b/core/src/main/java/bisq/core/btc/setup/WalletsSetup.java index 773fe88d72b..bbee2d45fd4 100644 --- a/core/src/main/java/bisq/core/btc/setup/WalletsSetup.java +++ b/core/src/main/java/bisq/core/btc/setup/WalletsSetup.java @@ -19,6 +19,7 @@ import bisq.core.app.BisqEnvironment; import bisq.core.btc.BtcOptionKeys; +import bisq.core.btc.exceptions.RejectedTxException; import bisq.core.btc.model.AddressEntry; import bisq.core.btc.model.AddressEntryList; import bisq.core.btc.nodes.BtcNetworkConfig; @@ -44,6 +45,7 @@ import org.bitcoinj.core.Peer; import org.bitcoinj.core.PeerAddress; import org.bitcoinj.core.PeerGroup; +import org.bitcoinj.core.RejectMessage; import org.bitcoinj.core.listeners.DownloadProgressTracker; import org.bitcoinj.params.RegTestParams; import org.bitcoinj.utils.Threading; @@ -170,7 +172,9 @@ public WalletsSetup(RegTestHost regTestHost, // Lifecycle /////////////////////////////////////////////////////////////////////////////////////////// - public void initialize(@Nullable DeterministicSeed seed, ResultHandler resultHandler, ExceptionHandler exceptionHandler) { + public void initialize(@Nullable DeterministicSeed seed, + ResultHandler resultHandler, + ExceptionHandler exceptionHandler) { // Tell bitcoinj to execute event handlers on the JavaFX UI thread. This keeps things simple and means // we cannot forget to switch threads when adding event handlers. Unfortunately, the DownloadListener // we give to the app kit is currently an exception and runs on a library thread. It'll get fixed in @@ -221,6 +225,20 @@ protected void onSetupCompleted() { peerGroup.addBlocksDownloadedEventListener((peer, block, filteredBlock, blocksLeft) -> { blocksDownloadedFromPeer.set(peer); }); + + // Need to be Threading.SAME_THREAD executor otherwise BitcoinJ will skip that listener + peerGroup.addPreMessageReceivedEventListener(Threading.SAME_THREAD, (peer, message) -> { + if (message instanceof RejectMessage) { + UserThread.execute(() -> { + RejectMessage rejectMessage = (RejectMessage) message; + String msg = rejectMessage.toString(); + log.warn(msg); + exceptionHandler.handleException(new RejectedTxException(msg, rejectMessage)); + }); + } + return message; + }); + chain.addNewBestBlockListener(block -> { connectedPeers.set(peerGroup.getConnectedPeers()); chainHeight.set(block.getHeight()); @@ -372,7 +390,9 @@ public void clearBackups() { // Restore /////////////////////////////////////////////////////////////////////////////////////////// - public void restoreSeedWords(@Nullable DeterministicSeed seed, ResultHandler resultHandler, ExceptionHandler exceptionHandler) { + public void restoreSeedWords(@Nullable DeterministicSeed seed, + ResultHandler resultHandler, + ExceptionHandler exceptionHandler) { checkNotNull(seed, "Seed must be not be null."); backupWallets(); diff --git a/core/src/main/java/bisq/core/btc/wallet/BsqWalletService.java b/core/src/main/java/bisq/core/btc/wallet/BsqWalletService.java index 915e00ca235..a6eff3e1de6 100644 --- a/core/src/main/java/bisq/core/btc/wallet/BsqWalletService.java +++ b/core/src/main/java/bisq/core/btc/wallet/BsqWalletService.java @@ -168,7 +168,7 @@ public void onTransactionConfidenceChanged(Wallet wallet, Transaction tx) { // We are only interested in updates from unconfirmed txs and confirmed txs at the // time when it gets into a block. Otherwise we would get called // updateBsqWalletTransactions for each tx as the block depth changes for all. - if (tx.getConfidence().getDepthInBlocks() <= 1 && + if (tx != null && tx.getConfidence() != null && tx.getConfidence().getDepthInBlocks() <= 1 && daoStateService.isParseBlockChainComplete()) { updateBsqWalletTransactions(); } diff --git a/core/src/main/java/bisq/core/btc/wallet/BtcWalletService.java b/core/src/main/java/bisq/core/btc/wallet/BtcWalletService.java index a1acce74f91..50d93c27160 100644 --- a/core/src/main/java/bisq/core/btc/wallet/BtcWalletService.java +++ b/core/src/main/java/bisq/core/btc/wallet/BtcWalletService.java @@ -672,7 +672,13 @@ public void resetAddressEntriesForOpenOffer(String offerId) { public void resetAddressEntriesForPendingTrade(String offerId) { swapTradeEntryToAvailableEntry(offerId, AddressEntry.Context.MULTI_SIG); - // Don't swap TRADE_PAYOUT as it might be still open in the last trade step to be used for external transfer + // We swap also TRADE_PAYOUT to be sure all is cleaned up. There might be cases where a user cannot send the funds + // to an external wallet directly in the last step of the trade, but the funds are in the Bisq wallet anyway and + // the dealing with the external wallet is pure UI thing. The user can move the funds to the wallet and then + // send out the funds to the external wallet. As this cleanup is a rare situation and most users do not use + // the feature to send out the funds we prefer that strategy (if we keep the address entry it might cause + // complications in some edge cases after a SPV resync). + swapTradeEntryToAvailableEntry(offerId, AddressEntry.Context.TRADE_PAYOUT); } public void swapAnyTradeEntryContextToAvailableEntry(String offerId) { diff --git a/core/src/main/java/bisq/core/dao/node/parser/BlockParser.java b/core/src/main/java/bisq/core/dao/node/parser/BlockParser.java index a1b7409bf2b..f370c1d61cb 100644 --- a/core/src/main/java/bisq/core/dao/node/parser/BlockParser.java +++ b/core/src/main/java/bisq/core/dao/node/parser/BlockParser.java @@ -115,9 +115,8 @@ public Block parseBlock(RawBlock rawBlock) throws BlockHashNotConnectingExceptio genesisTotalSupply) .ifPresent(txList::add)); - if (System.currentTimeMillis() - startTs > 0) - log.info("Parsing {} transactions at block height {} took {} ms", rawBlock.getRawTxs().size(), - blockHeight, System.currentTimeMillis() - startTs); + log.info("Parsing {} transactions at block height {} took {} ms", rawBlock.getRawTxs().size(), + blockHeight, System.currentTimeMillis() - startTs); daoStateService.onParseBlockComplete(block); return block; diff --git a/core/src/main/java/bisq/core/trade/Trade.java b/core/src/main/java/bisq/core/trade/Trade.java index 7006fba886f..24c82180e1b 100644 --- a/core/src/main/java/bisq/core/trade/Trade.java +++ b/core/src/main/java/bisq/core/trade/Trade.java @@ -678,7 +678,9 @@ public void applyDelayedPayoutTxBytes(byte[] delayedPayoutTxBytes) { @Nullable public Transaction getDelayedPayoutTx() { if (delayedPayoutTx == null) { - delayedPayoutTx = delayedPayoutTxBytes != null ? processModel.getBtcWalletService().getTxFromSerializedTx(delayedPayoutTxBytes) : null; + delayedPayoutTx = delayedPayoutTxBytes != null && processModel.getBtcWalletService() != null ? + processModel.getBtcWalletService().getTxFromSerializedTx(delayedPayoutTxBytes) : + null; } return delayedPayoutTx; } @@ -712,6 +714,11 @@ public void addAndPersistChatMessage(ChatMessage chatMessage) { } } + public void appendErrorMessage(String msg) { + errorMessage = errorMessage == null ? msg : errorMessage + "\n" + msg; + } + + /////////////////////////////////////////////////////////////////////////////////////////// // Model implementation /////////////////////////////////////////////////////////////////////////////////////////// diff --git a/core/src/main/java/bisq/core/trade/TradeManager.java b/core/src/main/java/bisq/core/trade/TradeManager.java index 1faf2d9a8a9..6cfbf7d5690 100644 --- a/core/src/main/java/bisq/core/trade/TradeManager.java +++ b/core/src/main/java/bisq/core/trade/TradeManager.java @@ -28,6 +28,7 @@ import bisq.core.btc.wallet.WalletService; import bisq.core.dao.DaoFacade; import bisq.core.filter.FilterManager; +import bisq.core.locale.Res; import bisq.core.offer.Offer; import bisq.core.offer.OfferPayload; import bisq.core.offer.OpenOffer; @@ -68,6 +69,7 @@ import org.bitcoinj.core.Coin; import org.bitcoinj.core.InsufficientMoneyException; import org.bitcoinj.core.Transaction; +import org.bitcoinj.core.TransactionConfidence; import javax.inject.Inject; @@ -78,6 +80,7 @@ import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleLongProperty; +import javafx.collections.FXCollections; import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; @@ -89,6 +92,7 @@ import java.util.Optional; import java.util.Set; import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -135,6 +139,8 @@ public class TradeManager implements PersistedDataHost { private ErrorMessageHandler takeOfferRequestErrorMessageHandler; @Getter private final LongProperty numPendingTrades = new SimpleLongProperty(); + @Getter + private final ObservableList tradesWithoutDepositTx = FXCollections.observableArrayList(); /////////////////////////////////////////////////////////////////////////////////////////// @@ -274,12 +280,33 @@ private void initPendingTrades() { tradesForStatistics.add(trade); } else if (trade.isTakerFeePublished() && !trade.isFundsLockedIn()) { addTradeToFailedTradesList.add(trade); + trade.appendErrorMessage("Invalid state: trade.isTakerFeePublished() && !trade.isFundsLockedIn()"); } else { removePreparedTradeList.add(trade); } + + if (trade.getDepositTx() == null) { + log.warn("Deposit tx for trader with ID {} is null at initPendingTrades. " + + "This can happen for valid transaction in rare cases (e.g. after a SPV resync). " + + "We leave it to the user to move the trade to failed trades if the problem persists.", + trade.getId()); + tradesWithoutDepositTx.add(trade); + } } ); + // If we have a closed trade where the deposit tx is still not confirmed we move it to failed trades as the + // payout tx cannot be valid as well in this case. As the trade do not progress without confirmation of the + // deposit tx this should normally not happen. If we detect such a trade at start up (done in BisqSetup) we + // show a popup telling the user to do a SPV resync. + closedTradableManager.getClosedTradables().stream() + .filter(tradable -> tradable instanceof Trade) + .map(tradable -> (Trade) tradable) + .filter(Trade::isFundsLockedIn) + .forEach(addTradeToFailedTradesList::add); + + addTradeToFailedTradesList.forEach(closedTradableManager::remove); + addTradeToFailedTradesList.forEach(this::addTradeToFailedTrades); removePreparedTradeList.forEach(this::removePreparedTrade); @@ -294,22 +321,16 @@ private void onTradesChanged() { } private void cleanUpAddressEntries() { - Set tradesIdSet = getLockedTradesStream() - .map(Trade::getId) - .collect(Collectors.toSet()); - - tradesIdSet.addAll(failedTradesManager.getLockedTradesStream() - .map(Trade::getId) - .collect(Collectors.toSet())); - - tradesIdSet.addAll(closedTradableManager.getLockedTradesStream() + // We check if we have address entries which are not in our pending trades and clean up those entries. + // They might be either from closed or failed trades or from trades we do not have at all in our data base files. + Set tradesIdSet = getTradesStreamWithFundsLockedIn() .map(Tradable::getId) - .collect(Collectors.toSet())); + .collect(Collectors.toSet()); btcWalletService.getAddressEntriesForTrade().stream() .filter(e -> !tradesIdSet.contains(e.getOfferId())) .forEach(e -> { - log.warn("We found an outdated addressEntry for trade {}", e.getOfferId()); + log.warn("We found an outdated addressEntry for trade {}: entry={}", e.getOfferId(), e); btcWalletService.resetAddressEntriesForPendingTrade(e.getOfferId()); }); } @@ -681,27 +702,46 @@ public Stream getAddressEntriesForAvailableBalanceStream() { return available.filter(addressEntry -> btcWalletService.getBalanceForAddress(addressEntry.getAddress()).isPositive()); } - public Stream getLockedTradesStream() { + public Stream getTradesStreamWithFundsLockedIn() { return getTradableList().stream() .filter(Trade::isFundsLockedIn); } - public Set getSetOfAllTradeIds() { - Set tradesIdSet = getLockedTradesStream() + public Set getSetOfFailedOrClosedTradeIdsFromLockedInFunds() throws TradeTxException { + AtomicReference tradeTxException = new AtomicReference<>(); + Set tradesIdSet = getTradesStreamWithFundsLockedIn() .filter(Trade::hasFailed) .map(Trade::getId) .collect(Collectors.toSet()); - tradesIdSet.addAll(failedTradesManager.getLockedTradesStream() - .map(Trade::getId) + tradesIdSet.addAll(failedTradesManager.getTradesStreamWithFundsLockedIn() + .filter(trade -> trade.getDepositTx() != null) + .map(trade -> { + log.warn("We found a failed trade with locked up funds. " + + "That should never happen. trade ID=" + trade.getId()); + return trade.getId(); + }) .collect(Collectors.toSet())); - tradesIdSet.addAll(closedTradableManager.getLockedTradesStream() - .map(e -> { - log.warn("We found a closed trade with locked up funds. " + - "That should never happen. trade ID=" + e.getId()); - return e.getId(); + tradesIdSet.addAll(closedTradableManager.getTradesStreamWithFundsLockedIn() + .map(trade -> { + Transaction depositTx = trade.getDepositTx(); + if (depositTx != null) { + TransactionConfidence confidence = btcWalletService.getConfidenceForTxId(depositTx.getHashAsString()); + if (confidence != null && confidence.getConfidenceType() != TransactionConfidence.ConfidenceType.BUILDING) { + tradeTxException.set(new TradeTxException(Res.get("error.closedTradeWithUnconfirmedDepositTx", trade.getShortId()))); + } else { + log.warn("We found a closed trade with locked up funds. " + + "That should never happen. trade ID=" + trade.getId()); + } + } else { + tradeTxException.set(new TradeTxException(Res.get("error.closedTradeWithNoDepositTx", trade.getShortId()))); + } + return trade.getId(); }) .collect(Collectors.toSet())); + if (tradeTxException.get() != null) + throw tradeTxException.get(); + return tradesIdSet; } diff --git a/core/src/main/java/bisq/core/trade/TradeTxException.java b/core/src/main/java/bisq/core/trade/TradeTxException.java new file mode 100644 index 00000000000..cbc79660678 --- /dev/null +++ b/core/src/main/java/bisq/core/trade/TradeTxException.java @@ -0,0 +1,24 @@ +/* + * 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.trade; + +public class TradeTxException extends Exception { + public TradeTxException(String message) { + super(message); + } +} diff --git a/core/src/main/java/bisq/core/trade/closed/ClosedTradableManager.java b/core/src/main/java/bisq/core/trade/closed/ClosedTradableManager.java index ba96ac431c6..2184600b058 100644 --- a/core/src/main/java/bisq/core/trade/closed/ClosedTradableManager.java +++ b/core/src/main/java/bisq/core/trade/closed/ClosedTradableManager.java @@ -76,6 +76,10 @@ public void add(Tradable tradable) { closedTradables.add(tradable); } + public void remove(Tradable tradable) { + closedTradables.remove(tradable); + } + public boolean wasMyOffer(Offer offer) { return offer.isMyOffer(keyRing); } @@ -95,7 +99,7 @@ public Optional getTradableById(String id) { return closedTradables.stream().filter(e -> e.getId().equals(id)).findFirst(); } - public Stream getLockedTradesStream() { + public Stream getTradesStreamWithFundsLockedIn() { return getClosedTrades().stream() .filter(Trade::isFundsLockedIn); } diff --git a/core/src/main/java/bisq/core/trade/failed/FailedTradesManager.java b/core/src/main/java/bisq/core/trade/failed/FailedTradesManager.java index b0c61e594a2..28a0572cfb0 100644 --- a/core/src/main/java/bisq/core/trade/failed/FailedTradesManager.java +++ b/core/src/main/java/bisq/core/trade/failed/FailedTradesManager.java @@ -60,16 +60,19 @@ public FailedTradesManager(KeyRing keyRing, @Override public void readPersisted() { this.failedTrades = new TradableList<>(tradableListStorage, "FailedTrades"); - failedTrades.forEach(e -> e.getOffer().setPriceFeedService(priceFeedService)); failedTrades.forEach(trade -> { - trade.getOffer().setPriceFeedService(priceFeedService); + if (trade.getOffer() != null) { + trade.getOffer().setPriceFeedService(priceFeedService); + } + trade.setTransientFields(tradableListStorage, btcWalletService); }); } public void add(Trade trade) { - if (!failedTrades.contains(trade)) + if (!failedTrades.contains(trade)) { failedTrades.add(trade); + } } public boolean wasMyOffer(Offer offer) { @@ -84,7 +87,7 @@ public Optional getTradeById(String id) { return failedTrades.stream().filter(e -> e.getId().equals(id)).findFirst(); } - public Stream getLockedTradesStream() { + public Stream getTradesStreamWithFundsLockedIn() { return failedTrades.stream() .filter(Trade::isFundsLockedIn); } diff --git a/core/src/main/resources/i18n/displayStrings.properties b/core/src/main/resources/i18n/displayStrings.properties index b477d7730f5..330127b116e 100644 --- a/core/src/main/resources/i18n/displayStrings.properties +++ b/core/src/main/resources/i18n/displayStrings.properties @@ -275,6 +275,8 @@ mainView.p2pNetworkWarnMsg.connectionToP2PFailed=Connecting to the Bisq network mainView.walletServiceErrorMsg.timeout=Connecting to the Bitcoin network failed because of a timeout. mainView.walletServiceErrorMsg.connectionError=Connection to the Bitcoin network failed because of an error: {0} +mainView.walletServiceErrorMsg.rejectedTxException=A transaction was rejected from the network.\n\n{0} + mainView.networkWarning.allConnectionsLost=You lost the connection to all {0} network peers.\nMaybe you lost your internet connection or your computer was in standby mode. mainView.networkWarning.localhostBitcoinLost=You lost the connection to the localhost Bitcoin node.\nPlease restart the Bisq application to connect to other Bitcoin nodes or restart the localhost Bitcoin node. mainView.version.update=(Update available) @@ -775,6 +777,14 @@ portfolio.pending.openSupportTicket.msg=Please use this function only in emergen handled by a mediator or arbitrator. portfolio.pending.timeLockNotOver=You have to wait until ≈{0} ({1} more blocks) before you can open an arbitration dispute. +portfolio.pending.error.depositTxNull=The deposit transaction is null. You cannot open a dispute \ + without a valid deposit transaction. Please go to \"Settings/Network info\" and do a SPV resync.\n\n\ + For further help please contact the Bisq support channel at the Bisq Keybase team. +portfolio.pending.mediationResult.error.depositTxNull=The deposit transaction is null. The trade gets moved to the \ + failed trades section. +portfolio.pending.error.depositTxNotConfirmed=The deposit transaction is not confirmed. You can not open an arbitration dispute \ + with an unconfirmed deposit transaction. Please wait until it is confirmed or go to \"Settings/Network info\" and do a SPV resync.\n\n\ + For further help please contact the Bisq support channel at the Bisq Keybase team. portfolio.pending.notification=Notification @@ -1107,7 +1117,9 @@ settings.net.outbound=outbound settings.net.reSyncSPVChainLabel=Resync SPV chain settings.net.reSyncSPVChainButton=Delete SPV file and resync settings.net.reSyncSPVSuccess=The SPV chain file will be deleted on the next startup. You need to restart your application now.\n\n\ -After the restart it can take a while to resync with the network and you will only see all transactions once the resync is completed.\n\nPlease restart again after the resync has completed because there are sometimes inconsistencies that result in incorrect balances to display. +After the restart it can take a while to resync with the network and you will only see all transactions once the resync is completed.\n\n\ + Depending on the number of transactions and the age of your wallet the resync can take up to a few hours and consumes 100% of CPU. \ + Do not interrupt the process otherwise you have to repeat it. settings.net.reSyncSPVAfterRestart=The SPV chain file has been deleted. Please be patient. It can take a while to resync with the network. settings.net.reSyncSPVAfterRestartCompleted=The resync is now completed. Please restart the application. settings.net.reSyncSPVFailed=Could not delete SPV chain file.\nError: {0} @@ -2432,6 +2444,11 @@ popup.error.takeOfferRequestFailed=An error occurred when someone tried to take error.spvFileCorrupted=An error occurred when reading the SPV chain file.\nIt might be that the SPV chain file is corrupted.\n\nError message: {0}\n\nDo you want to delete it and start a resync? error.deleteAddressEntryListFailed=Could not delete AddressEntryList file.\nError: {0} +error.closedTradeWithUnconfirmedDepositTx=The deposit transaction of the closed trade with the trade ID {0} is still \ + unconfirmed.\n\n\ + Please do a SPV resync at \"Setting/Network info\" to see if the transaction is valid. +error.closedTradeWithNoDepositTx=The deposit transaction of the closed trade with the trade ID {0} is null.\n\n\ + Please restart the application to clean up the closed trades list. popup.warning.walletNotInitialized=The wallet is not initialized yet popup.warning.wrongVersion=You probably have the wrong Bisq version for this computer.\n\ @@ -2502,6 +2519,30 @@ popup.warning.disable.dao=The Bisq DAO and BSQ are temporary disabled. \ popup.warning.burnBTC=This transaction is not possible, as the mining fees of {0} would exceed the amount to transfer of {1}. \ Please wait until the mining fees are low again or until you''ve accumulated more BTC to transfer. +popup.warning.openOffer.makerFeeTxRejected=The maker fee transaction for offer with ID {0} was rejected by the Bitcoin network.\n\ + Transaction ID={1}.\n\ + The offer has been removed to avoid further problems.\n\ + Please go to \"Settings/Network info\" and do a SPV resync.\n\ + For further help please contact the Bisq support channel at the Bisq Keybase team. + +popup.warning.trade.txRejected.tradeFee=trade fee +popup.warning.trade.txRejected.deposit=deposit +popup.warning.trade.txRejected=The {0} transaction for trade with ID {1} was rejected by the Bitcoin network.\n\ + Transaction ID={2}}\n\ + The trade has been moved to failed trades.\n\ + Please go to \"Settings/Network info\" and do a SPV resync.\n\ + For further help please contact the Bisq support channel at the Bisq Keybase team. + +popup.warning.openOfferWithInvalidMakerFeeTx=The maker fee transaction for offer with ID {0} is invalid.\n\ + Transaction ID={1}.\n\ + Please go to \"Settings/Network info\" and do a SPV resync.\n\ + For further help please contact the Bisq support channel at the Bisq Keybase team. + +popup.warning.trade.depositTxNull=The trade with ID {0} has no deposit transaction set.\n\ + Please restart the application and if the problem remains move the trade to failed trades and report the problem to \ + the Bisq support channel at the Bisq Keybase team. +popup.warning.trade.depositTxNull.moveToFailedTrades=Move to failed trades + popup.info.securityDepositInfo=To ensure both traders follow the trade protocol, both traders need to pay a security \ deposit.\n\nThis deposit is kept in your trade wallet until your trade has been successfully completed, and then it's \ refunded to you.\n\nPlease note: if you're creating a new offer, Bisq needs to be running for another trader to take \ @@ -2517,7 +2558,6 @@ popup.info.shutDownWithOpenOffers=Bisq is being shut down, but there are open of To keep your offers online, keep Bisq running and make sure this computer remains online too \ (i.e., make sure it doesn't go into standby mode...monitor standby is not a problem). - popup.privateNotification.headline=Important private notification! popup.securityRecommendation.headline=Important security recommendation diff --git a/desktop/src/main/java/bisq/desktop/main/MainViewModel.java b/desktop/src/main/java/bisq/desktop/main/MainViewModel.java index a26eab84b08..130aa33e91f 100644 --- a/desktop/src/main/java/bisq/desktop/main/MainViewModel.java +++ b/desktop/src/main/java/bisq/desktop/main/MainViewModel.java @@ -55,6 +55,7 @@ import bisq.core.presentation.TradePresentation; import bisq.core.provider.fee.FeeService; import bisq.core.provider.price.PriceFeedService; +import bisq.core.trade.Trade; import bisq.core.trade.TradeManager; import bisq.core.user.DontShowAgainLookup; import bisq.core.user.Preferences; @@ -83,6 +84,7 @@ import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; +import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; import java.util.Comparator; @@ -309,7 +311,7 @@ private void setupHandlers() { bisqSetup.setChainFileLockedExceptionHandler(msg -> new Popup<>().warning(msg) .useShutDownButton() .show()); - bisqSetup.setLockedUpFundsHandler(msg -> new Popup<>().warning(msg).show()); + bisqSetup.setLockedUpFundsHandler(msg -> new Popup<>().width(850).warning(msg).show()); bisqSetup.setShowFirstPopupIfResyncSPVRequestedHandler(this::showFirstPopupIfResyncSPVRequested); bisqSetup.setRequestWalletPasswordHandler(aesKeyHandler -> walletPasswordWindow .onAesKey(aesKeyHandler::accept) @@ -364,6 +366,8 @@ private void setupHandlers() { bisqSetup.setWrongOSArchitectureHandler(msg -> new Popup<>().warning(msg).show()); + bisqSetup.setRejectedTxErrorMessageHandler(msg -> new Popup<>().width(850).warning(msg).show()); + corruptedDatabaseFilesHandler.getCorruptedDatabaseFiles().ifPresent(files -> new Popup<>() .warning(Res.get("popup.warning.incompatibleDB", files.toString(), bisqEnvironment.getProperty(AppOptionKeys.APP_DATA_DIR_KEY))) @@ -374,6 +378,18 @@ private void setupHandlers() { .warning(Res.get("popup.error.takeOfferRequestFailed", errorMessage)) .show()); + tradeManager.getTradesWithoutDepositTx().addListener((ListChangeListener) c -> { + c.next(); + if (c.wasAdded()) { + c.getAddedSubList().forEach(trade -> { + new Popup<>().warning(Res.get("popup.warning.trade.depositTxNull", trade.getShortId())) + .actionButtonText(Res.get("popup.warning.trade.depositTxNull.moveToFailedTrades")) + .onAction(() -> tradeManager.addTradeToFailedTrades(trade)) + .show(); + }); + } + }); + bisqSetup.getBtcSyncProgress().addListener((observable, oldValue, newValue) -> updateBtcSyncProgress()); daoPresentation.getBsqSyncProgress().addListener((observable, oldValue, newValue) -> updateBtcSyncProgress()); diff --git a/desktop/src/main/java/bisq/desktop/main/funds/locked/LockedView.java b/desktop/src/main/java/bisq/desktop/main/funds/locked/LockedView.java index 65a36f83e36..84e55264806 100644 --- a/desktop/src/main/java/bisq/desktop/main/funds/locked/LockedView.java +++ b/desktop/src/main/java/bisq/desktop/main/funds/locked/LockedView.java @@ -165,7 +165,7 @@ protected void deactivate() { private void updateList() { observableList.forEach(LockedListItem::cleanup); - observableList.setAll(tradeManager.getLockedTradesStream() + observableList.setAll(tradeManager.getTradesStreamWithFundsLockedIn() .map(trade -> { final Optional addressEntryOptional = btcWalletService.getAddressEntry(trade.getId(), AddressEntry.Context.MULTI_SIG); return addressEntryOptional.map(addressEntry -> new LockedListItem(trade, diff --git a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/PendingTradesDataModel.java b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/PendingTradesDataModel.java index 2526349e60d..53fc9023ea2 100644 --- a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/PendingTradesDataModel.java +++ b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/PendingTradesDataModel.java @@ -61,6 +61,7 @@ import org.bitcoinj.core.Coin; import org.bitcoinj.core.Transaction; +import org.bitcoinj.core.TransactionConfidence; import com.google.inject.Inject; @@ -76,8 +77,6 @@ import org.spongycastle.crypto.params.KeyParameter; -import java.util.ArrayList; -import java.util.List; import java.util.stream.Collectors; import lombok.Getter; @@ -448,41 +447,19 @@ private void tryOpenDispute(boolean isSupportTicket) { return; } - Transaction depositTx = trade.getDepositTx(); - if (depositTx != null) { - doOpenDispute(isSupportTicket, depositTx); - } else { - //TODO consider to remove that - log.info("Trade.depositTx is null. We try to find the tx in our wallet."); - List candidates = new ArrayList<>(); - List transactions = btcWalletService.getRecentTransactions(100, true); - transactions.forEach(transaction -> { - Coin valueSentFromMe = btcWalletService.getValueSentFromMeForTransaction(transaction); - if (!valueSentFromMe.isZero()) { - // spending tx - // MS tx - candidates.addAll(transaction.getOutputs().stream() - .filter(output -> !btcWalletService.isTransactionOutputMine(output)) - .filter(output -> output.getScriptPubKey().isPayToScriptHash()) - .map(transactionOutput -> transaction) - .collect(Collectors.toList())); - } - }); - - if (candidates.size() > 0) { - log.error("Trade.depositTx is null. We take the first possible MultiSig tx just to be able to open a dispute. " + - "candidates={}", candidates); - doOpenDispute(isSupportTicket, candidates.get(0)); - } else if (transactions.size() > 0) { - doOpenDispute(isSupportTicket, transactions.get(0)); - log.error("Trade.depositTx is null and we did not find any MultiSig transaction. We take any random tx just to be able to open a dispute"); - } else { - log.error("Trade.depositTx is null and we did not find any transaction."); - } - } + doOpenDispute(isSupportTicket, trade.getDepositTx()); } private void doOpenDispute(boolean isSupportTicket, Transaction depositTx) { + // We do not support opening a dispute if the deposit tx is null. Traders have to use the support channel at keybase + // in such cases. The mediators or arbitrators could not help anyway with a payout in such cases. + if (depositTx == null) { + log.error("Deposit tx must not be null"); + new Popup<>().instruction(Res.get("portfolio.pending.error.depositTxNull")).show(); + return; + } + String depositTxId = depositTx.getHashAsString(); + Trade trade = getTrade(); if (trade == null) { log.warn("trade is null at doOpenDispute"); @@ -523,7 +500,6 @@ private void doOpenDispute(boolean isSupportTicket, Transaction depositTx) { PubKeyRing mediatorPubKeyRing = trade.getMediatorPubKeyRing(); checkNotNull(mediatorPubKeyRing, "mediatorPubKeyRing must not be null"); byte[] depositTxSerialized = depositTx.bitcoinSerialize(); - String depositTxHashAsString = depositTx.getHashAsString(); Dispute dispute = new Dispute(disputeManager.getStorage(), trade.getId(), pubKeyRing.hashCode(), // traderId @@ -535,7 +511,7 @@ private void doOpenDispute(boolean isSupportTicket, Transaction depositTx) { trade.getContractHash(), depositTxSerialized, payoutTxSerialized, - depositTxHashAsString, + depositTxId, payoutTxHashAsString, trade.getContractAsJson(), trade.getMakerContractSignature(), @@ -572,6 +548,16 @@ private void doOpenDispute(boolean isSupportTicket, Transaction depositTx) { return; } + // We only require for refund agent a confirmed deposit tx. For mediation we tolerate a unconfirmed tx as + // no harm can be done to the mediator (refund agent who would accept a invalid deposit tx might reimburse + // the traders but the funds never have been spent). + TransactionConfidence confidenceForTxId = btcWalletService.getConfidenceForTxId(depositTxId); + if (confidenceForTxId == null || confidenceForTxId.getConfidenceType() != TransactionConfidence.ConfidenceType.BUILDING) { + log.error("Confidence for deposit tx must be BUILDING, confidenceForTxId={}", confidenceForTxId); + new Popup<>().instruction(Res.get("portfolio.pending.error.depositTxNotConfirmed")).show(); + return; + } + long lockTime = trade.getDelayedPayoutTx().getLockTime(); int bestChainHeight = btcWalletService.getBestChainHeight(); long remaining = lockTime - bestChainHeight; @@ -666,5 +652,9 @@ public boolean isReadyForTxBroadcast() { public boolean isBootstrappedOrShowPopup() { return GUIUtil.isBootstrappedOrShowPopup(p2PService); } + + public void addTradeToFailedTrades() { + tradeManager.addTradeToFailedTrades(selectedTrade); + } } diff --git a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/TradeStepView.java b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/TradeStepView.java index 6387a5d7488..164b95eb263 100644 --- a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/TradeStepView.java +++ b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/TradeStepView.java @@ -523,6 +523,14 @@ private void openMediationResultPopup(String headLine) { return; } + if (trade.getDepositTx() == null || trade.getDelayedPayoutTx() == null) { + log.error("trade.getDepositTx() or trade.getDelayedPayoutTx() was null at openMediationResultPopup. " + + "We add the trade to failed trades. TradeId={}", trade.getId()); + model.dataModel.addTradeToFailedTrades(); + new Popup<>().warning(Res.get("portfolio.pending.mediationResult.error.depositTxNull")).show(); + return; + } + DisputeResult disputeResult = optionalDispute.get().getDisputeResultProperty().get(); Contract contract = checkNotNull(trade.getContract(), "contract must not be null"); boolean isMyRoleBuyer = contract.isMyRoleBuyer(model.dataModel.getPubKeyRing()); @@ -531,8 +539,6 @@ private void openMediationResultPopup(String headLine) { String myPayoutAmount = isMyRoleBuyer ? buyerPayoutAmount : sellerPayoutAmount; String peersPayoutAmount = isMyRoleBuyer ? sellerPayoutAmount : buyerPayoutAmount; - checkNotNull(trade.getDelayedPayoutTx(), - "trade.getDelayedPayoutTx() must not be null at openMediationResultPopup"); long lockTime = trade.getDelayedPayoutTx().getLockTime(); int bestChainHeight = model.dataModel.btcWalletService.getBestChainHeight(); long remaining = lockTime - bestChainHeight;