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

View File

@ -103,7 +103,6 @@ shared.XMRMinMax=XMR (min - max)
shared.removeOffer=Remove offer
shared.dontRemoveOffer=Don't remove offer
shared.editOffer=Edit offer
shared.duplicateOffer=Duplicate offer
shared.openLargeQRWindow=Open large QR code window
shared.chooseTradingAccount=Choose trading account
shared.faq=Visit FAQ page
@ -385,6 +384,21 @@ offerbook.xmrAutoConf=Is auto-confirm enabled
offerbook.buyXmrWith=Buy XMR with:
offerbook.sellXmrFor=Sell XMR for:
offerbook.cloneOffer=Clone offer with shared funds
offerbook.clonedOffer.tooltip=This is a cloned offer with shared funds.\n\Group ID: {0}
offerbook.nonClonedOffer.tooltip=Regular offer without shared funds.\n\Maker reserve transaction ID: {0}
offerbook.hasConflictingClone.warning=This cloned offer with shared funds cannot be activated because it uses \
the same payment method and currency as another active offer.\n\n\
You need to edit the offer and change the \
payment method or currency or deactivate the offer which has the same payment method and currency.
offerbook.cannotActivateEditedOffer.warning=You can't activate an offer that is currently edited.
offerbook.clonedOffer.headline=Cloning an offer
offerbook.clonedOffer.info=Cloning an offer creates a copy without reserving additional funds.\n\n\
This helps reduce locked capital, making it easier to list the same offer across multiple markets or payment methods.\n\n\
If one of the cloned offers is taken, the others will close automatically, since they all share the same reserved funds.\n\n\
Cloned offers must use the same trade amount and security deposit, but they must differ in payment method or currency.\n\n\
For more information about cloning offers see: [HYPERLINK:https://docs.haveno.exchange/haveno-ui/Cloning_an_offer/]
offerbook.timeSinceSigning.help=When you successfully complete a trade with a peer who has a signed payment account, your payment account is signed.\n\
{0} days later, the initial limit of {1} is lifted and your account can sign other peers'' payment accounts.
offerbook.timeSinceSigning.notSigned=Not signed yet
@ -443,8 +457,9 @@ offerbook.warning.requireUpdateToNewVersion=Your version of Haveno is not compat
offerbook.warning.offerWasAlreadyUsedInTrade=You cannot take this offer because you already took it earlier. \
It could be that your previous take-offer attempt resulted in a failed trade.
offerbook.warning.arbitratorNotValidated=This offer cannot be taken because the arbitrator is invalid
offerbook.warning.signatureNotValidated=This offer cannot be taken because the arbitrator's signature is invalid
offerbook.warning.arbitratorNotValidated=This offer cannot be taken because the arbitrator is invalid.
offerbook.warning.signatureNotValidated=This offer cannot be taken because the arbitrator's signature is invalid.
offerbook.warning.reserveFundsSpent=This offer cannot be taken because the reserved funds were already spent.
offerbook.info.sellAtMarketPrice=You will sell at market price (updated every minute).
offerbook.info.buyAtMarketPrice=You will buy at market price (updated every minute).
@ -600,6 +615,7 @@ takeOffer.tac=With taking this offer I agree to the trade conditions as defined
####################################################################
openOffer.header.triggerPrice=Trigger price
openOffer.header.groupId=Group ID
openOffer.triggerPrice=Trigger price {0}
openOffer.triggered=The offer has been deactivated because the market price reached your trigger price.\n\
Please edit the offer to define a new trigger price
@ -610,6 +626,21 @@ editOffer.publishOffer=Publishing your offer.
editOffer.failed=Editing of offer failed:\n{0}
editOffer.success=Your offer has been successfully edited.
editOffer.invalidDeposit=The buyer's security deposit is not within the constraints defined by Haveno and can no longer be edited.
editOffer.openTabWarning=You have already the \"Edit Offer\" tab open.
editOffer.hasConflictingClone=You have edited an offer which uses shared funding with another offer and your edit \
made the payment method and currency now the same as another active cloned offer. Your edited offer will be \
deactivated because it is not permitted to publish 2 offers sharing the funds with the same payment method \
and currency.\n\n\
You can edit the offer again at \"Portfolio/My open offers\" to fulfill the requirements to activate it.
cloneOffer.clone=Clone offer
cloneOffer.publishOffer=Publishing cloned offer.
cloneOffer.success=Your offer has been successfully cloned.
cloneOffer.hasConflictingClone=You have not changed the payment method or the currency. You still can clone the offer, but it will \
be deactivated and not published.\n\n\
You can edit the offer later again at \"Portfolio/My open offers\" to fulfill the requirements to activate it.\n\n\
Do you still want to clone the offer?
cloneOffer.openTabWarning=You have already the \"Clone Offer\" tab open.
####################################################################
# Portfolio
@ -620,7 +651,8 @@ portfolio.tab.pendingTrades=Open trades
portfolio.tab.history=History
portfolio.tab.failed=Failed
portfolio.tab.editOpenOffer=Edit offer
portfolio.tab.duplicateOffer=Duplicate offer
portfolio.tab.duplicateOffer=Create offer
portfolio.tab.cloneOpenOffer=Clone offer
portfolio.context.offerLikeThis=Create new offer like this...
portfolio.context.notYourOffer=You can only duplicate offers where you were the maker.

View File

@ -103,7 +103,6 @@ shared.XMRMinMax=XMR (min - max)
shared.removeOffer=Odstranit nabídku
shared.dontRemoveOffer=Neodstraňovat nabídku
shared.editOffer=Upravit nabídku
shared.duplicateOffer=Duplikovat nabídku
shared.openLargeQRWindow=Otevřít velké okno s QR kódem
shared.chooseTradingAccount=Vyberte obchodní účet
shared.faq=Navštívit stránku FAQ

View File

@ -103,7 +103,6 @@ shared.XMRMinMax=XMR (min - max)
shared.removeOffer=Teklifi kaldır
shared.dontRemoveOffer=Teklifi kaldırma
shared.editOffer=Teklifi düzenle
shared.duplicateOffer=Teklifi çoğalt
shared.openLargeQRWindow=Büyük QR kodu penceresini aç
shared.chooseTradingAccount=İşlem hesabını seç
shared.faq=SSS sayfasını ziyaret et

View File

@ -157,6 +157,7 @@ class GrpcOffersService extends OffersImplBase {
req.getIsPrivateOffer(),
req.getBuyerAsTakerWithoutDeposit(),
req.getExtraInfo(),
req.getSourceOfferId(),
offer -> {
// This result handling consumer's accept operation will return
// the new offer to the gRPC client after async placement is done.

View File

@ -366,7 +366,7 @@ public class HavenoApp extends Application implements UncaughtExceptionHandler {
}
// check for open offers
if (injector.getInstance(OpenOfferManager.class).hasOpenOffers()) {
if (injector.getInstance(OpenOfferManager.class).hasAvailableOpenOffers()) {
String key = "showOpenOfferWarnPopupAtShutDown";
if (injector.getInstance(Preferences.class).showAgain(key) && !DevEnv.isDevMode()) {
new Popup().warning(Res.get("popup.info.shutDownWithOpenOffers"))

View File

@ -822,6 +822,10 @@ tree-table-view:focused {
-fx-text-fill: -bs-rd-error-red;
}
.icon {
-fx-fill: -bs-text-color;
}
.opaque-icon {
-fx-fill: -bs-color-gray-bbb;
-fx-opacity: 1;

View File

@ -346,7 +346,7 @@ public class DepositView extends ActivatableView<VBox, Void> {
List<XmrAddressEntry> addressEntries = xmrWalletService.getAddressEntries();
List<DepositListItem> items = new ArrayList<>();
for (XmrAddressEntry addressEntry : addressEntries) {
if (addressEntry.isTrade()) continue; // skip reserved for trade
if (addressEntry.isTradePayout()) continue; // do not show trade payout addresses
items.add(new DepositListItem(addressEntry, xmrWalletService, formatter));
}

View File

@ -77,7 +77,7 @@ class WithdrawalListItem {
public final String getLabel() {
if (addressEntry.isOpenOffer())
return Res.getWithCol("shared.offerId") + " " + addressEntry.getShortOfferId();
else if (addressEntry.isTrade())
else if (addressEntry.isTradePayout())
return Res.getWithCol("shared.tradeId") + " " + addressEntry.getShortOfferId();
else if (addressEntry.getContext() == XmrAddressEntry.Context.ARBITRATOR)
return Res.get("funds.withdrawal.arbitrationFee");

View File

@ -82,7 +82,7 @@ import lombok.Getter;
import org.jetbrains.annotations.NotNull;
public abstract class MutableOfferDataModel extends OfferDataModel {
private final CreateOfferService createOfferService;
protected final CreateOfferService createOfferService;
protected final OpenOfferManager openOfferManager;
private final XmrWalletService xmrWalletService;
private final Preferences preferences;
@ -115,7 +115,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
protected PaymentAccount paymentAccount;
boolean isTabSelected;
protected double marketPriceMargin = 0;
protected double marketPriceMarginPct = 0;
@Getter
private boolean marketPriceAvailable;
protected boolean allowAmountUpdate = true;
@ -189,12 +189,12 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
}
private void addListeners() {
xmrWalletService.addBalanceListener(xmrBalanceListener);
if (xmrBalanceListener != null) xmrWalletService.addBalanceListener(xmrBalanceListener);
user.getPaymentAccountsAsObservable().addListener(paymentAccountsChangeListener);
}
private void removeListeners() {
xmrWalletService.removeBalanceListener(xmrBalanceListener);
if (xmrBalanceListener != null) xmrWalletService.removeBalanceListener(xmrBalanceListener);
user.getPaymentAccountsAsObservable().removeListener(paymentAccountsChangeListener);
}
@ -204,14 +204,16 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
///////////////////////////////////////////////////////////////////////////////////////////
// called before activate()
public boolean initWithData(OfferDirection direction, TradeCurrency tradeCurrency) {
addressEntry = xmrWalletService.getOrCreateAddressEntry(offerId, XmrAddressEntry.Context.OFFER_FUNDING);
xmrBalanceListener = new XmrBalanceListener(getAddressEntry().getSubaddressIndex()) {
@Override
public void onBalanceChanged(BigInteger balance) {
updateBalances();
}
};
public boolean initWithData(OfferDirection direction, TradeCurrency tradeCurrency, boolean initAddressEntry) {
if (initAddressEntry) {
addressEntry = xmrWalletService.getOrCreateAddressEntry(offerId, XmrAddressEntry.Context.OFFER_FUNDING);
xmrBalanceListener = new XmrBalanceListener(getAddressEntry().getSubaddressIndex()) {
@Override
public void onBalanceChanged(BigInteger balance) {
updateBalances();
}
};
}
this.direction = direction;
this.tradeCurrency = tradeCurrency;
@ -278,6 +280,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
}
protected void updateBalances() {
if (addressEntry == null) return;
super.updateBalances();
// update remaining balance
@ -302,7 +305,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
minAmount.get(),
useMarketBasedPrice.get() ? null : price.get(),
useMarketBasedPrice.get(),
useMarketBasedPrice.get() ? marketPriceMargin : 0,
useMarketBasedPrice.get() ? marketPriceMarginPct : 0,
securityDepositPct.get(),
paymentAccount,
buyerAsTakerWithoutDeposit.get(), // private offer if buyer as taker without deposit
@ -316,6 +319,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
triggerPrice,
reserveExactAmount,
false, // desktop ui resets address entries on cancel
null,
resultHandler,
errorMessageHandler);
}
@ -387,7 +391,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
volume.set(null);
minVolume.set(null);
price.set(null);
marketPriceMargin = 0;
marketPriceMarginPct = 0;
}
this.tradeCurrency = tradeCurrency;
@ -416,10 +420,6 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
updateBalances();
}
protected void setMarketPriceMarginPct(double marketPriceMargin) {
this.marketPriceMargin = marketPriceMargin;
}
///////////////////////////////////////////////////////////////////////////////////////////
// Getters
///////////////////////////////////////////////////////////////////////////////////////////
@ -469,7 +469,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
}
public double getMarketPriceMarginPct() {
return marketPriceMargin;
return marketPriceMarginPct;
}
long getMaxTradeLimit() {
@ -609,6 +609,10 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
this.triggerPrice = triggerPrice;
}
public void setMarketPriceMarginPct(double marketPriceMarginPct) {
this.marketPriceMarginPct = marketPriceMarginPct;
}
public void setReserveExactAmount(boolean reserveExactAmount) {
this.reserveExactAmount = reserveExactAmount;
}
@ -684,6 +688,14 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
return Restrictions.getMinSecurityDeposit().max(value);
}
protected double getSecurityAsPercent(Offer offer) {
BigInteger offerSellerSecurityDeposit = getBoundedSecurityDeposit(offer.getMaxSellerSecurityDeposit());
double offerSellerSecurityDepositAsPercent = CoinUtil.getAsPercentPerXmr(offerSellerSecurityDeposit,
offer.getAmount());
return Math.min(offerSellerSecurityDepositAsPercent,
Restrictions.getMaxSecurityDepositAsPercent());
}
ReadOnlyObjectProperty<BigInteger> totalToPayAsProperty() {
return totalToPay;
}

View File

@ -297,11 +297,13 @@ public abstract class MutableOfferView<M extends MutableOfferViewModel<?>> exten
model.getDataModel().onTabSelected(isSelected);
}
public void initWithData(OfferDirection direction, TradeCurrency tradeCurrency,
public void initWithData(OfferDirection direction,
TradeCurrency tradeCurrency,
boolean initAddressEntry,
OfferView.OfferActionHandler offerActionHandler) {
this.offerActionHandler = offerActionHandler;
boolean result = model.initWithData(direction, tradeCurrency);
boolean result = model.initWithData(direction, tradeCurrency, initAddressEntry);
if (!result) {
new Popup().headLine(Res.get("popup.warning.noTradingAccountSetup.headline"))

View File

@ -601,8 +601,8 @@ public abstract class MutableOfferViewModel<M extends MutableOfferDataModel> ext
// API
///////////////////////////////////////////////////////////////////////////////////////////
boolean initWithData(OfferDirection direction, TradeCurrency tradeCurrency) {
boolean result = dataModel.initWithData(direction, tradeCurrency);
boolean initWithData(OfferDirection direction, TradeCurrency tradeCurrency, boolean initAddressEntry) {
boolean result = dataModel.initWithData(direction, tradeCurrency, initAddressEntry);
if (dataModel.getAddressEntry() != null) {
addressAsString = dataModel.getAddressEntry().getAddressString();
}

View File

@ -47,11 +47,10 @@ public class CreateOfferView extends MutableOfferView<CreateOfferViewModel> {
super(model, navigation, preferences, offerDetailsWindow, btcFormatter);
}
@Override
public void initWithData(OfferDirection direction,
TradeCurrency tradeCurrency,
OfferView.OfferActionHandler offerActionHandler) {
super.initWithData(direction, tradeCurrency, offerActionHandler);
super.initWithData(direction, tradeCurrency, true, offerActionHandler);
}
@Override

View File

@ -19,13 +19,12 @@ package haveno.desktop.main.offer.offerbook;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import haveno.core.filter.FilterManager;
import haveno.common.UserThread;
import haveno.core.offer.Offer;
import haveno.core.offer.OfferBookService;
import static haveno.core.offer.OfferDirection.BUY;
import haveno.core.offer.OfferRestrictions;
import haveno.network.p2p.storage.P2PDataStorage;
import haveno.network.utils.Utils;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -49,7 +48,6 @@ public class OfferBook {
private final ObservableList<OfferBookListItem> offerBookListItems = FXCollections.observableArrayList();
private final Map<String, Integer> buyOfferCountMap = new HashMap<>();
private final Map<String, Integer> sellOfferCountMap = new HashMap<>();
private final FilterManager filterManager;
///////////////////////////////////////////////////////////////////////////////////////////
@ -57,64 +55,47 @@ public class OfferBook {
///////////////////////////////////////////////////////////////////////////////////////////
@Inject
OfferBook(OfferBookService offerBookService, FilterManager filterManager) {
OfferBook(OfferBookService offerBookService) {
this.offerBookService = offerBookService;
this.filterManager = filterManager;
offerBookService.addOfferBookChangedListener(new OfferBookService.OfferBookChangedListener() {
@Override
public void onAdded(Offer offer) {
printOfferBookListItems("Before onAdded");
// We get onAdded called every time a new ProtectedStorageEntry is received.
// Mostly it is the same OfferPayload but the ProtectedStorageEntry is different.
// We filter here to only add new offers if the same offer (using equals) was not already added and it
// is not banned.
UserThread.execute(() -> {
printOfferBookListItems("Before onAdded");
if (filterManager.isOfferIdBanned(offer.getId())) {
log.debug("Ignored banned offer. ID={}", offer.getId());
return;
}
if (OfferRestrictions.requiresNodeAddressUpdate() && !Utils.isV3Address(offer.getMakerNodeAddress().getHostName())) {
log.debug("Ignored offer with Tor v2 node address. ID={}", offer.getId());
return;
}
// Use offer.equals(offer) to see if the OfferBook list contains an exact
// match -- offer.equals(offer) includes comparisons of payload, state
// and errorMessage.
synchronized (offerBookListItems) {
boolean hasSameOffer = offerBookListItems.stream().anyMatch(item -> item.getOffer().equals(offer));
if (!hasSameOffer) {
OfferBookListItem newOfferBookListItem = new OfferBookListItem(offer);
removeDuplicateItem(newOfferBookListItem);
offerBookListItems.add(newOfferBookListItem); // Add replacement.
if (log.isDebugEnabled()) { // TODO delete debug stmt in future PR.
log.debug("onAdded: Added new offer {}\n"
+ "\twith newItem.payloadHash: {}",
offer.getId(),
newOfferBookListItem.hashOfPayload.getHex());
// Use offer.equals(offer) to see if the OfferBook list contains an exact
// match -- offer.equals(offer) includes comparisons of payload, state
// and errorMessage.
synchronized (offerBookListItems) {
boolean hasSameOffer = offerBookListItems.stream().anyMatch(item -> item.getOffer().equals(offer));
if (!hasSameOffer) {
OfferBookListItem newOfferBookListItem = new OfferBookListItem(offer);
removeDuplicateItem(newOfferBookListItem);
offerBookListItems.add(newOfferBookListItem); // Add replacement.
if (log.isDebugEnabled()) { // TODO delete debug stmt in future PR.
log.debug("onAdded: Added new offer {}\n"
+ "\twith newItem.payloadHash: {}",
offer.getId(),
newOfferBookListItem.hashOfPayload.getHex());
}
} else {
log.debug("We have the exact same offer already in our list and ignore the onAdded call. ID={}", offer.getId());
}
} else {
log.debug("We have the exact same offer already in our list and ignore the onAdded call. ID={}", offer.getId());
printOfferBookListItems("After onAdded");
}
printOfferBookListItems("After onAdded");
}
});
}
@Override
public void onRemoved(Offer offer) {
synchronized (offerBookListItems) {
printOfferBookListItems("Before onRemoved");
removeOffer(offer);
printOfferBookListItems("After onRemoved");
}
}
});
filterManager.filterProperty().addListener((observable, oldValue, newValue) -> {
if (newValue != null) {
// any notifications
UserThread.execute(() -> {
synchronized (offerBookListItems) {
printOfferBookListItems("Before onRemoved");
removeOffer(offer);
printOfferBookListItems("After onRemoved");
}
});
}
});
}
@ -212,7 +193,6 @@ public class OfferBook {
// Investigate why....
offerBookListItems.clear();
offerBookListItems.addAll(offerBookService.getOffers().stream()
.filter(this::isOfferAllowed)
.map(OfferBookListItem::new)
.collect(Collectors.toList()));
@ -248,13 +228,6 @@ public class OfferBook {
return sellOfferCountMap;
}
private boolean isOfferAllowed(Offer offer) {
boolean isBanned = filterManager.isOfferIdBanned(offer.getId());
boolean isV3NodeAddressCompliant = !OfferRestrictions.requiresNodeAddressUpdate()
|| Utils.isV3Address(offer.getMakerNodeAddress().getHostName());
return !isBanned && isV3NodeAddressCompliant;
}
private void fillOfferCountMaps() {
buyOfferCountMap.clear();
sellOfferCountMap.clear();

View File

@ -695,8 +695,13 @@ abstract public class OfferBookView<R extends GridPane, M extends OfferBookViewM
case SIGNATURE_NOT_VALIDATED:
new Popup().warning(Res.get("offerbook.warning.signatureNotValidated")).show();
break;
case RESERVE_FUNDS_SPENT:
new Popup().warning(Res.get("offerbook.warning.reserveFundsSpent")).show();
break;
case VALID:
break;
default:
log.warn("Unhandled offer filter service result: " + result);
break;
}
}

View File

@ -173,6 +173,11 @@ abstract class OfferBookViewModel extends ActivatableViewModel {
tradeCurrencyListChangeListener = c -> fillCurrencies();
// refresh filter on changes
offerBook.getOfferBookListItems().addListener((ListChangeListener<OfferBookListItem>) c -> {
filterOffers();
});
filterItemsListener = c -> {
final Optional<OfferBookListItem> highestAmountOffer = filteredItems.stream()
.max(Comparator.comparingLong(o -> o.getOffer().getAmount().longValueExact()));

View File

@ -30,6 +30,8 @@ import haveno.desktop.common.view.CachingViewLoader;
import haveno.desktop.common.view.FxmlView;
import haveno.desktop.common.view.View;
import haveno.desktop.main.MainView;
import haveno.desktop.main.overlays.popups.Popup;
import haveno.desktop.main.portfolio.cloneoffer.CloneOfferView;
import haveno.desktop.main.portfolio.closedtrades.ClosedTradesView;
import haveno.desktop.main.portfolio.duplicateoffer.DuplicateOfferView;
import haveno.desktop.main.portfolio.editoffer.EditOfferView;
@ -49,7 +51,7 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
@FXML
Tab openOffersTab, pendingTradesTab, closedTradesTab;
private Tab editOpenOfferTab, duplicateOfferTab;
private Tab editOpenOfferTab, duplicateOfferTab, cloneOpenOfferTab;
private final Tab failedTradesTab = new Tab(Res.get("portfolio.tab.failed").toUpperCase());
private Tab currentTab;
private Navigation.Listener navigationListener;
@ -61,7 +63,8 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
private final FailedTradesManager failedTradesManager;
private EditOfferView editOfferView;
private DuplicateOfferView duplicateOfferView;
private boolean editOpenOfferViewOpen;
private CloneOfferView cloneOfferView;
private boolean editOpenOfferViewOpen, cloneOpenOfferViewOpen;
private OpenOffer openOffer;
private OpenOffersView openOffersView;
@ -99,12 +102,16 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
navigation.navigateTo(MainView.class, PortfolioView.class, EditOfferView.class);
else if (newValue == duplicateOfferTab) {
navigation.navigateTo(MainView.class, PortfolioView.class, DuplicateOfferView.class);
} else if (newValue == cloneOpenOfferTab) {
navigation.navigateTo(MainView.class, PortfolioView.class, CloneOfferView.class);
}
if (oldValue != null && oldValue == editOpenOfferTab)
editOfferView.onTabSelected(false);
if (oldValue != null && oldValue == duplicateOfferTab)
duplicateOfferView.onTabSelected(false);
if (oldValue != null && oldValue == cloneOpenOfferTab)
cloneOfferView.onTabSelected(false);
};
@ -115,6 +122,8 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
onEditOpenOfferRemoved();
if (removedTabs.size() == 1 && removedTabs.get(0).equals(duplicateOfferTab))
onDuplicateOfferRemoved();
if (removedTabs.size() == 1 && removedTabs.get(0).equals(cloneOpenOfferTab))
onCloneOpenOfferRemoved();
};
}
@ -137,6 +146,16 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
navigation.navigateTo(MainView.class, this.getClass(), OpenOffersView.class);
}
private void onCloneOpenOfferRemoved() {
cloneOpenOfferViewOpen = false;
if (cloneOfferView != null) {
cloneOfferView.onClose();
cloneOfferView = null;
}
navigation.navigateTo(MainView.class, this.getClass(), OpenOffersView.class);
}
@Override
protected void activate() {
failedTradesManager.getObservableList().addListener((ListChangeListener<Trade>) c -> {
@ -166,6 +185,9 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
} else if (root.getSelectionModel().getSelectedItem() == duplicateOfferTab) {
navigation.navigateTo(MainView.class, PortfolioView.class, DuplicateOfferView.class);
if (duplicateOfferView != null) duplicateOfferView.onTabSelected(true);
} else if (root.getSelectionModel().getSelectedItem() == cloneOpenOfferTab) {
navigation.navigateTo(MainView.class, PortfolioView.class, CloneOfferView.class);
if (cloneOfferView != null) cloneOfferView.onTabSelected(true);
}
}
@ -178,10 +200,9 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
}
private void loadView(Class<? extends View> viewClass, @Nullable Object data) {
// we want to get activate/deactivate called, so we remove the old view on tab change
// TODO Don't understand the check for currentTab != editOpenOfferTab
if (currentTab != null && currentTab != editOpenOfferTab)
currentTab.setContent(null);
// nullify current tab to trigger activate/deactivate
if (currentTab != null) currentTab.setContent(null);
View view = viewLoader.load(viewClass);
@ -235,6 +256,28 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
view = viewLoader.load(OpenOffersView.class);
selectOpenOffersView((OpenOffersView) view);
}
} else if (view instanceof CloneOfferView) {
if (data instanceof OpenOffer) {
openOffer = (OpenOffer) data;
}
if (openOffer != null) {
if (cloneOfferView == null) {
cloneOfferView = (CloneOfferView) view;
cloneOfferView.applyOpenOffer(openOffer);
cloneOpenOfferTab = new Tab(Res.get("portfolio.tab.cloneOpenOffer").toUpperCase());
cloneOfferView.setCloseHandler(() -> {
root.getTabs().remove(cloneOpenOfferTab);
});
root.getTabs().add(cloneOpenOfferTab);
}
if (currentTab != cloneOpenOfferTab)
cloneOfferView.onTabSelected(true);
currentTab = cloneOpenOfferTab;
} else {
view = viewLoader.load(OpenOffersView.class);
selectOpenOffersView((OpenOffersView) view);
}
}
currentTab.setContent(view.getRoot());
@ -245,20 +288,35 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
openOffersView = view;
currentTab = openOffersTab;
OpenOfferActionHandler openOfferActionHandler = openOffer -> {
EditOpenOfferHandler editOpenOfferHandler = openOffer -> {
if (!editOpenOfferViewOpen) {
editOpenOfferViewOpen = true;
PortfolioView.this.openOffer = openOffer;
navigation.navigateTo(MainView.class, PortfolioView.this.getClass(), EditOfferView.class);
} else {
log.error("You have already a \"Edit Offer\" tab open.");
new Popup().warning(Res.get("editOffer.openTabWarning")).show();
}
};
openOffersView.setOpenOfferActionHandler(openOfferActionHandler);
openOffersView.setEditOpenOfferHandler(editOpenOfferHandler);
CloneOpenOfferHandler cloneOpenOfferHandler = openOffer -> {
if (!cloneOpenOfferViewOpen) {
cloneOpenOfferViewOpen = true;
PortfolioView.this.openOffer = openOffer;
navigation.navigateTo(MainView.class, PortfolioView.this.getClass(), CloneOfferView.class);
} else {
new Popup().warning(Res.get("cloneOffer.openTabWarning")).show();
}
};
openOffersView.setCloneOpenOfferHandler(cloneOpenOfferHandler);
}
public interface OpenOfferActionHandler {
public interface EditOpenOfferHandler {
void onEditOpenOffer(OpenOffer openOffer);
}
public interface CloneOpenOfferHandler {
void onCloneOpenOffer(OpenOffer openOffer);
}
}

View File

@ -0,0 +1,195 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package haveno.desktop.main.portfolio.cloneoffer;
import haveno.desktop.Navigation;
import haveno.desktop.main.offer.MutableOfferDataModel;
import haveno.core.account.witness.AccountAgeWitnessService;
import haveno.core.locale.CurrencyUtil;
import haveno.core.locale.TradeCurrency;
import haveno.core.offer.CreateOfferService;
import haveno.core.offer.Offer;
import haveno.core.offer.OfferDirection;
import haveno.core.offer.OfferUtil;
import haveno.core.offer.OpenOffer;
import haveno.core.offer.OpenOfferManager;
import haveno.core.payment.PaymentAccount;
import haveno.core.proto.persistable.CorePersistenceProtoResolver;
import haveno.core.provider.price.PriceFeedService;
import haveno.core.trade.statistics.TradeStatisticsManager;
import haveno.core.user.Preferences;
import haveno.core.user.User;
import haveno.core.util.FormattingUtils;
import haveno.core.util.coin.CoinFormatter;
import haveno.core.xmr.wallet.XmrWalletService;
import haveno.network.p2p.P2PService;
import haveno.common.handlers.ErrorMessageHandler;
import haveno.common.handlers.ResultHandler;
import com.google.inject.Inject;
import com.google.inject.name.Named;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
class CloneOfferDataModel extends MutableOfferDataModel {
private final CorePersistenceProtoResolver corePersistenceProtoResolver;
private OpenOffer sourceOpenOffer;
@Inject
CloneOfferDataModel(CreateOfferService createOfferService,
OpenOfferManager openOfferManager,
OfferUtil offerUtil,
XmrWalletService xmrWalletService,
Preferences preferences,
User user,
P2PService p2PService,
PriceFeedService priceFeedService,
AccountAgeWitnessService accountAgeWitnessService,
@Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter xmrFormatter,
CorePersistenceProtoResolver corePersistenceProtoResolver,
TradeStatisticsManager tradeStatisticsManager,
Navigation navigation) {
super(createOfferService,
openOfferManager,
offerUtil,
xmrWalletService,
preferences,
user,
p2PService,
priceFeedService,
accountAgeWitnessService,
xmrFormatter,
tradeStatisticsManager,
navigation);
this.corePersistenceProtoResolver = corePersistenceProtoResolver;
}
public void reset() {
direction = null;
tradeCurrency = null;
tradeCurrencyCode.set(null);
useMarketBasedPrice.set(false);
amount.set(null);
minAmount.set(null);
price.set(null);
volume.set(null);
minVolume.set(null);
securityDepositPct.set(0);
paymentAccounts.clear();
paymentAccount = null;
marketPriceMarginPct = 0;
sourceOpenOffer = null;
}
public void applyOpenOffer(OpenOffer openOffer) {
this.sourceOpenOffer = openOffer;
Offer offer = openOffer.getOffer();
direction = offer.getDirection();
CurrencyUtil.getTradeCurrency(offer.getCurrencyCode())
.ifPresent(c -> this.tradeCurrency = c);
tradeCurrencyCode.set(offer.getCurrencyCode());
PaymentAccount tmpPaymentAccount = user.getPaymentAccount(openOffer.getOffer().getMakerPaymentAccountId());
Optional<TradeCurrency> optionalTradeCurrency = CurrencyUtil.getTradeCurrency(openOffer.getOffer().getCurrencyCode());
if (optionalTradeCurrency.isPresent() && tmpPaymentAccount != null) {
TradeCurrency selectedTradeCurrency = optionalTradeCurrency.get();
this.paymentAccount = PaymentAccount.fromProto(tmpPaymentAccount.toProtoMessage(), corePersistenceProtoResolver);
if (paymentAccount.getSingleTradeCurrency() != null)
paymentAccount.setSingleTradeCurrency(selectedTradeCurrency);
else
paymentAccount.setSelectedTradeCurrency(selectedTradeCurrency);
}
allowAmountUpdate = false;
}
public boolean initWithData(OfferDirection direction, TradeCurrency tradeCurrency) {
try {
return super.initWithData(direction, tradeCurrency, false);
} catch (NullPointerException e) {
if (e.getMessage().contains("tradeCurrency")) {
throw new IllegalArgumentException("Offers of removed assets cannot be edited. You can only cancel it.", e);
}
return false;
}
}
@Override
protected Set<PaymentAccount> getUserPaymentAccounts() {
return Objects.requireNonNull(user.getPaymentAccounts()).stream()
.filter(account -> !account.getPaymentMethod().isBsqSwap())
.collect(Collectors.toSet());
}
@Override
protected PaymentAccount getPreselectedPaymentAccount() {
return paymentAccount;
}
public void populateData() {
Offer offer = sourceOpenOffer.getOffer();
// Min amount need to be set before amount as if minAmount is null it would be set by amount
setMinAmount(offer.getMinAmount());
setAmount(offer.getAmount());
setPrice(offer.getPrice());
setVolume(offer.getVolume());
setUseMarketBasedPrice(offer.isUseMarketBasedPrice());
setTriggerPrice(sourceOpenOffer.getTriggerPrice());
if (offer.isUseMarketBasedPrice()) {
setMarketPriceMarginPct(offer.getMarketPriceMarginPct());
}
setBuyerAsTakerWithoutDeposit(offer.hasBuyerAsTakerWithoutDeposit());
setSecurityDepositPct(getSecurityAsPercent(offer));
setExtraInfo(offer.getOfferExtraInfo());
}
public void onCloneOffer(ResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) {
Offer clonedOffer = createClonedOffer();
openOfferManager.placeOffer(clonedOffer,
false,
triggerPrice,
false,
true,
sourceOpenOffer.getId(),
transaction -> resultHandler.handleResult(),
errorMessageHandler);
}
private Offer createClonedOffer() {
return createOfferService.createClonedOffer(sourceOpenOffer.getOffer(),
tradeCurrencyCode.get(),
useMarketBasedPrice.get() ? null : price.get(),
useMarketBasedPrice.get(),
useMarketBasedPrice.get() ? marketPriceMarginPct : 0,
paymentAccount,
extraInfo.get());
}
public boolean hasConflictingClone() {
Offer clonedOffer = createClonedOffer();
return openOfferManager.hasConflictingClone(clonedOffer, sourceOpenOffer);
}
}

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ This file is part of Bisq.
~
~ Bisq is free software: you can redistribute it and/or modify it
~ under the terms of the GNU Affero General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or (at
~ your option) any later version.
~
~ Bisq is distributed in the hope that it will be useful, but WITHOUT
~ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
~ FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
~ License for more details.
~
~ You should have received a copy of the GNU Affero General Public License
~ along with Bisq. If not, see <http://www.gnu.org/licenses/>.
-->
<?import javafx.scene.layout.AnchorPane?>
<AnchorPane fx:id="root" fx:controller="haveno.desktop.main.portfolio.cloneoffer.CloneOfferView"
xmlns:fx="http://javafx.com/fxml">
</AnchorPane>

View File

@ -0,0 +1,261 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package haveno.desktop.main.portfolio.cloneoffer;
import haveno.desktop.Navigation;
import haveno.desktop.common.view.FxmlView;
import haveno.desktop.components.AutoTooltipButton;
import haveno.desktop.components.BusyAnimation;
import haveno.desktop.main.offer.MutableOfferView;
import haveno.desktop.main.overlays.popups.Popup;
import haveno.desktop.main.overlays.windows.OfferDetailsWindow;
import haveno.core.locale.CurrencyUtil;
import haveno.core.locale.Res;
import haveno.core.offer.OpenOffer;
import haveno.core.payment.PaymentAccount;
import haveno.core.user.DontShowAgainLookup;
import haveno.core.user.Preferences;
import haveno.core.util.FormattingUtils;
import haveno.core.util.coin.CoinFormatter;
import haveno.common.UserThread;
import haveno.common.util.Tuple4;
import com.google.inject.Inject;
import com.google.inject.name.Named;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.image.ImageView;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.collections.ObservableList;
import static haveno.desktop.util.FormBuilder.addButtonBusyAnimationLabelAfterGroup;
@FxmlView
public class CloneOfferView extends MutableOfferView<CloneOfferViewModel> {
private BusyAnimation busyAnimation;
private Button cloneButton;
private Button cancelButton;
private Label spinnerInfoLabel;
///////////////////////////////////////////////////////////////////////////////////////////
// Constructor, lifecycle
///////////////////////////////////////////////////////////////////////////////////////////
@Inject
private CloneOfferView(CloneOfferViewModel model,
Navigation navigation,
Preferences preferences,
OfferDetailsWindow offerDetailsWindow,
@Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter btcFormatter) {
super(model, navigation, preferences, offerDetailsWindow, btcFormatter);
}
@Override
protected void initialize() {
super.initialize();
addCloneGroup();
renameAmountGroup();
}
private void renameAmountGroup() {
amountTitledGroupBg.setText(Res.get("editOffer.setPrice"));
}
@Override
protected void doSetFocus() {
// Don't focus in any field before data was set
}
@Override
protected void doActivate() {
super.doActivate();
addBindings();
hideOptionsGroup();
hideNextButtons();
// Lock amount field as it would require bigger changes to support increased amount values.
amountTextField.setDisable(true);
amountBtcLabel.setDisable(true);
minAmountTextField.setDisable(true);
minAmountBtcLabel.setDisable(true);
volumeTextField.setDisable(true);
volumeCurrencyLabel.setDisable(true);
// Workaround to fix margin on top of amount group
gridPane.setPadding(new Insets(-20, 25, -1, 25));
updatePriceToggle();
updateElementsWithDirection();
model.isNextButtonDisabled.setValue(false);
cancelButton.setDisable(false);
model.onInvalidateMarketPriceMargin();
model.onInvalidatePrice();
// To force re-validation of payment account validation
onPaymentAccountsComboBoxSelected();
}
@Override
protected void deactivate() {
super.deactivate();
removeBindings();
}
@Override
public void onClose() {
}
///////////////////////////////////////////////////////////////////////////////////////////
// API
///////////////////////////////////////////////////////////////////////////////////////////
public void applyOpenOffer(OpenOffer openOffer) {
model.applyOpenOffer(openOffer);
initWithData(openOffer.getOffer().getDirection(),
CurrencyUtil.getTradeCurrency(openOffer.getOffer().getCurrencyCode()).get(),
false,
null);
if (!model.isSecurityDepositValid()) {
new Popup().warning(Res.get("editOffer.invalidDeposit"))
.onClose(this::close)
.show();
}
}
///////////////////////////////////////////////////////////////////////////////////////////
// Bindings, Listeners
///////////////////////////////////////////////////////////////////////////////////////////
private void addBindings() {
cloneButton.disableProperty().bind(model.isNextButtonDisabled);
}
private void removeBindings() {
cloneButton.disableProperty().unbind();
}
@Override
protected ObservableList<PaymentAccount> filterPaymentAccounts(ObservableList<PaymentAccount> paymentAccounts) {
return paymentAccounts;
}
///////////////////////////////////////////////////////////////////////////////////////////
// Build UI elements
///////////////////////////////////////////////////////////////////////////////////////////
private void addCloneGroup() {
Tuple4<Button, BusyAnimation, Label, HBox> tuple4 = addButtonBusyAnimationLabelAfterGroup(gridPane, 6, Res.get("cloneOffer.clone"));
HBox hBox = tuple4.fourth;
hBox.setAlignment(Pos.CENTER_LEFT);
GridPane.setHalignment(hBox, HPos.LEFT);
cloneButton = tuple4.first;
cloneButton.setMinHeight(40);
cloneButton.setPadding(new Insets(0, 20, 0, 20));
cloneButton.setGraphicTextGap(10);
busyAnimation = tuple4.second;
spinnerInfoLabel = tuple4.third;
cancelButton = new AutoTooltipButton(Res.get("shared.cancel"));
cancelButton.setDefaultButton(false);
cancelButton.setOnAction(event -> close());
hBox.getChildren().add(cancelButton);
cloneButton.setOnAction(e -> {
cloneButton.requestFocus(); // fix issue #5460 (when enter key used, focus is wrong)
onClone();
});
}
private void onClone() {
if (model.dataModel.hasConflictingClone()) {
new Popup().warning(Res.get("cloneOffer.hasConflictingClone"))
.actionButtonText(Res.get("shared.yes"))
.onAction(this::doClone)
.closeButtonText(Res.get("shared.no"))
.show();
} else {
doClone();
}
}
private void doClone() {
if (model.isPriceInRange()) {
model.isNextButtonDisabled.setValue(true);
cancelButton.setDisable(true);
busyAnimation.play();
spinnerInfoLabel.setText(Res.get("cloneOffer.publishOffer"));
model.onCloneOffer(() -> {
UserThread.execute(() -> {
String key = "cloneOfferSuccess";
if (DontShowAgainLookup.showAgain(key)) {
new Popup()
.feedback(Res.get("cloneOffer.success"))
.dontShowAgainId(key)
.show();
}
spinnerInfoLabel.setText("");
busyAnimation.stop();
close();
});
},
errorMessage -> {
UserThread.execute(() -> {
log.error(errorMessage);
spinnerInfoLabel.setText("");
busyAnimation.stop();
model.isNextButtonDisabled.setValue(false);
cancelButton.setDisable(false);
new Popup().warning(errorMessage).show();
});
});
}
}
///////////////////////////////////////////////////////////////////////////////////////////
// Utils
///////////////////////////////////////////////////////////////////////////////////////////
private void updateElementsWithDirection() {
ImageView iconView = new ImageView();
iconView.setId(model.isShownAsSellOffer() ? "image-sell-white" : "image-buy-white");
cloneButton.setGraphic(iconView);
cloneButton.setId(model.isShownAsSellOffer() ? "sell-button-big" : "buy-button-big");
}
}

View File

@ -0,0 +1,120 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package haveno.desktop.main.portfolio.cloneoffer;
import haveno.desktop.Navigation;
import haveno.desktop.main.offer.MutableOfferViewModel;
import haveno.desktop.main.offer.OfferViewUtil;
import haveno.core.account.witness.AccountAgeWitnessService;
import haveno.core.offer.OfferUtil;
import haveno.core.offer.OpenOffer;
import haveno.core.payment.validation.FiatVolumeValidator;
import haveno.core.payment.validation.SecurityDepositValidator;
import haveno.core.payment.validation.XmrValidator;
import haveno.core.provider.price.PriceFeedService;
import haveno.core.user.Preferences;
import haveno.core.util.FormattingUtils;
import haveno.core.util.PriceUtil;
import haveno.core.util.coin.CoinFormatter;
import haveno.core.util.validation.AmountValidator4Decimals;
import haveno.core.util.validation.AmountValidator8Decimals;
import haveno.common.handlers.ErrorMessageHandler;
import haveno.common.handlers.ResultHandler;
import com.google.inject.Inject;
import com.google.inject.name.Named;
class CloneOfferViewModel extends MutableOfferViewModel<CloneOfferDataModel> {
@Inject
public CloneOfferViewModel(CloneOfferDataModel dataModel,
FiatVolumeValidator fiatVolumeValidator,
AmountValidator4Decimals priceValidator4Decimals,
AmountValidator8Decimals priceValidator8Decimals,
XmrValidator xmrValidator,
SecurityDepositValidator securityDepositValidator,
PriceFeedService priceFeedService,
AccountAgeWitnessService accountAgeWitnessService,
Navigation navigation,
Preferences preferences,
@Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter btcFormatter,
OfferUtil offerUtil) {
super(dataModel,
fiatVolumeValidator,
priceValidator4Decimals,
priceValidator8Decimals,
xmrValidator,
securityDepositValidator,
priceFeedService,
accountAgeWitnessService,
navigation,
preferences,
btcFormatter,
offerUtil);
syncMinAmountWithAmount = false;
}
@Override
public void activate() {
super.activate();
dataModel.populateData();
long triggerPriceAsLong = dataModel.getTriggerPrice();
dataModel.setTriggerPrice(triggerPriceAsLong);
if (triggerPriceAsLong > 0) {
triggerPrice.set(PriceUtil.formatMarketPrice(triggerPriceAsLong, dataModel.getCurrencyCode()));
} else {
triggerPrice.set("");
}
onTriggerPriceTextFieldChanged();
}
public void applyOpenOffer(OpenOffer openOffer) {
dataModel.reset();
dataModel.applyOpenOffer(openOffer);
}
public void onCloneOffer(ResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) {
dataModel.onCloneOffer(resultHandler, errorMessageHandler);
}
public void onInvalidateMarketPriceMargin() {
marketPriceMargin.set(FormattingUtils.formatToPercent(dataModel.getMarketPriceMarginPct()));
}
public void onInvalidatePrice() {
price.set(FormattingUtils.formatPrice(null));
price.set(FormattingUtils.formatPrice(dataModel.getPrice().get()));
}
public boolean isSecurityDepositValid() {
return securityDepositValidator.validate(securityDeposit.get()).isValid;
}
@Override
public void triggerFocusOutOnAmountFields() {
// do not update BTC Amount or minAmount here
// issue 2798: "after a few edits of offer the BTC amount has increased"
}
public boolean isShownAsSellOffer() {
return OfferViewUtil.isShownAsSellOffer(getTradeCurrency(), dataModel.getDirection());
}
}

View File

@ -444,7 +444,7 @@ public class ClosedTradesView extends ActivatableViewAndModel<VBox, ClosedTrades
if (item != null && !empty && isMyOfferAsMaker(item.getTradable().getOffer().getOfferPayload())) {
if (button == null) {
button = FormBuilder.getRegularIconButton(MaterialDesignIcon.CONTENT_COPY);
button.setTooltip(new Tooltip(Res.get("shared.duplicateOffer")));
button.setTooltip(new Tooltip(Res.get("portfolio.context.offerLikeThis")));
setGraphic(button);
}
button.setOnAction(event -> onDuplicateOffer(item.getTradable().getOffer()));

View File

@ -35,13 +35,10 @@ import haveno.core.user.Preferences;
import haveno.core.user.User;
import haveno.core.util.FormattingUtils;
import haveno.core.util.coin.CoinFormatter;
import haveno.core.util.coin.CoinUtil;
import haveno.core.xmr.wallet.Restrictions;
import haveno.core.xmr.wallet.XmrWalletService;
import haveno.desktop.Navigation;
import haveno.desktop.main.offer.MutableOfferDataModel;
import haveno.network.p2p.P2PService;
import java.math.BigInteger;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
@ -101,14 +98,6 @@ class DuplicateOfferDataModel extends MutableOfferDataModel {
if (openOffer != null) setTriggerPrice(openOffer.getTriggerPrice());
}
private double getSecurityAsPercent(Offer offer) {
BigInteger offerSellerSecurityDeposit = getBoundedSecurityDeposit(offer.getMaxSellerSecurityDeposit());
double offerSellerSecurityDepositAsPercent = CoinUtil.getAsPercentPerXmr(offerSellerSecurityDeposit,
offer.getAmount());
return Math.min(offerSellerSecurityDepositAsPercent,
Restrictions.getMaxSecurityDepositAsPercent());
}
@Override
protected Set<PaymentAccount> getUserPaymentAccounts() {
return Objects.requireNonNull(user.getPaymentAccounts()).stream()

View File

@ -70,6 +70,7 @@ public class DuplicateOfferView extends MutableOfferView<DuplicateOfferViewModel
public void initWithData(OfferPayload offerPayload) {
initWithData(offerPayload.getDirection(),
CurrencyUtil.getTradeCurrency(offerPayload.getCurrencyCode()).get(),
true,
null);
model.initWithData(offerPayload);
}

View File

@ -21,7 +21,6 @@ package haveno.desktop.main.portfolio.editoffer;
import com.google.inject.Inject;
import com.google.inject.name.Named;
import haveno.common.UserThread;
import haveno.common.handlers.ErrorMessageHandler;
import haveno.common.handlers.ResultHandler;
import haveno.core.account.witness.AccountAgeWitnessService;
@ -56,6 +55,7 @@ class EditOfferDataModel extends MutableOfferDataModel {
private final CorePersistenceProtoResolver corePersistenceProtoResolver;
private OpenOffer openOffer;
private OpenOffer.State initialState;
private Offer editedOffer;
@Inject
EditOfferDataModel(CreateOfferService createOfferService,
@ -100,7 +100,7 @@ class EditOfferDataModel extends MutableOfferDataModel {
securityDepositPct.set(0);
paymentAccounts.clear();
paymentAccount = null;
marketPriceMargin = 0;
marketPriceMarginPct = 0;
}
public void applyOpenOffer(OpenOffer openOffer) {
@ -142,10 +142,9 @@ class EditOfferDataModel extends MutableOfferDataModel {
extraInfo.set(offer.getOfferExtraInfo());
}
@Override
public boolean initWithData(OfferDirection direction, TradeCurrency tradeCurrency) {
try {
return super.initWithData(direction, tradeCurrency);
return super.initWithData(direction, tradeCurrency, false);
} catch (NullPointerException e) {
if (e.getMessage().contains("tradeCurrency")) {
throw new IllegalArgumentException("Offers of removed assets cannot be edited. You can only cancel it.", e);
@ -225,15 +224,16 @@ class EditOfferDataModel extends MutableOfferDataModel {
offerPayload.getReserveTxKeyImages(),
newOfferPayload.getExtraInfo());
final Offer editedOffer = new Offer(editedPayload);
editedOffer = new Offer(editedPayload);
editedOffer.setPriceFeedService(priceFeedService);
editedOffer.setState(Offer.State.AVAILABLE);
openOfferManager.editOpenOfferPublish(editedOffer, triggerPrice, initialState, () -> {
resultHandler.handleResult(); // process result before nullifying state
openOffer = null;
UserThread.execute(() -> resultHandler.handleResult());
editedOffer = null;
}, (errorMsg) -> {
UserThread.execute(() -> errorMessageHandler.handleErrorMessage(errorMsg));
errorMessageHandler.handleErrorMessage(errorMsg);
});
}
@ -243,6 +243,15 @@ class EditOfferDataModel extends MutableOfferDataModel {
}, errorMessageHandler);
}
public boolean hasConflictingClone() {
Optional<OpenOffer> editedOpenOffer = openOfferManager.getOpenOffer(openOffer.getId());
if (!editedOpenOffer.isPresent()) {
log.warn("Edited open offer is no longer present");
return false;
}
return openOfferManager.hasConflictingClone(editedOpenOffer.get());
}
@Override
protected Set<PaymentAccount> getUserPaymentAccounts() {
throw new RuntimeException("Edit offer not supported with XMR");

View File

@ -19,6 +19,8 @@ package haveno.desktop.main.portfolio.editoffer;
import com.google.inject.Inject;
import com.google.inject.name.Named;
import haveno.common.UserThread;
import haveno.common.util.Tuple4;
import haveno.core.locale.CurrencyUtil;
import haveno.core.locale.Res;
@ -140,6 +142,7 @@ public class EditOfferView extends MutableOfferView<EditOfferViewModel> {
initWithData(openOffer.getOffer().getDirection(),
CurrencyUtil.getTradeCurrency(openOffer.getOffer().getCurrencyCode()).get(),
false,
null);
model.onStartEditOffer(errorMessage -> {
@ -208,23 +211,31 @@ public class EditOfferView extends MutableOfferView<EditOfferViewModel> {
// edit offer
model.onPublishOffer(() -> {
String key = "editOfferSuccess";
if (DontShowAgainLookup.showAgain(key)) {
new Popup()
.feedback(Res.get("editOffer.success"))
.dontShowAgainId(key)
.show();
if (model.dataModel.hasConflictingClone()) {
new Popup().warning(Res.get("editOffer.hasConflictingClone")).show();
} else {
String key = "editOfferSuccess";
if (DontShowAgainLookup.showAgain(key)) {
new Popup()
.feedback(Res.get("editOffer.success"))
.dontShowAgainId(key)
.show();
}
}
spinnerInfoLabel.setText("");
busyAnimation.stop();
close();
UserThread.execute(() -> {
spinnerInfoLabel.setText("");
busyAnimation.stop();
close();
});
}, (message) -> {
log.error(message);
spinnerInfoLabel.setText("");
busyAnimation.stop();
model.isNextButtonDisabled.setValue(false);
cancelButton.setDisable(false);
new Popup().warning(Res.get("editOffer.failed", message)).show();
UserThread.execute(() -> {
log.error(message);
spinnerInfoLabel.setText("");
busyAnimation.stop();
model.isNextButtonDisabled.setValue(false);
cancelButton.setDisable(false);
new Popup().warning(Res.get("editOffer.failed", message)).show();
});
});
}
});

View File

@ -44,7 +44,7 @@ class EditOfferViewModel extends MutableOfferViewModel<EditOfferDataModel> {
FiatVolumeValidator fiatVolumeValidator,
AmountValidator4Decimals priceValidator4Decimals,
AmountValidator8Decimals priceValidator8Decimals,
XmrValidator btcValidator,
XmrValidator xmrValidator,
SecurityDepositValidator securityDepositValidator,
PriceFeedService priceFeedService,
AccountAgeWitnessService accountAgeWitnessService,
@ -56,7 +56,7 @@ class EditOfferViewModel extends MutableOfferViewModel<EditOfferDataModel> {
fiatVolumeValidator,
priceValidator4Decimals,
priceValidator8Decimals,
btcValidator,
xmrValidator,
securityDepositValidator,
priceFeedService,
accountAgeWitnessService,

View File

@ -39,4 +39,8 @@ class OpenOfferListItem {
public Offer getOffer() {
return openOffer.getOffer();
}
public String getGroupId() {
return openOffer.getGroupId();
}
}

View File

@ -42,7 +42,8 @@
</HBox>
<TableView fx:id="tableView" VBox.vgrow="ALWAYS">
<columns>
<TableColumn fx:id="offerIdColumn" minWidth="110" maxWidth="120"/>
<TableColumn fx:id="offerIdColumn" minWidth="100" maxWidth="120"/>
<TableColumn fx:id="groupIdColumn" minWidth="70"/>
<TableColumn fx:id="dateColumn" minWidth="170"/>
<TableColumn fx:id="marketColumn" minWidth="75"/>
<TableColumn fx:id="priceColumn" minWidth="100"/>
@ -50,11 +51,13 @@
<TableColumn fx:id="triggerPriceColumn" minWidth="90"/>
<TableColumn fx:id="amountColumn" minWidth="110"/>
<TableColumn fx:id="volumeColumn" minWidth="110"/>
<TableColumn fx:id="paymentMethodColumn" minWidth="120" maxWidth="170"/>
<TableColumn fx:id="paymentMethodColumn" minWidth="110" maxWidth="170"/>
<TableColumn fx:id="directionColumn" minWidth="70"/>
<TableColumn fx:id="deactivateItemColumn" minWidth="60" maxWidth="60" sortable="false"/>
<TableColumn fx:id="editItemColumn" minWidth="30" maxWidth="30" sortable="false"/>
<TableColumn fx:id="triggerIconColumn" minWidth="30" maxWidth="30" sortable="false"/>
<TableColumn fx:id="duplicateItemColumn" minWidth="30" maxWidth="30" sortable="false"/>
<TableColumn fx:id="cloneItemColumn" minWidth="30" maxWidth="30" sortable="false"/>
<TableColumn fx:id="removeItemColumn" minWidth="30" maxWidth="30" sortable="false"/>
</columns>
</TableView>

View File

@ -22,8 +22,8 @@ import com.googlecode.jcsv.writer.CSVEntryConverter;
import de.jensd.fx.glyphs.materialdesignicons.MaterialDesignIcon;
import haveno.core.locale.Res;
import haveno.core.offer.Offer;
import haveno.core.offer.OfferPayload;
import haveno.core.offer.OpenOffer;
import haveno.core.offer.OpenOfferManager;
import haveno.core.user.DontShowAgainLookup;
import haveno.desktop.Navigation;
import haveno.desktop.common.view.ActivatableViewAndModel;
@ -40,8 +40,9 @@ import haveno.desktop.main.funds.withdrawal.WithdrawalView;
import haveno.desktop.main.overlays.popups.Popup;
import haveno.desktop.main.overlays.windows.OfferDetailsWindow;
import haveno.desktop.main.portfolio.PortfolioView;
import haveno.desktop.main.portfolio.duplicateoffer.DuplicateOfferView;
import haveno.desktop.main.portfolio.presentation.PortfolioUtil;
import static haveno.desktop.util.FormBuilder.getRegularIconButton;
import haveno.desktop.util.FormBuilder;
import haveno.desktop.util.GUIUtil;
import java.util.Comparator;
import java.util.HashMap;
@ -51,13 +52,11 @@ import java.util.stream.Collectors;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.value.ChangeListener;
import javafx.collections.ObservableList;
import javafx.collections.ListChangeListener;
import javafx.collections.transformation.FilteredList;
import javafx.collections.transformation.SortedList;
import javafx.fxml.FXML;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.control.Button;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.Label;
@ -73,6 +72,7 @@ import javafx.scene.layout.Pane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import javafx.scene.text.Text;
import javafx.stage.Stage;
import javafx.util.Callback;
import org.jetbrains.annotations.NotNull;
@ -80,12 +80,39 @@ import org.jetbrains.annotations.NotNull;
@FxmlView
public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersViewModel> {
private enum ColumnNames {
OFFER_ID(Res.get("shared.offerId")),
GROUP_ID(Res.get("openOffer.header.groupId")),
DATE(Res.get("shared.dateTime")),
MARKET(Res.get("shared.market")),
PRICE(Res.get("shared.price")),
DEVIATION(Res.get("shared.deviation")),
TRIGGER_PRICE(Res.get("openOffer.header.triggerPrice")),
AMOUNT(Res.get("shared.XMRMinMax")),
VOLUME(Res.get("shared.amountMinMax")),
PAYMENT_METHOD(Res.get("shared.paymentMethod")),
DIRECTION(Res.get("shared.offerType")),
STATUS(Res.get("shared.state"));
private final String text;
ColumnNames(String text) {
this.text = text;
}
@Override
public String toString() {
return text;
}
}
@FXML
TableView<OpenOfferListItem> tableView;
@FXML
TableColumn<OpenOfferListItem, OpenOfferListItem> priceColumn, deviationColumn, amountColumn, volumeColumn,
marketColumn, directionColumn, dateColumn, offerIdColumn, deactivateItemColumn,
removeItemColumn, editItemColumn, triggerPriceColumn, triggerIconColumn, paymentMethodColumn;
marketColumn, directionColumn, dateColumn, offerIdColumn, deactivateItemColumn, groupIdColumn,
removeItemColumn, editItemColumn, triggerPriceColumn, triggerIconColumn, paymentMethodColumn, duplicateItemColumn,
cloneItemColumn;
@FXML
HBox searchBox;
@FXML
@ -108,37 +135,48 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
private SortedList<OpenOfferListItem> sortedList;
private FilteredList<OpenOfferListItem> filteredList;
private ChangeListener<String> filterTextFieldListener;
private PortfolioView.OpenOfferActionHandler openOfferActionHandler;
private final OpenOfferManager openOfferManager;
private PortfolioView.EditOpenOfferHandler editOpenOfferHandler;
private PortfolioView.CloneOpenOfferHandler cloneOpenOfferHandler;
private ChangeListener<Number> widthListener;
private ListChangeListener<OpenOfferListItem> sortedListChangedListener;
private Map<String, ChangeListener<OpenOffer.State>> offerStateChangeListeners = new HashMap<String, ChangeListener<OpenOffer.State>>();
@Inject
public OpenOffersView(OpenOffersViewModel model, Navigation navigation, OfferDetailsWindow offerDetailsWindow) {
public OpenOffersView(OpenOffersViewModel model,
OpenOfferManager openOfferManager,
Navigation navigation,
OfferDetailsWindow offerDetailsWindow) {
super(model);
this.navigation = navigation;
this.offerDetailsWindow = offerDetailsWindow;
this.openOfferManager = openOfferManager;
}
@Override
public void initialize() {
widthListener = (observable, oldValue, newValue) -> onWidthChange((double) newValue);
paymentMethodColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.paymentMethod")));
priceColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.price")));
deviationColumn.setGraphic(new AutoTooltipTableColumn<>(Res.get("shared.deviation"),
groupIdColumn.setGraphic(new AutoTooltipLabel(ColumnNames.GROUP_ID.toString()));
paymentMethodColumn.setGraphic(new AutoTooltipLabel(ColumnNames.PAYMENT_METHOD.toString()));
priceColumn.setGraphic(new AutoTooltipLabel(ColumnNames.PRICE.toString()));
deviationColumn.setGraphic(new AutoTooltipTableColumn<>(ColumnNames.DEVIATION.toString(),
Res.get("portfolio.closedTrades.deviation.help")).getGraphic());
amountColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.XMRMinMax")));
volumeColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.amountMinMax")));
marketColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.market")));
directionColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.offerType")));
dateColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.dateTime")));
offerIdColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.offerId")));
triggerPriceColumn.setGraphic(new AutoTooltipLabel(Res.get("openOffer.header.triggerPrice")));
deactivateItemColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.enabled")));
triggerPriceColumn.setGraphic(new AutoTooltipLabel(ColumnNames.TRIGGER_PRICE.toString()));
amountColumn.setGraphic(new AutoTooltipLabel(ColumnNames.AMOUNT.toString()));
volumeColumn.setGraphic(new AutoTooltipLabel(ColumnNames.VOLUME.toString()));
marketColumn.setGraphic(new AutoTooltipLabel(ColumnNames.MARKET.toString()));
directionColumn.setGraphic(new AutoTooltipLabel(ColumnNames.DIRECTION.toString()));
dateColumn.setGraphic(new AutoTooltipLabel(ColumnNames.DATE.toString()));
offerIdColumn.setGraphic(new AutoTooltipLabel(ColumnNames.OFFER_ID.toString()));
deactivateItemColumn.setGraphic(new AutoTooltipLabel(ColumnNames.STATUS.toString()));
editItemColumn.setGraphic(new AutoTooltipLabel(""));
duplicateItemColumn.setText("");
cloneItemColumn.setText("");
removeItemColumn.setGraphic(new AutoTooltipLabel(""));
setOfferIdColumnCellFactory();
setGroupIdCellFactory();
setDirectionColumnCellFactory();
setMarketColumnCellFactory();
setPriceColumnCellFactory();
@ -151,12 +189,15 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
setEditColumnCellFactory();
setTriggerIconColumnCellFactory();
setTriggerPriceColumnCellFactory();
setDuplicateColumnCellFactory();
setCloneColumnCellFactory();
setRemoveColumnCellFactory();
tableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY);
tableView.setPlaceholder(new AutoTooltipLabel(Res.get("table.placeholder.noItems", Res.get("shared.openOffers"))));
offerIdColumn.setComparator(Comparator.comparing(o -> o.getOffer().getId()));
groupIdColumn.setComparator(Comparator.comparing(o -> o.getOpenOffer().getReserveTxHash()));
directionColumn.setComparator(Comparator.comparing(o -> o.getOffer().getDirection()));
marketColumn.setComparator(Comparator.comparing(model::getMarketLabel));
amountColumn.setComparator(Comparator.comparing(o -> o.getOffer().getAmount()));
@ -168,23 +209,21 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
dateColumn.setComparator(Comparator.comparing(o -> o.getOffer().getDate()));
paymentMethodColumn.setComparator(Comparator.comparing(o -> Res.get(o.getOffer().getPaymentMethod().getId())));
dateColumn.setSortType(TableColumn.SortType.DESCENDING);
dateColumn.setSortType(TableColumn.SortType.ASCENDING);
tableView.getSortOrder().add(dateColumn);
tableView.setRowFactory(
tableView -> {
final TableRow<OpenOfferListItem> row = new TableRow<>();
final ContextMenu rowMenu = new ContextMenu();
MenuItem editItem = new MenuItem(Res.get("portfolio.context.offerLikeThis"));
editItem.setOnAction((event) -> {
try {
OfferPayload offerPayload = row.getItem().getOffer().getOfferPayload();
navigation.navigateToWithData(offerPayload, MainView.class, PortfolioView.class, DuplicateOfferView.class);
} catch (NullPointerException e) {
log.warn("Unable to get offerPayload - {}", e.toString());
}
});
rowMenu.getItems().add(editItem);
MenuItem duplicateOfferMenuItem = new MenuItem(Res.get("portfolio.context.offerLikeThis"));
duplicateOfferMenuItem.setOnAction((event) -> onDuplicateOffer(row.getItem()));
rowMenu.getItems().add(duplicateOfferMenuItem);
MenuItem cloneOfferMenuItem = new MenuItem(Res.get("offerbook.cloneOffer"));
cloneOfferMenuItem.setOnAction((event) -> onCloneOffer(row.getItem()));
rowMenu.getItems().add(cloneOfferMenuItem);
row.contextMenuProperty().bind(
Bindings.when(Bindings.isNotNull(row.itemProperty()))
.then(rowMenu)
@ -207,6 +246,15 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
HBox.setHgrow(footerSpacer, Priority.ALWAYS);
HBox.setMargin(exportButton, new Insets(0, 10, 0, 0));
exportButton.updateText(Res.get("shared.exportCSV"));
sortedListChangedListener = c -> {
c.next();
if (c.wasAdded() || c.wasRemoved()) {
updateNumberOfOffers();
updateGroupIdColumnVisibility();
updateTriggerColumnVisibility();
}
};
}
@Override
@ -214,8 +262,11 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
filteredList = new FilteredList<>(model.getList());
sortedList = new SortedList<>(filteredList);
sortedList.comparatorProperty().bind(tableView.comparatorProperty());
sortedList.addListener(sortedListChangedListener);
tableView.setItems(sortedList);
updateGroupIdColumnVisibility();
updateTriggerColumnVisibility();
updateSelectToggleButtonState();
selectToggleButton.setOnAction(event -> {
@ -231,37 +282,27 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
numItems.setText(Res.get("shared.numItemsLabel", sortedList.size()));
exportButton.setOnAction(event -> {
ObservableList<TableColumn<OpenOfferListItem, ?>> tableColumns = tableView.getColumns();
int reportColumns = tableColumns.size() - 3; // CSV report excludes the last columns (icons)
CSVEntryConverter<OpenOfferListItem> headerConverter = item -> {
String[] columns = new String[reportColumns];
for (int i = 0; i < columns.length; i++) {
Node graphic = tableColumns.get(i).getGraphic();
if (graphic instanceof AutoTooltipLabel) {
columns[i] = ((AutoTooltipLabel) graphic).getText();
} else if (graphic instanceof HBox) {
// Deviation has a Hbox with AutoTooltipLabel as first child in header
columns[i] = ((AutoTooltipLabel) ((Parent) graphic).getChildrenUnmodifiable().get(0)).getText();
} else {
// Not expected
columns[i] = "N/A";
}
String[] columns = new String[ColumnNames.values().length];
for (ColumnNames m : ColumnNames.values()) {
columns[m.ordinal()] = m.toString();
}
return columns;
};
CSVEntryConverter<OpenOfferListItem> contentConverter = item -> {
String[] columns = new String[reportColumns];
columns[0] = model.getOfferId(item);
columns[1] = model.getDate(item);
columns[2] = model.getMarketLabel(item);
columns[3] = model.getPrice(item);
columns[4] = model.getPriceDeviation(item);
columns[5] = model.getTriggerPrice(item);
columns[6] = model.getAmount(item);
columns[7] = model.getVolume(item);
columns[8] = model.getPaymentMethod(item);
columns[9] = model.getDirectionLabel(item);
columns[10] = String.valueOf(!item.getOpenOffer().isDeactivated());
String[] columns = new String[ColumnNames.values().length];
columns[ColumnNames.OFFER_ID.ordinal()] = model.getOfferId(item);
columns[ColumnNames.GROUP_ID.ordinal()] = openOfferManager.hasClonedOffer(item.getOffer().getId()) ? getShortenedGroupId(item.getGroupId()) : "";
columns[ColumnNames.DATE.ordinal()] = model.getDate(item);
columns[ColumnNames.MARKET.ordinal()] = model.getMarketLabel(item);
columns[ColumnNames.PRICE.ordinal()] = model.getPrice(item);
columns[ColumnNames.DEVIATION.ordinal()] = model.getPriceDeviation(item);
columns[ColumnNames.TRIGGER_PRICE.ordinal()] = model.getTriggerPrice(item);
columns[ColumnNames.AMOUNT.ordinal()] = model.getAmount(item);
columns[ColumnNames.VOLUME.ordinal()] = model.getVolume(item);
columns[ColumnNames.PAYMENT_METHOD.ordinal()] = model.getPaymentMethod(item);
columns[ColumnNames.DIRECTION.ordinal()] = model.getDirectionLabel(item);
columns[ColumnNames.STATUS.ordinal()] = String.valueOf(!item.getOpenOffer().isDeactivated());
return columns;
};
@ -280,9 +321,24 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
onWidthChange(root.getWidth());
}
private void updateNumberOfOffers() {
numItems.setText(Res.get("shared.numItemsLabel", sortedList.size()));
}
private void updateGroupIdColumnVisibility() {
groupIdColumn.setVisible(openOfferManager.hasClonedOffers());
}
private void updateTriggerColumnVisibility() {
triggerIconColumn.setVisible(model.dataModel.getList().stream()
.mapToLong(item -> item.getOpenOffer().getTriggerPrice())
.sum() > 0);
}
@Override
protected void deactivate() {
sortedList.comparatorProperty().unbind();
sortedList.removeListener(sortedListChangedListener);
exportButton.setOnAction(null);
filterTextField.textProperty().removeListener(filterTextFieldListener);
@ -352,7 +408,7 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
}
private void onWidthChange(double width) {
triggerPriceColumn.setVisible(width > 1200);
triggerPriceColumn.setVisible(width > 1300);
}
private void onDeactivateOpenOffer(OpenOffer openOffer) {
@ -361,7 +417,7 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
() -> log.debug("Deactivate offer was successful"),
(message) -> {
log.error(message);
new Popup().warning(Res.get("offerbook.deactivateOffer.failed", message)).show();
new Popup().warning(message).show();
});
updateSelectToggleButtonState();
}
@ -397,12 +453,18 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
}
private void doRemoveOpenOffer(OpenOffer openOffer) {
boolean hasClonedOffer = openOfferManager.hasClonedOffer(openOffer.getId());
model.onRemoveOpenOffer(openOffer,
() -> {
log.debug("Remove offer was successful");
tableView.refresh();
// We do not show the popup if it's a cloned offer with shared maker reserve tx
if (hasClonedOffer) {
return;
}
String key = "WithdrawFundsAfterRemoveOfferInfo";
if (DontShowAgainLookup.showAgain(key)) {
new Popup().instruction(Res.get("offerbook.withdrawFundsHint", Res.get("navigation.funds.availableForWithdrawal")))
@ -420,10 +482,46 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
private void onEditOpenOffer(OpenOffer openOffer) {
if (model.isBootstrappedOrShowPopup()) {
openOfferActionHandler.onEditOpenOffer(openOffer);
editOpenOfferHandler.onEditOpenOffer(openOffer);
}
}
private void onDuplicateOffer(OpenOfferListItem item) {
if (item == null || item.getOffer().getOfferPayload() == null) {
return;
}
if (model.isBootstrappedOrShowPopup()) {
PortfolioUtil.duplicateOffer(navigation, item.getOffer().getOfferPayload());
}
}
private void onCloneOffer(OpenOfferListItem item) {
if (item == null) {
return;
}
if (model.isBootstrappedOrShowPopup()) {
String key = "clonedOfferInfo";
if (DontShowAgainLookup.showAgain(key)) {
new Popup().headLine(Res.get("offerbook.clonedOffer.headline"))
.instruction(Res.get("offerbook.clonedOffer.info"))
.useIUnderstandButton()
.dontShowAgainId(key)
.onClose(() -> doCloneOffer(item))
.show();
} else {
doCloneOffer(item);
}
}
}
private void doCloneOffer(OpenOfferListItem item) {
OpenOffer openOffer = item.getOpenOffer();
if (openOffer == null || openOffer.getOffer() == null || openOffer.getOffer().getOfferPayload() == null) {
return;
}
cloneOpenOfferHandler.onCloneOpenOffer(openOffer);
}
private void setOfferIdColumnCellFactory() {
offerIdColumn.setCellValueFactory((openOfferListItem) -> new ReadOnlyObjectWrapper<>(openOfferListItem.getValue()));
offerIdColumn.getStyleClass().addAll("number-column", "first-column");
@ -434,21 +532,28 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
public TableCell<OpenOfferListItem, OpenOfferListItem> call(TableColumn<OpenOfferListItem,
OpenOfferListItem> column) {
return new TableCell<>() {
private HyperlinkWithIcon field;
private HyperlinkWithIcon hyperlinkWithIcon;
@Override
public void updateItem(final OpenOfferListItem item, boolean empty) {
super.updateItem(item, empty);
if (item != null && !empty) {
field = new HyperlinkWithIcon(model.getOfferId(item));
field.setOnAction(event -> offerDetailsWindow.show(item.getOffer()));
field.setTooltip(new Tooltip(Res.get("tooltip.openPopupForDetails")));
setGraphic(field);
hyperlinkWithIcon = new HyperlinkWithIcon(item.getOffer().getShortId());
if (model.isDeactivated(item)) {
// getStyleClass().add("offer-disabled"); does not work with hyperlinkWithIcon;-(
hyperlinkWithIcon.setStyle("-fx-text-fill: -bs-color-gray-3;");
hyperlinkWithIcon.getIcon().setOpacity(0.2);
}
hyperlinkWithIcon.setOnAction(event -> {
offerDetailsWindow.show(item.getOffer());
});
hyperlinkWithIcon.setTooltip(new Tooltip(Res.get("tooltip.openPopupForDetails")));
setGraphic(hyperlinkWithIcon);
} else {
setGraphic(null);
if (field != null)
field.setOnAction(null);
if (hyperlinkWithIcon != null)
hyperlinkWithIcon.setOnAction(null);
}
}
};
@ -456,6 +561,55 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
});
}
private void setGroupIdCellFactory() {
groupIdColumn.setCellValueFactory((offerListItem) -> new ReadOnlyObjectWrapper<>(offerListItem.getValue()));
groupIdColumn.setCellFactory(
new Callback<>() {
@Override
public TableCell<OpenOfferListItem, OpenOfferListItem> call(
TableColumn<OpenOfferListItem, OpenOfferListItem> column) {
return new TableCell<>() {
@Override
public void updateItem(final OpenOfferListItem item, boolean empty) {
super.updateItem(item, empty);
getStyleClass().removeAll("offer-disabled");
if (item != null) {
Label label;
Text icon;
if (openOfferManager.hasClonedOffer(item.getOpenOffer().getId())) {
label = new Label(getShortenedGroupId(item.getOpenOffer().getGroupId()));
icon = FormBuilder.getRegularIconForLabel(MaterialDesignIcon.LINK, label, "icon");
icon.setVisible(true);
setTooltip(new Tooltip(Res.get("offerbook.clonedOffer.tooltip", item.getOpenOffer().getReserveTxHash())));
} else {
label = new Label("");
icon = FormBuilder.getRegularIconForLabel(MaterialDesignIcon.LINK_OFF, label, "icon");
icon.setVisible(false);
setTooltip(new Tooltip(Res.get("offerbook.nonClonedOffer.tooltip", item.getOpenOffer().getReserveTxHash())));
}
if (model.isDeactivated(item)) {
getStyleClass().add("offer-disabled");
icon.setOpacity(0.2);
}
setGraphic(label);
} else {
setGraphic(null);
}
}
};
}
});
}
private String getShortenedGroupId(String groupId) {
if (groupId.length() > 5) {
return groupId.substring(0, 5);
}
return groupId;
}
private void setDateColumnCellFactory() {
dateColumn.setCellValueFactory((openOfferListItem) -> new ReadOnlyObjectWrapper<>(openOfferListItem.getValue()));
dateColumn.setCellFactory(
@ -779,6 +933,74 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
});
}
private void setDuplicateColumnCellFactory() {
duplicateItemColumn.getStyleClass().add("avatar-column");
duplicateItemColumn.setCellValueFactory((offerListItem) -> new ReadOnlyObjectWrapper<>(offerListItem.getValue()));
duplicateItemColumn.setCellFactory(
new Callback<>() {
@Override
public TableCell<OpenOfferListItem, OpenOfferListItem> call(TableColumn<OpenOfferListItem, OpenOfferListItem> column) {
return new TableCell<>() {
Button button;
@Override
public void updateItem(final OpenOfferListItem item, boolean empty) {
super.updateItem(item, empty);
if (item != null && !empty) {
if (button == null) {
button = getRegularIconButton(MaterialDesignIcon.CONTENT_COPY);
button.setTooltip(new Tooltip(Res.get("portfolio.context.offerLikeThis")));
setGraphic(button);
}
button.setOnAction(event -> onDuplicateOffer(item));
} else {
setGraphic(null);
if (button != null) {
button.setOnAction(null);
button = null;
}
}
}
};
}
});
}
private void setCloneColumnCellFactory() {
cloneItemColumn.getStyleClass().add("avatar-column");
cloneItemColumn.setCellValueFactory((offerListItem) -> new ReadOnlyObjectWrapper<>(offerListItem.getValue()));
cloneItemColumn.setCellFactory(
new Callback<>() {
@Override
public TableCell<OpenOfferListItem, OpenOfferListItem> call(TableColumn<OpenOfferListItem, OpenOfferListItem> column) {
return new TableCell<>() {
Button button;
@Override
public void updateItem(final OpenOfferListItem item, boolean empty) {
super.updateItem(item, empty);
if (item != null && !empty) {
if (button == null) {
button = getRegularIconButton(MaterialDesignIcon.BOX_SHADOW);
button.setTooltip(new Tooltip(Res.get("offerbook.cloneOffer")));
setGraphic(button);
}
button.setOnAction(event -> onCloneOffer(item));
} else {
setGraphic(null);
if (button != null) {
button.setOnAction(null);
button = null;
}
}
}
};
}
});
}
private void setTriggerIconColumnCellFactory() {
triggerIconColumn.setCellValueFactory((offerListItem) -> new ReadOnlyObjectWrapper<>(offerListItem.getValue()));
triggerIconColumn.setCellFactory(
@ -854,8 +1076,12 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
});
}
public void setOpenOfferActionHandler(PortfolioView.OpenOfferActionHandler openOfferActionHandler) {
this.openOfferActionHandler = openOfferActionHandler;
public void setEditOpenOfferHandler(PortfolioView.EditOpenOfferHandler editOpenOfferHandler) {
this.editOpenOfferHandler = editOpenOfferHandler;
}
public void setCloneOpenOfferHandler(PortfolioView.CloneOpenOfferHandler cloneOpenOfferHandler) {
this.cloneOpenOfferHandler = cloneOpenOfferHandler;
}
}

View File

@ -84,6 +84,10 @@ class OpenOffersViewModel extends ActivatableWithDataModel<OpenOffersDataModel>
return item.getOffer().getShortId();
}
String getGroupId(OpenOfferListItem item) {
return item.getGroupId();
}
String getAmount(OpenOfferListItem item) {
return (item != null) ? DisplayUtils.formatAmount(item.getOffer(), btcFormatter) : "";
}

View File

@ -2340,8 +2340,8 @@ public class FormBuilder {
return getRegularIconForLabel(icon, label, null);
}
public static Text getRegularIconForLabel(GlyphIcons icon, Label label, String style) {
return getIconForLabel(icon, "1.231em", label, style);
public static Text getRegularIconForLabel(GlyphIcons icon, Label label, String styleClass) {
return getIconForLabel(icon, "1.231em", label, styleClass);
}
public static Text getIcon(GlyphIcons icon) {

View File

@ -88,7 +88,7 @@ public class CreateOfferDataModelTest {
when(user.getPaymentAccounts()).thenReturn(paymentAccounts);
when(preferences.getSelectedPaymentAccountForCreateOffer()).thenReturn(revolutAccount);
model.initWithData(OfferDirection.BUY, new TraditionalCurrency("USD"));
model.initWithData(OfferDirection.BUY, new TraditionalCurrency("USD"), true);
assertEquals("USD", model.getTradeCurrencyCode().get());
}
@ -109,7 +109,7 @@ public class CreateOfferDataModelTest {
when(user.findFirstPaymentAccountWithCurrency(new TraditionalCurrency("USD"))).thenReturn(zelleAccount);
when(preferences.getSelectedPaymentAccountForCreateOffer()).thenReturn(revolutAccount);
model.initWithData(OfferDirection.BUY, new TraditionalCurrency("USD"));
model.initWithData(OfferDirection.BUY, new TraditionalCurrency("USD"), true);
assertEquals("USD", model.getTradeCurrencyCode().get());
}
}

View File

@ -117,7 +117,7 @@ public class CreateOfferViewModelTest {
coinFormatter,
tradeStats,
null);
dataModel.initWithData(OfferDirection.BUY, new CryptoCurrency("XMR", "monero"));
dataModel.initWithData(OfferDirection.BUY, new CryptoCurrency("XMR", "monero"), true);
dataModel.activate();
model = new CreateOfferViewModel(dataModel,

View File

@ -528,6 +528,7 @@ message PostOfferRequest {
bool is_private_offer = 12;
bool buyer_as_taker_without_deposit = 13;
string extra_info = 14;
string source_offer_id = 15;
}
message PostOfferReply {

View File

@ -1422,6 +1422,7 @@ message OpenOffer {
string reserve_tx_key = 11;
string challenge = 12;
bool deactivated_by_trigger = 13;
string group_id = 14;
}
message Tradable {