implement batch key image polling for offer book in background
This commit is contained in:
parent
03b26fa423
commit
38864d71ff
@ -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 {
|
||||
|
@ -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<Offer> getOffers(String direction, String currencyCode) {
|
||||
// excludes my offers
|
||||
List<Offer> getOffers() {
|
||||
List<Offer> 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<Offer> getMyOffers(String direction, String currencyCode) {
|
||||
|
||||
// get my open offers
|
||||
List<Offer> offers = new ArrayList<>(openOfferManager.getObservableList()).stream()
|
||||
.map(OpenOffer::getOffer)
|
||||
.filter(o -> o.isMyOffer(keyRing))
|
||||
List<Offer> getOffers(String direction, String currencyCode) {
|
||||
return getOffers().stream()
|
||||
.filter(o -> offerMatchesDirectionAndCurrency(o, direction, currencyCode))
|
||||
.sorted(priceComparator(direction))
|
||||
.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);
|
||||
|
||||
return offers;
|
||||
}
|
||||
|
||||
private Set<Offer> getUnreservedOffers(List<Offer> offers) {
|
||||
Set<Offer> unreservedOffers = new HashSet<Offer>();
|
||||
|
||||
// collect reserved key images and check for duplicate funds
|
||||
List<String> allKeyImages = new ArrayList<String>();
|
||||
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;
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
List<Offer> 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<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) {
|
||||
if (!isPaymentAccountValidForOffer(offer, paymentAccount)) {
|
||||
String error = format("cannot create %s offer with payment account %s",
|
||||
|
@ -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<MoneroKeyImageListener> listeners = new HashSet<MoneroKeyImageListener>();
|
||||
private TaskLooper looper;
|
||||
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.
|
||||
@ -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<String> 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<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() {
|
||||
synchronized (keyImages) {
|
||||
|
||||
// fetch spent statuses
|
||||
List<MoneroKeyImageSpentStatus> spentStatuses = keyImages.isEmpty() ? new ArrayList<MoneroKeyImageSpentStatus>() : daemon.getKeyImageSpentStatuses(keyImages);
|
||||
|
||||
// collect changed statuses
|
||||
Map<String, MoneroKeyImageSpentStatus> changedStatuses = new HashMap<String, MoneroKeyImageSpentStatus>();
|
||||
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<MoneroKeyImageSpentStatus> spentStatuses = keyImages.isEmpty() ? new ArrayList<MoneroKeyImageSpentStatus>() : daemon.getKeyImageSpentStatuses(keyImages);
|
||||
|
||||
// announce changes
|
||||
for (MoneroKeyImageListener listener : new ArrayList<MoneroKeyImageListener>(listeners)) listener.onSpentStatusChanged(changedStatuses);
|
||||
// collect changed statuses
|
||||
Map<String, MoneroKeyImageSpentStatus> changedStatuses = new HashMap<String, MoneroKeyImageSpentStatus>();
|
||||
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<MoneroKeyImageListener>(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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -112,6 +112,11 @@ public class Offer implements NetworkPayload, PersistablePayload {
|
||||
@JsonExclude
|
||||
transient private String currencyCode;
|
||||
|
||||
@JsonExclude
|
||||
@Getter
|
||||
@Setter
|
||||
transient private boolean isReservedFundsSpent;
|
||||
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Constructor
|
||||
|
@ -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<OfferBookChangedListener> 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<ProtectedStorageEntry> 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<ProtectedStorageEntry> 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<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() {
|
||||
// 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
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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<OpenOffer> 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<OpenOffer> openOffers, @Nullable Runnable completeHandler) {
|
||||
int size = openOffers.size();
|
||||
// Copy list as we remove in the loop
|
||||
|
@ -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) {
|
||||
|
Loading…
Reference in New Issue
Block a user