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 8e1e43be87..4ad0b87d35 100644 --- a/core/src/main/java/bisq/core/support/dispute/DisputeManager.java +++ b/core/src/main/java/bisq/core/support/dispute/DisputeManager.java @@ -324,9 +324,9 @@ protected void checkForMediatedTradePayout(Trade trade, Dispute dispute) { disputedTradeUpdate(newValue.toString(), dispute, false); } }); - // user rejected mediation after lockup period: opening arbitration + // user rejected mediation after lockup period: opening arbitration after peer redirects trade.disputeStateProperty().addListener((observable, oldValue, newValue) -> { - if (newValue.isArbitrated()) { + if (newValue.isEscalated()) { disputedTradeUpdate(newValue.toString(), dispute, true); } }); @@ -453,28 +453,34 @@ protected void onOpenNewDisputeMessage(OpenNewDisputeMessage openNewDisputeMessa protected void onPeerOpenedDisputeMessage(PeerOpenedDisputeMessage peerOpenedDisputeMessage) { Dispute dispute = peerOpenedDisputeMessage.getDispute(); tradeManager.getTradeById(dispute.getTradeId()).ifPresentOrElse( - trade -> peerOpenedDisputeForTrade(peerOpenedDisputeMessage, dispute, trade), - () -> closedTradableManager.getTradableById(dispute.getTradeId()).ifPresentOrElse( - closedTradable -> newDisputeRevertsClosedTrade(peerOpenedDisputeMessage, dispute, (Trade)closedTradable), - () -> failedTradesManager.getTradeById(dispute.getTradeId()).ifPresent( - trade -> newDisputeRevertsFailedTrade(peerOpenedDisputeMessage, dispute, trade)))); + trade -> peerOpenedDisputeForTrade(peerOpenedDisputeMessage, dispute, trade), + () -> closedTradableManager.getTradableById(dispute.getTradeId()).ifPresentOrElse( + closedTradable -> newDisputeRevertsClosedTrade(peerOpenedDisputeMessage, dispute, (Trade) closedTradable), + () -> failedTradesManager.getTradeById(dispute.getTradeId()).ifPresent( + trade -> newDisputeRevertsFailedTrade(peerOpenedDisputeMessage, dispute, trade)))); } - private void newDisputeRevertsFailedTrade(PeerOpenedDisputeMessage peerOpenedDisputeMessage, Dispute dispute, Trade trade) { + private void newDisputeRevertsFailedTrade(PeerOpenedDisputeMessage peerOpenedDisputeMessage, + Dispute dispute, + Trade trade) { log.info("Peer dispute ticket received, reverting failed trade {} to pending", trade.getShortId()); failedTradesManager.removeTrade(trade); tradeManager.addTradeToPendingTrades(trade); peerOpenedDisputeForTrade(peerOpenedDisputeMessage, dispute, trade); } - private void newDisputeRevertsClosedTrade(PeerOpenedDisputeMessage peerOpenedDisputeMessage, Dispute dispute, Trade trade) { + private void newDisputeRevertsClosedTrade(PeerOpenedDisputeMessage peerOpenedDisputeMessage, + Dispute dispute, + Trade trade) { log.info("Peer dispute ticket received, reverting closed trade {} to pending", trade.getShortId()); closedTradableManager.remove(trade); tradeManager.addTradeToPendingTrades(trade); peerOpenedDisputeForTrade(peerOpenedDisputeMessage, dispute, trade); } - private void peerOpenedDisputeForTrade(PeerOpenedDisputeMessage peerOpenedDisputeMessage, Dispute dispute, Trade trade) { + private void peerOpenedDisputeForTrade(PeerOpenedDisputeMessage peerOpenedDisputeMessage, + Dispute dispute, + Trade trade) { String errorMessage = null; T disputeList = getDisputeList(); if (disputeList == null) { diff --git a/core/src/main/resources/i18n/displayStrings.properties b/core/src/main/resources/i18n/displayStrings.properties index b5c70e205b..335da22fe1 100644 --- a/core/src/main/resources/i18n/displayStrings.properties +++ b/core/src/main/resources/i18n/displayStrings.properties @@ -1053,7 +1053,7 @@ portfolio.pending.mediationResult.popup.info.v5=The mediator has suggested the f If agreement is not possible, you will have to wait until {2} (block {3}) to Send a Warning to your trading peer, \ by broadcasting your warning transaction. The peer will then have a further 10 days (1440 blocks) to respond, by \ opening a second-round dispute with an arbitrator who will investigate the case again and do a payout based on their \ - findings. If the peer does not respond in time, you can automatically claim the entire trade collateral.\n\n\ + findings. If the peer does not respond in time, you can unilaterally claim the entire trade collateral.\n\n\ If the trade goes to arbitration the arbitrator will pay out the trade amount plus one peer''s security deposit. \ This means the total arbitration payout will be less than the mediation payout. \ Sending a warning or requesting arbitration is meant for exceptional circumstances. such as; \ @@ -1072,7 +1072,7 @@ portfolio.pending.mediationResult.popup.selfAccepted.lockTimeOver.v5=You have ac Once the lock time is over on {0} (block {1}), you can Send a Warning to your trading peer, by broadcasting your \ warning transaction. The peer will then have a further 10 days (1440 blocks) to respond, by opening a second-round \ dispute with an arbitrator who will investigate the case again and do a payout based on their findings. If the peer \ - does not respond in time, you can automatically claim the entire trade collateral.\n\n\ + does not respond in time, you can unilaterally claim the entire trade collateral.\n\n\ You can find more details about the arbitration model at: \ [HYPERLINK:https://bisq.wiki/Dispute_resolution#Level_3:_Arbitration] portfolio.pending.mediationResult.popup.reject=Reject @@ -1080,6 +1080,19 @@ portfolio.pending.mediationResult.popup.openArbitration=Send to arbitration portfolio.pending.mediationResult.popup.sendWarning=Send warning to peer portfolio.pending.mediationResult.popup.alreadyAccepted=You've already accepted +portfolio.pending.warningSent.headline=Warning sent +portfolio.pending.warningSent.claimLocked.info=Your trading peer has until {0} (Block {1}) to redirect to arbitration. If \ + they fail to do so by then, you can unilaterally claim the entire trade collateral. +portfolio.pending.warningSent.claimUnlocked.info=Your trading peer has failed to redirect to arbitration in time. You may \ + now claim the entire trade collateral at any time before they attempt to redirect. +portfolio.pending.warningSent.button=Claim trade collateral +portfolio.pending.warningSentByPeer.headline=Warning sent by peer +portfolio.pending.warningSentByPeer.claimLocked.info=You have until {0} (Block {1}) to redirect to arbitration, before your \ + trading peer can unilaterally claim the entire trade collateral. +portfolio.pending.warningSentByPeer.claimUnlocked.info=Your trading peer can unilaterally claim the entire trade collateral \ + at any time. You must redirect to arbitration now to prevent this. +portfolio.pending.warningSentByPeer.button=Redirect to arbitration + portfolio.pending.failedTrade.taker.missingTakerFeeTx=The taker fee transaction is missing.\n\n\ Without this tx, the trade cannot be completed. No funds have been locked and no trade fee has been paid. \ You can move this trade to failed trades. diff --git a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/TradeStepInfo.java b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/TradeStepInfo.java index 1142e93977..16dbd2c84e 100644 --- a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/TradeStepInfo.java +++ b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/TradeStepInfo.java @@ -39,7 +39,6 @@ @Slf4j public class TradeStepInfo { - public enum State { UNDEFINED, SHOW_GET_HELP_BUTTON, @@ -48,6 +47,10 @@ public enum State { MEDIATION_RESULT, MEDIATION_RESULT_SELF_ACCEPTED, MEDIATION_RESULT_PEER_ACCEPTED, + WARNING_SENT_CLAIM_LOCKED, + WARNING_SENT_CLAIM_UNLOCKED, + WARNING_SENT_BY_PEER_CLAIM_LOCKED, + WARNING_SENT_BY_PEER_CLAIM_UNLOCKED, IN_ARBITRATION_SELF_REQUESTED, IN_ARBITRATION_PEER_REQUESTED, IN_REFUND_REQUEST_SELF_REQUESTED, @@ -66,7 +69,9 @@ public enum State { private Trade trade; @Getter private State state = State.UNDEFINED; + @Setter private Supplier firstHalfOverWarnTextSupplier = () -> ""; + @Setter private Supplier periodOverWarnTextSupplier = () -> ""; TradeStepInfo(TitledGroupBg titledGroupBg, @@ -92,15 +97,7 @@ public void setOnAction(EventHandler e) { button.setOnAction(e); } - public void setFirstHalfOverWarnTextSupplier(Supplier firstHalfOverWarnTextSupplier) { - this.firstHalfOverWarnTextSupplier = firstHalfOverWarnTextSupplier; - } - - public void setPeriodOverWarnTextSupplier(Supplier periodOverWarnTextSupplier) { - this.periodOverWarnTextSupplier = periodOverWarnTextSupplier; - } - - public void setState(State state) { + public void setState(State state, Object... labelTextArguments) { this.state = state; switch (state) { case UNDEFINED: @@ -159,6 +156,42 @@ public void setState(State state) { button.getStyleClass().add("action-button"); button.setDisable(false); break; + case WARNING_SENT_CLAIM_LOCKED: + // grey button disabled + titledGroupBg.setText(Res.get("portfolio.pending.warningSent.headline")); + label.updateContent(Res.get("portfolio.pending.warningSent.claimLocked.info", labelTextArguments)); + button.setText(Res.get("portfolio.pending.warningSent.button").toUpperCase()); + button.setId(null); + button.getStyleClass().add("action-button"); + button.setDisable(true); + break; + case WARNING_SENT_CLAIM_UNLOCKED: + // green button + titledGroupBg.setText(Res.get("portfolio.pending.warningSent.headline")); + label.updateContent(Res.get("portfolio.pending.warningSent.claimUnlocked.info")); + button.setText(Res.get("portfolio.pending.warningSent.button").toUpperCase()); + button.setId(null); + button.getStyleClass().add("action-button"); + button.setDisable(false); + break; + case WARNING_SENT_BY_PEER_CLAIM_LOCKED: + // red button + titledGroupBg.setText(Res.get("portfolio.pending.warningSentByPeer.headline")); + label.updateContent(Res.get("portfolio.pending.warningSentByPeer.claimLocked.info", labelTextArguments)); + button.setText(Res.get("portfolio.pending.warningSentByPeer.button").toUpperCase()); + button.setId("open-dispute-button"); + button.getStyleClass().remove("action-button"); + button.setDisable(false); + break; + case WARNING_SENT_BY_PEER_CLAIM_UNLOCKED: + // red button + titledGroupBg.setText(Res.get("portfolio.pending.warningSentByPeer.headline")); + label.updateContent(Res.get("portfolio.pending.warningSentByPeer.claimUnlocked.info")); + button.setText(Res.get("portfolio.pending.warningSentByPeer.button").toUpperCase()); + button.setId("open-dispute-button"); + button.getStyleClass().remove("action-button"); + button.setDisable(false); + break; case IN_REFUND_REQUEST_SELF_REQUESTED: // red button titledGroupBg.setText(Res.get("portfolio.pending.refundRequested")); 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 36e0de49c8..99b619d846 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 @@ -36,6 +36,7 @@ import bisq.core.trade.model.bisq_v1.BuyerTrade; import bisq.core.trade.model.bisq_v1.Contract; import bisq.core.trade.model.bisq_v1.Trade; +import bisq.core.trade.protocol.bisq_v5.model.StagedPayoutTxParameters; import bisq.core.user.Preferences; import bisq.core.util.FormattingUtils; @@ -43,9 +44,11 @@ import bisq.common.ClockWatcher; import bisq.common.UserThread; +import bisq.common.util.Tuple2; import bisq.common.util.Tuple3; import org.bitcoinj.core.Transaction; +import org.bitcoinj.core.TransactionConfidence; import org.bitcoinj.core.listeners.NewBestBlockListener; import de.jensd.fx.fontawesome.AwesomeDude; @@ -72,7 +75,9 @@ import javafx.beans.property.BooleanProperty; import javafx.beans.value.ChangeListener; +import java.util.Objects; import java.util.Optional; +import java.util.stream.Stream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -248,6 +253,10 @@ private void registerSubscriptions() { updateDisputeState(newValue); } }); + // TODO: Should call updateDisputeState(..) once the claim tx becomes publishable, if the trade is in either of + // the WARNING_SENT* dispute states, so that the tradeStepInfo button & label text get updated as appropriate + // and a popup gets displayed. (Maybe the label text should be refreshed for every new block in those cases + // as well, to keep the claim lock time estimate accurate.) mediationResultStateSubscription = EasyBind.subscribe(trade.mediationResultStateProperty(), newValue -> { if (newValue != null) { @@ -299,10 +308,7 @@ public void deactivate() { model.dataModel.btcWalletService.removeNewBestBlockListener(newBestBlockListener); } - if (acceptMediationResultPopup != null) { - acceptMediationResultPopup.hide(); - acceptMediationResultPopup = null; - } + hideAcceptMediationResultPopup(); } /////////////////////////////////////////////////////////////////////////////////////////// @@ -433,6 +439,7 @@ protected void applyOnDisputeOpened() { protected void updateDisputeState(Trade.DisputeState disputeState) { Optional ownDispute; + Tuple2 remainingBlocksAndLockTime; switch (disputeState) { case NO_DISPUTE: break; @@ -443,10 +450,9 @@ protected void updateDisputeState(Trade.DisputeState disputeState) { applyOnDisputeOpened(); ownDispute = model.dataModel.mediationManager.findOwnDispute(trade.getId()); - ownDispute.ifPresent(dispute -> { - if (tradeStepInfo != null) - tradeStepInfo.setState(TradeStepInfo.State.IN_MEDIATION_SELF_REQUESTED); - }); + if (tradeStepInfo != null && ownDispute.isPresent()) { + tradeStepInfo.setState(TradeStepInfo.State.IN_MEDIATION_SELF_REQUESTED); + } break; case MEDIATION_STARTED_BY_PEER: if (tradeStepInfo != null) { @@ -455,23 +461,48 @@ protected void updateDisputeState(Trade.DisputeState disputeState) { applyOnDisputeOpened(); ownDispute = model.dataModel.mediationManager.findOwnDispute(trade.getId()); - ownDispute.ifPresent(dispute -> { - if (tradeStepInfo != null) { - tradeStepInfo.setState(TradeStepInfo.State.IN_MEDIATION_PEER_REQUESTED); - } - }); + if (tradeStepInfo != null && ownDispute.isPresent()) { + tradeStepInfo.setState(TradeStepInfo.State.IN_MEDIATION_PEER_REQUESTED); + } break; case MEDIATION_CLOSED: if (tradeStepInfo != null) { tradeStepInfo.setOnAction(e -> updateMediationResultState(false)); - } - - if (tradeStepInfo != null) { tradeStepInfo.setState(TradeStepInfo.State.MEDIATION_RESULT); } updateMediationResultState(true); break; + case WARNING_SENT: + remainingBlocksAndLockTime = getClaimTxRemainingBlocksAndLockTime(); + if (tradeStepInfo != null && remainingBlocksAndLockTime.first > 0) { + tradeStepInfo.setState(TradeStepInfo.State.WARNING_SENT_CLAIM_LOCKED, + FormattingUtils.getDateFromBlockHeight(remainingBlocksAndLockTime.first), + remainingBlocksAndLockTime.second); + } else if (tradeStepInfo != null) { + tradeStepInfo.setOnAction(e -> openClaimCollateralPopup()); + tradeStepInfo.setState(TradeStepInfo.State.WARNING_SENT_CLAIM_UNLOCKED); + } + hideAcceptMediationResultPopup(); + if (remainingBlocksAndLockTime.first <= 0) { + openClaimCollateralPopup(); + } + break; + case WARNING_SENT_BY_PEER: + if (tradeStepInfo != null) { + tradeStepInfo.setOnAction(e -> openRedirectToArbitrationPopup()); + remainingBlocksAndLockTime = getClaimTxRemainingBlocksAndLockTime(); + if (remainingBlocksAndLockTime.first > 0) { + tradeStepInfo.setState(TradeStepInfo.State.WARNING_SENT_BY_PEER_CLAIM_LOCKED, + FormattingUtils.getDateFromBlockHeight(remainingBlocksAndLockTime.first), + remainingBlocksAndLockTime.second); + } else { + tradeStepInfo.setState(TradeStepInfo.State.WARNING_SENT_BY_PEER_CLAIM_UNLOCKED); + } + } + hideAcceptMediationResultPopup(); + openRedirectToArbitrationPopup(); + break; case REFUND_REQUESTED: if (tradeStepInfo != null) { tradeStepInfo.setFirstHalfOverWarnTextSupplier(this::getFirstHalfOverWarnText); @@ -479,16 +510,11 @@ protected void updateDisputeState(Trade.DisputeState disputeState) { applyOnDisputeOpened(); ownDispute = model.dataModel.refundManager.findOwnDispute(trade.getId()); - ownDispute.ifPresent(dispute -> { - if (tradeStepInfo != null) - tradeStepInfo.setState(TradeStepInfo.State.IN_REFUND_REQUEST_SELF_REQUESTED); - }); - - if (acceptMediationResultPopup != null) { - acceptMediationResultPopup.hide(); - acceptMediationResultPopup = null; + if (tradeStepInfo != null && ownDispute.isPresent()) { + tradeStepInfo.setState(TradeStepInfo.State.IN_REFUND_REQUEST_SELF_REQUESTED); } + hideAcceptMediationResultPopup(); break; case REFUND_REQUEST_STARTED_BY_PEER: if (tradeStepInfo != null) { @@ -497,15 +523,11 @@ protected void updateDisputeState(Trade.DisputeState disputeState) { applyOnDisputeOpened(); ownDispute = model.dataModel.refundManager.findOwnDispute(trade.getId()); - ownDispute.ifPresent(dispute -> { - if (tradeStepInfo != null) - tradeStepInfo.setState(TradeStepInfo.State.IN_REFUND_REQUEST_PEER_REQUESTED); - }); - - if (acceptMediationResultPopup != null) { - acceptMediationResultPopup.hide(); - acceptMediationResultPopup = null; + if (tradeStepInfo != null && ownDispute.isPresent()) { + tradeStepInfo.setState(TradeStepInfo.State.IN_REFUND_REQUEST_PEER_REQUESTED); } + + hideAcceptMediationResultPopup(); break; case REFUND_REQUEST_CLOSED: break; @@ -514,6 +536,37 @@ protected void updateDisputeState(Trade.DisputeState disputeState) { } } + private void hideAcceptMediationResultPopup() { + if (acceptMediationResultPopup != null) { + acceptMediationResultPopup.hide(); + acceptMediationResultPopup = null; + } + } + + private Tuple2 getClaimTxRemainingBlocksAndLockTime() { + BtcWalletService btcWalletService = model.dataModel.btcWalletService; + int bestChainHeight = btcWalletService.getBestChainHeight(); + int warningTxHeight = Stream.of(trade.getBuyersWarningTx(btcWalletService), trade.getSellersWarningTx(btcWalletService)) + .filter(Objects::nonNull) + .map(Transaction::getConfidence) + .filter(c -> c != null && c.getConfidenceType() == TransactionConfidence.ConfidenceType.BUILDING) + .mapToInt(TransactionConfidence::getAppearedAtChainHeight) + .findAny() + .orElse(bestChainHeight + 1); // If no confirmed warningTx, assume it will appear in the next block. + + long claimTxLockTime = warningTxHeight + StagedPayoutTxParameters.getClaimDelay(); + long remaining = claimTxLockTime - bestChainHeight; + return new Tuple2<>(remaining, claimTxLockTime); + } + + private void openRedirectToArbitrationPopup() { + // TODO: Implement. + } + + private void openClaimCollateralPopup() { + // TODO: Implement. + } + protected void updateMediationResultState(boolean blockOpeningOfResultAcceptedPopup) { if (isInArbitration()) { if (isRefundRequestStartedByPeer()) { @@ -669,10 +722,7 @@ private void acceptProposal() { }, errorMessage -> UserThread.execute(() -> { new Popup().error(errorMessage).show(); - if (acceptMediationResultPopup != null) { - acceptMediationResultPopup.hide(); - acceptMediationResultPopup = null; - } + hideAcceptMediationResultPopup(); })); }