refactor dispute preparation and requesting off main thread

This commit is contained in:
woodser 2025-09-22 03:23:31 -04:00 committed by woodser
parent 4e188a9343
commit 06f472dc53
35 changed files with 416 additions and 246 deletions

View file

@ -22,7 +22,6 @@ import com.google.inject.Inject;
import com.google.inject.Singleton;
import com.google.inject.name.Named;
import haveno.common.ThreadUtils;
import haveno.common.crypto.KeyRing;
import haveno.common.crypto.PubKeyRing;
import haveno.common.handlers.FaultHandler;
@ -101,57 +100,51 @@ public class CoreDisputesService {
public void openDispute(String tradeId, ResultHandler resultHandler, FaultHandler faultHandler) {
Trade trade = tradeManager.getOpenTrade(tradeId).orElseThrow(() ->
new IllegalArgumentException(format("trade with id '%s' not found", tradeId)));
Offer offer = trade.getOffer();
if (offer == null) throw new IllegalStateException(format("offer with tradeId '%s' is null", tradeId));
// open dispute on trade thread
ThreadUtils.execute(() -> {
Offer offer = trade.getOffer();
if (offer == null) throw new IllegalStateException(format("offer with tradeId '%s' is null", tradeId));
// Dispute agents are registered as mediators and refund agents, but current UI appears to be hardcoded
// to reference the arbitrator. Reference code is in desktop PendingTradesDataModel.java and could be refactored.
var disputeManager = arbitrationManager;
var isSupportTicket = false;
var isMaker = tradeManager.isMyOffer(offer);
var dispute = createDisputeForTrade(trade, offer, keyRing.getPubKeyRing(), isMaker, isSupportTicket);
// Dispute agents are registered as mediators and refund agents, but current UI appears to be hardcoded
// to reference the arbitrator. Reference code is in desktop PendingTradesDataModel.java and could be refactored.
var disputeManager = arbitrationManager;
var isSupportTicket = false;
var isMaker = tradeManager.isMyOffer(offer);
var dispute = createDisputeForTrade(trade, offer, keyRing.getPubKeyRing(), isMaker, isSupportTicket);
// Sends the openNewDisputeMessage to arbitrator, who will then create 2 disputes
// one for the opener, the other for the peer, see sendPeerOpenedDisputeMessage.
disputeManager.sendDisputeOpenedMessage(dispute, resultHandler, faultHandler);
tradeManager.requestPersistence();
}, trade.getId());
// Sends the openNewDisputeMessage to arbitrator, who will then create 2 disputes
// one for the opener, the other for the peer, see sendPeerOpenedDisputeMessage.
disputeManager.sendDisputeOpenedMessage(dispute, resultHandler, faultHandler);
tradeManager.requestPersistence();
}
public Dispute createDisputeForTrade(Trade trade, Offer offer, PubKeyRing pubKey, boolean isMaker, boolean isSupportTicket) {
synchronized (trade.getLock()) {
byte[] payoutTxSerialized = null;
String payoutTxHashAsString = null;
byte[] payoutTxSerialized = null;
String payoutTxHashAsString = null;
PubKeyRing arbitratorPubKeyRing = trade.getArbitrator().getPubKeyRing();
checkNotNull(arbitratorPubKeyRing, "arbitratorPubKeyRing must not be null");
Dispute dispute = new Dispute(new Date().getTime(),
trade.getId(),
pubKey.hashCode(), // trader id,
true,
(offer.getDirection() == OfferDirection.BUY) == isMaker,
isMaker,
pubKey,
trade.getDate().getTime(),
trade.getMaxTradePeriodDate().getTime(),
trade.getContract(),
trade.getContractHash(),
payoutTxSerialized,
payoutTxHashAsString,
trade.getContractAsJson(),
trade.getMaker().getContractSignature(),
trade.getTaker().getContractSignature(),
trade.getMaker().getPaymentAccountPayload(),
trade.getTaker().getPaymentAccountPayload(),
arbitratorPubKeyRing,
isSupportTicket,
SupportType.ARBITRATION);
PubKeyRing arbitratorPubKeyRing = trade.getArbitrator().getPubKeyRing();
checkNotNull(arbitratorPubKeyRing, "arbitratorPubKeyRing must not be null");
Dispute dispute = new Dispute(new Date().getTime(),
trade.getId(),
pubKey.hashCode(), // trader id,
true,
(offer.getDirection() == OfferDirection.BUY) == isMaker,
isMaker,
pubKey,
trade.getDate().getTime(),
trade.getMaxTradePeriodDate().getTime(),
trade.getContract(),
trade.getContractHash(),
payoutTxSerialized,
payoutTxHashAsString,
trade.getContractAsJson(),
trade.getMaker().getContractSignature(),
trade.getTaker().getContractSignature(),
trade.getMaker().getPaymentAccountPayload(),
trade.getTaker().getPaymentAccountPayload(),
arbitratorPubKeyRing,
isSupportTicket,
SupportType.ARBITRATION);
return dispute;
}
return dispute;
}
// TODO: does not wait for success or error response
@ -313,6 +306,7 @@ public class CoreDisputesService {
Dispute dispute;
if (disputeOptional.isPresent()) dispute = disputeOptional.get();
else throw new IllegalStateException(format("dispute with id '%s' not found", disputeId));
if (!arbitrationManager.canSendChatMessages(dispute)) throw new IllegalStateException(format("dispute with id '%s' cannot send chat messages (must be open or stored to mailbox)", disputeId));
ChatMessage chatMessage = new ChatMessage(
arbitrationManager.getSupportType(),
dispute.getTradeId(),

View file

@ -122,6 +122,8 @@ public abstract class SupportManager {
public abstract void requestPersistence();
public abstract void persistNow(@Nullable Runnable completeHandler);
///////////////////////////////////////////////////////////////////////////////////////////
// Delegates p2pService
@ -195,7 +197,7 @@ public abstract class SupportManager {
synchronized (dispute.getChatMessages()) {
for (ChatMessage chatMessage : dispute.getChatMessages()) {
if (chatMessage.getUid().equals(ackMessage.getSourceUid())) {
if (trade.getDisputeState() == Trade.DisputeState.DISPUTE_REQUESTED) {
if (trade.getDisputeState() == Trade.DisputeState.DISPUTE_PREPARING || trade.getDisputeState() == Trade.DisputeState.DISPUTE_REQUESTED) { // ack can arrive before saw arrived
if (dispute.isClosed()) dispute.reOpen();
trade.advanceDisputeState(Trade.DisputeState.DISPUTE_OPENED);
} else if (dispute.isClosed()) {
@ -217,14 +219,10 @@ public abstract class SupportManager {
synchronized (dispute.getChatMessages()) {
for (ChatMessage chatMessage : dispute.getChatMessages()) {
if (chatMessage.getUid().equals(ackMessage.getSourceUid())) {
if (trade.getDisputeState().isRequested()) {
if (!trade.isArbitrator() && (trade.getDisputeState().isRequested() || trade.getDisputeState().isCloseRequested())) {
log.warn("DisputeOpenedMessage was nacked. We close the dispute now. tradeId={}, nack sender={}", trade.getId(), ackMessage.getSenderNodeAddress());
dispute.setIsClosed();
trade.advanceDisputeState(Trade.DisputeState.DISPUTE_CLOSED);
} else if (trade.getDisputeState().isCloseRequested()) {
log.warn("DisputeCloseMessage was nacked. We close the dispute now. tradeId={}, nack sender={}", trade.getId(), ackMessage.getSenderNodeAddress());
dispute.setIsClosed();
trade.advanceDisputeState(Trade.DisputeState.DISPUTE_CLOSED);
}
}
}
@ -243,6 +241,9 @@ public abstract class SupportManager {
msg.setAckError(ackMessage.getErrorMessage());
});
});
tradeManager.persistNow(null);
persistNow(null);
requestPersistence();
}
}
@ -266,7 +267,7 @@ public abstract class SupportManager {
mailboxMessageService.sendEncryptedMailboxMessage(peersNodeAddress,
receiverPubKeyRing,
message,
message.copy(),
new SendMailboxMessageListener() {
@Override
public void onArrived() {

View file

@ -159,14 +159,12 @@ public abstract class DisputeListService<T extends DisputeList<Dispute>> impleme
EasyBind.subscribe(dispute.getBadgeCountProperty(),
isAlerting -> {
// We get the event before the list gets updated, so we execute on next frame
UserThread.execute(() -> {
synchronized (disputeList.getObservableList()) {
int numAlerts = (int) disputeList.getList().stream()
.mapToLong(x -> x.getBadgeCountProperty().getValue())
.sum();
numOpenDisputes.set(numAlerts);
}
});
synchronized (disputeList.getObservableList()) {
int numAlerts = (int) disputeList.getList().stream()
.mapToLong(x -> x.getBadgeCountProperty().getValue())
.sum();
UserThread.execute(() -> numOpenDisputes.set(numAlerts));
}
});
disputedTradeIds.add(dispute.getTradeId());
});
@ -176,4 +174,8 @@ public abstract class DisputeListService<T extends DisputeList<Dispute>> impleme
public void requestPersistence() {
persistenceManager.requestPersistence();
}
public void persistNow(@Nullable Runnable completeHandler) {
persistenceManager.persistNow(completeHandler);
}
}

View file

@ -66,6 +66,7 @@ import haveno.core.trade.Contract;
import haveno.core.trade.HavenoUtils;
import haveno.core.trade.SellerTrade;
import haveno.core.trade.Trade;
import haveno.core.trade.Trade.DisputeState;
import haveno.core.trade.TradeManager;
import haveno.core.trade.protocol.TradePeer;
import haveno.core.xmr.wallet.Restrictions;
@ -158,6 +159,7 @@ public abstract class DisputeManager<T extends DisputeList<Dispute>> extends Sup
@Override
public void requestPersistence() {
tradeManager.requestPersistence();
disputeListService.requestPersistence();
}
@ -166,6 +168,12 @@ public abstract class DisputeManager<T extends DisputeList<Dispute>> extends Sup
disputeListService.requestPersistence();
}
@Override
public void persistNow(@Nullable Runnable completeHandler) {
tradeManager.persistNow(null);
disputeListService.persistNow(completeHandler);
}
@Override
public NodeAddress getPeerNodeAddress(ChatMessage message) {
Optional<Dispute> disputeOptional = findDispute(message);
@ -347,6 +355,36 @@ public abstract class DisputeManager<T extends DisputeList<Dispute>> extends Sup
return;
}
// update dispute state
trade.setDisputeState(Trade.DisputeState.DISPUTE_PREPARING);
// open dispute on trade thread
ThreadUtils.execute(() -> {
// try to send dispute
try {
sendDisputeOpenedMessageAux(trade, dispute, resultHandler, (errorMessage, throwable) -> {
log.warn("Failed to open dispute for trade: " + dispute.getTradeId() + ": " + errorMessage, throwable);
removeDisputes(trade);
faultHandler.handleFault(errorMessage, throwable);
});
} catch (Exception e) {
String errorMsg = "Failed to open dispute for trade " + dispute.getTradeId() + ": " + e.getMessage();
log.error(errorMsg, e);
removeDisputes(trade);
faultHandler.handleFault(errorMsg, e);
}
}, trade.getId());
}
private void sendDisputeOpenedMessageAux(Trade trade,
Dispute dispute,
ResultHandler resultHandler,
FaultHandler faultHandler) {
log.info("Opening dispute for {} {}, dispute {}",
DisputeOpenedMessage.class.getSimpleName(), trade.getClass().getSimpleName(),
dispute.getTradeId(), dispute.getId());
// arbitrator cannot open disputes
if (trade.isArbitrator()) {
String errorMsg = "Arbitrators cannot open disputes.";
@ -354,13 +392,10 @@ public abstract class DisputeManager<T extends DisputeList<Dispute>> extends Sup
return;
}
log.info("Sending {} for {} {}, dispute {}",
DisputeOpenedMessage.class.getSimpleName(), trade.getClass().getSimpleName(),
dispute.getTradeId(), dispute.getId());
T disputeList = getDisputeList();
if (disputeList == null) {
log.warn("disputes is null");
// verify deposits unlocked or one is missing
if (trade.getPhase().ordinal() < Trade.Phase.DEPOSITS_UNLOCKED.ordinal() && !trade.isDepositTxMissing()) {
String errorMsg = Res.get("portfolio.pending.error.depositTxNotConfirmed");
faultHandler.handleFault(errorMsg, new IllegalStateException(errorMsg));
return;
}
@ -371,6 +406,8 @@ public abstract class DisputeManager<T extends DisputeList<Dispute>> extends Sup
return;
}
// set dispute
T disputeList = getDisputeList();
synchronized (disputeList.getObservableList()) {
if (disputeList.contains(dispute)) {
String msg = "We got a dispute msg that we have already stored. TradeId = " + dispute.getTradeId() + ", DisputeId = " + dispute.getId();
@ -388,114 +425,144 @@ public abstract class DisputeManager<T extends DisputeList<Dispute>> extends Sup
} else {
disputeList.add(dispute);
}
String disputeInfo = getDisputeInfo(dispute);
String sysMsg = dispute.isSupportTicket() ?
Res.get("support.youOpenedTicket", disputeInfo, Version.VERSION) :
Res.get("support.youOpenedDispute", disputeInfo, Version.VERSION);
ChatMessage chatMessage = new ChatMessage(
getSupportType(),
dispute.getTradeId(),
keyRing.getPubKeyRing().hashCode(),
false,
Res.get("support.systemMsg", sysMsg),
p2PService.getAddress());
chatMessage.setSystemMessage(true);
dispute.addAndPersistChatMessage(chatMessage);
// try to import latest multisig info
try {
trade.importMultisigHex();
} catch (Exception e) {
log.error("Failed to import multisig hex", e);
}
// try to export latest multisig info
try {
trade.exportMultisigHex();
if (trade instanceof SellerTrade) {
trade.getProcessModel().setPaymentSentPayoutTxStale(true); // exporting multisig hex will invalidate previously unsigned payout txs
trade.getSelf().setUnsignedPayoutTxHex(null);
}
} catch (Exception e) {
log.error("Failed to export multisig hex", e);
}
// create dispute opened message
NodeAddress agentNodeAddress = getAgentNodeAddress(dispute);
DisputeOpenedMessage disputeOpenedMessage = new DisputeOpenedMessage(dispute,
p2PService.getAddress(),
UUID.randomUUID().toString(),
getSupportType(),
trade.getSelf().getUpdatedMultisigHex(),
trade.getArbitrator().getPaymentSentMessage());
log.info("Send {} to peer {}. tradeId={}, openNewDisputeMessage.uid={}, " +
"chatMessage.uid={}",
disputeOpenedMessage.getClass().getSimpleName(), agentNodeAddress,
disputeOpenedMessage.getTradeId(), disputeOpenedMessage.getUid(),
chatMessage.getUid());
recordPendingMessage(disputeOpenedMessage.getClass().getSimpleName());
// send dispute opened message
trade.setDisputeState(Trade.DisputeState.DISPUTE_REQUESTED);
mailboxMessageService.sendEncryptedMailboxMessage(agentNodeAddress,
dispute.getAgentPubKeyRing(),
disputeOpenedMessage,
new SendMailboxMessageListener() {
@Override
public void onArrived() {
log.info("{} arrived at peer {}. tradeId={}, openNewDisputeMessage.uid={}, " +
"chatMessage.uid={}",
disputeOpenedMessage.getClass().getSimpleName(), agentNodeAddress,
disputeOpenedMessage.getTradeId(), disputeOpenedMessage.getUid(),
chatMessage.getUid());
clearPendingMessage();
// We use the chatMessage wrapped inside the openNewDisputeMessage for
// the state, as that is displayed to the user and we only persist that msg
chatMessage.setArrived(true);
trade.advanceDisputeState(Trade.DisputeState.DISPUTE_REQUESTED);
requestPersistence();
resultHandler.handleResult();
}
@Override
public void onStoredInMailbox() {
log.info("{} stored in mailbox for peer {}. tradeId={}, openNewDisputeMessage.uid={}, " +
"chatMessage.uid={}",
disputeOpenedMessage.getClass().getSimpleName(), agentNodeAddress,
disputeOpenedMessage.getTradeId(), disputeOpenedMessage.getUid(),
chatMessage.getUid());
clearPendingMessage();
// We use the chatMessage wrapped inside the openNewDisputeMessage for
// the state, as that is displayed to the user and we only persist that msg
chatMessage.setStoredInMailbox(true);
requestPersistence();
resultHandler.handleResult();
}
@Override
public void onFault(String errorMessage) {
log.error("{} failed: Peer {}. tradeId={}, openNewDisputeMessage.uid={}, " +
"chatMessage.uid={}, errorMessage={}",
disputeOpenedMessage.getClass().getSimpleName(), agentNodeAddress,
disputeOpenedMessage.getTradeId(), disputeOpenedMessage.getUid(),
chatMessage.getUid(), errorMessage);
clearPendingMessage();
// We use the chatMessage wrapped inside the openNewDisputeMessage for
// the state, as that is displayed to the user and we only persist that msg
chatMessage.setSendMessageError(errorMessage);
trade.setDisputeState(Trade.DisputeState.NO_DISPUTE);
requestPersistence();
faultHandler.handleFault("Sending dispute message failed: " +
errorMessage, new DisputeMessageDeliveryFailedException());
}
});
}
// save state
persistNow(null);
String disputeInfo = getDisputeInfo(dispute);
String sysMsg = dispute.isSupportTicket() ?
Res.get("support.youOpenedTicket", disputeInfo, Version.VERSION) :
Res.get("support.youOpenedDispute", disputeInfo, Version.VERSION);
ChatMessage chatMessage = new ChatMessage(
getSupportType(),
dispute.getTradeId(),
keyRing.getPubKeyRing().hashCode(),
false,
Res.get("support.systemMsg", sysMsg),
p2PService.getAddress());
chatMessage.setSystemMessage(true);
dispute.addAndPersistChatMessage(chatMessage);
// try to import latest multisig info
try {
trade.importMultisigHex();
} catch (Exception e) {
if (!trade.isShutDownStarted()) log.error("Failed to import multisig hex", e);
}
// abort if shutting down
if (trade.isShutDownStarted()) {
String errorMsg = "Aborting opening dispute for " + trade.getClass().getSimpleName() + " " + trade.getId() + " because shut down is started";
faultHandler.handleFault(errorMsg, new IllegalStateException(errorMsg));
return;
}
// try to export latest multisig info
try {
trade.exportMultisigHex();
if (trade instanceof SellerTrade) {
trade.getProcessModel().setPaymentSentPayoutTxStale(true); // exporting multisig hex will invalidate previously unsigned payout txs
trade.getSelf().setUnsignedPayoutTxHex(null);
}
} catch (Exception e) {
if (!trade.isShutDownStarted()) log.error("Failed to export multisig hex", e);
}
// abort if shutting down
if (trade.isShutDownStarted()) {
String errorMsg = "Aborting opening dispute for " + trade.getClass().getSimpleName() + " " + trade.getId() + " because shut down is started";
faultHandler.handleFault(errorMsg, new IllegalStateException(errorMsg));
return;
}
// create dispute opened message
NodeAddress agentNodeAddress = getAgentNodeAddress(dispute);
DisputeOpenedMessage disputeOpenedMessage = new DisputeOpenedMessage(dispute,
p2PService.getAddress(),
UUID.randomUUID().toString(),
getSupportType(),
trade.getSelf().getUpdatedMultisigHex(),
trade.getArbitrator().getPaymentSentMessage());
log.info("Send {} to peer {}. tradeId={}, openNewDisputeMessage.uid={}, " +
"chatMessage.uid={}",
disputeOpenedMessage.getClass().getSimpleName(), agentNodeAddress,
disputeOpenedMessage.getTradeId(), disputeOpenedMessage.getUid(),
chatMessage.getUid());
recordPendingMessage(disputeOpenedMessage.getClass().getSimpleName());
// send dispute opened message
mailboxMessageService.sendEncryptedMailboxMessage(agentNodeAddress,
dispute.getAgentPubKeyRing(),
disputeOpenedMessage,
new SendMailboxMessageListener() {
@Override
public void onArrived() {
log.info("{} arrived at peer {}. tradeId={}, openNewDisputeMessage.uid={}, " +
"chatMessage.uid={}",
disputeOpenedMessage.getClass().getSimpleName(), agentNodeAddress,
disputeOpenedMessage.getTradeId(), disputeOpenedMessage.getUid(),
chatMessage.getUid());
clearPendingMessage();
// We use the chatMessage wrapped inside the openNewDisputeMessage for
// the state, as that is displayed to the user and we only persist that msg
chatMessage.setArrived(true);
trade.advanceDisputeState(Trade.DisputeState.DISPUTE_REQUESTED);
persistNow(null);
if (resultHandler != null) resultHandler.handleResult();
}
@Override
public void onStoredInMailbox() {
log.info("{} stored in mailbox for peer {}. tradeId={}, openNewDisputeMessage.uid={}, " +
"chatMessage.uid={}",
disputeOpenedMessage.getClass().getSimpleName(), agentNodeAddress,
disputeOpenedMessage.getTradeId(), disputeOpenedMessage.getUid(),
chatMessage.getUid());
clearPendingMessage();
// We use the chatMessage wrapped inside the openNewDisputeMessage for
// the state, as that is displayed to the user and we only persist that msg
chatMessage.setStoredInMailbox(true);
trade.advanceDisputeState(Trade.DisputeState.DISPUTE_REQUESTED);
trade.persistNow(null);
persistNow(null);
if (resultHandler != null) resultHandler.handleResult();
}
@Override
public void onFault(String errorMessage) {
log.error("{} failed: Peer {}. tradeId={}, openNewDisputeMessage.uid={}, " +
"chatMessage.uid={}, errorMessage={}",
disputeOpenedMessage.getClass().getSimpleName(), agentNodeAddress,
disputeOpenedMessage.getTradeId(), disputeOpenedMessage.getUid(),
chatMessage.getUid(), errorMessage);
clearPendingMessage();
// We use the chatMessage wrapped inside the openNewDisputeMessage for
// the state, as that is displayed to the user and we only persist that msg
chatMessage.setSendMessageError(errorMessage);
trade.setDisputeState(Trade.DisputeState.NO_DISPUTE);
persistNow(null);
faultHandler.handleFault("Sending dispute message failed: " +
errorMessage, new DisputeMessageDeliveryFailedException());
}
});
persistNow(null);
}
public void removeDisputes(Trade trade) {
T disputeList = getDisputeList();
synchronized (disputeList.getObservableList()) {
for (Dispute dispute : trade.getDisputes()) {
disputeList.remove(dispute);
}
}
trade.setDisputeState(Trade.DisputeState.NO_DISPUTE);
clearPendingMessage();
requestPersistence();
}
@ -793,7 +860,7 @@ public abstract class DisputeManager<T extends DisputeList<Dispute>> extends Sup
// We use the chatMessage wrapped inside the peerOpenedDisputeMessage for
// the state, as that is displayed to the user and we only persist that msg
chatMessage.setArrived(true);
requestPersistence();
persistNow(null);
}
@Override
@ -808,7 +875,7 @@ public abstract class DisputeManager<T extends DisputeList<Dispute>> extends Sup
// We use the chatMessage wrapped inside the peerOpenedDisputeMessage for
// the state, as that is displayed to the user and we only persist that msg
chatMessage.setStoredInMailbox(true);
requestPersistence();
persistNow(null);
}
@Override
@ -823,11 +890,11 @@ public abstract class DisputeManager<T extends DisputeList<Dispute>> extends Sup
// We use the chatMessage wrapped inside the peerOpenedDisputeMessage for
// the state, as that is displayed to the user and we only persist that msg
chatMessage.setSendMessageError(errorMessage);
requestPersistence();
persistNow(null);
}
}
);
requestPersistence();
persistNow(null);
}
// arbitrator sends result to trader when their dispute is closed
@ -1110,6 +1177,24 @@ public abstract class DisputeManager<T extends DisputeList<Dispute>> extends Sup
return retVal;
}
public boolean canSendChatMessages(Dispute dispute) {
Optional<Trade> tradeOptional = findTrade(dispute);
if (!tradeOptional.isPresent()) {
log.warn("Dispute trade {} does not exist", dispute.getTradeId());
return false;
}
Trade trade = tradeOptional.get();
if (trade.isPayoutPublished()) return false;
if (trade.getDisputeState() == DisputeState.DISPUTE_REQUESTED) {
for (ChatMessage msg : dispute.getChatMessages()) {
if (Boolean.TRUE.equals(msg.getStoredInMailboxProperty().get())) {
return true;
}
}
}
return trade.getDisputeState().isOpen();
}
private void addMediationResultMessage(Dispute dispute) {
// In case of refundAgent we add a message with the mediatorsDisputeSummary. Only visible for refundAgent.
if (dispute.getMediatorsDisputeResult() != null) {

View file

@ -67,6 +67,7 @@ import haveno.core.trade.Contract;
import haveno.core.trade.HavenoUtils;
import haveno.core.trade.Trade;
import haveno.core.trade.TradeManager;
import haveno.core.trade.Trade.DisputeState;
import haveno.core.trade.protocol.TradeProtocol;
import haveno.core.xmr.wallet.TradeWalletService;
import haveno.core.xmr.wallet.XmrWalletService;
@ -172,11 +173,11 @@ public final class ArbitrationManager extends DisputeManager<ArbitrationDisputeL
@Override
public void cleanupDisputes() {
// remove disputes opened by arbitrator, which is not allowed
Set<Dispute> toRemoves = new HashSet<>();
List<Dispute> disputes = getDisputeList().getList();
synchronized (disputes) {
// collect disputes to remove
Set<Dispute> toRemoves = new HashSet<>();
for (Dispute dispute : disputes) {
// get dispute's trade
@ -186,15 +187,47 @@ public final class ArbitrationManager extends DisputeManager<ArbitrationDisputeL
return;
}
// collect dispute if owned by arbitrator
// remove dispute if owned by arbitrator
if (dispute.getTraderPubKeyRing().equals(trade.getArbitrator().getPubKeyRing())) {
log.warn("Removing invalid dispute opened by arbitrator, disputeId={}", trade.getId(), dispute.getId());
toRemoves.add(dispute);
}
// remove dispute if preparing
if (trade.getDisputeState() == DisputeState.DISPUTE_PREPARING) {
log.warn("Removing dispute for {} {} with disputeState={}, disputeId={}", trade.getClass().getSimpleName(), trade.getId(), trade.getDisputeState(), dispute.getId());
toRemoves.add(dispute);
}
// remove dispute if requested and not stored in mailbox
if (trade.getDisputeState() == DisputeState.DISPUTE_REQUESTED) {
boolean storedInMailbox = false;
for (ChatMessage msg : dispute.getChatMessages()) {
if (Boolean.TRUE.equals(msg.getStoredInMailboxProperty().get())) {
storedInMailbox = true;
log.info("Keeping dispute for {} {} with disputeState={}, disputeId={}. Stored in mailbox", trade.getClass().getSimpleName(), trade.getId(), trade.getDisputeState(), dispute.getId());
break;
}
}
if (!storedInMailbox) {
log.warn("Removing dispute for {} {} with disputeState={}, disputeId={}. Not stored in mailbox", trade.getClass().getSimpleName(), trade.getId(), trade.getDisputeState(), dispute.getId());
toRemoves.add(dispute);
}
}
}
// remove disputes and reset state
for (Dispute dispute : toRemoves) {
getDisputeList().remove(dispute);
// get dispute's trade
final Trade trade = tradeManager.getTrade(dispute.getTradeId());
if (trade == null) {
log.warn("Dispute trade {} does not exist", dispute.getTradeId());
continue;
}
trade.setDisputeState(DisputeState.NO_DISPUTE);
}
}
for (Dispute toRemove : toRemoves) {
log.warn("Removing invalid dispute opened by arbitrator, disputeId={}", toRemove.getTradeId(), toRemove.getId());
getDisputeList().remove(toRemove);
}
}

View file

@ -169,6 +169,25 @@ public final class ChatMessage extends SupportMessage {
false);
}
public ChatMessage copy() {
return new ChatMessage(supportType,
tradeId,
traderId,
senderIsTrader,
message,
new ArrayList<>(attachments),
senderNodeAddress,
date,
arrivedProperty.get(),
storedInMailboxProperty.get(),
uid,
messageVersion,
acknowledgedProperty.get(),
sendMessageErrorProperty.get(),
ackErrorProperty.get(),
wasDisplayed);
}
///////////////////////////////////////////////////////////////////////////////////////////
// PROTO BUFFER

View file

@ -77,6 +77,11 @@ public class TraderChatManager extends SupportManager {
tradeManager.requestPersistence();
}
@Override
public void persistNow(Runnable completeHandler) {
tradeManager.persistNow(completeHandler);
}
@Override
public NodeAddress getPeerNodeAddress(ChatMessage message) {
return tradeManager.getOpenTrade(message.getTradeId()).map(trade -> {

View file

@ -298,6 +298,7 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model {
public enum DisputeState {
NO_DISPUTE,
DISPUTE_PREPARING,
DISPUTE_REQUESTED,
DISPUTE_OPENED,
ARBITRATOR_SENT_DISPUTE_CLOSED_MSG,
@ -324,19 +325,18 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model {
return protobuf.Trade.DisputeState.valueOf(disputeState.name());
}
public boolean isNotDisputed() {
return this == Trade.DisputeState.NO_DISPUTE;
}
public boolean isMediated() {
return this == Trade.DisputeState.MEDIATION_REQUESTED ||
this == Trade.DisputeState.MEDIATION_STARTED_BY_PEER ||
this == Trade.DisputeState.MEDIATION_CLOSED;
}
public boolean isArbitrated() {
if (isMediated()) return false; // TODO: remove mediation?
return this.ordinal() >= DisputeState.DISPUTE_REQUESTED.ordinal();
public boolean isDisputed() {
return this.ordinal() >= DisputeState.DISPUTE_PREPARING.ordinal();
}
public boolean isPreparing() {
return this == DisputeState.DISPUTE_PREPARING;
}
public boolean isRequested() {
@ -720,7 +720,7 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model {
});
// complete disputed trade
if (getDisputeState().isArbitrated() && !getDisputeState().isClosed()) {
if (getDisputeState().isDisputed() && !getDisputeState().isClosed()) {
processModel.getTradeManager().closeDisputedTrade(getId(), Trade.DisputeState.DISPUTE_CLOSED);
if (!isArbitrator()) for (Dispute dispute : getDisputes()) dispute.setIsClosed(); // auto close trader tickets
}
@ -1215,6 +1215,10 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model {
} catch (IllegalArgumentException | IllegalStateException e) {
throw e;
} catch (Exception e) {
if (isShutDownStarted && wallet == null) {
log.warn("Aborting import of multisig hex for {} {} because shut down is started and wallet is closed", getClass().getSimpleName(), getShortId());
break;
}
log.warn("Failed to import multisig hex, tradeId={}, attempt={}/{}, error={}", getShortId(), i + 1, TradeProtocol.MAX_ATTEMPTS, e.getMessage());
handleWalletError(e, sourceConnection, i + 1);
doPollWallet();

View file

@ -67,6 +67,7 @@ import haveno.core.support.dispute.arbitration.arbitrator.ArbitratorManager;
import haveno.core.support.dispute.mediation.mediator.MediatorManager;
import haveno.core.support.dispute.messages.DisputeClosedMessage;
import haveno.core.support.dispute.messages.DisputeOpenedMessage;
import haveno.core.support.messages.ChatMessage;
import haveno.core.trade.Trade.DisputeState;
import haveno.core.trade.failed.FailedTradesManager;
import haveno.core.trade.handlers.TradeResultHandler;
@ -196,6 +197,7 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi
PaymentSentMessage.class,
PaymentReceivedMessage.class,
DisputeOpenedMessage.class,
ChatMessage.class,
DisputeClosedMessage.class);
@Override
@ -459,13 +461,6 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi
if (!trade.isArbitrator()) continue;
if (trade.isIdling()) {
ThreadUtils.submitToPool(() -> {
// add random delay to avoid syncing at exactly the same time
if (trades.size() > 1 && trade.walletExists()) {
int delay = (int) (Math.random() * INIT_TRADE_RANDOM_DELAY_MS);
HavenoUtils.waitFor(delay);
}
trade.syncAndPollWallet();
});
}
@ -536,12 +531,6 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi
}
}
// add random delay to avoid syncing at exactly the same time
if (trades.size() > 1 && trade.walletExists()) {
int delay = (int) (Math.random() * INIT_TRADE_RANDOM_DELAY_MS);
HavenoUtils.waitFor(delay);
}
// initialize trade
initTrade(trade);

View file

@ -108,7 +108,7 @@ public class ProcessPaymentReceivedMessage extends TradeTask {
if (!trade.isPayoutConfirmed()) processPayoutTx(message);
// close open disputes
if (trade.isPayoutPublished() && trade.getDisputeState().ordinal() >= Trade.DisputeState.DISPUTE_REQUESTED.ordinal()) {
if (trade.isPayoutPublished() && trade.getDisputeState().isDisputed()) {
trade.advanceDisputeState(Trade.DisputeState.DISPUTE_CLOSED);
for (Dispute dispute : trade.getDisputes()) dispute.setIsClosed();
}

View file

@ -98,7 +98,7 @@ public class SellerPreparePaymentReceivedMessage extends TradeTask {
}
// close open disputes
if (trade.isPayoutPublished() && trade.getDisputeState().ordinal() >= Trade.DisputeState.DISPUTE_REQUESTED.ordinal()) {
if (trade.isPayoutPublished() && trade.getDisputeState().ordinal() >= Trade.DisputeState.DISPUTE_PREPARING.ordinal()) {
trade.advanceDisputeState(Trade.DisputeState.DISPUTE_CLOSED);
for (Dispute dispute : trade.getDisputes()) dispute.setIsClosed();
}

View file

@ -1216,6 +1216,7 @@ support.role=Role
support.agent=Support agent
support.state=State
support.chat=Chat
support.preparing=Preparing
support.requested=Requested
support.closed=Closed
support.open=Open

View file

@ -1180,7 +1180,9 @@ support.agent=Agent podpory
support.state=Stav
support.chat=Chat
support.requested=Požádáno
support.closed=Zavřeno
support.preparing=Připravuje se
support.requested=Požadováno
support.closed=Uzavřeno
support.open=Otevřeno
support.moreButton=VÍCE...
support.sendLogFiles=Odeslat soubory logů

View file

@ -971,6 +971,8 @@ support.role=Rolle
support.agent=Support-Mitarbeiter
support.state=Status
support.chat=Chat
support.preparing=In Vorbereitung
support.requested=Angefragt
support.closed=Geschlossen
support.open=Offen
support.process=Process

View file

@ -972,6 +972,8 @@ support.role=Rol
support.agent=Agente de soporte
support.state=Estado
support.chat=Chat
support.preparing=Preparando
support.requested=Solicitado
support.closed=Cerrado
support.open=Abierto
support.process=Proceso

View file

@ -969,6 +969,8 @@ support.role=نقش
support.agent=Support agent
support.state=حالت
support.chat=Chat
support.preparing=در حال آماده‌سازی
support.requested=درخواست شده
support.closed=بسته
support.open=باز
support.process=Process

View file

@ -973,6 +973,8 @@ support.role=Rôle
support.agent=Agent d'assistance
support.state=État
support.chat=Chat
support.preparing=Préparation
support.requested=Demandé
support.closed=Fermé
support.open=Ouvert
support.process=Processus

View file

@ -970,6 +970,8 @@ support.role=Ruolo
support.agent=Support agent
support.state=Stato
support.chat=Chat
support.preparing=In preparazione
support.requested=Richiesto
support.closed=Chiuso
support.open=Aperto
support.process=Process

View file

@ -971,7 +971,9 @@ support.role=役割
support.agent=サポート代理人
support.state=状態
support.chat=Chat
support.closed=クローズ
support.preparing=準備中
support.requested=リクエスト済
support.closed=閉鎖
support.open=オープン
support.process=Process
support.buyerMaker=XMR 買い手/メイカー

View file

@ -972,6 +972,8 @@ support.role=Função
support.agent=Support agent
support.state=Estado
support.chat=Chat
support.preparing=Preparando
support.requested=Solicitado
support.closed=Fechado
support.open=Aberto
support.process=Process

View file

@ -969,6 +969,8 @@ support.role=Cargo
support.agent=Support agent
support.state=Estado
support.chat=Chat
support.preparing=Preparando
support.requested=Solicitado
support.closed=Fechado
support.open=Aberto
support.process=Process

View file

@ -969,6 +969,8 @@ support.role=Роль
support.agent=Support agent
support.state=Состояние
support.chat=Chat
support.preparing=В процессе
support.requested=Запрошено
support.closed=Закрыто
support.open=Открыто
support.process=Process

View file

@ -969,6 +969,8 @@ support.role=บทบาท
support.agent=Support agent
support.state=สถานะ
support.chat=Chat
support.preparing=กำลังเตรียม
support.requested=ร้องขอ
support.closed=ปิดแล้ว
support.open=เปิด
support.process=Process

View file

@ -1175,6 +1175,7 @@ support.role=Rol
support.agent=Destek temsilcisi
support.state=Durum
support.chat=Sohbet
support.preparing=Hazırlanıyor
support.requested=Talep edildi
support.closed=Kapalı
support.open=ık

View file

@ -971,6 +971,8 @@ support.role=Vai trò
support.agent=Support agent
support.state=Trạng thái
support.chat=Chat
support.preparing=Đang chuẩn bị
support.requested=Yêu cầu
support.closed=Đóng
support.open=Mở
support.process=Process

View file

@ -971,6 +971,8 @@ support.role=角色
support.agent=Support agent
support.state=状态
support.chat=Chat
support.preparing=准备中
support.requested=请求
support.closed=关闭
support.open=打开
support.process=Process

View file

@ -971,6 +971,8 @@ support.role=角色
support.agent=Support agent
support.state=狀態
support.chat=Chat
support.preparing=準備中
support.requested=已請求
support.closed=關閉
support.open=打開
support.process=Process

View file

@ -19,6 +19,8 @@ package haveno.desktop.main.overlays.notifications;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import haveno.common.ThreadUtils;
import haveno.common.UserThread;
import haveno.core.api.NotificationListener;
import haveno.core.locale.Res;
@ -135,7 +137,7 @@ public class NotificationCenter {
log.debug("We have already an entry in disputeStateSubscriptionsMap.");
} else {
Subscription disputeStateSubscription = EasyBind.subscribe(trade.disputeStateProperty(),
disputeState -> onDisputeStateChanged(trade, disputeState));
disputeState -> ThreadUtils.submitToPool(() -> onDisputeStateChanged(trade, disputeState)));
disputeStateSubscriptionsMap.put(tradeId, disputeStateSubscription);
}
@ -153,7 +155,7 @@ public class NotificationCenter {
tradeManager.getObservableList().forEach(trade -> {
String tradeId = trade.getId();
Subscription disputeStateSubscription = EasyBind.subscribe(trade.disputeStateProperty(),
disputeState -> onDisputeStateChanged(trade, disputeState));
disputeState -> ThreadUtils.submitToPool(() -> onDisputeStateChanged(trade, disputeState)));
disputeStateSubscriptionsMap.put(tradeId, disputeStateSubscription);
Subscription tradePhaseSubscription = EasyBind.subscribe(trade.statePhaseProperty(),
@ -267,7 +269,7 @@ public class NotificationCenter {
if (message != null) {
goToSupport(trade, message, trade.isArbitrator() ? ArbitratorView.class : ArbitrationClientView.class);
}
}else if (refundManager.findDispute(trade.getId()).isPresent()) {
} else if (refundManager.findDispute(trade.getId()).isPresent()) {
String disputeOrTicket = refundManager.findDispute(trade.getId()).get().isSupportTicket() ?
Res.get("shared.supportTicket") :
Res.get("shared.dispute");

View file

@ -437,25 +437,6 @@ public class PendingTradesDataModel extends ActivatableDataModel {
return;
}
// We do not support opening a dispute if the deposit tx is null. Traders have to use the support channel at keybase
// in such cases. The mediators or arbitrators could not help anyway with a payout in such cases.
String depositTxId = null;
if (isMaker) {
if (trade.getMaker().getDepositTxHash() == null) {
log.error("Deposit tx must not be null");
new Popup().instruction(Res.get("portfolio.pending.error.depositTxNull")).show();
return;
}
depositTxId = trade.getMaker().getDepositTxHash();
} else {
if (trade.getTaker().getDepositTxHash() == null && !trade.hasBuyerAsTakerWithoutDeposit()) {
log.error("Deposit tx must not be null");
new Popup().instruction(Res.get("portfolio.pending.error.depositTxNull")).show();
return;
}
depositTxId = trade.getTaker().getDepositTxHash();
}
Offer offer = trade.getOffer();
if (offer == null) {
log.warn("offer is null at doOpenDispute");
@ -576,8 +557,9 @@ public class PendingTradesDataModel extends ActivatableDataModel {
}
private void doSendDisputeOpenedMessage(Dispute dispute, DisputeManager<? extends DisputeList<Dispute>> disputeManager) {
navigation.navigateTo(MainView.class, SupportView.class, ArbitrationClientView.class);
disputeManager.sendDisputeOpenedMessage(dispute,
() -> navigation.navigateTo(MainView.class, SupportView.class, ArbitrationClientView.class),
null,
(errorMessage, throwable) -> new Popup().warning(errorMessage).show());
}

View file

@ -274,12 +274,8 @@ public abstract class TradeStepView extends AnchorPane {
}
private void openSupportTicket() {
if (trade.isDepositTxMissing() || trade.getPhase().ordinal() >= Trade.Phase.DEPOSITS_UNLOCKED.ordinal()) {
applyOnDisputeOpened();
model.dataModel.onOpenDispute();
} else {
new Popup().warning(Res.get("portfolio.pending.error.depositTxNotConfirmed")).show();
}
applyOnDisputeOpened();
model.dataModel.onOpenDispute();
}
private void openChat() {
@ -494,6 +490,7 @@ public abstract class TradeStepView extends AnchorPane {
case NO_DISPUTE:
break;
case DISPUTE_PREPARING:
case DISPUTE_REQUESTED:
case DISPUTE_OPENED:
if (tradeStepInfo != null) {
@ -770,7 +767,7 @@ public abstract class TradeStepView extends AnchorPane {
}
private void updateTradePeriodState(Trade.TradePeriodState tradePeriodState) {
if (!trade.getDisputeState().isOpen()) {
if (!trade.getDisputeState().isDisputed()) {
switch (tradePeriodState) {
case FIRST_HALF:
// just for dev testing. not possible to go back in time ;-)

View file

@ -79,7 +79,7 @@ public class BuyerStep4View extends TradeStepView {
TitledGroupBg completedTradeLabel = new TitledGroupBg();
if (trade.getDisputeState().isMediated()) {
completedTradeLabel.setText(Res.get("portfolio.pending.step5_buyer.groupTitle.mediated"));
} else if (trade.getDisputeState().isArbitrated() && trade.getDisputeResult() != null) {
} else if (trade.getDisputeState().isDisputed() && trade.getDisputeResult() != null) {
completedTradeLabel.setText(Res.get("portfolio.pending.step5_buyer.groupTitle.arbitrated"));
} else {
completedTradeLabel.setText(Res.get("portfolio.pending.step5_buyer.groupTitle"));

View file

@ -214,14 +214,15 @@ public class ChatView extends AnchorPane {
sendButton = new AutoTooltipButton(Res.get("support.send"));
sendButton.setDefaultButton(true);
sendButton.setOnAction(e -> onTrySendMessage());
sendButton.setStyle("-fx-pref-width: 125; -fx-min-width: 110; -fx-padding: 3 3 3 3;");
inputTextAreaTextSubscription = EasyBind.subscribe(inputTextArea.textProperty(), t -> sendButton.setDisable(t.isEmpty()));
Button uploadButton = new AutoTooltipButton(Res.get("support.addAttachments"));
uploadButton.setOnAction(e -> onRequestUpload());
Button clipboardButton = new AutoTooltipButton(Res.get("shared.copyToClipboard"));
clipboardButton.setOnAction(e -> copyChatMessagesToClipboard(clipboardButton));
uploadButton.setStyle("-fx-pref-width: 125; -fx-padding: 3 3 3 3;");
clipboardButton.setStyle("-fx-pref-width: 125; -fx-padding: 3 3 3 3;");
uploadButton.setStyle("-fx-pref-width: 125; -fx-min-width: 110; -fx-padding: 3 3 3 3;");
clipboardButton.setStyle("-fx-pref-width: 125; -fx-min-width: 110; -fx-padding: 3 3 3 3;");
sendMsgInfoLabel = new AutoTooltipLabel();
sendMsgInfoLabel.setVisible(false);

View file

@ -125,7 +125,7 @@ public class DisputeChatPopup {
menuItem2.setOnAction(e -> chatCallback.onSendLogsFromChatWindow(selectedDispute));
menuButton.getItems().addAll(menuItem1, menuItem2);
menuButton.getStyleClass().add("jfx-button");
menuButton.setStyle("-fx-padding: 0 10 0 10;");
menuButton.setStyle("-fx-min-width: 95; -fx-padding: 0 10 0 10;");
chatView.display(concreteDisputeSession, menuButton, pane.widthProperty());
}
}

View file

@ -332,6 +332,7 @@ public abstract class DisputeView extends ActivatableView<VBox, Void> implements
TableRow<Dispute> row = new TableRow<>();
row.setOnMouseClicked(event -> {
if (event.getClickCount() == 2 && (!row.isEmpty())) {
if (!canViewChatMessages(row.getItem())) return;
openChat(row.getItem());
}
});
@ -1045,10 +1046,17 @@ public abstract class DisputeView extends ActivatableView<VBox, Void> implements
@Override
public TableCell<Dispute, Dispute> call(TableColumn<Dispute, Dispute> column) {
return new TableCell<>() {
Subscription subscription;
@Override
public void updateItem(final Dispute item, boolean empty) {
super.updateItem(item, empty);
if (item != null && !empty) {
if (subscription != null) {
subscription.unsubscribe();
subscription = null;
}
String id = item.getId();
Button button;
if (!chatButtonByDispute.containsKey(id)) {
@ -1075,10 +1083,22 @@ public abstract class DisputeView extends ActivatableView<VBox, Void> implements
listenerByDispute.put(id, listener);
item.getChatMessages().addListener(listener);
}
// subscribe to trade's dispute state
Trade trade = tradeManager.getTrade(item.getTradeId());
if (trade == null) log.warn("Dispute's trade is null for trade {}", item.getTradeId());
else subscription = EasyBind.subscribe(trade.disputeStateProperty(), disputeState -> {
chatBadge.setDisable(!canViewChatMessages(item));
updateChatMessageCount(item, chatBadge);
});
updateChatMessageCount(item, chatBadge);
setGraphic(chatBadge);
} else {
setGraphic(null);
if (subscription != null) {
subscription.unsubscribe();
subscription = null;
}
}
}
};
@ -1356,7 +1376,6 @@ public abstract class DisputeView extends ActivatableView<VBox, Void> implements
public TableCell<Dispute, Dispute> call(TableColumn<Dispute, Dispute> column) {
return new TableCell<>() {
ReadOnlyBooleanProperty closedProperty;
ChangeListener<Boolean> listener;
Subscription subscription;
@ -1419,6 +1438,8 @@ public abstract class DisputeView extends ActivatableView<VBox, Void> implements
switch (trade.getDisputeState()) {
case NO_DISPUTE:
return Res.get("shared.pending");
case DISPUTE_PREPARING:
return Res.get("support.preparing");
case DISPUTE_REQUESTED:
return Res.get("support.requested");
default:
@ -1447,7 +1468,7 @@ public abstract class DisputeView extends ActivatableView<VBox, Void> implements
return;
}
if (dispute.unreadMessageCount(senderFlag()) > 0) {
if (canViewChatMessages(dispute) && dispute.unreadMessageCount(senderFlag()) > 0) {
chatBadge.setText(String.valueOf(dispute.unreadMessageCount(senderFlag())));
chatBadge.setEnabled(true);
} else {
@ -1498,4 +1519,8 @@ public abstract class DisputeView extends ActivatableView<VBox, Void> implements
ArbitrationManager arbitrationManager = (ArbitrationManager) disputeManager;
new SendLogFilesWindow(dispute.getTradeId(), dispute.getTraderId(), arbitrationManager).show();
}
private boolean canViewChatMessages(Dispute dispute) {
return disputeManager.canSendChatMessages(dispute) || dispute.isClosed();
}
}

View file

@ -1495,6 +1495,7 @@ message Trade {
enum DisputeState {
PB_ERROR_DISPUTE_STATE = 0;
NO_DISPUTE = 1;
DISPUTE_PREPARING = 15;
DISPUTE_REQUESTED = 2;
DISPUTE_OPENED = 3;
ARBITRATOR_SENT_DISPUTE_CLOSED_MSG = 4;