From 38864d71ff90e23f1876b43f59edc85f286ab158 Mon Sep 17 00:00:00 2001 From: woodser Date: Sun, 18 Dec 2022 18:37:44 +0000 Subject: [PATCH] implement batch key image polling for offer book in background --- .../api/CoreMoneroConnectionsService.java | 8 +- .../java/bisq/core/api/CoreOffersService.java | 150 +++++++----------- .../core/btc/wallet/MoneroKeyImagePoller.java | 122 ++++++++++---- core/src/main/java/bisq/core/offer/Offer.java | 5 + .../bisq/core/offer/OfferBookService.java | 91 ++++++++++- .../bisq/core/offer/OfferFilterService.java | 10 +- .../bisq/core/offer/OpenOfferManager.java | 20 +++ .../java/bisq/core/trade/TradeManager.java | 4 +- 8 files changed, 272 insertions(+), 138 deletions(-) diff --git a/core/src/main/java/bisq/core/api/CoreMoneroConnectionsService.java b/core/src/main/java/bisq/core/api/CoreMoneroConnectionsService.java index 3423238f..65ec788f 100644 --- a/core/src/main/java/bisq/core/api/CoreMoneroConnectionsService.java +++ b/core/src/main/java/bisq/core/api/CoreMoneroConnectionsService.java @@ -140,7 +140,6 @@ public final class CoreMoneroConnectionsService { public void addListener(MoneroConnectionManagerListener listener) { synchronized (lock) { - accountService.checkAccountOpen(); connectionManager.addListener(listener); } } @@ -236,11 +235,14 @@ public final class CoreMoneroConnectionsService { } } + public boolean isConnectionLocal() { + return getConnection() != null && HavenoUtils.isLocalHost(getConnection().getUri()); + } + public long getDefaultRefreshPeriodMs() { if (daemon == null) return REFRESH_PERIOD_LOCAL_MS; else { - boolean isLocal = HavenoUtils.isLocalHost(daemon.getRpcConnection().getUri()); - if (isLocal) { + if (isConnectionLocal()) { if (lastInfo != null && (lastInfo.isBusySyncing() || (lastInfo.getHeightWithoutBootstrap() != null && lastInfo.getHeightWithoutBootstrap() > 0 && lastInfo.getHeightWithoutBootstrap() < lastInfo.getHeight()))) return REFRESH_PERIOD_REMOTE_MS; // refresh slower if syncing or bootstrapped else return REFRESH_PERIOD_LOCAL_MS; // TODO: announce faster refresh after done syncing } else { diff --git a/core/src/main/java/bisq/core/api/CoreOffersService.java b/core/src/main/java/bisq/core/api/CoreOffersService.java index d42b427c..30763dfe 100644 --- a/core/src/main/java/bisq/core/api/CoreOffersService.java +++ b/core/src/main/java/bisq/core/api/CoreOffersService.java @@ -17,7 +17,6 @@ package bisq.core.api; -import bisq.core.btc.wallet.XmrWalletService; import bisq.core.monetary.Altcoin; import bisq.core.monetary.Price; import bisq.core.offer.CreateOfferService; @@ -52,7 +51,6 @@ import java.util.function.Supplier; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; -import monero.daemon.model.MoneroKeyImageSpentStatus; import static bisq.common.util.MathUtils.exactMultiply; import static bisq.common.util.MathUtils.roundDoubleToLong; @@ -81,8 +79,7 @@ public class CoreOffersService { private final OfferFilterService offerFilter; private final OpenOfferManager openOfferManager; private final User user; - private final XmrWalletService xmrWalletService; - + @Inject public CoreOffersService(CoreContext coreContext, KeyRing keyRing, @@ -92,8 +89,7 @@ public class CoreOffersService { OfferFilterService offerFilter, OpenOfferManager openOfferManager, OfferUtil offerUtil, - User user, - XmrWalletService xmrWalletService) { + User user) { this.coreContext = coreContext; this.keyRing = keyRing; this.coreWalletsService = coreWalletsService; @@ -102,116 +98,66 @@ public class CoreOffersService { this.offerFilter = offerFilter; this.openOfferManager = openOfferManager; this.user = user; - this.xmrWalletService = xmrWalletService; } - Offer getOffer(String id) { - return new ArrayList<>(offerBookService.getOffers()).stream() - .filter(o -> o.getId().equals(id)) - .filter(o -> !o.isMyOffer(keyRing)) - .filter(o -> { - Result result = offerFilter.canTakeOffer(o, coreContext.isApiUser()); - boolean valid = result.isValid() || result == Result.HAS_NO_PAYMENT_ACCOUNT_VALID_FOR_OFFER; - if (!valid) log.warn("Cannot take offer " + o.getId() + " with invalid state : " + result); - return valid; - }) - .findAny().orElseThrow(() -> - new IllegalStateException(format("offer with id '%s' not found", id))); - } - - Offer getMyOffer(String id) { - return new ArrayList<>(openOfferManager.getObservableList()).stream() - .map(OpenOffer::getOffer) - .filter(o -> o.getId().equals(id)) - .filter(o -> o.isMyOffer(keyRing)) - .findAny().orElseThrow(() -> - new IllegalStateException(format("offer with id '%s' not found", id))); - } - - List getOffers(String direction, String currencyCode) { + // excludes my offers + List getOffers() { List offers = new ArrayList<>(offerBookService.getOffers()).stream() .filter(o -> !o.isMyOffer(keyRing)) - .filter(o -> offerMatchesDirectionAndCurrency(o, direction, currencyCode)) .filter(o -> { Result result = offerFilter.canTakeOffer(o, coreContext.isApiUser()); return result.isValid() || result == Result.HAS_NO_PAYMENT_ACCOUNT_VALID_FOR_OFFER; }) - .sorted(priceComparator(direction)) .collect(Collectors.toList()); - offers.removeAll(getUnreservedOffers(offers)); + offers.removeAll(getOffersWithDuplicateKeyImages(offers)); return offers; } - List getMyOffers(String direction, String currencyCode) { - - // get my open offers - List offers = new ArrayList<>(openOfferManager.getObservableList()).stream() - .map(OpenOffer::getOffer) - .filter(o -> o.isMyOffer(keyRing)) + List getOffers(String direction, String currencyCode) { + return getOffers().stream() .filter(o -> offerMatchesDirectionAndCurrency(o, direction, currencyCode)) .sorted(priceComparator(direction)) .collect(Collectors.toList()); - - // remove unreserved offers - Set unreservedOffers = getUnreservedOffers(offers); // TODO (woodser): optimize performance, probably don't call here - offers.removeAll(unreservedOffers); - - // remove my unreserved offers from offer manager - List unreservedOpenOffers = new ArrayList(); - for (Offer unreservedOffer : unreservedOffers) { - unreservedOpenOffers.add(openOfferManager.getOpenOfferById(unreservedOffer.getId()).get()); - } - openOfferManager.removeOpenOffers(unreservedOpenOffers, null); - - return offers; } - - private Set getUnreservedOffers(List offers) { - Set unreservedOffers = new HashSet(); - - // collect reserved key images and check for duplicate funds - List allKeyImages = new ArrayList(); - for (Offer offer : offers) { - if (offer.getOfferPayload().getReserveTxKeyImages() == null) continue; - for (String keyImage : offer.getOfferPayload().getReserveTxKeyImages()) { - if (!allKeyImages.add(keyImage)) { - log.warn("Key image {} belongs to another offer, removing offer {}", keyImage, offer.getId()); // TODO (woodser): this is list, not set, so not checking for duplicates - unreservedOffers.add(offer); - } - } - } - - // get spent key images - // TODO (woodser): paginate offers and only check key images of current page - List spentKeyImages = new ArrayList(); - List spentStatuses = allKeyImages.isEmpty() ? new ArrayList() : xmrWalletService.getDaemon().getKeyImageSpentStatuses(allKeyImages); - for (int i = 0; i < spentStatuses.size(); i++) { - if (spentStatuses.get(i) != MoneroKeyImageSpentStatus.NOT_SPENT) spentKeyImages.add(allKeyImages.get(i)); - } - - // check for offers with spent key images - for (Offer offer : offers) { - if (offer.getOfferPayload().getReserveTxKeyImages() == null) continue; - if (unreservedOffers.contains(offer)) continue; - for (String keyImage : offer.getOfferPayload().getReserveTxKeyImages()) { - if (spentKeyImages.contains(keyImage)) { - log.warn("Offer {} reserved funds have already been spent with key image {}", offer.getId(), keyImage); - unreservedOffers.add(offer); - } - } - } - - return unreservedOffers; + + Offer getOffer(String id) { + return getOffers().stream() + .filter(o -> o.getId().equals(id)) + .findAny().orElseThrow(() -> + new IllegalStateException(format("offer with id '%s' not found", id))); + } + + List getMyOffers() { + List offers = new ArrayList<>(openOfferManager.getObservableList()).stream() + .map(OpenOffer::getOffer) + .filter(o -> o.isMyOffer(keyRing)) + .collect(Collectors.toList()); + offers.removeAll(getOffersWithDuplicateKeyImages(offers)); + return offers; + }; + + List getMyOffers(String direction, String currencyCode) { + return getMyOffers().stream() + .filter(o -> offerMatchesDirectionAndCurrency(o, direction, currencyCode)) + .sorted(priceComparator(direction)) + .collect(Collectors.toList()); + } + + Offer getMyOffer(String id) { + return getMyOffers().stream() + .filter(o -> o.getId().equals(id)) + .findAny().orElseThrow(() -> + new IllegalStateException(format("offer with id '%s' not found", id))); } OpenOffer getMyOpenOffer(String id) { + getMyOffer(id); // ensure offer is valid return openOfferManager.getOpenOfferById(id) .filter(open -> open.getOffer().isMyOffer(keyRing)) .orElseThrow(() -> new IllegalStateException(format("openoffer with id '%s' not found", id))); } - // Create and place new offer. void postOffer(String currencyCode, String directionAsString, String priceAsString, @@ -262,7 +208,6 @@ public class CoreOffersService { errorMessageHandler); } - // Edit a placed offer. Offer editOffer(String offerId, String currencyCode, OfferDirection direction, @@ -297,6 +242,27 @@ 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 (offer2.getOfferPayload().getReserveTxKeyImages().contains(keyImage)) { + log.warn("Key image {} belongs to multiple offers, removing offer {}", keyImage, 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", diff --git a/core/src/main/java/bisq/core/btc/wallet/MoneroKeyImagePoller.java b/core/src/main/java/bisq/core/btc/wallet/MoneroKeyImagePoller.java index a7b7f7de..5cc8221e 100644 --- a/core/src/main/java/bisq/core/btc/wallet/MoneroKeyImagePoller.java +++ b/core/src/main/java/bisq/core/btc/wallet/MoneroKeyImagePoller.java @@ -9,6 +9,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import lombok.extern.slf4j.Slf4j; import monero.common.MoneroError; import monero.common.TaskLooper; import monero.daemon.MoneroDaemon; @@ -17,6 +18,7 @@ import monero.daemon.model.MoneroKeyImageSpentStatus; /** * Poll for changes to the spent status of key images. */ +@Slf4j public class MoneroKeyImagePoller { private MoneroDaemon daemon; @@ -25,6 +27,17 @@ public class MoneroKeyImagePoller { private Set listeners = new HashSet(); private TaskLooper looper; private Map lastStatuses = new HashMap(); + private boolean isPolling = false; + + /** + * Construct the listener. + * + * @param refreshPeriodMs - refresh period in milliseconds + * @param keyImages - key images to listen to + */ + public MoneroKeyImagePoller() { + looper = new TaskLooper(() -> poll()); + } /** * Construct the listener. @@ -111,10 +124,9 @@ public class MoneroKeyImagePoller { * @return the key images to listen to */ public void setKeyImages(String... keyImages) { - synchronized (keyImages) { + synchronized (this.keyImages) { this.keyImages.clear(); - this.keyImages.addAll(Arrays.asList(keyImages)); - refreshPolling(); + addKeyImages(keyImages); } } @@ -124,10 +136,7 @@ public class MoneroKeyImagePoller { * @param keyImage - the key image to listen to */ public void addKeyImage(String keyImage) { - synchronized (keyImages) { - addKeyImages(keyImage); - refreshPolling(); - } + addKeyImages(keyImage); } /** @@ -136,7 +145,16 @@ public class MoneroKeyImagePoller { * @param keyImages - key images to listen to */ public void addKeyImages(String... keyImages) { - synchronized (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); refreshPolling(); } @@ -148,10 +166,7 @@ public class MoneroKeyImagePoller { * @param keyImage - the key image to unlisten to */ public void removeKeyImage(String keyImage) { - synchronized (keyImages) { - removeKeyImages(keyImage); - refreshPolling(); - } + removeKeyImages(keyImage); } /** @@ -160,38 +175,81 @@ public class MoneroKeyImagePoller { * @param keyImages - key images to unlisten to */ public void removeKeyImages(String... keyImages) { - synchronized (keyImages) { - for (String keyImage : keyImages) if (!this.keyImages.contains(keyImage)) throw new MoneroError("Key image not registered with poller: " + keyImage); - this.keyImages.removeAll(Arrays.asList(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); + for (String lastKeyImage : new HashSet<>(lastStatuses.keySet())) lastStatuses.remove(lastKeyImage); + refreshPolling(); } } + /** + * Indicates if the given key image is spent. + * + * @param keyImage - the key image to check + * @return true if the key is spent, false if unspent, null if unknown + */ + public Boolean isSpent(String keyImage) { + if (!lastStatuses.containsKey(keyImage)) return null; + return lastStatuses.get(keyImage) != MoneroKeyImageSpentStatus.NOT_SPENT; + } + public void poll() { synchronized (keyImages) { - - // fetch spent statuses - List spentStatuses = keyImages.isEmpty() ? new ArrayList() : daemon.getKeyImageSpentStatuses(keyImages); - - // collect changed statuses - Map changedStatuses = new HashMap(); - for (int i = 0; i < keyImages.size(); i++) { - if (lastStatuses.get(keyImages.get(i)) != spentStatuses.get(i)) { - lastStatuses.put(keyImages.get(i), spentStatuses.get(i)); - changedStatuses.put(keyImages.get(i), spentStatuses.get(i)); - } + if (daemon == null) { + log.warn("Cannot poll key images because daemon is null"); + return; } + try { + + // fetch spent statuses + List spentStatuses = keyImages.isEmpty() ? new ArrayList() : daemon.getKeyImageSpentStatuses(keyImages); - // announce changes - for (MoneroKeyImageListener listener : new ArrayList(listeners)) listener.onSpentStatusChanged(changedStatuses); + // collect changed statuses + Map changedStatuses = new HashMap(); + for (int i = 0; i < keyImages.size(); i++) { + if (lastStatuses.get(keyImages.get(i)) != spentStatuses.get(i)) { + lastStatuses.put(keyImages.get(i), spentStatuses.get(i)); + changedStatuses.put(keyImages.get(i), spentStatuses.get(i)); + } + } + + // announce changes + if (!changedStatuses.isEmpty()) { + for (MoneroKeyImageListener listener : new ArrayList(listeners)) { + listener.onSpentStatusChanged(changedStatuses); + } + } + } catch (Exception e) { + log.warn("Error polling key images: " + e.getMessage()); + e.printStackTrace(); + } } } private void refreshPolling() { - setIsPolling(listeners.size() > 0); + setIsPolling(keyImages.size() > 0 && listeners.size() > 0); } - private void setIsPolling(boolean isPolling) { - if (isPolling) looper.start(refreshPeriodMs); - else looper.stop(); + private void setIsPolling(boolean enabled) { + if (enabled) { + if (!isPolling) { + isPolling = true; // TODO monero-java: looper.isPolling() + looper.start(refreshPeriodMs); + } + } else { + isPolling = false; + looper.stop(); + } } } diff --git a/core/src/main/java/bisq/core/offer/Offer.java b/core/src/main/java/bisq/core/offer/Offer.java index ab90866a..6077a9a0 100644 --- a/core/src/main/java/bisq/core/offer/Offer.java +++ b/core/src/main/java/bisq/core/offer/Offer.java @@ -112,6 +112,11 @@ public class Offer implements NetworkPayload, PersistablePayload { @JsonExclude transient private String currencyCode; + @JsonExclude + @Getter + @Setter + transient private boolean isReservedFundsSpent; + /////////////////////////////////////////////////////////////////////////////////////////// // Constructor diff --git a/core/src/main/java/bisq/core/offer/OfferBookService.java b/core/src/main/java/bisq/core/offer/OfferBookService.java index 82f12c77..ac0c190d 100644 --- a/core/src/main/java/bisq/core/offer/OfferBookService.java +++ b/core/src/main/java/bisq/core/offer/OfferBookService.java @@ -17,6 +17,9 @@ package bisq.core.offer; +import bisq.core.api.CoreMoneroConnectionsService; +import bisq.core.btc.wallet.MoneroKeyImageListener; +import bisq.core.btc.wallet.MoneroKeyImagePoller; import bisq.core.filter.FilterManager; import bisq.core.locale.Res; import bisq.core.provider.price.PriceFeedService; @@ -25,13 +28,15 @@ import bisq.network.p2p.BootstrapListener; import bisq.network.p2p.P2PService; import bisq.network.p2p.storage.HashMapChangedListener; import bisq.network.p2p.storage.payload.ProtectedStorageEntry; - +import common.utils.GenUtils; +import monero.common.MoneroConnectionManagerListener; +import monero.common.MoneroRpcConnection; +import monero.daemon.model.MoneroKeyImageSpentStatus; import bisq.common.UserThread; import bisq.common.config.Config; import bisq.common.file.JsonFileManager; import bisq.common.handlers.ErrorMessageHandler; import bisq.common.handlers.ResultHandler; -import bisq.common.util.Utilities; import javax.inject.Inject; import javax.inject.Named; @@ -41,6 +46,7 @@ import java.io.File; import java.util.Collection; import java.util.LinkedList; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.stream.Collectors; @@ -56,18 +62,22 @@ import javax.annotation.Nullable; public class OfferBookService { private static final Logger log = LoggerFactory.getLogger(OfferBookService.class); - public interface OfferBookChangedListener { - void onAdded(Offer offer); - - void onRemoved(Offer offer); - } - private final P2PService p2PService; private final PriceFeedService priceFeedService; private final List offerBookChangedListeners = new LinkedList<>(); private final FilterManager filterManager; private final JsonFileManager jsonFileManager; + private final CoreMoneroConnectionsService connectionsService; + private MoneroKeyImagePoller 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 + + public interface OfferBookChangedListener { + void onAdded(Offer offer); + + void onRemoved(Offer offer); + } /////////////////////////////////////////////////////////////////////////////////////////// // Constructor @@ -77,21 +87,36 @@ public class OfferBookService { public OfferBookService(P2PService p2PService, PriceFeedService priceFeedService, FilterManager filterManager, + CoreMoneroConnectionsService connectionsService, @Named(Config.STORAGE_DIR) File storageDir, @Named(Config.DUMP_STATISTICS) boolean dumpStatistics) { this.p2PService = p2PService; this.priceFeedService = priceFeedService; this.filterManager = filterManager; + this.connectionsService = connectionsService; jsonFileManager = new JsonFileManager(storageDir); + // listen for monero connection changes + connectionsService.addListener(new MoneroConnectionManagerListener() { + @Override + public void onConnectionChanged(MoneroRpcConnection connection) { + keyImagePoller.setDaemon(connectionsService.getDaemon()); + keyImagePoller.setRefreshPeriodMs(getKeyImageRefreshPeriodMs()); + } + }); + + // listen for offers p2PService.addHashSetChangedListener(new HashMapChangedListener() { @Override public void onAdded(Collection protectedStorageEntries) { protectedStorageEntries.forEach(protectedStorageEntry -> offerBookChangedListeners.forEach(listener -> { if (protectedStorageEntry.getProtectedStoragePayload() instanceof OfferPayload) { + maybeInitializeKeyImagePoller(); OfferPayload offerPayload = (OfferPayload) protectedStorageEntry.getProtectedStoragePayload(); + keyImagePoller.addKeyImages(offerPayload.getReserveTxKeyImages()); Offer offer = new Offer(offerPayload); offer.setPriceFeedService(priceFeedService); + setReservedFundsSpent(offer); listener.onAdded(offer); } })); @@ -101,9 +126,12 @@ public class OfferBookService { public void onRemoved(Collection protectedStorageEntries) { protectedStorageEntries.forEach(protectedStorageEntry -> offerBookChangedListeners.forEach(listener -> { if (protectedStorageEntry.getProtectedStoragePayload() instanceof OfferPayload) { + maybeInitializeKeyImagePoller(); OfferPayload offerPayload = (OfferPayload) protectedStorageEntry.getProtectedStoragePayload(); + keyImagePoller.removeKeyImages(offerPayload.getReserveTxKeyImages()); Offer offer = new Offer(offerPayload); offer.setPriceFeedService(priceFeedService); + setReservedFundsSpent(offer); listener.onRemoved(offer); } })); @@ -197,6 +225,7 @@ public class OfferBookService { OfferPayload offerPayload = (OfferPayload) data.getProtectedStoragePayload(); Offer offer = new Offer(offerPayload); offer.setPriceFeedService(priceFeedService); + setReservedFundsSpent(offer); return offer; }) .collect(Collectors.toList()); @@ -225,6 +254,52 @@ public class OfferBookService { // Private /////////////////////////////////////////////////////////////////////////////////////////// + private void maybeInitializeKeyImagePoller() { + synchronized (this) { + if (keyImagePoller != null) return; + keyImagePoller = new MoneroKeyImagePoller(connectionsService.getDaemon(), getKeyImageRefreshPeriodMs()); + keyImagePoller.addListener(new MoneroKeyImageListener() { + @Override + public void onSpentStatusChanged(Map spentStatuses) { + for (String keyImage : spentStatuses.keySet()) { + updateAffectedOffers(keyImage); + } + } + }); + + // first poll after 5s + new Thread(() -> { + GenUtils.waitFor(5000); + keyImagePoller.poll(); + }); + } + } + + private long getKeyImageRefreshPeriodMs() { + return connectionsService.isConnectionLocal() ? KEY_IMAGE_REFRESH_PERIOD_MS_LOCAL : KEY_IMAGE_REFRESH_PERIOD_MS_REMOTE; + } + + private void updateAffectedOffers(String keyImage) { + for (Offer offer : getOffers()) { + if (offer.getOfferPayload().getReserveTxKeyImages().contains(keyImage)) { + offerBookChangedListeners.forEach(listener -> { + listener.onRemoved(offer); + listener.onAdded(offer); + }); + } + } + } + + private void setReservedFundsSpent(Offer offer) { + if (keyImagePoller == null) return; + for (String keyImage : offer.getOfferPayload().getReserveTxKeyImages()) { + if (Boolean.TRUE.equals(keyImagePoller.isSpent(keyImage))) { + log.warn("Reserved funds spent for offer {}", offer.getId()); + offer.setReservedFundsSpent(true); + } + } + } + private void doDumpStatistics() { // We filter the case that it is a MarketBasedPrice but the price is not available // That should only be possible if the price feed provider is not available diff --git a/core/src/main/java/bisq/core/offer/OfferFilterService.java b/core/src/main/java/bisq/core/offer/OfferFilterService.java index a4fa2a68..d4161946 100644 --- a/core/src/main/java/bisq/core/offer/OfferFilterService.java +++ b/core/src/main/java/bisq/core/offer/OfferFilterService.java @@ -83,7 +83,8 @@ public class OfferFilterService { IS_INSUFFICIENT_COUNTERPARTY_TRADE_LIMIT, IS_MY_INSUFFICIENT_TRADE_LIMIT, ARBITRATOR_NOT_VALIDATED, - SIGNATURE_NOT_VALIDATED; + SIGNATURE_NOT_VALIDATED, + RESERVE_FUNDS_SPENT; @Getter private final boolean isValid; @@ -134,6 +135,9 @@ public class OfferFilterService { if (!hasValidSignature(offer)) { return Result.SIGNATURE_NOT_VALIDATED; } + if (isReservedFundsSpent(offer)) { + return Result.RESERVE_FUNDS_SPENT; + } if (!isAnyPaymentAccountValidForOffer(offer)) { return Result.HAS_NO_PAYMENT_ACCOUNT_VALID_FOR_OFFER; } @@ -226,4 +230,8 @@ public class OfferFilterService { if (arbitrator == null) return false; // invalid arbitrator return HavenoUtils.isArbitratorSignatureValid(offer, arbitrator); } + + public boolean isReservedFundsSpent(Offer offer) { + return offer.isReservedFundsSpent(); + } } diff --git a/core/src/main/java/bisq/core/offer/OpenOfferManager.java b/core/src/main/java/bisq/core/offer/OpenOfferManager.java index 07de48dc..72fb56e8 100644 --- a/core/src/main/java/bisq/core/offer/OpenOfferManager.java +++ b/core/src/main/java/bisq/core/offer/OpenOfferManager.java @@ -25,6 +25,7 @@ import bisq.core.btc.wallet.TradeWalletService; import bisq.core.btc.wallet.XmrWalletService; import bisq.core.exceptions.TradePriceOutOfToleranceException; import bisq.core.filter.FilterManager; +import bisq.core.offer.OfferBookService.OfferBookChangedListener; import bisq.core.offer.messages.OfferAvailabilityRequest; import bisq.core.offer.messages.OfferAvailabilityResponse; import bisq.core.offer.messages.SignOfferRequest; @@ -189,6 +190,21 @@ 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 + + // remove open offer if reserved funds spent + offerBookService.addOfferBookChangedListener(new OfferBookChangedListener() { + @Override + public void onAdded(Offer offer) { + Optional openOfferOptional = getOpenOfferById(offer.getId()); + if (openOfferOptional.isPresent() && offer.isReservedFundsSpent()) { + removeOpenOffer(openOfferOptional.get(), null); + } + } + @Override + public void onRemoved(Offer offer) { + // nothing to do + } + }); } @Override @@ -301,6 +317,10 @@ 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) { int size = openOffers.size(); // Copy list as we remove in the loop diff --git a/core/src/main/java/bisq/core/trade/TradeManager.java b/core/src/main/java/bisq/core/trade/TradeManager.java index ce5f2df9..56fbf7d3 100644 --- a/core/src/main/java/bisq/core/trade/TradeManager.java +++ b/core/src/main/java/bisq/core/trade/TradeManager.java @@ -1065,8 +1065,8 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi return; } - // delete trade wallet - trade.deleteWallet(); + // delete trade wallet if exists + if (xmrWalletService.multisigWalletExists(trade.getId())) trade.deleteWallet(); // unreserve key images if (trade instanceof TakerTrade && trade.getSelf().getReserveTxKeyImages() != null) {