diff --git a/core/src/main/java/bisq/core/app/misc/ExecutableForAppWithP2p.java b/core/src/main/java/bisq/core/app/misc/ExecutableForAppWithP2p.java index d4c14206708..b13c5e9e6d0 100644 --- a/core/src/main/java/bisq/core/app/misc/ExecutableForAppWithP2p.java +++ b/core/src/main/java/bisq/core/app/misc/ExecutableForAppWithP2p.java @@ -159,11 +159,13 @@ public void startShutDownInterval(GracefulShutDownHandler gracefulShutDownHandle UserThread.runAfter(() -> { // We check every hour if we are in the target hour. UserThread.runPeriodically(() -> { - int currentHour = ZonedDateTime.ofInstant(Instant.now(), ZoneId.of("GMT0")).getHour(); + int currentHour = ZonedDateTime.ofInstant(Instant.now(), ZoneId.of("UTC")).getHour(); if (currentHour == target) { log.warn("\n\n%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%\n" + - "Shut down node at hour {}" + - "\n%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%\n\n", target); + "Shut down node at hour {} (UTC time is {})" + + "\n%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%\n\n", + target, + ZonedDateTime.ofInstant(Instant.now(), ZoneId.of("UTC")).toString()); shutDown(gracefulShutDownHandler); } }, TimeUnit.MINUTES.toSeconds(10)); diff --git a/core/src/main/java/bisq/core/monetary/Price.java b/core/src/main/java/bisq/core/monetary/Price.java index 6e588353681..e07a896efe2 100644 --- a/core/src/main/java/bisq/core/monetary/Price.java +++ b/core/src/main/java/bisq/core/monetary/Price.java @@ -129,7 +129,9 @@ public Price subtract(Price other) { } public String toFriendlyString() { - return monetary instanceof Altcoin ? ((Altcoin) monetary).toFriendlyString() : ((Fiat) monetary).toFriendlyString(); + return monetary instanceof Altcoin ? + ((Altcoin) monetary).toFriendlyString() + "/BTC" : + ((Fiat) monetary).toFriendlyString().replace(((Fiat) monetary).currencyCode, "") + "BTC/" + ((Fiat) monetary).currencyCode; } public String toPlainString() { diff --git a/core/src/main/java/bisq/core/provider/price/PriceFeedService.java b/core/src/main/java/bisq/core/provider/price/PriceFeedService.java index 4d2928d5b86..75128ea59b3 100644 --- a/core/src/main/java/bisq/core/provider/price/PriceFeedService.java +++ b/core/src/main/java/bisq/core/provider/price/PriceFeedService.java @@ -84,7 +84,6 @@ public class PriceFeedService { private final StringProperty currencyCodeProperty = new SimpleStringProperty(); private final IntegerProperty updateCounter = new SimpleIntegerProperty(0); private long epochInMillisAtLastRequest; - private Map timeStampMap = new HashMap<>(); private long retryDelay = 1; private long requestTs; @Nullable @@ -126,6 +125,10 @@ public void initialRequestPriceFeed() { request(false); } + public boolean hasPrices() { + return !cache.isEmpty(); + } + public void requestPriceFeed(Consumer resultHandler, FaultHandler faultHandler) { this.priceConsumer = resultHandler; this.faultHandler = faultHandler; @@ -156,7 +159,7 @@ private void request(boolean repeatRequests) { // At applyPriceToConsumer we also check if price is not exceeding max. age for price data. boolean success = applyPriceToConsumer(); if (success) { - final MarketPrice marketPrice = cache.get(currencyCode); + MarketPrice marketPrice = cache.get(currencyCode); if (marketPrice != null) log.debug("Received new {} from provider {} after {} sec.", marketPrice, @@ -326,7 +329,7 @@ private boolean applyPriceToConsumer() { boolean result = false; String errorMessage = null; if (currencyCode != null) { - final String baseUrl = priceProvider.getBaseUrl(); + String baseUrl = priceProvider.getBaseUrl(); if (cache.containsKey(currencyCode)) { try { MarketPrice marketPrice = cache.get(currencyCode); @@ -383,14 +386,12 @@ private void requestAllPrices(PriceProvider provider, Runnable resultHandler, Fa public void onSuccess(@Nullable Tuple2, Map> result) { UserThread.execute(() -> { checkNotNull(result, "Result must not be null at requestAllPrices"); - timeStampMap = result.first; - // Each currency rate has a different timestamp, depending on when // the pricenode aggregate rate was calculated // However, the request timestamp is when the pricenode was queried epochInMillisAtLastRequest = System.currentTimeMillis(); - final Map priceMap = result.second; + Map priceMap = result.second; cache.putAll(priceMap); diff --git a/core/src/main/java/bisq/core/support/SupportManager.java b/core/src/main/java/bisq/core/support/SupportManager.java index 908a594aee8..5d3f33200c5 100644 --- a/core/src/main/java/bisq/core/support/SupportManager.java +++ b/core/src/main/java/bisq/core/support/SupportManager.java @@ -62,10 +62,14 @@ public SupportManager(P2PService p2PService, WalletsSetup walletsSetup) { // We get first the message handler called then the onBootstrapped p2PService.addDecryptedDirectMessageListener((decryptedMessageWithPubKey, senderAddress) -> { + // As decryptedDirectMessageWithPubKeys is a CopyOnWriteArraySet we do not need to check if it was + // already stored decryptedDirectMessageWithPubKeys.add(decryptedMessageWithPubKey); tryApplyMessages(); }); p2PService.addDecryptedMailboxListener((decryptedMessageWithPubKey, senderAddress) -> { + // As decryptedMailboxMessageWithPubKeys is a CopyOnWriteArraySet we do not need to check if it was + // already stored decryptedMailboxMessageWithPubKeys.add(decryptedMessageWithPubKey); tryApplyMessages(); }); 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 4e13adb1fac..9998e1f2eba 100644 --- a/core/src/main/java/bisq/core/support/dispute/DisputeManager.java +++ b/core/src/main/java/bisq/core/support/dispute/DisputeManager.java @@ -19,9 +19,16 @@ import bisq.core.btc.setup.WalletsSetup; import bisq.core.btc.wallet.BtcWalletService; +import bisq.core.btc.wallet.Restrictions; import bisq.core.btc.wallet.TradeWalletService; +import bisq.core.locale.CurrencyUtil; import bisq.core.locale.Res; +import bisq.core.monetary.Altcoin; +import bisq.core.monetary.Price; +import bisq.core.offer.OfferPayload; import bisq.core.offer.OpenOfferManager; +import bisq.core.provider.price.MarketPrice; +import bisq.core.provider.price.PriceFeedService; import bisq.core.support.SupportManager; import bisq.core.support.dispute.messages.DisputeResultMessage; import bisq.core.support.dispute.messages.OpenNewDisputeMessage; @@ -37,13 +44,18 @@ import bisq.network.p2p.P2PService; import bisq.network.p2p.SendMailboxMessageListener; +import bisq.common.UserThread; import bisq.common.app.Version; import bisq.common.crypto.PubKeyRing; import bisq.common.handlers.FaultHandler; import bisq.common.handlers.ResultHandler; import bisq.common.storage.Storage; +import bisq.common.util.MathUtils; import bisq.common.util.Tuple2; +import org.bitcoinj.core.Coin; +import org.bitcoinj.utils.Fiat; + import javafx.beans.property.IntegerProperty; import javafx.collections.ObservableList; @@ -51,6 +63,7 @@ import java.util.List; import java.util.Optional; import java.util.UUID; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; @@ -68,6 +81,7 @@ public abstract class DisputeManager disputeListService; + private final PriceFeedService priceFeedService; /////////////////////////////////////////////////////////////////////////////////////////// @@ -82,7 +96,8 @@ public DisputeManager(P2PService p2PService, ClosedTradableManager closedTradableManager, OpenOfferManager openOfferManager, PubKeyRing pubKeyRing, - DisputeListService disputeListService) { + DisputeListService disputeListService, + PriceFeedService priceFeedService) { super(p2PService, walletsSetup); this.tradeWalletService = tradeWalletService; @@ -92,6 +107,7 @@ public DisputeManager(P2PService p2PService, this.openOfferManager = openOfferManager; this.pubKeyRing = pubKeyRing; this.disputeListService = disputeListService; + this.priceFeedService = priceFeedService; } @@ -255,19 +271,20 @@ protected void onOpenNewDisputeMessage(OpenNewDisputeMessage openNewDisputeMessa String errorMessage = null; Dispute dispute = openNewDisputeMessage.getDispute(); - + dispute.setStorage(disputeListService.getStorage()); // Disputes from clients < 1.2.0 always have support type ARBITRATION in dispute as the field didn't exist before dispute.setSupportType(openNewDisputeMessage.getSupportType()); - dispute.setStorage(disputeListService.getStorage()); - Contract contractFromOpener = dispute.getContract(); - PubKeyRing peersPubKeyRing = dispute.isDisputeOpenerIsBuyer() ? contractFromOpener.getSellerPubKeyRing() : contractFromOpener.getBuyerPubKeyRing(); + Contract contract = dispute.getContract(); + addPriceInfoMessage(dispute, 0); + + PubKeyRing peersPubKeyRing = dispute.isDisputeOpenerIsBuyer() ? contract.getSellerPubKeyRing() : contract.getBuyerPubKeyRing(); if (isAgent(dispute)) { if (!disputeList.contains(dispute)) { Optional storedDisputeOptional = findDispute(dispute); if (!storedDisputeOptional.isPresent()) { disputeList.add(dispute); - errorMessage = sendPeerOpenedDisputeMessage(dispute, contractFromOpener, peersPubKeyRing); + sendPeerOpenedDisputeMessage(dispute, contract, peersPubKeyRing); } 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 = {}", @@ -286,23 +303,11 @@ protected void onOpenNewDisputeMessage(OpenNewDisputeMessage openNewDisputeMessa ObservableList messages = dispute.getChatMessages(); if (!messages.isEmpty()) { ChatMessage chatMessage = messages.get(0); - PubKeyRing sendersPubKeyRing = dispute.isDisputeOpenerIsBuyer() ? contractFromOpener.getBuyerPubKeyRing() : contractFromOpener.getSellerPubKeyRing(); + PubKeyRing sendersPubKeyRing = dispute.isDisputeOpenerIsBuyer() ? contract.getBuyerPubKeyRing() : contract.getSellerPubKeyRing(); sendAckMessage(chatMessage, sendersPubKeyRing, errorMessage == null, errorMessage); } - // In case of refundAgent we add a message with the mediatorsDisputeSummary. Only visible for refundAgent. - if (dispute.getMediatorsDisputeResult() != null) { - String mediatorsDisputeResult = Res.get("support.mediatorsDisputeSummary", dispute.getMediatorsDisputeResult()); - ChatMessage mediatorsDisputeResultMessage = new ChatMessage( - getSupportType(), - dispute.getTradeId(), - pubKeyRing.hashCode(), - false, - mediatorsDisputeResult, - p2PService.getAddress()); - mediatorsDisputeResultMessage.setSystemMessage(true); - dispute.addAndPersistChatMessage(mediatorsDisputeResultMessage); - } + addMediationResultMessage(dispute); } // not dispute requester receives that from dispute agent @@ -468,14 +473,27 @@ public void onFault(String errorMessage) { } } - // dispute agent sends that to trading peer when he received openDispute request - private String sendPeerOpenedDisputeMessage(Dispute disputeFromOpener, + // Dispute agent sends that to trading peer when he received openDispute request + private void sendPeerOpenedDisputeMessage(Dispute disputeFromOpener, + Contract contractFromOpener, + PubKeyRing pubKeyRing) { + // We delay a bit for sending the message to the peer to allow that a openDispute message from the peer is + // being used as the valid msg. If dispute agent was offline and both peer requested we want to see the correct + // message and not skip the system message of the peer as it would be the case if we have created the system msg + // from the code below. + UserThread.runAfter(() -> doSendPeerOpenedDisputeMessage(disputeFromOpener, + contractFromOpener, + pubKeyRing), + 100, TimeUnit.MILLISECONDS); + } + + private void doSendPeerOpenedDisputeMessage(Dispute disputeFromOpener, Contract contractFromOpener, PubKeyRing pubKeyRing) { T disputeList = getDisputeList(); if (disputeList == null) { log.warn("disputes is null"); - return null; + return; } Dispute dispute = new Dispute(disputeListService.getStorage(), @@ -500,91 +518,94 @@ private String sendPeerOpenedDisputeMessage(Dispute disputeFromOpener, dispute.setDelayedPayoutTxId(disputeFromOpener.getDelayedPayoutTxId()); Optional storedDisputeOptional = findDispute(dispute); - if (!storedDisputeOptional.isPresent()) { - String disputeInfo = getDisputeInfo(dispute); - String disputeMessage = getDisputeIntroForPeer(disputeInfo); - String sysMsg = dispute.isSupportTicket() ? - Res.get("support.peerOpenedTicket", disputeInfo, Version.VERSION) - : disputeMessage; - ChatMessage chatMessage = new ChatMessage( - getSupportType(), - dispute.getTradeId(), - pubKeyRing.hashCode(), - false, - Res.get("support.systemMsg", sysMsg), - p2PService.getAddress()); - chatMessage.setSystemMessage(true); - dispute.addAndPersistChatMessage(chatMessage); - disputeList.add(dispute); - // we mirrored dispute already! - Contract contract = dispute.getContract(); - PubKeyRing peersPubKeyRing = dispute.isDisputeOpenerIsBuyer() ? contract.getBuyerPubKeyRing() : contract.getSellerPubKeyRing(); - NodeAddress peersNodeAddress = dispute.isDisputeOpenerIsBuyer() ? contract.getBuyerNodeAddress() : contract.getSellerNodeAddress(); - PeerOpenedDisputeMessage peerOpenedDisputeMessage = new PeerOpenedDisputeMessage(dispute, - p2PService.getAddress(), - UUID.randomUUID().toString(), - getSupportType()); + // Valid case if both have opened a dispute and agent was not online. + if (storedDisputeOptional.isPresent()) { + log.info("We got a dispute already open for that trade and trading peer. TradeId = {}", + dispute.getTradeId()); + return; + } - log.info("Send {} to peer {}. tradeId={}, peerOpenedDisputeMessage.uid={}, chatMessage.uid={}", - peerOpenedDisputeMessage.getClass().getSimpleName(), peersNodeAddress, - peerOpenedDisputeMessage.getTradeId(), peerOpenedDisputeMessage.getUid(), - chatMessage.getUid()); + String disputeInfo = getDisputeInfo(dispute); + String disputeMessage = getDisputeIntroForPeer(disputeInfo); + String sysMsg = dispute.isSupportTicket() ? + Res.get("support.peerOpenedTicket", disputeInfo, Version.VERSION) + : disputeMessage; + ChatMessage chatMessage = new ChatMessage( + getSupportType(), + dispute.getTradeId(), + pubKeyRing.hashCode(), + false, + Res.get("support.systemMsg", sysMsg), + p2PService.getAddress()); + chatMessage.setSystemMessage(true); + dispute.addAndPersistChatMessage(chatMessage); - p2PService.sendEncryptedMailboxMessage(peersNodeAddress, - peersPubKeyRing, - peerOpenedDisputeMessage, - new SendMailboxMessageListener() { - @Override - public void onArrived() { - log.info("{} arrived at peer {}. tradeId={}, peerOpenedDisputeMessage.uid={}, " + - "chatMessage.uid={}", - peerOpenedDisputeMessage.getClass().getSimpleName(), peersNodeAddress, - peerOpenedDisputeMessage.getTradeId(), peerOpenedDisputeMessage.getUid(), - chatMessage.getUid()); + addPriceInfoMessage(dispute, 0); - // We use the chatMessage wrapped inside the peerOpenedDisputeMessage for - // the state, as that is displayed to the user and we only persist that msg - chatMessage.setArrived(true); - disputeList.persist(); - } + disputeList.add(dispute); - @Override - public void onStoredInMailbox() { - log.info("{} stored in mailbox for peer {}. tradeId={}, peerOpenedDisputeMessage.uid={}, " + - "chatMessage.uid={}", - peerOpenedDisputeMessage.getClass().getSimpleName(), peersNodeAddress, - peerOpenedDisputeMessage.getTradeId(), peerOpenedDisputeMessage.getUid(), - chatMessage.getUid()); + // We mirrored dispute already! + Contract contract = dispute.getContract(); + PubKeyRing peersPubKeyRing = dispute.isDisputeOpenerIsBuyer() ? contract.getBuyerPubKeyRing() : contract.getSellerPubKeyRing(); + NodeAddress peersNodeAddress = dispute.isDisputeOpenerIsBuyer() ? contract.getBuyerNodeAddress() : contract.getSellerNodeAddress(); + PeerOpenedDisputeMessage peerOpenedDisputeMessage = new PeerOpenedDisputeMessage(dispute, + p2PService.getAddress(), + UUID.randomUUID().toString(), + getSupportType()); - // We use the chatMessage wrapped inside the peerOpenedDisputeMessage for - // the state, as that is displayed to the user and we only persist that msg - chatMessage.setStoredInMailbox(true); - disputeList.persist(); - } + log.info("Send {} to peer {}. tradeId={}, peerOpenedDisputeMessage.uid={}, chatMessage.uid={}", + peerOpenedDisputeMessage.getClass().getSimpleName(), peersNodeAddress, + peerOpenedDisputeMessage.getTradeId(), peerOpenedDisputeMessage.getUid(), + chatMessage.getUid()); - @Override - public void onFault(String errorMessage) { - log.error("{} failed: Peer {}. tradeId={}, peerOpenedDisputeMessage.uid={}, " + - "chatMessage.uid={}, errorMessage={}", - peerOpenedDisputeMessage.getClass().getSimpleName(), peersNodeAddress, - peerOpenedDisputeMessage.getTradeId(), peerOpenedDisputeMessage.getUid(), - chatMessage.getUid(), errorMessage); + p2PService.sendEncryptedMailboxMessage(peersNodeAddress, + peersPubKeyRing, + peerOpenedDisputeMessage, + new SendMailboxMessageListener() { + @Override + public void onArrived() { + log.info("{} arrived at peer {}. tradeId={}, peerOpenedDisputeMessage.uid={}, " + + "chatMessage.uid={}", + peerOpenedDisputeMessage.getClass().getSimpleName(), peersNodeAddress, + peerOpenedDisputeMessage.getTradeId(), peerOpenedDisputeMessage.getUid(), + chatMessage.getUid()); - // We use the chatMessage wrapped inside the peerOpenedDisputeMessage for - // the state, as that is displayed to the user and we only persist that msg - chatMessage.setSendMessageError(errorMessage); - disputeList.persist(); - } + // We use the chatMessage wrapped inside the peerOpenedDisputeMessage for + // the state, as that is displayed to the user and we only persist that msg + chatMessage.setArrived(true); + disputeList.persist(); } - ); - return 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()); - return null; - } + + @Override + public void onStoredInMailbox() { + log.info("{} stored in mailbox for peer {}. tradeId={}, peerOpenedDisputeMessage.uid={}, " + + "chatMessage.uid={}", + peerOpenedDisputeMessage.getClass().getSimpleName(), peersNodeAddress, + peerOpenedDisputeMessage.getTradeId(), peerOpenedDisputeMessage.getUid(), + chatMessage.getUid()); + + // We use the chatMessage wrapped inside the peerOpenedDisputeMessage for + // the state, as that is displayed to the user and we only persist that msg + chatMessage.setStoredInMailbox(true); + disputeList.persist(); + } + + @Override + public void onFault(String errorMessage) { + log.error("{} failed: Peer {}. tradeId={}, peerOpenedDisputeMessage.uid={}, " + + "chatMessage.uid={}, errorMessage={}", + peerOpenedDisputeMessage.getClass().getSimpleName(), peersNodeAddress, + peerOpenedDisputeMessage.getTradeId(), peerOpenedDisputeMessage.getUid(), + chatMessage.getUid(), errorMessage); + + // We use the chatMessage wrapped inside the peerOpenedDisputeMessage for + // the state, as that is displayed to the user and we only persist that msg + chatMessage.setSendMessageError(errorMessage); + disputeList.persist(); + } + } + ); } // dispute agent send result to trader @@ -731,4 +752,112 @@ public Optional findDispute(String tradeId) { .filter(e -> e.getTradeId().equals(tradeId)) .findAny(); } + + private void addMediationResultMessage(Dispute dispute) { + // In case of refundAgent we add a message with the mediatorsDisputeSummary. Only visible for refundAgent. + if (dispute.getMediatorsDisputeResult() != null) { + String mediatorsDisputeResult = Res.get("support.mediatorsDisputeSummary", dispute.getMediatorsDisputeResult()); + ChatMessage mediatorsDisputeResultMessage = new ChatMessage( + getSupportType(), + dispute.getTradeId(), + pubKeyRing.hashCode(), + false, + mediatorsDisputeResult, + p2PService.getAddress()); + mediatorsDisputeResultMessage.setSystemMessage(true); + dispute.addAndPersistChatMessage(mediatorsDisputeResultMessage); + } + } + + // If price was going down between take offer time and open dispute time the buyer has an incentive to + // not send the payment but to try to make a new trade with the better price. We risks to lose part of the + // security deposit (in mediation we will always get back 0.003 BTC to keep some incentive to accept mediated + // proposal). But if gain is larger than this loss he has economically an incentive to default in the trade. + // We do all those calculations to give a hint to mediators to detect option trades. + protected void addPriceInfoMessage(Dispute dispute, int counter) { + if (!priceFeedService.hasPrices()) { + if (counter < 3) { + log.info("Price provider has still no data. This is expected at startup. We try again in 10 sec."); + UserThread.runAfter(() -> addPriceInfoMessage(dispute, counter + 1), 10); + } else { + log.warn("Price provider still has no data after 3 repeated requests and 30 seconds delay. We give up."); + } + return; + } + + Contract contract = dispute.getContract(); + OfferPayload offerPayload = contract.getOfferPayload(); + Price priceAtDisputeOpening = getPrice(offerPayload.getCurrencyCode()); + if (priceAtDisputeOpening == null) { + log.info("Price provider did not provide a price for {}. " + + "This is expected if this currency is not supported by the price providers.", + offerPayload.getCurrencyCode()); + return; + } + + // The amount we would get if we do a new trade with current price + Coin potentialAmountAtDisputeOpening = priceAtDisputeOpening.getAmountByVolume(contract.getTradeVolume()); + Coin buyerSecurityDeposit = Coin.valueOf(offerPayload.getBuyerSecurityDeposit()); + Coin minRefundAtMediatedDispute = Restrictions.getMinRefundAtMediatedDispute(); + // minRefundAtMediatedDispute is always larger as buyerSecurityDeposit at mediated payout, we ignore refund agent case here as there it can be 0. + Coin maxLossSecDeposit = buyerSecurityDeposit.subtract(minRefundAtMediatedDispute); + Coin tradeAmount = contract.getTradeAmount(); + Coin potentialGain = potentialAmountAtDisputeOpening.subtract(tradeAmount).subtract(maxLossSecDeposit); + String optionTradeDetails; + // We don't translate those strings (yet) as it is only displayed to mediators/arbitrators. + String headline; + if (potentialGain.isPositive()) { + headline = "This might be a potential option trade!"; + optionTradeDetails = "\nBTC amount calculated with price at dispute opening: " + potentialAmountAtDisputeOpening.toFriendlyString() + + "\nMax loss of security deposit is: " + maxLossSecDeposit.toFriendlyString() + + "\nPossible gain from an option trade is: " + potentialGain.toFriendlyString(); + } else { + headline = "It does not appear to be an option trade."; + optionTradeDetails = "\nBTC amount calculated with price at dispute opening: " + potentialAmountAtDisputeOpening.toFriendlyString() + + "\nMax loss of security deposit is: " + maxLossSecDeposit.toFriendlyString() + + "\nPossible loss from an option trade is: " + potentialGain.multiply(-1).toFriendlyString(); + } + + String percentagePriceDetails = offerPayload.isUseMarketBasedPrice() ? + " (market based price was used: " + offerPayload.getMarketPriceMargin() * 100 + "%)" : + " (fix price was used)"; + + String priceInfoText = "System message: " + headline + + "\n\nTrade price: " + contract.getTradePrice().toFriendlyString() + percentagePriceDetails + + "\nTrade amount: " + tradeAmount.toFriendlyString() + + "\nPrice at dispute opening: " + priceAtDisputeOpening.toFriendlyString() + + optionTradeDetails; + + // We use the existing msg to copy over the users data + ChatMessage priceInfoMessage = new ChatMessage( + getSupportType(), + dispute.getTradeId(), + pubKeyRing.hashCode(), + false, + priceInfoText, + p2PService.getAddress()); + priceInfoMessage.setSystemMessage(true); + dispute.addAndPersistChatMessage(priceInfoMessage); + } + + @Nullable + private Price getPrice(String currencyCode) { + MarketPrice marketPrice = priceFeedService.getMarketPrice(currencyCode); + if (marketPrice != null && marketPrice.isRecentExternalPriceAvailable()) { + double marketPriceAsDouble = marketPrice.getPrice(); + try { + int precision = CurrencyUtil.isCryptoCurrency(currencyCode) ? + Altcoin.SMALLEST_UNIT_EXPONENT : + Fiat.SMALLEST_UNIT_EXPONENT; + double scaled = MathUtils.scaleUpByPowerOf10(marketPriceAsDouble, precision); + long roundedToLong = MathUtils.roundDoubleToLong(scaled); + return Price.valueOf(currencyCode, roundedToLong); + } catch (Exception e) { + log.error("Exception at getPrice / parseToFiat: " + e.toString()); + return null; + } + } else { + return null; + } + } } 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 e063e1c8528..e77daf2b048 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 @@ -28,6 +28,7 @@ import bisq.core.locale.Res; import bisq.core.offer.OpenOffer; import bisq.core.offer.OpenOfferManager; +import bisq.core.provider.price.PriceFeedService; import bisq.core.support.SupportType; import bisq.core.support.dispute.Dispute; import bisq.core.support.dispute.DisputeManager; @@ -88,9 +89,10 @@ public ArbitrationManager(P2PService p2PService, ClosedTradableManager closedTradableManager, OpenOfferManager openOfferManager, PubKeyRing pubKeyRing, - ArbitrationDisputeListService arbitrationDisputeListService) { + ArbitrationDisputeListService arbitrationDisputeListService, + PriceFeedService priceFeedService) { super(p2PService, tradeWalletService, walletService, walletsSetup, tradeManager, closedTradableManager, - openOfferManager, pubKeyRing, arbitrationDisputeListService); + openOfferManager, pubKeyRing, arbitrationDisputeListService, priceFeedService); } @@ -163,6 +165,11 @@ protected String getDisputeIntroForDisputeCreator(String disputeInfo) { return Res.get("support.youOpenedDispute", disputeInfo, Version.VERSION); } + @Override + protected void addPriceInfoMessage(Dispute dispute, int counter) { + // Arbitrator is not used anymore. + } + /////////////////////////////////////////////////////////////////////////////////////////// // Message handler /////////////////////////////////////////////////////////////////////////////////////////// diff --git a/core/src/main/java/bisq/core/support/dispute/mediation/MediationManager.java b/core/src/main/java/bisq/core/support/dispute/mediation/MediationManager.java index 44c5a309d7f..42f9b37f5cf 100644 --- a/core/src/main/java/bisq/core/support/dispute/mediation/MediationManager.java +++ b/core/src/main/java/bisq/core/support/dispute/mediation/MediationManager.java @@ -23,6 +23,7 @@ import bisq.core.locale.Res; import bisq.core.offer.OpenOffer; import bisq.core.offer.OpenOfferManager; +import bisq.core.provider.price.PriceFeedService; import bisq.core.support.SupportType; import bisq.core.support.dispute.Dispute; import bisq.core.support.dispute.DisputeManager; @@ -80,9 +81,10 @@ public MediationManager(P2PService p2PService, ClosedTradableManager closedTradableManager, OpenOfferManager openOfferManager, PubKeyRing pubKeyRing, - MediationDisputeListService mediationDisputeListService) { + MediationDisputeListService mediationDisputeListService, + PriceFeedService priceFeedService) { super(p2PService, tradeWalletService, walletService, walletsSetup, tradeManager, closedTradableManager, - openOfferManager, pubKeyRing, mediationDisputeListService); + openOfferManager, pubKeyRing, mediationDisputeListService, priceFeedService); } /////////////////////////////////////////////////////////////////////////////////////////// diff --git a/core/src/main/java/bisq/core/support/dispute/refund/RefundManager.java b/core/src/main/java/bisq/core/support/dispute/refund/RefundManager.java index 139832a1c16..f28208f050a 100644 --- a/core/src/main/java/bisq/core/support/dispute/refund/RefundManager.java +++ b/core/src/main/java/bisq/core/support/dispute/refund/RefundManager.java @@ -23,6 +23,7 @@ import bisq.core.locale.Res; import bisq.core.offer.OpenOffer; import bisq.core.offer.OpenOfferManager; +import bisq.core.provider.price.PriceFeedService; import bisq.core.support.SupportType; import bisq.core.support.dispute.Dispute; import bisq.core.support.dispute.DisputeManager; @@ -74,9 +75,10 @@ public RefundManager(P2PService p2PService, ClosedTradableManager closedTradableManager, OpenOfferManager openOfferManager, PubKeyRing pubKeyRing, - RefundDisputeListService refundDisputeListService) { + RefundDisputeListService refundDisputeListService, + PriceFeedService priceFeedService) { super(p2PService, tradeWalletService, walletService, walletsSetup, tradeManager, closedTradableManager, - openOfferManager, pubKeyRing, refundDisputeListService); + openOfferManager, pubKeyRing, refundDisputeListService, priceFeedService); } /////////////////////////////////////////////////////////////////////////////////////////// @@ -140,6 +142,14 @@ protected String getDisputeIntroForDisputeCreator(String disputeInfo) { return Res.get("support.youOpenedDispute", disputeInfo, Version.VERSION); } + @Override + protected void addPriceInfoMessage(Dispute dispute, int counter) { + // At refund agent we do not add the option trade price check as the time for dispute opening is not correct. + // In case of an option trade the mediator adds to the result summary message automatically the system message + // with the option trade detection info so the refund agent can see that as well. + } + + /////////////////////////////////////////////////////////////////////////////////////////// // Message handler /////////////////////////////////////////////////////////////////////////////////////////// diff --git a/core/src/main/resources/i18n/displayStrings.properties b/core/src/main/resources/i18n/displayStrings.properties index 276eecf83b1..eca00ef9d4e 100644 --- a/core/src/main/resources/i18n/displayStrings.properties +++ b/core/src/main/resources/i18n/displayStrings.properties @@ -1039,7 +1039,7 @@ support.youOpenedDisputeForMediation=You requested mediation.\n\n{0}\n\nBisq ver support.peerOpenedTicket=Your trading peer has requested support due to technical problems.\n\n{0}\n\nBisq version: {1} support.peerOpenedDispute=Your trading peer has requested a dispute.\n\n{0}\n\nBisq version: {1} support.peerOpenedDisputeForMediation=Your trading peer has requested mediation.\n\n{0}\n\nBisq version: {1} -support.mediatorsDisputeSummary=System message:\nMediator''s dispute summary:\n{0} +support.mediatorsDisputeSummary=System message: Mediator''s dispute summary:\n{0} support.mediatorsAddress=Mediator''s node address: {0} @@ -2376,18 +2376,32 @@ disputeSummaryWindow.payoutAmount.buyer=Buyer's payout amount disputeSummaryWindow.payoutAmount.seller=Seller's payout amount disputeSummaryWindow.payoutAmount.invert=Use loser as publisher disputeSummaryWindow.reason=Reason of dispute -disputeSummaryWindow.reason.bug=Bug -disputeSummaryWindow.reason.usability=Usability -disputeSummaryWindow.reason.protocolViolation=Protocol violation -disputeSummaryWindow.reason.noReply=No reply -disputeSummaryWindow.reason.scam=Scam -disputeSummaryWindow.reason.other=Other -disputeSummaryWindow.reason.bank=Bank -disputeSummaryWindow.reason.optionTrade=Option trade -disputeSummaryWindow.reason.sellerNotResponding=Seller not responding -disputeSummaryWindow.reason.wrongSenderAccount=Wrong sender account -disputeSummaryWindow.reason.peerWasLate=Peer was late -disputeSummaryWindow.reason.tradeAlreadySettled=Trade already settled + +# dynamic values are not recognized by IntelliJ +# suppress inspection "UnusedProperty" +disputeSummaryWindow.reason.BUG=Bug +# suppress inspection "UnusedProperty" +disputeSummaryWindow.reason.USABILITY=Usability +# suppress inspection "UnusedProperty" +disputeSummaryWindow.reason.PROTOCOL_VIOLATION=Protocol violation +# suppress inspection "UnusedProperty" +disputeSummaryWindow.reason.NO_REPLY=No reply +# suppress inspection "UnusedProperty" +disputeSummaryWindow.reason.SCAM=Scam +# suppress inspection "UnusedProperty" +disputeSummaryWindow.reason.OTHER=Other +# suppress inspection "UnusedProperty" +disputeSummaryWindow.reason.BANK_PROBLEMS=Bank +# suppress inspection "UnusedProperty" +disputeSummaryWindow.reason.OPTION_TRADE=Option trade +# suppress inspection "UnusedProperty" +disputeSummaryWindow.reason.SELLER_NOT_RESPONDING=Seller not responding +# suppress inspection "UnusedProperty" +disputeSummaryWindow.reason.WRONG_SENDER_ACCOUNT=Wrong sender account +# suppress inspection "UnusedProperty" +disputeSummaryWindow.reason.PEER_WAS_LATE=Peer was late +# suppress inspection "UnusedProperty" +disputeSummaryWindow.reason.TRADE_ALREADY_SETTLED=Trade already settled disputeSummaryWindow.summaryNotes=Summary notes disputeSummaryWindow.addSummaryNotes=Add summary notes @@ -2396,7 +2410,8 @@ disputeSummaryWindow.close.msg=Ticket closed on {0}\n\n\ Summary:\n\ Payout amount for BTC buyer: {1}\n\ Payout amount for BTC seller: {2}\n\n\ -Summary notes:\n{3} +Reason for dispute: {3}\n\n\ +Summary notes:\n{4} disputeSummaryWindow.close.nextStepsForMediation=\n\nNext steps:\n\ Open trade and accept or reject suggestion from mediator disputeSummaryWindow.close.nextStepsForRefundAgentArbitration=\n\nNext steps:\n\ diff --git a/core/src/test/java/bisq/core/monetary/PriceTest.java b/core/src/test/java/bisq/core/monetary/PriceTest.java index 3f824921584..f72aca7ae8b 100644 --- a/core/src/test/java/bisq/core/monetary/PriceTest.java +++ b/core/src/test/java/bisq/core/monetary/PriceTest.java @@ -27,14 +27,14 @@ public void testParse() { Price result = Price.parse("USD", "0.1"); Assert.assertEquals( "Fiat value should be formatted with two decimals.", - "0.10 USD", + "0.10 BTC/USD", result.toFriendlyString() ); result = Price.parse("EUR", "0.1234"); Assert.assertEquals( "Fiat value should be given two decimals", - "0.1234 EUR", + "0.1234 BTC/EUR", result.toFriendlyString() ); @@ -57,19 +57,19 @@ public void testParse() { Assert.assertEquals( "Comma (',') as decimal separator should be converted to period ('.')", - "0.0001 USD", + "0.0001 BTC/USD", Price.parse("USD", "0,0001").toFriendlyString() ); Assert.assertEquals( "Too many decimals should get rounded up properly.", - "10000.2346 LTC", + "10000.2346 LTC/BTC", Price.parse("LTC", "10000,23456789").toFriendlyString() ); Assert.assertEquals( "Too many decimals should get rounded down properly.", - "10000.2345 LTC", + "10000.2345 LTC/BTC", Price.parse("LTC", "10000,23454999").toFriendlyString() ); @@ -95,14 +95,14 @@ public void testValueOf() { Price result = Price.valueOf("USD", 1); Assert.assertEquals( "Fiat value should have four decimals.", - "0.0001 USD", + "0.0001 BTC/USD", result.toFriendlyString() ); result = Price.valueOf("EUR", 1234); Assert.assertEquals( "Fiat value should be given two decimals", - "0.1234 EUR", + "0.1234 BTC/EUR", result.toFriendlyString() ); @@ -114,13 +114,13 @@ public void testValueOf() { Assert.assertEquals( "Too many decimals should get rounded up properly.", - "10000.2346 LTC", + "10000.2346 LTC/BTC", Price.valueOf("LTC", 1000023456789L).toFriendlyString() ); Assert.assertEquals( "Too many decimals should get rounded down properly.", - "10000.2345 LTC", + "10000.2345 LTC/BTC", Price.valueOf("LTC", 1000023454999L).toFriendlyString() ); diff --git a/desktop/src/main/java/bisq/desktop/main/overlays/windows/DisputeSummaryWindow.java b/desktop/src/main/java/bisq/desktop/main/overlays/windows/DisputeSummaryWindow.java index b55aa07e081..6202b6b2042 100644 --- a/desktop/src/main/java/bisq/desktop/main/overlays/windows/DisputeSummaryWindow.java +++ b/desktop/src/main/java/bisq/desktop/main/overlays/windows/DisputeSummaryWindow.java @@ -486,18 +486,18 @@ private void addPayoutAmountTextFields() { } private void addReasonControls() { - reasonWasBugRadioButton = new AutoTooltipRadioButton(Res.get("disputeSummaryWindow.reason.bug")); - reasonWasUsabilityIssueRadioButton = new AutoTooltipRadioButton(Res.get("disputeSummaryWindow.reason.usability")); - reasonProtocolViolationRadioButton = new AutoTooltipRadioButton(Res.get("disputeSummaryWindow.reason.protocolViolation")); - reasonNoReplyRadioButton = new AutoTooltipRadioButton(Res.get("disputeSummaryWindow.reason.noReply")); - reasonWasScamRadioButton = new AutoTooltipRadioButton(Res.get("disputeSummaryWindow.reason.scam")); - reasonWasBankRadioButton = new AutoTooltipRadioButton(Res.get("disputeSummaryWindow.reason.bank")); - reasonWasOtherRadioButton = new AutoTooltipRadioButton(Res.get("disputeSummaryWindow.reason.other")); - reasonWasOptionTradeRadioButton = new AutoTooltipRadioButton(Res.get("disputeSummaryWindow.reason.optionTrade")); - reasonWasSellerNotRespondingRadioButton = new AutoTooltipRadioButton(Res.get("disputeSummaryWindow.reason.sellerNotResponding")); - reasonWasWrongSenderAccountRadioButton = new AutoTooltipRadioButton(Res.get("disputeSummaryWindow.reason.wrongSenderAccount")); - reasonWasPeerWasLateRadioButton = new AutoTooltipRadioButton(Res.get("disputeSummaryWindow.reason.peerWasLate")); - reasonWasTradeAlreadySettledRadioButton = new AutoTooltipRadioButton(Res.get("disputeSummaryWindow.reason.tradeAlreadySettled")); + reasonWasBugRadioButton = new AutoTooltipRadioButton(Res.get("disputeSummaryWindow.reason." + DisputeResult.Reason.BUG.name())); + reasonWasUsabilityIssueRadioButton = new AutoTooltipRadioButton(Res.get("disputeSummaryWindow.reason." + DisputeResult.Reason.USABILITY.name())); + reasonProtocolViolationRadioButton = new AutoTooltipRadioButton(Res.get("disputeSummaryWindow.reason." + DisputeResult.Reason.PROTOCOL_VIOLATION.name())); + reasonNoReplyRadioButton = new AutoTooltipRadioButton(Res.get("disputeSummaryWindow.reason." + DisputeResult.Reason.NO_REPLY.name())); + reasonWasScamRadioButton = new AutoTooltipRadioButton(Res.get("disputeSummaryWindow.reason." + DisputeResult.Reason.SCAM.name())); + reasonWasBankRadioButton = new AutoTooltipRadioButton(Res.get("disputeSummaryWindow.reason." + DisputeResult.Reason.BANK_PROBLEMS.name())); + reasonWasOtherRadioButton = new AutoTooltipRadioButton(Res.get("disputeSummaryWindow.reason." + DisputeResult.Reason.OTHER.name())); + reasonWasOptionTradeRadioButton = new AutoTooltipRadioButton(Res.get("disputeSummaryWindow.reason." + DisputeResult.Reason.OPTION_TRADE.name())); + reasonWasSellerNotRespondingRadioButton = new AutoTooltipRadioButton(Res.get("disputeSummaryWindow.reason." + DisputeResult.Reason.SELLER_NOT_RESPONDING.name())); + reasonWasWrongSenderAccountRadioButton = new AutoTooltipRadioButton(Res.get("disputeSummaryWindow.reason." + DisputeResult.Reason.WRONG_SENDER_ACCOUNT.name())); + reasonWasPeerWasLateRadioButton = new AutoTooltipRadioButton(Res.get("disputeSummaryWindow.reason." + DisputeResult.Reason.PEER_WAS_LATE.name())); + reasonWasTradeAlreadySettledRadioButton = new AutoTooltipRadioButton(Res.get("disputeSummaryWindow.reason." + DisputeResult.Reason.TRADE_ALREADY_SETTLED.name())); HBox feeRadioButtonPane = new HBox(); feeRadioButtonPane.setSpacing(20); @@ -745,12 +745,20 @@ private void doClose(Button closeTicketButton) { disputeResult.setCloseDate(new Date()); dispute.setDisputeResult(disputeResult); dispute.setIsClosed(true); + DisputeResult.Reason reason = disputeResult.getReason(); String text = Res.get("disputeSummaryWindow.close.msg", DisplayUtils.formatDateTime(disputeResult.getCloseDate()), formatter.formatCoinWithCode(disputeResult.getBuyerPayoutAmount()), formatter.formatCoinWithCode(disputeResult.getSellerPayoutAmount()), + Res.get("disputeSummaryWindow.reason." + reason.name()), disputeResult.summaryNotesProperty().get()); + if (reason == DisputeResult.Reason.OPTION_TRADE && + dispute.getChatMessages().size() > 1 && + dispute.getChatMessages().get(1).isSystemMessage()) { + text += "\n\n" + dispute.getChatMessages().get(1).getMessage(); + } + if (dispute.getSupportType() == SupportType.MEDIATION) { text += Res.get("disputeSummaryWindow.close.nextStepsForMediation"); } else if (dispute.getSupportType() == SupportType.REFUND) {