diff --git a/Makefile b/Makefile index ae0652d865..8f9ad076ad 100644 --- a/Makefile +++ b/Makefile @@ -440,6 +440,7 @@ monerod: ./.localnet/monerod \ --bootstrap-daemon-address auto \ --rpc-access-control-origins http://localhost:8080 \ + --rpc-max-connections-per-private-ip 100 \ seednode: ./haveno-seednode$(APP_EXT) \ diff --git a/core/src/main/java/haveno/core/account/witness/AccountAgeWitnessUtils.java b/core/src/main/java/haveno/core/account/witness/AccountAgeWitnessUtils.java index ed5645b910..cdce0e0079 100644 --- a/core/src/main/java/haveno/core/account/witness/AccountAgeWitnessUtils.java +++ b/core/src/main/java/haveno/core/account/witness/AccountAgeWitnessUtils.java @@ -140,7 +140,7 @@ public class AccountAgeWitnessUtils { boolean isSignWitnessTrade = accountAgeWitnessService.accountIsSigner(witness) && !accountAgeWitnessService.peerHasSignedWitness(trade) && accountAgeWitnessService.tradeAmountIsSufficient(trade.getAmount()); - log.info("AccountSigning debug log: " + + log.debug("AccountSigning debug log: " + "\ntradeId: {}" + "\nis buyer: {}" + "\nbuyer account age witness info: {}" + diff --git a/core/src/main/java/haveno/core/api/CoreDisputesService.java b/core/src/main/java/haveno/core/api/CoreDisputesService.java index ece5a816cc..ef6d56472b 100644 --- a/core/src/main/java/haveno/core/api/CoreDisputesService.java +++ b/core/src/main/java/haveno/core/api/CoreDisputesService.java @@ -242,7 +242,7 @@ public class CoreDisputesService { disputeResult.setBuyerPayoutAmountBeforeCost(tradeAmount.add(buyerSecurityDeposit).add(sellerSecurityDeposit)); // TODO (woodser): apply min payout to incentivize loser? (see post v1.1.7) disputeResult.setSellerPayoutAmountBeforeCost(BigInteger.ZERO); if (disputeResult.getBuyerPayoutAmountBeforeCost().compareTo(trade.getWallet().getBalance()) > 0) { // in case peer's deposit transaction is not confirmed - log.warn("Payout amount for buyer is more than wallet's balance. Decreasing payout amount from {} to {}", + log.warn("Payout amount for buyer is more than wallet's balance. This can happen if a deposit tx is dropped. Decreasing payout amount from {} to {}", HavenoUtils.formatXmr(disputeResult.getBuyerPayoutAmountBeforeCost()), HavenoUtils.formatXmr(trade.getWallet().getBalance())); disputeResult.setBuyerPayoutAmountBeforeCost(trade.getWallet().getBalance()); @@ -254,7 +254,7 @@ public class CoreDisputesService { disputeResult.setBuyerPayoutAmountBeforeCost(BigInteger.ZERO); disputeResult.setSellerPayoutAmountBeforeCost(tradeAmount.add(sellerSecurityDeposit).add(buyerSecurityDeposit)); if (disputeResult.getSellerPayoutAmountBeforeCost().compareTo(trade.getWallet().getBalance()) > 0) { // in case peer's deposit transaction is not confirmed - log.warn("Payout amount for seller is more than wallet's balance. Decreasing payout amount from {} to {}", + log.warn("Payout amount for seller is more than wallet's balance. This can happen if a deposit tx is dropped. Decreasing payout amount from {} to {}", HavenoUtils.formatXmr(disputeResult.getSellerPayoutAmountBeforeCost()), HavenoUtils.formatXmr(trade.getWallet().getBalance())); disputeResult.setSellerPayoutAmountBeforeCost(trade.getWallet().getBalance()); diff --git a/core/src/main/java/haveno/core/api/XmrConnectionService.java b/core/src/main/java/haveno/core/api/XmrConnectionService.java index a11bbbd2bb..0f51f1be2b 100644 --- a/core/src/main/java/haveno/core/api/XmrConnectionService.java +++ b/core/src/main/java/haveno/core/api/XmrConnectionService.java @@ -75,7 +75,7 @@ public final class XmrConnectionService { private static final long REFRESH_PERIOD_ONION_MS = 30000; // refresh period when connected to remote node over tor private static final long KEY_IMAGE_REFRESH_PERIOD_MS_LOCAL = 20000; // 20 seconds private static final long KEY_IMAGE_REFRESH_PERIOD_MS_REMOTE = 300000; // 5 minutes - private static final int MAX_CONSECUTIVE_ERRORS = 4; // max errors before switching connections + private static final int MAX_CONSECUTIVE_ERRORS = 3; // max errors before switching connections private static int numConsecutiveErrors = 0; public enum XmrConnectionFallbackType { diff --git a/core/src/main/java/haveno/core/api/model/TradeInfo.java b/core/src/main/java/haveno/core/api/model/TradeInfo.java index 0803966ef7..ab86a05240 100644 --- a/core/src/main/java/haveno/core/api/model/TradeInfo.java +++ b/core/src/main/java/haveno/core/api/model/TradeInfo.java @@ -91,11 +91,13 @@ public class TradeInfo implements Payload { private final boolean isDepositsPublished; private final boolean isDepositsConfirmed; private final boolean isDepositsUnlocked; + private final boolean isDepositsFinalized; private final boolean isPaymentSent; private final boolean isPaymentReceived; private final boolean isPayoutPublished; private final boolean isPayoutConfirmed; private final boolean isPayoutUnlocked; + private final boolean isPayoutFinalized; private final boolean isCompleted; private final String contractAsJson; private final ContractInfo contract; @@ -135,11 +137,13 @@ public class TradeInfo implements Payload { this.isDepositsPublished = builder.isDepositsPublished(); this.isDepositsConfirmed = builder.isDepositsConfirmed(); this.isDepositsUnlocked = builder.isDepositsUnlocked(); + this.isDepositsFinalized = builder.isDepositsFinalized(); this.isPaymentSent = builder.isPaymentSent(); this.isPaymentReceived = builder.isPaymentReceived(); this.isPayoutPublished = builder.isPayoutPublished(); this.isPayoutConfirmed = builder.isPayoutConfirmed(); this.isPayoutUnlocked = builder.isPayoutUnlocked(); + this.isPayoutFinalized = builder.isPayoutFinalized(); this.isCompleted = builder.isCompleted(); this.contractAsJson = builder.getContractAsJson(); this.contract = builder.getContract(); @@ -199,11 +203,13 @@ public class TradeInfo implements Payload { .withIsDepositsPublished(trade.isDepositsPublished()) .withIsDepositsConfirmed(trade.isDepositsConfirmed()) .withIsDepositsUnlocked(trade.isDepositsUnlocked()) + .withIsDepositsFinalized(trade.isDepositsFinalized()) .withIsPaymentSent(trade.isPaymentSent()) .withIsPaymentReceived(trade.isPaymentReceived()) .withIsPayoutPublished(trade.isPayoutPublished()) .withIsPayoutConfirmed(trade.isPayoutConfirmed()) .withIsPayoutUnlocked(trade.isPayoutUnlocked()) + .withIsPayoutFinalized(trade.isPayoutFinalized()) .withIsCompleted(trade.isCompleted()) .withContractAsJson(trade.getContractAsJson()) .withContract(contractInfo) @@ -252,12 +258,14 @@ public class TradeInfo implements Payload { .setIsDepositsPublished(isDepositsPublished) .setIsDepositsConfirmed(isDepositsConfirmed) .setIsDepositsUnlocked(isDepositsUnlocked) + .setIsDepositsFinalized(isDepositsFinalized) .setIsPaymentSent(isPaymentSent) .setIsPaymentReceived(isPaymentReceived) .setIsCompleted(isCompleted) .setIsPayoutPublished(isPayoutPublished) .setIsPayoutConfirmed(isPayoutConfirmed) .setIsPayoutUnlocked(isPayoutUnlocked) + .setIsPayoutFinalized(isPayoutFinalized) .setContractAsJson(contractAsJson == null ? "" : contractAsJson) .setContract(contract.toProtoMessage()) .setStartTime(startTime) @@ -299,12 +307,14 @@ public class TradeInfo implements Payload { .withIsDepositsPublished(proto.getIsDepositsPublished()) .withIsDepositsConfirmed(proto.getIsDepositsConfirmed()) .withIsDepositsUnlocked(proto.getIsDepositsUnlocked()) + .withIsDepositsFinalized(proto.getIsDepositsFinalized()) .withIsPaymentSent(proto.getIsPaymentSent()) .withIsPaymentReceived(proto.getIsPaymentReceived()) .withIsCompleted(proto.getIsCompleted()) .withIsPayoutPublished(proto.getIsPayoutPublished()) .withIsPayoutConfirmed(proto.getIsPayoutConfirmed()) .withIsPayoutUnlocked(proto.getIsPayoutUnlocked()) + .withIsPayoutFinalized(proto.getIsPayoutFinalized()) .withContractAsJson(proto.getContractAsJson()) .withContract((ContractInfo.fromProto(proto.getContract()))) .withStartTime(proto.getStartTime()) @@ -345,11 +355,13 @@ public class TradeInfo implements Payload { ", isDepositsPublished=" + isDepositsPublished + "\n" + ", isDepositsConfirmed=" + isDepositsConfirmed + "\n" + ", isDepositsUnlocked=" + isDepositsUnlocked + "\n" + + ", isDepositsFinalized=" + isDepositsFinalized + "\n" + ", isPaymentSent=" + isPaymentSent + "\n" + ", isPaymentReceived=" + isPaymentReceived + "\n" + ", isPayoutPublished=" + isPayoutPublished + "\n" + ", isPayoutConfirmed=" + isPayoutConfirmed + "\n" + ", isPayoutUnlocked=" + isPayoutUnlocked + "\n" + + ", isPayoutFinalized=" + isPayoutFinalized + "\n" + ", isCompleted=" + isCompleted + "\n" + ", offer=" + offer + "\n" + ", contractAsJson=" + contractAsJson + "\n" + diff --git a/core/src/main/java/haveno/core/api/model/builder/TradeInfoV1Builder.java b/core/src/main/java/haveno/core/api/model/builder/TradeInfoV1Builder.java index dfb99f820b..ec751e0d5b 100644 --- a/core/src/main/java/haveno/core/api/model/builder/TradeInfoV1Builder.java +++ b/core/src/main/java/haveno/core/api/model/builder/TradeInfoV1Builder.java @@ -64,11 +64,13 @@ public final class TradeInfoV1Builder { private boolean isDepositsPublished; private boolean isDepositsConfirmed; private boolean isDepositsUnlocked; + private boolean isDepositsFinalized; private boolean isPaymentSent; private boolean isPaymentReceived; private boolean isPayoutPublished; private boolean isPayoutConfirmed; private boolean isPayoutUnlocked; + private boolean isPayoutFinalized; private boolean isCompleted; private String contractAsJson; private ContractInfo contract; @@ -242,6 +244,11 @@ public final class TradeInfoV1Builder { return this; } + public TradeInfoV1Builder withIsDepositsFinalized(boolean isDepositsFinalized) { + this.isDepositsFinalized = isDepositsFinalized; + return this; + } + public TradeInfoV1Builder withIsPaymentSent(boolean isPaymentSent) { this.isPaymentSent = isPaymentSent; return this; @@ -267,6 +274,11 @@ public final class TradeInfoV1Builder { return this; } + public TradeInfoV1Builder withIsPayoutFinalized(boolean isPayoutFinalized) { + this.isPayoutFinalized = isPayoutFinalized; + return this; + } + public TradeInfoV1Builder withIsCompleted(boolean isCompleted) { this.isCompleted = isCompleted; return this; diff --git a/core/src/main/java/haveno/core/notifications/alerts/TradeEvents.java b/core/src/main/java/haveno/core/notifications/alerts/TradeEvents.java index a1274a3813..04554c7c2c 100644 --- a/core/src/main/java/haveno/core/notifications/alerts/TradeEvents.java +++ b/core/src/main/java/haveno/core/notifications/alerts/TradeEvents.java @@ -70,6 +70,7 @@ public class TradeEvents { case DEPOSITS_PUBLISHED: break; case DEPOSITS_UNLOCKED: + case DEPOSITS_FINALIZED: // TODO: use a separate message for deposits finalized? if (trade.getContract() != null && pubKeyRingProvider.get().equals(trade.getContract().getBuyerPubKeyRing())) msg = Res.get("account.notifications.trade.message.msg.conf", shortId); break; diff --git a/core/src/main/java/haveno/core/offer/OpenOfferManager.java b/core/src/main/java/haveno/core/offer/OpenOfferManager.java index d196b2a35c..22159a8e11 100644 --- a/core/src/main/java/haveno/core/offer/OpenOfferManager.java +++ b/core/src/main/java/haveno/core/offer/OpenOfferManager.java @@ -1224,17 +1224,21 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe MoneroTxWallet splitOutputTx = xmrWalletService.getTx(openOffer.getSplitOutputTxHash()); // check if split output tx is available for offer - if (splitOutputTx.isLocked()) return splitOutputTx; - else { - boolean isAvailable = true; - for (MoneroOutputWallet output : splitOutputTx.getOutputsWallet()) { - if (output.isSpent() || output.isFrozen()) { - isAvailable = false; - break; + if (splitOutputTx != null) { + if (splitOutputTx.isLocked()) return splitOutputTx; + else { + boolean isAvailable = true; + for (MoneroOutputWallet output : splitOutputTx.getOutputsWallet()) { + if (output.isSpent() || output.isFrozen()) { + isAvailable = false; + break; + } } + if (isAvailable || isReservedByOffer(openOffer, splitOutputTx)) return splitOutputTx; + else log.warn("Split output tx {} is no longer available for offer {}", openOffer.getSplitOutputTxHash(), openOffer.getId()); } - if (isAvailable || isReservedByOffer(openOffer, splitOutputTx)) return splitOutputTx; - else log.warn("Split output tx is no longer available for offer {}", openOffer.getId()); + } else { + log.warn("Split output tx {} no longer exists for offer {}", openOffer.getSplitOutputTxHash(), openOffer.getId()); } } @@ -1757,6 +1761,11 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe errorMessage = "Exception at handleSignOfferRequest " + e.getMessage(); log.error(errorMessage + "\n", e); } finally { + if (result == false && errorMessage == null) { + log.warn("Arbitrator is NACKing SignOfferRequest for unknown reason with offerId={}. That should never happen", request.getOfferId()); + log.warn("Printing stacktrace:"); + Thread.dumpStack(); + } sendAckMessage(request.getClass(), peer, request.getPubKeyRing(), request.getOfferId(), request.getUid(), result, errorMessage); } } @@ -1946,8 +1955,14 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe result, errorMessage); - log.info("Send AckMessage for {} to peer {} with offerId {} and sourceUid {}", - reqClass.getSimpleName(), sender, offerId, ackMessage.getSourceUid()); + if (ackMessage.isSuccess()) { + log.info("Send AckMessage for {} to peer {} with offerId {} and sourceUid {}", + reqClass.getSimpleName(), sender, offerId, ackMessage.getSourceUid()); + } else { + log.warn("Sending NACK for {} to peer {} with offerId {} and sourceUid {}, errorMessage={}", + reqClass.getSimpleName(), sender, offerId, ackMessage.getSourceUid(), errorMessage); + } + p2PService.sendEncryptedDirectMessage( sender, senderPubKeyRing, diff --git a/core/src/main/java/haveno/core/support/SupportManager.java b/core/src/main/java/haveno/core/support/SupportManager.java index 4bb8e86d82..8764794e12 100644 --- a/core/src/main/java/haveno/core/support/SupportManager.java +++ b/core/src/main/java/haveno/core/support/SupportManager.java @@ -154,15 +154,15 @@ public abstract class SupportManager { // Message handler /////////////////////////////////////////////////////////////////////////////////////////// - protected void handleChatMessage(ChatMessage chatMessage) { + protected void handle(ChatMessage chatMessage) { final String tradeId = chatMessage.getTradeId(); final String uid = chatMessage.getUid(); log.info("Received {} from peer {}. tradeId={}, uid={}", chatMessage.getClass().getSimpleName(), chatMessage.getSenderNodeAddress(), tradeId, uid); boolean channelOpen = channelOpen(chatMessage); if (!channelOpen) { - log.debug("We got a chatMessage but we don't have a matching chat. TradeId = " + tradeId); + log.warn("We got a chatMessage but we don't have a matching chat. TradeId = " + tradeId); if (!delayMsgMap.containsKey(uid)) { - Timer timer = UserThread.runAfter(() -> handleChatMessage(chatMessage), 1); + Timer timer = UserThread.runAfter(() -> handle(chatMessage), 1); delayMsgMap.put(uid, timer); } else { String msg = "We got a chatMessage after we already repeated to apply the message after a delay. That should never happen. TradeId = " + tradeId; @@ -217,7 +217,11 @@ public abstract class SupportManager { synchronized (dispute.getChatMessages()) { for (ChatMessage chatMessage : dispute.getChatMessages()) { if (chatMessage.getUid().equals(ackMessage.getSourceUid())) { - if (trade.getDisputeState().isCloseRequested()) { + if (trade.getDisputeState().isRequested()) { + 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); diff --git a/core/src/main/java/haveno/core/support/dispute/DisputeManager.java b/core/src/main/java/haveno/core/support/dispute/DisputeManager.java index 6fc308f9fc..1b7bb841ab 100644 --- a/core/src/main/java/haveno/core/support/dispute/DisputeManager.java +++ b/core/src/main/java/haveno/core/support/dispute/DisputeManager.java @@ -64,6 +64,7 @@ import haveno.core.trade.ArbitratorTrade; import haveno.core.trade.ClosedTradableManager; import haveno.core.trade.Contract; import haveno.core.trade.HavenoUtils; +import haveno.core.trade.SellerTrade; import haveno.core.trade.Trade; import haveno.core.trade.TradeManager; import haveno.core.trade.protocol.TradePeer; @@ -222,7 +223,7 @@ public abstract class DisputeManager> extends Sup /////////////////////////////////////////////////////////////////////////////////////////// // We get this message at both peers. The dispute object is in context of the trader - public abstract void handleDisputeClosedMessage(DisputeClosedMessage disputeClosedMessage); + public abstract void handle(DisputeClosedMessage disputeClosedMessage); public abstract NodeAddress getAgentNodeAddress(Dispute dispute); @@ -403,13 +404,22 @@ public abstract class DisputeManager> extends Sup chatMessage.setSystemMessage(true); dispute.addAndPersistChatMessage(chatMessage); - // export multisig hex if needed - if (trade.getSelf().getUpdatedMultisigHex() == null) { - try { - trade.exportMultisigHex(); - } catch (Exception e) { - log.error("Failed to export multisig hex", e); + // 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 @@ -490,7 +500,7 @@ public abstract class DisputeManager> extends Sup } // arbitrator receives dispute opened message from opener, opener's peer receives from arbitrator - protected void handleDisputeOpenedMessage(DisputeOpenedMessage message) { + protected void handle(DisputeOpenedMessage message) { Dispute msgDispute = message.getDispute(); log.info("Processing {} with trade {}, dispute {}", message.getClass().getSimpleName(), msgDispute.getTradeId(), msgDispute.getId()); @@ -500,16 +510,12 @@ public abstract class DisputeManager> extends Sup log.warn("Ignoring DisputeOpenedMessage for trade {} because it does not exist", msgDispute.getTradeId()); return; } - if (trade.isPayoutPublished()) { - log.warn("Ignoring DisputeOpenedMessage for {} {} because payout is already published", trade.getClass().getSimpleName(), trade.getId()); - return; - } // find existing dispute Optional storedDisputeOptional = findDispute(msgDispute); // determine if re-opening dispute - boolean reOpen = storedDisputeOptional.isPresent() && storedDisputeOptional.get().isClosed(); + boolean reOpen = storedDisputeOptional.isPresent(); // use existing dispute or create new Dispute dispute = reOpen ? storedDisputeOptional.get() : msgDispute; @@ -588,6 +594,21 @@ public abstract class DisputeManager> extends Sup TradePeer opener = sender == trade.getArbitrator() ? trade.getTradePeer() : sender; if (message.getOpenerUpdatedMultisigHex() != null) opener.setUpdatedMultisigHex(message.getOpenerUpdatedMultisigHex()); + // TODO: peer needs to import multisig hex at some point + // TODO: DisputeOpenedMessage should include arbitrator's updated multisig hex too + // TODO: arbitrator needs to import multisig info then scan for updated state? + + // arbitrator syncs and polls wallet unless finalized + if (trade.isArbitrator() && !trade.isPayoutFinalized()) { + trade.syncAndPollWallet(); + trade.recoverIfMissingWalletData(); + } + + // nack if payout published + if (trade.isPayoutPublished()) { + throw new RuntimeException("Ignoring DisputeOpenedMessage because payout is already published for " + trade.getClass().getSimpleName() + " " + trade.getId() + ", payoutTxId=" + trade.getPayoutTxId()); + } + // add chat message with price info if (trade instanceof ArbitratorTrade) addPriceInfoMessage(dispute, 0); @@ -934,6 +955,9 @@ public abstract class DisputeManager> extends Sup // sync and poll trade.syncAndPollWallet(); + // recover if missing wallet data + trade.recoverIfMissingWalletData(); + // check if payout tx already published String alreadyPublishedMsg = "Cannot create dispute payout tx because payout tx is already published for trade " + trade.getId(); if (trade.isPayoutPublished()) throw new RuntimeException(alreadyPublishedMsg); diff --git a/core/src/main/java/haveno/core/support/dispute/arbitration/ArbitrationManager.java b/core/src/main/java/haveno/core/support/dispute/arbitration/ArbitrationManager.java index 6b54e3fb48..3b5eab267d 100644 --- a/core/src/main/java/haveno/core/support/dispute/arbitration/ArbitrationManager.java +++ b/core/src/main/java/haveno/core/support/dispute/arbitration/ArbitrationManager.java @@ -148,11 +148,11 @@ public final class ArbitrationManager extends DisputeManager { if (message instanceof DisputeOpenedMessage) { - handleDisputeOpenedMessage((DisputeOpenedMessage) message); + handle((DisputeOpenedMessage) message); } else if (message instanceof ChatMessage) { - handleChatMessage((ChatMessage) message); + handle((ChatMessage) message); } else if (message instanceof DisputeClosedMessage) { - handleDisputeClosedMessage((DisputeClosedMessage) message); + handle((DisputeClosedMessage) message); } else { log.warn("Unsupported message at dispatchMessage. message={}", message); } @@ -226,11 +226,11 @@ public final class ArbitrationManager extends DisputeManager message.getClass().getSimpleName(), message.getTradeId(), message.getUid()); if (message instanceof DisputeOpenedMessage) { - handleDisputeOpenedMessage((DisputeOpenedMessage) message); + handle((DisputeOpenedMessage) message); } else if (message instanceof ChatMessage) { - handleChatMessage((ChatMessage) message); + handle((ChatMessage) message); } else if (message instanceof DisputeClosedMessage) { - handleDisputeClosedMessage((DisputeClosedMessage) message); + handle((DisputeClosedMessage) message); } else { log.warn("Unsupported message at dispatchMessage. message={}", message); } @@ -150,7 +150,7 @@ public final class MediationManager extends DisputeManager @Override // We get that message at both peers. The dispute object is in context of the trader - public void handleDisputeClosedMessage(DisputeClosedMessage disputeResultMessage) { + public void handle(DisputeClosedMessage disputeResultMessage) { DisputeResult disputeResult = disputeResultMessage.getDisputeResult(); String tradeId = disputeResult.getTradeId(); ChatMessage chatMessage = disputeResult.getChatMessage(); @@ -163,7 +163,7 @@ public final class MediationManager extends DisputeManager "We try again after 2 sec. to apply the disputeResultMessage. TradeId = " + tradeId); if (!delayMsgMap.containsKey(uid)) { // We delay 2 sec. to be sure the comm. msg gets added first - Timer timer = UserThread.runAfter(() -> handleDisputeClosedMessage(disputeResultMessage), 2); + Timer timer = UserThread.runAfter(() -> handle(disputeResultMessage), 2); delayMsgMap.put(uid, timer); } else { log.warn("We got a dispute result msg after we already repeated to apply the message after a delay. " + diff --git a/core/src/main/java/haveno/core/support/dispute/refund/RefundManager.java b/core/src/main/java/haveno/core/support/dispute/refund/RefundManager.java index 8748def337..ad078e96ac 100644 --- a/core/src/main/java/haveno/core/support/dispute/refund/RefundManager.java +++ b/core/src/main/java/haveno/core/support/dispute/refund/RefundManager.java @@ -97,11 +97,11 @@ public final class RefundManager extends DisputeManager { message.getClass().getSimpleName(), message.getTradeId(), message.getUid()); if (message instanceof DisputeOpenedMessage) { - handleDisputeOpenedMessage((DisputeOpenedMessage) message); + handle((DisputeOpenedMessage) message); } else if (message instanceof ChatMessage) { - handleChatMessage((ChatMessage) message); + handle((ChatMessage) message); } else if (message instanceof DisputeClosedMessage) { - handleDisputeClosedMessage((DisputeClosedMessage) message); + handle((DisputeClosedMessage) message); } else { log.warn("Unsupported message at dispatchMessage. message={}", message); } @@ -149,7 +149,7 @@ public final class RefundManager extends DisputeManager { @Override // We get that message at both peers. The dispute object is in context of the trader - public void handleDisputeClosedMessage(DisputeClosedMessage disputeResultMessage) { + public void handle(DisputeClosedMessage disputeResultMessage) { DisputeResult disputeResult = disputeResultMessage.getDisputeResult(); String tradeId = disputeResult.getTradeId(); ChatMessage chatMessage = disputeResult.getChatMessage(); @@ -162,7 +162,7 @@ public final class RefundManager extends DisputeManager { "We try again after 2 sec. to apply the disputeResultMessage. TradeId = " + tradeId); if (!delayMsgMap.containsKey(uid)) { // We delay 2 sec. to be sure the comm. msg gets added first - Timer timer = UserThread.runAfter(() -> handleDisputeClosedMessage(disputeResultMessage), 2); + Timer timer = UserThread.runAfter(() -> handle(disputeResultMessage), 2); delayMsgMap.put(uid, timer); } else { log.warn("We got a dispute result msg after we already repeated to apply the message after a delay. " + diff --git a/core/src/main/java/haveno/core/support/traderchat/TraderChatManager.java b/core/src/main/java/haveno/core/support/traderchat/TraderChatManager.java index f462becdf0..7279c6021e 100644 --- a/core/src/main/java/haveno/core/support/traderchat/TraderChatManager.java +++ b/core/src/main/java/haveno/core/support/traderchat/TraderChatManager.java @@ -148,7 +148,7 @@ public class TraderChatManager extends SupportManager { log.info("Received {} with tradeId {} and uid {}", message.getClass().getSimpleName(), message.getTradeId(), message.getUid()); if (message instanceof ChatMessage) { - handleChatMessage((ChatMessage) message); + handle((ChatMessage) message); } else { log.warn("Unsupported message at dispatchMessage. message={}", message); } diff --git a/core/src/main/java/haveno/core/trade/HavenoUtils.java b/core/src/main/java/haveno/core/trade/HavenoUtils.java index 9dd3760e6d..ba73712598 100644 --- a/core/src/main/java/haveno/core/trade/HavenoUtils.java +++ b/core/src/main/java/haveno/core/trade/HavenoUtils.java @@ -44,6 +44,7 @@ import haveno.core.trade.messages.PaymentSentMessage; import haveno.core.trade.statistics.TradeStatisticsManager; import haveno.core.user.Preferences; import haveno.core.util.JsonUtil; +import haveno.core.xmr.wallet.XmrWalletBase; import haveno.core.xmr.wallet.XmrWalletService; import haveno.network.p2p.NodeAddress; @@ -626,13 +627,17 @@ public class HavenoUtils { } public static boolean isUnresponsive(Throwable e) { - return isConnectionRefused(e) || isReadTimeout(e); + return isConnectionRefused(e) || isReadTimeout(e) || XmrWalletBase.isSyncWithProgressTimeout(e); } public static boolean isNotEnoughSigners(Throwable e) { return e != null && e.getMessage().contains("Not enough signers"); } + public static boolean isFailedToParse(Throwable e) { + return e != null && e.getMessage().contains("Failed to parse"); + } + public static boolean isTransactionRejected(Throwable e) { return e != null && e.getMessage().contains("was rejected"); } diff --git a/core/src/main/java/haveno/core/trade/Trade.java b/core/src/main/java/haveno/core/trade/Trade.java index 9243f2f4f5..1ce14c729b 100644 --- a/core/src/main/java/haveno/core/trade/Trade.java +++ b/core/src/main/java/haveno/core/trade/Trade.java @@ -38,12 +38,14 @@ import com.google.common.base.Preconditions; import com.google.protobuf.ByteString; import com.google.protobuf.Message; import haveno.common.ThreadUtils; +import haveno.common.Timer; import haveno.common.UserThread; import haveno.common.crypto.Encryption; import haveno.common.crypto.PubKeyRing; import haveno.common.proto.ProtoUtil; import haveno.common.taskrunner.Model; import haveno.common.util.Utilities; +import haveno.core.locale.Res; import haveno.core.monetary.Price; import haveno.core.monetary.Volume; import haveno.core.network.MessageState; @@ -62,6 +64,7 @@ import haveno.core.support.messages.ChatMessage; import haveno.core.trade.messages.TradeMessage; import haveno.core.trade.protocol.ProcessModel; import haveno.core.trade.protocol.ProcessModelServiceProvider; +import haveno.core.trade.protocol.SellerProtocol; import haveno.core.trade.protocol.TradeListener; import haveno.core.trade.protocol.TradePeer; import haveno.core.trade.protocol.TradeProtocol; @@ -75,12 +78,14 @@ import haveno.network.p2p.AckMessage; import haveno.network.p2p.NodeAddress; import haveno.network.p2p.P2PService; import haveno.network.p2p.network.TorNetworkNode; +import javafx.beans.property.BooleanProperty; import javafx.beans.property.DoubleProperty; import javafx.beans.property.IntegerProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.ReadOnlyDoubleProperty; import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.ReadOnlyStringProperty; +import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleDoubleProperty; import javafx.beans.property.SimpleIntegerProperty; import javafx.beans.property.SimpleObjectProperty; @@ -119,6 +124,8 @@ import javax.annotation.Nullable; import javax.crypto.SecretKey; import java.math.BigInteger; import java.time.Clock; +import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.Date; @@ -146,6 +153,8 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { private static final long EXTENDED_RPC_TIMEOUT = 600000; // 10 minutes private static final long DELETE_AFTER_MS = TradeProtocol.TRADE_STEP_TIMEOUT_SECONDS; private static final int NUM_CONFIRMATIONS_FOR_SCHEDULED_IMPORT = 5; + public static final int NUM_BLOCKS_DEPOSITS_FINALIZED = 30; // ~1 hour before deposits are considered finalized + public static final int NUM_BLOCKS_PAYOUT_FINALIZED = 60; // ~2 hours before payout is considered finalized and multisig wallet deleted protected final Object pollLock = new Object(); private final Object removeTradeOnErrorLock = new Object(); protected static final Object importMultisigLock = new Object(); @@ -154,6 +163,15 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { private Subscription protocolErrorStateSubscription; private Subscription protocolErrorHeightSubscription; public static final String PROTOCOL_VERSION = "protocolVersion"; // key for extraDataMap in trade statistics + public BooleanProperty wasWalletPolled = new SimpleBooleanProperty(false); + + // missing or failed payout tx handling + private static final int HANDLE_MISSING_PAYOUT_AFTER_MINS = 6; // minimum delay before handling missing payout tx in minutes + private static final long MIN_MISSING_TX_POLL_INTERVAL_MS = 30000; // throttle missing payout tx processing to once per 30s + private Object missingPayoutTxLock = new Object(); + private Timer missingPayoutTxTimer; + private boolean handleMissingPayoutTxOnNextPoll = false; + private long lastMissingTxPollTime = 0; /////////////////////////////////////////////////////////////////////////////////////////// // Enums @@ -176,16 +194,19 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { SAW_ARRIVED_PUBLISH_DEPOSIT_TX_REQUEST(Phase.DEPOSIT_REQUESTED), PUBLISH_DEPOSIT_TX_REQUEST_FAILED(Phase.DEPOSIT_REQUESTED), - // deposit published + // deposits published ARBITRATOR_PUBLISHED_DEPOSIT_TXS(Phase.DEPOSITS_PUBLISHED), DEPOSIT_TXS_SEEN_IN_NETWORK(Phase.DEPOSITS_PUBLISHED), - // deposit confirmed + // deposits confirmed DEPOSIT_TXS_CONFIRMED_IN_BLOCKCHAIN(Phase.DEPOSITS_CONFIRMED), - // deposit unlocked + // deposits unlocked DEPOSIT_TXS_UNLOCKED_IN_BLOCKCHAIN(Phase.DEPOSITS_UNLOCKED), + // deposits finalized + DEPOSIT_TXS_FINALIZED_IN_BLOCKCHAIN(Phase.DEPOSITS_FINALIZED), + // payment sent BUYER_CONFIRMED_PAYMENT_SENT(Phase.PAYMENT_SENT), BUYER_SENT_PAYMENT_SENT_MSG(Phase.PAYMENT_SENT), @@ -238,6 +259,7 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { DEPOSITS_PUBLISHED, DEPOSITS_CONFIRMED, DEPOSITS_UNLOCKED, + DEPOSITS_FINALIZED, PAYMENT_SENT, PAYMENT_RECEIVED; @@ -261,7 +283,8 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { PAYOUT_UNPUBLISHED, PAYOUT_PUBLISHED, PAYOUT_CONFIRMED, - PAYOUT_UNLOCKED; + PAYOUT_UNLOCKED, + PAYOUT_FINALIZED; public static Trade.PayoutState fromProto(protobuf.Trade.PayoutState state) { return ProtoUtil.enumFromProto(Trade.PayoutState.class, state.name()); @@ -324,7 +347,7 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { } public boolean isOpen() { - return isRequested() && !isClosed(); + return ordinal() >= DisputeState.DISPUTE_OPENED.ordinal() && !isClosed(); } public boolean isCloseRequested() { @@ -640,18 +663,18 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { ThreadUtils.execute(() -> onConnectionChanged(connection), getId()); }); - // reset states if no ack receive + // reset states if not awaiting processing if (!isPayoutPublished()) { - // reset buyer's payment sent state if no ack receive - if (this instanceof BuyerTrade && getState().ordinal() >= Trade.State.BUYER_CONFIRMED_PAYMENT_SENT.ordinal() && getState().ordinal() < Trade.State.BUYER_STORED_IN_MAILBOX_PAYMENT_SENT_MSG.ordinal()) { - log.warn("Resetting state of {} {} from {} to {} because no ack was received", getClass().getSimpleName(), getId(), getState(), Trade.State.DEPOSIT_TXS_UNLOCKED_IN_BLOCKCHAIN); + // reset buyer's payment sent state + if (this instanceof BuyerTrade && (getState().ordinal() == Trade.State.BUYER_CONFIRMED_PAYMENT_SENT.ordinal() || getState() == State.BUYER_SEND_FAILED_PAYMENT_SENT_MSG)) { + log.warn("Resetting state of {} {} from {} to {} because sending PaymentSentMessage failed", getClass().getSimpleName(), getId(), getState(), Trade.State.DEPOSIT_TXS_UNLOCKED_IN_BLOCKCHAIN); setState(Trade.State.DEPOSIT_TXS_UNLOCKED_IN_BLOCKCHAIN); } - - // reset seller's payment received state if no ack receive - if (this instanceof SellerTrade && getState().ordinal() >= Trade.State.SELLER_CONFIRMED_PAYMENT_RECEIPT.ordinal() && getState().ordinal() < Trade.State.SELLER_STORED_IN_MAILBOX_PAYMENT_RECEIVED_MSG.ordinal()) { - log.warn("Resetting state of {} {} from {} to {} because no ack was received", getClass().getSimpleName(), getId(), getState(), Trade.State.BUYER_SENT_PAYMENT_SENT_MSG); + + // reset seller's payment received state + if (this instanceof SellerTrade && (getState().ordinal() == Trade.State.SELLER_CONFIRMED_PAYMENT_RECEIPT.ordinal() || getState() == State.SELLER_SEND_FAILED_PAYMENT_RECEIVED_MSG)) { + log.warn("Resetting state of {} {} from {} to {} because sending PaymentReceivedMessage failed", getClass().getSimpleName(), getId(), getState(), Trade.State.BUYER_SENT_PAYMENT_SENT_MSG); resetToPaymentSentState(); } } @@ -666,12 +689,13 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { tradePhaseSubscription = EasyBind.subscribe(phaseProperty, newValue -> { if (!isInitialized || isShutDownStarted) return; ThreadUtils.submitToPool(() -> { - if (newValue == Trade.Phase.DEPOSIT_REQUESTED) startPolling(); + if (newValue == Trade.Phase.DEPOSIT_REQUESTED) onDepositRequested(); if (newValue == Trade.Phase.DEPOSITS_PUBLISHED) onDepositsPublished(); if (newValue == Trade.Phase.DEPOSITS_CONFIRMED) onDepositsConfirmed(); if (newValue == Trade.Phase.DEPOSITS_UNLOCKED) onDepositsUnlocked(); + if (newValue == Trade.Phase.DEPOSITS_FINALIZED) onDepositsFinalized(); if (newValue == Trade.Phase.PAYMENT_SENT) onPaymentSent(); - if (isDepositsPublished() && !isPayoutUnlocked()) updatePollPeriod(); + if (isDepositsPublished() && !isPayoutFinalized()) updatePollPeriod(); if (isPaymentReceived()) { UserThread.execute(() -> { if (tradePhaseSubscription != null) { @@ -717,10 +741,10 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { processModel.getXmrWalletService().swapPayoutAddressEntryToAvailable(getId()); } - // handle when payout unlocks - if (newValue == Trade.PayoutState.PAYOUT_UNLOCKED) { + // handle when payout finalized + if (newValue == Trade.PayoutState.PAYOUT_FINALIZED) { if (!isInitialized) return; - log.info("Payout unlocked for {} {}, deleting multisig wallet", getClass().getSimpleName(), getId()); + log.info("Payout finalized for {} {}, deleting multisig wallet", getClass().getSimpleName(), getId()); if (isInitialized && isFinished()) clearAndShutDown(); else deleteWallet(); } @@ -757,8 +781,8 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { importMultisigHexIfScheduled(); }); - // done if deposit not requested or payout unlocked - if (!isDepositRequested() || isPayoutUnlocked()) { + // done if deposit not requested or payout finalized + if (!isDepositRequested() || isPayoutFinalized()) { isInitialized = true; isFullyInitialized = true; return; @@ -768,9 +792,17 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { if (walletExists()) getWallet(); else { MoneroTx payoutTx = getPayoutTx(); - if (payoutTx != null && payoutTx.getNumConfirmations() >= XmrWalletService.NUM_BLOCKS_UNLOCK) { - log.warn("Payout state for {} {} is {} but payout is unlocked, updating state", getClass().getSimpleName(), getId(), getPayoutState()); - setPayoutStateUnlocked(); + if (payoutTx != null) { + + // update payout state if necessary + if (!isPayoutUnlocked() && payoutTx.getNumConfirmations() >= XmrWalletService.NUM_BLOCKS_UNLOCK) { + log.warn("Payout state for {} {} is {} but payout is unlocked, updating state", getClass().getSimpleName(), getId(), getPayoutState()); + setPayoutStateUnlocked(); + } + if (!isPayoutFinalized() && payoutTx.getNumConfirmations() >= NUM_BLOCKS_PAYOUT_FINALIZED) { + log.warn("Payout state for {} {} is {} but payout is finalized, updating state", getClass().getSimpleName(), getId(), getPayoutState()); + setPayoutStateFinalized(); + } isInitialized = true; isFullyInitialized = true; return; @@ -779,6 +811,9 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { } } + // poll wallet without network calls + doPollWallet(true); + // trade is initialized isInitialized = true; @@ -790,7 +825,9 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { } public boolean isFinished() { - return isPayoutUnlocked() && isCompleted(); + if (!isCompleted()) return false; + if (isPayoutUnlocked() && !walletExists()) return true; + return isPayoutFinalized(); } public void resetToPaymentSentState() { @@ -802,11 +839,33 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { setPayoutTxHex(null); } - public void reprocessApplicableMessages() { - if (!isDepositRequested() || isPayoutUnlocked() || isCompleted()) return; + public void initializeAfterMailboxMessages() { + if (!isDepositRequested() || isPayoutFinalized() || isCompleted()) return; getProtocol().maybeReprocessPaymentSentMessage(false); getProtocol().maybeReprocessPaymentReceivedMessage(false); HavenoUtils.arbitrationManager.maybeReprocessDisputeClosedMessage(this, false); + + // handle when wallet first polled + if (wasWalletPolled.get()) onWalletFirstPolled(); + else { + wasWalletPolled.addListener((observable, oldValue, newValue) -> { + if (newValue) onWalletFirstPolled(); + }); + } + } + + private void onWalletFirstPolled() { + requestSaveWallet(); + checkForUnconfirmedTimeout(); + } + + private void checkForUnconfirmedTimeout() { + if (isDepositsConfirmed()) return; + long unconfirmedHours = Duration.between(getDate().toInstant(), Instant.now()).toHours(); + if (unconfirmedHours >= 3 && !hasFailed()) { + String errorMessage = Res.get("portfolio.pending.unconfirmedTooLong", getShortId(), unconfirmedHours); + prependErrorMessage(errorMessage); + } } public void awaitInitialized() { @@ -1021,23 +1080,28 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { syncedWallet = true; } - // sync wallet if deposit requested and payout not unlocked - if (!isPayoutUnlocked() && !syncedWallet) { + // sync wallet if deposit requested and payout not finalized + if (!isPayoutFinalized() && !syncedWallet) { log.warn("Syncing wallet on deletion for trade {} {}, syncing", getClass().getSimpleName(), getId()); syncWallet(true); } - // check if deposits published and payout not unlocked - if (isDepositsPublished() && !isPayoutUnlocked()) { - throw new IllegalStateException("Refusing to delete wallet for " + getClass().getSimpleName() + " " + getId() + " because the deposit txs have been published but payout tx has not unlocked"); + // check if deposits published and payout not finalized + if (isDepositsPublished() && !isPayoutFinalized()) { + throw new IllegalStateException("Refusing to delete wallet for " + getClass().getSimpleName() + " " + getId() + " because the deposit txs have been published but payout tx has not finalized"); } // check for balance if (wallet.getBalance().compareTo(BigInteger.ZERO) > 0) { log.warn("Rescanning spent outputs for {} {}", getClass().getSimpleName(), getId()); - wallet.rescanSpent(); + rescanSpent(false); if (wallet.getBalance().compareTo(BigInteger.ZERO) > 0) { - throw new IllegalStateException("Refusing to delete wallet for " + getClass().getSimpleName() + " " + getId() + " because it has a balance of " + wallet.getBalance()); + if (isBuyer()) { + processBuyerPayout(payoutTxId); // process payout to main wallet + log.warn("Trade wallet for " + getClass().getSimpleName() + " " + getId() + " has a balance of " + wallet.getBalance() + ", but payout tx " + payoutTxId + " is verified, so proceeding to delete wallet"); + } else { + throw new IllegalStateException("Refusing to delete wallet for " + getClass().getSimpleName() + " " + getId() + " because it has a balance of " + wallet.getBalance()); + } } } } @@ -1051,7 +1115,7 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { xmrWalletService.deleteWalletBackups(getWalletName()); } catch (Exception e) { log.warn("Error deleting wallet for {} {}: {}\n", getClass().getSimpleName(), getId(), e.getMessage(), e); - setErrorMessage(e.getMessage()); + prependErrorMessage(e.getMessage()); processModel.getTradeManager().getNotificationService().sendErrorNotification("Error", e.getMessage()); } } else { @@ -1108,6 +1172,7 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { public void exportMultisigHex() { synchronized (walletLock) { + log.info("Exporting multisig info for {} {}", getClass().getSimpleName(), getShortId()); getSelf().setUpdatedMultisigHex(wallet.exportMultisigHex()); saveWallet(); } @@ -1128,8 +1193,9 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { private void importMultisigHexIfScheduled() { if (!isInitialized || isShutDownStarted) return; - if (!isDepositsConfirmed() || getMaker().getDepositTx() == null) return; - if (walletHeight.get() - getMaker().getDepositTx().getHeight() < NUM_CONFIRMATIONS_FOR_SCHEDULED_IMPORT) return; + MoneroTxWallet makerDepositTx = getMaker().getDepositTx(); + if (!isDepositsConfirmed() || makerDepositTx == null) return; + if (walletHeight.get() - makerDepositTx.getHeight() < NUM_CONFIRMATIONS_FOR_SCHEDULED_IMPORT) return; ThreadUtils.execute(() -> { if (!isInitialized || isShutDownStarted) return; synchronized (getLock()) { @@ -1168,8 +1234,8 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { private void doImportMultisigHex() { - // ensure wallet sees deposits confirmed - if (!isDepositsConfirmed()) syncAndPollWallet(); + // sync and poll wallet if deposits not confirmed (unless only one deposit unlocked) + if (!isDepositsConfirmed() && !hasUnlockedTx()) syncAndPollWallet(); // collect multisig hex from peers List multisigHexes = new ArrayList(); @@ -1242,7 +1308,7 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { private void handleWalletError(Exception e, MoneroRpcConnection sourceConnection, int numAttempts) { if (HavenoUtils.isUnresponsive(e)) forceCloseWallet(); // wallet can be stuck a while if (numAttempts % TradeProtocol.REQUEST_CONNECTION_SWITCH_EVERY_NUM_ATTEMPTS == 0 && !HavenoUtils.isIllegal(e) && xmrConnectionService.isConnected()) requestSwitchToNextBestConnection(sourceConnection); // request connection switch every n attempts - getWallet(); // re-open wallet + if (!isShutDownStarted) getWallet(); // re-open wallet } private String getMultisigHexRole(String multisigHex) { @@ -1273,7 +1339,9 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { for (int i = 0; i < TradeProtocol.MAX_ATTEMPTS; i++) { MoneroRpcConnection sourceConnection = xmrConnectionService.getConnection(); try { - return doCreatePayoutTx(); + MoneroTxWallet unsignedPayoutTx = doCreatePayoutTx(); + log.info("Done creating unsigned payout tx for {} {}", getClass().getSimpleName(), getShortId()); + return unsignedPayoutTx; } catch (IllegalArgumentException | IllegalStateException e) { throw e; } catch (Exception e) { @@ -1486,13 +1554,44 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { setPayoutStatePublished(); } catch (Exception e) { if (!isPayoutPublished()) { - if (HavenoUtils.isTransactionRejected(e) || HavenoUtils.isNotEnoughSigners(e)) throw new IllegalArgumentException(e); + if (HavenoUtils.isTransactionRejected(e) || HavenoUtils.isNotEnoughSigners(e) || HavenoUtils.isFailedToParse(e)) throw new IllegalArgumentException(e); throw new RuntimeException("Failed to submit payout tx for " + getClass().getSimpleName() + " " + getId() + ", error=" + e.getMessage(), e); } } } } + /** + * In case there's a problem observing the payout tx (e.g. due to stale multisig state), + * peers can communicate the payout tx id. + * + * @param payoutTxId is the payout tx id to process + */ + public void processBuyerPayout(String payoutTxId) { + if (payoutTxId == null) throw new IllegalArgumentException("Payout tx id cannot be null"); + if (!isBuyer()) throw new IllegalStateException("Only buyer can process buyer payout tx for " + getClass().getSimpleName() + " " + getShortId()); + + // poll the main wallet + log.warn("Processing payout tx for {} {} by polling main wallet", getClass().getSimpleName(), getShortId()); + long startTime = System.currentTimeMillis(); + xmrWalletService.doPollWallet(true); + log.info("Done polling main wallet to verify payout tx for {} {} in {} ms", getClass().getSimpleName(), getShortId(), System.currentTimeMillis() - startTime); + + // fetch payout tx from main wallet + MoneroTxWallet payoutTx = xmrWalletService.getWallet().getTx(payoutTxId); + if (payoutTx == null) throw new RuntimeException("Payout tx id " + payoutTxId + " not found for " + getClass().getSimpleName() + " " + getId()); + if (payoutTx.isFailed()) throw new RuntimeException("Payout tx " + payoutTxId + " is failed for " + getClass().getSimpleName() + " " + getId()); + + // verify incoming amount + BigInteger txCost = payoutTx.getFee(); + BigInteger txCostSplit = txCost.divide(BigInteger.valueOf(2)); + BigInteger expectedAmount = getBuyer().getSecurityDeposit().add(getAmount()).subtract(txCostSplit); + if (!payoutTx.getIncomingAmount().equals(expectedAmount)) throw new IllegalStateException("Payout tx incoming amount is not deposit amount + trade amount - 1/2 tx costs, " + payoutTx.getIncomingAmount() + " vs " + getBuyer().getSecurityDeposit().add(getAmount()).subtract(txCostSplit)); + + // update payout tx + updatePayout(payoutTx); + } + /** * Decrypt the peer's payment account payload using the given key. * @@ -1521,15 +1620,24 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { } @Nullable - public MoneroTx getTakerDepositTx() { + public MoneroTxWallet getTakerDepositTx() { return getTaker().getDepositTx(); } @Nullable - public MoneroTx getMakerDepositTx() { + public MoneroTxWallet getMakerDepositTx() { return getMaker().getDepositTx(); } + private Long getMinDepositTxConfirmations() { + MoneroTxWallet makerDepositTx = getMakerDepositTx(); + if (makerDepositTx == null) return null; + if (hasBuyerAsTakerWithoutDeposit()) return makerDepositTx.getNumConfirmations(); + MoneroTxWallet takerDepositTx = getTakerDepositTx(); + if (takerDepositTx == null) return null; + return Math.min(makerDepositTx.getNumConfirmations(), takerDepositTx.getNumConfirmations()); + } + public void addAndPersistChatMessage(ChatMessage chatMessage) { synchronized (chatMessages) { if (!chatMessages.contains(chatMessage)) { @@ -1632,7 +1740,7 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { public void shutDown() { if (isShutDown) return; // ignore if already shut down isShutDownStarted = true; - if (!isPayoutUnlocked()) log.info("Shutting down {} {}", getClass().getSimpleName(), getId()); + if (!isPayoutFinalized()) log.info("Shutting down {} {}", getClass().getSimpleName(), getId()); // unregister p2p message listener removeDecryptedDirectMessageListener(); @@ -1693,9 +1801,9 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { // Trade error cleanup /////////////////////////////////////////////////////////////////////////////////////////// - public void onProtocolError() { + public void onProtocolInitializationError() { - // check if deposit published + // check if deposits published if (isDepositsPublished()) { restoreDepositsPublishedTrade(); return; @@ -1715,7 +1823,7 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { } // remove if deposit not requested or is failed - if (!isDepositRequested() || isDepositFailed()) { + if (!isDepositRequested() || isDepositRequestFailed()) { removeTradeOnError(); return; } @@ -1881,7 +1989,7 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { public void setState(State state) { if (isInitialized) { // We don't want to log at startup the setState calls from all persisted trades - log.info("Set new state at {} (id={}): {}", this.getClass().getSimpleName(), getShortId(), state); + log.info("Set new state for trade {} {}: {}", getShortId(), this.getClass().getSimpleName(), state); } if (state.getPhase().ordinal() < this.state.getPhase().ordinal()) { String message = "We got a state change to a previous phase (id=" + getShortId() + ").\n" + @@ -1890,11 +1998,22 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { } this.state = state; + + persistNow(null); UserThread.execute(() -> { stateProperty.set(state); phaseProperty.set(state.getPhase()); }); + + // automatically advance unlocked state to finalized if sufficient confirmations + if (state == State.DEPOSIT_TXS_UNLOCKED_IN_BLOCKCHAIN) { + Long minDepositTxConfirmations = getMinDepositTxConfirmations(); + if (minDepositTxConfirmations != null && minDepositTxConfirmations >= NUM_BLOCKS_DEPOSITS_FINALIZED) { + log.info("Auto-advancing state to {} for {} {} because deposits are unlocked and have at least {} confirmations", State.DEPOSIT_TXS_FINALIZED_IN_BLOCKCHAIN, this.getClass().getSimpleName(), getShortId(), NUM_BLOCKS_DEPOSITS_FINALIZED); + setState(State.DEPOSIT_TXS_FINALIZED_IN_BLOCKCHAIN); + } + } } public void advanceState(State state) { @@ -1913,7 +2032,7 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { public void setPayoutState(PayoutState payoutState) { if (isInitialized) { // We don't want to log at startup the setState calls from all persisted trades - log.info("Set new payout state for {} {}: {}", this.getClass().getSimpleName(), getId(), payoutState); + log.info("Set new payout state for trade {} {}: {}", getShortId(), this.getClass().getSimpleName(), payoutState); } if (payoutState.ordinal() < this.payoutState.ordinal()) { String message = "We got a payout state change to a previous phase (id=" + getShortId() + ").\n" + @@ -1929,7 +2048,7 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { public void setDisputeState(DisputeState disputeState) { if (isInitialized) { // We don't want to log at startup the setState calls from all persisted trades - log.info("Set new dispute state for {} {}: {}", this.getClass().getSimpleName(), getShortId(), disputeState); + log.info("Set new dispute state for trade {} {}: {}", getShortId(), this.getClass().getSimpleName(), disputeState); } if (disputeState.ordinal() < this.disputeState.ordinal()) { String message = "We got a dispute state change to a previous state (id=" + getShortId() + ").\n" + @@ -1973,14 +2092,14 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { getVolumeProperty().set(getVolume()); } - public void updatePayout(MoneroTxWallet payoutTx) { + public void updatePayout(MoneroTx payoutTx) { // set payout tx fields this.payoutTx = payoutTx; - payoutTxKey = payoutTx.getKey(); - payoutTxFee = payoutTx.getFee().longValueExact(); - payoutTxId = payoutTx.getHash(); - if ("".equals(payoutTxId)) payoutTxId = null; // tx id is empty until signed + this.payoutTxId = payoutTx.getHash(); + this.payoutTxFee = payoutTx.getFee() == null ? 0 : payoutTx.getFee().longValueExact(); + this.payoutTxKey = payoutTx.getKey(); + if ("".equals(payoutTxId)) this.payoutTxId = null; // tx id is empty until signed // set payout tx id in dispute(s) for (Dispute dispute : getDisputes()) dispute.setDisputePayoutTxId(payoutTxId); @@ -2002,6 +2121,14 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { getSeller().setPayoutAmount(disputeResult.getSellerPayoutAmountBeforeCost().subtract(getSeller().getPayoutTxFee())); } } + + // set payout tx state + if (Boolean.TRUE.equals(payoutTx.isRelayed())) setPayoutStatePublished(); + if (payoutTx.isConfirmed()) setPayoutStateConfirmed(); + if (payoutTx.getNumConfirmations() != null) { + if (payoutTx.getNumConfirmations() >= XmrWalletService.NUM_BLOCKS_UNLOCK) setPayoutStateUnlocked(); + if (payoutTx.getNumConfirmations() >= NUM_BLOCKS_PAYOUT_FINALIZED) setPayoutStateFinalized(); + } } public DisputeResult getDisputeResult() { @@ -2011,8 +2138,16 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { @Nullable public MoneroTx getPayoutTx() { - if (payoutTx == null) { - payoutTx = payoutTxId == null ? null : (this instanceof ArbitratorTrade) ? xmrWalletService.getDaemonTxWithCache(payoutTxId) : xmrWalletService.getTx(payoutTxId); + if (payoutTx == null && payoutTxId != null) { + if (this instanceof ArbitratorTrade) { + payoutTx = xmrWalletService.getDaemonTxWithCache(payoutTxId); + } else { + payoutTx = xmrWalletService.getTx(payoutTxId); + if (payoutTx == null) { + log.warn("Main wallet is missing payout tx for {} {}, fetching from daemon", getClass().getSimpleName(), getShortId()); + payoutTx = xmrWalletService.getDaemonTxWithCache(payoutTxId); + } + } } return payoutTx; } @@ -2108,6 +2243,14 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { return offer.getDirection() == OfferDirection.BUY ? processModel.getTaker() : processModel.getMaker(); } + public TradePeer getOtherPeer(TradePeer peer) { + List peers = getAllPeers(); + if (!peers.remove(peer)) throw new IllegalArgumentException("Peer is not maker, taker, or arbitrator"); + if (!peers.remove(getSelf())) throw new IllegalStateException("Self is not maker, taker, or arbitrator"); + if (peers.size() != 1) throw new IllegalStateException("There should be exactly one other peer"); + return peers.get(0); + } + // get the taker if maker, maker if taker, null if arbitrator public TradePeer getTradePeer() { if (this instanceof MakerTrade) return processModel.getTaker(); @@ -2188,57 +2331,60 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { } } + public void maybeUpdateTradePeriod() { + if (startTime > 0) return; // already set + if (getTakeOfferDate() == null) return; // trade not started yet + if (!isDepositsFinalized()) return; // deposits not finalized yet + + long now = System.currentTimeMillis(); + long tradeTime = getTakeOfferDate().getTime(); + MoneroDaemon monerod = xmrWalletService.getMonerod(); + if (monerod == null) throw new RuntimeException("Cannot set start time for trade " + getId() + " because it has no connection to monerod"); + + // get finalize time of last deposit tx + long finalizeHeight = getDepositsFinalizedHeight(); + long finalizeTime = monerod.getBlockByHeight(finalizeHeight).getTimestamp() * 1000; + + // If block date is in future (Date in blocks can be off by +/- 2 hours) we use our current date. + // If block date is earlier than our trade date we use our trade date. + if (finalizeTime > now) + startTime = now; + else + startTime = Math.max(finalizeTime, tradeTime); + + log.debug("We set the start for the trade period to {}. Trade started at: {}. Block got mined at: {}", + new Date(startTime), new Date(tradeTime), new Date(finalizeTime)); + } + + private long getDepositsFinalizedHeight() { + MoneroTxWallet makerDepositTx = getMakerDepositTx(); + MoneroTxWallet takerDepositTx = getTakerDepositTx(); + if (makerDepositTx == null || (takerDepositTx == null && !hasBuyerAsTakerWithoutDeposit())) throw new RuntimeException("Cannot get finalized height for trade " + getId() + " because its deposit tx is null. Is client connected to a daemon?"); + return Math.max(makerDepositTx.getHeight() + NUM_BLOCKS_DEPOSITS_FINALIZED - 1, hasBuyerAsTakerWithoutDeposit() ? 0l : takerDepositTx.getHeight() + NUM_BLOCKS_DEPOSITS_FINALIZED - 1); + } + public long getMaxTradePeriod() { return getOffer().getPaymentMethod().getMaxTradePeriod(); } public Date getHalfTradePeriodDate() { - return new Date(getStartTime() + getMaxTradePeriod() / 2); + return new Date(getEffectiveStartTime() + getMaxTradePeriod() / 2); } public Date getMaxTradePeriodDate() { - return new Date(getStartTime() + getMaxTradePeriod()); + return new Date(getEffectiveStartTime() + getMaxTradePeriod()); } public Date getStartDate() { - return new Date(getStartTime()); + return new Date(getEffectiveStartTime()); } - private long getStartTime() { - long now = System.currentTimeMillis(); - if (isDepositsConfirmed() && getTakeOfferDate() != null) { - if (isDepositsUnlocked()) { - if (startTime <= 0) setStartTimeFromUnlockedTxs(); // save to model - return startTime; - } else { - log.debug("depositTx not confirmed yet. We don't start counting remaining trade period yet. makerTxId={}, takerTxId={}", getMaker().getDepositTxHash(), getTaker().getDepositTxHash()); - return now; - } - } else { - return now; - } - } - - private void setStartTimeFromUnlockedTxs() { - long now = System.currentTimeMillis(); - final long tradeTime = getTakeOfferDate().getTime(); - MoneroDaemon monerod = xmrWalletService.getMonerod(); - if (monerod == null) throw new RuntimeException("Cannot set start time for trade " + getId() + " because it has no connection to monerod"); - if (getMakerDepositTx() == null || (getTakerDepositTx() == null && !hasBuyerAsTakerWithoutDeposit())) throw new RuntimeException("Cannot set start time for trade " + getId() + " because its unlocked deposit tx is null. Is client connected to a daemon?"); - - // get unlock time of last deposit tx - long unlockHeight = Math.max(getMakerDepositTx().getHeight() + XmrWalletService.NUM_BLOCKS_UNLOCK - 1, hasBuyerAsTakerWithoutDeposit() ? 0l : getTakerDepositTx().getHeight() + XmrWalletService.NUM_BLOCKS_UNLOCK - 1); - long unlockTime = monerod.getBlockByHeight(unlockHeight).getTimestamp() * 1000; - - // If block date is in future (Date in blocks can be off by +/- 2 hours) we use our current date. - // If block date is earlier than our trade date we use our trade date. - if (unlockTime > now) - startTime = now; - else - startTime = Math.max(unlockTime, tradeTime); - - log.debug("We set the start for the trade period to {}. Trade started at: {}. Block got mined at: {}", - new Date(startTime), new Date(tradeTime), new Date(unlockTime)); + /** + * Returns the effective start time for the trade period. + * Returns the current time until the deposits are finalized. + */ + private long getEffectiveStartTime() { + return startTime > 0 ? startTime : System.currentTimeMillis(); } public boolean hasFailed() { @@ -2249,21 +2395,35 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { return getState().getPhase().ordinal() == Phase.INIT.ordinal(); } + public boolean isFundsLockedIn() { + return isDepositsPublished() && !isPayoutPublished(); + } + public boolean isDepositRequested() { return getState().getPhase().ordinal() >= Phase.DEPOSIT_REQUESTED.ordinal(); } - public boolean isDepositFailed() { + public boolean isDepositRequestFailed() { return getState() == Trade.State.PUBLISH_DEPOSIT_TX_REQUEST_FAILED; } + public boolean isDepositTxMissing() { + if (!wasWalletPolled.get()) throw new IllegalStateException("Cannot determine if deposit tx is missing because wallet has not been polled"); + MoneroTxWallet makerDepositTx = getMakerDepositTx(); + MoneroTxWallet takerDepositTx = getTakerDepositTx(); + boolean hasUnlockedDepositTx = (makerDepositTx != null && Boolean.FALSE.equals(makerDepositTx.isLocked())) || (takerDepositTx != null && Boolean.FALSE.equals(takerDepositTx.isLocked())); + if (!hasUnlockedDepositTx) return false; + boolean hasMissingDepositTx = makerDepositTx == null || (!hasBuyerAsTakerWithoutDeposit() && takerDepositTx == null); + return hasMissingDepositTx; + } + public boolean isDepositsPublished() { - if (isDepositFailed()) return false; + if (isDepositRequestFailed()) return false; return getState().getPhase().ordinal() >= Phase.DEPOSITS_PUBLISHED.ordinal() && getMaker().getDepositTxHash() != null && (getTaker().getDepositTxHash() != null || hasBuyerAsTakerWithoutDeposit()); } - public boolean isFundsLockedIn() { - return isDepositsPublished() && !isPayoutPublished(); + public boolean isDepositsSeen() { + return isDepositsPublished() && getState().ordinal() >= State.DEPOSIT_TXS_SEEN_IN_NETWORK.ordinal(); } public boolean isDepositsConfirmed() { @@ -2284,10 +2444,30 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { return isDepositsPublished() && getState().getPhase().ordinal() >= Phase.DEPOSITS_UNLOCKED.ordinal(); } + public boolean isDepositsFinalized() { + if (getState().getPhase().ordinal() < Phase.DEPOSITS_FINALIZED.ordinal()) return false; + else if (getState().getPhase() == Phase.DEPOSITS_FINALIZED) return true; + else if (isPayoutFinalized()) return true; + else { + Long minDepositTxConfirmations = getMinDepositTxConfirmations(); + + // TODO: state can be past finalized (e.g. payment_sent) before the deposits are finalized, ideally use separate enum for deposits + if (minDepositTxConfirmations == null) { + log.warn("Assuming that deposit txs are finalized for trade {} {} because trade is in phase {} but has unknown confirmations", getClass().getSimpleName(), getShortId(), getState().getPhase()); + return true; + } + return minDepositTxConfirmations >= NUM_BLOCKS_DEPOSITS_FINALIZED; + } + } + public boolean isPaymentSent() { return getState().getPhase().ordinal() >= Phase.PAYMENT_SENT.ordinal() && getState() != State.BUYER_SEND_FAILED_PAYMENT_SENT_MSG; } + public boolean hasPaymentSentMessage() { + return (isBuyer() ? getSeller() : getBuyer()).getPaymentSentMessage() != null; // buyer stores message to seller and arbitrator, peers store message from buyer + } + public boolean hasPaymentReceivedMessage() { return (isSeller() ? getBuyer() : getSeller()).getPaymentReceivedMessage() != null; // seller stores message to buyer and arbitrator, peers store message from seller } @@ -2302,6 +2482,14 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { return getDisputeState().isClosed(); } + public boolean isPaymentMarkedSent() { + return getState().getPhase().ordinal() >= Phase.PAYMENT_SENT.ordinal(); + } + + public boolean isPaymentMarkedReceived() { + return getState().getPhase().ordinal() >= Phase.PAYMENT_RECEIVED.ordinal(); + } + public boolean isPaymentReceived() { return getState().getPhase().ordinal() >= Phase.PAYMENT_RECEIVED.ordinal() && getState() != State.SELLER_SEND_FAILED_PAYMENT_RECEIVED_MSG; } @@ -2318,6 +2506,10 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { return getPayoutState().ordinal() >= PayoutState.PAYOUT_UNLOCKED.ordinal(); } + public boolean isPayoutFinalized() { + return getPayoutState().ordinal() >= PayoutState.PAYOUT_FINALIZED.ordinal(); + } + public ReadOnlyDoubleProperty initProgressProperty() { return initProgressProperty; } @@ -2602,10 +2794,10 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { if (getWallet() == null) throw new IllegalStateException("Cannot sync trade wallet because it doesn't exist for " + getClass().getSimpleName() + ", " + getId()); if (getWallet().getDaemonConnection() == null) throw new RuntimeException("Cannot sync trade wallet because it's not connected to a Monero daemon for " + getClass().getSimpleName() + ", " + getId()); if (isWalletBehind()) { - log.info("Syncing wallet for {} {}", getClass().getSimpleName(), getShortId()); + log.info("Syncing wallet for {} {}", getShortId(), getClass().getSimpleName()); long startTime = System.currentTimeMillis(); syncWalletIfBehind(); - log.info("Done syncing wallet for {} {} in {} ms", getClass().getSimpleName(), getShortId(), System.currentTimeMillis() - startTime); + log.info("Done syncing wallet for {} {} in {} ms", getShortId(), getClass().getSimpleName(), System.currentTimeMillis() - startTime); } } @@ -2622,6 +2814,10 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { if (!(e instanceof IllegalStateException) && !isShutDownStarted) { ThreadUtils.execute(() -> requestSwitchToNextBestConnection(sourceConnection), getId()); } + if (HavenoUtils.isUnresponsive(e)) { // wallet can be stuck a while + if (isShutDownStarted) forceCloseWallet(); + else forceRestartTradeWallet(); + } throw e; } } @@ -2681,6 +2877,10 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { } private void doPollWallet() { + doPollWallet(false); + } + + private void doPollWallet(boolean offlinePoll) { // skip if shut down started if (isShutDownStarted) return; @@ -2693,42 +2893,34 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { } // poll wallet + MoneroRpcConnection sourceConnection = xmrConnectionService.getConnection(); try { // skip if shut down started if (isShutDownStarted) return; - // skip if payout unlocked - if (isPayoutUnlocked()) return; + // skip if payout finalized + if (isPayoutFinalized()) return; - // skip if deposit txs unknown or not requested - if (!isDepositRequested() || processModel.getMaker().getDepositTxHash() == null || (processModel.getTaker().getDepositTxHash() == null && !hasBuyerAsTakerWithoutDeposit())) return; + // skip if deposit txs unknown or not expected + if (!isDepositRequested() || isDepositRequestFailed() || processModel.getMaker().getDepositTxHash() == null || (processModel.getTaker().getDepositTxHash() == null && !hasBuyerAsTakerWithoutDeposit())) return; // skip if daemon not synced - if (xmrConnectionService.getTargetHeight() == null || !xmrConnectionService.isSyncedWithinTolerance()) return; + if (!offlinePoll && (xmrConnectionService.getTargetHeight() == null || !xmrConnectionService.isSyncedWithinTolerance())) return; // sync if wallet too far behind daemon - if (walletHeight.get() < xmrConnectionService.getTargetHeight() - SYNC_EVERY_NUM_BLOCKS) syncWallet(false); + if (!offlinePoll && walletHeight.get() < xmrConnectionService.getTargetHeight() - SYNC_EVERY_NUM_BLOCKS) syncWallet(false); // update deposit txs - if (!isDepositsUnlocked()) { + boolean depositTxsUninitialized = isDepositRequested() && (getMaker().getDepositTx() == null || (getTaker().getDepositTx() == null && !hasBuyerAsTakerWithoutDeposit())); + if (depositTxsUninitialized || !isDepositsFinalized()) { // sync wallet if behind - syncWalletIfBehind(); + if (!offlinePoll) syncWalletIfBehind(); // get txs from trade wallet - MoneroTxQuery query = new MoneroTxQuery().setIncludeOutputs(true); - Boolean updatePool = !isDepositsConfirmed() && (getMaker().getDepositTx() == null || (getTaker().getDepositTx() == null && hasBuyerAsTakerWithoutDeposit())); - if (!updatePool) query.setInTxPool(false); // avoid updating from pool if possible - List txs; - if (!updatePool) txs = wallet.getTxs(query); - else { - synchronized (walletLock) { - synchronized (HavenoUtils.getDaemonLock()) { - txs = wallet.getTxs(query); - } - } - } + boolean updatePool = !offlinePoll && !isDepositsConfirmed() && (getMaker().getDepositTx() == null || (getTaker().getDepositTx() == null && !hasBuyerAsTakerWithoutDeposit())); + List txs = getTxs(updatePool); setDepositTxs(txs); // set actual buyer security deposit @@ -2754,86 +2946,84 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { setStateDepositsSeen(); // check for deposit txs confirmed - if (getMaker().getDepositTx().isConfirmed() && (hasBuyerAsTakerWithoutDeposit() || getTaker().getDepositTx().isConfirmed())) setStateDepositsConfirmed(); + if (getMaker().getDepositTx().isConfirmed() && (hasBuyerAsTakerWithoutDeposit() || getTaker().getDepositTx().isConfirmed())) { + setStateDepositsConfirmed(); + } // check for deposit txs unlocked if (getMaker().getDepositTx().getNumConfirmations() >= XmrWalletService.NUM_BLOCKS_UNLOCK && (hasBuyerAsTakerWithoutDeposit() || getTaker().getDepositTx().getNumConfirmations() >= XmrWalletService.NUM_BLOCKS_UNLOCK)) { setStateDepositsUnlocked(); } + + // check for deposit txs finalized + if (getMaker().getDepositTx().getNumConfirmations() >= NUM_BLOCKS_DEPOSITS_FINALIZED && (hasBuyerAsTakerWithoutDeposit() || getTaker().getDepositTx().getNumConfirmations() >= NUM_BLOCKS_DEPOSITS_FINALIZED)) { + setStateDepositsFinalized(); + } + } else if (isDepositsSeen()) { + log.warn("Resetting state to {} for {} {} because one or both deposit txs no longer seen as valid", Trade.State.ARBITRATOR_PUBLISHED_DEPOSIT_TXS, getClass().getSimpleName(), getShortId()); + setState(Trade.State.ARBITRATOR_PUBLISHED_DEPOSIT_TXS); } } - // check for payout tx - boolean hasUnlockedDeposit = isUnlocked(getMaker().getDepositTx()) || isUnlocked(getTaker().getDepositTx()); + // update payout tx + boolean hasUnlockedDeposit = hasUnlockedTx(); if (isDepositsUnlocked() || hasUnlockedDeposit) { // arbitrator idles so these may not be the same // determine if payout tx expected - boolean isPayoutExpected = isDepositsUnlocked() && isPaymentReceived() || hasPaymentReceivedMessage() || hasDisputeClosedMessage() || disputeState.ordinal() >= DisputeState.ARBITRATOR_SENT_DISPUTE_CLOSED_MSG.ordinal(); + boolean isPayoutExpected = isPaymentReceived() || hasPaymentReceivedMessage() || hasDisputeClosedMessage() || disputeState.ordinal() >= DisputeState.ARBITRATOR_SENT_DISPUTE_CLOSED_MSG.ordinal(); // sync wallet if payout expected or payout is published - if (isPayoutExpected || isPayoutPublished()) syncWalletIfBehind(); + if (!offlinePoll && (isPayoutExpected || isPayoutPublished())) syncWalletIfBehind(); // rescan spent outputs to detect unconfirmed payout tx if (isPayoutExpected && wallet.getBalance().compareTo(BigInteger.ZERO) > 0) { - MoneroRpcConnection sourceConnection = xmrConnectionService.getConnection(); try { - wallet.rescanSpent(); + rescanSpent(true); } catch (Exception e) { - log.warn("Failed to rescan spent outputs for {} {}, errorMessage={}", getClass().getSimpleName(), getShortId(), e.getMessage()); - ThreadUtils.execute(() -> requestSwitchToNextBestConnection(sourceConnection), getId()); // do not block polling thread + ThreadUtils.submitToPool(() -> requestSwitchToNextBestConnection(sourceConnection)); // do not block polling thread } } // get txs from trade wallet - MoneroTxQuery query = new MoneroTxQuery().setIncludeOutputs(true); - boolean updatePool = isPayoutExpected && !isPayoutConfirmed(); - if (!updatePool) query.setInTxPool(false); // avoid updating from pool if possible - List txs = null; - if (!updatePool) txs = wallet.getTxs(query); - else { - synchronized (walletLock) { - synchronized (HavenoUtils.getDaemonLock()) { - txs = wallet.getTxs(query); + boolean updatePool = !offlinePoll && isPayoutExpected && !isPayoutConfirmed(); + List txs = getTxs(updatePool); + setDepositTxs(txs); + + // update payout state + boolean hasValidPayout = false; + for (MoneroTxWallet tx : txs) { + boolean isOutgoing = !Boolean.TRUE.equals(tx.isIncoming()); // outgoing tx observed after wallet submits payout or on first confirmation + if (isOutgoing && !tx.isFailed()) { + hasValidPayout = true; + updatePayout(tx); + } else { + for (MoneroOutputWallet output : tx.getOutputsWallet()) { + if (Boolean.TRUE.equals(output.isSpent())) hasValidPayout = true; // spent outputs observed on payout published (after rescanning) } } } - setDepositTxs(txs); - // check if any outputs spent (observed on payout published) - boolean hasSpentOutput = false; - boolean hasFailedTx = false; - for (MoneroTxWallet tx : txs) { - if (tx.isFailed()) hasFailedTx = true; - for (MoneroOutputWallet output : tx.getOutputsWallet()) { - if (Boolean.TRUE.equals(output.isSpent())) hasSpentOutput = true; - } - } - if (hasSpentOutput) setPayoutStatePublished(); - else if (hasFailedTx && isPayoutPublished()) { - log.warn("{} {} is in payout published state but has failed tx and no spent outputs, resetting payout state to unpublished", getClass().getSimpleName(), getShortId()); - setPayoutState(PayoutState.PAYOUT_UNPUBLISHED); - } - - // check for outgoing txs (appears after wallet submits payout tx or on payout confirmed) - for (MoneroTxWallet tx : txs) { - if (tx.isOutgoing() && !tx.isFailed()) { - updatePayout(tx); - setPayoutStatePublished(); - if (tx.isConfirmed()) setPayoutStateConfirmed(); - if (!tx.isLocked()) setPayoutStateUnlocked(); - } + // handle payout validity + if (hasValidPayout) { + onValidPayoutTxPoll(); + } else if (isPayoutPublished() && !offlinePoll) { + onMissingPayoutTxPoll(); } } } catch (Exception e) { - if (HavenoUtils.isUnresponsive(e)) { + if (!(e instanceof IllegalStateException) && !isShutDownStarted && !wasWalletPolled.get()) { // request connection switch if failure on first poll + ThreadUtils.execute(() -> requestSwitchToNextBestConnection(sourceConnection), getId()); + } + if (HavenoUtils.isUnresponsive(e)) { // wallet can be stuck a while + if (wallet != null && !isShutDownStarted) { + log.warn("Error polling unresponsive trade wallet for {} {}, errorMessage={}. Monerod={}", getClass().getSimpleName(), getShortId(), e.getMessage(), wallet.getDaemonConnection()); + } if (isShutDownStarted) forceCloseWallet(); else forceRestartTradeWallet(); - } - else { + } else { boolean isWalletConnected = isWalletConnectedToDaemon(); if (wallet != null && !isShutDownStarted && isWalletConnected) { log.warn("Error polling trade wallet for {} {}, errorMessage={}. Monerod={}", getClass().getSimpleName(), getShortId(), e.getMessage(), wallet.getDaemonConnection()); - //e.printStackTrace(); } } } finally { @@ -2842,10 +3032,128 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { pollInProgress = false; } } + wasWalletPolled.set(true); saveWalletWithDelay(); } } + private List getTxs(boolean updatePool) { + MoneroTxQuery query = new MoneroTxQuery().setIncludeOutputs(true); + if (!updatePool) query.setInTxPool(false); // avoid updating from pool if possible + List txs = null; + if (!updatePool) txs = wallet.getTxs(query); + else { + synchronized (walletLock) { + synchronized (HavenoUtils.getDaemonLock()) { + txs = wallet.getTxs(query); + } + } + } + return txs; + } + + private void onValidPayoutTxPoll() { + setPayoutStatePublished(); + synchronized (missingPayoutTxLock) { + handleMissingPayoutTxOnNextPoll = false; + if (missingPayoutTxTimer == null) return; + log.warn("Valid payout tx seen after failed or missing for {} {} with payout state {}", getClass().getSimpleName(), getShortId(), getPayoutState()); + missingPayoutTxTimer.stop(); + missingPayoutTxTimer = null; + } + } + + private void onMissingPayoutTxPoll() { + + // throttle processing + if (System.currentTimeMillis() - lastMissingTxPollTime < MIN_MISSING_TX_POLL_INTERVAL_MS) return; + lastMissingTxPollTime = System.currentTimeMillis(); + + // process missing payout tx by id + if (getPayoutTxId() != null) { + log.warn("The payout tx is failed or missing for {} {}, payout state={}, payout id={}. Possibly due to reorg?", getClass().getSimpleName(), getShortId(), getPayoutState(), getPayoutTxId()); + + // buyer can process payout tx received to their main wallet + try { + if (isBuyer()) processBuyerPayout(getPayoutTxId()); + } catch (Exception e) { + log.warn("Error checking for payout tx for {} {}: {}", getClass().getSimpleName(), getId(), e.getMessage()); + } + } + + synchronized (missingPayoutTxLock) { + + // handle missing payout tx if previously set + if (handleMissingPayoutTxOnNextPoll) { + handleMissingPayoutTxOnNextPoll = false; + ThreadUtils.execute(() -> handleMissingPayoutTx(), getId()); + } else { + if (missingPayoutTxTimer != null) return; + log.info("Scheduling handling if payout becomes failed or missing for {} {}", getClass().getSimpleName(), getShortId()); + missingPayoutTxTimer = UserThread.runAfter(() -> { + ThreadUtils.execute(() -> { + synchronized (missingPayoutTxLock) { + if (missingPayoutTxTimer == null) return; + missingPayoutTxTimer.stop(); + missingPayoutTxTimer = null; + handleMissingPayoutTxOnNextPoll = true; // handle missing payout tx on next poll in case we're outdated + } + }, getId()); + }, HANDLE_MISSING_PAYOUT_AFTER_MINS, TimeUnit.MINUTES); + } + } + } + + private void handleMissingPayoutTx() { + log.warn("Handling failed or missing payout tx for {} {} with payout state {}. Possibly due to reorg?", getClass().getSimpleName(), getShortId(), getPayoutState()); + setPayoutState(PayoutState.PAYOUT_UNPUBLISHED); + onPayoutError(false, null); + } + + /** + * Handle a payout error due to NACK or the transaction failing (e.g. due to reorg). + * + * @param syncAndPoll whether to sync and poll + * @param autoMarkPaymentReceived whether to automatically mark payment received if previously confirmed + * @return true if the payment received was auto marked, false otherwise + */ + public boolean onPayoutError(boolean syncAndPoll, TradePeer paymentReceivedNackSender) { + log.warn("Handling payout error for {} {}", getClass().getSimpleName(), getId()); + if (syncAndPoll) { + try { + syncAndPollWallet(); + } catch (Exception e) { + log.warn("Error syncing and polling wallet for {} {}: {}", getClass().getSimpleName(), getId(), e.getMessage()); + } + } + + // reset trade state + log.warn("Resetting trade state after payout error for {} {}, nackSender={}", getClass().getSimpleName(), getId(), paymentReceivedNackSender == null ? null : getPeerRole(paymentReceivedNackSender)); + processModel.setPaymentSentPayoutTxStale(true); + if (paymentReceivedNackSender != null) { + paymentReceivedNackSender.setPaymentReceivedMessage(null); + paymentReceivedNackSender.setPaymentReceivedMessageState(MessageState.UNDEFINED); + } + if (!isPayoutPublished()) { + getSelf().setUnsignedPayoutTxHex(null); + setPayoutTxHex(null); + } + + persistNow(null); + + // send updated payment received message when payout is confirmed + if (paymentReceivedNackSender != null && isSeller()) { + log.warn("Sending updated PaymentReceivedMessages for {} {} after payout error", getClass().getSimpleName(), getId()); + ((SellerProtocol) getProtocol()).onPaymentReceived(() -> { + log.info("Done sending updated PaymentReceivedMessages on payout error for {} {}", getClass().getSimpleName(), getId()); + }, (errorMessage) -> { + log.warn("Error sending updated PaymentReceivedMessages on payout error for {} {}: {}", getClass().getSimpleName(), getId(), errorMessage); + }); + return true; + } + return false; + } + private static boolean isSeen(MoneroTx tx) { if (tx == null) return false; if (Boolean.TRUE.equals(tx.isFailed())) return false; @@ -2859,13 +3167,17 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { return true; } + private boolean hasUnlockedTx() { + return isUnlocked(getMaker().getDepositTx()) || isUnlocked(getTaker().getDepositTx()); + } + private void syncWalletIfBehind() { synchronized (walletLock) { if (isWalletBehind()) { // TODO: local tests have timing failures unless sync called directly if (xmrConnectionService.getTargetHeight() - walletHeight.get() < XmrWalletBase.DIRECT_SYNC_WITHIN_BLOCKS) { - xmrWalletService.syncWallet(wallet); + xmrWalletService.syncWallet(wallet); // TODO: always running this causes "Wallet is not connected to daemon" error } else { syncWithProgress(); } @@ -2879,15 +3191,19 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { } private void setDepositTxs(List txs) { + MoneroTxWallet makerDepositTx = null; + MoneroTxWallet takerDepositTx = null; for (MoneroTxWallet tx : txs) { - if (tx.getHash().equals(getMaker().getDepositTxHash())) getMaker().setDepositTx(tx); - if (tx.getHash().equals(getTaker().getDepositTxHash())) getTaker().setDepositTx(tx); + if (tx.getHash().equals(getMaker().getDepositTxHash())) makerDepositTx = tx; + if (tx.getHash().equals(getTaker().getDepositTxHash())) takerDepositTx = tx; } + getMaker().setDepositTx(makerDepositTx); + getTaker().setDepositTx(takerDepositTx); depositTxsUpdateCounter.set(depositTxsUpdateCounter.get() + 1); } - // TODO: wallet is sometimes missing balance or deposits, due to specific daemon connections, not saving? - private void recoverIfMissingWalletData() { + // TODO: wallet is sometimes missing balance or deposits, due to reorgs, specific daemon connections, not saving? + public void recoverIfMissingWalletData() { synchronized (walletLock) { if (isWalletMissingData()) { log.warn("Wallet is missing data for {} {}, attempting to recover", getClass().getSimpleName(), getShortId()); @@ -2898,32 +3214,8 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { // skip if payout published in the meantime if (isPayoutPublished()) return; - // rescan blockchain with global daemon lock - synchronized (HavenoUtils.getDaemonLock()) { - Long timeout = null; - try { - - // extend rpc timeout for rescan - if (wallet instanceof MoneroWalletRpc) { - timeout = ((MoneroWalletRpc) wallet).getRpcConnection().getTimeout(); - ((MoneroWalletRpc) wallet).getRpcConnection().setTimeout(EXTENDED_RPC_TIMEOUT); - } - - // rescan blockchain - log.warn("Rescanning blockchain for {} {}", getClass().getSimpleName(), getShortId()); - wallet.rescanBlockchain(); - } catch (Exception e) { - log.warn("Error rescanning blockchain for {} {}, errorMessage={}", getClass().getSimpleName(), getShortId(), e.getMessage()); - if (HavenoUtils.isUnresponsive(e)) forceRestartTradeWallet(); // wallet can be stuck a while - throw e; - } finally { - - // restore rpc timeout - if (wallet instanceof MoneroWalletRpc) { - ((MoneroWalletRpc) wallet).getRpcConnection().setTimeout(timeout); - } - } - } + // rescan blockchain + rescanBlockchain(); // import multisig hex log.warn("Importing multisig hex to recover wallet data for {} {}", getClass().getSimpleName(), getShortId()); @@ -2938,6 +3230,70 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { } } + public void rescanBlockchain() { + synchronized (walletLock) { + synchronized (HavenoUtils.getDaemonLock()) { + if (getWallet() == null) throw new IllegalStateException("Cannot rescan blockchain because trade wallet doesn't exist for " + getClass().getSimpleName() + ", " + getId()); + if (getWallet().getDaemonConnection() == null) throw new RuntimeException("Cannot rescan blockchain because trade wallet is not connected to a Monero daemon for " + getClass().getSimpleName() + ", " + getId()); + Long timeout = null; + try { + + // extend rpc timeout for rescan + if (wallet instanceof MoneroWalletRpc) { + timeout = ((MoneroWalletRpc) wallet).getRpcConnection().getTimeout(); + ((MoneroWalletRpc) wallet).getRpcConnection().setTimeout(EXTENDED_RPC_TIMEOUT); + } + + // rescan blockchain + log.warn("Rescanning blockchain for {} {}", getClass().getSimpleName(), getShortId()); + wallet.rescanBlockchain(); + } catch (Exception e) { + log.warn("Error rescanning blockchain for {} {}, errorMessage={}", getClass().getSimpleName(), getShortId(), e.getMessage()); + if (HavenoUtils.isUnresponsive(e)) forceRestartTradeWallet(); // wallet can be stuck a while + throw e; + } finally { + + // restore rpc timeout + if (wallet instanceof MoneroWalletRpc) { + ((MoneroWalletRpc) wallet).getRpcConnection().setTimeout(timeout); + } + } + } + } + } + + public void rescanSpent(boolean skipLog) { + synchronized (walletLock) { + if (getWallet() == null) throw new IllegalStateException("Cannot rescan spent outputs because trade wallet doesn't exist for " + getClass().getSimpleName() + ", " + getId()); + if (getWallet().getDaemonConnection() == null) throw new RuntimeException("Cannot rescan spent outputs because trade wallet is not connected to a Monero daemon for " + getClass().getSimpleName() + ", " + getId()); + Long timeout = null; + try { + + // extend rpc timeout for rescan + if (wallet instanceof MoneroWalletRpc) { + timeout = ((MoneroWalletRpc) wallet).getRpcConnection().getTimeout(); + ((MoneroWalletRpc) wallet).getRpcConnection().setTimeout(EXTENDED_RPC_TIMEOUT); + } + + // rescan spent outputs + if (!skipLog) log.info("Rescanning spent outputs for {} {}", getClass().getSimpleName(), getShortId()); + wallet.rescanSpent(); + if (!skipLog) log.info("Done rescanning spent outputs for {} {}", getClass().getSimpleName(), getShortId()); + saveWalletWithDelay(); + } catch (Exception e) { + log.warn("Error rescanning spent outputs for {} {}, errorMessage={}", getClass().getSimpleName(), getShortId(), e.getMessage()); + if (HavenoUtils.isUnresponsive(e)) forceRestartTradeWallet(); // wallet can be stuck a while + throw e; + } finally { + + // restore rpc timeout + if (wallet instanceof MoneroWalletRpc) { + ((MoneroWalletRpc) wallet).getRpcConnection().setTimeout(timeout); + } + } + } + } + private boolean isWalletMissingData() { synchronized (walletLock) { if (!isDepositsUnlocked() || isPayoutPublished()) return false; @@ -2979,9 +3335,13 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { } private void setStateDepositsUnlocked() { - if (!isDepositsUnlocked()) { - setState(State.DEPOSIT_TXS_UNLOCKED_IN_BLOCKCHAIN); - setStartTimeFromUnlockedTxs(); + if (!isDepositsUnlocked()) setState(State.DEPOSIT_TXS_UNLOCKED_IN_BLOCKCHAIN); + } + + private void setStateDepositsFinalized() { + if (!isDepositsFinalized()) { + setStateIfValidTransitionTo(State.DEPOSIT_TXS_FINALIZED_IN_BLOCKCHAIN); + ThreadUtils.submitToPool(() -> maybeUpdateTradePeriod()); } } @@ -2997,6 +3357,10 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { if (!isPayoutUnlocked()) setPayoutState(PayoutState.PAYOUT_UNLOCKED); } + private void setPayoutStateFinalized() { + if (!isPayoutFinalized()) setPayoutState(PayoutState.PAYOUT_FINALIZED); + } + private Trade getTrade() { return this; } @@ -3017,8 +3381,8 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { if (processing) return; processing = true; - // skip if not idling and not waiting for payout to unlock - if (!isIdling() || !isPayoutPublished() || isPayoutUnlocked()) { + // skip if not idling and not waiting for payout to finalize + if (!isIdling() || !isPayoutPublished() || isPayoutFinalized()) { processing = false; return; } @@ -3034,7 +3398,9 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { // sync wallet if confirm or unlock expected long currentHeight = xmrWalletService.getMonerod().getHeight(); - if (!isPayoutConfirmed() || (payoutHeight != null && currentHeight >= payoutHeight + XmrWalletService.NUM_BLOCKS_UNLOCK)) { + if (!isPayoutConfirmed() || (payoutHeight != null && + ((!isPayoutUnlocked() && currentHeight >= payoutHeight + XmrWalletService.NUM_BLOCKS_UNLOCK) || + (!isPayoutFinalized() && currentHeight >= payoutHeight + NUM_BLOCKS_PAYOUT_FINALIZED)))) { log.info("Syncing idle trade wallet to update payout tx, tradeId={}", getId()); syncAndPollWallet(); } @@ -3050,10 +3416,17 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { } } + private void onDepositRequested() { + if (!isArbitrator()) startPolling(); // peers start polling after deposits requested + } + private void onDepositsPublished() { - // skip if arbitrator - if (this instanceof ArbitratorTrade) return; + // arbitrator starts polling after deposits published + if (isArbitrator()) { + startPolling(); + return; + } // close open offer or reset address entries if (this instanceof MakerTrade) { @@ -3075,6 +3448,10 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { HavenoUtils.notificationService.sendTradeNotification(this, Phase.DEPOSITS_UNLOCKED, "Trade Deposits Unlocked", "The deposit transactions have unlocked"); } + private void onDepositsFinalized() { + HavenoUtils.notificationService.sendTradeNotification(this, Phase.DEPOSITS_FINALIZED, "Trade Deposits Finalized", "The deposit transactions have finalized"); + } + private void onPaymentSent() { HavenoUtils.notificationService.sendTradeNotification(this, Phase.PAYMENT_SENT, "Payment Sent", "The buyer has sent the payment"); } diff --git a/core/src/main/java/haveno/core/trade/TradeManager.java b/core/src/main/java/haveno/core/trade/TradeManager.java index 62c3478e32..27cd6f0ae7 100644 --- a/core/src/main/java/haveno/core/trade/TradeManager.java +++ b/core/src/main/java/haveno/core/trade/TradeManager.java @@ -459,6 +459,12 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi return; } + // add random delay up to 10s to avoid syncing at exactly the same time + if (trades.size() > 1 && trade.walletExists()) { + int delay = (int) (Math.random() * 10000); + HavenoUtils.waitFor(delay); + } + // initialize trade initPersistedTrade(trade); @@ -484,7 +490,16 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi // sync idle trades once in background after active trades for (Trade trade : trades) { - if (trade.isIdling()) ThreadUtils.submitToPool(() -> trade.syncAndPollWallet()); + if (trade.isIdling()) ThreadUtils.submitToPool(() -> { + + // add random delay up to 10s to avoid syncing at exactly the same time + if (trades.size() > 1 && trade.walletExists()) { + int delay = (int) (Math.random() * 10000); + HavenoUtils.waitFor(delay); + } + + trade.syncAndPollWallet(); + }); } // process after all wallets initialized @@ -492,7 +507,7 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi // handle uninitialized trades for (Trade trade : uninitializedTrades) { - trade.onProtocolError(); + trade.onProtocolInitializationError(); } // freeze or thaw outputs @@ -630,7 +645,7 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi // process with protocol ((MakerProtocol) getTradeProtocol(trade)).handleInitTradeRequest(request, sender, errorMessage -> { log.warn("Maker error during trade initialization: " + errorMessage); - trade.onProtocolError(); + trade.onProtocolInitializationError(); }); } @@ -724,7 +739,7 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi // process with protocol ((ArbitratorProtocol) getTradeProtocol(trade)).handleInitTradeRequest(request, sender, errorMessage -> { log.warn("Arbitrator error during trade initialization for trade {}: {}", trade.getId(), errorMessage); - trade.onProtocolError(); + trade.onProtocolInitializationError(); }); requestPersistence(); @@ -936,7 +951,7 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi requestPersistence(); }, errorMessage -> { log.warn("Taker error during trade initialization: " + errorMessage); - trade.onProtocolError(); + trade.onProtocolInitializationError(); xmrWalletService.resetAddressEntriesForOpenOffer(trade.getId()); // TODO: move this into protocol error handling errorMessageHandler.handleErrorMessage(errorMessage); }); @@ -1039,16 +1054,18 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi @Override public void onMinuteTick() { - updateTradePeriodState(); + ThreadUtils.submitToPool(() -> updateTradePeriodState()); // update trade period off main thread } }); } + // TODO: could use monerod.getBlocksByHeight() to more efficiently update trade period state private void updateTradePeriodState() { if (isShutDownStarted) return; - synchronized (tradableList.getList()) { - for (Trade trade : tradableList.getList()) { - if (!trade.isInitialized() || trade.isPayoutPublished()) continue; + for (Trade trade : getOpenTrades()) { + if (!trade.isInitialized() || trade.isPayoutPublished()) continue; + try { + trade.maybeUpdateTradePeriod(); Date maxTradePeriodDate = trade.getMaxTradePeriodDate(); Date halfTradePeriodDate = trade.getHalfTradePeriodDate(); if (maxTradePeriodDate != null && halfTradePeriodDate != null) { @@ -1061,6 +1078,9 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi requestPersistence(); } } + } catch (Exception e) { + log.warn("Error updating trade period state for {} {}: {}", trade.getClass().getSimpleName(), trade.getShortId(), e.getMessage(), e); + continue; } } } @@ -1075,11 +1095,13 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi public void onMoveInvalidTradeToFailedTrades(Trade trade) { failedTradesManager.add(trade); removeTrade(trade); + xmrWalletService.fixReservedOutputs(); } public void onMoveFailedTradeToPendingTrades(Trade trade) { addTradeToPendingTrades(trade); failedTradesManager.removeTrade(trade); + xmrWalletService.fixReservedOutputs(); } public void onMoveClosedTradeToPendingTrades(Trade trade) { @@ -1224,8 +1246,17 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi updatedMultisigHex); // send ack message - log.info("Send AckMessage for {} to peer {}. tradeId={}, sourceUid={}", - ackMessage.getSourceMsgClassName(), peer, tradeId, sourceUid); + if (!result) { + if (errorMessage == null) { + log.warn("Sending NACK for {} to peer {} without error message. That should never happen. tradeId={}, sourceUid={}", + ackMessage.getSourceMsgClassName(), peer, tradeId, sourceUid); + } + log.warn("Sending NACK for {} to peer {}. tradeId={}, sourceUid={}, errorMessage={}, updatedMultisigHex={}", + ackMessage.getSourceMsgClassName(), peer, tradeId, sourceUid, errorMessage, updatedMultisigHex == null ? "null" : updatedMultisigHex.length() + " characters"); + } else { + log.info("Sending AckMessage for {} to peer {}. tradeId={}, sourceUid={}", + ackMessage.getSourceMsgClassName(), peer, tradeId, sourceUid); + } p2PService.getMailboxMessageService().sendEncryptedMailboxMessage( peer, peersPubKeyRing, diff --git a/core/src/main/java/haveno/core/trade/messages/PaymentReceivedMessage.java b/core/src/main/java/haveno/core/trade/messages/PaymentReceivedMessage.java index a7f23190d6..8909fcf226 100644 --- a/core/src/main/java/haveno/core/trade/messages/PaymentReceivedMessage.java +++ b/core/src/main/java/haveno/core/trade/messages/PaymentReceivedMessage.java @@ -51,6 +51,9 @@ public final class PaymentReceivedMessage extends TradeMailboxMessage { @Setter @Nullable private byte[] sellerSignature; + @Setter + @Nullable + private String payoutTxId; public PaymentReceivedMessage(String tradeId, NodeAddress senderNodeAddress, @@ -61,7 +64,8 @@ public final class PaymentReceivedMessage extends TradeMailboxMessage { boolean deferPublishPayout, AccountAgeWitness buyerAccountAgeWitness, @Nullable SignedWitness buyerSignedWitness, - @Nullable PaymentSentMessage paymentSentMessage) { + @Nullable PaymentSentMessage paymentSentMessage, + @Nullable String payoutTxId) { this(tradeId, senderNodeAddress, uid, @@ -72,7 +76,8 @@ public final class PaymentReceivedMessage extends TradeMailboxMessage { deferPublishPayout, buyerAccountAgeWitness, buyerSignedWitness, - paymentSentMessage); + paymentSentMessage, + payoutTxId); } @@ -90,7 +95,8 @@ public final class PaymentReceivedMessage extends TradeMailboxMessage { boolean deferPublishPayout, AccountAgeWitness buyerAccountAgeWitness, @Nullable SignedWitness buyerSignedWitness, - PaymentSentMessage paymentSentMessage) { + PaymentSentMessage paymentSentMessage, + @Nullable String payoutTxId) { super(messageVersion, tradeId, uid); this.senderNodeAddress = senderNodeAddress; this.unsignedPayoutTxHex = unsignedPayoutTxHex; @@ -100,6 +106,7 @@ public final class PaymentReceivedMessage extends TradeMailboxMessage { this.paymentSentMessage = paymentSentMessage; this.buyerAccountAgeWitness = buyerAccountAgeWitness; this.buyerSignedWitness = buyerSignedWitness; + this.payoutTxId = payoutTxId; } @Override @@ -116,6 +123,7 @@ public final class PaymentReceivedMessage extends TradeMailboxMessage { Optional.ofNullable(buyerSignedWitness).ifPresent(buyerSignedWitness -> builder.setBuyerSignedWitness(buyerSignedWitness.toProtoSignedWitness())); Optional.ofNullable(paymentSentMessage).ifPresent(e -> builder.setPaymentSentMessage(paymentSentMessage.toProtoNetworkEnvelope().getPaymentSentMessage())); Optional.ofNullable(sellerSignature).ifPresent(e -> builder.setSellerSignature(ByteString.copyFrom(e))); + Optional.ofNullable(payoutTxId).ifPresent(builder::setPayoutTxId); return getNetworkEnvelopeBuilder().setPaymentReceivedMessage(builder).build(); } @@ -138,7 +146,8 @@ public final class PaymentReceivedMessage extends TradeMailboxMessage { proto.getDeferPublishPayout(), buyerAccountAgeWitness, buyerSignedWitness, - proto.hasPaymentSentMessage() ? PaymentSentMessage.fromProto(proto.getPaymentSentMessage(), messageVersion) : null); + proto.hasPaymentSentMessage() ? PaymentSentMessage.fromProto(proto.getPaymentSentMessage(), messageVersion) : null, + ProtoUtil.stringOrNullFromProto(proto.getPayoutTxId())); message.setSellerSignature(ProtoUtil.byteArrayOrNullFromProto(proto.getSellerSignature())); return message; } @@ -154,6 +163,7 @@ public final class PaymentReceivedMessage extends TradeMailboxMessage { ",\n deferPublishPayout=" + deferPublishPayout + ",\n paymentSentMessage=" + paymentSentMessage + ",\n sellerSignature=" + sellerSignature + + ",\n payoutTxId=" + payoutTxId + "\n} " + super.toString(); } } diff --git a/core/src/main/java/haveno/core/trade/protocol/BuyerProtocol.java b/core/src/main/java/haveno/core/trade/protocol/BuyerProtocol.java index 4302f6db6f..348f629410 100644 --- a/core/src/main/java/haveno/core/trade/protocol/BuyerProtocol.java +++ b/core/src/main/java/haveno/core/trade/protocol/BuyerProtocol.java @@ -120,13 +120,23 @@ public class BuyerProtocol extends DisputeProtocol { public void onPaymentSent(ResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) { log.info(TradeProtocol.LOG_HIGHLIGHT + "BuyerProtocol.onPaymentSent() for {} {}", trade.getClass().getSimpleName(), trade.getShortId()); + + // advance trade state + if (trade.isDepositsUnlocked() || trade.isDepositsFinalized() || trade.isPaymentSent()) { + trade.advanceState(Trade.State.BUYER_CONFIRMED_PAYMENT_SENT); + } else { + errorMessageHandler.handleErrorMessage("Cannot confirm payment sent for " + trade.getClass().getSimpleName() + " " + trade.getShortId() + " in state " + trade.getState()); + return; + } + + // process on trade thread ThreadUtils.execute(() -> { synchronized (trade.getLock()) { latchTrade(); this.errorMessageHandler = errorMessageHandler; BuyerEvent event = BuyerEvent.PAYMENT_SENT; try { - expect(anyPhase(Trade.Phase.DEPOSITS_UNLOCKED, Trade.Phase.PAYMENT_SENT) + expect(anyPhase(Trade.Phase.DEPOSITS_UNLOCKED, Trade.Phase.DEPOSITS_FINALIZED, Trade.Phase.PAYMENT_SENT) .with(event) .preCondition(trade.confirmPermitted())) .setup(tasks(ApplyFilter.class, @@ -145,7 +155,6 @@ public class BuyerProtocol extends DisputeProtocol { trade.setState(Trade.State.DEPOSIT_TXS_UNLOCKED_IN_BLOCKCHAIN); handleTaskRunnerFault(event, errorMessage); }))) - .run(() -> trade.advanceState(Trade.State.BUYER_CONFIRMED_PAYMENT_SENT)) .executeTasks(true); } catch (Exception e) { errorMessageHandler.handleErrorMessage("Error confirming payment sent: " + e.getMessage()); diff --git a/core/src/main/java/haveno/core/trade/protocol/DisputeProtocol.java b/core/src/main/java/haveno/core/trade/protocol/DisputeProtocol.java index 75715559be..fbb40ee1c0 100644 --- a/core/src/main/java/haveno/core/trade/protocol/DisputeProtocol.java +++ b/core/src/main/java/haveno/core/trade/protocol/DisputeProtocol.java @@ -80,7 +80,9 @@ public abstract class DisputeProtocol extends TradeProtocol { // Trader has not yet received the peer's signature but has clicked the accept button. public void onAcceptMediationResult(ResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) { DisputeEvent event = DisputeEvent.MEDIATION_RESULT_ACCEPTED; - expect(anyPhase(Trade.Phase.DEPOSITS_UNLOCKED, + expect(anyPhase( + Trade.Phase.DEPOSITS_UNLOCKED, + Trade.Phase.DEPOSITS_FINALIZED, Trade.Phase.PAYMENT_SENT, Trade.Phase.PAYMENT_RECEIVED) .with(event) @@ -107,7 +109,9 @@ public abstract class DisputeProtocol extends TradeProtocol { // Trader has already received the peer's signature and has clicked the accept button as well. public void onFinalizeMediationResultPayout(ResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) { DisputeEvent event = DisputeEvent.MEDIATION_RESULT_ACCEPTED; - expect(anyPhase(Trade.Phase.DEPOSITS_UNLOCKED, + expect(anyPhase( + Trade.Phase.DEPOSITS_UNLOCKED, + Trade.Phase.DEPOSITS_FINALIZED, Trade.Phase.PAYMENT_SENT, Trade.Phase.PAYMENT_RECEIVED) .with(event) @@ -135,7 +139,9 @@ public abstract class DisputeProtocol extends TradeProtocol { /////////////////////////////////////////////////////////////////////////////////////////// protected void handle(MediatedPayoutTxSignatureMessage message, NodeAddress peer) { - expect(anyPhase(Trade.Phase.DEPOSITS_UNLOCKED, + expect(anyPhase( + Trade.Phase.DEPOSITS_UNLOCKED, + Trade.Phase.DEPOSITS_FINALIZED, Trade.Phase.PAYMENT_SENT, Trade.Phase.PAYMENT_RECEIVED) .with(message) @@ -145,7 +151,9 @@ public abstract class DisputeProtocol extends TradeProtocol { } protected void handle(MediatedPayoutTxPublishedMessage message, NodeAddress peer) { - expect(anyPhase(Trade.Phase.DEPOSITS_UNLOCKED, + expect(anyPhase( + Trade.Phase.DEPOSITS_UNLOCKED, + Trade.Phase.DEPOSITS_FINALIZED, Trade.Phase.PAYMENT_SENT, Trade.Phase.PAYMENT_RECEIVED) .with(message) diff --git a/core/src/main/java/haveno/core/trade/protocol/ProcessModel.java b/core/src/main/java/haveno/core/trade/protocol/ProcessModel.java index 7339585560..91d23d40f4 100644 --- a/core/src/main/java/haveno/core/trade/protocol/ProcessModel.java +++ b/core/src/main/java/haveno/core/trade/protocol/ProcessModel.java @@ -166,6 +166,9 @@ public class ProcessModel implements Model, PersistablePayload { @Getter @Setter private boolean importMultisigHexScheduled; + @Getter + @Setter + private boolean paymentSentPayoutTxStale; private ObjectProperty paymentAccountDecryptedProperty = new SimpleObjectProperty<>(false); @Deprecated private ObjectProperty paymentSentMessageStatePropertySeller = new SimpleObjectProperty<>(MessageState.UNDEFINED); @@ -237,7 +240,8 @@ public class ProcessModel implements Model, PersistablePayload { .setBuyerPayoutAmountFromMediation(buyerPayoutAmountFromMediation) .setSellerPayoutAmountFromMediation(sellerPayoutAmountFromMediation) .setTradeProtocolErrorHeight(tradeProtocolErrorHeight) - .setImportMultisigHexScheduled(importMultisigHexScheduled); + .setImportMultisigHexScheduled(importMultisigHexScheduled) + .setPaymentSentPayoutTxStale(paymentSentPayoutTxStale); Optional.ofNullable(maker).ifPresent(e -> builder.setMaker((protobuf.TradePeer) maker.toProtoMessage())); Optional.ofNullable(taker).ifPresent(e -> builder.setTaker((protobuf.TradePeer) taker.toProtoMessage())); Optional.ofNullable(arbitrator).ifPresent(e -> builder.setArbitrator((protobuf.TradePeer) arbitrator.toProtoMessage())); @@ -262,6 +266,7 @@ public class ProcessModel implements Model, PersistablePayload { processModel.setSellerPayoutAmountFromMediation(proto.getSellerPayoutAmountFromMediation()); processModel.setTradeProtocolErrorHeight(proto.getTradeProtocolErrorHeight()); processModel.setImportMultisigHexScheduled(proto.getImportMultisigHexScheduled()); + processModel.setPaymentSentPayoutTxStale(proto.getPaymentSentPayoutTxStale()); // nullable processModel.setPayoutTxSignature(ProtoUtil.byteArrayOrNullFromProto(proto.getPayoutTxSignature())); diff --git a/core/src/main/java/haveno/core/trade/protocol/SellerProtocol.java b/core/src/main/java/haveno/core/trade/protocol/SellerProtocol.java index a0aaa10b1f..2d7479a777 100644 --- a/core/src/main/java/haveno/core/trade/protocol/SellerProtocol.java +++ b/core/src/main/java/haveno/core/trade/protocol/SellerProtocol.java @@ -72,11 +72,12 @@ public class SellerProtocol extends DisputeProtocol { ThreadUtils.execute(() -> { if (!((SellerTrade) trade).needsToResendPaymentReceivedMessages()) return; synchronized (trade.getLock()) { - if (!!((SellerTrade) trade).needsToResendPaymentReceivedMessages()) return; + if (!((SellerTrade) trade).needsToResendPaymentReceivedMessages()) return; latchTrade(); given(anyPhase(Trade.Phase.PAYMENT_RECEIVED) .with(SellerEvent.STARTUP)) .setup(tasks( + SellerPreparePaymentReceivedMessage.class, SellerSendPaymentReceivedMessageToBuyer.class, SellerSendPaymentReceivedMessageToArbitrator.class) .using(new TradeTaskRunner(trade, @@ -116,6 +117,16 @@ public class SellerProtocol extends DisputeProtocol { public void onPaymentReceived(ResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) { log.info(TradeProtocol.LOG_HIGHLIGHT + "SellerProtocol.onPaymentReceived() for {} {}", trade.getClass().getSimpleName(), trade.getShortId()); + + // advance trade state + if (trade.isPaymentSent() || trade.isPaymentReceived()) { + trade.advanceState(Trade.State.SELLER_CONFIRMED_PAYMENT_RECEIPT); + } else { + errorMessageHandler.handleErrorMessage("Cannot confirm payment received for " + trade.getClass().getSimpleName() + " " + trade.getShortId() + " in state " + trade.getState()); + return; + } + + // process on trade thread ThreadUtils.execute(() -> { synchronized (trade.getLock()) { latchTrade(); @@ -137,10 +148,9 @@ public class SellerProtocol extends DisputeProtocol { resultHandler.handleResult(); }, (errorMessage) -> { log.warn("Error confirming payment received, reverting state to {}, error={}", Trade.State.BUYER_SENT_PAYMENT_SENT_MSG, errorMessage); - trade.resetToPaymentSentState(); + if (!trade.isPayoutPublished()) trade.resetToPaymentSentState(); handleTaskRunnerFault(event, errorMessage); }))) - .run(() -> trade.advanceState(Trade.State.SELLER_CONFIRMED_PAYMENT_RECEIPT)) .executeTasks(true); } catch (Exception e) { errorMessageHandler.handleErrorMessage("Error confirming payment received: " + e.getMessage()); diff --git a/core/src/main/java/haveno/core/trade/protocol/TradePeer.java b/core/src/main/java/haveno/core/trade/protocol/TradePeer.java index 7ec33716a1..3116746dd6 100644 --- a/core/src/main/java/haveno/core/trade/protocol/TradePeer.java +++ b/core/src/main/java/haveno/core/trade/protocol/TradePeer.java @@ -256,7 +256,11 @@ public final class TradePeer implements PersistablePayload { } public boolean isPaymentReceivedMessageReceived() { - return paymentReceivedMessageStateProperty.get() == MessageState.ACKNOWLEDGED || paymentReceivedMessageStateProperty.get() == MessageState.STORED_IN_MAILBOX || paymentReceivedMessageStateProperty.get() == MessageState.NACKED; + return paymentReceivedMessageStateProperty.get() == MessageState.ACKNOWLEDGED || paymentReceivedMessageStateProperty.get() == MessageState.STORED_IN_MAILBOX; + } + + public boolean isPaymentReceivedMessageArrived() { + return paymentReceivedMessageStateProperty.get() == MessageState.ARRIVED; } @Override diff --git a/core/src/main/java/haveno/core/trade/protocol/TradeProtocol.java b/core/src/main/java/haveno/core/trade/protocol/TradeProtocol.java index e14b6f3097..fc9386bc3b 100644 --- a/core/src/main/java/haveno/core/trade/protocol/TradeProtocol.java +++ b/core/src/main/java/haveno/core/trade/protocol/TradeProtocol.java @@ -42,8 +42,8 @@ import haveno.common.crypto.PubKeyRing; import haveno.common.handlers.ErrorMessageHandler; import haveno.common.proto.network.NetworkEnvelope; import haveno.common.taskrunner.Task; -import haveno.core.network.MessageState; import haveno.core.offer.OpenOffer; +import haveno.core.support.messages.ChatMessage; import haveno.core.trade.ArbitratorTrade; import haveno.core.trade.BuyerTrade; import haveno.core.trade.HavenoUtils; @@ -100,7 +100,7 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D public static final int TRADE_STEP_TIMEOUT_SECONDS = Config.baseCurrencyNetwork().isTestnet() ? 60 : 180; private static final String TIMEOUT_REACHED = "Timeout reached."; - public static final int MAX_ATTEMPTS = 5; // max attempts to create txs and other wallet functions + public static final int MAX_ATTEMPTS = 5; // max attempts to create txs and other protocol functions public static final int REQUEST_CONNECTION_SWITCH_EVERY_NUM_ATTEMPTS = 2; // request connection switch on even attempts public static final long REPROCESS_DELAY_MS = 5000; public static final String LOG_HIGHLIGHT = ""; // TODO: how to highlight some logs with cyan? ("\u001B[36m")? coloring works in the terminal but prints character literals to .log files @@ -118,6 +118,10 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D private int reprocessPaymentSentMessageCount; private int reprocessPaymentReceivedMessageCount; private boolean makerInitTradeRequestHasBeenNacked = false; + private PaymentReceivedMessage lastAckedPaymentReceivedMessage = null; + + private static int MAX_PAYMENT_RECEIVED_NACKS = 5; + private int numPaymentReceivedNacks = 0; /////////////////////////////////////////////////////////////////////////////////////////// // Constructor @@ -258,7 +262,7 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D handleMailboxCollection(mailboxMessageService.getMyDecryptedMailboxMessages()); // reprocess applicable messages - trade.reprocessApplicableMessages(); + trade.initializeAfterMailboxMessages(); } // send deposits confirmed message if applicable @@ -567,6 +571,10 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D removeMailboxMessageAfterProcessing(message); return; } + if (message != trade.getBuyer().getPaymentSentMessage()) { + log.warn("Ignoring PaymentSentMessage which was replaced by a newer message", trade.getClass().getSimpleName(), trade.getId()); + return; + } latchTrade(); expect(anyPhase() .with(message) @@ -625,18 +633,31 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D // save message for reprocessing trade.getSeller().setPaymentReceivedMessage(message); + + // persist trade before processing on trade thread trade.persistNow(() -> { // process message on trade thread if (!trade.isInitialized() || trade.isShutDownStarted()) return; ThreadUtils.execute(() -> { synchronized (trade.getLock()) { - if (!trade.isInitialized() || trade.isShutDownStarted()) return; - if (trade.getPhase().ordinal() >= Trade.Phase.PAYMENT_RECEIVED.ordinal()) { - log.warn("Received another PaymentReceivedMessage which was already processed for {} {}, ACKing", trade.getClass().getSimpleName(), trade.getId()); + if (!trade.isInitialized() || trade.isShutDownStarted()) { + log.warn("Skipping processing PaymentReceivedMessage because the trade is not initialized or it's shutting down for {} {}", trade.getClass().getSimpleName(), trade.getId()); + return; + } + if (trade.getPhase().ordinal() >= Trade.Phase.PAYMENT_RECEIVED.ordinal() && trade.isPayoutPublished()) { + log.warn("Received another PaymentReceivedMessage after payout is published {} {}, ACKing", trade.getClass().getSimpleName(), trade.getId()); handleTaskRunnerSuccess(peer, message); return; } + if (message != trade.getSeller().getPaymentReceivedMessage()) { + log.warn("Ignoring PaymentReceivedMessage which was replaced by a newer message for {} {}", trade.getClass().getSimpleName(), trade.getId()); + return; + } + if (lastAckedPaymentReceivedMessage != null && lastAckedPaymentReceivedMessage.equals(trade.getSeller().getPaymentReceivedMessage())) { + log.warn("Ignoring PaymentReceivedMessage which was already processed and responded to for {} {}", trade.getClass().getSimpleName(), trade.getId()); + return; + } latchTrade(); Validator.checkTradeId(processModel.getOfferId(), message); processModel.setTradeMessage(message); @@ -662,22 +683,32 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D ProcessPaymentReceivedMessage.class) .using(new TradeTaskRunner(trade, () -> { + lastAckedPaymentReceivedMessage = message; handleTaskRunnerSuccess(peer, message); }, errorMessage -> { log.warn("Error processing payment received message: " + errorMessage); processModel.getTradeManager().requestPersistence(); - // schedule to reprocess message unless deleted + // schedule to reprocess message or nack if (trade.getSeller().getPaymentReceivedMessage() != null) { - UserThread.runAfter(() -> { - reprocessPaymentReceivedMessageCount++; - maybeReprocessPaymentReceivedMessage(reprocessOnError); - }, trade.getReprocessDelayInSeconds(reprocessPaymentReceivedMessageCount)); + if (reprocessOnError) { + UserThread.runAfter(() -> { + reprocessPaymentReceivedMessageCount++; + maybeReprocessPaymentReceivedMessage(reprocessOnError); + }, trade.getReprocessDelayInSeconds(reprocessPaymentReceivedMessageCount)); + } + unlatchTrade(); } else { - handleTaskRunnerFault(peer, message, null, errorMessage, trade.getSelf().getUpdatedMultisigHex()); // otherwise send nack + + // export fresh multisig info for nack + trade.exportMultisigHex(); + + // handle payout error + lastAckedPaymentReceivedMessage = message; + trade.onPayoutError(false, null); + handleTaskRunnerFault(peer, message, null, errorMessage, trade.getSelf().getUpdatedMultisigHex()); // send nack } - unlatchTrade(); }))) .executeTasks(true); awaitTradeLatch(); @@ -743,7 +774,17 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D /////////////////////////////////////////////////////////////////////////////////////////// private void onAckMessage(AckMessage ackMessage, NodeAddress sender) { + boolean processOnTradeThread = !ackMessage.getSourceMsgClassName().equals(ChatMessage.class.getSimpleName()); // handle chat message acks off trade thread for responsiveness if the thread is busy + if (processOnTradeThread) { + ThreadUtils.execute(() -> onAckMessageAux(ackMessage, sender), trade.getId()); + } else { + onAckMessageAux(ackMessage, sender); + } + } + // TODO: this has grown in complexity over time and could use refactoring + private void onAckMessageAux(AckMessage ackMessage, NodeAddress sender) { + // ignore if trade is completely finished if (trade.isFinished()) return; @@ -769,11 +810,10 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D // TODO: arbitrator may nack maker's InitTradeRequest if reserve tx has become invalid (e.g. check_tx_key shows 0 funds received). recreate reserve tx in this case if (!ackMessage.isSuccess() && trade.isMaker() && peer == trade.getArbitrator() && ackMessage.getSourceMsgClassName().equals(InitTradeRequest.class.getSimpleName())) { if (ackMessage.getErrorMessage() != null && ackMessage.getErrorMessage().contains(SEND_INIT_TRADE_REQUEST_FAILED)) { - // use default postprocessing to cancel maker's trade if arbitrator cannot send message to taker - } else { + // use default postprocessing if (makerInitTradeRequestHasBeenNacked) { handleSecondMakerInitTradeRequestNack(ackMessage); - // use default postprocessing to cancel maker's trade + // use default postprocessing } else { makerInitTradeRequestHasBeenNacked = true; handleFirstMakerInitTradeRequestNack(ackMessage); @@ -798,6 +838,10 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D // handle ack message for PaymentSentMessage, which automatically re-sends if not ACKed in a certain time if (ackMessage.getSourceMsgClassName().equals(PaymentSentMessage.class.getSimpleName())) { + if (!trade.isPaymentMarkedSent()) { + log.warn("Received AckMessage for PaymentSentMessage but trade is in unexpected state, ignoring. Sender={}, trade={} {}, state={}, success={}, error={}, messageUid={}", sender, trade.getClass().getSimpleName(), trade.getId(), trade.getState(), ackMessage.isSuccess(), ackMessage.getErrorMessage(), ackMessage.getSourceUid()); + return; + } if (peer == trade.getSeller()) { trade.getSeller().setPaymentSentAckMessage(ackMessage); if (ackMessage.isSuccess()) trade.setStateIfValidTransitionTo(Trade.State.SELLER_RECEIVED_PAYMENT_SENT_MSG); @@ -807,63 +851,67 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D trade.getArbitrator().setPaymentSentAckMessage(ackMessage); processModel.getTradeManager().requestPersistence(); } else { - log.warn("Received AckMessage from unexpected peer for {}, sender={}, trade={} {}, messageUid={}, success={}, errorMsg={}", ackMessage.getSourceMsgClassName(), sender, trade.getClass().getSimpleName(), trade.getId(), ackMessage.getSourceUid(), ackMessage.isSuccess(), ackMessage.getErrorMessage()); + log.warn("Received AckMessage from unexpected peer. Sender={}, trade={} {}, state={}, success={}, error={}, messageUid={}", sender, trade.getClass().getSimpleName(), trade.getId(), trade.getState(), ackMessage.isSuccess(), ackMessage.getErrorMessage(), ackMessage.getSourceUid()); return; } } // handle ack message for PaymentReceivedMessage, which automatically re-sends if not ACKed in a certain time + // TODO: trade state can be reset twice if both peers nack before published payout is detected + // TODO: do not reset state if payment received message is acknowledged because payout is likely broadcast? if (ackMessage.getSourceMsgClassName().equals(PaymentReceivedMessage.class.getSimpleName())) { // ack message from buyer if (peer == trade.getBuyer()) { trade.getBuyer().setPaymentReceivedAckMessage(ackMessage); + processModel.getTradeManager().persistNow(null); // handle successful ack if (ackMessage.isSuccess()) { + + // validate state + if (!trade.isPaymentMarkedReceived()) { + log.warn("Received AckMessage for PaymentReceivedMessage but trade is in unexpected state, ignoring. Sender={}, trade={} {}, state={}, success={}, error={}, messageUid={}", sender, trade.getClass().getSimpleName(), trade.getId(), trade.getState(), ackMessage.isSuccess(), ackMessage.getErrorMessage(), ackMessage.getSourceUid()); + return; + } + trade.setStateIfValidTransitionTo(Trade.State.BUYER_RECEIVED_PAYMENT_RECEIVED_MSG); + processModel.getTradeManager().persistNow(null); } // handle nack else { - log.warn("We received a NACK for our PaymentReceivedMessage to the buyer for {} {}", trade.getClass().getSimpleName(), trade.getId()); - + log.warn("We received a NACK for our PaymentReceivedMessage to the buyer for {} {}: {}", trade.getClass().getSimpleName(), trade.getId(), ackMessage.getErrorMessage()); + // nack includes updated multisig hex since v1.1.1 if (ackMessage.getUpdatedMultisigHex() != null) { trade.getBuyer().setUpdatedMultisigHex(ackMessage.getUpdatedMultisigHex()); - - // reset state if not processed - if (trade.isPaymentReceived() && !trade.isPayoutPublished() && !isPaymentReceivedMessageAckedByEither()) { - log.warn("Resetting state to payment sent for {} {}", trade.getClass().getSimpleName(), trade.getId()); - trade.resetToPaymentSentState(); - } + processModel.getTradeManager().persistNow(null); + boolean autoResent = onPayoutError(true, peer); + if (autoResent) return; // skip remaining processing if auto resent } } - processModel.getTradeManager().requestPersistence(); } // ack message from arbitrator else if (peer == trade.getArbitrator()) { trade.getArbitrator().setPaymentReceivedAckMessage(ackMessage); + processModel.getTradeManager().persistNow(null); // handle nack if (!ackMessage.isSuccess()) { - log.warn("We received a NACK for our PaymentReceivedMessage to the arbitrator for {} {}", trade.getClass().getSimpleName(), trade.getId()); + log.warn("We received a NACK for our PaymentReceivedMessage to the arbitrator for {} {}: {}", trade.getClass().getSimpleName(), trade.getId(), ackMessage.getErrorMessage()); // nack includes updated multisig hex since v1.1.1 if (ackMessage.getUpdatedMultisigHex() != null) { trade.getArbitrator().setUpdatedMultisigHex(ackMessage.getUpdatedMultisigHex()); - - // reset state if not processed - if (trade.isPaymentReceived() && !trade.isPayoutPublished() && !isPaymentReceivedMessageAckedByEither()) { - log.warn("Resetting state to payment sent for {} {}", trade.getClass().getSimpleName(), trade.getId()); - trade.resetToPaymentSentState(); - } + processModel.getTradeManager().persistNow(null); + boolean autoResent = onPayoutError(true, peer); + if (autoResent) return; // skip remaining processing if auto resent } } - processModel.getTradeManager().requestPersistence(); } else { - log.warn("Received AckMessage from unexpected peer for {}, sender={}, trade={} {}, messageUid={}, success={}, errorMsg={}", ackMessage.getSourceMsgClassName(), sender, trade.getClass().getSimpleName(), trade.getId(), ackMessage.getSourceUid(), ackMessage.isSuccess(), ackMessage.getErrorMessage()); + log.warn("Received AckMessage from unexpected peer. Sender={}, trade={} {}, state={}, success={}, error={}, messageUid={}", sender, trade.getClass().getSimpleName(), trade.getId(), trade.getState(), ackMessage.isSuccess(), ackMessage.getErrorMessage(), ackMessage.getSourceUid()); return; } @@ -886,6 +934,19 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D trade.onAckMessage(ackMessage, sender); } + private boolean onPayoutError(boolean syncAndPoll, TradePeer peer) { + + // prevent infinite nack loop with max attempts + numPaymentReceivedNacks++; + if (numPaymentReceivedNacks > MAX_PAYMENT_RECEIVED_NACKS) { + log.warn("Maximum number of PaymentReceivedMessage NACKs reached for {} {}, not retrying", trade.getClass().getSimpleName(), trade.getId()); + return false; + } + + // handle payout error + return trade.onPayoutError(syncAndPoll, peer); + } + private void handleFirstMakerInitTradeRequestNack(AckMessage ackMessage) { log.warn("Maker received NACK to InitTradeRequest from arbitrator for {} {}, messageUid={}, errorMessage={}", trade.getClass().getSimpleName(), trade.getId(), ackMessage.getSourceUid(), ackMessage.getErrorMessage()); ThreadUtils.execute(() -> { @@ -928,12 +989,6 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D log.warn(warningMessage); } - private boolean isPaymentReceivedMessageAckedByEither() { - if (trade.getBuyer().getPaymentReceivedMessageStateProperty().get() == MessageState.ACKNOWLEDGED) return true; - if (trade.getArbitrator().getPaymentReceivedMessageStateProperty().get() == MessageState.ACKNOWLEDGED) return true; - return false; - } - protected void sendAckMessage(NodeAddress peer, TradeMessage message, boolean result, @Nullable String errorMessage) { sendAckMessage(peer, message, result, errorMessage, null); } diff --git a/core/src/main/java/haveno/core/trade/protocol/tasks/ArbitratorProcessDepositRequest.java b/core/src/main/java/haveno/core/trade/protocol/tasks/ArbitratorProcessDepositRequest.java index e1969c9550..026604fc75 100644 --- a/core/src/main/java/haveno/core/trade/protocol/tasks/ArbitratorProcessDepositRequest.java +++ b/core/src/main/java/haveno/core/trade/protocol/tasks/ArbitratorProcessDepositRequest.java @@ -113,9 +113,9 @@ public class ArbitratorProcessDepositRequest extends TradeTask { boolean isFromBuyer = sender == trade.getBuyer(); BigInteger tradeFee = isFromTaker ? trade.getTakerFee() : trade.getMakerFee(); BigInteger sendTradeAmount = isFromBuyer ? BigInteger.ZERO : trade.getAmount(); - BigInteger securityDeposit = isFromBuyer ? trade.getBuyerSecurityDepositBeforeMiningFee() : trade.getSellerSecurityDepositBeforeMiningFee(); + BigInteger securityDepositBeforeMiningFee = isFromBuyer ? trade.getBuyerSecurityDepositBeforeMiningFee() : trade.getSellerSecurityDepositBeforeMiningFee(); String depositAddress = processModel.getMultisigAddress(); - sender.setSecurityDeposit(securityDeposit); + sender.setSecurityDeposit(securityDepositBeforeMiningFee); // verify deposit tx boolean isFromBuyerAsTakerWithoutDeposit = isFromBuyer && isFromTaker && trade.hasBuyerAsTakerWithoutDeposit(); @@ -126,7 +126,7 @@ public class ArbitratorProcessDepositRequest extends TradeTask { tradeFee, trade.getProcessModel().getTradeFeeAddress(), sendTradeAmount, - securityDeposit, + securityDepositBeforeMiningFee, depositAddress, sender.getDepositTxHash(), request.getDepositTxHex(), @@ -141,7 +141,7 @@ public class ArbitratorProcessDepositRequest extends TradeTask { } // update trade state - sender.setSecurityDeposit(sender.getSecurityDeposit().subtract(verifiedTx.getFee())); // subtract mining fee from security deposit + sender.setSecurityDeposit(securityDepositBeforeMiningFee.subtract(verifiedTx.getFee())); // subtract mining fee from security deposit sender.setDepositTxFee(verifiedTx.getFee()); sender.setDepositTxHex(request.getDepositTxHex()); sender.setDepositTxKey(request.getDepositTxKey()); @@ -183,7 +183,7 @@ public class ArbitratorProcessDepositRequest extends TradeTask { try { monerod.relayTxsByHash(txHashes); // call will error if txs are already confirmed, but they're still relayed } catch (Exception e) { - log.warn("Error relaying deposit txs: " + e.getMessage()); + log.warn("Error relaying deposit txs for trade {}. They could already be confirmed. Error={}", trade.getId(), e.getMessage()); } depositTxsRelayed = true; diff --git a/core/src/main/java/haveno/core/trade/protocol/tasks/ProcessDepositResponse.java b/core/src/main/java/haveno/core/trade/protocol/tasks/ProcessDepositResponse.java index 22abb085c4..b994c84800 100644 --- a/core/src/main/java/haveno/core/trade/protocol/tasks/ProcessDepositResponse.java +++ b/core/src/main/java/haveno/core/trade/protocol/tasks/ProcessDepositResponse.java @@ -41,7 +41,7 @@ public class ProcessDepositResponse extends TradeTask { // handle error DepositResponse message = (DepositResponse) processModel.getTradeMessage(); if (message.getErrorMessage() != null) { - log.warn("Deposit response for {} {} has error message={}", trade.getClass().getSimpleName(), trade.getShortId(), message.getErrorMessage()); + log.warn("Deposit response has error message for {} {}: {}", trade.getClass().getSimpleName(), trade.getShortId(), message.getErrorMessage()); trade.setStateIfValidTransitionTo(Trade.State.PUBLISH_DEPOSIT_TX_REQUEST_FAILED); trade.setInitError(new RuntimeException(message.getErrorMessage())); complete(); @@ -52,7 +52,7 @@ public class ProcessDepositResponse extends TradeTask { try { model.getXmrWalletService().getMonerod().submitTxHex(trade.getSelf().getDepositTxHex()); } catch (Exception e) { - log.error("Failed to redundantly publish deposit transaction for {} {}", trade.getClass().getSimpleName(), trade.getShortId(), e); + log.warn("Failed to redundantly publish deposit transaction for {} {}", trade.getClass().getSimpleName(), trade.getShortId()); } // record security deposits diff --git a/core/src/main/java/haveno/core/trade/protocol/tasks/ProcessPaymentReceivedMessage.java b/core/src/main/java/haveno/core/trade/protocol/tasks/ProcessPaymentReceivedMessage.java index 331494f767..13273dfbc4 100644 --- a/core/src/main/java/haveno/core/trade/protocol/tasks/ProcessPaymentReceivedMessage.java +++ b/core/src/main/java/haveno/core/trade/protocol/tasks/ProcessPaymentReceivedMessage.java @@ -51,6 +51,7 @@ import static com.google.common.base.Preconditions.checkNotNull; @Slf4j public class ProcessPaymentReceivedMessage extends TradeTask { + public ProcessPaymentReceivedMessage(TaskRunner taskHandler, Trade trade) { super(taskHandler, trade); } @@ -122,9 +123,9 @@ public class ProcessPaymentReceivedMessage extends TradeTask { complete(); } catch (Throwable t) { - // do not reprocess illegal argument + // handle illegal exception if (HavenoUtils.isIllegal(t)) { - trade.getSeller().setPaymentReceivedMessage(null); // do not reprocess + trade.getSeller().setPaymentReceivedMessage(null); // stops reprocessing trade.requestPersistence(); } @@ -155,8 +156,9 @@ public class ProcessPaymentReceivedMessage extends TradeTask { // verify and publish payout tx if (!trade.isPayoutPublished()) { try { - boolean isSigned = message.getSignedPayoutTxHex() != null; - if (isSigned) { + if (message.getPayoutTxId() != null && trade.isBuyer()) { + trade.processBuyerPayout(message.getPayoutTxId()); // buyer can validate payout tx by id with main wallet (in case of multisig issues) + } else if (message.getSignedPayoutTxHex() != null) { log.info("{} {} publishing signed payout tx from seller", trade.getClass().getSimpleName(), trade.getId()); trade.processPayoutTx(message.getSignedPayoutTxHex(), false, true); } else { diff --git a/core/src/main/java/haveno/core/trade/protocol/tasks/SellerPreparePaymentReceivedMessage.java b/core/src/main/java/haveno/core/trade/protocol/tasks/SellerPreparePaymentReceivedMessage.java index a146fa3419..cc276e0d58 100644 --- a/core/src/main/java/haveno/core/trade/protocol/tasks/SellerPreparePaymentReceivedMessage.java +++ b/core/src/main/java/haveno/core/trade/protocol/tasks/SellerPreparePaymentReceivedMessage.java @@ -40,48 +40,61 @@ public class SellerPreparePaymentReceivedMessage extends TradeTask { // check connection trade.verifyDaemonConnection(); - // handle first time preparation - if (trade.getArbitrator().getPaymentReceivedMessage() == null) { - - // synchronize on lock for wallet operations + // import and export multisig hex if payout already published + if (trade.isPayoutPublished()) { synchronized (trade.getWalletLock()) { - synchronized (HavenoUtils.getWalletFunctionLock()) { - - // import multisig hex unless already signed - if (trade.getPayoutTxHex() == null) { + if (trade.walletExists()) { + synchronized (HavenoUtils.getWalletFunctionLock()) { trade.importMultisigHex(); - } - - // verify, sign, and publish payout tx if given - if (trade.getBuyer().getPaymentSentMessage().getPayoutTxHex() != null) { - try { - if (trade.getPayoutTxHex() == null) { - log.info("Seller verifying, signing, and publishing payout tx for trade {}", trade.getId()); - trade.processPayoutTx(trade.getBuyer().getPaymentSentMessage().getPayoutTxHex(), true, true); - } else { - log.warn("Seller publishing previously signed payout tx for trade {}", trade.getId()); - trade.processPayoutTx(trade.getPayoutTxHex(), false, true); - } - } catch (IllegalArgumentException | IllegalStateException e) { - log.warn("Illegal state or argument verifying, signing, and publishing payout tx for {} {}. Creating new unsigned payout tx. error={}. ", trade.getClass().getSimpleName(), trade.getId(), e.getMessage(), e); - createUnsignedPayoutTx(); - } catch (Exception e) { - log.warn("Error verifying, signing, and publishing payout tx for trade {}: {}", trade.getId(), e.getMessage(), e); - throw e; - } - } - - // otherwise create unsigned payout tx - else if (trade.getSelf().getUnsignedPayoutTxHex() == null) { - createUnsignedPayoutTx(); + trade.exportMultisigHex(); } } } - } else if (trade.getArbitrator().getPaymentReceivedMessage().getSignedPayoutTxHex() != null && !trade.isPayoutPublished()) { + } else { - // republish payout tx from previous message - log.info("Seller re-verifying and publishing signed payout tx for trade {}", trade.getId()); - trade.processPayoutTx(trade.getArbitrator().getPaymentReceivedMessage().getSignedPayoutTxHex(), false, true); + // process or create payout tx + if (trade.getPayoutTxHex() == null) { + + // synchronize on lock for wallet operations + synchronized (trade.getWalletLock()) { + synchronized (HavenoUtils.getWalletFunctionLock()) { + + // import multisig hex unless already signed + if (trade.getPayoutTxHex() == null) { + trade.importMultisigHex(); + } + + // verify, sign, and publish payout tx if given + if (trade.getBuyer().getPaymentSentMessage().getPayoutTxHex() != null && !trade.getProcessModel().isPaymentSentPayoutTxStale()) { + try { + if (trade.getPayoutTxHex() == null) { + log.info("Seller verifying, signing, and publishing payout tx for trade {}", trade.getId()); + trade.processPayoutTx(trade.getBuyer().getPaymentSentMessage().getPayoutTxHex(), true, true); + } else { + log.warn("Seller publishing previously signed payout tx for trade {}", trade.getId()); + trade.processPayoutTx(trade.getPayoutTxHex(), false, true); + } + } catch (IllegalArgumentException | IllegalStateException e) { + log.warn("Illegal state or argument verifying, signing, and publishing payout tx for {} {}. Creating new unsigned payout tx. error={}. ", trade.getClass().getSimpleName(), trade.getId(), e.getMessage(), e); + createUnsignedPayoutTx(); + } catch (Exception e) { + log.warn("Error verifying, signing, and publishing payout tx for trade {}: {}", trade.getId(), e.getMessage(), e); + throw e; + } + } + + // otherwise create unsigned payout tx + else if (trade.getSelf().getUnsignedPayoutTxHex() == null) { + createUnsignedPayoutTx(); + } + } + } + } else { + + // republish payout tx from previous message + log.info("Seller re-verifying and publishing signed payout tx for trade {}", trade.getId()); + trade.processPayoutTx(trade.getPayoutTxHex(), false, true); + } } // close open disputes @@ -99,6 +112,7 @@ public class SellerPreparePaymentReceivedMessage extends TradeTask { private void createUnsignedPayoutTx() { log.info("Seller creating unsigned payout tx for trade {}", trade.getId()); + trade.getProcessModel().setPaymentSentPayoutTxStale(true); MoneroTxWallet payoutTx = trade.createPayoutTx(); trade.updatePayout(payoutTx); trade.getSelf().setUnsignedPayoutTxHex(payoutTx.getTxSet().getMultisigTxHex()); diff --git a/core/src/main/java/haveno/core/trade/protocol/tasks/SellerSendPaymentReceivedMessage.java b/core/src/main/java/haveno/core/trade/protocol/tasks/SellerSendPaymentReceivedMessage.java index 88650894f2..c930ad0924 100644 --- a/core/src/main/java/haveno/core/trade/protocol/tasks/SellerSendPaymentReceivedMessage.java +++ b/core/src/main/java/haveno/core/trade/protocol/tasks/SellerSendPaymentReceivedMessage.java @@ -60,6 +60,8 @@ import static com.google.common.base.Preconditions.checkArgument; import java.util.concurrent.TimeUnit; +import org.apache.commons.lang3.StringUtils; + @Slf4j @EqualsAndHashCode(callSuper = true) public abstract class SellerSendPaymentReceivedMessage extends SendMailboxMessageTask { @@ -69,6 +71,9 @@ public abstract class SellerSendPaymentReceivedMessage extends SendMailboxMessag private static final int MAX_RESEND_ATTEMPTS = 20; private int delayInMin = 10; private int resendCounter = 0; + private String unsignedPayoutTxHex = null; + private String signedPayoutTxHex = null; + private String updatedMultisigHex = null; public SellerSendPaymentReceivedMessage(TaskRunner taskHandler, Trade trade) { super(taskHandler, trade); @@ -123,20 +128,30 @@ public abstract class SellerSendPaymentReceivedMessage extends SendMailboxMessag // messages where only the one which gets processed by the peer would be removed we use the same uid. All // other data stays the same when we re-send the message at any time later. String deterministicId = HavenoUtils.getDeterministicId(trade, PaymentReceivedMessage.class, getReceiverNodeAddress()); - boolean deferPublishPayout = trade.isPayoutPublished() || trade.getState().ordinal() >= Trade.State.SELLER_SAW_ARRIVED_PAYMENT_RECEIVED_MSG.ordinal(); // informs receiver to expect payout so delay processing + boolean deferPublishPayout = getReceiver() == trade.getArbitrator() && (trade.isPayoutPublished() || trade.getOtherPeer(getReceiver()).isPaymentReceivedMessageArrived()); // informs receiver to expect payout so delay processing + unsignedPayoutTxHex = trade.getPayoutTxHex() == null ? trade.getSelf().getUnsignedPayoutTxHex() : null; // signed + signedPayoutTxHex = trade.getPayoutTxHex(); + updatedMultisigHex = trade.getSelf().getUpdatedMultisigHex(); PaymentReceivedMessage message = new PaymentReceivedMessage( tradeId, processModel.getMyNodeAddress(), deterministicId, - trade.getPayoutTxHex() == null ? trade.getSelf().getUnsignedPayoutTxHex() : null, // unsigned // TODO: phase in after next update to clear old style trades - trade.getPayoutTxHex() == null ? null : trade.getPayoutTxHex(), // signed - trade.getSelf().getUpdatedMultisigHex(), + unsignedPayoutTxHex, + signedPayoutTxHex, + updatedMultisigHex, deferPublishPayout, trade.getTradePeer().getAccountAgeWitness(), signedWitness, - getReceiver() == trade.getArbitrator() ? trade.getBuyer().getPaymentSentMessage() : null // buyer already has payment sent message + getReceiver() == trade.getArbitrator() ? trade.getBuyer().getPaymentSentMessage() : null, // buyer already has payment sent message, + trade.getPayoutTxId() ); - checkArgument(message.getUnsignedPayoutTxHex() != null || message.getSignedPayoutTxHex() != null, "PaymentReceivedMessage does not include payout tx hex"); + + // verify message + if (trade.isPayoutPublished()) { + checkArgument(message.getUpdatedMultisigHex() != null || message.getPayoutTxId() != null, "PaymentReceivedMessage does not include updated multisig hex or payout tx id after payout published"); + } else { + checkArgument(message.getUnsignedPayoutTxHex() != null || message.getSignedPayoutTxHex() != null, "PaymentReceivedMessage does not include payout tx hex"); + } // sign message try { @@ -240,6 +255,9 @@ public abstract class SellerSendPaymentReceivedMessage extends SendMailboxMessag if (isMessageReceived()) return true; // stop if message received if (!trade.isPaymentReceived()) return true; // stop if trade state reset if (trade.isPayoutPublished() && !((SellerTrade) trade).resendPaymentReceivedMessagesWithinDuration()) return true; // stop if payout is published and we are not in the resend period + if (unsignedPayoutTxHex != null && !StringUtils.equals(unsignedPayoutTxHex, trade.getSelf().getUnsignedPayoutTxHex())) return true; + if (signedPayoutTxHex != null && !StringUtils.equals(signedPayoutTxHex, trade.getPayoutTxHex())) return true; + if (updatedMultisigHex != null && !StringUtils.equals(updatedMultisigHex, trade.getSelf().getUpdatedMultisigHex())) return true; return false; } } diff --git a/core/src/main/java/haveno/core/xmr/setup/DownloadListener.java b/core/src/main/java/haveno/core/xmr/setup/DownloadListener.java index 2b88b30cd7..8e70245ca7 100644 --- a/core/src/main/java/haveno/core/xmr/setup/DownloadListener.java +++ b/core/src/main/java/haveno/core/xmr/setup/DownloadListener.java @@ -11,7 +11,9 @@ public class DownloadListener { private final DoubleProperty percentage = new SimpleDoubleProperty(-1); public void progress(double percentage, long blocksLeft, Date date) { - UserThread.await(() -> this.percentage.set(percentage)); + UserThread.execute(() -> { + UserThread.await(() -> this.percentage.set(percentage)); // TODO: these awaits are jenky + }); } public void doneDownload() { diff --git a/core/src/main/java/haveno/core/xmr/wallet/XmrWalletBase.java b/core/src/main/java/haveno/core/xmr/wallet/XmrWalletBase.java index 594f58b6fb..c60e3ea0e3 100644 --- a/core/src/main/java/haveno/core/xmr/wallet/XmrWalletBase.java +++ b/core/src/main/java/haveno/core/xmr/wallet/XmrWalletBase.java @@ -6,8 +6,6 @@ import java.util.Optional; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; -import org.apache.commons.lang3.exception.ExceptionUtils; - import haveno.common.Timer; import haveno.common.UserThread; import haveno.core.api.XmrConnectionService; @@ -28,9 +26,10 @@ import monero.wallet.model.MoneroWalletListener; public abstract class XmrWalletBase { // constants - public static final int SYNC_PROGRESS_TIMEOUT_SECONDS = 120; + public static final int SYNC_PROGRESS_TIMEOUT_SECONDS = 180; public static final int DIRECT_SYNC_WITHIN_BLOCKS = 100; public static final int SAVE_WALLET_DELAY_SECONDS = 300; + private static final String SYNC_PROGRESS_TIMEOUT_MSG = "Sync progress timeout called"; // inherited protected MoneroWallet wallet; @@ -70,74 +69,96 @@ public abstract class XmrWalletBase { public void syncWithProgress(boolean repeatSyncToLatestHeight) { synchronized (walletLock) { + try { - // set initial state - isSyncingWithProgress = true; - syncProgressError = null; - long targetHeightAtStart = xmrConnectionService.getTargetHeight(); - syncStartHeight = walletHeight.get(); - updateSyncProgress(syncStartHeight, targetHeightAtStart); + // set initial state + if (isSyncingWithProgress) log.warn("Syncing with progress while already syncing with progress. That should never happen"); + resetSyncProgressTimeout(); + isSyncingWithProgress = true; + syncProgressError = null; + long targetHeightAtStart = xmrConnectionService.getTargetHeight(); + syncStartHeight = walletHeight.get(); + updateSyncProgress(syncStartHeight, targetHeightAtStart); - // test connection changing on startup before wallet synced - if (testReconnectOnStartup) { - UserThread.runAfter(() -> { - log.warn("Testing connection change on startup before wallet synced"); - if (xmrConnectionService.getConnection().getUri().equals(testReconnectMonerod1)) xmrConnectionService.setConnection(testReconnectMonerod2); - else xmrConnectionService.setConnection(testReconnectMonerod1); - }, 1); - testReconnectOnStartup = false; // only run once - } + // test connection changing on startup before wallet synced + if (testReconnectOnStartup) { + UserThread.runAfter(() -> { + log.warn("Testing connection change on startup before wallet synced"); + if (xmrConnectionService.getConnection().getUri().equals(testReconnectMonerod1)) xmrConnectionService.setConnection(testReconnectMonerod2); + else xmrConnectionService.setConnection(testReconnectMonerod1); + }, 1); + testReconnectOnStartup = false; // only run once + } - // native wallet provides sync notifications - if (wallet instanceof MoneroWalletFull) { - if (testReconnectOnStartup) HavenoUtils.waitFor(1000); // delay sync to test - wallet.sync(new MoneroWalletListener() { - @Override - public void onSyncProgress(long height, long startHeight, long endHeight, double percentDone, String message) { - long appliedTargetHeight = repeatSyncToLatestHeight ? xmrConnectionService.getTargetHeight() : targetHeightAtStart; - updateSyncProgress(height, appliedTargetHeight); - } - }); - setWalletSyncedWithProgress(); - return; - } - - // start polling wallet for progress - syncProgressLatch = new CountDownLatch(1); - syncProgressLooper = new TaskLooper(() -> { - long height; - try { - height = wallet.getHeight(); // can get read timeout while syncing - } catch (Exception e) { - if (wallet != null && !isShutDownStarted) { - log.warn("Error getting wallet height while syncing with progress: " + e.getMessage()); - log.warn(ExceptionUtils.getStackTrace(e)); - } - - // stop polling and release latch - syncProgressError = e; - syncProgressLatch.countDown(); + // native wallet provides sync notifications + if (wallet instanceof MoneroWalletFull) { + if (testReconnectOnStartup) HavenoUtils.waitFor(1000); // delay sync to test + wallet.sync(new MoneroWalletListener() { + @Override + public void onSyncProgress(long height, long startHeight, long endHeight, double percentDone, String message) { + long appliedTargetHeight = repeatSyncToLatestHeight ? xmrConnectionService.getTargetHeight() : targetHeightAtStart; + updateSyncProgress(height, appliedTargetHeight); + } + }); + setWalletSyncedWithProgress(); return; } - long appliedTargetHeight = repeatSyncToLatestHeight ? xmrConnectionService.getTargetHeight() : targetHeightAtStart; - updateSyncProgress(height, appliedTargetHeight); - if (height >= appliedTargetHeight) { - setWalletSyncedWithProgress(); - syncProgressLatch.countDown(); + + // start polling wallet for progress + syncProgressLatch = new CountDownLatch(1); + syncProgressLooper = new TaskLooper(() -> { + + // stop if shutdown or null wallet + if (isShutDownStarted || wallet == null) { + syncProgressError = new RuntimeException("Shut down or wallet has become null while syncing with progress"); + syncProgressLatch.countDown(); + return; + } + + // get height + long height; + try { + height = wallet.getHeight(); // can get read timeout while syncing + } catch (Exception e) { + if (wallet != null && !isShutDownStarted) { + log.warn("Error getting wallet height while syncing with progress: " + e.getMessage()); + } + if (wallet == null) { + syncProgressError = new RuntimeException("Wallet has become null while syncing with progress"); + syncProgressLatch.countDown(); + } + return; + } + + // update sync progress + long appliedTargetHeight = repeatSyncToLatestHeight ? xmrConnectionService.getTargetHeight() : targetHeightAtStart; + updateSyncProgress(height, appliedTargetHeight); + if (height >= appliedTargetHeight) { + setWalletSyncedWithProgress(); + syncProgressLatch.countDown(); + } + }); + wallet.startSyncing(xmrConnectionService.getRefreshPeriodMs()); + syncProgressLooper.start(1000); + + // wait for sync to complete + HavenoUtils.awaitLatch(syncProgressLatch); + + // stop polling + syncProgressLooper.stop(); + syncProgressTimeout.stop(); + if (wallet != null) { // can become null if interrupted by force close + if (syncProgressError == null || !HavenoUtils.isUnresponsive(syncProgressError)) { // TODO: skipping stop sync if unresponsive because wallet will hang. if unresponsive, wallet is assumed to be force restarted by caller, but that should be done internally here instead of externally? + wallet.stopSyncing(); + } } - }); - wallet.startSyncing(xmrConnectionService.getRefreshPeriodMs()); - syncProgressLooper.start(1000); - - // wait for sync to complete - HavenoUtils.awaitLatch(syncProgressLatch); - - // stop polling - syncProgressLooper.stop(); - syncProgressTimeout.stop(); - if (wallet != null) wallet.stopSyncing(); // can become null if interrupted by force close - isSyncingWithProgress = false; - if (syncProgressError != null) throw new RuntimeException(syncProgressError); + saveWallet(); + if (syncProgressError != null) throw new RuntimeException(syncProgressError); + } catch (Exception e) { + throw e; + } finally { + isSyncingWithProgress = false; + } } } @@ -161,6 +182,10 @@ public abstract class XmrWalletBase { // --------------------------------- ABSTRACT ----------------------------- + public static boolean isSyncWithProgressTimeout(Throwable e) { + return e.getMessage().contains(SYNC_PROGRESS_TIMEOUT_MSG); + } + public abstract void saveWallet(); public abstract void requestSaveWallet(); @@ -170,31 +195,33 @@ public abstract class XmrWalletBase { // ------------------------------ PRIVATE HELPERS ------------------------- private void updateSyncProgress(long height, long targetHeight) { - resetSyncProgressTimeout(); - UserThread.execute(() -> { - // set wallet height - walletHeight.set(height); + // reset progress timeout if height advanced + if (height != walletHeight.get()) { + resetSyncProgressTimeout(); + } - // new wallet reports height 1 before synced - if (height == 1) { - downloadListener.progress(0, targetHeight - height, null); - return; - } + // set wallet height + walletHeight.set(height); - // set progress - long blocksLeft = targetHeight - walletHeight.get(); - if (syncStartHeight == null) syncStartHeight = walletHeight.get(); - double percent = Math.min(1.0, targetHeight == syncStartHeight ? 1.0 : ((double) walletHeight.get() - syncStartHeight) / (double) (targetHeight - syncStartHeight)); - downloadListener.progress(percent, blocksLeft, null); - }); + // new wallet reports height 1 before synced + if (height == 1) { + downloadListener.progress(0, targetHeight - height, null); + return; + } + + // set progress + long blocksLeft = targetHeight - height; + if (syncStartHeight == null) syncStartHeight = height; + double percent = Math.min(1.0, targetHeight == syncStartHeight ? 1.0 : ((double) height - syncStartHeight) / (double) (targetHeight - syncStartHeight)); + downloadListener.progress(percent, blocksLeft, null); } private synchronized void resetSyncProgressTimeout() { if (syncProgressTimeout != null) syncProgressTimeout.stop(); syncProgressTimeout = UserThread.runAfter(() -> { if (isShutDownStarted) return; - syncProgressError = new RuntimeException("Sync progress timeout called"); + syncProgressError = new RuntimeException(SYNC_PROGRESS_TIMEOUT_MSG); syncProgressLatch.countDown(); }, SYNC_PROGRESS_TIMEOUT_SECONDS, TimeUnit.SECONDS); } @@ -202,6 +229,6 @@ public abstract class XmrWalletBase { private void setWalletSyncedWithProgress() { wasWalletSynced = true; isSyncingWithProgress = false; - syncProgressTimeout.stop(); + if (syncProgressTimeout != null) syncProgressTimeout.stop(); } } diff --git a/core/src/main/java/haveno/core/xmr/wallet/XmrWalletService.java b/core/src/main/java/haveno/core/xmr/wallet/XmrWalletService.java index 03475c3e75..d7bc24c239 100644 --- a/core/src/main/java/haveno/core/xmr/wallet/XmrWalletService.java +++ b/core/src/main/java/haveno/core/xmr/wallet/XmrWalletService.java @@ -1077,7 +1077,7 @@ public class XmrWalletService extends XmrWalletBase { // swap trade payout to available if applicable if (tradeManager == null) return; Trade trade = tradeManager.getTrade(offerId); - if (trade == null || trade.isPayoutUnlocked()) swapAddressEntryToAvailable(offerId, XmrAddressEntry.Context.TRADE_PAYOUT); + if (trade == null || trade.isPayoutFinalized()) swapAddressEntryToAvailable(offerId, XmrAddressEntry.Context.TRADE_PAYOUT); } public synchronized void swapPayoutAddressEntryToAvailable(String offerId) { @@ -1221,7 +1221,7 @@ public class XmrWalletService extends XmrWalletBase { Stream available = getFundedAvailableAddressEntries().stream(); available = Stream.concat(available, getAddressEntries(XmrAddressEntry.Context.ARBITRATOR).stream()); available = Stream.concat(available, getAddressEntries(XmrAddressEntry.Context.OFFER_FUNDING).stream().filter(entry -> !tradeManager.getOpenOfferManager().getOpenOffer(entry.getOfferId()).isPresent())); - available = Stream.concat(available, getAddressEntries(XmrAddressEntry.Context.TRADE_PAYOUT).stream().filter(entry -> tradeManager.getTrade(entry.getOfferId()) == null || tradeManager.getTrade(entry.getOfferId()).isPayoutUnlocked())); + available = Stream.concat(available, getAddressEntries(XmrAddressEntry.Context.TRADE_PAYOUT).stream().filter(entry -> tradeManager.getTrade(entry.getOfferId()) == null || tradeManager.getTrade(entry.getOfferId()).isPayoutFinalized())); return available.filter(addressEntry -> getBalanceForSubaddress(addressEntry.getSubaddressIndex()).compareTo(BigInteger.ZERO) > 0); } @@ -2020,7 +2020,7 @@ public class XmrWalletService extends XmrWalletBase { doPollWallet(true); } - private void doPollWallet(boolean updateTxs) { + public void doPollWallet(boolean updateTxs) { // skip if shut down started if (isShutDownStarted) return; @@ -2073,6 +2073,7 @@ public class XmrWalletService extends XmrWalletBase { synchronized (walletLock) { // avoid long fetch from blocking other operations synchronized (HavenoUtils.getDaemonLock()) { MoneroRpcConnection sourceConnection = xmrConnectionService.getConnection(); + if (lastPollTxsTimestamp == 0) lastPollTxsTimestamp = System.currentTimeMillis(); // set initial timestamp try { cachedTxs = wallet.getTxs(new MoneroTxQuery().setIncludeOutputs(true)); lastPollTxsTimestamp = System.currentTimeMillis(); diff --git a/core/src/main/resources/i18n/displayStrings.properties b/core/src/main/resources/i18n/displayStrings.properties index ed67e5d14c..a392fc7af2 100644 --- a/core/src/main/resources/i18n/displayStrings.properties +++ b/core/src/main/resources/i18n/displayStrings.properties @@ -660,6 +660,7 @@ portfolio.pending.unconfirmedTooLong=Deposit transactions on trade {0} are still If the problem persists, contact Haveno support [HYPERLINK:https://matrix.to/#/#haveno:monero.social]. portfolio.pending.step1.waitForConf=Wait for blockchain confirmations +portfolio.pending.step2_buyer.additionalConf=Deposits have reached 10 confirmations.\nFor extra security, we recommend waiting {0} confirmations before sending payment.\nProceed early at your own risk. portfolio.pending.step2_buyer.startPayment=Start payment portfolio.pending.step2_seller.waitPaymentSent=Wait until payment has been sent portfolio.pending.step3_buyer.waitPaymentArrived=Wait until payment arrived @@ -936,6 +937,8 @@ portfolio.pending.support.headline.getHelp=Need help? portfolio.pending.support.button.getHelp=Open Trader Chat portfolio.pending.support.headline.halfPeriodOver=Check payment portfolio.pending.support.headline.periodOver=Trade period is over +portfolio.pending.support.headline.depositTxMissing=Missing deposit transaction +portfolio.pending.support.depositTxMissing=A deposit transaction is missing for this trade. Open a support ticket to contact an arbitrator for assistance. portfolio.pending.arbitrationRequested=Arbitration requested portfolio.pending.mediationRequested=Mediation requested @@ -2338,6 +2341,7 @@ notification.ticket.headline=Support ticket for trade with ID {0} notification.trade.completed=The trade is now completed, and you can withdraw your funds. notification.trade.accepted=Your offer has been accepted by a XMR {0}. notification.trade.unlocked=Your trade has been confirmed.\nYou can start the payment now. +notification.trade.finalized=The trade has {0} confirmations.\nYou can start the payment now. notification.trade.paymentSent=The XMR buyer has sent the payment. notification.trade.selectTrade=Select trade notification.trade.peerOpenedDispute=Your trading peer has opened a {0}. diff --git a/core/src/main/resources/i18n/displayStrings_cs.properties b/core/src/main/resources/i18n/displayStrings_cs.properties index 149f3fa657..17913734f9 100644 --- a/core/src/main/resources/i18n/displayStrings_cs.properties +++ b/core/src/main/resources/i18n/displayStrings_cs.properties @@ -625,6 +625,7 @@ portfolio.pending.unconfirmedTooLong=Vkladové transakce obchodu {0} jsou stále Pokud problém přetrvává, kontaktujte podporu Haveno [HYPERLINK:https://matrix.to/#/#haveno:monero.social]. portfolio.pending.step1.waitForConf=Počkejte na potvrzení na blockchainu +portfolio.pending.step2_buyer.additionalConf=Vklady dosáhly 10 potvrzení.\nPro vyšší bezpečnost doporučujeme počkat na {0} potvrzení před odesláním platby.\nPokračujte dříve na vlastní riziko. portfolio.pending.step2_buyer.startPayment=Zahajte platbu portfolio.pending.step2_seller.waitPaymentSent=Počkejte, než začne platba portfolio.pending.step3_buyer.waitPaymentArrived=Počkejte, než dorazí platba @@ -901,6 +902,8 @@ portfolio.pending.support.headline.getHelp=Potřebujete pomoc? portfolio.pending.support.button.getHelp=Otevřít obchodní chat portfolio.pending.support.headline.halfPeriodOver=Zkontrolujte platbu portfolio.pending.support.headline.periodOver=Obchodní období skončilo +portfolio.pending.support.headline.depositTxMissing=Chybějící vkladová transakce +portfolio.pending.support.depositTxMissing=U tohoto obchodu chybí transakce vkladu. Otevřete podporu, abyste kontaktovali rozhodce a získali pomoc. portfolio.pending.arbitrationRequested=Požádáno o arbitráž portfolio.pending.mediationRequested=Požádáno o mediaci diff --git a/core/src/main/resources/i18n/displayStrings_de.properties b/core/src/main/resources/i18n/displayStrings_de.properties index d1c4c0ce74..801d322bf4 100644 --- a/core/src/main/resources/i18n/displayStrings_de.properties +++ b/core/src/main/resources/i18n/displayStrings_de.properties @@ -577,6 +577,7 @@ portfolio.closedTrades.deviation.help=Prozentuale Preisabweichung vom Markt portfolio.pending.invalidTx=There is an issue with a missing or invalid transaction.\n\nPlease do NOT send the traditional or crypto payment.\n\nOpen a support ticket to get assistance from a Mediator.\n\nError message: {0} portfolio.pending.step1.waitForConf=Auf Blockchain-Bestätigung warten +portfolio.pending.step2_buyer.additionalConf=Einzahlungen haben 10 Bestätigungen erreicht.\nFür zusätzliche Sicherheit empfehlen wir, {0} Bestätigungen abzuwarten, bevor Sie die Zahlung senden.\nEin früheres Vorgehen erfolgt auf eigenes Risiko. portfolio.pending.step2_buyer.startPayment=Zahlung beginnen portfolio.pending.step2_seller.waitPaymentSent=Auf Zahlungsbeginn warten portfolio.pending.step3_buyer.waitPaymentArrived=Auf Zahlungseingang warten @@ -792,6 +793,8 @@ portfolio.pending.support.text.getHelp=Wenn Sie irgendwelche Probleme haben, kö portfolio.pending.support.button.getHelp=Trader Chat öffnen portfolio.pending.support.headline.halfPeriodOver=Zahlung überprüfen portfolio.pending.support.headline.periodOver=Die Handelsdauer ist abgelaufen +portfolio.pending.support.headline.depositTxMissing=Fehlende Einzahlungstransaktion +portfolio.pending.support.depositTxMissing=Für diesen Handel fehlt eine Einzahlungstransaktion. Öffnen Sie ein Support-Ticket, um einen Schlichter um Hilfe zu bitten. portfolio.pending.mediationRequested=Mediation beantragt portfolio.pending.refundRequested=Rückerstattung beantragt diff --git a/core/src/main/resources/i18n/displayStrings_es.properties b/core/src/main/resources/i18n/displayStrings_es.properties index cd1b02486a..bec137f693 100644 --- a/core/src/main/resources/i18n/displayStrings_es.properties +++ b/core/src/main/resources/i18n/displayStrings_es.properties @@ -577,6 +577,7 @@ portfolio.closedTrades.deviation.help=Desviación porcentual de precio de mercad portfolio.pending.invalidTx=Hay un problema con una transacción inválida o no encontrada.\n\nPor faovr NO envíe el pago de traditional o cryptos.\n\nAbra un ticket de soporte para obtener asistencia de un mediador.\n\nMensaje de error: {0} portfolio.pending.step1.waitForConf=Esperar a la confirmación en la cadena de bloques +portfolio.pending.step2_buyer.additionalConf=Los depósitos han alcanzado 10 confirmaciones.\nPara mayor seguridad, recomendamos esperar {0} confirmaciones antes de enviar el pago.\nProceda antes bajo su propio riesgo. portfolio.pending.step2_buyer.startPayment=Comenzar pago portfolio.pending.step2_seller.waitPaymentSent=Esperar hasta que el pago se haya iniciado portfolio.pending.step3_buyer.waitPaymentArrived=Esperar hasta que el pago haya llegado @@ -792,6 +793,8 @@ portfolio.pending.support.text.getHelp=Si tiene algún problema puede intentar c portfolio.pending.support.button.getHelp=Abrir chat de intercambio portfolio.pending.support.headline.halfPeriodOver=Comprobar pago portfolio.pending.support.headline.periodOver=El periodo de intercambio se acabó +portfolio.pending.support.headline.depositTxMissing=Transacción de depósito faltante +portfolio.pending.support.depositTxMissing=Falta una transacción de depósito para este comercio. Abra un ticket de soporte para contactar a un árbitro y recibir asistencia. portfolio.pending.mediationRequested=Mediación solicitada portfolio.pending.refundRequested=Devolución de fondos solicitada diff --git a/core/src/main/resources/i18n/displayStrings_fa.properties b/core/src/main/resources/i18n/displayStrings_fa.properties index 0247d66e17..725353f936 100644 --- a/core/src/main/resources/i18n/displayStrings_fa.properties +++ b/core/src/main/resources/i18n/displayStrings_fa.properties @@ -576,6 +576,7 @@ portfolio.closedTrades.deviation.help=Percentage price deviation from market portfolio.pending.invalidTx=There is an issue with a missing or invalid transaction.\n\nPlease do NOT send the traditional or crypto payment.\n\nOpen a support ticket to get assistance from a Mediator.\n\nError message: {0} portfolio.pending.step1.waitForConf=برای تأییدیه بلاک چین منتظر باشید +portfolio.pending.step2_buyer.additionalConf=واریزها به ۱۰ تأیید رسیده‌اند.\nبرای امنیت بیشتر، توصیه می‌کنیم قبل از ارسال پرداخت، {0} تأیید صبر کنید.\nاقدام زودهنگام با مسئولیت خودتان است. portfolio.pending.step2_buyer.startPayment=آغاز پرداخت portfolio.pending.step2_seller.waitPaymentSent=صبر کنید تا پرداخت شروع شود portfolio.pending.step3_buyer.waitPaymentArrived=صبر کنید تا پرداخت حاصل شود @@ -791,6 +792,8 @@ portfolio.pending.support.text.getHelp=If you have any problems you can try to c portfolio.pending.support.button.getHelp=Open Trader Chat portfolio.pending.support.headline.halfPeriodOver=Check payment portfolio.pending.support.headline.periodOver=Trade period is over +portfolio.pending.support.headline.depositTxMissing=تراکنش واریز مفقود شده +portfolio.pending.support.depositTxMissing=برای این معامله، تراکنش واریز وجود ندارد. برای دریافت کمک با داور، یک تیکت پشتیبانی باز کنید. portfolio.pending.mediationRequested=Mediation requested portfolio.pending.refundRequested=Refund requested diff --git a/core/src/main/resources/i18n/displayStrings_fr.properties b/core/src/main/resources/i18n/displayStrings_fr.properties index f427dd0a96..fff87395f5 100644 --- a/core/src/main/resources/i18n/displayStrings_fr.properties +++ b/core/src/main/resources/i18n/displayStrings_fr.properties @@ -577,6 +577,7 @@ portfolio.closedTrades.deviation.help=Pourcentage de déviation du prix par rapp portfolio.pending.invalidTx=Il y'a un problème avec une transaction manquante ou invalide.\n\nVeuillez NE PAS envoyer le payement Traditional ou crypto.\n\nOuvrez un ticket de support pour avoir l'aide d'un médiateur.\n\nMessage d'erreur: {0} portfolio.pending.step1.waitForConf=Attendre la confirmation de la blockchain +portfolio.pending.step2_buyer.additionalConf=Les dépôts ont atteint 10 confirmations.\nPour plus de sécurité, nous recommandons d’attendre {0} confirmations avant d’envoyer le paiement.\nProcédez plus tôt à vos propres risques. portfolio.pending.step2_buyer.startPayment=Initier le paiement portfolio.pending.step2_seller.waitPaymentSent=Patientez jusqu'à ce que le paiement soit commencé. portfolio.pending.step3_buyer.waitPaymentArrived=Patientez jusqu'à la réception du paiement @@ -793,6 +794,8 @@ portfolio.pending.support.text.getHelp=Si vous rencontrez des problèmes, vous p portfolio.pending.support.button.getHelp=Ouvrir le chat de trade portfolio.pending.support.headline.halfPeriodOver=Vérifier le paiement portfolio.pending.support.headline.periodOver=Le délai alloué pour ce trade est écoulé. +portfolio.pending.support.headline.depositTxMissing=Transaction de dépôt manquante +portfolio.pending.support.depositTxMissing=Une transaction de dépôt est manquante pour cette opération. Ouvrez un ticket de support pour contacter un arbitre et obtenir de l’aide. portfolio.pending.mediationRequested=Médiation demandée portfolio.pending.refundRequested=Remboursement demandé diff --git a/core/src/main/resources/i18n/displayStrings_it.properties b/core/src/main/resources/i18n/displayStrings_it.properties index 2ad8db100a..d00857a2db 100644 --- a/core/src/main/resources/i18n/displayStrings_it.properties +++ b/core/src/main/resources/i18n/displayStrings_it.properties @@ -576,6 +576,7 @@ portfolio.closedTrades.deviation.help=Percentage price deviation from market portfolio.pending.invalidTx=There is an issue with a missing or invalid transaction.\n\nPlease do NOT send the traditional or crypto payment.\n\nOpen a support ticket to get assistance from a Mediator.\n\nError message: {0} portfolio.pending.step1.waitForConf=Attendi la conferma della blockchain +portfolio.pending.step2_buyer.additionalConf=I depositi hanno raggiunto 10 conferme.\nPer maggiore sicurezza, consigliamo di attendere {0} conferme prima di inviare il pagamento.\nProcedi in anticipo a tuo rischio. portfolio.pending.step2_buyer.startPayment=Inizia il pagamento portfolio.pending.step2_seller.waitPaymentSent=Attendi fino all'avvio del pagamento portfolio.pending.step3_buyer.waitPaymentArrived=Attendi fino all'arrivo del pagamento @@ -791,6 +792,8 @@ portfolio.pending.support.text.getHelp=In caso di problemi, puoi provare a conta portfolio.pending.support.button.getHelp=Apri la chat dello scambio portfolio.pending.support.headline.halfPeriodOver=Controlla il pagamento portfolio.pending.support.headline.periodOver=Il periodo di scambio è finito +portfolio.pending.support.headline.depositTxMissing=Transazione di deposito mancante +portfolio.pending.support.depositTxMissing=Manca una transazione di deposito per questa operazione. Apri un ticket di supporto per contattare un arbitro per assistenza. portfolio.pending.mediationRequested=Mediazione richiesta portfolio.pending.refundRequested=Rimborso richiesto diff --git a/core/src/main/resources/i18n/displayStrings_ja.properties b/core/src/main/resources/i18n/displayStrings_ja.properties index 92298f055f..ee23831447 100644 --- a/core/src/main/resources/i18n/displayStrings_ja.properties +++ b/core/src/main/resources/i18n/displayStrings_ja.properties @@ -577,6 +577,7 @@ portfolio.closedTrades.deviation.help=市場からの割合価格偏差 portfolio.pending.invalidTx=There is an issue with a missing or invalid transaction.\n\nPlease do NOT send the traditional or crypto payment.\n\nOpen a support ticket to get assistance from a Mediator.\n\nError message: {0} portfolio.pending.step1.waitForConf=ブロックチェーンの承認をお待ち下さい +portfolio.pending.step2_buyer.additionalConf=入金は10承認に達しました。\n追加の安全のため、支払いを送信する前に{0}承認を待つことをお勧めします。\n早めに進める場合は自己責任となります。 portfolio.pending.step2_buyer.startPayment=支払い開始 portfolio.pending.step2_seller.waitPaymentSent=支払いが始まるまでお待ち下さい portfolio.pending.step3_buyer.waitPaymentArrived=支払いが到着するまでお待ち下さい @@ -792,6 +793,8 @@ portfolio.pending.support.text.getHelp=問題があれば、トレードチャ portfolio.pending.support.button.getHelp=取引者チャットを開く portfolio.pending.support.headline.halfPeriodOver=支払いを確認 portfolio.pending.support.headline.periodOver=トレード期間は終了しました +portfolio.pending.support.headline.depositTxMissing=入金トランザクションが見つかりません +portfolio.pending.support.depositTxMissing=この取引には入金トランザクションが見つかりません。サポートチケットを開いて、仲裁者に連絡してサポートを受けてください。 portfolio.pending.mediationRequested=調停は依頼されました portfolio.pending.refundRequested=返金は請求されました diff --git a/core/src/main/resources/i18n/displayStrings_pt-br.properties b/core/src/main/resources/i18n/displayStrings_pt-br.properties index c5938abd30..0e92411654 100644 --- a/core/src/main/resources/i18n/displayStrings_pt-br.properties +++ b/core/src/main/resources/i18n/displayStrings_pt-br.properties @@ -579,6 +579,7 @@ portfolio.closedTrades.deviation.help=Percentage price deviation from market portfolio.pending.invalidTx=There is an issue with a missing or invalid transaction.\n\nPlease do NOT send the traditional or crypto payment.\n\nOpen a support ticket to get assistance from a Mediator.\n\nError message: {0} portfolio.pending.step1.waitForConf=Aguardar confirmação da blockchain +portfolio.pending.step2_buyer.additionalConf=Depósitos alcançaram 10 confirmações.\nPara maior segurança, recomendamos aguardar {0} confirmações antes de enviar o pagamento.\nProssiga antecipadamente por sua própria conta e risco. portfolio.pending.step2_buyer.startPayment=Iniciar pagamento portfolio.pending.step2_seller.waitPaymentSent=Aguardar início do pagamento portfolio.pending.step3_buyer.waitPaymentArrived=Aguardar recebimento do pagamento @@ -794,6 +795,8 @@ portfolio.pending.support.text.getHelp=Caso tenha problemas, você pode tentar c portfolio.pending.support.button.getHelp=Abrir Chat de Negociante portfolio.pending.support.headline.halfPeriodOver=Verifique o pagamento portfolio.pending.support.headline.periodOver=O período de negociação acabou +portfolio.pending.support.headline.depositTxMissing=Transação de depósito ausente +portfolio.pending.support.depositTxMissing=Está faltando uma transação de depósito para esta negociação. Abra um ticket de suporte para contatar um árbitro para assistência. portfolio.pending.mediationRequested=Mediação requerida portfolio.pending.refundRequested=Reembolso requerido diff --git a/core/src/main/resources/i18n/displayStrings_pt.properties b/core/src/main/resources/i18n/displayStrings_pt.properties index f31fc5b0e9..451dc5ddbd 100644 --- a/core/src/main/resources/i18n/displayStrings_pt.properties +++ b/core/src/main/resources/i18n/displayStrings_pt.properties @@ -576,6 +576,7 @@ portfolio.closedTrades.deviation.help=Percentage price deviation from market portfolio.pending.invalidTx=There is an issue with a missing or invalid transaction.\n\nPlease do NOT send the traditional or crypto payment.\n\nOpen a support ticket to get assistance from a Mediator.\n\nError message: {0} portfolio.pending.step1.waitForConf=Esperando confirmação da blockchain +portfolio.pending.step2_buyer.additionalConf=Os depósitos alcançaram 10 confirmações.\nPara maior segurança, recomendamos aguardar {0} confirmações antes de enviar o pagamento.\nProceda antecipadamente por sua própria conta e risco. portfolio.pending.step2_buyer.startPayment=Iniciar pagamento portfolio.pending.step2_seller.waitPaymentSent=Aguardar até que o pagamento inicie portfolio.pending.step3_buyer.waitPaymentArrived=Aguardar até que o pagamento chegue @@ -791,6 +792,8 @@ portfolio.pending.support.text.getHelp=Se tiver algum problema você pode tentar portfolio.pending.support.button.getHelp=Open Trader Chat portfolio.pending.support.headline.halfPeriodOver=Verificar o pagamento portfolio.pending.support.headline.periodOver=O período de negócio acabou +portfolio.pending.support.headline.depositTxMissing=Transação de depósito ausente +portfolio.pending.support.depositTxMissing=Está faltando uma transação de depósito para esta negociação. Abra um ticket de suporte para contatar um árbitro para assistência. portfolio.pending.mediationRequested=Mediação solicitada portfolio.pending.refundRequested=Reembolso pedido diff --git a/core/src/main/resources/i18n/displayStrings_ru.properties b/core/src/main/resources/i18n/displayStrings_ru.properties index b36ccfe3aa..25e5b0536d 100644 --- a/core/src/main/resources/i18n/displayStrings_ru.properties +++ b/core/src/main/resources/i18n/displayStrings_ru.properties @@ -576,6 +576,7 @@ portfolio.closedTrades.deviation.help=Percentage price deviation from market portfolio.pending.invalidTx=There is an issue with a missing or invalid transaction.\n\nPlease do NOT send the traditional or crypto payment.\n\nOpen a support ticket to get assistance from a Mediator.\n\nError message: {0} portfolio.pending.step1.waitForConf=Ожидание подтверждения в блокчейне +portfolio.pending.step2_buyer.additionalConf=Депозиты достигли 10 подтверждений.\nДля дополнительной безопасности мы рекомендуем дождаться {0} подтверждений перед отправкой платежа.\nРанее действия осуществляются на ваш страх и риск. portfolio.pending.step2_buyer.startPayment=Сделать платеж portfolio.pending.step2_seller.waitPaymentSent=Дождитесь начала платежа portfolio.pending.step3_buyer.waitPaymentArrived=Дождитесь получения платежа @@ -791,6 +792,8 @@ portfolio.pending.support.text.getHelp=If you have any problems you can try to c portfolio.pending.support.button.getHelp=Open Trader Chat portfolio.pending.support.headline.halfPeriodOver=Check payment portfolio.pending.support.headline.periodOver=Время сделки истекло +portfolio.pending.support.headline.depositTxMissing=Отсутствует депозитная транзакция +portfolio.pending.support.depositTxMissing=Для этой сделки отсутствует депозитная транзакция. Откройте тикет в службу поддержки, чтобы связаться с арбитром для получения помощи. portfolio.pending.mediationRequested=Mediation requested portfolio.pending.refundRequested=Refund requested diff --git a/core/src/main/resources/i18n/displayStrings_th.properties b/core/src/main/resources/i18n/displayStrings_th.properties index eda88327dc..8e8cd7afec 100644 --- a/core/src/main/resources/i18n/displayStrings_th.properties +++ b/core/src/main/resources/i18n/displayStrings_th.properties @@ -576,6 +576,7 @@ portfolio.closedTrades.deviation.help=Percentage price deviation from market portfolio.pending.invalidTx=There is an issue with a missing or invalid transaction.\n\nPlease do NOT send the traditional or crypto payment.\n\nOpen a support ticket to get assistance from a Mediator.\n\nError message: {0} portfolio.pending.step1.waitForConf=รอการยืนยันของบล็อกเชน +portfolio.pending.step2_buyer.additionalConf=ยอดฝากถึง 10 การยืนยันแล้ว\nเพื่อความปลอดภัยเพิ่มเติม เราแนะนำให้รอ {0} การยืนยันก่อนทำการชำระเงิน\nดำเนินการล่วงหน้าตามความเสี่ยงของคุณเอง portfolio.pending.step2_buyer.startPayment=เริ่มการชำระเงิน portfolio.pending.step2_seller.waitPaymentSent=รอจนกว่าการชำระเงินจะเริ่มขึ้น portfolio.pending.step3_buyer.waitPaymentArrived=รอจนกว่าจะถึงการชำระเงิน @@ -791,6 +792,8 @@ portfolio.pending.support.text.getHelp=If you have any problems you can try to c portfolio.pending.support.button.getHelp=Open Trader Chat portfolio.pending.support.headline.halfPeriodOver=Check payment portfolio.pending.support.headline.periodOver=Trade period is over +portfolio.pending.support.headline.depositTxMissing=การฝากธุรกรรมหายไป +portfolio.pending.support.depositTxMissing=รายการฝากสำหรับการซื้อขายนี้หายไป กรุณาเปิดตั๋วสนับสนุนเพื่อติดต่อผู้ตัดสินเพื่อขอความช่วยเหลือ portfolio.pending.mediationRequested=Mediation requested portfolio.pending.refundRequested=Refund requested diff --git a/core/src/main/resources/i18n/displayStrings_tr.properties b/core/src/main/resources/i18n/displayStrings_tr.properties index 603fc228a4..9d2e74e7f3 100644 --- a/core/src/main/resources/i18n/displayStrings_tr.properties +++ b/core/src/main/resources/i18n/displayStrings_tr.properties @@ -623,6 +623,7 @@ portfolio.pending.unconfirmedTooLong=İşlem {0} üzerindeki güvence işlemleri Sorun devam ederse, Haveno desteğiyle iletişime geçin [HYPERLINK:https://matrix.to/#/#haveno:monero.social]. portfolio.pending.step1.waitForConf=Blok zinciri onaylarını bekleyin +portfolio.pending.step2_buyer.additionalConf=Mevduatlar 10 onayı ulaştı.\nEkstra güvenlik için, ödeme göndermeden önce {0} onayı beklemenizi öneririz.\nErken ilerlemek kendi riskinizdedir. portfolio.pending.step2_buyer.startPayment=Ödemeyi başlat portfolio.pending.step2_seller.waitPaymentSent=Ödeme gönderilene kadar bekle portfolio.pending.step3_buyer.waitPaymentArrived=Ödeme gelene kadar bekle @@ -899,6 +900,8 @@ portfolio.pending.support.headline.getHelp=Yardıma mı ihtiyacınız var? portfolio.pending.support.button.getHelp=Tüccar Sohbetini Aç portfolio.pending.support.headline.halfPeriodOver=Ödemeyi kontrol edin portfolio.pending.support.headline.periodOver=Ticaret süresi doldu +portfolio.pending.support.headline.depositTxMissing=Eksik yatırma işlemi +portfolio.pending.support.depositTxMissing=Bu işlem için bir para yatırma işlemi eksik. Yardım almak için bir tahkimciyle iletişime geçmek üzere bir destek talebi açın. portfolio.pending.arbitrationRequested=Arabuluculuk talep edildi portfolio.pending.mediationRequested=Arabuluculuk talep edildi diff --git a/core/src/main/resources/i18n/displayStrings_vi.properties b/core/src/main/resources/i18n/displayStrings_vi.properties index ed1e87215d..4db976f7ee 100644 --- a/core/src/main/resources/i18n/displayStrings_vi.properties +++ b/core/src/main/resources/i18n/displayStrings_vi.properties @@ -576,6 +576,7 @@ portfolio.closedTrades.deviation.help=Percentage price deviation from market portfolio.pending.invalidTx=There is an issue with a missing or invalid transaction.\n\nPlease do NOT send the traditional or crypto payment.\n\nOpen a support ticket to get assistance from a Mediator.\n\nError message: {0} portfolio.pending.step1.waitForConf=Đợi xác nhận blockchain +portfolio.pending.step2_buyer.additionalConf=Tiền gửi đã đạt 10 xác nhận.\nĐể tăng cường bảo mật, chúng tôi khuyên bạn chờ {0} xác nhận trước khi gửi thanh toán.\nTiến hành sớm là rủi ro của bạn. portfolio.pending.step2_buyer.startPayment=Bắt đầu thanh toán portfolio.pending.step2_seller.waitPaymentSent=Đợi đến khi bắt đầu thanh toán portfolio.pending.step3_buyer.waitPaymentArrived=Đợi đến khi khoản thanh toán đến @@ -791,6 +792,8 @@ portfolio.pending.support.text.getHelp=If you have any problems you can try to c portfolio.pending.support.button.getHelp=Open Trader Chat portfolio.pending.support.headline.halfPeriodOver=Check payment portfolio.pending.support.headline.periodOver=Trade period is over +portfolio.pending.support.headline.depositTxMissing=Thiếu giao dịch ký quỹ +portfolio.pending.support.depositTxMissing=Giao dịch gửi tiền cho thương vụ này bị thiếu. Mở phiếu hỗ trợ để liên hệ với trọng tài để được trợ giúp. portfolio.pending.mediationRequested=Mediation requested portfolio.pending.refundRequested=Refund requested diff --git a/core/src/main/resources/i18n/displayStrings_zh-hans.properties b/core/src/main/resources/i18n/displayStrings_zh-hans.properties index ad12b8e956..db813c05aa 100644 --- a/core/src/main/resources/i18n/displayStrings_zh-hans.properties +++ b/core/src/main/resources/i18n/displayStrings_zh-hans.properties @@ -577,6 +577,7 @@ portfolio.closedTrades.deviation.help=与市场价格偏差百分比 portfolio.pending.invalidTx=There is an issue with a missing or invalid transaction.\n\nPlease do NOT send the traditional or crypto payment.\n\nOpen a support ticket to get assistance from a Mediator.\n\nError message: {0} portfolio.pending.step1.waitForConf=等待区块链确认 +portfolio.pending.step2_buyer.additionalConf=存款已达到 10 个确认。\n为了额外安全,我们建议在发送付款前等待 {0} 个确认。\n提前操作风险自负。 portfolio.pending.step2_buyer.startPayment=开始付款 portfolio.pending.step2_seller.waitPaymentSent=等待直到付款 portfolio.pending.step3_buyer.waitPaymentArrived=等待直到付款到达 @@ -792,6 +793,8 @@ portfolio.pending.support.text.getHelp=如果您有任何问题,您可以尝 portfolio.pending.support.button.getHelp=开启交易聊天 portfolio.pending.support.headline.halfPeriodOver=确认付款 portfolio.pending.support.headline.periodOver=交易期结束 +portfolio.pending.support.headline.depositTxMissing=缺少存款交易 +portfolio.pending.support.depositTxMissing=此交易缺少存款交易。请提交支持工单以联系仲裁员寻求帮助。 portfolio.pending.mediationRequested=已请求调解员协助 portfolio.pending.refundRequested=已请求退款 diff --git a/core/src/main/resources/i18n/displayStrings_zh-hant.properties b/core/src/main/resources/i18n/displayStrings_zh-hant.properties index 7e95905ba8..e3f943d80c 100644 --- a/core/src/main/resources/i18n/displayStrings_zh-hant.properties +++ b/core/src/main/resources/i18n/displayStrings_zh-hant.properties @@ -577,6 +577,7 @@ portfolio.closedTrades.deviation.help=Percentage price deviation from market portfolio.pending.invalidTx=There is an issue with a missing or invalid transaction.\n\nPlease do NOT send the traditional or crypto payment.\n\nOpen a support ticket to get assistance from a Mediator.\n\nError message: {0} portfolio.pending.step1.waitForConf=等待區塊鏈確認 +portfolio.pending.step2_buyer.additionalConf=存款已達 10 次確認。\n為了額外安全,我們建議在發送付款前等待 {0} 次確認。\n提前操作風險自負。 portfolio.pending.step2_buyer.startPayment=開始付款 portfolio.pending.step2_seller.waitPaymentSent=等待直到付款 portfolio.pending.step3_buyer.waitPaymentArrived=等待直到付款到達 @@ -792,6 +793,8 @@ portfolio.pending.support.text.getHelp=如果您有任何問題,您可以嘗 portfolio.pending.support.button.getHelp=開啟交易聊天 portfolio.pending.support.headline.halfPeriodOver=確認付款 portfolio.pending.support.headline.periodOver=交易期結束 +portfolio.pending.support.headline.depositTxMissing=缺少存款交易 +portfolio.pending.support.depositTxMissing=此交易缺少存款。請開啟支援工單以聯絡仲裁者協助處理。 portfolio.pending.mediationRequested=已請求調解員協助 portfolio.pending.refundRequested=已請求退款 diff --git a/desktop/src/main/java/haveno/desktop/main/MainViewModel.java b/desktop/src/main/java/haveno/desktop/main/MainViewModel.java index 03c38e99ab..0029d46bbb 100644 --- a/desktop/src/main/java/haveno/desktop/main/MainViewModel.java +++ b/desktop/src/main/java/haveno/desktop/main/MainViewModel.java @@ -18,6 +18,8 @@ package haveno.desktop.main; import com.google.inject.Inject; + +import haveno.common.ThreadUtils; import haveno.common.Timer; import haveno.common.UserThread; import haveno.common.app.DevEnv; @@ -219,47 +221,49 @@ public class MainViewModel implements ViewModel, HavenoSetup.HavenoSetupListener (a, b) -> a && b); tradesAndUIReady.subscribe((observable, oldValue, newValue) -> { if (newValue) { - tradeManager.applyTradePeriodState(); + ThreadUtils.submitToPool(() -> { + tradeManager.applyTradePeriodState(); - tradeManager.getOpenTrades().forEach(trade -> { + tradeManager.getOpenTrades().forEach(trade -> { - // check initialization error - if (trade.getInitError() != null) { - new Popup().warning("Error initializing trade" + " " + trade.getShortId() + "\n\n" + - trade.getInitError().getMessage()) - .show(); - return; - } + // check initialization error + if (trade.getInitError() != null) { + new Popup().warning("Error initializing trade" + " " + trade.getShortId() + "\n\n" + + trade.getInitError().getMessage()) + .show(); + return; + } - // check trade period - Date maxTradePeriodDate = trade.getMaxTradePeriodDate(); - String key; - switch (trade.getPeriodState()) { - case FIRST_HALF: - break; - case SECOND_HALF: - key = "displayHalfTradePeriodOver" + trade.getId(); - if (DontShowAgainLookup.showAgain(key)) { - DontShowAgainLookup.dontShowAgain(key, true); - if (trade instanceof ArbitratorTrade) break; // skip popup if arbitrator trade - new Popup().warning(Res.get("popup.warning.tradePeriod.halfReached", - trade.getShortId(), - DisplayUtils.formatDateTime(maxTradePeriodDate))) - .show(); - } - break; - case TRADE_PERIOD_OVER: - key = "displayTradePeriodOver" + trade.getId(); - if (DontShowAgainLookup.showAgain(key)) { - DontShowAgainLookup.dontShowAgain(key, true); - if (trade instanceof ArbitratorTrade) break; // skip popup if arbitrator trade - new Popup().warning(Res.get("popup.warning.tradePeriod.ended", - trade.getShortId(), - DisplayUtils.formatDateTime(maxTradePeriodDate))) - .show(); - } - break; - } + // check trade period + Date maxTradePeriodDate = trade.getMaxTradePeriodDate(); + String key; + switch (trade.getPeriodState()) { + case FIRST_HALF: + break; + case SECOND_HALF: + key = "displayHalfTradePeriodOver" + trade.getId(); + if (DontShowAgainLookup.showAgain(key)) { + DontShowAgainLookup.dontShowAgain(key, true); + if (trade instanceof ArbitratorTrade) break; // skip popup if arbitrator trade + new Popup().warning(Res.get("popup.warning.tradePeriod.halfReached", + trade.getShortId(), + DisplayUtils.formatDateTime(maxTradePeriodDate))) + .show(); + } + break; + case TRADE_PERIOD_OVER: + key = "displayTradePeriodOver" + trade.getId(); + if (DontShowAgainLookup.showAgain(key)) { + DontShowAgainLookup.dontShowAgain(key, true); + if (trade instanceof ArbitratorTrade) break; // skip popup if arbitrator trade + new Popup().warning(Res.get("popup.warning.tradePeriod.ended", + trade.getShortId(), + DisplayUtils.formatDateTime(maxTradePeriodDate))) + .show(); + } + break; + } + }); }); } }); diff --git a/desktop/src/main/java/haveno/desktop/main/funds/transactions/TransactionsListItem.java b/desktop/src/main/java/haveno/desktop/main/funds/transactions/TransactionsListItem.java index 11a013e42f..930d8f9572 100644 --- a/desktop/src/main/java/haveno/desktop/main/funds/transactions/TransactionsListItem.java +++ b/desktop/src/main/java/haveno/desktop/main/funds/transactions/TransactionsListItem.java @@ -170,10 +170,6 @@ public class TransactionsListItem { } } } - } else { - if (amount.compareTo(BigInteger.ZERO) == 0) { - details = Res.get("funds.tx.noFundsFromDispute"); - } } // get tx date/time diff --git a/desktop/src/main/java/haveno/desktop/main/overlays/notifications/NotificationCenter.java b/desktop/src/main/java/haveno/desktop/main/overlays/notifications/NotificationCenter.java index f9211cd17f..6af7a30601 100644 --- a/desktop/src/main/java/haveno/desktop/main/overlays/notifications/NotificationCenter.java +++ b/desktop/src/main/java/haveno/desktop/main/overlays/notifications/NotificationCenter.java @@ -205,8 +205,12 @@ public class NotificationCenter { message = Res.get("notification.trade.accepted", role); } - if (trade instanceof BuyerTrade && phase.ordinal() == Trade.Phase.DEPOSITS_UNLOCKED.ordinal()) - message = Res.get("notification.trade.unlocked"); + if (trade instanceof BuyerTrade) { + if (phase.ordinal() == Trade.Phase.DEPOSITS_UNLOCKED.ordinal()) + message = Res.get("notification.trade.unlocked"); + else if (phase.ordinal() == Trade.Phase.DEPOSITS_FINALIZED.ordinal()) + message = Res.get("notification.trade.finalized", Trade.NUM_BLOCKS_DEPOSITS_FINALIZED); + } else if (trade instanceof SellerTrade && phase.ordinal() == Trade.Phase.PAYMENT_SENT.ordinal()) message = Res.get("notification.trade.paymentSent"); } diff --git a/desktop/src/main/java/haveno/desktop/main/overlays/windows/DisputeSummaryWindow.java b/desktop/src/main/java/haveno/desktop/main/overlays/windows/DisputeSummaryWindow.java index a90395907f..9991638847 100644 --- a/desktop/src/main/java/haveno/desktop/main/overlays/windows/DisputeSummaryWindow.java +++ b/desktop/src/main/java/haveno/desktop/main/overlays/windows/DisputeSummaryWindow.java @@ -225,6 +225,9 @@ public class DisputeSummaryWindow extends Overlay { } else if (trade.isPayoutPublished()) { log.warn("Payout is already published for {} {}, disabling payout controls", trade.getClass().getSimpleName(), trade.getId()); disableTradeAmountPayoutControls(); + } else if (trade.isDepositTxMissing()) { + log.warn("Missing deposit tx for {} {}, disabling some payout controls", trade.getClass().getSimpleName(), trade.getId()); + disableTradeAmountPayoutControlsWhenDepositMissing(); } setReasonRadioButtonState(); @@ -259,6 +262,11 @@ public class DisputeSummaryWindow extends Overlay { reasonWasTradeAlreadySettledRadioButton.setDisable(true); } + private void disableTradeAmountPayoutControlsWhenDepositMissing() { + buyerGetsTradeAmountRadioButton.setDisable(true); + sellerGetsTradeAmountRadioButton.setDisable(true); + } + private void addInfoPane() { Contract contract = dispute.getContract(); addTitledGroupBg(gridPane, ++rowIndex, 17, Res.get("disputeSummaryWindow.title")).getStyleClass().add("last"); @@ -374,10 +382,7 @@ public class DisputeSummaryWindow extends Overlay { return; } - Contract contract = dispute.getContract(); - BigInteger available = contract.getTradeAmount() - .add(trade.getBuyer().getSecurityDeposit()) - .add(trade.getSeller().getSecurityDeposit()); + BigInteger available = trade.getWallet().getBalance(); BigInteger enteredAmount = HavenoUtils.parseXmr(inputTextField.getText()); if (enteredAmount.compareTo(available) > 0) { enteredAmount = available; diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/pendingtrades/PendingTradesViewModel.java b/desktop/src/main/java/haveno/desktop/main/portfolio/pendingtrades/PendingTradesViewModel.java index 6a34504dad..6604271799 100644 --- a/desktop/src/main/java/haveno/desktop/main/portfolio/pendingtrades/PendingTradesViewModel.java +++ b/desktop/src/main/java/haveno/desktop/main/portfolio/pendingtrades/PendingTradesViewModel.java @@ -48,6 +48,8 @@ import haveno.desktop.util.GUIUtil; import haveno.network.p2p.P2PService; import java.math.BigInteger; import java.util.Date; +import java.util.HashMap; +import java.util.Map; import java.util.stream.Collectors; import javafx.beans.property.IntegerProperty; import javafx.beans.property.ObjectProperty; @@ -107,6 +109,7 @@ public class PendingTradesViewModel extends ActivatableWithDataModel showPaymentDetailsEarly = new HashMap(); /////////////////////////////////////////////////////////////////////////////////////////// @@ -266,7 +269,13 @@ public class PendingTradesViewModel extends ActivatableWithDataModel firstHalfOverWarnTextSupplier = () -> ""; private Supplier periodOverWarnTextSupplier = () -> ""; + private Supplier depositTxMissingWarnTextSupplier = () -> ""; TradeStepInfo(TitledGroupBg titledGroupBg, SimpleMarkdownLabel label, @@ -95,6 +97,10 @@ public class TradeStepInfo { this.periodOverWarnTextSupplier = periodOverWarnTextSupplier; } + public void setDepositTxMissingWarnTextSupplier(Supplier depositTxMissingWarnTextSupplier) { + this.depositTxMissingWarnTextSupplier = depositTxMissingWarnTextSupplier; + } + public void setState(State state) { this.state = state; switch (state) { @@ -192,12 +198,23 @@ public class TradeStepInfo { button.getStyleClass().remove("action-button"); button.setDisable(false); break; + case DEPOSIT_MISSING: + // red button + titledGroupBg.setText(Res.get("portfolio.pending.support.headline.depositTxMissing")); + label.updateContent(depositTxMissingWarnTextSupplier.get()); + button.setText(Res.get("portfolio.pending.openSupport").toUpperCase()); + button.setId("open-dispute-button"); + button.getStyleClass().remove("action-button"); + button.setDisable(false); + break; case TRADE_COMPLETED: // hide group titledGroupBg.setVisible(false); label.setVisible(false); button.setVisible(false); footerLabel.setVisible(false); + default: + break; } if (trade != null && trade.getPayoutTxId() != null) { diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/pendingtrades/steps/TradeStepView.java b/desktop/src/main/java/haveno/desktop/main/portfolio/pendingtrades/steps/TradeStepView.java index 779d86ea12..65d0781284 100644 --- a/desktop/src/main/java/haveno/desktop/main/portfolio/pendingtrades/steps/TradeStepView.java +++ b/desktop/src/main/java/haveno/desktop/main/portfolio/pendingtrades/steps/TradeStepView.java @@ -34,7 +34,6 @@ import haveno.core.trade.HavenoUtils; import haveno.core.trade.MakerTrade; import haveno.core.trade.TakerTrade; import haveno.core.trade.Trade; -import haveno.core.user.DontShowAgainLookup; import haveno.core.user.Preferences; import haveno.desktop.components.InfoTextField; import haveno.desktop.components.TitledGroupBg; @@ -50,8 +49,6 @@ import static haveno.desktop.util.FormBuilder.addTitledGroupBg; import static haveno.desktop.util.FormBuilder.addTopLabelTxIdTextField; import haveno.desktop.util.Layout; import haveno.network.p2p.BootstrapListener; -import java.time.Duration; -import java.time.Instant; import java.util.Optional; import javafx.application.Platform; import javafx.beans.property.BooleanProperty; @@ -80,7 +77,7 @@ public abstract class TradeStepView extends AnchorPane { protected final Preferences preferences; protected final GridPane gridPane; - private Subscription tradePeriodStateSubscription, disputeStateSubscription, mediationResultStateSubscription; + private Subscription tradePeriodStateSubscription, tradeStateSubscription, disputeStateSubscription, mediationResultStateSubscription; protected int gridRow = 0; private TextField timeLeftTextField; private ProgressBar timeLeftProgressBar; @@ -144,8 +141,10 @@ public abstract class TradeStepView extends AnchorPane { addContent(); errorMessageListener = (observable, oldValue, newValue) -> { - if (newValue != null) + if (newValue != null) { + log.warn("Showing popup for trade error {} {}", trade.getClass().getSimpleName(), trade.getId(), new RuntimeException(newValue)); new Popup().error(newValue).show(); + } }; clockListener = new ClockWatcher.Listener() { @@ -192,7 +191,7 @@ public abstract class TradeStepView extends AnchorPane { trade.errorMessageProperty().addListener(errorMessageListener); tradeStepInfo.setOnAction(e -> { - if (!isArbitrationOpenedState() && this.isTradePeriodOver()) { + if (!isArbitrationOpenedState() && (this.isTradePeriodOver() || trade.isDepositTxMissing())) { openSupportTicket(); } else { openChat(); @@ -258,15 +257,28 @@ public abstract class TradeStepView extends AnchorPane { } }); + if (trade.wasWalletPolled.get()) addTradeStateSubscription(); + else trade.wasWalletPolled.addListener((observable, oldValue, newValue) -> { + if (newValue) addTradeStateSubscription(); + }); + UserThread.execute(() -> model.p2PService.removeP2PServiceListener(bootstrapListener)); } + private void addTradeStateSubscription() { + tradeStateSubscription = EasyBind.subscribe(trade.stateProperty(), newValue -> { + if (newValue != null) { + UserThread.execute(() -> updateTradeState(newValue)); + } + }); + } + private void openSupportTicket() { - if (trade.getPhase().ordinal() < Trade.Phase.DEPOSITS_UNLOCKED.ordinal()) { - new Popup().warning(Res.get("portfolio.pending.error.depositTxNotConfirmed")).show(); - } else { + 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(); } } @@ -299,6 +311,8 @@ public abstract class TradeStepView extends AnchorPane { if (tradePeriodStateSubscription != null) tradePeriodStateSubscription.unsubscribe(); + if (tradeStateSubscription != null) + tradeStateSubscription.unsubscribe(); if (clockListener != null) model.clockWatcher.removeListener(clockListener); @@ -447,6 +461,7 @@ public abstract class TradeStepView extends AnchorPane { tradeStepInfo.setFirstHalfOverWarnTextSupplier(this::getFirstHalfOverWarnText); tradeStepInfo.setPeriodOverWarnTextSupplier(this::getPeriodOverWarnText); + tradeStepInfo.setDepositTxMissingWarnTextSupplier(this::getDepositTxMissingWarnText); } protected void hideTradeStepInfo() { @@ -466,6 +481,10 @@ public abstract class TradeStepView extends AnchorPane { return ""; } + protected String getDepositTxMissingWarnText() { + return Res.get("portfolio.pending.support.depositTxMissing"); + } + protected void applyOnDisputeOpened() { } @@ -782,34 +801,40 @@ public abstract class TradeStepView extends AnchorPane { } } -// private void checkIfLockTimeIsOver() { -// if (trade.getDisputeState() == Trade.DisputeState.MEDIATION_CLOSED) { -// Transaction delayedPayoutTx = trade.getDelayedPayoutTx(); -// if (delayedPayoutTx != null) { -// long lockTime = delayedPayoutTx.getLockTime(); -// int bestChainHeight = model.dataModel.btcWalletService.getBestChainHeight(); -// long remaining = lockTime - bestChainHeight; -// if (remaining <= 0) { -// openMediationResultPopup(Res.get("portfolio.pending.mediationResult.popup.headline", trade.getShortId())); -// } -// } -// } -// } - - protected void checkForUnconfirmedTimeout() { - if (trade.isDepositsConfirmed()) return; - long unconfirmedHours = Duration.between(trade.getDate().toInstant(), Instant.now()).toHours(); - if (unconfirmedHours >= 3 && !trade.hasFailed()) { - String key = "tradeUnconfirmedTooLong_" + trade.getShortId(); - if (DontShowAgainLookup.showAgain(key)) { - new Popup().warning(Res.get("portfolio.pending.unconfirmedTooLong", trade.getShortId(), unconfirmedHours)) - .dontShowAgainId(key) - .closeButtonText(Res.get("shared.ok")) - .show(); - } + private void updateTradeState(Trade.State tradeState) { + if (!trade.getDisputeState().isOpen() && trade.isDepositTxMissing()) { + tradeStepInfo.setState(TradeStepInfo.State.DEPOSIT_MISSING); } } + // private void checkIfLockTimeIsOver() { + // if (trade.getDisputeState() == Trade.DisputeState.MEDIATION_CLOSED) { + // Transaction delayedPayoutTx = trade.getDelayedPayoutTx(); + // if (delayedPayoutTx != null) { + // long lockTime = delayedPayoutTx.getLockTime(); + // int bestChainHeight = model.dataModel.btcWalletService.getBestChainHeight(); + // long remaining = lockTime - bestChainHeight; + // if (remaining <= 0) { + // openMediationResultPopup(Res.get("portfolio.pending.mediationResult.popup.headline", trade.getShortId())); + // } + // } + // } + // } + + // protected void checkForUnconfirmedTimeout() { + // if (trade.isDepositsConfirmed()) return; + // long unconfirmedHours = Duration.between(trade.getDate().toInstant(), Instant.now()).toHours(); + // if (unconfirmedHours >= 3 && !trade.hasFailed()) { + // String key = "tradeUnconfirmedTooLong_" + trade.getShortId(); + // if (DontShowAgainLookup.showAgain(key)) { + // new Popup().warning(Res.get("portfolio.pending.unconfirmedTooLong", trade.getShortId(), unconfirmedHours)) + // .dontShowAgainId(key) + // .closeButtonText(Res.get("shared.ok")) + // .show(); + // } + // } + // } + /////////////////////////////////////////////////////////////////////////////////////////// // TradeDurationLimitInfo /////////////////////////////////////////////////////////////////////////////////////////// diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/pendingtrades/steps/buyer/BuyerStep1View.java b/desktop/src/main/java/haveno/desktop/main/portfolio/pendingtrades/steps/buyer/BuyerStep1View.java index 4888d5b4a8..e1936d5cbc 100644 --- a/desktop/src/main/java/haveno/desktop/main/portfolio/pendingtrades/steps/buyer/BuyerStep1View.java +++ b/desktop/src/main/java/haveno/desktop/main/portfolio/pendingtrades/steps/buyer/BuyerStep1View.java @@ -36,7 +36,7 @@ public class BuyerStep1View extends TradeStepView { super.onPendingTradesInitialized(); //validatePayoutTx(); // TODO (woodser): no payout tx in xmr integration, do something else? //validateDepositInputs(); - checkForUnconfirmedTimeout(); + //checkForUnconfirmedTimeout(); } diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/pendingtrades/steps/buyer/BuyerStep2View.java b/desktop/src/main/java/haveno/desktop/main/portfolio/pendingtrades/steps/buyer/BuyerStep2View.java index 51a36488eb..30041c85df 100644 --- a/desktop/src/main/java/haveno/desktop/main/portfolio/pendingtrades/steps/buyer/BuyerStep2View.java +++ b/desktop/src/main/java/haveno/desktop/main/portfolio/pendingtrades/steps/buyer/BuyerStep2View.java @@ -108,9 +108,12 @@ import javafx.geometry.Insets; import javafx.scene.control.Button; import javafx.scene.control.Label; import javafx.scene.control.TextArea; +import javafx.scene.layout.ColumnConstraints; import javafx.scene.layout.GridPane; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; +import javafx.scene.text.Font; + import org.fxmisc.easybind.EasyBind; import org.fxmisc.easybind.Subscription; @@ -129,6 +132,9 @@ public class BuyerStep2View extends TradeStepView { private BusyAnimation busyAnimation; private Subscription tradeStatePropertySubscription; private Timer timeoutTimer; + private int paymentAccountGridRow = 0; + private GridPane paymentAccountGridPane; + private GridPane moreConfirmationsGridPane; /////////////////////////////////////////////////////////////////////////////////////////// // Constructor, Initialisation @@ -224,202 +230,216 @@ public class BuyerStep2View extends TradeStepView { gridPane.getColumnConstraints().get(1).setHgrow(Priority.ALWAYS); addTradeInfoBlock(); + createPaymentDetailsGridPane(); + createRecommendationGridPane(); + // attach grid pane based on current state + EasyBind.subscribe(trade.statePhaseProperty(), newValue -> { + if (trade.isDepositsFinalized() || trade.isPaymentSent() || model.getShowPaymentDetailsEarly()) { + attachPaymentDetailsGrid(); + } else { + attachRecommendationGrid(); + } + }); + } + private void createPaymentDetailsGridPane() { PaymentAccountPayload paymentAccountPayload = model.dataModel.getSellersPaymentAccountPayload(); String paymentMethodId = paymentAccountPayload != null ? paymentAccountPayload.getPaymentMethodId() : ""; - TitledGroupBg accountTitledGroupBg = addTitledGroupBg(gridPane, ++gridRow, 4, + + paymentAccountGridPane = createGridPane(); + TitledGroupBg accountTitledGroupBg = addTitledGroupBg(paymentAccountGridPane, paymentAccountGridRow, 4, Res.get("portfolio.pending.step2_buyer.startPaymentUsing", Res.get(paymentMethodId)), Layout.COMPACT_GROUP_DISTANCE); - TextFieldWithCopyIcon field = addTopLabelTextFieldWithCopyIcon(gridPane, gridRow, 0, + TextFieldWithCopyIcon field = addTopLabelTextFieldWithCopyIcon(paymentAccountGridPane, paymentAccountGridRow, 0, Res.get("portfolio.pending.step2_buyer.amountToTransfer"), model.getFiatVolume(), Layout.COMPACT_FIRST_ROW_AND_GROUP_DISTANCE).second; field.setCopyWithoutCurrencyPostFix(true); - //preland: this fixes a textarea layout glitch + //preland: this fixes a textarea layout glitch // TODO: can this be removed now? TextArea uiHack = new TextArea(); uiHack.setMaxHeight(1); GridPane.setRowIndex(uiHack, 1); GridPane.setMargin(uiHack, new Insets(0, 0, 0, 0)); uiHack.setVisible(false); - gridPane.getChildren().add(uiHack); + paymentAccountGridPane.getChildren().add(uiHack); switch (paymentMethodId) { case PaymentMethod.UPHOLD_ID: - gridRow = UpholdForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = UpholdForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.MONEY_BEAM_ID: - gridRow = MoneyBeamForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = MoneyBeamForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.POPMONEY_ID: - gridRow = PopmoneyForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = PopmoneyForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.REVOLUT_ID: - gridRow = RevolutForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = RevolutForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.PERFECT_MONEY_ID: - gridRow = PerfectMoneyForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = PerfectMoneyForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.SEPA_ID: - gridRow = SepaForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = SepaForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.SEPA_INSTANT_ID: - gridRow = SepaInstantForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = SepaInstantForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.FASTER_PAYMENTS_ID: - gridRow = FasterPaymentsForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = FasterPaymentsForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.NATIONAL_BANK_ID: - gridRow = NationalBankForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = NationalBankForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.AUSTRALIA_PAYID_ID: - gridRow = AustraliaPayidForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = AustraliaPayidForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.SAME_BANK_ID: - gridRow = SameBankForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = SameBankForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.SPECIFIC_BANKS_ID: - gridRow = SpecificBankForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = SpecificBankForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.SWISH_ID: - gridRow = SwishForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = SwishForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.ALI_PAY_ID: - gridRow = AliPayForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = AliPayForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.WECHAT_PAY_ID: - gridRow = WeChatPayForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = WeChatPayForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.ZELLE_ID: - gridRow = ZelleForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = ZelleForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.CHASE_QUICK_PAY_ID: - gridRow = ChaseQuickPayForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = ChaseQuickPayForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.INTERAC_E_TRANSFER_ID: - gridRow = InteracETransferForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = InteracETransferForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.JAPAN_BANK_ID: - gridRow = JapanBankTransferForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = JapanBankTransferForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.US_POSTAL_MONEY_ORDER_ID: - gridRow = USPostalMoneyOrderForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = USPostalMoneyOrderForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.CASH_DEPOSIT_ID: - gridRow = CashDepositForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = CashDepositForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.PAY_BY_MAIL_ID: - gridRow = PayByMailForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = PayByMailForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.CASH_AT_ATM_ID: - gridRow = CashAtAtmForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = CashAtAtmForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.MONEY_GRAM_ID: - gridRow = MoneyGramForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = MoneyGramForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.WESTERN_UNION_ID: - gridRow = WesternUnionForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = WesternUnionForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.HAL_CASH_ID: - gridRow = HalCashForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = HalCashForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.F2F_ID: checkNotNull(model.dataModel.getTrade(), "model.dataModel.getTrade() must not be null"); checkNotNull(model.dataModel.getTrade().getOffer(), "model.dataModel.getTrade().getOffer() must not be null"); - gridRow = F2FForm.addStep2Form(gridPane, gridRow, paymentAccountPayload, model.dataModel.getTrade().getOffer(), 0, true); + paymentAccountGridRow = F2FForm.addStep2Form(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload, model.dataModel.getTrade().getOffer(), 0, true); break; case PaymentMethod.BLOCK_CHAINS_ID: case PaymentMethod.BLOCK_CHAINS_INSTANT_ID: String labelTitle = Res.get("portfolio.pending.step2_buyer.sellersAddress", getCurrencyName(trade)); - gridRow = AssetsForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload, labelTitle); + paymentAccountGridRow = AssetsForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload, labelTitle); break; case PaymentMethod.PROMPT_PAY_ID: - gridRow = PromptPayForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = PromptPayForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.ADVANCED_CASH_ID: - gridRow = AdvancedCashForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = AdvancedCashForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.TRANSFERWISE_ID: - gridRow = TransferwiseForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = TransferwiseForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.TRANSFERWISE_USD_ID: - gridRow = TransferwiseUsdForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = TransferwiseUsdForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.PAYSERA_ID: - gridRow = PayseraForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = PayseraForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.PAXUM_ID: - gridRow = PaxumForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = PaxumForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.NEFT_ID: - gridRow = NeftForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = NeftForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.RTGS_ID: - gridRow = RtgsForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = RtgsForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.IMPS_ID: - gridRow = ImpsForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = ImpsForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.UPI_ID: - gridRow = UpiForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = UpiForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.PAYTM_ID: - gridRow = PaytmForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = PaytmForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.NEQUI_ID: - gridRow = NequiForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = NequiForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.BIZUM_ID: - gridRow = BizumForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = BizumForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.PIX_ID: - gridRow = PixForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = PixForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.AMAZON_GIFT_CARD_ID: - gridRow = AmazonGiftCardForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = AmazonGiftCardForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.CAPITUAL_ID: - gridRow = CapitualForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = CapitualForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.CELPAY_ID: - gridRow = CelPayForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = CelPayForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.MONESE_ID: - gridRow = MoneseForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = MoneseForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.SATISPAY_ID: - gridRow = SatispayForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = SatispayForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.TIKKIE_ID: - gridRow = TikkieForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = TikkieForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.VERSE_ID: - gridRow = VerseForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = VerseForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.STRIKE_ID: - gridRow = StrikeForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = StrikeForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.SWIFT_ID: - gridRow = SwiftForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload, trade); + paymentAccountGridRow = SwiftForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload, trade); break; case PaymentMethod.ACH_TRANSFER_ID: - gridRow = AchTransferForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = AchTransferForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.DOMESTIC_WIRE_TRANSFER_ID: - gridRow = DomesticWireTransferForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = DomesticWireTransferForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.CASH_APP_ID: - gridRow = CashAppForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = CashAppForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.PAYPAL_ID: - gridRow = PayPalForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = PayPalForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.VENMO_ID: - gridRow = VenmoForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = VenmoForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; case PaymentMethod.PAYSAFE_ID: - gridRow = PaysafeForm.addFormForBuyer(gridPane, gridRow, paymentAccountPayload); + paymentAccountGridRow = PaysafeForm.addFormForBuyer(paymentAccountGridPane, paymentAccountGridRow, paymentAccountPayload); break; default: log.error("Not supported PaymentMethod: " + paymentMethodId); @@ -438,19 +458,19 @@ public class BuyerStep2View extends TradeStepView { .findFirst() .ifPresent(paymentAccount -> { String accountName = paymentAccount.getAccountName(); - addCompactTopLabelTextFieldWithCopyIcon(gridPane, ++gridRow, 0, + addCompactTopLabelTextFieldWithCopyIcon(paymentAccountGridPane, ++paymentAccountGridRow, 0, Res.get("portfolio.pending.step2_buyer.buyerAccount"), accountName); }); } } - GridPane.setRowSpan(accountTitledGroupBg, gridRow - 1); + GridPane.setRowSpan(accountTitledGroupBg, gridRow + paymentAccountGridRow - 1); - Tuple4 tuple3 = addButtonBusyAnimationLabel(gridPane, ++gridRow, 0, + Tuple4 tuple3 = addButtonBusyAnimationLabel(paymentAccountGridPane, ++paymentAccountGridRow, 0, Res.get("portfolio.pending.step2_buyer.paymentSent"), 10); - HBox hBox = tuple3.fourth; - GridPane.setColumnSpan(hBox, 2); + HBox confirmButtonHBox = tuple3.fourth; + GridPane.setColumnSpan(confirmButtonHBox, 2); confirmButton = tuple3.first; confirmButton.setDisable(!confirmPaymentSentPermitted()); confirmButton.setOnAction(e -> onPaymentSent()); @@ -458,6 +478,64 @@ public class BuyerStep2View extends TradeStepView { statusLabel = tuple3.third; } + private void createRecommendationGridPane() { + + // create grid pane to show recommendation for more blocks + moreConfirmationsGridPane = new GridPane(); + moreConfirmationsGridPane.setStyle("-fx-background-color: -bs-content-background-gray;"); + moreConfirmationsGridPane.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE); + + // add title + addTitledGroupBg(moreConfirmationsGridPane, 0, 1, Res.get("portfolio.pending.step1.waitForConf"), Layout.COMPACT_GROUP_DISTANCE); + + // add text + Label label = new Label(Res.get("portfolio.pending.step2_buyer.additionalConf", Trade.NUM_BLOCKS_DEPOSITS_FINALIZED)); + label.setFont(new Font(16)); + GridPane.setMargin(label, new Insets(20, 0, 0, 0)); + moreConfirmationsGridPane.add(label, 0, 1, 2, 1); + + // add button to show payment details + Button showPaymentDetailsButton = new Button("Show payment details early"); + showPaymentDetailsButton.getStyleClass().add("action-button"); + GridPane.setMargin(showPaymentDetailsButton, new Insets(20, 0, 0, 0)); + showPaymentDetailsButton.setOnAction(e -> { + model.setShowPaymentDetailsEarly(true); + gridPane.getChildren().remove(moreConfirmationsGridPane); + gridPane.getChildren().add(paymentAccountGridPane); + GridPane.setRowIndex(paymentAccountGridPane, gridRow + 1); + GridPane.setColumnSpan(paymentAccountGridPane, 2); + }); + moreConfirmationsGridPane.add(showPaymentDetailsButton, 0, 2); + } + + private GridPane createGridPane() { + GridPane gridPane = new GridPane(); + gridPane.setHgap(Layout.GRID_GAP); + gridPane.setVgap(Layout.GRID_GAP); + ColumnConstraints columnConstraints1 = new ColumnConstraints(); + columnConstraints1.setHgrow(Priority.ALWAYS); + ColumnConstraints columnConstraints2 = new ColumnConstraints(); + columnConstraints2.setHgrow(Priority.ALWAYS); + gridPane.getColumnConstraints().addAll(columnConstraints1, columnConstraints2); + return gridPane; + } + + private void attachRecommendationGrid() { + if (gridPane.getChildren().contains(moreConfirmationsGridPane)) return; + if (gridPane.getChildren().contains(paymentAccountGridPane)) gridPane.getChildren().remove(paymentAccountGridPane); + gridPane.getChildren().add(moreConfirmationsGridPane); + GridPane.setRowIndex(moreConfirmationsGridPane, gridRow + 1); + GridPane.setColumnSpan(moreConfirmationsGridPane, 2); + } + + private void attachPaymentDetailsGrid() { + if (gridPane.getChildren().contains(paymentAccountGridPane)) return; + if (gridPane.getChildren().contains(moreConfirmationsGridPane)) gridPane.getChildren().remove(moreConfirmationsGridPane); + gridPane.getChildren().add(paymentAccountGridPane); + GridPane.setRowIndex(paymentAccountGridPane, gridRow + 1); + GridPane.setColumnSpan(paymentAccountGridPane, 2); + } + private boolean confirmPaymentSentPermitted() { if (!trade.confirmPermitted()) return false; if (trade.getState() == Trade.State.BUYER_SEND_FAILED_PAYMENT_SENT_MSG) return true; diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/pendingtrades/steps/seller/SellerStep1View.java b/desktop/src/main/java/haveno/desktop/main/portfolio/pendingtrades/steps/seller/SellerStep1View.java index 2ca41f1d42..9c23250888 100644 --- a/desktop/src/main/java/haveno/desktop/main/portfolio/pendingtrades/steps/seller/SellerStep1View.java +++ b/desktop/src/main/java/haveno/desktop/main/portfolio/pendingtrades/steps/seller/SellerStep1View.java @@ -34,7 +34,7 @@ public class SellerStep1View extends TradeStepView { @Override protected void onPendingTradesInitialized() { super.onPendingTradesInitialized(); - checkForUnconfirmedTimeout(); + //checkForUnconfirmedTimeout(); } /////////////////////////////////////////////////////////////////////////////////////////// diff --git a/proto/src/main/proto/grpc.proto b/proto/src/main/proto/grpc.proto index 882e0a0623..aa44587adf 100644 --- a/proto/src/main/proto/grpc.proto +++ b/proto/src/main/proto/grpc.proto @@ -896,11 +896,13 @@ message TradeInfo { bool is_deposits_published = 25; bool is_deposits_confirmed = 26; bool is_deposits_unlocked = 27; + bool is_deposits_finalized = 43; bool is_payment_sent = 28; bool is_payment_received = 29; bool is_payout_published = 30; bool is_payout_confirmed = 31; bool is_payout_unlocked = 32; + bool is_payout_finalized = 44; bool is_completed = 33; string contract_as_json = 34; ContractInfo contract = 35; diff --git a/proto/src/main/proto/pb.proto b/proto/src/main/proto/pb.proto index 647272b261..dd232fa647 100644 --- a/proto/src/main/proto/pb.proto +++ b/proto/src/main/proto/pb.proto @@ -337,6 +337,7 @@ message PaymentReceivedMessage { SignedWitness buyer_signed_witness = 9; PaymentSentMessage payment_sent_message = 10; bytes seller_signature = 11; + string payout_tx_id = 12; } message MediatedPayoutTxPublishedMessage { @@ -1456,6 +1457,7 @@ message Trade { DEPOSIT_TXS_SEEN_IN_NETWORK = 13; DEPOSIT_TXS_CONFIRMED_IN_BLOCKCHAIN = 14; DEPOSIT_TXS_UNLOCKED_IN_BLOCKCHAIN = 15; + DEPOSIT_TXS_FINALIZED_IN_BLOCKCHAIN = 28; BUYER_CONFIRMED_PAYMENT_SENT = 16; BUYER_SENT_PAYMENT_SENT_MSG = 17; BUYER_SEND_FAILED_PAYMENT_SENT_MSG = 18; @@ -1477,6 +1479,7 @@ message Trade { DEPOSITS_PUBLISHED = 3; DEPOSITS_CONFIRMED = 4; DEPOSITS_UNLOCKED = 5; + DEPOSITS_FINALIZED = 8; PAYMENT_SENT = 6; PAYMENT_RECEIVED = 7; } @@ -1486,6 +1489,7 @@ message Trade { PAYOUT_PUBLISHED = 1; PAYOUT_CONFIRMED = 2; PAYOUT_UNLOCKED = 3; + PAYOUT_FINALIZED = 4; } enum DisputeState { @@ -1585,6 +1589,8 @@ message ProcessModel { int64 trade_protocol_error_height = 18; string trade_fee_address = 19; bool import_multisig_hex_scheduled = 20; + bool payment_sent_payout_tx_stale = 21; + bool error_on_payment_received_msg = 22 [deprecated = true]; // used during debugging across clients (can be repurposed) } message TradePeer {