support cloning up to 10 offers with shared reserved funds (#1668)
This commit is contained in:
parent
7e3a47de4a
commit
40e18890d6
55 changed files with 2006 additions and 611 deletions
|
@ -413,21 +413,22 @@ public class CoreApi {
|
||||||
}
|
}
|
||||||
|
|
||||||
public void postOffer(String currencyCode,
|
public void postOffer(String currencyCode,
|
||||||
String directionAsString,
|
String directionAsString,
|
||||||
String priceAsString,
|
String priceAsString,
|
||||||
boolean useMarketBasedPrice,
|
boolean useMarketBasedPrice,
|
||||||
double marketPriceMargin,
|
double marketPriceMargin,
|
||||||
long amountAsLong,
|
long amountAsLong,
|
||||||
long minAmountAsLong,
|
long minAmountAsLong,
|
||||||
double securityDepositPct,
|
double securityDepositPct,
|
||||||
String triggerPriceAsString,
|
String triggerPriceAsString,
|
||||||
boolean reserveExactAmount,
|
boolean reserveExactAmount,
|
||||||
String paymentAccountId,
|
String paymentAccountId,
|
||||||
boolean isPrivateOffer,
|
boolean isPrivateOffer,
|
||||||
boolean buyerAsTakerWithoutDeposit,
|
boolean buyerAsTakerWithoutDeposit,
|
||||||
String extraInfo,
|
String extraInfo,
|
||||||
Consumer<Offer> resultHandler,
|
String sourceOfferId,
|
||||||
ErrorMessageHandler errorMessageHandler) {
|
Consumer<Offer> resultHandler,
|
||||||
|
ErrorMessageHandler errorMessageHandler) {
|
||||||
coreOffersService.postOffer(currencyCode,
|
coreOffersService.postOffer(currencyCode,
|
||||||
directionAsString,
|
directionAsString,
|
||||||
priceAsString,
|
priceAsString,
|
||||||
|
@ -442,6 +443,7 @@ public class CoreApi {
|
||||||
isPrivateOffer,
|
isPrivateOffer,
|
||||||
buyerAsTakerWithoutDeposit,
|
buyerAsTakerWithoutDeposit,
|
||||||
extraInfo,
|
extraInfo,
|
||||||
|
sourceOfferId,
|
||||||
resultHandler,
|
resultHandler,
|
||||||
errorMessageHandler);
|
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.roundDoubleToLong;
|
||||||
import static haveno.common.util.MathUtils.scaleUpByPowerOf10;
|
import static haveno.common.util.MathUtils.scaleUpByPowerOf10;
|
||||||
import haveno.core.locale.CurrencyUtil;
|
import haveno.core.locale.CurrencyUtil;
|
||||||
|
import haveno.core.locale.Res;
|
||||||
import haveno.core.monetary.CryptoMoney;
|
import haveno.core.monetary.CryptoMoney;
|
||||||
import haveno.core.monetary.Price;
|
import haveno.core.monetary.Price;
|
||||||
import haveno.core.monetary.TraditionalMoney;
|
import haveno.core.monetary.TraditionalMoney;
|
||||||
|
@ -66,9 +67,7 @@ import java.math.BigInteger;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import static java.util.Comparator.comparing;
|
import static java.util.Comparator.comparing;
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
@ -124,7 +123,6 @@ public class CoreOffersService {
|
||||||
return result.isValid() || result == Result.HAS_NO_PAYMENT_ACCOUNT_VALID_FOR_OFFER;
|
return result.isValid() || result == Result.HAS_NO_PAYMENT_ACCOUNT_VALID_FOR_OFFER;
|
||||||
})
|
})
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
offers.removeAll(getOffersWithDuplicateKeyImages(offers));
|
|
||||||
return offers;
|
return offers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -143,12 +141,9 @@ public class CoreOffersService {
|
||||||
}
|
}
|
||||||
|
|
||||||
List<OpenOffer> getMyOffers() {
|
List<OpenOffer> getMyOffers() {
|
||||||
List<OpenOffer> offers = openOfferManager.getOpenOffers().stream()
|
return openOfferManager.getOpenOffers().stream()
|
||||||
.filter(o -> o.getOffer().isMyOffer(keyRing))
|
.filter(o -> o.getOffer().isMyOffer(keyRing))
|
||||||
.collect(Collectors.toList());
|
.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) {
|
List<OpenOffer> getMyOffers(String direction, String currencyCode) {
|
||||||
|
@ -179,15 +174,31 @@ public class CoreOffersService {
|
||||||
boolean isPrivateOffer,
|
boolean isPrivateOffer,
|
||||||
boolean buyerAsTakerWithoutDeposit,
|
boolean buyerAsTakerWithoutDeposit,
|
||||||
String extraInfo,
|
String extraInfo,
|
||||||
|
String sourceOfferId,
|
||||||
Consumer<Offer> resultHandler,
|
Consumer<Offer> resultHandler,
|
||||||
ErrorMessageHandler errorMessageHandler) {
|
ErrorMessageHandler errorMessageHandler) {
|
||||||
coreWalletsService.verifyWalletsAreAvailable();
|
coreWalletsService.verifyWalletsAreAvailable();
|
||||||
coreWalletsService.verifyEncryptedWalletIsUnlocked();
|
coreWalletsService.verifyEncryptedWalletIsUnlocked();
|
||||||
|
|
||||||
PaymentAccount paymentAccount = user.getPaymentAccount(paymentAccountId);
|
PaymentAccount paymentAccount = user.getPaymentAccount(paymentAccountId);
|
||||||
if (paymentAccount == null)
|
if (paymentAccount == null) throw new IllegalArgumentException(format("payment account with id %s not found", paymentAccountId));
|
||||||
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 upperCaseCurrencyCode = currencyCode.toUpperCase();
|
||||||
String offerId = createOfferService.getRandomOfferId();
|
String offerId = createOfferService.getRandomOfferId();
|
||||||
OfferDirection direction = OfferDirection.valueOf(directionAsString.toUpperCase());
|
OfferDirection direction = OfferDirection.valueOf(directionAsString.toUpperCase());
|
||||||
|
@ -210,17 +221,70 @@ public class CoreOffersService {
|
||||||
|
|
||||||
verifyPaymentAccountIsValidForNewOffer(offer, paymentAccount);
|
verifyPaymentAccountIsValidForNewOffer(offer, paymentAccount);
|
||||||
|
|
||||||
// We don't support atm funding from external wallet to keep it simple.
|
|
||||||
boolean useSavingsWallet = true;
|
|
||||||
//noinspection ConstantConditions
|
|
||||||
placeOffer(offer,
|
placeOffer(offer,
|
||||||
triggerPriceAsString,
|
triggerPriceAsString,
|
||||||
useSavingsWallet,
|
true,
|
||||||
reserveExactAmount,
|
reserveExactAmount,
|
||||||
|
null,
|
||||||
transaction -> resultHandler.accept(offer),
|
transaction -> resultHandler.accept(offer),
|
||||||
errorMessageHandler);
|
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,
|
Offer editOffer(String offerId,
|
||||||
String currencyCode,
|
String currencyCode,
|
||||||
OfferDirection direction,
|
OfferDirection direction,
|
||||||
|
@ -256,27 +320,6 @@ public class CoreOffersService {
|
||||||
|
|
||||||
// -------------------------- PRIVATE HELPERS -----------------------------
|
// -------------------------- 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) {
|
private void verifyPaymentAccountIsValidForNewOffer(Offer offer, PaymentAccount paymentAccount) {
|
||||||
if (!isPaymentAccountValidForOffer(offer, paymentAccount)) {
|
if (!isPaymentAccountValidForOffer(offer, paymentAccount)) {
|
||||||
String error = format("cannot create %s offer with payment account %s",
|
String error = format("cannot create %s offer with payment account %s",
|
||||||
|
@ -290,6 +333,7 @@ public class CoreOffersService {
|
||||||
String triggerPriceAsString,
|
String triggerPriceAsString,
|
||||||
boolean useSavingsWallet,
|
boolean useSavingsWallet,
|
||||||
boolean reserveExactAmount,
|
boolean reserveExactAmount,
|
||||||
|
String sourceOfferId,
|
||||||
Consumer<Transaction> resultHandler,
|
Consumer<Transaction> resultHandler,
|
||||||
ErrorMessageHandler errorMessageHandler) {
|
ErrorMessageHandler errorMessageHandler) {
|
||||||
long triggerPriceAsLong = PriceUtil.getMarketPriceAsLong(triggerPriceAsString, offer.getCurrencyCode());
|
long triggerPriceAsLong = PriceUtil.getMarketPriceAsLong(triggerPriceAsString, offer.getCurrencyCode());
|
||||||
|
@ -298,6 +342,7 @@ public class CoreOffersService {
|
||||||
triggerPriceAsLong,
|
triggerPriceAsLong,
|
||||||
reserveExactAmount,
|
reserveExactAmount,
|
||||||
true,
|
true,
|
||||||
|
sourceOfferId,
|
||||||
resultHandler::accept,
|
resultHandler::accept,
|
||||||
errorMessageHandler);
|
errorMessageHandler);
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,6 +32,7 @@ import haveno.core.xmr.nodes.XmrNodes.XmrNode;
|
||||||
import haveno.core.xmr.nodes.XmrNodesSetupPreferences;
|
import haveno.core.xmr.nodes.XmrNodesSetupPreferences;
|
||||||
import haveno.core.xmr.setup.DownloadListener;
|
import haveno.core.xmr.setup.DownloadListener;
|
||||||
import haveno.core.xmr.setup.WalletsSetup;
|
import haveno.core.xmr.setup.WalletsSetup;
|
||||||
|
import haveno.core.xmr.wallet.XmrKeyImagePoller;
|
||||||
import haveno.network.Socks5ProxyProvider;
|
import haveno.network.Socks5ProxyProvider;
|
||||||
import haveno.network.p2p.P2PService;
|
import haveno.network.p2p.P2PService;
|
||||||
import haveno.network.p2p.P2PServiceListener;
|
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 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_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 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 {
|
public enum XmrConnectionError {
|
||||||
LOCAL,
|
LOCAL,
|
||||||
|
@ -115,6 +118,7 @@ public final class XmrConnectionService {
|
||||||
@Getter
|
@Getter
|
||||||
private boolean isShutDownStarted;
|
private boolean isShutDownStarted;
|
||||||
private List<MoneroConnectionManagerListener> listeners = new ArrayList<>();
|
private List<MoneroConnectionManagerListener> listeners = new ArrayList<>();
|
||||||
|
private XmrKeyImagePoller keyImagePoller;
|
||||||
|
|
||||||
// connection switching
|
// connection switching
|
||||||
private static final int EXCLUDE_CONNECTION_SECONDS = 180;
|
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
|
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 ------------------------------
|
// ----------------------------- APP METHODS ------------------------------
|
||||||
|
|
||||||
public ReadOnlyIntegerProperty numConnectionsProperty() {
|
public ReadOnlyIntegerProperty numConnectionsProperty() {
|
||||||
|
@ -488,6 +503,13 @@ public final class XmrConnectionService {
|
||||||
|
|
||||||
private void initialize() {
|
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
|
// initialize connections
|
||||||
initializeConnections();
|
initializeConnections();
|
||||||
|
|
||||||
|
@ -694,6 +716,10 @@ public final class XmrConnectionService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// update key image poller
|
||||||
|
keyImagePoller.setDaemon(getDaemon());
|
||||||
|
keyImagePoller.setRefreshPeriodMs(getKeyImageRefreshPeriodMs());
|
||||||
|
|
||||||
// update polling
|
// update polling
|
||||||
doPollDaemon();
|
doPollDaemon();
|
||||||
if (currentConnection != getConnection()) return; // polling can change connection
|
if (currentConnection != getConnection()) return; // polling can change connection
|
||||||
|
|
|
@ -92,6 +92,7 @@ public class CreateOfferService {
|
||||||
Version.VERSION.replace(".", "");
|
Version.VERSION.replace(".", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: add trigger price?
|
||||||
public Offer createAndGetOffer(String offerId,
|
public Offer createAndGetOffer(String offerId,
|
||||||
OfferDirection direction,
|
OfferDirection direction,
|
||||||
String currencyCode,
|
String currencyCode,
|
||||||
|
@ -105,7 +106,7 @@ public class CreateOfferService {
|
||||||
boolean isPrivateOffer,
|
boolean isPrivateOffer,
|
||||||
boolean buyerAsTakerWithoutDeposit,
|
boolean buyerAsTakerWithoutDeposit,
|
||||||
String extraInfo) {
|
String extraInfo) {
|
||||||
log.info("create and get offer with offerId={}, " +
|
log.info("Create and get offer with offerId={}, " +
|
||||||
"currencyCode={}, " +
|
"currencyCode={}, " +
|
||||||
"direction={}, " +
|
"direction={}, " +
|
||||||
"fixedPrice={}, " +
|
"fixedPrice={}, " +
|
||||||
|
@ -238,6 +239,99 @@ public class CreateOfferService {
|
||||||
return offer;
|
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
|
// Private
|
||||||
///////////////////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
|
@ -36,6 +36,9 @@ package haveno.core.offer;
|
||||||
|
|
||||||
import com.google.inject.Inject;
|
import com.google.inject.Inject;
|
||||||
import com.google.inject.name.Named;
|
import com.google.inject.name.Named;
|
||||||
|
|
||||||
|
import haveno.common.ThreadUtils;
|
||||||
|
import haveno.common.Timer;
|
||||||
import haveno.common.UserThread;
|
import haveno.common.UserThread;
|
||||||
import haveno.common.config.Config;
|
import haveno.common.config.Config;
|
||||||
import haveno.common.file.JsonFileManager;
|
import haveno.common.file.JsonFileManager;
|
||||||
|
@ -45,45 +48,51 @@ import haveno.core.api.XmrConnectionService;
|
||||||
import haveno.core.filter.FilterManager;
|
import haveno.core.filter.FilterManager;
|
||||||
import haveno.core.locale.Res;
|
import haveno.core.locale.Res;
|
||||||
import haveno.core.provider.price.PriceFeedService;
|
import haveno.core.provider.price.PriceFeedService;
|
||||||
import haveno.core.trade.HavenoUtils;
|
|
||||||
import haveno.core.util.JsonUtil;
|
import haveno.core.util.JsonUtil;
|
||||||
|
import haveno.core.xmr.wallet.Restrictions;
|
||||||
import haveno.core.xmr.wallet.XmrKeyImageListener;
|
import haveno.core.xmr.wallet.XmrKeyImageListener;
|
||||||
import haveno.core.xmr.wallet.XmrKeyImagePoller;
|
|
||||||
import haveno.network.p2p.BootstrapListener;
|
import haveno.network.p2p.BootstrapListener;
|
||||||
import haveno.network.p2p.P2PService;
|
import haveno.network.p2p.P2PService;
|
||||||
import haveno.network.p2p.storage.HashMapChangedListener;
|
import haveno.network.p2p.storage.HashMapChangedListener;
|
||||||
import haveno.network.p2p.storage.payload.ProtectedStorageEntry;
|
import haveno.network.p2p.storage.payload.ProtectedStorageEntry;
|
||||||
|
import haveno.network.utils.Utils;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.LinkedList;
|
import java.util.LinkedList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import javax.annotation.Nullable;
|
import javax.annotation.Nullable;
|
||||||
import monero.daemon.model.MoneroKeyImageSpentStatus;
|
import monero.daemon.model.MoneroKeyImageSpentStatus;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles storage and retrieval of offers.
|
* Handles validation and announcement of offers added or removed.
|
||||||
* Uses an invalidation flag to only request the full offer map in case there was a change (anyone has added or removed an offer).
|
|
||||||
*/
|
*/
|
||||||
|
@Slf4j
|
||||||
public class OfferBookService {
|
public class OfferBookService {
|
||||||
|
|
||||||
|
private final static long INVALID_OFFERS_TIMEOUT = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
private final P2PService p2PService;
|
private final P2PService p2PService;
|
||||||
private final PriceFeedService priceFeedService;
|
private final PriceFeedService priceFeedService;
|
||||||
private final List<OfferBookChangedListener> offerBookChangedListeners = new LinkedList<>();
|
private final List<OfferBookChangedListener> offerBookChangedListeners = new LinkedList<>();
|
||||||
private final FilterManager filterManager;
|
private final FilterManager filterManager;
|
||||||
private final JsonFileManager jsonFileManager;
|
private final JsonFileManager jsonFileManager;
|
||||||
private final XmrConnectionService xmrConnectionService;
|
private final XmrConnectionService xmrConnectionService;
|
||||||
|
private final List<Offer> validOffers = new ArrayList<Offer>();
|
||||||
// poll key images of offers
|
private final List<Offer> invalidOffers = new ArrayList<Offer>();
|
||||||
private XmrKeyImagePoller keyImagePoller;
|
private final Map<String, Timer> invalidOfferTimers = new HashMap<>();
|
||||||
private static final long KEY_IMAGE_REFRESH_PERIOD_MS_LOCAL = 20000; // 20 seconds
|
|
||||||
private static final long KEY_IMAGE_REFRESH_PERIOD_MS_REMOTE = 300000; // 5 minutes
|
|
||||||
|
|
||||||
public interface OfferBookChangedListener {
|
public interface OfferBookChangedListener {
|
||||||
void onAdded(Offer offer);
|
void onAdded(Offer offer);
|
||||||
|
|
||||||
void onRemoved(Offer offer);
|
void onRemoved(Offer offer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -104,51 +113,45 @@ public class OfferBookService {
|
||||||
this.xmrConnectionService = xmrConnectionService;
|
this.xmrConnectionService = xmrConnectionService;
|
||||||
jsonFileManager = new JsonFileManager(storageDir);
|
jsonFileManager = new JsonFileManager(storageDir);
|
||||||
|
|
||||||
// listen for connection changes to monerod
|
|
||||||
xmrConnectionService.addConnectionListener((connection) -> {
|
|
||||||
maybeInitializeKeyImagePoller();
|
|
||||||
keyImagePoller.setDaemon(xmrConnectionService.getDaemon());
|
|
||||||
keyImagePoller.setRefreshPeriodMs(getKeyImageRefreshPeriodMs());
|
|
||||||
});
|
|
||||||
|
|
||||||
// listen for offers
|
// listen for offers
|
||||||
p2PService.addHashSetChangedListener(new HashMapChangedListener() {
|
p2PService.addHashSetChangedListener(new HashMapChangedListener() {
|
||||||
@Override
|
@Override
|
||||||
public void onAdded(Collection<ProtectedStorageEntry> protectedStorageEntries) {
|
public void onAdded(Collection<ProtectedStorageEntry> protectedStorageEntries) {
|
||||||
UserThread.execute(() -> {
|
ThreadUtils.execute(() -> {
|
||||||
protectedStorageEntries.forEach(protectedStorageEntry -> {
|
protectedStorageEntries.forEach(protectedStorageEntry -> {
|
||||||
if (protectedStorageEntry.getProtectedStoragePayload() instanceof OfferPayload) {
|
if (protectedStorageEntry.getProtectedStoragePayload() instanceof OfferPayload) {
|
||||||
OfferPayload offerPayload = (OfferPayload) protectedStorageEntry.getProtectedStoragePayload();
|
OfferPayload offerPayload = (OfferPayload) protectedStorageEntry.getProtectedStoragePayload();
|
||||||
maybeInitializeKeyImagePoller();
|
|
||||||
keyImagePoller.addKeyImages(offerPayload.getReserveTxKeyImages());
|
|
||||||
Offer offer = new Offer(offerPayload);
|
Offer offer = new Offer(offerPayload);
|
||||||
offer.setPriceFeedService(priceFeedService);
|
offer.setPriceFeedService(priceFeedService);
|
||||||
setReservedFundsSpent(offer);
|
synchronized (validOffers) {
|
||||||
synchronized (offerBookChangedListeners) {
|
try {
|
||||||
offerBookChangedListeners.forEach(listener -> listener.onAdded(offer));
|
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
|
@Override
|
||||||
public void onRemoved(Collection<ProtectedStorageEntry> protectedStorageEntries) {
|
public void onRemoved(Collection<ProtectedStorageEntry> protectedStorageEntries) {
|
||||||
UserThread.execute(() -> {
|
ThreadUtils.execute(() -> {
|
||||||
protectedStorageEntries.forEach(protectedStorageEntry -> {
|
protectedStorageEntries.forEach(protectedStorageEntry -> {
|
||||||
if (protectedStorageEntry.getProtectedStoragePayload() instanceof OfferPayload) {
|
if (protectedStorageEntry.getProtectedStoragePayload() instanceof OfferPayload) {
|
||||||
OfferPayload offerPayload = (OfferPayload) protectedStorageEntry.getProtectedStoragePayload();
|
OfferPayload offerPayload = (OfferPayload) protectedStorageEntry.getProtectedStoragePayload();
|
||||||
maybeInitializeKeyImagePoller();
|
removeValidOffer(offerPayload.getId());
|
||||||
keyImagePoller.removeKeyImages(offerPayload.getReserveTxKeyImages());
|
|
||||||
Offer offer = new Offer(offerPayload);
|
Offer offer = new Offer(offerPayload);
|
||||||
offer.setPriceFeedService(priceFeedService);
|
offer.setPriceFeedService(priceFeedService);
|
||||||
setReservedFundsSpent(offer);
|
announceOfferRemoved(offer);
|
||||||
synchronized (offerBookChangedListeners) {
|
|
||||||
offerBookChangedListeners.forEach(listener -> listener.onRemoved(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
|
// API
|
||||||
///////////////////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
public boolean hasOffer(String offerId) {
|
||||||
|
return hasValidOffer(offerId);
|
||||||
|
}
|
||||||
|
|
||||||
public void addOffer(Offer offer, ResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) {
|
public void addOffer(Offer offer, ResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) {
|
||||||
if (filterManager.requireUpdateToNewVersionForTrading()) {
|
if (filterManager.requireUpdateToNewVersionForTrading()) {
|
||||||
errorMessageHandler.handleErrorMessage(Res.get("popup.warning.mandatoryUpdate.trading"));
|
errorMessageHandler.handleErrorMessage(Res.get("popup.warning.mandatoryUpdate.trading"));
|
||||||
|
@ -233,16 +250,9 @@ public class OfferBookService {
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Offer> getOffers() {
|
public List<Offer> getOffers() {
|
||||||
return p2PService.getDataMap().values().stream()
|
synchronized (validOffers) {
|
||||||
.filter(data -> data.getProtectedStoragePayload() instanceof OfferPayload)
|
return new ArrayList<>(validOffers);
|
||||||
.map(data -> {
|
}
|
||||||
OfferPayload offerPayload = (OfferPayload) data.getProtectedStoragePayload();
|
|
||||||
Offer offer = new Offer(offerPayload);
|
|
||||||
offer.setPriceFeedService(priceFeedService);
|
|
||||||
setReservedFundsSpent(offer);
|
|
||||||
return offer;
|
|
||||||
})
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Offer> getOffersByCurrency(String direction, String currencyCode) {
|
public List<Offer> getOffersByCurrency(String direction, String currencyCode) {
|
||||||
|
@ -266,7 +276,7 @@ public class OfferBookService {
|
||||||
}
|
}
|
||||||
|
|
||||||
public void shutDown() {
|
public void shutDown() {
|
||||||
if (keyImagePoller != null) keyImagePoller.clearKeyImages();
|
xmrConnectionService.getKeyImagePoller().removeKeyImages(OfferBookService.class.getName());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -274,37 +284,145 @@ public class OfferBookService {
|
||||||
// Private
|
// Private
|
||||||
///////////////////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
private synchronized void maybeInitializeKeyImagePoller() {
|
private void announceOfferAdded(Offer offer) {
|
||||||
if (keyImagePoller != null) return;
|
xmrConnectionService.getKeyImagePoller().addKeyImages(offer.getOfferPayload().getReserveTxKeyImages(), OfferBookService.class.getSimpleName());
|
||||||
keyImagePoller = new XmrKeyImagePoller(xmrConnectionService.getDaemon(), getKeyImageRefreshPeriodMs());
|
updateReservedFundsSpentStatus(offer);
|
||||||
|
synchronized (offerBookChangedListeners) {
|
||||||
// handle when key images spent
|
offerBookChangedListeners.forEach(listener -> listener.onAdded(offer));
|
||||||
keyImagePoller.addListener(new XmrKeyImageListener() {
|
}
|
||||||
@Override
|
|
||||||
public void onSpentStatusChanged(Map<String, MoneroKeyImageSpentStatus> spentStatuses) {
|
|
||||||
UserThread.execute(() -> {
|
|
||||||
for (String keyImage : spentStatuses.keySet()) {
|
|
||||||
updateAffectedOffers(keyImage);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// first poll after 20s
|
|
||||||
// TODO: remove?
|
|
||||||
new Thread(() -> {
|
|
||||||
HavenoUtils.waitFor(20000);
|
|
||||||
keyImagePoller.poll();
|
|
||||||
}).start();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private long getKeyImageRefreshPeriodMs() {
|
private void announceOfferRemoved(Offer offer) {
|
||||||
return xmrConnectionService.isConnectionLocalHost() ? KEY_IMAGE_REFRESH_PERIOD_MS_LOCAL : KEY_IMAGE_REFRESH_PERIOD_MS_REMOTE;
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
private void updateAffectedOffers(String keyImage) {
|
||||||
for (Offer offer : getOffers()) {
|
for (Offer offer : getOffers()) {
|
||||||
if (offer.getOfferPayload().getReserveTxKeyImages().contains(keyImage)) {
|
if (offer.getOfferPayload().getReserveTxKeyImages().contains(keyImage)) {
|
||||||
|
updateReservedFundsSpentStatus(offer);
|
||||||
synchronized (offerBookChangedListeners) {
|
synchronized (offerBookChangedListeners) {
|
||||||
offerBookChangedListeners.forEach(listener -> {
|
offerBookChangedListeners.forEach(listener -> {
|
||||||
listener.onRemoved(offer);
|
listener.onRemoved(offer);
|
||||||
|
@ -315,10 +433,9 @@ public class OfferBookService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setReservedFundsSpent(Offer offer) {
|
private void updateReservedFundsSpentStatus(Offer offer) {
|
||||||
if (keyImagePoller == null) return;
|
|
||||||
for (String keyImage : offer.getOfferPayload().getReserveTxKeyImages()) {
|
for (String keyImage : offer.getOfferPayload().getReserveTxKeyImages()) {
|
||||||
if (Boolean.TRUE.equals(keyImagePoller.isSpent(keyImage))) {
|
if (Boolean.TRUE.equals(xmrConnectionService.getKeyImagePoller().isSpent(keyImage))) {
|
||||||
offer.setReservedFundsSpent(true);
|
offer.setReservedFundsSpent(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -347,6 +347,10 @@ public final class OfferPayload implements ProtectedStoragePayload, ExpirablePay
|
||||||
return Restrictions.getMinSecurityDeposit().max(securityDepositUnadjusted);
|
return Restrictions.getMinSecurityDeposit().max(securityDepositUnadjusted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isBuyerAsTakerWithoutDeposit() {
|
||||||
|
return getDirection() == OfferDirection.SELL && getBuyerSecurityDepositPct() == 0;
|
||||||
|
}
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////////////////
|
||||||
// PROTO BUFFER
|
// PROTO BUFFER
|
||||||
///////////////////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
|
@ -48,6 +48,7 @@ import java.util.ArrayList;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
@EqualsAndHashCode
|
@EqualsAndHashCode
|
||||||
public final class OpenOffer implements Tradable {
|
public final class OpenOffer implements Tradable {
|
||||||
|
@ -113,6 +114,9 @@ public final class OpenOffer implements Tradable {
|
||||||
@Getter
|
@Getter
|
||||||
@Setter
|
@Setter
|
||||||
private boolean deactivatedByTrigger;
|
private boolean deactivatedByTrigger;
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
private String groupId;
|
||||||
|
|
||||||
public OpenOffer(Offer offer) {
|
public OpenOffer(Offer offer) {
|
||||||
this(offer, 0, false);
|
this(offer, 0, false);
|
||||||
|
@ -127,6 +131,7 @@ public final class OpenOffer implements Tradable {
|
||||||
this.triggerPrice = triggerPrice;
|
this.triggerPrice = triggerPrice;
|
||||||
this.reserveExactAmount = reserveExactAmount;
|
this.reserveExactAmount = reserveExactAmount;
|
||||||
this.challenge = offer.getChallenge();
|
this.challenge = offer.getChallenge();
|
||||||
|
this.groupId = UUID.randomUUID().toString();
|
||||||
state = State.PENDING;
|
state = State.PENDING;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -146,6 +151,7 @@ public final class OpenOffer implements Tradable {
|
||||||
this.reserveTxKey = openOffer.reserveTxKey;
|
this.reserveTxKey = openOffer.reserveTxKey;
|
||||||
this.challenge = openOffer.challenge;
|
this.challenge = openOffer.challenge;
|
||||||
this.deactivatedByTrigger = openOffer.deactivatedByTrigger;
|
this.deactivatedByTrigger = openOffer.deactivatedByTrigger;
|
||||||
|
this.groupId = openOffer.groupId;
|
||||||
}
|
}
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
@ -164,7 +170,8 @@ public final class OpenOffer implements Tradable {
|
||||||
@Nullable String reserveTxHex,
|
@Nullable String reserveTxHex,
|
||||||
@Nullable String reserveTxKey,
|
@Nullable String reserveTxKey,
|
||||||
@Nullable String challenge,
|
@Nullable String challenge,
|
||||||
boolean deactivatedByTrigger) {
|
boolean deactivatedByTrigger,
|
||||||
|
@Nullable String groupId) {
|
||||||
this.offer = offer;
|
this.offer = offer;
|
||||||
this.state = state;
|
this.state = state;
|
||||||
this.triggerPrice = triggerPrice;
|
this.triggerPrice = triggerPrice;
|
||||||
|
@ -177,6 +184,8 @@ public final class OpenOffer implements Tradable {
|
||||||
this.reserveTxKey = reserveTxKey;
|
this.reserveTxKey = reserveTxKey;
|
||||||
this.challenge = challenge;
|
this.challenge = challenge;
|
||||||
this.deactivatedByTrigger = deactivatedByTrigger;
|
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
|
// reset reserved state to available
|
||||||
if (this.state == State.RESERVED) setState(State.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(reserveTxHex).ifPresent(e -> builder.setReserveTxHex(reserveTxHex));
|
||||||
Optional.ofNullable(reserveTxKey).ifPresent(e -> builder.setReserveTxKey(reserveTxKey));
|
Optional.ofNullable(reserveTxKey).ifPresent(e -> builder.setReserveTxKey(reserveTxKey));
|
||||||
Optional.ofNullable(challenge).ifPresent(e -> builder.setChallenge(challenge));
|
Optional.ofNullable(challenge).ifPresent(e -> builder.setChallenge(challenge));
|
||||||
|
Optional.ofNullable(groupId).ifPresent(e -> builder.setGroupId(groupId));
|
||||||
|
|
||||||
return protobuf.Tradable.newBuilder().setOpenOffer(builder).build();
|
return protobuf.Tradable.newBuilder().setOpenOffer(builder).build();
|
||||||
}
|
}
|
||||||
|
@ -216,7 +226,8 @@ public final class OpenOffer implements Tradable {
|
||||||
ProtoUtil.stringOrNullFromProto(proto.getReserveTxHex()),
|
ProtoUtil.stringOrNullFromProto(proto.getReserveTxHex()),
|
||||||
ProtoUtil.stringOrNullFromProto(proto.getReserveTxKey()),
|
ProtoUtil.stringOrNullFromProto(proto.getReserveTxKey()),
|
||||||
ProtoUtil.stringOrNullFromProto(proto.getChallenge()),
|
ProtoUtil.stringOrNullFromProto(proto.getChallenge()),
|
||||||
proto.getDeactivatedByTrigger());
|
proto.getDeactivatedByTrigger(),
|
||||||
|
ProtoUtil.stringOrNullFromProto(proto.getGroupId()));
|
||||||
return openOffer;
|
return openOffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -282,6 +293,7 @@ public final class OpenOffer implements Tradable {
|
||||||
",\n reserveExactAmount=" + reserveExactAmount +
|
",\n reserveExactAmount=" + reserveExactAmount +
|
||||||
",\n scheduledAmount=" + scheduledAmount +
|
",\n scheduledAmount=" + scheduledAmount +
|
||||||
",\n splitOutputTxFee=" + splitOutputTxFee +
|
",\n splitOutputTxFee=" + splitOutputTxFee +
|
||||||
|
",\n groupId=" + groupId +
|
||||||
"\n}";
|
"\n}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -55,7 +55,7 @@ import haveno.core.api.CoreContext;
|
||||||
import haveno.core.api.XmrConnectionService;
|
import haveno.core.api.XmrConnectionService;
|
||||||
import haveno.core.exceptions.TradePriceOutOfToleranceException;
|
import haveno.core.exceptions.TradePriceOutOfToleranceException;
|
||||||
import haveno.core.filter.FilterManager;
|
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.OfferAvailabilityRequest;
|
||||||
import haveno.core.offer.messages.OfferAvailabilityResponse;
|
import haveno.core.offer.messages.OfferAvailabilityResponse;
|
||||||
import haveno.core.offer.messages.SignOfferRequest;
|
import haveno.core.offer.messages.SignOfferRequest;
|
||||||
|
@ -97,7 +97,6 @@ import haveno.network.p2p.peers.PeerManager;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
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 REPUBLISH_INTERVAL_MS = TimeUnit.MINUTES.toMillis(30);
|
||||||
private static final long REFRESH_INTERVAL_MS = OfferPayload.TTL / 2;
|
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 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 CoreContext coreContext;
|
||||||
private final KeyRing keyRing;
|
private final KeyRing keyRing;
|
||||||
|
@ -169,12 +171,6 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
||||||
@Getter
|
@Getter
|
||||||
private final AccountAgeWitnessService accountAgeWitnessService;
|
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
|
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.persistenceManager.initialize(openOffers, "OpenOffers", PersistenceManager.Source.PRIVATE);
|
||||||
this.signedOfferPersistenceManager.initialize(signedOffers, "SignedOffers", PersistenceManager.Source.PRIVATE); // arbitrator stores reserve tx for signed offers
|
this.signedOfferPersistenceManager.initialize(signedOffers, "SignedOffers", PersistenceManager.Source.PRIVATE); // arbitrator stores reserve tx for signed offers
|
||||||
|
|
||||||
// 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
|
@Override
|
||||||
|
@ -268,34 +243,6 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
||||||
completeHandler);
|
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() {
|
public void onAllServicesInitialized() {
|
||||||
p2PService.addDecryptedDirectMessageListener(this);
|
p2PService.addDecryptedDirectMessageListener(this);
|
||||||
|
|
||||||
|
@ -330,7 +277,8 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
||||||
stopped = true;
|
stopped = true;
|
||||||
p2PService.getPeerManager().removeListener(this);
|
p2PService.getPeerManager().removeListener(this);
|
||||||
p2PService.removeDecryptedDirectMessageListener(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();
|
stopPeriodicRefreshOffersTimer();
|
||||||
stopPeriodicRepublishOffersTimer();
|
stopPeriodicRepublishOffersTimer();
|
||||||
|
@ -385,11 +333,7 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
||||||
removeOpenOffers(getObservableList(), completeHandler);
|
removeOpenOffers(getObservableList(), completeHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void removeOpenOffer(OpenOffer openOffer, @Nullable Runnable completeHandler) {
|
private void removeOpenOffers(List<OpenOffer> openOffers, @Nullable Runnable completeHandler) {
|
||||||
removeOpenOffers(List.of(openOffer), completeHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void removeOpenOffers(List<OpenOffer> openOffers, @Nullable Runnable completeHandler) {
|
|
||||||
int size = openOffers.size();
|
int size = openOffers.size();
|
||||||
// Copy list as we remove in the loop
|
// Copy list as we remove in the loop
|
||||||
List<OpenOffer> openOffersList = new ArrayList<>(openOffers);
|
List<OpenOffer> openOffersList = new ArrayList<>(openOffers);
|
||||||
|
@ -442,6 +386,19 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
||||||
|
|
||||||
maybeUpdatePersistedOffers();
|
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
|
// run off user thread so app is not blocked from starting
|
||||||
ThreadUtils.submitToPool(() -> {
|
ThreadUtils.submitToPool(() -> {
|
||||||
|
|
||||||
|
@ -492,12 +449,14 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// initialize key image poller for signed offers
|
// poll spent status of open offer key images
|
||||||
maybeInitializeKeyImagePoller();
|
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()) {
|
for (SignedOffer signedOffer : signedOffers.getList()) {
|
||||||
signedOfferKeyImagePoller.addKeyImages(signedOffer.getReserveTxKeyImages());
|
xmrConnectionService.getKeyImagePoller().addKeyImages(signedOffer.getReserveTxKeyImages(), SIGNED_OFFER_KEY_IMAGE_GROUP_ID);
|
||||||
}
|
}
|
||||||
}, THREAD_ID);
|
}, THREAD_ID);
|
||||||
});
|
});
|
||||||
|
@ -544,17 +503,59 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
||||||
long triggerPrice,
|
long triggerPrice,
|
||||||
boolean reserveExactAmount,
|
boolean reserveExactAmount,
|
||||||
boolean resetAddressEntriesOnError,
|
boolean resetAddressEntriesOnError,
|
||||||
|
String sourceOfferId,
|
||||||
TransactionResultHandler resultHandler,
|
TransactionResultHandler resultHandler,
|
||||||
ErrorMessageHandler errorMessageHandler) {
|
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
|
// 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
|
// schedule or post offer
|
||||||
ThreadUtils.execute(() -> {
|
ThreadUtils.execute(() -> {
|
||||||
synchronized (processOffersLock) {
|
synchronized (processOffersLock) {
|
||||||
CountDownLatch latch = new CountDownLatch(1);
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
addOpenOffer(openOffer);
|
|
||||||
processOffer(getOpenOffers(), openOffer, (transaction) -> {
|
processOffer(getOpenOffers(), openOffer, (transaction) -> {
|
||||||
requestPersistence();
|
requestPersistence();
|
||||||
latch.countDown();
|
latch.countDown();
|
||||||
|
@ -591,18 +592,30 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
||||||
if (openOffer.isPending()) {
|
if (openOffer.isPending()) {
|
||||||
resultHandler.handleResult(); // ignore if pending
|
resultHandler.handleResult(); // ignore if pending
|
||||||
} else if (offersToBeEdited.containsKey(openOffer.getId())) {
|
} 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 {
|
} else {
|
||||||
Offer offer = openOffer.getOffer();
|
try {
|
||||||
offerBookService.activateOffer(offer,
|
|
||||||
() -> {
|
// validate arbitrator signature
|
||||||
openOffer.setState(OpenOffer.State.AVAILABLE);
|
validateSignedState(openOffer);
|
||||||
applyTriggerState(openOffer);
|
|
||||||
requestPersistence();
|
// activate offer on offer book
|
||||||
log.debug("activateOpenOffer, offerId={}", offer.getId());
|
Offer offer = openOffer.getOffer();
|
||||||
resultHandler.handleResult();
|
offerBookService.activateOffer(offer,
|
||||||
},
|
() -> {
|
||||||
errorMessageHandler);
|
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 {
|
} 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);
|
OpenOffer editedOpenOffer = new OpenOffer(editedOffer, triggerPrice, openOffer);
|
||||||
if (originalState == OpenOffer.State.DEACTIVATED && openOffer.isDeactivatedByTrigger()) {
|
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);
|
applyTriggerState(editedOpenOffer);
|
||||||
} else {
|
} else {
|
||||||
editedOpenOffer.setState(originalState);
|
if (originalState == OpenOffer.State.AVAILABLE && hasConflictingClone(editedOpenOffer)) {
|
||||||
|
editedOpenOffer.setState(OpenOffer.State.DEACTIVATED);
|
||||||
|
} else {
|
||||||
|
editedOpenOffer.setState(originalState);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addOpenOffer(editedOpenOffer);
|
addOpenOffer(editedOpenOffer);
|
||||||
|
|
||||||
// reset arbitrator signature if invalid
|
// check for valid arbitrator signature after editing
|
||||||
Arbitrator arbitrator = user.getAcceptedArbitratorByAddress(editedOpenOffer.getOffer().getOfferPayload().getArbitratorSigner());
|
Arbitrator arbitrator = user.getAcceptedArbitratorByAddress(editedOpenOffer.getOffer().getOfferPayload().getArbitratorSigner());
|
||||||
if (arbitrator == null || !HavenoUtils.isArbitratorSignatureValid(editedOpenOffer.getOffer().getOfferPayload(), arbitrator)) {
|
if (arbitrator == null || !HavenoUtils.isArbitratorSignatureValid(editedOpenOffer.getOffer().getOfferPayload(), arbitrator)) {
|
||||||
|
|
||||||
|
// reset arbitrator signature
|
||||||
editedOpenOffer.getOffer().getOfferPayload().setArbitratorSignature(null);
|
editedOpenOffer.getOffer().getOfferPayload().setArbitratorSignature(null);
|
||||||
editedOpenOffer.getOffer().getOfferPayload().setArbitratorSigner(null);
|
editedOpenOffer.getOffer().getOfferPayload().setArbitratorSigner(null);
|
||||||
}
|
|
||||||
|
|
||||||
// process offer which might sign and publish
|
// process offer to sign and publish
|
||||||
processOffer(getOpenOffers(), editedOpenOffer, (transaction) -> {
|
processOffer(getOpenOffers(), editedOpenOffer, (transaction) -> {
|
||||||
|
offersToBeEdited.remove(openOffer.getId());
|
||||||
|
requestPersistence();
|
||||||
|
resultHandler.handleResult();
|
||||||
|
}, (errorMsg) -> {
|
||||||
|
errorMessageHandler.handleErrorMessage(errorMsg);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
maybeRepublishOffer(editedOpenOffer, null);
|
||||||
offersToBeEdited.remove(openOffer.getId());
|
offersToBeEdited.remove(openOffer.getId());
|
||||||
requestPersistence();
|
requestPersistence();
|
||||||
resultHandler.handleResult();
|
resultHandler.handleResult();
|
||||||
}, (errorMsg) -> {
|
}
|
||||||
errorMessageHandler.handleErrorMessage(errorMsg);
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
errorMessageHandler.handleErrorMessage("There is no offer with this id existing to be published.");
|
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 offer = openOffer.getOffer();
|
||||||
offer.setState(Offer.State.REMOVED);
|
offer.setState(Offer.State.REMOVED);
|
||||||
openOffer.setState(OpenOffer.State.CANCELED);
|
openOffer.setState(OpenOffer.State.CANCELED);
|
||||||
|
boolean hasClonedOffer = hasClonedOffer(offer.getId()); // record before removing open offer
|
||||||
removeOpenOffer(openOffer);
|
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());
|
if (resetAddressEntries) xmrWalletService.resetAddressEntriesForOpenOffer(offer.getId());
|
||||||
requestPersistence();
|
requestPersistence();
|
||||||
xmrWalletService.thawOutputs(offer.getOfferPayload().getReserveTxKeyImages());
|
if (!hasClonedOffer) xmrWalletService.thawOutputs(offer.getOfferPayload().getReserveTxKeyImages());
|
||||||
}
|
}
|
||||||
|
|
||||||
// close open offer after key images spent
|
// close open offer group after key images spent
|
||||||
public void closeOpenOffer(Offer offer) {
|
public void closeSpentOffer(Offer offer) {
|
||||||
getOpenOffer(offer.getId()).ifPresent(openOffer -> {
|
getOpenOffer(offer.getId()).ifPresent(openOffer -> {
|
||||||
removeOpenOffer(openOffer);
|
for (OpenOffer groupOffer: getOpenOfferGroup(openOffer.getGroupId())) {
|
||||||
openOffer.setState(OpenOffer.State.CLOSED);
|
doCloseOpenOffer(groupOffer);
|
||||||
xmrWalletService.resetAddressEntriesForOpenOffer(offer.getId());
|
}
|
||||||
offerBookService.removeOffer(openOffer.getOffer().getOfferPayload(),
|
|
||||||
() -> log.info("Successfully removed offer {}", offer.getId()),
|
|
||||||
log::error);
|
|
||||||
requestPersistence();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
public void reserveOpenOffer(OpenOffer openOffer) {
|
||||||
openOffer.setState(OpenOffer.State.RESERVED);
|
openOffer.setState(OpenOffer.State.RESERVED);
|
||||||
requestPersistence();
|
requestPersistence();
|
||||||
|
@ -783,6 +818,37 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
||||||
requestPersistence();
|
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
|
// Getters
|
||||||
///////////////////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
@ -791,7 +857,7 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
||||||
return offer.isMyOffer(keyRing);
|
return offer.isMyOffer(keyRing);
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean hasOpenOffers() {
|
public boolean hasAvailableOpenOffers() {
|
||||||
synchronized (openOffers) {
|
synchronized (openOffers) {
|
||||||
for (OpenOffer openOffer : getOpenOffers()) {
|
for (OpenOffer openOffer : getOpenOffers()) {
|
||||||
if (openOffer.getState() == OpenOffer.State.AVAILABLE) {
|
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() {
|
public List<SignedOffer> getSignedOffers() {
|
||||||
synchronized (signedOffers) {
|
synchronized (signedOffers) {
|
||||||
return new ArrayList<>(signedOffers.getObservableList());
|
return new ArrayList<>(signedOffers.getObservableList());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public ObservableList<SignedOffer> getObservableSignedOffersList() {
|
public ObservableList<SignedOffer> getObservableSignedOffersList() {
|
||||||
synchronized (signedOffers) {
|
synchronized (signedOffers) {
|
||||||
return signedOffers.getObservableList();
|
return signedOffers.getObservableList();
|
||||||
|
@ -846,6 +937,9 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
||||||
synchronized (openOffers) {
|
synchronized (openOffers) {
|
||||||
openOffers.add(openOffer);
|
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) {
|
private void removeOpenOffer(OpenOffer openOffer) {
|
||||||
|
@ -857,6 +951,18 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
||||||
PlaceOfferProtocol protocol = placeOfferProtocols.remove(openOffer.getId());
|
PlaceOfferProtocol protocol = placeOfferProtocols.remove(openOffer.getId());
|
||||||
if (protocol != null) protocol.cancelOffer();
|
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) {
|
private void addSignedOffer(SignedOffer signedOffer) {
|
||||||
|
@ -870,7 +976,7 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
||||||
|
|
||||||
// add new signed offer
|
// add new signed offer
|
||||||
signedOffers.add(signedOffer);
|
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());
|
log.info("Removing SignedOffer for offer {}", signedOffer.getOfferId());
|
||||||
synchronized (signedOffers) {
|
synchronized (signedOffers) {
|
||||||
signedOffers.remove(signedOffer);
|
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>();
|
List<String> errorMessages = new ArrayList<String>();
|
||||||
synchronized (processOffersLock) {
|
synchronized (processOffersLock) {
|
||||||
List<OpenOffer> openOffers = getOpenOffers();
|
List<OpenOffer> openOffers = getOpenOffers();
|
||||||
removeOffersWithDuplicateKeyImages(openOffers);
|
|
||||||
for (OpenOffer offer : openOffers) {
|
for (OpenOffer offer : openOffers) {
|
||||||
if (skipOffersWithTooManyAttempts && offer.getNumProcessingAttempts() > NUM_ATTEMPTS_THRESHOLD) continue; // skip offers with too many attempts
|
if (skipOffersWithTooManyAttempts && offer.getNumProcessingAttempts() > NUM_ATTEMPTS_THRESHOLD) continue; // skip offers with too many attempts
|
||||||
CountDownLatch latch = new CountDownLatch(1);
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
@ -922,28 +1027,6 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
||||||
}, THREAD_ID);
|
}, 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) {
|
private void processOffer(List<OpenOffer> openOffers, OpenOffer openOffer, TransactionResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) {
|
||||||
|
|
||||||
// skip if already processing
|
// skip if already processing
|
||||||
|
@ -993,33 +1076,40 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// validate non-pending state
|
// handle pending offer
|
||||||
if (!openOffer.isPending()) {
|
if (openOffer.isPending()) {
|
||||||
boolean isValid = true;
|
|
||||||
Arbitrator arbitrator = user.getAcceptedArbitratorByAddress(openOffer.getOffer().getOfferPayload().getArbitratorSigner());
|
// only process the first offer of a pending clone group
|
||||||
if (openOffer.getOffer().getOfferPayload().getArbitratorSigner() == null) {
|
if (openOffer.getGroupId() != null) {
|
||||||
isValid = false;
|
List<OpenOffer> openOfferClones = getOpenOfferGroup(openOffer.getGroupId());
|
||||||
} else if (arbitrator == null) {
|
if (openOfferClones.size() > 1 && !openOfferClones.get(0).getId().equals(openOffer.getId()) && openOfferClones.get(0).isPending()) {
|
||||||
log.warn("Offer {} signed by unavailable arbitrator, reposting", openOffer.getId());
|
resultHandler.handleResult(null);
|
||||||
isValid = false;
|
return;
|
||||||
} else if (!HavenoUtils.isArbitratorSignatureValid(openOffer.getOffer().getOfferPayload(), arbitrator)) {
|
}
|
||||||
log.warn("Offer {} has invalid arbitrator signature, reposting", openOffer.getId());
|
|
||||||
isValid = false;
|
|
||||||
}
|
}
|
||||||
if ((openOffer.getOffer().getOfferPayload().getReserveTxKeyImages() != null || openOffer.getOffer().getOfferPayload().getReserveTxKeyImages().isEmpty()) && (openOffer.getReserveTxHash() == null || openOffer.getReserveTxHash().isEmpty())) {
|
} else {
|
||||||
log.warn("Offer {} is missing reserve tx hash but has reserved key images, reposting", openOffer.getId());
|
|
||||||
isValid = false;
|
// validate non-pending state
|
||||||
}
|
try {
|
||||||
if (isValid) {
|
validateSignedState(openOffer);
|
||||||
resultHandler.handleResult(null);
|
resultHandler.handleResult(null); // done processing if non-pending state is valid
|
||||||
return;
|
return;
|
||||||
} else {
|
} catch (Exception e) {
|
||||||
|
log.warn(e.getMessage());
|
||||||
|
|
||||||
|
// reset arbitrator signature
|
||||||
openOffer.getOffer().getOfferPayload().setArbitratorSignature(null);
|
openOffer.getOffer().getOfferPayload().setArbitratorSignature(null);
|
||||||
openOffer.getOffer().getOfferPayload().setArbitratorSigner(null);
|
openOffer.getOffer().getOfferPayload().setArbitratorSigner(null);
|
||||||
if (openOffer.isAvailable()) openOffer.setState(OpenOffer.State.PENDING);
|
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
|
// cancel offer if scheduled txs unavailable
|
||||||
if (openOffer.getScheduledTxHashes() != null) {
|
if (openOffer.getScheduledTxHashes() != null) {
|
||||||
boolean scheduledTxsAvailable = true;
|
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
|
// get amount needed to reserve offer
|
||||||
BigInteger amountNeeded = openOffer.getOffer().getAmountNeeded();
|
BigInteger amountNeeded = openOffer.getOffer().getAmountNeeded();
|
||||||
|
|
||||||
|
@ -1084,6 +1168,21 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
||||||
}).start();
|
}).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) {
|
private MoneroTxWallet getSplitOutputFundingTx(List<OpenOffer> openOffers, OpenOffer openOffer) {
|
||||||
XmrAddressEntry addressEntry = xmrWalletService.getOrCreateAddressEntry(openOffer.getId(), XmrAddressEntry.Context.OFFER_FUNDING);
|
XmrAddressEntry addressEntry = xmrWalletService.getOrCreateAddressEntry(openOffer.getId(), XmrAddressEntry.Context.OFFER_FUNDING);
|
||||||
return getSplitOutputFundingTx(openOffers, openOffer, openOffer.getOffer().getAmountNeeded(), addressEntry.getSubaddressIndex());
|
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) {
|
private boolean preventedFromPublishing(OpenOffer openOffer) {
|
||||||
if (!Boolean.TRUE.equals(xmrConnectionService.isConnected())) return true;
|
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() {
|
private void startPeriodicRepublishOffersTimer() {
|
||||||
|
|
|
@ -19,7 +19,9 @@ package haveno.core.offer.placeoffer.tasks;
|
||||||
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
import haveno.common.taskrunner.Task;
|
import haveno.common.taskrunner.Task;
|
||||||
import haveno.common.taskrunner.TaskRunner;
|
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);
|
XmrAddressEntry fundingEntry = model.getXmrWalletService().getAddressEntry(offer.getId(), XmrAddressEntry.Context.OFFER_FUNDING).orElse(null);
|
||||||
Integer preferredSubaddressIndex = fundingEntry == null ? null : fundingEntry.getSubaddressIndex();
|
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
|
// attempt creating reserve tx
|
||||||
MoneroTxWallet reserveTx = null;
|
MoneroTxWallet reserveTx = null;
|
||||||
try {
|
try {
|
||||||
|
@ -120,23 +128,42 @@ public class MakerReserveOfferFunds extends Task<PlaceOfferModel> {
|
||||||
List<String> reservedKeyImages = new ArrayList<String>();
|
List<String> reservedKeyImages = new ArrayList<String>();
|
||||||
for (MoneroOutput input : reserveTx.getInputs()) reservedKeyImages.add(input.getKeyImage().getHex());
|
for (MoneroOutput input : reserveTx.getInputs()) reservedKeyImages.add(input.getKeyImage().getHex());
|
||||||
|
|
||||||
// update offer state
|
// update offer state including clones
|
||||||
openOffer.setReserveTxHash(reserveTx.getHash());
|
if (openOffer.getGroupId() == null) {
|
||||||
openOffer.setReserveTxHex(reserveTx.getFullHex());
|
openOffer.setReserveTxHash(reserveTx.getHash());
|
||||||
openOffer.setReserveTxKey(reserveTx.getKey());
|
openOffer.setReserveTxHex(reserveTx.getFullHex());
|
||||||
offer.getOfferPayload().setReserveTxKeyImages(reservedKeyImages);
|
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) {
|
if (fundingEntry != null) {
|
||||||
|
|
||||||
|
// get reserve tx inputs
|
||||||
List<MoneroOutputWallet> inputs = model.getXmrWalletService().getOutputs(reservedKeyImages);
|
List<MoneroOutputWallet> inputs = model.getXmrWalletService().getOutputs(reservedKeyImages);
|
||||||
boolean usesFundingEntry = false;
|
|
||||||
|
// collect subaddress indices of inputs
|
||||||
|
Set<Integer> inputSubaddressIndices = new HashSet<>();
|
||||||
for (MoneroOutputWallet input : inputs) {
|
for (MoneroOutputWallet input : inputs) {
|
||||||
if (input.getAccountIndex() == 0 && input.getSubaddressIndex() == fundingEntry.getSubaddressIndex()) {
|
if (input.getAccountIndex() == 0) inputSubaddressIndices.add(input.getSubaddressIndex());
|
||||||
usesFundingEntry = true;
|
}
|
||||||
break;
|
|
||||||
|
// 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();
|
complete();
|
||||||
|
|
|
@ -36,6 +36,16 @@ public class MaybeAddToOfferBook extends Task<PlaceOfferModel> {
|
||||||
try {
|
try {
|
||||||
runInterceptHook();
|
runInterceptHook();
|
||||||
checkNotNull(model.getSignOfferResponse().getSignedOfferPayload().getArbitratorSignature(), "Offer's arbitrator signature is null: " + model.getOpenOffer().getOffer().getId());
|
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()) {
|
if (model.getOpenOffer().isPending() || model.getOpenOffer().isAvailable()) {
|
||||||
model.getOfferBookService().addOffer(new Offer(model.getSignOfferResponse().getSignedOfferPayload()),
|
model.getOfferBookService().addOffer(new Offer(model.getSignOfferResponse().getSignedOfferPayload()),
|
||||||
() -> {
|
() -> {
|
||||||
|
|
|
@ -197,7 +197,7 @@ public final class MediationManager extends DisputeManager<MediationDisputeList>
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Optional<OpenOffer> openOfferOptional = openOfferManager.getOpenOffer(tradeId);
|
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);
|
sendAckMessage(chatMessage, dispute.getAgentPubKeyRing(), true, null);
|
||||||
|
|
||||||
|
|
|
@ -197,7 +197,7 @@ public final class RefundManager extends DisputeManager<RefundDisputeList> {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Optional<OpenOffer> openOfferOptional = openOfferManager.getOpenOffer(tradeId);
|
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);
|
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);
|
tradeManager.closeDisputedTrade(tradeId, Trade.DisputeState.REFUND_REQUEST_CLOSED);
|
||||||
} else {
|
} else {
|
||||||
Optional<OpenOffer> openOfferOptional = openOfferManager.getOpenOffer(tradeId);
|
Optional<OpenOffer> openOfferOptional = openOfferManager.getOpenOffer(tradeId);
|
||||||
openOfferOptional.ifPresent(openOffer -> openOfferManager.closeOpenOffer(openOffer.getOffer()));
|
openOfferOptional.ifPresent(openOffer -> openOfferManager.closeSpentOffer(openOffer.getOffer()));
|
||||||
}
|
}
|
||||||
|
|
||||||
requestPersistence();
|
requestPersistence();
|
||||||
|
|
|
@ -710,7 +710,7 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model {
|
||||||
maybePublishTradeStatistics();
|
maybePublishTradeStatistics();
|
||||||
|
|
||||||
// reset address entries
|
// reset address entries
|
||||||
processModel.getXmrWalletService().resetAddressEntriesForTrade(getId());
|
processModel.getXmrWalletService().swapPayoutAddressEntryToAvailable(getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
// handle when payout unlocks
|
// handle when payout unlocks
|
||||||
|
@ -1755,7 +1755,7 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model {
|
||||||
// close open offer
|
// close open offer
|
||||||
if (this instanceof MakerTrade && processModel.getOpenOfferManager().getOpenOffer(getId()).isPresent()) {
|
if (this instanceof MakerTrade && processModel.getOpenOfferManager().getOpenOffer(getId()).isPresent()) {
|
||||||
log.info("Closing open offer because {} {} was restored after protocol error", getClass().getSimpleName(), getShortId());
|
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
|
// re-freeze outputs
|
||||||
|
@ -2371,7 +2371,7 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model {
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean hasBuyerAsTakerWithoutDeposit() {
|
public boolean hasBuyerAsTakerWithoutDeposit() {
|
||||||
return getBuyer() == getTaker() && BigInteger.ZERO.equals(getBuyerSecurityDepositBeforeMiningFee());
|
return getOffer().getOfferPayload().isBuyerAsTakerWithoutDeposit();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -2945,7 +2945,7 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model {
|
||||||
|
|
||||||
// close open offer or reset address entries
|
// close open offer or reset address entries
|
||||||
if (this instanceof MakerTrade) {
|
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
|
HavenoUtils.notificationService.sendTradeNotification(this, Phase.DEPOSITS_PUBLISHED, "Offer Taken", "Your offer " + offer.getId() + " has been accepted"); // TODO (woodser): use language translation
|
||||||
} else {
|
} else {
|
||||||
getXmrWalletService().resetAddressEntriesForOpenOffer(getId());
|
getXmrWalletService().resetAddressEntriesForOpenOffer(getId());
|
||||||
|
|
|
@ -977,7 +977,7 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi
|
||||||
removeTrade(trade, true);
|
removeTrade(trade, true);
|
||||||
|
|
||||||
// TODO The address entry should have been removed already. Check and if its the case remove that.
|
// 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();
|
requestPersistence();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1011,7 +1011,7 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi
|
||||||
if (tradeOptional.isPresent()) {
|
if (tradeOptional.isPresent()) {
|
||||||
Trade trade = tradeOptional.get();
|
Trade trade = tradeOptional.get();
|
||||||
trade.setDisputeState(disputeState);
|
trade.setDisputeState(disputeState);
|
||||||
xmrWalletService.resetAddressEntriesForTrade(trade.getId());
|
xmrWalletService.swapPayoutAddressEntryToAvailable(trade.getId());
|
||||||
requestPersistence();
|
requestPersistence();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -89,7 +89,7 @@ public class TakerReserveTradeFunds extends TradeTask {
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|
||||||
// reset state with wallet lock
|
// reset state with wallet lock
|
||||||
model.getXmrWalletService().resetAddressEntriesForTrade(trade.getId());
|
model.getXmrWalletService().swapPayoutAddressEntryToAvailable(trade.getId());
|
||||||
if (reserveTx != null) {
|
if (reserveTx != null) {
|
||||||
model.getXmrWalletService().thawOutputs(HavenoUtils.getInputKeyImages(reserveTx));
|
model.getXmrWalletService().thawOutputs(HavenoUtils.getInputKeyImages(reserveTx));
|
||||||
trade.getSelf().setReserveTxKeyImages(null);
|
trade.getSelf().setReserveTxKeyImages(null);
|
||||||
|
|
|
@ -122,12 +122,12 @@ public final class XmrAddressEntry implements PersistablePayload {
|
||||||
return context == Context.OFFER_FUNDING;
|
return context == Context.OFFER_FUNDING;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isTrade() {
|
public boolean isTradePayout() {
|
||||||
return context == Context.TRADE_PAYOUT;
|
return context == Context.TRADE_PAYOUT;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isTradable() {
|
public boolean isTradable() {
|
||||||
return isOpenOffer() || isTrade();
|
return isOpenOffer() || isTradePayout();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Coin getCoinLockedInMultiSig() {
|
public Coin getCoinLockedInMultiSig() {
|
||||||
|
|
|
@ -110,10 +110,25 @@ public final class XmrAddressEntryList implements PersistableEnvelope, Persisted
|
||||||
}
|
}
|
||||||
|
|
||||||
public void swapToAvailable(XmrAddressEntry addressEntry) {
|
public void swapToAvailable(XmrAddressEntry addressEntry) {
|
||||||
boolean setChangedByRemove = entrySet.remove(addressEntry);
|
log.info("swapToAvailable addressEntry to swap={}", addressEntry);
|
||||||
boolean setChangedByAdd = entrySet.add(new XmrAddressEntry(addressEntry.getSubaddressIndex(), addressEntry.getAddressString(),
|
if (entrySet.remove(addressEntry)) {
|
||||||
XmrAddressEntry.Context.AVAILABLE));
|
requestPersistence();
|
||||||
if (setChangedByRemove || setChangedByAdd) {
|
}
|
||||||
|
// 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();
|
requestPersistence();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,6 +31,7 @@ public class Restrictions {
|
||||||
public static BigInteger MIN_TRADE_AMOUNT = HavenoUtils.xmrToAtomicUnits(0.1);
|
public static BigInteger MIN_TRADE_AMOUNT = HavenoUtils.xmrToAtomicUnits(0.1);
|
||||||
public static BigInteger MIN_SECURITY_DEPOSIT = 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_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
|
// 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.
|
// 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.
|
* Poll for changes to the spent status of key images.
|
||||||
*
|
|
||||||
* TODO: move to monero-java?
|
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class XmrKeyImagePoller {
|
public class XmrKeyImagePoller {
|
||||||
|
|
||||||
private MoneroDaemon daemon;
|
private MoneroDaemon daemon;
|
||||||
private long refreshPeriodMs;
|
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 Set<XmrKeyImageListener> listeners = new HashSet<XmrKeyImageListener>();
|
||||||
private TaskLooper looper;
|
private TaskLooper looper;
|
||||||
private Map<String, MoneroKeyImageSpentStatus> lastStatuses = new HashMap<String, MoneroKeyImageSpentStatus>();
|
private Map<String, MoneroKeyImageSpentStatus> lastStatuses = new HashMap<String, MoneroKeyImageSpentStatus>();
|
||||||
|
@ -53,9 +51,6 @@ public class XmrKeyImagePoller {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construct the listener.
|
* Construct the listener.
|
||||||
*
|
|
||||||
* @param refreshPeriodMs - refresh period in milliseconds
|
|
||||||
* @param keyImages - key images to listen to
|
|
||||||
*/
|
*/
|
||||||
public XmrKeyImagePoller() {
|
public XmrKeyImagePoller() {
|
||||||
looper = new TaskLooper(() -> poll());
|
looper = new TaskLooper(() -> poll());
|
||||||
|
@ -64,14 +59,13 @@ public class XmrKeyImagePoller {
|
||||||
/**
|
/**
|
||||||
* Construct the listener.
|
* Construct the listener.
|
||||||
*
|
*
|
||||||
|
* @param daemon - the Monero daemon to poll
|
||||||
* @param refreshPeriodMs - refresh period in milliseconds
|
* @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());
|
looper = new TaskLooper(() -> poll());
|
||||||
setDaemon(daemon);
|
setDaemon(daemon);
|
||||||
setRefreshPeriodMs(refreshPeriodMs);
|
setRefreshPeriodMs(refreshPeriodMs);
|
||||||
setKeyImages(keyImages);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -131,36 +125,13 @@ public class XmrKeyImagePoller {
|
||||||
return refreshPeriodMs;
|
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.
|
* Add a key image to listen to.
|
||||||
*
|
*
|
||||||
* @param keyImage - the key image to listen to
|
* @param keyImage - the key image to listen to
|
||||||
*/
|
*/
|
||||||
public void addKeyImage(String keyImage) {
|
public void addKeyImage(String keyImage, String groupId) {
|
||||||
addKeyImages(keyImage);
|
addKeyImages(Arrays.asList(keyImage), groupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -168,50 +139,26 @@ public class XmrKeyImagePoller {
|
||||||
*
|
*
|
||||||
* @param keyImages - key images to listen to
|
* @param keyImages - key images to listen to
|
||||||
*/
|
*/
|
||||||
public void addKeyImages(String... keyImages) {
|
public void addKeyImages(Collection<String> keyImages, String groupId) {
|
||||||
addKeyImages(Arrays.asList(keyImages));
|
synchronized (this.keyImageGroups) {
|
||||||
}
|
if (!keyImageGroups.containsKey(groupId)) keyImageGroups.put(groupId, new HashSet<String>());
|
||||||
|
Set<String> keyImagesGroup = keyImageGroups.get(groupId);
|
||||||
/**
|
keyImagesGroup.addAll(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);
|
|
||||||
refreshPolling();
|
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.
|
* Remove key images to listen to.
|
||||||
*
|
*
|
||||||
* @param keyImages - key images to unlisten to
|
* @param keyImages - key images to unlisten to
|
||||||
*/
|
*/
|
||||||
public void removeKeyImages(String... keyImages) {
|
public void removeKeyImages(Collection<String> keyImages, String groupId) {
|
||||||
removeKeyImages(Arrays.asList(keyImages));
|
synchronized (keyImageGroups) {
|
||||||
}
|
Set<String> keyImagesGroup = keyImageGroups.get(groupId);
|
||||||
|
if (keyImagesGroup == null) return;
|
||||||
/**
|
keyImagesGroup.removeAll(keyImages);
|
||||||
* Remove key images to listen to.
|
if (keyImagesGroup.isEmpty()) keyImageGroups.remove(groupId);
|
||||||
*
|
|
||||||
* @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);
|
|
||||||
synchronized (lastStatuses) {
|
synchronized (lastStatuses) {
|
||||||
for (String lastKeyImage : new HashSet<>(lastStatuses.keySet())) lastStatuses.remove(lastKeyImage);
|
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.
|
* Clear the key images which stops polling.
|
||||||
*/
|
*/
|
||||||
public void clearKeyImages() {
|
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) {
|
public Boolean isSpent(String keyImage) {
|
||||||
synchronized (lastStatuses) {
|
synchronized (lastStatuses) {
|
||||||
if (!lastStatuses.containsKey(keyImage)) return null;
|
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.
|
* Get the last known spent status for the given key image.
|
||||||
*
|
*
|
||||||
|
@ -257,16 +237,11 @@ public class XmrKeyImagePoller {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// get copy of key images to fetch
|
|
||||||
List<String> keyImages = new ArrayList<String>(getKeyImages());
|
|
||||||
|
|
||||||
// fetch spent statuses
|
// fetch spent statuses
|
||||||
List<MoneroKeyImageSpentStatus> spentStatuses = null;
|
List<MoneroKeyImageSpentStatus> spentStatuses = null;
|
||||||
|
List<String> keyImages = new ArrayList<String>(getKeyImages());
|
||||||
try {
|
try {
|
||||||
if (keyImages.isEmpty()) spentStatuses = new ArrayList<MoneroKeyImageSpentStatus>();
|
spentStatuses = keyImages.isEmpty() ? new ArrayList<MoneroKeyImageSpentStatus>() : daemon.getKeyImageSpentStatuses(keyImages); // TODO monero-java: if order of getKeyImageSpentStatuses is guaranteed, then it should take list parameter
|
||||||
else {
|
|
||||||
spentStatuses = daemon.getKeyImageSpentStatuses(keyImages); // TODO monero-java: if order of getKeyImageSpentStatuses is guaranteed, then it should take list parameter
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|
||||||
// limit error logging
|
// limit error logging
|
||||||
|
@ -297,8 +272,8 @@ public class XmrKeyImagePoller {
|
||||||
}
|
}
|
||||||
|
|
||||||
private void refreshPolling() {
|
private void refreshPolling() {
|
||||||
synchronized (keyImages) {
|
synchronized (keyImageGroups) {
|
||||||
setIsPolling(keyImages.size() > 0 && listeners.size() > 0);
|
setIsPolling(!getKeyImages().isEmpty() && listeners.size() > 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -313,4 +288,14 @@ public class XmrKeyImagePoller {
|
||||||
looper.stop();
|
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) {
|
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();
|
Optional<XmrAddressEntry> addressEntryOptional = getAddressEntryListAsImmutableList().stream().filter(e -> offerId.equals(e.getOfferId())).filter(e -> context == e.getContext()).findAny();
|
||||||
addressEntryOptional.ifPresent(e -> {
|
addressEntryOptional.ifPresent(e -> {
|
||||||
log.info("swap addressEntry with address {} and offerId {} from context {} to available", e.getAddressString(), e.getOfferId(), context);
|
|
||||||
xmrAddressEntryList.swapToAvailable(e);
|
xmrAddressEntryList.swapToAvailable(e);
|
||||||
saveAddressEntryList();
|
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) {
|
public synchronized void resetAddressEntriesForOpenOffer(String offerId) {
|
||||||
log.info("resetAddressEntriesForOpenOffer offerId={}", 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);
|
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);
|
swapAddressEntryToAvailable(offerId, XmrAddressEntry.Context.TRADE_PAYOUT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1191,26 +1200,34 @@ public class XmrWalletService extends XmrWalletBase {
|
||||||
|
|
||||||
// TODO (woodser): update balance and other listening
|
// TODO (woodser): update balance and other listening
|
||||||
public void addBalanceListener(XmrBalanceListener listener) {
|
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) {
|
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() {
|
public void updateBalanceListeners() {
|
||||||
BigInteger availableBalance = getAvailableBalance();
|
BigInteger availableBalance = getAvailableBalance();
|
||||||
for (XmrBalanceListener balanceListener : balanceListeners) {
|
synchronized (balanceListeners) {
|
||||||
BigInteger balance;
|
for (XmrBalanceListener balanceListener : balanceListeners) {
|
||||||
if (balanceListener.getSubaddressIndex() != null && balanceListener.getSubaddressIndex() != 0) balance = getBalanceForSubaddress(balanceListener.getSubaddressIndex());
|
BigInteger balance;
|
||||||
else balance = availableBalance;
|
if (balanceListener.getSubaddressIndex() != null && balanceListener.getSubaddressIndex() != 0) balance = getBalanceForSubaddress(balanceListener.getSubaddressIndex());
|
||||||
ThreadUtils.submitToPool(() -> {
|
else balance = availableBalance;
|
||||||
try {
|
ThreadUtils.submitToPool(() -> {
|
||||||
balanceListener.onBalanceChanged(balance);
|
try {
|
||||||
} catch (Exception e) {
|
balanceListener.onBalanceChanged(balance);
|
||||||
log.warn("Failed to notify balance listener of change: {}\n", e.getMessage(), e);
|
} 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.removeOffer=Remove offer
|
||||||
shared.dontRemoveOffer=Don't remove offer
|
shared.dontRemoveOffer=Don't remove offer
|
||||||
shared.editOffer=Edit offer
|
shared.editOffer=Edit offer
|
||||||
shared.duplicateOffer=Duplicate offer
|
|
||||||
shared.openLargeQRWindow=Open large QR code window
|
shared.openLargeQRWindow=Open large QR code window
|
||||||
shared.chooseTradingAccount=Choose trading account
|
shared.chooseTradingAccount=Choose trading account
|
||||||
shared.faq=Visit FAQ page
|
shared.faq=Visit FAQ page
|
||||||
|
@ -385,6 +384,21 @@ offerbook.xmrAutoConf=Is auto-confirm enabled
|
||||||
offerbook.buyXmrWith=Buy XMR with:
|
offerbook.buyXmrWith=Buy XMR with:
|
||||||
offerbook.sellXmrFor=Sell XMR for:
|
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\
|
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.
|
{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
|
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. \
|
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.
|
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.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.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.sellAtMarketPrice=You will sell at market price (updated every minute).
|
||||||
offerbook.info.buyAtMarketPrice=You will buy 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.triggerPrice=Trigger price
|
||||||
|
openOffer.header.groupId=Group ID
|
||||||
openOffer.triggerPrice=Trigger price {0}
|
openOffer.triggerPrice=Trigger price {0}
|
||||||
openOffer.triggered=The offer has been deactivated because the market price reached your trigger price.\n\
|
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
|
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.failed=Editing of offer failed:\n{0}
|
||||||
editOffer.success=Your offer has been successfully edited.
|
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.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
|
# Portfolio
|
||||||
|
@ -620,7 +651,8 @@ portfolio.tab.pendingTrades=Open trades
|
||||||
portfolio.tab.history=History
|
portfolio.tab.history=History
|
||||||
portfolio.tab.failed=Failed
|
portfolio.tab.failed=Failed
|
||||||
portfolio.tab.editOpenOffer=Edit offer
|
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.offerLikeThis=Create new offer like this...
|
||||||
portfolio.context.notYourOffer=You can only duplicate offers where you were the maker.
|
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.removeOffer=Odstranit nabídku
|
||||||
shared.dontRemoveOffer=Neodstraňovat nabídku
|
shared.dontRemoveOffer=Neodstraňovat nabídku
|
||||||
shared.editOffer=Upravit nabídku
|
shared.editOffer=Upravit nabídku
|
||||||
shared.duplicateOffer=Duplikovat nabídku
|
|
||||||
shared.openLargeQRWindow=Otevřít velké okno s QR kódem
|
shared.openLargeQRWindow=Otevřít velké okno s QR kódem
|
||||||
shared.chooseTradingAccount=Vyberte obchodní účet
|
shared.chooseTradingAccount=Vyberte obchodní účet
|
||||||
shared.faq=Navštívit stránku FAQ
|
shared.faq=Navštívit stránku FAQ
|
||||||
|
|
|
@ -103,7 +103,6 @@ shared.XMRMinMax=XMR (min - max)
|
||||||
shared.removeOffer=Teklifi kaldır
|
shared.removeOffer=Teklifi kaldır
|
||||||
shared.dontRemoveOffer=Teklifi kaldırma
|
shared.dontRemoveOffer=Teklifi kaldırma
|
||||||
shared.editOffer=Teklifi düzenle
|
shared.editOffer=Teklifi düzenle
|
||||||
shared.duplicateOffer=Teklifi çoğalt
|
|
||||||
shared.openLargeQRWindow=Büyük QR kodu penceresini aç
|
shared.openLargeQRWindow=Büyük QR kodu penceresini aç
|
||||||
shared.chooseTradingAccount=İşlem hesabını seç
|
shared.chooseTradingAccount=İşlem hesabını seç
|
||||||
shared.faq=SSS sayfasını ziyaret et
|
shared.faq=SSS sayfasını ziyaret et
|
||||||
|
|
|
@ -157,6 +157,7 @@ class GrpcOffersService extends OffersImplBase {
|
||||||
req.getIsPrivateOffer(),
|
req.getIsPrivateOffer(),
|
||||||
req.getBuyerAsTakerWithoutDeposit(),
|
req.getBuyerAsTakerWithoutDeposit(),
|
||||||
req.getExtraInfo(),
|
req.getExtraInfo(),
|
||||||
|
req.getSourceOfferId(),
|
||||||
offer -> {
|
offer -> {
|
||||||
// This result handling consumer's accept operation will return
|
// This result handling consumer's accept operation will return
|
||||||
// the new offer to the gRPC client after async placement is done.
|
// 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
|
// check for open offers
|
||||||
if (injector.getInstance(OpenOfferManager.class).hasOpenOffers()) {
|
if (injector.getInstance(OpenOfferManager.class).hasAvailableOpenOffers()) {
|
||||||
String key = "showOpenOfferWarnPopupAtShutDown";
|
String key = "showOpenOfferWarnPopupAtShutDown";
|
||||||
if (injector.getInstance(Preferences.class).showAgain(key) && !DevEnv.isDevMode()) {
|
if (injector.getInstance(Preferences.class).showAgain(key) && !DevEnv.isDevMode()) {
|
||||||
new Popup().warning(Res.get("popup.info.shutDownWithOpenOffers"))
|
new Popup().warning(Res.get("popup.info.shutDownWithOpenOffers"))
|
||||||
|
|
|
@ -822,6 +822,10 @@ tree-table-view:focused {
|
||||||
-fx-text-fill: -bs-rd-error-red;
|
-fx-text-fill: -bs-rd-error-red;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
-fx-fill: -bs-text-color;
|
||||||
|
}
|
||||||
|
|
||||||
.opaque-icon {
|
.opaque-icon {
|
||||||
-fx-fill: -bs-color-gray-bbb;
|
-fx-fill: -bs-color-gray-bbb;
|
||||||
-fx-opacity: 1;
|
-fx-opacity: 1;
|
||||||
|
|
|
@ -346,7 +346,7 @@ public class DepositView extends ActivatableView<VBox, Void> {
|
||||||
List<XmrAddressEntry> addressEntries = xmrWalletService.getAddressEntries();
|
List<XmrAddressEntry> addressEntries = xmrWalletService.getAddressEntries();
|
||||||
List<DepositListItem> items = new ArrayList<>();
|
List<DepositListItem> items = new ArrayList<>();
|
||||||
for (XmrAddressEntry addressEntry : addressEntries) {
|
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));
|
items.add(new DepositListItem(addressEntry, xmrWalletService, formatter));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -77,7 +77,7 @@ class WithdrawalListItem {
|
||||||
public final String getLabel() {
|
public final String getLabel() {
|
||||||
if (addressEntry.isOpenOffer())
|
if (addressEntry.isOpenOffer())
|
||||||
return Res.getWithCol("shared.offerId") + " " + addressEntry.getShortOfferId();
|
return Res.getWithCol("shared.offerId") + " " + addressEntry.getShortOfferId();
|
||||||
else if (addressEntry.isTrade())
|
else if (addressEntry.isTradePayout())
|
||||||
return Res.getWithCol("shared.tradeId") + " " + addressEntry.getShortOfferId();
|
return Res.getWithCol("shared.tradeId") + " " + addressEntry.getShortOfferId();
|
||||||
else if (addressEntry.getContext() == XmrAddressEntry.Context.ARBITRATOR)
|
else if (addressEntry.getContext() == XmrAddressEntry.Context.ARBITRATOR)
|
||||||
return Res.get("funds.withdrawal.arbitrationFee");
|
return Res.get("funds.withdrawal.arbitrationFee");
|
||||||
|
|
|
@ -82,7 +82,7 @@ import lombok.Getter;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
public abstract class MutableOfferDataModel extends OfferDataModel {
|
public abstract class MutableOfferDataModel extends OfferDataModel {
|
||||||
private final CreateOfferService createOfferService;
|
protected final CreateOfferService createOfferService;
|
||||||
protected final OpenOfferManager openOfferManager;
|
protected final OpenOfferManager openOfferManager;
|
||||||
private final XmrWalletService xmrWalletService;
|
private final XmrWalletService xmrWalletService;
|
||||||
private final Preferences preferences;
|
private final Preferences preferences;
|
||||||
|
@ -115,7 +115,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
|
||||||
|
|
||||||
protected PaymentAccount paymentAccount;
|
protected PaymentAccount paymentAccount;
|
||||||
boolean isTabSelected;
|
boolean isTabSelected;
|
||||||
protected double marketPriceMargin = 0;
|
protected double marketPriceMarginPct = 0;
|
||||||
@Getter
|
@Getter
|
||||||
private boolean marketPriceAvailable;
|
private boolean marketPriceAvailable;
|
||||||
protected boolean allowAmountUpdate = true;
|
protected boolean allowAmountUpdate = true;
|
||||||
|
@ -189,12 +189,12 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
private void addListeners() {
|
private void addListeners() {
|
||||||
xmrWalletService.addBalanceListener(xmrBalanceListener);
|
if (xmrBalanceListener != null) xmrWalletService.addBalanceListener(xmrBalanceListener);
|
||||||
user.getPaymentAccountsAsObservable().addListener(paymentAccountsChangeListener);
|
user.getPaymentAccountsAsObservable().addListener(paymentAccountsChangeListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void removeListeners() {
|
private void removeListeners() {
|
||||||
xmrWalletService.removeBalanceListener(xmrBalanceListener);
|
if (xmrBalanceListener != null) xmrWalletService.removeBalanceListener(xmrBalanceListener);
|
||||||
user.getPaymentAccountsAsObservable().removeListener(paymentAccountsChangeListener);
|
user.getPaymentAccountsAsObservable().removeListener(paymentAccountsChangeListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -204,14 +204,16 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
|
||||||
///////////////////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
// called before activate()
|
// called before activate()
|
||||||
public boolean initWithData(OfferDirection direction, TradeCurrency tradeCurrency) {
|
public boolean initWithData(OfferDirection direction, TradeCurrency tradeCurrency, boolean initAddressEntry) {
|
||||||
addressEntry = xmrWalletService.getOrCreateAddressEntry(offerId, XmrAddressEntry.Context.OFFER_FUNDING);
|
if (initAddressEntry) {
|
||||||
xmrBalanceListener = new XmrBalanceListener(getAddressEntry().getSubaddressIndex()) {
|
addressEntry = xmrWalletService.getOrCreateAddressEntry(offerId, XmrAddressEntry.Context.OFFER_FUNDING);
|
||||||
@Override
|
xmrBalanceListener = new XmrBalanceListener(getAddressEntry().getSubaddressIndex()) {
|
||||||
public void onBalanceChanged(BigInteger balance) {
|
@Override
|
||||||
updateBalances();
|
public void onBalanceChanged(BigInteger balance) {
|
||||||
}
|
updateBalances();
|
||||||
};
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
this.direction = direction;
|
this.direction = direction;
|
||||||
this.tradeCurrency = tradeCurrency;
|
this.tradeCurrency = tradeCurrency;
|
||||||
|
@ -278,6 +280,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void updateBalances() {
|
protected void updateBalances() {
|
||||||
|
if (addressEntry == null) return;
|
||||||
super.updateBalances();
|
super.updateBalances();
|
||||||
|
|
||||||
// update remaining balance
|
// update remaining balance
|
||||||
|
@ -302,7 +305,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
|
||||||
minAmount.get(),
|
minAmount.get(),
|
||||||
useMarketBasedPrice.get() ? null : price.get(),
|
useMarketBasedPrice.get() ? null : price.get(),
|
||||||
useMarketBasedPrice.get(),
|
useMarketBasedPrice.get(),
|
||||||
useMarketBasedPrice.get() ? marketPriceMargin : 0,
|
useMarketBasedPrice.get() ? marketPriceMarginPct : 0,
|
||||||
securityDepositPct.get(),
|
securityDepositPct.get(),
|
||||||
paymentAccount,
|
paymentAccount,
|
||||||
buyerAsTakerWithoutDeposit.get(), // private offer if buyer as taker without deposit
|
buyerAsTakerWithoutDeposit.get(), // private offer if buyer as taker without deposit
|
||||||
|
@ -316,6 +319,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
|
||||||
triggerPrice,
|
triggerPrice,
|
||||||
reserveExactAmount,
|
reserveExactAmount,
|
||||||
false, // desktop ui resets address entries on cancel
|
false, // desktop ui resets address entries on cancel
|
||||||
|
null,
|
||||||
resultHandler,
|
resultHandler,
|
||||||
errorMessageHandler);
|
errorMessageHandler);
|
||||||
}
|
}
|
||||||
|
@ -387,7 +391,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
|
||||||
volume.set(null);
|
volume.set(null);
|
||||||
minVolume.set(null);
|
minVolume.set(null);
|
||||||
price.set(null);
|
price.set(null);
|
||||||
marketPriceMargin = 0;
|
marketPriceMarginPct = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.tradeCurrency = tradeCurrency;
|
this.tradeCurrency = tradeCurrency;
|
||||||
|
@ -416,10 +420,6 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
|
||||||
updateBalances();
|
updateBalances();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void setMarketPriceMarginPct(double marketPriceMargin) {
|
|
||||||
this.marketPriceMargin = marketPriceMargin;
|
|
||||||
}
|
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////////////////
|
||||||
// Getters
|
// Getters
|
||||||
///////////////////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
@ -469,7 +469,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
public double getMarketPriceMarginPct() {
|
public double getMarketPriceMarginPct() {
|
||||||
return marketPriceMargin;
|
return marketPriceMarginPct;
|
||||||
}
|
}
|
||||||
|
|
||||||
long getMaxTradeLimit() {
|
long getMaxTradeLimit() {
|
||||||
|
@ -609,6 +609,10 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
|
||||||
this.triggerPrice = triggerPrice;
|
this.triggerPrice = triggerPrice;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setMarketPriceMarginPct(double marketPriceMarginPct) {
|
||||||
|
this.marketPriceMarginPct = marketPriceMarginPct;
|
||||||
|
}
|
||||||
|
|
||||||
public void setReserveExactAmount(boolean reserveExactAmount) {
|
public void setReserveExactAmount(boolean reserveExactAmount) {
|
||||||
this.reserveExactAmount = reserveExactAmount;
|
this.reserveExactAmount = reserveExactAmount;
|
||||||
}
|
}
|
||||||
|
@ -684,6 +688,14 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
|
||||||
return Restrictions.getMinSecurityDeposit().max(value);
|
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() {
|
ReadOnlyObjectProperty<BigInteger> totalToPayAsProperty() {
|
||||||
return totalToPay;
|
return totalToPay;
|
||||||
}
|
}
|
||||||
|
|
|
@ -297,11 +297,13 @@ public abstract class MutableOfferView<M extends MutableOfferViewModel<?>> exten
|
||||||
model.getDataModel().onTabSelected(isSelected);
|
model.getDataModel().onTabSelected(isSelected);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void initWithData(OfferDirection direction, TradeCurrency tradeCurrency,
|
public void initWithData(OfferDirection direction,
|
||||||
|
TradeCurrency tradeCurrency,
|
||||||
|
boolean initAddressEntry,
|
||||||
OfferView.OfferActionHandler offerActionHandler) {
|
OfferView.OfferActionHandler offerActionHandler) {
|
||||||
this.offerActionHandler = offerActionHandler;
|
this.offerActionHandler = offerActionHandler;
|
||||||
|
|
||||||
boolean result = model.initWithData(direction, tradeCurrency);
|
boolean result = model.initWithData(direction, tradeCurrency, initAddressEntry);
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
new Popup().headLine(Res.get("popup.warning.noTradingAccountSetup.headline"))
|
new Popup().headLine(Res.get("popup.warning.noTradingAccountSetup.headline"))
|
||||||
|
|
|
@ -601,8 +601,8 @@ public abstract class MutableOfferViewModel<M extends MutableOfferDataModel> ext
|
||||||
// API
|
// API
|
||||||
///////////////////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
boolean initWithData(OfferDirection direction, TradeCurrency tradeCurrency) {
|
boolean initWithData(OfferDirection direction, TradeCurrency tradeCurrency, boolean initAddressEntry) {
|
||||||
boolean result = dataModel.initWithData(direction, tradeCurrency);
|
boolean result = dataModel.initWithData(direction, tradeCurrency, initAddressEntry);
|
||||||
if (dataModel.getAddressEntry() != null) {
|
if (dataModel.getAddressEntry() != null) {
|
||||||
addressAsString = dataModel.getAddressEntry().getAddressString();
|
addressAsString = dataModel.getAddressEntry().getAddressString();
|
||||||
}
|
}
|
||||||
|
|
|
@ -47,11 +47,10 @@ public class CreateOfferView extends MutableOfferView<CreateOfferViewModel> {
|
||||||
super(model, navigation, preferences, offerDetailsWindow, btcFormatter);
|
super(model, navigation, preferences, offerDetailsWindow, btcFormatter);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public void initWithData(OfferDirection direction,
|
public void initWithData(OfferDirection direction,
|
||||||
TradeCurrency tradeCurrency,
|
TradeCurrency tradeCurrency,
|
||||||
OfferView.OfferActionHandler offerActionHandler) {
|
OfferView.OfferActionHandler offerActionHandler) {
|
||||||
super.initWithData(direction, tradeCurrency, offerActionHandler);
|
super.initWithData(direction, tradeCurrency, true, offerActionHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -19,13 +19,12 @@ package haveno.desktop.main.offer.offerbook;
|
||||||
|
|
||||||
import com.google.inject.Inject;
|
import com.google.inject.Inject;
|
||||||
import com.google.inject.Singleton;
|
import com.google.inject.Singleton;
|
||||||
import haveno.core.filter.FilterManager;
|
|
||||||
|
import haveno.common.UserThread;
|
||||||
import haveno.core.offer.Offer;
|
import haveno.core.offer.Offer;
|
||||||
import haveno.core.offer.OfferBookService;
|
import haveno.core.offer.OfferBookService;
|
||||||
import static haveno.core.offer.OfferDirection.BUY;
|
import static haveno.core.offer.OfferDirection.BUY;
|
||||||
import haveno.core.offer.OfferRestrictions;
|
|
||||||
import haveno.network.p2p.storage.P2PDataStorage;
|
import haveno.network.p2p.storage.P2PDataStorage;
|
||||||
import haveno.network.utils.Utils;
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
@ -49,7 +48,6 @@ public class OfferBook {
|
||||||
private final ObservableList<OfferBookListItem> offerBookListItems = FXCollections.observableArrayList();
|
private final ObservableList<OfferBookListItem> offerBookListItems = FXCollections.observableArrayList();
|
||||||
private final Map<String, Integer> buyOfferCountMap = new HashMap<>();
|
private final Map<String, Integer> buyOfferCountMap = new HashMap<>();
|
||||||
private final Map<String, Integer> sellOfferCountMap = new HashMap<>();
|
private final Map<String, Integer> sellOfferCountMap = new HashMap<>();
|
||||||
private final FilterManager filterManager;
|
|
||||||
|
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
@ -57,64 +55,47 @@ public class OfferBook {
|
||||||
///////////////////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
OfferBook(OfferBookService offerBookService, FilterManager filterManager) {
|
OfferBook(OfferBookService offerBookService) {
|
||||||
this.offerBookService = offerBookService;
|
this.offerBookService = offerBookService;
|
||||||
this.filterManager = filterManager;
|
|
||||||
|
|
||||||
offerBookService.addOfferBookChangedListener(new OfferBookService.OfferBookChangedListener() {
|
offerBookService.addOfferBookChangedListener(new OfferBookService.OfferBookChangedListener() {
|
||||||
@Override
|
@Override
|
||||||
public void onAdded(Offer offer) {
|
public void onAdded(Offer offer) {
|
||||||
printOfferBookListItems("Before onAdded");
|
UserThread.execute(() -> {
|
||||||
// We get onAdded called every time a new ProtectedStorageEntry is received.
|
printOfferBookListItems("Before onAdded");
|
||||||
// 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.
|
|
||||||
|
|
||||||
if (filterManager.isOfferIdBanned(offer.getId())) {
|
// Use offer.equals(offer) to see if the OfferBook list contains an exact
|
||||||
log.debug("Ignored banned offer. ID={}", offer.getId());
|
// match -- offer.equals(offer) includes comparisons of payload, state
|
||||||
return;
|
// and errorMessage.
|
||||||
}
|
synchronized (offerBookListItems) {
|
||||||
|
boolean hasSameOffer = offerBookListItems.stream().anyMatch(item -> item.getOffer().equals(offer));
|
||||||
if (OfferRestrictions.requiresNodeAddressUpdate() && !Utils.isV3Address(offer.getMakerNodeAddress().getHostName())) {
|
if (!hasSameOffer) {
|
||||||
log.debug("Ignored offer with Tor v2 node address. ID={}", offer.getId());
|
OfferBookListItem newOfferBookListItem = new OfferBookListItem(offer);
|
||||||
return;
|
removeDuplicateItem(newOfferBookListItem);
|
||||||
}
|
offerBookListItems.add(newOfferBookListItem); // Add replacement.
|
||||||
|
if (log.isDebugEnabled()) { // TODO delete debug stmt in future PR.
|
||||||
// Use offer.equals(offer) to see if the OfferBook list contains an exact
|
log.debug("onAdded: Added new offer {}\n"
|
||||||
// match -- offer.equals(offer) includes comparisons of payload, state
|
+ "\twith newItem.payloadHash: {}",
|
||||||
// and errorMessage.
|
offer.getId(),
|
||||||
synchronized (offerBookListItems) {
|
newOfferBookListItem.hashOfPayload.getHex());
|
||||||
boolean hasSameOffer = offerBookListItems.stream().anyMatch(item -> item.getOffer().equals(offer));
|
}
|
||||||
if (!hasSameOffer) {
|
} else {
|
||||||
OfferBookListItem newOfferBookListItem = new OfferBookListItem(offer);
|
log.debug("We have the exact same offer already in our list and ignore the onAdded call. ID={}", offer.getId());
|
||||||
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 {
|
printOfferBookListItems("After onAdded");
|
||||||
log.debug("We have the exact same offer already in our list and ignore the onAdded call. ID={}", offer.getId());
|
|
||||||
}
|
}
|
||||||
printOfferBookListItems("After onAdded");
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onRemoved(Offer offer) {
|
public void onRemoved(Offer offer) {
|
||||||
synchronized (offerBookListItems) {
|
UserThread.execute(() -> {
|
||||||
printOfferBookListItems("Before onRemoved");
|
synchronized (offerBookListItems) {
|
||||||
removeOffer(offer);
|
printOfferBookListItems("Before onRemoved");
|
||||||
printOfferBookListItems("After onRemoved");
|
removeOffer(offer);
|
||||||
}
|
printOfferBookListItems("After onRemoved");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
filterManager.filterProperty().addListener((observable, oldValue, newValue) -> {
|
|
||||||
if (newValue != null) {
|
|
||||||
// any notifications
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -212,7 +193,6 @@ public class OfferBook {
|
||||||
// Investigate why....
|
// Investigate why....
|
||||||
offerBookListItems.clear();
|
offerBookListItems.clear();
|
||||||
offerBookListItems.addAll(offerBookService.getOffers().stream()
|
offerBookListItems.addAll(offerBookService.getOffers().stream()
|
||||||
.filter(this::isOfferAllowed)
|
|
||||||
.map(OfferBookListItem::new)
|
.map(OfferBookListItem::new)
|
||||||
.collect(Collectors.toList()));
|
.collect(Collectors.toList()));
|
||||||
|
|
||||||
|
@ -248,13 +228,6 @@ public class OfferBook {
|
||||||
return sellOfferCountMap;
|
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() {
|
private void fillOfferCountMaps() {
|
||||||
buyOfferCountMap.clear();
|
buyOfferCountMap.clear();
|
||||||
sellOfferCountMap.clear();
|
sellOfferCountMap.clear();
|
||||||
|
|
|
@ -695,8 +695,13 @@ abstract public class OfferBookView<R extends GridPane, M extends OfferBookViewM
|
||||||
case SIGNATURE_NOT_VALIDATED:
|
case SIGNATURE_NOT_VALIDATED:
|
||||||
new Popup().warning(Res.get("offerbook.warning.signatureNotValidated")).show();
|
new Popup().warning(Res.get("offerbook.warning.signatureNotValidated")).show();
|
||||||
break;
|
break;
|
||||||
|
case RESERVE_FUNDS_SPENT:
|
||||||
|
new Popup().warning(Res.get("offerbook.warning.reserveFundsSpent")).show();
|
||||||
|
break;
|
||||||
case VALID:
|
case VALID:
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
|
log.warn("Unhandled offer filter service result: " + result);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -173,6 +173,11 @@ abstract class OfferBookViewModel extends ActivatableViewModel {
|
||||||
|
|
||||||
tradeCurrencyListChangeListener = c -> fillCurrencies();
|
tradeCurrencyListChangeListener = c -> fillCurrencies();
|
||||||
|
|
||||||
|
// refresh filter on changes
|
||||||
|
offerBook.getOfferBookListItems().addListener((ListChangeListener<OfferBookListItem>) c -> {
|
||||||
|
filterOffers();
|
||||||
|
});
|
||||||
|
|
||||||
filterItemsListener = c -> {
|
filterItemsListener = c -> {
|
||||||
final Optional<OfferBookListItem> highestAmountOffer = filteredItems.stream()
|
final Optional<OfferBookListItem> highestAmountOffer = filteredItems.stream()
|
||||||
.max(Comparator.comparingLong(o -> o.getOffer().getAmount().longValueExact()));
|
.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.FxmlView;
|
||||||
import haveno.desktop.common.view.View;
|
import haveno.desktop.common.view.View;
|
||||||
import haveno.desktop.main.MainView;
|
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.closedtrades.ClosedTradesView;
|
||||||
import haveno.desktop.main.portfolio.duplicateoffer.DuplicateOfferView;
|
import haveno.desktop.main.portfolio.duplicateoffer.DuplicateOfferView;
|
||||||
import haveno.desktop.main.portfolio.editoffer.EditOfferView;
|
import haveno.desktop.main.portfolio.editoffer.EditOfferView;
|
||||||
|
@ -49,7 +51,7 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
|
||||||
|
|
||||||
@FXML
|
@FXML
|
||||||
Tab openOffersTab, pendingTradesTab, closedTradesTab;
|
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 final Tab failedTradesTab = new Tab(Res.get("portfolio.tab.failed").toUpperCase());
|
||||||
private Tab currentTab;
|
private Tab currentTab;
|
||||||
private Navigation.Listener navigationListener;
|
private Navigation.Listener navigationListener;
|
||||||
|
@ -61,7 +63,8 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
|
||||||
private final FailedTradesManager failedTradesManager;
|
private final FailedTradesManager failedTradesManager;
|
||||||
private EditOfferView editOfferView;
|
private EditOfferView editOfferView;
|
||||||
private DuplicateOfferView duplicateOfferView;
|
private DuplicateOfferView duplicateOfferView;
|
||||||
private boolean editOpenOfferViewOpen;
|
private CloneOfferView cloneOfferView;
|
||||||
|
private boolean editOpenOfferViewOpen, cloneOpenOfferViewOpen;
|
||||||
private OpenOffer openOffer;
|
private OpenOffer openOffer;
|
||||||
private OpenOffersView openOffersView;
|
private OpenOffersView openOffersView;
|
||||||
|
|
||||||
|
@ -99,12 +102,16 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
|
||||||
navigation.navigateTo(MainView.class, PortfolioView.class, EditOfferView.class);
|
navigation.navigateTo(MainView.class, PortfolioView.class, EditOfferView.class);
|
||||||
else if (newValue == duplicateOfferTab) {
|
else if (newValue == duplicateOfferTab) {
|
||||||
navigation.navigateTo(MainView.class, PortfolioView.class, DuplicateOfferView.class);
|
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)
|
if (oldValue != null && oldValue == editOpenOfferTab)
|
||||||
editOfferView.onTabSelected(false);
|
editOfferView.onTabSelected(false);
|
||||||
if (oldValue != null && oldValue == duplicateOfferTab)
|
if (oldValue != null && oldValue == duplicateOfferTab)
|
||||||
duplicateOfferView.onTabSelected(false);
|
duplicateOfferView.onTabSelected(false);
|
||||||
|
if (oldValue != null && oldValue == cloneOpenOfferTab)
|
||||||
|
cloneOfferView.onTabSelected(false);
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -115,6 +122,8 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
|
||||||
onEditOpenOfferRemoved();
|
onEditOpenOfferRemoved();
|
||||||
if (removedTabs.size() == 1 && removedTabs.get(0).equals(duplicateOfferTab))
|
if (removedTabs.size() == 1 && removedTabs.get(0).equals(duplicateOfferTab))
|
||||||
onDuplicateOfferRemoved();
|
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);
|
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
|
@Override
|
||||||
protected void activate() {
|
protected void activate() {
|
||||||
failedTradesManager.getObservableList().addListener((ListChangeListener<Trade>) c -> {
|
failedTradesManager.getObservableList().addListener((ListChangeListener<Trade>) c -> {
|
||||||
|
@ -166,6 +185,9 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
|
||||||
} else if (root.getSelectionModel().getSelectedItem() == duplicateOfferTab) {
|
} else if (root.getSelectionModel().getSelectedItem() == duplicateOfferTab) {
|
||||||
navigation.navigateTo(MainView.class, PortfolioView.class, DuplicateOfferView.class);
|
navigation.navigateTo(MainView.class, PortfolioView.class, DuplicateOfferView.class);
|
||||||
if (duplicateOfferView != null) duplicateOfferView.onTabSelected(true);
|
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) {
|
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
|
// nullify current tab to trigger activate/deactivate
|
||||||
if (currentTab != null && currentTab != editOpenOfferTab)
|
if (currentTab != null) currentTab.setContent(null);
|
||||||
currentTab.setContent(null);
|
|
||||||
|
|
||||||
View view = viewLoader.load(viewClass);
|
View view = viewLoader.load(viewClass);
|
||||||
|
|
||||||
|
@ -235,6 +256,28 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
|
||||||
view = viewLoader.load(OpenOffersView.class);
|
view = viewLoader.load(OpenOffersView.class);
|
||||||
selectOpenOffersView((OpenOffersView) view);
|
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());
|
currentTab.setContent(view.getRoot());
|
||||||
|
@ -245,20 +288,35 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
|
||||||
openOffersView = view;
|
openOffersView = view;
|
||||||
currentTab = openOffersTab;
|
currentTab = openOffersTab;
|
||||||
|
|
||||||
OpenOfferActionHandler openOfferActionHandler = openOffer -> {
|
EditOpenOfferHandler editOpenOfferHandler = openOffer -> {
|
||||||
if (!editOpenOfferViewOpen) {
|
if (!editOpenOfferViewOpen) {
|
||||||
editOpenOfferViewOpen = true;
|
editOpenOfferViewOpen = true;
|
||||||
PortfolioView.this.openOffer = openOffer;
|
PortfolioView.this.openOffer = openOffer;
|
||||||
navigation.navigateTo(MainView.class, PortfolioView.this.getClass(), EditOfferView.class);
|
navigation.navigateTo(MainView.class, PortfolioView.this.getClass(), EditOfferView.class);
|
||||||
} else {
|
} 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);
|
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 (item != null && !empty && isMyOfferAsMaker(item.getTradable().getOffer().getOfferPayload())) {
|
||||||
if (button == null) {
|
if (button == null) {
|
||||||
button = FormBuilder.getRegularIconButton(MaterialDesignIcon.CONTENT_COPY);
|
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);
|
setGraphic(button);
|
||||||
}
|
}
|
||||||
button.setOnAction(event -> onDuplicateOffer(item.getTradable().getOffer()));
|
button.setOnAction(event -> onDuplicateOffer(item.getTradable().getOffer()));
|
||||||
|
|
|
@ -35,13 +35,10 @@ import haveno.core.user.Preferences;
|
||||||
import haveno.core.user.User;
|
import haveno.core.user.User;
|
||||||
import haveno.core.util.FormattingUtils;
|
import haveno.core.util.FormattingUtils;
|
||||||
import haveno.core.util.coin.CoinFormatter;
|
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.core.xmr.wallet.XmrWalletService;
|
||||||
import haveno.desktop.Navigation;
|
import haveno.desktop.Navigation;
|
||||||
import haveno.desktop.main.offer.MutableOfferDataModel;
|
import haveno.desktop.main.offer.MutableOfferDataModel;
|
||||||
import haveno.network.p2p.P2PService;
|
import haveno.network.p2p.P2PService;
|
||||||
import java.math.BigInteger;
|
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
@ -101,14 +98,6 @@ class DuplicateOfferDataModel extends MutableOfferDataModel {
|
||||||
if (openOffer != null) setTriggerPrice(openOffer.getTriggerPrice());
|
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
|
@Override
|
||||||
protected Set<PaymentAccount> getUserPaymentAccounts() {
|
protected Set<PaymentAccount> getUserPaymentAccounts() {
|
||||||
return Objects.requireNonNull(user.getPaymentAccounts()).stream()
|
return Objects.requireNonNull(user.getPaymentAccounts()).stream()
|
||||||
|
|
|
@ -70,6 +70,7 @@ public class DuplicateOfferView extends MutableOfferView<DuplicateOfferViewModel
|
||||||
public void initWithData(OfferPayload offerPayload) {
|
public void initWithData(OfferPayload offerPayload) {
|
||||||
initWithData(offerPayload.getDirection(),
|
initWithData(offerPayload.getDirection(),
|
||||||
CurrencyUtil.getTradeCurrency(offerPayload.getCurrencyCode()).get(),
|
CurrencyUtil.getTradeCurrency(offerPayload.getCurrencyCode()).get(),
|
||||||
|
true,
|
||||||
null);
|
null);
|
||||||
model.initWithData(offerPayload);
|
model.initWithData(offerPayload);
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,7 +21,6 @@ package haveno.desktop.main.portfolio.editoffer;
|
||||||
import com.google.inject.Inject;
|
import com.google.inject.Inject;
|
||||||
import com.google.inject.name.Named;
|
import com.google.inject.name.Named;
|
||||||
|
|
||||||
import haveno.common.UserThread;
|
|
||||||
import haveno.common.handlers.ErrorMessageHandler;
|
import haveno.common.handlers.ErrorMessageHandler;
|
||||||
import haveno.common.handlers.ResultHandler;
|
import haveno.common.handlers.ResultHandler;
|
||||||
import haveno.core.account.witness.AccountAgeWitnessService;
|
import haveno.core.account.witness.AccountAgeWitnessService;
|
||||||
|
@ -56,6 +55,7 @@ class EditOfferDataModel extends MutableOfferDataModel {
|
||||||
private final CorePersistenceProtoResolver corePersistenceProtoResolver;
|
private final CorePersistenceProtoResolver corePersistenceProtoResolver;
|
||||||
private OpenOffer openOffer;
|
private OpenOffer openOffer;
|
||||||
private OpenOffer.State initialState;
|
private OpenOffer.State initialState;
|
||||||
|
private Offer editedOffer;
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
EditOfferDataModel(CreateOfferService createOfferService,
|
EditOfferDataModel(CreateOfferService createOfferService,
|
||||||
|
@ -100,7 +100,7 @@ class EditOfferDataModel extends MutableOfferDataModel {
|
||||||
securityDepositPct.set(0);
|
securityDepositPct.set(0);
|
||||||
paymentAccounts.clear();
|
paymentAccounts.clear();
|
||||||
paymentAccount = null;
|
paymentAccount = null;
|
||||||
marketPriceMargin = 0;
|
marketPriceMarginPct = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void applyOpenOffer(OpenOffer openOffer) {
|
public void applyOpenOffer(OpenOffer openOffer) {
|
||||||
|
@ -142,10 +142,9 @@ class EditOfferDataModel extends MutableOfferDataModel {
|
||||||
extraInfo.set(offer.getOfferExtraInfo());
|
extraInfo.set(offer.getOfferExtraInfo());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean initWithData(OfferDirection direction, TradeCurrency tradeCurrency) {
|
public boolean initWithData(OfferDirection direction, TradeCurrency tradeCurrency) {
|
||||||
try {
|
try {
|
||||||
return super.initWithData(direction, tradeCurrency);
|
return super.initWithData(direction, tradeCurrency, false);
|
||||||
} catch (NullPointerException e) {
|
} catch (NullPointerException e) {
|
||||||
if (e.getMessage().contains("tradeCurrency")) {
|
if (e.getMessage().contains("tradeCurrency")) {
|
||||||
throw new IllegalArgumentException("Offers of removed assets cannot be edited. You can only cancel it.", e);
|
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(),
|
offerPayload.getReserveTxKeyImages(),
|
||||||
newOfferPayload.getExtraInfo());
|
newOfferPayload.getExtraInfo());
|
||||||
|
|
||||||
final Offer editedOffer = new Offer(editedPayload);
|
editedOffer = new Offer(editedPayload);
|
||||||
editedOffer.setPriceFeedService(priceFeedService);
|
editedOffer.setPriceFeedService(priceFeedService);
|
||||||
editedOffer.setState(Offer.State.AVAILABLE);
|
editedOffer.setState(Offer.State.AVAILABLE);
|
||||||
|
|
||||||
openOfferManager.editOpenOfferPublish(editedOffer, triggerPrice, initialState, () -> {
|
openOfferManager.editOpenOfferPublish(editedOffer, triggerPrice, initialState, () -> {
|
||||||
|
resultHandler.handleResult(); // process result before nullifying state
|
||||||
openOffer = null;
|
openOffer = null;
|
||||||
UserThread.execute(() -> resultHandler.handleResult());
|
editedOffer = null;
|
||||||
}, (errorMsg) -> {
|
}, (errorMsg) -> {
|
||||||
UserThread.execute(() -> errorMessageHandler.handleErrorMessage(errorMsg));
|
errorMessageHandler.handleErrorMessage(errorMsg);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -243,6 +243,15 @@ class EditOfferDataModel extends MutableOfferDataModel {
|
||||||
}, errorMessageHandler);
|
}, 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
|
@Override
|
||||||
protected Set<PaymentAccount> getUserPaymentAccounts() {
|
protected Set<PaymentAccount> getUserPaymentAccounts() {
|
||||||
throw new RuntimeException("Edit offer not supported with XMR");
|
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.Inject;
|
||||||
import com.google.inject.name.Named;
|
import com.google.inject.name.Named;
|
||||||
|
|
||||||
|
import haveno.common.UserThread;
|
||||||
import haveno.common.util.Tuple4;
|
import haveno.common.util.Tuple4;
|
||||||
import haveno.core.locale.CurrencyUtil;
|
import haveno.core.locale.CurrencyUtil;
|
||||||
import haveno.core.locale.Res;
|
import haveno.core.locale.Res;
|
||||||
|
@ -140,6 +142,7 @@ public class EditOfferView extends MutableOfferView<EditOfferViewModel> {
|
||||||
|
|
||||||
initWithData(openOffer.getOffer().getDirection(),
|
initWithData(openOffer.getOffer().getDirection(),
|
||||||
CurrencyUtil.getTradeCurrency(openOffer.getOffer().getCurrencyCode()).get(),
|
CurrencyUtil.getTradeCurrency(openOffer.getOffer().getCurrencyCode()).get(),
|
||||||
|
false,
|
||||||
null);
|
null);
|
||||||
|
|
||||||
model.onStartEditOffer(errorMessage -> {
|
model.onStartEditOffer(errorMessage -> {
|
||||||
|
@ -208,23 +211,31 @@ public class EditOfferView extends MutableOfferView<EditOfferViewModel> {
|
||||||
|
|
||||||
// edit offer
|
// edit offer
|
||||||
model.onPublishOffer(() -> {
|
model.onPublishOffer(() -> {
|
||||||
String key = "editOfferSuccess";
|
if (model.dataModel.hasConflictingClone()) {
|
||||||
if (DontShowAgainLookup.showAgain(key)) {
|
new Popup().warning(Res.get("editOffer.hasConflictingClone")).show();
|
||||||
new Popup()
|
} else {
|
||||||
.feedback(Res.get("editOffer.success"))
|
String key = "editOfferSuccess";
|
||||||
.dontShowAgainId(key)
|
if (DontShowAgainLookup.showAgain(key)) {
|
||||||
.show();
|
new Popup()
|
||||||
|
.feedback(Res.get("editOffer.success"))
|
||||||
|
.dontShowAgainId(key)
|
||||||
|
.show();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
spinnerInfoLabel.setText("");
|
UserThread.execute(() -> {
|
||||||
busyAnimation.stop();
|
spinnerInfoLabel.setText("");
|
||||||
close();
|
busyAnimation.stop();
|
||||||
|
close();
|
||||||
|
});
|
||||||
}, (message) -> {
|
}, (message) -> {
|
||||||
log.error(message);
|
UserThread.execute(() -> {
|
||||||
spinnerInfoLabel.setText("");
|
log.error(message);
|
||||||
busyAnimation.stop();
|
spinnerInfoLabel.setText("");
|
||||||
model.isNextButtonDisabled.setValue(false);
|
busyAnimation.stop();
|
||||||
cancelButton.setDisable(false);
|
model.isNextButtonDisabled.setValue(false);
|
||||||
new Popup().warning(Res.get("editOffer.failed", message)).show();
|
cancelButton.setDisable(false);
|
||||||
|
new Popup().warning(Res.get("editOffer.failed", message)).show();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -44,7 +44,7 @@ class EditOfferViewModel extends MutableOfferViewModel<EditOfferDataModel> {
|
||||||
FiatVolumeValidator fiatVolumeValidator,
|
FiatVolumeValidator fiatVolumeValidator,
|
||||||
AmountValidator4Decimals priceValidator4Decimals,
|
AmountValidator4Decimals priceValidator4Decimals,
|
||||||
AmountValidator8Decimals priceValidator8Decimals,
|
AmountValidator8Decimals priceValidator8Decimals,
|
||||||
XmrValidator btcValidator,
|
XmrValidator xmrValidator,
|
||||||
SecurityDepositValidator securityDepositValidator,
|
SecurityDepositValidator securityDepositValidator,
|
||||||
PriceFeedService priceFeedService,
|
PriceFeedService priceFeedService,
|
||||||
AccountAgeWitnessService accountAgeWitnessService,
|
AccountAgeWitnessService accountAgeWitnessService,
|
||||||
|
@ -56,7 +56,7 @@ class EditOfferViewModel extends MutableOfferViewModel<EditOfferDataModel> {
|
||||||
fiatVolumeValidator,
|
fiatVolumeValidator,
|
||||||
priceValidator4Decimals,
|
priceValidator4Decimals,
|
||||||
priceValidator8Decimals,
|
priceValidator8Decimals,
|
||||||
btcValidator,
|
xmrValidator,
|
||||||
securityDepositValidator,
|
securityDepositValidator,
|
||||||
priceFeedService,
|
priceFeedService,
|
||||||
accountAgeWitnessService,
|
accountAgeWitnessService,
|
||||||
|
|
|
@ -39,4 +39,8 @@ class OpenOfferListItem {
|
||||||
public Offer getOffer() {
|
public Offer getOffer() {
|
||||||
return openOffer.getOffer();
|
return openOffer.getOffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getGroupId() {
|
||||||
|
return openOffer.getGroupId();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,7 +42,8 @@
|
||||||
</HBox>
|
</HBox>
|
||||||
<TableView fx:id="tableView" VBox.vgrow="ALWAYS">
|
<TableView fx:id="tableView" VBox.vgrow="ALWAYS">
|
||||||
<columns>
|
<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="dateColumn" minWidth="170"/>
|
||||||
<TableColumn fx:id="marketColumn" minWidth="75"/>
|
<TableColumn fx:id="marketColumn" minWidth="75"/>
|
||||||
<TableColumn fx:id="priceColumn" minWidth="100"/>
|
<TableColumn fx:id="priceColumn" minWidth="100"/>
|
||||||
|
@ -50,11 +51,13 @@
|
||||||
<TableColumn fx:id="triggerPriceColumn" minWidth="90"/>
|
<TableColumn fx:id="triggerPriceColumn" minWidth="90"/>
|
||||||
<TableColumn fx:id="amountColumn" minWidth="110"/>
|
<TableColumn fx:id="amountColumn" minWidth="110"/>
|
||||||
<TableColumn fx:id="volumeColumn" 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="directionColumn" minWidth="70"/>
|
||||||
<TableColumn fx:id="deactivateItemColumn" minWidth="60" maxWidth="60" sortable="false"/>
|
<TableColumn fx:id="deactivateItemColumn" minWidth="60" maxWidth="60" sortable="false"/>
|
||||||
<TableColumn fx:id="editItemColumn" minWidth="30" maxWidth="30" 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="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"/>
|
<TableColumn fx:id="removeItemColumn" minWidth="30" maxWidth="30" sortable="false"/>
|
||||||
</columns>
|
</columns>
|
||||||
</TableView>
|
</TableView>
|
||||||
|
|
|
@ -22,8 +22,8 @@ import com.googlecode.jcsv.writer.CSVEntryConverter;
|
||||||
import de.jensd.fx.glyphs.materialdesignicons.MaterialDesignIcon;
|
import de.jensd.fx.glyphs.materialdesignicons.MaterialDesignIcon;
|
||||||
import haveno.core.locale.Res;
|
import haveno.core.locale.Res;
|
||||||
import haveno.core.offer.Offer;
|
import haveno.core.offer.Offer;
|
||||||
import haveno.core.offer.OfferPayload;
|
|
||||||
import haveno.core.offer.OpenOffer;
|
import haveno.core.offer.OpenOffer;
|
||||||
|
import haveno.core.offer.OpenOfferManager;
|
||||||
import haveno.core.user.DontShowAgainLookup;
|
import haveno.core.user.DontShowAgainLookup;
|
||||||
import haveno.desktop.Navigation;
|
import haveno.desktop.Navigation;
|
||||||
import haveno.desktop.common.view.ActivatableViewAndModel;
|
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.popups.Popup;
|
||||||
import haveno.desktop.main.overlays.windows.OfferDetailsWindow;
|
import haveno.desktop.main.overlays.windows.OfferDetailsWindow;
|
||||||
import haveno.desktop.main.portfolio.PortfolioView;
|
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 static haveno.desktop.util.FormBuilder.getRegularIconButton;
|
||||||
|
import haveno.desktop.util.FormBuilder;
|
||||||
import haveno.desktop.util.GUIUtil;
|
import haveno.desktop.util.GUIUtil;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
@ -51,13 +52,11 @@ import java.util.stream.Collectors;
|
||||||
import javafx.beans.binding.Bindings;
|
import javafx.beans.binding.Bindings;
|
||||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||||
import javafx.beans.value.ChangeListener;
|
import javafx.beans.value.ChangeListener;
|
||||||
import javafx.collections.ObservableList;
|
import javafx.collections.ListChangeListener;
|
||||||
import javafx.collections.transformation.FilteredList;
|
import javafx.collections.transformation.FilteredList;
|
||||||
import javafx.collections.transformation.SortedList;
|
import javafx.collections.transformation.SortedList;
|
||||||
import javafx.fxml.FXML;
|
import javafx.fxml.FXML;
|
||||||
import javafx.geometry.Insets;
|
import javafx.geometry.Insets;
|
||||||
import javafx.scene.Node;
|
|
||||||
import javafx.scene.Parent;
|
|
||||||
import javafx.scene.control.Button;
|
import javafx.scene.control.Button;
|
||||||
import javafx.scene.control.ContextMenu;
|
import javafx.scene.control.ContextMenu;
|
||||||
import javafx.scene.control.Label;
|
import javafx.scene.control.Label;
|
||||||
|
@ -73,6 +72,7 @@ import javafx.scene.layout.Pane;
|
||||||
import javafx.scene.layout.Priority;
|
import javafx.scene.layout.Priority;
|
||||||
import javafx.scene.layout.Region;
|
import javafx.scene.layout.Region;
|
||||||
import javafx.scene.layout.VBox;
|
import javafx.scene.layout.VBox;
|
||||||
|
import javafx.scene.text.Text;
|
||||||
import javafx.stage.Stage;
|
import javafx.stage.Stage;
|
||||||
import javafx.util.Callback;
|
import javafx.util.Callback;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
@ -80,12 +80,39 @@ import org.jetbrains.annotations.NotNull;
|
||||||
@FxmlView
|
@FxmlView
|
||||||
public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersViewModel> {
|
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
|
@FXML
|
||||||
TableView<OpenOfferListItem> tableView;
|
TableView<OpenOfferListItem> tableView;
|
||||||
@FXML
|
@FXML
|
||||||
TableColumn<OpenOfferListItem, OpenOfferListItem> priceColumn, deviationColumn, amountColumn, volumeColumn,
|
TableColumn<OpenOfferListItem, OpenOfferListItem> priceColumn, deviationColumn, amountColumn, volumeColumn,
|
||||||
marketColumn, directionColumn, dateColumn, offerIdColumn, deactivateItemColumn,
|
marketColumn, directionColumn, dateColumn, offerIdColumn, deactivateItemColumn, groupIdColumn,
|
||||||
removeItemColumn, editItemColumn, triggerPriceColumn, triggerIconColumn, paymentMethodColumn;
|
removeItemColumn, editItemColumn, triggerPriceColumn, triggerIconColumn, paymentMethodColumn, duplicateItemColumn,
|
||||||
|
cloneItemColumn;
|
||||||
@FXML
|
@FXML
|
||||||
HBox searchBox;
|
HBox searchBox;
|
||||||
@FXML
|
@FXML
|
||||||
|
@ -108,37 +135,48 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
|
||||||
private SortedList<OpenOfferListItem> sortedList;
|
private SortedList<OpenOfferListItem> sortedList;
|
||||||
private FilteredList<OpenOfferListItem> filteredList;
|
private FilteredList<OpenOfferListItem> filteredList;
|
||||||
private ChangeListener<String> filterTextFieldListener;
|
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 ChangeListener<Number> widthListener;
|
||||||
|
private ListChangeListener<OpenOfferListItem> sortedListChangedListener;
|
||||||
|
|
||||||
private Map<String, ChangeListener<OpenOffer.State>> offerStateChangeListeners = new HashMap<String, ChangeListener<OpenOffer.State>>();
|
private Map<String, ChangeListener<OpenOffer.State>> offerStateChangeListeners = new HashMap<String, ChangeListener<OpenOffer.State>>();
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
public OpenOffersView(OpenOffersViewModel model, Navigation navigation, OfferDetailsWindow offerDetailsWindow) {
|
public OpenOffersView(OpenOffersViewModel model,
|
||||||
|
OpenOfferManager openOfferManager,
|
||||||
|
Navigation navigation,
|
||||||
|
OfferDetailsWindow offerDetailsWindow) {
|
||||||
super(model);
|
super(model);
|
||||||
this.navigation = navigation;
|
this.navigation = navigation;
|
||||||
this.offerDetailsWindow = offerDetailsWindow;
|
this.offerDetailsWindow = offerDetailsWindow;
|
||||||
|
this.openOfferManager = openOfferManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void initialize() {
|
public void initialize() {
|
||||||
widthListener = (observable, oldValue, newValue) -> onWidthChange((double) newValue);
|
widthListener = (observable, oldValue, newValue) -> onWidthChange((double) newValue);
|
||||||
paymentMethodColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.paymentMethod")));
|
groupIdColumn.setGraphic(new AutoTooltipLabel(ColumnNames.GROUP_ID.toString()));
|
||||||
priceColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.price")));
|
paymentMethodColumn.setGraphic(new AutoTooltipLabel(ColumnNames.PAYMENT_METHOD.toString()));
|
||||||
deviationColumn.setGraphic(new AutoTooltipTableColumn<>(Res.get("shared.deviation"),
|
priceColumn.setGraphic(new AutoTooltipLabel(ColumnNames.PRICE.toString()));
|
||||||
|
deviationColumn.setGraphic(new AutoTooltipTableColumn<>(ColumnNames.DEVIATION.toString(),
|
||||||
Res.get("portfolio.closedTrades.deviation.help")).getGraphic());
|
Res.get("portfolio.closedTrades.deviation.help")).getGraphic());
|
||||||
amountColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.XMRMinMax")));
|
triggerPriceColumn.setGraphic(new AutoTooltipLabel(ColumnNames.TRIGGER_PRICE.toString()));
|
||||||
volumeColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.amountMinMax")));
|
amountColumn.setGraphic(new AutoTooltipLabel(ColumnNames.AMOUNT.toString()));
|
||||||
marketColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.market")));
|
volumeColumn.setGraphic(new AutoTooltipLabel(ColumnNames.VOLUME.toString()));
|
||||||
directionColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.offerType")));
|
marketColumn.setGraphic(new AutoTooltipLabel(ColumnNames.MARKET.toString()));
|
||||||
dateColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.dateTime")));
|
directionColumn.setGraphic(new AutoTooltipLabel(ColumnNames.DIRECTION.toString()));
|
||||||
offerIdColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.offerId")));
|
dateColumn.setGraphic(new AutoTooltipLabel(ColumnNames.DATE.toString()));
|
||||||
triggerPriceColumn.setGraphic(new AutoTooltipLabel(Res.get("openOffer.header.triggerPrice")));
|
offerIdColumn.setGraphic(new AutoTooltipLabel(ColumnNames.OFFER_ID.toString()));
|
||||||
deactivateItemColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.enabled")));
|
deactivateItemColumn.setGraphic(new AutoTooltipLabel(ColumnNames.STATUS.toString()));
|
||||||
editItemColumn.setGraphic(new AutoTooltipLabel(""));
|
editItemColumn.setGraphic(new AutoTooltipLabel(""));
|
||||||
|
duplicateItemColumn.setText("");
|
||||||
|
cloneItemColumn.setText("");
|
||||||
removeItemColumn.setGraphic(new AutoTooltipLabel(""));
|
removeItemColumn.setGraphic(new AutoTooltipLabel(""));
|
||||||
|
|
||||||
setOfferIdColumnCellFactory();
|
setOfferIdColumnCellFactory();
|
||||||
|
setGroupIdCellFactory();
|
||||||
setDirectionColumnCellFactory();
|
setDirectionColumnCellFactory();
|
||||||
setMarketColumnCellFactory();
|
setMarketColumnCellFactory();
|
||||||
setPriceColumnCellFactory();
|
setPriceColumnCellFactory();
|
||||||
|
@ -151,12 +189,15 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
|
||||||
setEditColumnCellFactory();
|
setEditColumnCellFactory();
|
||||||
setTriggerIconColumnCellFactory();
|
setTriggerIconColumnCellFactory();
|
||||||
setTriggerPriceColumnCellFactory();
|
setTriggerPriceColumnCellFactory();
|
||||||
|
setDuplicateColumnCellFactory();
|
||||||
|
setCloneColumnCellFactory();
|
||||||
setRemoveColumnCellFactory();
|
setRemoveColumnCellFactory();
|
||||||
|
|
||||||
tableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY);
|
tableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY);
|
||||||
tableView.setPlaceholder(new AutoTooltipLabel(Res.get("table.placeholder.noItems", Res.get("shared.openOffers"))));
|
tableView.setPlaceholder(new AutoTooltipLabel(Res.get("table.placeholder.noItems", Res.get("shared.openOffers"))));
|
||||||
|
|
||||||
offerIdColumn.setComparator(Comparator.comparing(o -> o.getOffer().getId()));
|
offerIdColumn.setComparator(Comparator.comparing(o -> o.getOffer().getId()));
|
||||||
|
groupIdColumn.setComparator(Comparator.comparing(o -> o.getOpenOffer().getReserveTxHash()));
|
||||||
directionColumn.setComparator(Comparator.comparing(o -> o.getOffer().getDirection()));
|
directionColumn.setComparator(Comparator.comparing(o -> o.getOffer().getDirection()));
|
||||||
marketColumn.setComparator(Comparator.comparing(model::getMarketLabel));
|
marketColumn.setComparator(Comparator.comparing(model::getMarketLabel));
|
||||||
amountColumn.setComparator(Comparator.comparing(o -> o.getOffer().getAmount()));
|
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()));
|
dateColumn.setComparator(Comparator.comparing(o -> o.getOffer().getDate()));
|
||||||
paymentMethodColumn.setComparator(Comparator.comparing(o -> Res.get(o.getOffer().getPaymentMethod().getId())));
|
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.getSortOrder().add(dateColumn);
|
||||||
|
|
||||||
tableView.setRowFactory(
|
tableView.setRowFactory(
|
||||||
tableView -> {
|
tableView -> {
|
||||||
final TableRow<OpenOfferListItem> row = new TableRow<>();
|
final TableRow<OpenOfferListItem> row = new TableRow<>();
|
||||||
final ContextMenu rowMenu = new ContextMenu();
|
final ContextMenu rowMenu = new ContextMenu();
|
||||||
MenuItem editItem = new MenuItem(Res.get("portfolio.context.offerLikeThis"));
|
|
||||||
editItem.setOnAction((event) -> {
|
MenuItem duplicateOfferMenuItem = new MenuItem(Res.get("portfolio.context.offerLikeThis"));
|
||||||
try {
|
duplicateOfferMenuItem.setOnAction((event) -> onDuplicateOffer(row.getItem()));
|
||||||
OfferPayload offerPayload = row.getItem().getOffer().getOfferPayload();
|
rowMenu.getItems().add(duplicateOfferMenuItem);
|
||||||
navigation.navigateToWithData(offerPayload, MainView.class, PortfolioView.class, DuplicateOfferView.class);
|
|
||||||
} catch (NullPointerException e) {
|
MenuItem cloneOfferMenuItem = new MenuItem(Res.get("offerbook.cloneOffer"));
|
||||||
log.warn("Unable to get offerPayload - {}", e.toString());
|
cloneOfferMenuItem.setOnAction((event) -> onCloneOffer(row.getItem()));
|
||||||
}
|
rowMenu.getItems().add(cloneOfferMenuItem);
|
||||||
});
|
|
||||||
rowMenu.getItems().add(editItem);
|
|
||||||
row.contextMenuProperty().bind(
|
row.contextMenuProperty().bind(
|
||||||
Bindings.when(Bindings.isNotNull(row.itemProperty()))
|
Bindings.when(Bindings.isNotNull(row.itemProperty()))
|
||||||
.then(rowMenu)
|
.then(rowMenu)
|
||||||
|
@ -207,6 +246,15 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
|
||||||
HBox.setHgrow(footerSpacer, Priority.ALWAYS);
|
HBox.setHgrow(footerSpacer, Priority.ALWAYS);
|
||||||
HBox.setMargin(exportButton, new Insets(0, 10, 0, 0));
|
HBox.setMargin(exportButton, new Insets(0, 10, 0, 0));
|
||||||
exportButton.updateText(Res.get("shared.exportCSV"));
|
exportButton.updateText(Res.get("shared.exportCSV"));
|
||||||
|
|
||||||
|
sortedListChangedListener = c -> {
|
||||||
|
c.next();
|
||||||
|
if (c.wasAdded() || c.wasRemoved()) {
|
||||||
|
updateNumberOfOffers();
|
||||||
|
updateGroupIdColumnVisibility();
|
||||||
|
updateTriggerColumnVisibility();
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -214,8 +262,11 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
|
||||||
filteredList = new FilteredList<>(model.getList());
|
filteredList = new FilteredList<>(model.getList());
|
||||||
sortedList = new SortedList<>(filteredList);
|
sortedList = new SortedList<>(filteredList);
|
||||||
sortedList.comparatorProperty().bind(tableView.comparatorProperty());
|
sortedList.comparatorProperty().bind(tableView.comparatorProperty());
|
||||||
|
sortedList.addListener(sortedListChangedListener);
|
||||||
tableView.setItems(sortedList);
|
tableView.setItems(sortedList);
|
||||||
|
|
||||||
|
updateGroupIdColumnVisibility();
|
||||||
|
updateTriggerColumnVisibility();
|
||||||
updateSelectToggleButtonState();
|
updateSelectToggleButtonState();
|
||||||
|
|
||||||
selectToggleButton.setOnAction(event -> {
|
selectToggleButton.setOnAction(event -> {
|
||||||
|
@ -231,37 +282,27 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
|
||||||
|
|
||||||
numItems.setText(Res.get("shared.numItemsLabel", sortedList.size()));
|
numItems.setText(Res.get("shared.numItemsLabel", sortedList.size()));
|
||||||
exportButton.setOnAction(event -> {
|
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 -> {
|
CSVEntryConverter<OpenOfferListItem> headerConverter = item -> {
|
||||||
String[] columns = new String[reportColumns];
|
String[] columns = new String[ColumnNames.values().length];
|
||||||
for (int i = 0; i < columns.length; i++) {
|
for (ColumnNames m : ColumnNames.values()) {
|
||||||
Node graphic = tableColumns.get(i).getGraphic();
|
columns[m.ordinal()] = m.toString();
|
||||||
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";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return columns;
|
return columns;
|
||||||
};
|
};
|
||||||
CSVEntryConverter<OpenOfferListItem> contentConverter = item -> {
|
CSVEntryConverter<OpenOfferListItem> contentConverter = item -> {
|
||||||
String[] columns = new String[reportColumns];
|
String[] columns = new String[ColumnNames.values().length];
|
||||||
columns[0] = model.getOfferId(item);
|
columns[ColumnNames.OFFER_ID.ordinal()] = model.getOfferId(item);
|
||||||
columns[1] = model.getDate(item);
|
columns[ColumnNames.GROUP_ID.ordinal()] = openOfferManager.hasClonedOffer(item.getOffer().getId()) ? getShortenedGroupId(item.getGroupId()) : "";
|
||||||
columns[2] = model.getMarketLabel(item);
|
columns[ColumnNames.DATE.ordinal()] = model.getDate(item);
|
||||||
columns[3] = model.getPrice(item);
|
columns[ColumnNames.MARKET.ordinal()] = model.getMarketLabel(item);
|
||||||
columns[4] = model.getPriceDeviation(item);
|
columns[ColumnNames.PRICE.ordinal()] = model.getPrice(item);
|
||||||
columns[5] = model.getTriggerPrice(item);
|
columns[ColumnNames.DEVIATION.ordinal()] = model.getPriceDeviation(item);
|
||||||
columns[6] = model.getAmount(item);
|
columns[ColumnNames.TRIGGER_PRICE.ordinal()] = model.getTriggerPrice(item);
|
||||||
columns[7] = model.getVolume(item);
|
columns[ColumnNames.AMOUNT.ordinal()] = model.getAmount(item);
|
||||||
columns[8] = model.getPaymentMethod(item);
|
columns[ColumnNames.VOLUME.ordinal()] = model.getVolume(item);
|
||||||
columns[9] = model.getDirectionLabel(item);
|
columns[ColumnNames.PAYMENT_METHOD.ordinal()] = model.getPaymentMethod(item);
|
||||||
columns[10] = String.valueOf(!item.getOpenOffer().isDeactivated());
|
columns[ColumnNames.DIRECTION.ordinal()] = model.getDirectionLabel(item);
|
||||||
|
columns[ColumnNames.STATUS.ordinal()] = String.valueOf(!item.getOpenOffer().isDeactivated());
|
||||||
return columns;
|
return columns;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -280,9 +321,24 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
|
||||||
onWidthChange(root.getWidth());
|
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
|
@Override
|
||||||
protected void deactivate() {
|
protected void deactivate() {
|
||||||
sortedList.comparatorProperty().unbind();
|
sortedList.comparatorProperty().unbind();
|
||||||
|
sortedList.removeListener(sortedListChangedListener);
|
||||||
exportButton.setOnAction(null);
|
exportButton.setOnAction(null);
|
||||||
|
|
||||||
filterTextField.textProperty().removeListener(filterTextFieldListener);
|
filterTextField.textProperty().removeListener(filterTextFieldListener);
|
||||||
|
@ -352,7 +408,7 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
|
||||||
}
|
}
|
||||||
|
|
||||||
private void onWidthChange(double width) {
|
private void onWidthChange(double width) {
|
||||||
triggerPriceColumn.setVisible(width > 1200);
|
triggerPriceColumn.setVisible(width > 1300);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void onDeactivateOpenOffer(OpenOffer openOffer) {
|
private void onDeactivateOpenOffer(OpenOffer openOffer) {
|
||||||
|
@ -361,7 +417,7 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
|
||||||
() -> log.debug("Deactivate offer was successful"),
|
() -> log.debug("Deactivate offer was successful"),
|
||||||
(message) -> {
|
(message) -> {
|
||||||
log.error(message);
|
log.error(message);
|
||||||
new Popup().warning(Res.get("offerbook.deactivateOffer.failed", message)).show();
|
new Popup().warning(message).show();
|
||||||
});
|
});
|
||||||
updateSelectToggleButtonState();
|
updateSelectToggleButtonState();
|
||||||
}
|
}
|
||||||
|
@ -397,12 +453,18 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
|
||||||
}
|
}
|
||||||
|
|
||||||
private void doRemoveOpenOffer(OpenOffer openOffer) {
|
private void doRemoveOpenOffer(OpenOffer openOffer) {
|
||||||
|
boolean hasClonedOffer = openOfferManager.hasClonedOffer(openOffer.getId());
|
||||||
model.onRemoveOpenOffer(openOffer,
|
model.onRemoveOpenOffer(openOffer,
|
||||||
() -> {
|
() -> {
|
||||||
log.debug("Remove offer was successful");
|
log.debug("Remove offer was successful");
|
||||||
|
|
||||||
tableView.refresh();
|
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";
|
String key = "WithdrawFundsAfterRemoveOfferInfo";
|
||||||
if (DontShowAgainLookup.showAgain(key)) {
|
if (DontShowAgainLookup.showAgain(key)) {
|
||||||
new Popup().instruction(Res.get("offerbook.withdrawFundsHint", Res.get("navigation.funds.availableForWithdrawal")))
|
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) {
|
private void onEditOpenOffer(OpenOffer openOffer) {
|
||||||
if (model.isBootstrappedOrShowPopup()) {
|
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() {
|
private void setOfferIdColumnCellFactory() {
|
||||||
offerIdColumn.setCellValueFactory((openOfferListItem) -> new ReadOnlyObjectWrapper<>(openOfferListItem.getValue()));
|
offerIdColumn.setCellValueFactory((openOfferListItem) -> new ReadOnlyObjectWrapper<>(openOfferListItem.getValue()));
|
||||||
offerIdColumn.getStyleClass().addAll("number-column", "first-column");
|
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,
|
public TableCell<OpenOfferListItem, OpenOfferListItem> call(TableColumn<OpenOfferListItem,
|
||||||
OpenOfferListItem> column) {
|
OpenOfferListItem> column) {
|
||||||
return new TableCell<>() {
|
return new TableCell<>() {
|
||||||
private HyperlinkWithIcon field;
|
private HyperlinkWithIcon hyperlinkWithIcon;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void updateItem(final OpenOfferListItem item, boolean empty) {
|
public void updateItem(final OpenOfferListItem item, boolean empty) {
|
||||||
super.updateItem(item, empty);
|
super.updateItem(item, empty);
|
||||||
|
|
||||||
if (item != null && !empty) {
|
if (item != null && !empty) {
|
||||||
field = new HyperlinkWithIcon(model.getOfferId(item));
|
hyperlinkWithIcon = new HyperlinkWithIcon(item.getOffer().getShortId());
|
||||||
field.setOnAction(event -> offerDetailsWindow.show(item.getOffer()));
|
if (model.isDeactivated(item)) {
|
||||||
field.setTooltip(new Tooltip(Res.get("tooltip.openPopupForDetails")));
|
// getStyleClass().add("offer-disabled"); does not work with hyperlinkWithIcon;-(
|
||||||
setGraphic(field);
|
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 {
|
} else {
|
||||||
setGraphic(null);
|
setGraphic(null);
|
||||||
if (field != null)
|
if (hyperlinkWithIcon != null)
|
||||||
field.setOnAction(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() {
|
private void setDateColumnCellFactory() {
|
||||||
dateColumn.setCellValueFactory((openOfferListItem) -> new ReadOnlyObjectWrapper<>(openOfferListItem.getValue()));
|
dateColumn.setCellValueFactory((openOfferListItem) -> new ReadOnlyObjectWrapper<>(openOfferListItem.getValue()));
|
||||||
dateColumn.setCellFactory(
|
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() {
|
private void setTriggerIconColumnCellFactory() {
|
||||||
triggerIconColumn.setCellValueFactory((offerListItem) -> new ReadOnlyObjectWrapper<>(offerListItem.getValue()));
|
triggerIconColumn.setCellValueFactory((offerListItem) -> new ReadOnlyObjectWrapper<>(offerListItem.getValue()));
|
||||||
triggerIconColumn.setCellFactory(
|
triggerIconColumn.setCellFactory(
|
||||||
|
@ -854,8 +1076,12 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setOpenOfferActionHandler(PortfolioView.OpenOfferActionHandler openOfferActionHandler) {
|
public void setEditOpenOfferHandler(PortfolioView.EditOpenOfferHandler editOpenOfferHandler) {
|
||||||
this.openOfferActionHandler = openOfferActionHandler;
|
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();
|
return item.getOffer().getShortId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String getGroupId(OpenOfferListItem item) {
|
||||||
|
return item.getGroupId();
|
||||||
|
}
|
||||||
|
|
||||||
String getAmount(OpenOfferListItem item) {
|
String getAmount(OpenOfferListItem item) {
|
||||||
return (item != null) ? DisplayUtils.formatAmount(item.getOffer(), btcFormatter) : "";
|
return (item != null) ? DisplayUtils.formatAmount(item.getOffer(), btcFormatter) : "";
|
||||||
}
|
}
|
||||||
|
|
|
@ -2340,8 +2340,8 @@ public class FormBuilder {
|
||||||
return getRegularIconForLabel(icon, label, null);
|
return getRegularIconForLabel(icon, label, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Text getRegularIconForLabel(GlyphIcons icon, Label label, String style) {
|
public static Text getRegularIconForLabel(GlyphIcons icon, Label label, String styleClass) {
|
||||||
return getIconForLabel(icon, "1.231em", label, style);
|
return getIconForLabel(icon, "1.231em", label, styleClass);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Text getIcon(GlyphIcons icon) {
|
public static Text getIcon(GlyphIcons icon) {
|
||||||
|
|
|
@ -88,7 +88,7 @@ public class CreateOfferDataModelTest {
|
||||||
when(user.getPaymentAccounts()).thenReturn(paymentAccounts);
|
when(user.getPaymentAccounts()).thenReturn(paymentAccounts);
|
||||||
when(preferences.getSelectedPaymentAccountForCreateOffer()).thenReturn(revolutAccount);
|
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());
|
assertEquals("USD", model.getTradeCurrencyCode().get());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -109,7 +109,7 @@ public class CreateOfferDataModelTest {
|
||||||
when(user.findFirstPaymentAccountWithCurrency(new TraditionalCurrency("USD"))).thenReturn(zelleAccount);
|
when(user.findFirstPaymentAccountWithCurrency(new TraditionalCurrency("USD"))).thenReturn(zelleAccount);
|
||||||
when(preferences.getSelectedPaymentAccountForCreateOffer()).thenReturn(revolutAccount);
|
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());
|
assertEquals("USD", model.getTradeCurrencyCode().get());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -117,7 +117,7 @@ public class CreateOfferViewModelTest {
|
||||||
coinFormatter,
|
coinFormatter,
|
||||||
tradeStats,
|
tradeStats,
|
||||||
null);
|
null);
|
||||||
dataModel.initWithData(OfferDirection.BUY, new CryptoCurrency("XMR", "monero"));
|
dataModel.initWithData(OfferDirection.BUY, new CryptoCurrency("XMR", "monero"), true);
|
||||||
dataModel.activate();
|
dataModel.activate();
|
||||||
|
|
||||||
model = new CreateOfferViewModel(dataModel,
|
model = new CreateOfferViewModel(dataModel,
|
||||||
|
|
|
@ -528,6 +528,7 @@ message PostOfferRequest {
|
||||||
bool is_private_offer = 12;
|
bool is_private_offer = 12;
|
||||||
bool buyer_as_taker_without_deposit = 13;
|
bool buyer_as_taker_without_deposit = 13;
|
||||||
string extra_info = 14;
|
string extra_info = 14;
|
||||||
|
string source_offer_id = 15;
|
||||||
}
|
}
|
||||||
|
|
||||||
message PostOfferReply {
|
message PostOfferReply {
|
||||||
|
|
|
@ -1422,6 +1422,7 @@ message OpenOffer {
|
||||||
string reserve_tx_key = 11;
|
string reserve_tx_key = 11;
|
||||||
string challenge = 12;
|
string challenge = 12;
|
||||||
bool deactivated_by_trigger = 13;
|
bool deactivated_by_trigger = 13;
|
||||||
|
string group_id = 14;
|
||||||
}
|
}
|
||||||
|
|
||||||
message Tradable {
|
message Tradable {
|
||||||
|
|
Loading…
Reference in a new issue