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 ec8a3e74564..dacc0491ab8 100644 --- a/core/src/main/java/bisq/core/support/dispute/Dispute.java +++ b/core/src/main/java/bisq/core/support/dispute/Dispute.java @@ -151,6 +151,14 @@ public static protobuf.Dispute.State toProtoMessage(Dispute.State state) { @Setter private long tradeTxFee; + // Added for v5 protocol + @Setter + @Nullable + private String warningTxId; + @Setter + @Nullable + private String redirectTxId; + // Should be only used in emergency case if we need to add data but do not want to break backward compatibility // at the P2P network storage checks. The hash of the object will be used to verify if the data is valid. Any new @@ -282,12 +290,14 @@ public protobuf.Dispute toProtoMessage() { Optional.ofNullable(disputePayoutTxId).ifPresent(builder::setDisputePayoutTxId); Optional.ofNullable(makerContractSignature).ifPresent(builder::setMakerContractSignature); Optional.ofNullable(takerContractSignature).ifPresent(builder::setTakerContractSignature); - Optional.ofNullable(disputeResultProperty.get()).ifPresent(result -> builder.setDisputeResult(disputeResultProperty.get().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(disputeResultProperty.get()).ifPresent(e -> builder.setDisputeResult(e.toProtoMessage())); + Optional.ofNullable(supportType).ifPresent(e -> builder.setSupportType(SupportType.toProtoMessage(e))); + Optional.ofNullable(mediatorsDisputeResult).ifPresent(builder::setMediatorsDisputeResult); + Optional.ofNullable(delayedPayoutTxId).ifPresent(builder::setDelayedPayoutTxId); + Optional.ofNullable(donationAddressOfDelayedPayoutTx).ifPresent(builder::setDonationAddressOfDelayedPayoutTx); Optional.ofNullable(getExtraDataMap()).ifPresent(builder::putAllExtraData); + Optional.ofNullable(warningTxId).ifPresent(builder::setWarningTxId); + Optional.ofNullable(redirectTxId).ifPresent(builder::setRedirectTxId); return builder.build(); } @@ -320,28 +330,21 @@ public static Dispute fromProto(protobuf.Dispute proto, CoreProtoResolver corePr .map(ChatMessage::fromPayloadProto) .collect(Collectors.toList())); - if (proto.hasDisputeResult()) + if (proto.hasDisputeResult()) { dispute.disputeResultProperty.set(DisputeResult.fromProto(proto.getDisputeResult())); - dispute.disputePayoutTxId = ProtoUtil.stringOrNullFromProto(proto.getDisputePayoutTxId()); - - String mediatorsDisputeResult = proto.getMediatorsDisputeResult(); - if (!mediatorsDisputeResult.isEmpty()) { - dispute.setMediatorsDisputeResult(mediatorsDisputeResult); - } - - String delayedPayoutTxId = proto.getDelayedPayoutTxId(); - if (!delayedPayoutTxId.isEmpty()) { - dispute.setDelayedPayoutTxId(delayedPayoutTxId); } + dispute.disputePayoutTxId = ProtoUtil.stringOrNullFromProto(proto.getDisputePayoutTxId()); - String donationAddressOfDelayedPayoutTx = proto.getDonationAddressOfDelayedPayoutTx(); - if (!donationAddressOfDelayedPayoutTx.isEmpty()) { - dispute.setDonationAddressOfDelayedPayoutTx(donationAddressOfDelayedPayoutTx); - } + dispute.setMediatorsDisputeResult(ProtoUtil.stringOrNullFromProto(proto.getMediatorsDisputeResult())); + dispute.setDelayedPayoutTxId(ProtoUtil.stringOrNullFromProto(proto.getDelayedPayoutTxId())); + dispute.setDonationAddressOfDelayedPayoutTx(ProtoUtil.stringOrNullFromProto(proto.getDonationAddressOfDelayedPayoutTx())); dispute.setBurningManSelectionHeight(proto.getBurningManSelectionHeight()); dispute.setTradeTxFee(proto.getTradeTxFee()); + dispute.setWarningTxId(ProtoUtil.stringOrNullFromProto(proto.getWarningTxId())); + dispute.setRedirectTxId(ProtoUtil.stringOrNullFromProto(proto.getRedirectTxId())); + if (Dispute.State.fromProto(proto.getState()) == State.NEEDS_UPGRADE) { // old disputes did not have a state field, so choose an appropriate state: dispute.setState(proto.getIsClosed() ? State.CLOSED : State.OPEN); @@ -387,7 +390,7 @@ public void maybeClearSensitiveData() { if (contract.maybeClearSensitiveData()) { change += "contract;"; } - String edited = contract.sanitizeContractAsJson(contractAsJson); + String edited = Contract.sanitizeContractAsJson(contractAsJson); if (!edited.equals(contractAsJson)) { contractAsJson = edited; change += "contractAsJson;"; @@ -571,6 +574,8 @@ public String toString() { ",\n cachedDepositTx='" + cachedDepositTx + '\'' + ",\n burningManSelectionHeight='" + burningManSelectionHeight + '\'' + ",\n tradeTxFee='" + tradeTxFee + '\'' + + ",\n warningTxId='" + warningTxId + '\'' + + ",\n redirectTxId='" + redirectTxId + '\'' + "\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 4ad0b87d354..882d2560b30 100644 --- a/core/src/main/java/bisq/core/support/dispute/DisputeManager.java +++ b/core/src/main/java/bisq/core/support/dispute/DisputeManager.java @@ -357,7 +357,7 @@ public void maybeClearSensitiveData() { log.info("{} checking closed disputes eligibility for having sensitive data cleared", super.getClass().getSimpleName()); Instant safeDate = closedTradableManager.getSafeDateForSensitiveDataClearing(); getDisputeList().getList().stream() - .filter(e -> e.isClosed()) + .filter(Dispute::isClosed) .filter(e -> e.getOpeningDate().toInstant().isBefore(safeDate)) .forEach(Dispute::maybeClearSensitiveData); requestPersistence(); @@ -491,7 +491,7 @@ private void peerOpenedDisputeForTrade(PeerOpenedDisputeMessage peerOpenedDisput try { DisputeValidation.validateDisputeData(dispute, btcWalletService); DisputeValidation.validateNodeAddresses(dispute, config); - DisputeValidation.validateTradeAndDispute(dispute, trade); + DisputeValidation.validateTradeAndDispute(dispute, trade, btcWalletService); TradeDataValidation.validateDelayedPayoutTx(trade, trade.getDelayedPayoutTx(), btcWalletService); @@ -713,6 +713,8 @@ private void doSendPeerOpenedDisputeMessage(Dispute disputeFromOpener, dispute.setDonationAddressOfDelayedPayoutTx(disputeFromOpener.getDonationAddressOfDelayedPayoutTx()); dispute.setBurningManSelectionHeight(disputeFromOpener.getBurningManSelectionHeight()); dispute.setTradeTxFee(disputeFromOpener.getTradeTxFee()); + dispute.setWarningTxId(disputeFromOpener.getWarningTxId()); + dispute.setRedirectTxId(disputeFromOpener.getRedirectTxId()); Optional storedDisputeOptional = findDispute(dispute); diff --git a/core/src/main/java/bisq/core/support/dispute/DisputeValidation.java b/core/src/main/java/bisq/core/support/dispute/DisputeValidation.java index e9f7e19018d..8b6ec62c228 100644 --- a/core/src/main/java/bisq/core/support/dispute/DisputeValidation.java +++ b/core/src/main/java/bisq/core/support/dispute/DisputeValidation.java @@ -31,7 +31,7 @@ import bisq.common.crypto.CryptoException; import bisq.common.crypto.Hash; import bisq.common.crypto.Sig; -import bisq.common.util.Tuple3; +import bisq.common.util.Tuple5; import org.bitcoinj.core.Address; import org.bitcoinj.core.NetworkParameters; @@ -51,6 +51,8 @@ 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; @@ -93,16 +95,36 @@ public static void validateDisputeData(Dispute dispute, } } - public static void validateTradeAndDispute(Dispute dispute, Trade trade) + public static void validateTradeAndDispute(Dispute dispute, Trade trade, BtcWalletService btcWalletService) throws ValidationException { try { checkArgument(dispute.getContract().equals(trade.getContract()), "contract must match contract from trade"); - checkNotNull(trade.getDelayedPayoutTx(), "trade.getDelayedPayoutTx() must not be null"); - checkNotNull(dispute.getDelayedPayoutTxId(), "delayedPayoutTxId must not be null"); - checkArgument(dispute.getDelayedPayoutTxId().equals(trade.getDelayedPayoutTx().getTxId().toString()), - "delayedPayoutTxId must match delayedPayoutTxId from trade"); + if (trade.hasV5Protocol()) { + String buyersWarningTxId = toTxId(trade.getBuyersWarningTx(btcWalletService)); + String sellersWarningTxId = toTxId(trade.getSellersWarningTx(btcWalletService)); + String buyersRedirectTxId = toTxId(trade.getBuyersRedirectTx(btcWalletService)); + String sellersRedirectTxId = toTxId(trade.getSellersRedirectTx(btcWalletService)); + checkNotNull(dispute.getWarningTxId(), "warningTxId must not be null"); + checkArgument(Arrays.asList(buyersWarningTxId, sellersWarningTxId).contains(dispute.getWarningTxId()), + "warningTxId must match either buyer's or seller's warningTxId from trade"); + checkNotNull(dispute.getRedirectTxId(), "redirectTxId must not be null"); + checkArgument(Arrays.asList(buyersRedirectTxId, sellersRedirectTxId).contains(dispute.getRedirectTxId()), + "redirectTxId must match either buyer's or seller's redirectTxId from trade"); + boolean isBuyerWarning = dispute.getWarningTxId().equals(buyersWarningTxId); + boolean isBuyerRedirect = dispute.getRedirectTxId().equals(buyersRedirectTxId); + if (isBuyerWarning) { + checkArgument(!isBuyerRedirect, "buyer's redirectTx must be used with seller's warningTx"); + } else { + checkArgument(isBuyerRedirect, "seller's redirectTx must be used with buyer's warningTx"); + } + } else { + checkNotNull(trade.getDelayedPayoutTx(), "trade.getDelayedPayoutTx() must not be null"); + checkNotNull(dispute.getDelayedPayoutTxId(), "delayedPayoutTxId must not be null"); + checkArgument(dispute.getDelayedPayoutTxId().equals(trade.getDelayedPayoutTx().getTxId().toString()), + "delayedPayoutTxId must match delayedPayoutTxId from trade"); + } checkNotNull(trade.getDepositTx(), "trade.getDepositTx() must not be null"); checkNotNull(dispute.getDepositTxId(), "depositTxId must not be null"); @@ -115,6 +137,11 @@ public static void validateTradeAndDispute(Dispute dispute, Trade trade) } } + @Nullable + private static String toTxId(@Nullable Transaction tx) { + return tx != null ? tx.getTxId().toString() : null; + } + public static void validateSenderNodeAddress(Dispute dispute, NodeAddress senderNodeAddress) throws NodeAddressException { @@ -176,18 +203,23 @@ public static void validateDonationAddress(Dispute dispute, "; dispute.getDonationAddressOfDelayedPayoutTx()=" + dispute.getDonationAddressOfDelayedPayoutTx()); } + // TODO: Refactor: public static void testIfAnyDisputeTriedReplay(List disputeList, Consumer exceptionHandler) { var tuple = getTestReplayHashMaps(disputeList); Map> disputesPerTradeId = tuple.first; Map> disputesPerDelayedPayoutTxId = tuple.second; - Map> disputesPerDepositTxId = tuple.third; + Map> disputesPerWarningId = tuple.third; + Map> disputesPerRedirectTxId = tuple.fourth; + Map> disputesPerDepositTxId = tuple.fifth; disputeList.forEach(disputeToTest -> { try { testIfDisputeTriesReplay(disputeToTest, disputesPerTradeId, disputesPerDelayedPayoutTxId, + disputesPerWarningId, + disputesPerRedirectTxId, disputesPerDepositTxId); } catch (DisputeReplayException e) { @@ -196,23 +228,31 @@ public static void testIfAnyDisputeTriedReplay(List disputeList, }); } + // TODO: Refactor: public static void testIfDisputeTriesReplay(Dispute dispute, List disputeList) throws DisputeReplayException { var tuple = getTestReplayHashMaps(disputeList); Map> disputesPerTradeId = tuple.first; Map> disputesPerDelayedPayoutTxId = tuple.second; - Map> disputesPerDepositTxId = tuple.third; + Map> disputesPerWarningTxId = tuple.third; + Map> disputesPerRedirectTxId = tuple.fourth; + Map> disputesPerDepositTxId = tuple.fifth; testIfDisputeTriesReplay(dispute, disputesPerTradeId, disputesPerDelayedPayoutTxId, + disputesPerWarningTxId, + disputesPerRedirectTxId, disputesPerDepositTxId); } - private static Tuple3>, Map>, Map>> getTestReplayHashMaps( + // TODO: Refactor: + private static Tuple5>, Map>, Map>, Map>, Map>> getTestReplayHashMaps( List disputeList) { Map> disputesPerTradeId = new HashMap<>(); Map> disputesPerDelayedPayoutTxId = new HashMap<>(); + Map> disputesPerWarningTxId = new HashMap<>(); + Map> disputesPerRedirectTxId = new HashMap<>(); Map> disputesPerDepositTxId = new HashMap<>(); disputeList.forEach(dispute -> { String uid = dispute.getUid(); @@ -224,24 +264,37 @@ private static Tuple3>, Map>, Map new HashSet<>()).add(uid); } - + String warningTxId = dispute.getWarningTxId(); + if (warningTxId != null) { + disputesPerWarningTxId.computeIfAbsent(warningTxId, id -> new HashSet<>()).add(uid); + } + String redirectTxId = dispute.getRedirectTxId(); + if (redirectTxId != null) { + disputesPerRedirectTxId.computeIfAbsent(redirectTxId, id -> new HashSet<>()).add(uid); + } String depositTxId = dispute.getDepositTxId(); if (depositTxId != null) { disputesPerDepositTxId.computeIfAbsent(depositTxId, id -> new HashSet<>()).add(uid); } }); - return new Tuple3<>(disputesPerTradeId, disputesPerDelayedPayoutTxId, disputesPerDepositTxId); + return new Tuple5<>(disputesPerTradeId, disputesPerDelayedPayoutTxId, disputesPerWarningTxId, + disputesPerRedirectTxId, disputesPerDepositTxId); } + // TODO: Refactor: private static void testIfDisputeTriesReplay(Dispute disputeToTest, Map> disputesPerTradeId, Map> disputesPerDelayedPayoutTxId, + Map> disputesPerWarningTxId, + Map> disputesPerRedirectTxId, Map> disputesPerDepositTxId) throws DisputeReplayException { try { String disputeToTestTradeId = disputeToTest.getTradeId(); String disputeToTestDelayedPayoutTxId = disputeToTest.getDelayedPayoutTxId(); + String disputeToTestWarningTxId = disputeToTest.getWarningTxId(); + String disputeToTestRedirectTxId = disputeToTest.getRedirectTxId(); String disputeToTestDepositTxId = disputeToTest.getDepositTxId(); String disputeToTestUid = disputeToTest.getUid(); @@ -249,39 +302,50 @@ private static void testIfDisputeTriesReplay(Dispute disputeToTest, // So until all users have updated to 1.4.0 we only check in refund agent case. With 1.4.0 we send the // delayed payout tx also in mediation cases and that if check can be removed. if (disputeToTest.getSupportType() == SupportType.REFUND) { - // TODO: Handle v5 protocol trades, which have no delayed payout tx. - checkNotNull(disputeToTestDelayedPayoutTxId, - "Delayed payout transaction ID is null. " + - "Trade ID: " + disputeToTestTradeId); + if (disputeToTestWarningTxId == null) { + checkNotNull(disputeToTestDelayedPayoutTxId, + "Delayed payout transaction ID is null. " + + "Trade ID: %s", disputeToTestTradeId); + } else { + checkNotNull(disputeToTestRedirectTxId, + "Redirect transaction ID is null. " + + "Trade ID: %s", disputeToTestTradeId); + } } checkNotNull(disputeToTestDepositTxId, - "depositTxId must not be null. Trade ID: " + disputeToTestTradeId); + "depositTxId must not be null. Trade ID: %s", disputeToTestTradeId); checkNotNull(disputeToTestUid, - "agentsUid must not be null. Trade ID: " + disputeToTestTradeId); + "agentsUid must not be null. Trade ID: %s", disputeToTestTradeId); Set disputesPerTradeIdItems = disputesPerTradeId.get(disputeToTestTradeId); checkArgument(disputesPerTradeIdItems == null || disputesPerTradeIdItems.size() <= 2, "We found more than 2 disputes with the same trade ID. " + "Trade ID: %s", disputeToTestTradeId); - if (!disputesPerDelayedPayoutTxId.isEmpty()) { - Set disputesPerDelayedPayoutTxIdItems = disputesPerDelayedPayoutTxId.get(disputeToTestDelayedPayoutTxId); - checkArgument(disputesPerDelayedPayoutTxIdItems == null || disputesPerDelayedPayoutTxIdItems.size() <= 2, - "We found more than 2 disputes with the same delayedPayoutTxId. " + - "Trade ID: %s", disputeToTestTradeId); - } - if (!disputesPerDepositTxId.isEmpty()) { - Set disputesPerDepositTxIdItems = disputesPerDepositTxId.get(disputeToTestDepositTxId); - checkArgument(disputesPerDepositTxIdItems == null || disputesPerDepositTxIdItems.size() <= 2, - "We found more than 2 disputes with the same depositTxId. " + - "Trade ID: %s", disputeToTestTradeId); - } + Set disputesPerDelayedPayoutTxIdItems = disputesPerDelayedPayoutTxId.get(disputeToTestDelayedPayoutTxId); + checkArgument(disputesPerDelayedPayoutTxIdItems == null || disputesPerDelayedPayoutTxIdItems.size() <= 2, + "We found more than 2 disputes with the same delayedPayoutTxId. " + + "Trade ID: %s", disputeToTestTradeId); + Set disputesPerWarningTxIdItems = disputesPerWarningTxId.get(disputeToTestWarningTxId); + checkArgument(disputesPerWarningTxIdItems == null || disputesPerWarningTxIdItems.size() <= 2, + "We found more than 2 disputes with the same warningTxId. " + + "Trade ID: %s", disputeToTestTradeId); + Set disputesPerRedirectTxIdItems = disputesPerRedirectTxId.get(disputeToTestRedirectTxId); + checkArgument(disputesPerRedirectTxIdItems == null || disputesPerRedirectTxIdItems.size() <= 2, + "We found more than 2 disputes with the same redirectTxId. " + + "Trade ID: %s", disputeToTestTradeId); + Set disputesPerDepositTxIdItems = disputesPerDepositTxId.get(disputeToTestDepositTxId); + checkArgument(disputesPerDepositTxIdItems == null || disputesPerDepositTxIdItems.size() <= 2, + "We found more than 2 disputes with the same depositTxId. " + + "Trade ID: %s", disputeToTestTradeId); } catch (IllegalArgumentException e) { throw new DisputeReplayException(disputeToTest, e.getMessage()); } catch (NullPointerException e) { log.error("NullPointerException at testIfDisputeTriesReplay: " + "disputeToTest={}, disputesPerTradeId={}, disputesPerDelayedPayoutTxId={}, " + + "disputesPerWarningTxId={}, disputesPerRedirectTxId={}, " + "disputesPerDepositTxId={}", - disputeToTest, disputesPerTradeId, disputesPerDelayedPayoutTxId, disputesPerDepositTxId); + disputeToTest, disputesPerTradeId, disputesPerDelayedPayoutTxId, disputesPerWarningTxId, + disputesPerRedirectTxId, disputesPerDepositTxId); throw new DisputeReplayException(disputeToTest, e + " at dispute " + disputeToTest); } } diff --git a/core/src/main/java/bisq/core/trade/protocol/bisq_v1/DisputeProtocol.java b/core/src/main/java/bisq/core/trade/protocol/bisq_v1/DisputeProtocol.java index 4ec55e8f3f3..f8b0306d819 100644 --- a/core/src/main/java/bisq/core/trade/protocol/bisq_v1/DisputeProtocol.java +++ b/core/src/main/java/bisq/core/trade/protocol/bisq_v1/DisputeProtocol.java @@ -41,6 +41,9 @@ import bisq.core.trade.protocol.bisq_v1.tasks.mediation.SetupMediatedPayoutTxListener; import bisq.core.trade.protocol.bisq_v1.tasks.mediation.SignMediatedPayoutTx; import bisq.core.trade.protocol.bisq_v5.messages.DepositTxAndSellerPaymentAccountMessage; +import bisq.core.trade.protocol.bisq_v5.tasks.arbitration.CreateSignedClaimTx; +import bisq.core.trade.protocol.bisq_v5.tasks.arbitration.PublishClaimTx; +import bisq.core.trade.protocol.bisq_v5.tasks.arbitration.PublishRedirectTx; import bisq.core.trade.protocol.bisq_v5.tasks.arbitration.PublishWarningTx; import bisq.network.p2p.AckMessage; @@ -56,11 +59,12 @@ public class DisputeProtocol extends TradeProtocol { protected Trade trade; protected final ProcessModel processModel; - enum DisputeEvent implements FluentProtocol.Event { + protected enum DisputeEvent implements FluentProtocol.Event { MEDIATION_RESULT_ACCEPTED, MEDIATION_RESULT_REJECTED, WARNING_SENT, - ARBITRATION_REQUESTED + ARBITRATION_REQUESTED, + COLLATERAL_CLAIMED } public DisputeProtocol(Trade trade) { @@ -208,6 +212,7 @@ public void onPublishDelayedPayoutTx(ResultHandler resultHandler, ErrorMessageHa .executeTasks(); } + // TODO: Consider refactoring the onPublish* methods below, by moving them into a subclass (& maybe also the above method). /////////////////////////////////////////////////////////////////////////////////////////// // Warning tx @@ -220,7 +225,64 @@ public void onPublishWarningTx(ResultHandler resultHandler, ErrorMessageHandler Trade.Phase.FIAT_RECEIVED) .with(event) .preCondition(trade.hasV5Protocol())) - .setup(tasks(PublishWarningTx.class)) + .setup(tasks(PublishWarningTx.class) + .using(new TradeTaskRunner(trade, + () -> { + resultHandler.handleResult(); + handleTaskRunnerSuccess(event); + }, + errorMessage -> { + errorMessageHandler.handleErrorMessage(errorMessage); + handleTaskRunnerFault(event, errorMessage); + }))) + .executeTasks(); + } + + /////////////////////////////////////////////////////////////////////////////////////////// + // Redirect tx + /////////////////////////////////////////////////////////////////////////////////////////// + + public void onPublishRedirectTx(ResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) { + DisputeEvent event = DisputeEvent.ARBITRATION_REQUESTED; + expect(anyPhase(Trade.Phase.DEPOSIT_CONFIRMED, + Trade.Phase.FIAT_SENT, + Trade.Phase.FIAT_RECEIVED) + .with(event) + .preCondition(trade.hasV5Protocol())) + .setup(tasks(PublishRedirectTx.class) + .using(new TradeTaskRunner(trade, + () -> { + resultHandler.handleResult(); + handleTaskRunnerSuccess(event); + }, + errorMessage -> { + errorMessageHandler.handleErrorMessage(errorMessage); + handleTaskRunnerFault(event, errorMessage); + }))) + .executeTasks(); + } + + /////////////////////////////////////////////////////////////////////////////////////////// + // Claim tx + /////////////////////////////////////////////////////////////////////////////////////////// + + public void onPublishClaimTx(ResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) { + DisputeEvent event = DisputeEvent.COLLATERAL_CLAIMED; + expect(anyPhase(Trade.Phase.DEPOSIT_CONFIRMED, + Trade.Phase.FIAT_SENT, + Trade.Phase.FIAT_RECEIVED) + .with(event) + .preCondition(trade.hasV5Protocol())) + .setup(tasks(CreateSignedClaimTx.class, PublishClaimTx.class) + .using(new TradeTaskRunner(trade, + () -> { + resultHandler.handleResult(); + handleTaskRunnerSuccess(event); + }, + errorMessage -> { + errorMessageHandler.handleErrorMessage(errorMessage); + handleTaskRunnerFault(event, errorMessage); + }))) .executeTasks(); } diff --git a/core/src/main/java/bisq/core/trade/protocol/bisq_v1/tasks/SetupPayoutTxListener.java b/core/src/main/java/bisq/core/trade/protocol/bisq_v1/tasks/SetupPayoutTxListener.java index 3ab8100d21b..e2687692cbc 100644 --- a/core/src/main/java/bisq/core/trade/protocol/bisq_v1/tasks/SetupPayoutTxListener.java +++ b/core/src/main/java/bisq/core/trade/protocol/bisq_v1/tasks/SetupPayoutTxListener.java @@ -46,7 +46,6 @@ public SetupPayoutTxListener(TaskRunner taskHandler, Trade trade) { super(taskHandler, trade); } - protected abstract void setState(); @Override @@ -60,15 +59,16 @@ protected void run() { // check if the payout already happened (ensuring it was > deposit block height, see GH #5725) TransactionConfidence confidence = walletService.getConfidenceForAddressFromBlockHeight(address, - Objects.requireNonNull(trade.getDepositTx()).getConfidence().getAppearedAtChainHeight()); - if (isInNetwork(confidence)) { + Objects.requireNonNull(trade.getDepositTx()).getConfidence().getAppearedAtChainHeight()); + if (isNonClaimTxInNetwork(confidence)) { applyConfidence(confidence); } else { confidenceListener = new AddressConfidenceListener(address) { @Override public void onTransactionConfidenceChanged(TransactionConfidence confidence) { - if (isInNetwork(confidence)) + if (isNonClaimTxInNetwork(confidence)) { applyConfidence(confidence); + } } }; walletService.addAddressConfidenceListener(confidenceListener); @@ -108,7 +108,16 @@ private void applyConfidence(TransactionConfidence confidence) { UserThread.execute(this::unSubscribe); } - private boolean isInNetwork(TransactionConfidence confidence) { + private boolean isNonClaimTxInNetwork(TransactionConfidence confidence) { + if (isInNetwork(confidence)) { + BtcWalletService btcWalletService = processModel.getBtcWalletService(); + Transaction tx = Objects.requireNonNull(btcWalletService.getTransaction(confidence.getTransactionHash())); + return !tx.hasRelativeLockTime(); + } + return false; + } + + private static boolean isInNetwork(TransactionConfidence confidence) { return confidence != null && (confidence.getConfidenceType().equals(TransactionConfidence.ConfidenceType.BUILDING) || confidence.getConfidenceType().equals(TransactionConfidence.ConfidenceType.PENDING)); diff --git a/core/src/main/java/bisq/core/trade/protocol/bisq_v5/tasks/SetupStagedTxListeners.java b/core/src/main/java/bisq/core/trade/protocol/bisq_v5/tasks/SetupStagedTxListeners.java index 51bde1c6710..718680e73ca 100644 --- a/core/src/main/java/bisq/core/trade/protocol/bisq_v5/tasks/SetupStagedTxListeners.java +++ b/core/src/main/java/bisq/core/trade/protocol/bisq_v5/tasks/SetupStagedTxListeners.java @@ -91,7 +91,7 @@ private void applyWarningConfidence(TransactionConfidence confidence) { // In case the peer's warning tx was cleared out as sensitive data, restore it. processModel.getTradePeer().setFinalizedWarningTx(warningTx.bitcoinSerialize()); } - log.info("Setting dispute state to {} for tradeId {}.", newDisputeState, processModel.getOfferId()); + log.info("Setting dispute state to {} for tradeId {}.", newDisputeState, trade.getId()); trade.setDisputeState(newDisputeState); processModel.getTradeManager().requestPersistence(); @@ -100,13 +100,13 @@ private void applyWarningConfidence(TransactionConfidence confidence) { TransactionConfidence spendConfidence = spentBy != null ? spentBy.getParentTransaction().getConfidence() : null; // TODO: Should sanity check that we really do have a redirect or claim tx, and not any kind of custom // payout from the warning escrow output, cooperatively constructed with the peer: - if (isTxInNetwork(spendConfidence)) { + if (isInNetwork(spendConfidence)) { applyRedirectOrClaimConfidence(spendConfidence); } else { redirectOrClaimConfidenceListener = new OutputSpendConfidenceListener(warningTxOutput) { @Override - public void onOutputSpendConfidenceChanged(TransactionConfidence confidence) { - if (isTxInNetwork(spendConfidence)) { + public void onOutputSpendConfidenceChanged(TransactionConfidence spendConfidence) { + if (isInNetwork(spendConfidence)) { applyRedirectOrClaimConfidence(spendConfidence); } } @@ -138,6 +138,9 @@ private void applyRedirectOrClaimConfidence(TransactionConfidence confidence) { // Set the peer's claim tx, so that it shows up in the details window for past trades. processModel.getTradePeer().setClaimTx(redirectOrClaimTx.bitcoinSerialize()); } + // TODO: Ensure mediator picks up the updated dispute state, in order to close the ticket at their end. + log.info("Closing trade with dispute state {} for tradeId {}.", newDisputeState, trade.getId()); + processModel.getTradeManager().closeDisputedTrade(trade.getId(), newDisputeState); } else { Transaction myRedirectTx = walletService.getTxFromSerializedTx(processModel.getFinalizedRedirectTx()); boolean isMine = redirectOrClaimTx.equals(myRedirectTx); @@ -146,10 +149,10 @@ private void applyRedirectOrClaimConfidence(TransactionConfidence confidence) { // In case the peer's redirect tx was cleared out as sensitive data, restore it. processModel.getTradePeer().setFinalizedRedirectTx(redirectOrClaimTx.bitcoinSerialize()); } + log.info("Setting dispute state to {} for tradeId {}.", newDisputeState, trade.getId()); + trade.setDisputeState(newDisputeState); + processModel.getTradeManager().requestPersistence(); } - log.info("Setting dispute state to {} for tradeId {}.", newDisputeState, processModel.getOfferId()); - trade.setDisputeState(newDisputeState); - processModel.getTradeManager().requestPersistence(); } else { log.info("Ignoring received redirect/claim tx, as trade funds have already been released."); } @@ -161,7 +164,7 @@ private boolean isFundsUnreleased() { @Contract("null -> false") // (IDEA really should be able to deduce this by itself.) private boolean isWarningTxInNetwork(TransactionConfidence confidence) { - if (isTxInNetwork(confidence)) { + if (isInNetwork(confidence)) { BtcWalletService btcWalletService = processModel.getBtcWalletService(); Transaction tx = Objects.requireNonNull(btcWalletService.getTransaction(confidence.getTransactionHash())); return tx.getLockTime() != 0; @@ -169,7 +172,7 @@ private boolean isWarningTxInNetwork(TransactionConfidence confidence) { return false; } - private static boolean isTxInNetwork(TransactionConfidence confidence) { + private static boolean isInNetwork(TransactionConfidence confidence) { return confidence != null && (confidence.getConfidenceType().equals(TransactionConfidence.ConfidenceType.BUILDING) || confidence.getConfidenceType().equals(TransactionConfidence.ConfidenceType.PENDING)); diff --git a/core/src/main/java/bisq/core/trade/protocol/bisq_v5/tasks/arbitration/CreateSignedClaimTx.java b/core/src/main/java/bisq/core/trade/protocol/bisq_v5/tasks/arbitration/CreateSignedClaimTx.java index 1fe2f59bc41..2ce0c0a714c 100644 --- a/core/src/main/java/bisq/core/trade/protocol/bisq_v5/tasks/arbitration/CreateSignedClaimTx.java +++ b/core/src/main/java/bisq/core/trade/protocol/bisq_v5/tasks/arbitration/CreateSignedClaimTx.java @@ -53,7 +53,8 @@ protected void run() { String tradeId = processModel.getOffer().getId(); TradingPeer tradingPeer = processModel.getTradePeer(); - TransactionOutput myWarningTxOutput = processModel.getWarningTx().getOutput(0); + Transaction myWarningTx = btcWalletService.getTxFromSerializedTx(processModel.getFinalizedWarningTx()); + TransactionOutput myWarningTxOutput = myWarningTx.getOutput(0); // TODO: At present, the claim tx can be picked up by the payout tx listener and set as the trade payout tx. // It's not clear we want this, or perhaps we should always consider a claim as the trade payout. AddressEntry addressEntry = processModel.getBtcWalletService().getOrCreateAddressEntry(tradeId, AddressEntry.Context.TRADE_PAYOUT); diff --git a/core/src/main/java/bisq/core/trade/protocol/bisq_v5/tasks/arbitration/PublishRedirectTx.java b/core/src/main/java/bisq/core/trade/protocol/bisq_v5/tasks/arbitration/PublishRedirectTx.java index 762f8c5f486..b50da3cb628 100644 --- a/core/src/main/java/bisq/core/trade/protocol/bisq_v5/tasks/arbitration/PublishRedirectTx.java +++ b/core/src/main/java/bisq/core/trade/protocol/bisq_v5/tasks/arbitration/PublishRedirectTx.java @@ -49,9 +49,9 @@ protected void run() { btcWalletService.resetCoinLockedInMultiSigAddressEntry(trade.getId()); // We might receive funds on AddressEntry.Context.TRADE_PAYOUT so we don't swap that - Transaction committedWarningTx = WalletService.maybeAddSelfTxToWallet(redirectTx, btcWalletService.getWallet()); + Transaction committedRedirectTx = WalletService.maybeAddSelfTxToWallet(redirectTx, btcWalletService.getWallet()); - processModel.getTradeWalletService().broadcastTx(committedWarningTx, new TxBroadcaster.Callback() { + processModel.getTradeWalletService().broadcastTx(committedRedirectTx, new TxBroadcaster.Callback() { @Override public void onSuccess(Transaction transaction) { log.info("publishRedirectTx onSuccess " + transaction); diff --git a/core/src/main/resources/i18n/displayStrings.properties b/core/src/main/resources/i18n/displayStrings.properties index 707e62fa9f3..0af3c049964 100644 --- a/core/src/main/resources/i18n/displayStrings.properties +++ b/core/src/main/resources/i18n/displayStrings.properties @@ -1087,12 +1087,19 @@ portfolio.pending.warningSent.claimLocked.info=Your trading peer has until {0} ( 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.warningSent.popup.headline=Claim trade collateral +portfolio.pending.warningSent.popup.info=Claim trade collateral +portfolio.pending.warningSent.popup.claim=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.warningSentByPeer.popup.headline=Redirect to arbitration +portfolio.pending.warningSentByPeer.popup.info=Redirect to arbitration +portfolio.pending.warningSentByPeer.popup.openArbitration=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. \ diff --git a/desktop/src/main/java/bisq/desktop/main/overlays/notifications/NotificationCenter.java b/desktop/src/main/java/bisq/desktop/main/overlays/notifications/NotificationCenter.java index dc721b5bf3a..b7efa94857b 100644 --- a/desktop/src/main/java/bisq/desktop/main/overlays/notifications/NotificationCenter.java +++ b/desktop/src/main/java/bisq/desktop/main/overlays/notifications/NotificationCenter.java @@ -228,6 +228,10 @@ else if (trade instanceof SellerTrade && phase.ordinal() == Trade.Phase.FIAT_SEN private void onDisputeStateChanged(Trade trade, Trade.DisputeState disputeState) { String message = null; + // FIXME: If a redirect tx is picked up from the bitcoin network before receipt of the peer's Dispute object, + // the dispute state would have already been changed to REFUND_REQUEST_STARTED_BY_PEER, so the branch below + // won't ever be reached. Need to use a listener to detect receipt of Dispute objects in addition to changes + // in the dispute state. (Or maybe just add another dispute state.) if (refundManager.findOwnDispute(trade.getId()).isPresent()) { String disputeOrTicket = refundManager.findOwnDispute(trade.getId()).get().isSupportTicket() ? Res.get("shared.supportTicket") : 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 b7a6b54423c..78c03b20483 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 @@ -168,13 +168,12 @@ public void show(Dispute dispute) { width = 1150; createGridPane(); addContent(); + // TODO: In case of v5 protocol, check confirmation of redirect tx instead. checkDelayedPayoutTransaction(); display(); if (DevEnv.isDevMode()) { - UserThread.execute(() -> { - summaryNotesTextArea.setText("dummy result...."); - }); + UserThread.execute(() -> summaryNotesTextArea.setText("dummy result....")); } } @@ -224,11 +223,9 @@ protected void createGridPane() { } private void addContent() { - Contract contract = dispute.getContract(); - if (dispute.getDisputeResultProperty().get() == null) - disputeResult = new DisputeResult(dispute.getTradeId(), dispute.getTraderId()); - else - disputeResult = dispute.getDisputeResultProperty().get(); + disputeResult = dispute.getDisputeResultProperty().get() == null + ? new DisputeResult(dispute.getTradeId(), dispute.getTraderId()) + : dispute.getDisputeResultProperty().get(); peersDisputeOptional = checkNotNull(getDisputeManager(dispute)).getDisputesAsObservableList().stream() .filter(d -> dispute.getTradeId().equals(d.getTradeId()) && dispute.getTraderId() != d.getTraderId()) @@ -322,11 +319,11 @@ private void addInfoPane() { } } if (dispute.getExtraDataMap() != null && dispute.getExtraDataMap().size() > 0) { - String extraDataSummary = ""; + var extraDataSummary = new StringBuilder(); for (Map.Entry entry : dispute.getExtraDataMap().entrySet()) { - extraDataSummary += "[" + entry.getKey() + ":" + entry.getValue() + "] "; + extraDataSummary.append('[').append(entry.getKey()).append(':').append(entry.getValue()).append("] "); } - addConfirmationLabelTextField(gridPane, ++rowIndex, Res.get("disputeSummaryWindow.extraInfo"), extraDataSummary); + addConfirmationLabelTextField(gridPane, ++rowIndex, Res.get("disputeSummaryWindow.extraInfo"), extraDataSummary.toString()); } } else { delayedPayoutTxStatus = addConfirmationLabelLabel(gridPane, ++rowIndex, Res.get("disputeSummaryWindow.delayedPayoutStatus"), "Checking...").second; @@ -377,14 +374,10 @@ private void addTradeAmountPayoutControls() { buyerPayoutAmountListener = (observable, oldValue, newValue) -> applyCustomAmounts(buyerPayoutAmountInputTextField, oldValue, newValue); sellerPayoutAmountListener = (observable, oldValue, newValue) -> applyCustomAmounts(sellerPayoutAmountInputTextField, oldValue, newValue); - buyerGetsTradeAmountSelectedListener = (observable, oldValue, newValue) -> { - compensationOrPenalty.setEditable(!newValue); - }; + buyerGetsTradeAmountSelectedListener = (observable, oldValue, newValue) -> compensationOrPenalty.setEditable(!newValue); buyerGetsTradeAmountRadioButton.selectedProperty().addListener(buyerGetsTradeAmountSelectedListener); - sellerGetsTradeAmountSelectedListener = (observable, oldValue, newValue) -> { - compensationOrPenalty.setEditable(!newValue); - }; + sellerGetsTradeAmountSelectedListener = (observable, oldValue, newValue) -> compensationOrPenalty.setEditable(!newValue); sellerGetsTradeAmountRadioButton.selectedProperty().addListener(sellerGetsTradeAmountSelectedListener); customRadioButtonSelectedListener = (observable, oldValue, newValue) -> { @@ -532,9 +525,8 @@ private void addPayoutAmountTextFields() { GridPane.setColumnIndex(vBox, 1); gridPane.getChildren().add(vBox); - compensationOrPenaltyListener = (observable, oldValue, newValue) -> { - applyUpdateFromUi(tradeAmountToggleGroup.selectedToggleProperty().get()); - }; + compensationOrPenaltyListener = (observable, oldValue, newValue) -> + applyUpdateFromUi(tradeAmountToggleGroup.selectedToggleProperty().get()); compensationOrPenalty.textProperty().addListener(compensationOrPenaltyListener); } @@ -692,29 +684,29 @@ protected void addButtons() { Button cancelButton = tuple.second; closeTicketButton.setOnAction(e -> { - if (dispute.getDepositTxSerialized() == null) { - log.warn("dispute.getDepositTxSerialized is null"); - return; - } + if (dispute.getDepositTxSerialized() == null) { + log.warn("dispute.getDepositTxSerialized is null"); + return; + } - if (peersDisputeOptional.isPresent() && peersDisputeOptional.get().isClosed()) { - applyDisputeResult(closeTicketButton); // all checks done already on peers ticket - } else { - maybeCheckTransactions().thenAccept(continue1 -> { - if (continue1) { - checkGeneralValidity().thenAccept(continue2 -> { - if (continue2) { - maybeMakePayout().thenAccept(continue3 -> { - if (continue3) { - applyDisputeResult(closeTicketButton); - } - }); + if (peersDisputeOptional.isPresent() && peersDisputeOptional.get().isClosed()) { + applyDisputeResult(closeTicketButton); // all checks done already on peers ticket + } else { + maybeCheckTransactions().thenAccept(continue1 -> { + if (continue1) { + checkGeneralValidity().thenAccept(continue2 -> { + if (continue2) { + maybeMakePayout().thenAccept(continue3 -> { + if (continue3) { + applyDisputeResult(closeTicketButton); } }); } }); } }); + } + }); cancelButton.setOnAction(e -> { dispute.setDisputeResult(disputeResult); @@ -848,6 +840,11 @@ public void onFailure(TxBroadcastException exception) { } } + // TODO: Need to adapt this to the v5 protocol case. Also, in that case, we should probably check that the tx fees + // paid by the warning & redirect txs don't reduce the amount going to the BM by too much, say by no more than the + // trade security deposit amount, as the fees could spike (or be manipulated) and in general the redirect tx pays + // out a final sum that will be less than the total trade collateral (due to fees). Finally, we should make sure + // that the refund agent can see from the UI whether the redirect tx has confirmed, before paying out. private CompletableFuture maybeCheckTransactions() { final CompletableFuture asyncStatus = new CompletableFuture<>(); var disputeManager = getDisputeManager(dispute); @@ -865,41 +862,39 @@ private CompletableFuture maybeCheckTransactions() { takerFeeTxId, depositTxId, delayedPayoutTxId - ).whenComplete((txList, throwable) -> { - UserThread.execute(() -> { - requestingTxsPopup.hide(); - - if (throwable == null) { - try { - refundManager.verifyTradeTxChain(txList); - if (!dispute.isUsingLegacyBurningMan()) { - Transaction delayedPayoutTx = txList.get(3); - refundManager.verifyDelayedPayoutTxReceivers(delayedPayoutTx, dispute); - } - asyncStatus.complete(true); - } catch (Throwable error) { - UserThread.runAfter(() -> { - Popup popup = new Popup(); - popup.warning(Res.get("disputeSummaryWindow.delayedPayoutTxVerificationFailed", error.getMessage())) - .actionButtonText(Res.get("shared.continueAnyway")) - .onAction(() -> asyncStatus.complete(true)) - .onClose(() -> asyncStatus.complete(false)) - .show(); - }, - 100, - TimeUnit.MILLISECONDS); + ).whenComplete((txList, throwable) -> UserThread.execute(() -> { + requestingTxsPopup.hide(); + + if (throwable == null) { + try { + refundManager.verifyTradeTxChain(txList); + if (!dispute.isUsingLegacyBurningMan()) { + Transaction delayedPayoutTx = txList.get(3); + refundManager.verifyDelayedPayoutTxReceivers(delayedPayoutTx, dispute); } - } else { - UserThread.runAfter(() -> - new Popup().warning(Res.get("disputeSummaryWindow.requestTransactionsError", throwable.getMessage())) - .onAction(() -> asyncStatus.complete(true)) - .onClose(() -> asyncStatus.complete(false)) - .show(), + asyncStatus.complete(true); + } catch (Throwable error) { + UserThread.runAfter(() -> { + Popup popup = new Popup(); + popup.warning(Res.get("disputeSummaryWindow.delayedPayoutTxVerificationFailed", error.getMessage())) + .actionButtonText(Res.get("shared.continueAnyway")) + .onAction(() -> asyncStatus.complete(true)) + .onClose(() -> asyncStatus.complete(false)) + .show(); + }, 100, TimeUnit.MILLISECONDS); } - }); - }); + } else { + UserThread.runAfter(() -> + new Popup().warning(Res.get("disputeSummaryWindow.requestTransactionsError", throwable.getMessage())) + .onAction(() -> asyncStatus.complete(true)) + .onClose(() -> asyncStatus.complete(false)) + .show(), + 100, + TimeUnit.MILLISECONDS); + } + })); } else { asyncStatus.complete(true); } @@ -1131,10 +1126,10 @@ private void applyUiControlsToDisputeResult(Toggle selectedTradeAmountToggle) { disputeResult.setBuyerPayoutAmount(totalPot.subtract(minRefundAtDispute)); } - // winner is the one who receives most from the multisig, or if equal, the buyer. + // winner is the one who receives most from the multisig, or if equal, the seller. // (winner is used to decide who publishes the tx) disputeResult.setWinner(disputeResult.getSellerPayoutAmount().isLessThan(disputeResult.getBuyerPayoutAmount()) ? - DisputeResult.Winner.BUYER : DisputeResult.Winner.BUYER); + DisputeResult.Winner.BUYER : DisputeResult.Winner.SELLER); } private void applyDisputeResultToUiControls() { 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 20f8c3d2d58..afee99981f6 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 @@ -268,6 +268,21 @@ public void onSendWarning() { errorMessage -> new Popup().error(errorMessage).show()); } + public void onClaimCollateral() { + Trade trade = getTrade(); + if (trade == null) { + log.error("Trade is null"); + return; + } + if (!trade.getDisputeState().equals(Trade.DisputeState.WARNING_SENT)) { + log.error("Wrong dispute state: expected WARNING_SENT but got {}", trade.getDisputeState()); + return; + } + ((DisputeProtocol) tradeManager.getTradeProtocol(trade)).onPublishClaimTx( + () -> log.info("Claim tx published"), + errorMessage -> new Popup().error(errorMessage).show()); + } + public void onOpenSupportTicket() { tryOpenDispute(true); } @@ -524,7 +539,8 @@ private void doOpenDispute(boolean isSupportTicket, Transaction depositTx) { } Trade.DisputeState disputeState = trade.getDisputeState(); DisputeManager> disputeManager; - long lockTime = trade.getDelayedPayoutTx() == null ? trade.getLockTime() : trade.getDelayedPayoutTx().getLockTime(); + Transaction delayedPayoutTx = trade.getDelayedPayoutTx(); + long lockTime = delayedPayoutTx == null ? trade.getLockTime() : delayedPayoutTx.getLockTime(); long remainingLockTime = lockTime - btcWalletService.getBestChainHeight(); // In case we re-open a dispute we allow Trade.DisputeState.MEDIATION_REQUESTED boolean useMediation = disputeState == Trade.DisputeState.NO_DISPUTE || @@ -534,7 +550,6 @@ private void doOpenDispute(boolean isSupportTicket, Transaction depositTx) { disputeState == Trade.DisputeState.REFUND_REQUESTED || remainingLockTime <= 0; AtomicReference donationAddressString = new AtomicReference<>(null); - Transaction delayedPayoutTx = trade.getDelayedPayoutTx(); try { TradeDataValidation.validateDelayedPayoutTx(trade, delayedPayoutTx, @@ -599,9 +614,25 @@ private void doOpenDispute(boolean isSupportTicket, Transaction depositTx) { } else if (useRefundAgent) { resultHandler = () -> navigation.navigateTo(MainView.class, SupportView.class, RefundClientView.class); - if (delayedPayoutTx == null) { - log.error("Delayed payout tx is missing"); - return; + Transaction peersWarningTx = trade instanceof BuyerTrade ? + trade.getSellersWarningTx(btcWalletService) : trade.getBuyersWarningTx(btcWalletService); + Transaction redirectTx = trade instanceof BuyerTrade ? + trade.getBuyersRedirectTx(btcWalletService) : trade.getSellersRedirectTx(btcWalletService); + + if (trade.hasV5Protocol()) { + if (peersWarningTx == null) { + log.error("Peer's warning tx is missing"); + return; + } + if (redirectTx == null) { + log.error("Redirect tx is missing"); + return; + } + } else { + if (delayedPayoutTx == null) { + log.error("Delayed payout tx is missing"); + return; + } } // We only require for refund agent a confirmed deposit tx. For mediation we tolerate a unconfirmed tx as @@ -663,15 +694,26 @@ private void doOpenDispute(boolean isSupportTicket, Transaction depositTx) { }); dispute.setDonationAddressOfDelayedPayoutTx(donationAddressString.get()); - dispute.setDelayedPayoutTxId(delayedPayoutTx.getTxId().toString()); - trade.setDisputeState(Trade.DisputeState.REFUND_REQUESTED); + if (delayedPayoutTx != null) { + dispute.setDelayedPayoutTxId(delayedPayoutTx.getTxId().toString()); + trade.setDisputeState(Trade.DisputeState.REFUND_REQUESTED); + } else { + dispute.setWarningTxId(peersWarningTx.getTxId().toString()); + dispute.setRedirectTxId(redirectTx.getTxId().toString()); + } dispute.setBurningManSelectionHeight(trade.getProcessModel().getBurningManSelectionHeight()); dispute.setTradeTxFee(trade.getTradeTxFeeAsLong()); - ((DisputeProtocol) tradeManager.getTradeProtocol(trade)).onPublishDelayedPayoutTx( - () -> log.info("DelayedPayoutTx published and message sent to peer"), - errorMessage -> new Popup().error(errorMessage).show()); + if (delayedPayoutTx != null) { + ((DisputeProtocol) tradeManager.getTradeProtocol(trade)).onPublishDelayedPayoutTx( + () -> log.info("DelayedPayoutTx published and message sent to peer"), + errorMessage -> new Popup().error(errorMessage).show()); + } else { + ((DisputeProtocol) tradeManager.getTradeProtocol(trade)).onPublishRedirectTx( + () -> log.info("redirectTx published"), + errorMessage -> new Popup().error(errorMessage).show()); + } sendOpenDisputeMessage(disputeManager, resultHandler, dispute); } else { log.warn("Invalid dispute state {}", disputeState.name()); 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 99b619d8467..75ac5c6d360 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 @@ -107,7 +107,7 @@ public abstract class TradeStepView extends AnchorPane { private final ClockWatcher.Listener clockListener; private final ChangeListener errorMessageListener; protected Label infoLabel; - private Popup acceptMediationResultPopup; + private Popup acceptMediationResultPopup; // TODO: Should we rename this, as it's no longer just being used for the mediation result? private BootstrapListener bootstrapListener; private TradeSubView.ChatCallback chatCallback; private final NewBestBlockListener newBestBlockListener; @@ -559,14 +559,6 @@ private Tuple2 getClaimTxRemainingBlocksAndLockTime() { return new Tuple2<>(remaining, claimTxLockTime); } - private void openRedirectToArbitrationPopup() { - // TODO: Implement. - } - - private void openClaimCollateralPopup() { - // TODO: Implement. - } - protected void updateMediationResultState(boolean blockOpeningOfResultAcceptedPopup) { if (isInArbitration()) { if (isRefundRequestStartedByPeer()) { @@ -714,6 +706,40 @@ private void openMediationResultPopup(String headLine) { acceptMediationResultPopup.show(); } + private void openRedirectToArbitrationPopup() { + if (acceptMediationResultPopup != null || trade.getPayoutTx() != null) { + return; + } + // TODO: Improve/elaborate display strings: + String headLine = Res.get("portfolio.pending.warningSentByPeer.popup.headline"); + String message = Res.get("portfolio.pending.warningSentByPeer.popup.info"); + String actionButtonText = Res.get("portfolio.pending.warningSentByPeer.popup.openArbitration"); + acceptMediationResultPopup = new Popup() + .headLine(headLine) + .instruction(message) + .actionButtonText(actionButtonText) + .onAction(this::startArbitration) + .onClose(() -> acceptMediationResultPopup = null); + acceptMediationResultPopup.show(); + } + + private void openClaimCollateralPopup() { + if (acceptMediationResultPopup != null || trade.getPayoutTx() != null) { + return; + } + // TODO: Improve/elaborate display strings: + String headLine = Res.get("portfolio.pending.warningSent.popup.headline"); + String message = Res.get("portfolio.pending.warningSent.popup.info"); + String actionButtonText = Res.get("portfolio.pending.warningSent.popup.claim"); + acceptMediationResultPopup = new Popup() + .headLine(headLine) + .instruction(message) + .actionButtonText(actionButtonText) + .onAction(this::claimCollateral) + .onClose(() -> acceptMediationResultPopup = null); + acceptMediationResultPopup.show(); + } + private void acceptProposal() { model.dataModel.mediationManager.onAcceptMediationResult(trade, () -> { @@ -731,13 +757,18 @@ private void rejectProposal() { acceptMediationResultPopup = null; } + private void sendWarning() { + model.dataModel.onSendWarning(); + acceptMediationResultPopup = null; + } + private void startArbitration() { model.dataModel.onOpenDispute(); acceptMediationResultPopup = null; } - private void sendWarning() { - model.dataModel.onSendWarning(); + private void claimCollateral() { + model.dataModel.onClaimCollateral(); acceptMediationResultPopup = null; } diff --git a/proto/src/main/proto/pb.proto b/proto/src/main/proto/pb.proto index 26ebbf75c44..ef2b874c5ff 100644 --- a/proto/src/main/proto/pb.proto +++ b/proto/src/main/proto/pb.proto @@ -1010,6 +1010,8 @@ message Dispute { map extra_data = 30; int32 burning_man_selection_height = 31; // Added in v 1.9.7 int64 trade_tx_fee = 32; // Added in v 1.9.7 + string warning_tx_id = 33; // Added for v5 protocol + string redirect_tx_id = 34; // Added for v5 protocol } message Attachment {