implement batch key image polling for offer book in background

This commit is contained in:
woodser 2022-12-18 18:37:44 +00:00
parent 03b26fa423
commit 38864d71ff
8 changed files with 272 additions and 138 deletions

View file

@ -140,7 +140,6 @@ public final class CoreMoneroConnectionsService {
public void addListener(MoneroConnectionManagerListener listener) { public void addListener(MoneroConnectionManagerListener listener) {
synchronized (lock) { synchronized (lock) {
accountService.checkAccountOpen();
connectionManager.addListener(listener); connectionManager.addListener(listener);
} }
} }
@ -236,11 +235,14 @@ public final class CoreMoneroConnectionsService {
} }
} }
public boolean isConnectionLocal() {
return getConnection() != null && HavenoUtils.isLocalHost(getConnection().getUri());
}
public long getDefaultRefreshPeriodMs() { public long getDefaultRefreshPeriodMs() {
if (daemon == null) return REFRESH_PERIOD_LOCAL_MS; if (daemon == null) return REFRESH_PERIOD_LOCAL_MS;
else { else {
boolean isLocal = HavenoUtils.isLocalHost(daemon.getRpcConnection().getUri()); if (isConnectionLocal()) {
if (isLocal) {
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 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 return REFRESH_PERIOD_LOCAL_MS; // TODO: announce faster refresh after done syncing
} else { } else {

View file

@ -17,7 +17,6 @@
package bisq.core.api; package bisq.core.api;
import bisq.core.btc.wallet.XmrWalletService;
import bisq.core.monetary.Altcoin; import bisq.core.monetary.Altcoin;
import bisq.core.monetary.Price; import bisq.core.monetary.Price;
import bisq.core.offer.CreateOfferService; import bisq.core.offer.CreateOfferService;
@ -52,7 +51,6 @@ import java.util.function.Supplier;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import monero.daemon.model.MoneroKeyImageSpentStatus;
import static bisq.common.util.MathUtils.exactMultiply; import static bisq.common.util.MathUtils.exactMultiply;
import static bisq.common.util.MathUtils.roundDoubleToLong; import static bisq.common.util.MathUtils.roundDoubleToLong;
@ -81,7 +79,6 @@ public class CoreOffersService {
private final OfferFilterService offerFilter; private final OfferFilterService offerFilter;
private final OpenOfferManager openOfferManager; private final OpenOfferManager openOfferManager;
private final User user; private final User user;
private final XmrWalletService xmrWalletService;
@Inject @Inject
public CoreOffersService(CoreContext coreContext, public CoreOffersService(CoreContext coreContext,
@ -92,8 +89,7 @@ public class CoreOffersService {
OfferFilterService offerFilter, OfferFilterService offerFilter,
OpenOfferManager openOfferManager, OpenOfferManager openOfferManager,
OfferUtil offerUtil, OfferUtil offerUtil,
User user, User user) {
XmrWalletService xmrWalletService) {
this.coreContext = coreContext; this.coreContext = coreContext;
this.keyRing = keyRing; this.keyRing = keyRing;
this.coreWalletsService = coreWalletsService; this.coreWalletsService = coreWalletsService;
@ -102,116 +98,66 @@ public class CoreOffersService {
this.offerFilter = offerFilter; this.offerFilter = offerFilter;
this.openOfferManager = openOfferManager; this.openOfferManager = openOfferManager;
this.user = user; this.user = user;
this.xmrWalletService = xmrWalletService;
} }
Offer getOffer(String id) { // excludes my offers
return new ArrayList<>(offerBookService.getOffers()).stream() List<Offer> getOffers() {
.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<Offer> getOffers(String direction, String currencyCode) {
List<Offer> offers = new ArrayList<>(offerBookService.getOffers()).stream() List<Offer> offers = new ArrayList<>(offerBookService.getOffers()).stream()
.filter(o -> !o.isMyOffer(keyRing)) .filter(o -> !o.isMyOffer(keyRing))
.filter(o -> offerMatchesDirectionAndCurrency(o, direction, currencyCode))
.filter(o -> { .filter(o -> {
Result result = offerFilter.canTakeOffer(o, coreContext.isApiUser()); Result result = offerFilter.canTakeOffer(o, coreContext.isApiUser());
return result.isValid() || result == Result.HAS_NO_PAYMENT_ACCOUNT_VALID_FOR_OFFER; return result.isValid() || result == Result.HAS_NO_PAYMENT_ACCOUNT_VALID_FOR_OFFER;
}) })
.sorted(priceComparator(direction))
.collect(Collectors.toList()); .collect(Collectors.toList());
offers.removeAll(getUnreservedOffers(offers)); offers.removeAll(getOffersWithDuplicateKeyImages(offers));
return offers; return offers;
} }
List<Offer> getMyOffers(String direction, String currencyCode) { List<Offer> getOffers(String direction, String currencyCode) {
return getOffers().stream()
// get my open offers
List<Offer> offers = new ArrayList<>(openOfferManager.getObservableList()).stream()
.map(OpenOffer::getOffer)
.filter(o -> o.isMyOffer(keyRing))
.filter(o -> offerMatchesDirectionAndCurrency(o, direction, currencyCode)) .filter(o -> offerMatchesDirectionAndCurrency(o, direction, currencyCode))
.sorted(priceComparator(direction)) .sorted(priceComparator(direction))
.collect(Collectors.toList()); .collect(Collectors.toList());
// remove unreserved offers
Set<Offer> unreservedOffers = getUnreservedOffers(offers); // TODO (woodser): optimize performance, probably don't call here
offers.removeAll(unreservedOffers);
// remove my unreserved offers from offer manager
List<OpenOffer> unreservedOpenOffers = new ArrayList<OpenOffer>();
for (Offer unreservedOffer : unreservedOffers) {
unreservedOpenOffers.add(openOfferManager.getOpenOfferById(unreservedOffer.getId()).get());
} }
openOfferManager.removeOpenOffers(unreservedOpenOffers, null);
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<Offer> getMyOffers() {
List<Offer> offers = new ArrayList<>(openOfferManager.getObservableList()).stream()
.map(OpenOffer::getOffer)
.filter(o -> o.isMyOffer(keyRing))
.collect(Collectors.toList());
offers.removeAll(getOffersWithDuplicateKeyImages(offers));
return offers; return offers;
};
List<Offer> getMyOffers(String direction, String currencyCode) {
return getMyOffers().stream()
.filter(o -> offerMatchesDirectionAndCurrency(o, direction, currencyCode))
.sorted(priceComparator(direction))
.collect(Collectors.toList());
} }
private Set<Offer> getUnreservedOffers(List<Offer> offers) { Offer getMyOffer(String id) {
Set<Offer> unreservedOffers = new HashSet<Offer>(); return getMyOffers().stream()
.filter(o -> o.getId().equals(id))
// collect reserved key images and check for duplicate funds .findAny().orElseThrow(() ->
List<String> allKeyImages = new ArrayList<String>(); new IllegalStateException(format("offer with id '%s' not found", id)));
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<String> spentKeyImages = new ArrayList<String>();
List<MoneroKeyImageSpentStatus> spentStatuses = allKeyImages.isEmpty() ? new ArrayList<MoneroKeyImageSpentStatus>() : 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;
} }
OpenOffer getMyOpenOffer(String id) { OpenOffer getMyOpenOffer(String id) {
getMyOffer(id); // ensure offer is valid
return openOfferManager.getOpenOfferById(id) return openOfferManager.getOpenOfferById(id)
.filter(open -> open.getOffer().isMyOffer(keyRing)) .filter(open -> open.getOffer().isMyOffer(keyRing))
.orElseThrow(() -> .orElseThrow(() ->
new IllegalStateException(format("openoffer with id '%s' not found", id))); new IllegalStateException(format("openoffer with id '%s' not found", id)));
} }
// Create and place new offer.
void postOffer(String currencyCode, void postOffer(String currencyCode,
String directionAsString, String directionAsString,
String priceAsString, String priceAsString,
@ -262,7 +208,6 @@ public class CoreOffersService {
errorMessageHandler); errorMessageHandler);
} }
// Edit a placed offer.
Offer editOffer(String offerId, Offer editOffer(String offerId,
String currencyCode, String currencyCode,
OfferDirection direction, OfferDirection direction,
@ -297,6 +242,27 @@ public class CoreOffersService {
}); });
} }
// -------------------------- PRIVATE HELPERS -----------------------------
private Set<Offer> getOffersWithDuplicateKeyImages(List<Offer> offers) {
Set<Offer> duplicateFundedOffers = new HashSet<Offer>();
Set<String> seenKeyImages = new HashSet<String>();
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) { private void verifyPaymentAccountIsValidForNewOffer(Offer offer, PaymentAccount paymentAccount) {
if (!isPaymentAccountValidForOffer(offer, paymentAccount)) { if (!isPaymentAccountValidForOffer(offer, paymentAccount)) {
String error = format("cannot create %s offer with payment account %s", String error = format("cannot create %s offer with payment account %s",

View file

@ -9,6 +9,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import lombok.extern.slf4j.Slf4j;
import monero.common.MoneroError; import monero.common.MoneroError;
import monero.common.TaskLooper; import monero.common.TaskLooper;
import monero.daemon.MoneroDaemon; import monero.daemon.MoneroDaemon;
@ -17,6 +18,7 @@ import monero.daemon.model.MoneroKeyImageSpentStatus;
/** /**
* Poll for changes to the spent status of key images. * Poll for changes to the spent status of key images.
*/ */
@Slf4j
public class MoneroKeyImagePoller { public class MoneroKeyImagePoller {
private MoneroDaemon daemon; private MoneroDaemon daemon;
@ -25,6 +27,17 @@ public class MoneroKeyImagePoller {
private Set<MoneroKeyImageListener> listeners = new HashSet<MoneroKeyImageListener>(); private Set<MoneroKeyImageListener> listeners = new HashSet<MoneroKeyImageListener>();
private TaskLooper looper; private TaskLooper looper;
private Map<String, MoneroKeyImageSpentStatus> lastStatuses = new HashMap<String, MoneroKeyImageSpentStatus>(); private Map<String, MoneroKeyImageSpentStatus> lastStatuses = new HashMap<String, MoneroKeyImageSpentStatus>();
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. * Construct the listener.
@ -111,10 +124,9 @@ public class MoneroKeyImagePoller {
* @return the key images to listen to * @return the key images to listen to
*/ */
public void setKeyImages(String... keyImages) { public void setKeyImages(String... keyImages) {
synchronized (keyImages) { synchronized (this.keyImages) {
this.keyImages.clear(); this.keyImages.clear();
this.keyImages.addAll(Arrays.asList(keyImages)); addKeyImages(keyImages);
refreshPolling();
} }
} }
@ -124,10 +136,7 @@ public class MoneroKeyImagePoller {
* @param keyImage - the key image to listen to * @param keyImage - the key image to listen to
*/ */
public void addKeyImage(String keyImage) { public void addKeyImage(String keyImage) {
synchronized (keyImages) {
addKeyImages(keyImage); addKeyImages(keyImage);
refreshPolling();
}
} }
/** /**
@ -136,7 +145,16 @@ public class MoneroKeyImagePoller {
* @param keyImages - key images to listen to * @param keyImages - key images to listen to
*/ */
public void addKeyImages(String... keyImages) { 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<String> keyImages) {
synchronized (this.keyImages) {
for (String keyImage : keyImages) if (!this.keyImages.contains(keyImage)) this.keyImages.add(keyImage); for (String keyImage : keyImages) if (!this.keyImages.contains(keyImage)) this.keyImages.add(keyImage);
refreshPolling(); refreshPolling();
} }
@ -148,10 +166,7 @@ public class MoneroKeyImagePoller {
* @param keyImage - the key image to unlisten to * @param keyImage - the key image to unlisten to
*/ */
public void removeKeyImage(String keyImage) { public void removeKeyImage(String keyImage) {
synchronized (keyImages) {
removeKeyImages(keyImage); removeKeyImages(keyImage);
refreshPolling();
}
} }
/** /**
@ -160,14 +175,42 @@ public class MoneroKeyImagePoller {
* @param keyImages - key images to unlisten to * @param keyImages - key images to unlisten to
*/ */
public void removeKeyImages(String... keyImages) { public void removeKeyImages(String... keyImages) {
synchronized (keyImages) { removeKeyImages(Arrays.asList(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));
} }
/**
* Remove key images to listen to.
*
* @param keyImages - key images to unlisten to
*/
public void removeKeyImages(Collection<String> keyImages) {
synchronized (this.keyImages) {
Set<String> containedKeyImages = new HashSet<String>(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() { public void poll() {
synchronized (keyImages) { synchronized (keyImages) {
if (daemon == null) {
log.warn("Cannot poll key images because daemon is null");
return;
}
try {
// fetch spent statuses // fetch spent statuses
List<MoneroKeyImageSpentStatus> spentStatuses = keyImages.isEmpty() ? new ArrayList<MoneroKeyImageSpentStatus>() : daemon.getKeyImageSpentStatuses(keyImages); List<MoneroKeyImageSpentStatus> spentStatuses = keyImages.isEmpty() ? new ArrayList<MoneroKeyImageSpentStatus>() : daemon.getKeyImageSpentStatuses(keyImages);
@ -182,16 +225,31 @@ public class MoneroKeyImagePoller {
} }
// announce changes // announce changes
for (MoneroKeyImageListener listener : new ArrayList<MoneroKeyImageListener>(listeners)) listener.onSpentStatusChanged(changedStatuses); if (!changedStatuses.isEmpty()) {
for (MoneroKeyImageListener listener : new ArrayList<MoneroKeyImageListener>(listeners)) {
listener.onSpentStatusChanged(changedStatuses);
}
}
} catch (Exception e) {
log.warn("Error polling key images: " + e.getMessage());
e.printStackTrace();
}
} }
} }
private void refreshPolling() { private void refreshPolling() {
setIsPolling(listeners.size() > 0); setIsPolling(keyImages.size() > 0 && listeners.size() > 0);
} }
private void setIsPolling(boolean isPolling) { private void setIsPolling(boolean enabled) {
if (isPolling) looper.start(refreshPeriodMs); if (enabled) {
else looper.stop(); if (!isPolling) {
isPolling = true; // TODO monero-java: looper.isPolling()
looper.start(refreshPeriodMs);
}
} else {
isPolling = false;
looper.stop();
}
} }
} }

View file

@ -112,6 +112,11 @@ public class Offer implements NetworkPayload, PersistablePayload {
@JsonExclude @JsonExclude
transient private String currencyCode; transient private String currencyCode;
@JsonExclude
@Getter
@Setter
transient private boolean isReservedFundsSpent;
/////////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////////
// Constructor // Constructor

View file

@ -17,6 +17,9 @@
package bisq.core.offer; 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.filter.FilterManager;
import bisq.core.locale.Res; import bisq.core.locale.Res;
import bisq.core.provider.price.PriceFeedService; import bisq.core.provider.price.PriceFeedService;
@ -25,13 +28,15 @@ import bisq.network.p2p.BootstrapListener;
import bisq.network.p2p.P2PService; import bisq.network.p2p.P2PService;
import bisq.network.p2p.storage.HashMapChangedListener; import bisq.network.p2p.storage.HashMapChangedListener;
import bisq.network.p2p.storage.payload.ProtectedStorageEntry; 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.UserThread;
import bisq.common.config.Config; import bisq.common.config.Config;
import bisq.common.file.JsonFileManager; import bisq.common.file.JsonFileManager;
import bisq.common.handlers.ErrorMessageHandler; import bisq.common.handlers.ErrorMessageHandler;
import bisq.common.handlers.ResultHandler; import bisq.common.handlers.ResultHandler;
import bisq.common.util.Utilities;
import javax.inject.Inject; import javax.inject.Inject;
import javax.inject.Named; import javax.inject.Named;
@ -41,6 +46,7 @@ import java.io.File;
import java.util.Collection; import java.util.Collection;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -56,18 +62,22 @@ import javax.annotation.Nullable;
public class OfferBookService { public class OfferBookService {
private static final Logger log = LoggerFactory.getLogger(OfferBookService.class); 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 P2PService p2PService;
private final PriceFeedService priceFeedService; private final PriceFeedService priceFeedService;
private final List<OfferBookChangedListener> offerBookChangedListeners = new LinkedList<>(); private final List<OfferBookChangedListener> offerBookChangedListeners = new LinkedList<>();
private final FilterManager filterManager; private final FilterManager filterManager;
private final JsonFileManager jsonFileManager; 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 // Constructor
@ -77,21 +87,36 @@ public class OfferBookService {
public OfferBookService(P2PService p2PService, public OfferBookService(P2PService p2PService,
PriceFeedService priceFeedService, PriceFeedService priceFeedService,
FilterManager filterManager, FilterManager filterManager,
CoreMoneroConnectionsService connectionsService,
@Named(Config.STORAGE_DIR) File storageDir, @Named(Config.STORAGE_DIR) File storageDir,
@Named(Config.DUMP_STATISTICS) boolean dumpStatistics) { @Named(Config.DUMP_STATISTICS) boolean dumpStatistics) {
this.p2PService = p2PService; this.p2PService = p2PService;
this.priceFeedService = priceFeedService; this.priceFeedService = priceFeedService;
this.filterManager = filterManager; this.filterManager = filterManager;
this.connectionsService = connectionsService;
jsonFileManager = new JsonFileManager(storageDir); 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() { p2PService.addHashSetChangedListener(new HashMapChangedListener() {
@Override @Override
public void onAdded(Collection<ProtectedStorageEntry> protectedStorageEntries) { public void onAdded(Collection<ProtectedStorageEntry> protectedStorageEntries) {
protectedStorageEntries.forEach(protectedStorageEntry -> offerBookChangedListeners.forEach(listener -> { protectedStorageEntries.forEach(protectedStorageEntry -> offerBookChangedListeners.forEach(listener -> {
if (protectedStorageEntry.getProtectedStoragePayload() instanceof OfferPayload) { if (protectedStorageEntry.getProtectedStoragePayload() instanceof OfferPayload) {
maybeInitializeKeyImagePoller();
OfferPayload offerPayload = (OfferPayload) protectedStorageEntry.getProtectedStoragePayload(); OfferPayload offerPayload = (OfferPayload) protectedStorageEntry.getProtectedStoragePayload();
keyImagePoller.addKeyImages(offerPayload.getReserveTxKeyImages());
Offer offer = new Offer(offerPayload); Offer offer = new Offer(offerPayload);
offer.setPriceFeedService(priceFeedService); offer.setPriceFeedService(priceFeedService);
setReservedFundsSpent(offer);
listener.onAdded(offer); listener.onAdded(offer);
} }
})); }));
@ -101,9 +126,12 @@ public class OfferBookService {
public void onRemoved(Collection<ProtectedStorageEntry> protectedStorageEntries) { public void onRemoved(Collection<ProtectedStorageEntry> protectedStorageEntries) {
protectedStorageEntries.forEach(protectedStorageEntry -> offerBookChangedListeners.forEach(listener -> { protectedStorageEntries.forEach(protectedStorageEntry -> offerBookChangedListeners.forEach(listener -> {
if (protectedStorageEntry.getProtectedStoragePayload() instanceof OfferPayload) { if (protectedStorageEntry.getProtectedStoragePayload() instanceof OfferPayload) {
maybeInitializeKeyImagePoller();
OfferPayload offerPayload = (OfferPayload) protectedStorageEntry.getProtectedStoragePayload(); OfferPayload offerPayload = (OfferPayload) protectedStorageEntry.getProtectedStoragePayload();
keyImagePoller.removeKeyImages(offerPayload.getReserveTxKeyImages());
Offer offer = new Offer(offerPayload); Offer offer = new Offer(offerPayload);
offer.setPriceFeedService(priceFeedService); offer.setPriceFeedService(priceFeedService);
setReservedFundsSpent(offer);
listener.onRemoved(offer); listener.onRemoved(offer);
} }
})); }));
@ -197,6 +225,7 @@ public class OfferBookService {
OfferPayload offerPayload = (OfferPayload) data.getProtectedStoragePayload(); OfferPayload offerPayload = (OfferPayload) data.getProtectedStoragePayload();
Offer offer = new Offer(offerPayload); Offer offer = new Offer(offerPayload);
offer.setPriceFeedService(priceFeedService); offer.setPriceFeedService(priceFeedService);
setReservedFundsSpent(offer);
return offer; return offer;
}) })
.collect(Collectors.toList()); .collect(Collectors.toList());
@ -225,6 +254,52 @@ public class OfferBookService {
// Private // 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<String, MoneroKeyImageSpentStatus> 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() { private void doDumpStatistics() {
// We filter the case that it is a MarketBasedPrice but the price is not available // 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 // That should only be possible if the price feed provider is not available

View file

@ -83,7 +83,8 @@ public class OfferFilterService {
IS_INSUFFICIENT_COUNTERPARTY_TRADE_LIMIT, IS_INSUFFICIENT_COUNTERPARTY_TRADE_LIMIT,
IS_MY_INSUFFICIENT_TRADE_LIMIT, IS_MY_INSUFFICIENT_TRADE_LIMIT,
ARBITRATOR_NOT_VALIDATED, ARBITRATOR_NOT_VALIDATED,
SIGNATURE_NOT_VALIDATED; SIGNATURE_NOT_VALIDATED,
RESERVE_FUNDS_SPENT;
@Getter @Getter
private final boolean isValid; private final boolean isValid;
@ -134,6 +135,9 @@ public class OfferFilterService {
if (!hasValidSignature(offer)) { if (!hasValidSignature(offer)) {
return Result.SIGNATURE_NOT_VALIDATED; return Result.SIGNATURE_NOT_VALIDATED;
} }
if (isReservedFundsSpent(offer)) {
return Result.RESERVE_FUNDS_SPENT;
}
if (!isAnyPaymentAccountValidForOffer(offer)) { if (!isAnyPaymentAccountValidForOffer(offer)) {
return Result.HAS_NO_PAYMENT_ACCOUNT_VALID_FOR_OFFER; return Result.HAS_NO_PAYMENT_ACCOUNT_VALID_FOR_OFFER;
} }
@ -226,4 +230,8 @@ public class OfferFilterService {
if (arbitrator == null) return false; // invalid arbitrator if (arbitrator == null) return false; // invalid arbitrator
return HavenoUtils.isArbitratorSignatureValid(offer, arbitrator); return HavenoUtils.isArbitratorSignatureValid(offer, arbitrator);
} }
public boolean isReservedFundsSpent(Offer offer) {
return offer.isReservedFundsSpent();
}
} }

View file

@ -25,6 +25,7 @@ import bisq.core.btc.wallet.TradeWalletService;
import bisq.core.btc.wallet.XmrWalletService; import bisq.core.btc.wallet.XmrWalletService;
import bisq.core.exceptions.TradePriceOutOfToleranceException; import bisq.core.exceptions.TradePriceOutOfToleranceException;
import bisq.core.filter.FilterManager; import bisq.core.filter.FilterManager;
import bisq.core.offer.OfferBookService.OfferBookChangedListener;
import bisq.core.offer.messages.OfferAvailabilityRequest; import bisq.core.offer.messages.OfferAvailabilityRequest;
import bisq.core.offer.messages.OfferAvailabilityResponse; import bisq.core.offer.messages.OfferAvailabilityResponse;
import bisq.core.offer.messages.SignOfferRequest; 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.persistenceManager.initialize(openOffers, "OpenOffers", PersistenceManager.Source.PRIVATE);
this.signedOfferPersistenceManager.initialize(signedOffers, "SignedOffers", PersistenceManager.Source.PRIVATE); // arbitrator stores reserve tx for signed offers 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<OpenOffer> openOfferOptional = getOpenOfferById(offer.getId());
if (openOfferOptional.isPresent() && offer.isReservedFundsSpent()) {
removeOpenOffer(openOfferOptional.get(), null);
}
}
@Override
public void onRemoved(Offer offer) {
// nothing to do
}
});
} }
@Override @Override
@ -301,6 +317,10 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
removeOpenOffers(getObservableList(), completeHandler); removeOpenOffers(getObservableList(), completeHandler);
} }
public void removeOpenOffer(OpenOffer openOffer, @Nullable Runnable completeHandler) {
removeOpenOffers(List.of(openOffer), completeHandler);
}
public void removeOpenOffers(List<OpenOffer> openOffers, @Nullable Runnable completeHandler) { public void removeOpenOffers(List<OpenOffer> openOffers, @Nullable Runnable completeHandler) {
int size = openOffers.size(); int size = openOffers.size();
// Copy list as we remove in the loop // Copy list as we remove in the loop

View file

@ -1065,8 +1065,8 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi
return; return;
} }
// delete trade wallet // delete trade wallet if exists
trade.deleteWallet(); if (xmrWalletService.multisigWalletExists(trade.getId())) trade.deleteWallet();
// unreserve key images // unreserve key images
if (trade instanceof TakerTrade && trade.getSelf().getReserveTxKeyImages() != null) { if (trade instanceof TakerTrade && trade.getSelf().getReserveTxKeyImages() != null) {