diff --git a/core/src/main/java/bisq/core/dao/DaoFacade.java b/core/src/main/java/bisq/core/dao/DaoFacade.java index 1485ae25465..7d9d39aaa05 100644 --- a/core/src/main/java/bisq/core/dao/DaoFacade.java +++ b/core/src/main/java/bisq/core/dao/DaoFacade.java @@ -73,6 +73,7 @@ import bisq.asset.Asset; +import bisq.common.config.Config; import bisq.common.handlers.ErrorMessageHandler; import bisq.common.handlers.ExceptionHandler; import bisq.common.handlers.ResultHandler; @@ -95,6 +96,7 @@ import java.io.IOException; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.Set; @@ -750,4 +752,32 @@ public long getRequiredBond(BondedRoleType bondedRoleType) { long baseFactor = daoStateService.getParamValueAsCoin(Param.BONDED_ROLE_FACTOR, height).value; return requiredBondUnit * baseFactor; } + + public Set getAllPastParamValues(Param param) { + Set set = new HashSet<>(); + periodService.getCycles().forEach(cycle -> { + set.add(getParamValue(param, cycle.getHeightOfFirstBlock())); + }); + return set; + } + + public Set getAllDonationAddresses() { + // We support any of the past addresses as well as in case the peer has not enabled the DAO or is out of sync we + // do not want to break validation. + Set allPastParamValues = getAllPastParamValues(Param.RECIPIENT_BTC_ADDRESS); + + // If Dao is deactivated we need to add the default address as getAllPastParamValues will not return us any. + if (allPastParamValues.isEmpty()) { + allPastParamValues.add(Param.RECIPIENT_BTC_ADDRESS.getDefaultValue()); + } + + if (Config.baseCurrencyNetwork().isMainnet()) { + // If Dao is deactivated we need to add the past addresses used as well. + // This list need to be updated once a new address gets defined. + allPastParamValues.add("3EtUWqsGThPtjwUczw27YCo6EWvQdaPUyp"); // burning man 2019 + allPastParamValues.add("3A8Zc1XioE2HRzYfbb5P8iemCS72M6vRJV"); // burningman2 + } + + return allPastParamValues; + } } diff --git a/core/src/main/java/bisq/core/support/dispute/Dispute.java b/core/src/main/java/bisq/core/support/dispute/Dispute.java index d5f45a7a185..df80aa8c3fe 100644 --- a/core/src/main/java/bisq/core/support/dispute/Dispute.java +++ b/core/src/main/java/bisq/core/support/dispute/Dispute.java @@ -103,6 +103,14 @@ public final class Dispute implements NetworkPayload { @Nullable private String delayedPayoutTxId; + // Added at v1.3.9 + @Setter + @Nullable + private String donationAddressOfDelayedPayoutTx; + @Setter + @Nullable + private String agentsUid; + /////////////////////////////////////////////////////////////////////////////////////////// // Constructor @@ -228,6 +236,8 @@ public protobuf.Dispute toProtoMessage() { Optional.ofNullable(supportType).ifPresent(result -> builder.setSupportType(SupportType.toProtoMessage(supportType))); Optional.ofNullable(mediatorsDisputeResult).ifPresent(result -> builder.setMediatorsDisputeResult(mediatorsDisputeResult)); Optional.ofNullable(delayedPayoutTxId).ifPresent(result -> builder.setDelayedPayoutTxId(delayedPayoutTxId)); + Optional.ofNullable(donationAddressOfDelayedPayoutTx).ifPresent(result -> builder.setDonationAddressOfDelayedPayoutTx(donationAddressOfDelayedPayoutTx)); + Optional.ofNullable(agentsUid).ifPresent(result -> builder.setAgentsUid(agentsUid)); return builder.build(); } @@ -271,6 +281,16 @@ public static Dispute fromProto(protobuf.Dispute proto, CoreProtoResolver corePr dispute.setDelayedPayoutTxId(delayedPayoutTxId); } + String donationAddressOfDelayedPayoutTx = proto.getDonationAddressOfDelayedPayoutTx(); + if (!donationAddressOfDelayedPayoutTx.isEmpty()) { + dispute.setDonationAddressOfDelayedPayoutTx(donationAddressOfDelayedPayoutTx); + } + + String agentsUid = proto.getAgentsUid(); + if (!agentsUid.isEmpty()) { + dispute.setAgentsUid(agentsUid); + } + return dispute; } @@ -382,6 +402,7 @@ public String toString() { ",\n supportType=" + supportType + ",\n mediatorsDisputeResult='" + mediatorsDisputeResult + '\'' + ",\n delayedPayoutTxId='" + delayedPayoutTxId + '\'' + + ",\n donationAddressOfDelayedPayoutTx='" + donationAddressOfDelayedPayoutTx + '\'' + "\n}"; } } diff --git a/core/src/main/java/bisq/core/support/dispute/DisputeManager.java b/core/src/main/java/bisq/core/support/dispute/DisputeManager.java index 9998e1f2eba..ea1ff28c540 100644 --- a/core/src/main/java/bisq/core/support/dispute/DisputeManager.java +++ b/core/src/main/java/bisq/core/support/dispute/DisputeManager.java @@ -21,6 +21,7 @@ import bisq.core.btc.wallet.BtcWalletService; import bisq.core.btc.wallet.Restrictions; import bisq.core.btc.wallet.TradeWalletService; +import bisq.core.dao.DaoFacade; import bisq.core.locale.CurrencyUtil; import bisq.core.locale.Res; import bisq.core.monetary.Altcoin; @@ -35,6 +36,7 @@ import bisq.core.support.dispute.messages.PeerOpenedDisputeMessage; import bisq.core.support.messages.ChatMessage; import bisq.core.trade.Contract; +import bisq.core.trade.DelayedPayoutTxValidation; import bisq.core.trade.Trade; import bisq.core.trade.TradeManager; import bisq.core.trade.closed.ClosedTradableManager; @@ -58,6 +60,7 @@ import javafx.beans.property.IntegerProperty; +import javafx.collections.FXCollections; import javafx.collections.ObservableList; import java.util.List; @@ -66,6 +69,7 @@ import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; import javax.annotation.Nullable; @@ -82,6 +86,11 @@ public abstract class DisputeManager disputeListService; private final PriceFeedService priceFeedService; + protected final DaoFacade daoFacade; + + @Getter + protected final ObservableList validationExceptions = + FXCollections.observableArrayList(); /////////////////////////////////////////////////////////////////////////////////////////// @@ -95,6 +104,7 @@ public DisputeManager(P2PService p2PService, TradeManager tradeManager, ClosedTradableManager closedTradableManager, OpenOfferManager openOfferManager, + DaoFacade daoFacade, PubKeyRing pubKeyRing, DisputeListService disputeListService, PriceFeedService priceFeedService) { @@ -105,6 +115,7 @@ public DisputeManager(P2PService p2PService, this.tradeManager = tradeManager; this.closedTradableManager = closedTradableManager; this.openOfferManager = openOfferManager; + this.daoFacade = daoFacade; this.pubKeyRing = pubKeyRing; this.disputeListService = disputeListService; this.priceFeedService = priceFeedService; @@ -178,7 +189,7 @@ public void addAndPersistChatMessage(ChatMessage message) { @Nullable public abstract NodeAddress getAgentNodeAddress(Dispute dispute); - protected abstract Trade.DisputeState getDisputeState_StartedByPeer(); + protected abstract Trade.DisputeState getDisputeStateStartedByPeer(); public abstract void cleanupDisputes(); @@ -209,7 +220,7 @@ public String getNrOfDisputes(boolean isBuyer, Contract contract) { return disputeListService.getNrOfDisputes(isBuyer, contract); } - private T getDisputeList() { + protected T getDisputeList() { return disputeListService.getDisputeList(); } @@ -241,6 +252,20 @@ public void onUpdatedDataReceived() { tryApplyMessages(); cleanupDisputes(); + + getDisputeList().getList().forEach(dispute -> { + if (dispute.getAgentsUid() == null) { + dispute.setAgentsUid(UUID.randomUUID().toString()); + } + + try { + DelayedPayoutTxValidation.validateDonationAddress(dispute, dispute.getDonationAddressOfDelayedPayoutTx(), daoFacade); + DelayedPayoutTxValidation.testIfDisputeTriesReplay(dispute, getDisputeList().getList()); + } catch (DelayedPayoutTxValidation.AddressException | DelayedPayoutTxValidation.DisputeReplayException e) { + log.error(e.toString()); + validationExceptions.add(e); + } + }); } public boolean isTrader(Dispute dispute) { @@ -257,6 +282,7 @@ public Optional findOwnDispute(String tradeId) { return disputeList.stream().filter(e -> e.getTradeId().equals(tradeId)).findAny(); } + /////////////////////////////////////////////////////////////////////////////////////////// // Message handler /////////////////////////////////////////////////////////////////////////////////////////// @@ -271,6 +297,8 @@ protected void onOpenNewDisputeMessage(OpenNewDisputeMessage openNewDisputeMessa String errorMessage = null; Dispute dispute = openNewDisputeMessage.getDispute(); + // Dispute agent sets uid to be sure to identify disputes uniquely to protect against replaying old disputes + dispute.setAgentsUid(UUID.randomUUID().toString()); dispute.setStorage(disputeListService.getStorage()); // Disputes from clients < 1.2.0 always have support type ARBITRATION in dispute as the field didn't exist before dispute.setSupportType(openNewDisputeMessage.getSupportType()); @@ -278,6 +306,14 @@ protected void onOpenNewDisputeMessage(OpenNewDisputeMessage openNewDisputeMessa Contract contract = dispute.getContract(); addPriceInfoMessage(dispute, 0); + try { + DelayedPayoutTxValidation.validateDonationAddress(dispute.getDonationAddressOfDelayedPayoutTx(), daoFacade); + DelayedPayoutTxValidation.testIfDisputeTriesReplay(dispute, disputeList.getList()); + } catch (DelayedPayoutTxValidation.AddressException | DelayedPayoutTxValidation.DisputeReplayException e) { + log.error(e.toString()); + validationExceptions.add(e); + } + PubKeyRing peersPubKeyRing = dispute.isDisputeOpenerIsBuyer() ? contract.getSellerPubKeyRing() : contract.getBuyerPubKeyRing(); if (isAgent(dispute)) { if (!disputeList.contains(dispute)) { @@ -310,7 +346,7 @@ protected void onOpenNewDisputeMessage(OpenNewDisputeMessage openNewDisputeMessa addMediationResultMessage(dispute); } - // not dispute requester receives that from dispute agent + // Not-dispute-requester receives that msg from dispute agent protected void onPeerOpenedDisputeMessage(PeerOpenedDisputeMessage peerOpenedDisputeMessage) { T disputeList = getDisputeList(); if (disputeList == null) { @@ -320,14 +356,33 @@ protected void onPeerOpenedDisputeMessage(PeerOpenedDisputeMessage peerOpenedDis String errorMessage = null; Dispute dispute = peerOpenedDisputeMessage.getDispute(); + + Optional optionalTrade = tradeManager.getTradeById(dispute.getTradeId()); + if (!optionalTrade.isPresent()) { + return; + } + + Trade trade = optionalTrade.get(); + try { + DelayedPayoutTxValidation.validatePayoutTx(trade, + trade.getDelayedPayoutTx(), + dispute, + daoFacade, + btcWalletService); + } catch (DelayedPayoutTxValidation.ValidationException e) { + // The peer sent us an invalid donation address. We do not return here as we don't want to break + // mediation/arbitration and log only the issue. The dispute agent will run validation as well and will get + // a popup displayed to react. + log.error("Donation address invalid. {}", e.toString()); + } + if (!isAgent(dispute)) { if (!disputeList.contains(dispute)) { Optional storedDisputeOptional = findDispute(dispute); if (!storedDisputeOptional.isPresent()) { dispute.setStorage(disputeListService.getStorage()); disputeList.add(dispute); - Optional tradeOptional = tradeManager.getTradeById(dispute.getTradeId()); - tradeOptional.ifPresent(trade -> trade.setDisputeState(getDisputeState_StartedByPeer())); + trade.setDisputeState(getDisputeStateStartedByPeer()); errorMessage = null; } else { // valid case if both have opened a dispute and agent was not online. @@ -516,6 +571,7 @@ private void doSendPeerOpenedDisputeMessage(Dispute disputeFromOpener, disputeFromOpener.isSupportTicket(), disputeFromOpener.getSupportType()); dispute.setDelayedPayoutTxId(disputeFromOpener.getDelayedPayoutTxId()); + dispute.setDonationAddressOfDelayedPayoutTx(disputeFromOpener.getDonationAddressOfDelayedPayoutTx()); Optional storedDisputeOptional = findDispute(dispute); @@ -543,6 +599,9 @@ private void doSendPeerOpenedDisputeMessage(Dispute disputeFromOpener, addPriceInfoMessage(dispute, 0); + // Dispute agent sets uid to be sure to identify disputes uniquely to protect against replaying old disputes + dispute.setAgentsUid(UUID.randomUUID().toString()); + disputeList.add(dispute); // We mirrored dispute already! diff --git a/core/src/main/java/bisq/core/support/dispute/arbitration/ArbitrationManager.java b/core/src/main/java/bisq/core/support/dispute/arbitration/ArbitrationManager.java index e77daf2b048..2ddacf350f5 100644 --- a/core/src/main/java/bisq/core/support/dispute/arbitration/ArbitrationManager.java +++ b/core/src/main/java/bisq/core/support/dispute/arbitration/ArbitrationManager.java @@ -25,6 +25,7 @@ import bisq.core.btc.wallet.TradeWalletService; import bisq.core.btc.wallet.TxBroadcaster; import bisq.core.btc.wallet.WalletService; +import bisq.core.dao.DaoFacade; import bisq.core.locale.Res; import bisq.core.offer.OpenOffer; import bisq.core.offer.OpenOfferManager; @@ -88,11 +89,12 @@ public ArbitrationManager(P2PService p2PService, TradeManager tradeManager, ClosedTradableManager closedTradableManager, OpenOfferManager openOfferManager, + DaoFacade daoFacade, PubKeyRing pubKeyRing, ArbitrationDisputeListService arbitrationDisputeListService, PriceFeedService priceFeedService) { super(p2PService, tradeWalletService, walletService, walletsSetup, tradeManager, closedTradableManager, - openOfferManager, pubKeyRing, arbitrationDisputeListService, priceFeedService); + openOfferManager, daoFacade, pubKeyRing, arbitrationDisputeListService, priceFeedService); } @@ -134,7 +136,7 @@ public NodeAddress getAgentNodeAddress(Dispute dispute) { } @Override - protected Trade.DisputeState getDisputeState_StartedByPeer() { + protected Trade.DisputeState getDisputeStateStartedByPeer() { return Trade.DisputeState.DISPUTE_STARTED_BY_PEER; } diff --git a/core/src/main/java/bisq/core/support/dispute/mediation/MediationManager.java b/core/src/main/java/bisq/core/support/dispute/mediation/MediationManager.java index 42f9b37f5cf..afaf8a7cd1e 100644 --- a/core/src/main/java/bisq/core/support/dispute/mediation/MediationManager.java +++ b/core/src/main/java/bisq/core/support/dispute/mediation/MediationManager.java @@ -20,6 +20,7 @@ import bisq.core.btc.setup.WalletsSetup; import bisq.core.btc.wallet.BtcWalletService; import bisq.core.btc.wallet.TradeWalletService; +import bisq.core.dao.DaoFacade; import bisq.core.locale.Res; import bisq.core.offer.OpenOffer; import bisq.core.offer.OpenOfferManager; @@ -80,13 +81,15 @@ public MediationManager(P2PService p2PService, TradeManager tradeManager, ClosedTradableManager closedTradableManager, OpenOfferManager openOfferManager, + DaoFacade daoFacade, PubKeyRing pubKeyRing, MediationDisputeListService mediationDisputeListService, PriceFeedService priceFeedService) { super(p2PService, tradeWalletService, walletService, walletsSetup, tradeManager, closedTradableManager, - openOfferManager, pubKeyRing, mediationDisputeListService, priceFeedService); + openOfferManager, daoFacade, pubKeyRing, mediationDisputeListService, priceFeedService); } + /////////////////////////////////////////////////////////////////////////////////////////// // Implement template methods /////////////////////////////////////////////////////////////////////////////////////////// @@ -117,7 +120,7 @@ public void dispatchMessage(SupportMessage message) { } @Override - protected Trade.DisputeState getDisputeState_StartedByPeer() { + protected Trade.DisputeState getDisputeStateStartedByPeer() { return Trade.DisputeState.MEDIATION_STARTED_BY_PEER; } diff --git a/core/src/main/java/bisq/core/support/dispute/refund/RefundManager.java b/core/src/main/java/bisq/core/support/dispute/refund/RefundManager.java index f28208f050a..ab3008d8b10 100644 --- a/core/src/main/java/bisq/core/support/dispute/refund/RefundManager.java +++ b/core/src/main/java/bisq/core/support/dispute/refund/RefundManager.java @@ -20,6 +20,7 @@ import bisq.core.btc.setup.WalletsSetup; import bisq.core.btc.wallet.BtcWalletService; import bisq.core.btc.wallet.TradeWalletService; +import bisq.core.dao.DaoFacade; import bisq.core.locale.Res; import bisq.core.offer.OpenOffer; import bisq.core.offer.OpenOfferManager; @@ -74,13 +75,15 @@ public RefundManager(P2PService p2PService, TradeManager tradeManager, ClosedTradableManager closedTradableManager, OpenOfferManager openOfferManager, + DaoFacade daoFacade, PubKeyRing pubKeyRing, RefundDisputeListService refundDisputeListService, PriceFeedService priceFeedService) { super(p2PService, tradeWalletService, walletService, walletsSetup, tradeManager, closedTradableManager, - openOfferManager, pubKeyRing, refundDisputeListService, priceFeedService); + openOfferManager, daoFacade, pubKeyRing, refundDisputeListService, priceFeedService); } + /////////////////////////////////////////////////////////////////////////////////////////// // Implement template methods /////////////////////////////////////////////////////////////////////////////////////////// @@ -111,7 +114,7 @@ public void dispatchMessage(SupportMessage message) { } @Override - protected Trade.DisputeState getDisputeState_StartedByPeer() { + protected Trade.DisputeState getDisputeStateStartedByPeer() { return Trade.DisputeState.REFUND_REQUEST_STARTED_BY_PEER; } diff --git a/core/src/main/java/bisq/core/trade/DelayedPayoutTxValidation.java b/core/src/main/java/bisq/core/trade/DelayedPayoutTxValidation.java index 6c835d2408b..25eb0771208 100644 --- a/core/src/main/java/bisq/core/trade/DelayedPayoutTxValidation.java +++ b/core/src/main/java/bisq/core/trade/DelayedPayoutTxValidation.java @@ -19,10 +19,8 @@ import bisq.core.btc.wallet.BtcWalletService; import bisq.core.dao.DaoFacade; -import bisq.core.dao.governance.param.Param; import bisq.core.offer.Offer; - -import bisq.common.config.Config; +import bisq.core.support.dispute.Dispute; import org.bitcoinj.core.Address; import org.bitcoinj.core.Coin; @@ -32,62 +30,152 @@ import org.bitcoinj.core.TransactionOutPoint; import org.bitcoinj.core.TransactionOutput; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; +import javax.annotation.Nullable; + +import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; @Slf4j public class DelayedPayoutTxValidation { - public static class DonationAddressException extends Exception { - DonationAddressException(String msg) { - super(msg); - } + public static void validateDonationAddress(String addressAsString, DaoFacade daoFacade) + throws AddressException { + validateDonationAddress(null, addressAsString, daoFacade); } - public static class MissingDelayedPayoutTxException extends Exception { - MissingDelayedPayoutTxException(String msg) { - super(msg); - } - } + public static void validateDonationAddress(@Nullable Dispute dispute, String addressAsString, DaoFacade daoFacade) + throws AddressException { - public static class InvalidTxException extends Exception { - InvalidTxException(String msg) { - super(msg); + if (addressAsString == null) { + log.warn("address is null at validateDonationAddress. This is expected in case of an not updated trader."); + return; } - } - public static class AmountMismatchException extends Exception { - AmountMismatchException(String msg) { - super(msg); + Set allPastParamValues = daoFacade.getAllDonationAddresses(); + if (!allPastParamValues.contains(addressAsString)) { + String errorMsg = "Donation address is not a valid DAO donation address." + + "\nAddress used in the dispute: " + addressAsString + + "\nAll DAO param donation addresses:" + allPastParamValues; + log.error(errorMsg); + throw new AddressException(dispute, errorMsg); } } - public static class InvalidLockTimeException extends Exception { - InvalidLockTimeException(String msg) { - super(msg); + public static void testIfDisputeTriesReplay(Dispute disputeToTest, List disputeList) + throws DisputeReplayException { + try { + String disputeToTestDelayedPayoutTxId = disputeToTest.getDelayedPayoutTxId(); + checkNotNull(disputeToTestDelayedPayoutTxId, + "delayedPayoutTxId must not be null. Trade ID: " + disputeToTest.getTradeId()); + String disputeToTestAgentsUid = checkNotNull(disputeToTest.getAgentsUid(), + "agentsUid must not be null. Trade ID: " + disputeToTest.getTradeId()); + // This method can be called with the existing list and a new dispute (at opening a new dispute) or with the + // dispute already added (at close dispute). So we will consider that in the for loop. + // We have 2 disputes per trade (one per trader). + + Map> disputesPerTradeId = new HashMap<>(); + Map> disputesPerDelayedPayoutTxId = new HashMap<>(); + disputeList.forEach(dispute -> { + String tradeId = dispute.getTradeId(); + String agentsUid = dispute.getAgentsUid(); + + // We use an uid we have created not data delivered by the trader to protect against replay attacks + // If our dispute got already added to the list we ignore it. We will check once we build our maps + + disputesPerTradeId.putIfAbsent(tradeId, new HashSet<>()); + Set set = disputesPerTradeId.get(tradeId); + if (!disputeToTestAgentsUid.equals(agentsUid)) { + set.add(agentsUid); + } + + String delayedPayoutTxId = dispute.getDelayedPayoutTxId(); + disputesPerDelayedPayoutTxId.putIfAbsent(delayedPayoutTxId, new HashSet<>()); + set = disputesPerDelayedPayoutTxId.get(delayedPayoutTxId); + if (!disputeToTestAgentsUid.equals(agentsUid)) { + set.add(agentsUid); + } + }); + + String disputeToTestTradeId = disputeToTest.getTradeId(); + checkArgument(disputesPerTradeId.get(disputeToTestTradeId).size() <= 1, + "We found more then 2 disputes with the same trade ID. " + + "Trade ID: " + disputeToTest.getTradeId()); + checkArgument(disputesPerDelayedPayoutTxId.get(disputeToTestDelayedPayoutTxId).size() <= 1, + "We found more then 2 disputes with the same delayedPayoutTxId. " + + "Trade ID: " + disputeToTest.getTradeId()); + + } catch (IllegalArgumentException | NullPointerException e) { + throw new DisputeReplayException(disputeToTest, e.getMessage()); } } - public static class InvalidInputException extends Exception { - InvalidInputException(String msg) { - super(msg); - } + public static void validatePayoutTx(Trade trade, + Transaction delayedPayoutTx, + DaoFacade daoFacade, + BtcWalletService btcWalletService) + throws AddressException, MissingTxException, + InvalidTxException, InvalidLockTimeException, InvalidAmountException { + validatePayoutTx(trade, + delayedPayoutTx, + null, + daoFacade, + btcWalletService, + null); } public static void validatePayoutTx(Trade trade, Transaction delayedPayoutTx, + @Nullable Dispute dispute, DaoFacade daoFacade, BtcWalletService btcWalletService) - throws DonationAddressException, MissingDelayedPayoutTxException, - InvalidTxException, InvalidLockTimeException, AmountMismatchException { + throws AddressException, MissingTxException, + InvalidTxException, InvalidLockTimeException, InvalidAmountException { + validatePayoutTx(trade, + delayedPayoutTx, + dispute, + daoFacade, + btcWalletService, + null); + } + + public static void validatePayoutTx(Trade trade, + Transaction delayedPayoutTx, + DaoFacade daoFacade, + BtcWalletService btcWalletService, + @Nullable Consumer addressConsumer) + throws AddressException, MissingTxException, + InvalidTxException, InvalidLockTimeException, InvalidAmountException { + validatePayoutTx(trade, + delayedPayoutTx, + null, + daoFacade, + btcWalletService, + addressConsumer); + } + + public static void validatePayoutTx(Trade trade, + Transaction delayedPayoutTx, + @Nullable Dispute dispute, + DaoFacade daoFacade, + BtcWalletService btcWalletService, + @Nullable Consumer addressConsumer) + throws AddressException, MissingTxException, + InvalidTxException, InvalidLockTimeException, InvalidAmountException { String errorMsg; if (delayedPayoutTx == null) { errorMsg = "DelayedPayoutTx must not be null"; log.error(errorMsg); - throw new MissingDelayedPayoutTxException("DelayedPayoutTx must not be null"); + throw new MissingTxException("DelayedPayoutTx must not be null"); } // Validate tx structure @@ -135,60 +223,37 @@ public static void validatePayoutTx(Trade trade, errorMsg = "Output value of deposit tx and delayed payout tx is not matching. Output: " + output + " / msOutputAmount: " + msOutputAmount; log.error(errorMsg); log.error(delayedPayoutTx.toString()); - throw new AmountMismatchException(errorMsg); + throw new InvalidAmountException(errorMsg); } - - // Validate donation address - // Get most recent donation address. - // We do not support past DAO param addresses to avoid that those receive funds (no bond set up anymore). - // Users who have not synced the DAO cannot trade. - - NetworkParameters params = btcWalletService.getParams(); Address address = output.getAddressFromP2PKHScript(params); if (address == null) { - // The donation address can be as well be a multisig address. + // The donation address can be a multisig address as well. address = output.getAddressFromP2SH(params); if (address == null) { errorMsg = "Donation address cannot be resolved (not of type P2PKHScript or P2SH). Output: " + output; log.error(errorMsg); log.error(delayedPayoutTx.toString()); - throw new DonationAddressException(errorMsg); + throw new AddressException(dispute, errorMsg); } } String addressAsString = address.toString(); + if (addressConsumer != null) { + addressConsumer.accept(addressAsString); + } - // In case the seller has deactivated the DAO the default address will be used. - String defaultDonationAddressString = Param.RECIPIENT_BTC_ADDRESS.getDefaultValue(); - boolean defaultNotMatching = !defaultDonationAddressString.equals(addressAsString); - String recentDonationAddressString = daoFacade.getParamValue(Param.RECIPIENT_BTC_ADDRESS); - boolean recentFromDaoNotMatching = !recentDonationAddressString.equals(addressAsString); - - // If buyer has DAO deactivated or not synced he will not be able to see recent address used by the seller, so - // we add it hard coded here. We need to support also the default one as - // FIXME This is a quick fix and should be improved in future. - // We use the default addresses for non mainnet networks. For dev testing it need to be changed here. - // We use a list to gain more flexibility at updates of DAO param, but still might fail if buyer has not updated - // software. Needs a better solution.... - List hardCodedAddresses = Config.baseCurrencyNetwork().isMainnet() ? - List.of("3EtUWqsGThPtjwUczw27YCo6EWvQdaPUyp", "3A8Zc1XioE2HRzYfbb5P8iemCS72M6vRJV") : // mainnet - Config.baseCurrencyNetwork().isDaoBetaNet() ? List.of("1BVxNn3T12veSK6DgqwU4Hdn7QHcDDRag7") : // daoBetaNet - Config.baseCurrencyNetwork().isTestnet() ? List.of("2N4mVTpUZAnhm9phnxB7VrHB4aBhnWrcUrV") : // testnet - List.of("2MzBNTJDjjXgViKBGnatDU3yWkJ8pJkEg9w"); // regtest or DAO testnet (regtest) - - boolean noneOfHardCodedMatching = hardCodedAddresses.stream().noneMatch(e -> e.equals(addressAsString)); - - // If seller has DAO deactivated as well we get default address - if (recentFromDaoNotMatching && defaultNotMatching && noneOfHardCodedMatching) { - errorMsg = "Donation address is invalid." + - "\nAddress used by BTC seller: " + addressAsString + - "\nRecent donation address:" + recentDonationAddressString + - "\nDefault donation address: " + defaultDonationAddressString; - log.error(errorMsg); - log.error(delayedPayoutTx.toString()); - throw new DonationAddressException(errorMsg); + validateDonationAddress(addressAsString, daoFacade); + + if (dispute != null) { + // Verify that address in the dispute matches the one in the trade. + String donationAddressOfDelayedPayoutTx = dispute.getDonationAddressOfDelayedPayoutTx(); + // Old clients don't have it set yet. Can be removed after a forced update + if (donationAddressOfDelayedPayoutTx != null) { + checkArgument(addressAsString.equals(donationAddressOfDelayedPayoutTx), + "donationAddressOfDelayedPayoutTx from dispute does not match address from delayed payout tx"); + } } } @@ -206,4 +271,66 @@ public static void validatePayoutTxInput(Transaction depositTx, "Deposit tx=" + depositTx); } } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Exceptions + /////////////////////////////////////////////////////////////////////////////////////////// + + public static class ValidationException extends Exception { + @Nullable + @Getter + private final Dispute dispute; + + ValidationException(String msg) { + this(null, msg); + } + + ValidationException(@Nullable Dispute dispute, String msg) { + super(msg); + this.dispute = dispute; + } + } + + public static class AddressException extends ValidationException { + AddressException(@Nullable Dispute dispute, String msg) { + super(dispute, msg); + } + } + + public static class MissingTxException extends ValidationException { + MissingTxException(String msg) { + super(msg); + } + } + + public static class InvalidTxException extends ValidationException { + InvalidTxException(String msg) { + super(msg); + } + } + + public static class InvalidAmountException extends ValidationException { + InvalidAmountException(String msg) { + super(msg); + } + } + + public static class InvalidLockTimeException extends ValidationException { + InvalidLockTimeException(String msg) { + super(msg); + } + } + + public static class InvalidInputException extends ValidationException { + InvalidInputException(String msg) { + super(msg); + } + } + + public static class DisputeReplayException extends ValidationException { + DisputeReplayException(Dispute dispute, String msg) { + super(dispute, msg); + } + } } diff --git a/core/src/main/java/bisq/core/trade/Trade.java b/core/src/main/java/bisq/core/trade/Trade.java index a00fe2dca89..3242bd535ed 100644 --- a/core/src/main/java/bisq/core/trade/Trade.java +++ b/core/src/main/java/bisq/core/trade/Trade.java @@ -725,9 +725,19 @@ public void applyDelayedPayoutTxBytes(byte[] delayedPayoutTxBytes) { @Nullable public Transaction getDelayedPayoutTx() { if (delayedPayoutTx == null) { - delayedPayoutTx = delayedPayoutTxBytes != null && processModel.getBtcWalletService() != null ? - processModel.getBtcWalletService().getTxFromSerializedTx(delayedPayoutTxBytes) : - null; + BtcWalletService btcWalletService = processModel.getBtcWalletService(); + if (btcWalletService == null) { + log.warn("btcWalletService is null. You might call that method before the tradeManager has " + + "initialized all trades"); + return null; + } + + if (delayedPayoutTxBytes == null) { + log.warn("delayedPayoutTxBytes are null"); + return null; + } + + delayedPayoutTx = btcWalletService.getTxFromSerializedTx(delayedPayoutTxBytes); } return delayedPayoutTx; } diff --git a/core/src/main/java/bisq/core/trade/TradeManager.java b/core/src/main/java/bisq/core/trade/TradeManager.java index 66962f10d68..ea9bcdf2937 100644 --- a/core/src/main/java/bisq/core/trade/TradeManager.java +++ b/core/src/main/java/bisq/core/trade/TradeManager.java @@ -135,6 +135,7 @@ public class TradeManager implements PersistedDataHost { private final Storage> tradableListStorage; private TradableList tradableList; + @Getter private final BooleanProperty pendingTradesInitialized = new SimpleBooleanProperty(); private List tradesForStatistics; @Setter @@ -309,11 +310,7 @@ private void initPendingTrades() { trade.getDelayedPayoutTx(), daoFacade, btcWalletService); - } catch (DelayedPayoutTxValidation.DonationAddressException | - DelayedPayoutTxValidation.InvalidTxException | - DelayedPayoutTxValidation.InvalidLockTimeException | - DelayedPayoutTxValidation.MissingDelayedPayoutTxException | - DelayedPayoutTxValidation.AmountMismatchException e) { + } catch (DelayedPayoutTxValidation.ValidationException e) { log.warn("Delayed payout tx exception, trade {}, exception {}", trade.getId(), e.getMessage()); if (!allowFaultyDelayedTxs) { // We move it to failed trades so it cannot be continued. diff --git a/core/src/main/java/bisq/core/trade/protocol/tasks/buyer/BuyerVerifiesFinalDelayedPayoutTx.java b/core/src/main/java/bisq/core/trade/protocol/tasks/buyer/BuyerVerifiesFinalDelayedPayoutTx.java index fa1aeacdbe6..3aaf36a39d9 100644 --- a/core/src/main/java/bisq/core/trade/protocol/tasks/buyer/BuyerVerifiesFinalDelayedPayoutTx.java +++ b/core/src/main/java/bisq/core/trade/protocol/tasks/buyer/BuyerVerifiesFinalDelayedPayoutTx.java @@ -55,12 +55,7 @@ protected void run() { DelayedPayoutTxValidation.validatePayoutTxInput(depositTx, delayedPayoutTx); complete(); - } catch (DelayedPayoutTxValidation.DonationAddressException | - DelayedPayoutTxValidation.MissingDelayedPayoutTxException | - DelayedPayoutTxValidation.InvalidTxException | - DelayedPayoutTxValidation.InvalidLockTimeException | - DelayedPayoutTxValidation.AmountMismatchException | - DelayedPayoutTxValidation.InvalidInputException e) { + } catch (DelayedPayoutTxValidation.ValidationException e) { failed(e.getMessage()); } catch (Throwable t) { failed(t); diff --git a/core/src/main/java/bisq/core/trade/protocol/tasks/buyer/BuyerVerifiesPreparedDelayedPayoutTx.java b/core/src/main/java/bisq/core/trade/protocol/tasks/buyer/BuyerVerifiesPreparedDelayedPayoutTx.java index 3242ba8cf6a..7853767d276 100644 --- a/core/src/main/java/bisq/core/trade/protocol/tasks/buyer/BuyerVerifiesPreparedDelayedPayoutTx.java +++ b/core/src/main/java/bisq/core/trade/protocol/tasks/buyer/BuyerVerifiesPreparedDelayedPayoutTx.java @@ -43,11 +43,7 @@ protected void run() { processModel.getBtcWalletService()); complete(); - } catch (DelayedPayoutTxValidation.DonationAddressException | - DelayedPayoutTxValidation.MissingDelayedPayoutTxException | - DelayedPayoutTxValidation.InvalidTxException | - DelayedPayoutTxValidation.InvalidLockTimeException | - DelayedPayoutTxValidation.AmountMismatchException e) { + } catch (DelayedPayoutTxValidation.ValidationException e) { failed(e.getMessage()); } catch (Throwable t) { failed(t); diff --git a/core/src/main/resources/i18n/displayStrings.properties b/core/src/main/resources/i18n/displayStrings.properties index a36cef1a568..33cd73538a7 100644 --- a/core/src/main/resources/i18n/displayStrings.properties +++ b/core/src/main/resources/i18n/displayStrings.properties @@ -214,7 +214,8 @@ shared.mediator=Mediator shared.arbitrator=Arbitrator shared.refundAgent=Arbitrator shared.refundAgentForSupportStaff=Refund agent -shared.delayedPayoutTxId=Refund collateral transaction ID +shared.delayedPayoutTxId=Delayed payout transaction ID +shared.delayedPayoutTxReceiverAddress=Delayed payout transaction sent to shared.unconfirmedTransactionsLimitReached=You have too many unconfirmed transactions at the moment. Please try again later. @@ -1080,6 +1081,15 @@ support.peerOpenedDispute=Your trading peer has requested a dispute.\n\n{0}\n\nB support.peerOpenedDisputeForMediation=Your trading peer has requested mediation.\n\n{0}\n\nBisq version: {1} support.mediatorsDisputeSummary=System message: Mediator''s dispute summary:\n{0} support.mediatorsAddress=Mediator''s node address: {0} +support.warning.disputesWithInvalidDonationAddress=The delayed payout transaction has used an invalid receiver address. \ + It does not match any of the DAO parameter values for the valid donation addresses.\n\nThis might be a scam attempt. \ + Please inform the developers about that incident and do not close that case before the situation is resolved!\n\n\ + Address used in the dispute: {0}\n\n\ + All DAO param donation addresses: {1}\n\n\ + Trade ID: {2}\ + {3} +support.warning.disputesWithInvalidDonationAddress.mediator=\n\nDo you still want to close the dispute? +support.warning.disputesWithInvalidDonationAddress.refundAgent=\n\nYou must not do the payout. #################################################################### diff --git a/desktop/src/main/java/bisq/desktop/main/offer/MutableOfferViewModel.java b/desktop/src/main/java/bisq/desktop/main/offer/MutableOfferViewModel.java index 989d063b25e..588c06cb564 100644 --- a/desktop/src/main/java/bisq/desktop/main/offer/MutableOfferViewModel.java +++ b/desktop/src/main/java/bisq/desktop/main/offer/MutableOfferViewModel.java @@ -231,7 +231,7 @@ public void activate() { if (DevEnv.isDevMode()) { UserThread.runAfter(() -> { amount.set("0.001"); - price.set("0.008"); + price.set("70000"); minAmount.set(amount.get()); onFocusOutPriceAsPercentageTextField(true, false); applyMakerFee(); diff --git a/desktop/src/main/java/bisq/desktop/main/overlays/windows/ContractWindow.java b/desktop/src/main/java/bisq/desktop/main/overlays/windows/ContractWindow.java index ff053d90338..ec8e10ede0b 100644 --- a/desktop/src/main/java/bisq/desktop/main/overlays/windows/ContractWindow.java +++ b/desktop/src/main/java/bisq/desktop/main/overlays/windows/ContractWindow.java @@ -141,6 +141,8 @@ private void addContent() { rows++; if (dispute.getDelayedPayoutTxId() != null) rows++; + if (dispute.getDonationAddressOfDelayedPayoutTx() != null) + rows++; if (showAcceptedCountryCodes) rows++; if (showAcceptedBanks) @@ -248,6 +250,11 @@ private void addContent() { if (dispute.getDelayedPayoutTxId() != null) addLabelTxIdTextField(gridPane, ++rowIndex, Res.get("shared.delayedPayoutTxId"), dispute.getDelayedPayoutTxId()); + if (dispute.getDonationAddressOfDelayedPayoutTx() != null) { + addLabelTxIdTextField(gridPane, ++rowIndex, Res.get("shared.delayedPayoutTxReceiverAddress"), + dispute.getDonationAddressOfDelayedPayoutTx()); + } + if (dispute.getPayoutTxSerialized() != null) addLabelTxIdTextField(gridPane, ++rowIndex, Res.get("shared.payoutTxId"), dispute.getPayoutTxId()); diff --git a/desktop/src/main/java/bisq/desktop/main/overlays/windows/DisputeSummaryWindow.java b/desktop/src/main/java/bisq/desktop/main/overlays/windows/DisputeSummaryWindow.java index 6202b6b2042..2fd0b33e8e2 100644 --- a/desktop/src/main/java/bisq/desktop/main/overlays/windows/DisputeSummaryWindow.java +++ b/desktop/src/main/java/bisq/desktop/main/overlays/windows/DisputeSummaryWindow.java @@ -34,6 +34,7 @@ import bisq.core.btc.wallet.Restrictions; import bisq.core.btc.wallet.TradeWalletService; import bisq.core.btc.wallet.TxBroadcaster; +import bisq.core.dao.DaoFacade; import bisq.core.locale.Res; import bisq.core.offer.Offer; import bisq.core.provider.fee.FeeService; @@ -45,6 +46,7 @@ import bisq.core.support.dispute.mediation.MediationManager; import bisq.core.support.dispute.refund.RefundManager; import bisq.core.trade.Contract; +import bisq.core.trade.DelayedPayoutTxValidation; import bisq.core.util.FormattingUtils; import bisq.core.util.ParsingUtils; import bisq.core.util.coin.CoinFormatter; @@ -105,6 +107,7 @@ public class DisputeSummaryWindow extends Overlay { private final BtcWalletService btcWalletService; private final TxFeeEstimationService txFeeEstimationService; private final FeeService feeService; + private final DaoFacade daoFacade; private Dispute dispute; private Optional finalizeDisputeHandlerOptional = Optional.empty(); private ToggleGroup tradeAmountToggleGroup, reasonToggleGroup; @@ -141,7 +144,8 @@ public DisputeSummaryWindow(@Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormat TradeWalletService tradeWalletService, BtcWalletService btcWalletService, TxFeeEstimationService txFeeEstimationService, - FeeService feeService) { + FeeService feeService, + DaoFacade daoFacade) { this.formatter = formatter; this.mediationManager = mediationManager; @@ -150,6 +154,7 @@ public DisputeSummaryWindow(@Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormat this.btcWalletService = btcWalletService; this.txFeeEstimationService = txFeeEstimationService; this.feeService = feeService; + this.daoFacade = daoFacade; type = Type.Confirmation; } @@ -642,15 +647,13 @@ private void addButtons(Contract contract) { log.warn("dispute.getDepositTxSerialized is null"); return; } + if (dispute.getSupportType() == SupportType.REFUND && peersDisputeOptional.isPresent() && !peersDisputeOptional.get().isClosed()) { - showPayoutTxConfirmation(contract, disputeResult, - () -> { - doClose(closeTicketButton); - }); + showPayoutTxConfirmation(contract, disputeResult, () -> doCloseIfValid(closeTicketButton)); } else { - doClose(closeTicketButton); + doCloseIfValid(closeTicketButton); } }); @@ -731,7 +734,6 @@ public void onSuccess(Transaction transaction) { public void onFailure(TxBroadcastException exception) { log.error("TxBroadcastException at doPayout", exception); new Popup().error(exception.toString()).show(); - ; } }); } catch (InsufficientMoneyException | WalletException | TransactionVerificationException e) { @@ -740,6 +742,58 @@ public void onFailure(TxBroadcastException exception) { } } + private void doCloseIfValid(Button closeTicketButton) { + var disputeManager = checkNotNull(getDisputeManager(dispute)); + try { + DelayedPayoutTxValidation.validateDonationAddress(dispute.getDonationAddressOfDelayedPayoutTx(), daoFacade); + DelayedPayoutTxValidation.testIfDisputeTriesReplay(dispute, disputeManager.getDisputesAsObservableList()); + doClose(closeTicketButton); + } catch (DelayedPayoutTxValidation.AddressException exception) { + String addressAsString = dispute.getDonationAddressOfDelayedPayoutTx(); + String tradeId = dispute.getTradeId(); + + // For mediators we do not enforce that the case cannot be closed to stay flexible, + // but for refund agents we do. + if (disputeManager instanceof MediationManager) { + new Popup().width(900) + .warning(Res.get("support.warning.disputesWithInvalidDonationAddress", + addressAsString, + daoFacade.getAllDonationAddresses(), + tradeId, + Res.get("support.warning.disputesWithInvalidDonationAddress.mediator"))) + .onAction(() -> { + doClose(closeTicketButton); + }) + .actionButtonText(Res.get("shared.yes")) + .closeButtonText(Res.get("shared.no")) + .show(); + } else { + new Popup().width(900) + .warning(Res.get("support.warning.disputesWithInvalidDonationAddress", + addressAsString, + daoFacade.getAllDonationAddresses(), + tradeId, + Res.get("support.warning.disputesWithInvalidDonationAddress.refundAgent"))) + .show(); + } + } catch (DelayedPayoutTxValidation.DisputeReplayException exception) { + if (disputeManager instanceof MediationManager) { + new Popup().width(900) + .warning(exception.getMessage()) + .onAction(() -> { + doClose(closeTicketButton); + }) + .actionButtonText(Res.get("shared.yes")) + .closeButtonText(Res.get("shared.no")) + .show(); + } else { + new Popup().width(900) + .warning(exception.getMessage()) + .show(); + } + } + } + private void doClose(Button closeTicketButton) { disputeResult.setLoserPublisher(isLoserPublisherCheckBox.isSelected()); disputeResult.setCloseDate(new Date()); 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 6d1a288f118..3da51c70c78 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 @@ -47,6 +47,7 @@ import bisq.core.support.messages.ChatMessage; import bisq.core.support.traderchat.TraderChatManager; import bisq.core.trade.BuyerTrade; +import bisq.core.trade.DelayedPayoutTxValidation; import bisq.core.trade.SellerTrade; import bisq.core.trade.Trade; import bisq.core.trade.TradeManager; @@ -82,6 +83,7 @@ import org.spongycastle.crypto.params.KeyParameter; import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; import lombok.Getter; @@ -536,6 +538,28 @@ private void doOpenDispute(boolean isSupportTicket, Transaction depositTx) { // In case we re-open a dispute we allow Trade.DisputeState.REFUND_REQUESTED useRefundAgent = disputeState == Trade.DisputeState.MEDIATION_CLOSED || disputeState == Trade.DisputeState.REFUND_REQUESTED; + AtomicReference donationAddressString = new AtomicReference<>(""); + Transaction delayedPayoutTx = trade.getDelayedPayoutTx(); + try { + DelayedPayoutTxValidation.validatePayoutTx(trade, + delayedPayoutTx, + daoFacade, + btcWalletService, + donationAddressString::set); + } catch (DelayedPayoutTxValidation.ValidationException e) { + // The peer sent us an invalid donation address. We do not return here as we don't want to break + // mediation/arbitration and log only the issue. The dispute agent will run validation as well and will get + // a popup displayed to react. + log.error("DelayedPayoutTxValidation failed. {}", e.toString()); + + if (useRefundAgent) { + // We don't allow to continue and publish payout tx and open refund agent case. + // In case it was caused by some bug we want to prevent a wrong payout. In case its a scam attempt we + // want to protect the refund agent. + return; + } + } + ResultHandler resultHandler; if (useMediation) { // If no dispute state set we start with mediation @@ -564,6 +588,11 @@ private void doOpenDispute(boolean isSupportTicket, Transaction depositTx) { isSupportTicket, SupportType.MEDIATION); + dispute.setDonationAddressOfDelayedPayoutTx(donationAddressString.get()); + if (delayedPayoutTx != null) { + dispute.setDelayedPayoutTxId(delayedPayoutTx.getHashAsString()); + } + trade.setDisputeState(Trade.DisputeState.MEDIATION_REQUESTED); disputeManager.sendOpenNewDisputeMessage(dispute, false, @@ -588,7 +617,7 @@ private void doOpenDispute(boolean isSupportTicket, Transaction depositTx) { } else if (useRefundAgent) { resultHandler = () -> navigation.navigateTo(MainView.class, SupportView.class, RefundClientView.class); - if (trade.getDelayedPayoutTx() == null) { + if (delayedPayoutTx == null) { log.error("Delayed payout tx is missing"); return; } @@ -603,13 +632,12 @@ private void doOpenDispute(boolean isSupportTicket, Transaction depositTx) { return; } - long lockTime = trade.getDelayedPayoutTx().getLockTime(); + long lockTime = delayedPayoutTx.getLockTime(); int bestChainHeight = btcWalletService.getBestChainHeight(); long remaining = lockTime - bestChainHeight; if (remaining > 0) { - new Popup() - .instruction(Res.get("portfolio.pending.timeLockNotOver", - FormattingUtils.getDateFromBlockHeight(remaining), remaining)) + new Popup().instruction(Res.get("portfolio.pending.timeLockNotOver", + FormattingUtils.getDateFromBlockHeight(remaining), remaining)) .show(); return; } @@ -639,6 +667,9 @@ private void doOpenDispute(boolean isSupportTicket, Transaction depositTx) { isSupportTicket, SupportType.REFUND); + dispute.setDonationAddressOfDelayedPayoutTx(donationAddressString.get()); + dispute.setDelayedPayoutTxId(delayedPayoutTx.getHashAsString()); + String tradeId = dispute.getTradeId(); mediationManager.findDispute(tradeId) .ifPresent(mediatorsDispute -> { @@ -651,9 +682,6 @@ private void doOpenDispute(boolean isSupportTicket, Transaction depositTx) { dispute.setMediatorsDisputeResult(message); } }); - - dispute.setDelayedPayoutTxId(trade.getDelayedPayoutTx().getHashAsString()); - trade.setDisputeState(Trade.DisputeState.REFUND_REQUESTED); //todo add UI spinner as it can take a bit if peer is offline diff --git a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/buyer/BuyerStep1View.java b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/buyer/BuyerStep1View.java index 184dcd038f9..716d6bae04b 100644 --- a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/buyer/BuyerStep1View.java +++ b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/buyer/BuyerStep1View.java @@ -24,7 +24,13 @@ import bisq.core.locale.Res; import bisq.core.trade.DelayedPayoutTxValidation; +import bisq.common.UserThread; + +import javafx.beans.property.BooleanProperty; +import javafx.beans.value.ChangeListener; + public class BuyerStep1View extends TradeStepView { + private ChangeListener pendingTradesInitializedListener; /////////////////////////////////////////////////////////////////////////////////////////// // Constructor, Initialisation @@ -38,21 +44,18 @@ public BuyerStep1View(PendingTradesViewModel model) { public void activate() { super.activate(); - try { - DelayedPayoutTxValidation.validatePayoutTx(trade, - trade.getDelayedPayoutTx(), - model.dataModel.daoFacade, - model.dataModel.btcWalletService); - } catch (DelayedPayoutTxValidation.DonationAddressException | - DelayedPayoutTxValidation.InvalidTxException | - DelayedPayoutTxValidation.AmountMismatchException | - DelayedPayoutTxValidation.InvalidLockTimeException e) { - if (!model.dataModel.tradeManager.isAllowFaultyDelayedTxs()) { - new Popup().warning(Res.get("portfolio.pending.invalidDelayedPayoutTx", e.getMessage())).show(); - } - } catch (DelayedPayoutTxValidation.MissingDelayedPayoutTxException ignore) { - // We don't react on those errors as a failed trade might get listed initially but getting removed from the - // trade manager after initPendingTrades which happens after activate might be called. + // We need to have the trades initialized before we can call validatePayoutTx. + BooleanProperty pendingTradesInitialized = model.dataModel.tradeManager.getPendingTradesInitialized(); + if (pendingTradesInitialized.get()) { + validatePayoutTx(); + } else { + pendingTradesInitializedListener = (observable, oldValue, newValue) -> { + if (newValue) { + validatePayoutTx(); + UserThread.execute(() -> pendingTradesInitialized.removeListener(pendingTradesInitializedListener)); + } + }; + pendingTradesInitialized.addListener(pendingTradesInitializedListener); } } @@ -88,6 +91,29 @@ protected String getFirstHalfOverWarnText() { protected String getPeriodOverWarnText() { return Res.get("portfolio.pending.step1.openForDispute"); } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Private + /////////////////////////////////////////////////////////////////////////////////////////// + + private void validatePayoutTx() { + try { + DelayedPayoutTxValidation.validatePayoutTx(trade, + trade.getDelayedPayoutTx(), + model.dataModel.daoFacade, + model.dataModel.btcWalletService); + } catch (DelayedPayoutTxValidation.MissingTxException ignore) { + // We don't react on those errors as a failed trade might get listed initially but getting removed from the + // trade manager after initPendingTrades which happens after activate might be called. + log.error(""); + } catch (DelayedPayoutTxValidation.ValidationException e) { + if (!model.dataModel.tradeManager.isAllowFaultyDelayedTxs()) { + new Popup().warning(Res.get("portfolio.pending.invalidDelayedPayoutTx", e.getMessage())).show(); + } + } + } + } diff --git a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/buyer/BuyerStep2View.java b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/buyer/BuyerStep2View.java index d6d2aeabe33..6096e0cf3d2 100644 --- a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/buyer/BuyerStep2View.java +++ b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/buyer/BuyerStep2View.java @@ -88,6 +88,9 @@ import org.fxmisc.easybind.EasyBind; import org.fxmisc.easybind.Subscription; +import javafx.beans.property.BooleanProperty; +import javafx.beans.value.ChangeListener; + import java.util.List; import java.util.concurrent.TimeUnit; @@ -104,6 +107,7 @@ public class BuyerStep2View extends TradeStepView { private BusyAnimation busyAnimation; private Subscription tradeStatePropertySubscription; private Timer timeoutTimer; + private ChangeListener pendingTradesInitializedListener; /////////////////////////////////////////////////////////////////////////////////////////// // Constructor, Initialisation @@ -117,21 +121,18 @@ public BuyerStep2View(PendingTradesViewModel model) { public void activate() { super.activate(); - try { - DelayedPayoutTxValidation.validatePayoutTx(trade, - trade.getDelayedPayoutTx(), - model.dataModel.daoFacade, - model.dataModel.btcWalletService); - } catch (DelayedPayoutTxValidation.DonationAddressException | - DelayedPayoutTxValidation.InvalidTxException | - DelayedPayoutTxValidation.AmountMismatchException | - DelayedPayoutTxValidation.InvalidLockTimeException e) { - if (!model.dataModel.tradeManager.isAllowFaultyDelayedTxs()) { - new Popup().warning(Res.get("portfolio.pending.invalidDelayedPayoutTx", e.getMessage())).show(); - } - } catch (DelayedPayoutTxValidation.MissingDelayedPayoutTxException ignore) { - // We don't react on those errors as a failed trade might get listed initially but getting removed from the - // trade manager after initPendingTrades which happens after activate might be called. + // We need to have the trades initialized before we can call validatePayoutTx. + BooleanProperty pendingTradesInitialized = model.dataModel.tradeManager.getPendingTradesInitialized(); + if (pendingTradesInitialized.get()) { + validatePayoutTx(); + } else { + pendingTradesInitializedListener = (observable, oldValue, newValue) -> { + if (newValue) { + validatePayoutTx(); + UserThread.execute(() -> pendingTradesInitialized.removeListener(pendingTradesInitializedListener)); + } + }; + pendingTradesInitialized.addListener(pendingTradesInitializedListener); } if (timeoutTimer != null) @@ -632,6 +633,23 @@ private void showPopup() { } } + private void validatePayoutTx() { + try { + DelayedPayoutTxValidation.validatePayoutTx(trade, + trade.getDelayedPayoutTx(), + model.dataModel.daoFacade, + model.dataModel.btcWalletService); + } catch (DelayedPayoutTxValidation.MissingTxException ignore) { + // We don't react on those errors as a failed trade might get listed initially but getting removed from the + // trade manager after initPendingTrades which happens after activate might be called. + log.error(""); + } catch (DelayedPayoutTxValidation.ValidationException e) { + if (!model.dataModel.tradeManager.isAllowFaultyDelayedTxs()) { + new Popup().warning(Res.get("portfolio.pending.invalidDelayedPayoutTx", e.getMessage())).show(); + } + } + } + @Override protected void updateConfirmButtonDisableState(boolean isDisabled) { confirmButton.setDisable(isDisabled); diff --git a/desktop/src/main/java/bisq/desktop/main/support/dispute/DisputeView.java b/desktop/src/main/java/bisq/desktop/main/support/dispute/DisputeView.java index fcba3c3b652..4c4e06f5d93 100644 --- a/desktop/src/main/java/bisq/desktop/main/support/dispute/DisputeView.java +++ b/desktop/src/main/java/bisq/desktop/main/support/dispute/DisputeView.java @@ -89,6 +89,7 @@ import javafx.collections.transformation.SortedList; import javafx.util.Callback; +import javafx.util.Duration; import java.text.DateFormat; import java.text.ParseException; @@ -102,6 +103,7 @@ import java.util.Map; import java.util.Optional; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import lombok.Getter; @@ -110,6 +112,29 @@ import static bisq.desktop.util.FormBuilder.getIconForLabel; public abstract class DisputeView extends ActivatableView { + public enum FilterResult { + NO_MATCH("No Match"), + NO_FILTER("No filter text"), + OPEN_DISPUTES("Open disputes"), + TRADE_ID("Trade ID"), + OPENING_DATE("Opening date"), + BUYER_NODE_ADDRESS("Buyer node address"), + SELLER_NODE_ADDRESS("Seller node address"), + BUYER_ACCOUNT_DETAILS("Buyer account details"), + SELLER_ACCOUNT_DETAILS("Seller account details"), + DEPOSIT_TX("Deposit tx ID"), + PAYOUT_TX("Payout tx ID"), + DEL_PAYOUT_TX("Delayed payout tx ID"), + JSON("Contract as json"); + + @Getter + private final String displayString; + + FilterResult(String displayString) { + + this.displayString = displayString; + } + } protected final DisputeManager> disputeManager; protected final KeyRing keyRing; @@ -177,6 +202,10 @@ public void initialize() { HBox.setHgrow(label, Priority.NEVER); filterTextField = new InputTextField(); + Tooltip tooltip = new Tooltip(); + tooltip.setShowDelay(Duration.millis(100)); + tooltip.setShowDuration(Duration.seconds(10)); + filterTextField.setTooltip(tooltip); filterTextFieldListener = (observable, oldValue, newValue) -> applyFilteredListPredicate(filterTextField.getText()); HBox.setHgrow(filterTextField, Priority.NEVER); @@ -385,10 +414,77 @@ protected void deactivateReOpenDisputeListener() { protected abstract DisputeSession getConcreteDisputeChatSession(Dispute dispute); protected void applyFilteredListPredicate(String filterString) { - // If in trader view we must not display arbitrators own disputes as trader (must not happen anyway) - filteredList.setPredicate(dispute -> !dispute.getAgentPubKeyRing().equals(keyRing.getPubKeyRing())); + AtomicReference filterResult = new AtomicReference<>(FilterResult.NO_FILTER); + filteredList.setPredicate(dispute -> { + FilterResult filterResult1 = getFilterResult(dispute, filterString); + filterResult.set(filterResult1); + boolean b = filterResult.get() != FilterResult.NO_MATCH; + log.error("filterResult1 {} {} {}, {}", filterResult1, dispute.getTraderId(), b, filterResult); + return b; + }); + + if (filterResult.get() == FilterResult.NO_MATCH) { + filterTextField.getTooltip().setText("No matches found"); + } else if (filterResult.get() == FilterResult.NO_FILTER) { + filterTextField.getTooltip().setText("No filter applied"); + } else if (filterResult.get() == FilterResult.OPEN_DISPUTES) { + filterTextField.getTooltip().setText("Show all open disputes"); + } else { + filterTextField.getTooltip().setText("Data matching filter string: " + filterResult.get().getDisplayString()); + } + } + + protected FilterResult getFilterResult(Dispute dispute, String filterString) { + if (filterString.isEmpty()) { + return FilterResult.NO_FILTER; + } + if (!dispute.isClosed() && filterString.toLowerCase().equals("open")) { + return FilterResult.OPEN_DISPUTES; + } + + if (dispute.getTradeId().contains(filterString)) { + return FilterResult.TRADE_ID; + } + + if (DisplayUtils.formatDate(dispute.getOpeningDate()).contains(filterString)) { + return FilterResult.OPENING_DATE; + } + + if (dispute.getContract().getBuyerNodeAddress().getFullAddress().contains(filterString)) { + return FilterResult.BUYER_NODE_ADDRESS; + } + + if (dispute.getContract().getSellerNodeAddress().getFullAddress().contains(filterString)) { + return FilterResult.SELLER_NODE_ADDRESS; + } + + if (dispute.getContract().getBuyerPaymentAccountPayload().getPaymentDetails().contains(filterString)) { + return FilterResult.BUYER_ACCOUNT_DETAILS; + } + + if (dispute.getContract().getSellerPaymentAccountPayload().getPaymentDetails().contains(filterString)) { + return FilterResult.SELLER_ACCOUNT_DETAILS; + } + + if (dispute.getDepositTxId() != null && dispute.getDepositTxId().contains(filterString)) { + return FilterResult.DEPOSIT_TX; + } + if (dispute.getPayoutTxId() != null && dispute.getPayoutTxId().contains(filterString)) { + return FilterResult.PAYOUT_TX; + } + + if (dispute.getDelayedPayoutTxId() != null && dispute.getDelayedPayoutTxId().contains(filterString)) { + return FilterResult.DEL_PAYOUT_TX; + } + + if (dispute.getContractAsJson().contains(filterString)) { + return FilterResult.JSON; + } + + return FilterResult.NO_MATCH; } + protected void reOpenDisputeFromButton() { reOpenDispute(); } @@ -412,16 +508,6 @@ protected void reOpenDispute() { } } - protected boolean anyMatchOfFilterString(Dispute dispute, String filterString) { - boolean matchesTradeId = dispute.getTradeId().contains(filterString); - boolean matchesDate = DisplayUtils.formatDate(dispute.getOpeningDate()).contains(filterString); - boolean isBuyerOnion = dispute.getContract().getBuyerNodeAddress().getFullAddress().contains(filterString); - boolean isSellerOnion = dispute.getContract().getSellerNodeAddress().getFullAddress().contains(filterString); - boolean matchesBuyersPaymentAccountData = dispute.getContract().getBuyerPaymentAccountPayload().getPaymentDetails().contains(filterString); - boolean matchesSellersPaymentAccountData = dispute.getContract().getSellerPaymentAccountPayload().getPaymentDetails().contains(filterString); - return matchesTradeId || matchesDate || isBuyerOnion || isSellerOnion || - matchesBuyersPaymentAccountData || matchesSellersPaymentAccountData; - } /////////////////////////////////////////////////////////////////////////////////////////// // UI actions diff --git a/desktop/src/main/java/bisq/desktop/main/support/dispute/agent/DisputeAgentView.java b/desktop/src/main/java/bisq/desktop/main/support/dispute/agent/DisputeAgentView.java index 13e52608678..b2802c28b43 100644 --- a/desktop/src/main/java/bisq/desktop/main/support/dispute/agent/DisputeAgentView.java +++ b/desktop/src/main/java/bisq/desktop/main/support/dispute/agent/DisputeAgentView.java @@ -27,12 +27,14 @@ import bisq.core.account.witness.AccountAgeWitnessService; import bisq.core.alert.PrivateNotificationManager; +import bisq.core.dao.DaoFacade; import bisq.core.locale.Res; import bisq.core.support.dispute.Dispute; import bisq.core.support.dispute.DisputeList; import bisq.core.support.dispute.DisputeManager; import bisq.core.support.dispute.DisputeSession; import bisq.core.support.dispute.agent.MultipleHolderNameDetection; +import bisq.core.trade.DelayedPayoutTxValidation; import bisq.core.trade.TradeManager; import bisq.core.user.DontShowAgainLookup; import bisq.core.util.coin.CoinFormatter; @@ -54,13 +56,18 @@ import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.collections.ListChangeListener; + import java.util.List; +import static bisq.core.trade.DelayedPayoutTxValidation.ValidationException; import static bisq.desktop.util.FormBuilder.getIconForLabel; public abstract class DisputeAgentView extends DisputeView implements MultipleHolderNameDetection.Listener { private final MultipleHolderNameDetection multipleHolderNameDetection; + private final DaoFacade daoFacade; + private ListChangeListener validationExceptionListener; public DisputeAgentView(DisputeManager> disputeManager, KeyRing keyRing, @@ -71,6 +78,7 @@ public DisputeAgentView(DisputeManager { + c.next(); + if (c.wasAdded()) { + showWarningForValidationExceptions(c.getAddedSubList()); + } + }; + } + + protected void showWarningForValidationExceptions(List exceptions) { + exceptions.stream() + .filter(ex -> ex.getDispute() != null) + .filter(ex -> !ex.getDispute().isClosed()) + .forEach(ex -> { + Dispute dispute = ex.getDispute(); + if (ex instanceof DelayedPayoutTxValidation.AddressException) { + new Popup().width(900).warning(Res.get("support.warning.disputesWithInvalidDonationAddress", + dispute.getDonationAddressOfDelayedPayoutTx(), + daoFacade.getAllDonationAddresses(), + dispute.getTradeId(), + "")) + .show(); + } else { + new Popup().width(900).warning(ex.getMessage()).show(); + } + }); } @Override @@ -117,6 +152,9 @@ protected void activate() { if (multipleHolderNameDetection.hasSuspiciousDisputesDetected()) { suspiciousDisputeDetected(); } + + disputeManager.getValidationExceptions().addListener(validationExceptionListener); + showWarningForValidationExceptions(disputeManager.getValidationExceptions()); } @Override @@ -124,6 +162,8 @@ protected void deactivate() { super.deactivate(); multipleHolderNameDetection.removeListener(this); + + disputeManager.getValidationExceptions().removeListener(validationExceptionListener); } @@ -142,17 +182,13 @@ public void onSuspiciousDisputeDetected() { /////////////////////////////////////////////////////////////////////////////////////////// @Override - protected void applyFilteredListPredicate(String filterString) { - filteredList.setPredicate(dispute -> { - // If in arbitrator view we must only display disputes where we are selected as arbitrator (must not receive others anyway) - if (!dispute.getAgentPubKeyRing().equals(keyRing.getPubKeyRing())) { - return false; - } - boolean isOpen = !dispute.isClosed() && filterString.toLowerCase().equals("open"); - return filterString.isEmpty() || - isOpen || - anyMatchOfFilterString(dispute, filterString); - }); + protected DisputeView.FilterResult getFilterResult(Dispute dispute, String filterString) { + // If in arbitrator view we must only display disputes where we are selected as arbitrator (must not receive others anyway) + if (!dispute.getAgentPubKeyRing().equals(keyRing.getPubKeyRing())) { + return FilterResult.NO_MATCH; + } + + return super.getFilterResult(dispute, filterString); } @Override diff --git a/desktop/src/main/java/bisq/desktop/main/support/dispute/agent/arbitration/ArbitratorView.java b/desktop/src/main/java/bisq/desktop/main/support/dispute/agent/arbitration/ArbitratorView.java index dcb33c61124..7c2278de28c 100644 --- a/desktop/src/main/java/bisq/desktop/main/support/dispute/agent/arbitration/ArbitratorView.java +++ b/desktop/src/main/java/bisq/desktop/main/support/dispute/agent/arbitration/ArbitratorView.java @@ -25,6 +25,7 @@ import bisq.core.account.witness.AccountAgeWitnessService; import bisq.core.alert.PrivateNotificationManager; +import bisq.core.dao.DaoFacade; import bisq.core.support.SupportType; import bisq.core.support.dispute.Dispute; import bisq.core.support.dispute.DisputeSession; @@ -53,6 +54,7 @@ public ArbitratorView(ArbitrationManager arbitrationManager, ContractWindow contractWindow, TradeDetailsWindow tradeDetailsWindow, AccountAgeWitnessService accountAgeWitnessService, + DaoFacade daoFacade, @Named(Config.USE_DEV_PRIVILEGE_KEYS) boolean useDevPrivilegeKeys) { super(arbitrationManager, keyRing, @@ -63,6 +65,7 @@ public ArbitratorView(ArbitrationManager arbitrationManager, contractWindow, tradeDetailsWindow, accountAgeWitnessService, + daoFacade, useDevPrivilegeKeys); } diff --git a/desktop/src/main/java/bisq/desktop/main/support/dispute/agent/mediation/MediatorView.java b/desktop/src/main/java/bisq/desktop/main/support/dispute/agent/mediation/MediatorView.java index ba939c34d23..d96fb9e8f2a 100644 --- a/desktop/src/main/java/bisq/desktop/main/support/dispute/agent/mediation/MediatorView.java +++ b/desktop/src/main/java/bisq/desktop/main/support/dispute/agent/mediation/MediatorView.java @@ -25,6 +25,7 @@ import bisq.core.account.witness.AccountAgeWitnessService; import bisq.core.alert.PrivateNotificationManager; +import bisq.core.dao.DaoFacade; import bisq.core.support.SupportType; import bisq.core.support.dispute.Dispute; import bisq.core.support.dispute.DisputeSession; @@ -53,6 +54,7 @@ public MediatorView(MediationManager mediationManager, ContractWindow contractWindow, TradeDetailsWindow tradeDetailsWindow, AccountAgeWitnessService accountAgeWitnessService, + DaoFacade daoFacade, @Named(Config.USE_DEV_PRIVILEGE_KEYS) boolean useDevPrivilegeKeys) { super(mediationManager, keyRing, @@ -63,6 +65,7 @@ public MediatorView(MediationManager mediationManager, contractWindow, tradeDetailsWindow, accountAgeWitnessService, + daoFacade, useDevPrivilegeKeys); } diff --git a/desktop/src/main/java/bisq/desktop/main/support/dispute/agent/refund/RefundAgentView.java b/desktop/src/main/java/bisq/desktop/main/support/dispute/agent/refund/RefundAgentView.java index 5aae5f53f81..71f27f8954e 100644 --- a/desktop/src/main/java/bisq/desktop/main/support/dispute/agent/refund/RefundAgentView.java +++ b/desktop/src/main/java/bisq/desktop/main/support/dispute/agent/refund/RefundAgentView.java @@ -25,6 +25,7 @@ import bisq.core.account.witness.AccountAgeWitnessService; import bisq.core.alert.PrivateNotificationManager; +import bisq.core.dao.DaoFacade; import bisq.core.support.SupportType; import bisq.core.support.dispute.Dispute; import bisq.core.support.dispute.DisputeSession; @@ -37,9 +38,8 @@ import bisq.common.config.Config; import bisq.common.crypto.KeyRing; -import javax.inject.Named; - import javax.inject.Inject; +import javax.inject.Named; @FxmlView public class RefundAgentView extends DisputeAgentView { @@ -54,6 +54,7 @@ public RefundAgentView(RefundManager refundManager, ContractWindow contractWindow, TradeDetailsWindow tradeDetailsWindow, AccountAgeWitnessService accountAgeWitnessService, + DaoFacade daoFacade, @Named(Config.USE_DEV_PRIVILEGE_KEYS) boolean useDevPrivilegeKeys) { super(refundManager, keyRing, @@ -64,6 +65,7 @@ public RefundAgentView(RefundManager refundManager, contractWindow, tradeDetailsWindow, accountAgeWitnessService, + daoFacade, useDevPrivilegeKeys); } diff --git a/desktop/src/main/java/bisq/desktop/main/support/dispute/client/DisputeClientView.java b/desktop/src/main/java/bisq/desktop/main/support/dispute/client/DisputeClientView.java index f06875202e7..265f5838ecb 100644 --- a/desktop/src/main/java/bisq/desktop/main/support/dispute/client/DisputeClientView.java +++ b/desktop/src/main/java/bisq/desktop/main/support/dispute/client/DisputeClientView.java @@ -55,14 +55,12 @@ protected void handleOnSelectDispute(Dispute dispute) { } @Override - protected void applyFilteredListPredicate(String filterString) { - filteredList.setPredicate(dispute -> { - // As we are in the client view we hide disputes where we are the agent - if (dispute.getAgentPubKeyRing().equals(keyRing.getPubKeyRing())) { - return false; - } + protected DisputeView.FilterResult getFilterResult(Dispute dispute, String filterString) { + // As we are in the client view we hide disputes where we are the agent + if (dispute.getAgentPubKeyRing().equals(keyRing.getPubKeyRing())) { + return FilterResult.NO_MATCH; + } - return filterString.isEmpty() || anyMatchOfFilterString(dispute, filterString); - }); + return super.getFilterResult(dispute, filterString); } } diff --git a/proto/src/main/proto/pb.proto b/proto/src/main/proto/pb.proto index 2f334e06e03..29efbd465b4 100644 --- a/proto/src/main/proto/pb.proto +++ b/proto/src/main/proto/pb.proto @@ -792,6 +792,8 @@ message Dispute { SupportType support_type = 24; string mediators_dispute_result = 25; string delayed_payout_tx_id = 26; + string donation_address_of_delayed_payout_tx = 27; + string agents_uid = 28; } message Attachment {