diff --git a/core/src/main/java/bisq/core/api/CoreOffersService.java b/core/src/main/java/bisq/core/api/CoreOffersService.java index 0405bbfe9dd..81feac117c0 100644 --- a/core/src/main/java/bisq/core/api/CoreOffersService.java +++ b/core/src/main/java/bisq/core/api/CoreOffersService.java @@ -447,6 +447,7 @@ private void placeOffer(Offer offer, openOfferManager.placeOffer(offer, buyerSecurityDepositPct, useSavingsWallet, + false, triggerPriceAsLong, resultHandler::accept, log::error); diff --git a/core/src/main/java/bisq/core/btc/Balances.java b/core/src/main/java/bisq/core/btc/Balances.java index d92a6bf0b40..dcc9e311724 100644 --- a/core/src/main/java/bisq/core/btc/Balances.java +++ b/core/src/main/java/bisq/core/btc/Balances.java @@ -114,7 +114,9 @@ private void updateReservedBalance() { .map(openOffer -> btcWalletService.getAddressEntry(openOffer.getId(), AddressEntry.Context.RESERVED_FOR_TRADE) .orElse(null)) .filter(Objects::nonNull) - .mapToLong(addressEntry -> btcWalletService.getBalanceForAddress(addressEntry.getAddress()).value) + .map(AddressEntry::getAddress) + .distinct() + .mapToLong(address -> btcWalletService.getBalanceForAddress(address).value) .sum(); reservedBalance.set(Coin.valueOf(sum)); } @@ -122,8 +124,9 @@ private void updateReservedBalance() { private void updateLockedBalance() { Stream lockedTrades = Stream.concat(closedTradableManager.getTradesStreamWithFundsLockedIn(), failedTradesManager.getTradesStreamWithFundsLockedIn()); lockedTrades = Stream.concat(lockedTrades, tradeManager.getTradesStreamWithFundsLockedIn()); - long sum = lockedTrades.map(trade -> btcWalletService.getAddressEntry(trade.getId(), AddressEntry.Context.MULTI_SIG) - .orElse(null)) + long sum = lockedTrades.map(trade -> btcWalletService.getAddressEntry(trade.getId(), + AddressEntry.Context.MULTI_SIG) + .orElse(null)) .filter(Objects::nonNull) .mapToLong(AddressEntry::getCoinLockedInMultiSig) .sum(); diff --git a/core/src/main/java/bisq/core/btc/model/AddressEntryList.java b/core/src/main/java/bisq/core/btc/model/AddressEntryList.java index be9dd78fb4b..61b32a6e5c6 100644 --- a/core/src/main/java/bisq/core/btc/model/AddressEntryList.java +++ b/core/src/main/java/bisq/core/btc/model/AddressEntryList.java @@ -149,7 +149,7 @@ public void onWalletReady(Wallet wallet) { wallet.getIssuedReceiveAddresses().stream() .filter(this::isAddressNotInEntries) .forEach(address -> { - DeterministicKey key = (DeterministicKey) wallet.findKeyFromAddress(address); + DeterministicKey key = (DeterministicKey) wallet.findKeyFromAddress(address); if (key != null) { // Address will be derived from key in getAddress method log.info("Create AddressEntry for IssuedReceiveAddress. address={}", address.toString()); @@ -209,11 +209,26 @@ public void swapToAvailable(AddressEntry addressEntry) { } log.info("swapToAvailable addressEntry to swap={}", addressEntry); - boolean setChangedByRemove = entrySet.remove(addressEntry); - boolean setChangedByAdd = entrySet.add(new AddressEntry(addressEntry.getKeyPair(), - AddressEntry.Context.AVAILABLE, - addressEntry.isSegwit())); - if (setChangedByRemove || setChangedByAdd) { + if (entrySet.remove(addressEntry)) { + requestPersistence(); + } + // If we have an address entry which shared the address with another one (shared maker fee offers 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 AddressEntry(addressEntry.getKeyPair(), + AddressEntry.Context.AVAILABLE, + addressEntry.isSegwit()))) { requestPersistence(); } } diff --git a/core/src/main/java/bisq/core/btc/wallet/BtcWalletService.java b/core/src/main/java/bisq/core/btc/wallet/BtcWalletService.java index f1c5e7ad01e..b6b3fbd24e1 100644 --- a/core/src/main/java/bisq/core/btc/wallet/BtcWalletService.java +++ b/core/src/main/java/bisq/core/btc/wallet/BtcWalletService.java @@ -630,6 +630,25 @@ public Optional getAddressEntry(String offerId, .findAny(); } + // For cloned offers with shared maker fee we create a new address entry based on the source entry + // and set the new offerId. + public AddressEntry getOrCloneAddressEntryWithOfferId(AddressEntry sourceAddressEntry, String offerId) { + Optional addressEntry = getAddressEntryListAsImmutableList().stream() + .filter(entry -> offerId.equals(entry.getOfferId())) + .filter(entry -> sourceAddressEntry.getContext() == entry.getContext()) + .findAny(); + if (addressEntry.isPresent()) { + return addressEntry.get(); + } else { + AddressEntry cloneWithNewOfferId = new AddressEntry(sourceAddressEntry.getKeyPair(), + sourceAddressEntry.getContext(), + offerId, + sourceAddressEntry.isSegwit()); + addressEntryList.addAddressEntry(cloneWithNewOfferId); + return cloneWithNewOfferId; + } + } + public AddressEntry getOrCreateAddressEntry(String offerId, AddressEntry.Context context) { Optional addressEntry = getAddressEntryListAsImmutableList().stream() .filter(e -> offerId.equals(e.getOfferId())) diff --git a/core/src/main/java/bisq/core/btc/wallet/WalletService.java b/core/src/main/java/bisq/core/btc/wallet/WalletService.java index 2511dd4c681..abbcc1d21a3 100644 --- a/core/src/main/java/bisq/core/btc/wallet/WalletService.java +++ b/core/src/main/java/bisq/core/btc/wallet/WalletService.java @@ -461,8 +461,8 @@ public TransactionConfidence getConfidenceForAddressFromBlockHeight(Address addr .map(tx -> getTransactionConfidence(tx, address)) .filter(Objects::nonNull) .filter(con -> con.getConfidenceType() == TransactionConfidence.ConfidenceType.PENDING || - (con.getConfidenceType() == TransactionConfidence.ConfidenceType.BUILDING && - con.getAppearedAtChainHeight() > targetHeight)) + (con.getConfidenceType() == TransactionConfidence.ConfidenceType.BUILDING && + con.getAppearedAtChainHeight() > targetHeight)) .collect(Collectors.toList())); } return getMostRecentConfidence(transactionConfidenceList); @@ -751,6 +751,10 @@ public boolean isEncrypted() { return wallet.isEncrypted(); } + public List getAllRecentTransactions(boolean includeDead) { + return getRecentTransactions(Integer.MAX_VALUE, includeDead); + } + public List getRecentTransactions(int numTransactions, boolean includeDead) { // Returns a list ordered by tx.getUpdateTime() desc return wallet.getRecentTransactions(numTransactions, includeDead); diff --git a/core/src/main/java/bisq/core/offer/Offer.java b/core/src/main/java/bisq/core/offer/Offer.java index b14f9c2c589..1009434c3a6 100644 --- a/core/src/main/java/bisq/core/offer/Offer.java +++ b/core/src/main/java/bisq/core/offer/Offer.java @@ -502,6 +502,7 @@ public String getMakerPaymentAccountId() { return offerPayloadBase.getMakerPaymentAccountId(); } + @Nullable public String getOfferFeePaymentTxId() { return getOfferPayload().map(OfferPayload::getOfferFeePaymentTxId).orElse(null); } diff --git a/core/src/main/java/bisq/core/offer/OpenOfferManager.java b/core/src/main/java/bisq/core/offer/OpenOfferManager.java index 2e6ac72488c..d275bf5fe3e 100644 --- a/core/src/main/java/bisq/core/offer/OpenOfferManager.java +++ b/core/src/main/java/bisq/core/offer/OpenOfferManager.java @@ -227,7 +227,10 @@ public void onUpdatedDataReceived() { } private void cleanUpAddressEntries() { - Set openOffersIdSet = openOffers.getList().stream().map(OpenOffer::getId).collect(Collectors.toSet()); + Set openOffersIdSet = openOffers.getList().stream() + .map(OpenOffer::getId) + .collect(Collectors.toSet()); + // We reset all AddressEntriesForOpenOffer which do not have a corresponding openOffer btcWalletService.getAddressEntriesForOpenOffer().stream() .filter(e -> !openOffersIdSet.contains(e.getOfferId())) .forEach(e -> { @@ -381,12 +384,19 @@ public void onAwakeFromStandby() { public void placeOffer(Offer offer, double buyerSecurityDeposit, boolean useSavingsWallet, + boolean isSharedMakerFee, long triggerPrice, TransactionResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) { checkNotNull(offer.getMakerFee(), "makerFee must not be null"); checkArgument(!offer.isBsqSwapOffer()); + int numClones = getOpenOffersByMakerFeeTxId(offer.getOfferFeePaymentTxId()).size(); + if (numClones >= 10) { + errorMessageHandler.handleErrorMessage("Cannot create offer because maximum number of 10 cloned offers with shared maker fee is reached."); + return; + } + Coin reservedFundsForOffer = createOfferService.getReservedFundsForOffer(offer.getDirection(), offer.getAmount(), buyerSecurityDeposit, @@ -395,6 +405,7 @@ public void placeOffer(Offer offer, PlaceOfferModel model = new PlaceOfferModel(offer, reservedFundsForOffer, useSavingsWallet, + isSharedMakerFee, btcWalletService, tradeWalletService, bsqWalletService, @@ -409,6 +420,19 @@ public void placeOffer(Offer offer, model, transaction -> { OpenOffer openOffer = new OpenOffer(offer, triggerPrice); + if (isSharedMakerFee) { + if (cannotActivateOffer(offer)) { + openOffer.setState(OpenOffer.State.DEACTIVATED); + } else { + // We did not use the AddToOfferBook task for publishing because we + // do not have created the openOffer during the protocol and we need that to determine if the offer can be activated. + // So in case we have an activated cloned offer we do the publishing here. + model.getOfferBookService().addOffer(model.getOffer(), + () -> model.setOfferAddedToOfferBook(true), + errorMessage -> model.getOffer().setErrorMessage("Could not add offer to offerbook.\n" + + "Please check your network connection and try again.")); + } + } addOpenOfferToList(openOffer); if (!stopped) { startPeriodicRepublishOffersTimer(); @@ -452,7 +476,12 @@ public void activateOpenOffer(OpenOffer openOffer, ResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) { if (offersToBeEdited.containsKey(openOffer.getId())) { - errorMessageHandler.handleErrorMessage("You can't activate an offer that is currently edited."); + errorMessageHandler.handleErrorMessage(Res.get("offerbook.cannotActivateEditedOffer.warning")); + return; + } + + if (cannotActivateOffer(openOffer.getOffer())) { + errorMessageHandler.handleErrorMessage(Res.get("offerbook.cannotActivate.warning")); return; } @@ -576,8 +605,6 @@ public void editOpenOfferCancel(OpenOffer openOffer, } else { resultHandler.handleResult(); } - } else { - errorMessageHandler.handleErrorMessage("Editing of offer can't be canceled as it is not edited."); } } @@ -585,9 +612,20 @@ private void onRemoved(OpenOffer openOffer, ResultHandler resultHandler, Offer o offer.setState(Offer.State.REMOVED); openOffer.setState(OpenOffer.State.CANCELED); removeOpenOfferFromList(openOffer); + if (!openOffer.getOffer().isBsqSwapOffer()) { - closedTradableManager.add(openOffer); - btcWalletService.resetAddressEntriesForOpenOffer(offer.getId()); + // In case of an offer which has its maker fee shared with other offers, we do not add the openOffer + // to history. Only when the last offer with that maker fee txId got removed we add it. + // Only canceled offers which have lost maker fees are shown in history. + // For that reason we also do not add BSQ offers. + if (getOpenOffersByMakerFeeTxId(offer.getOfferFeePaymentTxId()).isEmpty()) { + closedTradableManager.add(openOffer); + + // We only reset if there are no other offers with the shared maker fee as otherwise the + // address in the addressEntry would become available while it's still RESERVED_FOR_TRADE + // for the remaining offers. + btcWalletService.resetAddressEntriesForOpenOffer(offer.getId()); + } } log.info("onRemoved offerId={}", offer.getId()); resultHandler.handleResult(); @@ -595,13 +633,31 @@ private void onRemoved(OpenOffer openOffer, ResultHandler resultHandler, Offer o // Close openOffer after deposit published public void closeOpenOffer(Offer offer) { - getOpenOfferById(offer.getId()).ifPresent(openOffer -> { - removeOpenOfferFromList(openOffer); - openOffer.setState(OpenOffer.State.CLOSED); - offerBookService.removeOffer(openOffer.getOffer().getOfferPayloadBase(), - () -> log.trace("Successful removed offer"), - log::error); - }); + if (offer.isBsqSwapOffer()) { + getOpenOfferById(offer.getId()).ifPresent(openOffer -> { + removeOpenOfferFromList(openOffer); + openOffer.setState(OpenOffer.State.CLOSED); + offerBookService.removeOffer(openOffer.getOffer().getOfferPayloadBase(), + () -> log.trace("Successfully removed offer"), + log::error); + }); + } else { + getOpenOffersByMakerFeeTxId(offer.getOfferFeePaymentTxId()).forEach(openOffer -> { + removeOpenOfferFromList(openOffer); + + if (offer.getId().equals(openOffer.getId())) { + openOffer.setState(OpenOffer.State.CLOSED); + } else { + // We use CANCELED for the offers which have shared maker fee but have not been taken for the trade. + openOffer.setState(OpenOffer.State.CANCELED); + // We need to reset now those entries as well + btcWalletService.resetAddressEntriesForOpenOffer(openOffer.getId()); + } + offerBookService.removeOffer(openOffer.getOffer().getOfferPayloadBase(), + () -> log.trace("Successfully removed offer"), + log::error); + }); + } } public void reserveOpenOffer(OpenOffer openOffer) { @@ -609,6 +665,25 @@ public void reserveOpenOffer(OpenOffer openOffer) { requestPersistence(); } + public boolean cannotActivateOffer(Offer offer) { + return openOffers.stream() + .filter(openOffer -> !openOffer.getOffer().isBsqSwapOffer()) // We only handle non-BSQ offers + .filter(openOffer -> !openOffer.getId().equals(offer.getId())) // our own offer gets skipped + .filter(openOffer -> !openOffer.isDeactivated()) // we only check with activated offers + .anyMatch(openOffer -> + // Offers which share our maker fee will get checked if they have the same payment method + // and currency. + openOffer.getOffer().getOfferFeePaymentTxId() != null && + openOffer.getOffer().getOfferFeePaymentTxId().equals(offer.getOfferFeePaymentTxId()) && + openOffer.getOffer().getPaymentMethodId().equalsIgnoreCase(offer.getPaymentMethodId()) && + openOffer.getOffer().getCounterCurrencyCode().equalsIgnoreCase(offer.getCounterCurrencyCode()) && + openOffer.getOffer().getBaseCurrencyCode().equalsIgnoreCase(offer.getBaseCurrencyCode())); + } + + public boolean hasOfferSharedMakerFee(OpenOffer openOffer) { + return getOpenOffersByMakerFeeTxId(openOffer.getOffer().getOfferFeePaymentTxId()).size() > 1; + } + /////////////////////////////////////////////////////////////////////////////////////////// // Getters @@ -818,7 +893,7 @@ private void sendAckMessage(OfferAvailabilityRequest message, result, errorMessage); - final NodeAddress takersNodeAddress = sender; + NodeAddress takersNodeAddress = sender; PubKeyRing takersPubKeyRing = message.getPubKeyRing(); log.info("Send AckMessage for OfferAvailabilityRequest to peer {} with offerId {} and sourceUid {}", takersNodeAddress, offerId, ackMessage.getSourceUid()); @@ -1144,6 +1219,16 @@ private boolean isBsqSwapOfferLackingFunds(OpenOffer openOffer) { } private boolean preventedFromPublishing(OpenOffer openOffer) { - return openOffer.isDeactivated() || openOffer.isBsqSwapOfferHasMissingFunds(); + return openOffer.isDeactivated() || + openOffer.isBsqSwapOfferHasMissingFunds() || + cannotActivateOffer(openOffer.getOffer()); + } + + private Set getOpenOffersByMakerFeeTxId(String makerFeeTxId) { + return openOffers.stream() + .filter(openOffer -> !openOffer.getOffer().isBsqSwapOffer() && + makerFeeTxId != null && + makerFeeTxId.equals(openOffer.getOffer().getOfferFeePaymentTxId())) + .collect(Collectors.toSet()); } } diff --git a/core/src/main/java/bisq/core/offer/placeoffer/bisq_v1/PlaceOfferModel.java b/core/src/main/java/bisq/core/offer/placeoffer/bisq_v1/PlaceOfferModel.java index 0b44a8b6491..8073a853128 100644 --- a/core/src/main/java/bisq/core/offer/placeoffer/bisq_v1/PlaceOfferModel.java +++ b/core/src/main/java/bisq/core/offer/placeoffer/bisq_v1/PlaceOfferModel.java @@ -45,6 +45,7 @@ public class PlaceOfferModel implements Model { private final Offer offer; private final Coin reservedFundsForOffer; private final boolean useSavingsWallet; + private final boolean isSharedMakerFee; private final BtcWalletService walletService; private final TradeWalletService tradeWalletService; private final BsqWalletService bsqWalletService; @@ -66,6 +67,7 @@ public class PlaceOfferModel implements Model { public PlaceOfferModel(Offer offer, Coin reservedFundsForOffer, boolean useSavingsWallet, + boolean isSharedMakerFee, BtcWalletService walletService, TradeWalletService tradeWalletService, BsqWalletService bsqWalletService, @@ -79,6 +81,7 @@ public PlaceOfferModel(Offer offer, this.offer = offer; this.reservedFundsForOffer = reservedFundsForOffer; this.useSavingsWallet = useSavingsWallet; + this.isSharedMakerFee = isSharedMakerFee; this.walletService = walletService; this.tradeWalletService = tradeWalletService; this.bsqWalletService = bsqWalletService; diff --git a/core/src/main/java/bisq/core/offer/placeoffer/bisq_v1/PlaceOfferProtocol.java b/core/src/main/java/bisq/core/offer/placeoffer/bisq_v1/PlaceOfferProtocol.java index b3f50a95e55..03d709e96b4 100644 --- a/core/src/main/java/bisq/core/offer/placeoffer/bisq_v1/PlaceOfferProtocol.java +++ b/core/src/main/java/bisq/core/offer/placeoffer/bisq_v1/PlaceOfferProtocol.java @@ -19,6 +19,7 @@ import bisq.core.offer.placeoffer.bisq_v1.tasks.AddToOfferBook; import bisq.core.offer.placeoffer.bisq_v1.tasks.CheckNumberOfUnconfirmedTransactions; +import bisq.core.offer.placeoffer.bisq_v1.tasks.CloneAddressEntryForSharedMakerFee; import bisq.core.offer.placeoffer.bisq_v1.tasks.CreateMakerFeeTx; import bisq.core.offer.placeoffer.bisq_v1.tasks.ValidateOffer; import bisq.core.trade.bisq_v1.TransactionResultHandler; @@ -76,12 +77,20 @@ public void placeOffer() { errorMessageHandler.handleErrorMessage(errorMessage); } ); - taskRunner.addTasks( - ValidateOffer.class, - CheckNumberOfUnconfirmedTransactions.class, - CreateMakerFeeTx.class, - AddToOfferBook.class - ); + + if (model.isSharedMakerFee()) { + taskRunner.addTasks( + ValidateOffer.class, + CloneAddressEntryForSharedMakerFee.class + ); + } else { + taskRunner.addTasks( + ValidateOffer.class, + CheckNumberOfUnconfirmedTransactions.class, + CreateMakerFeeTx.class, + AddToOfferBook.class + ); + } taskRunner.run(); } diff --git a/core/src/main/java/bisq/core/offer/placeoffer/bisq_v1/tasks/CloneAddressEntryForSharedMakerFee.java b/core/src/main/java/bisq/core/offer/placeoffer/bisq_v1/tasks/CloneAddressEntryForSharedMakerFee.java new file mode 100644 index 00000000000..037f8c96c9a --- /dev/null +++ b/core/src/main/java/bisq/core/offer/placeoffer/bisq_v1/tasks/CloneAddressEntryForSharedMakerFee.java @@ -0,0 +1,81 @@ +/* + * 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 . + */ + +package bisq.core.offer.placeoffer.bisq_v1.tasks; + +import bisq.core.btc.model.AddressEntry; +import bisq.core.btc.wallet.BtcWalletService; +import bisq.core.btc.wallet.WalletService; +import bisq.core.offer.Offer; +import bisq.core.offer.placeoffer.bisq_v1.PlaceOfferModel; + +import bisq.common.taskrunner.Task; +import bisq.common.taskrunner.TaskRunner; + +import org.bitcoinj.core.Address; +import org.bitcoinj.core.Transaction; +import org.bitcoinj.core.TransactionOutput; + +import java.util.List; +import java.util.Optional; + +// +public class CloneAddressEntryForSharedMakerFee extends Task { + @SuppressWarnings({"unused"}) + public CloneAddressEntryForSharedMakerFee(TaskRunner taskHandler, PlaceOfferModel model) { + super(taskHandler, model); + } + + @Override + protected void run() { + runInterceptHook(); + + Offer offer = model.getOffer(); + String makerFeeTxId = offer.getOfferFeePaymentTxId(); + BtcWalletService walletService = model.getWalletService(); + for (AddressEntry reservedForTradeEntry : walletService.getAddressEntries(AddressEntry.Context.RESERVED_FOR_TRADE)) { + if (findTxId(reservedForTradeEntry.getAddress()) + .map(txId -> txId.equals(makerFeeTxId)) + .orElse(false)) { + walletService.getOrCloneAddressEntryWithOfferId(reservedForTradeEntry, offer.getId()); + complete(); + return; + } + } + + failed(); + } + + // We look up the most recent transaction with unspent outputs associated with the given address and return + // the txId if found. + private Optional findTxId(Address address) { + BtcWalletService walletService = model.getWalletService(); + List transactions = walletService.getAllRecentTransactions(false); + for (Transaction transaction : transactions) { + for (TransactionOutput output : transaction.getOutputs()) { + if (walletService.isTransactionOutputMine(output) && WalletService.isOutputScriptConvertibleToAddress(output)) { + String addressString = WalletService.getAddressStringFromOutput(output); + // make sure the output is still unspent + if (addressString != null && addressString.equals(address.toString()) && output.getSpentBy() == null) { + return Optional.of(transaction.getTxId().toString()); + } + } + } + } + return Optional.empty(); + } +} diff --git a/core/src/main/resources/i18n/displayStrings.properties b/core/src/main/resources/i18n/displayStrings.properties index ccd4b2800f7..0d296bce71e 100644 --- a/core/src/main/resources/i18n/displayStrings.properties +++ b/core/src/main/resources/i18n/displayStrings.properties @@ -365,6 +365,26 @@ offerbook.timeSinceSigning.tooltip.checkmark.wait=wait a minimum of {0} days offerbook.timeSinceSigning.tooltip.learnMore=Learn more offerbook.xmrAutoConf=Is auto-confirm enabled +offerbook.cloneOffer=Clone offer (with shared maker fee) +offerbook.clonedOffer.tooltip=This is a cloned offer with shared maker fee transaction ID.\n\Maker fee transaction ID: {0} +offerbook.nonClonedOffer.tooltip=Regular offer without shared maker fee transaction ID.\n\Maker fee transaction ID: {0} +offerbook.cannotActivate.warning=This cloned offer with shared maker fee 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.info=By cloning an offer one creates a copy of the given offer with a new offer ID but using the same \ + maker fee transaction ID.\n\n\ + This means there is no extra maker fee needed to get paid and the funds reserved for that offer can \ + be re-used by the cloned offers. This reduces the liquidity requirements for market makers and allows them to post the \ + same offer in different markets or with different payment methods.\n\n\ + As a consequence if one of the offers sharing the same maker fee transaction is taken all the other offers \ + will get closed as well because the transaction output of that maker fee transaction is spent and would render the \ + other offers invalid. \n\n\ + This feature requires to use the same trade amount and security deposit and is only permitted for offers with different \ + payment methods or currencies.\n\n\ + For more information about cloning an offer see: [HYPERLINK:https://bisq.wiki/Cloning_an_offer] + offerbook.timeSinceSigning.help=When you successfully complete a trade with a peer who has a signed payment account, your payment account is signed.\n\ {0} days later, the initial limit of {1} is lifted and your account can sign other peers'' payment accounts. offerbook.timeSinceSigning.notSigned=Not signed yet @@ -607,6 +627,7 @@ takeOffer.tac=With taking this offer I agree to the trade conditions as defined #################################################################### openOffer.header.triggerPrice=Trigger price +openOffer.header.makerFeeTxId=Maker fee openOffer.triggerPrice=Trigger price {0} openOffer.triggered=The offer has been deactivated because the market price reached your trigger price.\n\ Please edit the offer to define a new trigger price @@ -619,7 +640,21 @@ editOffer.publishOffer=Publishing your offer. editOffer.failed=Editing of offer failed:\n{0} editOffer.success=Your offer has been successfully edited. editOffer.invalidDeposit=The buyer's security deposit is not within the constraints defined by the Bisq DAO and can no longer be edited. - +editOffer.openTabWarning=You have already the \"Edit Offer\" tab open. +editOffer.cannotActivateOffer=You have edited an offer which uses a shared maker fee with another offer and your edit \ + made the payment method and currency now the same as that of another active cloned offer. Your edited offer will be \ + deactivated because it is not permitted to publish 2 offers sharing the same maker fee 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.cannotActivateOffer=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. #################################################################### # BSQ Swap offer @@ -654,7 +689,7 @@ portfolio.tab.bsqSwap=Unconfirmed BSQ swaps portfolio.tab.failed=Failed portfolio.tab.editOpenOffer=Edit offer portfolio.tab.duplicateOffer=Duplicate offer -portfolio.context.offerLikeThis=Create new offer like this... +portfolio.tab.cloneOpenOffer=Clone offer portfolio.context.notYourOffer=You can only duplicate offers where you were the maker. portfolio.closedTrades.deviation.help=Percentage price deviation from market diff --git a/desktop/src/main/java/bisq/desktop/bisq.css b/desktop/src/main/java/bisq/desktop/bisq.css index 02f81d5c992..a082485fbcc 100644 --- a/desktop/src/main/java/bisq/desktop/bisq.css +++ b/desktop/src/main/java/bisq/desktop/bisq.css @@ -832,6 +832,10 @@ tree-table-view:focused { -fx-text-fill: -bs-rd-error-red; } +.icon { + -fx-fill: -bs-text-color; +} + .opaque-icon { -fx-fill: -bs-color-gray-bbb; -fx-opacity: 1; diff --git a/desktop/src/main/java/bisq/desktop/main/offer/bisq_v1/MutableOfferDataModel.java b/desktop/src/main/java/bisq/desktop/main/offer/bisq_v1/MutableOfferDataModel.java index 68ff7444745..27181ef0c78 100644 --- a/desktop/src/main/java/bisq/desktop/main/offer/bisq_v1/MutableOfferDataModel.java +++ b/desktop/src/main/java/bisq/desktop/main/offer/bisq_v1/MutableOfferDataModel.java @@ -334,6 +334,7 @@ void onPlaceOffer(Offer offer, TransactionResultHandler resultHandler) { openOfferManager.placeOffer(offer, buyerSecurityDeposit.get(), useSavingsWallet, + false, triggerPrice, resultHandler, log::error); diff --git a/desktop/src/main/java/bisq/desktop/main/offer/bisq_v1/MutableOfferView.java b/desktop/src/main/java/bisq/desktop/main/offer/bisq_v1/MutableOfferView.java index bd3d9a19f3c..06b7b6da4d9 100644 --- a/desktop/src/main/java/bisq/desktop/main/offer/bisq_v1/MutableOfferView.java +++ b/desktop/src/main/java/bisq/desktop/main/offer/bisq_v1/MutableOfferView.java @@ -258,8 +258,7 @@ protected void doActivate() { currencyComboBox.getSelectionModel().select(model.getTradeCurrency()); paymentAccountsComboBox.setItems(getPaymentAccounts()); - paymentAccountsComboBox.getSelectionModel().select(model.getPaymentAccount()); - + UserThread.execute(() -> paymentAccountsComboBox.getSelectionModel().select(model.getPaymentAccount())); onPaymentAccountsComboBoxSelected(); balanceTextField.setTargetAmount(model.getDataModel().totalToPayAsCoinProperty().get()); diff --git a/desktop/src/main/java/bisq/desktop/main/portfolio/PortfolioView.java b/desktop/src/main/java/bisq/desktop/main/portfolio/PortfolioView.java index 46cc33712ce..0cae99934bd 100644 --- a/desktop/src/main/java/bisq/desktop/main/portfolio/PortfolioView.java +++ b/desktop/src/main/java/bisq/desktop/main/portfolio/PortfolioView.java @@ -23,7 +23,9 @@ import bisq.desktop.common.view.FxmlView; import bisq.desktop.common.view.View; import bisq.desktop.main.MainView; +import bisq.desktop.main.overlays.popups.Popup; import bisq.desktop.main.portfolio.bsqswaps.UnconfirmedBsqSwapsView; +import bisq.desktop.main.portfolio.cloneoffer.CloneOfferView; import bisq.desktop.main.portfolio.closedtrades.ClosedTradesView; import bisq.desktop.main.portfolio.duplicateoffer.DuplicateOfferView; import bisq.desktop.main.portfolio.editoffer.EditOfferView; @@ -58,7 +60,7 @@ public class PortfolioView extends ActivatableView { @FXML Tab openOffersTab, pendingTradesTab, closedTradesTab, bsqSwapTradesTab; - private Tab editOpenOfferTab, duplicateOfferTab; + private Tab editOpenOfferTab, duplicateOfferTab, cloneOpenOfferTab; private final Tab failedTradesTab = new Tab(Res.get("portfolio.tab.failed").toUpperCase()); private Tab currentTab; private Navigation.Listener navigationListener; @@ -71,7 +73,8 @@ public class PortfolioView extends ActivatableView { private final OpenOfferManager openOfferManager; private EditOfferView editOfferView; private DuplicateOfferView duplicateOfferView; - private boolean editOpenOfferViewOpen; + private CloneOfferView cloneOfferView; + private boolean editOpenOfferViewOpen, cloneOpenOfferViewOpen; private OpenOffer openOffer; private OpenOffersView openOffersView; private int initialTabCount = 0; @@ -116,13 +119,16 @@ else if (newValue == editOpenOfferTab) navigation.navigateTo(MainView.class, PortfolioView.class, EditOfferView.class); else if (newValue == duplicateOfferTab) { navigation.navigateTo(MainView.class, PortfolioView.class, DuplicateOfferView.class); + } else if (newValue == cloneOpenOfferTab) { + navigation.navigateTo(MainView.class, PortfolioView.class, CloneOfferView.class); } if (oldValue != null && oldValue == editOpenOfferTab) editOfferView.onTabSelected(false); if (oldValue != null && oldValue == duplicateOfferTab) duplicateOfferView.onTabSelected(false); - + if (oldValue != null && oldValue == cloneOpenOfferTab) + cloneOfferView.onTabSelected(false); }; tabListChangeListener = change -> { @@ -132,6 +138,8 @@ else if (newValue == duplicateOfferTab) { onEditOpenOfferRemoved(); if (removedTabs.size() == 1 && removedTabs.get(0).equals(duplicateOfferTab)) onDuplicateOfferRemoved(); + if (removedTabs.size() == 1 && removedTabs.get(0).equals(cloneOpenOfferTab)) + onCloneOpenOfferRemoved(); }; } @@ -154,6 +162,16 @@ private void onDuplicateOfferRemoved() { navigation.navigateTo(MainView.class, this.getClass(), OpenOffersView.class); } + private void onCloneOpenOfferRemoved() { + cloneOpenOfferViewOpen = false; + if (cloneOfferView != null) { + cloneOfferView.onClose(); + cloneOfferView = null; + } + + navigation.navigateTo(MainView.class, this.getClass(), OpenOffersView.class); + } + @Override protected void activate() { failedTradesManager.getObservableList().addListener((ListChangeListener) c -> { @@ -183,6 +201,9 @@ else if (root.getSelectionModel().getSelectedItem() == editOpenOfferTab) { } else if (root.getSelectionModel().getSelectedItem() == duplicateOfferTab) { navigation.navigateTo(MainView.class, PortfolioView.class, DuplicateOfferView.class); if (duplicateOfferView != null) duplicateOfferView.onTabSelected(true); + } else if (root.getSelectionModel().getSelectedItem() == cloneOpenOfferTab) { + navigation.navigateTo(MainView.class, PortfolioView.class, CloneOfferView.class); + if (cloneOfferView != null) cloneOfferView.onTabSelected(true); } } @@ -195,10 +216,18 @@ protected void deactivate() { } private void loadView(Class viewClass, @Nullable Object data) { - // we want to get activate/deactivate called, so we remove the old view on tab change - // TODO Don't understand the check for currentTab != editOpenOfferTab - if (currentTab != null && currentTab != editOpenOfferTab) - currentTab.setContent(null); + // We want to get activate/deactivate triggered, so we remove the old view on tab change + // for the tab views which are not-closable by calling `currentTab.setContent(null)`. + // The closable tab views like editOpenOfferTab, duplicateOfferTab and cloneOpenOfferTab + // do not need to be triggered. + if (currentTab != null) { + boolean isClosableTabView = currentTab == editOpenOfferTab || + currentTab == duplicateOfferTab || + currentTab == cloneOpenOfferTab; + if (!isClosableTabView) { + currentTab.setContent(null); + } + } View view = viewLoader.load(viewClass); @@ -254,6 +283,28 @@ private void loadView(Class viewClass, @Nullable Object data) { view = viewLoader.load(OpenOffersView.class); selectOpenOffersView((OpenOffersView) view); } + } else if (view instanceof CloneOfferView) { + if (data instanceof OpenOffer) { + openOffer = (OpenOffer) data; + } + if (openOffer != null) { + if (cloneOfferView == null) { + cloneOfferView = (CloneOfferView) view; + cloneOfferView.applyOpenOffer(openOffer); + cloneOpenOfferTab = new Tab(Res.get("portfolio.tab.cloneOpenOffer").toUpperCase()); + cloneOfferView.setCloseHandler(() -> { + root.getTabs().remove(cloneOpenOfferTab); + }); + root.getTabs().add(cloneOpenOfferTab); + } + if (currentTab != cloneOpenOfferTab) + cloneOfferView.onTabSelected(true); + + currentTab = cloneOpenOfferTab; + } else { + view = viewLoader.load(OpenOffersView.class); + selectOpenOffersView((OpenOffersView) view); + } } currentTab.setContent(view.getRoot()); @@ -264,20 +315,35 @@ private void selectOpenOffersView(OpenOffersView view) { openOffersView = view; currentTab = openOffersTab; - OpenOfferActionHandler openOfferActionHandler = openOffer -> { + EditOpenOfferHandler editOpenOfferHandler = openOffer -> { if (!editOpenOfferViewOpen) { editOpenOfferViewOpen = true; PortfolioView.this.openOffer = openOffer; navigation.navigateTo(MainView.class, PortfolioView.this.getClass(), EditOfferView.class); } else { - log.error("You have already a \"Edit Offer\" tab open."); + new Popup().warning(Res.get("editOffer.openTabWarning")).show(); } }; - openOffersView.setOpenOfferActionHandler(openOfferActionHandler); + openOffersView.setEditOpenOfferHandler(editOpenOfferHandler); + + CloneOpenOfferHandler cloneOpenOfferHandler = openOffer -> { + if (!cloneOpenOfferViewOpen) { + cloneOpenOfferViewOpen = true; + PortfolioView.this.openOffer = openOffer; + navigation.navigateTo(MainView.class, PortfolioView.this.getClass(), CloneOfferView.class); + } else { + new Popup().warning(Res.get("cloneOffer.openTabWarning")).show(); + } + }; + openOffersView.setCloneOpenOfferHandler(cloneOpenOfferHandler); } - public interface OpenOfferActionHandler { + public interface EditOpenOfferHandler { void onEditOpenOffer(OpenOffer openOffer); } + + public interface CloneOpenOfferHandler { + void onCloneOpenOffer(OpenOffer openOffer); + } } diff --git a/desktop/src/main/java/bisq/desktop/main/portfolio/cloneoffer/CloneOfferDataModel.java b/desktop/src/main/java/bisq/desktop/main/portfolio/cloneoffer/CloneOfferDataModel.java new file mode 100644 index 00000000000..98ce733322b --- /dev/null +++ b/desktop/src/main/java/bisq/desktop/main/portfolio/cloneoffer/CloneOfferDataModel.java @@ -0,0 +1,248 @@ +/* + * 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 . + */ + +package bisq.desktop.main.portfolio.cloneoffer; + + +import bisq.desktop.Navigation; +import bisq.desktop.main.offer.bisq_v1.MutableOfferDataModel; + +import bisq.core.account.witness.AccountAgeWitnessService; +import bisq.core.btc.wallet.BsqWalletService; +import bisq.core.btc.wallet.BtcWalletService; +import bisq.core.locale.CurrencyUtil; +import bisq.core.locale.TradeCurrency; +import bisq.core.offer.Offer; +import bisq.core.offer.OfferDirection; +import bisq.core.offer.OfferUtil; +import bisq.core.offer.OpenOffer; +import bisq.core.offer.OpenOfferManager; +import bisq.core.offer.bisq_v1.CreateOfferService; +import bisq.core.offer.bisq_v1.OfferPayload; +import bisq.core.payment.PaymentAccount; +import bisq.core.proto.persistable.CorePersistenceProtoResolver; +import bisq.core.provider.fee.FeeService; +import bisq.core.provider.price.PriceFeedService; +import bisq.core.trade.statistics.TradeStatisticsManager; +import bisq.core.user.Preferences; +import bisq.core.user.User; +import bisq.core.util.FormattingUtils; +import bisq.core.util.coin.CoinFormatter; + +import bisq.network.p2p.P2PService; + +import bisq.common.handlers.ErrorMessageHandler; +import bisq.common.handlers.ResultHandler; + +import com.google.inject.Inject; + +import javax.inject.Named; + +import java.util.Date; +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, + BtcWalletService btcWalletService, + BsqWalletService bsqWalletService, + Preferences preferences, + User user, + P2PService p2PService, + PriceFeedService priceFeedService, + AccountAgeWitnessService accountAgeWitnessService, + FeeService feeService, + @Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter btcFormatter, + CorePersistenceProtoResolver corePersistenceProtoResolver, + TradeStatisticsManager tradeStatisticsManager, + Navigation navigation) { + + super(createOfferService, + openOfferManager, + offerUtil, + btcWalletService, + bsqWalletService, + preferences, + user, + p2PService, + priceFeedService, + accountAgeWitnessService, + feeService, + btcFormatter, + 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); + buyerSecurityDeposit.set(0); + paymentAccounts.clear(); + paymentAccount = null; + marketPriceMargin = 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 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; + } + + @Override + public boolean initWithData(OfferDirection direction, TradeCurrency tradeCurrency) { + try { + return super.initWithData(direction, tradeCurrency); + } 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 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()) { + setMarketPriceMargin(offer.getMarketPriceMargin()); + } + } + + public void onCloneOffer(ResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) { + Offer clonedOffer = createClonedOffer(); + openOfferManager.placeOffer(clonedOffer, + sourceOpenOffer.getOffer().getBuyerSecurityDeposit().getValue(), + false, + true, + triggerPrice, + transaction -> resultHandler.handleResult(), + errorMessageHandler); + } + + private Offer createClonedOffer() { + Offer sourceOffer = sourceOpenOffer.getOffer(); + OfferPayload sourceOfferPayload = sourceOffer.getOfferPayload().orElseThrow(); + // We create a new offer based on our source offer and the edited fields in the UI + Offer editedOffer = createAndGetOffer(); + OfferPayload editedOfferPayload = editedOffer.getOfferPayload().orElseThrow(); + // We clone the edited offer but use the maker tx ID from the source offer as well as a new offerId and + // a fresh date. + String sharedMakerTxId = sourceOfferPayload.getOfferFeePaymentTxId(); + String newOfferId = OfferUtil.getRandomOfferId(); + long date = new Date().getTime(); + OfferPayload clonedOfferPayload = new OfferPayload(newOfferId, + date, + sourceOfferPayload.getOwnerNodeAddress(), + sourceOfferPayload.getPubKeyRing(), + sourceOfferPayload.getDirection(), + editedOfferPayload.getPrice(), + editedOfferPayload.getMarketPriceMargin(), + editedOfferPayload.isUseMarketBasedPrice(), + sourceOfferPayload.getAmount(), + sourceOfferPayload.getMinAmount(), + editedOfferPayload.getBaseCurrencyCode(), + editedOfferPayload.getCounterCurrencyCode(), + sourceOfferPayload.getArbitratorNodeAddresses(), + sourceOfferPayload.getMediatorNodeAddresses(), + editedOfferPayload.getPaymentMethodId(), + editedOfferPayload.getMakerPaymentAccountId(), + sharedMakerTxId, + editedOfferPayload.getCountryCode(), + editedOfferPayload.getAcceptedCountryCodes(), + editedOfferPayload.getBankId(), + editedOfferPayload.getAcceptedBankIds(), + editedOfferPayload.getVersionNr(), + sourceOfferPayload.getBlockHeightAtOfferCreation(), + sourceOfferPayload.getTxFee(), + sourceOfferPayload.getMakerFee(), + sourceOfferPayload.isCurrencyForMakerFeeBtc(), + sourceOfferPayload.getBuyerSecurityDeposit(), + sourceOfferPayload.getSellerSecurityDeposit(), + editedOfferPayload.getMaxTradeLimit(), + editedOfferPayload.getMaxTradePeriod(), + sourceOfferPayload.isUseAutoClose(), + sourceOfferPayload.isUseReOpenAfterAutoClose(), + sourceOfferPayload.getLowerClosePrice(), + sourceOfferPayload.getUpperClosePrice(), + sourceOfferPayload.isPrivateOffer(), + sourceOfferPayload.getHashOfChallenge(), + editedOfferPayload.getExtraDataMap(), + sourceOfferPayload.getProtocolVersion()); + Offer clonedOffer = new Offer(clonedOfferPayload); + clonedOffer.setPriceFeedService(priceFeedService); + clonedOffer.setState(Offer.State.OFFER_FEE_PAID); + return clonedOffer; + } + + public boolean cannotActivateOffer() { + Offer clonedOffer = createClonedOffer(); + return openOfferManager.cannotActivateOffer(clonedOffer); + } +} diff --git a/desktop/src/main/java/bisq/desktop/main/portfolio/cloneoffer/CloneOfferView.fxml b/desktop/src/main/java/bisq/desktop/main/portfolio/cloneoffer/CloneOfferView.fxml new file mode 100644 index 00000000000..72089b91891 --- /dev/null +++ b/desktop/src/main/java/bisq/desktop/main/portfolio/cloneoffer/CloneOfferView.fxml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/desktop/src/main/java/bisq/desktop/main/portfolio/cloneoffer/CloneOfferView.java b/desktop/src/main/java/bisq/desktop/main/portfolio/cloneoffer/CloneOfferView.java new file mode 100644 index 00000000000..702d8420420 --- /dev/null +++ b/desktop/src/main/java/bisq/desktop/main/portfolio/cloneoffer/CloneOfferView.java @@ -0,0 +1,268 @@ +/* + * 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 . + */ + +package bisq.desktop.main.portfolio.cloneoffer; + +import bisq.desktop.Navigation; +import bisq.desktop.common.view.FxmlView; +import bisq.desktop.components.AutoTooltipButton; +import bisq.desktop.components.BusyAnimation; +import bisq.desktop.main.offer.bisq_v1.MutableOfferView; +import bisq.desktop.main.overlays.popups.Popup; +import bisq.desktop.main.overlays.windows.OfferDetailsWindow; +import bisq.desktop.util.GUIUtil; + +import bisq.core.locale.CurrencyUtil; +import bisq.core.locale.Res; +import bisq.core.offer.OpenOffer; +import bisq.core.payment.PaymentAccount; +import bisq.core.user.DontShowAgainLookup; +import bisq.core.user.Preferences; +import bisq.core.util.FormattingUtils; +import bisq.core.util.coin.BsqFormatter; +import bisq.core.util.coin.CoinFormatter; + +import bisq.common.util.Tuple4; + +import com.google.inject.Inject; + +import javax.inject.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 java.util.List; +import java.util.stream.Collectors; + +import static bisq.desktop.util.FormBuilder.addButtonBusyAnimationLabelAfterGroup; + +@FxmlView +public class CloneOfferView extends MutableOfferView { + + 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, + BsqFormatter bsqFormatter) { + super(model, navigation, preferences, offerDetailsWindow, btcFormatter, bsqFormatter); + } + + @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(); + + // 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(), + 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 filterPaymentAccounts(ObservableList paymentAccounts) { + // We do not allow cloning or BSQ as there is no maker fee and requirement for reserved funds. + // Do not create a new ObservableList as that would cause bugs with the selected account. + List toRemove = paymentAccounts.stream() + .filter(paymentAccount -> GUIUtil.BSQ.equals(paymentAccount.getSingleTradeCurrency())) + .collect(Collectors.toList()); + toRemove.forEach(paymentAccounts::remove); + return paymentAccounts; + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Build UI elements + /////////////////////////////////////////////////////////////////////////////////////////// + + private void addCloneGroup() { + Tuple4 tuple4 = addButtonBusyAnimationLabelAfterGroup(gridPane, 4, 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.cannotActivateOffer()) { + new Popup().warning(Res.get("cloneOffer.cannotActivateOffer")) + .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(() -> { + String key = "cloneOfferSuccess"; + if (DontShowAgainLookup.showAgain(key)) { + new Popup() + .feedback(Res.get("cloneOffer.success")) + .dontShowAgainId(key) + .show(); + } + spinnerInfoLabel.setText(""); + busyAnimation.stop(); + close(); + }, + errorMessage -> { + 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"); + } +} diff --git a/desktop/src/main/java/bisq/desktop/main/portfolio/cloneoffer/CloneOfferViewModel.java b/desktop/src/main/java/bisq/desktop/main/portfolio/cloneoffer/CloneOfferViewModel.java new file mode 100644 index 00000000000..855c2ae86b8 --- /dev/null +++ b/desktop/src/main/java/bisq/desktop/main/portfolio/cloneoffer/CloneOfferViewModel.java @@ -0,0 +1,128 @@ +/* + * 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 . + */ + +package bisq.desktop.main.portfolio.cloneoffer; + +import bisq.desktop.Navigation; +import bisq.desktop.main.offer.OfferViewUtil; +import bisq.desktop.main.offer.bisq_v1.MutableOfferViewModel; +import bisq.desktop.util.validation.BsqValidator; +import bisq.desktop.util.validation.BtcValidator; +import bisq.desktop.util.validation.FiatVolumeValidator; +import bisq.desktop.util.validation.SecurityDepositValidator; + +import bisq.core.account.witness.AccountAgeWitnessService; +import bisq.core.offer.OfferUtil; +import bisq.core.offer.OpenOffer; +import bisq.core.provider.price.PriceFeedService; +import bisq.core.user.Preferences; +import bisq.core.util.FormattingUtils; +import bisq.core.util.PriceUtil; +import bisq.core.util.coin.BsqFormatter; +import bisq.core.util.coin.CoinFormatter; +import bisq.core.util.validation.AltcoinValidator; +import bisq.core.util.validation.FiatPriceValidator; + +import bisq.common.handlers.ErrorMessageHandler; +import bisq.common.handlers.ResultHandler; + +import com.google.inject.Inject; + +import javax.inject.Named; + +class CloneOfferViewModel extends MutableOfferViewModel { + + @Inject + public CloneOfferViewModel(CloneOfferDataModel dataModel, + FiatVolumeValidator fiatVolumeValidator, + FiatPriceValidator fiatPriceValidator, + AltcoinValidator altcoinValidator, + BtcValidator btcValidator, + BsqValidator bsqValidator, + SecurityDepositValidator securityDepositValidator, + PriceFeedService priceFeedService, + AccountAgeWitnessService accountAgeWitnessService, + Navigation navigation, + Preferences preferences, + @Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter btcFormatter, + BsqFormatter bsqFormatter, + OfferUtil offerUtil) { + super(dataModel, + fiatVolumeValidator, + fiatPriceValidator, + altcoinValidator, + btcValidator, + bsqValidator, + securityDepositValidator, + priceFeedService, + accountAgeWitnessService, + navigation, + preferences, + btcFormatter, + bsqFormatter, + 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.getMarketPriceMargin())); + } + + public void onInvalidatePrice() { + price.set(FormattingUtils.formatPrice(null)); + price.set(FormattingUtils.formatPrice(dataModel.getPrice().get())); + } + + public boolean isSecurityDepositValid() { + return securityDepositValidator.validate(buyerSecurityDeposit.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()); + } +} diff --git a/desktop/src/main/java/bisq/desktop/main/portfolio/closedtrades/ClosedTradesView.java b/desktop/src/main/java/bisq/desktop/main/portfolio/closedtrades/ClosedTradesView.java index c9291ec3a2c..d93ea742e39 100644 --- a/desktop/src/main/java/bisq/desktop/main/portfolio/closedtrades/ClosedTradesView.java +++ b/desktop/src/main/java/bisq/desktop/main/portfolio/closedtrades/ClosedTradesView.java @@ -264,7 +264,7 @@ public void initialize() { tableView -> { TableRow row = new TableRow<>(); ContextMenu rowMenu = new ContextMenu(); - MenuItem duplicateItem = new MenuItem(Res.get("portfolio.context.offerLikeThis")); + MenuItem duplicateItem = new MenuItem(Res.get("portfolio.tab.duplicateOffer")); duplicateItem.setOnAction((ActionEvent event) -> onDuplicateOffer(row.getItem().getTradable().getOffer())); rowMenu.getItems().add(duplicateItem); row.contextMenuProperty().bind( diff --git a/desktop/src/main/java/bisq/desktop/main/portfolio/editoffer/EditOfferDataModel.java b/desktop/src/main/java/bisq/desktop/main/portfolio/editoffer/EditOfferDataModel.java index 96223fe0659..6d529133061 100644 --- a/desktop/src/main/java/bisq/desktop/main/portfolio/editoffer/EditOfferDataModel.java +++ b/desktop/src/main/java/bisq/desktop/main/portfolio/editoffer/EditOfferDataModel.java @@ -63,8 +63,9 @@ class EditOfferDataModel extends MutableOfferDataModel { private final CorePersistenceProtoResolver corePersistenceProtoResolver; - private OpenOffer openOffer; + private OpenOffer originalOpenOffer; private OpenOffer.State initialState; + private Offer editedOffer; @Inject EditOfferDataModel(CreateOfferService createOfferService, @@ -117,7 +118,7 @@ public void reset() { } public void applyOpenOffer(OpenOffer openOffer) { - this.openOffer = openOffer; + this.originalOpenOffer = openOffer; Offer offer = openOffer.getOffer(); direction = offer.getDirection(); @@ -175,21 +176,21 @@ protected PaymentAccount getPreselectedPaymentAccount() { } public void populateData() { - Offer offer = openOffer.getOffer(); + Offer offer = originalOpenOffer.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(openOffer.getTriggerPrice()); + setTriggerPrice(originalOpenOffer.getTriggerPrice()); if (offer.isUseMarketBasedPrice()) { setMarketPriceMargin(offer.getMarketPriceMargin()); } } public void onStartEditOffer(ErrorMessageHandler errorMessageHandler) { - openOfferManager.editOpenOfferStart(openOffer, () -> { + openOfferManager.editOpenOfferStart(originalOpenOffer, () -> { }, errorMessageHandler); } @@ -201,19 +202,33 @@ public void onPublishOffer(ResultHandler resultHandler, ErrorMessageHandler erro OfferPayload offerPayload = offer.getOfferPayload().orElseThrow(); var mutableOfferPayloadFields = new MutableOfferPayloadFields(offerPayload); - OfferPayload editedPayload = offerUtil.getMergedOfferPayload(openOffer, mutableOfferPayloadFields); - Offer editedOffer = new Offer(editedPayload); + OfferPayload editedPayload = offerUtil.getMergedOfferPayload(originalOpenOffer, mutableOfferPayloadFields); + editedOffer = new Offer(editedPayload); editedOffer.setPriceFeedService(priceFeedService); editedOffer.setState(Offer.State.AVAILABLE); openOfferManager.editOpenOfferPublish(editedOffer, triggerPrice, initialState, () -> { - openOffer = null; + if (cannotActivateOffer()) { + OpenOffer editedOpenOffer = openOfferManager.getOpenOfferById(editedOffer.getId()).orElseThrow(); + editedOpenOffer.setState(OpenOffer.State.DEACTIVATED); + } resultHandler.handleResult(); + originalOpenOffer = null; + editedOffer = null; }, errorMessageHandler); } public void onCancelEditOffer(ErrorMessageHandler errorMessageHandler) { - if (openOffer != null) - openOfferManager.editOpenOfferCancel(openOffer, initialState, () -> { + if (originalOpenOffer != null) + openOfferManager.editOpenOfferCancel(originalOpenOffer, initialState, () -> { }, errorMessageHandler); } + + public boolean cannotActivateOffer() { + // The cannotActivateOffer check considers only activated offers but at editing offer we have set the + // offer DEACTIVATED. We temporarily flip the state so that our cannotActivateOffer works as expected. + originalOpenOffer.setState(OpenOffer.State.AVAILABLE); + boolean result = openOfferManager.cannotActivateOffer(editedOffer); + originalOpenOffer.setState(OpenOffer.State.DEACTIVATED); + return result; + } } diff --git a/desktop/src/main/java/bisq/desktop/main/portfolio/editoffer/EditOfferView.java b/desktop/src/main/java/bisq/desktop/main/portfolio/editoffer/EditOfferView.java index 7f9db5a0c8c..61f1e7cf05c 100644 --- a/desktop/src/main/java/bisq/desktop/main/portfolio/editoffer/EditOfferView.java +++ b/desktop/src/main/java/bisq/desktop/main/portfolio/editoffer/EditOfferView.java @@ -59,8 +59,9 @@ public class EditOfferView extends MutableOfferView { private BusyAnimation busyAnimation; - private Button confirmButton; + private Button confirmEditButton; private Button cancelButton; + private Label spinnerInfoLabel; /////////////////////////////////////////////////////////////////////////////////////////// // Constructor, lifecycle @@ -170,11 +171,11 @@ public void applyOpenOffer(OpenOffer openOffer) { /////////////////////////////////////////////////////////////////////////////////////////// private void addBindings() { - confirmButton.disableProperty().bind(model.isNextButtonDisabled); + confirmEditButton.disableProperty().bind(model.isNextButtonDisabled); } private void removeBindings() { - confirmButton.disableProperty().unbind(); + confirmEditButton.disableProperty().unbind(); } @Override @@ -195,28 +196,36 @@ private void addConfirmEditGroup() { editOfferConfirmationBox.setAlignment(Pos.CENTER_LEFT); GridPane.setHalignment(editOfferConfirmationBox, HPos.LEFT); - confirmButton = editOfferTuple.first; - confirmButton.setMinHeight(40); - confirmButton.setPadding(new Insets(0, 20, 0, 20)); - confirmButton.setGraphicTextGap(10); + confirmEditButton = editOfferTuple.first; + confirmEditButton.setMinHeight(40); + confirmEditButton.setPadding(new Insets(0, 20, 0, 20)); + confirmEditButton.setGraphicTextGap(10); busyAnimation = editOfferTuple.second; - Label spinnerInfoLabel = editOfferTuple.third; + spinnerInfoLabel = editOfferTuple.third; cancelButton = new AutoTooltipButton(Res.get("shared.cancel")); cancelButton.setDefaultButton(false); cancelButton.setOnAction(event -> close()); editOfferConfirmationBox.getChildren().add(cancelButton); - confirmButton.setOnAction(e -> { - confirmButton.requestFocus(); // fix issue #5460 (when enter key used, focus is wrong) - if (model.isPriceInRange()) { - model.isNextButtonDisabled.setValue(true); - cancelButton.setDisable(true); - busyAnimation.play(); - spinnerInfoLabel.setText(Res.get("editOffer.publishOffer")); - //edit offer - model.onPublishOffer(() -> { + confirmEditButton.setOnAction(e -> { + confirmEditButton.requestFocus(); // fix issue #5460 (when enter key used, focus is wrong) + onConfirmEdit(); + }); + } + + private void onConfirmEdit() { + if (model.isPriceInRange()) { + model.isNextButtonDisabled.setValue(true); + cancelButton.setDisable(true); + busyAnimation.play(); + spinnerInfoLabel.setText(Res.get("editOffer.publishOffer")); + + model.onPublishOffer(() -> { + if (model.dataModel.cannotActivateOffer()) { + new Popup().warning(Res.get("editOffer.cannotActivateOffer")).show(); + } else { String key = "editOfferSuccess"; if (DontShowAgainLookup.showAgain(key)) { new Popup() @@ -224,19 +233,19 @@ private void addConfirmEditGroup() { .dontShowAgainId(key) .show(); } - spinnerInfoLabel.setText(""); - busyAnimation.stop(); - close(); - }, (message) -> { - log.error(message); - spinnerInfoLabel.setText(""); - busyAnimation.stop(); - model.isNextButtonDisabled.setValue(false); - cancelButton.setDisable(false); - new Popup().warning(Res.get("editOffer.failed", message)).show(); - }); - } - }); + } + spinnerInfoLabel.setText(""); + busyAnimation.stop(); + close(); + }, (message) -> { + log.error(message); + spinnerInfoLabel.setText(""); + busyAnimation.stop(); + model.isNextButtonDisabled.setValue(false); + cancelButton.setDisable(false); + new Popup().warning(Res.get("editOffer.failed", message)).show(); + }); + } } /////////////////////////////////////////////////////////////////////////////////////////// @@ -246,7 +255,7 @@ private void addConfirmEditGroup() { private void updateElementsWithDirection() { ImageView iconView = new ImageView(); iconView.setId(model.isShownAsSellOffer() ? "image-sell-white" : "image-buy-white"); - confirmButton.setGraphic(iconView); - confirmButton.setId(model.isShownAsSellOffer() ? "sell-button-big" : "buy-button-big"); + confirmEditButton.setGraphic(iconView); + confirmEditButton.setId(model.isShownAsSellOffer() ? "sell-button-big" : "buy-button-big"); } } diff --git a/desktop/src/main/java/bisq/desktop/main/portfolio/openoffer/OpenOfferListItem.java b/desktop/src/main/java/bisq/desktop/main/portfolio/openoffer/OpenOfferListItem.java index 11cf4266284..b194334b7c3 100644 --- a/desktop/src/main/java/bisq/desktop/main/portfolio/openoffer/OpenOfferListItem.java +++ b/desktop/src/main/java/bisq/desktop/main/portfolio/openoffer/OpenOfferListItem.java @@ -50,7 +50,11 @@ class OpenOfferListItem implements FilterableListItem { private final OpenOfferManager openOfferManager; - OpenOfferListItem(OpenOffer openOffer, PriceUtil priceUtil, CoinFormatter btcFormatter, BsqFormatter bsqFormatter, OpenOfferManager openOfferManager) { + OpenOfferListItem(OpenOffer openOffer, + PriceUtil priceUtil, + CoinFormatter btcFormatter, + BsqFormatter bsqFormatter, + OpenOfferManager openOfferManager) { this.openOffer = openOffer; this.priceUtil = priceUtil; this.btcFormatter = btcFormatter; @@ -134,6 +138,11 @@ public String getTriggerPriceAsString() { } } + String getMakerFeeTxId() { + String makerFeeTxId = getOffer().getOfferFeePaymentTxId(); + return makerFeeTxId != null ? makerFeeTxId : ""; + } + @Override public boolean match(String filterString) { if (filterString.isEmpty()) { diff --git a/desktop/src/main/java/bisq/desktop/main/portfolio/openoffer/OpenOffersView.fxml b/desktop/src/main/java/bisq/desktop/main/portfolio/openoffer/OpenOffersView.fxml index 4a0e6201e85..3bda1667aca 100644 --- a/desktop/src/main/java/bisq/desktop/main/portfolio/openoffer/OpenOffersView.fxml +++ b/desktop/src/main/java/bisq/desktop/main/portfolio/openoffer/OpenOffersView.fxml @@ -41,6 +41,7 @@ + @@ -54,6 +55,7 @@ + diff --git a/desktop/src/main/java/bisq/desktop/main/portfolio/openoffer/OpenOffersView.java b/desktop/src/main/java/bisq/desktop/main/portfolio/openoffer/OpenOffersView.java index 7a0d8d59c17..1b75ed1fdf9 100644 --- a/desktop/src/main/java/bisq/desktop/main/portfolio/openoffer/OpenOffersView.java +++ b/desktop/src/main/java/bisq/desktop/main/portfolio/openoffer/OpenOffersView.java @@ -37,8 +37,9 @@ import bisq.desktop.util.GUIUtil; import bisq.core.locale.Res; -import bisq.core.offer.Offer; import bisq.core.offer.OpenOffer; +import bisq.core.offer.OpenOfferManager; +import bisq.core.provider.price.PriceFeedService; import bisq.core.user.DontShowAgainLookup; import com.googlecode.jcsv.writer.CSVEntryConverter; @@ -80,6 +81,7 @@ import javafx.util.Callback; import java.util.Comparator; +import java.util.stream.Collectors; import org.jetbrains.annotations.NotNull; @@ -89,9 +91,9 @@ @FxmlView public class OpenOffersView extends ActivatableViewAndModel { - private enum ColumnNames { OFFER_ID(Res.get("shared.offerId")), + MAKER_FEE_TX_ID(Res.get("openOffer.header.makerFeeTxId")), DATE(Res.get("shared.dateTime")), MARKET(Res.get("shared.market")), PRICE(Res.get("shared.price")), @@ -119,8 +121,9 @@ public String toString() { TableView tableView; @FXML TableColumn priceColumn, deviationColumn, amountColumn, volumeColumn, - marketColumn, directionColumn, dateColumn, offerIdColumn, deactivateItemColumn, - removeItemColumn, editItemColumn, triggerPriceColumn, triggerIconColumn, paymentMethodColumn, duplicateItemColumn; + marketColumn, directionColumn, dateColumn, offerIdColumn, deactivateItemColumn, makerFeeTxIdColumn, + removeItemColumn, editItemColumn, triggerPriceColumn, triggerIconColumn, paymentMethodColumn, duplicateItemColumn, + cloneItemColumn; @FXML FilterBox filterBox; @FXML @@ -132,28 +135,36 @@ public String toString() { @FXML AutoTooltipSlideToggleButton selectToggleButton; + private final PriceFeedService priceFeedService; private final Navigation navigation; private final OfferDetailsWindow offerDetailsWindow; private final BsqSwapOfferDetailsWindow bsqSwapOfferDetailsWindow; + private final OpenOfferManager openOfferManager; private SortedList sortedList; - private PortfolioView.OpenOfferActionHandler openOfferActionHandler; + private PortfolioView.EditOpenOfferHandler editOpenOfferHandler; + private PortfolioView.CloneOpenOfferHandler cloneOpenOfferHandler; private ChangeListener widthListener; private ListChangeListener sortedListeChangedListener; @Inject public OpenOffersView(OpenOffersViewModel model, + OpenOfferManager openOfferManager, + PriceFeedService priceFeedService, Navigation navigation, OfferDetailsWindow offerDetailsWindow, BsqSwapOfferDetailsWindow bsqSwapOfferDetailsWindow) { super(model); + this.priceFeedService = priceFeedService; this.navigation = navigation; this.offerDetailsWindow = offerDetailsWindow; this.bsqSwapOfferDetailsWindow = bsqSwapOfferDetailsWindow; + this.openOfferManager = openOfferManager; } @Override public void initialize() { widthListener = (observable, oldValue, newValue) -> onWidthChange((double) newValue); + makerFeeTxIdColumn.setGraphic(new AutoTooltipLabel(ColumnNames.MAKER_FEE_TX_ID.toString())); paymentMethodColumn.setGraphic(new AutoTooltipLabel(ColumnNames.PAYMENT_METHOD.toString())); priceColumn.setGraphic(new AutoTooltipLabel(ColumnNames.PRICE.toString())); deviationColumn.setGraphic(new AutoTooltipTableColumn<>(ColumnNames.DEVIATION.toString(), @@ -168,9 +179,11 @@ public void initialize() { deactivateItemColumn.setGraphic(new AutoTooltipLabel(ColumnNames.STATUS.toString())); editItemColumn.setText(""); duplicateItemColumn.setText(""); + cloneItemColumn.setText(""); removeItemColumn.setText(""); setOfferIdColumnCellFactory(); + setMakerFeeTxIdColumnCellFactory(); setDirectionColumnCellFactory(); setMarketColumnCellFactory(); setPriceColumnCellFactory(); @@ -184,12 +197,14 @@ public void initialize() { setTriggerIconColumnCellFactory(); setTriggerPriceColumnCellFactory(); setDuplicateColumnCellFactory(); + setCloneColumnCellFactory(); setRemoveColumnCellFactory(); tableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY); tableView.setPlaceholder(new AutoTooltipLabel(Res.get("table.placeholder.noItems", Res.get("shared.openOffers")))); offerIdColumn.setComparator(Comparator.comparing(o -> o.getOffer().getId())); + makerFeeTxIdColumn.setComparator(Comparator.comparing(OpenOfferListItem::getMakerFeeTxId)); directionColumn.setComparator(Comparator.comparing(o -> o.getOffer().getDirection())); marketColumn.setComparator(Comparator.comparing(OpenOfferListItem::getMarketDescription)); amountColumn.setComparator(Comparator.comparing(o -> o.getOffer().getAmount())); @@ -201,16 +216,22 @@ public void initialize() { dateColumn.setComparator(Comparator.comparing(o -> o.getOffer().getDate())); paymentMethodColumn.setComparator(Comparator.comparing(o -> Res.get(o.getOffer().getPaymentMethod().getId()))); - dateColumn.setSortType(TableColumn.SortType.DESCENDING); + dateColumn.setSortType(TableColumn.SortType.ASCENDING); tableView.getSortOrder().add(dateColumn); tableView.setRowFactory( tableView -> { final TableRow row = new TableRow<>(); final ContextMenu rowMenu = new ContextMenu(); - MenuItem duplicateItem = new MenuItem(Res.get("portfolio.context.offerLikeThis")); - duplicateItem.setOnAction((event) -> onDuplicateOffer(row.getItem().getOffer())); - rowMenu.getItems().add(duplicateItem); + + MenuItem duplicateOfferMenuItem = new MenuItem(Res.get("portfolio.tab.duplicateOffer")); + duplicateOfferMenuItem.setOnAction((event) -> onDuplicateOffer(row.getItem())); + rowMenu.getItems().add(duplicateOfferMenuItem); + + MenuItem cloneOfferMenuItem = new MenuItem(Res.get("offerbook.cloneOffer")); + cloneOfferMenuItem.setOnAction((event) -> onCloneOffer(row.getItem())); + rowMenu.getItems().add(cloneOfferMenuItem); + row.contextMenuProperty().bind( Bindings.when(Bindings.isNotNull(row.itemProperty())) .then(rowMenu) @@ -233,6 +254,8 @@ public void initialize() { c.next(); if (c.wasAdded() || c.wasRemoved()) { updateNumberOfOffers(); + updateMakerFeeTxIdColumnVisibility(); + updateTriggerColumnVisibility(); } }; } @@ -247,7 +270,8 @@ protected void activate() { filterBox.initializeWithCallback(filteredList, tableView, this::updateNumberOfOffers); filterBox.activate(); - + updateMakerFeeTxIdColumnVisibility(); + updateTriggerColumnVisibility(); updateSelectToggleButtonState(); selectToggleButton.setOnAction(event -> { @@ -272,6 +296,7 @@ protected void activate() { CSVEntryConverter contentConverter = item -> { String[] columns = new String[ColumnNames.values().length]; columns[ColumnNames.OFFER_ID.ordinal()] = item.getOffer().getShortId(); + columns[ColumnNames.MAKER_FEE_TX_ID.ordinal()] = item.getMakerFeeTxId(); columns[ColumnNames.DATE.ordinal()] = item.getDateAsString(); columns[ColumnNames.MARKET.ordinal()] = item.getMarketDescription(); columns[ColumnNames.PRICE.ordinal()] = item.getPriceAsString(); @@ -301,6 +326,18 @@ private void updateNumberOfOffers() { numItems.setText(Res.get("shared.numItemsLabel", sortedList.size())); } + private void updateMakerFeeTxIdColumnVisibility() { + makerFeeTxIdColumn.setVisible(model.dataModel.getList().stream() + .collect(Collectors.groupingBy(OpenOfferListItem::getMakerFeeTxId, Collectors.counting())) + .values().stream().anyMatch(i -> i > 1)); + } + + private void updateTriggerColumnVisibility() { + triggerIconColumn.setVisible(model.dataModel.getList().stream() + .mapToLong(item -> item.getOpenOffer().getTriggerPrice()) + .sum() > 0); + } + @Override protected void deactivate() { sortedList.comparatorProperty().unbind(); @@ -329,7 +366,7 @@ private void updateSelectToggleButtonState() { } private void onWidthChange(double width) { - triggerPriceColumn.setVisible(width > 1200); + triggerPriceColumn.setVisible(width > 1300); } private void onDeactivateOpenOffer(OpenOffer openOffer) { @@ -350,7 +387,7 @@ private void onActivateOpenOffer(OpenOffer openOffer) { () -> log.debug("Activate offer was successful"), (message) -> { log.error(message); - new Popup().warning(Res.get("offerbook.activateOffer.failed", message)).show(); + new Popup().warning(message).show(); }); updateSelectToggleButtonState(); } @@ -359,19 +396,23 @@ private void onActivateOpenOffer(OpenOffer openOffer) { private void onRemoveOpenOffer(OpenOfferListItem item) { OpenOffer openOffer = item.getOpenOffer(); if (model.isBootstrappedOrShowPopup()) { - String key = (openOffer.getOffer().isBsqSwapOffer() ? "RemoveBsqSwapWarning" : "RemoveOfferWarning"); - if (DontShowAgainLookup.showAgain(key)) { - String message = openOffer.getOffer().isBsqSwapOffer() ? - Res.get("popup.warning.removeNoFeeOffer") : - Res.get("popup.warning.removeOffer", item.getMakerFeeAsString()); - new Popup().warning(message) - .actionButtonText(Res.get("shared.removeOffer")) - .onAction(() -> doRemoveOpenOffer(openOffer)) - .closeButtonText(Res.get("shared.dontRemoveOffer")) - .dontShowAgainId(key) - .show(); - } else { + if (openOfferManager.hasOfferSharedMakerFee(openOffer)) { doRemoveOpenOffer(openOffer); + } else { + String key = (openOffer.getOffer().isBsqSwapOffer() ? "RemoveBsqSwapWarning" : "RemoveOfferWarning"); + if (DontShowAgainLookup.showAgain(key)) { + String message = openOffer.getOffer().isBsqSwapOffer() ? + Res.get("popup.warning.removeNoFeeOffer") : + Res.get("popup.warning.removeOffer", item.getMakerFeeAsString()); + new Popup().warning(message) + .actionButtonText(Res.get("shared.removeOffer")) + .onAction(() -> doRemoveOpenOffer(openOffer)) + .closeButtonText(Res.get("shared.dontRemoveOffer")) + .dontShowAgainId(key) + .show(); + } else { + doRemoveOpenOffer(openOffer); + } } updateSelectToggleButtonState(); } @@ -384,9 +425,12 @@ private void doRemoveOpenOffer(OpenOffer openOffer) { tableView.refresh(); - if (openOffer.getOffer().isBsqSwapOffer()) { - return; // nothing to withdraw when Bsq swap is canceled (issue #5956) + // We do not show the popup if it's a BSQ offer or a cloned offer with shared maker fee + if (openOffer.getOffer().isBsqSwapOffer() || + openOfferManager.hasOfferSharedMakerFee(openOffer)) { + return; } + String key = "WithdrawFundsAfterRemoveOfferInfo"; if (DontShowAgainLookup.showAgain(key)) { new Popup().instruction(Res.get("offerbook.withdrawFundsHint", Res.get("navigation.funds.availableForWithdrawal"))) @@ -404,16 +448,43 @@ private void doRemoveOpenOffer(OpenOffer openOffer) { private void onEditOpenOffer(OpenOffer openOffer) { if (model.isBootstrappedOrShowPopup()) { - openOfferActionHandler.onEditOpenOffer(openOffer); + editOpenOfferHandler.onEditOpenOffer(openOffer); + } + } + + private void onDuplicateOffer(OpenOfferListItem item) { + if (item == null || item.getOffer().getOfferPayloadBase() == null) { + return; + } + if (model.isBootstrappedOrShowPopup()) { + PortfolioUtil.duplicateOffer(navigation, item.getOffer().getOfferPayloadBase()); + } + } + + private void onCloneOffer(OpenOfferListItem item) { + if (item == null) { + return; + } + if (model.isBootstrappedOrShowPopup()) { + String key = "clonedOfferInfo"; + if (DontShowAgainLookup.showAgain(key)) { + new Popup().backgroundInfo(Res.get("offerbook.clonedOffer.info")) + .useIUnderstandButton() + .dontShowAgainId(key) + .onClose(() -> doCloneOffer(item)) + .show(); + } else { + doCloneOffer(item); + } } } - private void onDuplicateOffer(Offer offer) { - try { - PortfolioUtil.duplicateOffer(navigation, offer.getOfferPayloadBase()); - } catch (NullPointerException e) { - log.warn("Unable to get offerPayload - {}", e.toString()); + private void doCloneOffer(OpenOfferListItem item) { + OpenOffer openOffer = item.getOpenOffer(); + if (openOffer == null || openOffer.getOffer() == null || openOffer.getOffer().getOfferPayload().isEmpty()) { + return; } + cloneOpenOfferHandler.onCloneOpenOffer(openOffer); } private void setOfferIdColumnCellFactory() { @@ -421,19 +492,23 @@ private void setOfferIdColumnCellFactory() { offerIdColumn.getStyleClass().addAll("number-column", "first-column"); offerIdColumn.setCellFactory( new Callback<>() { - @Override public TableCell call(TableColumn column) { return new TableCell<>() { - private HyperlinkWithIcon field; + private HyperlinkWithIcon hyperlinkWithIcon; @Override public void updateItem(final OpenOfferListItem item, boolean empty) { super.updateItem(item, empty); if (item != null && !empty) { - field = new HyperlinkWithIcon(item.getOffer().getShortId()); - field.setOnAction(event -> { + hyperlinkWithIcon = new HyperlinkWithIcon(item.getOffer().getShortId()); + if (item.isNotPublished()) { + // getStyleClass().add("offer-disabled"); does not work with hyperlinkWithIcon;-( + hyperlinkWithIcon.setStyle("-fx-text-fill: -bs-color-gray-3;"); + hyperlinkWithIcon.getIcon().setOpacity(0.2); + } + hyperlinkWithIcon.setOnAction(event -> { if (item.getOffer().isBsqSwapOffer()) { bsqSwapOfferDetailsWindow.show(item.getOffer()); } else { @@ -441,12 +516,52 @@ public void updateItem(final OpenOfferListItem item, boolean empty) { } }); - field.setTooltip(new Tooltip(Res.get("tooltip.openPopupForDetails"))); - setGraphic(field); + hyperlinkWithIcon.setTooltip(new Tooltip(Res.get("tooltip.openPopupForDetails"))); + setGraphic(hyperlinkWithIcon); + } else { + setGraphic(null); + if (hyperlinkWithIcon != null) + hyperlinkWithIcon.setOnAction(null); + } + } + }; + } + }); + } + + private void setMakerFeeTxIdColumnCellFactory() { + makerFeeTxIdColumn.setCellValueFactory((offerListItem) -> new ReadOnlyObjectWrapper<>(offerListItem.getValue())); + makerFeeTxIdColumn.setCellFactory( + new Callback<>() { + @Override + public TableCell call( + TableColumn 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 = new Label(item.getMakerFeeTxId()); + Text icon; + if (openOfferManager.hasOfferSharedMakerFee(item.getOpenOffer())) { + icon = getRegularIconForLabel(MaterialDesignIcon.LINK, label, "icon"); + setTooltip(new Tooltip(Res.get("offerbook.clonedOffer.tooltip", item.getMakerFeeTxId()))); + } else { + icon = getRegularIconForLabel(MaterialDesignIcon.LINK_OFF, label, "icon"); + setTooltip(new Tooltip(Res.get("offerbook.nonClonedOffer.tooltip", item.getMakerFeeTxId()))); + } + icon.setVisible(!item.getOffer().isBsqSwapOffer()); + + if (item.isNotPublished()) { + getStyleClass().add("offer-disabled"); + icon.setOpacity(0.2); + } + setGraphic(label); } else { setGraphic(null); - if (field != null) - field.setOnAction(null); } } }; @@ -785,7 +900,41 @@ public void updateItem(final OpenOfferListItem item, boolean empty) { button.setTooltip(new Tooltip(Res.get("shared.duplicateOffer"))); setGraphic(button); } - button.setOnAction(event -> onDuplicateOffer(item.getOffer())); + 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 call(TableColumn 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) { @@ -889,8 +1038,12 @@ public void updateItem(final OpenOfferListItem item, boolean empty) { }); } - public void setOpenOfferActionHandler(PortfolioView.OpenOfferActionHandler openOfferActionHandler) { - this.openOfferActionHandler = openOfferActionHandler; + public void setEditOpenOfferHandler(PortfolioView.EditOpenOfferHandler editOpenOfferHandler) { + this.editOpenOfferHandler = editOpenOfferHandler; + } + + public void setCloneOpenOfferHandler(PortfolioView.CloneOpenOfferHandler cloneOpenOfferHandler) { + this.cloneOpenOfferHandler = cloneOpenOfferHandler; } } diff --git a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/PendingTradesView.java b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/PendingTradesView.java index bcb1e0b5836..978d2f10f36 100644 --- a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/PendingTradesView.java +++ b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/PendingTradesView.java @@ -234,7 +234,7 @@ public void initialize() { tableView -> { final TableRow row = new TableRow<>(); final ContextMenu rowMenu = new ContextMenu(); - MenuItem duplicateItem = new MenuItem(Res.get("portfolio.context.offerLikeThis")); + MenuItem duplicateItem = new MenuItem(Res.get("portfolio.tab.duplicateOffer")); duplicateItem.setOnAction((event) -> { try { OfferPayload offerPayload = row.getItem().getTrade().getOffer().getOfferPayload().orElseThrow(); diff --git a/desktop/src/main/java/bisq/desktop/theme-dark.css b/desktop/src/main/java/bisq/desktop/theme-dark.css index d5d31af370f..1be24acf044 100644 --- a/desktop/src/main/java/bisq/desktop/theme-dark.css +++ b/desktop/src/main/java/bisq/desktop/theme-dark.css @@ -557,3 +557,7 @@ -fx-text-fill: -bs-text-color; -fx-fill: -bs-text-color; } + +.icon { + -fx-fill: #fff; +} diff --git a/desktop/src/main/java/bisq/desktop/util/FormBuilder.java b/desktop/src/main/java/bisq/desktop/util/FormBuilder.java index f91a71d2210..ecab5c5f65b 100644 --- a/desktop/src/main/java/bisq/desktop/util/FormBuilder.java +++ b/desktop/src/main/java/bisq/desktop/util/FormBuilder.java @@ -2248,12 +2248,12 @@ public static void removeRowsFromGridPane(GridPane gridPane, int fromGridRow, in // Icons /////////////////////////////////////////////////////////////////////////////////////////// - public static Text getIconForLabel(GlyphIcons icon, String iconSize, Label label, String style) { + public static Text getIconForLabel(GlyphIcons icon, String iconSize, Label label, String styleClass) { if (icon.fontFamily().equals(MATERIAL_DESIGN_ICONS)) { final Text textIcon = MaterialDesignIconFactory.get().createIcon(icon, iconSize); textIcon.setOpacity(0.7); - if (style != null) { - textIcon.getStyleClass().add(style); + if (styleClass != null) { + textIcon.getStyleClass().add(styleClass); } label.setContentDisplay(ContentDisplay.LEFT); label.setGraphic(textIcon); @@ -2279,8 +2279,8 @@ public static Text getRegularIconForLabel(GlyphIcons icon, Label label) { return getRegularIconForLabel(icon, label, null); } - public static Text getRegularIconForLabel(GlyphIcons icon, Label label, String style) { - return getIconForLabel(icon, "1.231em", label, style); + public static Text getRegularIconForLabel(GlyphIcons icon, Label label, String styleClass) { + return getIconForLabel(icon, "1.231em", label, styleClass); } public static Text getIcon(GlyphIcons icon) {