From 15d2c24a82b4dbda7c5f78638823a722a826a822 Mon Sep 17 00:00:00 2001 From: woodser Date: Thu, 2 Feb 2023 15:16:14 -0500 Subject: [PATCH] reprocess payout messages on error to improve resilience reprocess on curved schedule, restart, or connection change invalid messages are nacked using IllegalArgumentException disputes are considered open by ack on chat message don't show trade completion screen until payout published cannot confirm payment sent/received while disconnected from monerod add operation manual w/ instructions to manually open dispute close account before deletion fix popup with error "still unconfirmed after X hours" for arbitrator misc refactoring and cleanup --- .../method/trade/AbstractTradeTest.java | 6 +- .../bisq/apitest/scenario/bot/BotClient.java | 2 +- .../scenario/bot/protocol/BotProtocol.java | 4 +- .../builder/TradeDetailTableBuilder.java | 4 +- .../bisq/core/api/CoreAccountService.java | 2 +- .../api/CoreMoneroConnectionsService.java | 9 +- .../java/bisq/core/api/model/TradeInfo.java | 36 +- .../api/model/builder/TradeInfoV1Builder.java | 18 +- .../main/java/bisq/core/app/HavenoSetup.java | 2 +- .../src/main/java/bisq/core/btc/Balances.java | 14 +- .../core/btc/wallet/XmrWalletService.java | 30 +- .../bisq/core/offer/OpenOfferManager.java | 2 +- .../tasks/MakerReserveOfferFunds.java | 3 + .../bisq/core/support/SupportManager.java | 22 +- .../core/support/dispute/DisputeManager.java | 187 ++++----- .../dispute/DisputeSummaryVerification.java | 2 +- .../arbitration/ArbitrationManager.java | 369 +++++++++++------- .../support/traderchat/TraderChatManager.java | 4 +- .../java/bisq/core/trade/HavenoUtils.java | 12 +- core/src/main/java/bisq/core/trade/Trade.java | 207 ++++++---- .../java/bisq/core/trade/TradeManager.java | 3 +- .../messages/PaymentReceivedMessage.java | 4 +- .../trade/messages/SignContractRequest.java | 2 - .../core/trade/protocol/BuyerProtocol.java | 2 +- .../core/trade/protocol/ProcessModel.java | 23 +- .../core/trade/protocol/SellerProtocol.java | 2 +- .../core/trade/protocol/TradeProtocol.java | 46 ++- .../bisq/core/trade/protocol/TradingPeer.java | 6 - .../tasks/BuyerPreparePaymentSentMessage.java | 9 +- .../tasks/BuyerSendPaymentSentMessage.java | 7 +- .../tasks/ProcessPaymentReceivedMessage.java | 44 ++- .../tasks/ProcessPaymentSentMessage.java | 6 +- .../SellerPreparePaymentReceivedMessage.java | 51 ++- .../SellerSendPaymentReceivedMessage.java | 6 +- ...llerSendPaymentReceivedMessageToBuyer.java | 11 + .../resources/i18n/displayStrings.properties | 2 + .../overlays/windows/OfferDetailsWindow.java | 4 +- .../overlays/windows/TradeDetailsWindow.java | 9 +- .../pendingtrades/PendingTradesViewModel.java | 21 +- .../pendingtrades/steps/TradeStepView.java | 5 +- .../steps/buyer/BuyerStep1View.java | 2 +- .../steps/buyer/BuyerStep2View.java | 7 +- .../steps/seller/SellerStep1View.java | 2 +- .../steps/seller/SellerStep3View.java | 10 +- .../main/support/dispute/DisputeView.java | 48 ++- .../main/java/bisq/desktop/util/GUIUtil.java | 9 +- docs/operation_manual.md | 14 + proto/src/main/proto/grpc.proto | 6 +- proto/src/main/proto/pb.proto | 16 +- 49 files changed, 841 insertions(+), 471 deletions(-) create mode 100644 docs/operation_manual.md diff --git a/apitest/src/test/java/bisq/apitest/method/trade/AbstractTradeTest.java b/apitest/src/test/java/bisq/apitest/method/trade/AbstractTradeTest.java index cfa651dce0..da11cd1c47 100644 --- a/apitest/src/test/java/bisq/apitest/method/trade/AbstractTradeTest.java +++ b/apitest/src/test/java/bisq/apitest/method/trade/AbstractTradeTest.java @@ -109,7 +109,7 @@ public class AbstractTradeTest extends AbstractOfferTest { } protected final void verifyTakerDepositConfirmed(TradeInfo trade) { - if (!trade.getIsDepositUnlocked()) { + if (!trade.getIsDepositsUnlocked()) { fail(format("INVALID_PHASE for trade %s in STATE=%s PHASE=%s, deposit tx never unlocked.", trade.getShortId(), trade.getState(), @@ -182,9 +182,9 @@ public class AbstractTradeTest extends AbstractOfferTest { assertEquals(EXPECTED_PROTOCOL_STATUS.phase.name(), trade.getPhase()); if (!isLongRunningTest) - assertEquals(EXPECTED_PROTOCOL_STATUS.isDepositPublished, trade.getIsDepositPublished()); + assertEquals(EXPECTED_PROTOCOL_STATUS.isDepositPublished, trade.getIsDepositsPublished()); - assertEquals(EXPECTED_PROTOCOL_STATUS.isDepositConfirmed, trade.getIsDepositUnlocked()); + assertEquals(EXPECTED_PROTOCOL_STATUS.isDepositConfirmed, trade.getIsDepositsUnlocked()); assertEquals(EXPECTED_PROTOCOL_STATUS.isPaymentStartedMessageSent, trade.getIsPaymentSent()); assertEquals(EXPECTED_PROTOCOL_STATUS.isPaymentReceivedMessageSent, trade.getIsPaymentReceived()); assertEquals(EXPECTED_PROTOCOL_STATUS.isPayoutPublished, trade.getIsPayoutPublished()); diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/BotClient.java b/apitest/src/test/java/bisq/apitest/scenario/bot/BotClient.java index 6049f4b8db..8d29a2c0de 100644 --- a/apitest/src/test/java/bisq/apitest/scenario/bot/BotClient.java +++ b/apitest/src/test/java/bisq/apitest/scenario/bot/BotClient.java @@ -231,7 +231,7 @@ public class BotClient { * @return boolean */ public boolean isTakerDepositFeeTxConfirmed(String tradeId) { - return grpcClient.getTrade(tradeId).getIsDepositUnlocked(); + return grpcClient.getTrade(tradeId).getIsDepositsUnlocked(); } /** diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/BotProtocol.java b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/BotProtocol.java index 566e687394..50e9cfb525 100644 --- a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/BotProtocol.java +++ b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/BotProtocol.java @@ -301,10 +301,10 @@ public abstract class BotProtocol { } private final Predicate isDepositFeeTxStepComplete = (trade) -> { - if (currentProtocolStep.equals(WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED) && trade.getIsDepositPublished()) { + if (currentProtocolStep.equals(WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED) && trade.getIsDepositsPublished()) { log.info("Taker deposit fee tx {} has been published.", trade.getTakerDepositTxId()); return true; - } else if (currentProtocolStep.equals(WAIT_FOR_TAKER_DEPOSIT_TX_CONFIRMED) && trade.getIsDepositUnlocked()) { + } else if (currentProtocolStep.equals(WAIT_FOR_TAKER_DEPOSIT_TX_CONFIRMED) && trade.getIsDepositsUnlocked()) { log.info("Taker deposit fee tx {} has been confirmed.", trade.getTakerDepositTxId()); return true; } else { diff --git a/cli/src/main/java/bisq/cli/table/builder/TradeDetailTableBuilder.java b/cli/src/main/java/bisq/cli/table/builder/TradeDetailTableBuilder.java index 287ed6686c..e0040453c5 100644 --- a/cli/src/main/java/bisq/cli/table/builder/TradeDetailTableBuilder.java +++ b/cli/src/main/java/bisq/cli/table/builder/TradeDetailTableBuilder.java @@ -65,8 +65,8 @@ class TradeDetailTableBuilder extends AbstractTradeListBuilder { colAmount.addRow(toTradeAmount.apply(trade)); colMinerTxFee.addRow(toMyMinerTxFee.apply(trade)); colBisqTradeFee.addRow(toMyMakerOrTakerFee.apply(trade)); - colIsDepositPublished.addRow(trade.getIsDepositPublished()); - colIsDepositConfirmed.addRow(trade.getIsDepositUnlocked()); + colIsDepositPublished.addRow(trade.getIsDepositsPublished()); + colIsDepositConfirmed.addRow(trade.getIsDepositsUnlocked()); colTradeCost.addRow(toTradeVolumeAsString.apply(trade)); colIsPaymentStartedMessageSent.addRow(trade.getIsPaymentSent()); colIsPaymentReceivedMessageSent.addRow(trade.getIsPaymentReceived()); diff --git a/core/src/main/java/bisq/core/api/CoreAccountService.java b/core/src/main/java/bisq/core/api/CoreAccountService.java index 3133ce31a3..f8cce7cb14 100644 --- a/core/src/main/java/bisq/core/api/CoreAccountService.java +++ b/core/src/main/java/bisq/core/api/CoreAccountService.java @@ -164,7 +164,7 @@ public class CoreAccountService { public void deleteAccount(Runnable onShutdown) { try { - keyRing.lockKeys(); + if (isAccountOpen()) closeAccount(); synchronized (listeners) { for (AccountServiceListener listener : listeners) listener.onAccountDeleted(onShutdown); } diff --git a/core/src/main/java/bisq/core/api/CoreMoneroConnectionsService.java b/core/src/main/java/bisq/core/api/CoreMoneroConnectionsService.java index 8c3ecaf5ce..4b1825115c 100644 --- a/core/src/main/java/bisq/core/api/CoreMoneroConnectionsService.java +++ b/core/src/main/java/bisq/core/api/CoreMoneroConnectionsService.java @@ -254,9 +254,12 @@ public final class CoreMoneroConnectionsService { } } - // ----------------------------- APP METHODS ------------------------------ + public void verifyConnection() { + if (daemon == null) throw new RuntimeException("No connection to Monero node"); + if (!isSyncedWithinTolerance()) throw new RuntimeException("Monero node is not synced"); + } - public boolean isChainHeightSyncedWithinTolerance() { + public boolean isSyncedWithinTolerance() { if (daemon == null) return false; Long targetHeight = lastInfo.getTargetHeight(); // the last time the node thought it was behind the network and was in active sync mode to catch up if (targetHeight == 0) return true; // monero-daemon-rpc sync_info's target_height returns 0 when node is fully synced @@ -268,6 +271,8 @@ public final class CoreMoneroConnectionsService { return false; } + // ----------------------------- APP METHODS ------------------------------ + public ReadOnlyIntegerProperty numPeersProperty() { return numPeers; } diff --git a/core/src/main/java/bisq/core/api/model/TradeInfo.java b/core/src/main/java/bisq/core/api/model/TradeInfo.java index 991d432f06..f882bb17eb 100644 --- a/core/src/main/java/bisq/core/api/model/TradeInfo.java +++ b/core/src/main/java/bisq/core/api/model/TradeInfo.java @@ -82,9 +82,9 @@ public class TradeInfo implements Payload { private final String periodState; private final String payoutState; private final String disputeState; - private final boolean isDepositPublished; - private final boolean isDepositConfirmed; - private final boolean isDepositUnlocked; + private final boolean isDepositsPublished; + private final boolean isDepositsConfirmed; + private final boolean isDepositsUnlocked; private final boolean isPaymentSent; private final boolean isPaymentReceived; private final boolean isPayoutPublished; @@ -117,9 +117,9 @@ public class TradeInfo implements Payload { this.periodState = builder.getPeriodState(); this.payoutState = builder.getPayoutState(); this.disputeState = builder.getDisputeState(); - this.isDepositPublished = builder.isDepositPublished(); - this.isDepositConfirmed = builder.isDepositConfirmed(); - this.isDepositUnlocked = builder.isDepositUnlocked(); + this.isDepositsPublished = builder.isDepositsPublished(); + this.isDepositsConfirmed = builder.isDepositsConfirmed(); + this.isDepositsUnlocked = builder.isDepositsUnlocked(); this.isPaymentSent = builder.isPaymentSent(); this.isPaymentReceived = builder.isPaymentReceived(); this.isPayoutPublished = builder.isPayoutPublished(); @@ -175,9 +175,9 @@ public class TradeInfo implements Payload { .withPeriodState(trade.getPeriodState().name()) .withPayoutState(trade.getPayoutState().name()) .withDisputeState(trade.getDisputeState().name()) - .withIsDepositPublished(trade.isDepositPublished()) - .withIsDepositConfirmed(trade.isDepositConfirmed()) - .withIsDepositUnlocked(trade.isDepositUnlocked()) + .withIsDepositsPublished(trade.isDepositsPublished()) + .withIsDepositsConfirmed(trade.isDepositsConfirmed()) + .withIsDepositsUnlocked(trade.isDepositsUnlocked()) .withIsPaymentSent(trade.isPaymentSent()) .withIsPaymentReceived(trade.isPaymentReceived()) .withIsPayoutPublished(trade.isPayoutPublished()) @@ -219,9 +219,9 @@ public class TradeInfo implements Payload { .setPeriodState(periodState) .setPayoutState(payoutState) .setDisputeState(disputeState) - .setIsDepositPublished(isDepositPublished) - .setIsDepositConfirmed(isDepositConfirmed) - .setIsDepositUnlocked(isDepositUnlocked) + .setIsDepositsPublished(isDepositsPublished) + .setIsDepositsConfirmed(isDepositsConfirmed) + .setIsDepositsUnlocked(isDepositsUnlocked) .setIsPaymentSent(isPaymentSent) .setIsPaymentReceived(isPaymentReceived) .setIsCompleted(isCompleted) @@ -257,9 +257,9 @@ public class TradeInfo implements Payload { .withPhase(proto.getPhase()) .withArbitratorNodeAddress(proto.getArbitratorNodeAddress()) .withTradingPeerNodeAddress(proto.getTradingPeerNodeAddress()) - .withIsDepositPublished(proto.getIsDepositPublished()) - .withIsDepositConfirmed(proto.getIsDepositConfirmed()) - .withIsDepositUnlocked(proto.getIsDepositUnlocked()) + .withIsDepositsPublished(proto.getIsDepositsPublished()) + .withIsDepositsConfirmed(proto.getIsDepositsConfirmed()) + .withIsDepositsUnlocked(proto.getIsDepositsUnlocked()) .withIsPaymentSent(proto.getIsPaymentSent()) .withIsPaymentReceived(proto.getIsPaymentReceived()) .withIsCompleted(proto.getIsCompleted()) @@ -294,9 +294,9 @@ public class TradeInfo implements Payload { ", periodState='" + periodState + '\'' + "\n" + ", payoutState='" + payoutState + '\'' + "\n" + ", disputeState='" + disputeState + '\'' + "\n" + - ", isDepositPublished=" + isDepositPublished + "\n" + - ", isDepositConfirmed=" + isDepositConfirmed + "\n" + - ", isDepositUnlocked=" + isDepositUnlocked + "\n" + + ", isDepositsPublished=" + isDepositsPublished + "\n" + + ", isDepositsConfirmed=" + isDepositsConfirmed + "\n" + + ", isDepositsUnlocked=" + isDepositsUnlocked + "\n" + ", isPaymentSent=" + isPaymentSent + "\n" + ", isPaymentReceived=" + isPaymentReceived + "\n" + ", isPayoutPublished=" + isPayoutPublished + "\n" + diff --git a/core/src/main/java/bisq/core/api/model/builder/TradeInfoV1Builder.java b/core/src/main/java/bisq/core/api/model/builder/TradeInfoV1Builder.java index 7de5608748..796a69f97b 100644 --- a/core/src/main/java/bisq/core/api/model/builder/TradeInfoV1Builder.java +++ b/core/src/main/java/bisq/core/api/model/builder/TradeInfoV1Builder.java @@ -55,9 +55,9 @@ public final class TradeInfoV1Builder { private String periodState; private String payoutState; private String disputeState; - private boolean isDepositPublished; - private boolean isDepositConfirmed; - private boolean isDepositUnlocked; + private boolean isDepositsPublished; + private boolean isDepositsConfirmed; + private boolean isDepositsUnlocked; private boolean isPaymentSent; private boolean isPaymentReceived; private boolean isPayoutPublished; @@ -183,18 +183,18 @@ public final class TradeInfoV1Builder { return this; } - public TradeInfoV1Builder withIsDepositPublished(boolean isDepositPublished) { - this.isDepositPublished = isDepositPublished; + public TradeInfoV1Builder withIsDepositsPublished(boolean isDepositsPublished) { + this.isDepositsPublished = isDepositsPublished; return this; } - public TradeInfoV1Builder withIsDepositConfirmed(boolean isDepositConfirmed) { - this.isDepositConfirmed = isDepositConfirmed; + public TradeInfoV1Builder withIsDepositsConfirmed(boolean isDepositsConfirmed) { + this.isDepositsConfirmed = isDepositsConfirmed; return this; } - public TradeInfoV1Builder withIsDepositUnlocked(boolean isDepositUnlocked) { - this.isDepositUnlocked = isDepositUnlocked; + public TradeInfoV1Builder withIsDepositsUnlocked(boolean isDepositsUnlocked) { + this.isDepositsUnlocked = isDepositsUnlocked; return this; } diff --git a/core/src/main/java/bisq/core/app/HavenoSetup.java b/core/src/main/java/bisq/core/app/HavenoSetup.java index 0904c10df1..3bcdcb3d17 100644 --- a/core/src/main/java/bisq/core/app/HavenoSetup.java +++ b/core/src/main/java/bisq/core/app/HavenoSetup.java @@ -500,7 +500,7 @@ public class HavenoSetup { revolutAccountsUpdateHandler, amazonGiftCardAccountsUpdateHandler); - if (walletsSetup.downloadPercentageProperty().get() == 1) { + if (walletsSetup.downloadPercentageProperty().get() == 1) { // TODO: update for XMR checkForLockedUpFunds(); checkForInvalidMakerFeeTxs(); } diff --git a/core/src/main/java/bisq/core/btc/Balances.java b/core/src/main/java/bisq/core/btc/Balances.java index 5e7f75319a..f52c41a3c4 100644 --- a/core/src/main/java/bisq/core/btc/Balances.java +++ b/core/src/main/java/bisq/core/btc/Balances.java @@ -91,11 +91,15 @@ public class Balances { private void updatedBalances() { if (!xmrWalletService.isWalletReady()) return; - updateAvailableBalance(); - updatePendingBalance(); - updateReservedOfferBalance(); - updateReservedTradeBalance(); - updateReservedBalance(); + try { + updateAvailableBalance(); + updatePendingBalance(); + updateReservedOfferBalance(); + updateReservedTradeBalance(); + updateReservedBalance(); + } catch (Exception e) { + if (xmrWalletService.isWalletReady()) throw e; // ignore exception if wallet isn't ready + } } // TODO (woodser): converting to long should generally be avoided since can lose precision, but in practice these amounts are below max value diff --git a/core/src/main/java/bisq/core/btc/wallet/XmrWalletService.java b/core/src/main/java/bisq/core/btc/wallet/XmrWalletService.java index 0f0067f26a..1b61cda04f 100644 --- a/core/src/main/java/bisq/core/btc/wallet/XmrWalletService.java +++ b/core/src/main/java/bisq/core/btc/wallet/XmrWalletService.java @@ -491,6 +491,7 @@ public class XmrWalletService { synchronized (txCache) { // fetch txs + if (getDaemon() == null) connectionsService.verifyConnection(); // will throw List txs = getDaemon().getTxs(txHashes, true); // store to cache @@ -549,6 +550,7 @@ public class XmrWalletService { } private void maybeInitMainWallet() { + if (wallet != null) throw new RuntimeException("Main wallet is already initialized"); // open or create wallet MoneroWalletConfig walletConfig = new MoneroWalletConfig().setPath(MONERO_WALLET_NAME).setPassword(getWalletPassword()); @@ -560,14 +562,9 @@ public class XmrWalletService { // wallet is not initialized until connected to a daemon if (wallet != null) { - try { - wallet.sync(); // blocking - wallet.startSyncing(connectionsService.getDefaultRefreshPeriodMs()); // start syncing wallet in background - connectionsService.doneDownload(); // TODO: using this to signify both daemon and wallet synced, refactor sync handling of both - saveMainWallet(false); // skip backup on open - } catch (Exception e) { - e.printStackTrace(); - } + + // sync wallet which updates app startup state + trySyncMainWallet(); if (connectionsService.getDaemon() == null) System.out.println("Daemon: null"); else { @@ -671,12 +668,25 @@ public class XmrWalletService { return MONERO_WALLET_RPC_MANAGER.startInstance(cmd); } + private void trySyncMainWallet() { + try { + log.info("Syncing main wallet"); + wallet.startSyncing(connectionsService.getDefaultRefreshPeriodMs()); // start syncing wallet in background + wallet.sync(); // blocking + connectionsService.doneDownload(); // TODO: using this to signify both daemon and wallet synced, refactor sync handling of both + log.info("Done syncing main wallet"); + saveMainWallet(false); // skip backup on open + } catch (Exception e) { + log.warn("Error syncing main wallet: {}", e.getMessage()); + } + } + private void setDaemonConnection(MoneroRpcConnection connection) { log.info("Setting wallet daemon connection: " + (connection == null ? null : connection.getUri())); if (wallet == null) maybeInitMainWallet(); - if (wallet != null) { + else { wallet.setDaemonConnection(connection); - wallet.startSyncing(connectionsService.getDefaultRefreshPeriodMs()); + if (connection != null) new Thread(() -> trySyncMainWallet()).start(); } } diff --git a/core/src/main/java/bisq/core/offer/OpenOfferManager.java b/core/src/main/java/bisq/core/offer/OpenOfferManager.java index dd8f814a6f..30999be8fd 100644 --- a/core/src/main/java/bisq/core/offer/OpenOfferManager.java +++ b/core/src/main/java/bisq/core/offer/OpenOfferManager.java @@ -1008,7 +1008,7 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe } // Don't allow trade start if Monero node is not fully synced - if (!connectionService.isChainHeightSyncedWithinTolerance()) { + if (!connectionService.isSyncedWithinTolerance()) { errorMessage = "We got a handleOfferAvailabilityRequest but our chain is not synced."; log.info(errorMessage); sendAckMessage(request.getClass(), peer, request.getPubKeyRing(), request.getOfferId(), request.getUid(), false, errorMessage); diff --git a/core/src/main/java/bisq/core/offer/placeoffer/tasks/MakerReserveOfferFunds.java b/core/src/main/java/bisq/core/offer/placeoffer/tasks/MakerReserveOfferFunds.java index 625ada36d5..8a9d611cce 100644 --- a/core/src/main/java/bisq/core/offer/placeoffer/tasks/MakerReserveOfferFunds.java +++ b/core/src/main/java/bisq/core/offer/placeoffer/tasks/MakerReserveOfferFunds.java @@ -50,6 +50,9 @@ public class MakerReserveOfferFunds extends Task { try { runInterceptHook(); + // verify monero connection + model.getXmrWalletService().getConnectionsService().verifyConnection(); + // create reserve tx BigInteger makerFee = HavenoUtils.coinToAtomicUnits(offer.getMakerFee()); BigInteger sendAmount = HavenoUtils.coinToAtomicUnits(offer.getDirection() == OfferDirection.BUY ? Coin.ZERO : offer.getAmount()); diff --git a/core/src/main/java/bisq/core/support/SupportManager.java b/core/src/main/java/bisq/core/support/SupportManager.java index b564aa35f4..284d797e8a 100644 --- a/core/src/main/java/bisq/core/support/SupportManager.java +++ b/core/src/main/java/bisq/core/support/SupportManager.java @@ -20,8 +20,11 @@ package bisq.core.support; import bisq.core.api.CoreMoneroConnectionsService; import bisq.core.api.CoreNotificationService; import bisq.core.locale.Res; +import bisq.core.support.dispute.Dispute; import bisq.core.support.messages.ChatMessage; import bisq.core.support.messages.SupportMessage; +import bisq.core.trade.Trade; +import bisq.core.trade.TradeManager; import bisq.core.trade.protocol.TradeProtocol; import bisq.core.trade.protocol.TradeProtocol.MailboxMessageComparator; import bisq.network.p2p.AckMessage; @@ -51,6 +54,7 @@ import javax.annotation.Nullable; @Slf4j public abstract class SupportManager { protected final P2PService p2PService; + protected final TradeManager tradeManager; protected final CoreMoneroConnectionsService connectionService; protected final CoreNotificationService notificationService; protected final Map delayMsgMap = new HashMap<>(); @@ -65,11 +69,15 @@ public abstract class SupportManager { // Constructor /////////////////////////////////////////////////////////////////////////////////////////// - public SupportManager(P2PService p2PService, CoreMoneroConnectionsService connectionService, CoreNotificationService notificationService) { + public SupportManager(P2PService p2PService, + CoreMoneroConnectionsService connectionService, + CoreNotificationService notificationService, + TradeManager tradeManager) { this.p2PService = p2PService; this.connectionService = connectionService; this.mailboxMessageService = p2PService.getMailboxMessageService(); this.notificationService = notificationService; + this.tradeManager = tradeManager; // We get first the message handler called then the onBootstrapped p2PService.addDecryptedDirectMessageListener((decryptedMessageWithPubKey, senderAddress) -> { @@ -181,6 +189,18 @@ public abstract class SupportManager { if (ackMessage.isSuccess()) { log.info("Received AckMessage for {} with tradeId {} and uid {}", ackMessage.getSourceMsgClassName(), ackMessage.getSourceId(), ackMessage.getSourceUid()); + + // dispute is opened by ack on chat message + if (ackMessage.getSourceMsgClassName().equals(ChatMessage.class.getSimpleName())) { + Trade trade = tradeManager.getTrade(ackMessage.getSourceId()); + for (Dispute dispute : trade.getDisputes()) { + for (ChatMessage chatMessage : dispute.getChatMessages()) { + if (chatMessage.getUid().equals(ackMessage.getSourceUid())) { + trade.setDisputeStateIfProgress(Trade.DisputeState.DISPUTE_OPENED); + } + } + } + } } else { log.warn("Received AckMessage with error state for {} with tradeId {} and errorMessage={}", ackMessage.getSourceMsgClassName(), ackMessage.getSourceId(), ackMessage.getErrorMessage()); diff --git a/core/src/main/java/bisq/core/support/dispute/DisputeManager.java b/core/src/main/java/bisq/core/support/dispute/DisputeManager.java index 8db2ba10de..1c04748252 100644 --- a/core/src/main/java/bisq/core/support/dispute/DisputeManager.java +++ b/core/src/main/java/bisq/core/support/dispute/DisputeManager.java @@ -47,7 +47,6 @@ import bisq.network.p2p.BootstrapListener; import bisq.network.p2p.NodeAddress; import bisq.network.p2p.P2PService; import bisq.network.p2p.SendMailboxMessageListener; - import bisq.common.UserThread; import bisq.common.app.Version; import bisq.common.config.Config; @@ -94,7 +93,6 @@ import static com.google.common.base.Preconditions.checkNotNull; public abstract class DisputeManager> extends SupportManager { protected final TradeWalletService tradeWalletService; protected final XmrWalletService xmrWalletService; - protected final TradeManager tradeManager; protected final ClosedTradableManager closedTradableManager; protected final OpenOfferManager openOfferManager; protected final KeyRing keyRing; @@ -122,11 +120,10 @@ public abstract class DisputeManager> extends Sup DisputeListService disputeListService, Config config, PriceFeedService priceFeedService) { - super(p2PService, connectionService, notificationService); + super(p2PService, connectionService, notificationService, tradeManager); this.tradeWalletService = tradeWalletService; this.xmrWalletService = xmrWalletService; - this.tradeManager = tradeManager; this.closedTradableManager = closedTradableManager; this.openOfferManager = openOfferManager; this.keyRing = keyRing; @@ -234,7 +231,9 @@ public abstract class DisputeManager> extends Sup } protected T getDisputeList() { - return disputeListService.getDisputeList(); + synchronized(disputeListService.getDisputeList()) { + return disputeListService.getDisputeList(); + } } public Set getDisputedTradeIds() { @@ -367,7 +366,7 @@ public abstract class DisputeManager> extends Sup UUID.randomUUID().toString(), getSupportType(), updatedMultisigHex, - trade.getBuyer().getPaymentSentMessage()); + trade.getProcessModel().getPaymentSentMessage()); log.info("Send {} to peer {}. tradeId={}, openNewDisputeMessage.uid={}, " + "chatMessage.uid={}", disputeOpenedMessage.getClass().getSimpleName(), agentNodeAddress, @@ -388,7 +387,7 @@ public abstract class DisputeManager> extends Sup // We use the chatMessage wrapped inside the openNewDisputeMessage for // the state, as that is displayed to the user and we only persist that msg chatMessage.setArrived(true); - trade.setDisputeStateIfProgress(Trade.DisputeState.DISPUTE_OPENED); + trade.setDisputeStateIfProgress(Trade.DisputeState.DISPUTE_REQUESTED); requestPersistence(); resultHandler.handleResult(); } @@ -404,7 +403,7 @@ public abstract class DisputeManager> extends Sup // We use the chatMessage wrapped inside the openNewDisputeMessage for // the state, as that is displayed to the user and we only persist that msg chatMessage.setStoredInMailbox(true); - trade.setDisputeStateIfProgress(Trade.DisputeState.DISPUTE_OPENED); + trade.setDisputeStateIfProgress(Trade.DisputeState.DISPUTE_REQUESTED); requestPersistence(); resultHandler.handleResult(); } @@ -441,86 +440,97 @@ public abstract class DisputeManager> extends Sup Dispute dispute = message.getDispute(); log.info("{}.onDisputeOpenedMessage() with trade {}, dispute {}", getClass().getSimpleName(), dispute.getTradeId(), dispute.getId()); - // intialize - T disputeList = getDisputeList(); - if (disputeList == null) { - log.warn("disputes is null"); - return; - } - dispute.setSupportType(message.getSupportType()); - dispute.setState(Dispute.State.NEW); // TODO: unused, remove? - Contract contract = dispute.getContract(); - - // validate dispute - try { - TradeDataValidation.validatePaymentAccountPayload(dispute); - TradeDataValidation.validateDonationAddress(dispute.getDonationAddressOfDelayedPayoutTx()); - //TradeDataValidation.testIfDisputeTriesReplay(dispute, disputeList.getList()); // TODO (woodser): disabled for xmr, needed? - TradeDataValidation.validateNodeAddress(dispute, contract.getBuyerNodeAddress(), config); - TradeDataValidation.validateNodeAddress(dispute, contract.getSellerNodeAddress(), config); - } catch (TradeDataValidation.AddressException | - TradeDataValidation.NodeAddressException | - TradeDataValidation.InvalidPaymentAccountPayloadException e) { - log.error(e.toString()); - validationExceptions.add(e); - } - - // get trade - Trade trade = tradeManager.getTrade(dispute.getTradeId()); - if (trade == null) { - log.warn("Dispute trade {} does not exist", dispute.getTradeId()); - return; - } - - // get sender - PubKeyRing senderPubKeyRing = trade.isArbitrator() ? (dispute.isDisputeOpenerIsBuyer() ? contract.getBuyerPubKeyRing() : contract.getSellerPubKeyRing()) : trade.getArbitrator().getPubKeyRing(); - TradingPeer sender = trade.getTradingPeer(senderPubKeyRing); - if (sender == null) throw new RuntimeException("Pub key ring is not from arbitrator, buyer, or seller"); - - // message to trader is expected from arbitrator - if (!trade.isArbitrator() && sender != trade.getArbitrator()) { - throw new RuntimeException(message.getClass().getSimpleName() + " to trader is expected only from arbitrator"); - } - - // arbitrator verifies signature of payment sent message if given - if (trade.isArbitrator() && message.getPaymentSentMessage() != null) { - HavenoUtils.verifyPaymentSentMessage(trade, message.getPaymentSentMessage()); - trade.getBuyer().setUpdatedMultisigHex(message.getPaymentSentMessage().getUpdatedMultisigHex()); - trade.setStateIfProgress(sender == trade.getBuyer() ? Trade.State.BUYER_SENT_PAYMENT_SENT_MSG : Trade.State.SELLER_RECEIVED_PAYMENT_SENT_MSG); - } - - // update multisig hex - if (message.getUpdatedMultisigHex() != null) sender.setUpdatedMultisigHex(message.getUpdatedMultisigHex()); - - // update peer node address - // TODO: tests can reuse the same addresses so nullify equal peer - sender.setNodeAddress(message.getSenderNodeAddress()); - - // add chat message with price info - if (trade instanceof ArbitratorTrade) addPriceInfoMessage(dispute, 0); - - // add dispute + Trade trade = null; String errorMessage = null; - synchronized (disputeList) { - if (!disputeList.contains(dispute)) { - Optional storedDisputeOptional = findDispute(dispute); - if (!storedDisputeOptional.isPresent()) { - disputeList.add(dispute); - trade.setDisputeStateIfProgress(Trade.DisputeState.DISPUTE_OPENED); - - // send dispute opened message to peer if arbitrator - if (trade.isArbitrator()) sendDisputeOpenedMessageToPeer(dispute, contract, dispute.isDisputeOpenerIsBuyer() ? contract.getSellerPubKeyRing() : contract.getBuyerPubKeyRing(), trade.getSelf().getUpdatedMultisigHex()); - tradeManager.requestPersistence(); - errorMessage = null; - } else { - // valid case if both have opened a dispute and agent was not online - log.debug("We got a dispute already open for that trade and trading peer. TradeId = {}", - dispute.getTradeId()); - } - } else { - errorMessage = "We got a dispute msg that we have already stored. TradeId = " + dispute.getTradeId(); - log.warn(errorMessage); + PubKeyRing senderPubKeyRing = null; + try { + + // intialize + T disputeList = getDisputeList(); + if (disputeList == null) { + log.warn("disputes is null"); + return; } + dispute.setSupportType(message.getSupportType()); + dispute.setState(Dispute.State.NEW); + Contract contract = dispute.getContract(); + + // validate dispute + try { + TradeDataValidation.validatePaymentAccountPayload(dispute); + TradeDataValidation.validateDonationAddress(dispute.getDonationAddressOfDelayedPayoutTx()); + //TradeDataValidation.testIfDisputeTriesReplay(dispute, disputeList.getList()); // TODO (woodser): disabled for xmr, needed? + TradeDataValidation.validateNodeAddress(dispute, contract.getBuyerNodeAddress(), config); + TradeDataValidation.validateNodeAddress(dispute, contract.getSellerNodeAddress(), config); + } catch (TradeDataValidation.AddressException | + TradeDataValidation.NodeAddressException | + TradeDataValidation.InvalidPaymentAccountPayloadException e) { + validationExceptions.add(e); + throw e; + } + + // get trade + trade = tradeManager.getTrade(dispute.getTradeId()); + if (trade == null) { + log.warn("Dispute trade {} does not exist", dispute.getTradeId()); + return; + } + + // get sender + senderPubKeyRing = trade.isArbitrator() ? (dispute.isDisputeOpenerIsBuyer() ? contract.getBuyerPubKeyRing() : contract.getSellerPubKeyRing()) : trade.getArbitrator().getPubKeyRing(); + TradingPeer sender = trade.getTradingPeer(senderPubKeyRing); + if (sender == null) throw new RuntimeException("Pub key ring is not from arbitrator, buyer, or seller"); + + // message to trader is expected from arbitrator + if (!trade.isArbitrator() && sender != trade.getArbitrator()) { + throw new RuntimeException(message.getClass().getSimpleName() + " to trader is expected only from arbitrator"); + } + + // arbitrator verifies signature of payment sent message if given + if (trade.isArbitrator() && message.getPaymentSentMessage() != null) { + HavenoUtils.verifyPaymentSentMessage(trade, message.getPaymentSentMessage()); + trade.getBuyer().setUpdatedMultisigHex(message.getPaymentSentMessage().getUpdatedMultisigHex()); + trade.setStateIfProgress(sender == trade.getBuyer() ? Trade.State.BUYER_SENT_PAYMENT_SENT_MSG : Trade.State.SELLER_RECEIVED_PAYMENT_SENT_MSG); + } + + // update multisig hex + if (message.getUpdatedMultisigHex() != null) sender.setUpdatedMultisigHex(message.getUpdatedMultisigHex()); + + // update peer node address + // TODO: tests can reuse the same addresses so nullify equal peer + sender.setNodeAddress(message.getSenderNodeAddress()); + + // add chat message with price info + if (trade instanceof ArbitratorTrade) addPriceInfoMessage(dispute, 0); + + // add dispute + synchronized (disputeList) { + if (!disputeList.contains(dispute)) { + Optional storedDisputeOptional = findDispute(dispute); + if (!storedDisputeOptional.isPresent()) { + disputeList.add(dispute); + trade.setDisputeStateIfProgress(Trade.DisputeState.DISPUTE_OPENED); + + // send dispute opened message to peer if arbitrator + if (trade.isArbitrator()) sendDisputeOpenedMessageToPeer(dispute, contract, dispute.isDisputeOpenerIsBuyer() ? contract.getSellerPubKeyRing() : contract.getBuyerPubKeyRing(), trade.getSelf().getUpdatedMultisigHex()); + tradeManager.requestPersistence(); + errorMessage = null; + } else { + // valid case if both have opened a dispute and agent was not online + log.debug("We got a dispute already open for that trade and trading peer. TradeId = {}", + dispute.getTradeId()); + } + + // add chat message with mediation info if applicable + addMediationResultMessage(dispute); + } else { + throw new RuntimeException("We got a dispute msg that we have already stored. TradeId = " + dispute.getTradeId()); + } + } + } catch (Exception e) { + errorMessage = e.getMessage(); + log.warn(errorMessage); + if (trade != null) trade.setErrorMessage(errorMessage); } // use chat message instead of open dispute message for the ack @@ -530,9 +540,6 @@ public abstract class DisputeManager> extends Sup sendAckMessage(msg, senderPubKeyRing, errorMessage == null, errorMessage); } - // add chat message with mediation info if applicable // TODO: not applicable in haveno - addMediationResultMessage(dispute); - requestPersistence(); } @@ -635,7 +642,7 @@ public abstract class DisputeManager> extends Sup UUID.randomUUID().toString(), getSupportType(), updatedMultisigHex, - trade.getSelf().getPaymentSentMessage()); + trade.getProcessModel().getPaymentSentMessage()); log.info("Send {} to peer {}. tradeId={}, peerOpenedDisputeMessage.uid={}, chatMessage.uid={}", peerOpenedDisputeMessage.getClass().getSimpleName(), peersNodeAddress, diff --git a/core/src/main/java/bisq/core/support/dispute/DisputeSummaryVerification.java b/core/src/main/java/bisq/core/support/dispute/DisputeSummaryVerification.java index f3b6c98168..9155b00a76 100644 --- a/core/src/main/java/bisq/core/support/dispute/DisputeSummaryVerification.java +++ b/core/src/main/java/bisq/core/support/dispute/DisputeSummaryVerification.java @@ -71,7 +71,7 @@ public class DisputeSummaryVerification { String fullAddress = textToSign.split("\n")[1].split(": ")[1]; NodeAddress nodeAddress = new NodeAddress(fullAddress); DisputeAgent disputeAgent = arbitratorMediator.getDisputeAgentByNodeAddress(nodeAddress).orElse(null); - checkNotNull(disputeAgent); + checkNotNull(disputeAgent, "Dispute agent is null"); PublicKey pubKey = disputeAgent.getPubKeyRing().getSignaturePubKey(); String sigString = parts[1].split(SEPARATOR2)[0]; diff --git a/core/src/main/java/bisq/core/support/dispute/arbitration/ArbitrationManager.java b/core/src/main/java/bisq/core/support/dispute/arbitration/ArbitrationManager.java index 7cf25d62d0..3366cf1173 100644 --- a/core/src/main/java/bisq/core/support/dispute/arbitration/ArbitrationManager.java +++ b/core/src/main/java/bisq/core/support/dispute/arbitration/ArbitrationManager.java @@ -56,8 +56,12 @@ import com.google.inject.Singleton; import java.math.BigInteger; import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.Set; import lombok.extern.slf4j.Slf4j; @@ -77,6 +81,8 @@ public final class ArbitrationManager extends DisputeManager reprocessDisputeClosedMessageCounts = new HashMap<>(); + /////////////////////////////////////////////////////////////////////////////////////////// // Constructor /////////////////////////////////////////////////////////////////////////////////////////// @@ -117,15 +123,17 @@ public final class ArbitrationManager extends DisputeManager { + if (message instanceof DisputeOpenedMessage) { + handleDisputeOpenedMessage((DisputeOpenedMessage) message); + } else if (message instanceof ChatMessage) { + handleChatMessage((ChatMessage) message); + } else if (message instanceof DisputeClosedMessage) { + handleDisputeClosedMessage((DisputeClosedMessage) message); + } else { + log.warn("Unsupported message at dispatchMessage. message={}", message); + } + }).start(); } } @@ -173,121 +181,166 @@ public final class ArbitrationManager extends DisputeManager disputeOptional = findDispute(disputeResult); - String uid = disputeClosedMessage.getUid(); - if (!disputeOptional.isPresent()) { - log.warn("We got a dispute closed msg but we don't have a matching dispute. " + - "That might happen when we get the DisputeClosedMessage before the dispute was created. " + - "We try again after 2 sec. to apply the DisputeClosedMessage. TradeId = " + tradeId); - if (!delayMsgMap.containsKey(uid)) { - // We delay 2 sec. to be sure the comm. msg gets added first - Timer timer = UserThread.runAfter(() -> handleDisputeClosedMessage(disputeClosedMessage), 2); - delayMsgMap.put(uid, timer); - } else { - log.warn("We got a dispute closed msg after we already repeated to apply the message after a delay. " + - "That should never happen. TradeId = " + tradeId); - } - return; - } - Dispute dispute = disputeOptional.get(); - - // verify that arbitrator does not get DisputeClosedMessage - if (keyRing.getPubKeyRing().equals(dispute.getAgentPubKeyRing())) { - log.error("Arbitrator received disputeResultMessage. That should never happen."); - return; - } - - // set dispute state - cleanupRetryMap(uid); - if (!dispute.getChatMessages().contains(chatMessage)) { - dispute.addAndPersistChatMessage(chatMessage); - } else { - log.warn("We got a dispute mail msg what we have already stored. TradeId = " + chatMessage.getTradeId()); - } - dispute.setIsClosed(); - if (dispute.disputeResultProperty().get() != null) { - log.info("We already got a dispute result, indicating the message was resent after updating multisig info. TradeId = " + tradeId); - } - dispute.setDisputeResult(disputeResult); - - // import multisig hex - List updatedMultisigHexes = new ArrayList(); - if (trade.getTradingPeer().getUpdatedMultisigHex() != null) updatedMultisigHexes.add(trade.getTradingPeer().getUpdatedMultisigHex()); - if (trade.getArbitrator().getUpdatedMultisigHex() != null) updatedMultisigHexes.add(trade.getArbitrator().getUpdatedMultisigHex()); - if (!updatedMultisigHexes.isEmpty()) trade.getWallet().importMultisigHex(updatedMultisigHexes.toArray(new String[0])); // TODO (monero-project): fails if multisig hex imported individually - - // sync and save wallet - trade.syncWallet(); - trade.saveWallet(); - - // run off main thread - new Thread(() -> { - String errorMessage = null; - boolean success = true; - - // attempt to sign and publish dispute payout tx if given and not already published - if (disputeClosedMessage.getUnsignedPayoutTxHex() != null && !trade.isPayoutPublished()) { - - // wait to sign and publish payout tx if defer flag set - if (disputeClosedMessage.isDeferPublishPayout()) { - log.info("Deferring signing and publishing dispute payout tx for {} {}", trade.getClass().getSimpleName(), trade.getId()); - GenUtils.waitFor(Trade.DEFER_PUBLISH_MS); - trade.syncWallet(); - } - - // sign and publish dispute payout tx if peer still has not published - if (!trade.isPayoutPublished()) { - try { - log.info("Signing and publishing dispute payout tx for {} {}", trade.getClass().getSimpleName(), trade.getId()); - signAndPublishDisputePayoutTx(trade, disputeClosedMessage.getUnsignedPayoutTxHex()); - } catch (Exception e) { - - // check if payout published again - trade.syncWallet(); - if (trade.isPayoutPublished()) { - log.info("Dispute payout tx already published for {} {}", trade.getClass().getSimpleName(), trade.getId()); - } else { - e.printStackTrace(); - errorMessage = "Failed to sign and publish dispute payout tx from arbitrator: " + e.getMessage() + ". TradeId = " + tradeId; - log.warn(errorMessage); - success = false; - } - } - } else { - log.info("Dispute payout tx already published for {} {}", trade.getClass().getSimpleName(), trade.getId()); - } - } else { - if (trade.isPayoutPublished()) log.info("Dispute payout tx already published for {} {}", trade.getClass().getSimpleName(), trade.getId()); - else if (disputeClosedMessage.getUnsignedPayoutTxHex() == null) log.info("{} did not receive unsigned dispute payout tx for trade {} because the arbitrator did not have their updated multisig info (can happen if trader went offline after trade started)", trade.getClass().getName(), trade.getId()); - } - - // We use the chatMessage as we only persist those not the DisputeClosedMessage. - // If we would use the DisputeClosedMessage we could not lookup for the msg when we receive the AckMessage. - sendAckMessage(chatMessage, dispute.getAgentPubKeyRing(), success, errorMessage); - requestPersistence(); - }).start(); + handleDisputeClosedMessage(disputeClosedMessage, true); } - private MoneroTxSet signAndPublishDisputePayoutTx(Trade trade, String payoutTxHex) { + private void handleDisputeClosedMessage(DisputeClosedMessage disputeClosedMessage, boolean reprocessOnError) { + + // get dispute's trade + final Trade trade = tradeManager.getTrade(disputeClosedMessage.getTradeId()); + if (trade == null) { + log.warn("Dispute trade {} does not exist", disputeClosedMessage.getTradeId()); + return; + } + + // try to process dispute closed message + ChatMessage chatMessage = null; + Dispute dispute = null; + synchronized (trade) { + try { + DisputeResult disputeResult = disputeClosedMessage.getDisputeResult(); + chatMessage = disputeResult.getChatMessage(); + checkNotNull(chatMessage, "chatMessage must not be null"); + String tradeId = disputeResult.getTradeId(); + + log.info("Processing {} for {} {}", disputeClosedMessage.getClass().getSimpleName(), trade.getClass().getSimpleName(), disputeResult.getTradeId()); + + // verify arbitrator signature + String summaryText = chatMessage.getMessage(); + DisputeSummaryVerification.verifySignature(summaryText, arbitratorManager); + + // save dispute closed message for reprocessing + trade.getProcessModel().setDisputeClosedMessage(disputeClosedMessage); + requestPersistence(); + + // get dispute + Optional disputeOptional = findDispute(disputeResult); + String uid = disputeClosedMessage.getUid(); + if (!disputeOptional.isPresent()) { + log.warn("We got a dispute closed msg but we don't have a matching dispute. " + + "That might happen when we get the DisputeClosedMessage before the dispute was created. " + + "We try again after 2 sec. to apply the DisputeClosedMessage. TradeId = " + tradeId); + if (!delayMsgMap.containsKey(uid)) { + // We delay 2 sec. to be sure the comm. msg gets added first + Timer timer = UserThread.runAfter(() -> handleDisputeClosedMessage(disputeClosedMessage), 2); + delayMsgMap.put(uid, timer); + } else { + log.warn("We got a dispute closed msg after we already repeated to apply the message after a delay. " + + "That should never happen. TradeId = " + tradeId); + } + return; + } + dispute = disputeOptional.get(); + + // verify that arbitrator does not get DisputeClosedMessage + if (keyRing.getPubKeyRing().equals(dispute.getAgentPubKeyRing())) { + log.error("Arbitrator received disputeResultMessage. That should never happen."); + trade.getProcessModel().setDisputeClosedMessage(null); // don't reprocess + return; + } + + // set dispute state + cleanupRetryMap(uid); + if (!dispute.getChatMessages().contains(chatMessage)) { + dispute.addAndPersistChatMessage(chatMessage); + } else { + log.warn("We got a dispute mail msg what we have already stored. TradeId = " + chatMessage.getTradeId()); + } + dispute.setIsClosed(); + if (dispute.disputeResultProperty().get() != null) { + log.info("We already got a dispute result, indicating the message was resent after updating multisig info. TradeId = " + tradeId); + } + dispute.setDisputeResult(disputeResult); + + // attempt to sign and publish dispute payout tx if given and not already published + if (disputeClosedMessage.getUnsignedPayoutTxHex() != null && !trade.isPayoutPublished()) { + + // check wallet connection + trade.checkWalletConnection(); + + // import multisig hex + List updatedMultisigHexes = new ArrayList(); + if (trade.getTradingPeer().getUpdatedMultisigHex() != null) updatedMultisigHexes.add(trade.getTradingPeer().getUpdatedMultisigHex()); + if (trade.getArbitrator().getUpdatedMultisigHex() != null) updatedMultisigHexes.add(trade.getArbitrator().getUpdatedMultisigHex()); + if (!updatedMultisigHexes.isEmpty()) trade.getWallet().importMultisigHex(updatedMultisigHexes.toArray(new String[0])); // TODO (monero-project): fails if multisig hex imported individually + + // sync and save wallet + trade.syncWallet(); + trade.saveWallet(); + + // wait to sign and publish payout tx if defer flag set + if (disputeClosedMessage.isDeferPublishPayout()) { + log.info("Deferring signing and publishing dispute payout tx for {} {}", trade.getClass().getSimpleName(), trade.getId()); + GenUtils.waitFor(Trade.DEFER_PUBLISH_MS); + if (!trade.isPayoutUnlocked()) trade.syncWallet(); + } + + // sign and publish dispute payout tx if peer still has not published + if (!trade.isPayoutPublished()) { + try { + log.info("Signing and publishing dispute payout tx for {} {}", trade.getClass().getSimpleName(), trade.getId()); + signAndPublishDisputePayoutTx(trade); + } catch (Exception e) { + + // check if payout published again + trade.syncWallet(); + if (trade.isPayoutPublished()) { + log.info("Dispute payout tx already published for {} {}", trade.getClass().getSimpleName(), trade.getId()); + } else { + throw new RuntimeException("Failed to sign and publish dispute payout tx from arbitrator: " + e.getMessage() + ". TradeId = " + tradeId); + } + } + } else { + log.info("Dispute payout tx already published for {} {}", trade.getClass().getSimpleName(), trade.getId()); + } + } else { + if (trade.isPayoutPublished()) log.info("Dispute payout tx already published for {} {}", trade.getClass().getSimpleName(), trade.getId()); + else if (disputeClosedMessage.getUnsignedPayoutTxHex() == null) log.info("{} did not receive unsigned dispute payout tx for trade {} because the arbitrator did not have their updated multisig info (can happen if trader went offline after trade started)", trade.getClass().getName(), trade.getId()); + } + + // We use the chatMessage as we only persist those not the DisputeClosedMessage. + // If we would use the DisputeClosedMessage we could not lookup for the msg when we receive the AckMessage. + sendAckMessage(chatMessage, dispute.getAgentPubKeyRing(), true, null); + requestPersistence(); + } catch (Exception e) { + log.warn("Error processing dispute closed message: " + e.getMessage()); + e.printStackTrace(); + requestPersistence(); + + // nack bad message and do not reprocess + if (e instanceof IllegalArgumentException) { + trade.getProcessModel().setPaymentReceivedMessage(null); // message is processed + sendAckMessage(chatMessage, dispute.getAgentPubKeyRing(), false, e.getMessage()); + requestPersistence(); + throw e; + } + + // reprocess on error + if (trade.getProcessModel().getDisputeClosedMessage() != null) { + if (!reprocessDisputeClosedMessageCounts.containsKey(trade.getId())) reprocessDisputeClosedMessageCounts.put(trade.getId(), 0); + UserThread.runAfter(() -> { + reprocessDisputeClosedMessageCounts.put(trade.getId(), reprocessDisputeClosedMessageCounts.get(trade.getId()) + 1); // increment reprocess count + maybeReprocessDisputeClosedMessage(trade, reprocessOnError); + }, trade.getReprocessDelayInSeconds(reprocessDisputeClosedMessageCounts.get(trade.getId()))); + } + } + } + } + + public void maybeReprocessDisputeClosedMessage(Trade trade, boolean reprocessOnError) { + synchronized (trade) { + + // skip if no need to reprocess + if (trade.isArbitrator() || trade.getProcessModel().getDisputeClosedMessage() == null || trade.getProcessModel().getDisputeClosedMessage().getUnsignedPayoutTxHex() == null || trade.getDisputeState().ordinal() >= Trade.DisputeState.DISPUTE_CLOSED.ordinal()) { + return; + } + + log.warn("Reprocessing dispute closed message for {} {}", trade.getClass().getSimpleName(), trade.getId()); + new Thread(() -> handleDisputeClosedMessage(trade.getProcessModel().getDisputeClosedMessage(), reprocessOnError)).start(); + } + } + + private MoneroTxSet signAndPublishDisputePayoutTx(Trade trade) { // gather trade info MoneroWallet multisigWallet = trade.getWallet(); @@ -296,6 +349,7 @@ public final class ArbitrationManager extends DisputeManager nonSignedDisputePayoutTxHexes = new HashSet(); + if (trade.getProcessModel().getPaymentSentMessage() != null) nonSignedDisputePayoutTxHexes.add(trade.getProcessModel().getPaymentSentMessage().getPayoutTxHex()); + if (trade.getProcessModel().getPaymentReceivedMessage() != null) { + nonSignedDisputePayoutTxHexes.add(trade.getProcessModel().getPaymentReceivedMessage().getUnsignedPayoutTxHex()); + nonSignedDisputePayoutTxHexes.add(trade.getProcessModel().getPaymentReceivedMessage().getSignedPayoutTxHex()); } - if (feeEstimateTx != null) { - BigInteger feeEstimate = feeEstimateTx.getFee(); - double feeDiff = arbitratorSignedPayoutTx.getFee().subtract(feeEstimate).abs().doubleValue() / feeEstimate.doubleValue(); // TODO: use BigDecimal? - if (feeDiff > XmrWalletService.MINER_FEE_TOLERANCE) throw new RuntimeException("Miner fee is not within " + (XmrWalletService.MINER_FEE_TOLERANCE * 100) + "% of estimated fee, expected " + feeEstimate + " but was " + arbitratorSignedPayoutTx.getFee()); - log.info("Payout tx fee {} is within tolerance, diff %={}", arbitratorSignedPayoutTx.getFee(), feeDiff); + boolean signed = trade.getPayoutTxHex() != null && !nonSignedDisputePayoutTxHexes.contains(trade.getPayoutTxHex()); + + // sign arbitrator-signed payout tx + if (!signed) { + MoneroMultisigSignResult result = multisigWallet.signMultisigTxHex(unsignedPayoutTxHex); + if (result.getSignedMultisigTxHex() == null) throw new RuntimeException("Error signing arbitrator-signed payout tx"); + String signedMultisigTxHex = result.getSignedMultisigTxHex(); + disputeTxSet.setMultisigTxHex(signedMultisigTxHex); + trade.setPayoutTxHex(signedMultisigTxHex); + requestPersistence(); + + // verify mining fee is within tolerance by recreating payout tx + // TODO (monero-project): creating tx will require exchanging updated multisig hex if message needs reprocessed. provide weight with describe_transfer so fee can be estimated? + MoneroTxWallet feeEstimateTx = null; + try { + feeEstimateTx = createDisputePayoutTx(trade, dispute, disputeResult, true); + } catch (Exception e) { + log.warn("Could not recreate dispute payout tx to verify fee: " + e.getMessage()); + } + if (feeEstimateTx != null) { + BigInteger feeEstimate = feeEstimateTx.getFee(); + double feeDiff = arbitratorSignedPayoutTx.getFee().subtract(feeEstimate).abs().doubleValue() / feeEstimate.doubleValue(); // TODO: use BigDecimal? + if (feeDiff > XmrWalletService.MINER_FEE_TOLERANCE) throw new IllegalArgumentException("Miner fee is not within " + (XmrWalletService.MINER_FEE_TOLERANCE * 100) + "% of estimated fee, expected " + feeEstimate + " but was " + arbitratorSignedPayoutTx.getFee()); + log.info("Payout tx fee {} is within tolerance, diff %={}", arbitratorSignedPayoutTx.getFee(), feeDiff); + } + } else { + disputeTxSet.setMultisigTxHex(trade.getPayoutTxHex()); } // submit fully signed payout tx to the network - List txHashes = multisigWallet.submitMultisigTxHex(signedTxSet.getMultisigTxHex()); - signedTxSet.getTxs().get(0).setHash(txHashes.get(0)); // manually update hash which is known after signed + List txHashes = multisigWallet.submitMultisigTxHex(disputeTxSet.getMultisigTxHex()); + disputeTxSet.getTxs().get(0).setHash(txHashes.get(0)); // manually update hash which is known after signed // update state - trade.setPayoutTx(signedTxSet.getTxs().get(0)); // TODO (woodser): is trade.payoutTx() mutually exclusive from dispute payout tx? - trade.setPayoutTxId(signedTxSet.getTxs().get(0).getHash()); + trade.setPayoutTx(disputeTxSet.getTxs().get(0)); // TODO (woodser): is trade.payoutTx() mutually exclusive from dispute payout tx? + trade.setPayoutTxId(disputeTxSet.getTxs().get(0).getHash()); trade.setPayoutState(Trade.PayoutState.PAYOUT_PUBLISHED); - dispute.setDisputePayoutTxId(signedTxSet.getTxs().get(0).getHash()); - return signedTxSet; + dispute.setDisputePayoutTxId(disputeTxSet.getTxs().get(0).getHash()); + return disputeTxSet; } } diff --git a/core/src/main/java/bisq/core/support/traderchat/TraderChatManager.java b/core/src/main/java/bisq/core/support/traderchat/TraderChatManager.java index a14e0de073..37452cc36d 100644 --- a/core/src/main/java/bisq/core/support/traderchat/TraderChatManager.java +++ b/core/src/main/java/bisq/core/support/traderchat/TraderChatManager.java @@ -47,7 +47,6 @@ import lombok.extern.slf4j.Slf4j; @Slf4j @Singleton public class TraderChatManager extends SupportManager { - private final TradeManager tradeManager; private final PubKeyRingProvider pubKeyRingProvider; @@ -61,8 +60,7 @@ public class TraderChatManager extends SupportManager { CoreNotificationService notificationService, TradeManager tradeManager, PubKeyRingProvider pubKeyRingProvider) { - super(p2PService, connectionService, notificationService); - this.tradeManager = tradeManager; + super(p2PService, connectionService, notificationService, tradeManager); this.pubKeyRingProvider = pubKeyRingProvider; } diff --git a/core/src/main/java/bisq/core/trade/HavenoUtils.java b/core/src/main/java/bisq/core/trade/HavenoUtils.java index 33817d777a..25851cb787 100644 --- a/core/src/main/java/bisq/core/trade/HavenoUtils.java +++ b/core/src/main/java/bisq/core/trade/HavenoUtils.java @@ -294,13 +294,13 @@ public class HavenoUtils { // verify signature String errMessage = "The buyer signature is invalid for the " + message.getClass().getSimpleName() + " for " + trade.getClass().getSimpleName() + " " + trade.getId(); try { - if (!Sig.verify(trade.getBuyer().getPubKeyRing().getSignaturePubKey(), unsignedMessageAsJson.getBytes(Charsets.UTF_8), signature)) throw new RuntimeException(errMessage); + if (!Sig.verify(trade.getBuyer().getPubKeyRing().getSignaturePubKey(), unsignedMessageAsJson.getBytes(Charsets.UTF_8), signature)) throw new IllegalArgumentException(errMessage); } catch (Exception e) { - throw new RuntimeException(errMessage); + throw new IllegalArgumentException(errMessage); } // verify trade id - if (!trade.getId().equals(message.getTradeId())) throw new RuntimeException("The " + message.getClass().getSimpleName() + " has the wrong trade id, expected " + trade.getId() + " but was " + message.getTradeId()); + if (!trade.getId().equals(message.getTradeId())) throw new IllegalArgumentException("The " + message.getClass().getSimpleName() + " has the wrong trade id, expected " + trade.getId() + " but was " + message.getTradeId()); } /** @@ -325,13 +325,13 @@ public class HavenoUtils { // verify signature String errMessage = "The seller signature is invalid for the " + message.getClass().getSimpleName() + " for " + trade.getClass().getSimpleName() + " " + trade.getId(); try { - if (!Sig.verify(trade.getSeller().getPubKeyRing().getSignaturePubKey(), unsignedMessageAsJson.getBytes(Charsets.UTF_8), signature)) throw new RuntimeException(errMessage); + if (!Sig.verify(trade.getSeller().getPubKeyRing().getSignaturePubKey(), unsignedMessageAsJson.getBytes(Charsets.UTF_8), signature)) throw new IllegalArgumentException(errMessage); } catch (Exception e) { - throw new RuntimeException(errMessage); + throw new IllegalArgumentException(errMessage); } // verify trade id - if (!trade.getId().equals(message.getTradeId())) throw new RuntimeException("The " + message.getClass().getSimpleName() + " has the wrong trade id, expected " + trade.getId() + " but was " + message.getTradeId()); + if (!trade.getId().equals(message.getTradeId())) throw new IllegalArgumentException("The " + message.getClass().getSimpleName() + " has the wrong trade id, expected " + trade.getId() + " but was " + message.getTradeId()); // verify buyer signature of payment sent message verifyPaymentSentMessage(trade, message.getPaymentSentMessage()); diff --git a/core/src/main/java/bisq/core/trade/Trade.java b/core/src/main/java/bisq/core/trade/Trade.java index b931820c0a..753b4fbe3a 100644 --- a/core/src/main/java/bisq/core/trade/Trade.java +++ b/core/src/main/java/bisq/core/trade/Trade.java @@ -17,6 +17,7 @@ package bisq.core.trade; +import bisq.core.api.CoreMoneroConnectionsService; import bisq.core.btc.model.XmrAddressEntry; import bisq.core.btc.wallet.XmrWalletService; import bisq.core.locale.CurrencyUtil; @@ -240,7 +241,7 @@ public abstract class Trade implements Tradable, Model { public enum DisputeState { NO_DISPUTE, - DISPUTE_REQUESTED, // TODO: not currently used; can use by subscribing to chat message ack in DisputeManager + DISPUTE_REQUESTED, DISPUTE_OPENED, ARBITRATOR_SENT_DISPUTE_CLOSED_MSG, ARBITRATOR_SEND_FAILED_DISPUTE_CLOSED_MSG, @@ -281,6 +282,14 @@ public abstract class Trade implements Tradable, Model { return this.ordinal() >= DisputeState.DISPUTE_REQUESTED.ordinal(); } + public boolean isRequested() { + return ordinal() >= DisputeState.DISPUTE_REQUESTED.ordinal(); + } + + public boolean isOpen() { + return this == DisputeState.DISPUTE_OPENED; + } + public boolean isClosed() { return this == DisputeState.DISPUTE_CLOSED; } @@ -404,6 +413,9 @@ public abstract class Trade implements Tradable, Model { @Setter private long lockTime; @Getter + @Setter + private long startTime; // added for haveno + @Getter @Nullable private RefundResultState refundResultState = RefundResultState.UNDEFINED_REFUND_RESULT; transient final private ObjectProperty refundResultStateProperty = new SimpleObjectProperty<>(refundResultState); @@ -444,8 +456,8 @@ public abstract class Trade implements Tradable, Model { @Getter @Setter private String payoutTxKey; - private Long startTime; // cache + private static final long MAX_REPROCESS_DELAY_SECONDS = 7200; // max delay to reprocess messages (once per 2 hours) /////////////////////////////////////////////////////////////////////////////////////////// // Constructors @@ -588,7 +600,7 @@ public abstract class Trade implements Tradable, Model { // handle trade state events tradePhaseSubscription = EasyBind.subscribe(phaseProperty, newValue -> { if (!isInitialized) return; - if (isDepositPublished() && !isPayoutUnlocked()) updateWalletRefreshPeriod(); + if (isDepositsPublished() && !isPayoutUnlocked()) updateWalletRefreshPeriod(); if (isCompleted()) { UserThread.execute(() -> { if (tradePhaseSubscription != null) { @@ -648,6 +660,10 @@ public abstract class Trade implements Tradable, Model { } } + public void requestPersistence() { + processModel.getTradeManager().requestPersistence(); + } + public TradeProtocol getProtocol() { return processModel.getTradeManager().getTradeProtocol(this); } @@ -664,6 +680,22 @@ public abstract class Trade implements Tradable, Model { return getArbitrator() == null ? null : getArbitrator().getNodeAddress(); } + public void checkWalletConnection() { + CoreMoneroConnectionsService connectionService = xmrWalletService.getConnectionsService(); + connectionService.checkConnection(); + connectionService.verifyConnection(); + if (!getWallet().isConnectedToDaemon()) throw new RuntimeException("Wallet is not connected to a Monero node"); + } + + public boolean isWalletConnected() { + try { + checkWalletConnection(); + return true; + } catch (Exception e) { + return false; + } + } + /** * Create a contract based on the current state. * @@ -717,6 +749,9 @@ public abstract class Trade implements Tradable, Model { BigInteger buyerPayoutAmount = buyerDepositAmount.add(tradeAmount); BigInteger sellerPayoutAmount = sellerDepositAmount.subtract(tradeAmount); + // check connection to monero daemon + checkWalletConnection(); + // create transaction to get fee estimate MoneroTxWallet feeEstimateTx = multisigWallet.createTx(new MoneroTxConfig() .setAccountIndex(0) @@ -760,20 +795,19 @@ public abstract class Trade implements Tradable, Model { log.info("Verifying payout tx"); // gather relevant info - XmrWalletService walletService = processModel.getProvider().getXmrWalletService(); - MoneroWallet multisigWallet = walletService.getMultisigWallet(getId()); + MoneroWallet wallet = getWallet(); Contract contract = getContract(); - BigInteger sellerDepositAmount = multisigWallet.getTx(getSeller().getDepositTxHash()).getIncomingAmount(); // TODO (woodser): redundancy of processModel.getPreparedDepositTxId() vs this.getDepositTxId() necessary or avoidable? - BigInteger buyerDepositAmount = multisigWallet.getTx(getBuyer().getDepositTxHash()).getIncomingAmount(); + BigInteger sellerDepositAmount = wallet.getTx(getSeller().getDepositTxHash()).getIncomingAmount(); // TODO (woodser): redundancy of processModel.getPreparedDepositTxId() vs this.getDepositTxId() necessary or avoidable? + BigInteger buyerDepositAmount = wallet.getTx(getBuyer().getDepositTxHash()).getIncomingAmount(); BigInteger tradeAmount = HavenoUtils.coinToAtomicUnits(getAmount()); // describe payout tx - MoneroTxSet describedTxSet = multisigWallet.describeTxSet(new MoneroTxSet().setMultisigTxHex(payoutTxHex)); - if (describedTxSet.getTxs() == null || describedTxSet.getTxs().size() != 1) throw new RuntimeException("Bad payout tx"); // TODO (woodser): test nack + MoneroTxSet describedTxSet = wallet.describeTxSet(new MoneroTxSet().setMultisigTxHex(payoutTxHex)); + if (describedTxSet.getTxs() == null || describedTxSet.getTxs().size() != 1) throw new IllegalArgumentException("Bad payout tx"); // TODO (woodser): test nack MoneroTxWallet payoutTx = describedTxSet.getTxs().get(0); // verify payout tx has exactly 2 destinations - if (payoutTx.getOutgoingTransfer() == null || payoutTx.getOutgoingTransfer().getDestinations() == null || payoutTx.getOutgoingTransfer().getDestinations().size() != 2) throw new RuntimeException("Payout tx does not have exactly two destinations"); + if (payoutTx.getOutgoingTransfer() == null || payoutTx.getOutgoingTransfer().getDestinations() == null || payoutTx.getOutgoingTransfer().getDestinations().size() != 2) throw new IllegalArgumentException("Payout tx does not have exactly two destinations"); // get buyer and seller destinations (order not preserved) boolean buyerFirst = payoutTx.getOutgoingTransfer().getDestinations().get(0).getAddress().equals(contract.getBuyerPayoutAddressString()); @@ -781,32 +815,35 @@ public abstract class Trade implements Tradable, Model { MoneroDestination sellerPayoutDestination = payoutTx.getOutgoingTransfer().getDestinations().get(buyerFirst ? 1 : 0); // verify payout addresses - if (!buyerPayoutDestination.getAddress().equals(contract.getBuyerPayoutAddressString())) throw new RuntimeException("Buyer payout address does not match contract"); - if (!sellerPayoutDestination.getAddress().equals(contract.getSellerPayoutAddressString())) throw new RuntimeException("Seller payout address does not match contract"); + if (!buyerPayoutDestination.getAddress().equals(contract.getBuyerPayoutAddressString())) throw new IllegalArgumentException("Buyer payout address does not match contract"); + if (!sellerPayoutDestination.getAddress().equals(contract.getSellerPayoutAddressString())) throw new IllegalArgumentException("Seller payout address does not match contract"); // verify change address is multisig's primary address - if (!payoutTx.getChangeAmount().equals(BigInteger.ZERO) && !payoutTx.getChangeAddress().equals(multisigWallet.getPrimaryAddress())) throw new RuntimeException("Change address is not multisig wallet's primary address"); + if (!payoutTx.getChangeAmount().equals(BigInteger.ZERO) && !payoutTx.getChangeAddress().equals(wallet.getPrimaryAddress())) throw new IllegalArgumentException("Change address is not multisig wallet's primary address"); // verify sum of outputs = destination amounts + change amount - if (!payoutTx.getOutputSum().equals(buyerPayoutDestination.getAmount().add(sellerPayoutDestination.getAmount()).add(payoutTx.getChangeAmount()))) throw new RuntimeException("Sum of outputs != destination amounts + change amount"); + if (!payoutTx.getOutputSum().equals(buyerPayoutDestination.getAmount().add(sellerPayoutDestination.getAmount()).add(payoutTx.getChangeAmount()))) throw new IllegalArgumentException("Sum of outputs != destination amounts + change amount"); // verify buyer destination amount is deposit amount + this amount - 1/2 tx costs BigInteger txCost = payoutTx.getFee().add(payoutTx.getChangeAmount()); BigInteger expectedBuyerPayout = buyerDepositAmount.add(tradeAmount).subtract(txCost.divide(BigInteger.valueOf(2))); - if (!buyerPayoutDestination.getAmount().equals(expectedBuyerPayout)) throw new RuntimeException("Buyer destination amount is not deposit amount + trade amount - 1/2 tx costs, " + buyerPayoutDestination.getAmount() + " vs " + expectedBuyerPayout); + if (!buyerPayoutDestination.getAmount().equals(expectedBuyerPayout)) throw new IllegalArgumentException("Buyer destination amount is not deposit amount + trade amount - 1/2 tx costs, " + buyerPayoutDestination.getAmount() + " vs " + expectedBuyerPayout); // verify seller destination amount is deposit amount - this amount - 1/2 tx costs BigInteger expectedSellerPayout = sellerDepositAmount.subtract(tradeAmount).subtract(txCost.divide(BigInteger.valueOf(2))); - if (!sellerPayoutDestination.getAmount().equals(expectedSellerPayout)) throw new RuntimeException("Seller destination amount is not deposit amount - trade amount - 1/2 tx costs, " + sellerPayoutDestination.getAmount() + " vs " + expectedSellerPayout); + if (!sellerPayoutDestination.getAmount().equals(expectedSellerPayout)) throw new IllegalArgumentException("Seller destination amount is not deposit amount - trade amount - 1/2 tx costs, " + sellerPayoutDestination.getAmount() + " vs " + expectedSellerPayout); + + // check wallet's daemon connection + checkWalletConnection(); // handle tx signing if (sign) { // sign tx - MoneroMultisigSignResult result = multisigWallet.signMultisigTxHex(payoutTxHex); + MoneroMultisigSignResult result = wallet.signMultisigTxHex(payoutTxHex); if (result.getSignedMultisigTxHex() == null) throw new RuntimeException("Error signing payout tx"); payoutTxHex = result.getSignedMultisigTxHex(); - describedTxSet = multisigWallet.describeMultisigTxSet(payoutTxHex); // update described set + describedTxSet = wallet.describeMultisigTxSet(payoutTxHex); // update described set payoutTx = describedTxSet.getTxs().get(0); // verify fee is within tolerance by recreating payout tx @@ -820,7 +857,7 @@ public abstract class Trade implements Tradable, Model { if (feeEstimateTx != null) { BigInteger feeEstimate = feeEstimateTx.getFee(); double feeDiff = payoutTx.getFee().subtract(feeEstimate).abs().doubleValue() / feeEstimate.doubleValue(); // TODO: use BigDecimal? - if (feeDiff > XmrWalletService.MINER_FEE_TOLERANCE) throw new RuntimeException("Miner fee is not within " + (XmrWalletService.MINER_FEE_TOLERANCE * 100) + "% of estimated fee, expected " + feeEstimate + " but was " + payoutTx.getFee()); + if (feeDiff > XmrWalletService.MINER_FEE_TOLERANCE) throw new IllegalArgumentException("Miner fee is not within " + (XmrWalletService.MINER_FEE_TOLERANCE * 100) + "% of estimated fee, expected " + feeEstimate + " but was " + payoutTx.getFee()); log.info("Payout tx fee {} is within tolerance, diff %={}", payoutTx.getFee(), feeDiff); } } @@ -831,7 +868,8 @@ public abstract class Trade implements Tradable, Model { // submit payout tx if (publish) { - multisigWallet.submitMultisigTxHex(payoutTxHex); + //if (true) throw new RuntimeException("Let's pretend there's an error last second submitting tx to daemon, so we need to resubmit payout hex"); + wallet.submitMultisigTxHex(payoutTxHex); pollWallet(); } } @@ -926,14 +964,8 @@ public abstract class Trade implements Tradable, Model { } public void syncWallet() { - if (getWallet() == null) { - log.warn("Cannot sync multisig wallet because it doesn't exist for {}, {}", getClass().getSimpleName(), getId()); - return; - } - if (getWallet().getDaemonConnection() == null) { - log.warn("Cannot sync multisig wallet because it's not connected to a Monero daemon for {}, {}", getClass().getSimpleName(), getId()); - return; - } + if (getWallet() == null) throw new RuntimeException("Cannot sync multisig wallet because it doesn't exist for " + getClass().getSimpleName() + ", " + getId()); + if (getWallet().getDaemonConnection() == null) throw new RuntimeException("Cannot sync multisig wallet because it's not connected to a Monero daemon for " + getClass().getSimpleName() + ", " + getId()); log.info("Syncing wallet for {} {}", getClass().getSimpleName(), getId()); getWallet().sync(); pollWallet(); @@ -941,6 +973,14 @@ public abstract class Trade implements Tradable, Model { updateWalletRefreshPeriod(); } + private void trySyncWallet() { + try { + syncWallet(); + } catch (Exception e) { + log.warn("Error syncing wallet for {} {}: {}", getClass().getSimpleName(), getId(), e.getMessage()); + } + } + public void syncWalletNormallyForMs(long syncNormalDuration) { syncNormalStartTime = System.currentTimeMillis(); setWalletRefreshPeriod(xmrWalletService.getConnectionsService().getDefaultRefreshPeriodMs()); @@ -957,7 +997,7 @@ public abstract class Trade implements Tradable, Model { if (xmrWalletService.multisigWalletExists(getId())) { // delete trade wallet unless funded - if (isDepositPublished() && !isPayoutUnlocked()) { + if (isDepositsPublished() && !isPayoutUnlocked()) { log.warn("Refusing to delete wallet for {} {} because it could be funded", getClass().getSimpleName(), getId()); return; } @@ -1258,36 +1298,37 @@ public abstract class Trade implements Tradable, Model { } private long getStartTime() { - if (startTime != null) return startTime; long now = System.currentTimeMillis(); - if (isDepositConfirmed() && getTakeOfferDate() != null) { - if (isDepositUnlocked()) { - final long tradeTime = getTakeOfferDate().getTime(); - long maxHeight = Math.max(getMakerDepositTx().getHeight(), getTakerDepositTx().getHeight()); - MoneroDaemon daemonRpc = xmrWalletService.getDaemon(); - long blockTime = daemonRpc.getBlockByHeight(maxHeight).getTimestamp(); - -// if (depositTx.getConfidence().getDepthInBlocks() > 0) { -// final long tradeTime = getTakeOfferDate().getTime(); -// // Use tx.getIncludedInBestChainAt() when available, otherwise use tx.getUpdateTime() -// long blockTime = depositTx.getIncludedInBestChainAt() != null ? depositTx.getIncludedInBestChainAt().getTime() : depositTx.getUpdateTime().getTime(); - // If block date is in future (Date in Bitcoin 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 (blockTime > now) - startTime = now; - else - startTime = Math.max(blockTime, 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(blockTime)); + 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()); - startTime = now; + return now; } } else { - startTime = now; + return now; } - return startTime; + } + + private void setStartTimeFromUnlockedTxs() { + long now = System.currentTimeMillis(); + final long tradeTime = getTakeOfferDate().getTime(); + MoneroDaemon daemonRpc = xmrWalletService.getDaemon(); + if (daemonRpc == null) throw new RuntimeException("Cannot set start time for trade " + getId() + " because it has no connection to monerod"); + long maxHeight = Math.max(getMakerDepositTx().getHeight(), getTakerDepositTx().getHeight()); + long blockTime = daemonRpc.getBlockByHeight(maxHeight).getTimestamp(); + + // 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 (blockTime > now) + startTime = now; + else + startTime = Math.max(blockTime, 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(blockTime)); } public boolean hasFailed() { @@ -1306,19 +1347,19 @@ public abstract class Trade implements Tradable, Model { return getState() == Trade.State.PUBLISH_DEPOSIT_TX_REQUEST_FAILED; } - public boolean isDepositPublished() { + public boolean isDepositsPublished() { return getState().getPhase().ordinal() >= Phase.DEPOSITS_PUBLISHED.ordinal(); } public boolean isFundsLockedIn() { - return isDepositPublished() && !isPayoutPublished(); + return isDepositsPublished() && !isPayoutPublished(); } - public boolean isDepositConfirmed() { + public boolean isDepositsConfirmed() { return getState().getPhase().ordinal() >= Phase.DEPOSITS_CONFIRMED.ordinal(); } - public boolean isDepositUnlocked() { + public boolean isDepositsUnlocked() { return getState().getPhase().ordinal() >= Phase.DEPOSITS_UNLOCKED.ordinal(); } @@ -1458,6 +1499,19 @@ public abstract class Trade implements Tradable, Model { processModel.getTaker().getDepositTxHash() == null; } + /** + * Get the duration to delay reprocessing a message based on its reprocess count. + * + * @return the duration to delay in seconds + */ + public long getReprocessDelayInSeconds(int reprocessCount) { + int retryCycles = 3; // reprocess on next refresh periods for first few attempts (app might auto switch to a good connection) + if (reprocessCount < retryCycles) return xmrWalletService.getConnectionsService().getDefaultRefreshPeriodMs() / 1000; + long delay = 60; + for (int i = retryCycles; i < reprocessCount; i++) delay *= 2; + return Math.min(MAX_REPROCESS_DELAY_SECONDS, delay); + } + /////////////////////////////////////////////////////////////////////////////////////////// // Private @@ -1479,18 +1533,27 @@ public abstract class Trade implements Tradable, Model { } private void setDaemonConnection(MoneroRpcConnection connection) { - if (getWallet() == null) return; - log.info("Setting daemon connection for trade wallet {}: {}: ", getId() , connection == null ? null : connection.getUri()); - if (getWallet() != null) getWallet().setDaemonConnection(connection); - updateSyncing(); + MoneroWallet wallet = getWallet(); + if (wallet == null) return; + log.info("Setting daemon connection for trade wallet {}: {}", getId() , connection == null ? null : connection.getUri()); + wallet.setDaemonConnection(connection); + + // sync and reprocess messages on new thread + new Thread(() -> { + updateSyncing(); + + // reprocess pending payout messages + this.getProtocol().maybeReprocessPaymentReceivedMessage(false); + HavenoUtils.arbitrationManager.maybeReprocessDisputeClosedMessage(this, false); + }).start(); } private void updateSyncing() { - if (!isIdling()) syncWallet(); + if (!isIdling()) trySyncWallet(); else { long startSyncingInMs = ThreadLocalRandom.current().nextLong(0, getWalletRefreshPeriod()); // random time to start syncing UserThread.runAfter(() -> { - if (isInitialized) syncWallet(); + if (isInitialized) trySyncWallet(); }, startSyncingInMs / 1000l); } } @@ -1525,7 +1588,7 @@ public abstract class Trade implements Tradable, Model { if (isPayoutUnlocked()) return; // rescan spent if deposits unlocked - if (isDepositUnlocked()) getWallet().rescanSpent(); + if (isDepositsUnlocked()) getWallet().rescanSpent(); // get txs with outputs List txs; @@ -1538,7 +1601,7 @@ public abstract class Trade implements Tradable, Model { } // check deposit txs - if (!isDepositUnlocked()) { + if (!isDepositsUnlocked()) { if (txs.size() == 2) { setStateDepositsPublished(); boolean makerFirst = txs.get(0).getHash().equals(processModel.getMaker().getDepositTxHash()); @@ -1585,19 +1648,22 @@ public abstract class Trade implements Tradable, Model { } private boolean isIdling() { - return this instanceof ArbitratorTrade && isDepositConfirmed(); // arbitrator idles trade after deposits confirm + return this instanceof ArbitratorTrade && isDepositsConfirmed(); // arbitrator idles trade after deposits confirm } private void setStateDepositsPublished() { - if (!isDepositPublished()) setState(State.DEPOSIT_TXS_SEEN_IN_NETWORK); + if (!isDepositsPublished()) setState(State.DEPOSIT_TXS_SEEN_IN_NETWORK); } private void setStateDepositsConfirmed() { - if (!isDepositConfirmed()) setState(State.DEPOSIT_TXS_CONFIRMED_IN_BLOCKCHAIN); + if (!isDepositsConfirmed()) setState(State.DEPOSIT_TXS_CONFIRMED_IN_BLOCKCHAIN); } private void setStateDepositsUnlocked() { - if (!isDepositUnlocked()) setState(State.DEPOSIT_TXS_UNLOCKED_IN_BLOCKCHAIN); + if (!isDepositsUnlocked()) { + setState(State.DEPOSIT_TXS_UNLOCKED_IN_BLOCKCHAIN); + setStartTimeFromUnlockedTxs(); + } } private void setPayoutStatePublished() { @@ -1634,6 +1700,7 @@ public abstract class Trade implements Tradable, Model { .map(msg -> msg.toProtoNetworkEnvelope().getChatMessage()) .collect(Collectors.toList())) .setLockTime(lockTime) + .setStartTime(startTime) .setUid(uid); Optional.ofNullable(payoutTxId).ifPresent(builder::setPayoutTxId); @@ -1668,6 +1735,7 @@ public abstract class Trade implements Tradable, Model { trade.setMediationResultState(MediationResultState.fromProto(proto.getMediationResultState())); trade.setRefundResultState(RefundResultState.fromProto(proto.getRefundResultState())); trade.setLockTime(proto.getLockTime()); + trade.setStartTime(proto.getStartTime()); trade.setCounterCurrencyExtraData(ProtoUtil.stringOrNullFromProto(proto.getCounterCurrencyExtraData())); AssetTxProofResult persistedAssetTxProofResult = ProtoUtil.enumFromProto(AssetTxProofResult.class, proto.getAssetTxProofResult()); @@ -1722,6 +1790,7 @@ public abstract class Trade implements Tradable, Model { ",\n mediationResultState=" + mediationResultState + ",\n mediationResultStateProperty=" + mediationResultStateProperty + ",\n lockTime=" + lockTime + + ",\n startTime=" + startTime + ",\n refundResultState=" + refundResultState + ",\n refundResultStateProperty=" + refundResultStateProperty + "\n}"; diff --git a/core/src/main/java/bisq/core/trade/TradeManager.java b/core/src/main/java/bisq/core/trade/TradeManager.java index 1c0fbad3ff..a62726e8c5 100644 --- a/core/src/main/java/bisq/core/trade/TradeManager.java +++ b/core/src/main/java/bisq/core/trade/TradeManager.java @@ -369,6 +369,7 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi xmrWalletService.swapTradeEntryToAvailableEntry(addressEntry.getOfferId(), addressEntry.getContext()); }); + // notify that persisted trades initialized persistedTradesInitialized.set(true); // We do not include failed trades as they should not be counted anyway in the trade statistics @@ -1100,7 +1101,7 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi } private void scheduleDeletionIfUnfunded(Trade trade) { - if (trade.isDepositRequested() && !trade.isDepositPublished()) { + if (trade.isDepositRequested() && !trade.isDepositsPublished()) { log.warn("Scheduling to delete trade if unfunded for {} {}", trade.getClass().getSimpleName(), trade.getId()); UserThread.runAfter(() -> { if (isShutDown) return; diff --git a/core/src/main/java/bisq/core/trade/messages/PaymentReceivedMessage.java b/core/src/main/java/bisq/core/trade/messages/PaymentReceivedMessage.java index f9857f5732..7db0325cca 100644 --- a/core/src/main/java/bisq/core/trade/messages/PaymentReceivedMessage.java +++ b/core/src/main/java/bisq/core/trade/messages/PaymentReceivedMessage.java @@ -23,10 +23,8 @@ import bisq.network.p2p.NodeAddress; import bisq.common.app.Version; import bisq.common.proto.ProtoUtil; -import bisq.common.proto.network.NetworkEnvelope; import java.util.Optional; -import java.util.UUID; import lombok.EqualsAndHashCode; import lombok.Getter; @@ -124,7 +122,7 @@ public final class PaymentReceivedMessage extends TradeMailboxMessage { return getNetworkEnvelopeBuilder().setPaymentReceivedMessage(builder).build(); } - public static NetworkEnvelope fromProto(protobuf.PaymentReceivedMessage proto, String messageVersion) { + public static PaymentReceivedMessage fromProto(protobuf.PaymentReceivedMessage proto, String messageVersion) { // There is no method to check for a nullable non-primitive data type object but we know that all fields // are empty/null, so we check for the signature to see if we got a valid buyerSignedWitness. protobuf.AccountAgeWitness protoAccountAgeWitness = proto.getBuyerAccountAgeWitness(); diff --git a/core/src/main/java/bisq/core/trade/messages/SignContractRequest.java b/core/src/main/java/bisq/core/trade/messages/SignContractRequest.java index e35e180028..0336084872 100644 --- a/core/src/main/java/bisq/core/trade/messages/SignContractRequest.java +++ b/core/src/main/java/bisq/core/trade/messages/SignContractRequest.java @@ -20,14 +20,12 @@ package bisq.core.trade.messages; import bisq.core.proto.CoreProtoResolver; import bisq.network.p2p.DirectMessage; -import bisq.network.p2p.NodeAddress; import java.util.Optional; import javax.annotation.Nullable; import com.google.protobuf.ByteString; -import bisq.common.crypto.PubKeyRing; import bisq.common.proto.ProtoUtil; import bisq.common.util.Utilities; import lombok.EqualsAndHashCode; diff --git a/core/src/main/java/bisq/core/trade/protocol/BuyerProtocol.java b/core/src/main/java/bisq/core/trade/protocol/BuyerProtocol.java index 6c5acfabf1..933d00cf8b 100644 --- a/core/src/main/java/bisq/core/trade/protocol/BuyerProtocol.java +++ b/core/src/main/java/bisq/core/trade/protocol/BuyerProtocol.java @@ -55,7 +55,7 @@ public class BuyerProtocol extends DisputeProtocol { // re-send payment sent message if not arrived synchronized (trade) { - if (trade.getState().ordinal() >= Trade.State.BUYER_CONFIRMED_IN_UI_PAYMENT_SENT.ordinal() && trade.getState().ordinal() <= Trade.State.BUYER_SEND_FAILED_PAYMENT_SENT_MSG.ordinal()) { + if (trade.getState().ordinal() >= Trade.State.BUYER_SENT_PAYMENT_SENT_MSG.ordinal() && trade.getState().ordinal() <= Trade.State.BUYER_SEND_FAILED_PAYMENT_SENT_MSG.ordinal()) { latchTrade(); given(anyPhase(Trade.Phase.PAYMENT_SENT) .with(BuyerEvent.STARTUP)) diff --git a/core/src/main/java/bisq/core/trade/protocol/ProcessModel.java b/core/src/main/java/bisq/core/trade/protocol/ProcessModel.java index f04a553881..4327dcde60 100644 --- a/core/src/main/java/bisq/core/trade/protocol/ProcessModel.java +++ b/core/src/main/java/bisq/core/trade/protocol/ProcessModel.java @@ -31,10 +31,13 @@ import bisq.core.payment.payload.PaymentAccountPayload; import bisq.core.proto.CoreProtoResolver; import bisq.core.support.dispute.arbitration.arbitrator.ArbitratorManager; import bisq.core.support.dispute.mediation.mediator.MediatorManager; +import bisq.core.support.dispute.messages.DisputeClosedMessage; import bisq.core.support.dispute.refund.refundagent.RefundAgentManager; import bisq.core.trade.MakerTrade; import bisq.core.trade.Trade; import bisq.core.trade.TradeManager; +import bisq.core.trade.messages.PaymentReceivedMessage; +import bisq.core.trade.messages.PaymentSentMessage; import bisq.core.trade.messages.TradeMessage; import bisq.core.trade.statistics.ReferralIdService; import bisq.core.trade.statistics.TradeStatisticsManager; @@ -43,7 +46,7 @@ import bisq.core.user.User; import bisq.network.p2p.AckMessage; import bisq.network.p2p.NodeAddress; import bisq.network.p2p.P2PService; - +import bisq.common.app.Version; import bisq.common.crypto.KeyRing; import bisq.common.crypto.PubKeyRing; import bisq.common.proto.ProtoUtil; @@ -175,6 +178,18 @@ public class ProcessModel implements Model, PersistablePayload { @Getter @Setter private boolean isDepositsConfirmedMessagesDelivered; + @Nullable + @Setter + @Getter + private PaymentSentMessage paymentSentMessage; + @Nullable + @Setter + @Getter + private PaymentReceivedMessage paymentReceivedMessage; + @Nullable + @Setter + @Getter + private DisputeClosedMessage disputeClosedMessage; // We want to indicate the user the state of the message delivery of the // PaymentSentMessage. As well we do an automatic re-send in case it was not ACKed yet. @@ -233,6 +248,9 @@ public class ProcessModel implements Model, PersistablePayload { Optional.ofNullable(tempTradingPeerNodeAddress).ifPresent(e -> builder.setTempTradingPeerNodeAddress(tempTradingPeerNodeAddress.toProtoMessage())); Optional.ofNullable(makerSignature).ifPresent(e -> builder.setMakerSignature(makerSignature)); Optional.ofNullable(multisigAddress).ifPresent(e -> builder.setMultisigAddress(multisigAddress)); + Optional.ofNullable(paymentSentMessage).ifPresent(e -> builder.setPaymentSentMessage(paymentSentMessage.toProtoNetworkEnvelope().getPaymentSentMessage())); + Optional.ofNullable(paymentReceivedMessage).ifPresent(e -> builder.setPaymentReceivedMessage(paymentReceivedMessage.toProtoNetworkEnvelope().getPaymentReceivedMessage())); + Optional.ofNullable(disputeClosedMessage).ifPresent(e -> builder.setDisputeClosedMessage(disputeClosedMessage.toProtoNetworkEnvelope().getDisputeClosedMessage())); return builder.build(); } @@ -267,6 +285,9 @@ public class ProcessModel implements Model, PersistablePayload { MessageState paymentStartedMessageState = ProtoUtil.enumFromProto(MessageState.class, paymentStartedMessageStateString); processModel.setPaymentStartedMessageState(paymentStartedMessageState); + processModel.setPaymentSentMessage(proto.hasPaymentSentMessage() ? PaymentSentMessage.fromProto(proto.getPaymentSentMessage(), Version.getP2PMessageVersion()) : null); + processModel.setPaymentReceivedMessage(proto.hasPaymentReceivedMessage() ? PaymentReceivedMessage.fromProto(proto.getPaymentReceivedMessage(), Version.getP2PMessageVersion()) : null); + processModel.setDisputeClosedMessage(proto.hasDisputeClosedMessage() ? DisputeClosedMessage.fromProto(proto.getDisputeClosedMessage(), Version.getP2PMessageVersion()) : null); return processModel; } diff --git a/core/src/main/java/bisq/core/trade/protocol/SellerProtocol.java b/core/src/main/java/bisq/core/trade/protocol/SellerProtocol.java index f315c834bf..6dcc31afe2 100644 --- a/core/src/main/java/bisq/core/trade/protocol/SellerProtocol.java +++ b/core/src/main/java/bisq/core/trade/protocol/SellerProtocol.java @@ -51,7 +51,7 @@ public class SellerProtocol extends DisputeProtocol { // re-send payment received message if not arrived synchronized (trade) { - if (trade.getState().ordinal() >= Trade.State.SELLER_CONFIRMED_IN_UI_PAYMENT_RECEIPT.ordinal() && trade.getState().ordinal() <= Trade.State.SELLER_SEND_FAILED_PAYMENT_RECEIVED_MSG.ordinal()) { + if (trade.getState().ordinal() >= Trade.State.SELLER_SENT_PAYMENT_RECEIVED_MSG.ordinal() && trade.getState().ordinal() <= Trade.State.SELLER_SEND_FAILED_PAYMENT_RECEIVED_MSG.ordinal()) { latchTrade(); given(anyPhase(Trade.Phase.PAYMENT_RECEIVED) .with(SellerEvent.STARTUP)) diff --git a/core/src/main/java/bisq/core/trade/protocol/TradeProtocol.java b/core/src/main/java/bisq/core/trade/protocol/TradeProtocol.java index 2304d2b45b..7e759965d0 100644 --- a/core/src/main/java/bisq/core/trade/protocol/TradeProtocol.java +++ b/core/src/main/java/bisq/core/trade/protocol/TradeProtocol.java @@ -74,7 +74,6 @@ import java.util.Comparator; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.fxmisc.easybind.EasyBind; @@ -94,6 +93,7 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D protected TradeResultHandler tradeResultHandler; protected ErrorMessageHandler errorMessageHandler; + private int reprocessPaymentReceivedMessageCount; /////////////////////////////////////////////////////////////////////////////////////////// // Constructor @@ -267,6 +267,23 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D } }); } + + // reprocess payout messages if pending + maybeReprocessPaymentReceivedMessage(true); + HavenoUtils.arbitrationManager.maybeReprocessDisputeClosedMessage(trade, true); + } + + public void maybeReprocessPaymentReceivedMessage(boolean reprocessOnError) { + synchronized (trade) { + + // skip if no need to reprocess + if (trade.isSeller() || trade.getProcessModel().getPaymentReceivedMessage() == null || trade.getState().ordinal() >= Trade.State.SELLER_SENT_PAYMENT_RECEIVED_MSG.ordinal()) { + return; + } + + log.warn("Reprocessing payment received message for {} {}", trade.getClass().getSimpleName(), trade.getId()); + new Thread(() -> handle(trade.getProcessModel().getPaymentReceivedMessage(), trade.getProcessModel().getPaymentReceivedMessage().getSenderNodeAddress(), reprocessOnError)).start(); + } } public void handleInitMultisigRequest(InitMultisigRequest request, NodeAddress sender) { @@ -462,17 +479,23 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D // received by buyer and arbitrator protected void handle(PaymentReceivedMessage message, NodeAddress peer) { + handle(message, peer, true); + } + + private void handle(PaymentReceivedMessage message, NodeAddress peer, boolean reprocessOnError) { System.out.println(getClass().getSimpleName() + ".handle(PaymentReceivedMessage)"); if (!(trade instanceof BuyerTrade || trade instanceof ArbitratorTrade)) { log.warn("Ignoring PaymentReceivedMessage since not buyer or arbitrator"); return; } - if (trade instanceof ArbitratorTrade && !trade.isPayoutUnlocked()) trade.syncWallet(); // arbitrator syncs slowly after deposits confirmed synchronized (trade) { latchTrade(); Validator.checkTradeId(processModel.getOfferId(), message); processModel.setTradeMessage(message); - expect(anyPhase(trade.isBuyer() ? new Trade.Phase[] {Trade.Phase.PAYMENT_SENT, Trade.Phase.PAYMENT_RECEIVED} : new Trade.Phase[] {Trade.Phase.DEPOSITS_UNLOCKED, Trade.Phase.PAYMENT_SENT}) + expect(anyPhase( + trade.isBuyer() ? new Trade.Phase[] {Trade.Phase.PAYMENT_SENT, Trade.Phase.PAYMENT_RECEIVED} : + trade.isArbitrator() ? new Trade.Phase[] {Trade.Phase.DEPOSITS_CONFIRMED, Trade.Phase.DEPOSITS_UNLOCKED, Trade.Phase.PAYMENT_SENT} : // arbitrator syncs slowly after deposits confirmed + new Trade.Phase[] {Trade.Phase.DEPOSITS_UNLOCKED, Trade.Phase.PAYMENT_SENT}) .with(message) .from(peer)) .setup(tasks( @@ -482,7 +505,19 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D handleTaskRunnerSuccess(peer, message); }, errorMessage -> { - handleTaskRunnerFault(peer, message, errorMessage); + log.warn("Error processing payment received message: " + errorMessage); + processModel.getTradeManager().requestPersistence(); + + // reprocess message depending on error + if (trade.getProcessModel().getPaymentReceivedMessage() != null) { + UserThread.runAfter(() -> { + reprocessPaymentReceivedMessageCount++; + maybeReprocessPaymentReceivedMessage(reprocessOnError); + }, trade.getReprocessDelayInSeconds(reprocessPaymentReceivedMessageCount)); + } else { + handleTaskRunnerFault(peer, message, errorMessage); // otherwise send nack + } + unlatchTrade(); }))) .executeTasks(true); awaitTradeLatch(); @@ -548,8 +583,7 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D private void onAckMessage(AckMessage ackMessage, NodeAddress peer) { // We handle the ack for PaymentSentMessage and DepositTxAndDelayedPayoutTxMessage // as we support automatic re-send of the msg in case it was not ACKed after a certain time - // TODO (woodser): add AckMessage for InitTradeRequest and support automatic re-send ? - if (ackMessage.getSourceMsgClassName().equals(PaymentSentMessage.class.getSimpleName())) { + if (ackMessage.getSourceMsgClassName().equals(PaymentSentMessage.class.getSimpleName()) && trade.getTradingPeer(peer) == trade.getSeller()) { processModel.setPaymentStartedAckMessage(ackMessage); } diff --git a/core/src/main/java/bisq/core/trade/protocol/TradingPeer.java b/core/src/main/java/bisq/core/trade/protocol/TradingPeer.java index 68f8a45fa3..71c4d1ad2e 100644 --- a/core/src/main/java/bisq/core/trade/protocol/TradingPeer.java +++ b/core/src/main/java/bisq/core/trade/protocol/TradingPeer.java @@ -21,9 +21,7 @@ import bisq.core.account.witness.AccountAgeWitness; import bisq.core.btc.model.RawTransactionInput; import bisq.core.payment.payload.PaymentAccountPayload; import bisq.core.proto.CoreProtoResolver; -import bisq.core.trade.messages.PaymentSentMessage; import bisq.network.p2p.NodeAddress; -import bisq.common.app.Version; import bisq.common.crypto.PubKeyRing; import bisq.common.proto.ProtoUtil; import bisq.common.proto.persistable.PersistablePayload; @@ -131,8 +129,6 @@ public final class TradingPeer implements PersistablePayload { private String depositTxKey; @Nullable private String updatedMultisigHex; - @Nullable - private PaymentSentMessage paymentSentMessage; public TradingPeer() { } @@ -173,7 +169,6 @@ public final class TradingPeer implements PersistablePayload { Optional.ofNullable(depositTxHex).ifPresent(e -> builder.setDepositTxHex(depositTxHex)); Optional.ofNullable(depositTxKey).ifPresent(e -> builder.setDepositTxKey(depositTxKey)); Optional.ofNullable(updatedMultisigHex).ifPresent(e -> builder.setUpdatedMultisigHex(updatedMultisigHex)); - Optional.ofNullable(paymentSentMessage).ifPresent(e -> builder.setPaymentSentMessage(paymentSentMessage.toProtoNetworkEnvelope().getPaymentSentMessage())); builder.setCurrentDate(currentDate); return builder.build(); @@ -224,7 +219,6 @@ public final class TradingPeer implements PersistablePayload { tradingPeer.setDepositTxHex(ProtoUtil.stringOrNullFromProto(proto.getDepositTxHex())); tradingPeer.setDepositTxKey(ProtoUtil.stringOrNullFromProto(proto.getDepositTxKey())); tradingPeer.setUpdatedMultisigHex(ProtoUtil.stringOrNullFromProto(proto.getUpdatedMultisigHex())); - tradingPeer.setPaymentSentMessage(proto.hasPaymentSentMessage() ? PaymentSentMessage.fromProto(proto.getPaymentSentMessage(), Version.getP2PMessageVersion()) : null); return tradingPeer; } } diff --git a/core/src/main/java/bisq/core/trade/protocol/tasks/BuyerPreparePaymentSentMessage.java b/core/src/main/java/bisq/core/trade/protocol/tasks/BuyerPreparePaymentSentMessage.java index b955791824..afd70a6908 100644 --- a/core/src/main/java/bisq/core/trade/protocol/tasks/BuyerPreparePaymentSentMessage.java +++ b/core/src/main/java/bisq/core/trade/protocol/tasks/BuyerPreparePaymentSentMessage.java @@ -52,6 +52,13 @@ public class BuyerPreparePaymentSentMessage extends TradeTask { try { runInterceptHook(); + // skip if already created + if (processModel.getPaymentSentMessage() != null) { + log.warn("Skipping preparation of payment sent message since it's already created for {} {}", trade.getClass().getSimpleName(), trade.getId()); + complete(); + return; + } + // validate state Preconditions.checkNotNull(trade.getSeller().getPaymentAccountPayload(), "Seller's payment account payload is null"); Preconditions.checkNotNull(trade.getAmount(), "trade.getTradeAmount() must not be null"); @@ -67,7 +74,7 @@ public class BuyerPreparePaymentSentMessage extends TradeTask { List updatedMultisigHexes = new ArrayList(); if (trade.getSeller().getUpdatedMultisigHex() != null) updatedMultisigHexes.add(trade.getSeller().getUpdatedMultisigHex()); if (trade.getArbitrator().getUpdatedMultisigHex() != null) updatedMultisigHexes.add(trade.getArbitrator().getUpdatedMultisigHex()); - if (!updatedMultisigHexes.isEmpty()) { + if (!updatedMultisigHexes.isEmpty()) { multisigWallet.importMultisigHex(updatedMultisigHexes.toArray(new String[0])); // TODO (monero-project): fails if multisig hex imported individually trade.saveWallet(); } diff --git a/core/src/main/java/bisq/core/trade/protocol/tasks/BuyerSendPaymentSentMessage.java b/core/src/main/java/bisq/core/trade/protocol/tasks/BuyerSendPaymentSentMessage.java index 866ac6cecd..66863f570d 100644 --- a/core/src/main/java/bisq/core/trade/protocol/tasks/BuyerSendPaymentSentMessage.java +++ b/core/src/main/java/bisq/core/trade/protocol/tasks/BuyerSendPaymentSentMessage.java @@ -73,7 +73,7 @@ public abstract class BuyerSendPaymentSentMessage extends SendMailboxMessageTask @Override protected TradeMailboxMessage getTradeMailboxMessage(String tradeId) { - if (trade.getSelf().getPaymentSentMessage() == null) { + if (processModel.getPaymentSentMessage() == null) { // We do not use a real unique ID here as we want to be able to re-send the exact same message in case the // peer does not respond with an ACK msg in a certain time interval. To avoid that we get dangling mailbox @@ -99,12 +99,13 @@ public abstract class BuyerSendPaymentSentMessage extends SendMailboxMessageTask String messageAsJson = JsonUtil.objectToJson(message); byte[] sig = Sig.sign(processModel.getP2PService().getKeyRing().getSignatureKeyPair().getPrivate(), messageAsJson.getBytes(Charsets.UTF_8)); message.setBuyerSignature(sig); - trade.getSelf().setPaymentSentMessage(message); + processModel.setPaymentSentMessage(message); + trade.requestPersistence(); } catch (Exception e) { throw new RuntimeException (e); } } - return trade.getSelf().getPaymentSentMessage(); + return processModel.getPaymentSentMessage(); } @Override diff --git a/core/src/main/java/bisq/core/trade/protocol/tasks/ProcessPaymentReceivedMessage.java b/core/src/main/java/bisq/core/trade/protocol/tasks/ProcessPaymentReceivedMessage.java index 5a50137671..53860114d3 100644 --- a/core/src/main/java/bisq/core/trade/protocol/tasks/ProcessPaymentReceivedMessage.java +++ b/core/src/main/java/bisq/core/trade/protocol/tasks/ProcessPaymentReceivedMessage.java @@ -36,6 +36,8 @@ import static com.google.common.base.Preconditions.checkNotNull; import java.util.ArrayList; import java.util.List; +import org.apache.commons.lang3.StringUtils; + @Slf4j public class ProcessPaymentReceivedMessage extends TradeTask { public ProcessPaymentReceivedMessage(TaskRunner taskHandler, Trade trade) { @@ -46,6 +48,7 @@ public class ProcessPaymentReceivedMessage extends TradeTask { protected void run() { try { runInterceptHook(); + log.debug("current trade state " + trade.getState()); PaymentReceivedMessage message = (PaymentReceivedMessage) processModel.getTradeMessage(); checkNotNull(message); @@ -54,6 +57,12 @@ public class ProcessPaymentReceivedMessage extends TradeTask { // verify signature of payment received message HavenoUtils.verifyPaymentReceivedMessage(trade, message); + + // save message for reprocessing + processModel.setPaymentReceivedMessage(message); + trade.requestPersistence(); + + // set state trade.getSeller().setUpdatedMultisigHex(message.getUpdatedMultisigHex()); trade.getBuyer().setUpdatedMultisigHex(message.getPaymentSentMessage().getUpdatedMultisigHex()); trade.getBuyer().setAccountAgeWitness(message.getBuyerAccountAgeWitness()); @@ -63,13 +72,16 @@ public class ProcessPaymentReceivedMessage extends TradeTask { if (trade.getSeller().getNodeAddress().equals(trade.getBuyer().getNodeAddress())) trade.getBuyer().setNodeAddress(null); // tests can reuse addresses // close open disputes - if (trade.getDisputeState().ordinal() >= Trade.DisputeState.DISPUTE_OPENED.ordinal()) { + if (trade.getDisputeState().ordinal() >= Trade.DisputeState.DISPUTE_REQUESTED.ordinal()) { trade.setDisputeStateIfProgress(Trade.DisputeState.DISPUTE_CLOSED); for (Dispute dispute : trade.getDisputes()) { dispute.setIsClosed(); } } + // ensure connected to monero network + trade.checkWalletConnection(); + // process payout tx unless already unlocked if (!trade.isPayoutUnlocked()) processPayoutTx(message); @@ -83,25 +95,32 @@ public class ProcessPaymentReceivedMessage extends TradeTask { // complete trade.setStateIfProgress(Trade.State.SELLER_SENT_PAYMENT_RECEIVED_MSG); // arbitrator auto completes when payout published - processModel.getTradeManager().requestPersistence(); + trade.requestPersistence(); complete(); } catch (Throwable t) { + + // do not reprocess illegal argument + if (t instanceof IllegalArgumentException) { + processModel.setPaymentReceivedMessage(null); // do not reprocess + trade.requestPersistence(); + } + failed(t); } } private void processPayoutTx(PaymentReceivedMessage message) { + // sync and save wallet + trade.syncWallet(); + trade.saveWallet(); + // import multisig hex List updatedMultisigHexes = new ArrayList(); if (trade.getSeller().getUpdatedMultisigHex() != null) updatedMultisigHexes.add(trade.getSeller().getUpdatedMultisigHex()); if (trade.getArbitrator().getUpdatedMultisigHex() != null) updatedMultisigHexes.add(trade.getArbitrator().getUpdatedMultisigHex()); if (!updatedMultisigHexes.isEmpty()) trade.getWallet().importMultisigHex(updatedMultisigHexes.toArray(new String[0])); // TODO (monero-project): fails if multisig hex imported individually - // sync and save wallet - trade.syncWallet(); - trade.saveWallet(); - // handle if payout tx not published if (!trade.isPayoutPublished()) { @@ -110,18 +129,23 @@ public class ProcessPaymentReceivedMessage extends TradeTask { if (trade instanceof ArbitratorTrade && !isSigned && message.isDeferPublishPayout()) { log.info("Deferring signing and publishing payout tx for {} {}", trade.getClass().getSimpleName(), trade.getId()); GenUtils.waitFor(Trade.DEFER_PUBLISH_MS); - trade.syncWallet(); + if (!trade.isPayoutUnlocked()) trade.syncWallet(); } // verify and publish payout tx if (!trade.isPayoutPublished()) { if (isSigned) { - log.info("{} publishing signed payout tx from seller", trade.getClass().getSimpleName()); + log.info("{} {} publishing signed payout tx from seller", trade.getClass().getSimpleName(), trade.getId()); trade.verifyPayoutTx(message.getSignedPayoutTxHex(), false, true); } else { - log.info("{} verifying, signing, and publishing seller's payout tx", trade.getClass().getSimpleName()); try { - trade.verifyPayoutTx(message.getUnsignedPayoutTxHex(), true, true); + if (StringUtils.equals(trade.getPayoutTxHex(), trade.getProcessModel().getPaymentSentMessage().getPayoutTxHex())) { // unsigned + log.info("{} {} verifying, signing, and publishing seller's payout tx", trade.getClass().getSimpleName(), trade.getId()); + trade.verifyPayoutTx(message.getUnsignedPayoutTxHex(), true, true); + } else { + log.info("{} {} re-verifying and publishing payout tx", trade.getClass().getSimpleName(), trade.getId()); + trade.verifyPayoutTx(trade.getPayoutTxHex(), false, true); + } } catch (Exception e) { if (trade.isPayoutPublished()) log.info("Payout tx already published for {} {}", trade.getClass().getName(), trade.getId()); else throw e; diff --git a/core/src/main/java/bisq/core/trade/protocol/tasks/ProcessPaymentSentMessage.java b/core/src/main/java/bisq/core/trade/protocol/tasks/ProcessPaymentSentMessage.java index af31d25bf6..2dd4605c29 100644 --- a/core/src/main/java/bisq/core/trade/protocol/tasks/ProcessPaymentSentMessage.java +++ b/core/src/main/java/bisq/core/trade/protocol/tasks/ProcessPaymentSentMessage.java @@ -44,10 +44,10 @@ public class ProcessPaymentSentMessage extends TradeTask { // verify signature of payment sent message HavenoUtils.verifyPaymentSentMessage(trade, message); - // update buyer info + // set state + processModel.setPaymentSentMessage(message); trade.setPayoutTxHex(message.getPayoutTxHex()); trade.getBuyer().setUpdatedMultisigHex(message.getUpdatedMultisigHex()); - trade.getBuyer().setPaymentSentMessage(message); trade.getSeller().setAccountAgeWitness(message.getSellerAccountAgeWitness()); // if seller, decrypt buyer's payment account payload @@ -62,7 +62,7 @@ public class ProcessPaymentSentMessage extends TradeTask { String counterCurrencyExtraData = message.getCounterCurrencyExtraData(); if (counterCurrencyExtraData != null && counterCurrencyExtraData.length() < 100) trade.setCounterCurrencyExtraData(counterCurrencyExtraData); trade.setStateIfProgress(trade.isSeller() ? Trade.State.SELLER_RECEIVED_PAYMENT_SENT_MSG : Trade.State.BUYER_SENT_PAYMENT_SENT_MSG); - processModel.getTradeManager().requestPersistence(); + trade.requestPersistence(); complete(); } catch (Throwable t) { failed(t); diff --git a/core/src/main/java/bisq/core/trade/protocol/tasks/SellerPreparePaymentReceivedMessage.java b/core/src/main/java/bisq/core/trade/protocol/tasks/SellerPreparePaymentReceivedMessage.java index 1a5b27db91..a253a9ef99 100644 --- a/core/src/main/java/bisq/core/trade/protocol/tasks/SellerPreparePaymentReceivedMessage.java +++ b/core/src/main/java/bisq/core/trade/protocol/tasks/SellerPreparePaymentReceivedMessage.java @@ -17,7 +17,6 @@ package bisq.core.trade.protocol.tasks; -import bisq.core.btc.wallet.XmrWalletService; import bisq.core.trade.Trade; import java.util.ArrayList; @@ -42,27 +41,39 @@ public class SellerPreparePaymentReceivedMessage extends TradeTask { try { runInterceptHook(); - // import multisig hex - MoneroWallet multisigWallet = trade.getWallet(); - List updatedMultisigHexes = new ArrayList(); - if (trade.getBuyer().getUpdatedMultisigHex() != null) updatedMultisigHexes.add(trade.getBuyer().getUpdatedMultisigHex()); - if (trade.getArbitrator().getUpdatedMultisigHex() != null) updatedMultisigHexes.add(trade.getArbitrator().getUpdatedMultisigHex()); - if (!updatedMultisigHexes.isEmpty()) { - multisigWallet.importMultisigHex(updatedMultisigHexes.toArray(new String[0])); - trade.saveWallet(); - } + // check connection + trade.checkWalletConnection(); - // verify, sign, and publish payout tx if given. otherwise create payout tx - if (trade.getPayoutTxHex() != null) { - log.info("Seller verifying, signing, and publishing payout tx for trade {}", trade.getId()); - trade.verifyPayoutTx(trade.getPayoutTxHex(), true, true); - } else { + // handle first time preparation + if (processModel.getPaymentReceivedMessage() == null) { - // create unsigned payout tx - log.info("Seller creating unsigned payout tx for trade {}", trade.getId()); - MoneroTxWallet payoutTx = trade.createPayoutTx(); - trade.setPayoutTx(payoutTx); - trade.setPayoutTxHex(payoutTx.getTxSet().getMultisigTxHex()); + // import multisig hex + MoneroWallet multisigWallet = trade.getWallet(); + List updatedMultisigHexes = new ArrayList(); + if (trade.getBuyer().getUpdatedMultisigHex() != null) updatedMultisigHexes.add(trade.getBuyer().getUpdatedMultisigHex()); + if (trade.getArbitrator().getUpdatedMultisigHex() != null) updatedMultisigHexes.add(trade.getArbitrator().getUpdatedMultisigHex()); + if (!updatedMultisigHexes.isEmpty()) { + multisigWallet.importMultisigHex(updatedMultisigHexes.toArray(new String[0])); + trade.saveWallet(); + } + + // verify, sign, and publish payout tx if given. otherwise create payout tx + if (trade.getPayoutTxHex() != null) { + log.info("Seller verifying, signing, and publishing payout tx for trade {}", trade.getId()); + trade.verifyPayoutTx(trade.getPayoutTxHex(), true, true); + } else { + + // create unsigned payout tx + log.info("Seller creating unsigned payout tx for trade {}", trade.getId()); + MoneroTxWallet payoutTx = trade.createPayoutTx(); + trade.setPayoutTx(payoutTx); + trade.setPayoutTxHex(payoutTx.getTxSet().getMultisigTxHex()); + } + } else if (processModel.getPaymentReceivedMessage().getSignedPayoutTxHex() != null && !trade.isPayoutPublished()) { + + // republish payout tx from previous message + log.info("Seller re-verifying and publishing payout tx for trade {}", trade.getId()); + trade.verifyPayoutTx(processModel.getPaymentReceivedMessage().getSignedPayoutTxHex(), false, true); } processModel.getTradeManager().requestPersistence(); diff --git a/core/src/main/java/bisq/core/trade/protocol/tasks/SellerSendPaymentReceivedMessage.java b/core/src/main/java/bisq/core/trade/protocol/tasks/SellerSendPaymentReceivedMessage.java index 8b1a3bad88..b127628911 100644 --- a/core/src/main/java/bisq/core/trade/protocol/tasks/SellerSendPaymentReceivedMessage.java +++ b/core/src/main/java/bisq/core/trade/protocol/tasks/SellerSendPaymentReceivedMessage.java @@ -39,8 +39,8 @@ import com.google.common.base.Charsets; @Slf4j @EqualsAndHashCode(callSuper = true) public abstract class SellerSendPaymentReceivedMessage extends SendMailboxMessageTask { - SignedWitness signedWitness = null; PaymentReceivedMessage message = null; + SignedWitness signedWitness = null; public SellerSendPaymentReceivedMessage(TaskRunner taskHandler, Trade trade) { super(taskHandler, trade); @@ -87,7 +87,7 @@ public abstract class SellerSendPaymentReceivedMessage extends SendMailboxMessag trade.getState().ordinal() >= Trade.State.SELLER_SAW_ARRIVED_PAYMENT_RECEIVED_MSG.ordinal(), // informs to expect payout trade.getTradingPeer().getAccountAgeWitness(), signedWitness, - trade.getBuyer().getPaymentSentMessage() + processModel.getPaymentSentMessage() ); // sign message @@ -95,6 +95,8 @@ public abstract class SellerSendPaymentReceivedMessage extends SendMailboxMessag String messageAsJson = JsonUtil.objectToJson(message); byte[] sig = Sig.sign(processModel.getP2PService().getKeyRing().getSignatureKeyPair().getPrivate(), messageAsJson.getBytes(Charsets.UTF_8)); message.setSellerSignature(sig); + processModel.setPaymentReceivedMessage(message); + trade.requestPersistence(); } catch (Exception e) { throw new RuntimeException(e); } diff --git a/core/src/main/java/bisq/core/trade/protocol/tasks/SellerSendPaymentReceivedMessageToBuyer.java b/core/src/main/java/bisq/core/trade/protocol/tasks/SellerSendPaymentReceivedMessageToBuyer.java index c4571c6777..23f55be893 100644 --- a/core/src/main/java/bisq/core/trade/protocol/tasks/SellerSendPaymentReceivedMessageToBuyer.java +++ b/core/src/main/java/bisq/core/trade/protocol/tasks/SellerSendPaymentReceivedMessageToBuyer.java @@ -18,6 +18,8 @@ package bisq.core.trade.protocol.tasks; import bisq.core.trade.Trade; +import bisq.core.trade.messages.PaymentReceivedMessage; +import bisq.core.trade.messages.TradeMailboxMessage; import bisq.core.trade.messages.TradeMessage; import bisq.network.p2p.NodeAddress; import bisq.common.crypto.PubKeyRing; @@ -34,6 +36,15 @@ public class SellerSendPaymentReceivedMessageToBuyer extends SellerSendPaymentRe super(taskHandler, trade); } + + @Override + protected TradeMailboxMessage getTradeMailboxMessage(String tradeId) { + if (processModel.getPaymentReceivedMessage() == null) { + processModel.setPaymentReceivedMessage((PaymentReceivedMessage) super.getTradeMailboxMessage(tradeId)); // save payment received message for buyer + } + return processModel.getPaymentReceivedMessage(); + } + protected NodeAddress getReceiverNodeAddress() { return trade.getBuyer().getNodeAddress(); } diff --git a/core/src/main/resources/i18n/displayStrings.properties b/core/src/main/resources/i18n/displayStrings.properties index 339b52fb39..bbc96c5c43 100644 --- a/core/src/main/resources/i18n/displayStrings.properties +++ b/core/src/main/resources/i18n/displayStrings.properties @@ -1139,6 +1139,7 @@ support.role=Role support.agent=Support agent support.state=State support.chat=Chat +support.requested=Requested support.closed=Closed support.open=Open support.process=Process @@ -1967,6 +1968,7 @@ tradeDetailsWindow.txFee=Mining fee tradeDetailsWindow.tradingPeersOnion=Trading peers onion address tradeDetailsWindow.tradingPeersPubKeyHash=Trading peers pubkey hash tradeDetailsWindow.tradeState=Trade state +tradeDetailsWindow.tradePhase=Trade phase tradeDetailsWindow.agentAddresses=Arbitrator/Mediator tradeDetailsWindow.detailData=Detail data diff --git a/desktop/src/main/java/bisq/desktop/main/overlays/windows/OfferDetailsWindow.java b/desktop/src/main/java/bisq/desktop/main/overlays/windows/OfferDetailsWindow.java index 72704096b7..399e8e535f 100644 --- a/desktop/src/main/java/bisq/desktop/main/overlays/windows/OfferDetailsWindow.java +++ b/desktop/src/main/java/bisq/desktop/main/overlays/windows/OfferDetailsWindow.java @@ -409,7 +409,7 @@ public class OfferDetailsWindow extends Overlay { placeOfferHandlerOptional.ifPresent(Runnable::run); } else { State lastState = Trade.State.ARBITRATOR_PUBLISHED_DEPOSIT_TXS; - spinnerInfoLabel.setText(Res.get("takeOffer.fundsBox.takeOfferSpinnerInfo") + " 1/" + (lastState.ordinal())); + spinnerInfoLabel.setText(Res.get("takeOffer.fundsBox.takeOfferSpinnerInfo") + " 1/" + (lastState.ordinal() + 1)); takeOfferHandlerOptional.ifPresent(Runnable::run); // update trade state progress @@ -417,7 +417,7 @@ public class OfferDetailsWindow extends Overlay { Trade trade = tradeManager.getTrade(offer.getId()); if (trade == null) return; tradeStateSubscription = EasyBind.subscribe(trade.stateProperty(), newState -> { - String progress = (newState.ordinal() + 1) + "/" + (lastState.ordinal()); + String progress = (newState.ordinal() + 1) + "/" + (lastState.ordinal() + 1); spinnerInfoLabel.setText(Res.get("takeOffer.fundsBox.takeOfferSpinnerInfo") + " " + progress); // unsubscribe when done diff --git a/desktop/src/main/java/bisq/desktop/main/overlays/windows/TradeDetailsWindow.java b/desktop/src/main/java/bisq/desktop/main/overlays/windows/TradeDetailsWindow.java index 7b66eb4c63..e2fdcffce7 100644 --- a/desktop/src/main/java/bisq/desktop/main/overlays/windows/TradeDetailsWindow.java +++ b/desktop/src/main/java/bisq/desktop/main/overlays/windows/TradeDetailsWindow.java @@ -299,7 +299,7 @@ public class TradeDetailsWindow extends Overlay { textArea.scrollTopProperty().addListener(changeListener); textArea.setScrollTop(30); - addConfirmationLabelTextField(gridPane, ++rowIndex, Res.get("tradeDetailsWindow.tradeState"), trade.getPhase().name()); + addConfirmationLabelTextField(gridPane, ++rowIndex, Res.get("tradeDetailsWindow.tradePhase"), trade.getPhase().name()); } Tuple3 tuple = add2ButtonsWithBox(gridPane, ++rowIndex, @@ -322,10 +322,13 @@ public class TradeDetailsWindow extends Overlay { viewContractButton.setOnAction(e -> { TextArea textArea = new HavenoTextArea(); textArea.setText(trade.getContractAsJson()); - String data = "Contract as json:\n"; + String data = "Trade state: " + trade.getState(); + data += "\nTrade payout state: " + trade.getPayoutState(); + data += "\nTrade dispute state: " + trade.getDisputeState(); + data += "\n\nContract as json:\n"; data += trade.getContractAsJson(); data += "\n\nOther detail data:"; - if (!trade.isDepositPublished()) { + if (!trade.isDepositsPublished()) { data += "\n\n" + (trade.getMaker() == trade.getBuyer() ? "Buyer" : "Seller") + " as maker reserve tx hex: " + trade.getMaker().getReserveTxHex(); data += "\n\n" + (trade.getTaker() == trade.getBuyer() ? "Buyer" : "Seller") + " as taker reserve tx hex: " + trade.getTaker().getReserveTxHex(); } diff --git a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/PendingTradesViewModel.java b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/PendingTradesViewModel.java index 09f7959e48..87b0515bdf 100644 --- a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/PendingTradesViewModel.java +++ b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/PendingTradesViewModel.java @@ -32,7 +32,6 @@ import bisq.core.provider.mempool.MempoolService; import bisq.core.trade.ArbitratorTrade; import bisq.core.trade.BuyerTrade; import bisq.core.trade.ClosedTradableManager; -import bisq.core.trade.Contract; import bisq.core.trade.HavenoUtils; import bisq.core.trade.SellerTrade; import bisq.core.trade.Trade; @@ -433,21 +432,19 @@ public class PendingTradesViewModel extends ActivatableWithDataModel= 3 && !trade.hasFailed()) { String key = "tradeUnconfirmedTooLong_" + trade.getShortId(); if (DontShowAgainLookup.showAgain(key)) { diff --git a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/buyer/BuyerStep1View.java b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/buyer/BuyerStep1View.java index 844dd50504..209a407263 100644 --- a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/buyer/BuyerStep1View.java +++ b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/buyer/BuyerStep1View.java @@ -37,7 +37,7 @@ public class BuyerStep1View extends TradeStepView { super.onPendingTradesInitialized(); //validatePayoutTx(); // TODO (woodser): no payout tx in xmr integration, do something else? //validateDepositInputs(); - checkForTimeout(); + checkForUnconfirmedTimeout(); } diff --git a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/buyer/BuyerStep2View.java b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/buyer/BuyerStep2View.java index f5fa9ffadf..c37d368bcc 100644 --- a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/buyer/BuyerStep2View.java +++ b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/buyer/BuyerStep2View.java @@ -17,7 +17,6 @@ package bisq.desktop.main.portfolio.pendingtrades.steps.buyer; -import bisq.desktop.components.AutoTooltipButton; import bisq.desktop.components.BusyAnimation; import bisq.desktop.components.TextFieldWithCopyIcon; import bisq.desktop.components.TitledGroupBg; @@ -155,7 +154,7 @@ public class BuyerStep2View extends TradeStepView { if (timeoutTimer != null) timeoutTimer.stop(); - if (trade.isDepositUnlocked() && !trade.isPaymentSent()) { + if (trade.isDepositsUnlocked() && !trade.isPaymentSent()) { showPopup(); } else if (state.ordinal() <= Trade.State.BUYER_SEND_FAILED_PAYMENT_SENT_MSG.ordinal()) { if (!trade.hasFailed()) { @@ -481,6 +480,10 @@ public class BuyerStep2View extends TradeStepView { return; } + if (!model.dataModel.isReadyForTxBroadcast()) { + return; + } + PaymentAccountPayload sellersPaymentAccountPayload = model.dataModel.getSellersPaymentAccountPayload(); Trade trade = checkNotNull(model.dataModel.getTrade(), "trade must not be null"); if (sellersPaymentAccountPayload instanceof CashDepositAccountPayload) { diff --git a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/seller/SellerStep1View.java b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/seller/SellerStep1View.java index cec5d69c28..997ef29242 100644 --- a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/seller/SellerStep1View.java +++ b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/seller/SellerStep1View.java @@ -37,7 +37,7 @@ public class SellerStep1View extends TradeStepView { super.onPendingTradesInitialized(); //validateDepositInputs(); log.warn("Need to validate fee and/or deposit txs in SellerStep1View for XMR?"); // TODO (woodser): need to validate fee and/or deposit txs in SellerStep1View? - checkForTimeout(); + checkForUnconfirmedTimeout(); } /////////////////////////////////////////////////////////////////////////////////////////// diff --git a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/seller/SellerStep3View.java b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/seller/SellerStep3View.java index 487b5db0d9..ee5e6347c8 100644 --- a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/seller/SellerStep3View.java +++ b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/seller/SellerStep3View.java @@ -306,11 +306,16 @@ public class SellerStep3View extends TradeStepView { HBox hBox = tuple.fourth; GridPane.setColumnSpan(tuple.fourth, 2); confirmButton = tuple.first; + confirmButton.setDisable(!confirmPaymentReceivedPermitted()); confirmButton.setOnAction(e -> onPaymentReceived()); busyAnimation = tuple.second; statusLabel = tuple.third; } + private boolean confirmPaymentReceivedPermitted() { + if (!trade.confirmPermitted()) return false; + return trade.getState().ordinal() >= Trade.State.BUYER_SENT_PAYMENT_SENT_MSG.ordinal() && trade.getState().ordinal() < Trade.State.SELLER_SENT_PAYMENT_RECEIVED_MSG.ordinal(); // TODO: test that can resen with same payout tx hex if delivery failed + } /////////////////////////////////////////////////////////////////////////////////////////// // Info @@ -357,7 +362,7 @@ public class SellerStep3View extends TradeStepView { protected void updateDisputeState(Trade.DisputeState disputeState) { super.updateDisputeState(disputeState); - confirmButton.setDisable(!trade.confirmPermitted()); + confirmButton.setDisable(!confirmPaymentReceivedPermitted()); } @@ -463,11 +468,14 @@ public class SellerStep3View extends TradeStepView { log.info("User pressed the [Confirm payment receipt] button for Trade {}", trade.getShortId()); busyAnimation.play(); statusLabel.setText(Res.get("shared.sendingConfirmation")); + confirmButton.setDisable(true); model.dataModel.onPaymentReceived(() -> { }, errorMessage -> { busyAnimation.stop(); new Popup().warning(Res.get("popup.warning.sendMsgFailed")).show(); + confirmButton.setDisable(!confirmPaymentReceivedPermitted()); + UserThread.execute(() -> statusLabel.setText("Error confirming payment received.")); }); } diff --git a/desktop/src/main/java/bisq/desktop/main/support/dispute/DisputeView.java b/desktop/src/main/java/bisq/desktop/main/support/dispute/DisputeView.java index 21fdee3333..46c76951b0 100644 --- a/desktop/src/main/java/bisq/desktop/main/support/dispute/DisputeView.java +++ b/desktop/src/main/java/bisq/desktop/main/support/dispute/DisputeView.java @@ -50,6 +50,7 @@ import bisq.core.support.messages.ChatMessage; import bisq.core.trade.Contract; import bisq.core.trade.Trade; import bisq.core.trade.TradeManager; +import bisq.core.trade.Trade.DisputeState; import bisq.core.user.Preferences; import bisq.core.util.FormattingUtils; import bisq.core.util.coin.CoinFormatter; @@ -1341,18 +1342,21 @@ public abstract class DisputeView extends ActivatableView { ReadOnlyBooleanProperty closedProperty; ChangeListener listener; + Subscription subscription; @Override public void updateItem(final Dispute item, boolean empty) { super.updateItem(item, empty); UserThread.execute(() -> { if (item != null && !empty) { - if (closedProperty != null) { - closedProperty.removeListener(listener); + if (closedProperty != null) closedProperty.removeListener(listener); + if (subscription != null) { + subscription.unsubscribe(); + subscription = null; } listener = (observable, oldValue, newValue) -> { - setText(newValue ? Res.get("support.closed") : Res.get("support.open")); + setText(getDisputeStateText(item)); if (getTableRow() != null) getTableRow().setOpacity(newValue && item.getBadgeCountProperty().get() == 0 ? 0.4 : 1); if (item.isClosed() && item == chatPopup.getSelectedDispute()) @@ -1361,14 +1365,23 @@ public abstract class DisputeView extends ActivatableView { closedProperty = item.isClosedProperty(); closedProperty.addListener(listener); boolean isClosed = item.isClosed(); - setText(isClosed ? Res.get("support.closed") : Res.get("support.open")); + setText(getDisputeStateText(item)); if (getTableRow() != null) getTableRow().setOpacity(isClosed && item.getBadgeCountProperty().get() == 0 ? 0.4 : 1); + + // subscribe to trade's dispute state + Trade trade = tradeManager.getTrade(item.getTradeId()); + if (trade == null) log.warn("Dispute's trade is null for trade {}", item.getTradeId()); + else subscription = EasyBind.subscribe(trade.disputeStateProperty(), disputeState -> setText(getDisputeStateText(disputeState))); } else { if (closedProperty != null) { closedProperty.removeListener(listener); closedProperty = null; } + if (subscription != null) { + subscription.unsubscribe(); + subscription = null; + } setText(""); } }); @@ -1379,6 +1392,33 @@ public abstract class DisputeView extends ActivatableView { return column; } + private String getDisputeStateText(DisputeState disputeState) { + switch (disputeState) { + case DISPUTE_REQUESTED: + return Res.get("support.requested"); + case DISPUTE_CLOSED: + return Res.get("support.closed"); + default: + return Res.get("support.open"); + } + } + + private String getDisputeStateText(Dispute dispute) { + Trade trade = tradeManager.getTrade(dispute.getTradeId()); + if (trade == null) { + log.warn("Dispute's trade is null for trade {}", dispute.getTradeId()); + return Res.get("support.closed"); + } + switch (trade.getDisputeState()) { + case DISPUTE_REQUESTED: + return Res.get("support.requested"); + case DISPUTE_CLOSED: + return Res.get("support.closed"); + default: + return Res.get("support.open"); + } + } + private void openChat(Dispute dispute) { chatPopup.openChat(dispute, getConcreteDisputeChatSession(dispute), getCounterpartyName()); dispute.setDisputeSeen(senderFlag()); diff --git a/desktop/src/main/java/bisq/desktop/util/GUIUtil.java b/desktop/src/main/java/bisq/desktop/util/GUIUtil.java index de1b20331b..42e60e8944 100644 --- a/desktop/src/main/java/bisq/desktop/util/GUIUtil.java +++ b/desktop/src/main/java/bisq/desktop/util/GUIUtil.java @@ -738,11 +738,18 @@ public class GUIUtil { return false; } + try { + connectionService.verifyConnection(); + } catch (Exception e) { + new Popup().information(e.getMessage()).show(); + return false; + } + return true; } public static boolean isChainHeightSyncedWithinToleranceOrShowPopup(CoreMoneroConnectionsService connectionService) { - if (!connectionService.isChainHeightSyncedWithinTolerance()) { + if (!connectionService.isSyncedWithinTolerance()) { new Popup().information(Res.get("popup.warning.chainNotSynced")).show(); return false; } diff --git a/docs/operation_manual.md b/docs/operation_manual.md new file mode 100644 index 0000000000..31d505542b --- /dev/null +++ b/docs/operation_manual.md @@ -0,0 +1,14 @@ +# Operation Manual + +This operation manual describes how to operate a Haveno network by: + +- Forking Haveno +- Creating and registering seed nodes +- Creating and registering arbitrators +- Building binaries of the application + +TODO + +## Manually open dispute by keyboard shortcut + +In the event a dispute does not open properly, try manually reopening the dispute with a keyboard shortcut: `ctrl+o` \ No newline at end of file diff --git a/proto/src/main/proto/grpc.proto b/proto/src/main/proto/grpc.proto index 882103548b..6cf7454e4b 100644 --- a/proto/src/main/proto/grpc.proto +++ b/proto/src/main/proto/grpc.proto @@ -841,9 +841,9 @@ message TradeInfo { string period_state = 19; string payout_state = 20; string dispute_state = 21; - bool is_deposit_published = 22; - bool is_deposit_confirmed = 23; - bool is_deposit_unlocked = 24; + bool is_deposits_published = 22; + bool is_deposits_confirmed = 23; + bool is_deposits_unlocked = 24; bool is_payment_sent = 25; bool is_payment_received = 26; bool is_payout_published = 27; diff --git a/proto/src/main/proto/pb.proto b/proto/src/main/proto/pb.proto index db6fd7470e..dbbe3b68f5 100644 --- a/proto/src/main/proto/pb.proto +++ b/proto/src/main/proto/pb.proto @@ -1652,11 +1652,12 @@ message Trade { repeated ChatMessage chat_message = 22; MediationResultState mediation_result_state = 23; int64 lock_time = 24; - NodeAddress refund_agent_node_address = 25; - RefundResultState refund_result_state = 26; - string counter_currency_extra_data = 27; - string asset_tx_proof_result = 28; // name of AssetTxProofResult enum - string uid = 29; + int64 start_time = 25; + NodeAddress refund_agent_node_address = 26; + RefundResultState refund_result_state = 27; + string counter_currency_extra_data = 28; + string asset_tx_proof_result = 29; // name of AssetTxProofResult enum + string uid = 30; } message BuyerAsMakerTrade { @@ -1708,6 +1709,10 @@ message ProcessModel { TradingPeer arbitrator = 1004; NodeAddress temp_trading_peer_node_address = 1005; string multisig_address = 1006; + + PaymentSentMessage payment_sent_message = 1012; + PaymentReceivedMessage payment_received_message = 1013; + DisputeClosedMessage dispute_closed_message = 1014; } message TradingPeer { @@ -1745,7 +1750,6 @@ message TradingPeer { string deposit_tx_hex = 1009; string deposit_tx_key = 1010; string updated_multisig_hex = 1011; - PaymentSentMessage payment_sent_message = 1012; } ///////////////////////////////////////////////////////////////////////////////////////////