diff --git a/java-examples/src/main/java/bisq/bots/AbstractBot.java b/java-examples/src/main/java/bisq/bots/AbstractBot.java
new file mode 100644
index 0000000..9f59f75
--- /dev/null
+++ b/java-examples/src/main/java/bisq/bots/AbstractBot.java
@@ -0,0 +1,712 @@
+/*
+ * 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.bots;
+
+import bisq.bots.table.builder.TableBuilder;
+import bisq.proto.grpc.*;
+import bisq.proto.grpc.GetTradesRequest.Category;
+import io.grpc.StatusRuntimeException;
+import lombok.extern.slf4j.Slf4j;
+import org.slf4j.Logger;
+import protobuf.PaymentAccount;
+
+import java.io.*;
+import java.math.BigDecimal;
+import java.time.Duration;
+import java.util.*;
+import java.util.function.BiConsumer;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+
+import static bisq.bots.BotUtils.*;
+import static bisq.bots.table.builder.TableType.BSQ_BALANCE_TBL;
+import static bisq.bots.table.builder.TableType.BTC_BALANCE_TBL;
+import static bisq.proto.grpc.GetOfferCategoryReply.OfferCategory.BSQ_SWAP;
+import static io.grpc.Status.*;
+import static java.lang.String.format;
+import static java.lang.System.exit;
+import static java.lang.System.out;
+import static java.math.RoundingMode.HALF_UP;
+import static java.util.Objects.requireNonNull;
+
+/**
+ * Base gRPC API bot example class, providing convenient methods for most types of interaction with the API daemon.
+ *
+ * Most gRPC exceptions from the API daemon should be handled by subclasses, with special exceptions made for
+ * the API takeoffer method, which can fail for many reasons, not all of which throw a gRPC StatusRuntimeException.
+ * TODO Study the problem with inconsistent API takeoffer error handling on both daemon and client, and try to
+ * come up with a better solution, i.e., all errors throw an exception instead of the current "failure reasons"
+ * derived from UI purposed TaskRunner errors that frequently occur during offer availability checks.
+ */
+@Slf4j
+public abstract class AbstractBot {
+
+ // Program args are saved in case they need to be forwarded to another bot instance.
+ protected final String[] args;
+ protected final String walletPassword;
+ protected final String conf;
+ protected final GrpcStubs grpcStubs;
+ protected final boolean isDryRun;
+ // This is an experimental option for simulating and automating protocol payment steps during bot development.
+ // Be extremely careful in its use; You do not want to "simulate" payments when API daemon is connected to mainnet.
+ protected final boolean canSimulatePaymentSteps;
+
+ // Bot's list of preferred trading peers (onion addresses).
+ // The list is defined in the subclass' properties (or external conf) file.
+ protected final List preferredTradingPeers = new ArrayList<>();
+
+ // Used during dry runs to track offers that would be taken.
+ // This list should remain empty if super.dryRun = FALSE until bot can take multiple offers in one session.
+ protected final List offersTaken = new ArrayList<>();
+
+ protected final boolean canUseBash = getBashPath().isPresent();
+ protected boolean isShutdown = false;
+
+ protected final Supplier defaultPropertiesFilename = () -> this.getClass().getSimpleName() + ".properties";
+ protected final Supplier txFeeRates = this::getTxFeeRateInfo;
+ protected final Supplier minimumTxFeeRate = () -> txFeeRates.get().getMinFeeServiceRate();
+ protected final Supplier mostRecentTxFeeRate = () -> txFeeRates.get().getFeeServiceRate();
+
+ // Constructor
+ public AbstractBot(String[] args) {
+ this.args = args;
+ Config bisqClientOpts = new Config(args, defaultPropertiesFilename.get());
+ this.walletPassword = bisqClientOpts.getWalletPassword();
+ this.conf = bisqClientOpts.getConf();
+ this.grpcStubs = new GrpcStubs(bisqClientOpts.getHost(), bisqClientOpts.getPort(), bisqClientOpts.getPassword());
+ this.isDryRun = bisqClientOpts.isDryRun();
+ this.canSimulatePaymentSteps = bisqClientOpts.isSimulatePaymentSteps();
+ }
+
+ public abstract void run();
+
+ /**
+ * Pings the API daemon with a getversion request. Any gRPC StatusRuntimeException exception
+ * from the daemon is fatal, resulting in an immediate Java runtime System.exit(1).
+ *
+ * @param startTime of the bot, for logging the bot's uptime
+ */
+ protected void pingDaemon(long startTime) {
+ try {
+ var now = new Date();
+ var upTime = Duration.ofMillis(now.getTime() - startTime);
+ log.info("Pinging API daemon. Uptime: {} hours {} minutes {} seconds.",
+ upTime.toHoursPart(),
+ upTime.toMinutesPart(),
+ upTime.toSecondsPart());
+ var request = GetVersionRequest.newBuilder().build();
+ var reply = grpcStubs.versionService.getVersion(request);
+ log.info("API daemon {} is available.", reply.getVersion());
+ } catch (StatusRuntimeException grpcException) {
+ log.error("Fatal Error: {}, daemon not available. Shutting down bot.", toCleanErrorMessage.apply(grpcException));
+ exit(1);
+ }
+ }
+
+ /**
+ * Return true if bot has a preferred trading peer list.
+ */
+ protected final Supplier iHavePreferredTradingPeers = () -> preferredTradingPeers.size() > 0;
+
+ /**
+ * Return true is the offer maker's onion address is configured as a preferred trading peer.
+ */
+ protected final Predicate isMakerPreferredTradingPeer = (offer) ->
+ iHavePreferredTradingPeers.get()
+ && preferredTradingPeers.contains(offer.getOwnerNodeAddress());
+
+ /**
+ * Returns true if the given maximum tx fee rate is <= the most recent Bisq network fee rate.
+ */
+ protected final Predicate isBisqNetworkTxFeeRateLowEnough = (maxTxFeeRate) -> {
+ var currentTxFeeRate = mostRecentTxFeeRate.get();
+ if (currentTxFeeRate <= maxTxFeeRate) {
+ log.info("Current tx fee rate: {} sats/byte.", currentTxFeeRate);
+ return true;
+ } else {
+ log.warn("Current network tx fee rate ({} sats/byte) is too high, it must fall below"
+ + " configured max fee rate ({} sats/byte) before attempting to take an offer.",
+ currentTxFeeRate,
+ maxTxFeeRate);
+ return false;
+ }
+ };
+
+ /**
+ * Loads the given List from a Java Properties object containing a comma separated list of onion
+ * addresses in hostname:port format, defined for property key "preferredTradingPeers". Will throw a
+ * fatal exception if the hostname:port pairs are not properly comma delimited.
+ */
+ protected final BiConsumer> loadPreferredOnionAddresses = (properties, list) -> {
+ var commaSeparatedValues = properties.getProperty("preferredTradingPeers");
+ if (commaSeparatedValues == null || commaSeparatedValues.isBlank() || commaSeparatedValues.isEmpty()) {
+ log.warn("Non-Fatal Error: no preferred trading peers defined in config file.");
+ return;
+ }
+ String[] onions = commaSeparatedValues.split(",");
+ // Do simple validation of each address or throw a fatal exception.
+ // The most likely problem is user not separating each onion address with a comma.
+ Arrays.stream(onions).forEach(onion -> list.add(getValidatedPeerAddress(onion)));
+ };
+
+ /**
+ * Return the name of the BTC network API daemon is currently connected to: mainnet, testnet3, or regtest.
+ *
+ * @return String name of BTC network
+ */
+ protected String getNetwork() {
+ try {
+ var request = GetNetworkRequest.newBuilder().build();
+ return grpcStubs.walletsService.getNetwork(request).getNetwork();
+ } finally {
+ // There is a 1 getnetwork call per second rate meter on the API daemon, and bots have reason to call
+ // getnetwork many times in rapid succession because the API daemon could be restarted against mainnet or
+ // regtest at any instant. So, we force the bot to wait a second after making this call, to avoid a
+ // StatusRuntimeException(PERMISSION_DENIED).
+ sleep(1_000);
+ }
+ }
+
+ /**
+ * Return true if API daemon is currently connected to BTC mainnet network.
+ *
+ * @return boolean true if API daemon is currently connected to BTC mainnet network
+ */
+ protected boolean isConnectedToMainnet() {
+ return getNetwork().equalsIgnoreCase("mainnet");
+ }
+
+ /**
+ * Return true if bot has taken the offer during this session -- for dry runs only.
+ */
+ protected final Predicate isAlreadyTaken = (offer) ->
+ offersTaken.stream().anyMatch(o -> o.getId().equals(offer.getId()));
+
+ /**
+ * Print a table of BSQ balance information.
+ *
+ * An encrypted wallet must be unlocked before making this request; this method will not unlock it for you.
+ *
+ * @param title to display above the table
+ */
+ protected void printBSQBalances(String title) {
+ try {
+ log.info(title);
+ var bsqBalances = getBalances().getBsq();
+ new TableBuilder(BSQ_BALANCE_TBL, bsqBalances).build().print(out);
+ } catch (StatusRuntimeException grpcException) {
+ log.warn(toCleanErrorMessage.apply(grpcException));
+ }
+ }
+
+ /**
+ * Print a table of BTC balance information.
+ *
+ * An encrypted wallet must be unlocked before making this request; this method will not unlock it for you.
+ *
+ * @param title to display above the table
+ */
+ protected void printBTCBalances(String title) {
+ try {
+ log.info(title);
+ var btcBalances = getBalances().getBtc();
+ new TableBuilder(BTC_BALANCE_TBL, btcBalances).build().print(out);
+ } catch (StatusRuntimeException grpcException) {
+ log.warn(toCleanErrorMessage.apply(grpcException));
+ }
+ }
+
+ /**
+ * Request BTC and BSQ wallet balance information.
+ *
+ * @return bisq.proto.grpc.BalancesInfo
+ * @see https://bisq-network.github.io/slate/#balancesinfohttps://bisq-network.github.io/slate/#rpc-method-getbalances
+ * @see https://bisq-network.github.io/slate/#balancesinfo
+ */
+ protected BalancesInfo getBalances() {
+ // An encrypted wallet must be unlocked before making this request.
+ var request = GetBalancesRequest.newBuilder().build();
+ var reply = grpcStubs.walletsService.getBalances(request);
+ return reply.getBalances();
+ }
+
+ /**
+ * Sends a stop reqeust to the API daemon.
+ *
+ * @see https://bisq-network.github.io/slate/?java#service-shutdownserver
+ */
+ protected void stopDaemon() {
+ var request = StopRequest.newBuilder().build();
+ //noinspection ResultOfMethodCallIgnored
+ grpcStubs.shutdownService.stop(request);
+ log.info("API server shutdown request sent.");
+ }
+
+ /**
+ * Locks an encrypted wallet.
+ *
+ * @throws NonFatalException if API daemon is not ready to perform wallet operations, wallet is not encrypted,
+ * or wallet is already locked.
+ * @throws StatusRuntimeException if a fatal error occurs.
+ * @see https://bisq-network.github.io/slate/?java#rpc-method-lockwallet
+ */
+ protected void lockWallet() throws NonFatalException {
+ try {
+ var request = LockWalletRequest.newBuilder().build();
+ //noinspection ResultOfMethodCallIgnored
+ grpcStubs.walletsService.lockWallet(request);
+ log.info("Wallet is locked.");
+ } catch (StatusRuntimeException grpcException) {
+ if (exceptionHasStatus.test(grpcException, UNAVAILABLE)) {
+ // The API server will throw a gPRC exception if the server is not ready to take calls, or wallet is
+ // not available (initialized), encrypted, or is already locked. These cases are not fatal.
+ throw new NonFatalException(toNonFatalErrorMessage.apply(grpcException));
+ } else if (exceptionHasStatus.test(grpcException, FAILED_PRECONDITION)) {
+ // If wallet is not encrypted, we got a FAILED_PRECONDITION status code.
+ throw new NonFatalException(toNonFatalErrorMessage.apply(grpcException));
+ } else if (exceptionHasStatus.test(grpcException, ALREADY_EXISTS)) {
+ // If wallet is already locked, we got an ALREADY_EXISTS status code.
+ throw new NonFatalException(toNonFatalErrorMessage.apply(grpcException));
+ } else {
+ // Else we throw the fatal, unexpected exception.
+ throw grpcException;
+ }
+ }
+ }
+
+ /**
+ * Unlocks a locked, encrypted wallet.
+ *
+ * @param walletPassword encrypted wallet's password
+ * @param timeoutInSeconds how long the wallet is to be unlocked
+ * @throws NonFatalException if API daemon is not ready to perform wallet operations, wallet is not encrypted,
+ * or wallet is already unlocked.
+ * @throws StatusRuntimeException if a fatal error occurs.
+ * @see https://bisq-network.github.io/slate/?java#rpc-method-unlockwallet
+ */
+ protected void unlockWallet(String walletPassword, long timeoutInSeconds) throws NonFatalException {
+ try {
+ var request = UnlockWalletRequest.newBuilder()
+ .setPassword(walletPassword)
+ .setTimeout(timeoutInSeconds)
+ .build();
+ //noinspection ResultOfMethodCallIgnored
+ grpcStubs.walletsService.unlockWallet(request);
+ log.info("Wallet is unlocked.");
+ } catch (StatusRuntimeException grpcException) {
+ // The API server will throw a gPRC exception if the server is not ready to take calls, or wallet is
+ // not available (initialized), encrypted, or is already unlocked. These cases are not fatal.
+ if (exceptionHasStatus.test(grpcException, UNAVAILABLE)) {
+ // If wallet is not yet initialized, we got an UNAVAILABLE status code.
+ throw new NonFatalException(toNonFatalErrorMessage.apply(grpcException));
+ } else if (exceptionHasStatus.test(grpcException, FAILED_PRECONDITION)) {
+ // If wallet is not encrypted, we got a FAILED_PRECONDITION status code.
+ throw new NonFatalException(toNonFatalErrorMessage.apply(grpcException));
+ } else if (exceptionHasStatus.test(grpcException, ALREADY_EXISTS)) {
+ // If wallet is already unlocked, we got an ALREADY_EXISTS status code.
+ throw new NonFatalException(toNonFatalErrorMessage.apply(grpcException));
+ } else {
+ // Else we throw the fatal, unexpected exception.
+ throw grpcException;
+ }
+ }
+ }
+
+ /**
+ * Returns PaymentAccount for given paymentAccountId, else throws an IllegalArgumentException.
+ *
+ * @param paymentAccountId Unique identifier of the payment account
+ * @return protobuf.PaymentAccount
+ * @see https://bisq-network.github.io/slate/?java#rpc-method-getpaymentaccounts
+ * @see https://bisq-network.github.io/slate/?java#paymentaccount
+ */
+ protected PaymentAccount getPaymentAccount(String paymentAccountId) {
+ var request = GetPaymentAccountsRequest.newBuilder().build();
+ var response = grpcStubs.paymentAccountsService.getPaymentAccounts(request);
+ return response.getPaymentAccountsList().stream()
+ .filter(p -> p.getId().equals(paymentAccountId))
+ .findFirst().orElseThrow(() ->
+ new IllegalArgumentException(
+ format("Payment account with ID '%s' not found.", paymentAccountId)));
+ }
+
+ /**
+ * Returns the default BSQ Swap PaymentAccount.
+ *
+ * @return protobuf.PaymentAccount
+ * @see https://bisq-network.github.io/slate/?java#rpc-method-getpaymentaccounts
+ * @see https://bisq-network.github.io/slate/?java#paymentaccount
+ */
+ protected PaymentAccount getBsqSwapPaymentAccount() {
+ var request = GetPaymentAccountsRequest.newBuilder().build();
+ var response = grpcStubs.paymentAccountsService.getPaymentAccounts(request);
+ var bsqSwapPaymentMethodId = BSQ_SWAP.name();
+ return response.getPaymentAccountsList().stream()
+ .filter(p -> p.getPaymentMethod().getId().equals(bsqSwapPaymentMethodId))
+ .findFirst().orElseThrow(() ->
+ new IllegalArgumentException("Your default BSQ Swap payment account was not found."));
+ }
+
+ protected void validateTradeFeeCurrencyCode(String bisqTradeFeeCurrency) {
+ var isValidCurrencyCode = bisqTradeFeeCurrency.equalsIgnoreCase("BSQ")
+ || bisqTradeFeeCurrency.equalsIgnoreCase("BTC");
+ if (!isValidCurrencyCode)
+ throw new IllegalStateException(
+ format("Bisq trade fees must be paid in BSQ or BTC, not %s.", bisqTradeFeeCurrency));
+ }
+
+ /**
+ * Verifies (1) the given PaymentAccount has a selected trade currency, and (2) is a fiat payment account or an
+ * XMR payment account, else throws an IllegalStateException. (The bot does not yet support BSQ Swaps.)
+ */
+ protected void validatePaymentAccount(PaymentAccount paymentAccount) {
+ if (!paymentAccount.hasSelectedTradeCurrency())
+ throw new IllegalStateException(
+ format("PaymentAccount with ID '%s' and name '%s' has no selected currency definition.",
+ paymentAccount.getId(),
+ paymentAccount.getAccountName()));
+
+ var selectedCurrencyCode = paymentAccount.getSelectedTradeCurrency().getCode();
+
+ // Hacky way to find out if this is an altcoin payment method, but there is no BLOCK_CHAINS proto enum or msg.
+ boolean isBlockChainsPaymentMethod = paymentAccount.getPaymentMethod().getId().equals("BLOCK_CHAINS");
+ if (isBlockChainsPaymentMethod && !isXmr.test(selectedCurrencyCode))
+ throw new IllegalStateException(
+ format("This bot only supports fiat and monero (XMR) trading, not the %s altcoin.",
+ selectedCurrencyCode));
+
+ if (isBsq.test(selectedCurrencyCode))
+ throw new IllegalStateException("This bot does not support BSQ Swaps.");
+ }
+
+ /**
+ * Returns an offer with the given offerId, else throws a gRPC StatusRuntimeException.
+ *
+ * @return bisq.proto.grpc.OfferInfo
+ * @see https://bisq-network.github.io/slate/?java#rpc-method-getoffer
+ * @see https://bisq-network.github.io/slate/?java#offerinfo
+ */
+ protected OfferInfo getOffer(String offerId) {
+ var request = GetOfferRequest.newBuilder()
+ .setId(requireNonNull(offerId, "offerId cannot be null"))
+ .build();
+ var response = grpcStubs.offersService.getOffer(request);
+ return response.getOffer();
+ }
+
+ /**
+ * Returns a list of offers with the given direction (BUY|SELL) and currency code, or an empty list.
+ *
+ * @return List
+ * @see https://bisq-network.github.io/slate/?java#rpc-method-getoffers
+ */
+ protected List getOffers(String direction, String currencyCode) {
+ var request = GetOffersRequest.newBuilder()
+ .setDirection(requireNonNull(direction, "direction cannot be null").toUpperCase())
+ .setCurrencyCode(requireNonNull(currencyCode, "currencyCode cannot be null").toUpperCase())
+ .build();
+ var response = grpcStubs.offersService.getOffers(request);
+ return response.getOffersList();
+ }
+
+ /**
+ * Takes a BSQ swap offer. Throws an exception if one of various possible causes of failure is detected.
+ *
+ * @throws NonFatalException if a failure to take the offer was detected. The offer could have been
+ * unavailable for one of many reasons, or the taker's wallet had insufficient BTC
+ * to cover the trade. Other problems could result in this exception being thrown,
+ * including attempts to make takeoffer requests more than once per minute.
+ * None of these are fatal errors for a bot, which can opt to not terminate itself.
+ * @throws StatusRuntimeException if a fatal error occurred while attempting to take the offer.
+ * @see https://bisq-network.github.io/slate/?java#rpc-method-takeoffer
+ */
+ public void takeBsqSwapOffer(OfferInfo offer, long pollingInterval) throws NonFatalException {
+ OfferTaker offerTaker = new OfferTaker(grpcStubs, offer, pollingInterval);
+ // May throw fatal StatusRuntimeException, or NonFatalException.
+ offerTaker.takeOffer();
+ log.info("You took offer '{}'; waiting on swap completion.", offer.getId());
+ offerTaker.waitForBsqSwapCompletion(); // Blocks until swap is completed, or times out.
+ }
+
+ /**
+ * Takes a fiat or xmr offer. Throws an exception if one of various possible causes of failure is detected.
+ *
+ * @throws NonFatalException if a failure to take the offer was detected. The offer could have been
+ * unavailable for one of many reasons, or the taker's wallet had insufficient BTC
+ * to cover the trade. Other problems could result in this exception being thrown,
+ * including attempts to make takeoffer requests more than once per minute.
+ * None of these are fatal errors for a bot, which can opt to not terminate itself.
+ * @throws StatusRuntimeException if a fatal error occurred while attempting to take the offer.
+ * @see https://bisq-network.github.io/slate/?java#rpc-method-takeoffer
+ */
+ public void takeV1ProtocolOffer(OfferInfo offer,
+ PaymentAccount paymentAccount,
+ String bisqTradeFeeCurrency,
+ long pollingInterval) throws NonFatalException {
+ OfferTaker offerTaker = new OfferTaker(grpcStubs,
+ offer,
+ paymentAccount,
+ bisqTradeFeeCurrency,
+ pollingInterval);
+ // May throw fatal StatusRuntimeException, or NonFatalException.
+ offerTaker.takeOffer();
+ log.info("You took offer '{}'; waiting on new trade contract preparation.", offer.getId());
+ offerTaker.waitForTradePreparation(); // Blocks until new trade is prepared, or times out.
+ }
+
+ /**
+ * Return a trade with the given ID.
+ * Use this method if you know the trade exists, and you want an exception thrown if not found.
+ *
+ * @param tradeId of the trade being requested
+ * @return bisq.proto.grpc.TradeInfo
+ * @see https://bisq-network.github.io/slate/?java#rpc-method-gettrade
+ */
+ protected TradeInfo getTrade(String tradeId) {
+ var request = GetTradeRequest.newBuilder().setTradeId(tradeId).build();
+ var response = grpcStubs.tradesService.getTrade(request);
+ return response.getTrade();
+ }
+
+ /**
+ * Return list of currently open, closed, or failed trades.
+ *
+ * @param category OPEN | CLOSED | FAILED
+ * @return List
+ */
+ protected List getTrades(Category category) {
+ var request = GetTradesRequest.newBuilder().setCategory(category).build();
+ var response = grpcStubs.tradesService.getTrades(request);
+ return response.getTradesList();
+ }
+
+ /**
+ * Print list of trade summaries to stdout.
+ *
+ * @param category category OPEN | CLOSED | FAILED
+ */
+ protected void printTradesSummary(Category category) {
+ var trades = getTrades(category);
+ BotUtils.printTradesSummary(category, trades);
+ }
+
+ /**
+ * Send a "payment started" message to the BTC seller.
+ *
+ * @param tradeId the trade's unique identifier
+ */
+ protected void confirmPaymentStarted(String tradeId) {
+ var request = ConfirmPaymentStartedRequest.newBuilder()
+ .setTradeId(tradeId)
+ .build();
+ //noinspection ResultOfMethodCallIgnored
+ grpcStubs.tradesService.confirmPaymentStarted(request);
+ }
+
+ /**
+ * Send a "payment received confirmation" message to the BTC buyer.
+ *
+ * @param tradeId the trade's unique identifier
+ */
+ protected void confirmPaymentReceived(String tradeId) {
+ var request = ConfirmPaymentReceivedRequest.newBuilder()
+ .setTradeId(tradeId)
+ .build();
+ //noinspection ResultOfMethodCallIgnored
+ grpcStubs.tradesService.confirmPaymentReceived(request);
+ }
+
+ /**
+ * Close a completed trade -- move it to trade history list.
+ *
+ * @param tradeId the trade's unique identifier
+ */
+ protected void closeTrade(String tradeId) {
+ var request = CloseTradeRequest.newBuilder()
+ .setTradeId(tradeId)
+ .build();
+ //noinspection ResultOfMethodCallIgnored
+ grpcStubs.tradesService.closeTrade(request);
+ }
+
+ /**
+ * Returns a BigDecimal representing the current market price, source from Bisq price feed services.
+ *
+ * @return BigDecimal the current market price
+ * @see https://bisq-network.github.io/slate/?java#rpc-method-getmarketprice
+ */
+ protected BigDecimal getCurrentMarketPrice(String currencyCode) {
+ var request = MarketPriceRequest.newBuilder()
+ .setCurrencyCode(currencyCode)
+ .build();
+ var response = grpcStubs.priceService.getMarketPrice(request);
+ var precision = isAltcoin.test(currencyCode) ? 8 : 4;
+ return BigDecimal.valueOf(response.getPrice()).setScale(precision, HALF_UP);
+ }
+
+ /**
+ * Returns a BigDecimal representing the 30-day, volume weighted average BSQ price in BTC.
+ *
+ * @return BigDecimal the 30-day average price
+ * // TODO fix link below to api doc
+ * @see https://bisq-network.github.io/slate/?java#rpc-method-getavgbsqprice
+ * // TODO fix link above to api doc
+ */
+ protected BigDecimal get30DayAvgBsqPriceInBtc() {
+ var request = GetAverageBsqTradePriceRequest.newBuilder()
+ .setDays(30)
+ .build();
+ var response = grpcStubs.priceService.getAverageBsqTradePrice(request);
+ return new BigDecimal(response.getPrice().getBtcPrice());
+ }
+
+ /**
+ * Return a summary of BTC transaction fee rate information, including the Bisq network's most recently available
+ * BTC tx fee rate, in sats/byte, and the user's custom tx fee rate, if set.
+ *
+ *
+ * @return bisq.proto.grpc.TxFeeRateInfo
+ * @see https://bisq-network.github.io/slate/#txfeerateinfo
+ */
+ protected TxFeeRateInfo getTxFeeRateInfo() {
+ var request = GetTxFeeRateRequest.newBuilder().build();
+ var response = grpcStubs.walletsService.getTxFeeRate(request);
+ return response.getTxFeeRateInfo();
+ }
+
+ protected void validatePollingInterval(long pollingInterval) {
+ if (pollingInterval < 1_000)
+ throw new IllegalStateException("Cannot poll offer-book faster than 1x per second.");
+ }
+
+ /**
+ * Print information about offers taken during bot simulation.
+ */
+ protected void printDryRunProgress() {
+ if (isDryRun && offersTaken.size() > 0) {
+ log.info("You have \"taken\" {} offer(s) during dry run:", offersTaken.size());
+ printOffersSummary(offersTaken);
+ }
+ }
+
+ /**
+ * Add offer to list of taken offers -- for dry runs only.
+ */
+ protected void addToOffersTaken(OfferInfo offer) {
+ offersTaken.add(offer);
+ printOfferSummary(offer);
+ log.info("Did not actually take that offer during this simulation.");
+ }
+
+ /**
+ * Stall the bot for the number of seconds represented by the given durationInMillis.
+ *
+ * If the bot can use the system's bash command language interpreter, show the countdown in the terminal,
+ * else log a "Will wake up in {} seconds" statement, and put the current thread to sleep.
+ *
+ * @param log bot implementation's logger
+ * @param durationInMillis number of milliseconds to stall
+ */
+ protected void runCountdown(Logger log, long durationInMillis) {
+ var seconds = toSeconds.apply(durationInMillis).intValue();
+ if (canUseBash) {
+ showCountdown(seconds);
+ } else {
+ log.info("Will wake up in {} seconds. ", seconds);
+ sleep(durationInMillis);
+ }
+ }
+
+ /**
+ * Lock the wallet, stop the API daemon, and terminate the bot with a non-zero status (error).
+ */
+ protected void shutdownAfterFatalError(String errorMessage) {
+ isShutdown = true;
+ try {
+ lockWallet();
+ } catch (NonFatalException ex) {
+ log.warn(ex.getMessage());
+ }
+ log.error(errorMessage);
+ sleep(5_000);
+ log.error("Sending stop request to daemon.");
+ stopDaemon();
+ exit(1);
+ }
+
+ /**
+ * Returns Properties object for this bot.
+ *
+ * @return Properties loaded from file specified in '--conf=path' program argument.
+ */
+ protected Properties loadConfigFile() {
+ if (conf.equals(defaultPropertiesFilename.get()))
+ return loadDefaultProperties();
+ else
+ return loadExternalProperties();
+ }
+
+ /**
+ * Returns default properties file named 'this.getClass().getSimpleName() + ".properties"'.
+ *
+ * @return Properties loaded from file
+ */
+ private Properties loadDefaultProperties() {
+ Properties properties = new java.util.Properties();
+ try {
+ var defaultFilename = defaultPropertiesFilename.get();
+ properties.load(this.getClass().getClassLoader().getResourceAsStream(defaultFilename));
+ log.info("Internal configuration loaded from {}.", defaultFilename);
+ } catch (Exception ex) {
+ throw new IllegalStateException(ex);
+ }
+ return properties;
+ }
+
+ /**
+ * Return a Properties object loaded from an optional conf file, specified by the --conf=path program argument.
+ */
+ private Properties loadExternalProperties() {
+ var confFile = new File(conf);
+ if (!confFile.exists())
+ throw new IllegalStateException(format("Configuration file %s does not exist.", conf));
+
+ InputStream is;
+ try {
+ is = new FileInputStream(confFile);
+ Properties properties = new java.util.Properties();
+ try {
+ properties.load(is);
+ log.info("External configuration loaded from {}.", confFile.getAbsolutePath());
+ return properties;
+ } catch (FileNotFoundException ignored) {
+ // Cannot happen here. Ignore FileNotFoundException because confFile.exists() == true.
+ } catch (IOException ex) {
+ // Not ignored because file can exist but fail to be loaded into Properties object.
+ throw new IllegalStateException(ex);
+ } finally {
+ is.close();
+ }
+ } catch (FileNotFoundException ignored) {
+ // Cannot happen here. Ignore FileNotFoundException because confFile.exists() == true.
+ } catch (IOException ex) {
+ // Not ignored because may fail to create new FileInputStream(confFile).
+ throw new IllegalStateException(ex);
+ }
+ throw new IllegalStateException(format("Could not load properties from %s.", conf));
+ }
+}
diff --git a/java-examples/src/main/java/bisq/bots/BotUtils.java b/java-examples/src/main/java/bisq/bots/BotUtils.java
new file mode 100644
index 0000000..86646ff
--- /dev/null
+++ b/java-examples/src/main/java/bisq/bots/BotUtils.java
@@ -0,0 +1,665 @@
+/*
+ * 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.bots;
+
+import bisq.bots.table.builder.TableBuilder;
+import bisq.proto.grpc.GetTradesRequest;
+import bisq.proto.grpc.OfferInfo;
+import bisq.proto.grpc.TradeInfo;
+import io.grpc.Status;
+import io.grpc.StatusRuntimeException;
+import lombok.extern.slf4j.Slf4j;
+import org.slf4j.Logger;
+import protobuf.PaymentAccount;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.math.BigDecimal;
+import java.math.MathContext;
+import java.time.Duration;
+import java.util.*;
+import java.util.function.*;
+
+import static bisq.bots.CurrencyFormat.toSatoshis;
+import static bisq.bots.table.builder.TableType.*;
+import static java.lang.String.format;
+import static java.lang.System.*;
+import static java.math.BigDecimal.ZERO;
+import static java.math.RoundingMode.HALF_UP;
+
+/**
+ * Convenience methods and functions not depending on a bot's state nor the need to send requests to the API daemon.
+ */
+@Slf4j
+public class BotUtils {
+
+ private static final String BANNER = "##############################################################################";
+
+ public static final Predicate isBsq = (currencyCode) -> currencyCode.equalsIgnoreCase("BSQ");
+ public static final Predicate isXmr = (currencyCode) -> currencyCode.equalsIgnoreCase("XMR");
+ public static final Predicate isAltcoin = (currencyCode) -> isBsq.test(currencyCode) || isXmr.test(currencyCode);
+
+ /**
+ * Return price precision of 8 for altcoin, 4 for fiat.
+ */
+ public static final Function toPricePrecision = (currencyCode) ->
+ isAltcoin.test(currencyCode) ? 8 : 4;
+
+ /**
+ * Calculates a target price from given max market price margin and market price.
+ *
+ * @param targetMarketPriceMargin the maximum or minimum market price margin
+ * @param currentMarketPrice the current market price
+ * @param currencyCode the asset currency code (calculated price precision = 4 for fiat, 8 for altcoin)
+ * @return BigDecimal a target price
+ */
+ public static BigDecimal calcTargetPrice(BigDecimal targetMarketPriceMargin,
+ BigDecimal currentMarketPrice,
+ String currencyCode) {
+ if (!isZero.test(targetMarketPriceMargin) && targetMarketPriceMargin.precision() <= 2)
+ throw new IllegalArgumentException(
+ format("Price margin percent literal argument %s is invalid;"
+ + " it must have a precision of at least 2 decimal places.",
+ targetMarketPriceMargin));
+
+ var maxMarketPriceMarginAsDecimal = scaleAsDecimal.apply(targetMarketPriceMargin);
+ var precision = toPricePrecision.apply(currencyCode);
+ return currentMarketPrice.add(currentMarketPrice
+ .multiply(maxMarketPriceMarginAsDecimal, new MathContext(precision, HALF_UP)))
+ .setScale(precision, HALF_UP);
+ }
+
+ /**
+ * Calculates a target BSQ price from given max market price margin and an average BSQ market price.
+ *
+ * @param targetMarketPriceMargin the maximum or minimum market price margin
+ * @param avgBsqPrice the average BSQ price
+ * @return BigDecimal a target price
+ */
+ public static BigDecimal calcTargetBsqPrice(BigDecimal targetMarketPriceMargin,
+ BigDecimal avgBsqPrice) {
+ if (!isZero.test(targetMarketPriceMargin) && targetMarketPriceMargin.precision() <= 2)
+ throw new IllegalArgumentException(
+ format("Price margin percent literal argument %s is invalid;"
+ + " it must have a precision of at least 2 decimal places.",
+ targetMarketPriceMargin));
+
+ var maxMarketPriceMarginAsDecimal = scaleAsDecimal.apply(targetMarketPriceMargin);
+ return avgBsqPrice.add(avgBsqPrice
+ .multiply(maxMarketPriceMarginAsDecimal, new MathContext(8, HALF_UP)))
+ .setScale(8, HALF_UP);
+ }
+
+ /**
+ * Convert milliseconds to seconds.
+ */
+ public static final Function toSeconds = (ms) -> Duration.ofMillis(ms).getSeconds();
+
+ /**
+ * Return true if given BigDecimal equals 0.00.
+ */
+ public static final Predicate isZero = (d) -> d.compareTo(ZERO) == 0;
+
+ /**
+ * Convert a BigDecimal representing a % literal to a BigDecimal representing
+ * the equivalent decimal, e.g., 1.00 (%) converts to 0.01.
+ */
+ public static final Function scaleAsDecimal = (pctLiteral) ->
+ pctLiteral.divide(new BigDecimal("100"), HALF_UP);
+
+ /**
+ * Return a BigDecimal representing the difference as a percentage between a base number and n,
+ * i.e., how much above or below (as a %) is n compared to base?
+ */
+ public static final BiFunction diffAsPercent = (base, n) -> {
+ BigDecimal factor = new BigDecimal("100");
+ BigDecimal diff = n.divide(base, 4, HALF_UP).multiply(factor);
+ return diff.subtract(factor);
+ };
+
+ /**
+ * Return true if the margin price based offer's market price margin (%) >= minMarketPriceMargin (%).
+ */
+ public static final BiPredicate isMarginGEMinMarketPriceMargin =
+ (offer, minMarketPriceMargin) -> offer.getUseMarketBasedPrice()
+ && offer.getMarketPriceMarginPct() >= minMarketPriceMargin.doubleValue();
+
+ /**
+ * Return true if the margin price based offer's market price margin (%) <= maxMarketPriceMargin (%).
+ */
+ public static final BiPredicate isMarginLEMaxMarketPriceMargin =
+ (offer, maxMarketPriceMargin) -> offer.getUseMarketBasedPrice()
+ && offer.getMarketPriceMarginPct() <= maxMarketPriceMargin.doubleValue();
+
+ /**
+ * Return true is fixed-price offer's price <= the bot's max market price margin. Allows bot to
+ * take a fixed-priced offer if the price is <= maxMarketPriceMargin (%) of the current market price.
+ */
+ public static boolean isFixedPriceLEMaxMarketPriceMargin(OfferInfo offer,
+ BigDecimal currentMarketPrice,
+ BigDecimal maxMarketPriceMargin) {
+ if (offer.getUseMarketBasedPrice())
+ return false;
+
+ BigDecimal offerPrice = new BigDecimal(offer.getPrice());
+
+ // How much above or below currentMarketPrice (as a %) is the offer's fixed-price?
+ BigDecimal distanceFromMarketPrice = diffAsPercent.apply(currentMarketPrice, offerPrice);
+
+ // Return true if distanceFromMarketPrice <= maxMarketPriceMargin.
+ return distanceFromMarketPrice.compareTo(maxMarketPriceMargin) <= 0;
+ }
+
+ /**
+ * Return true is fixed-price offer's price >= the bot's minimum market price margin. Allows bot to
+ * take a fixed-priced offer if the price is >= minMarketPriceMargin (%) of the current market price.
+ */
+ public static boolean isFixedPriceGEMinMarketPriceMargin(OfferInfo offer,
+ BigDecimal currentMarketPrice,
+ BigDecimal minMarketPriceMargin) {
+ if (offer.getUseMarketBasedPrice())
+ return false;
+
+ BigDecimal offerPrice = new BigDecimal(offer.getPrice());
+
+ // How much above or below currentMarketPrice (as a %) is the offer's fixed-price?
+ BigDecimal distanceFromMarketPrice = diffAsPercent.apply(currentMarketPrice, offerPrice);
+
+ // Return true if distanceFromMarketPrice <= maxMarketPriceMargin.
+ return distanceFromMarketPrice.compareTo(minMarketPriceMargin) >= 0;
+ }
+
+ /**
+ * Return String "below" if maxMarketPriceMargin (%) <= 0.00, else "above".
+ */
+ public static final Function aboveOrBelowMarketPrice = (maxMarketPriceMargin) ->
+ maxMarketPriceMargin.compareTo(ZERO) <= 0 ? "below" : "above";
+
+ /**
+ * Return true if offer.amt >= minAmount AND offer.amt <= maxAmount (within the boundaries).
+ * TODO API's takeoffer needs to support taking offer's minAmount.
+ */
+ public static boolean isWithinBTCAmountBounds(OfferInfo offer, BigDecimal minAmount, BigDecimal maxAmount) {
+ return offer.getAmount() >= toSatoshis(minAmount) && offer.getAmount() <= toSatoshis(maxAmount);
+ }
+
+ /**
+ * Return true if the given StatusRuntimeException's Status matches the given Status.
+ */
+ public static final BiPredicate exceptionHasStatus = (ex, status) ->
+ ex.getStatus().getCode() == status.getCode();
+
+ /**
+ * Return a StatusRuntimeException message stripped of it's leading Status Code's enum name.
+ */
+ public static final Function toCleanErrorMessage = (grpcException) ->
+ grpcException.getMessage().replaceFirst("^[A-Z_]+: ", "");
+
+ /**
+ * Return a StatusRuntimeException message stripped of it's leading Status Code's enum name,
+ * then prepended with String "Non-Fatal Error (", and appended with String ")".
+ */
+ public static final Function toNonFatalErrorMessage = (grpcException) ->
+ "Non-Fatal Error (" + toCleanErrorMessage.apply(grpcException) + ")";
+
+ /**
+ * Return true if the given offer's payment method matches the given payment account's payment method.
+ */
+ public static final BiPredicate usesSamePaymentMethod = (o, p) ->
+ o.getPaymentMethodId().equals(p.getPaymentMethod().getId());
+
+ /**
+ * Return true if I am the BTC buyer for the given trade.
+ */
+ public static final Predicate isBtcBuyer = (trade) -> {
+ var isMyOffer = trade.getOffer().getIsMyOffer();
+ var isBuyerMakerAndSellerTaker = trade.getContract().getIsBuyerMakerAndSellerTaker();
+ return isMyOffer == isBuyerMakerAndSellerTaker;
+ };
+
+ /**
+ * Put the current thread to sleep for given number of milliseconds.
+ */
+ public static void sleep(long ms) {
+ try {
+ Thread.sleep(ms);
+ } catch (InterruptedException ex) {
+ // ignored
+ }
+ }
+
+ /**
+ * Return a wallet password read from stdin. If read from a command terminal, input will not be echoed.
+ * If run in a virtual terminal (IDE console), the input will be echoed.
+ *
+ * @param prompt password prompt
+ * @return String the password
+ */
+ public static String readWalletPassword(String prompt) {
+ String walletPassword;
+ var console = console(); // Returns null in IDE console!
+ // System.console() returns null if you do not launch your java application with a real console.
+ if (console == null) {
+ // Have to read it in a less secure way in the IDE's virtual console.
+ out.println(prompt);
+ BufferedReader reader = new BufferedReader(new InputStreamReader(in));
+ try {
+ walletPassword = reader.readLine();
+ } catch (IOException ex) {
+ throw new RuntimeException(ex);
+ }
+ } else {
+ char[] pwdChars = console.readPassword(prompt);
+ walletPassword = new String(pwdChars);
+ }
+ return walletPassword;
+ }
+
+ /**
+ * Return the given String[] args with an additional --wallet-password=xyz option appended to it.
+ *
+ * @param args program arguments
+ * @param walletPassword wallet password
+ * @return String[] appended program arguments
+ */
+ public static String[] appendWalletPasswordOpt(String[] args, String walletPassword) {
+ String[] walletPasswordOpt = new String[]{"--wallet-password=" + walletPassword};
+ String[] newOpts = new String[args.length + 1];
+ arraycopy(args, 0, newOpts, 0, args.length);
+ arraycopy(walletPasswordOpt, 0, newOpts, args.length, walletPasswordOpt.length);
+ return newOpts;
+ }
+
+ /**
+ * Returns a validated address:port specification as a String.
+ *
+ * @param addressString The address:port pair being validated.
+ * @return String
+ */
+ public static String getValidatedPeerAddress(String addressString) {
+ String[] hostnameAndPort = addressString.split(":");
+ String hostname;
+ int port;
+ try {
+ if (hostnameAndPort.length < 2) {
+ throw new IllegalStateException(format("Invalid preferredTradingPeers configuration:%n"
+ + "\t\t%s%n\tEach address much include a port, i.e, host:port.",
+ addressString));
+ }
+ hostname = hostnameAndPort[0].trim();
+ port = Integer.parseInt(hostnameAndPort[1].trim());
+ } catch (Exception ex) {
+ throw new IllegalStateException(format("Invalid preferredTradingPeers configuration:%n"
+ + "\t\t%s%n\tMultiple addresses must be separated by commas.",
+ addressString),
+ ex);
+ }
+ return hostname + ":" + port;
+ }
+
+ /**
+ * Return given Map transformed into a String representing a table with two columns: label and value.
+ *
+ * The map argument should contain only scalar values or short strings as values
+ * (not lists or maps), or you will get ugly results.
+ */
+ public static final BiFunction, String> toTable = (title, map) -> {
+ var mapElements = map.entrySet();
+ Supplier longestLabel = () -> {
+ int[] len = {0}; // Make implicitly final to modify in map element iteration.
+ mapElements.forEach((e) -> {
+ var labelLen = e.getKey().length();
+ len[0] = Math.max(labelLen, len[0]);
+ });
+ return len[0];
+ };
+ int labelWidth = longestLabel.get() + 2;
+ Supplier resultsTable = () -> {
+ var numRows = mapElements.size();
+ var rows = new StringBuilder();
+ int[] rowNum = {0}; // Make implicitly final to modify in map element iteration.
+ mapElements.forEach((e) -> {
+ var label = e.getKey();
+ String value;
+ if (e.getValue() instanceof Boolean) {
+ value = ((Boolean) e.getValue()) ? "YES" : "NO";
+ } else {
+ value = e.getValue().toString();
+ }
+ var rowFormatSpec = (label.startsWith("\t"))
+ ? "%-" + labelWidth + "s" + " " + "%s"
+ : "%-" + (labelWidth + 3) + "s" + " " + "%s";
+ var row = format(rowFormatSpec, label, value);
+ rows.append("\t").append(row);
+ if (++rowNum[0] < numRows) {
+ rows.append("\n");
+ }
+ });
+ return rows.toString();
+ };
+ return title + "\n" +
+ resultsTable.get();
+ };
+
+ /**
+ * Print offer summary to stdout.
+ *
+ * @param offer printed offer
+ */
+ public static void printOfferSummary(OfferInfo offer) {
+ new TableBuilder(OFFER_TBL, offer).build().print(out);
+ }
+
+ /**
+ * Print list of offer summaries to stdout
+ *
+ * @param offers printed offer list
+ */
+ public static void printOffersSummary(List offers) {
+ new TableBuilder(OFFER_TBL, offers).build().print(out);
+ }
+
+ /**
+ * Print trade summary to stdout.
+ *
+ * @param trade printed trade
+ */
+ public static void printTradeSummary(TradeInfo trade) {
+ new TableBuilder(TRADE_DETAIL_TBL, trade).build().print(out);
+ }
+
+ /**
+ * Print list of trade summaries to stdout.
+ *
+ * @param category category OPEN | CLOSED | FAILED
+ * @param trades list of trades
+ */
+ public static void printTradesSummary(GetTradesRequest.Category category, List trades) {
+ log.info("{} trades:", category.name());
+ switch (category) {
+ case CLOSED -> new TableBuilder(CLOSED_TRADES_TBL, trades).build().print(out);
+ case FAILED -> new TableBuilder(FAILED_TRADES_TBL, trades).build().print(out);
+ default -> new TableBuilder(OPEN_TRADES_TBL, trades).build().print(out);
+ }
+ }
+
+ /**
+ * Prints PaymentAccount summary to stdout.
+ *
+ * @param paymentAccount the printed PaymentAccount
+ */
+ public static void printPaymentAccountSummary(PaymentAccount paymentAccount) {
+ new TableBuilder(PAYMENT_ACCOUNT_TBL, paymentAccount).build().print(out);
+ }
+
+ /**
+ * Log a CLI confirmpaymentstarted command for a simulated trading peer.
+ *
+ * @param log calling bot's logger
+ * @param tradingPeerApiPassword trading peer's CLI --password param value
+ * @param tradingPeerApiPort trading peer's CLI --port param value
+ * @param tradeId trade's unique identifier (cannot be short-id)
+ */
+ public static void printCliPaymentStartedCommand(Logger log,
+ String tradingPeerApiPassword,
+ int tradingPeerApiPort,
+ String currencyCode,
+ String tradeId) {
+ log.warn(BANNER);
+ log.warn("BTC buyer must manually confirm {} payment has been sent"
+ + " with a confirmpaymentstarted CLI command:",
+ currencyCode);
+ log.warn("./bisq-cli --password={} --port={} confirmpaymentstarted --trade-id={}",
+ tradingPeerApiPassword,
+ tradingPeerApiPort,
+ tradeId);
+ log.warn(BANNER);
+ }
+
+ /**
+ * Log a CLI confirmpaymentreceived command for a simulated trading peer.
+ *
+ * @param log calling bot's logger
+ * @param tradingPeerApiPassword trading peer's CLI --password param value
+ * @param tradingPeerApiPort trading peer's CLI --port param value
+ * @param tradeId trade's unique identifier (cannot be short-id)
+ */
+ public static void printCliPaymentReceivedConfirmationCommand(Logger log,
+ String tradingPeerApiPassword,
+ int tradingPeerApiPort,
+ String currencyCode,
+ String tradeId) {
+ log.warn(BANNER);
+ log.warn("BTC seller must manually confirm {} payment was received"
+ + " with a confirmpaymentreceived CLI command:",
+ currencyCode);
+ log.warn("./bisq-cli --password={} --port={} confirmpaymentreceived --trade-id={}",
+ tradingPeerApiPassword,
+ tradingPeerApiPort,
+ tradeId);
+ log.warn(BANNER);
+ }
+
+ /**
+ * Log a CLI closetrade command for a simulated trading peer.
+ *
+ * @param log calling bot's logger
+ * @param tradingPeerApiPassword trading peer's CLI --password param value
+ * @param tradingPeerApiPort trading peer's CLI --port param value
+ * @param tradeId trade's unique identifier (cannot be short-id)
+ */
+ public static void printCliCloseTradeCommand(Logger log,
+ String tradingPeerApiPassword,
+ int tradingPeerApiPort,
+ String tradeId) {
+ log.warn(BANNER);
+ log.warn("Trading peer must manually close trade with a closetrade CLI command:");
+ log.warn("./bisq-cli --password={} --port={} closetrade --trade-id={}",
+ tradingPeerApiPassword,
+ tradingPeerApiPort,
+ tradeId);
+ log.warn(BANNER);
+ }
+
+ /**
+ * Log a CLI gettrades --category=closed command for a simulated trading peer.
+ *
+ * @param log calling bot's logger
+ * @param tradingPeerApiPassword trading peer's CLI --password param value
+ * @param tradingPeerApiPort trading peer's CLI --port param value
+ */
+ public static void printCliGetClosedTradesCommand(Logger log,
+ String tradingPeerApiPassword,
+ int tradingPeerApiPort) {
+ log.warn(BANNER);
+ log.warn("Trading peer can view completed trade history with a gettrades CLI command:");
+ log.warn("./bisq-cli --password={} --port={} gettrades --category=closed",
+ tradingPeerApiPassword,
+ tradingPeerApiPort);
+ log.warn(BANNER);
+ }
+
+ /**
+ * Run a bash script to count down the given number of seconds, printing each character of output from stdout.
+ *
+ * Can only be run if the system's bash command language interpreter can be found.
+ *
+ * @param seconds to count down
+ */
+ public static void showCountdown(int seconds) {
+ getBashPath().ifPresentOrElse((bashPath) -> {
+ var bashScript = format(
+ "for i in {%d..1}; do echo -ne \"Waking up in $i seconds...\\r\" && sleep 1; done; echo -ne \"\\r\"", seconds);
+ try {
+ BotUtils.runBashCommand(bashScript);
+ } catch (IOException ex) {
+ throw new RuntimeException("Error running bash script.", ex);
+ } catch (InterruptedException ignored) {
+ // ignored
+ }
+ }, () -> {
+ throw new UnsupportedOperationException("Bash command language interpreter not found.");
+ });
+ }
+
+ /**
+ * Execute a bash system command, print process' stdout during the command's execution,
+ * and return its status code (0 or 1).
+ *
+ * @param bashCommand the system bash command
+ * @return int system command status code
+ * @throws IOException if an I/O error occurs
+ * @throws InterruptedException if the current thread is interrupted by another thread while it is waiting,
+ * then the wait is ended and an InterruptedException is thrown.
+ * @throws UnsupportedOperationException if the command language interpreter could not be found on the system, or
+ * if the operating system does not support the creation of processes.
+ */
+ @SuppressWarnings("UnusedReturnValue")
+ public static int runBashCommand(String bashCommand) throws IOException, InterruptedException {
+ var bashPath = getBashPath();
+ if (bashPath.isPresent()) {
+ List cmdOptions = new ArrayList<>() {{
+ //noinspection OptionalGetWithoutIsPresent
+ add(bashPath.get());
+ add("-c");
+ add(bashCommand);
+ }};
+ Process process = new ProcessBuilder(cmdOptions).start();
+ try (InputStreamReader isr = new InputStreamReader(process.getInputStream())) {
+ int c;
+ while ((c = isr.read()) >= 0) {
+ out.print((char) c);
+ out.flush();
+ }
+ }
+ return process.waitFor();
+ } else {
+ throw new UnsupportedOperationException("Bash util not found on this " + getOSName() + " system.");
+ }
+ }
+
+ /**
+ * Return an Optional for the absolute path of the system's bash utility,
+ * if it exists at one of two locations: "/bin/bash", or "/usr/bin/bash".
+ *
+ * @return Optional
+ */
+ public static Optional getBashPath() {
+ if (isUnix()) {
+ var f1 = new File("/bin/bash");
+ var f2 = new File("/usr/bin/bash");
+ if (f1.exists() && f1.canExecute()) {
+ return Optional.of(f1.getAbsolutePath());
+ } else if (f2.exists() && f2.canExecute()) {
+ return Optional.of(f2.getAbsolutePath());
+ } else {
+ return Optional.empty();
+ }
+ } else {
+ return Optional.empty();
+ }
+ }
+
+ /**
+ * Return true if OS is any flavor of Linux.
+ *
+ * @return true if OS is any flavor of Linux
+ */
+ public static boolean isUnix() {
+ return isOSX() || isLinux() || getOSName().contains("freebsd");
+ }
+
+ /**
+ * Return true if OS is Windows.
+ *
+ * @return true if OS is Windows
+ */
+ public static boolean isWindows() {
+ return getOSName().contains("win");
+ }
+
+ /**
+ * Return true if running on a virtualized OS within Qubes.
+ *
+ * @return true if running on a virtualized OS within Qubes
+ */
+ public static boolean isQubesOS() {
+ // For Linux qubes, "os.version" looks like "4.19.132-1.pvops.qubes.x86_64"
+ // The presence of the "qubes" substring indicates this Linux is running as a qube
+ // This is the case for all 3 virtualization modes (PV, PVH, HVM)
+ // In addition, this works for both simple AppVMs, as well as for StandaloneVMs
+ // TODO This might not work for detecting Qubes virtualization for other OSes
+ // like Windows
+ return getOSVersion().contains("qubes");
+ }
+
+ /**
+ * Return true if OS is Mac.
+ *
+ * @return true if OS is Mac
+ */
+ public static boolean isOSX() {
+ return getOSName().contains("mac") || getOSName().contains("darwin");
+ }
+
+ /**
+ * Return true if OS is Linux.
+ *
+ * @return true if OS is Linux
+ */
+ public static boolean isLinux() {
+ return getOSName().contains("linux");
+ }
+
+ /**
+ * Return true if OS is Debian Linux.
+ *
+ * @return true if OS is Debian Linux
+ */
+ public static boolean isDebianLinux() {
+ return isLinux() && new File("/etc/debian_version").isFile();
+ }
+
+ /**
+ * Return true if OS is Redhat Linux.
+ *
+ * @return true if OS is Redhat Linux
+ */
+ public static boolean isRedHatLinux() {
+ return isLinux() && new File("/etc/redhat-release").isFile();
+ }
+
+ /**
+ * Returns the OS name in lower case.
+ *
+ * @return OS name
+ */
+ public static String getOSName() {
+ return System.getProperty("os.name").toLowerCase(Locale.US);
+ }
+
+ /**
+ * Returns the OS version in lower case.
+ *
+ * @return OS version
+ */
+ public static String getOSVersion() {
+ return System.getProperty("os.version").toLowerCase(Locale.US);
+ }
+}
diff --git a/java-examples/src/main/java/bisq/bots/Config.java b/java-examples/src/main/java/bisq/bots/Config.java
new file mode 100644
index 0000000..7438073
--- /dev/null
+++ b/java-examples/src/main/java/bisq/bots/Config.java
@@ -0,0 +1,135 @@
+/*
+ * 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.bots;
+
+
+import joptsimple.OptionParser;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.PrintStream;
+
+import static java.lang.Boolean.FALSE;
+import static java.lang.System.*;
+
+
+/**
+ * Parses program arguments common to Java bot examples.
+ */
+@Slf4j
+@Getter
+public class Config {
+
+ private final String host;
+ private final int port;
+ private final String password;
+ private final String walletPassword;
+ private final String conf;
+ private final boolean dryRun;
+ // This is an experimental option for simulating and automating protocol payment steps during bot development.
+ // Be extremely careful in its use; You do not want to "simulate" payments when API daemon is connected to mainnet.
+ private final boolean simulatePaymentSteps;
+
+ public Config(String[] args, String defaultPropertiesFilename) {
+ var parser = new OptionParser();
+ var helpOpt = parser.accepts("help", "Print this help text")
+ .forHelp();
+ var hostOpt = parser.accepts("host", "API daemon hostname or IP")
+ .withRequiredArg()
+ .defaultsTo("localhost");
+ var portOpt = parser.accepts("port", "API daemon port")
+ .withRequiredArg()
+ .ofType(Integer.class)
+ .defaultsTo(9998);
+ var passwordOpt = parser.accepts("password", "API daemon password (required)")
+ .withRequiredArg();
+ var walletPasswordOpt = parser.accepts("wallet-password", "API wallet password (required)")
+ .withRequiredArg();
+ var confOpt = parser.accepts("conf", "Bot configuration file (required)")
+ .withRequiredArg();
+ var dryRunOpt = parser.accepts("dryrun", "Pretend to take an offer (default=false)")
+ .withRequiredArg()
+ .ofType(boolean.class)
+ .defaultsTo(FALSE);
+ var simulateRegtestPaymentStepsOpt =
+ parser.accepts("simulate-regtest-payment", "Simulate regtest payment steps (default=false)")
+ .withOptionalArg()
+ .ofType(boolean.class)
+ .defaultsTo(FALSE);
+
+ var options = parser.parse(args);
+ if (options.has(helpOpt)) {
+ printHelp(parser, out);
+ exit(0);
+ }
+
+ this.host = options.valueOf(hostOpt);
+ this.port = options.valueOf(portOpt);
+
+ this.password = options.valueOf(passwordOpt);
+ if (password == null) {
+ log.error("Missing required '--password=' option");
+ printHelp(parser, err);
+ exit(1);
+ }
+
+ this.walletPassword = options.valueOf(walletPasswordOpt);
+ if (walletPassword == null) {
+ log.error("Missing required '--wallet-password=' option");
+ printHelp(parser, err);
+ exit(1);
+ }
+
+ if (!options.has(confOpt)) {
+ this.conf = defaultPropertiesFilename;
+ } else {
+ this.conf = options.valueOf(confOpt);
+ if (!(new File(conf).exists())) {
+ log.error("Invalid '--conf=' option: external file does not exist.");
+ printHelp(parser, err);
+ exit(1);
+ }
+ }
+
+ this.dryRun = options.valueOf(dryRunOpt);
+ this.simulatePaymentSteps = options.valueOf(simulateRegtestPaymentStepsOpt);
+
+ if (dryRun && simulatePaymentSteps) {
+ log.error("""
+ The '--dryrun` and '--simulate-regtest-payment' options are mutually exclusive.
+ They cannot both be true. Use '--dryrun=true` on mainnet, to see what real offers your bot would take.
+ Use '--simulate-regtest-payment=true' on regtest, to simulate a trade to its completion.""");
+ printHelp(parser, err);
+ exit(1);
+ }
+ }
+
+ private static void printHelp(OptionParser parser, @SuppressWarnings("SameParameterValue") PrintStream stream) {
+ try {
+ stream.println("Bisq RPC Client");
+ stream.println();
+ stream.println("Usage: ScriptName [options]");
+ stream.println();
+ parser.printHelpOn(stream);
+ stream.println();
+ } catch (IOException ex) {
+ ex.printStackTrace(stream);
+ }
+ }
+}
diff --git a/java-examples/src/main/java/bisq/bots/NonFatalException.java b/java-examples/src/main/java/bisq/bots/NonFatalException.java
new file mode 100644
index 0000000..99e5240
--- /dev/null
+++ b/java-examples/src/main/java/bisq/bots/NonFatalException.java
@@ -0,0 +1,45 @@
+/*
+ * 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.bots;
+
+import lombok.Getter;
+
+/**
+ * Custom exception for handling non-fatal exceptions occurring in bots.
+ */
+public class NonFatalException extends Exception {
+
+ @Getter
+ private final long stallTime;
+
+ public NonFatalException(String message) {
+ this(message, null, 0);
+ }
+
+ public NonFatalException(String message, long stallTime) {
+ this(message, null, stallTime);
+ }
+
+ public NonFatalException(String message, Throwable cause, long stallTime) {
+ super(message, cause);
+ this.stallTime = stallTime;
+ }
+
+ public boolean hasStallTime() {
+ return stallTime > 0;
+ }
+}
diff --git a/java-examples/src/main/java/bisq/bots/OfferTaker.java b/java-examples/src/main/java/bisq/bots/OfferTaker.java
new file mode 100644
index 0000000..08a29b3
--- /dev/null
+++ b/java-examples/src/main/java/bisq/bots/OfferTaker.java
@@ -0,0 +1,321 @@
+/*
+ * 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.bots;
+
+import bisq.proto.grpc.*;
+import io.grpc.StatusRuntimeException;
+import lombok.extern.slf4j.Slf4j;
+import protobuf.PaymentAccount;
+
+import javax.annotation.Nullable;
+import java.util.Optional;
+
+import static bisq.bots.BotUtils.*;
+import static bisq.proto.grpc.GetOfferCategoryReply.OfferCategory.BSQ_SWAP;
+import static io.grpc.Status.*;
+import static java.lang.String.format;
+import static java.util.Objects.requireNonNull;
+
+/**
+ * This helper class exists to encapsulate takeoffer error handling, and waiting for a new trade to be fully
+ * initialized trade.
+ *
+ * For v1 protocol trades, the happy path is a successful takeoffer call, resulting in a quickly prepared, new trade.
+ * But several things can go wrong. An offer may be unavailable for one of several reasons. When an offer availability
+ * check fails while processing a takeoffer request, the server's response object will contain a "failure reason"
+ * instead of a new trade, and no gRPC StatusRuntimeException is thrown.
+ *
+ * See https://bisq-network.github.io/slate/#rpc-method-takeoffer
+ * See https://bisq-network.github.io/slate/#availabilityresultwithdescription
+ * See https://bisq-network.github.io/slate/#availabilityresult
+ *
+ *
+ * If offer availability checks passed, the takeoffer request can still fail for other reasons, and the API server
+ * will send a gRPC StatusRuntimeException to the client. Two likely errors are (1) insufficient BTC in the offer
+ * taker's wallet, or (2) the API server's takeoffer call rate of 1 takeoffer request per minute has been exceeded.
+ * In both cases, the error should not be fatal to a bot. The bot can keep running while the user funds the wallet,
+ * and the bot should stall long enough to make the rate meter happy again.
+ *
+ * Any other gRPC StatusRuntimeException passed up to the client should be considered fatal.
+ *
+ * The happy path for taking a BSQ Swap offer is a completed swap.
+ */
+@Slf4j
+class OfferTaker {
+
+ private static final int MAX_GET_NEW_TRADE_ATTEMPTS = 15;
+
+ private final GrpcStubs grpcStubs;
+ private final OfferInfo offer;
+ @Nullable
+ private final PaymentAccount paymentAccount; // Not used for taking bsq swaps.
+ @Nullable
+ private final String bisqTradeFeeCurrency; // Not used for taking bsq swaps.
+ private final long pollingInterval;
+ private final GetTradeRequest getTradeRequest;
+
+ /**
+ * Constructor for taking BSQ swap offers.
+ * Payment acct id nor trade fee currency code are used in takeoffer requests.
+ *
+ * @param grpcStubs gRPC service stubs, initialized with hostname, port, and credentials.
+ * @param offer The offer to take.
+ * @param pollingInterval The calling bot's polling interval, in milliseconds (some situations require calculating
+ * a stalling period before making the next request).
+ */
+ OfferTaker(GrpcStubs grpcStubs,
+ OfferInfo offer,
+ long pollingInterval) {
+ this(grpcStubs, offer, null, null, pollingInterval);
+ }
+
+ /**
+ * Constructor for taking v1 protocol offers (fiat or xmr).
+ *
+ * @param grpcStubs gRPC service stubs, initialized with hostname, port, and credentials.
+ * @param offer The offer to take.
+ * @param paymentAccount The payment account used to take the offer.
+ * @param bisqTradeFeeCurrency The Bisq trade fee currency code (BSQ or BTC).
+ * @param pollingInterval The calling bot's polling interval, in milliseconds (some situations require
+ * calculating a stalling period before making the next request).
+ */
+ OfferTaker(GrpcStubs grpcStubs,
+ OfferInfo offer,
+ @Nullable PaymentAccount paymentAccount,
+ @Nullable String bisqTradeFeeCurrency,
+ long pollingInterval) {
+ this.grpcStubs = grpcStubs;
+ this.offer = offer;
+ this.paymentAccount = paymentAccount;
+ this.bisqTradeFeeCurrency = bisqTradeFeeCurrency;
+ this.pollingInterval = pollingInterval;
+ this.getTradeRequest = GetTradeRequest.newBuilder().setTradeId(offer.getId()).build();
+ }
+
+ private OfferTaker() {
+ throw new UnsupportedOperationException("Default, no-arg constructor is invalid.");
+ }
+
+ /**
+ * Takes an offer. Throws an exception if one of various possible causes of failure is detected.
+ * Only fiat offers are supported by this class, so far.
+ *
+ * @throws NonFatalException if a failure to take the offer was detected. The offer could have been
+ * unavailable for one of many reasons, or the taker's wallet had insufficient BTC
+ * to cover the trade. Other problems could result in this exception being thrown,
+ * including attempts to make takeoffer requests more than once per minute.
+ * None of these are fatal errors for a bot, which can opt to not terminate itself.
+ * @throws StatusRuntimeException if a fatal error occurred while attempting to take the offer.
+ */
+ void takeOffer() throws NonFatalException {
+ // What kind of offer is being taken: FIAT, ALTCOIN, or BSQ_SWAP?
+ var offerCategoryRequest = GetOfferCategoryRequest.newBuilder()
+ .setId(offer.getId())
+ .build();
+ var offerCategory = grpcStubs.offersService
+ .getOfferCategory(offerCategoryRequest)
+ .getOfferCategory();
+
+ if (offerCategory.equals(BSQ_SWAP)) {
+ sendTakeOfferRequest(offerCategory);
+ // The happy path: No non-fatal or fatal exception was thrown. There was no offer availability problem,
+ // no insufficient funds problem, and the takeoffer call rate meter did not block the request. A new swap
+ // is being executed on the server, and the bot should check for the new trade, then shut down.
+ log.info("New BSQ swap '{}' is being executed.", offer.getId());
+ // Let the daemon completely execute the swap before making any gettrade requests.
+ sleep(2_000);
+ } else {
+ sendTakeOfferRequest(offerCategory);
+ // The happy path: No non-fatal or fatal exception was thrown. There was no offer availability problem,
+ // no insufficient funds problem, and the takeoffer call rate meter did not block the request. A new trade
+ // is being prepared on the server, and the bot should check for the new trade, then shut down so the
+ // trade can be completed in the UI.
+ log.info("New trade '{}' is being prepared:", offer.getId());
+ printTradeSummary(getTrade());
+ }
+ }
+
+ /**
+ * Sends a TakeOfferRequest. Throws a NonFatalException if there was an offer availability problem, insufficient
+ * funds in the taker's wallet, or a fatal gRPC StatusRuntimeException. If no exception is thrown, it is assumed
+ * a new trade has been created and will be fully prepared in a few seconds.
+ *
+ * @param offerCategory FIAT | ALTCOIN | BSQ_SWAP
+ * @see https://bisq-network.github.io/slate/?java#rpc-method-takeoffer
+ */
+ // TODO refactor (combine) with sendTakeBsqSwapOfferRequest?
+ private void sendTakeOfferRequest(GetOfferCategoryReply.OfferCategory offerCategory)
+ throws NonFatalException {
+ TakeOfferReply reply;
+ try {
+ var requestBuilder = TakeOfferRequest.newBuilder().setOfferId(offer.getId());
+ if (!offerCategory.equals(BSQ_SWAP)) {
+ // V1 protocol takeoffer requests require a paymentAccountId and optional takerFeeCurrencyCode.
+ var paymentAccountId = requireNonNull(paymentAccount,
+ "The takeoffer requests' paymentAccountId cannot be null").getId();
+ requestBuilder.setPaymentAccountId(paymentAccountId)
+ .setTakerFeeCurrencyCode(bisqTradeFeeCurrency);
+ }
+ // The TakeOffer reply will contain a new trade, or a reason the offer was not available for the taking.
+ // However, offer availability checks do not check for sufficient funds in the taker's wallet, and that
+ // situation is handled in the catch block.
+ var request = requestBuilder.build();
+ reply = grpcStubs.tradesService.takeOffer(request);
+ if (reply.hasFailureReason()) {
+ // A failure reason results from an offer availability problem, which does not include
+ // an insufficient funds problem with the taker's wallet. Neither case is fatal for the
+ // bot, but the two problems must be handled differently in the client.
+ // TODO Failure reason and exception due to insufficient funds needs to be treated in a more
+ // uniform manner in the server, so the client does not need to concern itself with this confusing
+ // difference. In the UI, offer availability checks are trigger by one button click (Take Offer),
+ // and insufficient funds exceptions may be triggered by another button click (Transfer Funds
+ // For The Trade). The API daemon needs to fix this in a a backwards compatible way.
+ AvailabilityResultWithDescription reason = reply.getFailureReason();
+ String errorMessage = format("Non-Fatal Error %s: %s", reason.getAvailabilityResult(), reason.getDescription());
+ throw new NonFatalException(errorMessage);
+ }
+ } catch (StatusRuntimeException grpcException) {
+ handleTakeOfferException(grpcException);
+ }
+ }
+
+ /**
+ * Handle a gRPC StatusRuntimeException from the API server while calling the API's takeoffer method. Some are
+ * fatal exceptions, others not.
+ *
+ * The gRPC exception's status code will be UNAVAILABLE if there is an insufficient funds error. This is not a
+ * fatal error, and the user can fund the wallet while the bot is running.
+ *
+ * The gRPC exception's status code will be PERMISSION_DENIED when the takeoffer request frequency is > 1 / minute.
+ * This is not a fatal error. In this case, set the NonFatalException's stallTime, so the bot can wait the minimum
+ * amount of time required to avoid another StatusRuntimeException(PERMISSION_DENIED).
+ *
+ * For any other gRPC exception status code, assumes a fatal error and throws the exception.
+ */
+ private void handleTakeOfferException(StatusRuntimeException ex) throws NonFatalException {
+ if (exceptionHasStatus.test(ex, UNAVAILABLE)) {
+ throw new NonFatalException(toNonFatalErrorMessage.apply(ex));
+ } else if (exceptionHasStatus.test(ex, PERMISSION_DENIED)) {
+ // Calculate how long we have to stall the bot before it can send the next takeoffer request.
+ long stallTime = 60_005 - pollingInterval;
+ throw new NonFatalException(toNonFatalErrorMessage.apply(ex), stallTime);
+ } else {
+ throw ex;
+ }
+ }
+
+ /**
+ * Wait and block until a new BSQ swap is executed.
+ *
+ * Should be called immediately after a takeoffer call. If the executed trade is not found
+ * within a maximum allowed amount of time ({@link #MAX_GET_NEW_TRADE_ATTEMPTS} * 1 second),
+ * throw a fatal StatusRuntimeException(NOT_FOUND).
+ */
+ void waitForBsqSwapCompletion() {
+ Optional newTrade = getPreparedTrade();
+ if (newTrade.isPresent()) {
+ TradeInfo trade = newTrade.get();
+ log.info("BSQ Swap is complete:");
+ printTradeSummary(trade);
+ } else {
+ throw new StatusRuntimeException(NOT_FOUND
+ .withDescription("Something bad happened, could not find the new trade."
+ + " Shut down the API bot and server, then check the server log."));
+ }
+ }
+
+ /**
+ * Wait and block until a new trade is fully initialized, with a trade contract and the user's trade role.
+ *
+ * Should be called immediately after a takeoffer call. If the new trade is not initialized within a maximum
+ * amount of time ({@link #MAX_GET_NEW_TRADE_ATTEMPTS} * 1 second) throw a fatal
+ * StatusRuntimeException(NOT_FOUND).
+ */
+ void waitForTradePreparation() {
+ Optional newTrade = getPreparedTrade();
+ if (newTrade.isPresent()) {
+ TradeInfo trade = newTrade.get();
+ log.info("New trade has been prepared:");
+ printTradeSummary(trade);
+ } else {
+ throw new StatusRuntimeException(NOT_FOUND
+ .withDescription("Something bad happened, could not find the new trade."
+ + " Shut down the API bot and server, then check the server log."));
+ }
+ }
+
+ /**
+ * Calls {@link #getNewTrade} every second, for a maximum of {@link #MAX_GET_NEW_TRADE_ATTEMPTS} seconds, or
+ * until the newly prepared trade is found -- whichever comes first.
+ *
+ * If the newly prepared trade is found within the time limit, returns an Optional object, else
+ * throws a gRPC StatusRuntimeException with Status.Code = NOT_FOUND.
+ *
+ * @return Optional containing a prepared trade.
+ */
+ private Optional getPreparedTrade() {
+ int attempts = 0;
+ while (attempts++ < MAX_GET_NEW_TRADE_ATTEMPTS - 1) {
+ Optional trade = getNewTrade();
+ if (trade.isPresent() && !trade.get().getRole().equalsIgnoreCase("Not Available")) {
+ return trade;
+ } else {
+ sleep(1_000);
+ }
+ }
+ // Try again, one last time, and throw the NOT_FOUND found exception from the gRPC server.
+ return Optional.of(getTrade());
+ }
+
+ /**
+ * Returns an Optional containing a trade, Optional.empty() if not found, or throws a
+ * gRPC StatusRuntimeException.
+ *
+ * Use this method if you took the trade just now and want an Optional.empty() if not found, instead of a gRPC
+ * StatusRuntimeException(NOT_FOUND) exception. Any gRPC StatusRuntimeException with a Status code != NOT_FOUND
+ * will be thrown.
+ *
+ * @return Optional containing a trade, or Optional.empty() if not found, or throws a
+ * gRPC StatusRuntimeException.
+ */
+ private Optional getNewTrade() {
+ try {
+ var trade = getTrade();
+ return Optional.of(trade);
+ } catch (Throwable t) {
+ if (t instanceof StatusRuntimeException) {
+ if (exceptionHasStatus.test((StatusRuntimeException) t, NOT_FOUND))
+ return Optional.empty();
+ else
+ throw t;
+ } else {
+ throw t;
+ }
+ }
+ }
+
+ /**
+ * Returns a trade, or throws a gRPC StatusRuntimeException.
+ *
+ * @return bisq.proto.grpc.TradeInfo
+ * @see https://bisq-network.github.io/slate/?java#rpc-method-gettrade
+ */
+ private TradeInfo getTrade() {
+ var response = grpcStubs.tradesService.getTrade(getTradeRequest);
+ return response.getTrade();
+ }
+}
diff --git a/java-examples/src/main/java/bisq/bots/RegtestTradePaymentSimulator.java b/java-examples/src/main/java/bisq/bots/RegtestTradePaymentSimulator.java
new file mode 100644
index 0000000..3bac466
--- /dev/null
+++ b/java-examples/src/main/java/bisq/bots/RegtestTradePaymentSimulator.java
@@ -0,0 +1,187 @@
+/*
+ * 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.bots;
+
+import io.grpc.StatusRuntimeException;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+import protobuf.PaymentAccount;
+
+import java.util.Properties;
+
+import static bisq.bots.BotUtils.*;
+import static bisq.proto.grpc.GetTradesRequest.Category.CLOSED;
+import static io.grpc.Status.Code.PERMISSION_DENIED;
+
+/**
+ * Simulates trade payment protocol steps on the BTC regtest network only (useful in bot development and testing).
+ */
+@Slf4j
+@Getter
+public class RegtestTradePaymentSimulator extends AbstractBot {
+
+ // Config file: resources/RegtestTradePaymentSimulator.properties.
+ private final Properties configFile;
+
+ // Payment simulator bot's payment account (passed from bot that created the trade).
+ private final PaymentAccount paymentAccount;
+ // Payment simulator bot's trade (passed from bot that created the trade).
+ private final String tradeId;
+ // Payment simulator bot's trade currency code (derived from payment account's selected currency code).
+ private final String currencyCode;
+ // Defined in RegtestTradePaymentSimulator.properties.
+ private final long pollingInterval;
+
+ // Constructor
+ public RegtestTradePaymentSimulator(String[] args, String tradeId, PaymentAccount paymentAccount) {
+ super(args);
+ this.tradeId = tradeId;
+ this.paymentAccount = paymentAccount;
+ this.currencyCode = paymentAccount.getSelectedTradeCurrency().getCode();
+ this.configFile = loadConfigFile();
+ this.pollingInterval = Long.parseLong(configFile.getProperty("pollingInterval"));
+ }
+
+ /**
+ * Performs trade protocol steps starting after a successful takeoffer request, resulting in a new trade.
+ *
+ * If the calling bot is a BTC buyer, will send a fiat or XMR confirmpaymentstarted message to trading peer,
+ * then print a CLI command for the trading peer to run and wait for a confirmpaymentreceived from the trading peer,
+ * then close the trade.
+ *
+ * If the calling bot is a BTC seller, will print a CLI confirmpaymentstarted command for the trading peer to run,
+ * then wait for a confirmpaymentstarted from the trading peer. After this bot receives the confirmpaymentstarted
+ * message from the trading peer, will send a confirmpaymentreceived, then close the trade.
+ *
+ * Never run this on mainnet. If you attempt to run this bot on mainnet, it will throw a fatal gRPC
+ * StatusRuntimeException(PERMISSION_DENIED).
+ */
+ @Override
+ public void run() {
+ verifyNotConnectedToMainnet();
+
+ waitForTakerDepositTxConfirmation();
+
+ var trade = getTrade(tradeId);
+
+ // All Bisq trades are based on buying or selling BTC. When a user thinks of "buying XMR (with BTC)",
+ // Bisq's code treats it as "selling BTC (for XMR)". This can be confusing; try not to allow Bisq UI labels
+ // and conversations about trading on Bisq mix you (API bot coder) up.
+ var iAmBtcBuyer = isBtcBuyer.test(trade);
+ if (iAmBtcBuyer) {
+ // I (bot) am BTC buyer. I send a confirmpaymentstarted msg and wait for a confirmpaymentreceived msg.
+ sendPaymentStartedMessage();
+ printCliPaymentReceivedConfirmationCommand(log,
+ "xyz",
+ 9999,
+ currencyCode,
+ trade.getTradeId());
+ waitForPaymentReceivedConfirmationMessage();
+ } else {
+ // I (bot) am BTC seller. I wait for a confirmpaymentstarted msg and send a confirmpaymentreceived msg.
+ printCliPaymentStartedCommand(log,
+ "xyz",
+ 9999,
+ currencyCode,
+ trade.getTradeId());
+ waitForPaymentStartedMessage();
+ sendPaymentReceivedConfirmationMessage();
+ }
+
+ sleep(pollingInterval);
+ closeTrade(tradeId);
+ log.info("You closed the trade here in the bot (mandatory, to move trades to history list).");
+
+ log.warn("##############################################################################");
+ log.warn("Bob closes trade in the CLI (mandatory, to move trades to history list):");
+ String copyPasteCliCommands = "./bisq-cli --password=xyz --port=9999 closetrade --trade-id=" + trade.getTradeId()
+ + "\n" + "./bisq-cli --password=xyz --port=9999 gettrades --category=closed";
+ log.warn(copyPasteCliCommands);
+ log.warn("##############################################################################");
+
+ sleep(pollingInterval);
+ log.info("Trade is completed, printing all closed trades and exiting {}.", this.getClass().getSimpleName());
+ printTradesSummary(CLOSED);
+ }
+
+ private void waitForTakerDepositTxConfirmation() {
+ var trade = getTrade(tradeId);
+ while (!trade.getIsDepositConfirmed()) {
+ log.info("The trade's taker deposit tx `{}` has not yet been confirmed on the bitcoin blockchain.",
+ trade.getDepositTxId());
+ sleep(pollingInterval);
+ trade = getTrade(trade.getTradeId());
+ }
+ printTradeSummary(trade);
+ log.info("The trade's taker deposit tx `{}` has been confirmed on the bitcoin blockchain.",
+ trade.getDepositTxId());
+ }
+
+ private void waitForPaymentStartedMessage() {
+ var trade = getTrade(tradeId);
+ while (!trade.getIsPaymentStartedMessageSent()) {
+ log.info("The trade's {} payment has not yet been sent.", currencyCode);
+ sleep(pollingInterval);
+ trade = getTrade(trade.getTradeId());
+ }
+ printTradeSummary(trade);
+ log.info("The trade's {} payment has been sent.", currencyCode);
+ }
+
+ private void sendPaymentStartedMessage() {
+ log.info("You send a {} payment started message to the BTC seller.", currencyCode);
+ sleep(pollingInterval);
+ confirmPaymentStarted(tradeId);
+ sleep(2_000);
+ var trade = getTrade(tradeId);
+ printTradeSummary(trade);
+ log.info("You sent a {} payment started message to the BTC seller.", currencyCode);
+ }
+
+ private void waitForPaymentReceivedConfirmationMessage() {
+ var trade = getTrade(tradeId);
+ while (!trade.getIsPaymentReceivedMessageSent()) {
+ log.info("The trade's {} payment received confirmation message has not yet been sent.", currencyCode);
+ sleep(pollingInterval);
+ trade = getTrade(trade.getTradeId());
+ }
+ printTradeSummary(trade);
+ log.info("The trade's {} payment has been sent.", currencyCode);
+ }
+
+ private void sendPaymentReceivedConfirmationMessage() {
+ log.info("You confirm {} payment was received to you wallet before"
+ + " sending confirmpaymentreceived to the BTC buyer.",
+ currencyCode);
+ sleep(pollingInterval);
+ confirmPaymentReceived(tradeId);
+ sleep(2_000);
+ var trade = getTrade(tradeId);
+ printTradeSummary(trade);
+ log.info("You sent a confirmpaymentreceived message to the BTC buyer.");
+ }
+
+ private void verifyNotConnectedToMainnet() {
+ if (isConnectedToMainnet()) {
+ // We throw a FATAL(!) gRPC StatusRuntimeException(PERMISSION_DENIED) if the calling bot attempts
+ // to simulate payment on the BTC mainnet network. It is very unusual for a gRPC client to throw
+ // a StatusRuntimeException, but make this one exception to emphasise the seriousness of the problem.
+ throw new StatusRuntimeException(PERMISSION_DENIED.toStatus()
+ .withDescription("API daemon is connected to BTC mainnet!"));
+ }
+ }
+}
diff --git a/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToBuyBsq.java b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToBuyBsq.java
new file mode 100644
index 0000000..b9b1226
--- /dev/null
+++ b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToBuyBsq.java
@@ -0,0 +1,351 @@
+/*
+ * 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.bots;
+
+import bisq.proto.grpc.OfferInfo;
+import io.grpc.StatusRuntimeException;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+import protobuf.PaymentAccount;
+
+import java.math.BigDecimal;
+import java.util.*;
+import java.util.function.BiPredicate;
+import java.util.function.Predicate;
+
+import static bisq.bots.BotUtils.*;
+import static java.lang.String.format;
+import static java.lang.System.exit;
+import static java.math.RoundingMode.HALF_UP;
+import static protobuf.OfferDirection.SELL;
+
+/**
+ * Bot for swapping BSQ for BTC at an attractive (high) price. The bot sends BSQ for BTC.
+ *
+ * I'm taking liberties with the classname by not naming it TakeBestPricedOfferToSellBtcForBsq.
+ */
+@Slf4j
+@Getter
+public class TakeBestPricedOfferToBuyBsq extends AbstractBot {
+
+ // Config file: resources/TakeBestPricedOfferToBuyBsq.properties.
+ private final Properties configFile;
+ // Taker bot's default BSQ Swap payment account.
+ private final PaymentAccount paymentAccount;
+ // Taker bot's payment account trading currency code (BSQ).
+ private final String currencyCode;
+ // Taker bot's minimum market price margin. A takeable BSQ Swap offer's fixed-price must be >= minMarketPriceMargin (%).
+ // Note: all BSQ Swap offers have a fixed-price, but the bot uses a margin (%) of the 30-day price for comparison.
+ private final BigDecimal minMarketPriceMargin;
+ // Hard coded 30-day average BSQ trade price, used for development over regtest (ignored when running on mainnet).
+ private final BigDecimal regtest30DayAvgBsqPrice;
+ // Taker bot's min BTC amount to sell (we are buying BSQ). A takeable offer's amount must be >= minAmount BTC.
+ private final BigDecimal minAmount;
+ // Taker bot's max BTC amount to sell (we are buying BSQ). A takeable offer's amount must be <= maxAmount BTC.
+ private final BigDecimal maxAmount;
+ // Taker bot's max acceptable transaction fee rate.
+ private final long maxTxFeeRate;
+ // Maximum # of offers to take during one bot session (shut down bot after N swaps).
+ private final int maxTakeOffers;
+
+ // Offer polling frequency must be > 1000 ms between each getoffers request.
+ private final long pollingInterval;
+
+ // The # of BSQ swap offers taken during the bot session (since startup).
+ private int numOffersTaken = 0;
+
+ public TakeBestPricedOfferToBuyBsq(String[] args) {
+ super(args);
+ pingDaemon(new Date().getTime()); // Shut down now if API daemon is not available.
+ this.configFile = loadConfigFile();
+ this.paymentAccount = getBsqSwapPaymentAccount();
+ this.currencyCode = paymentAccount.getSelectedTradeCurrency().getCode();
+ this.minMarketPriceMargin = new BigDecimal(configFile.getProperty("minMarketPriceMargin"))
+ .setScale(2, HALF_UP);
+ this.regtest30DayAvgBsqPrice = new BigDecimal(configFile.getProperty("regtest30DayAvgBsqPrice"))
+ .setScale(8, HALF_UP);
+ this.minAmount = new BigDecimal(configFile.getProperty("minAmount"));
+ this.maxAmount = new BigDecimal(configFile.getProperty("maxAmount"));
+ this.maxTxFeeRate = Long.parseLong(configFile.getProperty("maxTxFeeRate"));
+ this.maxTakeOffers = Integer.parseInt(configFile.getProperty("maxTakeOffers"));
+ loadPreferredOnionAddresses.accept(configFile, preferredTradingPeers);
+ this.pollingInterval = Long.parseLong(configFile.getProperty("pollingInterval"));
+ }
+
+ /**
+ * Checks for the most attractive BSQ Swap offer to take every {@link #pollingInterval} ms.
+ * Will only terminate when manually shut down, or a fatal gRPC StatusRuntimeException is thrown.
+ */
+ @Override
+ public void run() {
+ var startTime = new Date().getTime();
+ validatePollingInterval(pollingInterval);
+ printBotConfiguration();
+
+ while (!isShutdown) {
+ if (!isBisqNetworkTxFeeRateLowEnough.test(maxTxFeeRate)) {
+ runCountdown(log, pollingInterval);
+ continue;
+ }
+
+ // Get all available and takeable offers, sorted by price descending.
+ var offers = getOffers(SELL.name(), currencyCode).stream()
+ .filter(o -> !isAlreadyTaken.test(o))
+ .toList();
+
+ if (offers.isEmpty()) {
+ log.info("No takeable offers found.");
+ runCountdown(log, pollingInterval);
+ continue;
+ }
+
+ // Define criteria for taking an offer, based on conf file.
+ TakeBestPricedOfferToBuyBsq.TakeCriteria takeCriteria = new TakeBestPricedOfferToBuyBsq.TakeCriteria();
+ takeCriteria.printCriteriaSummary();
+ takeCriteria.printOffersAgainstCriteria(offers);
+
+ // Find takeable offer based on criteria.
+ Optional selectedOffer = takeCriteria.findTakeableOffer(offers);
+ // Try to take the offer, if found, or say 'no offer found' before going to sleep.
+ selectedOffer.ifPresentOrElse(offer -> takeOffer(takeCriteria, offer),
+ () -> {
+ var highestPricedOffer = offers.get(0);
+ log.info("No acceptable offer found. Closest possible candidate did not pass filters:");
+ takeCriteria.printOfferAgainstCriteria(highestPricedOffer);
+ });
+
+ printDryRunProgress();
+ runCountdown(log, pollingInterval);
+ pingDaemon(startTime);
+ }
+ }
+
+ private void takeOffer(TakeCriteria takeCriteria, OfferInfo offer) {
+ log.info("Will attempt to take offer '{}'.", offer.getId());
+ takeCriteria.printOfferAgainstCriteria(offer);
+ if (isDryRun) {
+ addToOffersTaken(offer);
+ numOffersTaken++;
+ maybeShutdownAfterSuccessfulSwap();
+ } else {
+ // An encrypted wallet must be unlocked before calling takeoffer and gettrade.
+ // Unlock the wallet for 10 minutes. If the wallet is already unlocked,
+ // this command will override the timeout of the previous unlock command.
+ try {
+ unlockWallet(walletPassword, 600);
+
+ printBTCBalances("BTC Balances Before Swap Execution");
+ printBSQBalances("BSQ Balances Before Swap Execution");
+
+ // Blocks until swap is executed, or times out.
+ takeBsqSwapOffer(offer, pollingInterval);
+
+ printBTCBalances("BTC Balances After Swap Execution");
+ printBSQBalances("BSQ Balances After Swap Execution");
+
+ numOffersTaken++;
+ maybeShutdownAfterSuccessfulSwap();
+ } catch (NonFatalException nonFatalException) {
+ handleNonFatalException(nonFatalException);
+ } catch (StatusRuntimeException fatalException) {
+ handleFatalException(fatalException);
+ }
+ }
+ }
+
+ private void printBotConfiguration() {
+ var configsByLabel = new LinkedHashMap();
+ configsByLabel.put("Bot OS:", getOSName() + " " + getOSVersion());
+ var network = getNetwork();
+ configsByLabel.put("BTC Network:", network);
+ var isMainnet = network.equalsIgnoreCase("mainnet");
+ var mainnet30DayAvgBsqPrice = isMainnet ? get30DayAvgBsqPriceInBtc() : null;
+ configsByLabel.put("My Payment Account:", "");
+ configsByLabel.put("\tPayment Account Id:", paymentAccount.getId());
+ configsByLabel.put("\tAccount Name:", paymentAccount.getAccountName());
+ configsByLabel.put("\tCurrency Code:", currencyCode);
+ configsByLabel.put("Trading Rules:", "");
+ configsByLabel.put("\tMax # of offers bot can take:", maxTakeOffers);
+ configsByLabel.put("\tMax Tx Fee Rate:", maxTxFeeRate + " sats/byte");
+ configsByLabel.put("\tMin Market Price Margin:", minMarketPriceMargin + "%");
+ if (isMainnet) {
+ configsByLabel.put("\tMainnet 30-Day Avg BSQ Price:", mainnet30DayAvgBsqPrice + " BTC");
+ } else {
+ configsByLabel.put("\tRegtest 30-Day Avg BSQ Price:", regtest30DayAvgBsqPrice + " BTC");
+ }
+ configsByLabel.put("\tMin BTC Amount:", minAmount + " BTC");
+ configsByLabel.put("\tMax BTC Amount: ", maxAmount + " BTC");
+ if (iHavePreferredTradingPeers.get()) {
+ configsByLabel.put("\tPreferred Trading Peers:", preferredTradingPeers.toString());
+ } else {
+ configsByLabel.put("\tPreferred Trading Peers:", "N/A");
+ }
+ configsByLabel.put("Bot Polling Interval:", pollingInterval + " ms");
+ log.info(toTable.apply("Bot Configuration", configsByLabel));
+ }
+
+ /**
+ * Log the non-fatal exception, and stall the bot if the NonFatalException has a stallTime value > 0.
+ */
+ private void handleNonFatalException(NonFatalException nonFatalException) {
+ log.warn(nonFatalException.getMessage());
+ if (nonFatalException.hasStallTime()) {
+ long stallTime = nonFatalException.getStallTime();
+ log.warn("A minute must pass between the previous and the next takeoffer attempt."
+ + " Stalling for {} seconds before the next takeoffer attempt.",
+ toSeconds.apply(stallTime + pollingInterval));
+ runCountdown(log, stallTime);
+ } else {
+ runCountdown(log, pollingInterval);
+ }
+ }
+
+ /**
+ * Log the fatal exception, and shut down daemon and bot.
+ */
+ private void handleFatalException(StatusRuntimeException fatalException) {
+ log.error("", fatalException);
+ shutdownAfterFatalError("Shutting down API daemon and bot after failing to execute BSQ swap.");
+ }
+
+ /**
+ * Lock the wallet, stop the API daemon, and terminate the bot.
+ */
+ private void maybeShutdownAfterSuccessfulSwap() {
+ if (!isDryRun) {
+ try {
+ lockWallet();
+ } catch (NonFatalException ex) {
+ log.warn(ex.getMessage());
+ }
+ }
+ if (numOffersTaken >= maxTakeOffers) {
+ isShutdown = true;
+ log.info("Shutting down API bot after executing {} BSQ swaps.", numOffersTaken);
+ exit(0);
+ } else {
+ log.info("You have completed {} BSQ swap(s) during this bot session.", numOffersTaken);
+ }
+ }
+
+ /**
+ * Return true is fixed-price offer's price >= the bot's max market price margin. Allows bot to take a
+ * fixed-priced offer if the price is >= {@link #minMarketPriceMargin} (%) of the current market price.
+ */
+ protected final BiPredicate isFixedPriceGEMaxMarketPriceMargin =
+ (offer, currentMarketPrice) -> BotUtils.isFixedPriceGEMinMarketPriceMargin(
+ offer,
+ currentMarketPrice,
+ this.getMinMarketPriceMargin());
+
+ /**
+ * Return true if offer.amt >= bot.minAmt AND offer.amt <= bot.maxAmt (within the boundaries).
+ * TODO API's takeoffer needs to support taking offer's minAmount.
+ */
+ protected final Predicate isWithinBTCAmountBounds = (offer) ->
+ BotUtils.isWithinBTCAmountBounds(offer, getMinAmount(), getMaxAmount());
+
+ public static void main(String[] args) {
+ @SuppressWarnings("unused")
+ String prompt = "An encrypted wallet must be unlocked before any offer can be taken.\n"
+ + " Please enter your wallet password:";
+ String walletPassword = "be careful"; // readWalletPassword(prompt);
+ log.info("Your wallet password is {}", walletPassword.isBlank() ? "blank" : walletPassword);
+ TakeBestPricedOfferToBuyBsq bot = new TakeBestPricedOfferToBuyBsq(appendWalletPasswordOpt(args, walletPassword));
+ bot.run();
+ }
+
+ /**
+ * Calculates additional takeoffer criteria based on conf file values,
+ * performs candidate offer filtering, and provides useful log statements.
+ */
+ private class TakeCriteria {
+ private static final String MARKET_DESCRIPTION = "Buy BSQ (Sell BTC)";
+
+ private final BigDecimal avgBsqPrice;
+ @Getter
+ private final BigDecimal targetPrice;
+
+ public TakeCriteria() {
+ this.avgBsqPrice = isConnectedToMainnet() ? get30DayAvgBsqPriceInBtc() : regtest30DayAvgBsqPrice;
+ this.targetPrice = calcTargetBsqPrice(minMarketPriceMargin, avgBsqPrice);
+ }
+
+ /**
+ * Returns the highest priced offer passing the filters, or Optional.empty() if not found.
+ * Max tx fee rate filtering should have passed prior to calling this method.
+ *
+ * @param offers to filter
+ */
+ Optional findTakeableOffer(List offers) {
+ if (iHavePreferredTradingPeers.get())
+ return offers.stream()
+ .filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount()))
+ .filter(isMakerPreferredTradingPeer)
+ .filter(o -> isFixedPriceGEMaxMarketPriceMargin.test(o, avgBsqPrice))
+ .filter(isWithinBTCAmountBounds)
+ .findFirst();
+ else
+ return offers.stream()
+ .filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount()))
+ .filter(o -> isFixedPriceGEMaxMarketPriceMargin.test(o, avgBsqPrice))
+ .filter(isWithinBTCAmountBounds)
+ .findFirst();
+ }
+
+ void printCriteriaSummary() {
+ log.info("Looking for offers to {}, with a fixed-price at or greater than"
+ + " {}% {} the 30-day average BSQ trade price of {} BTC.",
+ MARKET_DESCRIPTION,
+ minMarketPriceMargin.abs(), // Hide the sign, text explains target price % "above or below".
+ aboveOrBelowMarketPrice.apply(minMarketPriceMargin),
+ avgBsqPrice);
+ }
+
+ void printOffersAgainstCriteria(List offers) {
+ log.info("Currently available {} offers -- want to take BSQ swap offer with fixed-price >= {} BTC.",
+ MARKET_DESCRIPTION,
+ targetPrice);
+ printOffersSummary(offers);
+ }
+
+ void printOfferAgainstCriteria(OfferInfo offer) {
+ printOfferSummary(offer);
+
+ var filterResultsByLabel = new LinkedHashMap();
+ filterResultsByLabel.put("30-day Avg BSQ trade price:", avgBsqPrice + " BTC");
+ filterResultsByLabel.put("Target Price (Min):", targetPrice + " BTC");
+ filterResultsByLabel.put("Offer Price:", offer.getPrice() + " BTC");
+ filterResultsByLabel.put("Offer maker used same payment method?",
+ usesSamePaymentMethod.test(offer, getPaymentAccount()));
+ filterResultsByLabel.put("Is offer's maker a preferred trading peer?",
+ iHavePreferredTradingPeers.get()
+ ? isMakerPreferredTradingPeer.test(offer) ? "YES" : "NO"
+ : "N/A");
+ var fixedPriceLabel = format("Is offer fixed-price (%s) >= bot's minimum price (%s)?",
+ offer.getPrice() + " " + currencyCode,
+ targetPrice + " " + currencyCode);
+ filterResultsByLabel.put(fixedPriceLabel, isFixedPriceGEMaxMarketPriceMargin.test(offer, avgBsqPrice));
+ var btcAmountBounds = format("%s BTC - %s BTC", minAmount, maxAmount);
+ filterResultsByLabel.put("Is offer's BTC amount within bot amount bounds (" + btcAmountBounds + ")?",
+ isWithinBTCAmountBounds.test(offer));
+
+ var title = format("Fixed price BSQ swap offer %s filter results:", offer.getId());
+ log.info(toTable.apply(title, filterResultsByLabel));
+ }
+ }
+}
diff --git a/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToBuyBtc.java b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToBuyBtc.java
new file mode 100644
index 0000000..a6f1d9a
--- /dev/null
+++ b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToBuyBtc.java
@@ -0,0 +1,439 @@
+/*
+ * 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.bots;
+
+import bisq.proto.grpc.OfferInfo;
+import io.grpc.StatusRuntimeException;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+import protobuf.PaymentAccount;
+
+import java.math.BigDecimal;
+import java.util.*;
+import java.util.function.BiPredicate;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+
+import static bisq.bots.BotUtils.*;
+import static java.lang.String.format;
+import static java.lang.System.exit;
+import static java.math.RoundingMode.HALF_UP;
+import static protobuf.OfferDirection.BUY;
+import static protobuf.OfferDirection.SELL;
+
+/**
+ * The TakeBestPricedOfferToBuyBtc bot waits for attractively priced BUY BTC offers to appear, takes the offers
+ * (up to a maximum of configured {@link #maxTakeOffers}, then shuts down both the API daemon and itself (the bot),
+ * to allow the user to start the desktop UI application and complete the trades.
+ *
+ * The benefit this bot provides is freeing up the user time spent watching the offer book in the UI, waiting for the
+ * right offer to take. This bot increases the chance of beating the other nodes at taking the offer.
+ *
+ * The disadvantage is that if the user takes offers with the API, she must complete the trades with the desktop UI.
+ * This problem is due to the inability of the API to fully automate every step of the trading protocol. Sending fiat
+ * payments, and confirming their receipt, are manual activities performed outside the Bisq daemon and desktop UI.
+ * Also, the API and the desktop UI cannot run at the same time. Care must be taken to shut down one before starting
+ * the other.
+ *
+ * The criteria for determining which offers to take are defined in the bot's configuration file
+ * TakeBestPricedOfferToBuyBtc.properties (located in project's src/main/resources directory). The individual
+ * configurations are commented in the existing TakeBestPricedOfferToBuyBtc.properties, which should be used as a
+ * template for your own use case.
+ *
+ * One possible use case for this bot is sell BTC for GBP:
+ *
+ * Take a "Faster Payment (Santander)" offer to buy BTC with GBP at or above current market price if:
+ * the offer maker is a preferred trading peer,
+ * and the offer's BTC amount is between 0.10 and 0.25 BTC,
+ * and the current transaction mining fee rate is below 20 sats / byte.
+ *
+ *
+ * Another possible use case for this bot is to buy BTC with XMR. (We might say "sell XMR for BTC", but we need to
+ * remember that all Bisq offers are for buying or selling BTC.)
+ *
+ * Take an offer to buy BTC with XMR at or above current market price if:
+ * the offer maker is a preferred trading peer,
+ * and the offer's BTC amount is between 0.50 and 1.00 BTC,
+ * and the current transaction mining fee rate is below 15 sats / byte.
+ *
+ *
+ *
+ * Usage: TakeBestPricedOfferToBuyBtc --password=api-password --port=api-port \
+ * [--conf=take-best-priced-offer-to-buy-btc.conf] \
+ * [--dryrun=true|false]
+ * [--simulate-regtest-payment=true|false]
+ *
+ */
+@Slf4j
+@Getter
+public class TakeBestPricedOfferToBuyBtc extends AbstractBot {
+
+ // Config file: resources/TakeBestPricedOfferToBuyBtc.properties.
+ private final Properties configFile;
+ // Taker bot's payment account (if the configured paymentAccountId is valid).
+ private final PaymentAccount paymentAccount;
+ // Taker bot's payment account trading currency code (if the configured paymentAccountId is valid).
+ private final String currencyCode;
+ // Taker bot's min market price margin. A takeable offer's price margin (%) must be >= minMarketPriceMargin (%).
+ private final BigDecimal minMarketPriceMargin;
+ // Taker bot's min BTC amount to buy (or sell in case of XMR). A takeable offer's amount must be >= minAmount BTC.
+ private final BigDecimal minAmount;
+ // Taker bot's max BTC amount to buy (or sell in case of XMR). A takeable offer's amount must be <= maxAmount BTC.
+ private final BigDecimal maxAmount;
+ // Taker bot's max acceptable transaction fee rate.
+ private final long maxTxFeeRate;
+ // Taker bot's trading fee currency code (BSQ or BTC).
+ private final String bisqTradeFeeCurrency;
+ // Maximum # of offers to take during one bot session (shut down bot after N swaps).
+ private final int maxTakeOffers;
+
+ // Offer polling frequency must be > 1000 ms between each getoffers request.
+ private final long pollingInterval;
+
+ // The # of BSQ swap offers taken during the bot session (since startup).
+ private int numOffersTaken = 0;
+
+ public TakeBestPricedOfferToBuyBtc(String[] args) {
+ super(args);
+ pingDaemon(new Date().getTime()); // Shut down now if API daemon is not available.
+ this.configFile = loadConfigFile();
+ this.paymentAccount = getPaymentAccount(configFile.getProperty("paymentAccountId"));
+ this.currencyCode = paymentAccount.getSelectedTradeCurrency().getCode();
+ this.minMarketPriceMargin = new BigDecimal(configFile.getProperty("minMarketPriceMargin"))
+ .setScale(2, HALF_UP);
+ this.minAmount = new BigDecimal(configFile.getProperty("minAmount"));
+ this.maxAmount = new BigDecimal(configFile.getProperty("maxAmount"));
+ this.maxTxFeeRate = Long.parseLong(configFile.getProperty("maxTxFeeRate"));
+ this.bisqTradeFeeCurrency = configFile.getProperty("bisqTradeFeeCurrency");
+ this.maxTakeOffers = Integer.parseInt(configFile.getProperty("maxTakeOffers"));
+ loadPreferredOnionAddresses.accept(configFile, preferredTradingPeers);
+ this.pollingInterval = Long.parseLong(configFile.getProperty("pollingInterval"));
+ }
+
+ /**
+ * Checks for the most attractive offer to take every {@link #pollingInterval} ms. After {@link #maxTakeOffers}
+ * are taken, bot will stop the API daemon, then shut itself down, prompting the user to start the desktop UI
+ * to complete the trade.
+ */
+ @Override
+ public void run() {
+ var startTime = new Date().getTime();
+ validatePollingInterval(pollingInterval);
+ validateTradeFeeCurrencyCode(bisqTradeFeeCurrency);
+ validatePaymentAccount(paymentAccount);
+ printBotConfiguration();
+
+ while (!isShutdown) {
+ if (!isBisqNetworkTxFeeRateLowEnough.test(maxTxFeeRate)) {
+ runCountdown(log, pollingInterval);
+ continue;
+ }
+
+ // Taker bot's getOffers(direction) request param. For fiat offers, is BUY (BTC), for XMR offers, is SELL (BTC).
+ String offerDirection = isXmr.test(currencyCode) ? SELL.name() : BUY.name();
+
+ // Get all available and takeable offers, sorted by price ascending.
+ // The list contains both fixed-price and market price margin based offers.
+ var offers = getOffers(offerDirection, currencyCode).stream()
+ .filter(o -> !isAlreadyTaken.test(o))
+ .toList();
+
+ if (offers.isEmpty()) {
+ log.info("No takeable offers found.");
+ runCountdown(log, pollingInterval);
+ continue;
+ }
+
+ // Define criteria for taking an offer, based on conf file.
+ TakeCriteria takeCriteria = new TakeCriteria();
+ takeCriteria.printCriteriaSummary();
+ takeCriteria.printOffersAgainstCriteria(offers);
+
+ // Find takeable offer based on criteria.
+ Optional selectedOffer = takeCriteria.findTakeableOffer(offers);
+ // Try to take the offer, if found, or say 'no offer found' before going to sleep.
+ selectedOffer.ifPresentOrElse(offer -> takeOffer(takeCriteria, offer),
+ () -> {
+ var highestPricedOffer = offers.get(0);
+ log.info("No acceptable offer found. Closest possible candidate did not pass filters:");
+ takeCriteria.printOfferAgainstCriteria(highestPricedOffer);
+ });
+
+ printDryRunProgress();
+ runCountdown(log, pollingInterval);
+ pingDaemon(startTime);
+ }
+ }
+
+ /**
+ * Attempt to take the available offer according to configured criteria. If successful, will block until a new
+ * trade is fully initialized with a trade contract. Otherwise, handles a non-fatal error and allows the bot to
+ * stay alive, or shuts down the bot upon fatal error.
+ */
+ private void takeOffer(TakeCriteria takeCriteria, OfferInfo offer) {
+ log.info("Will attempt to take offer '{}'.", offer.getId());
+ takeCriteria.printOfferAgainstCriteria(offer);
+ if (isDryRun) {
+ addToOffersTaken(offer);
+ numOffersTaken++;
+ maybeShutdownAfterSuccessfulTradeCreation();
+ } else {
+ // An encrypted wallet must be unlocked before calling takeoffer and gettrade.
+ // Unlock the wallet for 5 minutes. If the wallet is already unlocked,
+ // this command will override the timeout of the previous unlock command.
+ try {
+ unlockWallet(walletPassword, 600);
+ printBTCBalances("BTC Balances Before Take Offer Attempt");
+ // Blocks until new trade is prepared, or times out.
+ takeV1ProtocolOffer(offer, paymentAccount, bisqTradeFeeCurrency, pollingInterval);
+ printBTCBalances("BTC Balances After Take Offer Attempt");
+
+ if (canSimulatePaymentSteps) {
+ var newTrade = getTrade(offer.getId());
+ RegtestTradePaymentSimulator tradePaymentSimulator = new RegtestTradePaymentSimulator(args,
+ newTrade.getTradeId(),
+ paymentAccount);
+ tradePaymentSimulator.run();
+ log.info("Trade payment simulation is complete. Closing bot channels and shutting down.");
+ printBTCBalances("BTC Balances After Simulated Trade Completion");
+ }
+ numOffersTaken++;
+ maybeShutdownAfterSuccessfulTradeCreation();
+ } catch (NonFatalException nonFatalException) {
+ handleNonFatalException(nonFatalException);
+ } catch (StatusRuntimeException fatalException) {
+ handleFatalException(fatalException);
+ }
+ }
+ }
+
+ /**
+ * Log the non-fatal exception, and stall the bot if the NonFatalException has a stallTime value > 0.
+ */
+ private void handleNonFatalException(NonFatalException nonFatalException) {
+ log.warn(nonFatalException.getMessage());
+ if (nonFatalException.hasStallTime()) {
+ long stallTime = nonFatalException.getStallTime();
+ log.warn("A minute must pass between the previous and the next takeoffer attempt."
+ + " Stalling for {} seconds before the next takeoffer attempt.",
+ toSeconds.apply(stallTime + pollingInterval));
+ runCountdown(log, stallTime);
+ } else {
+ runCountdown(log, pollingInterval);
+ }
+ }
+
+ /**
+ * Log the fatal exception, and shut down daemon and bot.
+ */
+ private void handleFatalException(StatusRuntimeException fatalException) {
+ log.error("", fatalException);
+ shutdownAfterFailedTradePreparation();
+ }
+
+ /**
+ * Lock the wallet, stop the API daemon, and terminate the bot.
+ */
+ private void maybeShutdownAfterSuccessfulTradeCreation() {
+ if (!isDryRun) {
+ try {
+ lockWallet();
+ } catch (NonFatalException ex) {
+ log.warn(ex.getMessage());
+ }
+ }
+ if (numOffersTaken >= maxTakeOffers) {
+ isShutdown = true;
+
+ if (canSimulatePaymentSteps) {
+ log.info("Shutting down bot after successful trade completion. API daemon will not be shut down.");
+ sleep(2_000);
+ } else {
+ log.info("Shutting down API daemon and bot after taking {} offers."
+ + " Complete the trade(s) with the desktop UI.",
+ numOffersTaken);
+ sleep(2_000);
+ log.info("Sending stop request to daemon.");
+ stopDaemon();
+ }
+
+ exit(0);
+
+ } else {
+ log.info("You have taken {} offers during this bot session.", numOffersTaken);
+ }
+ }
+
+ /**
+ * Lock the wallet, stop the API daemon, and terminate the bot with a non-zero status (error).
+ */
+ private void shutdownAfterFailedTradePreparation() {
+ shutdownAfterFatalError("Shutting down API daemon and bot after failing to find new trade.");
+ }
+
+ /**
+ * Return true is fixed-price offer's price >= the bot's min market price margin. Allows bot to take a
+ * fixed-priced offer if the price is >= {@link #minMarketPriceMargin} (%) of the current market price.
+ */
+ protected final BiPredicate isFixedPriceGEMinMarketPriceMargin =
+ (offer, currentMarketPrice) -> BotUtils.isFixedPriceGEMinMarketPriceMargin(
+ offer,
+ currentMarketPrice,
+ this.getMinMarketPriceMargin());
+
+ /**
+ * Return true if offer.amt >= bot.minAmt AND offer.amt <= bot.maxAmt (within the boundaries).
+ * TODO API's takeoffer needs to support taking offer's minAmount.
+ */
+ protected final Predicate isWithinBTCAmountBounds = (offer) ->
+ BotUtils.isWithinBTCAmountBounds(offer, getMinAmount(), getMaxAmount());
+
+ private void printBotConfiguration() {
+ var configsByLabel = new LinkedHashMap();
+ configsByLabel.put("Bot OS:", getOSName() + " " + getOSVersion());
+ var network = getNetwork();
+ configsByLabel.put("BTC Network:", network);
+ configsByLabel.put("My Payment Account:", "");
+ configsByLabel.put("\tPayment Account Id:", paymentAccount.getId());
+ configsByLabel.put("\tAccount Name:", paymentAccount.getAccountName());
+ configsByLabel.put("\tCurrency Code:", currencyCode);
+ configsByLabel.put("Trading Rules:", "");
+ configsByLabel.put("\tMax # of offers bot can take:", maxTakeOffers);
+ configsByLabel.put("\tMax Tx Fee Rate:", maxTxFeeRate + " sats/byte");
+ configsByLabel.put("\tMin Market Price Margin:", minMarketPriceMargin + "%");
+ configsByLabel.put("\tMin BTC Amount:", minAmount + " BTC");
+ configsByLabel.put("\tMax BTC Amount: ", maxAmount + " BTC");
+ if (iHavePreferredTradingPeers.get()) {
+ configsByLabel.put("\tPreferred Trading Peers:", preferredTradingPeers.toString());
+ } else {
+ configsByLabel.put("\tPreferred Trading Peers:", "N/A");
+ }
+ configsByLabel.put("Bot Polling Interval:", pollingInterval + " ms");
+ log.info(toTable.apply("Bot Configuration", configsByLabel));
+ }
+
+ public static void main(String[] args) {
+ @SuppressWarnings("unused")
+ String prompt = "An encrypted wallet must be unlocked before any offer can be taken.\n"
+ + " Please enter your wallet password:";
+ String walletPassword = "be careful"; // readWalletPassword(prompt);
+ log.info("Your wallet password is {}", walletPassword.isBlank() ? "blank" : walletPassword);
+ TakeBestPricedOfferToBuyBtc bot = new TakeBestPricedOfferToBuyBtc(appendWalletPasswordOpt(args, walletPassword));
+ bot.run();
+ }
+
+ /**
+ * Calculates additional takeoffer criteria based on conf file values,
+ * performs candidate offer filtering, and provides useful log statements.
+ */
+ private class TakeCriteria {
+ private final BigDecimal currentMarketPrice;
+ @Getter
+ private final BigDecimal targetPrice;
+
+ private final Supplier marketDescription = () -> {
+ if (isXmr.test(currencyCode))
+ return "Buy XMR (Sell BTC)";
+ else
+ return "Buy BTC";
+ };
+
+ public TakeCriteria() {
+ this.currentMarketPrice = getCurrentMarketPrice(currencyCode);
+ this.targetPrice = calcTargetPrice(minMarketPriceMargin, currentMarketPrice, currencyCode);
+ }
+
+ /**
+ * Returns the highest priced offer passing the filters, or Optional.empty() if not found.
+ * Max tx fee rate filtering should have passed prior to calling this method.
+ *
+ * @param offers to filter
+ */
+ Optional findTakeableOffer(List offers) {
+ if (iHavePreferredTradingPeers.get())
+ return offers.stream()
+ .filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount()))
+ .filter(isMakerPreferredTradingPeer)
+ .filter(o -> isMarginGEMinMarketPriceMargin.test(o, minMarketPriceMargin)
+ || isFixedPriceGEMinMarketPriceMargin.test(o, currentMarketPrice))
+ .filter(isWithinBTCAmountBounds)
+ .findFirst();
+ else
+ return offers.stream()
+ .filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount()))
+ .filter(o -> isMarginGEMinMarketPriceMargin.test(o, minMarketPriceMargin)
+ || isFixedPriceGEMinMarketPriceMargin.test(o, currentMarketPrice))
+ .filter(isWithinBTCAmountBounds)
+ .findFirst();
+ }
+
+ void printCriteriaSummary() {
+ log.info("Looking for offers to {}, priced at or more than {}% {} the current market price {} {}.",
+ marketDescription.get(),
+ minMarketPriceMargin.abs(), // Hide the sign, text explains target price % "above or below".
+ aboveOrBelowMarketPrice.apply(minMarketPriceMargin),
+ currentMarketPrice,
+ isXmr.test(currencyCode) ? "BTC" : currencyCode);
+ }
+
+ void printOffersAgainstCriteria(List offers) {
+ log.info("Currently available {} offers -- want to take {} offer with price >= {} {}.",
+ marketDescription.get(),
+ currencyCode,
+ targetPrice,
+ isXmr.test(currencyCode) ? "BTC" : currencyCode);
+ printOffersSummary(offers);
+ }
+
+ void printOfferAgainstCriteria(OfferInfo offer) {
+ printOfferSummary(offer);
+
+ var filterResultsByLabel = new LinkedHashMap();
+ filterResultsByLabel.put("Current Market Price:", currentMarketPrice + " " + currencyCode);
+ filterResultsByLabel.put("Target Price (Max):", targetPrice + " " + currencyCode);
+ filterResultsByLabel.put("Offer Price:", offer.getPrice() + " " + currencyCode);
+ filterResultsByLabel.put("Offer maker used same payment method?",
+ usesSamePaymentMethod.test(offer, getPaymentAccount()));
+ filterResultsByLabel.put("Is offer maker a preferred trading peer?",
+ iHavePreferredTradingPeers.get()
+ ? isMakerPreferredTradingPeer.test(offer) ? "YES" : "NO"
+ : "N/A");
+ var marginPriceLabel = format("Is offer's price margin (%s%%) >= bot's min market price margin (%s%%)?",
+ offer.getMarketPriceMarginPct(),
+ minMarketPriceMargin);
+ filterResultsByLabel.put(marginPriceLabel,
+ offer.getUseMarketBasedPrice()
+ ? isMarginGEMinMarketPriceMargin.test(offer, minMarketPriceMargin)
+ : "N/A");
+ var fixedPriceLabel = format("Is offer's fixed-price (%s) >= bot's target price (%s)?",
+ offer.getUseMarketBasedPrice() ? "N/A" : offer.getPrice() + " " + currencyCode,
+ offer.getUseMarketBasedPrice() ? "N/A" : targetPrice + " " + currencyCode);
+ filterResultsByLabel.put(fixedPriceLabel,
+ offer.getUseMarketBasedPrice()
+ ? "N/A"
+ : isFixedPriceGEMinMarketPriceMargin.test(offer, currentMarketPrice));
+ String btcAmountBounds = format("%s BTC - %s BTC", minAmount, maxAmount);
+ filterResultsByLabel.put("Is offer's BTC amount within bot amount bounds (" + btcAmountBounds + ")?",
+ isWithinBTCAmountBounds.test(offer));
+
+ var title = format("%s offer %s filter results:",
+ offer.getUseMarketBasedPrice() ? "Margin based" : "Fixed price",
+ offer.getId());
+ log.info(toTable.apply(title, filterResultsByLabel));
+ }
+ }
+}
diff --git a/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToSellBsq.java b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToSellBsq.java
new file mode 100644
index 0000000..9ce7d1b
--- /dev/null
+++ b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToSellBsq.java
@@ -0,0 +1,353 @@
+/*
+ * 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.bots;
+
+import bisq.proto.grpc.OfferInfo;
+import io.grpc.StatusRuntimeException;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+import protobuf.PaymentAccount;
+
+import java.math.BigDecimal;
+import java.util.*;
+import java.util.function.BiPredicate;
+import java.util.function.Predicate;
+
+import static bisq.bots.BotUtils.*;
+import static java.lang.String.format;
+import static java.lang.System.exit;
+import static java.math.RoundingMode.HALF_UP;
+import static protobuf.OfferDirection.BUY;
+
+/**
+ * Bot for swapping BTC for BSQ at an attractive (low) price. The bot receives BSQ for BTC.
+ *
+ * I'm taking liberties with the classname by not naming it TakeBestPricedOfferToBuyBtcForBsq.
+ */
+@Slf4j
+@Getter
+public class TakeBestPricedOfferToSellBsq extends AbstractBot {
+
+ // Config file: resources/TakeBestPricedOfferToSellBsq.properties.
+ private final Properties configFile;
+ // Taker bot's default BSQ Swap payment account.
+ private final PaymentAccount paymentAccount;
+ // Taker bot's payment account trading currency code (BSQ).
+ private final String currencyCode;
+ // Taker bot's max market price margin. A takeable BSQ Swap offer's fixed-price must be <= maxMarketPriceMargin (%).
+ // Note: all BSQ Swap offers have a fixed-price, but the bot uses a margin (%) of the 30-day price for comparison.
+ private final BigDecimal maxMarketPriceMargin;
+ // Hard coded 30-day average BSQ trade price, used for development over regtest (ignored when running on mainnet).
+ private final BigDecimal regtest30DayAvgBsqPrice;
+ // Taker bot's min BTC amount to sell (we are buying BSQ). A takeable offer's amount must be >= minAmount BTC.
+ private final BigDecimal minAmount;
+ // Taker bot's max BTC amount to sell (we are buying BSQ). A takeable offer's amount must be <= maxAmount BTC.
+ private final BigDecimal maxAmount;
+ // Taker bot's max acceptable transaction fee rate.
+ private final long maxTxFeeRate;
+ // Maximum # of offers to take during one bot session (shut down bot after N swaps).
+ private final int maxTakeOffers;
+
+ // Offer polling frequency must be > 1000 ms between each getoffers request.
+ private final long pollingInterval;
+
+ // The # of BSQ swap offers taken during the bot session (since startup).
+ private int numOffersTaken = 0;
+
+ public TakeBestPricedOfferToSellBsq(String[] args) {
+ super(args);
+ pingDaemon(new Date().getTime()); // Shut down now if API daemon is not available.
+ this.configFile = loadConfigFile();
+ this.paymentAccount = getBsqSwapPaymentAccount();
+ this.currencyCode = paymentAccount.getSelectedTradeCurrency().getCode();
+ this.maxMarketPriceMargin = new BigDecimal(configFile.getProperty("maxMarketPriceMargin"))
+ .setScale(2, HALF_UP);
+ this.regtest30DayAvgBsqPrice = new BigDecimal(configFile.getProperty("regtest30DayAvgBsqPrice"))
+ .setScale(8, HALF_UP);
+ this.minAmount = new BigDecimal(configFile.getProperty("minAmount"));
+ this.maxAmount = new BigDecimal(configFile.getProperty("maxAmount"));
+ this.maxTxFeeRate = Long.parseLong(configFile.getProperty("maxTxFeeRate"));
+ this.maxTakeOffers = Integer.parseInt(configFile.getProperty("maxTakeOffers"));
+ loadPreferredOnionAddresses.accept(configFile, preferredTradingPeers);
+ this.pollingInterval = Long.parseLong(configFile.getProperty("pollingInterval"));
+ }
+
+ /**
+ * Checks for the most attractive BSQ Swap offer to take every {@link #pollingInterval} ms.
+ * Will only terminate when manually shut down, or a fatal gRPC StatusRuntimeException is thrown.
+ */
+ @Override
+ public void run() {
+ var startTime = new Date().getTime();
+ validatePollingInterval(pollingInterval);
+ printBotConfiguration();
+
+ while (!isShutdown) {
+ if (!isBisqNetworkTxFeeRateLowEnough.test(maxTxFeeRate)) {
+ runCountdown(log, pollingInterval);
+ continue;
+ }
+
+ // Get all available and takeable offers, sorted by price ascending.
+ var offers = getOffers(BUY.name(), currencyCode).stream()
+ .filter(o -> !isAlreadyTaken.test(o))
+ .toList();
+
+ if (offers.isEmpty()) {
+ log.info("No takeable offers found.");
+ runCountdown(log, pollingInterval);
+ continue;
+ }
+
+ // Define criteria for taking an offer, based on conf file.
+ TakeBestPricedOfferToSellBsq.TakeCriteria takeCriteria = new TakeBestPricedOfferToSellBsq.TakeCriteria();
+ takeCriteria.printCriteriaSummary();
+ takeCriteria.printOffersAgainstCriteria(offers);
+
+ // Find takeable offer based on criteria.
+ Optional selectedOffer = takeCriteria.findTakeableOffer(offers);
+ // Try to take the offer, if found, or say 'no offer found' before going to sleep.
+ selectedOffer.ifPresentOrElse(offer -> takeOffer(takeCriteria, offer),
+ () -> {
+ var cheapestOffer = offers.get(0);
+ log.info("No acceptable offer found. Closest possible candidate did not pass filters:");
+ takeCriteria.printOfferAgainstCriteria(cheapestOffer);
+ });
+
+ printDryRunProgress();
+ runCountdown(log, pollingInterval);
+ pingDaemon(startTime);
+ }
+ }
+
+ private void takeOffer(TakeCriteria takeCriteria, OfferInfo offer) {
+ log.info("Will attempt to take offer '{}'.", offer.getId());
+ takeCriteria.printOfferAgainstCriteria(offer);
+ if (isDryRun) {
+ addToOffersTaken(offer);
+ numOffersTaken++;
+ maybeShutdownAfterSuccessfulSwap();
+ } else {
+ // An encrypted wallet must be unlocked before calling takeoffer and gettrade.
+ // Unlock the wallet for 10 minutes. If the wallet is already unlocked,
+ // this command will override the timeout of the previous unlock command.
+ try {
+ unlockWallet(walletPassword, 600);
+
+ printBTCBalances("BTC Balances Before Swap Execution");
+ printBSQBalances("BSQ Balances Before Swap Execution");
+
+ // Blocks until swap is executed, or times out.
+ takeBsqSwapOffer(offer, pollingInterval);
+
+ printBTCBalances("BTC Balances After Swap Execution");
+ printBSQBalances("BSQ Balances After Swap Execution");
+
+ numOffersTaken++;
+ maybeShutdownAfterSuccessfulSwap();
+ } catch (NonFatalException nonFatalException) {
+ handleNonFatalException(nonFatalException);
+ } catch (StatusRuntimeException fatalException) {
+ handleFatalException(fatalException);
+ }
+ }
+ }
+
+ private void printBotConfiguration() {
+ var configsByLabel = new LinkedHashMap();
+ configsByLabel.put("Bot OS:", getOSName() + " " + getOSVersion());
+ var network = getNetwork();
+ configsByLabel.put("BTC Network:", network);
+ var isMainnet = network.equalsIgnoreCase("mainnet");
+ var mainnet30DayAvgBsqPrice = isMainnet ? get30DayAvgBsqPriceInBtc() : null;
+ configsByLabel.put("My Payment Account:", "");
+ configsByLabel.put("\tPayment Account Id:", paymentAccount.getId());
+ configsByLabel.put("\tAccount Name:", paymentAccount.getAccountName());
+ configsByLabel.put("\tCurrency Code:", currencyCode);
+ configsByLabel.put("Trading Rules:", "");
+ configsByLabel.put("\tMax # of offers bot can take:", maxTakeOffers);
+ configsByLabel.put("\tMax Tx Fee Rate:", maxTxFeeRate + " sats/byte");
+ configsByLabel.put("\tMax Market Price Margin:", maxMarketPriceMargin + "%");
+ if (isMainnet) {
+ configsByLabel.put("\tMainnet 30-Day Avg BSQ Price:", mainnet30DayAvgBsqPrice + " BTC");
+ } else {
+ configsByLabel.put("\tRegtest 30-Day Avg BSQ Price:", regtest30DayAvgBsqPrice + " BTC");
+ }
+ configsByLabel.put("\tMin BTC Amount:", minAmount + " BTC");
+ configsByLabel.put("\tMax BTC Amount: ", maxAmount + " BTC");
+ if (iHavePreferredTradingPeers.get()) {
+ configsByLabel.put("\tPreferred Trading Peers:", preferredTradingPeers.toString());
+ } else {
+ configsByLabel.put("\tPreferred Trading Peers:", "N/A");
+ }
+ configsByLabel.put("Bot Polling Interval:", pollingInterval + " ms");
+ log.info(toTable.apply("Bot Configuration", configsByLabel));
+ }
+
+ /**
+ * Log the non-fatal exception, and stall the bot if the NonFatalException has a stallTime value > 0.
+ */
+ private void handleNonFatalException(NonFatalException nonFatalException) {
+ log.warn(nonFatalException.getMessage());
+ if (nonFatalException.hasStallTime()) {
+ long stallTime = nonFatalException.getStallTime();
+ log.warn("A minute must pass between the previous and the next takeoffer attempt."
+ + " Stalling for {} seconds before the next takeoffer attempt.",
+ toSeconds.apply(stallTime + pollingInterval));
+ runCountdown(log, stallTime);
+ } else {
+ runCountdown(log, pollingInterval);
+ }
+ }
+
+ /**
+ * Log the fatal exception, and shut down daemon and bot.
+ */
+ private void handleFatalException(StatusRuntimeException fatalException) {
+ log.error("", fatalException);
+ shutdownAfterFatalError("Shutting down API daemon and bot after failing to execute BSQ swap.");
+ }
+
+ /**
+ * Lock the wallet, stop the API daemon, and terminate the bot.
+ */
+ private void maybeShutdownAfterSuccessfulSwap() {
+ if (!isDryRun) {
+ try {
+ lockWallet();
+ } catch (NonFatalException ex) {
+ log.warn(ex.getMessage());
+ }
+ }
+ if (numOffersTaken >= maxTakeOffers) {
+ isShutdown = true;
+ log.info("Shutting down API bot after executing {} BSQ swaps.", numOffersTaken);
+ exit(0);
+ } else {
+ log.info("You have completed {} BSQ swap(s) during this bot session.", numOffersTaken);
+ }
+ }
+
+ /**
+ * Return true is fixed-price offer's price <= the bot's max market price margin. Allows bot to take a
+ * fixed-priced offer if the price is <= {@link #maxMarketPriceMargin} (%) of the current market price.
+ */
+ protected final BiPredicate isFixedPriceLEMaxMarketPriceMargin =
+ (offer, currentMarketPrice) -> BotUtils.isFixedPriceLEMaxMarketPriceMargin(
+ offer,
+ currentMarketPrice,
+ getMaxMarketPriceMargin());
+
+ /**
+ * Return true if offer.amt >= bot.minAmt AND offer.amt <= bot.maxAmt (within the boundaries).
+ * TODO API's takeoffer needs to support taking offer's minAmount.
+ */
+ protected final Predicate isWithinBTCAmountBounds = (offer) ->
+ BotUtils.isWithinBTCAmountBounds(offer, getMinAmount(), getMaxAmount());
+
+ public static void main(String[] args) {
+ @SuppressWarnings("unused")
+ String prompt = "An encrypted wallet must be unlocked before any offer can be taken.\n"
+ + " Please enter your wallet password:";
+ String walletPassword = "be careful"; // readWalletPassword(prompt);
+ log.info("Your wallet password is {}", walletPassword.isBlank() ? "blank" : walletPassword);
+ TakeBestPricedOfferToSellBsq bot = new TakeBestPricedOfferToSellBsq(appendWalletPasswordOpt(args, walletPassword));
+ bot.run();
+ }
+
+ /**
+ * Calculates additional takeoffer criteria based on conf file values,
+ * performs candidate offer filtering, and provides useful log statements.
+ */
+ private class TakeCriteria {
+ private static final String MARKET_DESCRIPTION = "Sell BSQ (Buy BTC)";
+
+ private final BigDecimal avgBsqPrice;
+ @Getter
+ private final BigDecimal targetPrice;
+
+ public TakeCriteria() {
+ this.avgBsqPrice = isConnectedToMainnet() ? get30DayAvgBsqPriceInBtc() : regtest30DayAvgBsqPrice;
+ this.targetPrice = calcTargetBsqPrice(maxMarketPriceMargin, avgBsqPrice);
+ }
+
+
+ /**
+ * Returns the lowest priced offer passing the filters, or Optional.empty() if not found.
+ * Max tx fee rate filtering should have passed prior to calling this method.
+ *
+ * @param offers to filter
+ */
+ Optional findTakeableOffer(List offers) {
+ if (iHavePreferredTradingPeers.get())
+ return offers.stream()
+ .filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount()))
+ .filter(isMakerPreferredTradingPeer)
+ .filter(o -> isFixedPriceLEMaxMarketPriceMargin.test(o, avgBsqPrice))
+ .filter(isWithinBTCAmountBounds)
+ .findFirst();
+ else
+ return offers.stream()
+ .filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount()))
+ .filter(o -> isFixedPriceLEMaxMarketPriceMargin.test(o, avgBsqPrice))
+ .filter(isWithinBTCAmountBounds)
+ .findFirst();
+ }
+
+ void printCriteriaSummary() {
+ log.info("Looking for offers to {}, with a fixed-price at or less than"
+ + " {}% {} the 30-day average BSQ trade price of {} BTC.",
+ MARKET_DESCRIPTION,
+ maxMarketPriceMargin.abs(), // Hide the sign, text explains target price % "above or below".
+ aboveOrBelowMarketPrice.apply(maxMarketPriceMargin),
+ avgBsqPrice);
+ }
+
+
+ void printOffersAgainstCriteria(List offers) {
+ log.info("Currently available {} offers -- want to take BSQ swap offer with fixed-price <= {} BTC.",
+ MARKET_DESCRIPTION,
+ targetPrice);
+ printOffersSummary(offers);
+ }
+
+ void printOfferAgainstCriteria(OfferInfo offer) {
+ printOfferSummary(offer);
+
+ var filterResultsByLabel = new LinkedHashMap();
+ filterResultsByLabel.put("30-day Avg BSQ trade price:", avgBsqPrice + " BTC");
+ filterResultsByLabel.put("Target Price (Min):", targetPrice + " BTC");
+ filterResultsByLabel.put("Offer Price:", offer.getPrice() + " BTC");
+ filterResultsByLabel.put("Offer maker used same payment method?",
+ usesSamePaymentMethod.test(offer, getPaymentAccount()));
+ filterResultsByLabel.put("Is offer's maker a preferred trading peer?",
+ iHavePreferredTradingPeers.get()
+ ? isMakerPreferredTradingPeer.test(offer) ? "YES" : "NO"
+ : "N/A");
+ var fixedPriceLabel = format("Is offer's fixed-price (%s) <= bot's minimum price (%s)?",
+ offer.getPrice() + " " + currencyCode,
+ targetPrice + " " + currencyCode);
+ filterResultsByLabel.put(fixedPriceLabel, isFixedPriceLEMaxMarketPriceMargin.test(offer, avgBsqPrice));
+ var btcAmountBounds = format("%s BTC - %s BTC", minAmount, maxAmount);
+ filterResultsByLabel.put("Is offer's BTC amount within bot amount bounds (" + btcAmountBounds + ")?",
+ isWithinBTCAmountBounds.test(offer));
+
+ var title = format("Fixed price BSQ swap offer %s filter results:", offer.getId());
+ log.info(toTable.apply(title, filterResultsByLabel));
+ }
+ }
+}
diff --git a/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToSellBtc.java b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToSellBtc.java
new file mode 100644
index 0000000..1e0dc89
--- /dev/null
+++ b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToSellBtc.java
@@ -0,0 +1,440 @@
+/*
+ * 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.bots;
+
+import bisq.proto.grpc.OfferInfo;
+import io.grpc.StatusRuntimeException;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+import protobuf.PaymentAccount;
+
+import java.math.BigDecimal;
+import java.util.*;
+import java.util.function.BiPredicate;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+
+import static bisq.bots.BotUtils.*;
+import static java.lang.String.format;
+import static java.lang.System.exit;
+import static java.math.RoundingMode.HALF_UP;
+import static protobuf.OfferDirection.BUY;
+import static protobuf.OfferDirection.SELL;
+
+/**
+ * The TakeBestPricedOfferToSellBtc bot waits for attractively priced SELL BTC offers to appear, takes the offers
+ * (up to a maximum of configured {@link #maxTakeOffers}, then shuts down both the API daemon and itself (the bot),
+ * to allow the user to start the desktop UI application and complete the trades.
+ *
+ * The benefit this bot provides is freeing up the user time spent watching the offer book in the UI, waiting for the
+ * right offer to take. Low-priced offers are taken relatively quickly; this bot increases the chance of beating
+ * the other nodes at taking the offer.
+ *
+ * The disadvantage is that if the user takes offers with the API, she must complete the trades with the desktop UI.
+ * This problem is due to the inability of the API to fully automate every step of the trading protocol. Sending fiat
+ * payments, and confirming their receipt, are manual activities performed outside the Bisq daemon and desktop UI.
+ * Also, the API and the desktop UI cannot run at the same time. Care must be taken to shut down one before starting
+ * the other.
+ *
+ * The criteria for determining which offers to take are defined in the bot's configuration file
+ * TakeBestPricedOfferToSellBtc.properties (located in project's src/main/resources directory). The individual
+ * configurations are commented in the existing TakeBestPricedOfferToSellBtc.properties, which should be used as a
+ * template for your own use case.
+ *
+ * One possible use case for this bot is buy BTC with GBP:
+ *
+ * Take a "Faster Payment (Santander)" offer to sell BTC for GBP at or below current market price if:
+ * the offer maker is a preferred trading peer,
+ * and the offer's BTC amount is between 0.10 and 0.25 BTC,
+ * and the current transaction mining fee rate is below 20 sats / byte.
+ *
+ *
+ * Another possible use case for this bot is to sell BTC for XMR. (We might say "buy XMR with BTC", but we need to
+ * remember that all Bisq offers are for buying or selling BTC.)
+ *
+ * Take an offer to sell BTC for XMR at or below current market price if:
+ * the offer maker is a preferred trading peer,
+ * and the offer's BTC amount is between 0.50 and 1.00 BTC,
+ * and the current transaction mining fee rate is below 15 sats / byte.
+ *
+ *
+ *
+ * Usage: TakeBestPricedOfferToSellBtc --password=api-password --port=api-port \
+ * [--conf=take-best-priced-offer-to-sell-btc.conf] \
+ * [--dryrun=true|false]
+ * [--simulate-regtest-payment=true|false]
+ *
+ */
+@Slf4j
+@Getter
+public class TakeBestPricedOfferToSellBtc extends AbstractBot {
+
+ // Config file: resources/TakeBestPricedOfferToSellBtc.properties.
+ private final Properties configFile;
+ // Taker bot's payment account (if the configured paymentAccountId is valid).
+ private final PaymentAccount paymentAccount;
+ // Taker bot's payment account trading currency code (if the configured paymentAccountId is valid).
+ private final String currencyCode;
+ // Taker bot's max market price margin. A takeable offer's price margin (%) must be <= maxMarketPriceMargin (%).
+ private final BigDecimal maxMarketPriceMargin;
+ // Taker bot's min BTC amount to buy (or sell in case of XMR). A takeable offer's amount must be >= minAmount BTC.
+ private final BigDecimal minAmount;
+ // Taker bot's max BTC amount to buy (or sell in case of XMR). A takeable offer's amount must be <= maxAmount BTC.
+ private final BigDecimal maxAmount;
+ // Taker bot's max acceptable transaction fee rate.
+ private final long maxTxFeeRate;
+ // Taker bot's trading fee currency code (BSQ or BTC).
+ private final String bisqTradeFeeCurrency;
+ // Maximum # of offers to take during one bot session (shut down bot after N swaps).
+ private final int maxTakeOffers;
+
+ // Offer polling frequency must be > 1000 ms between each getoffers request.
+ private final long pollingInterval;
+
+ // The # of BSQ swap offers taken during the bot session (since startup).
+ private int numOffersTaken = 0;
+
+ public TakeBestPricedOfferToSellBtc(String[] args) {
+ super(args);
+ pingDaemon(new Date().getTime()); // Shut down now if API daemon is not available.
+ this.configFile = loadConfigFile();
+ this.paymentAccount = getPaymentAccount(configFile.getProperty("paymentAccountId"));
+ this.currencyCode = paymentAccount.getSelectedTradeCurrency().getCode();
+ this.maxMarketPriceMargin = new BigDecimal(configFile.getProperty("maxMarketPriceMargin"))
+ .setScale(2, HALF_UP);
+ this.minAmount = new BigDecimal(configFile.getProperty("minAmount"));
+ this.maxAmount = new BigDecimal(configFile.getProperty("maxAmount"));
+ this.maxTxFeeRate = Long.parseLong(configFile.getProperty("maxTxFeeRate"));
+ this.bisqTradeFeeCurrency = configFile.getProperty("bisqTradeFeeCurrency");
+ this.maxTakeOffers = Integer.parseInt(configFile.getProperty("maxTakeOffers"));
+ loadPreferredOnionAddresses.accept(configFile, preferredTradingPeers);
+ this.pollingInterval = Long.parseLong(configFile.getProperty("pollingInterval"));
+ }
+
+ /**
+ * Checks for the most attractive offer to take every {@link #pollingInterval} ms. After {@link #maxTakeOffers}
+ * are taken, bot will stop the API daemon, then shut itself down, prompting the user to start the desktop UI
+ * to complete the trade.
+ */
+ @Override
+ public void run() {
+ var startTime = new Date().getTime();
+ validatePollingInterval(pollingInterval);
+ validateTradeFeeCurrencyCode(bisqTradeFeeCurrency);
+ validatePaymentAccount(paymentAccount);
+ printBotConfiguration();
+
+ while (!isShutdown) {
+ if (!isBisqNetworkTxFeeRateLowEnough.test(maxTxFeeRate)) {
+ runCountdown(log, pollingInterval);
+ continue;
+ }
+
+ // Taker bot's getOffers(direction) request param. For fiat offers, is SELL (BTC), for XMR offers, is BUY (BTC).
+ String offerDirection = isXmr.test(currencyCode) ? BUY.name() : SELL.name();
+
+ // Get all available and takeable offers, sorted by price ascending.
+ // The list contains both fixed-price and market price margin based offers.
+ var offers = getOffers(offerDirection, currencyCode).stream()
+ .filter(o -> !isAlreadyTaken.test(o))
+ .toList();
+
+ if (offers.isEmpty()) {
+ log.info("No takeable offers found.");
+ runCountdown(log, pollingInterval);
+ continue;
+ }
+
+ // Define criteria for taking an offer, based on conf file.
+ TakeCriteria takeCriteria = new TakeCriteria();
+ takeCriteria.printCriteriaSummary();
+ takeCriteria.printOffersAgainstCriteria(offers);
+
+ // Find takeable offer based on criteria.
+ Optional selectedOffer = takeCriteria.findTakeableOffer(offers);
+ // Try to take the offer, if found, or say 'no offer found' before going to sleep.
+ selectedOffer.ifPresentOrElse(offer -> takeOffer(takeCriteria, offer),
+ () -> {
+ var cheapestOffer = offers.get(0);
+ log.info("No acceptable offer found. Closest possible candidate did not pass filters:");
+ takeCriteria.printOfferAgainstCriteria(cheapestOffer);
+ });
+
+ printDryRunProgress();
+ runCountdown(log, pollingInterval);
+ pingDaemon(startTime);
+ }
+ }
+
+ /**
+ * Attempt to take the available offer according to configured criteria. If successful, will block until a new
+ * trade is fully initialized with a trade contract. Otherwise, handles a non-fatal error and allows the bot to
+ * stay alive, or shuts down the bot upon fatal error.
+ */
+ private void takeOffer(TakeCriteria takeCriteria, OfferInfo offer) {
+ log.info("Will attempt to take offer '{}'.", offer.getId());
+ takeCriteria.printOfferAgainstCriteria(offer);
+ if (isDryRun) {
+ addToOffersTaken(offer);
+ numOffersTaken++;
+ maybeShutdownAfterSuccessfulTradeCreation();
+ } else {
+ // An encrypted wallet must be unlocked before calling takeoffer and gettrade.
+ // Unlock the wallet for 5 minutes. If the wallet is already unlocked,
+ // this command will override the timeout of the previous unlock command.
+ try {
+ unlockWallet(walletPassword, 600);
+ printBTCBalances("BTC Balances Before Take Offer Attempt");
+ // Blocks until new trade is prepared, or times out.
+ takeV1ProtocolOffer(offer, paymentAccount, bisqTradeFeeCurrency, pollingInterval);
+ printBTCBalances("BTC Balances After Take Offer Attempt");
+
+ if (canSimulatePaymentSteps) {
+ var newTrade = getTrade(offer.getId());
+ RegtestTradePaymentSimulator tradePaymentSimulator = new RegtestTradePaymentSimulator(args,
+ newTrade.getTradeId(),
+ paymentAccount);
+ tradePaymentSimulator.run();
+ log.info("Trade payment simulation is complete. Closing bot channels and shutting down.");
+ printBTCBalances("BTC Balances After Simulated Trade Completion");
+ }
+ numOffersTaken++;
+ maybeShutdownAfterSuccessfulTradeCreation();
+ } catch (NonFatalException nonFatalException) {
+ handleNonFatalException(nonFatalException);
+ } catch (StatusRuntimeException fatalException) {
+ handleFatalException(fatalException);
+ }
+ }
+ }
+
+ /**
+ * Log the non-fatal exception, and stall the bot if the NonFatalException has a stallTime value > 0.
+ */
+ private void handleNonFatalException(NonFatalException nonFatalException) {
+ log.warn(nonFatalException.getMessage());
+ if (nonFatalException.hasStallTime()) {
+ long stallTime = nonFatalException.getStallTime();
+ log.warn("A minute must pass between the previous and the next takeoffer attempt."
+ + " Stalling for {} seconds before the next takeoffer attempt.",
+ toSeconds.apply(stallTime + pollingInterval));
+ runCountdown(log, stallTime);
+ } else {
+ runCountdown(log, pollingInterval);
+ }
+ }
+
+ /**
+ * Log the fatal exception, and shut down daemon and bot.
+ */
+ private void handleFatalException(StatusRuntimeException fatalException) {
+ log.error("", fatalException);
+ shutdownAfterFailedTradePreparation();
+ }
+
+ /**
+ * Lock the wallet, stop the API daemon, and terminate the bot.
+ */
+ private void maybeShutdownAfterSuccessfulTradeCreation() {
+ if (!isDryRun) {
+ try {
+ lockWallet();
+ } catch (NonFatalException ex) {
+ log.warn(ex.getMessage());
+ }
+ }
+ if (numOffersTaken >= maxTakeOffers) {
+ isShutdown = true;
+
+ if (canSimulatePaymentSteps) {
+ log.info("Shutting down bot after successful trade completion. API daemon will not be shut down.");
+ sleep(2_000);
+ } else {
+ log.info("Shutting down API daemon and bot after taking {} offers."
+ + " Complete the trade(s) with the desktop UI.",
+ numOffersTaken);
+ sleep(2_000);
+ log.info("Sending stop request to daemon.");
+ stopDaemon();
+ }
+
+ exit(0);
+
+ } else {
+ log.info("You have taken {} offers during this bot session.", numOffersTaken);
+ }
+ }
+
+ /**
+ * Lock the wallet, stop the API daemon, and terminate the bot with a non-zero status (error).
+ */
+ private void shutdownAfterFailedTradePreparation() {
+ shutdownAfterFatalError("Shutting down API daemon and bot after failing to find new trade.");
+ }
+
+ /**
+ * Return true is fixed-price offer's price <= the bot's max market price margin. Allows bot to take a
+ * fixed-priced offer if the price is <= {@link #maxMarketPriceMargin} (%) of the current market price.
+ */
+ protected final BiPredicate isFixedPriceLEMaxMarketPriceMargin =
+ (offer, currentMarketPrice) -> BotUtils.isFixedPriceLEMaxMarketPriceMargin(
+ offer,
+ currentMarketPrice,
+ getMaxMarketPriceMargin());
+
+ /**
+ * Return true if offer.amt >= bot.minAmt AND offer.amt <= bot.maxAmt (within the boundaries).
+ * TODO API's takeoffer needs to support taking offer's minAmount.
+ */
+ protected final Predicate isWithinBTCAmountBounds = (offer) ->
+ BotUtils.isWithinBTCAmountBounds(offer, getMinAmount(), getMaxAmount());
+
+ private void printBotConfiguration() {
+ var configsByLabel = new LinkedHashMap();
+ configsByLabel.put("Bot OS:", getOSName() + " " + getOSVersion());
+ var network = getNetwork();
+ configsByLabel.put("BTC Network:", network);
+ configsByLabel.put("My Payment Account:", "");
+ configsByLabel.put("\tPayment Account Id:", paymentAccount.getId());
+ configsByLabel.put("\tAccount Name:", paymentAccount.getAccountName());
+ configsByLabel.put("\tCurrency Code:", currencyCode);
+ configsByLabel.put("Trading Rules:", "");
+ configsByLabel.put("\tMax # of offers bot can take:", maxTakeOffers);
+ configsByLabel.put("\tMax Tx Fee Rate:", maxTxFeeRate + " sats/byte");
+ configsByLabel.put("\tMax Market Price Margin:", maxMarketPriceMargin + "%");
+ configsByLabel.put("\tMin BTC Amount:", minAmount + " BTC");
+ configsByLabel.put("\tMax BTC Amount: ", maxAmount + " BTC");
+ if (iHavePreferredTradingPeers.get()) {
+ configsByLabel.put("\tPreferred Trading Peers:", preferredTradingPeers.toString());
+ } else {
+ configsByLabel.put("\tPreferred Trading Peers:", "N/A");
+ }
+ configsByLabel.put("Bot Polling Interval:", pollingInterval + " ms");
+ log.info(toTable.apply("Bot Configuration", configsByLabel));
+ }
+
+ public static void main(String[] args) {
+ @SuppressWarnings("unused")
+ String prompt = "An encrypted wallet must be unlocked before any offer can be taken.\n"
+ + " Please enter your wallet password:";
+ String walletPassword = "be careful"; // readWalletPassword(prompt);
+ log.info("Your wallet password is {}", walletPassword.isBlank() ? "blank" : walletPassword);
+ TakeBestPricedOfferToSellBtc bot = new TakeBestPricedOfferToSellBtc(appendWalletPasswordOpt(args, walletPassword));
+ bot.run();
+ }
+
+ /**
+ * Calculates additional takeoffer criteria based on conf file values,
+ * performs candidate offer filtering, and provides useful log statements.
+ */
+ private class TakeCriteria {
+ private final BigDecimal currentMarketPrice;
+ @Getter
+ private final BigDecimal targetPrice;
+
+ private final Supplier marketDescription = () -> {
+ if (isXmr.test(currencyCode))
+ return "Sell XMR (Buy BTC)";
+ else
+ return "Sell BTC";
+ };
+
+ public TakeCriteria() {
+ this.currentMarketPrice = getCurrentMarketPrice(currencyCode);
+ this.targetPrice = calcTargetPrice(maxMarketPriceMargin, currentMarketPrice, currencyCode);
+ }
+
+ /**
+ * Returns the lowest priced offer passing the filters, or Optional.empty() if not found.
+ * Max tx fee rate filtering should have passed prior to calling this method.
+ *
+ * @param offers to filter
+ */
+ Optional findTakeableOffer(List offers) {
+ if (iHavePreferredTradingPeers.get())
+ return offers.stream()
+ .filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount()))
+ .filter(isMakerPreferredTradingPeer)
+ .filter(o -> isMarginLEMaxMarketPriceMargin.test(o, maxMarketPriceMargin)
+ || isFixedPriceLEMaxMarketPriceMargin.test(o, currentMarketPrice))
+ .filter(isWithinBTCAmountBounds)
+ .findFirst();
+ else
+ return offers.stream()
+ .filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount()))
+ .filter(o -> isMarginLEMaxMarketPriceMargin.test(o, maxMarketPriceMargin)
+ || isFixedPriceLEMaxMarketPriceMargin.test(o, currentMarketPrice))
+ .filter(isWithinBTCAmountBounds)
+ .findFirst();
+ }
+
+ void printCriteriaSummary() {
+ log.info("Looking for offers to {}, priced at or less than {}% {} the current market price {} {}.",
+ marketDescription.get(),
+ maxMarketPriceMargin.abs(), // Hide the sign, text explains target price % "above or below".
+ aboveOrBelowMarketPrice.apply(maxMarketPriceMargin),
+ currentMarketPrice,
+ isXmr.test(currencyCode) ? "BTC" : currencyCode);
+ }
+
+ void printOffersAgainstCriteria(List offers) {
+ log.info("Currently available {} offers -- want to take {} offer with price <= {} {}.",
+ marketDescription.get(),
+ currencyCode,
+ targetPrice,
+ isXmr.test(currencyCode) ? "BTC" : currencyCode);
+ printOffersSummary(offers);
+ }
+
+ void printOfferAgainstCriteria(OfferInfo offer) {
+ printOfferSummary(offer);
+
+ var filterResultsByLabel = new LinkedHashMap();
+ filterResultsByLabel.put("Current Market Price:", currentMarketPrice + " " + currencyCode);
+ filterResultsByLabel.put("Target Price (Max):", targetPrice + " " + currencyCode);
+ filterResultsByLabel.put("Offer Price:", offer.getPrice() + " " + currencyCode);
+ filterResultsByLabel.put("Offer maker used same payment method?",
+ usesSamePaymentMethod.test(offer, getPaymentAccount()));
+ filterResultsByLabel.put("Is offer maker a preferred trading peer?",
+ iHavePreferredTradingPeers.get()
+ ? isMakerPreferredTradingPeer.test(offer) ? "YES" : "NO"
+ : "N/A");
+ var marginPriceLabel = format("Is offer's price margin (%s%%) <= bot's max market price margin (%s%%)?",
+ offer.getMarketPriceMarginPct(),
+ maxMarketPriceMargin);
+ filterResultsByLabel.put(marginPriceLabel,
+ offer.getUseMarketBasedPrice()
+ ? isMarginLEMaxMarketPriceMargin.test(offer, maxMarketPriceMargin)
+ : "N/A");
+ var fixedPriceLabel = format("Is offer's fixed-price (%s) <= bot's target price (%s)?",
+ offer.getUseMarketBasedPrice() ? "N/A" : offer.getPrice() + " " + currencyCode,
+ offer.getUseMarketBasedPrice() ? "N/A" : targetPrice + " " + currencyCode);
+ filterResultsByLabel.put(fixedPriceLabel,
+ offer.getUseMarketBasedPrice()
+ ? "N/A"
+ : isFixedPriceLEMaxMarketPriceMargin.test(offer, currentMarketPrice));
+ String btcAmountBounds = format("%s BTC - %s BTC", minAmount, maxAmount);
+ filterResultsByLabel.put("Is offer's BTC amount within bot amount bounds (" + btcAmountBounds + ")?",
+ isWithinBTCAmountBounds.test(offer));
+
+ var title = format("%s offer %s filter results:",
+ offer.getUseMarketBasedPrice() ? "Margin based" : "Fixed price",
+ offer.getId());
+ log.info(toTable.apply(title, filterResultsByLabel));
+ }
+ }
+}
diff --git a/java-examples/src/main/resources/RegtestTradePaymentSimulator.properties b/java-examples/src/main/resources/RegtestTradePaymentSimulator.properties
new file mode 100644
index 0000000..d17c79e
--- /dev/null
+++ b/java-examples/src/main/resources/RegtestTradePaymentSimulator.properties
@@ -0,0 +1 @@
+pollingInterval=10000
diff --git a/java-examples/src/main/resources/TakeBestPricedOfferToBuyBsq.properties b/java-examples/src/main/resources/TakeBestPricedOfferToBuyBsq.properties
new file mode 100644
index 0000000..22176e7
--- /dev/null
+++ b/java-examples/src/main/resources/TakeBestPricedOfferToBuyBsq.properties
@@ -0,0 +1,26 @@
+# Maximum # of offers to take during one bot session. When reached, bot will shut down (but not the API daemon).
+maxTakeOffers=1
+#
+# Minimum distance from 30-day average BSQ trade price.
+# Note: all BSQ Swap offers have a fixed-price, but the bot uses a margin (%) of the 30-day price for comparison.
+minMarketPriceMargin=0
+#
+# Hard coded 30-day average BSQ trade price, used for development over regtest.
+regtest30DayAvgBsqPrice=0.00005
+#
+# Taker bot's min BTC amount to sell. The candidate SELL BTC offer's amount must be >= minAmount BTC.
+minAmount=0.01
+#
+# Taker bot's max BTC amount to sell. The candidate SELL BTC offer's amount must be <= maxAmount BTC.
+maxAmount=0.50
+#
+# Taker bot's max acceptable transaction fee rate (sats / byte).
+# Regtest fee rates are from https://price.bisq.wiz.biz/getFees
+maxTxFeeRate=25
+#
+# Taker bot's list of preferred trading peers (their onion addresses).
+# If you do not want to constrict trading to preferred peers, comment this line out with a '#' character.
+preferredTradingPeers=localhost:8888
+#
+# Offer polling frequency must be >= 1s (1000ms) between each getoffers request.
+pollingInterval=30000
diff --git a/java-examples/src/main/resources/TakeBestPricedOfferToBuyBtc.properties b/java-examples/src/main/resources/TakeBestPricedOfferToBuyBtc.properties
new file mode 100644
index 0000000..0f92400
--- /dev/null
+++ b/java-examples/src/main/resources/TakeBestPricedOfferToBuyBtc.properties
@@ -0,0 +1,38 @@
+#
+# Maximum # of offers to take during one bot session. When reached, bot will shut down API daemon then itself.
+maxTakeOffers=10
+#
+# Taker bot's payment account id. Only BUY BTC offers using the same payment method will be considered for taking.
+paymentAccountId=9ad3cc7a-7d32-453c-b9db-a3714b5b8f61
+#
+# Taker bot's min market price margin. A candidate BUY BTC offer's price margin must be >= minMarketPriceMargin.
+#
+minMarketPriceMargin=1.00
+#
+# Taker bot's min BTC amount to sell. The candidate BUY offer's amount must be >= minAmount BTC.
+minAmount=0.01
+#
+# Taker bot's max BTC amount to sell. The candidate BUY offer's amount must be <= maxAmount BTC.
+maxAmount=0.50
+#
+# Taker bot's max acceptable transaction fee rate (sats / byte).
+# Regtest fee rates are from https://price.bisq.wiz.biz/getFees
+maxTxFeeRate=25
+#
+# Bisq trade fee currency code (BSQ or BTC).
+bisqTradeFeeCurrency=BSQ
+#
+# Taker bot's list of preferred trading peers (their onion addresses).
+# If you do not want to constrict trading to preferred peers, comment this line out with a '#' character.
+preferredTradingPeers=localhost:8888, \
+ nysf2pknaaxfh26k42ego5mnfzpbozyi3nuoxdu745unvva4pvywffyd.onion:9999, \
+ vrexx4kqxszmwn6tlde3rvrzmvi3klgzlgjl5dfa2rc7orp4saext3yd.onion:9999, \
+ y7lvgclwyg6kz7mgystx67vd56vlvxbeg62wgrnrtknspmneeq6ecfid.onion:9999, \
+ yv4sfwskwbzsokil5vdua4vsfrfbve6dgv7rwjfn5ensxxrzuulkehyd.onion:9999, \
+ unskoifqryw22b53ut4cghrs7ekp6k7zlvqd3pi73y4zctpmmy2fhnyd.onion:9999, \
+ tspph3aycnnfuxnxy73bx7p3uxirplawmnizli3y74gbzvrjdruz2rad.onion:9999, \
+ zdlbe7xfmmmtvr6esqwr52qk2bysq7jicmj3rxjr5ieuagtvraqpfrid.onion:9999, \
+ x6x2o3m6rxhkfuf2v6lalbharf3whwvkts5rdn3jkhgieqvnq6mvdfyd.onion:9999
+#
+# Offer polling frequency must be >= 1s (1000ms) between each getoffers request.
+pollingInterval=20000
diff --git a/java-examples/src/main/resources/TakeBestPricedOfferToSellBsq.properties b/java-examples/src/main/resources/TakeBestPricedOfferToSellBsq.properties
new file mode 100644
index 0000000..f45e0a8
--- /dev/null
+++ b/java-examples/src/main/resources/TakeBestPricedOfferToSellBsq.properties
@@ -0,0 +1,26 @@
+# Maximum # of offers to take during one bot session. When reached, bot will shut down (but not the API daemon).
+maxTakeOffers=10
+#
+# Maximum distance from 30-day average BSQ trade price.
+# Note: all BSQ Swap offers have a fixed-price, but the bot uses a margin (%) of the 30-day price for comparison.
+maxMarketPriceMargin=0.00
+#
+# Hard coded 30-day average BSQ trade price, used for development over regtest.
+regtest30DayAvgBsqPrice=0.00005
+#
+# Taker bot's min BTC amount to buy. The candidate BUY BTC offer's amount must be >= minAmount BTC.
+minAmount=0.01
+#
+# Taker bot's max BTC amount to buy. The candidate BUY BTC offer's amount must be <= maxAmount BTC.
+maxAmount=0.50
+#
+# Taker bot's max acceptable transaction fee rate (sats / byte).
+# Regtest fee rates are from https://price.bisq.wiz.biz/getFees
+maxTxFeeRate=25
+#
+# Taker bot's list of preferred trading peers (their onion addresses).
+# If you do not want to constrict trading to preferred peers, comment this line out with a '#' character.
+preferredTradingPeers=localhost:8888
+#
+# Offer polling frequency must be >= 1s (1000ms) between each getoffers request.
+pollingInterval=30000
diff --git a/java-examples/src/main/resources/TakeBestPricedOfferToSellBtc.properties b/java-examples/src/main/resources/TakeBestPricedOfferToSellBtc.properties
new file mode 100644
index 0000000..f689581
--- /dev/null
+++ b/java-examples/src/main/resources/TakeBestPricedOfferToSellBtc.properties
@@ -0,0 +1,37 @@
+#
+# Maximum # of offers to take during one bot session. When reached, bot will shut down API daemon then itself.
+maxTakeOffers=4
+#
+# Taker bot's payment account id. Only SELL BTC offers using the same payment method will be considered for taking.
+paymentAccountId=9ad3cc7a-7d32-453c-b9db-a3714b5b8f61
+#
+# Taker bot's max market price margin. A candidate SELL BTC offer's price margin must be <= maxMarketPriceMargin.
+maxMarketPriceMargin=3.00
+#
+# Taker bot's min BTC amount to buy. The candidate SELL offer's amount must be >= minAmount BTC.
+minAmount=0.01
+#
+# Taker bot's max BTC amount to buy. The candidate SELL offer's amount must be <= maxAmount BTC.
+maxAmount=0.50
+#
+# Taker bot's max acceptable transaction fee rate (sats / byte).
+# Regtest fee rates are from https://price.bisq.wiz.biz/getFees
+maxTxFeeRate=100
+#
+# Bisq trade fee currency code (BSQ or BTC).
+bisqTradeFeeCurrency=BSQ
+#
+# Taker bot's list of preferred trading peers (their onion addresses).
+# If you do not want to constrict trading to preferred peers, comment this line out with a '#' character.
+preferredTradingPeers=localhost:8888, \
+ nysf2pknaaxfh26k42ego5mnfzpbozyi3nuoxdu745unvva4pvywffyd.onion:9999, \
+ vrexx4kqxszmwn6tlde3rvrzmvi3klgzlgjl5dfa2rc7orp4saext3yd.onion:9999, \
+ y7lvgclwyg6kz7mgystx67vd56vlvxbeg62wgrnrtknspmneeq6ecfid.onion:9999, \
+ yv4sfwskwbzsokil5vdua4vsfrfbve6dgv7rwjfn5ensxxrzuulkehyd.onion:9999, \
+ unskoifqryw22b53ut4cghrs7ekp6k7zlvqd3pi73y4zctpmmy2fhnyd.onion:9999, \
+ tspph3aycnnfuxnxy73bx7p3uxirplawmnizli3y74gbzvrjdruz2rad.onion:9999, \
+ zdlbe7xfmmmtvr6esqwr52qk2bysq7jicmj3rxjr5ieuagtvraqpfrid.onion:9999, \
+ x6x2o3m6rxhkfuf2v6lalbharf3whwvkts5rdn3jkhgieqvnq6mvdfyd.onion:9999
+#
+# Offer polling frequency must be >= 1s (1000ms) between each getoffers request.
+pollingInterval=20000