support cloning up to 10 offers with shared reserved funds (#1668)

This commit is contained in:
woodser 2025-04-05 17:29:55 -04:00 committed by GitHub
parent 7e3a47de4a
commit 40e18890d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 2006 additions and 611 deletions

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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();

View file

@ -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
///////////////////////////////////////////////////////////////////////////////////////////

View file

@ -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);
}
}

View file

@ -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
///////////////////////////////////////////////////////////////////////////////////////////

View file

@ -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}";
}
}

View file

@ -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() {

View file

@ -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();

View file

@ -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()),
() -> {

View file

@ -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);

View file

@ -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();

View file

@ -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());

View file

@ -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();
}
}

View file

@ -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);

View file

@ -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() {

View file

@ -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();
}
}

View file

@ -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.

View file

@ -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;
}
}

View file

@ -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);
}
});
}
}
}