mirror of
https://github.com/haveno-dex/haveno.git
synced 2025-07-26 00:15:18 -04:00
support cloning up to 10 offers with shared reserved funds (#1668)
This commit is contained in:
parent
7e3a47de4a
commit
40e18890d6
55 changed files with 2006 additions and 611 deletions
|
@ -413,21 +413,22 @@ public class CoreApi {
|
|||
}
|
||||
|
||||
public void postOffer(String currencyCode,
|
||||
String directionAsString,
|
||||
String priceAsString,
|
||||
boolean useMarketBasedPrice,
|
||||
double marketPriceMargin,
|
||||
long amountAsLong,
|
||||
long minAmountAsLong,
|
||||
double securityDepositPct,
|
||||
String triggerPriceAsString,
|
||||
boolean reserveExactAmount,
|
||||
String paymentAccountId,
|
||||
boolean isPrivateOffer,
|
||||
boolean buyerAsTakerWithoutDeposit,
|
||||
String extraInfo,
|
||||
Consumer<Offer> resultHandler,
|
||||
ErrorMessageHandler errorMessageHandler) {
|
||||
String directionAsString,
|
||||
String priceAsString,
|
||||
boolean useMarketBasedPrice,
|
||||
double marketPriceMargin,
|
||||
long amountAsLong,
|
||||
long minAmountAsLong,
|
||||
double securityDepositPct,
|
||||
String triggerPriceAsString,
|
||||
boolean reserveExactAmount,
|
||||
String paymentAccountId,
|
||||
boolean isPrivateOffer,
|
||||
boolean buyerAsTakerWithoutDeposit,
|
||||
String extraInfo,
|
||||
String sourceOfferId,
|
||||
Consumer<Offer> resultHandler,
|
||||
ErrorMessageHandler errorMessageHandler) {
|
||||
coreOffersService.postOffer(currencyCode,
|
||||
directionAsString,
|
||||
priceAsString,
|
||||
|
@ -442,6 +443,7 @@ public class CoreApi {
|
|||
isPrivateOffer,
|
||||
buyerAsTakerWithoutDeposit,
|
||||
extraInfo,
|
||||
sourceOfferId,
|
||||
resultHandler,
|
||||
errorMessageHandler);
|
||||
}
|
||||
|
|
|
@ -43,6 +43,7 @@ import static haveno.common.util.MathUtils.exactMultiply;
|
|||
import static haveno.common.util.MathUtils.roundDoubleToLong;
|
||||
import static haveno.common.util.MathUtils.scaleUpByPowerOf10;
|
||||
import haveno.core.locale.CurrencyUtil;
|
||||
import haveno.core.locale.Res;
|
||||
import haveno.core.monetary.CryptoMoney;
|
||||
import haveno.core.monetary.Price;
|
||||
import haveno.core.monetary.TraditionalMoney;
|
||||
|
@ -66,9 +67,7 @@ import java.math.BigInteger;
|
|||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import static java.util.Comparator.comparing;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
|
@ -124,7 +123,6 @@ public class CoreOffersService {
|
|||
return result.isValid() || result == Result.HAS_NO_PAYMENT_ACCOUNT_VALID_FOR_OFFER;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
offers.removeAll(getOffersWithDuplicateKeyImages(offers));
|
||||
return offers;
|
||||
}
|
||||
|
||||
|
@ -143,12 +141,9 @@ public class CoreOffersService {
|
|||
}
|
||||
|
||||
List<OpenOffer> getMyOffers() {
|
||||
List<OpenOffer> offers = openOfferManager.getOpenOffers().stream()
|
||||
return openOfferManager.getOpenOffers().stream()
|
||||
.filter(o -> o.getOffer().isMyOffer(keyRing))
|
||||
.collect(Collectors.toList());
|
||||
Set<Offer> offersWithDuplicateKeyImages = getOffersWithDuplicateKeyImages(offers.stream().map(OpenOffer::getOffer).collect(Collectors.toList())); // TODO: this is hacky way of filtering offers with duplicate key images
|
||||
Set<String> offerIdsWithDuplicateKeyImages = offersWithDuplicateKeyImages.stream().map(Offer::getId).collect(Collectors.toSet());
|
||||
return offers.stream().filter(o -> !offerIdsWithDuplicateKeyImages.contains(o.getId())).collect(Collectors.toList());
|
||||
};
|
||||
|
||||
List<OpenOffer> getMyOffers(String direction, String currencyCode) {
|
||||
|
@ -179,15 +174,31 @@ public class CoreOffersService {
|
|||
boolean isPrivateOffer,
|
||||
boolean buyerAsTakerWithoutDeposit,
|
||||
String extraInfo,
|
||||
String sourceOfferId,
|
||||
Consumer<Offer> resultHandler,
|
||||
ErrorMessageHandler errorMessageHandler) {
|
||||
coreWalletsService.verifyWalletsAreAvailable();
|
||||
coreWalletsService.verifyEncryptedWalletIsUnlocked();
|
||||
|
||||
PaymentAccount paymentAccount = user.getPaymentAccount(paymentAccountId);
|
||||
if (paymentAccount == null)
|
||||
throw new IllegalArgumentException(format("payment account with id %s not found", paymentAccountId));
|
||||
if (paymentAccount == null) throw new IllegalArgumentException(format("payment account with id %s not found", paymentAccountId));
|
||||
|
||||
// clone offer if sourceOfferId given
|
||||
if (!sourceOfferId.isEmpty()) {
|
||||
cloneOffer(sourceOfferId,
|
||||
currencyCode,
|
||||
priceAsString,
|
||||
useMarketBasedPrice,
|
||||
marketPriceMargin,
|
||||
triggerPriceAsString,
|
||||
paymentAccountId,
|
||||
extraInfo,
|
||||
resultHandler,
|
||||
errorMessageHandler);
|
||||
return;
|
||||
}
|
||||
|
||||
// create new offer
|
||||
String upperCaseCurrencyCode = currencyCode.toUpperCase();
|
||||
String offerId = createOfferService.getRandomOfferId();
|
||||
OfferDirection direction = OfferDirection.valueOf(directionAsString.toUpperCase());
|
||||
|
@ -210,17 +221,70 @@ public class CoreOffersService {
|
|||
|
||||
verifyPaymentAccountIsValidForNewOffer(offer, paymentAccount);
|
||||
|
||||
// We don't support atm funding from external wallet to keep it simple.
|
||||
boolean useSavingsWallet = true;
|
||||
//noinspection ConstantConditions
|
||||
placeOffer(offer,
|
||||
triggerPriceAsString,
|
||||
useSavingsWallet,
|
||||
true,
|
||||
reserveExactAmount,
|
||||
null,
|
||||
transaction -> resultHandler.accept(offer),
|
||||
errorMessageHandler);
|
||||
}
|
||||
|
||||
private void cloneOffer(String sourceOfferId,
|
||||
String currencyCode,
|
||||
String priceAsString,
|
||||
boolean useMarketBasedPrice,
|
||||
double marketPriceMargin,
|
||||
String triggerPriceAsString,
|
||||
String paymentAccountId,
|
||||
String extraInfo,
|
||||
Consumer<Offer> resultHandler,
|
||||
ErrorMessageHandler errorMessageHandler) {
|
||||
|
||||
// get source offer
|
||||
OpenOffer sourceOpenOffer = getMyOffer(sourceOfferId);
|
||||
Offer sourceOffer = sourceOpenOffer.getOffer();
|
||||
|
||||
// get trade currency (default source currency)
|
||||
if (currencyCode.isEmpty()) currencyCode = sourceOffer.getOfferPayload().getBaseCurrencyCode();
|
||||
if (currencyCode.equalsIgnoreCase(Res.getBaseCurrencyCode())) currencyCode = sourceOffer.getOfferPayload().getCounterCurrencyCode();
|
||||
String upperCaseCurrencyCode = currencyCode.toUpperCase();
|
||||
|
||||
// get price (default source price)
|
||||
Price price = useMarketBasedPrice ? null : priceAsString.isEmpty() ? sourceOffer.isUseMarketBasedPrice() ? null : sourceOffer.getPrice() : Price.parse(upperCaseCurrencyCode, priceAsString);
|
||||
if (price == null) useMarketBasedPrice = true;
|
||||
|
||||
// get payment account
|
||||
if (paymentAccountId.isEmpty()) paymentAccountId = sourceOffer.getOfferPayload().getMakerPaymentAccountId();
|
||||
PaymentAccount paymentAccount = user.getPaymentAccount(paymentAccountId);
|
||||
if (paymentAccount == null) throw new IllegalArgumentException(format("payment acRcount with id %s not found", paymentAccountId));
|
||||
|
||||
// get extra info
|
||||
if (extraInfo.isEmpty()) extraInfo = sourceOffer.getOfferPayload().getExtraInfo();
|
||||
|
||||
// create cloned offer
|
||||
Offer offer = createOfferService.createClonedOffer(sourceOffer,
|
||||
upperCaseCurrencyCode,
|
||||
price,
|
||||
useMarketBasedPrice,
|
||||
exactMultiply(marketPriceMargin, 0.01),
|
||||
paymentAccount,
|
||||
extraInfo);
|
||||
|
||||
// verify cloned offer
|
||||
verifyPaymentAccountIsValidForNewOffer(offer, paymentAccount);
|
||||
|
||||
// place offer
|
||||
placeOffer(offer,
|
||||
triggerPriceAsString,
|
||||
true,
|
||||
false, // ignored when cloning
|
||||
sourceOfferId,
|
||||
transaction -> resultHandler.accept(offer),
|
||||
errorMessageHandler);
|
||||
}
|
||||
|
||||
// TODO: this implementation is missing; implement.
|
||||
Offer editOffer(String offerId,
|
||||
String currencyCode,
|
||||
OfferDirection direction,
|
||||
|
@ -256,27 +320,6 @@ public class CoreOffersService {
|
|||
|
||||
// -------------------------- PRIVATE HELPERS -----------------------------
|
||||
|
||||
private Set<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 (offer == offer2) continue;
|
||||
if (offer2.getOfferPayload().getReserveTxKeyImages() == null) continue;
|
||||
if (offer2.getOfferPayload().getReserveTxKeyImages().contains(keyImage)) {
|
||||
log.warn("Key image {} belongs to multiple offers, seen in offer {} and {}", keyImage, offer.getId(), offer2.getId());
|
||||
duplicateFundedOffers.add(offer2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return duplicateFundedOffers;
|
||||
}
|
||||
|
||||
private void verifyPaymentAccountIsValidForNewOffer(Offer offer, PaymentAccount paymentAccount) {
|
||||
if (!isPaymentAccountValidForOffer(offer, paymentAccount)) {
|
||||
String error = format("cannot create %s offer with payment account %s",
|
||||
|
@ -290,6 +333,7 @@ public class CoreOffersService {
|
|||
String triggerPriceAsString,
|
||||
boolean useSavingsWallet,
|
||||
boolean reserveExactAmount,
|
||||
String sourceOfferId,
|
||||
Consumer<Transaction> resultHandler,
|
||||
ErrorMessageHandler errorMessageHandler) {
|
||||
long triggerPriceAsLong = PriceUtil.getMarketPriceAsLong(triggerPriceAsString, offer.getCurrencyCode());
|
||||
|
@ -298,6 +342,7 @@ public class CoreOffersService {
|
|||
triggerPriceAsLong,
|
||||
reserveExactAmount,
|
||||
true,
|
||||
sourceOfferId,
|
||||
resultHandler::accept,
|
||||
errorMessageHandler);
|
||||
}
|
||||
|
|
|
@ -32,6 +32,7 @@ import haveno.core.xmr.nodes.XmrNodes.XmrNode;
|
|||
import haveno.core.xmr.nodes.XmrNodesSetupPreferences;
|
||||
import haveno.core.xmr.setup.DownloadListener;
|
||||
import haveno.core.xmr.setup.WalletsSetup;
|
||||
import haveno.core.xmr.wallet.XmrKeyImagePoller;
|
||||
import haveno.network.Socks5ProxyProvider;
|
||||
import haveno.network.p2p.P2PService;
|
||||
import haveno.network.p2p.P2PServiceListener;
|
||||
|
@ -71,6 +72,8 @@ public final class XmrConnectionService {
|
|||
private static final int MIN_BROADCAST_CONNECTIONS = 0; // TODO: 0 for stagenet, 5+ for mainnet
|
||||
private static final long REFRESH_PERIOD_HTTP_MS = 20000; // refresh period when connected to remote node over http
|
||||
private static final long REFRESH_PERIOD_ONION_MS = 30000; // refresh period when connected to remote node over tor
|
||||
private static final long KEY_IMAGE_REFRESH_PERIOD_MS_LOCAL = 20000; // 20 seconds
|
||||
private static final long KEY_IMAGE_REFRESH_PERIOD_MS_REMOTE = 300000; // 5 minutes
|
||||
|
||||
public enum XmrConnectionError {
|
||||
LOCAL,
|
||||
|
@ -115,6 +118,7 @@ public final class XmrConnectionService {
|
|||
@Getter
|
||||
private boolean isShutDownStarted;
|
||||
private List<MoneroConnectionManagerListener> listeners = new ArrayList<>();
|
||||
private XmrKeyImagePoller keyImagePoller;
|
||||
|
||||
// connection switching
|
||||
private static final int EXCLUDE_CONNECTION_SECONDS = 180;
|
||||
|
@ -403,6 +407,17 @@ public final class XmrConnectionService {
|
|||
return lastInfo.getTargetHeight() == 0 ? chainHeight.get() : lastInfo.getTargetHeight(); // monerod sync_info's target_height returns 0 when node is fully synced
|
||||
}
|
||||
|
||||
public XmrKeyImagePoller getKeyImagePoller() {
|
||||
synchronized (lock) {
|
||||
if (keyImagePoller == null) keyImagePoller = new XmrKeyImagePoller();
|
||||
return keyImagePoller;
|
||||
}
|
||||
}
|
||||
|
||||
private long getKeyImageRefreshPeriodMs() {
|
||||
return isConnectionLocalHost() ? KEY_IMAGE_REFRESH_PERIOD_MS_LOCAL : KEY_IMAGE_REFRESH_PERIOD_MS_REMOTE;
|
||||
}
|
||||
|
||||
// ----------------------------- APP METHODS ------------------------------
|
||||
|
||||
public ReadOnlyIntegerProperty numConnectionsProperty() {
|
||||
|
@ -488,6 +503,13 @@ public final class XmrConnectionService {
|
|||
|
||||
private void initialize() {
|
||||
|
||||
// initialize key image poller
|
||||
getKeyImagePoller();
|
||||
new Thread(() -> {
|
||||
HavenoUtils.waitFor(20000);
|
||||
keyImagePoller.poll(); // TODO: keep or remove first poll?s
|
||||
}).start();
|
||||
|
||||
// initialize connections
|
||||
initializeConnections();
|
||||
|
||||
|
@ -693,6 +715,10 @@ public final class XmrConnectionService {
|
|||
numUpdates.set(numUpdates.get() + 1);
|
||||
});
|
||||
}
|
||||
|
||||
// update key image poller
|
||||
keyImagePoller.setDaemon(getDaemon());
|
||||
keyImagePoller.setRefreshPeriodMs(getKeyImageRefreshPeriodMs());
|
||||
|
||||
// update polling
|
||||
doPollDaemon();
|
||||
|
|
|
@ -92,6 +92,7 @@ public class CreateOfferService {
|
|||
Version.VERSION.replace(".", "");
|
||||
}
|
||||
|
||||
// TODO: add trigger price?
|
||||
public Offer createAndGetOffer(String offerId,
|
||||
OfferDirection direction,
|
||||
String currencyCode,
|
||||
|
@ -105,7 +106,7 @@ public class CreateOfferService {
|
|||
boolean isPrivateOffer,
|
||||
boolean buyerAsTakerWithoutDeposit,
|
||||
String extraInfo) {
|
||||
log.info("create and get offer with offerId={}, " +
|
||||
log.info("Create and get offer with offerId={}, " +
|
||||
"currencyCode={}, " +
|
||||
"direction={}, " +
|
||||
"fixedPrice={}, " +
|
||||
|
@ -238,6 +239,99 @@ public class CreateOfferService {
|
|||
return offer;
|
||||
}
|
||||
|
||||
// TODO: add trigger price?
|
||||
public Offer createClonedOffer(Offer sourceOffer,
|
||||
String currencyCode,
|
||||
Price fixedPrice,
|
||||
boolean useMarketBasedPrice,
|
||||
double marketPriceMargin,
|
||||
PaymentAccount paymentAccount,
|
||||
String extraInfo) {
|
||||
log.info("Cloning offer with sourceId={}, " +
|
||||
"currencyCode={}, " +
|
||||
"fixedPrice={}, " +
|
||||
"useMarketBasedPrice={}, " +
|
||||
"marketPriceMargin={}, " +
|
||||
"extraInfo={}",
|
||||
sourceOffer.getId(),
|
||||
currencyCode,
|
||||
fixedPrice == null ? null : fixedPrice.getValue(),
|
||||
useMarketBasedPrice,
|
||||
marketPriceMargin,
|
||||
extraInfo);
|
||||
|
||||
OfferPayload sourceOfferPayload = sourceOffer.getOfferPayload();
|
||||
String newOfferId = OfferUtil.getRandomOfferId();
|
||||
Offer editedOffer = createAndGetOffer(newOfferId,
|
||||
sourceOfferPayload.getDirection(),
|
||||
currencyCode,
|
||||
BigInteger.valueOf(sourceOfferPayload.getAmount()),
|
||||
BigInteger.valueOf(sourceOfferPayload.getMinAmount()),
|
||||
fixedPrice,
|
||||
useMarketBasedPrice,
|
||||
marketPriceMargin,
|
||||
sourceOfferPayload.getSellerSecurityDepositPct(),
|
||||
paymentAccount,
|
||||
sourceOfferPayload.isPrivateOffer(),
|
||||
sourceOfferPayload.isBuyerAsTakerWithoutDeposit(),
|
||||
extraInfo);
|
||||
|
||||
// generate one-time challenge for private offer
|
||||
String challenge = null;
|
||||
String challengeHash = null;
|
||||
if (sourceOfferPayload.isPrivateOffer()) {
|
||||
challenge = HavenoUtils.generateChallenge();
|
||||
challengeHash = HavenoUtils.getChallengeHash(challenge);
|
||||
}
|
||||
|
||||
OfferPayload editedOfferPayload = editedOffer.getOfferPayload();
|
||||
long date = new Date().getTime();
|
||||
OfferPayload clonedOfferPayload = new OfferPayload(newOfferId,
|
||||
date,
|
||||
sourceOfferPayload.getOwnerNodeAddress(),
|
||||
sourceOfferPayload.getPubKeyRing(),
|
||||
sourceOfferPayload.getDirection(),
|
||||
editedOfferPayload.getPrice(),
|
||||
editedOfferPayload.getMarketPriceMarginPct(),
|
||||
editedOfferPayload.isUseMarketBasedPrice(),
|
||||
sourceOfferPayload.getAmount(),
|
||||
sourceOfferPayload.getMinAmount(),
|
||||
sourceOfferPayload.getMakerFeePct(),
|
||||
sourceOfferPayload.getTakerFeePct(),
|
||||
sourceOfferPayload.getPenaltyFeePct(),
|
||||
sourceOfferPayload.getBuyerSecurityDepositPct(),
|
||||
sourceOfferPayload.getSellerSecurityDepositPct(),
|
||||
editedOfferPayload.getBaseCurrencyCode(),
|
||||
editedOfferPayload.getCounterCurrencyCode(),
|
||||
editedOfferPayload.getPaymentMethodId(),
|
||||
editedOfferPayload.getMakerPaymentAccountId(),
|
||||
editedOfferPayload.getCountryCode(),
|
||||
editedOfferPayload.getAcceptedCountryCodes(),
|
||||
editedOfferPayload.getBankId(),
|
||||
editedOfferPayload.getAcceptedBankIds(),
|
||||
editedOfferPayload.getVersionNr(),
|
||||
sourceOfferPayload.getBlockHeightAtOfferCreation(),
|
||||
editedOfferPayload.getMaxTradeLimit(),
|
||||
editedOfferPayload.getMaxTradePeriod(),
|
||||
sourceOfferPayload.isUseAutoClose(),
|
||||
sourceOfferPayload.isUseReOpenAfterAutoClose(),
|
||||
sourceOfferPayload.getLowerClosePrice(),
|
||||
sourceOfferPayload.getUpperClosePrice(),
|
||||
sourceOfferPayload.isPrivateOffer(),
|
||||
challengeHash,
|
||||
editedOfferPayload.getExtraDataMap(),
|
||||
sourceOfferPayload.getProtocolVersion(),
|
||||
null,
|
||||
null,
|
||||
sourceOfferPayload.getReserveTxKeyImages(),
|
||||
editedOfferPayload.getExtraInfo());
|
||||
Offer clonedOffer = new Offer(clonedOfferPayload);
|
||||
clonedOffer.setPriceFeedService(priceFeedService);
|
||||
clonedOffer.setChallenge(challenge);
|
||||
clonedOffer.setState(Offer.State.AVAILABLE);
|
||||
return clonedOffer;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Private
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
|
|
@ -36,6 +36,9 @@ package haveno.core.offer;
|
|||
|
||||
import com.google.inject.Inject;
|
||||
import com.google.inject.name.Named;
|
||||
|
||||
import haveno.common.ThreadUtils;
|
||||
import haveno.common.Timer;
|
||||
import haveno.common.UserThread;
|
||||
import haveno.common.config.Config;
|
||||
import haveno.common.file.JsonFileManager;
|
||||
|
@ -45,45 +48,51 @@ import haveno.core.api.XmrConnectionService;
|
|||
import haveno.core.filter.FilterManager;
|
||||
import haveno.core.locale.Res;
|
||||
import haveno.core.provider.price.PriceFeedService;
|
||||
import haveno.core.trade.HavenoUtils;
|
||||
import haveno.core.util.JsonUtil;
|
||||
import haveno.core.xmr.wallet.Restrictions;
|
||||
import haveno.core.xmr.wallet.XmrKeyImageListener;
|
||||
import haveno.core.xmr.wallet.XmrKeyImagePoller;
|
||||
import haveno.network.p2p.BootstrapListener;
|
||||
import haveno.network.p2p.P2PService;
|
||||
import haveno.network.p2p.storage.HashMapChangedListener;
|
||||
import haveno.network.p2p.storage.payload.ProtectedStorageEntry;
|
||||
import haveno.network.utils.Utils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.annotation.Nullable;
|
||||
import monero.daemon.model.MoneroKeyImageSpentStatus;
|
||||
|
||||
/**
|
||||
* Handles storage and retrieval of offers.
|
||||
* Uses an invalidation flag to only request the full offer map in case there was a change (anyone has added or removed an offer).
|
||||
* Handles validation and announcement of offers added or removed.
|
||||
*/
|
||||
@Slf4j
|
||||
public class OfferBookService {
|
||||
|
||||
private final static long INVALID_OFFERS_TIMEOUT = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
private final P2PService p2PService;
|
||||
private final PriceFeedService priceFeedService;
|
||||
private final List<OfferBookChangedListener> offerBookChangedListeners = new LinkedList<>();
|
||||
private final FilterManager filterManager;
|
||||
private final JsonFileManager jsonFileManager;
|
||||
private final XmrConnectionService xmrConnectionService;
|
||||
|
||||
// poll key images of offers
|
||||
private XmrKeyImagePoller keyImagePoller;
|
||||
private static final long KEY_IMAGE_REFRESH_PERIOD_MS_LOCAL = 20000; // 20 seconds
|
||||
private static final long KEY_IMAGE_REFRESH_PERIOD_MS_REMOTE = 300000; // 5 minutes
|
||||
private final List<Offer> validOffers = new ArrayList<Offer>();
|
||||
private final List<Offer> invalidOffers = new ArrayList<Offer>();
|
||||
private final Map<String, Timer> invalidOfferTimers = new HashMap<>();
|
||||
|
||||
public interface OfferBookChangedListener {
|
||||
void onAdded(Offer offer);
|
||||
|
||||
void onRemoved(Offer offer);
|
||||
}
|
||||
|
||||
|
@ -104,51 +113,45 @@ public class OfferBookService {
|
|||
this.xmrConnectionService = xmrConnectionService;
|
||||
jsonFileManager = new JsonFileManager(storageDir);
|
||||
|
||||
// listen for connection changes to monerod
|
||||
xmrConnectionService.addConnectionListener((connection) -> {
|
||||
maybeInitializeKeyImagePoller();
|
||||
keyImagePoller.setDaemon(xmrConnectionService.getDaemon());
|
||||
keyImagePoller.setRefreshPeriodMs(getKeyImageRefreshPeriodMs());
|
||||
});
|
||||
|
||||
// listen for offers
|
||||
p2PService.addHashSetChangedListener(new HashMapChangedListener() {
|
||||
@Override
|
||||
public void onAdded(Collection<ProtectedStorageEntry> protectedStorageEntries) {
|
||||
UserThread.execute(() -> {
|
||||
ThreadUtils.execute(() -> {
|
||||
protectedStorageEntries.forEach(protectedStorageEntry -> {
|
||||
if (protectedStorageEntry.getProtectedStoragePayload() instanceof OfferPayload) {
|
||||
OfferPayload offerPayload = (OfferPayload) protectedStorageEntry.getProtectedStoragePayload();
|
||||
maybeInitializeKeyImagePoller();
|
||||
keyImagePoller.addKeyImages(offerPayload.getReserveTxKeyImages());
|
||||
Offer offer = new Offer(offerPayload);
|
||||
offer.setPriceFeedService(priceFeedService);
|
||||
setReservedFundsSpent(offer);
|
||||
synchronized (offerBookChangedListeners) {
|
||||
offerBookChangedListeners.forEach(listener -> listener.onAdded(offer));
|
||||
synchronized (validOffers) {
|
||||
try {
|
||||
validateOfferPayload(offerPayload);
|
||||
replaceValidOffer(offer);
|
||||
announceOfferAdded(offer);
|
||||
} catch (IllegalArgumentException e) {
|
||||
// ignore illegal offers
|
||||
} catch (RuntimeException e) {
|
||||
replaceInvalidOffer(offer); // offer can become valid later
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}, OfferBookService.class.getSimpleName());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRemoved(Collection<ProtectedStorageEntry> protectedStorageEntries) {
|
||||
UserThread.execute(() -> {
|
||||
ThreadUtils.execute(() -> {
|
||||
protectedStorageEntries.forEach(protectedStorageEntry -> {
|
||||
if (protectedStorageEntry.getProtectedStoragePayload() instanceof OfferPayload) {
|
||||
OfferPayload offerPayload = (OfferPayload) protectedStorageEntry.getProtectedStoragePayload();
|
||||
maybeInitializeKeyImagePoller();
|
||||
keyImagePoller.removeKeyImages(offerPayload.getReserveTxKeyImages());
|
||||
removeValidOffer(offerPayload.getId());
|
||||
Offer offer = new Offer(offerPayload);
|
||||
offer.setPriceFeedService(priceFeedService);
|
||||
setReservedFundsSpent(offer);
|
||||
synchronized (offerBookChangedListeners) {
|
||||
offerBookChangedListeners.forEach(listener -> listener.onRemoved(offer));
|
||||
}
|
||||
announceOfferRemoved(offer);
|
||||
}
|
||||
});
|
||||
});
|
||||
}, OfferBookService.class.getSimpleName());
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -171,6 +174,16 @@ public class OfferBookService {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
// listen for changes to key images
|
||||
xmrConnectionService.getKeyImagePoller().addListener(new XmrKeyImageListener() {
|
||||
@Override
|
||||
public void onSpentStatusChanged(Map<String, MoneroKeyImageSpentStatus> spentStatuses) {
|
||||
for (String keyImage : spentStatuses.keySet()) {
|
||||
updateAffectedOffers(keyImage);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
@ -178,6 +191,10 @@ public class OfferBookService {
|
|||
// API
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
public boolean hasOffer(String offerId) {
|
||||
return hasValidOffer(offerId);
|
||||
}
|
||||
|
||||
public void addOffer(Offer offer, ResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) {
|
||||
if (filterManager.requireUpdateToNewVersionForTrading()) {
|
||||
errorMessageHandler.handleErrorMessage(Res.get("popup.warning.mandatoryUpdate.trading"));
|
||||
|
@ -233,16 +250,9 @@ public class OfferBookService {
|
|||
}
|
||||
|
||||
public List<Offer> getOffers() {
|
||||
return p2PService.getDataMap().values().stream()
|
||||
.filter(data -> data.getProtectedStoragePayload() instanceof OfferPayload)
|
||||
.map(data -> {
|
||||
OfferPayload offerPayload = (OfferPayload) data.getProtectedStoragePayload();
|
||||
Offer offer = new Offer(offerPayload);
|
||||
offer.setPriceFeedService(priceFeedService);
|
||||
setReservedFundsSpent(offer);
|
||||
return offer;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
synchronized (validOffers) {
|
||||
return new ArrayList<>(validOffers);
|
||||
}
|
||||
}
|
||||
|
||||
public List<Offer> getOffersByCurrency(String direction, String currencyCode) {
|
||||
|
@ -266,7 +276,7 @@ public class OfferBookService {
|
|||
}
|
||||
|
||||
public void shutDown() {
|
||||
if (keyImagePoller != null) keyImagePoller.clearKeyImages();
|
||||
xmrConnectionService.getKeyImagePoller().removeKeyImages(OfferBookService.class.getName());
|
||||
}
|
||||
|
||||
|
||||
|
@ -274,37 +284,145 @@ public class OfferBookService {
|
|||
// Private
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
private synchronized void maybeInitializeKeyImagePoller() {
|
||||
if (keyImagePoller != null) return;
|
||||
keyImagePoller = new XmrKeyImagePoller(xmrConnectionService.getDaemon(), getKeyImageRefreshPeriodMs());
|
||||
private void announceOfferAdded(Offer offer) {
|
||||
xmrConnectionService.getKeyImagePoller().addKeyImages(offer.getOfferPayload().getReserveTxKeyImages(), OfferBookService.class.getSimpleName());
|
||||
updateReservedFundsSpentStatus(offer);
|
||||
synchronized (offerBookChangedListeners) {
|
||||
offerBookChangedListeners.forEach(listener -> listener.onAdded(offer));
|
||||
}
|
||||
}
|
||||
|
||||
// handle when key images spent
|
||||
keyImagePoller.addListener(new XmrKeyImageListener() {
|
||||
@Override
|
||||
public void onSpentStatusChanged(Map<String, MoneroKeyImageSpentStatus> spentStatuses) {
|
||||
UserThread.execute(() -> {
|
||||
for (String keyImage : spentStatuses.keySet()) {
|
||||
updateAffectedOffers(keyImage);
|
||||
}
|
||||
});
|
||||
private void announceOfferRemoved(Offer offer) {
|
||||
updateReservedFundsSpentStatus(offer);
|
||||
removeKeyImages(offer);
|
||||
synchronized (offerBookChangedListeners) {
|
||||
offerBookChangedListeners.forEach(listener -> listener.onRemoved(offer));
|
||||
}
|
||||
|
||||
// check if invalid offers are now valid
|
||||
synchronized (invalidOffers) {
|
||||
for (Offer invalidOffer : new ArrayList<Offer>(invalidOffers)) {
|
||||
try {
|
||||
validateOfferPayload(invalidOffer.getOfferPayload());
|
||||
removeInvalidOffer(invalidOffer.getId());
|
||||
replaceValidOffer(invalidOffer);
|
||||
announceOfferAdded(invalidOffer);
|
||||
} catch (Exception e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// first poll after 20s
|
||||
// TODO: remove?
|
||||
new Thread(() -> {
|
||||
HavenoUtils.waitFor(20000);
|
||||
keyImagePoller.poll();
|
||||
}).start();
|
||||
}
|
||||
}
|
||||
|
||||
private long getKeyImageRefreshPeriodMs() {
|
||||
return xmrConnectionService.isConnectionLocalHost() ? KEY_IMAGE_REFRESH_PERIOD_MS_LOCAL : KEY_IMAGE_REFRESH_PERIOD_MS_REMOTE;
|
||||
private boolean hasValidOffer(String offerId) {
|
||||
for (Offer offer : getOffers()) {
|
||||
if (offer.getId().equals(offerId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void replaceValidOffer(Offer offer) {
|
||||
synchronized (validOffers) {
|
||||
removeValidOffer(offer.getId());
|
||||
validOffers.add(offer);
|
||||
}
|
||||
}
|
||||
|
||||
private void replaceInvalidOffer(Offer offer) {
|
||||
synchronized (invalidOffers) {
|
||||
removeInvalidOffer(offer.getId());
|
||||
invalidOffers.add(offer);
|
||||
|
||||
// remove invalid offer after timeout
|
||||
synchronized (invalidOfferTimers) {
|
||||
Timer timer = invalidOfferTimers.get(offer.getId());
|
||||
if (timer != null) timer.stop();
|
||||
timer = UserThread.runAfter(() -> {
|
||||
removeInvalidOffer(offer.getId());
|
||||
}, INVALID_OFFERS_TIMEOUT);
|
||||
invalidOfferTimers.put(offer.getId(), timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void removeValidOffer(String offerId) {
|
||||
synchronized (validOffers) {
|
||||
validOffers.removeIf(offer -> offer.getId().equals(offerId));
|
||||
}
|
||||
}
|
||||
|
||||
private void removeInvalidOffer(String offerId) {
|
||||
synchronized (invalidOffers) {
|
||||
invalidOffers.removeIf(offer -> offer.getId().equals(offerId));
|
||||
|
||||
// remove timeout
|
||||
synchronized (invalidOfferTimers) {
|
||||
Timer timer = invalidOfferTimers.get(offerId);
|
||||
if (timer != null) timer.stop();
|
||||
invalidOfferTimers.remove(offerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateOfferPayload(OfferPayload offerPayload) {
|
||||
|
||||
// validate offer is not banned
|
||||
if (filterManager.isOfferIdBanned(offerPayload.getId())) {
|
||||
throw new IllegalArgumentException("Offer is banned with offerId=" + offerPayload.getId());
|
||||
}
|
||||
|
||||
// validate v3 node address compliance
|
||||
boolean isV3NodeAddressCompliant = !OfferRestrictions.requiresNodeAddressUpdate() || Utils.isV3Address(offerPayload.getOwnerNodeAddress().getHostName());
|
||||
if (!isV3NodeAddressCompliant) {
|
||||
throw new IllegalArgumentException("Offer with non-V3 node address is not allowed with offerId=" + offerPayload.getId());
|
||||
}
|
||||
|
||||
// validate against existing offers
|
||||
synchronized (validOffers) {
|
||||
int numOffersWithSharedKeyImages = 0;
|
||||
for (Offer offer : validOffers) {
|
||||
|
||||
// validate that no offer has overlapping but different key images
|
||||
if (!offer.getOfferPayload().getReserveTxKeyImages().equals(offerPayload.getReserveTxKeyImages()) &&
|
||||
!Collections.disjoint(offer.getOfferPayload().getReserveTxKeyImages(), offerPayload.getReserveTxKeyImages())) {
|
||||
throw new RuntimeException("Offer with overlapping key images already exists with offerId=" + offer.getId());
|
||||
}
|
||||
|
||||
// validate that no offer has same key images, payment method, and currency
|
||||
if (!offer.getId().equals(offerPayload.getId()) &&
|
||||
offer.getOfferPayload().getReserveTxKeyImages().equals(offerPayload.getReserveTxKeyImages()) &&
|
||||
offer.getOfferPayload().getPaymentMethodId().equals(offerPayload.getPaymentMethodId()) &&
|
||||
offer.getOfferPayload().getBaseCurrencyCode().equals(offerPayload.getBaseCurrencyCode()) &&
|
||||
offer.getOfferPayload().getCounterCurrencyCode().equals(offerPayload.getCounterCurrencyCode())) {
|
||||
throw new RuntimeException("Offer with same key images, payment method, and currency already exists with offerId=" + offer.getId());
|
||||
}
|
||||
|
||||
// count offers with same key images
|
||||
if (!offer.getId().equals(offerPayload.getId()) && !Collections.disjoint(offer.getOfferPayload().getReserveTxKeyImages(), offerPayload.getReserveTxKeyImages())) numOffersWithSharedKeyImages = Math.max(2, numOffersWithSharedKeyImages + 1);
|
||||
}
|
||||
|
||||
// validate max offers with same key images
|
||||
if (numOffersWithSharedKeyImages > Restrictions.MAX_OFFERS_WITH_SHARED_FUNDS) throw new RuntimeException("More than " + Restrictions.MAX_OFFERS_WITH_SHARED_FUNDS + " offers exist with same same key images as new offerId=" + offerPayload.getId());
|
||||
}
|
||||
}
|
||||
|
||||
private void removeKeyImages(Offer offer) {
|
||||
Set<String> unsharedKeyImages = new HashSet<>(offer.getOfferPayload().getReserveTxKeyImages());
|
||||
synchronized (validOffers) {
|
||||
for (Offer validOffer : validOffers) {
|
||||
if (validOffer.getId().equals(offer.getId())) continue;
|
||||
unsharedKeyImages.removeAll(validOffer.getOfferPayload().getReserveTxKeyImages());
|
||||
}
|
||||
}
|
||||
xmrConnectionService.getKeyImagePoller().removeKeyImages(unsharedKeyImages, OfferBookService.class.getSimpleName());
|
||||
}
|
||||
|
||||
private void updateAffectedOffers(String keyImage) {
|
||||
for (Offer offer : getOffers()) {
|
||||
if (offer.getOfferPayload().getReserveTxKeyImages().contains(keyImage)) {
|
||||
updateReservedFundsSpentStatus(offer);
|
||||
synchronized (offerBookChangedListeners) {
|
||||
offerBookChangedListeners.forEach(listener -> {
|
||||
listener.onRemoved(offer);
|
||||
|
@ -315,10 +433,9 @@ public class OfferBookService {
|
|||
}
|
||||
}
|
||||
|
||||
private void setReservedFundsSpent(Offer offer) {
|
||||
if (keyImagePoller == null) return;
|
||||
private void updateReservedFundsSpentStatus(Offer offer) {
|
||||
for (String keyImage : offer.getOfferPayload().getReserveTxKeyImages()) {
|
||||
if (Boolean.TRUE.equals(keyImagePoller.isSpent(keyImage))) {
|
||||
if (Boolean.TRUE.equals(xmrConnectionService.getKeyImagePoller().isSpent(keyImage))) {
|
||||
offer.setReservedFundsSpent(true);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -347,6 +347,10 @@ public final class OfferPayload implements ProtectedStoragePayload, ExpirablePay
|
|||
return Restrictions.getMinSecurityDeposit().max(securityDepositUnadjusted);
|
||||
}
|
||||
|
||||
public boolean isBuyerAsTakerWithoutDeposit() {
|
||||
return getDirection() == OfferDirection.SELL && getBuyerSecurityDepositPct() == 0;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
// PROTO BUFFER
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
|
|
@ -48,6 +48,7 @@ import java.util.ArrayList;
|
|||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@EqualsAndHashCode
|
||||
public final class OpenOffer implements Tradable {
|
||||
|
@ -113,6 +114,9 @@ public final class OpenOffer implements Tradable {
|
|||
@Getter
|
||||
@Setter
|
||||
private boolean deactivatedByTrigger;
|
||||
@Getter
|
||||
@Setter
|
||||
private String groupId;
|
||||
|
||||
public OpenOffer(Offer offer) {
|
||||
this(offer, 0, false);
|
||||
|
@ -127,6 +131,7 @@ public final class OpenOffer implements Tradable {
|
|||
this.triggerPrice = triggerPrice;
|
||||
this.reserveExactAmount = reserveExactAmount;
|
||||
this.challenge = offer.getChallenge();
|
||||
this.groupId = UUID.randomUUID().toString();
|
||||
state = State.PENDING;
|
||||
}
|
||||
|
||||
|
@ -146,6 +151,7 @@ public final class OpenOffer implements Tradable {
|
|||
this.reserveTxKey = openOffer.reserveTxKey;
|
||||
this.challenge = openOffer.challenge;
|
||||
this.deactivatedByTrigger = openOffer.deactivatedByTrigger;
|
||||
this.groupId = openOffer.groupId;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
@ -164,7 +170,8 @@ public final class OpenOffer implements Tradable {
|
|||
@Nullable String reserveTxHex,
|
||||
@Nullable String reserveTxKey,
|
||||
@Nullable String challenge,
|
||||
boolean deactivatedByTrigger) {
|
||||
boolean deactivatedByTrigger,
|
||||
@Nullable String groupId) {
|
||||
this.offer = offer;
|
||||
this.state = state;
|
||||
this.triggerPrice = triggerPrice;
|
||||
|
@ -177,6 +184,8 @@ public final class OpenOffer implements Tradable {
|
|||
this.reserveTxKey = reserveTxKey;
|
||||
this.challenge = challenge;
|
||||
this.deactivatedByTrigger = deactivatedByTrigger;
|
||||
if (groupId == null) groupId = UUID.randomUUID().toString(); // initialize groupId if not set (added in v1.0.19)
|
||||
this.groupId = groupId;
|
||||
|
||||
// reset reserved state to available
|
||||
if (this.state == State.RESERVED) setState(State.AVAILABLE);
|
||||
|
@ -199,6 +208,7 @@ public final class OpenOffer implements Tradable {
|
|||
Optional.ofNullable(reserveTxHex).ifPresent(e -> builder.setReserveTxHex(reserveTxHex));
|
||||
Optional.ofNullable(reserveTxKey).ifPresent(e -> builder.setReserveTxKey(reserveTxKey));
|
||||
Optional.ofNullable(challenge).ifPresent(e -> builder.setChallenge(challenge));
|
||||
Optional.ofNullable(groupId).ifPresent(e -> builder.setGroupId(groupId));
|
||||
|
||||
return protobuf.Tradable.newBuilder().setOpenOffer(builder).build();
|
||||
}
|
||||
|
@ -216,7 +226,8 @@ public final class OpenOffer implements Tradable {
|
|||
ProtoUtil.stringOrNullFromProto(proto.getReserveTxHex()),
|
||||
ProtoUtil.stringOrNullFromProto(proto.getReserveTxKey()),
|
||||
ProtoUtil.stringOrNullFromProto(proto.getChallenge()),
|
||||
proto.getDeactivatedByTrigger());
|
||||
proto.getDeactivatedByTrigger(),
|
||||
ProtoUtil.stringOrNullFromProto(proto.getGroupId()));
|
||||
return openOffer;
|
||||
}
|
||||
|
||||
|
@ -282,6 +293,7 @@ public final class OpenOffer implements Tradable {
|
|||
",\n reserveExactAmount=" + reserveExactAmount +
|
||||
",\n scheduledAmount=" + scheduledAmount +
|
||||
",\n splitOutputTxFee=" + splitOutputTxFee +
|
||||
",\n groupId=" + groupId +
|
||||
"\n}";
|
||||
}
|
||||
}
|
||||
|
|
|
@ -55,7 +55,7 @@ import haveno.core.api.CoreContext;
|
|||
import haveno.core.api.XmrConnectionService;
|
||||
import haveno.core.exceptions.TradePriceOutOfToleranceException;
|
||||
import haveno.core.filter.FilterManager;
|
||||
import haveno.core.offer.OfferBookService.OfferBookChangedListener;
|
||||
import haveno.core.locale.Res;
|
||||
import haveno.core.offer.messages.OfferAvailabilityRequest;
|
||||
import haveno.core.offer.messages.OfferAvailabilityResponse;
|
||||
import haveno.core.offer.messages.SignOfferRequest;
|
||||
|
@ -97,7 +97,6 @@ import haveno.network.p2p.peers.PeerManager;
|
|||
import java.math.BigInteger;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
|
@ -136,6 +135,9 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
private static final long REPUBLISH_INTERVAL_MS = TimeUnit.MINUTES.toMillis(30);
|
||||
private static final long REFRESH_INTERVAL_MS = OfferPayload.TTL / 2;
|
||||
private static final int NUM_ATTEMPTS_THRESHOLD = 5; // process offer only on republish cycle after this many attempts
|
||||
private static final long SHUTDOWN_TIMEOUT_MS = 60000;
|
||||
private static final String OPEN_OFFER_GROUP_KEY_IMAGE_ID = OpenOffer.class.getSimpleName();
|
||||
private static final String SIGNED_OFFER_KEY_IMAGE_GROUP_ID = SignedOffer.class.getSimpleName();
|
||||
|
||||
private final CoreContext coreContext;
|
||||
private final KeyRing keyRing;
|
||||
|
@ -169,12 +171,6 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
@Getter
|
||||
private final AccountAgeWitnessService accountAgeWitnessService;
|
||||
|
||||
// poll key images of signed offers
|
||||
private XmrKeyImagePoller signedOfferKeyImagePoller;
|
||||
private static final long SHUTDOWN_TIMEOUT_MS = 60000;
|
||||
private static final long KEY_IMAGE_REFRESH_PERIOD_MS_LOCAL = 20000; // 20 seconds
|
||||
private static final long KEY_IMAGE_REFRESH_PERIOD_MS_REMOTE = 300000; // 5 minutes
|
||||
|
||||
private Object processOffersLock = new Object(); // lock for processing offers
|
||||
|
||||
|
||||
|
@ -227,27 +223,6 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
|
||||
this.persistenceManager.initialize(openOffers, "OpenOffers", PersistenceManager.Source.PRIVATE);
|
||||
this.signedOfferPersistenceManager.initialize(signedOffers, "SignedOffers", PersistenceManager.Source.PRIVATE); // arbitrator stores reserve tx for signed offers
|
||||
|
||||
// listen for connection changes to monerod
|
||||
xmrConnectionService.addConnectionListener((connection) -> maybeInitializeKeyImagePoller());
|
||||
|
||||
// close open offer if reserved funds spent
|
||||
offerBookService.addOfferBookChangedListener(new OfferBookChangedListener() {
|
||||
@Override
|
||||
public void onAdded(Offer offer) {
|
||||
|
||||
// cancel offer if reserved funds spent
|
||||
Optional<OpenOffer> openOfferOptional = getOpenOffer(offer.getId());
|
||||
if (openOfferOptional.isPresent() && openOfferOptional.get().getState() != OpenOffer.State.RESERVED && offer.isReservedFundsSpent()) {
|
||||
log.warn("Canceling open offer because reserved funds have been spent, offerId={}, state={}", offer.getId(), openOfferOptional.get().getState());
|
||||
cancelOpenOffer(openOfferOptional.get(), null, null);
|
||||
}
|
||||
}
|
||||
@Override
|
||||
public void onRemoved(Offer offer) {
|
||||
// nothing to do
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -268,34 +243,6 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
completeHandler);
|
||||
}
|
||||
|
||||
private synchronized void maybeInitializeKeyImagePoller() {
|
||||
if (signedOfferKeyImagePoller != null) return;
|
||||
signedOfferKeyImagePoller = new XmrKeyImagePoller(xmrConnectionService.getDaemon(), getKeyImageRefreshPeriodMs());
|
||||
|
||||
// handle when key images confirmed spent
|
||||
signedOfferKeyImagePoller.addListener(new XmrKeyImageListener() {
|
||||
@Override
|
||||
public void onSpentStatusChanged(Map<String, MoneroKeyImageSpentStatus> spentStatuses) {
|
||||
for (Entry<String, MoneroKeyImageSpentStatus> entry : spentStatuses.entrySet()) {
|
||||
if (entry.getValue() == MoneroKeyImageSpentStatus.CONFIRMED) {
|
||||
removeSignedOffers(entry.getKey());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// first poll in 5s
|
||||
// TODO: remove?
|
||||
new Thread(() -> {
|
||||
HavenoUtils.waitFor(5000);
|
||||
signedOfferKeyImagePoller.poll();
|
||||
}).start();
|
||||
}
|
||||
|
||||
private long getKeyImageRefreshPeriodMs() {
|
||||
return xmrConnectionService.isConnectionLocalHost() ? KEY_IMAGE_REFRESH_PERIOD_MS_LOCAL : KEY_IMAGE_REFRESH_PERIOD_MS_REMOTE;
|
||||
}
|
||||
|
||||
public void onAllServicesInitialized() {
|
||||
p2PService.addDecryptedDirectMessageListener(this);
|
||||
|
||||
|
@ -330,7 +277,8 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
stopped = true;
|
||||
p2PService.getPeerManager().removeListener(this);
|
||||
p2PService.removeDecryptedDirectMessageListener(this);
|
||||
if (signedOfferKeyImagePoller != null) signedOfferKeyImagePoller.clearKeyImages();
|
||||
xmrConnectionService.getKeyImagePoller().removeKeyImages(OPEN_OFFER_GROUP_KEY_IMAGE_ID);
|
||||
xmrConnectionService.getKeyImagePoller().removeKeyImages(SIGNED_OFFER_KEY_IMAGE_GROUP_ID);
|
||||
|
||||
stopPeriodicRefreshOffersTimer();
|
||||
stopPeriodicRepublishOffersTimer();
|
||||
|
@ -385,11 +333,7 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
removeOpenOffers(getObservableList(), completeHandler);
|
||||
}
|
||||
|
||||
public void removeOpenOffer(OpenOffer openOffer, @Nullable Runnable completeHandler) {
|
||||
removeOpenOffers(List.of(openOffer), completeHandler);
|
||||
}
|
||||
|
||||
public void removeOpenOffers(List<OpenOffer> openOffers, @Nullable Runnable completeHandler) {
|
||||
private void removeOpenOffers(List<OpenOffer> openOffers, @Nullable Runnable completeHandler) {
|
||||
int size = openOffers.size();
|
||||
// Copy list as we remove in the loop
|
||||
List<OpenOffer> openOffersList = new ArrayList<>(openOffers);
|
||||
|
@ -442,6 +386,19 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
|
||||
maybeUpdatePersistedOffers();
|
||||
|
||||
// listen for spent key images to close open and signed offers
|
||||
xmrConnectionService.getKeyImagePoller().addListener(new XmrKeyImageListener() {
|
||||
@Override
|
||||
public void onSpentStatusChanged(Map<String, MoneroKeyImageSpentStatus> spentStatuses) {
|
||||
for (Entry<String, MoneroKeyImageSpentStatus> entry : spentStatuses.entrySet()) {
|
||||
if (XmrKeyImagePoller.isSpent(entry.getValue())) {
|
||||
cancelOpenOffersOnSpent(entry.getKey());
|
||||
removeSignedOffers(entry.getKey());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// run off user thread so app is not blocked from starting
|
||||
ThreadUtils.submitToPool(() -> {
|
||||
|
||||
|
@ -492,12 +449,14 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
}
|
||||
});
|
||||
|
||||
// initialize key image poller for signed offers
|
||||
maybeInitializeKeyImagePoller();
|
||||
// poll spent status of open offer key images
|
||||
for (OpenOffer openOffer : getOpenOffers()) {
|
||||
xmrConnectionService.getKeyImagePoller().addKeyImages(openOffer.getOffer().getOfferPayload().getReserveTxKeyImages(), OPEN_OFFER_GROUP_KEY_IMAGE_ID);
|
||||
}
|
||||
|
||||
// poll spent status of key images
|
||||
// poll spent status of signed offer key images
|
||||
for (SignedOffer signedOffer : signedOffers.getList()) {
|
||||
signedOfferKeyImagePoller.addKeyImages(signedOffer.getReserveTxKeyImages());
|
||||
xmrConnectionService.getKeyImagePoller().addKeyImages(signedOffer.getReserveTxKeyImages(), SIGNED_OFFER_KEY_IMAGE_GROUP_ID);
|
||||
}
|
||||
}, THREAD_ID);
|
||||
});
|
||||
|
@ -544,17 +503,59 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
long triggerPrice,
|
||||
boolean reserveExactAmount,
|
||||
boolean resetAddressEntriesOnError,
|
||||
String sourceOfferId,
|
||||
TransactionResultHandler resultHandler,
|
||||
ErrorMessageHandler errorMessageHandler) {
|
||||
|
||||
// check source offer and clone limit
|
||||
OpenOffer sourceOffer = null;
|
||||
if (sourceOfferId != null) {
|
||||
|
||||
// get source offer
|
||||
Optional<OpenOffer> sourceOfferOptional = getOpenOffer(sourceOfferId);
|
||||
if (!sourceOfferOptional.isPresent()) {
|
||||
errorMessageHandler.handleErrorMessage("Source offer not found to clone, offerId=" + sourceOfferId);
|
||||
return;
|
||||
}
|
||||
sourceOffer = sourceOfferOptional.get();
|
||||
|
||||
// check clone limit
|
||||
int numClones = getOpenOfferGroup(sourceOffer.getGroupId()).size();
|
||||
if (numClones >= Restrictions.MAX_OFFERS_WITH_SHARED_FUNDS) {
|
||||
errorMessageHandler.handleErrorMessage("Cannot create offer because maximum number of " + Restrictions.MAX_OFFERS_WITH_SHARED_FUNDS + " cloned offers with shared funds reached.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// create open offer
|
||||
OpenOffer openOffer = new OpenOffer(offer, triggerPrice, reserveExactAmount);
|
||||
OpenOffer openOffer = new OpenOffer(offer, triggerPrice, sourceOffer == null ? reserveExactAmount : sourceOffer.isReserveExactAmount());
|
||||
|
||||
// set state from source offer
|
||||
if (sourceOffer != null) {
|
||||
openOffer.setReserveTxHash(sourceOffer.getReserveTxHash());
|
||||
openOffer.setReserveTxHex(sourceOffer.getReserveTxHex());
|
||||
openOffer.setReserveTxKey(sourceOffer.getReserveTxKey());
|
||||
openOffer.setGroupId(sourceOffer.getGroupId());
|
||||
openOffer.getOffer().getOfferPayload().setReserveTxKeyImages(sourceOffer.getOffer().getOfferPayload().getReserveTxKeyImages());
|
||||
xmrWalletService.cloneAddressEntries(sourceOffer.getOffer().getId(), openOffer.getOffer().getId());
|
||||
if (hasConflictingClone(openOffer)) openOffer.setState(OpenOffer.State.DEACTIVATED);
|
||||
}
|
||||
|
||||
// add the open offer
|
||||
synchronized (processOffersLock) {
|
||||
addOpenOffer(openOffer);
|
||||
}
|
||||
|
||||
// done if source offer is pending
|
||||
if (sourceOffer != null && sourceOffer.isPending()) {
|
||||
resultHandler.handleResult(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// schedule or post offer
|
||||
ThreadUtils.execute(() -> {
|
||||
synchronized (processOffersLock) {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
addOpenOffer(openOffer);
|
||||
processOffer(getOpenOffers(), openOffer, (transaction) -> {
|
||||
requestPersistence();
|
||||
latch.countDown();
|
||||
|
@ -591,18 +592,30 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
if (openOffer.isPending()) {
|
||||
resultHandler.handleResult(); // ignore if pending
|
||||
} else if (offersToBeEdited.containsKey(openOffer.getId())) {
|
||||
errorMessageHandler.handleErrorMessage("You can't activate an offer that is currently edited.");
|
||||
errorMessageHandler.handleErrorMessage(Res.get("offerbook.cannotActivateEditedOffer.warning"));
|
||||
} else if (hasConflictingClone(openOffer)) {
|
||||
errorMessageHandler.handleErrorMessage(Res.get("offerbook.hasConflictingClone.warning"));
|
||||
} else {
|
||||
Offer offer = openOffer.getOffer();
|
||||
offerBookService.activateOffer(offer,
|
||||
() -> {
|
||||
openOffer.setState(OpenOffer.State.AVAILABLE);
|
||||
applyTriggerState(openOffer);
|
||||
requestPersistence();
|
||||
log.debug("activateOpenOffer, offerId={}", offer.getId());
|
||||
resultHandler.handleResult();
|
||||
},
|
||||
errorMessageHandler);
|
||||
try {
|
||||
|
||||
// validate arbitrator signature
|
||||
validateSignedState(openOffer);
|
||||
|
||||
// activate offer on offer book
|
||||
Offer offer = openOffer.getOffer();
|
||||
offerBookService.activateOffer(offer,
|
||||
() -> {
|
||||
openOffer.setState(OpenOffer.State.AVAILABLE);
|
||||
applyTriggerState(openOffer);
|
||||
requestPersistence();
|
||||
log.debug("activateOpenOffer, offerId={}", offer.getId());
|
||||
resultHandler.handleResult();
|
||||
},
|
||||
errorMessageHandler);
|
||||
} catch (Exception e) {
|
||||
errorMessageHandler.handleErrorMessage(e.getMessage());
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -655,7 +668,7 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
});
|
||||
}
|
||||
} else {
|
||||
if (errorMessageHandler != null) errorMessageHandler.handleErrorMessage("You can't remove an offer that is currently edited.");
|
||||
if (errorMessageHandler != null) errorMessageHandler.handleErrorMessage("You can't cancel an offer that is currently edited.");
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -699,29 +712,44 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
|
||||
OpenOffer editedOpenOffer = new OpenOffer(editedOffer, triggerPrice, openOffer);
|
||||
if (originalState == OpenOffer.State.DEACTIVATED && openOffer.isDeactivatedByTrigger()) {
|
||||
editedOpenOffer.setState(OpenOffer.State.AVAILABLE);
|
||||
if (hasConflictingClone(editedOpenOffer)) {
|
||||
editedOpenOffer.setState(OpenOffer.State.DEACTIVATED);
|
||||
} else {
|
||||
editedOpenOffer.setState(OpenOffer.State.AVAILABLE);
|
||||
}
|
||||
applyTriggerState(editedOpenOffer);
|
||||
} else {
|
||||
editedOpenOffer.setState(originalState);
|
||||
if (originalState == OpenOffer.State.AVAILABLE && hasConflictingClone(editedOpenOffer)) {
|
||||
editedOpenOffer.setState(OpenOffer.State.DEACTIVATED);
|
||||
} else {
|
||||
editedOpenOffer.setState(originalState);
|
||||
}
|
||||
}
|
||||
|
||||
addOpenOffer(editedOpenOffer);
|
||||
|
||||
// reset arbitrator signature if invalid
|
||||
// check for valid arbitrator signature after editing
|
||||
Arbitrator arbitrator = user.getAcceptedArbitratorByAddress(editedOpenOffer.getOffer().getOfferPayload().getArbitratorSigner());
|
||||
if (arbitrator == null || !HavenoUtils.isArbitratorSignatureValid(editedOpenOffer.getOffer().getOfferPayload(), arbitrator)) {
|
||||
|
||||
// reset arbitrator signature
|
||||
editedOpenOffer.getOffer().getOfferPayload().setArbitratorSignature(null);
|
||||
editedOpenOffer.getOffer().getOfferPayload().setArbitratorSigner(null);
|
||||
}
|
||||
|
||||
// process offer which might sign and publish
|
||||
processOffer(getOpenOffers(), editedOpenOffer, (transaction) -> {
|
||||
// process offer to sign and publish
|
||||
processOffer(getOpenOffers(), editedOpenOffer, (transaction) -> {
|
||||
offersToBeEdited.remove(openOffer.getId());
|
||||
requestPersistence();
|
||||
resultHandler.handleResult();
|
||||
}, (errorMsg) -> {
|
||||
errorMessageHandler.handleErrorMessage(errorMsg);
|
||||
});
|
||||
} else {
|
||||
maybeRepublishOffer(editedOpenOffer, null);
|
||||
offersToBeEdited.remove(openOffer.getId());
|
||||
requestPersistence();
|
||||
resultHandler.handleResult();
|
||||
}, (errorMsg) -> {
|
||||
errorMessageHandler.handleErrorMessage(errorMsg);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
errorMessageHandler.handleErrorMessage("There is no offer with this id existing to be published.");
|
||||
}
|
||||
|
@ -753,26 +781,33 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
Offer offer = openOffer.getOffer();
|
||||
offer.setState(Offer.State.REMOVED);
|
||||
openOffer.setState(OpenOffer.State.CANCELED);
|
||||
boolean hasClonedOffer = hasClonedOffer(offer.getId()); // record before removing open offer
|
||||
removeOpenOffer(openOffer);
|
||||
closedTradableManager.add(openOffer); // TODO: don't add these to closed tradables?
|
||||
if (!hasClonedOffer) closedTradableManager.add(openOffer); // do not add clones to closed trades TODO: don't add canceled offers to closed tradables?
|
||||
if (resetAddressEntries) xmrWalletService.resetAddressEntriesForOpenOffer(offer.getId());
|
||||
requestPersistence();
|
||||
xmrWalletService.thawOutputs(offer.getOfferPayload().getReserveTxKeyImages());
|
||||
if (!hasClonedOffer) xmrWalletService.thawOutputs(offer.getOfferPayload().getReserveTxKeyImages());
|
||||
}
|
||||
|
||||
// close open offer after key images spent
|
||||
public void closeOpenOffer(Offer offer) {
|
||||
// close open offer group after key images spent
|
||||
public void closeSpentOffer(Offer offer) {
|
||||
getOpenOffer(offer.getId()).ifPresent(openOffer -> {
|
||||
removeOpenOffer(openOffer);
|
||||
openOffer.setState(OpenOffer.State.CLOSED);
|
||||
xmrWalletService.resetAddressEntriesForOpenOffer(offer.getId());
|
||||
offerBookService.removeOffer(openOffer.getOffer().getOfferPayload(),
|
||||
() -> log.info("Successfully removed offer {}", offer.getId()),
|
||||
log::error);
|
||||
requestPersistence();
|
||||
for (OpenOffer groupOffer: getOpenOfferGroup(openOffer.getGroupId())) {
|
||||
doCloseOpenOffer(groupOffer);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void doCloseOpenOffer(OpenOffer openOffer) {
|
||||
removeOpenOffer(openOffer);
|
||||
openOffer.setState(OpenOffer.State.CLOSED);
|
||||
xmrWalletService.resetAddressEntriesForOpenOffer(openOffer.getId());
|
||||
offerBookService.removeOffer(openOffer.getOffer().getOfferPayload(),
|
||||
() -> log.info("Successfully removed offer {}", openOffer.getId()),
|
||||
log::error);
|
||||
requestPersistence();
|
||||
}
|
||||
|
||||
public void reserveOpenOffer(OpenOffer openOffer) {
|
||||
openOffer.setState(OpenOffer.State.RESERVED);
|
||||
requestPersistence();
|
||||
|
@ -783,6 +818,37 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
requestPersistence();
|
||||
}
|
||||
|
||||
public boolean hasConflictingClone(OpenOffer openOffer) {
|
||||
for (OpenOffer clonedOffer : getOpenOfferGroup(openOffer.getGroupId())) {
|
||||
if (clonedOffer.getId().equals(openOffer.getId())) continue;
|
||||
if (clonedOffer.isDeactivated()) continue; // deactivated offers do not conflict
|
||||
|
||||
// pending offers later in the order do not conflict
|
||||
List<OpenOffer> openOffers = getOpenOffers();
|
||||
if (clonedOffer.isPending() && openOffers.indexOf(clonedOffer) > openOffers.indexOf(openOffer)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// conflicts if same payment method and currency
|
||||
if (samePaymentMethodAndCurrency(clonedOffer.getOffer(), openOffer.getOffer())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean hasConflictingClone(Offer offer, OpenOffer sourceOffer) {
|
||||
return getOpenOfferGroup(sourceOffer.getGroupId()).stream()
|
||||
.filter(openOffer -> !openOffer.isDeactivated()) // we only check with activated offers
|
||||
.anyMatch(openOffer -> samePaymentMethodAndCurrency(openOffer.getOffer(), offer));
|
||||
}
|
||||
|
||||
private boolean samePaymentMethodAndCurrency(Offer offer1, Offer offer2) {
|
||||
return offer1.getPaymentMethodId().equalsIgnoreCase(offer2.getPaymentMethodId()) &&
|
||||
offer1.getCounterCurrencyCode().equalsIgnoreCase(offer2.getCounterCurrencyCode()) &&
|
||||
offer1.getBaseCurrencyCode().equalsIgnoreCase(offer2.getBaseCurrencyCode());
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Getters
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
@ -791,7 +857,7 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
return offer.isMyOffer(keyRing);
|
||||
}
|
||||
|
||||
public boolean hasOpenOffers() {
|
||||
public boolean hasAvailableOpenOffers() {
|
||||
synchronized (openOffers) {
|
||||
for (OpenOffer openOffer : getOpenOffers()) {
|
||||
if (openOffer.getState() == OpenOffer.State.AVAILABLE) {
|
||||
|
@ -808,13 +874,38 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
}
|
||||
}
|
||||
|
||||
public List<OpenOffer> getOpenOfferGroup(String groupId) {
|
||||
if (groupId == null) throw new IllegalArgumentException("groupId cannot be null");
|
||||
synchronized (openOffers) {
|
||||
return getOpenOffers().stream()
|
||||
.filter(openOffer -> groupId.equals(openOffer.getGroupId()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
public boolean hasClonedOffer(String offerId) {
|
||||
OpenOffer openOffer = getOpenOffer(offerId).orElse(null);
|
||||
if (openOffer == null) return false;
|
||||
return getOpenOfferGroup(openOffer.getGroupId()).size() > 1;
|
||||
}
|
||||
|
||||
public boolean hasClonedOffers() {
|
||||
synchronized (openOffers) {
|
||||
for (OpenOffer openOffer : getOpenOffers()) {
|
||||
if (getOpenOfferGroup(openOffer.getGroupId()).size() > 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public List<SignedOffer> getSignedOffers() {
|
||||
synchronized (signedOffers) {
|
||||
return new ArrayList<>(signedOffers.getObservableList());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public ObservableList<SignedOffer> getObservableSignedOffersList() {
|
||||
synchronized (signedOffers) {
|
||||
return signedOffers.getObservableList();
|
||||
|
@ -846,6 +937,9 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
synchronized (openOffers) {
|
||||
openOffers.add(openOffer);
|
||||
}
|
||||
if (openOffer.getOffer().getOfferPayload().getReserveTxKeyImages() != null) {
|
||||
xmrConnectionService.getKeyImagePoller().addKeyImages(openOffer.getOffer().getOfferPayload().getReserveTxKeyImages(), OPEN_OFFER_GROUP_KEY_IMAGE_ID);
|
||||
}
|
||||
}
|
||||
|
||||
private void removeOpenOffer(OpenOffer openOffer) {
|
||||
|
@ -857,6 +951,18 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
PlaceOfferProtocol protocol = placeOfferProtocols.remove(openOffer.getId());
|
||||
if (protocol != null) protocol.cancelOffer();
|
||||
}
|
||||
if (openOffer.getOffer().getOfferPayload().getReserveTxKeyImages() != null) {
|
||||
xmrConnectionService.getKeyImagePoller().removeKeyImages(openOffer.getOffer().getOfferPayload().getReserveTxKeyImages(), OPEN_OFFER_GROUP_KEY_IMAGE_ID);
|
||||
}
|
||||
}
|
||||
|
||||
private void cancelOpenOffersOnSpent(String keyImage) {
|
||||
for (OpenOffer openOffer : getOpenOffers()) {
|
||||
if (openOffer.getOffer().getOfferPayload().getReserveTxKeyImages() != null && openOffer.getOffer().getOfferPayload().getReserveTxKeyImages().contains(keyImage)) {
|
||||
log.warn("Canceling open offer because reserved funds have been spent, offerId={}, state={}", openOffer.getId(), openOffer.getState());
|
||||
cancelOpenOffer(openOffer, null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void addSignedOffer(SignedOffer signedOffer) {
|
||||
|
@ -870,7 +976,7 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
|
||||
// add new signed offer
|
||||
signedOffers.add(signedOffer);
|
||||
signedOfferKeyImagePoller.addKeyImages(signedOffer.getReserveTxKeyImages());
|
||||
xmrConnectionService.getKeyImagePoller().addKeyImages(signedOffer.getReserveTxKeyImages(), SIGNED_OFFER_KEY_IMAGE_GROUP_ID);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -878,7 +984,7 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
log.info("Removing SignedOffer for offer {}", signedOffer.getOfferId());
|
||||
synchronized (signedOffers) {
|
||||
signedOffers.remove(signedOffer);
|
||||
signedOfferKeyImagePoller.removeKeyImages(signedOffer.getReserveTxKeyImages());
|
||||
xmrConnectionService.getKeyImagePoller().removeKeyImages(signedOffer.getReserveTxKeyImages(), SIGNED_OFFER_KEY_IMAGE_GROUP_ID);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -900,7 +1006,6 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
List<String> errorMessages = new ArrayList<String>();
|
||||
synchronized (processOffersLock) {
|
||||
List<OpenOffer> openOffers = getOpenOffers();
|
||||
removeOffersWithDuplicateKeyImages(openOffers);
|
||||
for (OpenOffer offer : openOffers) {
|
||||
if (skipOffersWithTooManyAttempts && offer.getNumProcessingAttempts() > NUM_ATTEMPTS_THRESHOLD) continue; // skip offers with too many attempts
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
|
@ -922,28 +1027,6 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
}, THREAD_ID);
|
||||
}
|
||||
|
||||
private void removeOffersWithDuplicateKeyImages(List<OpenOffer> openOffers) {
|
||||
|
||||
// collect offers with duplicate key images
|
||||
Set<String> keyImages = new HashSet<>();
|
||||
Set<OpenOffer> offersToRemove = new HashSet<>();
|
||||
for (OpenOffer openOffer : openOffers) {
|
||||
if (openOffer.getOffer().getOfferPayload().getReserveTxKeyImages() == null) continue;
|
||||
if (Collections.disjoint(keyImages, openOffer.getOffer().getOfferPayload().getReserveTxKeyImages())) {
|
||||
keyImages.addAll(openOffer.getOffer().getOfferPayload().getReserveTxKeyImages());
|
||||
} else {
|
||||
offersToRemove.add(openOffer);
|
||||
}
|
||||
}
|
||||
|
||||
// remove offers with duplicate key images
|
||||
for (OpenOffer offerToRemove : offersToRemove) {
|
||||
log.warn("Removing open offer which has duplicate key images with other open offers: {}", offerToRemove.getId());
|
||||
doCancelOffer(offerToRemove);
|
||||
openOffers.remove(offerToRemove);
|
||||
}
|
||||
}
|
||||
|
||||
private void processOffer(List<OpenOffer> openOffers, OpenOffer openOffer, TransactionResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) {
|
||||
|
||||
// skip if already processing
|
||||
|
@ -993,33 +1076,40 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
return;
|
||||
}
|
||||
|
||||
// validate non-pending state
|
||||
if (!openOffer.isPending()) {
|
||||
boolean isValid = true;
|
||||
Arbitrator arbitrator = user.getAcceptedArbitratorByAddress(openOffer.getOffer().getOfferPayload().getArbitratorSigner());
|
||||
if (openOffer.getOffer().getOfferPayload().getArbitratorSigner() == null) {
|
||||
isValid = false;
|
||||
} else if (arbitrator == null) {
|
||||
log.warn("Offer {} signed by unavailable arbitrator, reposting", openOffer.getId());
|
||||
isValid = false;
|
||||
} else if (!HavenoUtils.isArbitratorSignatureValid(openOffer.getOffer().getOfferPayload(), arbitrator)) {
|
||||
log.warn("Offer {} has invalid arbitrator signature, reposting", openOffer.getId());
|
||||
isValid = false;
|
||||
// handle pending offer
|
||||
if (openOffer.isPending()) {
|
||||
|
||||
// only process the first offer of a pending clone group
|
||||
if (openOffer.getGroupId() != null) {
|
||||
List<OpenOffer> openOfferClones = getOpenOfferGroup(openOffer.getGroupId());
|
||||
if (openOfferClones.size() > 1 && !openOfferClones.get(0).getId().equals(openOffer.getId()) && openOfferClones.get(0).isPending()) {
|
||||
resultHandler.handleResult(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if ((openOffer.getOffer().getOfferPayload().getReserveTxKeyImages() != null || openOffer.getOffer().getOfferPayload().getReserveTxKeyImages().isEmpty()) && (openOffer.getReserveTxHash() == null || openOffer.getReserveTxHash().isEmpty())) {
|
||||
log.warn("Offer {} is missing reserve tx hash but has reserved key images, reposting", openOffer.getId());
|
||||
isValid = false;
|
||||
}
|
||||
if (isValid) {
|
||||
resultHandler.handleResult(null);
|
||||
} else {
|
||||
|
||||
// validate non-pending state
|
||||
try {
|
||||
validateSignedState(openOffer);
|
||||
resultHandler.handleResult(null); // done processing if non-pending state is valid
|
||||
return;
|
||||
} else {
|
||||
} catch (Exception e) {
|
||||
log.warn(e.getMessage());
|
||||
|
||||
// reset arbitrator signature
|
||||
openOffer.getOffer().getOfferPayload().setArbitratorSignature(null);
|
||||
openOffer.getOffer().getOfferPayload().setArbitratorSigner(null);
|
||||
if (openOffer.isAvailable()) openOffer.setState(OpenOffer.State.PENDING);
|
||||
}
|
||||
}
|
||||
|
||||
// sign and post offer if already funded
|
||||
if (openOffer.getReserveTxHash() != null) {
|
||||
signAndPostOffer(openOffer, false, resultHandler, errorMessageHandler);
|
||||
return;
|
||||
}
|
||||
|
||||
// cancel offer if scheduled txs unavailable
|
||||
if (openOffer.getScheduledTxHashes() != null) {
|
||||
boolean scheduledTxsAvailable = true;
|
||||
|
@ -1037,12 +1127,6 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
}
|
||||
}
|
||||
|
||||
// sign and post offer if already funded
|
||||
if (openOffer.getReserveTxHash() != null) {
|
||||
signAndPostOffer(openOffer, false, resultHandler, errorMessageHandler);
|
||||
return;
|
||||
}
|
||||
|
||||
// get amount needed to reserve offer
|
||||
BigInteger amountNeeded = openOffer.getOffer().getAmountNeeded();
|
||||
|
||||
|
@ -1084,6 +1168,21 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
}).start();
|
||||
}
|
||||
|
||||
private void validateSignedState(OpenOffer openOffer) {
|
||||
Arbitrator arbitrator = user.getAcceptedArbitratorByAddress(openOffer.getOffer().getOfferPayload().getArbitratorSigner());
|
||||
if (openOffer.getOffer().getOfferPayload().getArbitratorSigner() == null) {
|
||||
throw new IllegalArgumentException("Offer " + openOffer.getId() + " has no arbitrator signer");
|
||||
} else if (openOffer.getOffer().getOfferPayload().getArbitratorSignature() == null) {
|
||||
throw new IllegalArgumentException("Offer " + openOffer.getId() + " has no arbitrator signature");
|
||||
} else if (arbitrator == null) {
|
||||
throw new IllegalArgumentException("Offer " + openOffer.getId() + " signed by unavailable arbitrator");
|
||||
} else if (!HavenoUtils.isArbitratorSignatureValid(openOffer.getOffer().getOfferPayload(), arbitrator)) {
|
||||
throw new IllegalArgumentException("Offer " + openOffer.getId() + " has invalid arbitrator signature");
|
||||
} else if (openOffer.getOffer().getOfferPayload().getReserveTxKeyImages() == null || openOffer.getOffer().getOfferPayload().getReserveTxKeyImages().isEmpty() || openOffer.getReserveTxHash() == null || openOffer.getReserveTxHash().isEmpty()) {
|
||||
throw new IllegalArgumentException("Offer " + openOffer.getId() + " is missing reserve tx hash or key images");
|
||||
}
|
||||
}
|
||||
|
||||
private MoneroTxWallet getSplitOutputFundingTx(List<OpenOffer> openOffers, OpenOffer openOffer) {
|
||||
XmrAddressEntry addressEntry = xmrWalletService.getOrCreateAddressEntry(openOffer.getId(), XmrAddressEntry.Context.OFFER_FUNDING);
|
||||
return getSplitOutputFundingTx(openOffers, openOffer, openOffer.getOffer().getAmountNeeded(), addressEntry.getSubaddressIndex());
|
||||
|
@ -2047,7 +2146,10 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
|
||||
private boolean preventedFromPublishing(OpenOffer openOffer) {
|
||||
if (!Boolean.TRUE.equals(xmrConnectionService.isConnected())) return true;
|
||||
return openOffer.isDeactivated() || openOffer.isCanceled() || openOffer.getOffer().getOfferPayload().getArbitratorSigner() == null;
|
||||
return openOffer.isDeactivated() ||
|
||||
openOffer.isCanceled() ||
|
||||
openOffer.getOffer().getOfferPayload().getArbitratorSigner() == null ||
|
||||
hasConflictingClone(openOffer);
|
||||
}
|
||||
|
||||
private void startPeriodicRepublishOffersTimer() {
|
||||
|
|
|
@ -19,7 +19,9 @@ package haveno.core.offer.placeoffer.tasks;
|
|||
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import haveno.common.taskrunner.Task;
|
||||
import haveno.common.taskrunner.TaskRunner;
|
||||
|
@ -78,6 +80,12 @@ public class MakerReserveOfferFunds extends Task<PlaceOfferModel> {
|
|||
XmrAddressEntry fundingEntry = model.getXmrWalletService().getAddressEntry(offer.getId(), XmrAddressEntry.Context.OFFER_FUNDING).orElse(null);
|
||||
Integer preferredSubaddressIndex = fundingEntry == null ? null : fundingEntry.getSubaddressIndex();
|
||||
|
||||
// copy address entries to clones
|
||||
for (OpenOffer offerClone : model.getOpenOfferManager().getOpenOfferGroup(model.getOpenOffer().getGroupId())) {
|
||||
if (offerClone.getId().equals(offer.getId())) continue; // skip self
|
||||
model.getXmrWalletService().cloneAddressEntries(openOffer.getId(), offerClone.getId());
|
||||
}
|
||||
|
||||
// attempt creating reserve tx
|
||||
MoneroTxWallet reserveTx = null;
|
||||
try {
|
||||
|
@ -120,23 +128,42 @@ public class MakerReserveOfferFunds extends Task<PlaceOfferModel> {
|
|||
List<String> reservedKeyImages = new ArrayList<String>();
|
||||
for (MoneroOutput input : reserveTx.getInputs()) reservedKeyImages.add(input.getKeyImage().getHex());
|
||||
|
||||
// update offer state
|
||||
openOffer.setReserveTxHash(reserveTx.getHash());
|
||||
openOffer.setReserveTxHex(reserveTx.getFullHex());
|
||||
openOffer.setReserveTxKey(reserveTx.getKey());
|
||||
offer.getOfferPayload().setReserveTxKeyImages(reservedKeyImages);
|
||||
// update offer state including clones
|
||||
if (openOffer.getGroupId() == null) {
|
||||
openOffer.setReserveTxHash(reserveTx.getHash());
|
||||
openOffer.setReserveTxHex(reserveTx.getFullHex());
|
||||
openOffer.setReserveTxKey(reserveTx.getKey());
|
||||
offer.getOfferPayload().setReserveTxKeyImages(reservedKeyImages);
|
||||
} else {
|
||||
for (OpenOffer offerClone : model.getOpenOfferManager().getOpenOfferGroup(model.getOpenOffer().getGroupId())) {
|
||||
offerClone.setReserveTxHash(reserveTx.getHash());
|
||||
offerClone.setReserveTxHex(reserveTx.getFullHex());
|
||||
offerClone.setReserveTxKey(reserveTx.getKey());
|
||||
offerClone.getOffer().getOfferPayload().setReserveTxKeyImages(reservedKeyImages);
|
||||
}
|
||||
}
|
||||
|
||||
// reset offer funding address entry if unused
|
||||
// reset offer funding address entries if unused
|
||||
if (fundingEntry != null) {
|
||||
|
||||
// get reserve tx inputs
|
||||
List<MoneroOutputWallet> inputs = model.getXmrWalletService().getOutputs(reservedKeyImages);
|
||||
boolean usesFundingEntry = false;
|
||||
|
||||
// collect subaddress indices of inputs
|
||||
Set<Integer> inputSubaddressIndices = new HashSet<>();
|
||||
for (MoneroOutputWallet input : inputs) {
|
||||
if (input.getAccountIndex() == 0 && input.getSubaddressIndex() == fundingEntry.getSubaddressIndex()) {
|
||||
usesFundingEntry = true;
|
||||
break;
|
||||
if (input.getAccountIndex() == 0) inputSubaddressIndices.add(input.getSubaddressIndex());
|
||||
}
|
||||
|
||||
// swap funding address entries to available if unused
|
||||
for (OpenOffer clone : model.getOpenOfferManager().getOpenOfferGroup(model.getOpenOffer().getGroupId())) {
|
||||
XmrAddressEntry cloneFundingEntry = model.getXmrWalletService().getAddressEntry(clone.getId(), XmrAddressEntry.Context.OFFER_FUNDING).orElse(null);
|
||||
if (cloneFundingEntry != null && !inputSubaddressIndices.contains(cloneFundingEntry.getSubaddressIndex())) {
|
||||
if (inputSubaddressIndices.contains(cloneFundingEntry.getSubaddressIndex())) {
|
||||
model.getXmrWalletService().swapAddressEntryToAvailable(offer.getId(), XmrAddressEntry.Context.OFFER_FUNDING);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!usesFundingEntry) model.getXmrWalletService().swapAddressEntryToAvailable(offer.getId(), XmrAddressEntry.Context.OFFER_FUNDING);
|
||||
}
|
||||
}
|
||||
complete();
|
||||
|
|
|
@ -36,6 +36,16 @@ public class MaybeAddToOfferBook extends Task<PlaceOfferModel> {
|
|||
try {
|
||||
runInterceptHook();
|
||||
checkNotNull(model.getSignOfferResponse().getSignedOfferPayload().getArbitratorSignature(), "Offer's arbitrator signature is null: " + model.getOpenOffer().getOffer().getId());
|
||||
|
||||
// deactivate if conflicting offer exists
|
||||
if (model.getOpenOfferManager().hasConflictingClone(model.getOpenOffer())) {
|
||||
model.getOpenOffer().setState(OpenOffer.State.DEACTIVATED);
|
||||
model.setOfferAddedToOfferBook(false);
|
||||
complete();
|
||||
return;
|
||||
}
|
||||
|
||||
// add to offer book and activate if pending or available
|
||||
if (model.getOpenOffer().isPending() || model.getOpenOffer().isAvailable()) {
|
||||
model.getOfferBookService().addOffer(new Offer(model.getSignOfferResponse().getSignedOfferPayload()),
|
||||
() -> {
|
||||
|
|
|
@ -197,7 +197,7 @@ public final class MediationManager extends DisputeManager<MediationDisputeList>
|
|||
}
|
||||
} else {
|
||||
Optional<OpenOffer> openOfferOptional = openOfferManager.getOpenOffer(tradeId);
|
||||
openOfferOptional.ifPresent(openOffer -> openOfferManager.closeOpenOffer(openOffer.getOffer()));
|
||||
openOfferOptional.ifPresent(openOffer -> openOfferManager.closeSpentOffer(openOffer.getOffer()));
|
||||
}
|
||||
sendAckMessage(chatMessage, dispute.getAgentPubKeyRing(), true, null);
|
||||
|
||||
|
|
|
@ -197,7 +197,7 @@ public final class RefundManager extends DisputeManager<RefundDisputeList> {
|
|||
}
|
||||
} else {
|
||||
Optional<OpenOffer> openOfferOptional = openOfferManager.getOpenOffer(tradeId);
|
||||
openOfferOptional.ifPresent(openOffer -> openOfferManager.closeOpenOffer(openOffer.getOffer()));
|
||||
openOfferOptional.ifPresent(openOffer -> openOfferManager.closeSpentOffer(openOffer.getOffer()));
|
||||
}
|
||||
sendAckMessage(chatMessage, dispute.getAgentPubKeyRing(), true, null);
|
||||
|
||||
|
@ -206,7 +206,7 @@ public final class RefundManager extends DisputeManager<RefundDisputeList> {
|
|||
tradeManager.closeDisputedTrade(tradeId, Trade.DisputeState.REFUND_REQUEST_CLOSED);
|
||||
} else {
|
||||
Optional<OpenOffer> openOfferOptional = openOfferManager.getOpenOffer(tradeId);
|
||||
openOfferOptional.ifPresent(openOffer -> openOfferManager.closeOpenOffer(openOffer.getOffer()));
|
||||
openOfferOptional.ifPresent(openOffer -> openOfferManager.closeSpentOffer(openOffer.getOffer()));
|
||||
}
|
||||
|
||||
requestPersistence();
|
||||
|
|
|
@ -710,7 +710,7 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model {
|
|||
maybePublishTradeStatistics();
|
||||
|
||||
// reset address entries
|
||||
processModel.getXmrWalletService().resetAddressEntriesForTrade(getId());
|
||||
processModel.getXmrWalletService().swapPayoutAddressEntryToAvailable(getId());
|
||||
}
|
||||
|
||||
// handle when payout unlocks
|
||||
|
@ -1755,7 +1755,7 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model {
|
|||
// close open offer
|
||||
if (this instanceof MakerTrade && processModel.getOpenOfferManager().getOpenOffer(getId()).isPresent()) {
|
||||
log.info("Closing open offer because {} {} was restored after protocol error", getClass().getSimpleName(), getShortId());
|
||||
processModel.getOpenOfferManager().closeOpenOffer(checkNotNull(getOffer()));
|
||||
processModel.getOpenOfferManager().closeSpentOffer(checkNotNull(getOffer()));
|
||||
}
|
||||
|
||||
// re-freeze outputs
|
||||
|
@ -2371,7 +2371,7 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model {
|
|||
}
|
||||
|
||||
public boolean hasBuyerAsTakerWithoutDeposit() {
|
||||
return getBuyer() == getTaker() && BigInteger.ZERO.equals(getBuyerSecurityDepositBeforeMiningFee());
|
||||
return getOffer().getOfferPayload().isBuyerAsTakerWithoutDeposit();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -2945,7 +2945,7 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model {
|
|||
|
||||
// close open offer or reset address entries
|
||||
if (this instanceof MakerTrade) {
|
||||
processModel.getOpenOfferManager().closeOpenOffer(getOffer());
|
||||
processModel.getOpenOfferManager().closeSpentOffer(getOffer());
|
||||
HavenoUtils.notificationService.sendTradeNotification(this, Phase.DEPOSITS_PUBLISHED, "Offer Taken", "Your offer " + offer.getId() + " has been accepted"); // TODO (woodser): use language translation
|
||||
} else {
|
||||
getXmrWalletService().resetAddressEntriesForOpenOffer(getId());
|
||||
|
|
|
@ -977,7 +977,7 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi
|
|||
removeTrade(trade, true);
|
||||
|
||||
// TODO The address entry should have been removed already. Check and if its the case remove that.
|
||||
xmrWalletService.resetAddressEntriesForTrade(trade.getId());
|
||||
xmrWalletService.swapPayoutAddressEntryToAvailable(trade.getId());
|
||||
requestPersistence();
|
||||
}
|
||||
|
||||
|
@ -1011,7 +1011,7 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi
|
|||
if (tradeOptional.isPresent()) {
|
||||
Trade trade = tradeOptional.get();
|
||||
trade.setDisputeState(disputeState);
|
||||
xmrWalletService.resetAddressEntriesForTrade(trade.getId());
|
||||
xmrWalletService.swapPayoutAddressEntryToAvailable(trade.getId());
|
||||
requestPersistence();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -89,7 +89,7 @@ public class TakerReserveTradeFunds extends TradeTask {
|
|||
} catch (Exception e) {
|
||||
|
||||
// reset state with wallet lock
|
||||
model.getXmrWalletService().resetAddressEntriesForTrade(trade.getId());
|
||||
model.getXmrWalletService().swapPayoutAddressEntryToAvailable(trade.getId());
|
||||
if (reserveTx != null) {
|
||||
model.getXmrWalletService().thawOutputs(HavenoUtils.getInputKeyImages(reserveTx));
|
||||
trade.getSelf().setReserveTxKeyImages(null);
|
||||
|
|
|
@ -122,12 +122,12 @@ public final class XmrAddressEntry implements PersistablePayload {
|
|||
return context == Context.OFFER_FUNDING;
|
||||
}
|
||||
|
||||
public boolean isTrade() {
|
||||
public boolean isTradePayout() {
|
||||
return context == Context.TRADE_PAYOUT;
|
||||
}
|
||||
|
||||
public boolean isTradable() {
|
||||
return isOpenOffer() || isTrade();
|
||||
return isOpenOffer() || isTradePayout();
|
||||
}
|
||||
|
||||
public Coin getCoinLockedInMultiSig() {
|
||||
|
|
|
@ -110,10 +110,25 @@ public final class XmrAddressEntryList implements PersistableEnvelope, Persisted
|
|||
}
|
||||
|
||||
public void swapToAvailable(XmrAddressEntry addressEntry) {
|
||||
boolean setChangedByRemove = entrySet.remove(addressEntry);
|
||||
boolean setChangedByAdd = entrySet.add(new XmrAddressEntry(addressEntry.getSubaddressIndex(), addressEntry.getAddressString(),
|
||||
XmrAddressEntry.Context.AVAILABLE));
|
||||
if (setChangedByRemove || setChangedByAdd) {
|
||||
log.info("swapToAvailable addressEntry to swap={}", addressEntry);
|
||||
if (entrySet.remove(addressEntry)) {
|
||||
requestPersistence();
|
||||
}
|
||||
// If we have an address entry which shared the address with another one (shared funding use case)
|
||||
// then we do not swap to available as we need to protect the address of the remaining entry.
|
||||
boolean entryWithSameContextStillExists = entrySet.stream().anyMatch(entry -> {
|
||||
if (addressEntry.getAddressString() != null) {
|
||||
return addressEntry.getAddressString().equals(entry.getAddressString()) &&
|
||||
addressEntry.getContext() == entry.getContext();
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (entryWithSameContextStillExists) {
|
||||
return;
|
||||
}
|
||||
// no other uses of the address context remain, so make it available
|
||||
if (entrySet.add(new XmrAddressEntry(addressEntry.getSubaddressIndex(), addressEntry.getAddressString(),
|
||||
XmrAddressEntry.Context.AVAILABLE))) {
|
||||
requestPersistence();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,6 +31,7 @@ public class Restrictions {
|
|||
public static BigInteger MIN_TRADE_AMOUNT = HavenoUtils.xmrToAtomicUnits(0.1);
|
||||
public static BigInteger MIN_SECURITY_DEPOSIT = HavenoUtils.xmrToAtomicUnits(0.1);
|
||||
public static int MAX_EXTRA_INFO_LENGTH = 1500;
|
||||
public static int MAX_OFFERS_WITH_SHARED_FUNDS = 10;
|
||||
|
||||
// At mediation we require a min. payout to the losing party to keep incentive for the trader to accept the
|
||||
// mediated payout. For Refund agent cases we do not have that restriction.
|
||||
|
|
|
@ -36,15 +36,13 @@ import haveno.core.trade.HavenoUtils;
|
|||
|
||||
/**
|
||||
* Poll for changes to the spent status of key images.
|
||||
*
|
||||
* TODO: move to monero-java?
|
||||
*/
|
||||
@Slf4j
|
||||
public class XmrKeyImagePoller {
|
||||
|
||||
private MoneroDaemon daemon;
|
||||
private long refreshPeriodMs;
|
||||
private List<String> keyImages = new ArrayList<String>();
|
||||
private Map<String, Set<String>> keyImageGroups = new HashMap<String, Set<String>>();
|
||||
private Set<XmrKeyImageListener> listeners = new HashSet<XmrKeyImageListener>();
|
||||
private TaskLooper looper;
|
||||
private Map<String, MoneroKeyImageSpentStatus> lastStatuses = new HashMap<String, MoneroKeyImageSpentStatus>();
|
||||
|
@ -53,9 +51,6 @@ public class XmrKeyImagePoller {
|
|||
|
||||
/**
|
||||
* Construct the listener.
|
||||
*
|
||||
* @param refreshPeriodMs - refresh period in milliseconds
|
||||
* @param keyImages - key images to listen to
|
||||
*/
|
||||
public XmrKeyImagePoller() {
|
||||
looper = new TaskLooper(() -> poll());
|
||||
|
@ -64,14 +59,13 @@ public class XmrKeyImagePoller {
|
|||
/**
|
||||
* Construct the listener.
|
||||
*
|
||||
* @param daemon - the Monero daemon to poll
|
||||
* @param refreshPeriodMs - refresh period in milliseconds
|
||||
* @param keyImages - key images to listen to
|
||||
*/
|
||||
public XmrKeyImagePoller(MoneroDaemon daemon, long refreshPeriodMs, String... keyImages) {
|
||||
public XmrKeyImagePoller(MoneroDaemon daemon, long refreshPeriodMs) {
|
||||
looper = new TaskLooper(() -> poll());
|
||||
setDaemon(daemon);
|
||||
setRefreshPeriodMs(refreshPeriodMs);
|
||||
setKeyImages(keyImages);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -131,36 +125,13 @@ public class XmrKeyImagePoller {
|
|||
return refreshPeriodMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a copy of the key images being listened to.
|
||||
*
|
||||
* @return the key images to listen to
|
||||
*/
|
||||
public Collection<String> getKeyImages() {
|
||||
synchronized (keyImages) {
|
||||
return new ArrayList<String>(keyImages);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the key images to listen to.
|
||||
*
|
||||
* @return the key images to listen to
|
||||
*/
|
||||
public void setKeyImages(String... keyImages) {
|
||||
synchronized (this.keyImages) {
|
||||
this.keyImages.clear();
|
||||
addKeyImages(keyImages);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a key image to listen to.
|
||||
*
|
||||
* @param keyImage - the key image to listen to
|
||||
*/
|
||||
public void addKeyImage(String keyImage) {
|
||||
addKeyImages(keyImage);
|
||||
public void addKeyImage(String keyImage, String groupId) {
|
||||
addKeyImages(Arrays.asList(keyImage), groupId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -168,50 +139,26 @@ public class XmrKeyImagePoller {
|
|||
*
|
||||
* @param keyImages - key images to listen to
|
||||
*/
|
||||
public void addKeyImages(String... keyImages) {
|
||||
addKeyImages(Arrays.asList(keyImages));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add key images to listen to.
|
||||
*
|
||||
* @param keyImages - key images to listen to
|
||||
*/
|
||||
public void addKeyImages(Collection<String> keyImages) {
|
||||
synchronized (this.keyImages) {
|
||||
for (String keyImage : keyImages) if (!this.keyImages.contains(keyImage)) this.keyImages.add(keyImage);
|
||||
public void addKeyImages(Collection<String> keyImages, String groupId) {
|
||||
synchronized (this.keyImageGroups) {
|
||||
if (!keyImageGroups.containsKey(groupId)) keyImageGroups.put(groupId, new HashSet<String>());
|
||||
Set<String> keyImagesGroup = keyImageGroups.get(groupId);
|
||||
keyImagesGroup.addAll(keyImages);
|
||||
refreshPolling();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a key image to listen to.
|
||||
*
|
||||
* @param keyImage - the key image to unlisten to
|
||||
*/
|
||||
public void removeKeyImage(String keyImage) {
|
||||
removeKeyImages(keyImage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove key images to listen to.
|
||||
*
|
||||
* @param keyImages - key images to unlisten to
|
||||
*/
|
||||
public void removeKeyImages(String... keyImages) {
|
||||
removeKeyImages(Arrays.asList(keyImages));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove key images to listen to.
|
||||
*
|
||||
* @param keyImages - key images to unlisten to
|
||||
*/
|
||||
public void removeKeyImages(Collection<String> keyImages) {
|
||||
synchronized (this.keyImages) {
|
||||
Set<String> containedKeyImages = new HashSet<String>(keyImages);
|
||||
containedKeyImages.retainAll(this.keyImages);
|
||||
this.keyImages.removeAll(containedKeyImages);
|
||||
public void removeKeyImages(Collection<String> keyImages, String groupId) {
|
||||
synchronized (keyImageGroups) {
|
||||
Set<String> keyImagesGroup = keyImageGroups.get(groupId);
|
||||
if (keyImagesGroup == null) return;
|
||||
keyImagesGroup.removeAll(keyImages);
|
||||
if (keyImagesGroup.isEmpty()) keyImageGroups.remove(groupId);
|
||||
synchronized (lastStatuses) {
|
||||
for (String lastKeyImage : new HashSet<>(lastStatuses.keySet())) lastStatuses.remove(lastKeyImage);
|
||||
}
|
||||
|
@ -219,11 +166,34 @@ public class XmrKeyImagePoller {
|
|||
}
|
||||
}
|
||||
|
||||
public void removeKeyImages(String groupId) {
|
||||
synchronized (keyImageGroups) {
|
||||
Set<String> keyImagesGroup = keyImageGroups.get(groupId);
|
||||
if (keyImagesGroup == null) return;
|
||||
keyImageGroups.remove(groupId);
|
||||
Set<String> keyImages = getKeyImages();
|
||||
synchronized (lastStatuses) {
|
||||
for (String keyImage : keyImagesGroup) {
|
||||
if (lastStatuses.containsKey(keyImage) && !keyImages.contains(keyImage)) {
|
||||
lastStatuses.remove(keyImage);
|
||||
}
|
||||
}
|
||||
}
|
||||
refreshPolling();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the key images which stops polling.
|
||||
*/
|
||||
public void clearKeyImages() {
|
||||
setKeyImages();
|
||||
synchronized (keyImageGroups) {
|
||||
keyImageGroups.clear();
|
||||
synchronized (lastStatuses) {
|
||||
lastStatuses.clear();
|
||||
}
|
||||
refreshPolling();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -235,10 +205,20 @@ public class XmrKeyImagePoller {
|
|||
public Boolean isSpent(String keyImage) {
|
||||
synchronized (lastStatuses) {
|
||||
if (!lastStatuses.containsKey(keyImage)) return null;
|
||||
return lastStatuses.get(keyImage) != MoneroKeyImageSpentStatus.NOT_SPENT;
|
||||
return XmrKeyImagePoller.isSpent(lastStatuses.get(keyImage));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the given key image spent status is spent.
|
||||
*
|
||||
* @param status the key image spent status to check
|
||||
* @return true if the key image is spent, false if unspent
|
||||
*/
|
||||
public static boolean isSpent(MoneroKeyImageSpentStatus status) {
|
||||
return status != MoneroKeyImageSpentStatus.NOT_SPENT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last known spent status for the given key image.
|
||||
*
|
||||
|
@ -257,16 +237,11 @@ public class XmrKeyImagePoller {
|
|||
return;
|
||||
}
|
||||
|
||||
// get copy of key images to fetch
|
||||
List<String> keyImages = new ArrayList<String>(getKeyImages());
|
||||
|
||||
// fetch spent statuses
|
||||
List<MoneroKeyImageSpentStatus> spentStatuses = null;
|
||||
List<String> keyImages = new ArrayList<String>(getKeyImages());
|
||||
try {
|
||||
if (keyImages.isEmpty()) spentStatuses = new ArrayList<MoneroKeyImageSpentStatus>();
|
||||
else {
|
||||
spentStatuses = daemon.getKeyImageSpentStatuses(keyImages); // TODO monero-java: if order of getKeyImageSpentStatuses is guaranteed, then it should take list parameter
|
||||
}
|
||||
spentStatuses = keyImages.isEmpty() ? new ArrayList<MoneroKeyImageSpentStatus>() : daemon.getKeyImageSpentStatuses(keyImages); // TODO monero-java: if order of getKeyImageSpentStatuses is guaranteed, then it should take list parameter
|
||||
} catch (Exception e) {
|
||||
|
||||
// limit error logging
|
||||
|
@ -297,8 +272,8 @@ public class XmrKeyImagePoller {
|
|||
}
|
||||
|
||||
private void refreshPolling() {
|
||||
synchronized (keyImages) {
|
||||
setIsPolling(keyImages.size() > 0 && listeners.size() > 0);
|
||||
synchronized (keyImageGroups) {
|
||||
setIsPolling(!getKeyImages().isEmpty() && listeners.size() > 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -313,4 +288,14 @@ public class XmrKeyImagePoller {
|
|||
looper.stop();
|
||||
}
|
||||
}
|
||||
|
||||
private Set<String> getKeyImages() {
|
||||
Set<String> allKeyImages = new HashSet<String>();
|
||||
synchronized (keyImageGroups) {
|
||||
for (Set<String> keyImagesGroup : keyImageGroups.values()) {
|
||||
allKeyImages.addAll(keyImagesGroup);
|
||||
}
|
||||
}
|
||||
return allKeyImages;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1008,12 +1008,21 @@ public class XmrWalletService extends XmrWalletBase {
|
|||
public synchronized void swapAddressEntryToAvailable(String offerId, XmrAddressEntry.Context context) {
|
||||
Optional<XmrAddressEntry> addressEntryOptional = getAddressEntryListAsImmutableList().stream().filter(e -> offerId.equals(e.getOfferId())).filter(e -> context == e.getContext()).findAny();
|
||||
addressEntryOptional.ifPresent(e -> {
|
||||
log.info("swap addressEntry with address {} and offerId {} from context {} to available", e.getAddressString(), e.getOfferId(), context);
|
||||
xmrAddressEntryList.swapToAvailable(e);
|
||||
saveAddressEntryList();
|
||||
});
|
||||
}
|
||||
|
||||
public synchronized void cloneAddressEntries(String offerId, String cloneOfferId) {
|
||||
List<XmrAddressEntry> entries = getAddressEntryListAsImmutableList().stream().filter(e -> offerId.equals(e.getOfferId())).collect(Collectors.toList());
|
||||
for (XmrAddressEntry entry : entries) {
|
||||
XmrAddressEntry clonedEntry = new XmrAddressEntry(entry.getSubaddressIndex(), entry.getAddressString(), entry.getContext(), cloneOfferId, null);
|
||||
Optional<XmrAddressEntry> existingEntry = getAddressEntry(clonedEntry.getOfferId(), clonedEntry.getContext());
|
||||
if (existingEntry.isPresent()) continue;
|
||||
xmrAddressEntryList.addAddressEntry(clonedEntry);
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void resetAddressEntriesForOpenOffer(String offerId) {
|
||||
log.info("resetAddressEntriesForOpenOffer offerId={}", offerId);
|
||||
|
||||
|
@ -1031,7 +1040,7 @@ public class XmrWalletService extends XmrWalletBase {
|
|||
if (trade == null || trade.isPayoutUnlocked()) swapAddressEntryToAvailable(offerId, XmrAddressEntry.Context.TRADE_PAYOUT);
|
||||
}
|
||||
|
||||
public synchronized void resetAddressEntriesForTrade(String offerId) {
|
||||
public synchronized void swapPayoutAddressEntryToAvailable(String offerId) {
|
||||
swapAddressEntryToAvailable(offerId, XmrAddressEntry.Context.TRADE_PAYOUT);
|
||||
}
|
||||
|
||||
|
@ -1191,26 +1200,34 @@ public class XmrWalletService extends XmrWalletBase {
|
|||
|
||||
// TODO (woodser): update balance and other listening
|
||||
public void addBalanceListener(XmrBalanceListener listener) {
|
||||
if (!balanceListeners.contains(listener)) balanceListeners.add(listener);
|
||||
if (listener == null) throw new IllegalArgumentException("Cannot add null balance listener");
|
||||
synchronized (balanceListeners) {
|
||||
if (!balanceListeners.contains(listener)) balanceListeners.add(listener);
|
||||
}
|
||||
}
|
||||
|
||||
public void removeBalanceListener(XmrBalanceListener listener) {
|
||||
balanceListeners.remove(listener);
|
||||
if (listener == null) throw new IllegalArgumentException("Cannot add null balance listener");
|
||||
synchronized (balanceListeners) {
|
||||
balanceListeners.remove(listener);
|
||||
}
|
||||
}
|
||||
|
||||
public void updateBalanceListeners() {
|
||||
BigInteger availableBalance = getAvailableBalance();
|
||||
for (XmrBalanceListener balanceListener : balanceListeners) {
|
||||
BigInteger balance;
|
||||
if (balanceListener.getSubaddressIndex() != null && balanceListener.getSubaddressIndex() != 0) balance = getBalanceForSubaddress(balanceListener.getSubaddressIndex());
|
||||
else balance = availableBalance;
|
||||
ThreadUtils.submitToPool(() -> {
|
||||
try {
|
||||
balanceListener.onBalanceChanged(balance);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to notify balance listener of change: {}\n", e.getMessage(), e);
|
||||
}
|
||||
});
|
||||
synchronized (balanceListeners) {
|
||||
for (XmrBalanceListener balanceListener : balanceListeners) {
|
||||
BigInteger balance;
|
||||
if (balanceListener.getSubaddressIndex() != null && balanceListener.getSubaddressIndex() != 0) balance = getBalanceForSubaddress(balanceListener.getSubaddressIndex());
|
||||
else balance = availableBalance;
|
||||
ThreadUtils.submitToPool(() -> {
|
||||
try {
|
||||
balanceListener.onBalanceChanged(balance);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to notify balance listener of change: {}\n", e.getMessage(), e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue