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/CurrencyFormat.java b/java-examples/src/main/java/bisq/bots/CurrencyFormat.java
new file mode 100644
index 0000000..fa4b64c
--- /dev/null
+++ b/java-examples/src/main/java/bisq/bots/CurrencyFormat.java
@@ -0,0 +1,120 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see .
+ */
+
+package bisq.bots;
+
+import bisq.proto.grpc.TxFeeRateInfo;
+import com.google.common.annotations.VisibleForTesting;
+
+import java.math.BigDecimal;
+import java.text.DecimalFormat;
+import java.text.DecimalFormatSymbols;
+import java.text.NumberFormat;
+import java.util.Locale;
+
+import static java.lang.String.format;
+import static java.math.RoundingMode.HALF_UP;
+import static java.math.RoundingMode.UNNECESSARY;
+
+/**
+ * Utility for formatting amounts, volumes and fees; there is no i18n support in the CLI.
+ */
+@VisibleForTesting
+public class CurrencyFormat {
+
+ // Use the US locale as a base for all DecimalFormats, but commas should be omitted from number strings.
+ private static final DecimalFormatSymbols DECIMAL_FORMAT_SYMBOLS = DecimalFormatSymbols.getInstance(Locale.US);
+
+ // Use the US locale as a base for all NumberFormats, but commas should be omitted from number strings.
+ private static final NumberFormat US_LOCALE_NUMBER_FORMAT = NumberFormat.getInstance(Locale.US);
+
+ // Formats numbers for internal use, i.e., grpc request parameters.
+ private static final DecimalFormat INTERNAL_FIAT_DECIMAL_FORMAT = new DecimalFormat("##############0.0000");
+
+ static final BigDecimal SATOSHI_DIVISOR = new BigDecimal(100_000_000);
+ static final DecimalFormat SATOSHI_FORMAT = new DecimalFormat("###,##0.00000000", DECIMAL_FORMAT_SYMBOLS);
+ static final DecimalFormat BTC_FORMAT = new DecimalFormat("###,##0.########", DECIMAL_FORMAT_SYMBOLS);
+ static final DecimalFormat BTC_TX_FEE_FORMAT = new DecimalFormat("###,###,##0", DECIMAL_FORMAT_SYMBOLS);
+
+ static final BigDecimal BSQ_SATOSHI_DIVISOR = new BigDecimal(100);
+ static final DecimalFormat BSQ_FORMAT = new DecimalFormat("###,###,###,##0.00", DECIMAL_FORMAT_SYMBOLS);
+
+ public static String formatSatoshis(String sats) {
+ //noinspection BigDecimalMethodWithoutRoundingCalled
+ return SATOSHI_FORMAT.format(new BigDecimal(sats).divide(SATOSHI_DIVISOR));
+ }
+
+ @SuppressWarnings("BigDecimalMethodWithoutRoundingCalled")
+ public static String formatSatoshis(long sats) {
+ return SATOSHI_FORMAT.format(new BigDecimal(sats).divide(SATOSHI_DIVISOR));
+ }
+
+ @SuppressWarnings("BigDecimalMethodWithoutRoundingCalled")
+ public static String formatBtc(long sats) {
+ return BTC_FORMAT.format(new BigDecimal(sats).divide(SATOSHI_DIVISOR));
+ }
+
+ @SuppressWarnings("BigDecimalMethodWithoutRoundingCalled")
+ public static String formatBsq(long sats) {
+ return BSQ_FORMAT.format(new BigDecimal(sats).divide(BSQ_SATOSHI_DIVISOR));
+ }
+
+ public static String formatTxFeeRateInfo(TxFeeRateInfo txFeeRateInfo) {
+ if (txFeeRateInfo.getUseCustomTxFeeRate())
+ return format("custom tx fee rate: %s sats/byte, network rate: %s sats/byte, min network rate: %s sats/byte",
+ formatFeeSatoshis(txFeeRateInfo.getCustomTxFeeRate()),
+ formatFeeSatoshis(txFeeRateInfo.getFeeServiceRate()),
+ formatFeeSatoshis(txFeeRateInfo.getMinFeeServiceRate()));
+ else
+ return format("tx fee rate: %s sats/byte, min tx fee rate: %s sats/byte",
+ formatFeeSatoshis(txFeeRateInfo.getFeeServiceRate()),
+ formatFeeSatoshis(txFeeRateInfo.getMinFeeServiceRate()));
+ }
+
+ public static String formatPrice(long price) {
+ US_LOCALE_NUMBER_FORMAT.setMinimumFractionDigits(4);
+ US_LOCALE_NUMBER_FORMAT.setMaximumFractionDigits(4);
+ US_LOCALE_NUMBER_FORMAT.setRoundingMode(UNNECESSARY);
+ return US_LOCALE_NUMBER_FORMAT.format((double) price / 10_000);
+ }
+
+ public static String formatFiatVolume(long volume) {
+ US_LOCALE_NUMBER_FORMAT.setMinimumFractionDigits(0);
+ US_LOCALE_NUMBER_FORMAT.setMaximumFractionDigits(0);
+ US_LOCALE_NUMBER_FORMAT.setRoundingMode(HALF_UP);
+ return US_LOCALE_NUMBER_FORMAT.format((double) volume / 10_000);
+ }
+
+ public static long toSatoshis(BigDecimal btc) {
+ return btc.multiply(SATOSHI_DIVISOR).longValue();
+ }
+
+ public static long toSatoshis(String btc) {
+ if (btc.startsWith("-"))
+ throw new IllegalArgumentException(format("'%s' is not a positive number", btc));
+
+ try {
+ return new BigDecimal(btc).multiply(SATOSHI_DIVISOR).longValue();
+ } catch (NumberFormatException e) {
+ throw new IllegalArgumentException(format("'%s' is not a number", btc));
+ }
+ }
+
+ public static String formatFeeSatoshis(long sats) {
+ return BTC_TX_FEE_FORMAT.format(BigDecimal.valueOf(sats));
+ }
+}
diff --git a/java-examples/src/main/java/bisq/bots/GrpcStubs.java b/java-examples/src/main/java/bisq/bots/GrpcStubs.java
new file mode 100644
index 0000000..c6ed9fb
--- /dev/null
+++ b/java-examples/src/main/java/bisq/bots/GrpcStubs.java
@@ -0,0 +1,67 @@
+/*
+ * 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.CallCredentials;
+import io.grpc.ManagedChannelBuilder;
+import lombok.extern.slf4j.Slf4j;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+/**
+ * gRPC Service Stubs -- all blocking.
+ */
+@Slf4j
+final class GrpcStubs {
+
+ public final DisputeAgentsGrpc.DisputeAgentsBlockingStub disputeAgentsService;
+ public final HelpGrpc.HelpBlockingStub helpService;
+ public final GetVersionGrpc.GetVersionBlockingStub versionService;
+ public final OffersGrpc.OffersBlockingStub offersService;
+ public final PaymentAccountsGrpc.PaymentAccountsBlockingStub paymentAccountsService;
+ public final PriceGrpc.PriceBlockingStub priceService;
+ public final ShutdownServerGrpc.ShutdownServerBlockingStub shutdownService;
+ public final TradesGrpc.TradesBlockingStub tradesService;
+ public final WalletsGrpc.WalletsBlockingStub walletsService;
+
+ public GrpcStubs(String apiHost, int apiPort, String apiPassword) {
+ CallCredentials credentials = new PasswordCallCredentials(apiPassword);
+
+ var channel = ManagedChannelBuilder.forAddress(apiHost, apiPort).usePlaintext().build();
+ Runtime.getRuntime().addShutdownHook(new Thread(() -> {
+ try {
+ log.info("Shutting down bot's grpc channel.");
+ channel.shutdown().awaitTermination(1, SECONDS);
+ log.info("Bot channel shutdown complete.");
+ } catch (InterruptedException ex) {
+ throw new IllegalStateException(ex);
+ }
+ }));
+
+ this.disputeAgentsService = DisputeAgentsGrpc.newBlockingStub(channel).withCallCredentials(credentials);
+ this.helpService = HelpGrpc.newBlockingStub(channel).withCallCredentials(credentials);
+ this.versionService = GetVersionGrpc.newBlockingStub(channel).withCallCredentials(credentials);
+ this.offersService = OffersGrpc.newBlockingStub(channel).withCallCredentials(credentials);
+ this.paymentAccountsService = PaymentAccountsGrpc.newBlockingStub(channel).withCallCredentials(credentials);
+ this.priceService = PriceGrpc.newBlockingStub(channel).withCallCredentials(credentials);
+ this.shutdownService = ShutdownServerGrpc.newBlockingStub(channel).withCallCredentials(credentials);
+ this.tradesService = TradesGrpc.newBlockingStub(channel).withCallCredentials(credentials);
+ this.walletsService = WalletsGrpc.newBlockingStub(channel).withCallCredentials(credentials);
+ }
+}
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/PasswordCallCredentials.java b/java-examples/src/main/java/bisq/bots/PasswordCallCredentials.java
new file mode 100644
index 0000000..a119c4b
--- /dev/null
+++ b/java-examples/src/main/java/bisq/bots/PasswordCallCredentials.java
@@ -0,0 +1,62 @@
+/*
+ * 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.CallCredentials;
+import io.grpc.Metadata;
+import io.grpc.Metadata.Key;
+
+import java.util.concurrent.Executor;
+
+import static io.grpc.Metadata.ASCII_STRING_MARSHALLER;
+import static io.grpc.Status.UNAUTHENTICATED;
+import static java.lang.String.format;
+
+/**
+ * Sets the {@value PASSWORD_KEY} rpc call header to a given value.
+ */
+class PasswordCallCredentials extends CallCredentials {
+
+ public static final String PASSWORD_KEY = "password";
+
+ private final String passwordValue;
+
+ public PasswordCallCredentials(String passwordValue) {
+ if (passwordValue == null)
+ throw new IllegalArgumentException(format("'%s' value must not be null", PASSWORD_KEY));
+ this.passwordValue = passwordValue;
+ }
+
+ @Override
+ public void applyRequestMetadata(RequestInfo requestInfo, Executor appExecutor, MetadataApplier metadataApplier) {
+ appExecutor.execute(() -> {
+ try {
+ var headers = new Metadata();
+ var passwordKey = Key.of(PASSWORD_KEY, ASCII_STRING_MARSHALLER);
+ headers.put(passwordKey, passwordValue);
+ metadataApplier.apply(headers);
+ } catch (Throwable ex) {
+ metadataApplier.fail(UNAUTHENTICATED.withCause(ex));
+ }
+ });
+ }
+
+ @Override
+ public void thisUsesUnstableApi() {
+ }
+}
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/java/bisq/bots/table/Table.java b/java-examples/src/main/java/bisq/bots/table/Table.java
new file mode 100644
index 0000000..67bed91
--- /dev/null
+++ b/java-examples/src/main/java/bisq/bots/table/Table.java
@@ -0,0 +1,153 @@
+/*
+ * 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.table;
+
+import bisq.bots.table.column.Column;
+
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+import java.util.stream.IntStream;
+
+import static bisq.bots.table.column.Column.JUSTIFICATION.RIGHT;
+import static com.google.common.base.Strings.padStart;
+import static java.lang.String.format;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+/**
+ * A simple table of formatted data for the CLI's output console. A table must be
+ * created with at least one populated column, and each column passed to the constructor
+ * must contain the same number of rows. Null checking is omitted because tables are
+ * populated by protobuf message fields which cannot be null.
+ *
+ * All data in a column has the same type: long, string, etc., but a table
+ * may contain an arbitrary number of columns of any type. For output formatting
+ * purposes, numeric and date columns should be transformed to a StringColumn type with
+ * formatted and justified string values before being passed to the constructor.
+ *
+ * This is not a relational, rdbms table.
+ */
+public class Table {
+
+ public final Column>[] columns;
+ public final int rowCount;
+
+ // Each printed column is delimited by two spaces.
+ private final int columnDelimiterLength = 2;
+
+ /**
+ * Default constructor. Takes populated Columns.
+ *
+ * @param columns containing the same number of rows
+ */
+ public Table(Column>... columns) {
+ this.columns = columns;
+ this.rowCount = columns.length > 0 ? columns[0].rowCount() : 0;
+ validateStructure();
+ }
+
+ /**
+ * Print table data to a PrintStream.
+ *
+ * @param printStream the target output stream
+ */
+ public void print(PrintStream printStream) {
+ printColumnNames(printStream);
+ for (int rowIndex = 0; rowIndex < rowCount; rowIndex++) {
+ printRow(printStream, rowIndex);
+ }
+ }
+
+ /**
+ * Print table column names to a PrintStream.
+ *
+ * @param printStream the target output stream
+ */
+ private void printColumnNames(PrintStream printStream) {
+ IntStream.range(0, columns.length).forEachOrdered(colIndex -> {
+ var c = columns[colIndex];
+ var justifiedName = c.getJustification().equals(RIGHT)
+ ? padStart(c.getName(), c.getWidth(), ' ')
+ : c.getName();
+ var paddedWidth = colIndex == columns.length - 1
+ ? c.getName().length()
+ : c.getWidth() + columnDelimiterLength;
+ printStream.printf("%-" + paddedWidth + "s", justifiedName);
+ });
+ printStream.println();
+ }
+
+ /**
+ * Print a table row to a PrintStream.
+ *
+ * @param printStream the target output stream
+ */
+ private void printRow(PrintStream printStream, int rowIndex) {
+ IntStream.range(0, columns.length).forEachOrdered(colIndex -> {
+ var c = columns[colIndex];
+ var paddedWidth = colIndex == columns.length - 1
+ ? c.getWidth()
+ : c.getWidth() + columnDelimiterLength;
+ printStream.printf("%-" + paddedWidth + "s", c.getRow(rowIndex));
+ if (colIndex == columns.length - 1)
+ printStream.println();
+ });
+ }
+
+ /**
+ * Returns the table's formatted output as a String.
+ *
+ * @return String
+ */
+ @Override
+ public String toString() {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ try (PrintStream ps = new PrintStream(baos, true, UTF_8)) {
+ print(ps);
+ }
+ return baos.toString();
+ }
+
+ /**
+ * Verifies the table has columns, and each column has the same number of rows.
+ */
+ private void validateStructure() {
+ if (columns.length == 0)
+ throw new IllegalArgumentException("Table has no columns.");
+
+ if (columns[0].isEmpty())
+ throw new IllegalArgumentException(
+ format("Table's 1st column (%s) has no data.",
+ columns[0].getName()));
+
+ IntStream.range(1, columns.length).forEachOrdered(colIndex -> {
+ var c = columns[colIndex];
+
+ if (c.isEmpty())
+ throw new IllegalStateException(
+ format("Table column # %d (%s) does not have any data.",
+ colIndex + 1,
+ c.getName()));
+
+ if (this.rowCount != c.rowCount())
+ throw new IllegalStateException(
+ format("Table column # %d (%s) does not have same number of rows as 1st column.",
+ colIndex + 1,
+ c.getName()));
+ });
+ }
+}
diff --git a/java-examples/src/main/java/bisq/bots/table/builder/AbstractTableBuilder.java b/java-examples/src/main/java/bisq/bots/table/builder/AbstractTableBuilder.java
new file mode 100644
index 0000000..994e1a5
--- /dev/null
+++ b/java-examples/src/main/java/bisq/bots/table/builder/AbstractTableBuilder.java
@@ -0,0 +1,44 @@
+/*
+ * 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.table.builder;
+
+import bisq.bots.table.Table;
+import bisq.proto.grpc.OfferInfo;
+
+import java.util.List;
+import java.util.function.Predicate;
+
+/**
+ * Abstract superclass for TableBuilder implementations.
+ */
+abstract class AbstractTableBuilder {
+
+ protected final Predicate isFiatOffer = (o) -> o.getBaseCurrencyCode().equals("BTC");
+
+ protected final TableType tableType;
+ protected final List> protos;
+
+ AbstractTableBuilder(TableType tableType, List> protos) {
+ this.tableType = tableType;
+ this.protos = protos;
+ if (protos.isEmpty())
+ throw new IllegalArgumentException("cannot build a table without rows");
+ }
+
+ public abstract Table build();
+}
diff --git a/java-examples/src/main/java/bisq/bots/table/builder/AbstractTradeListBuilder.java b/java-examples/src/main/java/bisq/bots/table/builder/AbstractTradeListBuilder.java
new file mode 100644
index 0000000..16d1a06
--- /dev/null
+++ b/java-examples/src/main/java/bisq/bots/table/builder/AbstractTradeListBuilder.java
@@ -0,0 +1,301 @@
+/*
+ * 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.table.builder;
+
+import bisq.bots.table.column.Column;
+import bisq.bots.table.column.MixedTradeFeeColumn;
+import bisq.proto.grpc.ContractInfo;
+import bisq.proto.grpc.TradeInfo;
+
+import javax.annotation.Nullable;
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+
+import static bisq.bots.CurrencyFormat.formatSatoshis;
+import static bisq.bots.table.builder.TableBuilderConstants.COL_HEADER_BUYER_DEPOSIT;
+import static bisq.bots.table.builder.TableBuilderConstants.COL_HEADER_SELLER_DEPOSIT;
+import static bisq.bots.table.builder.TableType.TRADE_DETAIL_TBL;
+import static java.lang.String.format;
+import static protobuf.OfferDirection.SELL;
+
+abstract class AbstractTradeListBuilder extends AbstractTableBuilder {
+
+ protected final List trades;
+
+ protected final TradeTableColumnSupplier colSupplier;
+
+ protected final Column colTradeId;
+ @Nullable
+ protected final Column colCreateDate;
+ @Nullable
+ protected final Column colMarket;
+ protected final Column colPrice;
+ @Nullable
+ protected final Column colPriceDeviation;
+ @Nullable
+ protected final Column colCurrency;
+ @Nullable
+ protected final Column colAmount;
+ @Nullable
+ protected final Column colMixedAmount;
+ @Nullable
+ protected final Column colMinerTxFee;
+ @Nullable
+ protected final MixedTradeFeeColumn colMixedTradeFee;
+ @Nullable
+ protected final Column colBuyerDeposit;
+ @Nullable
+ protected final Column colSellerDeposit;
+ @Nullable
+ protected final Column colPaymentMethod;
+ @Nullable
+ protected final Column colRole;
+ @Nullable
+ protected final Column colOfferType;
+ @Nullable
+ protected final Column colClosingStatus;
+
+ // Trade detail tbl specific columns
+
+ @Nullable
+ protected final Column colIsDepositPublished;
+ @Nullable
+ protected final Column colIsDepositConfirmed;
+ @Nullable
+ protected final Column colIsPayoutPublished;
+ @Nullable
+ protected final Column colIsCompleted;
+ @Nullable
+ protected final Column colBisqTradeFee;
+ @Nullable
+ protected final Column colTradeCost;
+ @Nullable
+ protected final Column colIsPaymentStartedMessageSent;
+ @Nullable
+ protected final Column colIsPaymentReceivedMessageSent;
+ @Nullable
+ protected final Column colAltcoinReceiveAddressColumn;
+
+ // BSQ swap trade detail specific columns
+
+ @Nullable
+ protected final Column status;
+ @Nullable
+ protected final Column colTxId;
+ @Nullable
+ protected final Column colNumConfirmations;
+
+ AbstractTradeListBuilder(TableType tableType, List> protos) {
+ super(tableType, protos);
+ validate();
+
+ this.trades = protos.stream().map(p -> (TradeInfo) p).collect(Collectors.toList());
+ this.colSupplier = new TradeTableColumnSupplier(tableType, trades);
+
+ this.colTradeId = colSupplier.tradeIdColumn.get();
+ this.colCreateDate = colSupplier.createDateColumn.get();
+ this.colMarket = colSupplier.marketColumn.get();
+ this.colPrice = colSupplier.priceColumn.get();
+ this.colPriceDeviation = colSupplier.priceDeviationColumn.get();
+ this.colCurrency = colSupplier.currencyColumn.get();
+ this.colAmount = colSupplier.amountColumn.get();
+ this.colMixedAmount = colSupplier.mixedAmountColumn.get();
+ this.colMinerTxFee = colSupplier.minerTxFeeColumn.get();
+ this.colMixedTradeFee = colSupplier.mixedTradeFeeColumn.get();
+ this.colBuyerDeposit = colSupplier.toSecurityDepositColumn.apply(COL_HEADER_BUYER_DEPOSIT);
+ this.colSellerDeposit = colSupplier.toSecurityDepositColumn.apply(COL_HEADER_SELLER_DEPOSIT);
+ this.colPaymentMethod = colSupplier.paymentMethodColumn.get();
+ this.colRole = colSupplier.roleColumn.get();
+ this.colOfferType = colSupplier.offerTypeColumn.get();
+ this.colClosingStatus = colSupplier.statusDescriptionColumn.get();
+
+ // Trade detail specific columns, some in common with BSQ swap trades detail.
+
+ this.colIsDepositPublished = colSupplier.depositPublishedColumn.get();
+ this.colIsDepositConfirmed = colSupplier.depositConfirmedColumn.get();
+ this.colIsPayoutPublished = colSupplier.payoutPublishedColumn.get();
+ this.colIsCompleted = colSupplier.fundsWithdrawnColumn.get();
+ this.colBisqTradeFee = colSupplier.bisqTradeDetailFeeColumn.get();
+ this.colTradeCost = colSupplier.tradeCostColumn.get();
+ this.colIsPaymentStartedMessageSent = colSupplier.paymentStartedMessageSentColumn.get();
+ this.colIsPaymentReceivedMessageSent = colSupplier.paymentReceivedMessageSentColumn.get();
+ //noinspection ConstantConditions
+ this.colAltcoinReceiveAddressColumn = colSupplier.altcoinReceiveAddressColumn.get();
+
+ // BSQ swap trade detail specific columns
+
+ this.status = colSupplier.bsqSwapStatusColumn.get();
+ this.colTxId = colSupplier.bsqSwapTxIdColumn.get();
+ this.colNumConfirmations = colSupplier.numConfirmationsColumn.get();
+ }
+
+ protected void validate() {
+ if (isTradeDetailTblBuilder.get()) {
+ if (protos.size() != 1)
+ throw new IllegalArgumentException("trade detail tbl can have only one row");
+ } else if (protos.isEmpty()) {
+ throw new IllegalArgumentException("trade tbl has no rows");
+ }
+ }
+
+ // Helper Functions
+
+ private final Supplier isTradeDetailTblBuilder = () -> tableType.equals(TRADE_DETAIL_TBL);
+ protected final Predicate isFiatTrade = (t) -> isFiatOffer.test(t.getOffer());
+ protected final Predicate isBsqTrade = (t) -> !isFiatOffer.test(t.getOffer()) && t.getOffer().getBaseCurrencyCode().equals("BSQ");
+ protected final Predicate isBsqSwapTrade = (t) -> t.getOffer().getIsBsqSwapOffer();
+ protected final Predicate isMyOffer = (t) -> t.getOffer().getIsMyOffer();
+ protected final Predicate isTaker = (t) -> t.getRole().toLowerCase().contains("taker");
+ protected final Predicate isSellOffer = (t) -> t.getOffer().getDirection().equals(SELL.name());
+ protected final Predicate isBtcSeller = (t) -> (isMyOffer.test(t) && isSellOffer.test(t))
+ || (!isMyOffer.test(t) && !isSellOffer.test(t));
+ protected final Predicate isTradeFeeBtc = (t) -> isMyOffer.test(t)
+ ? t.getOffer().getIsCurrencyForMakerFeeBtc()
+ : t.getIsCurrencyForTakerFeeBtc();
+
+
+ // Column Value Functions
+
+ // Altcoin volumes from server are string representations of decimals.
+ // Converting them to longs ("sats") requires shifting the decimal points
+ // to left: 2 for BSQ, 8 for other altcoins.
+ protected final Function toAltcoinTradeVolumeAsLong = (t) ->
+ isBsqTrade.test(t)
+ ? new BigDecimal(t.getTradeVolume()).movePointRight(2).longValue()
+ : new BigDecimal(t.getTradeVolume()).movePointRight(8).longValue();
+
+ protected final Function toTradeVolumeAsString = (t) ->
+ isFiatTrade.test(t)
+ ? t.getTradeVolume()
+ : formatSatoshis(t.getTradeAmountAsLong());
+
+ protected final Function toTradeVolumeAsLong = (t) ->
+ isFiatTrade.test(t)
+ ? Long.parseLong(t.getTradeVolume())
+ : toAltcoinTradeVolumeAsLong.apply(t);
+
+ protected final Function toTradeAmount = (t) ->
+ isFiatTrade.test(t)
+ ? t.getTradeAmountAsLong()
+ : toTradeVolumeAsLong.apply(t);
+
+ protected final Function toMarket = (t) ->
+ t.getOffer().getBaseCurrencyCode() + "/"
+ + t.getOffer().getCounterCurrencyCode();
+
+ protected final Function toPaymentCurrencyCode = (t) ->
+ isFiatTrade.test(t)
+ ? t.getOffer().getCounterCurrencyCode()
+ : t.getOffer().getBaseCurrencyCode();
+
+ protected final Function toPriceDeviation = (t) ->
+ t.getOffer().getUseMarketBasedPrice()
+ ? format("%.2f%s", t.getOffer().getMarketPriceMarginPct(), "%")
+ : "N/A";
+
+ protected final Function toMyMinerTxFee = (t) -> {
+ if (isBsqSwapTrade.test(t)) {
+ // The BTC seller pays the miner fee for both sides.
+ return isBtcSeller.test(t) ? t.getTxFeeAsLong() : 0L;
+ } else {
+ return isTaker.test(t)
+ ? t.getTxFeeAsLong()
+ : t.getOffer().getTxFee();
+ }
+ };
+
+ protected final Function toTradeFeeBsq = (t) -> {
+ var isMyOffer = t.getOffer().getIsMyOffer();
+ if (isMyOffer) {
+ return t.getOffer().getIsCurrencyForMakerFeeBtc()
+ ? 0L // Maker paid BTC fee, return 0.
+ : t.getOffer().getMakerFee();
+ } else {
+ return t.getIsCurrencyForTakerFeeBtc()
+ ? 0L // Taker paid BTC fee, return 0.
+ : t.getTakerFeeAsLong();
+ }
+ };
+
+ protected final Function toTradeFeeBtc = (t) -> {
+ var isMyOffer = t.getOffer().getIsMyOffer();
+ if (isMyOffer) {
+ return t.getOffer().getIsCurrencyForMakerFeeBtc()
+ ? t.getOffer().getMakerFee()
+ : 0L; // Maker paid BSQ fee, return 0.
+ } else {
+ return t.getIsCurrencyForTakerFeeBtc()
+ ? t.getTakerFeeAsLong()
+ : 0L; // Taker paid BSQ fee, return 0.
+ }
+ };
+
+ protected final Function toMyMakerOrTakerFee = (t) -> {
+ if (isBsqSwapTrade.test(t)) {
+ return isTaker.test(t)
+ ? t.getBsqSwapTradeInfo().getBsqTakerTradeFee()
+ : t.getBsqSwapTradeInfo().getBsqMakerTradeFee();
+ } else {
+ return isTaker.test(t)
+ ? t.getTakerFeeAsLong()
+ : t.getOffer().getMakerFee();
+ }
+ };
+
+ protected final Function toOfferType = (t) -> {
+ if (isFiatTrade.test(t)) {
+ return t.getOffer().getDirection() + " " + t.getOffer().getBaseCurrencyCode();
+ } else {
+ if (t.getOffer().getDirection().equals("BUY")) {
+ return "SELL " + t.getOffer().getBaseCurrencyCode();
+ } else {
+ return "BUY " + t.getOffer().getBaseCurrencyCode();
+ }
+ }
+ };
+
+ protected final Predicate showAltCoinBuyerAddress = (t) -> {
+ if (isFiatTrade.test(t)) {
+ return false;
+ } else {
+ ContractInfo contract = t.getContract();
+ boolean isBuyerMakerAndSellerTaker = contract.getIsBuyerMakerAndSellerTaker();
+ if (isTaker.test(t)) {
+ return !isBuyerMakerAndSellerTaker;
+ } else {
+ return isBuyerMakerAndSellerTaker;
+ }
+ }
+ };
+
+ protected final Function toAltcoinReceiveAddress = (t) -> {
+ if (showAltCoinBuyerAddress.test(t)) {
+ ContractInfo contract = t.getContract();
+ boolean isBuyerMakerAndSellerTaker = contract.getIsBuyerMakerAndSellerTaker();
+ return isBuyerMakerAndSellerTaker // (is BTC buyer / maker)
+ ? contract.getTakerPaymentAccountPayload().getAddress()
+ : contract.getMakerPaymentAccountPayload().getAddress();
+ } else {
+ return "";
+ }
+ };
+}
diff --git a/java-examples/src/main/java/bisq/bots/table/builder/AddressBalanceTableBuilder.java b/java-examples/src/main/java/bisq/bots/table/builder/AddressBalanceTableBuilder.java
new file mode 100644
index 0000000..a003585
--- /dev/null
+++ b/java-examples/src/main/java/bisq/bots/table/builder/AddressBalanceTableBuilder.java
@@ -0,0 +1,71 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see .
+ */
+
+package bisq.bots.table.builder;
+
+import bisq.bots.table.Table;
+import bisq.bots.table.column.*;
+import bisq.proto.grpc.AddressBalanceInfo;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static bisq.bots.table.builder.TableBuilderConstants.*;
+import static bisq.bots.table.builder.TableType.ADDRESS_BALANCE_TBL;
+import static java.lang.String.format;
+
+/**
+ * Builds a {@code bisq.bots.table.Table} from a List of
+ * {@code bisq.proto.grpc.AddressBalanceInfo} objects.
+ */
+class AddressBalanceTableBuilder extends AbstractTableBuilder {
+
+ // Default columns not dynamically generated with address info.
+ private final Column colAddress;
+ private final Column colAvailableBalance;
+ private final Column colConfirmations;
+ private final Column colIsUsed;
+
+ AddressBalanceTableBuilder(List> protos) {
+ super(ADDRESS_BALANCE_TBL, protos);
+ colAddress = new StringColumn(format(COL_HEADER_ADDRESS, "BTC"));
+ this.colAvailableBalance = new SatoshiColumn(COL_HEADER_AVAILABLE_BALANCE);
+ this.colConfirmations = new LongColumn(COL_HEADER_CONFIRMATIONS);
+ this.colIsUsed = new BooleanColumn(COL_HEADER_IS_USED_ADDRESS);
+ }
+
+ public Table build() {
+ List addresses = protos.stream()
+ .map(a -> (AddressBalanceInfo) a)
+ .collect(Collectors.toList());
+
+ // Populate columns with address info.
+ //noinspection SimplifyStreamApiCallChains
+ addresses.stream().forEachOrdered(a -> {
+ colAddress.addRow(a.getAddress());
+ colAvailableBalance.addRow(a.getBalance());
+ colConfirmations.addRow(a.getNumConfirmations());
+ colIsUsed.addRow(!a.getIsAddressUnused());
+ });
+
+ // Define and return the table instance with populated columns.
+ return new Table(colAddress,
+ colAvailableBalance.asStringColumn(),
+ colConfirmations.asStringColumn(),
+ colIsUsed.asStringColumn());
+ }
+}
diff --git a/java-examples/src/main/java/bisq/bots/table/builder/BsqBalanceTableBuilder.java b/java-examples/src/main/java/bisq/bots/table/builder/BsqBalanceTableBuilder.java
new file mode 100644
index 0000000..19c40d3
--- /dev/null
+++ b/java-examples/src/main/java/bisq/bots/table/builder/BsqBalanceTableBuilder.java
@@ -0,0 +1,75 @@
+/*
+ * 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.table.builder;
+
+import bisq.bots.table.Table;
+import bisq.bots.table.column.Column;
+import bisq.bots.table.column.SatoshiColumn;
+import bisq.proto.grpc.BsqBalanceInfo;
+
+import java.util.List;
+
+import static bisq.bots.table.builder.TableBuilderConstants.*;
+import static bisq.bots.table.builder.TableType.BSQ_BALANCE_TBL;
+
+/**
+ * Builds a {@code bisq.bots.table.Table} from a
+ * {@code bisq.proto.grpc.BsqBalanceInfo} object.
+ */
+class BsqBalanceTableBuilder extends AbstractTableBuilder {
+
+ // Default columns not dynamically generated with bsq balance info.
+ private final Column colAvailableConfirmedBalance;
+ private final Column colUnverifiedBalance;
+ private final Column colUnconfirmedChangeBalance;
+ private final Column colLockedForVotingBalance;
+ private final Column colLockupBondsBalance;
+ private final Column colUnlockingBondsBalance;
+
+ BsqBalanceTableBuilder(List> protos) {
+ super(BSQ_BALANCE_TBL, protos);
+ this.colAvailableConfirmedBalance = new SatoshiColumn(COL_HEADER_AVAILABLE_CONFIRMED_BALANCE, true);
+ this.colUnverifiedBalance = new SatoshiColumn(COL_HEADER_UNVERIFIED_BALANCE, true);
+ this.colUnconfirmedChangeBalance = new SatoshiColumn(COL_HEADER_UNCONFIRMED_CHANGE_BALANCE, true);
+ this.colLockedForVotingBalance = new SatoshiColumn(COL_HEADER_LOCKED_FOR_VOTING_BALANCE, true);
+ this.colLockupBondsBalance = new SatoshiColumn(COL_HEADER_LOCKUP_BONDS_BALANCE, true);
+ this.colUnlockingBondsBalance = new SatoshiColumn(COL_HEADER_UNLOCKING_BONDS_BALANCE, true);
+ }
+
+ public Table build() {
+ BsqBalanceInfo balance = (BsqBalanceInfo) protos.get(0);
+
+ // Populate columns with bsq balance info.
+
+ colAvailableConfirmedBalance.addRow(balance.getAvailableConfirmedBalance());
+ colUnverifiedBalance.addRow(balance.getUnverifiedBalance());
+ colUnconfirmedChangeBalance.addRow(balance.getUnconfirmedChangeBalance());
+ colLockedForVotingBalance.addRow(balance.getLockedForVotingBalance());
+ colLockupBondsBalance.addRow(balance.getLockupBondsBalance());
+ colUnlockingBondsBalance.addRow(balance.getUnlockingBondsBalance());
+
+ // Define and return the table instance with populated columns.
+
+ return new Table(colAvailableConfirmedBalance.asStringColumn(),
+ colUnverifiedBalance.asStringColumn(),
+ colUnconfirmedChangeBalance.asStringColumn(),
+ colLockedForVotingBalance.asStringColumn(),
+ colLockupBondsBalance.asStringColumn(),
+ colUnlockingBondsBalance.asStringColumn());
+ }
+}
diff --git a/java-examples/src/main/java/bisq/bots/table/builder/BtcBalanceTableBuilder.java b/java-examples/src/main/java/bisq/bots/table/builder/BtcBalanceTableBuilder.java
new file mode 100644
index 0000000..0360026
--- /dev/null
+++ b/java-examples/src/main/java/bisq/bots/table/builder/BtcBalanceTableBuilder.java
@@ -0,0 +1,67 @@
+/*
+ * 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.table.builder;
+
+import bisq.bots.table.Table;
+import bisq.bots.table.column.Column;
+import bisq.bots.table.column.SatoshiColumn;
+import bisq.proto.grpc.BtcBalanceInfo;
+
+import java.util.List;
+
+import static bisq.bots.table.builder.TableBuilderConstants.*;
+import static bisq.bots.table.builder.TableType.BTC_BALANCE_TBL;
+
+/**
+ * Builds a {@code bisq.bots.table.Table} from a
+ * {@code bisq.proto.grpc.BtcBalanceInfo} object.
+ */
+class BtcBalanceTableBuilder extends AbstractTableBuilder {
+
+ // Default columns not dynamically generated with btc balance info.
+ private final Column colAvailableBalance;
+ private final Column colReservedBalance;
+ private final Column colTotalAvailableBalance;
+ private final Column colLockedBalance;
+
+ BtcBalanceTableBuilder(List> protos) {
+ super(BTC_BALANCE_TBL, protos);
+ this.colAvailableBalance = new SatoshiColumn(COL_HEADER_AVAILABLE_BALANCE);
+ this.colReservedBalance = new SatoshiColumn(COL_HEADER_RESERVED_BALANCE);
+ this.colTotalAvailableBalance = new SatoshiColumn(COL_HEADER_TOTAL_AVAILABLE_BALANCE);
+ this.colLockedBalance = new SatoshiColumn(COL_HEADER_LOCKED_BALANCE);
+ }
+
+ public Table build() {
+ BtcBalanceInfo balance = (BtcBalanceInfo) protos.get(0);
+
+ // Populate columns with btc balance info.
+
+ colAvailableBalance.addRow(balance.getAvailableBalance());
+ colReservedBalance.addRow(balance.getReservedBalance());
+ colTotalAvailableBalance.addRow(balance.getTotalAvailableBalance());
+ colLockedBalance.addRow(balance.getLockedBalance());
+
+ // Define and return the table instance with populated columns.
+
+ return new Table(colAvailableBalance.asStringColumn(),
+ colReservedBalance.asStringColumn(),
+ colTotalAvailableBalance.asStringColumn(),
+ colLockedBalance.asStringColumn());
+ }
+}
diff --git a/java-examples/src/main/java/bisq/bots/table/builder/ClosedTradeTableBuilder.java b/java-examples/src/main/java/bisq/bots/table/builder/ClosedTradeTableBuilder.java
new file mode 100644
index 0000000..dc76d51
--- /dev/null
+++ b/java-examples/src/main/java/bisq/bots/table/builder/ClosedTradeTableBuilder.java
@@ -0,0 +1,80 @@
+/*
+ * 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.table.builder;
+
+import bisq.bots.table.Table;
+
+import java.util.List;
+
+import static bisq.bots.table.builder.TableType.CLOSED_TRADES_TBL;
+
+@SuppressWarnings("ConstantConditions")
+class ClosedTradeTableBuilder extends AbstractTradeListBuilder {
+
+ ClosedTradeTableBuilder(List> protos) {
+ super(CLOSED_TRADES_TBL, protos);
+ }
+
+ public Table build() {
+ populateColumns();
+ return new Table(colTradeId,
+ colCreateDate.asStringColumn(),
+ colMarket,
+ colPrice.justify(),
+ colPriceDeviation.justify(),
+ colAmount.asStringColumn(),
+ colMixedAmount.justify(),
+ colCurrency,
+ colMinerTxFee.asStringColumn(),
+ colMixedTradeFee.asStringColumn(),
+ colBuyerDeposit.asStringColumn(),
+ colSellerDeposit.asStringColumn(),
+ colOfferType,
+ colClosingStatus);
+ }
+
+ private void populateColumns() {
+ trades.forEach(t -> {
+ colTradeId.addRow(t.getTradeId());
+ colCreateDate.addRow(t.getDate());
+ colMarket.addRow(toMarket.apply(t));
+ colPrice.addRow(t.getTradePrice());
+ colPriceDeviation.addRow(toPriceDeviation.apply(t));
+ colAmount.addRow(t.getTradeAmountAsLong());
+ colMixedAmount.addRow(t.getTradeVolume());
+ colCurrency.addRow(toPaymentCurrencyCode.apply(t));
+ colMinerTxFee.addRow(toMyMinerTxFee.apply(t));
+
+ if (t.getOffer().getIsBsqSwapOffer()) {
+ // For BSQ Swaps, BTC buyer pays the BSQ trade fee for both sides (BTC seller pays no fee).
+ var optionalTradeFeeBsq = isBtcSeller.test(t) ? 0L : toTradeFeeBsq.apply(t);
+ colMixedTradeFee.addRow(optionalTradeFeeBsq, true);
+ } else if (isTradeFeeBtc.test(t)) {
+ colMixedTradeFee.addRow(toTradeFeeBtc.apply(t), false);
+ } else {
+ // V1 trade fee paid in BSQ.
+ colMixedTradeFee.addRow(toTradeFeeBsq.apply(t), true);
+ }
+
+ colBuyerDeposit.addRow(t.getOffer().getBuyerSecurityDeposit());
+ colSellerDeposit.addRow(t.getOffer().getSellerSecurityDeposit());
+ colOfferType.addRow(toOfferType.apply(t));
+ colClosingStatus.addRow(t.getClosingStatus());
+ });
+ }
+}
diff --git a/java-examples/src/main/java/bisq/bots/table/builder/FailedTradeTableBuilder.java b/java-examples/src/main/java/bisq/bots/table/builder/FailedTradeTableBuilder.java
new file mode 100644
index 0000000..7a78d2c
--- /dev/null
+++ b/java-examples/src/main/java/bisq/bots/table/builder/FailedTradeTableBuilder.java
@@ -0,0 +1,64 @@
+/*
+ * 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.table.builder;
+
+import bisq.bots.table.Table;
+
+import java.util.List;
+
+import static bisq.bots.table.builder.TableType.FAILED_TRADES_TBL;
+
+/**
+ * Builds a {@code bisq.bots.table.Table} from a list of {@code bisq.proto.grpc.TradeInfo} objects.
+ */
+@SuppressWarnings("ConstantConditions")
+class FailedTradeTableBuilder extends AbstractTradeListBuilder {
+
+ FailedTradeTableBuilder(List> protos) {
+ super(FAILED_TRADES_TBL, protos);
+ }
+
+ public Table build() {
+ populateColumns();
+ return new Table(colTradeId,
+ colCreateDate.asStringColumn(),
+ colMarket,
+ colPrice.justify(),
+ colAmount.asStringColumn(),
+ colMixedAmount.justify(),
+ colCurrency,
+ colOfferType,
+ colRole,
+ colClosingStatus);
+ }
+
+ private void populateColumns() {
+ trades.forEach(t -> {
+ colTradeId.addRow(t.getTradeId());
+ colCreateDate.addRow(t.getDate());
+ colMarket.addRow(toMarket.apply(t));
+ colPrice.addRow(t.getTradePrice());
+ colAmount.addRow(t.getTradeAmountAsLong());
+ colMixedAmount.addRow(t.getTradeVolume());
+ colCurrency.addRow(toPaymentCurrencyCode.apply(t));
+ colOfferType.addRow(toOfferType.apply(t));
+ colRole.addRow(t.getRole());
+ colClosingStatus.addRow("Failed");
+ });
+ }
+}
diff --git a/java-examples/src/main/java/bisq/bots/table/builder/OfferTableBuilder.java b/java-examples/src/main/java/bisq/bots/table/builder/OfferTableBuilder.java
new file mode 100644
index 0000000..3136ec6
--- /dev/null
+++ b/java-examples/src/main/java/bisq/bots/table/builder/OfferTableBuilder.java
@@ -0,0 +1,259 @@
+/*
+ * 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.table.builder;
+
+import bisq.bots.table.Table;
+import bisq.bots.table.column.*;
+import bisq.proto.grpc.OfferInfo;
+
+import javax.annotation.Nullable;
+import java.util.List;
+import java.util.function.Function;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+
+import static bisq.bots.table.builder.TableBuilderConstants.*;
+import static bisq.bots.table.builder.TableType.OFFER_TBL;
+import static bisq.bots.table.column.Column.JUSTIFICATION.*;
+import static bisq.bots.table.column.ZippedStringColumns.DUPLICATION_MODE.EXCLUDE_DUPLICATES;
+import static java.lang.String.format;
+import static protobuf.OfferDirection.BUY;
+import static protobuf.OfferDirection.SELL;
+
+/**
+ * Builds a {@code bisq.bots.table.Table} from a List of
+ * {@code bisq.proto.grpc.OfferInfo} objects.
+ */
+class OfferTableBuilder extends AbstractTableBuilder {
+
+ // Columns common to both fiat and cryptocurrency offers.
+ private final Column colOfferId = new StringColumn(COL_HEADER_UUID, LEFT);
+ private final Column colDirection = new StringColumn(COL_HEADER_DIRECTION, LEFT);
+ private final Column colAmount = new SatoshiColumn("Temp Amount", NONE);
+ private final Column colMinAmount = new SatoshiColumn("Temp Min Amount", NONE);
+ private final Column colPaymentMethod = new StringColumn(COL_HEADER_PAYMENT_METHOD, LEFT);
+ private final Column colCreateDate = new Iso8601DateTimeColumn(COL_HEADER_CREATION_DATE);
+
+ OfferTableBuilder(List> protos) {
+ super(OFFER_TBL, protos);
+ }
+
+ public Table build() {
+ List offers = protos.stream().map(p -> (OfferInfo) p).collect(Collectors.toList());
+ return isShowingFiatOffers.get()
+ ? buildFiatOfferTable(offers)
+ : buildCryptoCurrencyOfferTable(offers);
+ }
+
+ @SuppressWarnings("ConstantConditions")
+ public Table buildFiatOfferTable(List offers) {
+ @Nullable
+ Column colEnabled = enabledColumn.get(); // Not boolean: "YES", "NO", or "PENDING"
+ Column colFiatPrice = new StringColumn(format(COL_HEADER_DETAILED_PRICE, fiatTradeCurrency.get()), RIGHT);
+ Column colVolume = new StringColumn(format("Temp Volume (%s)", fiatTradeCurrency.get()), NONE);
+ Column colMinVolume = new StringColumn(format("Temp Min Volume (%s)", fiatTradeCurrency.get()), NONE);
+ @Nullable
+ Column colTriggerPrice = fiatTriggerPriceColumn.get();
+
+ // Populate columns with offer info.
+
+ offers.forEach(o -> {
+ if (colEnabled != null)
+ colEnabled.addRow(toEnabled.apply(o));
+
+ colDirection.addRow(o.getDirection());
+ colFiatPrice.addRow(o.getPrice());
+ colMinAmount.addRow(o.getMinAmount());
+ colAmount.addRow(o.getAmount());
+ colVolume.addRow(o.getVolume());
+ colMinVolume.addRow(o.getMinVolume());
+
+ if (colTriggerPrice != null)
+ colTriggerPrice.addRow(toBlankOrNonZeroValue.apply(o.getTriggerPrice()));
+
+ colPaymentMethod.addRow(o.getPaymentMethodShortName());
+ colCreateDate.addRow(o.getDate());
+ colOfferId.addRow(o.getId());
+ });
+
+ ZippedStringColumns amountRange = zippedAmountRangeColumns.get();
+ ZippedStringColumns volumeRange =
+ new ZippedStringColumns(format(COL_HEADER_VOLUME_RANGE, fiatTradeCurrency.get()),
+ RIGHT,
+ " - ",
+ colMinVolume.asStringColumn(),
+ colVolume.asStringColumn());
+
+ // Define and return the table instance with populated columns.
+
+ if (isShowingMyOffers.get()) {
+ return new Table(colEnabled.asStringColumn(),
+ colDirection,
+ colFiatPrice.justify(),
+ amountRange.asStringColumn(EXCLUDE_DUPLICATES),
+ volumeRange.asStringColumn(EXCLUDE_DUPLICATES),
+ colTriggerPrice.justify(),
+ colPaymentMethod,
+ colCreateDate.asStringColumn(),
+ colOfferId);
+ } else {
+ return new Table(colDirection,
+ colFiatPrice.justify(),
+ amountRange.asStringColumn(EXCLUDE_DUPLICATES),
+ volumeRange.asStringColumn(EXCLUDE_DUPLICATES),
+ colPaymentMethod,
+ colCreateDate.asStringColumn(),
+ colOfferId);
+ }
+ }
+
+ @SuppressWarnings("ConstantConditions")
+ public Table buildCryptoCurrencyOfferTable(List offers) {
+ @Nullable
+ Column colEnabled = enabledColumn.get(); // Not boolean: YES, NO, or PENDING
+ Column colBtcPrice = new StringColumn(format(COL_HEADER_DETAILED_PRICE_OF_ALTCOIN, altcoinTradeCurrency.get()), RIGHT);
+ Column colVolume = new StringColumn(format("Temp Volume (%s)", altcoinTradeCurrency.get()), NONE);
+ Column colMinVolume = new StringColumn(format("Temp Min Volume (%s)", altcoinTradeCurrency.get()), NONE);
+ @Nullable
+ Column colTriggerPrice = altcoinTriggerPriceColumn.get();
+
+ // Populate columns with offer info.
+
+ offers.forEach(o -> {
+ if (colEnabled != null)
+ colEnabled.addRow(toEnabled.apply(o));
+
+ colDirection.addRow(directionFormat.apply(o));
+ colBtcPrice.addRow(o.getPrice());
+ colAmount.addRow(o.getAmount());
+ colMinAmount.addRow(o.getMinAmount());
+ colVolume.addRow(o.getVolume());
+ colMinVolume.addRow(o.getMinVolume());
+
+ if (colTriggerPrice != null)
+ colTriggerPrice.addRow(toBlankOrNonZeroValue.apply(o.getTriggerPrice()));
+
+ colPaymentMethod.addRow(o.getPaymentMethodShortName());
+ colCreateDate.addRow(o.getDate());
+ colOfferId.addRow(o.getId());
+ });
+
+ ZippedStringColumns amountRange = zippedAmountRangeColumns.get();
+ ZippedStringColumns volumeRange =
+ new ZippedStringColumns(format(COL_HEADER_VOLUME_RANGE, altcoinTradeCurrency.get()),
+ RIGHT,
+ " - ",
+ colMinVolume.asStringColumn(),
+ colVolume.asStringColumn());
+
+ // Define and return the table instance with populated columns.
+
+ if (isShowingMyOffers.get()) {
+ if (isShowingBsqOffers.get()) {
+ return new Table(colEnabled.asStringColumn(),
+ colDirection,
+ colBtcPrice.justify(),
+ amountRange.asStringColumn(EXCLUDE_DUPLICATES),
+ volumeRange.asStringColumn(EXCLUDE_DUPLICATES),
+ colPaymentMethod,
+ colCreateDate.asStringColumn(),
+ colOfferId);
+ } else {
+ return new Table(colEnabled.asStringColumn(),
+ colDirection,
+ colBtcPrice.justify(),
+ amountRange.asStringColumn(EXCLUDE_DUPLICATES),
+ volumeRange.asStringColumn(EXCLUDE_DUPLICATES),
+ colTriggerPrice.justify(),
+ colPaymentMethod,
+ colCreateDate.asStringColumn(),
+ colOfferId);
+ }
+ } else {
+ return new Table(colDirection,
+ colBtcPrice.justify(),
+ amountRange.asStringColumn(EXCLUDE_DUPLICATES),
+ volumeRange.asStringColumn(EXCLUDE_DUPLICATES),
+ colPaymentMethod,
+ colCreateDate.asStringColumn(),
+ colOfferId);
+ }
+ }
+
+ private final Function toBlankOrNonZeroValue = (s) -> s.trim().equals("0") ? "" : s;
+ private final Supplier firstOfferInList = () -> (OfferInfo) protos.get(0);
+ private final Supplier isShowingMyOffers = () -> firstOfferInList.get().getIsMyOffer();
+ private final Supplier isShowingFiatOffers = () -> isFiatOffer.test(firstOfferInList.get());
+ private final Supplier fiatTradeCurrency = () -> firstOfferInList.get().getCounterCurrencyCode();
+ private final Supplier altcoinTradeCurrency = () -> firstOfferInList.get().getBaseCurrencyCode();
+ private final Supplier isShowingBsqOffers = () ->
+ !isFiatOffer.test(firstOfferInList.get()) && altcoinTradeCurrency.get().equals("BSQ");
+
+ @Nullable // Not a boolean column: YES, NO, or PENDING.
+ private final Supplier enabledColumn = () ->
+ isShowingMyOffers.get()
+ ? new StringColumn(COL_HEADER_ENABLED, LEFT)
+ : null;
+ @Nullable
+ private final Supplier fiatTriggerPriceColumn = () ->
+ isShowingMyOffers.get()
+ ? new StringColumn(format(COL_HEADER_TRIGGER_PRICE, fiatTradeCurrency.get()), RIGHT)
+ : null;
+ @Nullable
+ private final Supplier altcoinTriggerPriceColumn = () ->
+ isShowingMyOffers.get() && !isShowingBsqOffers.get()
+ ? new StringColumn(format(COL_HEADER_TRIGGER_PRICE, altcoinTradeCurrency.get()), RIGHT)
+ : null;
+
+ private final Function toEnabled = (o) -> {
+ if (o.getIsMyOffer() && o.getIsMyPendingOffer())
+ return "PENDING";
+ else
+ return o.getIsActivated() ? "YES" : "NO";
+ };
+
+ private final Function toMirroredDirection = (d) ->
+ d.equalsIgnoreCase(BUY.name()) ? SELL.name() : BUY.name();
+
+ private final Function directionFormat = (o) -> {
+ if (isFiatOffer.test(o)) {
+ return o.getBaseCurrencyCode();
+ } else {
+ // Return "Sell BSQ (Buy BTC)", or "Buy BSQ (Sell BTC)".
+ String direction = o.getDirection();
+ String mirroredDirection = toMirroredDirection.apply(direction);
+ Function mixedCase = (word) -> word.charAt(0) + word.substring(1).toLowerCase();
+ return format("%s %s (%s %s)",
+ mixedCase.apply(mirroredDirection),
+ o.getBaseCurrencyCode(),
+ mixedCase.apply(direction),
+ o.getCounterCurrencyCode());
+ }
+ };
+
+ private final Supplier zippedAmountRangeColumns = () -> {
+ if (colMinAmount.isEmpty() || colAmount.isEmpty())
+ throw new IllegalStateException("amount columns must have data");
+
+ return new ZippedStringColumns(COL_HEADER_AMOUNT_RANGE,
+ RIGHT,
+ " - ",
+ colMinAmount.asStringColumn(),
+ colAmount.asStringColumn());
+ };
+}
diff --git a/java-examples/src/main/java/bisq/bots/table/builder/OpenTradeTableBuilder.java b/java-examples/src/main/java/bisq/bots/table/builder/OpenTradeTableBuilder.java
new file mode 100644
index 0000000..f962102
--- /dev/null
+++ b/java-examples/src/main/java/bisq/bots/table/builder/OpenTradeTableBuilder.java
@@ -0,0 +1,62 @@
+/*
+ * 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.table.builder;
+
+import bisq.bots.table.Table;
+
+import java.util.List;
+
+import static bisq.bots.table.builder.TableType.OPEN_TRADES_TBL;
+
+/**
+ * Builds a {@code bisq.bots.table.Table} from a list of {@code bisq.proto.grpc.TradeInfo} objects.
+ */
+@SuppressWarnings("ConstantConditions")
+class OpenTradeTableBuilder extends AbstractTradeListBuilder {
+
+ OpenTradeTableBuilder(List> protos) {
+ super(OPEN_TRADES_TBL, protos);
+ }
+
+ public Table build() {
+ populateColumns();
+ return new Table(colTradeId,
+ colCreateDate.asStringColumn(),
+ colMarket,
+ colPrice.justify(),
+ colAmount.asStringColumn(),
+ colMixedAmount.justify(),
+ colCurrency,
+ colPaymentMethod,
+ colRole);
+ }
+
+ private void populateColumns() {
+ trades.forEach(t -> {
+ colTradeId.addRow(t.getTradeId());
+ colCreateDate.addRow(t.getDate());
+ colMarket.addRow(toMarket.apply(t));
+ colPrice.addRow(t.getTradePrice());
+ colAmount.addRow(t.getTradeAmountAsLong());
+ colMixedAmount.addRow(t.getTradeVolume());
+ colCurrency.addRow(toPaymentCurrencyCode.apply(t));
+ colPaymentMethod.addRow(t.getOffer().getPaymentMethodShortName());
+ colRole.addRow(t.getRole());
+ });
+ }
+}
diff --git a/java-examples/src/main/java/bisq/bots/table/builder/PaymentAccountTableBuilder.java b/java-examples/src/main/java/bisq/bots/table/builder/PaymentAccountTableBuilder.java
new file mode 100644
index 0000000..eff8428
--- /dev/null
+++ b/java-examples/src/main/java/bisq/bots/table/builder/PaymentAccountTableBuilder.java
@@ -0,0 +1,68 @@
+/*
+ * 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.table.builder;
+
+import bisq.bots.table.Table;
+import bisq.bots.table.column.Column;
+import bisq.bots.table.column.StringColumn;
+import protobuf.PaymentAccount;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static bisq.bots.table.builder.TableBuilderConstants.*;
+import static bisq.bots.table.builder.TableType.PAYMENT_ACCOUNT_TBL;
+
+/**
+ * Builds a {@code bisq.bots.table.Table} from a List of
+ * {@code protobuf.PaymentAccount} objects.
+ */
+class PaymentAccountTableBuilder extends AbstractTableBuilder {
+
+ // Default columns not dynamically generated with payment account info.
+ private final Column colName;
+ private final Column colCurrency;
+ private final Column colPaymentMethod;
+ private final Column colId;
+
+ PaymentAccountTableBuilder(List> protos) {
+ super(PAYMENT_ACCOUNT_TBL, protos);
+ this.colName = new StringColumn(COL_HEADER_NAME);
+ this.colCurrency = new StringColumn(COL_HEADER_CURRENCY);
+ this.colPaymentMethod = new StringColumn(COL_HEADER_PAYMENT_METHOD);
+ this.colId = new StringColumn(COL_HEADER_UUID);
+ }
+
+ public Table build() {
+ List paymentAccounts = protos.stream()
+ .map(a -> (PaymentAccount) a)
+ .collect(Collectors.toList());
+
+ // Populate columns with payment account info.
+ //noinspection SimplifyStreamApiCallChains
+ paymentAccounts.stream().forEachOrdered(a -> {
+ colName.addRow(a.getAccountName());
+ colCurrency.addRow(a.getSelectedTradeCurrency().getCode());
+ colPaymentMethod.addRow(a.getPaymentMethod().getId());
+ colId.addRow(a.getId());
+ });
+
+ // Define and return the table instance with populated columns.
+ return new Table(colName, colCurrency, colPaymentMethod, colId);
+ }
+}
diff --git a/java-examples/src/main/java/bisq/bots/table/builder/TableBuilder.java b/java-examples/src/main/java/bisq/bots/table/builder/TableBuilder.java
new file mode 100644
index 0000000..366f5cb
--- /dev/null
+++ b/java-examples/src/main/java/bisq/bots/table/builder/TableBuilder.java
@@ -0,0 +1,68 @@
+/*
+ * 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.table.builder;
+
+import bisq.bots.table.Table;
+
+import java.util.List;
+
+import static java.util.Collections.singletonList;
+
+/**
+ * Table builder factory. It is not conventionally named TableBuilderFactory because
+ * it has no static factory methods. The number of static fields and methods in the
+ * {@code bisq.bots.table} are kept to a minimum in an effort o reduce class load time
+ * in the session-less CLI.
+ */
+public class TableBuilder extends AbstractTableBuilder {
+
+ public TableBuilder(TableType tableType, Object proto) {
+ this(tableType, singletonList(proto));
+ }
+
+ public TableBuilder(TableType tableType, List> protos) {
+ super(tableType, protos);
+ }
+
+ public Table build() {
+ switch (tableType) {
+ case ADDRESS_BALANCE_TBL:
+ return new AddressBalanceTableBuilder(protos).build();
+ case BSQ_BALANCE_TBL:
+ return new BsqBalanceTableBuilder(protos).build();
+ case BTC_BALANCE_TBL:
+ return new BtcBalanceTableBuilder(protos).build();
+ case CLOSED_TRADES_TBL:
+ return new ClosedTradeTableBuilder(protos).build();
+ case FAILED_TRADES_TBL:
+ return new FailedTradeTableBuilder(protos).build();
+ case OFFER_TBL:
+ return new OfferTableBuilder(protos).build();
+ case OPEN_TRADES_TBL:
+ return new OpenTradeTableBuilder(protos).build();
+ case PAYMENT_ACCOUNT_TBL:
+ return new PaymentAccountTableBuilder(protos).build();
+ case TRADE_DETAIL_TBL:
+ return new TradeDetailTableBuilder(protos).build();
+ case TRANSACTION_TBL:
+ return new TransactionTableBuilder(protos).build();
+ default:
+ throw new IllegalArgumentException("invalid cli table type " + tableType.name());
+ }
+ }
+}
diff --git a/java-examples/src/main/java/bisq/bots/table/builder/TableBuilderConstants.java b/java-examples/src/main/java/bisq/bots/table/builder/TableBuilderConstants.java
new file mode 100644
index 0000000..118a281
--- /dev/null
+++ b/java-examples/src/main/java/bisq/bots/table/builder/TableBuilderConstants.java
@@ -0,0 +1,82 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see .
+ */
+
+package bisq.bots.table.builder;
+
+/**
+ * Table column name constants.
+ */
+class TableBuilderConstants {
+ static final String COL_HEADER_ADDRESS = "%-3s Address";
+ static final String COL_HEADER_AMOUNT = "Amount";
+ static final String COL_HEADER_AMOUNT_IN_BTC = "Amount in BTC";
+ static final String COL_HEADER_AMOUNT_RANGE = "BTC(min - max)";
+ static final String COL_HEADER_AVAILABLE_BALANCE = "Available Balance";
+ static final String COL_HEADER_AVAILABLE_CONFIRMED_BALANCE = "Available Confirmed Balance";
+ static final String COL_HEADER_UNCONFIRMED_CHANGE_BALANCE = "Unconfirmed Change Balance";
+ static final String COL_HEADER_RESERVED_BALANCE = "Reserved Balance";
+ static final String COL_HEADER_TOTAL_AVAILABLE_BALANCE = "Total Available Balance";
+ static final String COL_HEADER_LOCKED_BALANCE = "Locked Balance";
+ static final String COL_HEADER_LOCKED_FOR_VOTING_BALANCE = "Locked For Voting Balance";
+ static final String COL_HEADER_LOCKUP_BONDS_BALANCE = "Lockup Bonds Balance";
+ static final String COL_HEADER_UNLOCKING_BONDS_BALANCE = "Unlocking Bonds Balance";
+ static final String COL_HEADER_UNVERIFIED_BALANCE = "Unverified Balance";
+ static final String COL_HEADER_BSQ_SWAP_TRADE_ROLE = "My BSQ Swap Role";
+ static final String COL_HEADER_BUYER_DEPOSIT = "Buyer Deposit (BTC)";
+ static final String COL_HEADER_SELLER_DEPOSIT = "Seller Deposit (BTC)";
+ static final String COL_HEADER_CONFIRMATIONS = "Confirmations";
+ static final String COL_HEADER_DEVIATION = "Deviation";
+ static final String COL_HEADER_IS_USED_ADDRESS = "Is Used";
+ static final String COL_HEADER_CREATION_DATE = "Creation Date (UTC)";
+ static final String COL_HEADER_CURRENCY = "Currency";
+ static final String COL_HEADER_DATE_TIME = "Date/Time (UTC)";
+ static final String COL_HEADER_DETAILED_AMOUNT = "Amount(%-3s)";
+ static final String COL_HEADER_DETAILED_PRICE = "Price in %-3s for 1 BTC";
+ static final String COL_HEADER_DETAILED_PRICE_OF_ALTCOIN = "Price in BTC for 1 %-3s";
+ static final String COL_HEADER_DIRECTION = "Buy/Sell";
+ static final String COL_HEADER_ENABLED = "Enabled";
+ static final String COL_HEADER_MARKET = "Market";
+ static final String COL_HEADER_NAME = "Name";
+ static final String COL_HEADER_OFFER_TYPE = "Offer Type";
+ static final String COL_HEADER_PAYMENT_METHOD = "Payment Method";
+ static final String COL_HEADER_PRICE = "Price";
+ static final String COL_HEADER_STATUS = "Status";
+ static final String COL_HEADER_TRADE_ALTCOIN_BUYER_ADDRESS = "%-3s Buyer Address";
+ static final String COL_HEADER_TRADE_BUYER_COST = "Buyer Cost(%-3s)";
+ static final String COL_HEADER_TRADE_DEPOSIT_CONFIRMED = "Deposit Confirmed";
+ static final String COL_HEADER_TRADE_DEPOSIT_PUBLISHED = "Deposit Published";
+ static final String COL_HEADER_TRADE_PAYMENT_SENT = "%-3s Sent";
+ static final String COL_HEADER_TRADE_PAYMENT_RECEIVED = "%-3s Received";
+ static final String COL_HEADER_TRADE_PAYOUT_PUBLISHED = "Payout Published";
+ static final String COL_HEADER_TRADE_WITHDRAWN = "Withdrawn";
+ static final String COL_HEADER_TRADE_ID = "Trade ID";
+ static final String COL_HEADER_TRADE_ROLE = "My Role";
+ static final String COL_HEADER_TRADE_SHORT_ID = "ID";
+ static final String COL_HEADER_TRADE_MAKER_FEE = "Maker Fee(%-3s)";
+ static final String COL_HEADER_TRADE_TAKER_FEE = "Taker Fee(%-3s)";
+ static final String COL_HEADER_TRADE_FEE = "Trade Fee";
+ static final String COL_HEADER_TRIGGER_PRICE = "Trigger Price(%-3s)";
+ static final String COL_HEADER_TX_ID = "Tx ID";
+ static final String COL_HEADER_TX_INPUT_SUM = "Tx Inputs (BTC)";
+ static final String COL_HEADER_TX_OUTPUT_SUM = "Tx Outputs (BTC)";
+ static final String COL_HEADER_TX_FEE = "Tx Fee (BTC)";
+ static final String COL_HEADER_TX_SIZE = "Tx Size (Bytes)";
+ static final String COL_HEADER_TX_IS_CONFIRMED = "Is Confirmed";
+ static final String COL_HEADER_TX_MEMO = "Memo";
+ static final String COL_HEADER_VOLUME_RANGE = "%-3s(min - max)";
+ static final String COL_HEADER_UUID = "ID";
+}
diff --git a/java-examples/src/main/java/bisq/bots/table/builder/TableType.java b/java-examples/src/main/java/bisq/bots/table/builder/TableType.java
new file mode 100644
index 0000000..9c1b3b3
--- /dev/null
+++ b/java-examples/src/main/java/bisq/bots/table/builder/TableType.java
@@ -0,0 +1,35 @@
+/*
+ * 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.table.builder;
+
+/**
+ * Used as param in TableBuilder constructor instead of inspecting
+ * protos to find out what kind of CLI output table should be built.
+ */
+public enum TableType {
+ ADDRESS_BALANCE_TBL,
+ BSQ_BALANCE_TBL,
+ BTC_BALANCE_TBL,
+ CLOSED_TRADES_TBL,
+ FAILED_TRADES_TBL,
+ OFFER_TBL,
+ OPEN_TRADES_TBL,
+ PAYMENT_ACCOUNT_TBL,
+ TRADE_DETAIL_TBL,
+ TRANSACTION_TBL
+}
diff --git a/java-examples/src/main/java/bisq/bots/table/builder/TradeDetailTableBuilder.java b/java-examples/src/main/java/bisq/bots/table/builder/TradeDetailTableBuilder.java
new file mode 100644
index 0000000..6fdbdaa
--- /dev/null
+++ b/java-examples/src/main/java/bisq/bots/table/builder/TradeDetailTableBuilder.java
@@ -0,0 +1,159 @@
+/*
+ * 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.table.builder;
+
+import bisq.bots.table.Table;
+import bisq.bots.table.column.Column;
+import bisq.proto.grpc.TradeInfo;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Predicate;
+
+import static bisq.bots.table.builder.TableType.TRADE_DETAIL_TBL;
+import static java.lang.String.format;
+import static protobuf.BsqSwapTrade.State.COMPLETED;
+import static protobuf.BsqSwapTrade.State.PREPARATION;
+
+/**
+ * Builds a {@code bisq.bots.table.Table} from a {@code bisq.proto.grpc.TradeInfo} object.
+ */
+@SuppressWarnings("ConstantConditions")
+class TradeDetailTableBuilder extends AbstractTradeListBuilder {
+
+ private final Predicate isPendingBsqSwap = (t) -> t.getState().equals(PREPARATION.name());
+ private final Predicate isCompletedBsqSwap = (t) -> t.getState().equals(COMPLETED.name());
+
+ TradeDetailTableBuilder(List> protos) {
+ super(TRADE_DETAIL_TBL, protos);
+ }
+
+ /**
+ * Build a single row trade detail table.
+ *
+ * @return Table containing one row
+ */
+ public Table build() {
+ // A trade detail table only has one row.
+ var trade = trades.get(0);
+ populateColumns(trade);
+ List> columns = defineColumnList(trade);
+ return new Table(columns.toArray(new Column>[0]));
+ }
+
+ private void populateColumns(TradeInfo trade) {
+ if (isBsqSwapTrade.test(trade)) {
+ var isPending = isPendingBsqSwap.test(trade);
+ var isCompleted = isCompletedBsqSwap.test(trade);
+ if (isPending == isCompleted)
+ throw new IllegalStateException(
+ format("programmer error: trade must be either pending or completed, is pending=%s and completed=%s",
+ isPending,
+ isCompleted));
+ populateBsqSwapTradeColumns(trade);
+ } else {
+ populateBisqV1TradeColumns(trade);
+ }
+ }
+
+ private void populateBisqV1TradeColumns(TradeInfo trade) {
+ colTradeId.addRow(trade.getShortId());
+ colRole.addRow(trade.getRole());
+ colPrice.addRow(trade.getTradePrice());
+ colAmount.addRow(toTradeAmount.apply(trade));
+ colMinerTxFee.addRow(toMyMinerTxFee.apply(trade));
+ colBisqTradeFee.addRow(toMyMakerOrTakerFee.apply(trade));
+ colIsDepositPublished.addRow(trade.getIsDepositPublished());
+ colIsDepositConfirmed.addRow(trade.getIsDepositConfirmed());
+ colTradeCost.addRow(toTradeVolumeAsString.apply(trade));
+ colIsPaymentStartedMessageSent.addRow(trade.getIsPaymentStartedMessageSent());
+ colIsPaymentReceivedMessageSent.addRow(trade.getIsPaymentReceivedMessageSent());
+ colIsPayoutPublished.addRow(trade.getIsPayoutPublished());
+ colIsCompleted.addRow(trade.getIsCompleted());
+ if (colAltcoinReceiveAddressColumn != null)
+ colAltcoinReceiveAddressColumn.addRow(toAltcoinReceiveAddress.apply(trade));
+ }
+
+ private void populateBsqSwapTradeColumns(TradeInfo trade) {
+ colTradeId.addRow(trade.getShortId());
+ colRole.addRow(trade.getRole());
+ colPrice.addRow(trade.getTradePrice());
+ colAmount.addRow(toTradeAmount.apply(trade));
+ colMinerTxFee.addRow(toMyMinerTxFee.apply(trade));
+ colBisqTradeFee.addRow(toMyMakerOrTakerFee.apply(trade));
+
+ colTradeCost.addRow(toTradeVolumeAsString.apply(trade));
+
+ var isCompleted = isCompletedBsqSwap.test(trade);
+ status.addRow(isCompleted ? "COMPLETED" : "PENDING");
+ if (isCompleted) {
+ colTxId.addRow(trade.getBsqSwapTradeInfo().getTxId());
+ colNumConfirmations.addRow(trade.getBsqSwapTradeInfo().getNumConfirmations());
+ }
+ }
+
+ private List> defineColumnList(TradeInfo trade) {
+ return isBsqSwapTrade.test(trade)
+ ? getBsqSwapTradeColumnList(isCompletedBsqSwap.test(trade))
+ : getBisqV1TradeColumnList();
+ }
+
+ private List> getBisqV1TradeColumnList() {
+ List> columns = new ArrayList<>() {{
+ add(colTradeId);
+ add(colRole);
+ add(colPrice.justify());
+ add(colAmount.asStringColumn());
+ add(colMinerTxFee.asStringColumn());
+ add(colBisqTradeFee.asStringColumn());
+ add(colIsDepositPublished.asStringColumn());
+ add(colIsDepositConfirmed.asStringColumn());
+ add(colTradeCost.justify());
+ add(colIsPaymentStartedMessageSent.asStringColumn());
+ add(colIsPaymentReceivedMessageSent.asStringColumn());
+ add(colIsPayoutPublished.asStringColumn());
+ add(colIsCompleted.asStringColumn());
+ }};
+
+ if (colAltcoinReceiveAddressColumn != null)
+ columns.add(colAltcoinReceiveAddressColumn);
+
+ return columns;
+ }
+
+ private List> getBsqSwapTradeColumnList(boolean isCompleted) {
+ List> columns = new ArrayList<>() {{
+ add(colTradeId);
+ add(colRole);
+ add(colPrice.justify());
+ add(colAmount.asStringColumn());
+ add(colMinerTxFee.asStringColumn());
+ add(colBisqTradeFee.asStringColumn());
+ add(colTradeCost.justify());
+ add(status);
+ }};
+
+ if (isCompleted)
+ columns.add(colTxId);
+
+ if (!colNumConfirmations.isEmpty())
+ columns.add(colNumConfirmations.asStringColumn());
+
+ return columns;
+ }
+}
diff --git a/java-examples/src/main/java/bisq/bots/table/builder/TradeTableColumnSupplier.java b/java-examples/src/main/java/bisq/bots/table/builder/TradeTableColumnSupplier.java
new file mode 100644
index 0000000..ad2eeed
--- /dev/null
+++ b/java-examples/src/main/java/bisq/bots/table/builder/TradeTableColumnSupplier.java
@@ -0,0 +1,285 @@
+/*
+ * 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.table.builder;
+
+import bisq.bots.table.column.*;
+import bisq.proto.grpc.ContractInfo;
+import bisq.proto.grpc.OfferInfo;
+import bisq.proto.grpc.TradeInfo;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+
+import javax.annotation.Nullable;
+import java.util.List;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+
+import static bisq.bots.table.builder.TableBuilderConstants.*;
+import static bisq.bots.table.builder.TableType.*;
+import static bisq.bots.table.column.AltcoinVolumeColumn.DISPLAY_MODE.ALTCOIN_VOLUME;
+import static bisq.bots.table.column.AltcoinVolumeColumn.DISPLAY_MODE.BSQ_VOLUME;
+import static bisq.bots.table.column.Column.JUSTIFICATION.LEFT;
+import static bisq.bots.table.column.Column.JUSTIFICATION.RIGHT;
+import static java.lang.String.format;
+
+/**
+ * Convenience for supplying column definitions to
+ * open/closed/failed/detail trade table builders.
+ */
+@Slf4j
+class TradeTableColumnSupplier {
+
+ @Getter
+ private final TableType tableType;
+ @Getter
+ private final List trades;
+
+ public TradeTableColumnSupplier(TableType tableType, List trades) {
+ this.tableType = tableType;
+ this.trades = trades;
+ }
+
+ private final Supplier isTradeDetailTblBuilder = () -> getTableType().equals(TRADE_DETAIL_TBL);
+ private final Supplier isOpenTradeTblBuilder = () -> getTableType().equals(OPEN_TRADES_TBL);
+ private final Supplier isClosedTradeTblBuilder = () -> getTableType().equals(CLOSED_TRADES_TBL);
+ private final Supplier isFailedTradeTblBuilder = () -> getTableType().equals(FAILED_TRADES_TBL);
+ private final Supplier firstRow = () -> getTrades().get(0);
+ private final Predicate isFiatOffer = (o) -> o.getBaseCurrencyCode().equals("BTC");
+ private final Predicate isFiatTrade = (t) -> isFiatOffer.test(t.getOffer());
+ private final Predicate isBsqSwapTrade = (t) -> t.getOffer().getIsBsqSwapOffer();
+ private final Predicate isTaker = (t) -> t.getRole().toLowerCase().contains("taker");
+ private final Supplier isSwapTradeDetail = () ->
+ isTradeDetailTblBuilder.get() && isBsqSwapTrade.test(firstRow.get());
+
+ final Supplier tradeIdColumn = () -> isTradeDetailTblBuilder.get()
+ ? new StringColumn(COL_HEADER_TRADE_SHORT_ID)
+ : new StringColumn(COL_HEADER_TRADE_ID);
+
+ final Supplier createDateColumn = () -> isTradeDetailTblBuilder.get()
+ ? null
+ : new Iso8601DateTimeColumn(COL_HEADER_DATE_TIME);
+
+ final Supplier marketColumn = () -> isTradeDetailTblBuilder.get()
+ ? null
+ : new StringColumn(COL_HEADER_MARKET);
+
+ private final Function> toDetailedPriceColumn = (t) -> {
+ String colHeader = isFiatTrade.test(t)
+ ? format(COL_HEADER_DETAILED_PRICE, t.getOffer().getCounterCurrencyCode())
+ : format(COL_HEADER_DETAILED_PRICE_OF_ALTCOIN, t.getOffer().getBaseCurrencyCode());
+ return new StringColumn(colHeader, RIGHT);
+ };
+
+ final Supplier> priceColumn = () -> isTradeDetailTblBuilder.get()
+ ? toDetailedPriceColumn.apply(firstRow.get())
+ : new StringColumn(COL_HEADER_PRICE, RIGHT);
+
+ final Supplier> priceDeviationColumn = () -> isTradeDetailTblBuilder.get()
+ ? null
+ : new StringColumn(COL_HEADER_DEVIATION, RIGHT);
+
+ final Supplier currencyColumn = () -> isTradeDetailTblBuilder.get()
+ ? null
+ : new StringColumn(COL_HEADER_CURRENCY);
+
+ private final Function> toDetailedAmountColumn = (t) -> {
+ String headerCurrencyCode = t.getOffer().getBaseCurrencyCode();
+ String colHeader = format(COL_HEADER_DETAILED_AMOUNT, headerCurrencyCode);
+ AltcoinVolumeColumn.DISPLAY_MODE displayMode = headerCurrencyCode.equals("BSQ") ? BSQ_VOLUME : ALTCOIN_VOLUME;
+ return isFiatTrade.test(t)
+ ? new SatoshiColumn(colHeader)
+ : new AltcoinVolumeColumn(colHeader, displayMode);
+ };
+
+ // Can be fiat, btc or altcoin amount represented as longs. Placing the decimal
+ // in the displayed string representation is done in the Column implementation.
+ final Supplier> amountColumn = () -> isTradeDetailTblBuilder.get()
+ ? toDetailedAmountColumn.apply(firstRow.get())
+ : new BtcColumn(COL_HEADER_AMOUNT_IN_BTC);
+
+ final Supplier mixedAmountColumn = () -> isTradeDetailTblBuilder.get()
+ ? null
+ : new StringColumn(COL_HEADER_AMOUNT, RIGHT);
+
+ final Supplier> minerTxFeeColumn = () -> isTradeDetailTblBuilder.get() || isClosedTradeTblBuilder.get()
+ ? new SatoshiColumn(COL_HEADER_TX_FEE)
+ : null;
+
+ final Supplier mixedTradeFeeColumn = () -> isTradeDetailTblBuilder.get()
+ ? null
+ : new MixedTradeFeeColumn(COL_HEADER_TRADE_FEE);
+
+ final Supplier paymentMethodColumn = () -> isTradeDetailTblBuilder.get() || isClosedTradeTblBuilder.get()
+ ? null
+ : new StringColumn(COL_HEADER_PAYMENT_METHOD, LEFT);
+
+ final Supplier roleColumn = () -> {
+ if (isSwapTradeDetail.get())
+ return new StringColumn(COL_HEADER_BSQ_SWAP_TRADE_ROLE);
+ else
+ return isTradeDetailTblBuilder.get() || isOpenTradeTblBuilder.get() || isFailedTradeTblBuilder.get()
+ ? new StringColumn(COL_HEADER_TRADE_ROLE)
+ : null;
+ };
+
+ final Function> toSecurityDepositColumn = (name) -> isClosedTradeTblBuilder.get()
+ ? new SatoshiColumn(name)
+ : null;
+
+ final Supplier offerTypeColumn = () -> isTradeDetailTblBuilder.get()
+ ? null
+ : new StringColumn(COL_HEADER_OFFER_TYPE);
+
+ final Supplier statusDescriptionColumn = () -> isTradeDetailTblBuilder.get()
+ ? null
+ : new StringColumn(COL_HEADER_STATUS);
+
+ private final Function> toBooleanColumn = BooleanColumn::new;
+
+ final Supplier> depositPublishedColumn = () -> {
+ if (isSwapTradeDetail.get())
+ return null;
+ else
+ return isTradeDetailTblBuilder.get()
+ ? toBooleanColumn.apply(COL_HEADER_TRADE_DEPOSIT_PUBLISHED)
+ : null;
+ };
+
+ final Supplier> depositConfirmedColumn = () -> {
+ if (isSwapTradeDetail.get())
+ return null;
+ else
+ return isTradeDetailTblBuilder.get()
+ ? toBooleanColumn.apply(COL_HEADER_TRADE_DEPOSIT_CONFIRMED)
+ : null;
+
+ };
+
+ final Supplier> payoutPublishedColumn = () -> {
+ if (isSwapTradeDetail.get())
+ return null;
+ else
+ return isTradeDetailTblBuilder.get()
+ ? toBooleanColumn.apply(COL_HEADER_TRADE_PAYOUT_PUBLISHED)
+ : null;
+ };
+
+ final Supplier> fundsWithdrawnColumn = () -> {
+ if (isSwapTradeDetail.get())
+ return null;
+ else
+ return isTradeDetailTblBuilder.get()
+ ? toBooleanColumn.apply(COL_HEADER_TRADE_WITHDRAWN)
+ : null;
+ };
+
+ final Supplier> bisqTradeDetailFeeColumn = () -> {
+ if (isTradeDetailTblBuilder.get()) {
+ TradeInfo t = firstRow.get();
+ String headerCurrencyCode = isTaker.test(t)
+ ? t.getIsCurrencyForTakerFeeBtc() ? "BTC" : "BSQ"
+ : t.getOffer().getIsCurrencyForMakerFeeBtc() ? "BTC" : "BSQ";
+ String colHeader = isTaker.test(t)
+ ? format(COL_HEADER_TRADE_TAKER_FEE, headerCurrencyCode)
+ : format(COL_HEADER_TRADE_MAKER_FEE, headerCurrencyCode);
+ boolean isBsqSatoshis = headerCurrencyCode.equals("BSQ");
+ return new SatoshiColumn(colHeader, isBsqSatoshis);
+ } else {
+ return null;
+ }
+ };
+
+ final Function toPaymentCurrencyCode = (t) ->
+ isFiatTrade.test(t)
+ ? t.getOffer().getCounterCurrencyCode()
+ : t.getOffer().getBaseCurrencyCode();
+
+ final Supplier> paymentStartedMessageSentColumn = () -> {
+ if (isTradeDetailTblBuilder.get()) {
+ String headerCurrencyCode = toPaymentCurrencyCode.apply(firstRow.get());
+ String colHeader = format(COL_HEADER_TRADE_PAYMENT_SENT, headerCurrencyCode);
+ return new BooleanColumn(colHeader);
+ } else {
+ return null;
+ }
+ };
+
+ final Supplier> paymentReceivedMessageSentColumn = () -> {
+ if (isTradeDetailTblBuilder.get()) {
+ String headerCurrencyCode = toPaymentCurrencyCode.apply(firstRow.get());
+ String colHeader = format(COL_HEADER_TRADE_PAYMENT_RECEIVED, headerCurrencyCode);
+ return new BooleanColumn(colHeader);
+ } else {
+ return null;
+ }
+ };
+
+ final Supplier> tradeCostColumn = () -> {
+ if (isTradeDetailTblBuilder.get()) {
+ TradeInfo t = firstRow.get();
+ String headerCurrencyCode = t.getOffer().getCounterCurrencyCode();
+ String colHeader = format(COL_HEADER_TRADE_BUYER_COST, headerCurrencyCode);
+ return new StringColumn(colHeader, RIGHT);
+ } else {
+ return null;
+ }
+ };
+
+ final Supplier> bsqSwapTxIdColumn = () -> isSwapTradeDetail.get()
+ ? new StringColumn(COL_HEADER_TX_ID)
+ : null;
+
+ final Supplier