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