From 1aca4b08a8412ccb17b2dd53afbb8370c7120f70 Mon Sep 17 00:00:00 2001 From: woodser <13068859+woodser@users.noreply.github.com> Date: Sat, 5 Apr 2025 11:35:59 -0400 Subject: [PATCH] support cloning offers with same reserved funds --- .../main/java/haveno/core/api/CoreApi.java | 32 +- .../haveno/core/api/CoreOffersService.java | 113 +++-- .../haveno/core/api/XmrConnectionService.java | 26 ++ .../haveno/core/offer/CreateOfferService.java | 96 +++- .../haveno/core/offer/OfferBookService.java | 253 ++++++++--- .../java/haveno/core/offer/OfferPayload.java | 4 + .../java/haveno/core/offer/OpenOffer.java | 16 +- .../haveno/core/offer/OpenOfferManager.java | 410 +++++++++++------- .../tasks/MakerReserveOfferFunds.java | 49 ++- .../placeoffer/tasks/MaybeAddToOfferBook.java | 10 + .../dispute/mediation/MediationManager.java | 2 +- .../support/dispute/refund/RefundManager.java | 4 +- .../main/java/haveno/core/trade/Trade.java | 8 +- .../java/haveno/core/trade/TradeManager.java | 4 +- .../tasks/TakerReserveTradeFunds.java | 2 +- .../core/xmr/model/XmrAddressEntry.java | 4 +- .../core/xmr/model/XmrAddressEntryList.java | 23 +- .../haveno/core/xmr/wallet/Restrictions.java | 1 + .../core/xmr/wallet/XmrKeyImagePoller.java | 145 +++---- .../core/xmr/wallet/XmrWalletService.java | 47 +- .../resources/i18n/displayStrings.properties | 40 +- .../i18n/displayStrings_cs.properties | 1 - .../i18n/displayStrings_tr.properties | 1 - .../haveno/daemon/grpc/GrpcOffersService.java | 1 + .../java/haveno/desktop/app/HavenoApp.java | 2 +- .../src/main/java/haveno/desktop/haveno.css | 4 + .../main/funds/deposit/DepositView.java | 2 +- .../funds/withdrawal/WithdrawalListItem.java | 2 +- .../main/offer/MutableOfferDataModel.java | 50 ++- .../desktop/main/offer/MutableOfferView.java | 6 +- .../main/offer/MutableOfferViewModel.java | 4 +- .../offer/createoffer/CreateOfferView.java | 3 +- .../main/offer/offerbook/OfferBook.java | 89 ++-- .../main/offer/offerbook/OfferBookView.java | 5 + .../offer/offerbook/OfferBookViewModel.java | 5 + .../desktop/main/portfolio/PortfolioView.java | 78 +++- .../cloneoffer/CloneOfferDataModel.java | 195 +++++++++ .../portfolio/cloneoffer/CloneOfferView.fxml | 24 + .../portfolio/cloneoffer/CloneOfferView.java | 261 +++++++++++ .../cloneoffer/CloneOfferViewModel.java | 120 +++++ .../closedtrades/ClosedTradesView.java | 2 +- .../DuplicateOfferDataModel.java | 11 - .../duplicateoffer/DuplicateOfferView.java | 1 + .../editoffer/EditOfferDataModel.java | 23 +- .../portfolio/editoffer/EditOfferView.java | 41 +- .../editoffer/EditOfferViewModel.java | 4 +- .../openoffer/OpenOfferListItem.java | 4 + .../portfolio/openoffer/OpenOffersView.fxml | 7 +- .../portfolio/openoffer/OpenOffersView.java | 366 +++++++++++++--- .../openoffer/OpenOffersViewModel.java | 4 + .../java/haveno/desktop/util/FormBuilder.java | 4 +- .../createoffer/CreateOfferDataModelTest.java | 4 +- .../createoffer/CreateOfferViewModelTest.java | 2 +- proto/src/main/proto/grpc.proto | 1 + proto/src/main/proto/pb.proto | 1 + 55 files changed, 2006 insertions(+), 611 deletions(-) create mode 100644 desktop/src/main/java/haveno/desktop/main/portfolio/cloneoffer/CloneOfferDataModel.java create mode 100644 desktop/src/main/java/haveno/desktop/main/portfolio/cloneoffer/CloneOfferView.fxml create mode 100644 desktop/src/main/java/haveno/desktop/main/portfolio/cloneoffer/CloneOfferView.java create mode 100644 desktop/src/main/java/haveno/desktop/main/portfolio/cloneoffer/CloneOfferViewModel.java diff --git a/core/src/main/java/haveno/core/api/CoreApi.java b/core/src/main/java/haveno/core/api/CoreApi.java index 99fb3bc74f..e8e83978eb 100644 --- a/core/src/main/java/haveno/core/api/CoreApi.java +++ b/core/src/main/java/haveno/core/api/CoreApi.java @@ -413,21 +413,22 @@ public class CoreApi { } public void postOffer(String currencyCode, - String directionAsString, - String priceAsString, - boolean useMarketBasedPrice, - double marketPriceMargin, - long amountAsLong, - long minAmountAsLong, - double securityDepositPct, - String triggerPriceAsString, - boolean reserveExactAmount, - String paymentAccountId, - boolean isPrivateOffer, - boolean buyerAsTakerWithoutDeposit, - String extraInfo, - Consumer resultHandler, - ErrorMessageHandler errorMessageHandler) { + String directionAsString, + String priceAsString, + boolean useMarketBasedPrice, + double marketPriceMargin, + long amountAsLong, + long minAmountAsLong, + double securityDepositPct, + String triggerPriceAsString, + boolean reserveExactAmount, + String paymentAccountId, + boolean isPrivateOffer, + boolean buyerAsTakerWithoutDeposit, + String extraInfo, + String sourceOfferId, + Consumer resultHandler, + ErrorMessageHandler errorMessageHandler) { coreOffersService.postOffer(currencyCode, directionAsString, priceAsString, @@ -442,6 +443,7 @@ public class CoreApi { isPrivateOffer, buyerAsTakerWithoutDeposit, extraInfo, + sourceOfferId, resultHandler, errorMessageHandler); } diff --git a/core/src/main/java/haveno/core/api/CoreOffersService.java b/core/src/main/java/haveno/core/api/CoreOffersService.java index f9c450b825..3ee7e047f1 100644 --- a/core/src/main/java/haveno/core/api/CoreOffersService.java +++ b/core/src/main/java/haveno/core/api/CoreOffersService.java @@ -43,6 +43,7 @@ import static haveno.common.util.MathUtils.exactMultiply; import static haveno.common.util.MathUtils.roundDoubleToLong; import static haveno.common.util.MathUtils.scaleUpByPowerOf10; import haveno.core.locale.CurrencyUtil; +import haveno.core.locale.Res; import haveno.core.monetary.CryptoMoney; import haveno.core.monetary.Price; import haveno.core.monetary.TraditionalMoney; @@ -66,9 +67,7 @@ import java.math.BigInteger; import java.util.ArrayList; import java.util.Comparator; import static java.util.Comparator.comparing; -import java.util.HashSet; import java.util.List; -import java.util.Set; import java.util.function.Consumer; import java.util.function.Supplier; import java.util.stream.Collectors; @@ -124,7 +123,6 @@ public class CoreOffersService { return result.isValid() || result == Result.HAS_NO_PAYMENT_ACCOUNT_VALID_FOR_OFFER; }) .collect(Collectors.toList()); - offers.removeAll(getOffersWithDuplicateKeyImages(offers)); return offers; } @@ -143,12 +141,9 @@ public class CoreOffersService { } List getMyOffers() { - List offers = openOfferManager.getOpenOffers().stream() + return openOfferManager.getOpenOffers().stream() .filter(o -> o.getOffer().isMyOffer(keyRing)) .collect(Collectors.toList()); - Set offersWithDuplicateKeyImages = getOffersWithDuplicateKeyImages(offers.stream().map(OpenOffer::getOffer).collect(Collectors.toList())); // TODO: this is hacky way of filtering offers with duplicate key images - Set offerIdsWithDuplicateKeyImages = offersWithDuplicateKeyImages.stream().map(Offer::getId).collect(Collectors.toSet()); - return offers.stream().filter(o -> !offerIdsWithDuplicateKeyImages.contains(o.getId())).collect(Collectors.toList()); }; List getMyOffers(String direction, String currencyCode) { @@ -179,15 +174,31 @@ public class CoreOffersService { boolean isPrivateOffer, boolean buyerAsTakerWithoutDeposit, String extraInfo, + String sourceOfferId, Consumer resultHandler, ErrorMessageHandler errorMessageHandler) { coreWalletsService.verifyWalletsAreAvailable(); coreWalletsService.verifyEncryptedWalletIsUnlocked(); PaymentAccount paymentAccount = user.getPaymentAccount(paymentAccountId); - if (paymentAccount == null) - throw new IllegalArgumentException(format("payment account with id %s not found", paymentAccountId)); + if (paymentAccount == null) throw new IllegalArgumentException(format("payment account with id %s not found", paymentAccountId)); + // clone offer if sourceOfferId given + if (!sourceOfferId.isEmpty()) { + cloneOffer(sourceOfferId, + currencyCode, + priceAsString, + useMarketBasedPrice, + marketPriceMargin, + triggerPriceAsString, + paymentAccountId, + extraInfo, + resultHandler, + errorMessageHandler); + return; + } + + // create new offer String upperCaseCurrencyCode = currencyCode.toUpperCase(); String offerId = createOfferService.getRandomOfferId(); OfferDirection direction = OfferDirection.valueOf(directionAsString.toUpperCase()); @@ -210,17 +221,70 @@ public class CoreOffersService { verifyPaymentAccountIsValidForNewOffer(offer, paymentAccount); - // We don't support atm funding from external wallet to keep it simple. - boolean useSavingsWallet = true; - //noinspection ConstantConditions placeOffer(offer, triggerPriceAsString, - useSavingsWallet, + true, reserveExactAmount, + null, transaction -> resultHandler.accept(offer), errorMessageHandler); } + private void cloneOffer(String sourceOfferId, + String currencyCode, + String priceAsString, + boolean useMarketBasedPrice, + double marketPriceMargin, + String triggerPriceAsString, + String paymentAccountId, + String extraInfo, + Consumer resultHandler, + ErrorMessageHandler errorMessageHandler) { + + // get source offer + OpenOffer sourceOpenOffer = getMyOffer(sourceOfferId); + Offer sourceOffer = sourceOpenOffer.getOffer(); + + // get trade currency (default source currency) + if (currencyCode.isEmpty()) currencyCode = sourceOffer.getOfferPayload().getBaseCurrencyCode(); + if (currencyCode.equalsIgnoreCase(Res.getBaseCurrencyCode())) currencyCode = sourceOffer.getOfferPayload().getCounterCurrencyCode(); + String upperCaseCurrencyCode = currencyCode.toUpperCase(); + + // get price (default source price) + Price price = useMarketBasedPrice ? null : priceAsString.isEmpty() ? sourceOffer.isUseMarketBasedPrice() ? null : sourceOffer.getPrice() : Price.parse(upperCaseCurrencyCode, priceAsString); + if (price == null) useMarketBasedPrice = true; + + // get payment account + if (paymentAccountId.isEmpty()) paymentAccountId = sourceOffer.getOfferPayload().getMakerPaymentAccountId(); + PaymentAccount paymentAccount = user.getPaymentAccount(paymentAccountId); + if (paymentAccount == null) throw new IllegalArgumentException(format("payment acRcount with id %s not found", paymentAccountId)); + + // get extra info + if (extraInfo.isEmpty()) extraInfo = sourceOffer.getOfferPayload().getExtraInfo(); + + // create cloned offer + Offer offer = createOfferService.createClonedOffer(sourceOffer, + upperCaseCurrencyCode, + price, + useMarketBasedPrice, + exactMultiply(marketPriceMargin, 0.01), + paymentAccount, + extraInfo); + + // verify cloned offer + verifyPaymentAccountIsValidForNewOffer(offer, paymentAccount); + + // place offer + placeOffer(offer, + triggerPriceAsString, + true, + false, // ignored when cloning + sourceOfferId, + transaction -> resultHandler.accept(offer), + errorMessageHandler); + } + + // TODO: this implementation is missing; implement. Offer editOffer(String offerId, String currencyCode, OfferDirection direction, @@ -256,27 +320,6 @@ public class CoreOffersService { // -------------------------- PRIVATE HELPERS ----------------------------- - private Set getOffersWithDuplicateKeyImages(List offers) { - Set duplicateFundedOffers = new HashSet(); - Set seenKeyImages = new HashSet(); - for (Offer offer : offers) { - if (offer.getOfferPayload().getReserveTxKeyImages() == null) continue; - for (String keyImage : offer.getOfferPayload().getReserveTxKeyImages()) { - if (!seenKeyImages.add(keyImage)) { - for (Offer offer2 : offers) { - if (offer == offer2) continue; - if (offer2.getOfferPayload().getReserveTxKeyImages() == null) continue; - if (offer2.getOfferPayload().getReserveTxKeyImages().contains(keyImage)) { - log.warn("Key image {} belongs to multiple offers, seen in offer {} and {}", keyImage, offer.getId(), offer2.getId()); - duplicateFundedOffers.add(offer2); - } - } - } - } - } - return duplicateFundedOffers; - } - private void verifyPaymentAccountIsValidForNewOffer(Offer offer, PaymentAccount paymentAccount) { if (!isPaymentAccountValidForOffer(offer, paymentAccount)) { String error = format("cannot create %s offer with payment account %s", @@ -290,6 +333,7 @@ public class CoreOffersService { String triggerPriceAsString, boolean useSavingsWallet, boolean reserveExactAmount, + String sourceOfferId, Consumer resultHandler, ErrorMessageHandler errorMessageHandler) { long triggerPriceAsLong = PriceUtil.getMarketPriceAsLong(triggerPriceAsString, offer.getCurrencyCode()); @@ -298,6 +342,7 @@ public class CoreOffersService { triggerPriceAsLong, reserveExactAmount, true, + sourceOfferId, resultHandler::accept, errorMessageHandler); } diff --git a/core/src/main/java/haveno/core/api/XmrConnectionService.java b/core/src/main/java/haveno/core/api/XmrConnectionService.java index d686d64925..f848113e5b 100644 --- a/core/src/main/java/haveno/core/api/XmrConnectionService.java +++ b/core/src/main/java/haveno/core/api/XmrConnectionService.java @@ -33,6 +33,7 @@ import haveno.core.xmr.nodes.XmrNodes.XmrNode; import haveno.core.xmr.nodes.XmrNodesSetupPreferences; import haveno.core.xmr.setup.DownloadListener; import haveno.core.xmr.setup.WalletsSetup; +import haveno.core.xmr.wallet.XmrKeyImagePoller; import haveno.network.Socks5ProxyProvider; import haveno.network.p2p.P2PService; import haveno.network.p2p.P2PServiceListener; @@ -72,6 +73,8 @@ public final class XmrConnectionService { private static final int MIN_BROADCAST_CONNECTIONS = 0; // TODO: 0 for stagenet, 5+ for mainnet private static final long REFRESH_PERIOD_HTTP_MS = 20000; // refresh period when connected to remote node over http private static final long REFRESH_PERIOD_ONION_MS = 30000; // refresh period when connected to remote node over tor + private static final long KEY_IMAGE_REFRESH_PERIOD_MS_LOCAL = 20000; // 20 seconds + private static final long KEY_IMAGE_REFRESH_PERIOD_MS_REMOTE = 300000; // 5 minutes private final Object lock = new Object(); private final Object pollLock = new Object(); @@ -111,6 +114,7 @@ public final class XmrConnectionService { @Getter private boolean isShutDownStarted; private List listeners = new ArrayList<>(); + private XmrKeyImagePoller keyImagePoller; // connection switching private static final int EXCLUDE_CONNECTION_SECONDS = 180; @@ -392,6 +396,17 @@ public final class XmrConnectionService { return lastInfo.getTargetHeight() == 0 ? chainHeight.get() : lastInfo.getTargetHeight(); // monerod sync_info's target_height returns 0 when node is fully synced } + public XmrKeyImagePoller getKeyImagePoller() { + synchronized (lock) { + if (keyImagePoller == null) keyImagePoller = new XmrKeyImagePoller(); + return keyImagePoller; + } + } + + private long getKeyImageRefreshPeriodMs() { + return isConnectionLocalHost() ? KEY_IMAGE_REFRESH_PERIOD_MS_LOCAL : KEY_IMAGE_REFRESH_PERIOD_MS_REMOTE; + } + // ----------------------------- APP METHODS ------------------------------ public ReadOnlyIntegerProperty numConnectionsProperty() { @@ -477,6 +492,13 @@ public final class XmrConnectionService { private void initialize() { + // initialize key image poller + getKeyImagePoller(); + new Thread(() -> { + HavenoUtils.waitFor(20000); + keyImagePoller.poll(); // TODO: keep or remove first poll?s + }).start(); + // initialize connections initializeConnections(); @@ -656,6 +678,10 @@ public final class XmrConnectionService { numUpdates.set(numUpdates.get() + 1); }); } + + // update key image poller + keyImagePoller.setDaemon(getDaemon()); + keyImagePoller.setRefreshPeriodMs(getKeyImageRefreshPeriodMs()); // update polling doPollDaemon(); diff --git a/core/src/main/java/haveno/core/offer/CreateOfferService.java b/core/src/main/java/haveno/core/offer/CreateOfferService.java index bca446827c..fab646433b 100644 --- a/core/src/main/java/haveno/core/offer/CreateOfferService.java +++ b/core/src/main/java/haveno/core/offer/CreateOfferService.java @@ -92,6 +92,7 @@ public class CreateOfferService { Version.VERSION.replace(".", ""); } + // TODO: add trigger price? public Offer createAndGetOffer(String offerId, OfferDirection direction, String currencyCode, @@ -105,7 +106,7 @@ public class CreateOfferService { boolean isPrivateOffer, boolean buyerAsTakerWithoutDeposit, String extraInfo) { - log.info("create and get offer with offerId={}, " + + log.info("Create and get offer with offerId={}, " + "currencyCode={}, " + "direction={}, " + "fixedPrice={}, " + @@ -238,6 +239,99 @@ public class CreateOfferService { return offer; } + // TODO: add trigger price? + public Offer createClonedOffer(Offer sourceOffer, + String currencyCode, + Price fixedPrice, + boolean useMarketBasedPrice, + double marketPriceMargin, + PaymentAccount paymentAccount, + String extraInfo) { + log.info("Cloning offer with sourceId={}, " + + "currencyCode={}, " + + "fixedPrice={}, " + + "useMarketBasedPrice={}, " + + "marketPriceMargin={}, " + + "extraInfo={}", + sourceOffer.getId(), + currencyCode, + fixedPrice == null ? null : fixedPrice.getValue(), + useMarketBasedPrice, + marketPriceMargin, + extraInfo); + + OfferPayload sourceOfferPayload = sourceOffer.getOfferPayload(); + String newOfferId = OfferUtil.getRandomOfferId(); + Offer editedOffer = createAndGetOffer(newOfferId, + sourceOfferPayload.getDirection(), + currencyCode, + BigInteger.valueOf(sourceOfferPayload.getAmount()), + BigInteger.valueOf(sourceOfferPayload.getMinAmount()), + fixedPrice, + useMarketBasedPrice, + marketPriceMargin, + sourceOfferPayload.getSellerSecurityDepositPct(), + paymentAccount, + sourceOfferPayload.isPrivateOffer(), + sourceOfferPayload.isBuyerAsTakerWithoutDeposit(), + extraInfo); + + // generate one-time challenge for private offer + String challenge = null; + String challengeHash = null; + if (sourceOfferPayload.isPrivateOffer()) { + challenge = HavenoUtils.generateChallenge(); + challengeHash = HavenoUtils.getChallengeHash(challenge); + } + + OfferPayload editedOfferPayload = editedOffer.getOfferPayload(); + long date = new Date().getTime(); + OfferPayload clonedOfferPayload = new OfferPayload(newOfferId, + date, + sourceOfferPayload.getOwnerNodeAddress(), + sourceOfferPayload.getPubKeyRing(), + sourceOfferPayload.getDirection(), + editedOfferPayload.getPrice(), + editedOfferPayload.getMarketPriceMarginPct(), + editedOfferPayload.isUseMarketBasedPrice(), + sourceOfferPayload.getAmount(), + sourceOfferPayload.getMinAmount(), + sourceOfferPayload.getMakerFeePct(), + sourceOfferPayload.getTakerFeePct(), + sourceOfferPayload.getPenaltyFeePct(), + sourceOfferPayload.getBuyerSecurityDepositPct(), + sourceOfferPayload.getSellerSecurityDepositPct(), + editedOfferPayload.getBaseCurrencyCode(), + editedOfferPayload.getCounterCurrencyCode(), + editedOfferPayload.getPaymentMethodId(), + editedOfferPayload.getMakerPaymentAccountId(), + editedOfferPayload.getCountryCode(), + editedOfferPayload.getAcceptedCountryCodes(), + editedOfferPayload.getBankId(), + editedOfferPayload.getAcceptedBankIds(), + editedOfferPayload.getVersionNr(), + sourceOfferPayload.getBlockHeightAtOfferCreation(), + editedOfferPayload.getMaxTradeLimit(), + editedOfferPayload.getMaxTradePeriod(), + sourceOfferPayload.isUseAutoClose(), + sourceOfferPayload.isUseReOpenAfterAutoClose(), + sourceOfferPayload.getLowerClosePrice(), + sourceOfferPayload.getUpperClosePrice(), + sourceOfferPayload.isPrivateOffer(), + challengeHash, + editedOfferPayload.getExtraDataMap(), + sourceOfferPayload.getProtocolVersion(), + null, + null, + sourceOfferPayload.getReserveTxKeyImages(), + editedOfferPayload.getExtraInfo()); + Offer clonedOffer = new Offer(clonedOfferPayload); + clonedOffer.setPriceFeedService(priceFeedService); + clonedOffer.setChallenge(challenge); + clonedOffer.setState(Offer.State.AVAILABLE); + return clonedOffer; + } + /////////////////////////////////////////////////////////////////////////////////////////// // Private /////////////////////////////////////////////////////////////////////////////////////////// diff --git a/core/src/main/java/haveno/core/offer/OfferBookService.java b/core/src/main/java/haveno/core/offer/OfferBookService.java index 7698aeb1ca..16faa81e57 100644 --- a/core/src/main/java/haveno/core/offer/OfferBookService.java +++ b/core/src/main/java/haveno/core/offer/OfferBookService.java @@ -36,6 +36,9 @@ package haveno.core.offer; import com.google.inject.Inject; import com.google.inject.name.Named; + +import haveno.common.ThreadUtils; +import haveno.common.Timer; import haveno.common.UserThread; import haveno.common.config.Config; import haveno.common.file.JsonFileManager; @@ -45,45 +48,51 @@ import haveno.core.api.XmrConnectionService; import haveno.core.filter.FilterManager; import haveno.core.locale.Res; import haveno.core.provider.price.PriceFeedService; -import haveno.core.trade.HavenoUtils; import haveno.core.util.JsonUtil; +import haveno.core.xmr.wallet.Restrictions; import haveno.core.xmr.wallet.XmrKeyImageListener; -import haveno.core.xmr.wallet.XmrKeyImagePoller; import haveno.network.p2p.BootstrapListener; import haveno.network.p2p.P2PService; import haveno.network.p2p.storage.HashMapChangedListener; import haveno.network.p2p.storage.payload.ProtectedStorageEntry; +import haveno.network.utils.Utils; +import lombok.extern.slf4j.Slf4j; + import java.io.File; +import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.stream.Collectors; import javax.annotation.Nullable; import monero.daemon.model.MoneroKeyImageSpentStatus; /** - * Handles storage and retrieval of offers. - * Uses an invalidation flag to only request the full offer map in case there was a change (anyone has added or removed an offer). + * Handles validation and announcement of offers added or removed. */ +@Slf4j public class OfferBookService { + private final static long INVALID_OFFERS_TIMEOUT = 5 * 60 * 1000; // 5 minutes + private final P2PService p2PService; private final PriceFeedService priceFeedService; private final List offerBookChangedListeners = new LinkedList<>(); private final FilterManager filterManager; private final JsonFileManager jsonFileManager; private final XmrConnectionService xmrConnectionService; - - // poll key images of offers - private XmrKeyImagePoller keyImagePoller; - private static final long KEY_IMAGE_REFRESH_PERIOD_MS_LOCAL = 20000; // 20 seconds - private static final long KEY_IMAGE_REFRESH_PERIOD_MS_REMOTE = 300000; // 5 minutes + private final List validOffers = new ArrayList(); + private final List invalidOffers = new ArrayList(); + private final Map invalidOfferTimers = new HashMap<>(); public interface OfferBookChangedListener { void onAdded(Offer offer); - void onRemoved(Offer offer); } @@ -104,51 +113,45 @@ public class OfferBookService { this.xmrConnectionService = xmrConnectionService; jsonFileManager = new JsonFileManager(storageDir); - // listen for connection changes to monerod - xmrConnectionService.addConnectionListener((connection) -> { - maybeInitializeKeyImagePoller(); - keyImagePoller.setDaemon(xmrConnectionService.getDaemon()); - keyImagePoller.setRefreshPeriodMs(getKeyImageRefreshPeriodMs()); - }); - // listen for offers p2PService.addHashSetChangedListener(new HashMapChangedListener() { @Override public void onAdded(Collection protectedStorageEntries) { - UserThread.execute(() -> { + ThreadUtils.execute(() -> { protectedStorageEntries.forEach(protectedStorageEntry -> { if (protectedStorageEntry.getProtectedStoragePayload() instanceof OfferPayload) { OfferPayload offerPayload = (OfferPayload) protectedStorageEntry.getProtectedStoragePayload(); - maybeInitializeKeyImagePoller(); - keyImagePoller.addKeyImages(offerPayload.getReserveTxKeyImages()); Offer offer = new Offer(offerPayload); offer.setPriceFeedService(priceFeedService); - setReservedFundsSpent(offer); - synchronized (offerBookChangedListeners) { - offerBookChangedListeners.forEach(listener -> listener.onAdded(offer)); + synchronized (validOffers) { + try { + validateOfferPayload(offerPayload); + replaceValidOffer(offer); + announceOfferAdded(offer); + } catch (IllegalArgumentException e) { + // ignore illegal offers + } catch (RuntimeException e) { + replaceInvalidOffer(offer); // offer can become valid later + } } } }); - }); + }, OfferBookService.class.getSimpleName()); } @Override public void onRemoved(Collection protectedStorageEntries) { - UserThread.execute(() -> { + ThreadUtils.execute(() -> { protectedStorageEntries.forEach(protectedStorageEntry -> { if (protectedStorageEntry.getProtectedStoragePayload() instanceof OfferPayload) { OfferPayload offerPayload = (OfferPayload) protectedStorageEntry.getProtectedStoragePayload(); - maybeInitializeKeyImagePoller(); - keyImagePoller.removeKeyImages(offerPayload.getReserveTxKeyImages()); + removeValidOffer(offerPayload.getId()); Offer offer = new Offer(offerPayload); offer.setPriceFeedService(priceFeedService); - setReservedFundsSpent(offer); - synchronized (offerBookChangedListeners) { - offerBookChangedListeners.forEach(listener -> listener.onRemoved(offer)); - } + announceOfferRemoved(offer); } }); - }); + }, OfferBookService.class.getSimpleName()); } }); @@ -171,6 +174,16 @@ public class OfferBookService { } }); } + + // listen for changes to key images + xmrConnectionService.getKeyImagePoller().addListener(new XmrKeyImageListener() { + @Override + public void onSpentStatusChanged(Map spentStatuses) { + for (String keyImage : spentStatuses.keySet()) { + updateAffectedOffers(keyImage); + } + } + }); } @@ -178,6 +191,10 @@ public class OfferBookService { // API /////////////////////////////////////////////////////////////////////////////////////////// + public boolean hasOffer(String offerId) { + return hasValidOffer(offerId); + } + public void addOffer(Offer offer, ResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) { if (filterManager.requireUpdateToNewVersionForTrading()) { errorMessageHandler.handleErrorMessage(Res.get("popup.warning.mandatoryUpdate.trading")); @@ -233,16 +250,9 @@ public class OfferBookService { } public List getOffers() { - return p2PService.getDataMap().values().stream() - .filter(data -> data.getProtectedStoragePayload() instanceof OfferPayload) - .map(data -> { - OfferPayload offerPayload = (OfferPayload) data.getProtectedStoragePayload(); - Offer offer = new Offer(offerPayload); - offer.setPriceFeedService(priceFeedService); - setReservedFundsSpent(offer); - return offer; - }) - .collect(Collectors.toList()); + synchronized (validOffers) { + return new ArrayList<>(validOffers); + } } public List getOffersByCurrency(String direction, String currencyCode) { @@ -266,7 +276,7 @@ public class OfferBookService { } public void shutDown() { - if (keyImagePoller != null) keyImagePoller.clearKeyImages(); + xmrConnectionService.getKeyImagePoller().removeKeyImages(OfferBookService.class.getName()); } @@ -274,37 +284,145 @@ public class OfferBookService { // Private /////////////////////////////////////////////////////////////////////////////////////////// - private synchronized void maybeInitializeKeyImagePoller() { - if (keyImagePoller != null) return; - keyImagePoller = new XmrKeyImagePoller(xmrConnectionService.getDaemon(), getKeyImageRefreshPeriodMs()); + private void announceOfferAdded(Offer offer) { + xmrConnectionService.getKeyImagePoller().addKeyImages(offer.getOfferPayload().getReserveTxKeyImages(), OfferBookService.class.getSimpleName()); + updateReservedFundsSpentStatus(offer); + synchronized (offerBookChangedListeners) { + offerBookChangedListeners.forEach(listener -> listener.onAdded(offer)); + } + } - // handle when key images spent - keyImagePoller.addListener(new XmrKeyImageListener() { - @Override - public void onSpentStatusChanged(Map spentStatuses) { - UserThread.execute(() -> { - for (String keyImage : spentStatuses.keySet()) { - updateAffectedOffers(keyImage); - } - }); + private void announceOfferRemoved(Offer offer) { + updateReservedFundsSpentStatus(offer); + removeKeyImages(offer); + synchronized (offerBookChangedListeners) { + offerBookChangedListeners.forEach(listener -> listener.onRemoved(offer)); + } + + // check if invalid offers are now valid + synchronized (invalidOffers) { + for (Offer invalidOffer : new ArrayList(invalidOffers)) { + try { + validateOfferPayload(invalidOffer.getOfferPayload()); + removeInvalidOffer(invalidOffer.getId()); + replaceValidOffer(invalidOffer); + announceOfferAdded(invalidOffer); + } catch (Exception e) { + // ignore + } } - }); - - // first poll after 20s - // TODO: remove? - new Thread(() -> { - HavenoUtils.waitFor(20000); - keyImagePoller.poll(); - }).start(); + } } - private long getKeyImageRefreshPeriodMs() { - return xmrConnectionService.isConnectionLocalHost() ? KEY_IMAGE_REFRESH_PERIOD_MS_LOCAL : KEY_IMAGE_REFRESH_PERIOD_MS_REMOTE; + private boolean hasValidOffer(String offerId) { + for (Offer offer : getOffers()) { + if (offer.getId().equals(offerId)) { + return true; + } + } + return false; + } + + private void replaceValidOffer(Offer offer) { + synchronized (validOffers) { + removeValidOffer(offer.getId()); + validOffers.add(offer); + } } + private void replaceInvalidOffer(Offer offer) { + synchronized (invalidOffers) { + removeInvalidOffer(offer.getId()); + invalidOffers.add(offer); + + // remove invalid offer after timeout + synchronized (invalidOfferTimers) { + Timer timer = invalidOfferTimers.get(offer.getId()); + if (timer != null) timer.stop(); + timer = UserThread.runAfter(() -> { + removeInvalidOffer(offer.getId()); + }, INVALID_OFFERS_TIMEOUT); + invalidOfferTimers.put(offer.getId(), timer); + } + } + } + + private void removeValidOffer(String offerId) { + synchronized (validOffers) { + validOffers.removeIf(offer -> offer.getId().equals(offerId)); + } + } + + private void removeInvalidOffer(String offerId) { + synchronized (invalidOffers) { + invalidOffers.removeIf(offer -> offer.getId().equals(offerId)); + + // remove timeout + synchronized (invalidOfferTimers) { + Timer timer = invalidOfferTimers.get(offerId); + if (timer != null) timer.stop(); + invalidOfferTimers.remove(offerId); + } + } + } + + private void validateOfferPayload(OfferPayload offerPayload) { + + // validate offer is not banned + if (filterManager.isOfferIdBanned(offerPayload.getId())) { + throw new IllegalArgumentException("Offer is banned with offerId=" + offerPayload.getId()); + } + + // validate v3 node address compliance + boolean isV3NodeAddressCompliant = !OfferRestrictions.requiresNodeAddressUpdate() || Utils.isV3Address(offerPayload.getOwnerNodeAddress().getHostName()); + if (!isV3NodeAddressCompliant) { + throw new IllegalArgumentException("Offer with non-V3 node address is not allowed with offerId=" + offerPayload.getId()); + } + + // validate against existing offers + synchronized (validOffers) { + int numOffersWithSharedKeyImages = 0; + for (Offer offer : validOffers) { + + // validate that no offer has overlapping but different key images + if (!offer.getOfferPayload().getReserveTxKeyImages().equals(offerPayload.getReserveTxKeyImages()) && + !Collections.disjoint(offer.getOfferPayload().getReserveTxKeyImages(), offerPayload.getReserveTxKeyImages())) { + throw new RuntimeException("Offer with overlapping key images already exists with offerId=" + offer.getId()); + } + + // validate that no offer has same key images, payment method, and currency + if (!offer.getId().equals(offerPayload.getId()) && + offer.getOfferPayload().getReserveTxKeyImages().equals(offerPayload.getReserveTxKeyImages()) && + offer.getOfferPayload().getPaymentMethodId().equals(offerPayload.getPaymentMethodId()) && + offer.getOfferPayload().getBaseCurrencyCode().equals(offerPayload.getBaseCurrencyCode()) && + offer.getOfferPayload().getCounterCurrencyCode().equals(offerPayload.getCounterCurrencyCode())) { + throw new RuntimeException("Offer with same key images, payment method, and currency already exists with offerId=" + offer.getId()); + } + + // count offers with same key images + if (!offer.getId().equals(offerPayload.getId()) && !Collections.disjoint(offer.getOfferPayload().getReserveTxKeyImages(), offerPayload.getReserveTxKeyImages())) numOffersWithSharedKeyImages = Math.max(2, numOffersWithSharedKeyImages + 1); + } + + // validate max offers with same key images + if (numOffersWithSharedKeyImages > Restrictions.MAX_OFFERS_WITH_SHARED_FUNDS) throw new RuntimeException("More than " + Restrictions.MAX_OFFERS_WITH_SHARED_FUNDS + " offers exist with same same key images as new offerId=" + offerPayload.getId()); + } + } + + private void removeKeyImages(Offer offer) { + Set unsharedKeyImages = new HashSet<>(offer.getOfferPayload().getReserveTxKeyImages()); + synchronized (validOffers) { + for (Offer validOffer : validOffers) { + if (validOffer.getId().equals(offer.getId())) continue; + unsharedKeyImages.removeAll(validOffer.getOfferPayload().getReserveTxKeyImages()); + } + } + xmrConnectionService.getKeyImagePoller().removeKeyImages(unsharedKeyImages, OfferBookService.class.getSimpleName()); + } + private void updateAffectedOffers(String keyImage) { for (Offer offer : getOffers()) { if (offer.getOfferPayload().getReserveTxKeyImages().contains(keyImage)) { + updateReservedFundsSpentStatus(offer); synchronized (offerBookChangedListeners) { offerBookChangedListeners.forEach(listener -> { listener.onRemoved(offer); @@ -315,10 +433,9 @@ public class OfferBookService { } } - private void setReservedFundsSpent(Offer offer) { - if (keyImagePoller == null) return; + private void updateReservedFundsSpentStatus(Offer offer) { for (String keyImage : offer.getOfferPayload().getReserveTxKeyImages()) { - if (Boolean.TRUE.equals(keyImagePoller.isSpent(keyImage))) { + if (Boolean.TRUE.equals(xmrConnectionService.getKeyImagePoller().isSpent(keyImage))) { offer.setReservedFundsSpent(true); } } diff --git a/core/src/main/java/haveno/core/offer/OfferPayload.java b/core/src/main/java/haveno/core/offer/OfferPayload.java index 8da91b4b15..1db2cca940 100644 --- a/core/src/main/java/haveno/core/offer/OfferPayload.java +++ b/core/src/main/java/haveno/core/offer/OfferPayload.java @@ -347,6 +347,10 @@ public final class OfferPayload implements ProtectedStoragePayload, ExpirablePay return Restrictions.getMinSecurityDeposit().max(securityDepositUnadjusted); } + public boolean isBuyerAsTakerWithoutDeposit() { + return getDirection() == OfferDirection.SELL && getBuyerSecurityDepositPct() == 0; + } + /////////////////////////////////////////////////////////////////////////////////////////// // PROTO BUFFER /////////////////////////////////////////////////////////////////////////////////////////// diff --git a/core/src/main/java/haveno/core/offer/OpenOffer.java b/core/src/main/java/haveno/core/offer/OpenOffer.java index fc4365ecba..f493b1b584 100644 --- a/core/src/main/java/haveno/core/offer/OpenOffer.java +++ b/core/src/main/java/haveno/core/offer/OpenOffer.java @@ -48,6 +48,7 @@ import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.Optional; +import java.util.UUID; @EqualsAndHashCode public final class OpenOffer implements Tradable { @@ -113,6 +114,9 @@ public final class OpenOffer implements Tradable { @Getter @Setter private boolean deactivatedByTrigger; + @Getter + @Setter + private String groupId; public OpenOffer(Offer offer) { this(offer, 0, false); @@ -127,6 +131,7 @@ public final class OpenOffer implements Tradable { this.triggerPrice = triggerPrice; this.reserveExactAmount = reserveExactAmount; this.challenge = offer.getChallenge(); + this.groupId = UUID.randomUUID().toString(); state = State.PENDING; } @@ -146,6 +151,7 @@ public final class OpenOffer implements Tradable { this.reserveTxKey = openOffer.reserveTxKey; this.challenge = openOffer.challenge; this.deactivatedByTrigger = openOffer.deactivatedByTrigger; + this.groupId = openOffer.groupId; } /////////////////////////////////////////////////////////////////////////////////////////// @@ -164,7 +170,8 @@ public final class OpenOffer implements Tradable { @Nullable String reserveTxHex, @Nullable String reserveTxKey, @Nullable String challenge, - boolean deactivatedByTrigger) { + boolean deactivatedByTrigger, + @Nullable String groupId) { this.offer = offer; this.state = state; this.triggerPrice = triggerPrice; @@ -177,6 +184,8 @@ public final class OpenOffer implements Tradable { this.reserveTxKey = reserveTxKey; this.challenge = challenge; this.deactivatedByTrigger = deactivatedByTrigger; + if (groupId == null) groupId = UUID.randomUUID().toString(); // initialize groupId if not set (added in v1.0.19) + this.groupId = groupId; // reset reserved state to available if (this.state == State.RESERVED) setState(State.AVAILABLE); @@ -199,6 +208,7 @@ public final class OpenOffer implements Tradable { Optional.ofNullable(reserveTxHex).ifPresent(e -> builder.setReserveTxHex(reserveTxHex)); Optional.ofNullable(reserveTxKey).ifPresent(e -> builder.setReserveTxKey(reserveTxKey)); Optional.ofNullable(challenge).ifPresent(e -> builder.setChallenge(challenge)); + Optional.ofNullable(groupId).ifPresent(e -> builder.setGroupId(groupId)); return protobuf.Tradable.newBuilder().setOpenOffer(builder).build(); } @@ -216,7 +226,8 @@ public final class OpenOffer implements Tradable { ProtoUtil.stringOrNullFromProto(proto.getReserveTxHex()), ProtoUtil.stringOrNullFromProto(proto.getReserveTxKey()), ProtoUtil.stringOrNullFromProto(proto.getChallenge()), - proto.getDeactivatedByTrigger()); + proto.getDeactivatedByTrigger(), + ProtoUtil.stringOrNullFromProto(proto.getGroupId())); return openOffer; } @@ -282,6 +293,7 @@ public final class OpenOffer implements Tradable { ",\n reserveExactAmount=" + reserveExactAmount + ",\n scheduledAmount=" + scheduledAmount + ",\n splitOutputTxFee=" + splitOutputTxFee + + ",\n groupId=" + groupId + "\n}"; } } diff --git a/core/src/main/java/haveno/core/offer/OpenOfferManager.java b/core/src/main/java/haveno/core/offer/OpenOfferManager.java index e68f90e484..c6a5847f01 100644 --- a/core/src/main/java/haveno/core/offer/OpenOfferManager.java +++ b/core/src/main/java/haveno/core/offer/OpenOfferManager.java @@ -55,7 +55,7 @@ import haveno.core.api.CoreContext; import haveno.core.api.XmrConnectionService; import haveno.core.exceptions.TradePriceOutOfToleranceException; import haveno.core.filter.FilterManager; -import haveno.core.offer.OfferBookService.OfferBookChangedListener; +import haveno.core.locale.Res; import haveno.core.offer.messages.OfferAvailabilityRequest; import haveno.core.offer.messages.OfferAvailabilityResponse; import haveno.core.offer.messages.SignOfferRequest; @@ -97,7 +97,6 @@ import haveno.network.p2p.peers.PeerManager; import java.math.BigInteger; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -136,6 +135,9 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe private static final long REPUBLISH_INTERVAL_MS = TimeUnit.MINUTES.toMillis(30); private static final long REFRESH_INTERVAL_MS = OfferPayload.TTL / 2; private static final int NUM_ATTEMPTS_THRESHOLD = 5; // process offer only on republish cycle after this many attempts + private static final long SHUTDOWN_TIMEOUT_MS = 60000; + private static final String OPEN_OFFER_GROUP_KEY_IMAGE_ID = OpenOffer.class.getSimpleName(); + private static final String SIGNED_OFFER_KEY_IMAGE_GROUP_ID = SignedOffer.class.getSimpleName(); private final CoreContext coreContext; private final KeyRing keyRing; @@ -169,12 +171,6 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe @Getter private final AccountAgeWitnessService accountAgeWitnessService; - // poll key images of signed offers - private XmrKeyImagePoller signedOfferKeyImagePoller; - private static final long SHUTDOWN_TIMEOUT_MS = 60000; - private static final long KEY_IMAGE_REFRESH_PERIOD_MS_LOCAL = 20000; // 20 seconds - private static final long KEY_IMAGE_REFRESH_PERIOD_MS_REMOTE = 300000; // 5 minutes - private Object processOffersLock = new Object(); // lock for processing offers @@ -227,27 +223,6 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe this.persistenceManager.initialize(openOffers, "OpenOffers", PersistenceManager.Source.PRIVATE); this.signedOfferPersistenceManager.initialize(signedOffers, "SignedOffers", PersistenceManager.Source.PRIVATE); // arbitrator stores reserve tx for signed offers - - // listen for connection changes to monerod - xmrConnectionService.addConnectionListener((connection) -> maybeInitializeKeyImagePoller()); - - // close open offer if reserved funds spent - offerBookService.addOfferBookChangedListener(new OfferBookChangedListener() { - @Override - public void onAdded(Offer offer) { - - // cancel offer if reserved funds spent - Optional openOfferOptional = getOpenOffer(offer.getId()); - if (openOfferOptional.isPresent() && openOfferOptional.get().getState() != OpenOffer.State.RESERVED && offer.isReservedFundsSpent()) { - log.warn("Canceling open offer because reserved funds have been spent, offerId={}, state={}", offer.getId(), openOfferOptional.get().getState()); - cancelOpenOffer(openOfferOptional.get(), null, null); - } - } - @Override - public void onRemoved(Offer offer) { - // nothing to do - } - }); } @Override @@ -268,34 +243,6 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe completeHandler); } - private synchronized void maybeInitializeKeyImagePoller() { - if (signedOfferKeyImagePoller != null) return; - signedOfferKeyImagePoller = new XmrKeyImagePoller(xmrConnectionService.getDaemon(), getKeyImageRefreshPeriodMs()); - - // handle when key images confirmed spent - signedOfferKeyImagePoller.addListener(new XmrKeyImageListener() { - @Override - public void onSpentStatusChanged(Map spentStatuses) { - for (Entry entry : spentStatuses.entrySet()) { - if (entry.getValue() == MoneroKeyImageSpentStatus.CONFIRMED) { - removeSignedOffers(entry.getKey()); - } - } - } - }); - - // first poll in 5s - // TODO: remove? - new Thread(() -> { - HavenoUtils.waitFor(5000); - signedOfferKeyImagePoller.poll(); - }).start(); - } - - private long getKeyImageRefreshPeriodMs() { - return xmrConnectionService.isConnectionLocalHost() ? KEY_IMAGE_REFRESH_PERIOD_MS_LOCAL : KEY_IMAGE_REFRESH_PERIOD_MS_REMOTE; - } - public void onAllServicesInitialized() { p2PService.addDecryptedDirectMessageListener(this); @@ -330,7 +277,8 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe stopped = true; p2PService.getPeerManager().removeListener(this); p2PService.removeDecryptedDirectMessageListener(this); - if (signedOfferKeyImagePoller != null) signedOfferKeyImagePoller.clearKeyImages(); + xmrConnectionService.getKeyImagePoller().removeKeyImages(OPEN_OFFER_GROUP_KEY_IMAGE_ID); + xmrConnectionService.getKeyImagePoller().removeKeyImages(SIGNED_OFFER_KEY_IMAGE_GROUP_ID); stopPeriodicRefreshOffersTimer(); stopPeriodicRepublishOffersTimer(); @@ -385,11 +333,7 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe removeOpenOffers(getObservableList(), completeHandler); } - public void removeOpenOffer(OpenOffer openOffer, @Nullable Runnable completeHandler) { - removeOpenOffers(List.of(openOffer), completeHandler); - } - - public void removeOpenOffers(List openOffers, @Nullable Runnable completeHandler) { + private void removeOpenOffers(List openOffers, @Nullable Runnable completeHandler) { int size = openOffers.size(); // Copy list as we remove in the loop List openOffersList = new ArrayList<>(openOffers); @@ -442,6 +386,19 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe maybeUpdatePersistedOffers(); + // listen for spent key images to close open and signed offers + xmrConnectionService.getKeyImagePoller().addListener(new XmrKeyImageListener() { + @Override + public void onSpentStatusChanged(Map spentStatuses) { + for (Entry entry : spentStatuses.entrySet()) { + if (XmrKeyImagePoller.isSpent(entry.getValue())) { + cancelOpenOffersOnSpent(entry.getKey()); + removeSignedOffers(entry.getKey()); + } + } + } + }); + // run off user thread so app is not blocked from starting ThreadUtils.submitToPool(() -> { @@ -492,12 +449,14 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe } }); - // initialize key image poller for signed offers - maybeInitializeKeyImagePoller(); + // poll spent status of open offer key images + for (OpenOffer openOffer : getOpenOffers()) { + xmrConnectionService.getKeyImagePoller().addKeyImages(openOffer.getOffer().getOfferPayload().getReserveTxKeyImages(), OPEN_OFFER_GROUP_KEY_IMAGE_ID); + } - // poll spent status of key images + // poll spent status of signed offer key images for (SignedOffer signedOffer : signedOffers.getList()) { - signedOfferKeyImagePoller.addKeyImages(signedOffer.getReserveTxKeyImages()); + xmrConnectionService.getKeyImagePoller().addKeyImages(signedOffer.getReserveTxKeyImages(), SIGNED_OFFER_KEY_IMAGE_GROUP_ID); } }, THREAD_ID); }); @@ -544,17 +503,59 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe long triggerPrice, boolean reserveExactAmount, boolean resetAddressEntriesOnError, + String sourceOfferId, TransactionResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) { + // check source offer and clone limit + OpenOffer sourceOffer = null; + if (sourceOfferId != null) { + + // get source offer + Optional sourceOfferOptional = getOpenOffer(sourceOfferId); + if (!sourceOfferOptional.isPresent()) { + errorMessageHandler.handleErrorMessage("Source offer not found to clone, offerId=" + sourceOfferId); + return; + } + sourceOffer = sourceOfferOptional.get(); + + // check clone limit + int numClones = getOpenOfferGroup(sourceOffer.getGroupId()).size(); + if (numClones >= Restrictions.MAX_OFFERS_WITH_SHARED_FUNDS) { + errorMessageHandler.handleErrorMessage("Cannot create offer because maximum number of " + Restrictions.MAX_OFFERS_WITH_SHARED_FUNDS + " cloned offers with shared funds reached."); + return; + } + } + // create open offer - OpenOffer openOffer = new OpenOffer(offer, triggerPrice, reserveExactAmount); + OpenOffer openOffer = new OpenOffer(offer, triggerPrice, sourceOffer == null ? reserveExactAmount : sourceOffer.isReserveExactAmount()); + + // set state from source offer + if (sourceOffer != null) { + openOffer.setReserveTxHash(sourceOffer.getReserveTxHash()); + openOffer.setReserveTxHex(sourceOffer.getReserveTxHex()); + openOffer.setReserveTxKey(sourceOffer.getReserveTxKey()); + openOffer.setGroupId(sourceOffer.getGroupId()); + openOffer.getOffer().getOfferPayload().setReserveTxKeyImages(sourceOffer.getOffer().getOfferPayload().getReserveTxKeyImages()); + xmrWalletService.cloneAddressEntries(sourceOffer.getOffer().getId(), openOffer.getOffer().getId()); + if (hasConflictingClone(openOffer)) openOffer.setState(OpenOffer.State.DEACTIVATED); + } + + // add the open offer + synchronized (processOffersLock) { + addOpenOffer(openOffer); + } + + // done if source offer is pending + if (sourceOffer != null && sourceOffer.isPending()) { + resultHandler.handleResult(null); + return; + } // schedule or post offer ThreadUtils.execute(() -> { synchronized (processOffersLock) { CountDownLatch latch = new CountDownLatch(1); - addOpenOffer(openOffer); processOffer(getOpenOffers(), openOffer, (transaction) -> { requestPersistence(); latch.countDown(); @@ -591,18 +592,30 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe if (openOffer.isPending()) { resultHandler.handleResult(); // ignore if pending } else if (offersToBeEdited.containsKey(openOffer.getId())) { - errorMessageHandler.handleErrorMessage("You can't activate an offer that is currently edited."); + errorMessageHandler.handleErrorMessage(Res.get("offerbook.cannotActivateEditedOffer.warning")); + } else if (hasConflictingClone(openOffer)) { + errorMessageHandler.handleErrorMessage(Res.get("offerbook.hasConflictingClone.warning")); } else { - Offer offer = openOffer.getOffer(); - offerBookService.activateOffer(offer, - () -> { - openOffer.setState(OpenOffer.State.AVAILABLE); - applyTriggerState(openOffer); - requestPersistence(); - log.debug("activateOpenOffer, offerId={}", offer.getId()); - resultHandler.handleResult(); - }, - errorMessageHandler); + try { + + // validate arbitrator signature + validateSignedState(openOffer); + + // activate offer on offer book + Offer offer = openOffer.getOffer(); + offerBookService.activateOffer(offer, + () -> { + openOffer.setState(OpenOffer.State.AVAILABLE); + applyTriggerState(openOffer); + requestPersistence(); + log.debug("activateOpenOffer, offerId={}", offer.getId()); + resultHandler.handleResult(); + }, + errorMessageHandler); + } catch (Exception e) { + errorMessageHandler.handleErrorMessage(e.getMessage()); + return; + } } } @@ -655,7 +668,7 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe }); } } else { - if (errorMessageHandler != null) errorMessageHandler.handleErrorMessage("You can't remove an offer that is currently edited."); + if (errorMessageHandler != null) errorMessageHandler.handleErrorMessage("You can't cancel an offer that is currently edited."); } } @@ -699,29 +712,44 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe OpenOffer editedOpenOffer = new OpenOffer(editedOffer, triggerPrice, openOffer); if (originalState == OpenOffer.State.DEACTIVATED && openOffer.isDeactivatedByTrigger()) { - editedOpenOffer.setState(OpenOffer.State.AVAILABLE); + if (hasConflictingClone(editedOpenOffer)) { + editedOpenOffer.setState(OpenOffer.State.DEACTIVATED); + } else { + editedOpenOffer.setState(OpenOffer.State.AVAILABLE); + } applyTriggerState(editedOpenOffer); } else { - editedOpenOffer.setState(originalState); + if (originalState == OpenOffer.State.AVAILABLE && hasConflictingClone(editedOpenOffer)) { + editedOpenOffer.setState(OpenOffer.State.DEACTIVATED); + } else { + editedOpenOffer.setState(originalState); + } } addOpenOffer(editedOpenOffer); - // reset arbitrator signature if invalid + // check for valid arbitrator signature after editing Arbitrator arbitrator = user.getAcceptedArbitratorByAddress(editedOpenOffer.getOffer().getOfferPayload().getArbitratorSigner()); if (arbitrator == null || !HavenoUtils.isArbitratorSignatureValid(editedOpenOffer.getOffer().getOfferPayload(), arbitrator)) { + + // reset arbitrator signature editedOpenOffer.getOffer().getOfferPayload().setArbitratorSignature(null); editedOpenOffer.getOffer().getOfferPayload().setArbitratorSigner(null); - } - // process offer which might sign and publish - processOffer(getOpenOffers(), editedOpenOffer, (transaction) -> { + // process offer to sign and publish + processOffer(getOpenOffers(), editedOpenOffer, (transaction) -> { + offersToBeEdited.remove(openOffer.getId()); + requestPersistence(); + resultHandler.handleResult(); + }, (errorMsg) -> { + errorMessageHandler.handleErrorMessage(errorMsg); + }); + } else { + maybeRepublishOffer(editedOpenOffer, null); offersToBeEdited.remove(openOffer.getId()); requestPersistence(); resultHandler.handleResult(); - }, (errorMsg) -> { - errorMessageHandler.handleErrorMessage(errorMsg); - }); + } } else { errorMessageHandler.handleErrorMessage("There is no offer with this id existing to be published."); } @@ -753,26 +781,33 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe Offer offer = openOffer.getOffer(); offer.setState(Offer.State.REMOVED); openOffer.setState(OpenOffer.State.CANCELED); + boolean hasClonedOffer = hasClonedOffer(offer.getId()); // record before removing open offer removeOpenOffer(openOffer); - closedTradableManager.add(openOffer); // TODO: don't add these to closed tradables? + if (!hasClonedOffer) closedTradableManager.add(openOffer); // do not add clones to closed trades TODO: don't add canceled offers to closed tradables? if (resetAddressEntries) xmrWalletService.resetAddressEntriesForOpenOffer(offer.getId()); requestPersistence(); - xmrWalletService.thawOutputs(offer.getOfferPayload().getReserveTxKeyImages()); + if (!hasClonedOffer) xmrWalletService.thawOutputs(offer.getOfferPayload().getReserveTxKeyImages()); } - // close open offer after key images spent - public void closeOpenOffer(Offer offer) { + // close open offer group after key images spent + public void closeSpentOffer(Offer offer) { getOpenOffer(offer.getId()).ifPresent(openOffer -> { - removeOpenOffer(openOffer); - openOffer.setState(OpenOffer.State.CLOSED); - xmrWalletService.resetAddressEntriesForOpenOffer(offer.getId()); - offerBookService.removeOffer(openOffer.getOffer().getOfferPayload(), - () -> log.info("Successfully removed offer {}", offer.getId()), - log::error); - requestPersistence(); + for (OpenOffer groupOffer: getOpenOfferGroup(openOffer.getGroupId())) { + doCloseOpenOffer(groupOffer); + } }); } + private void doCloseOpenOffer(OpenOffer openOffer) { + removeOpenOffer(openOffer); + openOffer.setState(OpenOffer.State.CLOSED); + xmrWalletService.resetAddressEntriesForOpenOffer(openOffer.getId()); + offerBookService.removeOffer(openOffer.getOffer().getOfferPayload(), + () -> log.info("Successfully removed offer {}", openOffer.getId()), + log::error); + requestPersistence(); + } + public void reserveOpenOffer(OpenOffer openOffer) { openOffer.setState(OpenOffer.State.RESERVED); requestPersistence(); @@ -783,6 +818,37 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe requestPersistence(); } + public boolean hasConflictingClone(OpenOffer openOffer) { + for (OpenOffer clonedOffer : getOpenOfferGroup(openOffer.getGroupId())) { + if (clonedOffer.getId().equals(openOffer.getId())) continue; + if (clonedOffer.isDeactivated()) continue; // deactivated offers do not conflict + + // pending offers later in the order do not conflict + List openOffers = getOpenOffers(); + if (clonedOffer.isPending() && openOffers.indexOf(clonedOffer) > openOffers.indexOf(openOffer)) { + continue; + } + + // conflicts if same payment method and currency + if (samePaymentMethodAndCurrency(clonedOffer.getOffer(), openOffer.getOffer())) { + return true; + } + } + return false; + } + + public boolean hasConflictingClone(Offer offer, OpenOffer sourceOffer) { + return getOpenOfferGroup(sourceOffer.getGroupId()).stream() + .filter(openOffer -> !openOffer.isDeactivated()) // we only check with activated offers + .anyMatch(openOffer -> samePaymentMethodAndCurrency(openOffer.getOffer(), offer)); + } + + private boolean samePaymentMethodAndCurrency(Offer offer1, Offer offer2) { + return offer1.getPaymentMethodId().equalsIgnoreCase(offer2.getPaymentMethodId()) && + offer1.getCounterCurrencyCode().equalsIgnoreCase(offer2.getCounterCurrencyCode()) && + offer1.getBaseCurrencyCode().equalsIgnoreCase(offer2.getBaseCurrencyCode()); + } + /////////////////////////////////////////////////////////////////////////////////////////// // Getters /////////////////////////////////////////////////////////////////////////////////////////// @@ -791,7 +857,7 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe return offer.isMyOffer(keyRing); } - public boolean hasOpenOffers() { + public boolean hasAvailableOpenOffers() { synchronized (openOffers) { for (OpenOffer openOffer : getOpenOffers()) { if (openOffer.getState() == OpenOffer.State.AVAILABLE) { @@ -808,13 +874,38 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe } } + public List getOpenOfferGroup(String groupId) { + if (groupId == null) throw new IllegalArgumentException("groupId cannot be null"); + synchronized (openOffers) { + return getOpenOffers().stream() + .filter(openOffer -> groupId.equals(openOffer.getGroupId())) + .collect(Collectors.toList()); + } + } + + public boolean hasClonedOffer(String offerId) { + OpenOffer openOffer = getOpenOffer(offerId).orElse(null); + if (openOffer == null) return false; + return getOpenOfferGroup(openOffer.getGroupId()).size() > 1; + } + + public boolean hasClonedOffers() { + synchronized (openOffers) { + for (OpenOffer openOffer : getOpenOffers()) { + if (getOpenOfferGroup(openOffer.getGroupId()).size() > 1) { + return true; + } + } + return false; + } + } + public List getSignedOffers() { synchronized (signedOffers) { return new ArrayList<>(signedOffers.getObservableList()); } } - public ObservableList getObservableSignedOffersList() { synchronized (signedOffers) { return signedOffers.getObservableList(); @@ -846,6 +937,9 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe synchronized (openOffers) { openOffers.add(openOffer); } + if (openOffer.getOffer().getOfferPayload().getReserveTxKeyImages() != null) { + xmrConnectionService.getKeyImagePoller().addKeyImages(openOffer.getOffer().getOfferPayload().getReserveTxKeyImages(), OPEN_OFFER_GROUP_KEY_IMAGE_ID); + } } private void removeOpenOffer(OpenOffer openOffer) { @@ -857,6 +951,18 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe PlaceOfferProtocol protocol = placeOfferProtocols.remove(openOffer.getId()); if (protocol != null) protocol.cancelOffer(); } + if (openOffer.getOffer().getOfferPayload().getReserveTxKeyImages() != null) { + xmrConnectionService.getKeyImagePoller().removeKeyImages(openOffer.getOffer().getOfferPayload().getReserveTxKeyImages(), OPEN_OFFER_GROUP_KEY_IMAGE_ID); + } + } + + private void cancelOpenOffersOnSpent(String keyImage) { + for (OpenOffer openOffer : getOpenOffers()) { + if (openOffer.getOffer().getOfferPayload().getReserveTxKeyImages() != null && openOffer.getOffer().getOfferPayload().getReserveTxKeyImages().contains(keyImage)) { + log.warn("Canceling open offer because reserved funds have been spent, offerId={}, state={}", openOffer.getId(), openOffer.getState()); + cancelOpenOffer(openOffer, null, null); + } + } } private void addSignedOffer(SignedOffer signedOffer) { @@ -870,7 +976,7 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe // add new signed offer signedOffers.add(signedOffer); - signedOfferKeyImagePoller.addKeyImages(signedOffer.getReserveTxKeyImages()); + xmrConnectionService.getKeyImagePoller().addKeyImages(signedOffer.getReserveTxKeyImages(), SIGNED_OFFER_KEY_IMAGE_GROUP_ID); } } @@ -878,7 +984,7 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe log.info("Removing SignedOffer for offer {}", signedOffer.getOfferId()); synchronized (signedOffers) { signedOffers.remove(signedOffer); - signedOfferKeyImagePoller.removeKeyImages(signedOffer.getReserveTxKeyImages()); + xmrConnectionService.getKeyImagePoller().removeKeyImages(signedOffer.getReserveTxKeyImages(), SIGNED_OFFER_KEY_IMAGE_GROUP_ID); } } @@ -900,7 +1006,6 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe List errorMessages = new ArrayList(); synchronized (processOffersLock) { List openOffers = getOpenOffers(); - removeOffersWithDuplicateKeyImages(openOffers); for (OpenOffer offer : openOffers) { if (skipOffersWithTooManyAttempts && offer.getNumProcessingAttempts() > NUM_ATTEMPTS_THRESHOLD) continue; // skip offers with too many attempts CountDownLatch latch = new CountDownLatch(1); @@ -922,28 +1027,6 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe }, THREAD_ID); } - private void removeOffersWithDuplicateKeyImages(List openOffers) { - - // collect offers with duplicate key images - Set keyImages = new HashSet<>(); - Set offersToRemove = new HashSet<>(); - for (OpenOffer openOffer : openOffers) { - if (openOffer.getOffer().getOfferPayload().getReserveTxKeyImages() == null) continue; - if (Collections.disjoint(keyImages, openOffer.getOffer().getOfferPayload().getReserveTxKeyImages())) { - keyImages.addAll(openOffer.getOffer().getOfferPayload().getReserveTxKeyImages()); - } else { - offersToRemove.add(openOffer); - } - } - - // remove offers with duplicate key images - for (OpenOffer offerToRemove : offersToRemove) { - log.warn("Removing open offer which has duplicate key images with other open offers: {}", offerToRemove.getId()); - doCancelOffer(offerToRemove); - openOffers.remove(offerToRemove); - } - } - private void processOffer(List openOffers, OpenOffer openOffer, TransactionResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) { // skip if already processing @@ -993,33 +1076,40 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe return; } - // validate non-pending state - if (!openOffer.isPending()) { - boolean isValid = true; - Arbitrator arbitrator = user.getAcceptedArbitratorByAddress(openOffer.getOffer().getOfferPayload().getArbitratorSigner()); - if (openOffer.getOffer().getOfferPayload().getArbitratorSigner() == null) { - isValid = false; - } else if (arbitrator == null) { - log.warn("Offer {} signed by unavailable arbitrator, reposting", openOffer.getId()); - isValid = false; - } else if (!HavenoUtils.isArbitratorSignatureValid(openOffer.getOffer().getOfferPayload(), arbitrator)) { - log.warn("Offer {} has invalid arbitrator signature, reposting", openOffer.getId()); - isValid = false; + // handle pending offer + if (openOffer.isPending()) { + + // only process the first offer of a pending clone group + if (openOffer.getGroupId() != null) { + List openOfferClones = getOpenOfferGroup(openOffer.getGroupId()); + if (openOfferClones.size() > 1 && !openOfferClones.get(0).getId().equals(openOffer.getId()) && openOfferClones.get(0).isPending()) { + resultHandler.handleResult(null); + return; + } } - if ((openOffer.getOffer().getOfferPayload().getReserveTxKeyImages() != null || openOffer.getOffer().getOfferPayload().getReserveTxKeyImages().isEmpty()) && (openOffer.getReserveTxHash() == null || openOffer.getReserveTxHash().isEmpty())) { - log.warn("Offer {} is missing reserve tx hash but has reserved key images, reposting", openOffer.getId()); - isValid = false; - } - if (isValid) { - resultHandler.handleResult(null); + } else { + + // validate non-pending state + try { + validateSignedState(openOffer); + resultHandler.handleResult(null); // done processing if non-pending state is valid return; - } else { + } catch (Exception e) { + log.warn(e.getMessage()); + + // reset arbitrator signature openOffer.getOffer().getOfferPayload().setArbitratorSignature(null); openOffer.getOffer().getOfferPayload().setArbitratorSigner(null); if (openOffer.isAvailable()) openOffer.setState(OpenOffer.State.PENDING); } } + // sign and post offer if already funded + if (openOffer.getReserveTxHash() != null) { + signAndPostOffer(openOffer, false, resultHandler, errorMessageHandler); + return; + } + // cancel offer if scheduled txs unavailable if (openOffer.getScheduledTxHashes() != null) { boolean scheduledTxsAvailable = true; @@ -1037,12 +1127,6 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe } } - // sign and post offer if already funded - if (openOffer.getReserveTxHash() != null) { - signAndPostOffer(openOffer, false, resultHandler, errorMessageHandler); - return; - } - // get amount needed to reserve offer BigInteger amountNeeded = openOffer.getOffer().getAmountNeeded(); @@ -1084,6 +1168,21 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe }).start(); } + private void validateSignedState(OpenOffer openOffer) { + Arbitrator arbitrator = user.getAcceptedArbitratorByAddress(openOffer.getOffer().getOfferPayload().getArbitratorSigner()); + if (openOffer.getOffer().getOfferPayload().getArbitratorSigner() == null) { + throw new IllegalArgumentException("Offer " + openOffer.getId() + " has no arbitrator signer"); + } else if (openOffer.getOffer().getOfferPayload().getArbitratorSignature() == null) { + throw new IllegalArgumentException("Offer " + openOffer.getId() + " has no arbitrator signature"); + } else if (arbitrator == null) { + throw new IllegalArgumentException("Offer " + openOffer.getId() + " signed by unavailable arbitrator"); + } else if (!HavenoUtils.isArbitratorSignatureValid(openOffer.getOffer().getOfferPayload(), arbitrator)) { + throw new IllegalArgumentException("Offer " + openOffer.getId() + " has invalid arbitrator signature"); + } else if (openOffer.getOffer().getOfferPayload().getReserveTxKeyImages() == null || openOffer.getOffer().getOfferPayload().getReserveTxKeyImages().isEmpty() || openOffer.getReserveTxHash() == null || openOffer.getReserveTxHash().isEmpty()) { + throw new IllegalArgumentException("Offer " + openOffer.getId() + " is missing reserve tx hash or key images"); + } + } + private MoneroTxWallet getSplitOutputFundingTx(List openOffers, OpenOffer openOffer) { XmrAddressEntry addressEntry = xmrWalletService.getOrCreateAddressEntry(openOffer.getId(), XmrAddressEntry.Context.OFFER_FUNDING); return getSplitOutputFundingTx(openOffers, openOffer, openOffer.getOffer().getAmountNeeded(), addressEntry.getSubaddressIndex()); @@ -2047,7 +2146,10 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe private boolean preventedFromPublishing(OpenOffer openOffer) { if (!Boolean.TRUE.equals(xmrConnectionService.isConnected())) return true; - return openOffer.isDeactivated() || openOffer.isCanceled() || openOffer.getOffer().getOfferPayload().getArbitratorSigner() == null; + return openOffer.isDeactivated() || + openOffer.isCanceled() || + openOffer.getOffer().getOfferPayload().getArbitratorSigner() == null || + hasConflictingClone(openOffer); } private void startPeriodicRepublishOffersTimer() { diff --git a/core/src/main/java/haveno/core/offer/placeoffer/tasks/MakerReserveOfferFunds.java b/core/src/main/java/haveno/core/offer/placeoffer/tasks/MakerReserveOfferFunds.java index e873d1e561..60eaa64cdc 100644 --- a/core/src/main/java/haveno/core/offer/placeoffer/tasks/MakerReserveOfferFunds.java +++ b/core/src/main/java/haveno/core/offer/placeoffer/tasks/MakerReserveOfferFunds.java @@ -19,7 +19,9 @@ package haveno.core.offer.placeoffer.tasks; import java.math.BigInteger; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; import haveno.common.taskrunner.Task; import haveno.common.taskrunner.TaskRunner; @@ -78,6 +80,12 @@ public class MakerReserveOfferFunds extends Task { XmrAddressEntry fundingEntry = model.getXmrWalletService().getAddressEntry(offer.getId(), XmrAddressEntry.Context.OFFER_FUNDING).orElse(null); Integer preferredSubaddressIndex = fundingEntry == null ? null : fundingEntry.getSubaddressIndex(); + // copy address entries to clones + for (OpenOffer offerClone : model.getOpenOfferManager().getOpenOfferGroup(model.getOpenOffer().getGroupId())) { + if (offerClone.getId().equals(offer.getId())) continue; // skip self + model.getXmrWalletService().cloneAddressEntries(openOffer.getId(), offerClone.getId()); + } + // attempt creating reserve tx MoneroTxWallet reserveTx = null; try { @@ -120,23 +128,42 @@ public class MakerReserveOfferFunds extends Task { List reservedKeyImages = new ArrayList(); for (MoneroOutput input : reserveTx.getInputs()) reservedKeyImages.add(input.getKeyImage().getHex()); - // update offer state - openOffer.setReserveTxHash(reserveTx.getHash()); - openOffer.setReserveTxHex(reserveTx.getFullHex()); - openOffer.setReserveTxKey(reserveTx.getKey()); - offer.getOfferPayload().setReserveTxKeyImages(reservedKeyImages); + // update offer state including clones + if (openOffer.getGroupId() == null) { + openOffer.setReserveTxHash(reserveTx.getHash()); + openOffer.setReserveTxHex(reserveTx.getFullHex()); + openOffer.setReserveTxKey(reserveTx.getKey()); + offer.getOfferPayload().setReserveTxKeyImages(reservedKeyImages); + } else { + for (OpenOffer offerClone : model.getOpenOfferManager().getOpenOfferGroup(model.getOpenOffer().getGroupId())) { + offerClone.setReserveTxHash(reserveTx.getHash()); + offerClone.setReserveTxHex(reserveTx.getFullHex()); + offerClone.setReserveTxKey(reserveTx.getKey()); + offerClone.getOffer().getOfferPayload().setReserveTxKeyImages(reservedKeyImages); + } + } - // reset offer funding address entry if unused + // reset offer funding address entries if unused if (fundingEntry != null) { + + // get reserve tx inputs List inputs = model.getXmrWalletService().getOutputs(reservedKeyImages); - boolean usesFundingEntry = false; + + // collect subaddress indices of inputs + Set inputSubaddressIndices = new HashSet<>(); for (MoneroOutputWallet input : inputs) { - if (input.getAccountIndex() == 0 && input.getSubaddressIndex() == fundingEntry.getSubaddressIndex()) { - usesFundingEntry = true; - break; + if (input.getAccountIndex() == 0) inputSubaddressIndices.add(input.getSubaddressIndex()); + } + + // swap funding address entries to available if unused + for (OpenOffer clone : model.getOpenOfferManager().getOpenOfferGroup(model.getOpenOffer().getGroupId())) { + XmrAddressEntry cloneFundingEntry = model.getXmrWalletService().getAddressEntry(clone.getId(), XmrAddressEntry.Context.OFFER_FUNDING).orElse(null); + if (cloneFundingEntry != null && !inputSubaddressIndices.contains(cloneFundingEntry.getSubaddressIndex())) { + if (inputSubaddressIndices.contains(cloneFundingEntry.getSubaddressIndex())) { + model.getXmrWalletService().swapAddressEntryToAvailable(offer.getId(), XmrAddressEntry.Context.OFFER_FUNDING); + } } } - if (!usesFundingEntry) model.getXmrWalletService().swapAddressEntryToAvailable(offer.getId(), XmrAddressEntry.Context.OFFER_FUNDING); } } complete(); diff --git a/core/src/main/java/haveno/core/offer/placeoffer/tasks/MaybeAddToOfferBook.java b/core/src/main/java/haveno/core/offer/placeoffer/tasks/MaybeAddToOfferBook.java index 8e3e3c23bc..2f8a10108b 100644 --- a/core/src/main/java/haveno/core/offer/placeoffer/tasks/MaybeAddToOfferBook.java +++ b/core/src/main/java/haveno/core/offer/placeoffer/tasks/MaybeAddToOfferBook.java @@ -36,6 +36,16 @@ public class MaybeAddToOfferBook extends Task { try { runInterceptHook(); checkNotNull(model.getSignOfferResponse().getSignedOfferPayload().getArbitratorSignature(), "Offer's arbitrator signature is null: " + model.getOpenOffer().getOffer().getId()); + + // deactivate if conflicting offer exists + if (model.getOpenOfferManager().hasConflictingClone(model.getOpenOffer())) { + model.getOpenOffer().setState(OpenOffer.State.DEACTIVATED); + model.setOfferAddedToOfferBook(false); + complete(); + return; + } + + // add to offer book and activate if pending or available if (model.getOpenOffer().isPending() || model.getOpenOffer().isAvailable()) { model.getOfferBookService().addOffer(new Offer(model.getSignOfferResponse().getSignedOfferPayload()), () -> { diff --git a/core/src/main/java/haveno/core/support/dispute/mediation/MediationManager.java b/core/src/main/java/haveno/core/support/dispute/mediation/MediationManager.java index 56686faa61..3ae1d99501 100644 --- a/core/src/main/java/haveno/core/support/dispute/mediation/MediationManager.java +++ b/core/src/main/java/haveno/core/support/dispute/mediation/MediationManager.java @@ -197,7 +197,7 @@ public final class MediationManager extends DisputeManager } } else { Optional openOfferOptional = openOfferManager.getOpenOffer(tradeId); - openOfferOptional.ifPresent(openOffer -> openOfferManager.closeOpenOffer(openOffer.getOffer())); + openOfferOptional.ifPresent(openOffer -> openOfferManager.closeSpentOffer(openOffer.getOffer())); } sendAckMessage(chatMessage, dispute.getAgentPubKeyRing(), true, null); diff --git a/core/src/main/java/haveno/core/support/dispute/refund/RefundManager.java b/core/src/main/java/haveno/core/support/dispute/refund/RefundManager.java index 034eac6d5a..8748def337 100644 --- a/core/src/main/java/haveno/core/support/dispute/refund/RefundManager.java +++ b/core/src/main/java/haveno/core/support/dispute/refund/RefundManager.java @@ -197,7 +197,7 @@ public final class RefundManager extends DisputeManager { } } else { Optional openOfferOptional = openOfferManager.getOpenOffer(tradeId); - openOfferOptional.ifPresent(openOffer -> openOfferManager.closeOpenOffer(openOffer.getOffer())); + openOfferOptional.ifPresent(openOffer -> openOfferManager.closeSpentOffer(openOffer.getOffer())); } sendAckMessage(chatMessage, dispute.getAgentPubKeyRing(), true, null); @@ -206,7 +206,7 @@ public final class RefundManager extends DisputeManager { tradeManager.closeDisputedTrade(tradeId, Trade.DisputeState.REFUND_REQUEST_CLOSED); } else { Optional openOfferOptional = openOfferManager.getOpenOffer(tradeId); - openOfferOptional.ifPresent(openOffer -> openOfferManager.closeOpenOffer(openOffer.getOffer())); + openOfferOptional.ifPresent(openOffer -> openOfferManager.closeSpentOffer(openOffer.getOffer())); } requestPersistence(); diff --git a/core/src/main/java/haveno/core/trade/Trade.java b/core/src/main/java/haveno/core/trade/Trade.java index 114edcabe5..41027600a2 100644 --- a/core/src/main/java/haveno/core/trade/Trade.java +++ b/core/src/main/java/haveno/core/trade/Trade.java @@ -710,7 +710,7 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { maybePublishTradeStatistics(); // reset address entries - processModel.getXmrWalletService().resetAddressEntriesForTrade(getId()); + processModel.getXmrWalletService().swapPayoutAddressEntryToAvailable(getId()); } // handle when payout unlocks @@ -1755,7 +1755,7 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { // close open offer if (this instanceof MakerTrade && processModel.getOpenOfferManager().getOpenOffer(getId()).isPresent()) { log.info("Closing open offer because {} {} was restored after protocol error", getClass().getSimpleName(), getShortId()); - processModel.getOpenOfferManager().closeOpenOffer(checkNotNull(getOffer())); + processModel.getOpenOfferManager().closeSpentOffer(checkNotNull(getOffer())); } // re-freeze outputs @@ -2371,7 +2371,7 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { } public boolean hasBuyerAsTakerWithoutDeposit() { - return getBuyer() == getTaker() && BigInteger.ZERO.equals(getBuyerSecurityDepositBeforeMiningFee()); + return getOffer().getOfferPayload().isBuyerAsTakerWithoutDeposit(); } @Override @@ -2945,7 +2945,7 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model { // close open offer or reset address entries if (this instanceof MakerTrade) { - processModel.getOpenOfferManager().closeOpenOffer(getOffer()); + processModel.getOpenOfferManager().closeSpentOffer(getOffer()); HavenoUtils.notificationService.sendTradeNotification(this, Phase.DEPOSITS_PUBLISHED, "Offer Taken", "Your offer " + offer.getId() + " has been accepted"); // TODO (woodser): use language translation } else { getXmrWalletService().resetAddressEntriesForOpenOffer(getId()); diff --git a/core/src/main/java/haveno/core/trade/TradeManager.java b/core/src/main/java/haveno/core/trade/TradeManager.java index c98978ae4c..975378ce11 100644 --- a/core/src/main/java/haveno/core/trade/TradeManager.java +++ b/core/src/main/java/haveno/core/trade/TradeManager.java @@ -977,7 +977,7 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi removeTrade(trade, true); // TODO The address entry should have been removed already. Check and if its the case remove that. - xmrWalletService.resetAddressEntriesForTrade(trade.getId()); + xmrWalletService.swapPayoutAddressEntryToAvailable(trade.getId()); requestPersistence(); } @@ -1011,7 +1011,7 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi if (tradeOptional.isPresent()) { Trade trade = tradeOptional.get(); trade.setDisputeState(disputeState); - xmrWalletService.resetAddressEntriesForTrade(trade.getId()); + xmrWalletService.swapPayoutAddressEntryToAvailable(trade.getId()); requestPersistence(); } } diff --git a/core/src/main/java/haveno/core/trade/protocol/tasks/TakerReserveTradeFunds.java b/core/src/main/java/haveno/core/trade/protocol/tasks/TakerReserveTradeFunds.java index aa0fc9dfe9..9a461b2d83 100644 --- a/core/src/main/java/haveno/core/trade/protocol/tasks/TakerReserveTradeFunds.java +++ b/core/src/main/java/haveno/core/trade/protocol/tasks/TakerReserveTradeFunds.java @@ -89,7 +89,7 @@ public class TakerReserveTradeFunds extends TradeTask { } catch (Exception e) { // reset state with wallet lock - model.getXmrWalletService().resetAddressEntriesForTrade(trade.getId()); + model.getXmrWalletService().swapPayoutAddressEntryToAvailable(trade.getId()); if (reserveTx != null) { model.getXmrWalletService().thawOutputs(HavenoUtils.getInputKeyImages(reserveTx)); trade.getSelf().setReserveTxKeyImages(null); diff --git a/core/src/main/java/haveno/core/xmr/model/XmrAddressEntry.java b/core/src/main/java/haveno/core/xmr/model/XmrAddressEntry.java index 025d6cda69..d863c92e74 100644 --- a/core/src/main/java/haveno/core/xmr/model/XmrAddressEntry.java +++ b/core/src/main/java/haveno/core/xmr/model/XmrAddressEntry.java @@ -122,12 +122,12 @@ public final class XmrAddressEntry implements PersistablePayload { return context == Context.OFFER_FUNDING; } - public boolean isTrade() { + public boolean isTradePayout() { return context == Context.TRADE_PAYOUT; } public boolean isTradable() { - return isOpenOffer() || isTrade(); + return isOpenOffer() || isTradePayout(); } public Coin getCoinLockedInMultiSig() { diff --git a/core/src/main/java/haveno/core/xmr/model/XmrAddressEntryList.java b/core/src/main/java/haveno/core/xmr/model/XmrAddressEntryList.java index 73f7379dfb..984f38bdfc 100644 --- a/core/src/main/java/haveno/core/xmr/model/XmrAddressEntryList.java +++ b/core/src/main/java/haveno/core/xmr/model/XmrAddressEntryList.java @@ -110,10 +110,25 @@ public final class XmrAddressEntryList implements PersistableEnvelope, Persisted } public void swapToAvailable(XmrAddressEntry addressEntry) { - boolean setChangedByRemove = entrySet.remove(addressEntry); - boolean setChangedByAdd = entrySet.add(new XmrAddressEntry(addressEntry.getSubaddressIndex(), addressEntry.getAddressString(), - XmrAddressEntry.Context.AVAILABLE)); - if (setChangedByRemove || setChangedByAdd) { + log.info("swapToAvailable addressEntry to swap={}", addressEntry); + if (entrySet.remove(addressEntry)) { + requestPersistence(); + } + // If we have an address entry which shared the address with another one (shared funding use case) + // then we do not swap to available as we need to protect the address of the remaining entry. + boolean entryWithSameContextStillExists = entrySet.stream().anyMatch(entry -> { + if (addressEntry.getAddressString() != null) { + return addressEntry.getAddressString().equals(entry.getAddressString()) && + addressEntry.getContext() == entry.getContext(); + } + return false; + }); + if (entryWithSameContextStillExists) { + return; + } + // no other uses of the address context remain, so make it available + if (entrySet.add(new XmrAddressEntry(addressEntry.getSubaddressIndex(), addressEntry.getAddressString(), + XmrAddressEntry.Context.AVAILABLE))) { requestPersistence(); } } diff --git a/core/src/main/java/haveno/core/xmr/wallet/Restrictions.java b/core/src/main/java/haveno/core/xmr/wallet/Restrictions.java index aefb92c41a..5cd181a4aa 100644 --- a/core/src/main/java/haveno/core/xmr/wallet/Restrictions.java +++ b/core/src/main/java/haveno/core/xmr/wallet/Restrictions.java @@ -31,6 +31,7 @@ public class Restrictions { public static BigInteger MIN_TRADE_AMOUNT = HavenoUtils.xmrToAtomicUnits(0.1); public static BigInteger MIN_SECURITY_DEPOSIT = HavenoUtils.xmrToAtomicUnits(0.1); public static int MAX_EXTRA_INFO_LENGTH = 1500; + public static int MAX_OFFERS_WITH_SHARED_FUNDS = 10; // At mediation we require a min. payout to the losing party to keep incentive for the trader to accept the // mediated payout. For Refund agent cases we do not have that restriction. diff --git a/core/src/main/java/haveno/core/xmr/wallet/XmrKeyImagePoller.java b/core/src/main/java/haveno/core/xmr/wallet/XmrKeyImagePoller.java index 731c1311e0..1cde84152c 100644 --- a/core/src/main/java/haveno/core/xmr/wallet/XmrKeyImagePoller.java +++ b/core/src/main/java/haveno/core/xmr/wallet/XmrKeyImagePoller.java @@ -36,15 +36,13 @@ import haveno.core.trade.HavenoUtils; /** * Poll for changes to the spent status of key images. - * - * TODO: move to monero-java? */ @Slf4j public class XmrKeyImagePoller { private MoneroDaemon daemon; private long refreshPeriodMs; - private List keyImages = new ArrayList(); + private Map> keyImageGroups = new HashMap>(); private Set listeners = new HashSet(); private TaskLooper looper; private Map lastStatuses = new HashMap(); @@ -53,9 +51,6 @@ public class XmrKeyImagePoller { /** * Construct the listener. - * - * @param refreshPeriodMs - refresh period in milliseconds - * @param keyImages - key images to listen to */ public XmrKeyImagePoller() { looper = new TaskLooper(() -> poll()); @@ -64,14 +59,13 @@ public class XmrKeyImagePoller { /** * Construct the listener. * + * @param daemon - the Monero daemon to poll * @param refreshPeriodMs - refresh period in milliseconds - * @param keyImages - key images to listen to */ - public XmrKeyImagePoller(MoneroDaemon daemon, long refreshPeriodMs, String... keyImages) { + public XmrKeyImagePoller(MoneroDaemon daemon, long refreshPeriodMs) { looper = new TaskLooper(() -> poll()); setDaemon(daemon); setRefreshPeriodMs(refreshPeriodMs); - setKeyImages(keyImages); } /** @@ -131,36 +125,13 @@ public class XmrKeyImagePoller { return refreshPeriodMs; } - /** - * Get a copy of the key images being listened to. - * - * @return the key images to listen to - */ - public Collection getKeyImages() { - synchronized (keyImages) { - return new ArrayList(keyImages); - } - } - - /** - * Set the key images to listen to. - * - * @return the key images to listen to - */ - public void setKeyImages(String... keyImages) { - synchronized (this.keyImages) { - this.keyImages.clear(); - addKeyImages(keyImages); - } - } - /** * Add a key image to listen to. * * @param keyImage - the key image to listen to */ - public void addKeyImage(String keyImage) { - addKeyImages(keyImage); + public void addKeyImage(String keyImage, String groupId) { + addKeyImages(Arrays.asList(keyImage), groupId); } /** @@ -168,50 +139,26 @@ public class XmrKeyImagePoller { * * @param keyImages - key images to listen to */ - public void addKeyImages(String... keyImages) { - addKeyImages(Arrays.asList(keyImages)); - } - - /** - * Add key images to listen to. - * - * @param keyImages - key images to listen to - */ - public void addKeyImages(Collection keyImages) { - synchronized (this.keyImages) { - for (String keyImage : keyImages) if (!this.keyImages.contains(keyImage)) this.keyImages.add(keyImage); + public void addKeyImages(Collection keyImages, String groupId) { + synchronized (this.keyImageGroups) { + if (!keyImageGroups.containsKey(groupId)) keyImageGroups.put(groupId, new HashSet()); + Set keyImagesGroup = keyImageGroups.get(groupId); + keyImagesGroup.addAll(keyImages); refreshPolling(); } } - /** - * Remove a key image to listen to. - * - * @param keyImage - the key image to unlisten to - */ - public void removeKeyImage(String keyImage) { - removeKeyImages(keyImage); - } - /** * Remove key images to listen to. * * @param keyImages - key images to unlisten to */ - public void removeKeyImages(String... keyImages) { - removeKeyImages(Arrays.asList(keyImages)); - } - - /** - * Remove key images to listen to. - * - * @param keyImages - key images to unlisten to - */ - public void removeKeyImages(Collection keyImages) { - synchronized (this.keyImages) { - Set containedKeyImages = new HashSet(keyImages); - containedKeyImages.retainAll(this.keyImages); - this.keyImages.removeAll(containedKeyImages); + public void removeKeyImages(Collection keyImages, String groupId) { + synchronized (keyImageGroups) { + Set keyImagesGroup = keyImageGroups.get(groupId); + if (keyImagesGroup == null) return; + keyImagesGroup.removeAll(keyImages); + if (keyImagesGroup.isEmpty()) keyImageGroups.remove(groupId); synchronized (lastStatuses) { for (String lastKeyImage : new HashSet<>(lastStatuses.keySet())) lastStatuses.remove(lastKeyImage); } @@ -219,11 +166,34 @@ public class XmrKeyImagePoller { } } + public void removeKeyImages(String groupId) { + synchronized (keyImageGroups) { + Set keyImagesGroup = keyImageGroups.get(groupId); + if (keyImagesGroup == null) return; + keyImageGroups.remove(groupId); + Set keyImages = getKeyImages(); + synchronized (lastStatuses) { + for (String keyImage : keyImagesGroup) { + if (lastStatuses.containsKey(keyImage) && !keyImages.contains(keyImage)) { + lastStatuses.remove(keyImage); + } + } + } + refreshPolling(); + } + } + /** * Clear the key images which stops polling. */ public void clearKeyImages() { - setKeyImages(); + synchronized (keyImageGroups) { + keyImageGroups.clear(); + synchronized (lastStatuses) { + lastStatuses.clear(); + } + refreshPolling(); + } } /** @@ -235,10 +205,20 @@ public class XmrKeyImagePoller { public Boolean isSpent(String keyImage) { synchronized (lastStatuses) { if (!lastStatuses.containsKey(keyImage)) return null; - return lastStatuses.get(keyImage) != MoneroKeyImageSpentStatus.NOT_SPENT; + return XmrKeyImagePoller.isSpent(lastStatuses.get(keyImage)); } } + /** + * Indicates if the given key image spent status is spent. + * + * @param status the key image spent status to check + * @return true if the key image is spent, false if unspent + */ + public static boolean isSpent(MoneroKeyImageSpentStatus status) { + return status != MoneroKeyImageSpentStatus.NOT_SPENT; + } + /** * Get the last known spent status for the given key image. * @@ -257,16 +237,11 @@ public class XmrKeyImagePoller { return; } - // get copy of key images to fetch - List keyImages = new ArrayList(getKeyImages()); - // fetch spent statuses List spentStatuses = null; + List keyImages = new ArrayList(getKeyImages()); try { - if (keyImages.isEmpty()) spentStatuses = new ArrayList(); - else { - spentStatuses = daemon.getKeyImageSpentStatuses(keyImages); // TODO monero-java: if order of getKeyImageSpentStatuses is guaranteed, then it should take list parameter - } + spentStatuses = keyImages.isEmpty() ? new ArrayList() : daemon.getKeyImageSpentStatuses(keyImages); // TODO monero-java: if order of getKeyImageSpentStatuses is guaranteed, then it should take list parameter } catch (Exception e) { // limit error logging @@ -297,8 +272,8 @@ public class XmrKeyImagePoller { } private void refreshPolling() { - synchronized (keyImages) { - setIsPolling(keyImages.size() > 0 && listeners.size() > 0); + synchronized (keyImageGroups) { + setIsPolling(!getKeyImages().isEmpty() && listeners.size() > 0); } } @@ -313,4 +288,14 @@ public class XmrKeyImagePoller { looper.stop(); } } + + private Set getKeyImages() { + Set allKeyImages = new HashSet(); + synchronized (keyImageGroups) { + for (Set keyImagesGroup : keyImageGroups.values()) { + allKeyImages.addAll(keyImagesGroup); + } + } + return allKeyImages; + } } diff --git a/core/src/main/java/haveno/core/xmr/wallet/XmrWalletService.java b/core/src/main/java/haveno/core/xmr/wallet/XmrWalletService.java index 7bfc37f8e2..f015280b61 100644 --- a/core/src/main/java/haveno/core/xmr/wallet/XmrWalletService.java +++ b/core/src/main/java/haveno/core/xmr/wallet/XmrWalletService.java @@ -1008,12 +1008,21 @@ public class XmrWalletService extends XmrWalletBase { public synchronized void swapAddressEntryToAvailable(String offerId, XmrAddressEntry.Context context) { Optional addressEntryOptional = getAddressEntryListAsImmutableList().stream().filter(e -> offerId.equals(e.getOfferId())).filter(e -> context == e.getContext()).findAny(); addressEntryOptional.ifPresent(e -> { - log.info("swap addressEntry with address {} and offerId {} from context {} to available", e.getAddressString(), e.getOfferId(), context); xmrAddressEntryList.swapToAvailable(e); saveAddressEntryList(); }); } + public synchronized void cloneAddressEntries(String offerId, String cloneOfferId) { + List entries = getAddressEntryListAsImmutableList().stream().filter(e -> offerId.equals(e.getOfferId())).collect(Collectors.toList()); + for (XmrAddressEntry entry : entries) { + XmrAddressEntry clonedEntry = new XmrAddressEntry(entry.getSubaddressIndex(), entry.getAddressString(), entry.getContext(), cloneOfferId, null); + Optional existingEntry = getAddressEntry(clonedEntry.getOfferId(), clonedEntry.getContext()); + if (existingEntry.isPresent()) continue; + xmrAddressEntryList.addAddressEntry(clonedEntry); + } + } + public synchronized void resetAddressEntriesForOpenOffer(String offerId) { log.info("resetAddressEntriesForOpenOffer offerId={}", offerId); @@ -1031,7 +1040,7 @@ public class XmrWalletService extends XmrWalletBase { if (trade == null || trade.isPayoutUnlocked()) swapAddressEntryToAvailable(offerId, XmrAddressEntry.Context.TRADE_PAYOUT); } - public synchronized void resetAddressEntriesForTrade(String offerId) { + public synchronized void swapPayoutAddressEntryToAvailable(String offerId) { swapAddressEntryToAvailable(offerId, XmrAddressEntry.Context.TRADE_PAYOUT); } @@ -1191,26 +1200,34 @@ public class XmrWalletService extends XmrWalletBase { // TODO (woodser): update balance and other listening public void addBalanceListener(XmrBalanceListener listener) { - if (!balanceListeners.contains(listener)) balanceListeners.add(listener); + if (listener == null) throw new IllegalArgumentException("Cannot add null balance listener"); + synchronized (balanceListeners) { + if (!balanceListeners.contains(listener)) balanceListeners.add(listener); + } } public void removeBalanceListener(XmrBalanceListener listener) { - balanceListeners.remove(listener); + if (listener == null) throw new IllegalArgumentException("Cannot add null balance listener"); + synchronized (balanceListeners) { + balanceListeners.remove(listener); + } } public void updateBalanceListeners() { BigInteger availableBalance = getAvailableBalance(); - for (XmrBalanceListener balanceListener : balanceListeners) { - BigInteger balance; - if (balanceListener.getSubaddressIndex() != null && balanceListener.getSubaddressIndex() != 0) balance = getBalanceForSubaddress(balanceListener.getSubaddressIndex()); - else balance = availableBalance; - ThreadUtils.submitToPool(() -> { - try { - balanceListener.onBalanceChanged(balance); - } catch (Exception e) { - log.warn("Failed to notify balance listener of change: {}\n", e.getMessage(), e); - } - }); + synchronized (balanceListeners) { + for (XmrBalanceListener balanceListener : balanceListeners) { + BigInteger balance; + if (balanceListener.getSubaddressIndex() != null && balanceListener.getSubaddressIndex() != 0) balance = getBalanceForSubaddress(balanceListener.getSubaddressIndex()); + else balance = availableBalance; + ThreadUtils.submitToPool(() -> { + try { + balanceListener.onBalanceChanged(balance); + } catch (Exception e) { + log.warn("Failed to notify balance listener of change: {}\n", e.getMessage(), e); + } + }); + } } } diff --git a/core/src/main/resources/i18n/displayStrings.properties b/core/src/main/resources/i18n/displayStrings.properties index 3a6b666102..4f227668bd 100644 --- a/core/src/main/resources/i18n/displayStrings.properties +++ b/core/src/main/resources/i18n/displayStrings.properties @@ -103,7 +103,6 @@ shared.XMRMinMax=XMR (min - max) shared.removeOffer=Remove offer shared.dontRemoveOffer=Don't remove offer shared.editOffer=Edit offer -shared.duplicateOffer=Duplicate offer shared.openLargeQRWindow=Open large QR code window shared.chooseTradingAccount=Choose trading account shared.faq=Visit FAQ page @@ -385,6 +384,21 @@ offerbook.xmrAutoConf=Is auto-confirm enabled offerbook.buyXmrWith=Buy XMR with: offerbook.sellXmrFor=Sell XMR for: +offerbook.cloneOffer=Clone offer with shared funds +offerbook.clonedOffer.tooltip=This is a cloned offer with shared funds.\n\Group ID: {0} +offerbook.nonClonedOffer.tooltip=Regular offer without shared funds.\n\Maker reserve transaction ID: {0} +offerbook.hasConflictingClone.warning=This cloned offer with shared funds cannot be activated because it uses \ + the same payment method and currency as another active offer.\n\n\ + You need to edit the offer and change the \ + payment method or currency or deactivate the offer which has the same payment method and currency. +offerbook.cannotActivateEditedOffer.warning=You can't activate an offer that is currently edited. +offerbook.clonedOffer.headline=Cloning an offer +offerbook.clonedOffer.info=Cloning an offer creates a copy without reserving additional funds.\n\n\ + This helps reduce locked capital, making it easier to list the same offer across multiple markets or payment methods.\n\n\ + If one of the cloned offers is taken, the others will close automatically, since they all share the same reserved funds.\n\n\ + Cloned offers must use the same trade amount and security deposit, but they must differ in payment method or currency.\n\n\ + For more information about cloning offers see: [HYPERLINK:https://docs.haveno.exchange/haveno-ui/Cloning_an_offer/] + offerbook.timeSinceSigning.help=When you successfully complete a trade with a peer who has a signed payment account, your payment account is signed.\n\ {0} days later, the initial limit of {1} is lifted and your account can sign other peers'' payment accounts. offerbook.timeSinceSigning.notSigned=Not signed yet @@ -443,8 +457,9 @@ offerbook.warning.requireUpdateToNewVersion=Your version of Haveno is not compat offerbook.warning.offerWasAlreadyUsedInTrade=You cannot take this offer because you already took it earlier. \ It could be that your previous take-offer attempt resulted in a failed trade. -offerbook.warning.arbitratorNotValidated=This offer cannot be taken because the arbitrator is invalid -offerbook.warning.signatureNotValidated=This offer cannot be taken because the arbitrator's signature is invalid +offerbook.warning.arbitratorNotValidated=This offer cannot be taken because the arbitrator is invalid. +offerbook.warning.signatureNotValidated=This offer cannot be taken because the arbitrator's signature is invalid. +offerbook.warning.reserveFundsSpent=This offer cannot be taken because the reserved funds were already spent. offerbook.info.sellAtMarketPrice=You will sell at market price (updated every minute). offerbook.info.buyAtMarketPrice=You will buy at market price (updated every minute). @@ -600,6 +615,7 @@ takeOffer.tac=With taking this offer I agree to the trade conditions as defined #################################################################### openOffer.header.triggerPrice=Trigger price +openOffer.header.groupId=Group ID openOffer.triggerPrice=Trigger price {0} openOffer.triggered=The offer has been deactivated because the market price reached your trigger price.\n\ Please edit the offer to define a new trigger price @@ -610,6 +626,21 @@ editOffer.publishOffer=Publishing your offer. editOffer.failed=Editing of offer failed:\n{0} editOffer.success=Your offer has been successfully edited. editOffer.invalidDeposit=The buyer's security deposit is not within the constraints defined by Haveno and can no longer be edited. +editOffer.openTabWarning=You have already the \"Edit Offer\" tab open. +editOffer.hasConflictingClone=You have edited an offer which uses shared funding with another offer and your edit \ + made the payment method and currency now the same as another active cloned offer. Your edited offer will be \ + deactivated because it is not permitted to publish 2 offers sharing the funds with the same payment method \ + and currency.\n\n\ + You can edit the offer again at \"Portfolio/My open offers\" to fulfill the requirements to activate it. + +cloneOffer.clone=Clone offer +cloneOffer.publishOffer=Publishing cloned offer. +cloneOffer.success=Your offer has been successfully cloned. +cloneOffer.hasConflictingClone=You have not changed the payment method or the currency. You still can clone the offer, but it will \ + be deactivated and not published.\n\n\ + You can edit the offer later again at \"Portfolio/My open offers\" to fulfill the requirements to activate it.\n\n\ + Do you still want to clone the offer? +cloneOffer.openTabWarning=You have already the \"Clone Offer\" tab open. #################################################################### # Portfolio @@ -620,7 +651,8 @@ portfolio.tab.pendingTrades=Open trades portfolio.tab.history=History portfolio.tab.failed=Failed portfolio.tab.editOpenOffer=Edit offer -portfolio.tab.duplicateOffer=Duplicate offer +portfolio.tab.duplicateOffer=Create offer +portfolio.tab.cloneOpenOffer=Clone offer portfolio.context.offerLikeThis=Create new offer like this... portfolio.context.notYourOffer=You can only duplicate offers where you were the maker. diff --git a/core/src/main/resources/i18n/displayStrings_cs.properties b/core/src/main/resources/i18n/displayStrings_cs.properties index bc1841259c..58445171e0 100644 --- a/core/src/main/resources/i18n/displayStrings_cs.properties +++ b/core/src/main/resources/i18n/displayStrings_cs.properties @@ -103,7 +103,6 @@ shared.XMRMinMax=XMR (min - max) shared.removeOffer=Odstranit nabídku shared.dontRemoveOffer=Neodstraňovat nabídku shared.editOffer=Upravit nabídku -shared.duplicateOffer=Duplikovat nabídku shared.openLargeQRWindow=Otevřít velké okno s QR kódem shared.chooseTradingAccount=Vyberte obchodní účet shared.faq=Navštívit stránku FAQ diff --git a/core/src/main/resources/i18n/displayStrings_tr.properties b/core/src/main/resources/i18n/displayStrings_tr.properties index eafef04dfc..9ffb43d66a 100644 --- a/core/src/main/resources/i18n/displayStrings_tr.properties +++ b/core/src/main/resources/i18n/displayStrings_tr.properties @@ -103,7 +103,6 @@ shared.XMRMinMax=XMR (min - max) shared.removeOffer=Teklifi kaldır shared.dontRemoveOffer=Teklifi kaldırma shared.editOffer=Teklifi düzenle -shared.duplicateOffer=Teklifi çoğalt shared.openLargeQRWindow=Büyük QR kodu penceresini aç shared.chooseTradingAccount=İşlem hesabını seç shared.faq=SSS sayfasını ziyaret et diff --git a/daemon/src/main/java/haveno/daemon/grpc/GrpcOffersService.java b/daemon/src/main/java/haveno/daemon/grpc/GrpcOffersService.java index cbc6b6b2e3..1c6b8b8de9 100644 --- a/daemon/src/main/java/haveno/daemon/grpc/GrpcOffersService.java +++ b/daemon/src/main/java/haveno/daemon/grpc/GrpcOffersService.java @@ -157,6 +157,7 @@ class GrpcOffersService extends OffersImplBase { req.getIsPrivateOffer(), req.getBuyerAsTakerWithoutDeposit(), req.getExtraInfo(), + req.getSourceOfferId(), offer -> { // This result handling consumer's accept operation will return // the new offer to the gRPC client after async placement is done. diff --git a/desktop/src/main/java/haveno/desktop/app/HavenoApp.java b/desktop/src/main/java/haveno/desktop/app/HavenoApp.java index 3e41aa2f8c..16370c2cdb 100644 --- a/desktop/src/main/java/haveno/desktop/app/HavenoApp.java +++ b/desktop/src/main/java/haveno/desktop/app/HavenoApp.java @@ -366,7 +366,7 @@ public class HavenoApp extends Application implements UncaughtExceptionHandler { } // check for open offers - if (injector.getInstance(OpenOfferManager.class).hasOpenOffers()) { + if (injector.getInstance(OpenOfferManager.class).hasAvailableOpenOffers()) { String key = "showOpenOfferWarnPopupAtShutDown"; if (injector.getInstance(Preferences.class).showAgain(key) && !DevEnv.isDevMode()) { new Popup().warning(Res.get("popup.info.shutDownWithOpenOffers")) diff --git a/desktop/src/main/java/haveno/desktop/haveno.css b/desktop/src/main/java/haveno/desktop/haveno.css index 2f50f8d0f3..e3cfac8c0e 100644 --- a/desktop/src/main/java/haveno/desktop/haveno.css +++ b/desktop/src/main/java/haveno/desktop/haveno.css @@ -822,6 +822,10 @@ tree-table-view:focused { -fx-text-fill: -bs-rd-error-red; } +.icon { + -fx-fill: -bs-text-color; +} + .opaque-icon { -fx-fill: -bs-color-gray-bbb; -fx-opacity: 1; diff --git a/desktop/src/main/java/haveno/desktop/main/funds/deposit/DepositView.java b/desktop/src/main/java/haveno/desktop/main/funds/deposit/DepositView.java index e7c4c89ab9..884df454e7 100644 --- a/desktop/src/main/java/haveno/desktop/main/funds/deposit/DepositView.java +++ b/desktop/src/main/java/haveno/desktop/main/funds/deposit/DepositView.java @@ -346,7 +346,7 @@ public class DepositView extends ActivatableView { List addressEntries = xmrWalletService.getAddressEntries(); List items = new ArrayList<>(); for (XmrAddressEntry addressEntry : addressEntries) { - if (addressEntry.isTrade()) continue; // skip reserved for trade + if (addressEntry.isTradePayout()) continue; // do not show trade payout addresses items.add(new DepositListItem(addressEntry, xmrWalletService, formatter)); } diff --git a/desktop/src/main/java/haveno/desktop/main/funds/withdrawal/WithdrawalListItem.java b/desktop/src/main/java/haveno/desktop/main/funds/withdrawal/WithdrawalListItem.java index ed9a0f0a7d..add84853c3 100644 --- a/desktop/src/main/java/haveno/desktop/main/funds/withdrawal/WithdrawalListItem.java +++ b/desktop/src/main/java/haveno/desktop/main/funds/withdrawal/WithdrawalListItem.java @@ -77,7 +77,7 @@ class WithdrawalListItem { public final String getLabel() { if (addressEntry.isOpenOffer()) return Res.getWithCol("shared.offerId") + " " + addressEntry.getShortOfferId(); - else if (addressEntry.isTrade()) + else if (addressEntry.isTradePayout()) return Res.getWithCol("shared.tradeId") + " " + addressEntry.getShortOfferId(); else if (addressEntry.getContext() == XmrAddressEntry.Context.ARBITRATOR) return Res.get("funds.withdrawal.arbitrationFee"); diff --git a/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferDataModel.java b/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferDataModel.java index 68c5edc733..47bfee0006 100644 --- a/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferDataModel.java +++ b/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferDataModel.java @@ -82,7 +82,7 @@ import lombok.Getter; import org.jetbrains.annotations.NotNull; public abstract class MutableOfferDataModel extends OfferDataModel { - private final CreateOfferService createOfferService; + protected final CreateOfferService createOfferService; protected final OpenOfferManager openOfferManager; private final XmrWalletService xmrWalletService; private final Preferences preferences; @@ -115,7 +115,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel { protected PaymentAccount paymentAccount; boolean isTabSelected; - protected double marketPriceMargin = 0; + protected double marketPriceMarginPct = 0; @Getter private boolean marketPriceAvailable; protected boolean allowAmountUpdate = true; @@ -189,12 +189,12 @@ public abstract class MutableOfferDataModel extends OfferDataModel { } private void addListeners() { - xmrWalletService.addBalanceListener(xmrBalanceListener); + if (xmrBalanceListener != null) xmrWalletService.addBalanceListener(xmrBalanceListener); user.getPaymentAccountsAsObservable().addListener(paymentAccountsChangeListener); } private void removeListeners() { - xmrWalletService.removeBalanceListener(xmrBalanceListener); + if (xmrBalanceListener != null) xmrWalletService.removeBalanceListener(xmrBalanceListener); user.getPaymentAccountsAsObservable().removeListener(paymentAccountsChangeListener); } @@ -204,14 +204,16 @@ public abstract class MutableOfferDataModel extends OfferDataModel { /////////////////////////////////////////////////////////////////////////////////////////// // called before activate() - public boolean initWithData(OfferDirection direction, TradeCurrency tradeCurrency) { - addressEntry = xmrWalletService.getOrCreateAddressEntry(offerId, XmrAddressEntry.Context.OFFER_FUNDING); - xmrBalanceListener = new XmrBalanceListener(getAddressEntry().getSubaddressIndex()) { - @Override - public void onBalanceChanged(BigInteger balance) { - updateBalances(); - } - }; + public boolean initWithData(OfferDirection direction, TradeCurrency tradeCurrency, boolean initAddressEntry) { + if (initAddressEntry) { + addressEntry = xmrWalletService.getOrCreateAddressEntry(offerId, XmrAddressEntry.Context.OFFER_FUNDING); + xmrBalanceListener = new XmrBalanceListener(getAddressEntry().getSubaddressIndex()) { + @Override + public void onBalanceChanged(BigInteger balance) { + updateBalances(); + } + }; + } this.direction = direction; this.tradeCurrency = tradeCurrency; @@ -278,6 +280,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel { } protected void updateBalances() { + if (addressEntry == null) return; super.updateBalances(); // update remaining balance @@ -302,7 +305,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel { minAmount.get(), useMarketBasedPrice.get() ? null : price.get(), useMarketBasedPrice.get(), - useMarketBasedPrice.get() ? marketPriceMargin : 0, + useMarketBasedPrice.get() ? marketPriceMarginPct : 0, securityDepositPct.get(), paymentAccount, buyerAsTakerWithoutDeposit.get(), // private offer if buyer as taker without deposit @@ -316,6 +319,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel { triggerPrice, reserveExactAmount, false, // desktop ui resets address entries on cancel + null, resultHandler, errorMessageHandler); } @@ -387,7 +391,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel { volume.set(null); minVolume.set(null); price.set(null); - marketPriceMargin = 0; + marketPriceMarginPct = 0; } this.tradeCurrency = tradeCurrency; @@ -416,10 +420,6 @@ public abstract class MutableOfferDataModel extends OfferDataModel { updateBalances(); } - protected void setMarketPriceMarginPct(double marketPriceMargin) { - this.marketPriceMargin = marketPriceMargin; - } - /////////////////////////////////////////////////////////////////////////////////////////// // Getters /////////////////////////////////////////////////////////////////////////////////////////// @@ -469,7 +469,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel { } public double getMarketPriceMarginPct() { - return marketPriceMargin; + return marketPriceMarginPct; } long getMaxTradeLimit() { @@ -609,6 +609,10 @@ public abstract class MutableOfferDataModel extends OfferDataModel { this.triggerPrice = triggerPrice; } + public void setMarketPriceMarginPct(double marketPriceMarginPct) { + this.marketPriceMarginPct = marketPriceMarginPct; + } + public void setReserveExactAmount(boolean reserveExactAmount) { this.reserveExactAmount = reserveExactAmount; } @@ -684,6 +688,14 @@ public abstract class MutableOfferDataModel extends OfferDataModel { return Restrictions.getMinSecurityDeposit().max(value); } + protected double getSecurityAsPercent(Offer offer) { + BigInteger offerSellerSecurityDeposit = getBoundedSecurityDeposit(offer.getMaxSellerSecurityDeposit()); + double offerSellerSecurityDepositAsPercent = CoinUtil.getAsPercentPerXmr(offerSellerSecurityDeposit, + offer.getAmount()); + return Math.min(offerSellerSecurityDepositAsPercent, + Restrictions.getMaxSecurityDepositAsPercent()); + } + ReadOnlyObjectProperty totalToPayAsProperty() { return totalToPay; } diff --git a/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferView.java b/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferView.java index abdd2d0ec7..31c02bdc0d 100644 --- a/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferView.java +++ b/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferView.java @@ -297,11 +297,13 @@ public abstract class MutableOfferView> exten model.getDataModel().onTabSelected(isSelected); } - public void initWithData(OfferDirection direction, TradeCurrency tradeCurrency, + public void initWithData(OfferDirection direction, + TradeCurrency tradeCurrency, + boolean initAddressEntry, OfferView.OfferActionHandler offerActionHandler) { this.offerActionHandler = offerActionHandler; - boolean result = model.initWithData(direction, tradeCurrency); + boolean result = model.initWithData(direction, tradeCurrency, initAddressEntry); if (!result) { new Popup().headLine(Res.get("popup.warning.noTradingAccountSetup.headline")) diff --git a/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferViewModel.java b/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferViewModel.java index d49b4f2d15..fa6cf41729 100644 --- a/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferViewModel.java +++ b/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferViewModel.java @@ -601,8 +601,8 @@ public abstract class MutableOfferViewModel ext // API /////////////////////////////////////////////////////////////////////////////////////////// - boolean initWithData(OfferDirection direction, TradeCurrency tradeCurrency) { - boolean result = dataModel.initWithData(direction, tradeCurrency); + boolean initWithData(OfferDirection direction, TradeCurrency tradeCurrency, boolean initAddressEntry) { + boolean result = dataModel.initWithData(direction, tradeCurrency, initAddressEntry); if (dataModel.getAddressEntry() != null) { addressAsString = dataModel.getAddressEntry().getAddressString(); } diff --git a/desktop/src/main/java/haveno/desktop/main/offer/createoffer/CreateOfferView.java b/desktop/src/main/java/haveno/desktop/main/offer/createoffer/CreateOfferView.java index ba9f3d312f..f88bed1808 100644 --- a/desktop/src/main/java/haveno/desktop/main/offer/createoffer/CreateOfferView.java +++ b/desktop/src/main/java/haveno/desktop/main/offer/createoffer/CreateOfferView.java @@ -47,11 +47,10 @@ public class CreateOfferView extends MutableOfferView { super(model, navigation, preferences, offerDetailsWindow, btcFormatter); } - @Override public void initWithData(OfferDirection direction, TradeCurrency tradeCurrency, OfferView.OfferActionHandler offerActionHandler) { - super.initWithData(direction, tradeCurrency, offerActionHandler); + super.initWithData(direction, tradeCurrency, true, offerActionHandler); } @Override diff --git a/desktop/src/main/java/haveno/desktop/main/offer/offerbook/OfferBook.java b/desktop/src/main/java/haveno/desktop/main/offer/offerbook/OfferBook.java index 2367f51c8d..2e92a9d42d 100644 --- a/desktop/src/main/java/haveno/desktop/main/offer/offerbook/OfferBook.java +++ b/desktop/src/main/java/haveno/desktop/main/offer/offerbook/OfferBook.java @@ -19,13 +19,12 @@ package haveno.desktop.main.offer.offerbook; import com.google.inject.Inject; import com.google.inject.Singleton; -import haveno.core.filter.FilterManager; + +import haveno.common.UserThread; import haveno.core.offer.Offer; import haveno.core.offer.OfferBookService; import static haveno.core.offer.OfferDirection.BUY; -import haveno.core.offer.OfferRestrictions; import haveno.network.p2p.storage.P2PDataStorage; -import haveno.network.utils.Utils; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -49,7 +48,6 @@ public class OfferBook { private final ObservableList offerBookListItems = FXCollections.observableArrayList(); private final Map buyOfferCountMap = new HashMap<>(); private final Map sellOfferCountMap = new HashMap<>(); - private final FilterManager filterManager; /////////////////////////////////////////////////////////////////////////////////////////// @@ -57,64 +55,47 @@ public class OfferBook { /////////////////////////////////////////////////////////////////////////////////////////// @Inject - OfferBook(OfferBookService offerBookService, FilterManager filterManager) { + OfferBook(OfferBookService offerBookService) { this.offerBookService = offerBookService; - this.filterManager = filterManager; offerBookService.addOfferBookChangedListener(new OfferBookService.OfferBookChangedListener() { @Override public void onAdded(Offer offer) { - printOfferBookListItems("Before onAdded"); - // We get onAdded called every time a new ProtectedStorageEntry is received. - // Mostly it is the same OfferPayload but the ProtectedStorageEntry is different. - // We filter here to only add new offers if the same offer (using equals) was not already added and it - // is not banned. + UserThread.execute(() -> { + printOfferBookListItems("Before onAdded"); - if (filterManager.isOfferIdBanned(offer.getId())) { - log.debug("Ignored banned offer. ID={}", offer.getId()); - return; - } - - if (OfferRestrictions.requiresNodeAddressUpdate() && !Utils.isV3Address(offer.getMakerNodeAddress().getHostName())) { - log.debug("Ignored offer with Tor v2 node address. ID={}", offer.getId()); - return; - } - - // Use offer.equals(offer) to see if the OfferBook list contains an exact - // match -- offer.equals(offer) includes comparisons of payload, state - // and errorMessage. - synchronized (offerBookListItems) { - boolean hasSameOffer = offerBookListItems.stream().anyMatch(item -> item.getOffer().equals(offer)); - if (!hasSameOffer) { - OfferBookListItem newOfferBookListItem = new OfferBookListItem(offer); - removeDuplicateItem(newOfferBookListItem); - offerBookListItems.add(newOfferBookListItem); // Add replacement. - if (log.isDebugEnabled()) { // TODO delete debug stmt in future PR. - log.debug("onAdded: Added new offer {}\n" - + "\twith newItem.payloadHash: {}", - offer.getId(), - newOfferBookListItem.hashOfPayload.getHex()); + // Use offer.equals(offer) to see if the OfferBook list contains an exact + // match -- offer.equals(offer) includes comparisons of payload, state + // and errorMessage. + synchronized (offerBookListItems) { + boolean hasSameOffer = offerBookListItems.stream().anyMatch(item -> item.getOffer().equals(offer)); + if (!hasSameOffer) { + OfferBookListItem newOfferBookListItem = new OfferBookListItem(offer); + removeDuplicateItem(newOfferBookListItem); + offerBookListItems.add(newOfferBookListItem); // Add replacement. + if (log.isDebugEnabled()) { // TODO delete debug stmt in future PR. + log.debug("onAdded: Added new offer {}\n" + + "\twith newItem.payloadHash: {}", + offer.getId(), + newOfferBookListItem.hashOfPayload.getHex()); + } + } else { + log.debug("We have the exact same offer already in our list and ignore the onAdded call. ID={}", offer.getId()); } - } else { - log.debug("We have the exact same offer already in our list and ignore the onAdded call. ID={}", offer.getId()); + printOfferBookListItems("After onAdded"); } - printOfferBookListItems("After onAdded"); - } + }); } @Override public void onRemoved(Offer offer) { - synchronized (offerBookListItems) { - printOfferBookListItems("Before onRemoved"); - removeOffer(offer); - printOfferBookListItems("After onRemoved"); - } - } - }); - - filterManager.filterProperty().addListener((observable, oldValue, newValue) -> { - if (newValue != null) { - // any notifications + UserThread.execute(() -> { + synchronized (offerBookListItems) { + printOfferBookListItems("Before onRemoved"); + removeOffer(offer); + printOfferBookListItems("After onRemoved"); + } + }); } }); } @@ -212,7 +193,6 @@ public class OfferBook { // Investigate why.... offerBookListItems.clear(); offerBookListItems.addAll(offerBookService.getOffers().stream() - .filter(this::isOfferAllowed) .map(OfferBookListItem::new) .collect(Collectors.toList())); @@ -248,13 +228,6 @@ public class OfferBook { return sellOfferCountMap; } - private boolean isOfferAllowed(Offer offer) { - boolean isBanned = filterManager.isOfferIdBanned(offer.getId()); - boolean isV3NodeAddressCompliant = !OfferRestrictions.requiresNodeAddressUpdate() - || Utils.isV3Address(offer.getMakerNodeAddress().getHostName()); - return !isBanned && isV3NodeAddressCompliant; - } - private void fillOfferCountMaps() { buyOfferCountMap.clear(); sellOfferCountMap.clear(); diff --git a/desktop/src/main/java/haveno/desktop/main/offer/offerbook/OfferBookView.java b/desktop/src/main/java/haveno/desktop/main/offer/offerbook/OfferBookView.java index af47d9bec5..0248b9c522 100644 --- a/desktop/src/main/java/haveno/desktop/main/offer/offerbook/OfferBookView.java +++ b/desktop/src/main/java/haveno/desktop/main/offer/offerbook/OfferBookView.java @@ -695,8 +695,13 @@ abstract public class OfferBookView fillCurrencies(); + // refresh filter on changes + offerBook.getOfferBookListItems().addListener((ListChangeListener) c -> { + filterOffers(); + }); + filterItemsListener = c -> { final Optional highestAmountOffer = filteredItems.stream() .max(Comparator.comparingLong(o -> o.getOffer().getAmount().longValueExact())); diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/PortfolioView.java b/desktop/src/main/java/haveno/desktop/main/portfolio/PortfolioView.java index 3b17109a29..c3a1e76738 100644 --- a/desktop/src/main/java/haveno/desktop/main/portfolio/PortfolioView.java +++ b/desktop/src/main/java/haveno/desktop/main/portfolio/PortfolioView.java @@ -30,6 +30,8 @@ import haveno.desktop.common.view.CachingViewLoader; import haveno.desktop.common.view.FxmlView; import haveno.desktop.common.view.View; import haveno.desktop.main.MainView; +import haveno.desktop.main.overlays.popups.Popup; +import haveno.desktop.main.portfolio.cloneoffer.CloneOfferView; import haveno.desktop.main.portfolio.closedtrades.ClosedTradesView; import haveno.desktop.main.portfolio.duplicateoffer.DuplicateOfferView; import haveno.desktop.main.portfolio.editoffer.EditOfferView; @@ -49,7 +51,7 @@ public class PortfolioView extends ActivatableView { @FXML Tab openOffersTab, pendingTradesTab, closedTradesTab; - private Tab editOpenOfferTab, duplicateOfferTab; + private Tab editOpenOfferTab, duplicateOfferTab, cloneOpenOfferTab; private final Tab failedTradesTab = new Tab(Res.get("portfolio.tab.failed").toUpperCase()); private Tab currentTab; private Navigation.Listener navigationListener; @@ -61,7 +63,8 @@ public class PortfolioView extends ActivatableView { private final FailedTradesManager failedTradesManager; private EditOfferView editOfferView; private DuplicateOfferView duplicateOfferView; - private boolean editOpenOfferViewOpen; + private CloneOfferView cloneOfferView; + private boolean editOpenOfferViewOpen, cloneOpenOfferViewOpen; private OpenOffer openOffer; private OpenOffersView openOffersView; @@ -99,12 +102,16 @@ public class PortfolioView extends ActivatableView { navigation.navigateTo(MainView.class, PortfolioView.class, EditOfferView.class); else if (newValue == duplicateOfferTab) { navigation.navigateTo(MainView.class, PortfolioView.class, DuplicateOfferView.class); + } else if (newValue == cloneOpenOfferTab) { + navigation.navigateTo(MainView.class, PortfolioView.class, CloneOfferView.class); } if (oldValue != null && oldValue == editOpenOfferTab) editOfferView.onTabSelected(false); if (oldValue != null && oldValue == duplicateOfferTab) duplicateOfferView.onTabSelected(false); + if (oldValue != null && oldValue == cloneOpenOfferTab) + cloneOfferView.onTabSelected(false); }; @@ -115,6 +122,8 @@ public class PortfolioView extends ActivatableView { onEditOpenOfferRemoved(); if (removedTabs.size() == 1 && removedTabs.get(0).equals(duplicateOfferTab)) onDuplicateOfferRemoved(); + if (removedTabs.size() == 1 && removedTabs.get(0).equals(cloneOpenOfferTab)) + onCloneOpenOfferRemoved(); }; } @@ -137,6 +146,16 @@ public class PortfolioView extends ActivatableView { navigation.navigateTo(MainView.class, this.getClass(), OpenOffersView.class); } + private void onCloneOpenOfferRemoved() { + cloneOpenOfferViewOpen = false; + if (cloneOfferView != null) { + cloneOfferView.onClose(); + cloneOfferView = null; + } + + navigation.navigateTo(MainView.class, this.getClass(), OpenOffersView.class); + } + @Override protected void activate() { failedTradesManager.getObservableList().addListener((ListChangeListener) c -> { @@ -166,6 +185,9 @@ public class PortfolioView extends ActivatableView { } else if (root.getSelectionModel().getSelectedItem() == duplicateOfferTab) { navigation.navigateTo(MainView.class, PortfolioView.class, DuplicateOfferView.class); if (duplicateOfferView != null) duplicateOfferView.onTabSelected(true); + } else if (root.getSelectionModel().getSelectedItem() == cloneOpenOfferTab) { + navigation.navigateTo(MainView.class, PortfolioView.class, CloneOfferView.class); + if (cloneOfferView != null) cloneOfferView.onTabSelected(true); } } @@ -178,10 +200,9 @@ public class PortfolioView extends ActivatableView { } private void loadView(Class viewClass, @Nullable Object data) { - // we want to get activate/deactivate called, so we remove the old view on tab change - // TODO Don't understand the check for currentTab != editOpenOfferTab - if (currentTab != null && currentTab != editOpenOfferTab) - currentTab.setContent(null); + + // nullify current tab to trigger activate/deactivate + if (currentTab != null) currentTab.setContent(null); View view = viewLoader.load(viewClass); @@ -235,6 +256,28 @@ public class PortfolioView extends ActivatableView { view = viewLoader.load(OpenOffersView.class); selectOpenOffersView((OpenOffersView) view); } + } else if (view instanceof CloneOfferView) { + if (data instanceof OpenOffer) { + openOffer = (OpenOffer) data; + } + if (openOffer != null) { + if (cloneOfferView == null) { + cloneOfferView = (CloneOfferView) view; + cloneOfferView.applyOpenOffer(openOffer); + cloneOpenOfferTab = new Tab(Res.get("portfolio.tab.cloneOpenOffer").toUpperCase()); + cloneOfferView.setCloseHandler(() -> { + root.getTabs().remove(cloneOpenOfferTab); + }); + root.getTabs().add(cloneOpenOfferTab); + } + if (currentTab != cloneOpenOfferTab) + cloneOfferView.onTabSelected(true); + + currentTab = cloneOpenOfferTab; + } else { + view = viewLoader.load(OpenOffersView.class); + selectOpenOffersView((OpenOffersView) view); + } } currentTab.setContent(view.getRoot()); @@ -245,20 +288,35 @@ public class PortfolioView extends ActivatableView { openOffersView = view; currentTab = openOffersTab; - OpenOfferActionHandler openOfferActionHandler = openOffer -> { + EditOpenOfferHandler editOpenOfferHandler = openOffer -> { if (!editOpenOfferViewOpen) { editOpenOfferViewOpen = true; PortfolioView.this.openOffer = openOffer; navigation.navigateTo(MainView.class, PortfolioView.this.getClass(), EditOfferView.class); } else { - log.error("You have already a \"Edit Offer\" tab open."); + new Popup().warning(Res.get("editOffer.openTabWarning")).show(); } }; - openOffersView.setOpenOfferActionHandler(openOfferActionHandler); + openOffersView.setEditOpenOfferHandler(editOpenOfferHandler); + + CloneOpenOfferHandler cloneOpenOfferHandler = openOffer -> { + if (!cloneOpenOfferViewOpen) { + cloneOpenOfferViewOpen = true; + PortfolioView.this.openOffer = openOffer; + navigation.navigateTo(MainView.class, PortfolioView.this.getClass(), CloneOfferView.class); + } else { + new Popup().warning(Res.get("cloneOffer.openTabWarning")).show(); + } + }; + openOffersView.setCloneOpenOfferHandler(cloneOpenOfferHandler); } - public interface OpenOfferActionHandler { + public interface EditOpenOfferHandler { void onEditOpenOffer(OpenOffer openOffer); } + + public interface CloneOpenOfferHandler { + void onCloneOpenOffer(OpenOffer openOffer); + } } diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/cloneoffer/CloneOfferDataModel.java b/desktop/src/main/java/haveno/desktop/main/portfolio/cloneoffer/CloneOfferDataModel.java new file mode 100644 index 0000000000..24d792005c --- /dev/null +++ b/desktop/src/main/java/haveno/desktop/main/portfolio/cloneoffer/CloneOfferDataModel.java @@ -0,0 +1,195 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package haveno.desktop.main.portfolio.cloneoffer; + + +import haveno.desktop.Navigation; +import haveno.desktop.main.offer.MutableOfferDataModel; +import haveno.core.account.witness.AccountAgeWitnessService; +import haveno.core.locale.CurrencyUtil; +import haveno.core.locale.TradeCurrency; +import haveno.core.offer.CreateOfferService; +import haveno.core.offer.Offer; +import haveno.core.offer.OfferDirection; +import haveno.core.offer.OfferUtil; +import haveno.core.offer.OpenOffer; +import haveno.core.offer.OpenOfferManager; +import haveno.core.payment.PaymentAccount; +import haveno.core.proto.persistable.CorePersistenceProtoResolver; +import haveno.core.provider.price.PriceFeedService; +import haveno.core.trade.statistics.TradeStatisticsManager; +import haveno.core.user.Preferences; +import haveno.core.user.User; +import haveno.core.util.FormattingUtils; +import haveno.core.util.coin.CoinFormatter; +import haveno.core.xmr.wallet.XmrWalletService; +import haveno.network.p2p.P2PService; + +import haveno.common.handlers.ErrorMessageHandler; +import haveno.common.handlers.ResultHandler; + +import com.google.inject.Inject; +import com.google.inject.name.Named; + +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +class CloneOfferDataModel extends MutableOfferDataModel { + + private final CorePersistenceProtoResolver corePersistenceProtoResolver; + private OpenOffer sourceOpenOffer; + + @Inject + CloneOfferDataModel(CreateOfferService createOfferService, + OpenOfferManager openOfferManager, + OfferUtil offerUtil, + XmrWalletService xmrWalletService, + Preferences preferences, + User user, + P2PService p2PService, + PriceFeedService priceFeedService, + AccountAgeWitnessService accountAgeWitnessService, + @Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter xmrFormatter, + CorePersistenceProtoResolver corePersistenceProtoResolver, + TradeStatisticsManager tradeStatisticsManager, + Navigation navigation) { + + super(createOfferService, + openOfferManager, + offerUtil, + xmrWalletService, + preferences, + user, + p2PService, + priceFeedService, + accountAgeWitnessService, + xmrFormatter, + tradeStatisticsManager, + navigation); + this.corePersistenceProtoResolver = corePersistenceProtoResolver; + } + + public void reset() { + direction = null; + tradeCurrency = null; + tradeCurrencyCode.set(null); + useMarketBasedPrice.set(false); + amount.set(null); + minAmount.set(null); + price.set(null); + volume.set(null); + minVolume.set(null); + securityDepositPct.set(0); + paymentAccounts.clear(); + paymentAccount = null; + marketPriceMarginPct = 0; + sourceOpenOffer = null; + } + + public void applyOpenOffer(OpenOffer openOffer) { + this.sourceOpenOffer = openOffer; + + Offer offer = openOffer.getOffer(); + direction = offer.getDirection(); + CurrencyUtil.getTradeCurrency(offer.getCurrencyCode()) + .ifPresent(c -> this.tradeCurrency = c); + tradeCurrencyCode.set(offer.getCurrencyCode()); + + PaymentAccount tmpPaymentAccount = user.getPaymentAccount(openOffer.getOffer().getMakerPaymentAccountId()); + Optional optionalTradeCurrency = CurrencyUtil.getTradeCurrency(openOffer.getOffer().getCurrencyCode()); + if (optionalTradeCurrency.isPresent() && tmpPaymentAccount != null) { + TradeCurrency selectedTradeCurrency = optionalTradeCurrency.get(); + this.paymentAccount = PaymentAccount.fromProto(tmpPaymentAccount.toProtoMessage(), corePersistenceProtoResolver); + if (paymentAccount.getSingleTradeCurrency() != null) + paymentAccount.setSingleTradeCurrency(selectedTradeCurrency); + else + paymentAccount.setSelectedTradeCurrency(selectedTradeCurrency); + } + + allowAmountUpdate = false; + } + + public boolean initWithData(OfferDirection direction, TradeCurrency tradeCurrency) { + try { + return super.initWithData(direction, tradeCurrency, false); + } catch (NullPointerException e) { + if (e.getMessage().contains("tradeCurrency")) { + throw new IllegalArgumentException("Offers of removed assets cannot be edited. You can only cancel it.", e); + } + return false; + } + } + + @Override + protected Set getUserPaymentAccounts() { + return Objects.requireNonNull(user.getPaymentAccounts()).stream() + .filter(account -> !account.getPaymentMethod().isBsqSwap()) + .collect(Collectors.toSet()); + } + + @Override + protected PaymentAccount getPreselectedPaymentAccount() { + return paymentAccount; + } + + public void populateData() { + Offer offer = sourceOpenOffer.getOffer(); + // Min amount need to be set before amount as if minAmount is null it would be set by amount + setMinAmount(offer.getMinAmount()); + setAmount(offer.getAmount()); + setPrice(offer.getPrice()); + setVolume(offer.getVolume()); + setUseMarketBasedPrice(offer.isUseMarketBasedPrice()); + setTriggerPrice(sourceOpenOffer.getTriggerPrice()); + if (offer.isUseMarketBasedPrice()) { + setMarketPriceMarginPct(offer.getMarketPriceMarginPct()); + } + setBuyerAsTakerWithoutDeposit(offer.hasBuyerAsTakerWithoutDeposit()); + setSecurityDepositPct(getSecurityAsPercent(offer)); + setExtraInfo(offer.getOfferExtraInfo()); + } + + public void onCloneOffer(ResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) { + Offer clonedOffer = createClonedOffer(); + openOfferManager.placeOffer(clonedOffer, + false, + triggerPrice, + false, + true, + sourceOpenOffer.getId(), + transaction -> resultHandler.handleResult(), + errorMessageHandler); + } + + private Offer createClonedOffer() { + return createOfferService.createClonedOffer(sourceOpenOffer.getOffer(), + tradeCurrencyCode.get(), + useMarketBasedPrice.get() ? null : price.get(), + useMarketBasedPrice.get(), + useMarketBasedPrice.get() ? marketPriceMarginPct : 0, + paymentAccount, + extraInfo.get()); + } + + public boolean hasConflictingClone() { + Offer clonedOffer = createClonedOffer(); + return openOfferManager.hasConflictingClone(clonedOffer, sourceOpenOffer); + } +} \ No newline at end of file diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/cloneoffer/CloneOfferView.fxml b/desktop/src/main/java/haveno/desktop/main/portfolio/cloneoffer/CloneOfferView.fxml new file mode 100644 index 0000000000..80c57192c0 --- /dev/null +++ b/desktop/src/main/java/haveno/desktop/main/portfolio/cloneoffer/CloneOfferView.fxml @@ -0,0 +1,24 @@ + + + + + + + + \ No newline at end of file diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/cloneoffer/CloneOfferView.java b/desktop/src/main/java/haveno/desktop/main/portfolio/cloneoffer/CloneOfferView.java new file mode 100644 index 0000000000..e48bdf80a7 --- /dev/null +++ b/desktop/src/main/java/haveno/desktop/main/portfolio/cloneoffer/CloneOfferView.java @@ -0,0 +1,261 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package haveno.desktop.main.portfolio.cloneoffer; + +import haveno.desktop.Navigation; +import haveno.desktop.common.view.FxmlView; +import haveno.desktop.components.AutoTooltipButton; +import haveno.desktop.components.BusyAnimation; +import haveno.desktop.main.offer.MutableOfferView; +import haveno.desktop.main.overlays.popups.Popup; +import haveno.desktop.main.overlays.windows.OfferDetailsWindow; + +import haveno.core.locale.CurrencyUtil; +import haveno.core.locale.Res; +import haveno.core.offer.OpenOffer; +import haveno.core.payment.PaymentAccount; +import haveno.core.user.DontShowAgainLookup; +import haveno.core.user.Preferences; +import haveno.core.util.FormattingUtils; +import haveno.core.util.coin.CoinFormatter; +import haveno.common.UserThread; +import haveno.common.util.Tuple4; + +import com.google.inject.Inject; +import com.google.inject.name.Named; + +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.image.ImageView; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; + +import javafx.geometry.HPos; +import javafx.geometry.Insets; +import javafx.geometry.Pos; + +import javafx.collections.ObservableList; + +import static haveno.desktop.util.FormBuilder.addButtonBusyAnimationLabelAfterGroup; + +@FxmlView +public class CloneOfferView extends MutableOfferView { + + private BusyAnimation busyAnimation; + private Button cloneButton; + private Button cancelButton; + private Label spinnerInfoLabel; + + /////////////////////////////////////////////////////////////////////////////////////////// + // Constructor, lifecycle + /////////////////////////////////////////////////////////////////////////////////////////// + + @Inject + private CloneOfferView(CloneOfferViewModel model, + Navigation navigation, + Preferences preferences, + OfferDetailsWindow offerDetailsWindow, + @Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter btcFormatter) { + super(model, navigation, preferences, offerDetailsWindow, btcFormatter); + } + + @Override + protected void initialize() { + super.initialize(); + + addCloneGroup(); + renameAmountGroup(); + } + + private void renameAmountGroup() { + amountTitledGroupBg.setText(Res.get("editOffer.setPrice")); + } + + @Override + protected void doSetFocus() { + // Don't focus in any field before data was set + } + + @Override + protected void doActivate() { + super.doActivate(); + + + addBindings(); + + hideOptionsGroup(); + hideNextButtons(); + + // Lock amount field as it would require bigger changes to support increased amount values. + amountTextField.setDisable(true); + amountBtcLabel.setDisable(true); + minAmountTextField.setDisable(true); + minAmountBtcLabel.setDisable(true); + volumeTextField.setDisable(true); + volumeCurrencyLabel.setDisable(true); + + // Workaround to fix margin on top of amount group + gridPane.setPadding(new Insets(-20, 25, -1, 25)); + + updatePriceToggle(); + updateElementsWithDirection(); + + model.isNextButtonDisabled.setValue(false); + cancelButton.setDisable(false); + + model.onInvalidateMarketPriceMargin(); + model.onInvalidatePrice(); + + // To force re-validation of payment account validation + onPaymentAccountsComboBoxSelected(); + } + + @Override + protected void deactivate() { + super.deactivate(); + + removeBindings(); + } + + @Override + public void onClose() { + } + + /////////////////////////////////////////////////////////////////////////////////////////// + // API + /////////////////////////////////////////////////////////////////////////////////////////// + + public void applyOpenOffer(OpenOffer openOffer) { + model.applyOpenOffer(openOffer); + + initWithData(openOffer.getOffer().getDirection(), + CurrencyUtil.getTradeCurrency(openOffer.getOffer().getCurrencyCode()).get(), + false, + null); + + if (!model.isSecurityDepositValid()) { + new Popup().warning(Res.get("editOffer.invalidDeposit")) + .onClose(this::close) + .show(); + } + } + + /////////////////////////////////////////////////////////////////////////////////////////// + // Bindings, Listeners + /////////////////////////////////////////////////////////////////////////////////////////// + + private void addBindings() { + cloneButton.disableProperty().bind(model.isNextButtonDisabled); + } + + private void removeBindings() { + cloneButton.disableProperty().unbind(); + } + + @Override + protected ObservableList filterPaymentAccounts(ObservableList paymentAccounts) { + return paymentAccounts; + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Build UI elements + /////////////////////////////////////////////////////////////////////////////////////////// + + private void addCloneGroup() { + Tuple4 tuple4 = addButtonBusyAnimationLabelAfterGroup(gridPane, 6, Res.get("cloneOffer.clone")); + + HBox hBox = tuple4.fourth; + hBox.setAlignment(Pos.CENTER_LEFT); + GridPane.setHalignment(hBox, HPos.LEFT); + + cloneButton = tuple4.first; + cloneButton.setMinHeight(40); + cloneButton.setPadding(new Insets(0, 20, 0, 20)); + cloneButton.setGraphicTextGap(10); + + busyAnimation = tuple4.second; + spinnerInfoLabel = tuple4.third; + + cancelButton = new AutoTooltipButton(Res.get("shared.cancel")); + cancelButton.setDefaultButton(false); + cancelButton.setOnAction(event -> close()); + hBox.getChildren().add(cancelButton); + + cloneButton.setOnAction(e -> { + cloneButton.requestFocus(); // fix issue #5460 (when enter key used, focus is wrong) + onClone(); + }); + } + + private void onClone() { + if (model.dataModel.hasConflictingClone()) { + new Popup().warning(Res.get("cloneOffer.hasConflictingClone")) + .actionButtonText(Res.get("shared.yes")) + .onAction(this::doClone) + .closeButtonText(Res.get("shared.no")) + .show(); + } else { + doClone(); + } + } + + private void doClone() { + if (model.isPriceInRange()) { + model.isNextButtonDisabled.setValue(true); + cancelButton.setDisable(true); + busyAnimation.play(); + spinnerInfoLabel.setText(Res.get("cloneOffer.publishOffer")); + model.onCloneOffer(() -> { + UserThread.execute(() -> { + String key = "cloneOfferSuccess"; + if (DontShowAgainLookup.showAgain(key)) { + new Popup() + .feedback(Res.get("cloneOffer.success")) + .dontShowAgainId(key) + .show(); + } + spinnerInfoLabel.setText(""); + busyAnimation.stop(); + close(); + }); + }, + errorMessage -> { + UserThread.execute(() -> { + log.error(errorMessage); + spinnerInfoLabel.setText(""); + busyAnimation.stop(); + model.isNextButtonDisabled.setValue(false); + cancelButton.setDisable(false); + new Popup().warning(errorMessage).show(); + }); + }); + } + } + + /////////////////////////////////////////////////////////////////////////////////////////// + // Utils + /////////////////////////////////////////////////////////////////////////////////////////// + + private void updateElementsWithDirection() { + ImageView iconView = new ImageView(); + iconView.setId(model.isShownAsSellOffer() ? "image-sell-white" : "image-buy-white"); + cloneButton.setGraphic(iconView); + cloneButton.setId(model.isShownAsSellOffer() ? "sell-button-big" : "buy-button-big"); + } +} \ No newline at end of file diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/cloneoffer/CloneOfferViewModel.java b/desktop/src/main/java/haveno/desktop/main/portfolio/cloneoffer/CloneOfferViewModel.java new file mode 100644 index 0000000000..f36e18da79 --- /dev/null +++ b/desktop/src/main/java/haveno/desktop/main/portfolio/cloneoffer/CloneOfferViewModel.java @@ -0,0 +1,120 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package haveno.desktop.main.portfolio.cloneoffer; + +import haveno.desktop.Navigation; +import haveno.desktop.main.offer.MutableOfferViewModel; +import haveno.desktop.main.offer.OfferViewUtil; + +import haveno.core.account.witness.AccountAgeWitnessService; +import haveno.core.offer.OfferUtil; +import haveno.core.offer.OpenOffer; +import haveno.core.payment.validation.FiatVolumeValidator; +import haveno.core.payment.validation.SecurityDepositValidator; +import haveno.core.payment.validation.XmrValidator; +import haveno.core.provider.price.PriceFeedService; +import haveno.core.user.Preferences; +import haveno.core.util.FormattingUtils; +import haveno.core.util.PriceUtil; +import haveno.core.util.coin.CoinFormatter; +import haveno.core.util.validation.AmountValidator4Decimals; +import haveno.core.util.validation.AmountValidator8Decimals; +import haveno.common.handlers.ErrorMessageHandler; +import haveno.common.handlers.ResultHandler; + +import com.google.inject.Inject; +import com.google.inject.name.Named; + +class CloneOfferViewModel extends MutableOfferViewModel { + + @Inject + public CloneOfferViewModel(CloneOfferDataModel dataModel, + FiatVolumeValidator fiatVolumeValidator, + AmountValidator4Decimals priceValidator4Decimals, + AmountValidator8Decimals priceValidator8Decimals, + XmrValidator xmrValidator, + SecurityDepositValidator securityDepositValidator, + PriceFeedService priceFeedService, + AccountAgeWitnessService accountAgeWitnessService, + Navigation navigation, + Preferences preferences, + @Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter btcFormatter, + OfferUtil offerUtil) { + super(dataModel, + fiatVolumeValidator, + priceValidator4Decimals, + priceValidator8Decimals, + xmrValidator, + securityDepositValidator, + priceFeedService, + accountAgeWitnessService, + navigation, + preferences, + btcFormatter, + offerUtil); + syncMinAmountWithAmount = false; + } + + @Override + public void activate() { + super.activate(); + + dataModel.populateData(); + + long triggerPriceAsLong = dataModel.getTriggerPrice(); + dataModel.setTriggerPrice(triggerPriceAsLong); + if (triggerPriceAsLong > 0) { + triggerPrice.set(PriceUtil.formatMarketPrice(triggerPriceAsLong, dataModel.getCurrencyCode())); + } else { + triggerPrice.set(""); + } + onTriggerPriceTextFieldChanged(); + } + + public void applyOpenOffer(OpenOffer openOffer) { + dataModel.reset(); + dataModel.applyOpenOffer(openOffer); + } + + public void onCloneOffer(ResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) { + dataModel.onCloneOffer(resultHandler, errorMessageHandler); + } + + public void onInvalidateMarketPriceMargin() { + marketPriceMargin.set(FormattingUtils.formatToPercent(dataModel.getMarketPriceMarginPct())); + } + + public void onInvalidatePrice() { + price.set(FormattingUtils.formatPrice(null)); + price.set(FormattingUtils.formatPrice(dataModel.getPrice().get())); + } + + public boolean isSecurityDepositValid() { + return securityDepositValidator.validate(securityDeposit.get()).isValid; + } + + @Override + public void triggerFocusOutOnAmountFields() { + // do not update BTC Amount or minAmount here + // issue 2798: "after a few edits of offer the BTC amount has increased" + } + + public boolean isShownAsSellOffer() { + return OfferViewUtil.isShownAsSellOffer(getTradeCurrency(), dataModel.getDirection()); + } +} \ No newline at end of file diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/closedtrades/ClosedTradesView.java b/desktop/src/main/java/haveno/desktop/main/portfolio/closedtrades/ClosedTradesView.java index 3257d21059..ab92f845db 100644 --- a/desktop/src/main/java/haveno/desktop/main/portfolio/closedtrades/ClosedTradesView.java +++ b/desktop/src/main/java/haveno/desktop/main/portfolio/closedtrades/ClosedTradesView.java @@ -444,7 +444,7 @@ public class ClosedTradesView extends ActivatableViewAndModel onDuplicateOffer(item.getTradable().getOffer())); diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/duplicateoffer/DuplicateOfferDataModel.java b/desktop/src/main/java/haveno/desktop/main/portfolio/duplicateoffer/DuplicateOfferDataModel.java index 5828b94348..4db019c021 100644 --- a/desktop/src/main/java/haveno/desktop/main/portfolio/duplicateoffer/DuplicateOfferDataModel.java +++ b/desktop/src/main/java/haveno/desktop/main/portfolio/duplicateoffer/DuplicateOfferDataModel.java @@ -35,13 +35,10 @@ import haveno.core.user.Preferences; import haveno.core.user.User; import haveno.core.util.FormattingUtils; import haveno.core.util.coin.CoinFormatter; -import haveno.core.util.coin.CoinUtil; -import haveno.core.xmr.wallet.Restrictions; import haveno.core.xmr.wallet.XmrWalletService; import haveno.desktop.Navigation; import haveno.desktop.main.offer.MutableOfferDataModel; import haveno.network.p2p.P2PService; -import java.math.BigInteger; import java.util.Objects; import java.util.Optional; import java.util.Set; @@ -101,14 +98,6 @@ class DuplicateOfferDataModel extends MutableOfferDataModel { if (openOffer != null) setTriggerPrice(openOffer.getTriggerPrice()); } - private double getSecurityAsPercent(Offer offer) { - BigInteger offerSellerSecurityDeposit = getBoundedSecurityDeposit(offer.getMaxSellerSecurityDeposit()); - double offerSellerSecurityDepositAsPercent = CoinUtil.getAsPercentPerXmr(offerSellerSecurityDeposit, - offer.getAmount()); - return Math.min(offerSellerSecurityDepositAsPercent, - Restrictions.getMaxSecurityDepositAsPercent()); - } - @Override protected Set getUserPaymentAccounts() { return Objects.requireNonNull(user.getPaymentAccounts()).stream() diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/duplicateoffer/DuplicateOfferView.java b/desktop/src/main/java/haveno/desktop/main/portfolio/duplicateoffer/DuplicateOfferView.java index 33285e7c32..7cb24ca3eb 100644 --- a/desktop/src/main/java/haveno/desktop/main/portfolio/duplicateoffer/DuplicateOfferView.java +++ b/desktop/src/main/java/haveno/desktop/main/portfolio/duplicateoffer/DuplicateOfferView.java @@ -70,6 +70,7 @@ public class DuplicateOfferView extends MutableOfferView { + resultHandler.handleResult(); // process result before nullifying state openOffer = null; - UserThread.execute(() -> resultHandler.handleResult()); + editedOffer = null; }, (errorMsg) -> { - UserThread.execute(() -> errorMessageHandler.handleErrorMessage(errorMsg)); + errorMessageHandler.handleErrorMessage(errorMsg); }); } @@ -243,6 +243,15 @@ class EditOfferDataModel extends MutableOfferDataModel { }, errorMessageHandler); } + public boolean hasConflictingClone() { + Optional editedOpenOffer = openOfferManager.getOpenOffer(openOffer.getId()); + if (!editedOpenOffer.isPresent()) { + log.warn("Edited open offer is no longer present"); + return false; + } + return openOfferManager.hasConflictingClone(editedOpenOffer.get()); + } + @Override protected Set getUserPaymentAccounts() { throw new RuntimeException("Edit offer not supported with XMR"); diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/editoffer/EditOfferView.java b/desktop/src/main/java/haveno/desktop/main/portfolio/editoffer/EditOfferView.java index bc804b5576..8b1d9775e6 100644 --- a/desktop/src/main/java/haveno/desktop/main/portfolio/editoffer/EditOfferView.java +++ b/desktop/src/main/java/haveno/desktop/main/portfolio/editoffer/EditOfferView.java @@ -19,6 +19,8 @@ package haveno.desktop.main.portfolio.editoffer; import com.google.inject.Inject; import com.google.inject.name.Named; + +import haveno.common.UserThread; import haveno.common.util.Tuple4; import haveno.core.locale.CurrencyUtil; import haveno.core.locale.Res; @@ -140,6 +142,7 @@ public class EditOfferView extends MutableOfferView { initWithData(openOffer.getOffer().getDirection(), CurrencyUtil.getTradeCurrency(openOffer.getOffer().getCurrencyCode()).get(), + false, null); model.onStartEditOffer(errorMessage -> { @@ -208,23 +211,31 @@ public class EditOfferView extends MutableOfferView { // edit offer model.onPublishOffer(() -> { - String key = "editOfferSuccess"; - if (DontShowAgainLookup.showAgain(key)) { - new Popup() - .feedback(Res.get("editOffer.success")) - .dontShowAgainId(key) - .show(); + if (model.dataModel.hasConflictingClone()) { + new Popup().warning(Res.get("editOffer.hasConflictingClone")).show(); + } else { + String key = "editOfferSuccess"; + if (DontShowAgainLookup.showAgain(key)) { + new Popup() + .feedback(Res.get("editOffer.success")) + .dontShowAgainId(key) + .show(); + } } - spinnerInfoLabel.setText(""); - busyAnimation.stop(); - close(); + UserThread.execute(() -> { + spinnerInfoLabel.setText(""); + busyAnimation.stop(); + close(); + }); }, (message) -> { - log.error(message); - spinnerInfoLabel.setText(""); - busyAnimation.stop(); - model.isNextButtonDisabled.setValue(false); - cancelButton.setDisable(false); - new Popup().warning(Res.get("editOffer.failed", message)).show(); + UserThread.execute(() -> { + log.error(message); + spinnerInfoLabel.setText(""); + busyAnimation.stop(); + model.isNextButtonDisabled.setValue(false); + cancelButton.setDisable(false); + new Popup().warning(Res.get("editOffer.failed", message)).show(); + }); }); } }); diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/editoffer/EditOfferViewModel.java b/desktop/src/main/java/haveno/desktop/main/portfolio/editoffer/EditOfferViewModel.java index 34b78be683..53febf4dc5 100644 --- a/desktop/src/main/java/haveno/desktop/main/portfolio/editoffer/EditOfferViewModel.java +++ b/desktop/src/main/java/haveno/desktop/main/portfolio/editoffer/EditOfferViewModel.java @@ -44,7 +44,7 @@ class EditOfferViewModel extends MutableOfferViewModel { FiatVolumeValidator fiatVolumeValidator, AmountValidator4Decimals priceValidator4Decimals, AmountValidator8Decimals priceValidator8Decimals, - XmrValidator btcValidator, + XmrValidator xmrValidator, SecurityDepositValidator securityDepositValidator, PriceFeedService priceFeedService, AccountAgeWitnessService accountAgeWitnessService, @@ -56,7 +56,7 @@ class EditOfferViewModel extends MutableOfferViewModel { fiatVolumeValidator, priceValidator4Decimals, priceValidator8Decimals, - btcValidator, + xmrValidator, securityDepositValidator, priceFeedService, accountAgeWitnessService, diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/openoffer/OpenOfferListItem.java b/desktop/src/main/java/haveno/desktop/main/portfolio/openoffer/OpenOfferListItem.java index 67869a9ff9..cb437ace39 100644 --- a/desktop/src/main/java/haveno/desktop/main/portfolio/openoffer/OpenOfferListItem.java +++ b/desktop/src/main/java/haveno/desktop/main/portfolio/openoffer/OpenOfferListItem.java @@ -39,4 +39,8 @@ class OpenOfferListItem { public Offer getOffer() { return openOffer.getOffer(); } + + public String getGroupId() { + return openOffer.getGroupId(); + } } diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/openoffer/OpenOffersView.fxml b/desktop/src/main/java/haveno/desktop/main/portfolio/openoffer/OpenOffersView.fxml index 30da4d0c19..035ec5fbdc 100644 --- a/desktop/src/main/java/haveno/desktop/main/portfolio/openoffer/OpenOffersView.fxml +++ b/desktop/src/main/java/haveno/desktop/main/portfolio/openoffer/OpenOffersView.fxml @@ -42,7 +42,8 @@ - + + @@ -50,11 +51,13 @@ - + + + diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/openoffer/OpenOffersView.java b/desktop/src/main/java/haveno/desktop/main/portfolio/openoffer/OpenOffersView.java index 7c41eef890..3ad6a8cb06 100644 --- a/desktop/src/main/java/haveno/desktop/main/portfolio/openoffer/OpenOffersView.java +++ b/desktop/src/main/java/haveno/desktop/main/portfolio/openoffer/OpenOffersView.java @@ -22,8 +22,8 @@ import com.googlecode.jcsv.writer.CSVEntryConverter; import de.jensd.fx.glyphs.materialdesignicons.MaterialDesignIcon; import haveno.core.locale.Res; import haveno.core.offer.Offer; -import haveno.core.offer.OfferPayload; import haveno.core.offer.OpenOffer; +import haveno.core.offer.OpenOfferManager; import haveno.core.user.DontShowAgainLookup; import haveno.desktop.Navigation; import haveno.desktop.common.view.ActivatableViewAndModel; @@ -40,8 +40,9 @@ import haveno.desktop.main.funds.withdrawal.WithdrawalView; import haveno.desktop.main.overlays.popups.Popup; import haveno.desktop.main.overlays.windows.OfferDetailsWindow; import haveno.desktop.main.portfolio.PortfolioView; -import haveno.desktop.main.portfolio.duplicateoffer.DuplicateOfferView; +import haveno.desktop.main.portfolio.presentation.PortfolioUtil; import static haveno.desktop.util.FormBuilder.getRegularIconButton; +import haveno.desktop.util.FormBuilder; import haveno.desktop.util.GUIUtil; import java.util.Comparator; import java.util.HashMap; @@ -51,13 +52,11 @@ import java.util.stream.Collectors; import javafx.beans.binding.Bindings; import javafx.beans.property.ReadOnlyObjectWrapper; import javafx.beans.value.ChangeListener; -import javafx.collections.ObservableList; +import javafx.collections.ListChangeListener; import javafx.collections.transformation.FilteredList; import javafx.collections.transformation.SortedList; import javafx.fxml.FXML; import javafx.geometry.Insets; -import javafx.scene.Node; -import javafx.scene.Parent; import javafx.scene.control.Button; import javafx.scene.control.ContextMenu; import javafx.scene.control.Label; @@ -73,6 +72,7 @@ import javafx.scene.layout.Pane; import javafx.scene.layout.Priority; import javafx.scene.layout.Region; import javafx.scene.layout.VBox; +import javafx.scene.text.Text; import javafx.stage.Stage; import javafx.util.Callback; import org.jetbrains.annotations.NotNull; @@ -80,12 +80,39 @@ import org.jetbrains.annotations.NotNull; @FxmlView public class OpenOffersView extends ActivatableViewAndModel { + private enum ColumnNames { + OFFER_ID(Res.get("shared.offerId")), + GROUP_ID(Res.get("openOffer.header.groupId")), + DATE(Res.get("shared.dateTime")), + MARKET(Res.get("shared.market")), + PRICE(Res.get("shared.price")), + DEVIATION(Res.get("shared.deviation")), + TRIGGER_PRICE(Res.get("openOffer.header.triggerPrice")), + AMOUNT(Res.get("shared.XMRMinMax")), + VOLUME(Res.get("shared.amountMinMax")), + PAYMENT_METHOD(Res.get("shared.paymentMethod")), + DIRECTION(Res.get("shared.offerType")), + STATUS(Res.get("shared.state")); + + private final String text; + + ColumnNames(String text) { + this.text = text; + } + + @Override + public String toString() { + return text; + } + } + @FXML TableView tableView; @FXML TableColumn priceColumn, deviationColumn, amountColumn, volumeColumn, - marketColumn, directionColumn, dateColumn, offerIdColumn, deactivateItemColumn, - removeItemColumn, editItemColumn, triggerPriceColumn, triggerIconColumn, paymentMethodColumn; + marketColumn, directionColumn, dateColumn, offerIdColumn, deactivateItemColumn, groupIdColumn, + removeItemColumn, editItemColumn, triggerPriceColumn, triggerIconColumn, paymentMethodColumn, duplicateItemColumn, + cloneItemColumn; @FXML HBox searchBox; @FXML @@ -108,37 +135,48 @@ public class OpenOffersView extends ActivatableViewAndModel sortedList; private FilteredList filteredList; private ChangeListener filterTextFieldListener; - private PortfolioView.OpenOfferActionHandler openOfferActionHandler; + private final OpenOfferManager openOfferManager; + private PortfolioView.EditOpenOfferHandler editOpenOfferHandler; + private PortfolioView.CloneOpenOfferHandler cloneOpenOfferHandler; private ChangeListener widthListener; + private ListChangeListener sortedListChangedListener; private Map> offerStateChangeListeners = new HashMap>(); @Inject - public OpenOffersView(OpenOffersViewModel model, Navigation navigation, OfferDetailsWindow offerDetailsWindow) { + public OpenOffersView(OpenOffersViewModel model, + OpenOfferManager openOfferManager, + Navigation navigation, + OfferDetailsWindow offerDetailsWindow) { super(model); this.navigation = navigation; this.offerDetailsWindow = offerDetailsWindow; + this.openOfferManager = openOfferManager; } @Override public void initialize() { widthListener = (observable, oldValue, newValue) -> onWidthChange((double) newValue); - paymentMethodColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.paymentMethod"))); - priceColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.price"))); - deviationColumn.setGraphic(new AutoTooltipTableColumn<>(Res.get("shared.deviation"), + groupIdColumn.setGraphic(new AutoTooltipLabel(ColumnNames.GROUP_ID.toString())); + paymentMethodColumn.setGraphic(new AutoTooltipLabel(ColumnNames.PAYMENT_METHOD.toString())); + priceColumn.setGraphic(new AutoTooltipLabel(ColumnNames.PRICE.toString())); + deviationColumn.setGraphic(new AutoTooltipTableColumn<>(ColumnNames.DEVIATION.toString(), Res.get("portfolio.closedTrades.deviation.help")).getGraphic()); - amountColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.XMRMinMax"))); - volumeColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.amountMinMax"))); - marketColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.market"))); - directionColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.offerType"))); - dateColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.dateTime"))); - offerIdColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.offerId"))); - triggerPriceColumn.setGraphic(new AutoTooltipLabel(Res.get("openOffer.header.triggerPrice"))); - deactivateItemColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.enabled"))); + triggerPriceColumn.setGraphic(new AutoTooltipLabel(ColumnNames.TRIGGER_PRICE.toString())); + amountColumn.setGraphic(new AutoTooltipLabel(ColumnNames.AMOUNT.toString())); + volumeColumn.setGraphic(new AutoTooltipLabel(ColumnNames.VOLUME.toString())); + marketColumn.setGraphic(new AutoTooltipLabel(ColumnNames.MARKET.toString())); + directionColumn.setGraphic(new AutoTooltipLabel(ColumnNames.DIRECTION.toString())); + dateColumn.setGraphic(new AutoTooltipLabel(ColumnNames.DATE.toString())); + offerIdColumn.setGraphic(new AutoTooltipLabel(ColumnNames.OFFER_ID.toString())); + deactivateItemColumn.setGraphic(new AutoTooltipLabel(ColumnNames.STATUS.toString())); editItemColumn.setGraphic(new AutoTooltipLabel("")); + duplicateItemColumn.setText(""); + cloneItemColumn.setText(""); removeItemColumn.setGraphic(new AutoTooltipLabel("")); setOfferIdColumnCellFactory(); + setGroupIdCellFactory(); setDirectionColumnCellFactory(); setMarketColumnCellFactory(); setPriceColumnCellFactory(); @@ -151,12 +189,15 @@ public class OpenOffersView extends ActivatableViewAndModel o.getOffer().getId())); + groupIdColumn.setComparator(Comparator.comparing(o -> o.getOpenOffer().getReserveTxHash())); directionColumn.setComparator(Comparator.comparing(o -> o.getOffer().getDirection())); marketColumn.setComparator(Comparator.comparing(model::getMarketLabel)); amountColumn.setComparator(Comparator.comparing(o -> o.getOffer().getAmount())); @@ -168,23 +209,21 @@ public class OpenOffersView extends ActivatableViewAndModel o.getOffer().getDate())); paymentMethodColumn.setComparator(Comparator.comparing(o -> Res.get(o.getOffer().getPaymentMethod().getId()))); - dateColumn.setSortType(TableColumn.SortType.DESCENDING); + dateColumn.setSortType(TableColumn.SortType.ASCENDING); tableView.getSortOrder().add(dateColumn); tableView.setRowFactory( tableView -> { final TableRow row = new TableRow<>(); final ContextMenu rowMenu = new ContextMenu(); - MenuItem editItem = new MenuItem(Res.get("portfolio.context.offerLikeThis")); - editItem.setOnAction((event) -> { - try { - OfferPayload offerPayload = row.getItem().getOffer().getOfferPayload(); - navigation.navigateToWithData(offerPayload, MainView.class, PortfolioView.class, DuplicateOfferView.class); - } catch (NullPointerException e) { - log.warn("Unable to get offerPayload - {}", e.toString()); - } - }); - rowMenu.getItems().add(editItem); + + MenuItem duplicateOfferMenuItem = new MenuItem(Res.get("portfolio.context.offerLikeThis")); + duplicateOfferMenuItem.setOnAction((event) -> onDuplicateOffer(row.getItem())); + rowMenu.getItems().add(duplicateOfferMenuItem); + + MenuItem cloneOfferMenuItem = new MenuItem(Res.get("offerbook.cloneOffer")); + cloneOfferMenuItem.setOnAction((event) -> onCloneOffer(row.getItem())); + rowMenu.getItems().add(cloneOfferMenuItem); row.contextMenuProperty().bind( Bindings.when(Bindings.isNotNull(row.itemProperty())) .then(rowMenu) @@ -207,6 +246,15 @@ public class OpenOffersView extends ActivatableViewAndModel { + c.next(); + if (c.wasAdded() || c.wasRemoved()) { + updateNumberOfOffers(); + updateGroupIdColumnVisibility(); + updateTriggerColumnVisibility(); + } + }; } @Override @@ -214,8 +262,11 @@ public class OpenOffersView extends ActivatableViewAndModel(model.getList()); sortedList = new SortedList<>(filteredList); sortedList.comparatorProperty().bind(tableView.comparatorProperty()); + sortedList.addListener(sortedListChangedListener); tableView.setItems(sortedList); + updateGroupIdColumnVisibility(); + updateTriggerColumnVisibility(); updateSelectToggleButtonState(); selectToggleButton.setOnAction(event -> { @@ -231,37 +282,27 @@ public class OpenOffersView extends ActivatableViewAndModel { - ObservableList> tableColumns = tableView.getColumns(); - int reportColumns = tableColumns.size() - 3; // CSV report excludes the last columns (icons) CSVEntryConverter headerConverter = item -> { - String[] columns = new String[reportColumns]; - for (int i = 0; i < columns.length; i++) { - Node graphic = tableColumns.get(i).getGraphic(); - if (graphic instanceof AutoTooltipLabel) { - columns[i] = ((AutoTooltipLabel) graphic).getText(); - } else if (graphic instanceof HBox) { - // Deviation has a Hbox with AutoTooltipLabel as first child in header - columns[i] = ((AutoTooltipLabel) ((Parent) graphic).getChildrenUnmodifiable().get(0)).getText(); - } else { - // Not expected - columns[i] = "N/A"; - } + String[] columns = new String[ColumnNames.values().length]; + for (ColumnNames m : ColumnNames.values()) { + columns[m.ordinal()] = m.toString(); } return columns; }; CSVEntryConverter contentConverter = item -> { - String[] columns = new String[reportColumns]; - columns[0] = model.getOfferId(item); - columns[1] = model.getDate(item); - columns[2] = model.getMarketLabel(item); - columns[3] = model.getPrice(item); - columns[4] = model.getPriceDeviation(item); - columns[5] = model.getTriggerPrice(item); - columns[6] = model.getAmount(item); - columns[7] = model.getVolume(item); - columns[8] = model.getPaymentMethod(item); - columns[9] = model.getDirectionLabel(item); - columns[10] = String.valueOf(!item.getOpenOffer().isDeactivated()); + String[] columns = new String[ColumnNames.values().length]; + columns[ColumnNames.OFFER_ID.ordinal()] = model.getOfferId(item); + columns[ColumnNames.GROUP_ID.ordinal()] = openOfferManager.hasClonedOffer(item.getOffer().getId()) ? getShortenedGroupId(item.getGroupId()) : ""; + columns[ColumnNames.DATE.ordinal()] = model.getDate(item); + columns[ColumnNames.MARKET.ordinal()] = model.getMarketLabel(item); + columns[ColumnNames.PRICE.ordinal()] = model.getPrice(item); + columns[ColumnNames.DEVIATION.ordinal()] = model.getPriceDeviation(item); + columns[ColumnNames.TRIGGER_PRICE.ordinal()] = model.getTriggerPrice(item); + columns[ColumnNames.AMOUNT.ordinal()] = model.getAmount(item); + columns[ColumnNames.VOLUME.ordinal()] = model.getVolume(item); + columns[ColumnNames.PAYMENT_METHOD.ordinal()] = model.getPaymentMethod(item); + columns[ColumnNames.DIRECTION.ordinal()] = model.getDirectionLabel(item); + columns[ColumnNames.STATUS.ordinal()] = String.valueOf(!item.getOpenOffer().isDeactivated()); return columns; }; @@ -280,9 +321,24 @@ public class OpenOffersView extends ActivatableViewAndModel item.getOpenOffer().getTriggerPrice()) + .sum() > 0); + } + @Override protected void deactivate() { sortedList.comparatorProperty().unbind(); + sortedList.removeListener(sortedListChangedListener); exportButton.setOnAction(null); filterTextField.textProperty().removeListener(filterTextFieldListener); @@ -352,7 +408,7 @@ public class OpenOffersView extends ActivatableViewAndModel 1200); + triggerPriceColumn.setVisible(width > 1300); } private void onDeactivateOpenOffer(OpenOffer openOffer) { @@ -361,7 +417,7 @@ public class OpenOffersView extends ActivatableViewAndModel log.debug("Deactivate offer was successful"), (message) -> { log.error(message); - new Popup().warning(Res.get("offerbook.deactivateOffer.failed", message)).show(); + new Popup().warning(message).show(); }); updateSelectToggleButtonState(); } @@ -397,12 +453,18 @@ public class OpenOffersView extends ActivatableViewAndModel { log.debug("Remove offer was successful"); tableView.refresh(); + // We do not show the popup if it's a cloned offer with shared maker reserve tx + if (hasClonedOffer) { + return; + } + String key = "WithdrawFundsAfterRemoveOfferInfo"; if (DontShowAgainLookup.showAgain(key)) { new Popup().instruction(Res.get("offerbook.withdrawFundsHint", Res.get("navigation.funds.availableForWithdrawal"))) @@ -420,10 +482,46 @@ public class OpenOffersView extends ActivatableViewAndModel doCloneOffer(item)) + .show(); + } else { + doCloneOffer(item); + } + } + } + + private void doCloneOffer(OpenOfferListItem item) { + OpenOffer openOffer = item.getOpenOffer(); + if (openOffer == null || openOffer.getOffer() == null || openOffer.getOffer().getOfferPayload() == null) { + return; + } + cloneOpenOfferHandler.onCloneOpenOffer(openOffer); + } + private void setOfferIdColumnCellFactory() { offerIdColumn.setCellValueFactory((openOfferListItem) -> new ReadOnlyObjectWrapper<>(openOfferListItem.getValue())); offerIdColumn.getStyleClass().addAll("number-column", "first-column"); @@ -434,21 +532,28 @@ public class OpenOffersView extends ActivatableViewAndModel call(TableColumn column) { return new TableCell<>() { - private HyperlinkWithIcon field; + private HyperlinkWithIcon hyperlinkWithIcon; @Override public void updateItem(final OpenOfferListItem item, boolean empty) { super.updateItem(item, empty); - if (item != null && !empty) { - field = new HyperlinkWithIcon(model.getOfferId(item)); - field.setOnAction(event -> offerDetailsWindow.show(item.getOffer())); - field.setTooltip(new Tooltip(Res.get("tooltip.openPopupForDetails"))); - setGraphic(field); + hyperlinkWithIcon = new HyperlinkWithIcon(item.getOffer().getShortId()); + if (model.isDeactivated(item)) { + // getStyleClass().add("offer-disabled"); does not work with hyperlinkWithIcon;-( + hyperlinkWithIcon.setStyle("-fx-text-fill: -bs-color-gray-3;"); + hyperlinkWithIcon.getIcon().setOpacity(0.2); + } + hyperlinkWithIcon.setOnAction(event -> { + offerDetailsWindow.show(item.getOffer()); + }); + + hyperlinkWithIcon.setTooltip(new Tooltip(Res.get("tooltip.openPopupForDetails"))); + setGraphic(hyperlinkWithIcon); } else { setGraphic(null); - if (field != null) - field.setOnAction(null); + if (hyperlinkWithIcon != null) + hyperlinkWithIcon.setOnAction(null); } } }; @@ -456,6 +561,55 @@ public class OpenOffersView extends ActivatableViewAndModel new ReadOnlyObjectWrapper<>(offerListItem.getValue())); + groupIdColumn.setCellFactory( + new Callback<>() { + @Override + public TableCell call( + TableColumn column) { + + return new TableCell<>() { + @Override + public void updateItem(final OpenOfferListItem item, boolean empty) { + super.updateItem(item, empty); + getStyleClass().removeAll("offer-disabled"); + if (item != null) { + Label label; + Text icon; + if (openOfferManager.hasClonedOffer(item.getOpenOffer().getId())) { + label = new Label(getShortenedGroupId(item.getOpenOffer().getGroupId())); + icon = FormBuilder.getRegularIconForLabel(MaterialDesignIcon.LINK, label, "icon"); + icon.setVisible(true); + setTooltip(new Tooltip(Res.get("offerbook.clonedOffer.tooltip", item.getOpenOffer().getReserveTxHash()))); + } else { + label = new Label(""); + icon = FormBuilder.getRegularIconForLabel(MaterialDesignIcon.LINK_OFF, label, "icon"); + icon.setVisible(false); + setTooltip(new Tooltip(Res.get("offerbook.nonClonedOffer.tooltip", item.getOpenOffer().getReserveTxHash()))); + } + + if (model.isDeactivated(item)) { + getStyleClass().add("offer-disabled"); + icon.setOpacity(0.2); + } + setGraphic(label); + } else { + setGraphic(null); + } + } + }; + } + }); + } + + private String getShortenedGroupId(String groupId) { + if (groupId.length() > 5) { + return groupId.substring(0, 5); + } + return groupId; + } + private void setDateColumnCellFactory() { dateColumn.setCellValueFactory((openOfferListItem) -> new ReadOnlyObjectWrapper<>(openOfferListItem.getValue())); dateColumn.setCellFactory( @@ -779,6 +933,74 @@ public class OpenOffersView extends ActivatableViewAndModel new ReadOnlyObjectWrapper<>(offerListItem.getValue())); + duplicateItemColumn.setCellFactory( + new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + Button button; + + @Override + public void updateItem(final OpenOfferListItem item, boolean empty) { + super.updateItem(item, empty); + + if (item != null && !empty) { + if (button == null) { + button = getRegularIconButton(MaterialDesignIcon.CONTENT_COPY); + button.setTooltip(new Tooltip(Res.get("portfolio.context.offerLikeThis"))); + setGraphic(button); + } + button.setOnAction(event -> onDuplicateOffer(item)); + } else { + setGraphic(null); + if (button != null) { + button.setOnAction(null); + button = null; + } + } + } + }; + } + }); + } + + private void setCloneColumnCellFactory() { + cloneItemColumn.getStyleClass().add("avatar-column"); + cloneItemColumn.setCellValueFactory((offerListItem) -> new ReadOnlyObjectWrapper<>(offerListItem.getValue())); + cloneItemColumn.setCellFactory( + new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + Button button; + + @Override + public void updateItem(final OpenOfferListItem item, boolean empty) { + super.updateItem(item, empty); + + if (item != null && !empty) { + if (button == null) { + button = getRegularIconButton(MaterialDesignIcon.BOX_SHADOW); + button.setTooltip(new Tooltip(Res.get("offerbook.cloneOffer"))); + setGraphic(button); + } + button.setOnAction(event -> onCloneOffer(item)); + } else { + setGraphic(null); + if (button != null) { + button.setOnAction(null); + button = null; + } + } + } + }; + } + }); + } + private void setTriggerIconColumnCellFactory() { triggerIconColumn.setCellValueFactory((offerListItem) -> new ReadOnlyObjectWrapper<>(offerListItem.getValue())); triggerIconColumn.setCellFactory( @@ -854,8 +1076,12 @@ public class OpenOffersView extends ActivatableViewAndModel return item.getOffer().getShortId(); } + String getGroupId(OpenOfferListItem item) { + return item.getGroupId(); + } + String getAmount(OpenOfferListItem item) { return (item != null) ? DisplayUtils.formatAmount(item.getOffer(), btcFormatter) : ""; } diff --git a/desktop/src/main/java/haveno/desktop/util/FormBuilder.java b/desktop/src/main/java/haveno/desktop/util/FormBuilder.java index 7df49216a2..ae3e7ed266 100644 --- a/desktop/src/main/java/haveno/desktop/util/FormBuilder.java +++ b/desktop/src/main/java/haveno/desktop/util/FormBuilder.java @@ -2340,8 +2340,8 @@ public class FormBuilder { return getRegularIconForLabel(icon, label, null); } - public static Text getRegularIconForLabel(GlyphIcons icon, Label label, String style) { - return getIconForLabel(icon, "1.231em", label, style); + public static Text getRegularIconForLabel(GlyphIcons icon, Label label, String styleClass) { + return getIconForLabel(icon, "1.231em", label, styleClass); } public static Text getIcon(GlyphIcons icon) { diff --git a/desktop/src/test/java/haveno/desktop/main/offer/createoffer/CreateOfferDataModelTest.java b/desktop/src/test/java/haveno/desktop/main/offer/createoffer/CreateOfferDataModelTest.java index 33d43b7bc5..e9d39f47bf 100644 --- a/desktop/src/test/java/haveno/desktop/main/offer/createoffer/CreateOfferDataModelTest.java +++ b/desktop/src/test/java/haveno/desktop/main/offer/createoffer/CreateOfferDataModelTest.java @@ -88,7 +88,7 @@ public class CreateOfferDataModelTest { when(user.getPaymentAccounts()).thenReturn(paymentAccounts); when(preferences.getSelectedPaymentAccountForCreateOffer()).thenReturn(revolutAccount); - model.initWithData(OfferDirection.BUY, new TraditionalCurrency("USD")); + model.initWithData(OfferDirection.BUY, new TraditionalCurrency("USD"), true); assertEquals("USD", model.getTradeCurrencyCode().get()); } @@ -109,7 +109,7 @@ public class CreateOfferDataModelTest { when(user.findFirstPaymentAccountWithCurrency(new TraditionalCurrency("USD"))).thenReturn(zelleAccount); when(preferences.getSelectedPaymentAccountForCreateOffer()).thenReturn(revolutAccount); - model.initWithData(OfferDirection.BUY, new TraditionalCurrency("USD")); + model.initWithData(OfferDirection.BUY, new TraditionalCurrency("USD"), true); assertEquals("USD", model.getTradeCurrencyCode().get()); } } diff --git a/desktop/src/test/java/haveno/desktop/main/offer/createoffer/CreateOfferViewModelTest.java b/desktop/src/test/java/haveno/desktop/main/offer/createoffer/CreateOfferViewModelTest.java index a8c6ede578..19756fc523 100644 --- a/desktop/src/test/java/haveno/desktop/main/offer/createoffer/CreateOfferViewModelTest.java +++ b/desktop/src/test/java/haveno/desktop/main/offer/createoffer/CreateOfferViewModelTest.java @@ -117,7 +117,7 @@ public class CreateOfferViewModelTest { coinFormatter, tradeStats, null); - dataModel.initWithData(OfferDirection.BUY, new CryptoCurrency("XMR", "monero")); + dataModel.initWithData(OfferDirection.BUY, new CryptoCurrency("XMR", "monero"), true); dataModel.activate(); model = new CreateOfferViewModel(dataModel, diff --git a/proto/src/main/proto/grpc.proto b/proto/src/main/proto/grpc.proto index 75ad4a0658..b9615b5bcb 100644 --- a/proto/src/main/proto/grpc.proto +++ b/proto/src/main/proto/grpc.proto @@ -528,6 +528,7 @@ message PostOfferRequest { bool is_private_offer = 12; bool buyer_as_taker_without_deposit = 13; string extra_info = 14; + string source_offer_id = 15; } message PostOfferReply { diff --git a/proto/src/main/proto/pb.proto b/proto/src/main/proto/pb.proto index f919f4f1e7..5cdde1f0ce 100644 --- a/proto/src/main/proto/pb.proto +++ b/proto/src/main/proto/pb.proto @@ -1422,6 +1422,7 @@ message OpenOffer { string reserve_tx_key = 11; string challenge = 12; bool deactivated_by_trigger = 13; + string group_id = 14; } message Tradable {