mirror of
https://github.com/haveno-dex/haveno.git
synced 2024-12-23 03:59:36 +00:00
implement batch key image polling for offer book in background
This commit is contained in:
parent
03b26fa423
commit
38864d71ff
8 changed files with 272 additions and 138 deletions
|
@ -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 {
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
Loading…
Reference in a new issue