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> bsqSwapStatusColumn = () -> isSwapTradeDetail.get() + ? new StringColumn(COL_HEADER_STATUS) + : null; + + final Supplier> numConfirmationsColumn = () -> isSwapTradeDetail.get() + ? new LongColumn(COL_HEADER_CONFIRMATIONS) + : null; + + 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; + } + } + }; + + @Nullable + final Supplier> altcoinReceiveAddressColumn = () -> { + if (isTradeDetailTblBuilder.get()) { + TradeInfo t = firstRow.get(); + if (showAltCoinBuyerAddress.test(t)) { + String headerCurrencyCode = toPaymentCurrencyCode.apply(t); + String colHeader = format(COL_HEADER_TRADE_ALTCOIN_BUYER_ADDRESS, headerCurrencyCode); + return new StringColumn(colHeader); + } else { + return null; + } + } else { + return null; + } + }; +} diff --git a/java-examples/src/main/java/bisq/bots/table/builder/TransactionTableBuilder.java b/java-examples/src/main/java/bisq/bots/table/builder/TransactionTableBuilder.java new file mode 100644 index 0000000..e686960 --- /dev/null +++ b/java-examples/src/main/java/bisq/bots/table/builder/TransactionTableBuilder.java @@ -0,0 +1,95 @@ +/* + * 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.TxInfo; + +import javax.annotation.Nullable; +import java.util.List; + +import static bisq.bots.table.builder.TableBuilderConstants.*; +import static bisq.bots.table.builder.TableType.TRANSACTION_TBL; + +/** + * Builds a {@code bisq.bots.table.Table} from a {@code bisq.proto.grpc.TxInfo} object. + */ +class TransactionTableBuilder extends AbstractTableBuilder { + + // Default columns not dynamically generated with tx info. + private final Column colTxId; + private final Column colIsConfirmed; + private final Column colInputSum; + private final Column colOutputSum; + private final Column colTxFee; + private final Column colTxSize; + + TransactionTableBuilder(List protos) { + super(TRANSACTION_TBL, protos); + this.colTxId = new StringColumn(COL_HEADER_TX_ID); + this.colIsConfirmed = new BooleanColumn(COL_HEADER_TX_IS_CONFIRMED); + this.colInputSum = new SatoshiColumn(COL_HEADER_TX_INPUT_SUM); + this.colOutputSum = new SatoshiColumn(COL_HEADER_TX_OUTPUT_SUM); + this.colTxFee = new SatoshiColumn(COL_HEADER_TX_FEE); + this.colTxSize = new LongColumn(COL_HEADER_TX_SIZE); + } + + public Table build() { + // TODO Add 'gettransactions' api method & show multiple tx in the console. + // For now, a tx tbl is only one row. + TxInfo tx = (TxInfo) protos.get(0); + + // Declare the columns derived from tx info. + + @Nullable + Column colMemo = tx.getMemo().isEmpty() + ? null + : new StringColumn(COL_HEADER_TX_MEMO); + + // Populate columns with tx info. + + colTxId.addRow(tx.getTxId()); + colIsConfirmed.addRow(!tx.getIsPending()); + colInputSum.addRow(tx.getInputSum()); + colOutputSum.addRow(tx.getOutputSum()); + colTxFee.addRow(tx.getFee()); + colTxSize.addRow((long) tx.getSize()); + if (colMemo != null) + colMemo.addRow(tx.getMemo()); + + // Define and return the table instance with populated columns. + + if (colMemo != null) { + return new Table(colTxId, + colIsConfirmed.asStringColumn(), + colInputSum.asStringColumn(), + colOutputSum.asStringColumn(), + colTxFee.asStringColumn(), + colTxSize.asStringColumn(), + colMemo); + } else { + return new Table(colTxId, + colIsConfirmed.asStringColumn(), + colInputSum.asStringColumn(), + colOutputSum.asStringColumn(), + colTxFee.asStringColumn(), + colTxSize.asStringColumn()); + } + } +} diff --git a/java-examples/src/main/java/bisq/bots/table/column/AbstractColumn.java b/java-examples/src/main/java/bisq/bots/table/column/AbstractColumn.java new file mode 100644 index 0000000..4a71588 --- /dev/null +++ b/java-examples/src/main/java/bisq/bots/table/column/AbstractColumn.java @@ -0,0 +1,86 @@ +/* + * 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.column; + +import static bisq.bots.table.column.Column.JUSTIFICATION.RIGHT; +import static com.google.common.base.Strings.padEnd; +import static com.google.common.base.Strings.padStart; + +/** + * Partial implementation of the {@link Column} interface. + */ +abstract class AbstractColumn, T> implements Column { + + // We create an encapsulated StringColumn up front to populate with formatted + // strings in each this.addRow(Long value) call. But we will not know how + // to justify the cached, formatted string until the column is fully populated. + protected final StringColumn stringColumn; + + // The name field is not final, so it can be re-set for column alignment. + protected String name; + protected final JUSTIFICATION justification; + // The max width is not known until after column is fully populated. + protected int maxWidth; + + public AbstractColumn(String name, JUSTIFICATION justification) { + this.name = name; + this.justification = justification; + this.stringColumn = this instanceof StringColumn ? null : new StringColumn(name, justification); + } + + @Override + public String getName() { + return this.name; + } + + @Override + public void setName(String name) { + this.name = name; + } + + @Override + public int getWidth() { + return maxWidth; + } + + @Override + public JUSTIFICATION getJustification() { + return this.justification; + } + + @Override + public Column justify() { + if (this instanceof StringColumn && this.justification.equals(RIGHT)) + return this.justify(); + else + return this; // no-op + } + + protected final String toJustifiedString(String s) { + switch (justification) { + case LEFT: + return padEnd(s, maxWidth, ' '); + case RIGHT: + return padStart(s, maxWidth, ' '); + case NONE: + default: + return s; + } + } +} + diff --git a/java-examples/src/main/java/bisq/bots/table/column/AltcoinVolumeColumn.java b/java-examples/src/main/java/bisq/bots/table/column/AltcoinVolumeColumn.java new file mode 100644 index 0000000..8e38e69 --- /dev/null +++ b/java-examples/src/main/java/bisq/bots/table/column/AltcoinVolumeColumn.java @@ -0,0 +1,88 @@ +/* + * 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.column; + +import java.math.BigDecimal; +import java.util.function.BiFunction; +import java.util.stream.IntStream; + +import static bisq.bots.table.column.Column.JUSTIFICATION.RIGHT; + +/** + * For displaying altcoin volume with appropriate precision. + */ +public class AltcoinVolumeColumn extends LongColumn { + + public enum DISPLAY_MODE { + ALTCOIN_VOLUME, + BSQ_VOLUME, + } + + private final DISPLAY_MODE displayMode; + + // The default AltcoinVolumeColumn JUSTIFICATION is RIGHT. + public AltcoinVolumeColumn(String name, DISPLAY_MODE displayMode) { + this(name, RIGHT, displayMode); + } + + public AltcoinVolumeColumn(String name, + JUSTIFICATION justification, + DISPLAY_MODE displayMode) { + super(name, justification); + this.displayMode = displayMode; + } + + @Override + public void addRow(Long value) { + rows.add(value); + + String s = toFormattedString.apply(value, displayMode); + stringColumn.addRow(s); + + if (isNewMaxWidth.test(s)) + maxWidth = s.length(); + } + + @Override + public String getRowAsFormattedString(int rowIndex) { + return toFormattedString.apply(getRow(rowIndex), displayMode); + } + + @Override + public StringColumn asStringColumn() { + // We cached the formatted altcoin value strings, but we did + // not know how much padding each string needed until now. + IntStream.range(0, stringColumn.getRows().size()).forEach(rowIndex -> { + String unjustified = stringColumn.getRow(rowIndex); + String justified = stringColumn.toJustifiedString(unjustified); + stringColumn.updateRow(rowIndex, justified); + }); + return this.stringColumn; + } + + private final BiFunction toFormattedString = (value, displayMode) -> { + switch (displayMode) { + case ALTCOIN_VOLUME: + return value > 0 ? new BigDecimal(value).movePointLeft(8).toString() : ""; + case BSQ_VOLUME: + return value > 0 ? new BigDecimal(value).movePointLeft(2).toString() : ""; + default: + throw new IllegalStateException("invalid display mode: " + displayMode); + } + }; +} diff --git a/java-examples/src/main/java/bisq/bots/table/column/BooleanColumn.java b/java-examples/src/main/java/bisq/bots/table/column/BooleanColumn.java new file mode 100644 index 0000000..37d8b14 --- /dev/null +++ b/java-examples/src/main/java/bisq/bots/table/column/BooleanColumn.java @@ -0,0 +1,131 @@ +/* + * 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.column; + + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; +import java.util.stream.IntStream; + +import static bisq.bots.table.column.Column.JUSTIFICATION.LEFT; + +/** + * For displaying boolean values as YES, NO, or user's choice for 'true' and 'false'. + */ +public class BooleanColumn extends AbstractColumn { + + private static final String DEFAULT_TRUE_AS_STRING = "YES"; + private static final String DEFAULT_FALSE_AS_STRING = "NO"; + + private final List rows = new ArrayList<>(); + + private final Predicate isNewMaxWidth = (s) -> s != null && !s.isEmpty() && s.length() > maxWidth; + + private final String trueAsString; + private final String falseAsString; + + // The default BooleanColumn JUSTIFICATION is LEFT. + // The default BooleanColumn True AsString value is YES. + // The default BooleanColumn False AsString value is NO. + public BooleanColumn(String name) { + this(name, LEFT, DEFAULT_TRUE_AS_STRING, DEFAULT_FALSE_AS_STRING); + } + + // Use this constructor to override default LEFT justification. + @SuppressWarnings("unused") + public BooleanColumn(String name, JUSTIFICATION justification) { + this(name, justification, DEFAULT_TRUE_AS_STRING, DEFAULT_FALSE_AS_STRING); + } + + // Use this constructor to override default true/false as string defaults. + public BooleanColumn(String name, String trueAsString, String falseAsString) { + this(name, LEFT, trueAsString, falseAsString); + } + + // Use this constructor to override default LEFT justification. + public BooleanColumn(String name, + JUSTIFICATION justification, + String trueAsString, + String falseAsString) { + super(name, justification); + this.trueAsString = trueAsString; + this.falseAsString = falseAsString; + this.maxWidth = name.length(); + } + + @Override + public void addRow(Boolean value) { + rows.add(value); + + // We do not know how much padding each StringColumn value needs until it has all the values. + String s = asString(value); + stringColumn.addRow(s); + + if (isNewMaxWidth.test(s)) + maxWidth = s.length(); + } + + @Override + public List getRows() { + return rows; + } + + @Override + public int rowCount() { + return rows.size(); + } + + @Override + public boolean isEmpty() { + return rows.isEmpty(); + } + + @Override + public Boolean getRow(int rowIndex) { + return rows.get(rowIndex); + } + + @Override + public void updateRow(int rowIndex, Boolean newValue) { + rows.set(rowIndex, newValue); + } + + @Override + public String getRowAsFormattedString(int rowIndex) { + return getRow(rowIndex) + ? trueAsString + : falseAsString; + } + + @Override + public StringColumn asStringColumn() { + // We cached the formatted satoshi strings, but we did + // not know how much padding each string needed until now. + IntStream.range(0, stringColumn.getRows().size()).forEach(rowIndex -> { + String unjustified = stringColumn.getRow(rowIndex); + String justified = stringColumn.toJustifiedString(unjustified); + stringColumn.updateRow(rowIndex, justified); + }); + return stringColumn; + } + + private String asString(boolean value) { + return value ? trueAsString : falseAsString; + } +} diff --git a/java-examples/src/main/java/bisq/bots/table/column/BtcColumn.java b/java-examples/src/main/java/bisq/bots/table/column/BtcColumn.java new file mode 100644 index 0000000..a2e037f --- /dev/null +++ b/java-examples/src/main/java/bisq/bots/table/column/BtcColumn.java @@ -0,0 +1,48 @@ +package bisq.bots.table.column; + +import java.util.stream.IntStream; + +import static bisq.bots.CurrencyFormat.formatBtc; +import static com.google.common.base.Strings.padEnd; +import static java.util.Comparator.comparingInt; + +public class BtcColumn extends SatoshiColumn { + + public BtcColumn(String name) { + super(name); + } + + @Override + public void addRow(Long value) { + rows.add(value); + + String s = formatBtc(value); + stringColumn.addRow(s); + + if (isNewMaxWidth.test(s)) + maxWidth = s.length(); + } + + @Override + public String getRowAsFormattedString(int rowIndex) { + return formatBtc(getRow(rowIndex)); + } + + @Override + public StringColumn asStringColumn() { + // We cached the formatted satoshi strings, but we did + // not know how much zero padding each string needed until now. + int maxColumnValueWidth = stringColumn.getRows().stream() + .max(comparingInt(String::length)) + .get() + .length(); + IntStream.range(0, stringColumn.getRows().size()).forEach(rowIndex -> { + String btcString = stringColumn.getRow(rowIndex); + if (btcString.length() < maxColumnValueWidth) { + String paddedBtcString = padEnd(btcString, maxColumnValueWidth, '0'); + stringColumn.updateRow(rowIndex, paddedBtcString); + } + }); + return stringColumn.justify(); + } +} diff --git a/java-examples/src/main/java/bisq/bots/table/column/Column.java b/java-examples/src/main/java/bisq/bots/table/column/Column.java new file mode 100644 index 0000000..08cacb6 --- /dev/null +++ b/java-examples/src/main/java/bisq/bots/table/column/Column.java @@ -0,0 +1,122 @@ +/* + * 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.column; + +import java.util.List; + +public interface Column { + + enum JUSTIFICATION { + LEFT, + RIGHT, + NONE + } + + /** + * Returns the column's name. + * + * @return name as String + */ + String getName(); + + /** + * Sets the column name. + * + * @param name of the column + */ + void setName(String name); + + /** + * Add column value. + * + * @param value added to column's data (row) + */ + void addRow(T value); + + /** + * Returns the column data. + * + * @return rows as List + */ + List getRows(); + + /** + * Returns the maximum width of the column name, or longest, + * formatted string value -- whichever is greater. + * + * @return width of the populated column as int + */ + int getWidth(); + + /** + * Returns the number of rows in the column. + * + * @return number of rows in the column as int. + */ + int rowCount(); + + /** + * Returns true if the column has no data. + * + * @return true if empty, false if not + */ + boolean isEmpty(); + + /** + * Returns the column value (data) at given row index. + * + * @return value object + */ + T getRow(int rowIndex); + + /** + * Update an existing value at the given row index to a new value. + * + * @param rowIndex row index of value to be updated + * @param newValue new value + */ + void updateRow(int rowIndex, T newValue); + + /** + * Returns the row value as a formatted String. + * + * @return a row value as formatted String + */ + String getRowAsFormattedString(int rowIndex); + + /** + * Return the column with all of its data as a StringColumn with all of its + * formatted string data. + * + * @return StringColumn + */ + StringColumn asStringColumn(); + + /** + * Convenience for justifying populated StringColumns before being displayed. + * Is only useful for StringColumn instances. + */ + Column justify(); + + /** + * Returns JUSTIFICATION value (RIGHT|LEFT|NONE) for the column. + * + * @return column JUSTIFICATION + */ + JUSTIFICATION getJustification(); +} diff --git a/java-examples/src/main/java/bisq/bots/table/column/DoubleColumn.java b/java-examples/src/main/java/bisq/bots/table/column/DoubleColumn.java new file mode 100644 index 0000000..1040e5b --- /dev/null +++ b/java-examples/src/main/java/bisq/bots/table/column/DoubleColumn.java @@ -0,0 +1,93 @@ +/* + * 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.column; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; +import java.util.stream.IntStream; + +import static bisq.bots.table.column.Column.JUSTIFICATION.RIGHT; + +/** + * For displaying Double values. + */ +public class DoubleColumn extends NumberColumn { + + protected final List rows = new ArrayList<>(); + + protected final Predicate isNewMaxWidth = (s) -> s != null && !s.isEmpty() && s.length() > maxWidth; + + // The default DoubleColumn JUSTIFICATION is RIGHT. + public DoubleColumn(String name) { + this(name, RIGHT); + } + + public DoubleColumn(String name, JUSTIFICATION justification) { + super(name, justification); + this.maxWidth = name.length(); + } + + @Override + public void addRow(Double value) { + rows.add(value); + + String s = String.valueOf(value); + if (isNewMaxWidth.test(s)) + maxWidth = s.length(); + } + + @Override + public List getRows() { + return rows; + } + + @Override + public int rowCount() { + return rows.size(); + } + + @Override + public boolean isEmpty() { + return rows.isEmpty(); + } + + @Override + public Double getRow(int rowIndex) { + return rows.get(rowIndex); + } + + @Override + public void updateRow(int rowIndex, Double newValue) { + rows.set(rowIndex, newValue); + } + + @Override + public String getRowAsFormattedString(int rowIndex) { + String s = String.valueOf(getRow(rowIndex)); + return toJustifiedString(s); + } + + @Override + public StringColumn asStringColumn() { + IntStream.range(0, rows.size()).forEachOrdered(rowIndex -> + stringColumn.addRow(getRowAsFormattedString(rowIndex))); + + return stringColumn; + } +} diff --git a/java-examples/src/main/java/bisq/bots/table/column/FiatColumn.java b/java-examples/src/main/java/bisq/bots/table/column/FiatColumn.java new file mode 100644 index 0000000..ffb314a --- /dev/null +++ b/java-examples/src/main/java/bisq/bots/table/column/FiatColumn.java @@ -0,0 +1,83 @@ +/* + * 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.column; + +import java.util.stream.IntStream; + +import static bisq.bots.CurrencyFormat.formatFiatVolume; +import static bisq.bots.CurrencyFormat.formatPrice; +import static bisq.bots.table.column.Column.JUSTIFICATION.RIGHT; + +/** + * For displaying fiat volume or price with appropriate precision. + */ +public class FiatColumn extends LongColumn { + + public enum DISPLAY_MODE { + FIAT_PRICE, + FIAT_VOLUME + } + + private final DISPLAY_MODE displayMode; + + // The default FiatColumn JUSTIFICATION is RIGHT. + // The default FiatColumn DISPLAY_MODE is PRICE. + public FiatColumn(String name) { + this(name, RIGHT, DISPLAY_MODE.FIAT_PRICE); + } + + public FiatColumn(String name, DISPLAY_MODE displayMode) { + this(name, RIGHT, displayMode); + } + + public FiatColumn(String name, + JUSTIFICATION justification, + DISPLAY_MODE displayMode) { + super(name, justification); + this.displayMode = displayMode; + } + + @Override + public void addRow(Long value) { + rows.add(value); + + String s = displayMode.equals(DISPLAY_MODE.FIAT_PRICE) ? formatPrice(value) : formatFiatVolume(value); + + stringColumn.addRow(s); + + if (isNewMaxWidth.test(s)) + maxWidth = s.length(); + } + + @Override + public String getRowAsFormattedString(int rowIndex) { + return getRow(rowIndex).toString(); + } + + @Override + public StringColumn asStringColumn() { + // We cached the formatted fiat price strings, but we did + // not know how much padding each string needed until now. + IntStream.range(0, stringColumn.getRows().size()).forEach(rowIndex -> { + String unjustified = stringColumn.getRow(rowIndex); + String justified = stringColumn.toJustifiedString(unjustified); + stringColumn.updateRow(rowIndex, justified); + }); + return this.stringColumn; + } +} diff --git a/java-examples/src/main/java/bisq/bots/table/column/IntegerColumn.java b/java-examples/src/main/java/bisq/bots/table/column/IntegerColumn.java new file mode 100644 index 0000000..9b8da9a --- /dev/null +++ b/java-examples/src/main/java/bisq/bots/table/column/IntegerColumn.java @@ -0,0 +1,93 @@ +/* + * 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.column; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; +import java.util.stream.IntStream; + +import static bisq.bots.table.column.Column.JUSTIFICATION.RIGHT; + +/** + * For displaying Integer values. + */ +public class IntegerColumn extends NumberColumn { + + protected final List rows = new ArrayList<>(); + + protected final Predicate isNewMaxWidth = (s) -> s != null && !s.isEmpty() && s.length() > maxWidth; + + // The default IntegerColumn JUSTIFICATION is RIGHT. + public IntegerColumn(String name) { + this(name, RIGHT); + } + + public IntegerColumn(String name, JUSTIFICATION justification) { + super(name, justification); + this.maxWidth = name.length(); + } + + @Override + public void addRow(Integer value) { + rows.add(value); + + String s = String.valueOf(value); + if (isNewMaxWidth.test(s)) + maxWidth = s.length(); + } + + @Override + public List getRows() { + return rows; + } + + @Override + public int rowCount() { + return rows.size(); + } + + @Override + public boolean isEmpty() { + return rows.isEmpty(); + } + + @Override + public Integer getRow(int rowIndex) { + return rows.get(rowIndex); + } + + @Override + public void updateRow(int rowIndex, Integer newValue) { + rows.set(rowIndex, newValue); + } + + @Override + public String getRowAsFormattedString(int rowIndex) { + String s = String.valueOf(getRow(rowIndex)); + return toJustifiedString(s); + } + + @Override + public StringColumn asStringColumn() { + IntStream.range(0, rows.size()).forEachOrdered(rowIndex -> + stringColumn.addRow(getRowAsFormattedString(rowIndex))); + + return stringColumn; + } +} diff --git a/java-examples/src/main/java/bisq/bots/table/column/Iso8601DateTimeColumn.java b/java-examples/src/main/java/bisq/bots/table/column/Iso8601DateTimeColumn.java new file mode 100644 index 0000000..7e44e6a --- /dev/null +++ b/java-examples/src/main/java/bisq/bots/table/column/Iso8601DateTimeColumn.java @@ -0,0 +1,63 @@ +/* + * 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.column; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.stream.IntStream; + +import static bisq.bots.table.column.Column.JUSTIFICATION.LEFT; +import static com.google.common.base.Strings.padEnd; +import static com.google.common.base.Strings.padStart; +import static java.lang.System.currentTimeMillis; +import static java.util.TimeZone.getTimeZone; + +/** + * For displaying (long) timestamp values as ISO-8601 dates in UTC time zone. + */ +public class Iso8601DateTimeColumn extends LongColumn { + + protected final SimpleDateFormat iso8601DateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); + + // The default Iso8601DateTimeColumn JUSTIFICATION is LEFT. + public Iso8601DateTimeColumn(String name) { + this(name, LEFT); + } + + public Iso8601DateTimeColumn(String name, JUSTIFICATION justification) { + super(name, justification); + iso8601DateFormat.setTimeZone(getTimeZone("UTC")); + this.maxWidth = Math.max(name.length(), String.valueOf(currentTimeMillis()).length()); + } + + @Override + public String getRowAsFormattedString(int rowIndex) { + long time = getRow(rowIndex); + return justification.equals(LEFT) + ? padEnd(iso8601DateFormat.format(new Date(time)), maxWidth, ' ') + : padStart(iso8601DateFormat.format(new Date(time)), maxWidth, ' '); + } + + @Override + public StringColumn asStringColumn() { + IntStream.range(0, rows.size()).forEachOrdered(rowIndex -> + stringColumn.addRow(getRowAsFormattedString(rowIndex))); + + return stringColumn; + } +} diff --git a/java-examples/src/main/java/bisq/bots/table/column/LongColumn.java b/java-examples/src/main/java/bisq/bots/table/column/LongColumn.java new file mode 100644 index 0000000..0b36f2d --- /dev/null +++ b/java-examples/src/main/java/bisq/bots/table/column/LongColumn.java @@ -0,0 +1,93 @@ +/* + * 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.column; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; +import java.util.stream.IntStream; + +import static bisq.bots.table.column.Column.JUSTIFICATION.RIGHT; + +/** + * For displaying Long values. + */ +public class LongColumn extends NumberColumn { + + protected final List rows = new ArrayList<>(); + + protected final Predicate isNewMaxWidth = (s) -> s != null && !s.isEmpty() && s.length() > maxWidth; + + // The default LongColumn JUSTIFICATION is RIGHT. + public LongColumn(String name) { + this(name, RIGHT); + } + + public LongColumn(String name, JUSTIFICATION justification) { + super(name, justification); + this.maxWidth = name.length(); + } + + @Override + public void addRow(Long value) { + rows.add(value); + + String s = String.valueOf(value); + if (isNewMaxWidth.test(s)) + maxWidth = s.length(); + } + + @Override + public List getRows() { + return rows; + } + + @Override + public int rowCount() { + return rows.size(); + } + + @Override + public boolean isEmpty() { + return rows.isEmpty(); + } + + @Override + public Long getRow(int rowIndex) { + return rows.get(rowIndex); + } + + @Override + public void updateRow(int rowIndex, Long newValue) { + rows.set(rowIndex, newValue); + } + + @Override + public String getRowAsFormattedString(int rowIndex) { + String s = String.valueOf(getRow(rowIndex)); + return toJustifiedString(s); + } + + @Override + public StringColumn asStringColumn() { + IntStream.range(0, rows.size()).forEachOrdered(rowIndex -> + stringColumn.addRow(getRowAsFormattedString(rowIndex))); + + return stringColumn; + } +} diff --git a/java-examples/src/main/java/bisq/bots/table/column/MixedTradeFeeColumn.java b/java-examples/src/main/java/bisq/bots/table/column/MixedTradeFeeColumn.java new file mode 100644 index 0000000..1481546 --- /dev/null +++ b/java-examples/src/main/java/bisq/bots/table/column/MixedTradeFeeColumn.java @@ -0,0 +1,59 @@ +/* + * 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.column; + +import static bisq.bots.CurrencyFormat.formatBsq; +import static bisq.bots.CurrencyFormat.formatSatoshis; +import static bisq.bots.table.column.Column.JUSTIFICATION.RIGHT; + +/** + * For displaying a mix of BSQ and BTC trade fees with appropriate precision. + */ +public class MixedTradeFeeColumn extends LongColumn { + + public MixedTradeFeeColumn(String name) { + super(name, RIGHT); + } + + @Override + public void addRow(Long value) { + throw new UnsupportedOperationException("use public void addRow(Long value, boolean isBsq) instead"); + } + + public void addRow(Long value, boolean isBsq) { + rows.add(value); + + String s = isBsq + ? formatBsq(value) + " BSQ" + : formatSatoshis(value) + " BTC"; + stringColumn.addRow(s); + + if (isNewMaxWidth.test(s)) + maxWidth = s.length(); + } + + @Override + public String getRowAsFormattedString(int rowIndex) { + return getRow(rowIndex).toString(); + } + + @Override + public StringColumn asStringColumn() { + return stringColumn.justify(); + } +} diff --git a/java-examples/src/main/java/bisq/bots/table/column/NumberColumn.java b/java-examples/src/main/java/bisq/bots/table/column/NumberColumn.java new file mode 100644 index 0000000..acf52a5 --- /dev/null +++ b/java-examples/src/main/java/bisq/bots/table/column/NumberColumn.java @@ -0,0 +1,32 @@ +/* + * 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.column; + +/** + * Abstract superclass for numeric Columns. + * + * @param the subclass column's type (LongColumn, IntegerColumn, ...) + * @param the subclass column's numeric Java type (Long, Integer, ...) + */ +abstract class NumberColumn, + T extends Number> extends AbstractColumn implements Column { + + public NumberColumn(String name, JUSTIFICATION justification) { + super(name, justification); + } +} diff --git a/java-examples/src/main/java/bisq/bots/table/column/SatoshiColumn.java b/java-examples/src/main/java/bisq/bots/table/column/SatoshiColumn.java new file mode 100644 index 0000000..7ab5dd6 --- /dev/null +++ b/java-examples/src/main/java/bisq/bots/table/column/SatoshiColumn.java @@ -0,0 +1,72 @@ +/* + * 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.column; + +import static bisq.bots.CurrencyFormat.formatBsq; +import static bisq.bots.CurrencyFormat.formatSatoshis; +import static bisq.bots.table.column.Column.JUSTIFICATION.RIGHT; + +/** + * For displaying BTC or BSQ satoshi values with appropriate precision. + */ +public class SatoshiColumn extends LongColumn { + + protected final boolean isBsqSatoshis; + + // The default SatoshiColumn JUSTIFICATION is RIGHT. + public SatoshiColumn(String name) { + this(name, RIGHT, false); + } + + public SatoshiColumn(String name, boolean isBsqSatoshis) { + this(name, RIGHT, isBsqSatoshis); + } + + public SatoshiColumn(String name, JUSTIFICATION justification) { + this(name, justification, false); + } + + public SatoshiColumn(String name, JUSTIFICATION justification, boolean isBsqSatoshis) { + super(name, justification); + this.isBsqSatoshis = isBsqSatoshis; + } + + @Override + public void addRow(Long value) { + rows.add(value); + + // We do not know how much padding each StringColumn value needs until it has all the values. + String s = isBsqSatoshis ? formatBsq(value) : formatSatoshis(value); + stringColumn.addRow(s); + + if (isNewMaxWidth.test(s)) + maxWidth = s.length(); + } + + @Override + public String getRowAsFormattedString(int rowIndex) { + return isBsqSatoshis + ? formatBsq(getRow(rowIndex)) + : formatSatoshis(getRow(rowIndex)); + } + + @Override + public StringColumn asStringColumn() { + return stringColumn.justify(); + } +} diff --git a/java-examples/src/main/java/bisq/bots/table/column/StringColumn.java b/java-examples/src/main/java/bisq/bots/table/column/StringColumn.java new file mode 100644 index 0000000..9197208 --- /dev/null +++ b/java-examples/src/main/java/bisq/bots/table/column/StringColumn.java @@ -0,0 +1,102 @@ +/* + * 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.column; + + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; +import java.util.stream.IntStream; + +import static bisq.bots.table.column.Column.JUSTIFICATION.LEFT; +import static bisq.bots.table.column.Column.JUSTIFICATION.RIGHT; + +/** + * For displaying justified string values. + */ +public class StringColumn extends AbstractColumn { + + private final List rows = new ArrayList<>(); + + private final Predicate isNewMaxWidth = (s) -> s != null && !s.isEmpty() && s.length() > maxWidth; + + // The default StringColumn JUSTIFICATION is LEFT. + public StringColumn(String name) { + this(name, LEFT); + } + + // Use this constructor to override default LEFT justification. + public StringColumn(String name, JUSTIFICATION justification) { + super(name, justification); + this.maxWidth = name.length(); + } + + @Override + public void addRow(String value) { + rows.add(value); + if (isNewMaxWidth.test(value)) + maxWidth = value.length(); + } + + @Override + public List getRows() { + return rows; + } + + @Override + public int rowCount() { + return rows.size(); + } + + @Override + public boolean isEmpty() { + return rows.isEmpty(); + } + + @Override + public String getRow(int rowIndex) { + return rows.get(rowIndex); + } + + @Override + public void updateRow(int rowIndex, String newValue) { + rows.set(rowIndex, newValue); + } + + @Override + public String getRowAsFormattedString(int rowIndex) { + return getRow(rowIndex); + } + + @Override + public StringColumn asStringColumn() { + return this; + } + + @Override + public StringColumn justify() { + if (justification.equals(RIGHT)) { + IntStream.range(0, getRows().size()).forEach(rowIndex -> { + String unjustified = getRow(rowIndex); + String justified = toJustifiedString(unjustified); + updateRow(rowIndex, justified); + }); + } + return this; + } +} diff --git a/java-examples/src/main/java/bisq/bots/table/column/ZippedStringColumns.java b/java-examples/src/main/java/bisq/bots/table/column/ZippedStringColumns.java new file mode 100644 index 0000000..f57a78b --- /dev/null +++ b/java-examples/src/main/java/bisq/bots/table/column/ZippedStringColumns.java @@ -0,0 +1,124 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.bots.table.column; + +import bisq.bots.table.column.Column.JUSTIFICATION; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.IntStream; + +/** + * For zipping multiple StringColumns into a single StringColumn. + * Useful for displaying amount and volume range values. + */ +public class ZippedStringColumns { + + public enum DUPLICATION_MODE { + EXCLUDE_DUPLICATES, + INCLUDE_DUPLICATES + } + + private final String name; + private final JUSTIFICATION justification; + private final String delimiter; + private final StringColumn[] columns; + + public ZippedStringColumns(String name, + JUSTIFICATION justification, + String delimiter, + StringColumn... columns) { + this.name = name; + this.justification = justification; + this.delimiter = delimiter; + this.columns = columns; + validateColumnData(); + } + + public StringColumn asStringColumn(DUPLICATION_MODE duplicationMode) { + StringColumn stringColumn = new StringColumn(name, justification); + + buildRows(stringColumn, duplicationMode); + + // Re-set the column name field to its justified value, in case any of the column + // values are longer than the name passed to this constructor. + stringColumn.setName(stringColumn.toJustifiedString(name)); + + return stringColumn; + } + + private void buildRows(StringColumn stringColumn, DUPLICATION_MODE duplicationMode) { + // Populate the StringColumn with unjustified zipped values; we cannot justify + // the zipped values until stringColumn knows its final maxWidth. + IntStream.range(0, columns[0].getRows().size()).forEach(rowIndex -> { + String row = buildRow(rowIndex, duplicationMode); + stringColumn.addRow(row); + }); + + formatRows(stringColumn); + } + + private String buildRow(int rowIndex, DUPLICATION_MODE duplicationMode) { + StringBuilder rowBuilder = new StringBuilder(); + @Nullable + List processedValues = duplicationMode.equals(DUPLICATION_MODE.EXCLUDE_DUPLICATES) + ? new ArrayList<>() + : null; + IntStream.range(0, columns.length).forEachOrdered(colIndex -> { + // For each column @ rowIndex ... + var value = columns[colIndex].getRows().get(rowIndex); + if (duplicationMode.equals(DUPLICATION_MODE.INCLUDE_DUPLICATES)) { + if (rowBuilder.length() > 0) + rowBuilder.append(delimiter); + + rowBuilder.append(value); + } else if (!processedValues.contains(value)) { + if (rowBuilder.length() > 0) + rowBuilder.append(delimiter); + + rowBuilder.append(value); + processedValues.add(value); + } + }); + return rowBuilder.toString(); + } + + private void formatRows(StringColumn stringColumn) { + // Now we can justify the zipped string values in the new StringColumn. + IntStream.range(0, stringColumn.getRows().size()).forEach(rowIndex -> { + String unjustified = stringColumn.getRow(rowIndex); + String justified = stringColumn.toJustifiedString(unjustified); + stringColumn.updateRow(rowIndex, justified); + }); + } + + private void validateColumnData() { + if (columns.length == 0) + throw new IllegalStateException("cannot zip columns because they do not have any data"); + + StringColumn firstColumn = columns[0]; + if (firstColumn.getRows().isEmpty()) + throw new IllegalStateException("1st column has no data"); + + IntStream.range(1, columns.length).forEach(colIndex -> { + if (columns[colIndex].getRows().size() != firstColumn.getRows().size()) + throw new IllegalStateException("columns do not have same number of rows"); + }); + } +} diff --git a/java-examples/src/main/resources/RegtestTradePaymentSimulator.properties b/java-examples/src/main/resources/RegtestTradePaymentSimulator.properties new file mode 100644 index 0000000..d17c79e --- /dev/null +++ b/java-examples/src/main/resources/RegtestTradePaymentSimulator.properties @@ -0,0 +1 @@ +pollingInterval=10000 diff --git a/java-examples/src/main/resources/TakeBestPricedOfferToBuyBsq.properties b/java-examples/src/main/resources/TakeBestPricedOfferToBuyBsq.properties new file mode 100644 index 0000000..22176e7 --- /dev/null +++ b/java-examples/src/main/resources/TakeBestPricedOfferToBuyBsq.properties @@ -0,0 +1,26 @@ +# Maximum # of offers to take during one bot session. When reached, bot will shut down (but not the API daemon). +maxTakeOffers=1 +# +# Minimum distance from 30-day average BSQ trade price. +# Note: all BSQ Swap offers have a fixed-price, but the bot uses a margin (%) of the 30-day price for comparison. +minMarketPriceMargin=0 +# +# Hard coded 30-day average BSQ trade price, used for development over regtest. +regtest30DayAvgBsqPrice=0.00005 +# +# Taker bot's min BTC amount to sell. The candidate SELL BTC offer's amount must be >= minAmount BTC. +minAmount=0.01 +# +# Taker bot's max BTC amount to sell. The candidate SELL BTC offer's amount must be <= maxAmount BTC. +maxAmount=0.50 +# +# Taker bot's max acceptable transaction fee rate (sats / byte). +# Regtest fee rates are from https://price.bisq.wiz.biz/getFees +maxTxFeeRate=25 +# +# Taker bot's list of preferred trading peers (their onion addresses). +# If you do not want to constrict trading to preferred peers, comment this line out with a '#' character. +preferredTradingPeers=localhost:8888 +# +# Offer polling frequency must be >= 1s (1000ms) between each getoffers request. +pollingInterval=30000 diff --git a/java-examples/src/main/resources/TakeBestPricedOfferToBuyBtc.properties b/java-examples/src/main/resources/TakeBestPricedOfferToBuyBtc.properties new file mode 100644 index 0000000..0f92400 --- /dev/null +++ b/java-examples/src/main/resources/TakeBestPricedOfferToBuyBtc.properties @@ -0,0 +1,38 @@ +# +# Maximum # of offers to take during one bot session. When reached, bot will shut down API daemon then itself. +maxTakeOffers=10 +# +# Taker bot's payment account id. Only BUY BTC offers using the same payment method will be considered for taking. +paymentAccountId=9ad3cc7a-7d32-453c-b9db-a3714b5b8f61 +# +# Taker bot's min market price margin. A candidate BUY BTC offer's price margin must be >= minMarketPriceMargin. +# +minMarketPriceMargin=1.00 +# +# Taker bot's min BTC amount to sell. The candidate BUY offer's amount must be >= minAmount BTC. +minAmount=0.01 +# +# Taker bot's max BTC amount to sell. The candidate BUY offer's amount must be <= maxAmount BTC. +maxAmount=0.50 +# +# Taker bot's max acceptable transaction fee rate (sats / byte). +# Regtest fee rates are from https://price.bisq.wiz.biz/getFees +maxTxFeeRate=25 +# +# Bisq trade fee currency code (BSQ or BTC). +bisqTradeFeeCurrency=BSQ +# +# Taker bot's list of preferred trading peers (their onion addresses). +# If you do not want to constrict trading to preferred peers, comment this line out with a '#' character. +preferredTradingPeers=localhost:8888, \ + nysf2pknaaxfh26k42ego5mnfzpbozyi3nuoxdu745unvva4pvywffyd.onion:9999, \ + vrexx4kqxszmwn6tlde3rvrzmvi3klgzlgjl5dfa2rc7orp4saext3yd.onion:9999, \ + y7lvgclwyg6kz7mgystx67vd56vlvxbeg62wgrnrtknspmneeq6ecfid.onion:9999, \ + yv4sfwskwbzsokil5vdua4vsfrfbve6dgv7rwjfn5ensxxrzuulkehyd.onion:9999, \ + unskoifqryw22b53ut4cghrs7ekp6k7zlvqd3pi73y4zctpmmy2fhnyd.onion:9999, \ + tspph3aycnnfuxnxy73bx7p3uxirplawmnizli3y74gbzvrjdruz2rad.onion:9999, \ + zdlbe7xfmmmtvr6esqwr52qk2bysq7jicmj3rxjr5ieuagtvraqpfrid.onion:9999, \ + x6x2o3m6rxhkfuf2v6lalbharf3whwvkts5rdn3jkhgieqvnq6mvdfyd.onion:9999 +# +# Offer polling frequency must be >= 1s (1000ms) between each getoffers request. +pollingInterval=20000 diff --git a/java-examples/src/main/resources/TakeBestPricedOfferToSellBsq.properties b/java-examples/src/main/resources/TakeBestPricedOfferToSellBsq.properties new file mode 100644 index 0000000..f45e0a8 --- /dev/null +++ b/java-examples/src/main/resources/TakeBestPricedOfferToSellBsq.properties @@ -0,0 +1,26 @@ +# Maximum # of offers to take during one bot session. When reached, bot will shut down (but not the API daemon). +maxTakeOffers=10 +# +# Maximum distance from 30-day average BSQ trade price. +# Note: all BSQ Swap offers have a fixed-price, but the bot uses a margin (%) of the 30-day price for comparison. +maxMarketPriceMargin=0.00 +# +# Hard coded 30-day average BSQ trade price, used for development over regtest. +regtest30DayAvgBsqPrice=0.00005 +# +# Taker bot's min BTC amount to buy. The candidate BUY BTC offer's amount must be >= minAmount BTC. +minAmount=0.01 +# +# Taker bot's max BTC amount to buy. The candidate BUY BTC offer's amount must be <= maxAmount BTC. +maxAmount=0.50 +# +# Taker bot's max acceptable transaction fee rate (sats / byte). +# Regtest fee rates are from https://price.bisq.wiz.biz/getFees +maxTxFeeRate=25 +# +# Taker bot's list of preferred trading peers (their onion addresses). +# If you do not want to constrict trading to preferred peers, comment this line out with a '#' character. +preferredTradingPeers=localhost:8888 +# +# Offer polling frequency must be >= 1s (1000ms) between each getoffers request. +pollingInterval=30000 diff --git a/java-examples/src/main/resources/TakeBestPricedOfferToSellBtc.properties b/java-examples/src/main/resources/TakeBestPricedOfferToSellBtc.properties new file mode 100644 index 0000000..f689581 --- /dev/null +++ b/java-examples/src/main/resources/TakeBestPricedOfferToSellBtc.properties @@ -0,0 +1,37 @@ +# +# Maximum # of offers to take during one bot session. When reached, bot will shut down API daemon then itself. +maxTakeOffers=4 +# +# Taker bot's payment account id. Only SELL BTC offers using the same payment method will be considered for taking. +paymentAccountId=9ad3cc7a-7d32-453c-b9db-a3714b5b8f61 +# +# Taker bot's max market price margin. A candidate SELL BTC offer's price margin must be <= maxMarketPriceMargin. +maxMarketPriceMargin=3.00 +# +# Taker bot's min BTC amount to buy. The candidate SELL offer's amount must be >= minAmount BTC. +minAmount=0.01 +# +# Taker bot's max BTC amount to buy. The candidate SELL offer's amount must be <= maxAmount BTC. +maxAmount=0.50 +# +# Taker bot's max acceptable transaction fee rate (sats / byte). +# Regtest fee rates are from https://price.bisq.wiz.biz/getFees +maxTxFeeRate=100 +# +# Bisq trade fee currency code (BSQ or BTC). +bisqTradeFeeCurrency=BSQ +# +# Taker bot's list of preferred trading peers (their onion addresses). +# If you do not want to constrict trading to preferred peers, comment this line out with a '#' character. +preferredTradingPeers=localhost:8888, \ + nysf2pknaaxfh26k42ego5mnfzpbozyi3nuoxdu745unvva4pvywffyd.onion:9999, \ + vrexx4kqxszmwn6tlde3rvrzmvi3klgzlgjl5dfa2rc7orp4saext3yd.onion:9999, \ + y7lvgclwyg6kz7mgystx67vd56vlvxbeg62wgrnrtknspmneeq6ecfid.onion:9999, \ + yv4sfwskwbzsokil5vdua4vsfrfbve6dgv7rwjfn5ensxxrzuulkehyd.onion:9999, \ + unskoifqryw22b53ut4cghrs7ekp6k7zlvqd3pi73y4zctpmmy2fhnyd.onion:9999, \ + tspph3aycnnfuxnxy73bx7p3uxirplawmnizli3y74gbzvrjdruz2rad.onion:9999, \ + zdlbe7xfmmmtvr6esqwr52qk2bysq7jicmj3rxjr5ieuagtvraqpfrid.onion:9999, \ + x6x2o3m6rxhkfuf2v6lalbharf3whwvkts5rdn3jkhgieqvnq6mvdfyd.onion:9999 +# +# Offer polling frequency must be >= 1s (1000ms) between each getoffers request. +pollingInterval=20000 diff --git a/java-examples/src/main/resources/logback.xml b/java-examples/src/main/resources/logback.xml index 92a7893..175cb62 100644 --- a/java-examples/src/main/resources/logback.xml +++ b/java-examples/src/main/resources/logback.xml @@ -6,7 +6,7 @@ - +