From 770eefc9245910151634da3950b0b16394253d78 Mon Sep 17 00:00:00 2001 From: Bernard Labno Date: Fri, 21 Dec 2018 10:44:01 +0100 Subject: [PATCH] Prepare for http-api incorporation --- build.gradle | 1 + core/src/main/java/bisq/core/CoreModule.java | 2 - .../java/bisq/core/app/BisqExecutable.java | 4 +- .../main/java/bisq/core/app/BisqFacade.java | 53 -- .../java/bisq/core/app/BisqHeadlessApp.java | 1 - .../bisq/core/app/BisqHeadlessAppMain.java | 5 + .../main/java/bisq/core/app/BisqSetup.java | 31 +- .../app/misc/ExecutableForAppWithP2p.java | 5 + .../main/java/bisq/core/btc/BalanceUtil.java | 82 ++ .../src/main/java/bisq/core/btc/Balances.java | 105 +++ .../java/bisq/core/btc/BitcoinModule.java | 4 +- .../bisq/core/btc/model/AddressEntryList.java | 9 +- .../core/btc/model/InputsAndChangeOutput.java | 6 +- .../core/btc/wallet/BtcWalletService.java | 19 + .../core/btc/wallet/TradeWalletService.java | 31 - .../java/bisq/core/filter/FilterManager.java | 4 +- .../main/java/bisq/core/offer/OfferUtil.java | 125 +++ .../main/java/bisq/core/offer/TakerUtil.java | 71 ++ .../java/bisq/core/offer/TxFeeEstimation.java | 138 ++++ .../placeoffer/tasks/CreateMakerFeeTx.java | 4 +- .../offer/placeoffer/tasks/ValidateOffer.java | 1 + .../payment/AccountAgeWitnessService.java | 11 +- .../presentation/BalancePresentation.java | 12 +- .../bisq/core/trade/TradeFailedException.java | 7 + .../java/bisq/core/trade/TradeManager.java | 222 ++++- .../tasks/taker/CreateTakerFeeTx.java | 27 +- core/src/main/java/bisq/core/user/User.java | 4 +- .../bisq/core/offer/TxFeeEstimationTest.java | 124 +++ .../trade/TradeManagerOnOfferTakeTest.java | 767 ++++++++++++++++++ .../java/bisq/desktop/app/BisqAppMain.java | 4 + .../main/offer/MutableOfferDataModel.java | 60 +- .../offer/takeoffer/TakeOfferDataModel.java | 154 ++-- 32 files changed, 1761 insertions(+), 332 deletions(-) delete mode 100644 core/src/main/java/bisq/core/app/BisqFacade.java create mode 100644 core/src/main/java/bisq/core/btc/BalanceUtil.java create mode 100644 core/src/main/java/bisq/core/btc/Balances.java create mode 100644 core/src/main/java/bisq/core/offer/TakerUtil.java create mode 100644 core/src/main/java/bisq/core/offer/TxFeeEstimation.java create mode 100644 core/src/main/java/bisq/core/trade/TradeFailedException.java create mode 100644 core/src/test/java/bisq/core/offer/TxFeeEstimationTest.java create mode 100644 core/src/test/java/bisq/core/trade/TradeManagerOnOfferTakeTest.java diff --git a/build.gradle b/build.gradle index 4de676ad815..12fd9ae7aaa 100644 --- a/build.gradle +++ b/build.gradle @@ -213,6 +213,7 @@ configure(project(':core')) { compile project(':assets') compile project(':p2p') compile "net.sf.jopt-simple:jopt-simple:$joptVersion" + compile "javax.validation:validation-api:1.1.0.Final" compile('network.bisq.btcd-cli4j:btcd-cli4j-core:065d3786') { exclude(module: 'slf4j-api') exclude(module: 'httpclient') diff --git a/core/src/main/java/bisq/core/CoreModule.java b/core/src/main/java/bisq/core/CoreModule.java index 9e94ab20215..db41abc2211 100644 --- a/core/src/main/java/bisq/core/CoreModule.java +++ b/core/src/main/java/bisq/core/CoreModule.java @@ -21,7 +21,6 @@ import bisq.core.app.AppOptionKeys; import bisq.core.app.AvoidStandbyModeService; import bisq.core.app.BisqEnvironment; -import bisq.core.app.BisqFacade; import bisq.core.app.BisqSetup; import bisq.core.app.P2PNetworkSetup; import bisq.core.app.WalletAppSetup; @@ -83,7 +82,6 @@ protected void configure() { bind(BisqSetup.class).in(Singleton.class); bind(P2PNetworkSetup.class).in(Singleton.class); bind(WalletAppSetup.class).in(Singleton.class); - bind(BisqFacade.class).in(Singleton.class); bind(BisqEnvironment.class).toInstance((BisqEnvironment) environment); diff --git a/core/src/main/java/bisq/core/app/BisqExecutable.java b/core/src/main/java/bisq/core/app/BisqExecutable.java index a1fb95faee2..775429a6e6d 100644 --- a/core/src/main/java/bisq/core/app/BisqExecutable.java +++ b/core/src/main/java/bisq/core/app/BisqExecutable.java @@ -75,7 +75,7 @@ import static java.lang.String.format; @Slf4j -public abstract class BisqExecutable implements GracefulShutDownHandler { +public abstract class BisqExecutable implements GracefulShutDownHandler, BisqSetup.BisqSetupCompleteListener { private final String fullName; private final String scriptName; @@ -271,9 +271,11 @@ protected void onApplicationStarted() { protected void startAppSetup() { BisqSetup bisqSetup = injector.getInstance(BisqSetup.class); + bisqSetup.addBisqSetupCompleteListener(this); bisqSetup.start(); } + public abstract void onSetupComplete(); /////////////////////////////////////////////////////////////////////////////////////////// // GracefulShutDownHandler implementation diff --git a/core/src/main/java/bisq/core/app/BisqFacade.java b/core/src/main/java/bisq/core/app/BisqFacade.java deleted file mode 100644 index 235c5f01179..00000000000 --- a/core/src/main/java/bisq/core/app/BisqFacade.java +++ /dev/null @@ -1,53 +0,0 @@ -/* - * 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.app; - -import bisq.core.btc.model.BalanceModel; -import bisq.core.presentation.BalancePresentation; - -import bisq.common.app.Version; - -import javax.inject.Inject; - -/** - * Provides high level interface to functionality of core Bisq features. - * E.g. useful for different APIs to access data of different domains of Bisq. - */ -public class BisqFacade { - private final BalanceModel balanceModel; - private final BalancePresentation balancePresentation; - - @Inject - public BisqFacade(BalanceModel balanceModel, BalancePresentation balancePresentation) { - this.balanceModel = balanceModel; - this.balancePresentation = balancePresentation; - } - - public String getVersion() { - return Version.VERSION; - } - - public long getAvailableBalance() { - balanceModel.updateBalance(); - return balanceModel.getAvailableBalance().get().getValue(); - } - - public String getAvailableBalanceAsString() { - return balancePresentation.getAvailableBalance().get(); - } -} diff --git a/core/src/main/java/bisq/core/app/BisqHeadlessApp.java b/core/src/main/java/bisq/core/app/BisqHeadlessApp.java index 3bb23f40f2b..a65ced861d6 100644 --- a/core/src/main/java/bisq/core/app/BisqHeadlessApp.java +++ b/core/src/main/java/bisq/core/app/BisqHeadlessApp.java @@ -54,7 +54,6 @@ public BisqHeadlessApp() { public void startApplication() { try { bisqSetup = injector.getInstance(BisqSetup.class); - bisqSetup.addBisqSetupCompleteListener(this); corruptedDatabaseFilesHandler = injector.getInstance(CorruptedDatabaseFilesHandler.class); tradeManager = injector.getInstance(TradeManager.class); diff --git a/core/src/main/java/bisq/core/app/BisqHeadlessAppMain.java b/core/src/main/java/bisq/core/app/BisqHeadlessAppMain.java index 23c5c50498e..b8a1f67e2ae 100644 --- a/core/src/main/java/bisq/core/app/BisqHeadlessAppMain.java +++ b/core/src/main/java/bisq/core/app/BisqHeadlessAppMain.java @@ -85,6 +85,11 @@ protected void onApplicationLaunched() { headlessApp.setGracefulShutDownHandler(this); } + @Override + public void onSetupComplete() { + log.info("onSetupComplete"); + } + /////////////////////////////////////////////////////////////////////////////////////////// // We continue with a series of synchronous execution tasks /////////////////////////////////////////////////////////////////////////////////////////// diff --git a/core/src/main/java/bisq/core/app/BisqSetup.java b/core/src/main/java/bisq/core/app/BisqSetup.java index 210ac1e1b39..af9c08a3ef8 100644 --- a/core/src/main/java/bisq/core/app/BisqSetup.java +++ b/core/src/main/java/bisq/core/app/BisqSetup.java @@ -23,9 +23,8 @@ import bisq.core.alert.PrivateNotificationPayload; import bisq.core.arbitration.ArbitratorManager; import bisq.core.arbitration.DisputeManager; -import bisq.core.btc.listeners.BalanceListener; +import bisq.core.btc.Balances; import bisq.core.btc.model.AddressEntry; -import bisq.core.btc.model.BalanceModel; import bisq.core.btc.setup.WalletsSetup; import bisq.core.btc.wallet.BtcWalletService; import bisq.core.btc.wallet.WalletsManager; @@ -41,14 +40,12 @@ import bisq.core.notifications.alerts.TradeEvents; import bisq.core.notifications.alerts.market.MarketAlerts; import bisq.core.notifications.alerts.price.PriceAlert; -import bisq.core.offer.OpenOffer; import bisq.core.offer.OpenOfferManager; import bisq.core.payment.AccountAgeWitnessService; import bisq.core.payment.PaymentAccount; import bisq.core.payment.payload.PaymentMethod; import bisq.core.provider.fee.FeeService; import bisq.core.provider.price.PriceFeedService; -import bisq.core.trade.Trade; import bisq.core.trade.TradeManager; import bisq.core.trade.statistics.AssetTradeActivityCheck; import bisq.core.trade.statistics.TradeStatisticsManager; @@ -73,7 +70,6 @@ import bisq.common.util.Utilities; import org.bitcoinj.core.Coin; -import org.bitcoinj.core.Transaction; import javax.inject.Inject; @@ -125,7 +121,7 @@ public interface BisqSetupCompleteListener { private final WalletsManager walletsManager; private final WalletsSetup walletsSetup; private final BtcWalletService btcWalletService; - private final BalanceModel balanceModel; + private final Balances balances; private final PriceFeedService priceFeedService; private final ArbitratorManager arbitratorManager; private final P2PService p2PService; @@ -201,7 +197,7 @@ public BisqSetup(P2PNetworkSetup p2PNetworkSetup, WalletsManager walletsManager, WalletsSetup walletsSetup, BtcWalletService btcWalletService, - BalanceModel balanceModel, + Balances balances, PriceFeedService priceFeedService, ArbitratorManager arbitratorManager, P2PService p2PService, @@ -239,7 +235,7 @@ public BisqSetup(P2PNetworkSetup p2PNetworkSetup, this.walletsManager = walletsManager; this.walletsSetup = walletsSetup; this.btcWalletService = btcWalletService; - this.balanceModel = balanceModel; + this.balances = balances; this.priceFeedService = priceFeedService; this.arbitratorManager = arbitratorManager; this.p2PService = p2PService; @@ -587,29 +583,12 @@ private void initDomainServices() { disputeManager.onAllServicesInitialized(); tradeManager.onAllServicesInitialized(); - tradeManager.getTradableList().addListener((ListChangeListener) change -> balanceModel.updateBalance()); - tradeManager.getAddressEntriesForAvailableBalanceStream() - .filter(addressEntry -> addressEntry.getOfferId() != null) - .forEach(addressEntry -> { - log.warn("Swapping pending OFFER_FUNDING entries at startup. offerId={}", addressEntry.getOfferId()); - btcWalletService.swapTradeEntryToAvailableEntry(addressEntry.getOfferId(), AddressEntry.Context.OFFER_FUNDING); - }); - - btcWalletService.addBalanceListener(new BalanceListener() { - @Override - public void onBalanceChanged(Coin balance, Transaction tx) { - balanceModel.updateBalance(); - } - }); if (walletsSetup.downloadPercentageProperty().get() == 1) checkForLockedUpFunds(); - balanceModel.updateBalance(); - - openOfferManager.getObservableList().addListener((ListChangeListener) c -> balanceModel.updateBalance()); openOfferManager.onAllServicesInitialized(); - + balances.onAllServicesInitialized(); arbitratorManager.onAllServicesInitialized(); alertManager.alertMessageProperty().addListener((observable, oldValue, newValue) -> diff --git a/core/src/main/java/bisq/core/app/misc/ExecutableForAppWithP2p.java b/core/src/main/java/bisq/core/app/misc/ExecutableForAppWithP2p.java index 8abd5fe6b7b..fdbeb384ff4 100644 --- a/core/src/main/java/bisq/core/app/misc/ExecutableForAppWithP2p.java +++ b/core/src/main/java/bisq/core/app/misc/ExecutableForAppWithP2p.java @@ -64,6 +64,11 @@ protected void configUserThread() { UserThread.setExecutor(Executors.newSingleThreadExecutor(threadFactory)); } + @Override + public void onSetupComplete() { + log.info("onSetupComplete"); + } + // We don't use the gracefulShutDown implementation of the super class as we have a limited set of modules @Override public void gracefulShutDown(ResultHandler resultHandler) { diff --git a/core/src/main/java/bisq/core/btc/BalanceUtil.java b/core/src/main/java/bisq/core/btc/BalanceUtil.java new file mode 100644 index 00000000000..24b0e79ecc1 --- /dev/null +++ b/core/src/main/java/bisq/core/btc/BalanceUtil.java @@ -0,0 +1,82 @@ +/* + * 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.btc; + +import bisq.core.btc.model.AddressEntry; +import bisq.core.btc.wallet.BtcWalletService; +import bisq.core.offer.OpenOffer; +import bisq.core.offer.OpenOfferManager; +import bisq.core.trade.Trade; +import bisq.core.trade.TradeManager; +import bisq.core.trade.closed.ClosedTradableManager; +import bisq.core.trade.failed.FailedTradesManager; + +import bisq.common.util.Tuple2; + +import javax.inject.Inject; + +import java.util.Objects; +import java.util.stream.Stream; + +public class BalanceUtil { + private final TradeManager tradeManager; + private final BtcWalletService btcWalletService; + private final OpenOfferManager openOfferManager; + private final ClosedTradableManager closedTradableManager; + private final FailedTradesManager failedTradesManager; + + @Inject + public BalanceUtil(TradeManager tradeManager, BtcWalletService btcWalletService, OpenOfferManager openOfferManager, + ClosedTradableManager closedTradableManager, FailedTradesManager failedTradesManager) { + this.tradeManager = tradeManager; + this.btcWalletService = btcWalletService; + this.openOfferManager = openOfferManager; + this.closedTradableManager = closedTradableManager; + this.failedTradesManager = failedTradesManager; + } + + public Stream getAddressEntriesForAvailableFunds() { + return tradeManager.getAddressEntriesForAvailableBalanceStream(); + } + + public Stream getAddressEntriesForReservedFunds() { + return getOpenOfferAndAddressEntriesForReservedFunds().map(tuple2 -> tuple2.second); + } + + public Stream> getOpenOfferAndAddressEntriesForReservedFunds() { + return openOfferManager.getObservableList().stream() + .map(openOffer -> btcWalletService.getAddressEntry(openOffer.getId(), AddressEntry.Context.RESERVED_FOR_TRADE) + .map(addressEntry -> new Tuple2<>(openOffer, addressEntry)) + .orElse(null)) + .filter(Objects::nonNull); + } + + public Stream getAddressEntriesForLockedFunds() { + return getTradesAndAddressEntriesForLockedFunds().map(tuple2 -> tuple2.second); + } + + public Stream> getTradesAndAddressEntriesForLockedFunds() { + Stream lockedTrades = Stream.concat(closedTradableManager.getLockedTradesStream(), failedTradesManager.getLockedTradesStream()); + lockedTrades = Stream.concat(lockedTrades, tradeManager.getLockedTradesStream()); + return lockedTrades + .map(trade -> btcWalletService.getAddressEntry(trade.getId(), AddressEntry.Context.MULTI_SIG) + .map(addressEntry -> new Tuple2<>(trade, addressEntry)) + .orElse(null)) + .filter(Objects::nonNull); + } +} diff --git a/core/src/main/java/bisq/core/btc/Balances.java b/core/src/main/java/bisq/core/btc/Balances.java new file mode 100644 index 00000000000..77b4e7b5fef --- /dev/null +++ b/core/src/main/java/bisq/core/btc/Balances.java @@ -0,0 +1,105 @@ +/* + * 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.btc; + +import bisq.core.btc.listeners.BalanceListener; +import bisq.core.btc.wallet.BtcWalletService; +import bisq.core.offer.OpenOffer; +import bisq.core.offer.OpenOfferManager; +import bisq.core.trade.Trade; +import bisq.core.trade.TradeManager; + +import bisq.common.UserThread; + +import org.bitcoinj.core.Coin; +import org.bitcoinj.core.Transaction; + +import javax.inject.Inject; + +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; + +import javafx.collections.ListChangeListener; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class Balances { + private final BalanceUtil balanceUtil; + private final TradeManager tradeManager; + private final BtcWalletService btcWalletService; + private final OpenOfferManager openOfferManager; + + @Getter + private final ObjectProperty availableBalance = new SimpleObjectProperty<>(); + @Getter + private final ObjectProperty reservedBalance = new SimpleObjectProperty<>(); + @Getter + private final ObjectProperty lockedBalance = new SimpleObjectProperty<>(); + + @Inject + public Balances(BalanceUtil balanceUtil, TradeManager tradeManager, BtcWalletService btcWalletService, OpenOfferManager openOfferManager) { + this.balanceUtil = balanceUtil; + this.tradeManager = tradeManager; + this.btcWalletService = btcWalletService; + this.openOfferManager = openOfferManager; + } + + public void onAllServicesInitialized() { + openOfferManager.getObservableList().addListener((ListChangeListener) c -> updateBalance()); + tradeManager.getTradableList().addListener((ListChangeListener) change -> updateBalance()); + btcWalletService.addBalanceListener(new BalanceListener() { + @Override + public void onBalanceChanged(Coin balance, Transaction tx) { + updateBalance(); + } + }); + updateBalance(); + } + + private void updateBalance() { + // Need to delay a bit to get the balances correct + UserThread.execute(() -> { + updateAvailableBalance(); + updateReservedBalance(); + updateLockedBalance(); + }); + } + + private void updateAvailableBalance() { + Coin sum = Coin.valueOf(balanceUtil.getAddressEntriesForAvailableFunds() + .mapToLong(addressEntry -> btcWalletService.getBalanceForAddress(addressEntry.getAddress()).value) + .sum()); + availableBalance.set(sum); + } + + private void updateReservedBalance() { + Coin sum = Coin.valueOf(balanceUtil.getAddressEntriesForReservedFunds() + .mapToLong(addressEntry -> btcWalletService.getBalanceForAddress(addressEntry.getAddress()).value) + .sum()); + reservedBalance.set(sum); + } + + private void updateLockedBalance() { + Coin sum = Coin.valueOf(balanceUtil.getAddressEntriesForLockedFunds() + .mapToLong(addressEntry -> addressEntry.getCoinLockedInMultiSig().getValue()) + .sum()); + lockedBalance.set(sum); + } +} diff --git a/core/src/main/java/bisq/core/btc/BitcoinModule.java b/core/src/main/java/bisq/core/btc/BitcoinModule.java index b9e365a3bbd..f2ab467e975 100644 --- a/core/src/main/java/bisq/core/btc/BitcoinModule.java +++ b/core/src/main/java/bisq/core/btc/BitcoinModule.java @@ -19,7 +19,6 @@ import bisq.core.app.AppOptionKeys; import bisq.core.btc.model.AddressEntryList; -import bisq.core.btc.model.BalanceModel; import bisq.core.btc.nodes.BtcNodes; import bisq.core.btc.setup.RegTestHost; import bisq.core.btc.setup.WalletsSetup; @@ -78,7 +77,8 @@ protected void configure() { bind(BsqCoinSelector.class).in(Singleton.class); bind(NonBsqCoinSelector.class).in(Singleton.class); bind(BtcNodes.class).in(Singleton.class); - bind(BalanceModel.class).in(Singleton.class); + bind(Balances.class).in(Singleton.class); + bind(BalanceUtil.class).in(Singleton.class); bind(PriceNodeHttpClient.class).in(Singleton.class); 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 42a5bb0ee57..e3157ade487 100644 --- a/core/src/main/java/bisq/core/btc/model/AddressEntryList.java +++ b/core/src/main/java/bisq/core/btc/model/AddressEntryList.java @@ -77,11 +77,14 @@ public static AddressEntryList fromProto(PB.AddressEntryList proto) { @Override public Message toProtoMessage() { + // We clone here as we got ConcurrentModificationExceptions + ArrayList clone = new ArrayList<>(this.list); + List addressEntries = clone.stream() + .map(AddressEntry::toProtoMessage) + .collect(Collectors.toList()); return PB.PersistableEnvelope.newBuilder() .setAddressEntryList(PB.AddressEntryList.newBuilder() - .addAllAddressEntry(list.stream() - .map(AddressEntry::toProtoMessage) - .collect(Collectors.toList()))) + .addAllAddressEntry(addressEntries)) .build(); } diff --git a/core/src/main/java/bisq/core/btc/model/InputsAndChangeOutput.java b/core/src/main/java/bisq/core/btc/model/InputsAndChangeOutput.java index 79a1bb6d0b4..aad74bd838e 100644 --- a/core/src/main/java/bisq/core/btc/model/InputsAndChangeOutput.java +++ b/core/src/main/java/bisq/core/btc/model/InputsAndChangeOutput.java @@ -17,21 +17,21 @@ package bisq.core.btc.model; -import java.util.ArrayList; +import java.util.List; import javax.annotation.Nullable; import static com.google.common.base.Preconditions.checkArgument; public class InputsAndChangeOutput { - public final ArrayList rawTransactionInputs; + public final List rawTransactionInputs; // Is set to 0L in case we don't have an output public final long changeOutputValue; @Nullable public final String changeOutputAddress; - public InputsAndChangeOutput(ArrayList rawTransactionInputs, long changeOutputValue, @Nullable String changeOutputAddress) { + public InputsAndChangeOutput(List rawTransactionInputs, long changeOutputValue, @Nullable String changeOutputAddress) { checkArgument(!rawTransactionInputs.isEmpty(), "rawInputs.isEmpty()"); this.rawTransactionInputs = rawTransactionInputs; 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 d00ac34d923..bd7c407cd70 100644 --- a/core/src/main/java/bisq/core/btc/wallet/BtcWalletService.java +++ b/core/src/main/java/bisq/core/btc/wallet/BtcWalletService.java @@ -17,6 +17,7 @@ package bisq.core.btc.wallet; +import bisq.core.app.BisqEnvironment; import bisq.core.btc.exceptions.AddressEntryException; import bisq.core.btc.exceptions.InsufficientFundsException; import bisq.core.btc.exceptions.TransactionVerificationException; @@ -957,6 +958,24 @@ private boolean feeEstimationNotSatisfied(int counter, Transaction tx) { tx.getFee().value - targetFee > 1000); } + public int getEstimatedFeeTxSize(List outputValues, Coin txFee) + throws InsufficientMoneyException, AddressFormatException { + Transaction transaction = new Transaction(params); + Address dummyAddress = wallet.currentReceiveKey().toAddress(BisqEnvironment.getParameters()); + outputValues.forEach(outputValue -> transaction.addOutput(outputValue, dummyAddress)); + + SendRequest sendRequest = SendRequest.forTx(transaction); + sendRequest.shuffleOutputs = false; + sendRequest.aesKey = aesKey; + sendRequest.coinSelector = new BtcCoinSelector(walletsSetup.getAddressesByContext(AddressEntry.Context.AVAILABLE)); + sendRequest.fee = txFee; + sendRequest.feePerKb = Coin.ZERO; + sendRequest.ensureMinRequiredFee = false; + sendRequest.changeAddress = dummyAddress; + wallet.completeTx(sendRequest); + return transaction.bitcoinSerialize().length; + } + /////////////////////////////////////////////////////////////////////////////////////////// // Withdrawal Send diff --git a/core/src/main/java/bisq/core/btc/wallet/TradeWalletService.java b/core/src/main/java/bisq/core/btc/wallet/TradeWalletService.java index cc41d021959..69a5162568a 100644 --- a/core/src/main/java/bisq/core/btc/wallet/TradeWalletService.java +++ b/core/src/main/java/bisq/core/btc/wallet/TradeWalletService.java @@ -230,37 +230,6 @@ public Transaction createBtcTradingFeeTx(Address fundingAddress, } } - public Transaction estimateBtcTradingFeeTxSize(Address fundingAddress, - Address reservedForTradeAddress, - Address changeAddress, - Coin reservedFundsForOffer, - boolean useSavingsWallet, - Coin tradingFee, - Coin txFee, - String feeReceiverAddresses) - throws InsufficientMoneyException, AddressFormatException { - Transaction tradingFeeTx = new Transaction(params); - tradingFeeTx.addOutput(tradingFee, Address.fromBase58(params, feeReceiverAddresses)); - tradingFeeTx.addOutput(reservedFundsForOffer, reservedForTradeAddress); - - SendRequest sendRequest = SendRequest.forTx(tradingFeeTx); - sendRequest.shuffleOutputs = false; - sendRequest.aesKey = aesKey; - if (useSavingsWallet) - sendRequest.coinSelector = new BtcCoinSelector(walletsSetup.getAddressesByContext(AddressEntry.Context.AVAILABLE)); - else - sendRequest.coinSelector = new BtcCoinSelector(fundingAddress); - - sendRequest.fee = txFee; - sendRequest.feePerKb = Coin.ZERO; - sendRequest.ensureMinRequiredFee = false; - sendRequest.changeAddress = changeAddress; - checkNotNull(wallet, "Wallet must not be null"); - log.info("estimateBtcTradingFeeTxSize"); - wallet.completeTx(sendRequest); - return tradingFeeTx; - } - public Transaction completeBsqTradingFeeTx(Transaction preparedBsqTx, Address fundingAddress, Address reservedForTradeAddress, diff --git a/core/src/main/java/bisq/core/filter/FilterManager.java b/core/src/main/java/bisq/core/filter/FilterManager.java index 5aeaa556616..d0002db06fc 100644 --- a/core/src/main/java/bisq/core/filter/FilterManager.java +++ b/core/src/main/java/bisq/core/filter/FilterManager.java @@ -323,7 +323,7 @@ public Filter getDevelopersFilter() { return user.getDevelopersFilter(); } - public boolean isCurrencyBanned(String currencyCode) { + public boolean isCurrencyBanned(@Nullable String currencyCode) { return getFilter() != null && getFilter().getBannedCurrencies() != null && getFilter().getBannedCurrencies().stream() @@ -337,7 +337,7 @@ public boolean isPaymentMethodBanned(PaymentMethod paymentMethod) { .anyMatch(e -> e.equals(paymentMethod.getId())); } - public boolean isOfferIdBanned(String offerId) { + public boolean isOfferIdBanned(@Nullable String offerId) { return getFilter() != null && getFilter().getBannedOfferIds().stream() .anyMatch(e -> e.equals(offerId)); diff --git a/core/src/main/java/bisq/core/offer/OfferUtil.java b/core/src/main/java/bisq/core/offer/OfferUtil.java index ad91bbc7ddc..e2749d1e877 100644 --- a/core/src/main/java/bisq/core/offer/OfferUtil.java +++ b/core/src/main/java/bisq/core/offer/OfferUtil.java @@ -20,18 +20,32 @@ import bisq.core.app.BisqEnvironment; import bisq.core.btc.wallet.BsqWalletService; import bisq.core.btc.wallet.Restrictions; +import bisq.core.filter.FilterManager; +import bisq.core.locale.Country; import bisq.core.locale.CurrencyUtil; import bisq.core.locale.Res; import bisq.core.monetary.Price; import bisq.core.monetary.Volume; +import bisq.core.payment.AccountAgeWitnessService; +import bisq.core.payment.BankAccount; +import bisq.core.payment.CountryBasedPaymentAccount; +import bisq.core.payment.F2FAccount; +import bisq.core.payment.PaymentAccount; +import bisq.core.payment.SameBankAccount; +import bisq.core.payment.SepaAccount; +import bisq.core.payment.SepaInstantAccount; +import bisq.core.payment.SpecificBanksAccount; import bisq.core.provider.fee.FeeService; import bisq.core.provider.price.MarketPrice; import bisq.core.provider.price.PriceFeedService; +import bisq.core.trade.statistics.ReferralIdService; import bisq.core.user.Preferences; import bisq.core.util.BSFormatter; import bisq.core.util.BsqFormatter; import bisq.core.util.CoinUtil; +import bisq.network.p2p.P2PService; + import bisq.common.util.MathUtils; import org.bitcoinj.core.Coin; @@ -39,6 +53,9 @@ import com.google.common.annotations.VisibleForTesting; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; import java.util.Optional; import lombok.extern.slf4j.Slf4j; @@ -46,6 +63,7 @@ import javax.annotation.Nullable; import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; /** * This class holds utility methods for the creation of an Offer. @@ -333,4 +351,111 @@ public static String getFeeWithFiatAmount(Coin makerFeeAsCoin, Optional } return Res.get("feeOptionWindow.fee", fee, feeInFiatAsString); } + + public static ArrayList getAcceptedCountryCodes(PaymentAccount paymentAccount) { + ArrayList acceptedCountryCodes = null; + if (paymentAccount instanceof SepaAccount) { + acceptedCountryCodes = new ArrayList<>(((SepaAccount) paymentAccount).getAcceptedCountryCodes()); + } else if (paymentAccount instanceof SepaInstantAccount) { + acceptedCountryCodes = new ArrayList<>(((SepaInstantAccount) paymentAccount).getAcceptedCountryCodes()); + } else if (paymentAccount instanceof CountryBasedPaymentAccount) { + acceptedCountryCodes = new ArrayList<>(); + Country country = ((CountryBasedPaymentAccount) paymentAccount).getCountry(); + if (country != null) + acceptedCountryCodes.add(country.code); + } + return acceptedCountryCodes; + } + + public static ArrayList getAcceptedBanks(PaymentAccount paymentAccount) { + ArrayList acceptedBanks = null; + if (paymentAccount instanceof SpecificBanksAccount) { + acceptedBanks = new ArrayList<>(((SpecificBanksAccount) paymentAccount).getAcceptedBanks()); + } else if (paymentAccount instanceof SameBankAccount) { + acceptedBanks = new ArrayList<>(); + acceptedBanks.add(((SameBankAccount) paymentAccount).getBankId()); + } + return acceptedBanks; + } + + public static String getBankId(PaymentAccount paymentAccount) { + return paymentAccount instanceof BankAccount ? ((BankAccount) paymentAccount).getBankId() : null; + } + + // That is optional and set to null if not supported (AltCoins, OKPay,...) + public static String getCountryCode(PaymentAccount paymentAccount) { + if (paymentAccount instanceof CountryBasedPaymentAccount) { + Country country = ((CountryBasedPaymentAccount) paymentAccount).getCountry(); + return country != null ? country.code : null; + } else { + return null; + } + } + + public static long getMaxTradeLimit(AccountAgeWitnessService accountAgeWitnessService, PaymentAccount paymentAccount, String currencyCode) { + if (paymentAccount != null) + return accountAgeWitnessService.getMyTradeLimit(paymentAccount, currencyCode); + else + return 0; + } + + public static long getMaxTradePeriod(PaymentAccount paymentAccount) { + return paymentAccount.getPaymentMethod().getMaxTradePeriod(); + } + + public static Map getExtraDataMap(AccountAgeWitnessService accountAgeWitnessService, + ReferralIdService referralIdService, + PaymentAccount paymentAccount, + String currencyCode) { + Map extraDataMap = null; + if (CurrencyUtil.isFiatCurrency(currencyCode)) { + extraDataMap = new HashMap<>(); + final String myWitnessHashAsHex = accountAgeWitnessService.getMyWitnessHashAsHex(paymentAccount.getPaymentAccountPayload()); + extraDataMap.put(OfferPayload.ACCOUNT_AGE_WITNESS_HASH, myWitnessHashAsHex); + } + + if (referralIdService.getOptionalReferralId().isPresent()) { + if (extraDataMap == null) + extraDataMap = new HashMap<>(); + extraDataMap.put(OfferPayload.REFERRAL_ID, referralIdService.getOptionalReferralId().get()); + } + + if (paymentAccount instanceof F2FAccount) { + if (extraDataMap == null) + extraDataMap = new HashMap<>(); + extraDataMap.put(OfferPayload.F2F_CITY, ((F2FAccount) paymentAccount).getCity()); + extraDataMap.put(OfferPayload.F2F_EXTRA_INFO, ((F2FAccount) paymentAccount).getExtraInfo()); + } + + return extraDataMap; + } + + public static void validateOfferData(FilterManager filterManager, + P2PService p2PService, + Coin buyerSecurityDepositAsCoin, + PaymentAccount paymentAccount, + String currencyCode, Coin makerFeeAsCoin) { + checkArgument(buyerSecurityDepositAsCoin.compareTo(Restrictions.getMaxBuyerSecurityDeposit()) <= 0, + "securityDeposit must be not exceed " + + Restrictions.getMaxBuyerSecurityDeposit().toFriendlyString()); + checkArgument(buyerSecurityDepositAsCoin.compareTo(Restrictions.getMinBuyerSecurityDeposit()) >= 0, + "securityDeposit must be not be less than " + + Restrictions.getMinBuyerSecurityDeposit().toFriendlyString()); + + checkArgument(!filterManager.isCurrencyBanned(currencyCode), + Res.get("offerbook.warning.currencyBanned")); + checkArgument(!filterManager.isPaymentMethodBanned(paymentAccount.getPaymentMethod()), + Res.get("offerbook.warning.paymentMethodBanned")); + checkNotNull(makerFeeAsCoin, "makerFee must not be null"); + checkNotNull(p2PService.getAddress(), "Address must not be null"); + } + + public static Coin getFundsNeededForOffer(Coin tradeAmount, Coin buyerSecurityDeposit, OfferPayload.Direction direction) { + boolean buyOffer = isBuyOffer(direction); + Coin needed = buyOffer ? buyerSecurityDeposit : Restrictions.getSellerSecurityDeposit(); + if (!buyOffer) + needed = needed.add(tradeAmount); + + return needed; + } } diff --git a/core/src/main/java/bisq/core/offer/TakerUtil.java b/core/src/main/java/bisq/core/offer/TakerUtil.java new file mode 100644 index 00000000000..6da4f7f9b1b --- /dev/null +++ b/core/src/main/java/bisq/core/offer/TakerUtil.java @@ -0,0 +1,71 @@ +/* + * 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; + +import bisq.core.app.BisqEnvironment; +import bisq.core.btc.wallet.BsqWalletService; +import bisq.core.provider.fee.FeeService; +import bisq.core.user.Preferences; +import bisq.core.util.CoinUtil; + +import org.bitcoinj.core.Coin; + +import javax.annotation.Nullable; + +public class TakerUtil { + public static Coin getFundsNeededForTakeOffer(Coin tradeAmount, Coin txFeeForDepositTx, Coin txFeeForPayoutTx, Offer offer) { + boolean buyOffer = OfferUtil.isBuyOffer(offer.getDirection()); + Coin needed = buyOffer ? offer.getSellerSecurityDeposit() : offer.getBuyerSecurityDeposit(); + + if (buyOffer) + needed = needed.add(tradeAmount); + + needed = needed.add(txFeeForDepositTx).add(txFeeForPayoutTx); + + return needed; + } + + @Nullable + public static Coin getTakerFee(Coin amount, Preferences preferences, BsqWalletService bsqWalletService) { + boolean currencyForTakerFeeBtc = isCurrencyForTakerFeeBtc(amount, preferences, bsqWalletService); + return getTakerFee(currencyForTakerFeeBtc, amount); + } + + @Nullable + public static Coin getTakerFee(boolean isCurrencyForTakerFeeBtc, Coin amount) { + if (amount != null) { + // TODO write unit test for that + Coin feePerBtc = CoinUtil.getFeePerBtc(FeeService.getTakerFeePerBtc(isCurrencyForTakerFeeBtc), amount); + return CoinUtil.maxCoin(feePerBtc, FeeService.getMinTakerFee(isCurrencyForTakerFeeBtc)); + } else { + return null; + } + } + + public static boolean isCurrencyForTakerFeeBtc(Coin amount, Preferences preferences, BsqWalletService bsqWalletService) { + return preferences.isPayFeeInBtc() || !isBsqForFeeAvailable(amount, bsqWalletService); + } + + public static boolean isBsqForFeeAvailable(Coin amount, BsqWalletService bsqWalletService) { + Coin takerFee = getTakerFee(false, amount); + return BisqEnvironment.isBaseCurrencySupportingBsq() && + takerFee != null && + bsqWalletService.getAvailableBalance() != null && + !bsqWalletService.getAvailableBalance().subtract(takerFee).isNegative(); + } +} diff --git a/core/src/main/java/bisq/core/offer/TxFeeEstimation.java b/core/src/main/java/bisq/core/offer/TxFeeEstimation.java new file mode 100644 index 00000000000..cf6437d539c --- /dev/null +++ b/core/src/main/java/bisq/core/offer/TxFeeEstimation.java @@ -0,0 +1,138 @@ +/* + * 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; + +import bisq.core.btc.wallet.BtcWalletService; +import bisq.core.provider.fee.FeeService; +import bisq.core.user.Preferences; + +import bisq.common.util.Tuple2; + +import org.bitcoinj.core.Coin; +import org.bitcoinj.core.InsufficientMoneyException; + +import com.google.common.annotations.VisibleForTesting; + +import java.util.List; + +import lombok.extern.slf4j.Slf4j; + +import static com.google.common.base.Preconditions.checkArgument; + +/** + * Util class for getting the estimated tx fee for maker or taker fee tx. + */ +@Slf4j +public class TxFeeEstimation { + public static int TYPICAL_TX_WITH_1_INPUT_SIZE = 260; + private static int counter; + + public static Tuple2 getEstimatedFeeAndTxSizeForTaker(Coin reservedFundsForOffer, + Coin tradeFee, + FeeService feeService, + BtcWalletService btcWalletService, + Preferences preferences) { + Coin txFeePerByte = feeService.getTxFeePerByte(); + // We start with min taker fee size of 260 + int estimatedTxSize = 260; + try { + estimatedTxSize = getEstimatedTxSize(List.of(tradeFee, reservedFundsForOffer), estimatedTxSize, txFeePerByte, btcWalletService); + } catch (InsufficientMoneyException e) { + // if we cannot do the estimation we use the payout tx size + estimatedTxSize = 380; + log.info("We cannot do the fee estimation because there are not enough funds in the wallet. This is expected " + + "if the user pays from an external wallet. In that case we use an estimated tx size of {} bytes.", estimatedTxSize); + } + + if (!preferences.isPayFeeInBtc()) { + // If we pay the fee in BSQ we have one input more which adds about 150 bytes + estimatedTxSize += 150; + } + + int averageSize = (estimatedTxSize + 320) / 2; + // We use at least the size of the payout tx to not underpay at payout. + int minSize = Math.max(380, averageSize); + Coin txFee = txFeePerByte.multiply(minSize); + log.info("Fee estimation resulted in a tx size of {} bytes.\n" + + "We use an average between the taker fee tx and the deposit tx (320 bytes) which results in {} bytes.\n" + + "The payout tx has 380 bytes so we use that as our min value which is {} bytes.\n" + + "The tx fee of {}", estimatedTxSize, averageSize, minSize, txFee.toFriendlyString()); + return new Tuple2<>(txFee, minSize); + } + + public static Tuple2 getEstimatedFeeAndTxSizeForMaker(Coin reservedFundsForOffer, + Coin tradeFee, + FeeService feeService, + BtcWalletService btcWalletService, + Preferences preferences) { + Coin txFeePerByte = feeService.getTxFeePerByte(); + // We start with min maker fee size of 260 + int estimatedTxSize = 260; + try { + estimatedTxSize = getEstimatedTxSize(List.of(tradeFee, reservedFundsForOffer), estimatedTxSize, txFeePerByte, btcWalletService); + } catch (InsufficientMoneyException e) { + log.info("We cannot do the fee estimation because there are not enough funds in the wallet. This is expected " + + "if the user pays from an external wallet. In that case we use an estimated tx size of {} bytes.", estimatedTxSize); + } + + if (!preferences.isPayFeeInBtc()) { + // If we pay the fee in BSQ we have one input more which adds about 150 bytes + estimatedTxSize += 150; + } + + Coin txFee = txFeePerByte.multiply(estimatedTxSize); + log.info("Fee estimation resulted in a tx size of {} bytes and a tx fee of {}", estimatedTxSize, txFee.toFriendlyString()); + return new Tuple2<>(txFee, estimatedTxSize); + } + + @VisibleForTesting + static int getEstimatedTxSize(List outputValues, + int initialEstimatedTxSize, + Coin txFeePerByte, + BtcWalletService btcWalletService) + throws InsufficientMoneyException { + boolean isInTolerance; + int estimatedTxSize = initialEstimatedTxSize; + int realTxSize; + do { + Coin txFee = txFeePerByte.multiply(estimatedTxSize); + realTxSize = btcWalletService.getEstimatedFeeTxSize(outputValues, txFee); + isInTolerance = isInTolerance(estimatedTxSize, realTxSize, 0.2); + if (!isInTolerance) { + estimatedTxSize = realTxSize; + } + counter++; + } + while (!isInTolerance && counter < 10); + if (!isInTolerance) { + log.warn("We could not find a tx which satisfies our tolerance requirement of 20%. " + + "realTxSize={}, estimatedTxSize={}", + realTxSize, estimatedTxSize); + } + return estimatedTxSize; + } + + @VisibleForTesting + static boolean isInTolerance(int estimatedSize, int txSize, double tolerance) { + checkArgument(estimatedSize > 0, "estimatedSize must be positive"); + checkArgument(txSize > 0, "txSize must be positive"); + checkArgument(tolerance > 0, "tolerance must be positive"); + double deviation = Math.abs(1 - ((double) estimatedSize / (double) txSize)); + return deviation <= tolerance; + } +} diff --git a/core/src/main/java/bisq/core/offer/placeoffer/tasks/CreateMakerFeeTx.java b/core/src/main/java/bisq/core/offer/placeoffer/tasks/CreateMakerFeeTx.java index 199ffb48025..0ba3bff7898 100644 --- a/core/src/main/java/bisq/core/offer/placeoffer/tasks/CreateMakerFeeTx.java +++ b/core/src/main/java/bisq/core/offer/placeoffer/tasks/CreateMakerFeeTx.java @@ -86,8 +86,8 @@ public void onSuccess(Transaction transaction) { // returned (tradeFeeTx would be null in that case) UserThread.execute(() -> { if (!completed) { - offer.setOfferFeePaymentTxId(tradeFeeTx.getHashAsString()); - model.setTransaction(tradeFeeTx); + offer.setOfferFeePaymentTxId(transaction.getHashAsString()); + model.setTransaction(transaction); walletService.swapTradeEntryToAvailableEntry(id, AddressEntry.Context.OFFER_FUNDING); model.getOffer().setState(Offer.State.OFFER_FEE_PAID); diff --git a/core/src/main/java/bisq/core/offer/placeoffer/tasks/ValidateOffer.java b/core/src/main/java/bisq/core/offer/placeoffer/tasks/ValidateOffer.java index efe6040d369..8216c924f21 100644 --- a/core/src/main/java/bisq/core/offer/placeoffer/tasks/ValidateOffer.java +++ b/core/src/main/java/bisq/core/offer/placeoffer/tasks/ValidateOffer.java @@ -98,6 +98,7 @@ protected void run() { "maxTradePeriod must be positive. maxTradePeriod=" + offer.getMaxTradePeriod()); // TODO check upper and lower bounds for fiat // TODO check rest of new parameters + // TODO check for account age witness base tradeLimit is missing complete(); } catch (Exception e) { diff --git a/core/src/main/java/bisq/core/payment/AccountAgeWitnessService.java b/core/src/main/java/bisq/core/payment/AccountAgeWitnessService.java index f06a4b35ca5..e30617ef8df 100644 --- a/core/src/main/java/bisq/core/payment/AccountAgeWitnessService.java +++ b/core/src/main/java/bisq/core/payment/AccountAgeWitnessService.java @@ -260,9 +260,14 @@ public long getMyAccountAge(PaymentAccountPayload paymentAccountPayload) { } public long getMyTradeLimit(PaymentAccount paymentAccount, String currencyCode) { - final Optional witnessOptional = Optional.of(getMyWitness(paymentAccount.getPaymentAccountPayload())); - return getTradeLimit(paymentAccount.getPaymentMethod() - .getMaxTradeLimitAsCoin(currencyCode), currencyCode, witnessOptional, new Date()); + if (paymentAccount == null) + return 0; + + Optional witnessOptional = Optional.of(getMyWitness(paymentAccount.getPaymentAccountPayload())); + return getTradeLimit(paymentAccount.getPaymentMethod().getMaxTradeLimitAsCoin(currencyCode), + currencyCode, + witnessOptional, + new Date()); } diff --git a/core/src/main/java/bisq/core/presentation/BalancePresentation.java b/core/src/main/java/bisq/core/presentation/BalancePresentation.java index 311a8570ea2..3b42d19ac6a 100644 --- a/core/src/main/java/bisq/core/presentation/BalancePresentation.java +++ b/core/src/main/java/bisq/core/presentation/BalancePresentation.java @@ -17,7 +17,7 @@ package bisq.core.presentation; -import bisq.core.btc.model.BalanceModel; +import bisq.core.btc.Balances; import bisq.core.util.BSFormatter; import javax.inject.Inject; @@ -26,7 +26,9 @@ import javafx.beans.property.StringProperty; import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +@Slf4j public class BalancePresentation { @Getter private final StringProperty availableBalance = new SimpleStringProperty(); @@ -36,8 +38,8 @@ public class BalancePresentation { private final StringProperty lockedBalance = new SimpleStringProperty(); @Inject - public BalancePresentation(BalanceModel balanceModel, BSFormatter formatter) { - balanceModel.getAvailableBalance().addListener((observable, oldValue, newValue) -> { + public BalancePresentation(Balances balances, BSFormatter formatter) { + balances.getAvailableBalance().addListener((observable, oldValue, newValue) -> { String value = formatter.formatCoinWithCode(newValue); // If we get full precision the BTC postfix breaks layout so we omit it if (value.length() > 11) @@ -45,10 +47,10 @@ public BalancePresentation(BalanceModel balanceModel, BSFormatter formatter) { availableBalance.set(value); }); - balanceModel.getReservedBalance().addListener((observable, oldValue, newValue) -> { + balances.getReservedBalance().addListener((observable, oldValue, newValue) -> { reservedBalance.set(formatter.formatCoinWithCode(newValue)); }); - balanceModel.getLockedBalance().addListener((observable, oldValue, newValue) -> { + balances.getLockedBalance().addListener((observable, oldValue, newValue) -> { lockedBalance.set(formatter.formatCoinWithCode(newValue)); }); } diff --git a/core/src/main/java/bisq/core/trade/TradeFailedException.java b/core/src/main/java/bisq/core/trade/TradeFailedException.java new file mode 100644 index 00000000000..ad7f85c82a9 --- /dev/null +++ b/core/src/main/java/bisq/core/trade/TradeFailedException.java @@ -0,0 +1,7 @@ +package bisq.core.trade; + +public class TradeFailedException extends Exception { + public TradeFailedException(String msg) { + super(msg); + } +} diff --git a/core/src/main/java/bisq/core/trade/TradeManager.java b/core/src/main/java/bisq/core/trade/TradeManager.java index 77e28418433..938db50bc43 100644 --- a/core/src/main/java/bisq/core/trade/TradeManager.java +++ b/core/src/main/java/bisq/core/trade/TradeManager.java @@ -24,20 +24,26 @@ import bisq.core.btc.wallet.BtcWalletService; import bisq.core.btc.wallet.TradeWalletService; import bisq.core.filter.FilterManager; +import bisq.core.locale.CurrencyUtil; +import bisq.core.locale.Res; import bisq.core.offer.Offer; import bisq.core.offer.OfferPayload; import bisq.core.offer.OpenOffer; import bisq.core.offer.OpenOfferManager; +import bisq.core.offer.TakerUtil; +import bisq.core.offer.TxFeeEstimation; import bisq.core.offer.availability.OfferAvailabilityModel; import bisq.core.payment.AccountAgeWitnessService; +import bisq.core.payment.PaymentAccount; +import bisq.core.provider.fee.FeeService; import bisq.core.provider.price.PriceFeedService; import bisq.core.trade.closed.ClosedTradableManager; import bisq.core.trade.failed.FailedTradesManager; -import bisq.core.trade.handlers.TradeResultHandler; import bisq.core.trade.messages.PayDepositRequest; import bisq.core.trade.messages.TradeMessage; import bisq.core.trade.statistics.ReferralIdService; import bisq.core.trade.statistics.TradeStatisticsManager; +import bisq.core.user.Preferences; import bisq.core.user.User; import bisq.core.util.Validator; @@ -58,6 +64,7 @@ import bisq.common.proto.persistable.PersistedDataHost; import bisq.common.proto.persistable.PersistenceProtoResolver; import bisq.common.storage.Storage; +import bisq.common.util.Tuple2; import org.bitcoinj.core.AddressFormatException; import org.bitcoinj.core.Coin; @@ -86,6 +93,7 @@ import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -100,6 +108,12 @@ import javax.annotation.Nullable; +import static bisq.core.payment.PaymentAccountUtil.isPaymentAccountValidForOffer; + + + +import javax.validation.ValidationException; + public class TradeManager implements PersistedDataHost { private static final Logger log = LoggerFactory.getLogger(TradeManager.class); @@ -111,7 +125,9 @@ public class TradeManager implements PersistedDataHost { private final OpenOfferManager openOfferManager; private final ClosedTradableManager closedTradableManager; private final FailedTradesManager failedTradesManager; + private final FeeService feeService; private final P2PService p2PService; + private final Preferences preferences; private final PriceFeedService priceFeedService; private final FilterManager filterManager; private final TradeStatisticsManager tradeStatisticsManager; @@ -121,14 +137,14 @@ public class TradeManager implements PersistedDataHost { private final Clock clock; private final Storage> tradableListStorage; - private TradableList tradableList; private final BooleanProperty pendingTradesInitialized = new SimpleBooleanProperty(); + @Getter + private final LongProperty numPendingTrades = new SimpleLongProperty(); + private TradableList tradableList; private List tradesForStatistics; @Setter @Nullable private ErrorMessageHandler takeOfferRequestErrorMessageHandler; - @Getter - private final LongProperty numPendingTrades = new SimpleLongProperty(); /////////////////////////////////////////////////////////////////////////////////////////// @@ -144,7 +160,9 @@ public TradeManager(User user, OpenOfferManager openOfferManager, ClosedTradableManager closedTradableManager, FailedTradesManager failedTradesManager, + FeeService feeService, P2PService p2PService, + Preferences preferences, PriceFeedService priceFeedService, FilterManager filterManager, TradeStatisticsManager tradeStatisticsManager, @@ -162,7 +180,9 @@ public TradeManager(User user, this.openOfferManager = openOfferManager; this.closedTradableManager = closedTradableManager; this.failedTradesManager = failedTradesManager; + this.feeService = feeService; this.p2PService = p2PService; + this.preferences = preferences; this.priceFeedService = priceFeedService; this.filterManager = filterManager; this.tradeStatisticsManager = tradeStatisticsManager; @@ -239,6 +259,13 @@ public void onUpdatedDataReceived() { tradableList.getList().addListener((ListChangeListener) change -> onTradesChanged()); onTradesChanged(); + + getAddressEntriesForAvailableBalanceStream() + .filter(addressEntry -> addressEntry.getOfferId() != null) + .forEach(addressEntry -> { + log.debug("swapPendingOfferFundingEntries, offerId={}, OFFER_FUNDING", addressEntry.getOfferId()); + btcWalletService.swapTradeEntryToAvailableEntry(addressEntry.getOfferId(), AddressEntry.Context.OFFER_FUNDING); + }); } public void shutDown() { @@ -394,48 +421,153 @@ public void onCancelAvailabilityRequest(Offer offer) { offer.cancelAvailabilityRequest(); } + public CompletableFuture onTakeOffer(Coin amount, long tradePrice, Offer offer, String paymentAccountId, boolean useSavingsWallet, @Nullable Long maxFundsForTrade) { + if (!amount.isPositive()) { + return CompletableFuture.failedFuture(new ValidationException("amount must be greater than or equal to 1")); + } + + // check that the price is correct ?? + Coin txFee = feeService.getTxFee(TxFeeEstimation.TYPICAL_TX_WITH_1_INPUT_SIZE); + Coin fundsNeededForTaker = TakerUtil.getFundsNeededForTakeOffer(amount, txFee, txFee, offer); + Coin takerFee = TakerUtil.getTakerFee(amount, preferences, bsqWalletService); + Tuple2 estimatedFeeAndTxSize = TxFeeEstimation.getEstimatedFeeAndTxSizeForTaker(fundsNeededForTaker, + takerFee, + feeService, + btcWalletService, + preferences); + // TODO what if txFee is now bigger than the one before calculating fundsNeededForTaker? + txFee = estimatedFeeAndTxSize.first; + fundsNeededForTaker = TakerUtil.getFundsNeededForTakeOffer(amount, txFee, txFee, offer); + boolean currencyForTakerFeeBtc = TakerUtil.isCurrencyForTakerFeeBtc(amount, preferences, bsqWalletService); + return onTakeOffer(amount, txFee, takerFee, currencyForTakerFeeBtc, tradePrice, fundsNeededForTaker, offer, paymentAccountId, useSavingsWallet, maxFundsForTrade); + } + // First we check if offer is still available then we create the trade with the protocol - public void onTakeOffer(Coin amount, - Coin txFee, - Coin takerFee, - boolean isCurrencyForTakerFeeBtc, - long tradePrice, - Coin fundsNeededForTrade, - Offer offer, - String paymentAccountId, - boolean useSavingsWallet, - TradeResultHandler tradeResultHandler, - ErrorMessageHandler errorMessageHandler) { - final OfferAvailabilityModel model = getOfferAvailabilityModel(offer); - offer.checkOfferAvailability(model, - () -> { - if (offer.getState() == Offer.State.AVAILABLE) - createTrade(amount, - txFee, - takerFee, - isCurrencyForTakerFeeBtc, - tradePrice, - fundsNeededForTrade, - offer, - paymentAccountId, - useSavingsWallet, - model, - tradeResultHandler); + public CompletableFuture onTakeOffer(Coin amount, + Coin txFee, + Coin takerFee, + boolean isCurrencyForTakerFeeBtc, + long tradePrice, + Coin fundsNeededForTrade, + Offer offer, + String paymentAccountId, + boolean useSavingsWallet, + @Nullable Long maxFundsForTrade) { +// TODO why tradePrice is a separate param instead of being read from offer.getPrice()? + /** + * - TODO actually instead of tradeResultHandler and errorMessageHandler this method should return completable future + * - TODO fundsNeededForTrade should be calculated here I think + * - TODO fundsNeededForTrade should be long as it is used in this form only + * - TODO check if user has enough funds here + * - TODO instead of coin we might use long + */ + CompletableFuture completableFuture = new CompletableFuture<>(); + try { + validateOnTakeOffer(amount, txFee, takerFee, tradePrice, fundsNeededForTrade, offer, paymentAccountId, maxFundsForTrade); + } catch (Exception e) { + completableFuture.completeExceptionally(e); + return completableFuture; + } + OfferAvailabilityModel model = getOfferAvailabilityModel(offer); + offer.checkOfferAvailability(model, () -> { +// TODO what if offer is in invalid state? +// TODO what if exception is thrown inside createTrade? + try { + if (offer.getState() == Offer.State.AVAILABLE) { + Trade trade = createTrade(amount, + txFee, + takerFee, + isCurrencyForTakerFeeBtc, + tradePrice, + fundsNeededForTrade, + offer, + paymentAccountId, + useSavingsWallet, + model); + completableFuture.complete(trade); + } else { + throw new ValidationException("Offer not available"); + } + } catch (Exception e) { + completableFuture.completeExceptionally(e); + } }, - errorMessageHandler::handleErrorMessage); - } - - private void createTrade(Coin amount, - Coin txFee, - Coin takerFee, - boolean isCurrencyForTakerFeeBtc, - long tradePrice, - Coin fundsNeededForTrade, - Offer offer, - String paymentAccountId, - boolean useSavingsWallet, - OfferAvailabilityModel model, - TradeResultHandler tradeResultHandler) { + errorMessage -> completableFuture.completeExceptionally(new TradeFailedException(errorMessage))); + return completableFuture; + } + + private void validateOnTakeOffer(Coin amount, + Coin txFee, + Coin takerFee, + long tradePrice, + Coin fundsNeededForTaker, + Offer offer, + String paymentAccountId, + @Nullable Long maxFundsForTrade) { + if (amount == null || !Coin.ZERO.isLessThan(amount)) { + throw new ValidationException("Amount must be a positive number"); + } + if (txFee == null || !Coin.ZERO.isLessThan(txFee)) { + throw new ValidationException("Transaction fee must be a positive number"); + } + if (takerFee == null || !Coin.ZERO.isLessThan(takerFee)) { + throw new ValidationException("Taker fee must be a positive number"); + } + if (tradePrice <= 0) { + throw new ValidationException("Trade price must be a positive number"); + } + PaymentAccount paymentAccount = user.getPaymentAccount(paymentAccountId); + if (paymentAccount == null) { + throw new ValidationException("Payment account for given id does not exist: " + paymentAccountId); + } + if (offer == null) { + throw new ValidationException("Offer must not be null"); + } + if (amount.isGreaterThan(offer.getAmount())) { + throw new ValidationException("Taken amount must not be higher than offer amount"); + } + String currencyCode = offer.getCurrencyCode(); + if (!CurrencyUtil.getTradeCurrency(currencyCode).isPresent()) { + throw new ValidationException("No such currency: " + currencyCode); + } + if (filterManager.isCurrencyBanned(currencyCode)) { + throw new ValidationException(Res.get("offerbook.warning.currencyBanned")); + } + if (filterManager.isPaymentMethodBanned(offer.getPaymentMethod())) { + throw new ValidationException(Res.get("offerbook.warning.paymentMethodBanned")); + } + if (filterManager.isOfferIdBanned(offer.getId())) { + throw new ValidationException(Res.get("offerbook.warning.offerBlocked")); + } + if (filterManager.isNodeAddressBanned(offer.getMakerNodeAddress())) { + throw new ValidationException(Res.get("offerbook.warning.nodeBlocked")); + } + if (offer.getMakerNodeAddress().equals(p2PService.getAddress())) { + throw new ValidationException("Taker's address same as maker's"); + } + if (!isPaymentAccountValidForOffer(offer, paymentAccount)) { + throw new ValidationException("PaymentAccount is not valid for offer, needs " + offer.getCurrencyCode()); + } + if (null != maxFundsForTrade && maxFundsForTrade < fundsNeededForTaker.longValue()) { + throw new ValidationException("Funds needed for traded calculated by Bisq exceed specified limit"); + } +// TODO shouldn't we compare available balance against funds needed for trade? + if (btcWalletService.getAvailableBalance().isLessThan(amount)) { + String errorMessage = String.format("Available balance %s is less than needed amount: %s", btcWalletService.getAvailableBalance().toString(), amount.toString()); + throw new ValidationException(errorMessage); + } + } + + private Trade createTrade(Coin amount, + Coin txFee, + Coin takerFee, + boolean isCurrencyForTakerFeeBtc, + long tradePrice, + Coin fundsNeededForTrade, + Offer offer, + String paymentAccountId, + boolean useSavingsWallet, + OfferAvailabilityModel model) { Trade trade; if (offer.isBuyOffer()) trade = new SellerAsTakerTrade(offer, @@ -466,7 +598,7 @@ private void createTrade(Coin amount, tradableList.add(trade); ((TakerTrade) trade).takeAvailableOffer(); - tradeResultHandler.handleResult(trade); + return trade; } private OfferAvailabilityModel getOfferAvailabilityModel(Offer offer) { diff --git a/core/src/main/java/bisq/core/trade/protocol/tasks/taker/CreateTakerFeeTx.java b/core/src/main/java/bisq/core/trade/protocol/tasks/taker/CreateTakerFeeTx.java index e0aa7b03e71..e874836acac 100644 --- a/core/src/main/java/bisq/core/trade/protocol/tasks/taker/CreateTakerFeeTx.java +++ b/core/src/main/java/bisq/core/trade/protocol/tasks/taker/CreateTakerFeeTx.java @@ -29,7 +29,6 @@ import bisq.core.trade.Trade; import bisq.core.trade.protocol.tasks.TradeTask; -import bisq.common.UserThread; import bisq.common.taskrunner.TaskRunner; import org.bitcoinj.core.Address; @@ -41,7 +40,6 @@ @Slf4j public class CreateTakerFeeTx extends TradeTask { - private Transaction tradeFeeTx; @SuppressWarnings({"WeakerAccess", "unused"}) public CreateTakerFeeTx(TaskRunner taskHandler, Trade trade) { @@ -65,7 +63,7 @@ protected void run() { Address changeAddress = changeAddressEntry.getAddress(); final TradeWalletService tradeWalletService = processModel.getTradeWalletService(); if (trade.isCurrencyForTakerFeeBtc()) { - tradeFeeTx = tradeWalletService.createBtcTradingFeeTx( + tradeWalletService.createBtcTradingFeeTx( fundingAddress, reservedForTradeAddress, changeAddress, @@ -77,20 +75,15 @@ protected void run() { new TxBroadcaster.Callback() { @Override public void onSuccess(Transaction transaction) { - // we delay one render frame to be sure we don't get called before the method call has - // returned (tradeFeeTx would be null in that case) - UserThread.execute(() -> { - if (!completed) { - processModel.setTakeOfferFeeTx(tradeFeeTx); - trade.setTakerFeeTxId(tradeFeeTx.getHashAsString()); - walletService.swapTradeEntryToAvailableEntry(id, AddressEntry.Context.OFFER_FUNDING); - trade.setState(Trade.State.TAKER_PUBLISHED_TAKER_FEE_TX); - - complete(); - } else { - log.warn("We got the onSuccess callback called after the timeout has been triggered a complete()."); - } - }); + if (!completed) { + processModel.setTakeOfferFeeTx(transaction); + trade.setTakerFeeTxId(transaction.getHashAsString()); + walletService.swapTradeEntryToAvailableEntry(id, AddressEntry.Context.OFFER_FUNDING); + trade.setState(Trade.State.TAKER_PUBLISHED_TAKER_FEE_TX); + complete(); + } else { + log.warn("We got the onSuccess callback called after the timeout has been triggered a complete()."); + } } @Override diff --git a/core/src/main/java/bisq/core/user/User.java b/core/src/main/java/bisq/core/user/User.java index f570c6b88f8..53f1f7e94b6 100644 --- a/core/src/main/java/bisq/core/user/User.java +++ b/core/src/main/java/bisq/core/user/User.java @@ -63,7 +63,7 @@ */ @Slf4j @AllArgsConstructor -public final class User implements PersistedDataHost { +public class User implements PersistedDataHost { final private Storage storage; final private KeyRing keyRing; @@ -342,7 +342,7 @@ public void removePriceAlertFilter() { /////////////////////////////////////////////////////////////////////////////////////////// @Nullable - public PaymentAccount getPaymentAccount(String paymentAccountId) { + public PaymentAccount getPaymentAccount(@Nullable String paymentAccountId) { Optional optional = userPayload.getPaymentAccounts() != null ? userPayload.getPaymentAccounts().stream().filter(e -> e.getId().equals(paymentAccountId)).findAny() : Optional.empty(); diff --git a/core/src/test/java/bisq/core/offer/TxFeeEstimationTest.java b/core/src/test/java/bisq/core/offer/TxFeeEstimationTest.java new file mode 100644 index 00000000000..b9d4db6d2eb --- /dev/null +++ b/core/src/test/java/bisq/core/offer/TxFeeEstimationTest.java @@ -0,0 +1,124 @@ +/* + * 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; + +import bisq.core.btc.wallet.BtcWalletService; + +import org.bitcoinj.core.Coin; +import org.bitcoinj.core.InsufficientMoneyException; + +import java.util.List; + +import org.powermock.core.classloader.annotations.PowerMockIgnore; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@RunWith(PowerMockRunner.class) +@PrepareForTest(BtcWalletService.class) +@PowerMockIgnore({"com.sun.org.apache.xerces.*", "javax.xml.*", "org.xml.*"}) +public class TxFeeEstimationTest { + + @Test + public void testGetEstimatedTxSize() throws InsufficientMoneyException { + List outputValues = List.of(Coin.valueOf(2000), Coin.valueOf(3000)); + int initialEstimatedTxSize; + Coin txFeePerByte; + BtcWalletService btcWalletService = mock(BtcWalletService.class); + int result; + int realTxSize; + Coin txFee; + + + initialEstimatedTxSize = 260; + txFeePerByte = Coin.valueOf(10); + realTxSize = 260; + + txFee = txFeePerByte.multiply(initialEstimatedTxSize); + when(btcWalletService.getEstimatedFeeTxSize(outputValues, txFee)).thenReturn(realTxSize); + result = TxFeeEstimation.getEstimatedTxSize(outputValues, initialEstimatedTxSize, txFeePerByte, btcWalletService); + assertEquals(260, result); + + + // TODO check how to use the mocking framework for repeated calls + // The btcWalletService.getEstimatedFeeTxSize returns 0 at repeated calls in the while loop.... + /* initialEstimatedTxSize = 260; + txFeePerByte = Coin.valueOf(10); + realTxSize = 2600; + + txFee = txFeePerByte.multiply(initialEstimatedTxSize); + when(btcWalletService.getEstimatedFeeTxSize(outputValues, txFee)).thenReturn(realTxSize); + result = TxFeeEstimation.getEstimatedTxSize(outputValues, initialEstimatedTxSize, txFeePerByte, btcWalletService); + assertEquals(2600, result); + + initialEstimatedTxSize = 2600; + txFeePerByte = Coin.valueOf(10); + realTxSize = 260; + + txFee = txFeePerByte.multiply(initialEstimatedTxSize); + when(btcWalletService.getEstimatedFeeTxSize(outputValues, txFee)).thenReturn(realTxSize); + result = TxFeeEstimation.getEstimatedTxSize(outputValues, initialEstimatedTxSize, txFeePerByte, btcWalletService); + assertEquals(260, result);*/ + } + + @Test + public void testIsInTolerance() { + int estimatedSize; + int txSize; + double tolerance; + boolean result; + + estimatedSize = 100; + txSize = 100; + tolerance = 0.0001; + result = TxFeeEstimation.isInTolerance(estimatedSize, txSize, tolerance); + assertTrue(result); + + estimatedSize = 100; + txSize = 200; + tolerance = 0.2; + result = TxFeeEstimation.isInTolerance(estimatedSize, txSize, tolerance); + assertFalse(result); + + estimatedSize = 120; + txSize = 100; + tolerance = 0.2; + result = TxFeeEstimation.isInTolerance(estimatedSize, txSize, tolerance); + assertTrue(result); + + estimatedSize = 200; + txSize = 100; + tolerance = 1; + result = TxFeeEstimation.isInTolerance(estimatedSize, txSize, tolerance); + assertTrue(result); + + estimatedSize = 201; + txSize = 100; + tolerance = 1; + result = TxFeeEstimation.isInTolerance(estimatedSize, txSize, tolerance); + assertFalse(result); + } +} diff --git a/core/src/test/java/bisq/core/trade/TradeManagerOnOfferTakeTest.java b/core/src/test/java/bisq/core/trade/TradeManagerOnOfferTakeTest.java new file mode 100644 index 00000000000..02c79130f71 --- /dev/null +++ b/core/src/test/java/bisq/core/trade/TradeManagerOnOfferTakeTest.java @@ -0,0 +1,767 @@ +package bisq.core.trade; + +import bisq.core.arbitration.Arbitrator; +import bisq.core.arbitration.ArbitratorManager; +import bisq.core.arbitration.Mediator; +import bisq.core.btc.model.AddressEntry; +import bisq.core.btc.model.InputsAndChangeOutput; +import bisq.core.btc.model.RawTransactionInput; +import bisq.core.btc.wallet.BsqWalletService; +import bisq.core.btc.wallet.BtcWalletService; +import bisq.core.btc.wallet.TradeWalletService; +import bisq.core.btc.wallet.TxBroadcaster; +import bisq.core.filter.FilterManager; +import bisq.core.locale.FiatCurrency; +import bisq.core.locale.Res; +import bisq.core.offer.Offer; +import bisq.core.offer.OfferPayload; +import bisq.core.offer.OpenOfferManager; +import bisq.core.payment.AccountAgeWitnessService; +import bisq.core.payment.AliPayAccount; +import bisq.core.payment.payload.PaymentMethod; +import bisq.core.provider.fee.FeeService; +import bisq.core.provider.price.PriceFeedService; +import bisq.core.trade.closed.ClosedTradableManager; +import bisq.core.trade.failed.FailedTradesManager; +import bisq.core.trade.statistics.ReferralIdService; +import bisq.core.trade.statistics.TradeStatisticsManager; +import bisq.core.user.Preferences; +import bisq.core.user.User; + +import bisq.network.p2p.NodeAddress; +import bisq.network.p2p.P2PService; + +import bisq.common.Clock; +import bisq.common.app.Version; +import bisq.common.crypto.KeyRing; +import bisq.common.crypto.PubKeyRing; +import bisq.common.crypto.Sig; +import bisq.common.handlers.ErrorMessageHandler; +import bisq.common.handlers.ResultHandler; +import bisq.common.proto.persistable.PersistenceProtoResolver; + +import org.bitcoinj.core.Coin; +import org.bitcoinj.core.Transaction; +import org.bitcoinj.crypto.DeterministicKey; + +import javafx.collections.FXCollections; + +import java.security.KeyPair; + +import java.nio.file.Files; + +import java.io.File; + +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +import org.jetbrains.annotations.NotNull; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + + + +import javax.validation.ValidationException; +import org.mockito.Mockito; + +public class TradeManagerOnOfferTakeTest { + + @Rule + public ExpectedException expectedException = ExpectedException.none(); + + private Arbitrator arbitratorA; + private Mediator mediatorA; + private KeyRing keyRing; + private BtcWalletService btcWalletService; + private BsqWalletService bsqWalletService; + private TradeWalletService tradeWalletService; + private OpenOfferManager openOfferManager; + private ClosedTradableManager closedTradableManager; + private FailedTradesManager failedTradesManager; + private FeeService feeService; + private P2PService p2PService; + private Preferences preferences; + private PriceFeedService priceFeedService; + private FilterManager filterManager; + private TradeStatisticsManager tradeStatisticsManager; + private ReferralIdService referralIdService; + private PersistenceProtoResolver persistenceProtoResolver; + private AccountAgeWitnessService accountAgeWitnessService; + private ArbitratorManager arbitratorManager; + private Clock clock; + private File storageDir; + private User user; + private AliPayAccount usersPaymentAccount; + private Offer offerToCreate; + + @Before + public void setUp() throws Exception { + Res.setup();//TODO this is ugly! We should be able inject Res instance as constructor param into TradeManager + PaymentMethod.onAllServicesInitialized(); + usersPaymentAccount = new AliPayAccount(); + usersPaymentAccount.init(); + usersPaymentAccount.addCurrency(new FiatCurrency("USD")); + Assert.assertNotNull(usersPaymentAccount.getId()); + + arbitratorA = new Arbitrator(new NodeAddress("arbitratorA", 0), null, null, null, null, 0, null, null, null, null, null); + mediatorA = new Mediator(new NodeAddress("mediatorA", 1), null, null, 0, null, null, null, null, null); + + keyRing = mock(KeyRing.class); + when(keyRing.getSignatureKeyPair()).thenReturn(Sig.generateKeyPair()); + KeyPair keyPair = Sig.generateKeyPair(); + PubKeyRing pubKeyRing = new PubKeyRing(keyPair.getPublic(), keyPair.getPublic(), null); + when(keyRing.getPubKeyRing()).thenReturn(pubKeyRing); + + btcWalletService = mock(BtcWalletService.class); + AddressEntry addressEntry = new AddressEntry(mock(DeterministicKey.class), AddressEntry.Context.OFFER_FUNDING); +// TODO stub more precisely, ideally with exact values or at least matchers + when(btcWalletService.getOrCreateAddressEntry(any(), any())).thenReturn(addressEntry); + when(btcWalletService.getFreshAddressEntry()).thenReturn(addressEntry); + when(btcWalletService.getAvailableBalance()).thenReturn(Coin.SATOSHI); + + bsqWalletService = null; + openOfferManager = null; + closedTradableManager = null; + failedTradesManager = null; + feeService = null; + p2PService = mock(P2PService.class); + preferences = null; + priceFeedService = null; + filterManager = mock(FilterManager.class); + + tradeWalletService = mock(TradeWalletService.class); + when(tradeWalletService.createBtcTradingFeeTx(any(), any(), any(), any(), anyBoolean(), any(), any(), any(), any())).thenAnswer(invocation -> { + Transaction transactionMock = mock(Transaction.class); + when(transactionMock.getHashAsString()).thenReturn("transactionHashAsString"); + TxBroadcaster.Callback callback = invocation.getArgument(8); + callback.onSuccess(transactionMock); + return transactionMock; + }); + when(tradeWalletService.takerCreatesDepositsTxInputs(any(), any(), any(), any())).thenReturn(new InputsAndChangeOutput(Collections.singletonList(new RawTransactionInput(0, new byte[0], 0)), 0, null)); + + + tradeStatisticsManager = mock(TradeStatisticsManager.class); + when(tradeStatisticsManager.getObservableTradeStatisticsSet()).thenReturn(FXCollections.observableSet()); + + referralIdService = null; + persistenceProtoResolver = null; + accountAgeWitnessService = null; + + arbitratorManager = mock(ArbitratorManager.class); + when(arbitratorManager.getArbitratorsObservableMap()).thenReturn(FXCollections.observableMap(Collections.singletonMap(arbitratorA.getNodeAddress(), arbitratorA))); + + clock = null; + +// TODO it would be good if this test did not create any files + storageDir = Files.createTempDirectory("tradeManagerTest").toFile(); + + user = mock(User.class); + when(user.getPaymentAccount(usersPaymentAccount.getId())).thenReturn(usersPaymentAccount); + when(user.getAcceptedArbitratorAddresses()).thenReturn(Collections.singletonList(arbitratorA.getNodeAddress())); + when(user.getAcceptedArbitratorByAddress(arbitratorA.getNodeAddress())).thenReturn(arbitratorA); + when(user.getAcceptedMediatorByAddress(mediatorA.getNodeAddress())).thenReturn(mediatorA); + when(user.getAcceptedMediatorAddresses()).thenReturn(Collections.singletonList(mediatorA.getNodeAddress())); + when(user.getAccountId()).thenReturn("userAccountId"); + + offerToCreate = createOffer(OfferPayload.Direction.SELL, UUID.randomUUID().toString(), arbitratorA, mediatorA); + } + + @Test + public void onTakeOffer_paymentAccountIdIsNull_completesExceptionally() { +// Given + Class expectedExceptionClass = ValidationException.class; + String expectedExceptionMessage = "Payment account for given id does not exist: null"; + OnTakeOfferParams params = getValidParams(); + params.paymentAccountId = null; + +// When + CompletableFuture completableFuture = onTakeOffer(params); + +// Then + assertCompletedExceptionally(completableFuture, expectedExceptionClass, expectedExceptionMessage); + } + + @Test + public void onTakeOffer_noPaymentAccountForGivenId_completesExceptionally() { +// Given + Class expectedExceptionClass = ValidationException.class; + String expectedExceptionMessage = "Payment account for given id does not exist: xyz"; + OnTakeOfferParams params = getValidParams(); + params.paymentAccountId = "xyz"; + +// When + CompletableFuture completableFuture = onTakeOffer(params); + +// Then + assertCompletedExceptionally(completableFuture, expectedExceptionClass, expectedExceptionMessage); + } + + @Test + public void onTakeOffer_paymentAccountNotValidForOffer_completesExceptionally() { +// Given + Class expectedExceptionClass = ValidationException.class; + String expectedExceptionMessage = "PaymentAccount is not valid for offer, needs PLN"; + OnTakeOfferParams params = getValidParams(); + when(offerToCreate.getCurrencyCode()).thenReturn("PLN"); + +// When + CompletableFuture completableFuture = onTakeOffer(params); + +// Then + assertCompletedExceptionally(completableFuture, expectedExceptionClass, expectedExceptionMessage); + } + + @Test + public void onTakeOffer_offerIsNull_completesExceptionally() { +// Given + Class expectedExceptionClass = ValidationException.class; + String expectedExceptionMessage = "Offer must not be null"; + OnTakeOfferParams params = getValidParams(); + params.offer = null; + +// When + CompletableFuture completableFuture = onTakeOffer(params); + +// Then + assertCompletedExceptionally(completableFuture, expectedExceptionClass, expectedExceptionMessage); + } + + @Test + public void onTakeOffer_currencyCodeIsNull_completesExceptionally() { +// Given + Class expectedExceptionClass = ValidationException.class; + String expectedExceptionMessage = "No such currency: null"; + OnTakeOfferParams params = getValidParams(); + when(params.offer.getCurrencyCode()).thenReturn(null); + +// When + CompletableFuture completableFuture = onTakeOffer(params); + +// Then + assertCompletedExceptionally(completableFuture, expectedExceptionClass, expectedExceptionMessage); + } + + @Test + public void onTakeOffer_currencyCodeReferencesNonExistingCurrency_completesExceptionally() { +// Given + Class expectedExceptionClass = ValidationException.class; + String expectedExceptionMessage = "No such currency: nonExistent"; + OnTakeOfferParams params = getValidParams(); + when(params.offer.getCurrencyCode()).thenReturn("nonExistent"); + +// When + CompletableFuture completableFuture = onTakeOffer(params); + +// Then + assertCompletedExceptionally(completableFuture, expectedExceptionClass, expectedExceptionMessage); + } + + @Test + public void onTakeOffer_currencyIsBanned_completesExceptionally() { +// Given + Class expectedExceptionClass = ValidationException.class; + String expectedExceptionMessage = Res.get("offerbook.warning.currencyBanned"); + Assert.assertNotNull(expectedExceptionMessage); + OnTakeOfferParams params = getValidParams(); + when(filterManager.isCurrencyBanned(params.offer.getCurrencyCode())).thenReturn(true); + +// When + CompletableFuture completableFuture = onTakeOffer(params); + +// Then + assertCompletedExceptionally(completableFuture, expectedExceptionClass, expectedExceptionMessage); + } + + @Test + public void onTakeOffer_paymentMethodIsBanned_completesExceptionally() { +// Given + Class expectedExceptionClass = ValidationException.class; + String expectedExceptionMessage = Res.get("offerbook.warning.paymentMethodBanned"); + Assert.assertNotNull(expectedExceptionMessage); + OnTakeOfferParams params = getValidParams(); + when(filterManager.isPaymentMethodBanned(params.offer.getPaymentMethod())).thenReturn(true); + +// When + CompletableFuture completableFuture = onTakeOffer(params); + +// Then + assertCompletedExceptionally(completableFuture, expectedExceptionClass, expectedExceptionMessage); + } + + @Test + public void onTakeOffer_offerIdIsBanned_completesExceptionally() { +// Given + Class expectedExceptionClass = ValidationException.class; + String expectedExceptionMessage = Res.get("offerbook.warning.offerBlocked"); + Assert.assertNotNull(expectedExceptionMessage); + OnTakeOfferParams params = getValidParams(); + when(filterManager.isOfferIdBanned(params.offer.getId())).thenReturn(true); + +// When + CompletableFuture completableFuture = onTakeOffer(params); + +// Then + assertCompletedExceptionally(completableFuture, expectedExceptionClass, expectedExceptionMessage); + } + + @Test + public void onTakeOffer_makerNodeAddressIsBanned_completesExceptionally() { +// Given + Class expectedExceptionClass = ValidationException.class; + String expectedExceptionMessage = Res.get("offerbook.warning.nodeBlocked"); + Assert.assertNotNull(expectedExceptionMessage); + OnTakeOfferParams params = getValidParams(); + when(filterManager.isNodeAddressBanned(params.offer.getMakerNodeAddress())).thenReturn(true); + +// When + CompletableFuture completableFuture = onTakeOffer(params); + +// Then + assertCompletedExceptionally(completableFuture, expectedExceptionClass, expectedExceptionMessage); + } + + @Test + public void onTakeOffer_makerNodeAddressSameAsTaker_completesExceptionally() { +// Given + Class expectedExceptionClass = ValidationException.class; + String expectedExceptionMessage = "Taker's address same as maker's"; + OnTakeOfferParams params = getValidParams(); + NodeAddress myNodeAddress = new NodeAddress("me", 0); + when(p2PService.getAddress()).thenReturn(myNodeAddress); + when(offerToCreate.getMakerNodeAddress()).thenReturn(myNodeAddress); + +// When + CompletableFuture completableFuture = onTakeOffer(params); + +// Then + assertCompletedExceptionally(completableFuture, expectedExceptionClass, expectedExceptionMessage); + } + + @Test + public void onTakeOffer_amountIsNull_completesExceptionally() { +// Given + Class expectedExceptionClass = ValidationException.class; + String expectedExceptionMessage = "Amount must be a positive number"; + OnTakeOfferParams params = getValidParams(); + params.amount = null; + +// When + CompletableFuture completableFuture = onTakeOffer(params); + +// Then + assertCompletedExceptionally(completableFuture, expectedExceptionClass, expectedExceptionMessage); + } + + @Test + public void onTakeOffer_amountIsNegative_completesExceptionally() { +// Given + Class expectedExceptionClass = ValidationException.class; + String expectedExceptionMessage = "Amount must be a positive number"; + OnTakeOfferParams params = getValidParams(); + params.amount = Coin.valueOf(-1); + +// When + CompletableFuture completableFuture = onTakeOffer(params); + +// Then + assertCompletedExceptionally(completableFuture, expectedExceptionClass, expectedExceptionMessage); + } + + @Test + public void onTakeOffer_amountIsZero_completesExceptionally() { +// Given + Class expectedExceptionClass = ValidationException.class; + String expectedExceptionMessage = "Amount must be a positive number"; + OnTakeOfferParams params = getValidParams(); + params.amount = Coin.ZERO; + +// When + CompletableFuture completableFuture = onTakeOffer(params); + +// Then + assertCompletedExceptionally(completableFuture, expectedExceptionClass, expectedExceptionMessage); + } + + @Test + public void onTakeOffer_amountHigherThanOfferAmount_completesExceptionally() { +// Given + Class expectedExceptionClass = ValidationException.class; + String expectedExceptionMessage = "Taken amount must not be higher than offer amount"; + OnTakeOfferParams params = getValidParams(); + params.amount = params.offer.getAmount().add(Coin.SATOSHI); + Assert.assertTrue(params.amount.isGreaterThan(params.offer.getAmount())); + +// When + CompletableFuture completableFuture = onTakeOffer(params); + +// Then + assertCompletedExceptionally(completableFuture, expectedExceptionClass, expectedExceptionMessage); + } + + @Test + public void onTakeOffer_txFeeIsNull_completesExceptionally() { +// Given + Class expectedExceptionClass = ValidationException.class; + String expectedExceptionMessage = "Transaction fee must be a positive number"; + OnTakeOfferParams params = getValidParams(); + params.txFee = null; + +// When + CompletableFuture completableFuture = onTakeOffer(params); + +// Then + assertCompletedExceptionally(completableFuture, expectedExceptionClass, expectedExceptionMessage); + } + + @Test + public void onTakeOffer_txFeeIsZero_completesExceptionally() { +// Given + Class expectedExceptionClass = ValidationException.class; + String expectedExceptionMessage = "Transaction fee must be a positive number"; + OnTakeOfferParams params = getValidParams(); + params.txFee = Coin.ZERO; + +// When + CompletableFuture completableFuture = onTakeOffer(params); + +// Then + assertCompletedExceptionally(completableFuture, expectedExceptionClass, expectedExceptionMessage); + } + + @Test + public void onTakeOffer_txFeeIsNegative_completesExceptionally() { +// Given + Class expectedExceptionClass = ValidationException.class; + String expectedExceptionMessage = "Transaction fee must be a positive number"; + OnTakeOfferParams params = getValidParams(); + params.txFee = Coin.valueOf(-1); + +// When + CompletableFuture completableFuture = onTakeOffer(params); + +// Then + assertCompletedExceptionally(completableFuture, expectedExceptionClass, expectedExceptionMessage); + } + + @Test + public void onTakeOffer_takerFeeIsNull_completesExceptionally() { +// Given + Class expectedExceptionClass = ValidationException.class; + String expectedExceptionMessage = "Taker fee must be a positive number"; + OnTakeOfferParams params = getValidParams(); + params.takerFee = null; + +// When + CompletableFuture completableFuture = onTakeOffer(params); + +// Then + assertCompletedExceptionally(completableFuture, expectedExceptionClass, expectedExceptionMessage); + } + + @Test + public void onTakeOffer_takerFeeIsZero_completesExceptionally() { +// Given + Class expectedExceptionClass = ValidationException.class; + String expectedExceptionMessage = "Taker fee must be a positive number"; + OnTakeOfferParams params = getValidParams(); + params.takerFee = Coin.ZERO; + +// When + CompletableFuture completableFuture = onTakeOffer(params); + +// Then + assertCompletedExceptionally(completableFuture, expectedExceptionClass, expectedExceptionMessage); + } + + @Test + public void onTakeOffer_takerFeeIsNegative_completesExceptionally() { +// Given + Class expectedExceptionClass = ValidationException.class; + String expectedExceptionMessage = "Taker fee must be a positive number"; + OnTakeOfferParams params = getValidParams(); + params.takerFee = Coin.valueOf(-1); + +// When + CompletableFuture completableFuture = onTakeOffer(params); + +// Then + assertCompletedExceptionally(completableFuture, expectedExceptionClass, expectedExceptionMessage); + } + + @Test + public void onTakeOffer_tradePriceIsNegative_completesExceptionally() { +// Given + Class expectedExceptionClass = ValidationException.class; + String expectedExceptionMessage = "Trade price must be a positive number"; + OnTakeOfferParams params = getValidParams(); + params.tradePrice = -1; + +// When + CompletableFuture completableFuture = onTakeOffer(params); + +// Then + assertCompletedExceptionally(completableFuture, expectedExceptionClass, expectedExceptionMessage); + } + + @Test + public void onTakeOffer_tradePriceIsZero_completesExceptionally() { +// Given + Class expectedExceptionClass = ValidationException.class; + String expectedExceptionMessage = "Trade price must be a positive number"; + OnTakeOfferParams params = getValidParams(); + params.tradePrice = 0; + +// When + CompletableFuture completableFuture = onTakeOffer(params); + +// Then + assertCompletedExceptionally(completableFuture, expectedExceptionClass, expectedExceptionMessage); + } + + + @Test + public void onTakeOffer_offerIsNotAvailable_completesExceptionally() { +// Given + Class expectedExceptionClass = ValidationException.class; + String expectedExceptionMessage = "Offer not available"; + OnTakeOfferParams params = getValidParams(); + when(params.offer.getState()).thenReturn(Offer.State.MAKER_OFFLINE); + + doAnswer(invocation -> { + ((ResultHandler) invocation.getArgument(1)).handleResult(); + return null; + }).when(offerToCreate).checkOfferAvailability(any(), any(), any()); + +// When + CompletableFuture completableFuture = onTakeOffer(params); + +// Then + assertCompletedExceptionally(completableFuture, expectedExceptionClass, expectedExceptionMessage); + } + + @Test + public void onTakeOffer_checkOfferAvailabilityFails_completesExceptionally() { +// Given + Class expectedExceptionClass = TradeFailedException.class; + String expectedExceptionMessage = "errorMessage" + new Date().toString(); + OnTakeOfferParams params = getValidParams(); + doAnswer(invocation -> { + ErrorMessageHandler handler = invocation.getArgument(2); + handler.handleErrorMessage(expectedExceptionMessage); + return null; + }).when(params.offer).checkOfferAvailability(any(), any(), any()); + +// When + CompletableFuture completableFuture = onTakeOffer(params); + +// Then + assertCompletedExceptionally(completableFuture, expectedExceptionClass, expectedExceptionMessage); + } + + // TODO do Tasks in BuyerAsTakerProtocol execute synchronously + @Test + public void onTakeOffer_firstAttemptToWriteATest_takeBuyOffer() throws Exception { +// TODO why User is final? We cannot mock it if it's final. +// Given + offerToCreate = createOffer(OfferPayload.Direction.BUY, UUID.randomUUID().toString(), arbitratorA, mediatorA); + doAnswer(invocation -> { + ((ResultHandler) invocation.getArgument(1)).handleResult(); + return null; + }).when(offerToCreate).checkOfferAvailability(any(), any(), any()); + + OnTakeOfferParams params = getValidParams(); + +// When + Trade trade = onTakeOffer(params).get(); + +// Then +// TODO what properties should the trade have? + Assert.assertNotNull(trade); + Assert.assertTrue(trade instanceof SellerAsTakerTrade); + Assert.assertNull("Trade should not have error property set", trade.getErrorMessage()); +// TODO what should happen as th e result of onTakeOffer call? What mocks should be called? +// Following instructions can be used what calls are actually made +// verifyNoMoreInteractions(p2pService); +// verifyNoMoreInteractions(user); +// verifyNoMoreInteractions(tradeStatisticsManager); +// verifyNoMoreInteractions(arbitratorManager); +// verifyNoMoreInteractions(btcWalletServiceMock); +// verifyNoMoreInteractions(tradeWalletServiceMock); + } + @Test + public void onTakeOffer_firstAttemptToWriteATest_takeSellOffer() throws Exception { +// TODO why User is final? We cannot mock it if it's final. +// Given + offerToCreate = createOffer(OfferPayload.Direction.SELL, UUID.randomUUID().toString(), arbitratorA, mediatorA); + doAnswer(invocation -> { + ((ResultHandler) invocation.getArgument(1)).handleResult(); + return null; + }).when(offerToCreate).checkOfferAvailability(any(), any(), any()); + + OnTakeOfferParams params = getValidParams(); + +// When + Trade trade = onTakeOffer(params).get(); + +// Then +// TODO what properties should the trade have? + Assert.assertNotNull(trade); + Assert.assertTrue(trade instanceof BuyerAsTakerTrade); + Assert.assertNull("Trade should not have error property set", trade.getErrorMessage()); +// TODO what should happen as th e result of onTakeOffer call? What mocks should be called? +// Following instructions can be used what calls are actually made +// verifyNoMoreInteractions(p2pService); +// verifyNoMoreInteractions(user); +// verifyNoMoreInteractions(tradeStatisticsManager); +// verifyNoMoreInteractions(arbitratorManager); +// verifyNoMoreInteractions(btcWalletServiceMock); +// verifyNoMoreInteractions(tradeWalletServiceMock); + } + + private void assertCompletedExceptionally(CompletableFuture completableFuture, Class exceptionClass, String exceptionMessage) { + Assert.assertTrue("Should complete exceptionally", completableFuture.isCompletedExceptionally()); + try { + completableFuture.get(); + Assert.fail("Expected exception:" + exceptionClass.getName() + " " + exceptionMessage); + } catch (Exception e) { + Throwable cause = e.getCause(); + Assert.assertEquals(exceptionClass, cause.getClass()); + Assert.assertEquals(exceptionMessage, cause.getMessage()); + } + } + + private CompletableFuture onTakeOffer(@NotNull OnTakeOfferParams params) { + return getTradeManager().onTakeOffer(params.amount, + params.txFee, + params.takerFee, + params.isCurrencyForTakerFeeBtc, + params.tradePrice, + params.fundsNeededForTrade, + params.offer, + params.paymentAccountId, + params.useSavingsWallet, + params.maxFundsForTrade); + } + + @NotNull + private TradeManager getTradeManager() { + TradeManager tradeManager = new TradeManager(user, keyRing, btcWalletService, bsqWalletService, tradeWalletService, openOfferManager, closedTradableManager, failedTradesManager, feeService, p2PService, preferences, priceFeedService, filterManager, tradeStatisticsManager, referralIdService, persistenceProtoResolver, accountAgeWitnessService, arbitratorManager, clock, storageDir); + tradeManager.readPersisted(); + return tradeManager; + } + + private OnTakeOfferParams getValidParams() { + OnTakeOfferParams params = new OnTakeOfferParams(); + params.amount = Coin.SATOSHI; + params.txFee = Coin.SATOSHI; + params.takerFee = Coin.SATOSHI; + params.isCurrencyForTakerFeeBtc = true; + params.tradePrice = 1; + + params.fundsNeededForTrade = Coin.ZERO; + params.offer = offerToCreate; + params.paymentAccountId = usersPaymentAccount.getId(); + params.useSavingsWallet = false; + return params; + } + + private Offer createOffer(OfferPayload.Direction direction, String offerId, Arbitrator arbitrator, Mediator mediator) { + long now = new Date().getTime(); + int price = 1; + double marketPriceMargin = 0.1; + boolean useMarketBasedPrice = false; + int amount = 1; + int minAmount = 1; + String baseCurrencyCode = "BTC"; + String counterCurrencyCode = "USD"; + long lastBlockSeenHeight = 1; + int txFee = 0; + int makerFee = 0; + boolean isCurrencyForMakerFeeBtc = false; + int buyerSecurityDeposit = 0; + int sellerSecurityDeposit = 0; + int maxTradeLimit = 0; + int maxTradePeriod = 0; + boolean useAutoClose = false; + boolean useReOpenAfterAutoClose = false; + int lowerClosePrice = 0; + int upperClosePrice = 0; + boolean isPrivateOffer = false; + String hashOfChallenge = null; + Map extraDataMap = null; + KeyPair keyPair = Sig.generateKeyPair(); + PubKeyRing pubKeyRing = new PubKeyRing(keyPair.getPublic(), keyPair.getPublic(), null); + List arbitrators = Collections.singletonList(arbitrator.getNodeAddress()); + List mediators = Collections.singletonList(mediator.getNodeAddress()); + OfferPayload offerPayload = new OfferPayload(offerId, + now, + new NodeAddress("0", 0), + pubKeyRing, + direction, + price, + marketPriceMargin, + useMarketBasedPrice, + amount, + minAmount, + baseCurrencyCode, + counterCurrencyCode, + arbitrators, + mediators, + PaymentMethod.ALI_PAY_ID, + "paymentAccountId", + null, + null, + null, + null, + null, + Version.VERSION, + lastBlockSeenHeight, + txFee, + makerFee, + isCurrencyForMakerFeeBtc, + buyerSecurityDeposit, + sellerSecurityDeposit, + maxTradeLimit, + maxTradePeriod, + useAutoClose, + useReOpenAfterAutoClose, + lowerClosePrice, + upperClosePrice, + isPrivateOffer, + hashOfChallenge, + extraDataMap, + Version.TRADE_PROTOCOL_VERSION); + Offer offer = new Offer(offerPayload); + offer.setState(Offer.State.AVAILABLE); + offer.setOfferFeePaymentTxId("abc"); +// TODO this is sick, offer should not include logic like checkOfferAvailability + return Mockito.spy(offer); + } + + private class OnTakeOfferParams { + Long maxFundsForTrade; + Coin amount; + Coin txFee; + Coin takerFee; + boolean isCurrencyForTakerFeeBtc; + long tradePrice; + Coin fundsNeededForTrade; + Offer offer; + String paymentAccountId; + boolean useSavingsWallet; + } +} diff --git a/desktop/src/main/java/bisq/desktop/app/BisqAppMain.java b/desktop/src/main/java/bisq/desktop/app/BisqAppMain.java index dd54b0b542a..7d8bbffd15a 100644 --- a/desktop/src/main/java/bisq/desktop/app/BisqAppMain.java +++ b/desktop/src/main/java/bisq/desktop/app/BisqAppMain.java @@ -59,6 +59,10 @@ public static void main(String[] args) throws Exception { } } + @Override + public void onSetupComplete() { + log.info("onSetupComplete"); + } /////////////////////////////////////////////////////////////////////////////////////////// // First synchronous execution tasks diff --git a/desktop/src/main/java/bisq/desktop/main/offer/MutableOfferDataModel.java b/desktop/src/main/java/bisq/desktop/main/offer/MutableOfferDataModel.java index 4b7ad4a946a..47f20f125f1 100644 --- a/desktop/src/main/java/bisq/desktop/main/offer/MutableOfferDataModel.java +++ b/desktop/src/main/java/bisq/desktop/main/offer/MutableOfferDataModel.java @@ -36,6 +36,7 @@ import bisq.core.offer.OfferPayload; import bisq.core.offer.OfferUtil; import bisq.core.offer.OpenOfferManager; +import bisq.core.offer.TxFeeEstimation; import bisq.core.payment.AccountAgeWitnessService; import bisq.core.payment.BankAccount; import bisq.core.payment.CountryBasedPaymentAccount; @@ -59,11 +60,10 @@ import bisq.common.app.Version; import bisq.common.crypto.KeyRing; +import bisq.common.util.Tuple2; import bisq.common.util.Utilities; -import org.bitcoinj.core.Address; import org.bitcoinj.core.Coin; -import org.bitcoinj.core.InsufficientMoneyException; import org.bitcoinj.core.Transaction; import com.google.inject.Inject; @@ -139,7 +139,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel implements Bs protected double marketPriceMargin = 0; protected Coin txFeeFromFeeService = Coin.ZERO; protected boolean marketPriceAvailable; - protected int feeTxSize = 260; // size of typical tx with 1 input + protected int feeTxSize = TxFeeEstimation.TYPICAL_TX_WITH_1_INPUT_SIZE; protected int feeTxSizeEstimationRecursionCounter; protected boolean allowAmountUpdate = true; @@ -443,57 +443,17 @@ Offer createAndGetOffer() { // This works only if we have already funds in the wallet public void estimateTxSize() { - txFeeFromFeeService = feeService.getTxFee(feeTxSize); - Address fundingAddress = btcWalletService.getFreshAddressEntry().getAddress(); - Address reservedForTradeAddress = btcWalletService.getOrCreateAddressEntry(offerId, AddressEntry.Context.RESERVED_FOR_TRADE).getAddress(); - Address changeAddress = btcWalletService.getFreshAddressEntry().getAddress(); - Coin reservedFundsForOffer = getSecurityDeposit(); if (!isBuyOffer()) reservedFundsForOffer = reservedFundsForOffer.add(amount.get()); - checkNotNull(user.getAcceptedArbitrators(), "user.getAcceptedArbitrators() must not be null"); - checkArgument(!user.getAcceptedArbitrators().isEmpty(), "user.getAcceptedArbitrators() must not be empty"); - String dummyArbitratorAddress = user.getAcceptedArbitrators().get(0).getBtcAddress(); - try { - log.info("We create a dummy tx to see if our estimated size is in the accepted range. feeTxSize={}," + - " txFee based on feeTxSize: {}, recommended txFee is {} sat/byte", - feeTxSize, txFeeFromFeeService.toFriendlyString(), feeService.getTxFeePerByte()); - Transaction tradeFeeTx = tradeWalletService.estimateBtcTradingFeeTxSize( - fundingAddress, - reservedForTradeAddress, - changeAddress, - reservedFundsForOffer, - true, - getMakerFee(), - txFeeFromFeeService, - dummyArbitratorAddress); - - final int txSize = tradeFeeTx.bitcoinSerialize().length; - // use feeTxSizeEstimationRecursionCounter to avoid risk for endless loop - if (txSize > feeTxSize * 1.2 && feeTxSizeEstimationRecursionCounter < 10) { - feeTxSizeEstimationRecursionCounter++; - log.info("txSize is {} bytes but feeTxSize used for txFee calculation was {} bytes. We try again with an " + - "adjusted txFee to reach the target tx fee.", txSize, feeTxSize); - feeTxSize = txSize; - txFeeFromFeeService = feeService.getTxFee(feeTxSize); - // lets try again with the adjusted txSize and fee. - estimateTxSize(); - } else { - log.info("feeTxSize {} bytes", feeTxSize); - log.info("txFee based on estimated size: {}, recommended txFee is {} sat/byte", - txFeeFromFeeService.toFriendlyString(), feeService.getTxFeePerByte()); - } - } catch (InsufficientMoneyException e) { - // If we need to fund from an external wallet we can assume we only have 1 input (260 bytes). - log.warn("We cannot do the fee estimation because there are not enough funds in the wallet. This is expected " + - "if the user pays from an external wallet. In that case we use an estimated tx size of 260 bytes."); - feeTxSize = 260; - txFeeFromFeeService = feeService.getTxFee(feeTxSize); - log.info("feeTxSize {} bytes", feeTxSize); - log.info("txFee based on estimated size: {}, recommended txFee is {} sat/byte", - txFeeFromFeeService.toFriendlyString(), feeService.getTxFeePerByte()); - } + Tuple2 estimatedFeeAndTxSize = TxFeeEstimation.getEstimatedFeeAndTxSizeForMaker(reservedFundsForOffer, + getMakerFee(), + feeService, + btcWalletService, + preferences); + txFeeFromFeeService = estimatedFeeAndTxSize.first; + feeTxSize = estimatedFeeAndTxSize.second; } void onPlaceOffer(Offer offer, TransactionResultHandler resultHandler) { diff --git a/desktop/src/main/java/bisq/desktop/main/offer/takeoffer/TakeOfferDataModel.java b/desktop/src/main/java/bisq/desktop/main/offer/takeoffer/TakeOfferDataModel.java index 2d47c5f3321..7aba93fa27e 100644 --- a/desktop/src/main/java/bisq/desktop/main/offer/takeoffer/TakeOfferDataModel.java +++ b/desktop/src/main/java/bisq/desktop/main/offer/takeoffer/TakeOfferDataModel.java @@ -35,6 +35,7 @@ import bisq.core.offer.Offer; import bisq.core.offer.OfferPayload; import bisq.core.offer.OfferUtil; +import bisq.core.offer.TxFeeEstimation; import bisq.core.payment.AccountAgeWitnessService; import bisq.core.payment.HalCashAccount; import bisq.core.payment.PaymentAccount; @@ -42,15 +43,16 @@ import bisq.core.payment.payload.PaymentMethod; import bisq.core.provider.fee.FeeService; import bisq.core.provider.price.PriceFeedService; +import bisq.core.trade.Trade; import bisq.core.trade.TradeManager; import bisq.core.trade.handlers.TradeResultHandler; import bisq.core.user.Preferences; import bisq.core.user.User; import bisq.core.util.CoinUtil; -import org.bitcoinj.core.Address; +import bisq.common.util.Tuple2; + import org.bitcoinj.core.Coin; -import org.bitcoinj.core.InsufficientMoneyException; import org.bitcoinj.core.Transaction; import org.bitcoinj.wallet.Wallet; @@ -64,6 +66,9 @@ import java.util.List; import java.util.Set; +import java.util.concurrent.CompletableFuture; + +import org.jetbrains.annotations.NotNull; import javax.annotation.Nullable; @@ -287,7 +292,7 @@ void onTakeOffer(TradeResultHandler tradeResultHandler) { checkNotNull(txFeeFromFeeService, "txFeeFromFeeService must not be null"); checkNotNull(getTakerFee(), "takerFee must not be null"); - Coin fundsNeededForTrade = getSecurityDeposit().add(txFeeFromFeeService).add(txFeeFromFeeService); + Coin fundsNeededForTrade = getFundsNeededForTrade(); if (isBuyOffer()) fundsNeededForTrade = fundsNeededForTrade.add(amount.get()); @@ -300,7 +305,7 @@ void onTakeOffer(TradeResultHandler tradeResultHandler) { } else if (filterManager.isNodeAddressBanned(offer.getMakerNodeAddress())) { new Popup<>().warning(Res.get("offerbook.warning.nodeBlocked")).show(); } else { - tradeManager.onTakeOffer(amount.get(), + CompletableFuture futureTrade = tradeManager.onTakeOffer(amount.get(), txFeeFromFeeService, getTakerFee(), isCurrencyForTakerFeeBtc(), @@ -309,12 +314,14 @@ void onTakeOffer(TradeResultHandler tradeResultHandler) { offer, paymentAccount.getId(), useSavingsWallet, - tradeResultHandler, - errorMessage -> { - log.warn(errorMessage); - new Popup<>().warning(errorMessage).show(); - } + null ); + futureTrade.thenAccept(tradeResultHandler::handleResult); + futureTrade.exceptionally(throwable -> { + log.warn(throwable.getMessage(), throwable); + new Popup<>().warning(throwable.getMessage()).show(); + return null; + }); } } @@ -328,82 +335,46 @@ void onTakeOffer(TradeResultHandler tradeResultHandler) { // leading to a smaller tx and too high fees. Simply updating the fee estimation would lead to changed required funds // and if funds get higher (if tx get larger) the user would get confused (adding small inputs would increase total required funds). // So that would require more thoughts how to deal with all those cases. - private void estimateTxSize() { - Address fundingAddress = btcWalletService.getFreshAddressEntry().getAddress(); - int txSize = 0; - if (btcWalletService.getBalance(Wallet.BalanceType.AVAILABLE).isPositive()) { - txFeeFromFeeService = getTxFeeBySize(feeTxSize); - - Address reservedForTradeAddress = btcWalletService.getOrCreateAddressEntry(offer.getId(), AddressEntry.Context.RESERVED_FOR_TRADE).getAddress(); - Address changeAddress = btcWalletService.getFreshAddressEntry().getAddress(); - - Coin reservedFundsForOffer = getSecurityDeposit().add(txFeeFromFeeService).add(txFeeFromFeeService); - if (isBuyOffer()) - reservedFundsForOffer = reservedFundsForOffer.add(amount.get()); - - checkNotNull(user.getAcceptedArbitrators(), "user.getAcceptedArbitrators() must not be null"); - checkArgument(!user.getAcceptedArbitrators().isEmpty(), "user.getAcceptedArbitrators() must not be empty"); - String dummyArbitratorAddress = user.getAcceptedArbitrators().get(0).getBtcAddress(); - try { - log.debug("We create a dummy tx to see if our estimated size is in the accepted range. feeTxSize={}," + - " txFee based on feeTxSize: {}, recommended txFee is {} sat/byte", - feeTxSize, txFeeFromFeeService.toFriendlyString(), feeService.getTxFeePerByte()); - Transaction tradeFeeTx = tradeWalletService.estimateBtcTradingFeeTxSize( - fundingAddress, - reservedForTradeAddress, - changeAddress, - reservedFundsForOffer, - true, + public void estimateTxSize() { + int txSize = 0; + if (btcWalletService.getBalance(Wallet.BalanceType.AVAILABLE).isPositive()) { + Coin fundsNeededForTrade = getFundsNeededForTrade(); + if (isBuyOffer()) + fundsNeededForTrade = fundsNeededForTrade.add(amount.get()); + + // As taker we pay 3 times the fee and currently the fee is the same for all 3 txs (trade fee tx, deposit + // tx and payout tx). + // We should try to change that in future to have the deposit and payout tx with a fixed fee as the size is + // there more deterministic. + // The trade fee tx can be in the worst case very large if there are many inputs so if we take that tx alone + // for the fee estimation we would overpay a lot. + // On the other side if we have the best case of a 1 input tx fee tx then it is only 260 bytes but the + // other 2 txs are larger (320 and 380 bytes) and would get a lower fee/byte as intended. + // We apply following model to not overpay too much but be on the safe side as well. + // We sum the taker fee tx and the deposit tx together as it can be assumed that both be in the same block and + // as they are dependent txs the miner will pick both if the fee in total is good enough. + // We make sure that the fee is sufficient to meet our intended fee/byte for the larger payout tx with 380 bytes. + Tuple2 estimatedFeeAndTxSize = TxFeeEstimation.getEstimatedFeeAndTxSizeForTaker(fundsNeededForTrade, getTakerFee(), - txFeeFromFeeService, - dummyArbitratorAddress); - - txSize = tradeFeeTx.bitcoinSerialize().length; - // use feeTxSizeEstimationRecursionCounter to avoid risk for endless loop - // We use the tx size for the trade fee tx as target for the fees. - // The deposit and payout txs are determined +/- 1 output but the trade fee tx can have either 1 or many inputs - // so we need to make sure the trade fee tx gets the correct fee to not get stuck. - // We use a 20% tolerance frm out default 320 byte size (typical for deposit and payout) and only if we get a - // larger size we increase the fee. Worst case is that we overpay for the other follow up txs, but better than - // use a too low fee and get stuck. - if (txSize > feeTxSize * 1.2 && feeTxSizeEstimationRecursionCounter < 10) { - feeTxSizeEstimationRecursionCounter++; - log.info("txSize is {} bytes but feeTxSize used for txFee calculation was {} bytes. We try again with an " + - "adjusted txFee to reach the target tx fee.", txSize, feeTxSize); - - feeTxSize = txSize; - txFeeFromFeeService = getTxFeeBySize(txSize); - - // lets try again with the adjusted txSize and fee. - estimateTxSize(); - } else { - // We are done with estimation iterations - if (feeTxSizeEstimationRecursionCounter < 10) - log.info("Fee estimation completed:\n" + - "txFee based on estimated size of {} bytes. Average tx size = {} bytes. Actual tx size = {} bytes. TxFee is {} ({} sat/byte)", - feeTxSize, getAverageSize(feeTxSize), txSize, txFeeFromFeeService.toFriendlyString(), feeService.getTxFeePerByte()); - else - log.warn("We could not estimate the fee as the feeTxSizeEstimationRecursionCounter exceeded our limit of 10 recursions.\n" + - "txFee based on estimated size of {} bytes. Average tx size = {} bytes. Actual tx size = {} bytes. " + - "TxFee is {} ({} sat/byte)", - feeTxSize, getAverageSize(feeTxSize), txSize, txFeeFromFeeService.toFriendlyString(), feeService.getTxFeePerByte()); - } - } catch (InsufficientMoneyException e) { - log.info("We cannot complete the fee estimation because there are not enough funds in the wallet.\n" + - "This is expected if the user has not sufficient funds yet.\n" + - "In that case we use the latest estimated tx size or the default if none has been calculated yet.\n" + - "txFee based on estimated size of {} bytes. Average tx size = {} bytes. Actual tx size = {} bytes. TxFee is {} ({} sat/byte)", - feeTxSize, getAverageSize(feeTxSize), txSize, txFeeFromFeeService.toFriendlyString(), feeService.getTxFeePerByte()); + feeService, + btcWalletService, + preferences); + txFeeFromFeeService = estimatedFeeAndTxSize.first; + feeTxSize = estimatedFeeAndTxSize.second; + } else { + feeTxSize = 380; + txFeeFromFeeService = txFeePerByteFromFeeService.multiply(feeTxSize); + log.info("We cannot do the fee estimation because there are no funds in the wallet.\nThis is expected " + + "if the user has not funded his wallet yet.\n" + + "In that case we use an estimated tx size of 380 bytes.\n" + + "txFee based on estimated size of {} bytes. feeTxSize = {} bytes. Actual tx size = {} bytes. TxFee is {} ({} sat/byte)", + feeTxSize, feeTxSize, txSize, txFeeFromFeeService.toFriendlyString(), feeService.getTxFeePerByte()); } - } else { - feeTxSize = 320; - txFeeFromFeeService = getTxFeeBySize(feeTxSize); - log.info("We cannot do the fee estimation because there are no funds in the wallet.\nThis is expected " + - "if the user has not funded his wallet yet.\n" + - "In that case we use an estimated tx size of 320 bytes.\n" + - "txFee based on estimated size of {} bytes. Average tx size = {} bytes. Actual tx size = {} bytes. TxFee is {} ({} sat/byte)", - feeTxSize, getAverageSize(feeTxSize), txSize, txFeeFromFeeService.toFriendlyString(), feeService.getTxFeePerByte()); } + + @NotNull + private Coin getFundsNeededForTrade() { + return getSecurityDeposit().add(getTxFeeForDepositTx()).add(getTxFeeForPayoutTx()); } public void onPaymentAccountSelected(PaymentAccount paymentAccount) { @@ -620,10 +591,25 @@ public String getCurrencyNameAndCode() { } public Coin getTotalTxFee() { + Coin totalTxTees = txFeeFromFeeService.add(getTxFeeForDepositTx()).add(getTxFeeForPayoutTx()); if (isCurrencyForTakerFeeBtc()) - return txFeeFromFeeService.multiply(3); + return totalTxTees; else - return txFeeFromFeeService.multiply(3).subtract(getTakerFee() != null ? getTakerFee() : Coin.ZERO); + return totalTxTees.subtract(getTakerFee() != null ? getTakerFee() : Coin.ZERO); + } + + private Coin getTxFeeForDepositTx() { + // Unfortunately we cannot change that to the correct fees as it would break backward compatibility + // We still might find a way with offer version or app version checks so lets keep that commented out + // code as that shows how it should be. + return txFeeFromFeeService; //feeService.getTxFee(320); + } + + private Coin getTxFeeForPayoutTx() { + // Unfortunately we cannot change that to the correct fees as it would break backward compatibility + // We still might find a way with offer version or app version checks so lets keep that commented out + // code as that shows how it should be. + return txFeeFromFeeService; //feeService.getTxFee(380); } public AddressEntry getAddressEntry() {