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 cfa651dc..da11cd1c 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 6049f4b8..8d29a2c0 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 566e6873..50e9cfb5 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 287ed668..e0040453 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 3133ce31..f8cce7cb 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 8c3ecaf5..4b182511 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 991d432f..f882bb17 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 7de56087..796a69f9 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 0904c10d..3bcdcb3d 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 5e7f7531..f52c41a3 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 0f0067f2..1b61cda0 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 dd8f814a..30999be8 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 625ada36..8a9d611c 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 b564aa35..284d797e 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 8db2ba10..1c047482 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 f3b6c981..9155b00a 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 7cf25d62..3366cf11 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 a14e0de0..37452cc3 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 33817d77..25851cb7 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 b931820c..753b4fbe 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 1c0fbad3..a62726e8 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 f9857f57..7db0325c 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 e35e1800..03360848 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 6c5acfab..933d00cf 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 f04a5538..4327dcde 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 f315c834..6dcc31af 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 2304d2b4..7e759965 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 68f8a45f..71c4d1ad 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 b9557918..afd70a69 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 866ac6ce..66863f57 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 5a501376..53860114 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 af31d25b..2dd4605c 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 1a5b27db..a253a9ef 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 8b1a3bad..b1276289 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 c4571c67..23f55be8 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 339b52fb..bbc96c5c 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 72704096..399e8e53 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 7b66eb4c..e2fdcffc 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 09f7959e..87b0515b 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 844dd505..209a4072 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 f5fa9ffa..c37d368b 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 cec5d69c..997ef292 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 487b5db0..ee5e6347 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 21fdee33..46c76951 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 de1b2033..42e60e89 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 00000000..31d50554 --- /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 88210354..6cf7454e 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 db6fd747..dbbe3b68 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; } ///////////////////////////////////////////////////////////////////////////////////////////