diff --git a/java-examples/src/main/java/bisq/bots/AbstractBot.java b/java-examples/src/main/java/bisq/bots/AbstractBot.java new file mode 100644 index 0000000..9f59f75 --- /dev/null +++ b/java-examples/src/main/java/bisq/bots/AbstractBot.java @@ -0,0 +1,712 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ +package bisq.bots; + +import bisq.bots.table.builder.TableBuilder; +import bisq.proto.grpc.*; +import bisq.proto.grpc.GetTradesRequest.Category; +import io.grpc.StatusRuntimeException; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.Logger; +import protobuf.PaymentAccount; + +import java.io.*; +import java.math.BigDecimal; +import java.time.Duration; +import java.util.*; +import java.util.function.BiConsumer; +import java.util.function.Predicate; +import java.util.function.Supplier; + +import static bisq.bots.BotUtils.*; +import static bisq.bots.table.builder.TableType.BSQ_BALANCE_TBL; +import static bisq.bots.table.builder.TableType.BTC_BALANCE_TBL; +import static bisq.proto.grpc.GetOfferCategoryReply.OfferCategory.BSQ_SWAP; +import static io.grpc.Status.*; +import static java.lang.String.format; +import static java.lang.System.exit; +import static java.lang.System.out; +import static java.math.RoundingMode.HALF_UP; +import static java.util.Objects.requireNonNull; + +/** + * Base gRPC API bot example class, providing convenient methods for most types of interaction with the API daemon. + *

+ * Most gRPC exceptions from the API daemon should be handled by subclasses, with special exceptions made for + * the API takeoffer method, which can fail for many reasons, not all of which throw a gRPC StatusRuntimeException. + * TODO Study the problem with inconsistent API takeoffer error handling on both daemon and client, and try to + * come up with a better solution, i.e., all errors throw an exception instead of the current "failure reasons" + * derived from UI purposed TaskRunner errors that frequently occur during offer availability checks. + */ +@Slf4j +public abstract class AbstractBot { + + // Program args are saved in case they need to be forwarded to another bot instance. + protected final String[] args; + protected final String walletPassword; + protected final String conf; + protected final GrpcStubs grpcStubs; + protected final boolean isDryRun; + // This is an experimental option for simulating and automating protocol payment steps during bot development. + // Be extremely careful in its use; You do not want to "simulate" payments when API daemon is connected to mainnet. + protected final boolean canSimulatePaymentSteps; + + // Bot's list of preferred trading peers (onion addresses). + // The list is defined in the subclass' properties (or external conf) file. + protected final List preferredTradingPeers = new ArrayList<>(); + + // Used during dry runs to track offers that would be taken. + // This list should remain empty if super.dryRun = FALSE until bot can take multiple offers in one session. + protected final List offersTaken = new ArrayList<>(); + + protected final boolean canUseBash = getBashPath().isPresent(); + protected boolean isShutdown = false; + + protected final Supplier defaultPropertiesFilename = () -> this.getClass().getSimpleName() + ".properties"; + protected final Supplier txFeeRates = this::getTxFeeRateInfo; + protected final Supplier minimumTxFeeRate = () -> txFeeRates.get().getMinFeeServiceRate(); + protected final Supplier mostRecentTxFeeRate = () -> txFeeRates.get().getFeeServiceRate(); + + // Constructor + public AbstractBot(String[] args) { + this.args = args; + Config bisqClientOpts = new Config(args, defaultPropertiesFilename.get()); + this.walletPassword = bisqClientOpts.getWalletPassword(); + this.conf = bisqClientOpts.getConf(); + this.grpcStubs = new GrpcStubs(bisqClientOpts.getHost(), bisqClientOpts.getPort(), bisqClientOpts.getPassword()); + this.isDryRun = bisqClientOpts.isDryRun(); + this.canSimulatePaymentSteps = bisqClientOpts.isSimulatePaymentSteps(); + } + + public abstract void run(); + + /** + * Pings the API daemon with a getversion request. Any gRPC StatusRuntimeException exception + * from the daemon is fatal, resulting in an immediate Java runtime System.exit(1). + * + * @param startTime of the bot, for logging the bot's uptime + */ + protected void pingDaemon(long startTime) { + try { + var now = new Date(); + var upTime = Duration.ofMillis(now.getTime() - startTime); + log.info("Pinging API daemon. Uptime: {} hours {} minutes {} seconds.", + upTime.toHoursPart(), + upTime.toMinutesPart(), + upTime.toSecondsPart()); + var request = GetVersionRequest.newBuilder().build(); + var reply = grpcStubs.versionService.getVersion(request); + log.info("API daemon {} is available.", reply.getVersion()); + } catch (StatusRuntimeException grpcException) { + log.error("Fatal Error: {}, daemon not available. Shutting down bot.", toCleanErrorMessage.apply(grpcException)); + exit(1); + } + } + + /** + * Return true if bot has a preferred trading peer list. + */ + protected final Supplier iHavePreferredTradingPeers = () -> preferredTradingPeers.size() > 0; + + /** + * Return true is the offer maker's onion address is configured as a preferred trading peer. + */ + protected final Predicate isMakerPreferredTradingPeer = (offer) -> + iHavePreferredTradingPeers.get() + && preferredTradingPeers.contains(offer.getOwnerNodeAddress()); + + /** + * Returns true if the given maximum tx fee rate is <= the most recent Bisq network fee rate. + */ + protected final Predicate isBisqNetworkTxFeeRateLowEnough = (maxTxFeeRate) -> { + var currentTxFeeRate = mostRecentTxFeeRate.get(); + if (currentTxFeeRate <= maxTxFeeRate) { + log.info("Current tx fee rate: {} sats/byte.", currentTxFeeRate); + return true; + } else { + log.warn("Current network tx fee rate ({} sats/byte) is too high, it must fall below" + + " configured max fee rate ({} sats/byte) before attempting to take an offer.", + currentTxFeeRate, + maxTxFeeRate); + return false; + } + }; + + /** + * Loads the given List from a Java Properties object containing a comma separated list of onion + * addresses in hostname:port format, defined for property key "preferredTradingPeers". Will throw a + * fatal exception if the hostname:port pairs are not properly comma delimited. + */ + protected final BiConsumer> loadPreferredOnionAddresses = (properties, list) -> { + var commaSeparatedValues = properties.getProperty("preferredTradingPeers"); + if (commaSeparatedValues == null || commaSeparatedValues.isBlank() || commaSeparatedValues.isEmpty()) { + log.warn("Non-Fatal Error: no preferred trading peers defined in config file."); + return; + } + String[] onions = commaSeparatedValues.split(","); + // Do simple validation of each address or throw a fatal exception. + // The most likely problem is user not separating each onion address with a comma. + Arrays.stream(onions).forEach(onion -> list.add(getValidatedPeerAddress(onion))); + }; + + /** + * Return the name of the BTC network API daemon is currently connected to: mainnet, testnet3, or regtest. + * + * @return String name of BTC network + */ + protected String getNetwork() { + try { + var request = GetNetworkRequest.newBuilder().build(); + return grpcStubs.walletsService.getNetwork(request).getNetwork(); + } finally { + // There is a 1 getnetwork call per second rate meter on the API daemon, and bots have reason to call + // getnetwork many times in rapid succession because the API daemon could be restarted against mainnet or + // regtest at any instant. So, we force the bot to wait a second after making this call, to avoid a + // StatusRuntimeException(PERMISSION_DENIED). + sleep(1_000); + } + } + + /** + * Return true if API daemon is currently connected to BTC mainnet network. + * + * @return boolean true if API daemon is currently connected to BTC mainnet network + */ + protected boolean isConnectedToMainnet() { + return getNetwork().equalsIgnoreCase("mainnet"); + } + + /** + * Return true if bot has taken the offer during this session -- for dry runs only. + */ + protected final Predicate isAlreadyTaken = (offer) -> + offersTaken.stream().anyMatch(o -> o.getId().equals(offer.getId())); + + /** + * Print a table of BSQ balance information. + *

+ * An encrypted wallet must be unlocked before making this request; this method will not unlock it for you. + * + * @param title to display above the table + */ + protected void printBSQBalances(String title) { + try { + log.info(title); + var bsqBalances = getBalances().getBsq(); + new TableBuilder(BSQ_BALANCE_TBL, bsqBalances).build().print(out); + } catch (StatusRuntimeException grpcException) { + log.warn(toCleanErrorMessage.apply(grpcException)); + } + } + + /** + * Print a table of BTC balance information. + *

+ * An encrypted wallet must be unlocked before making this request; this method will not unlock it for you. + * + * @param title to display above the table + */ + protected void printBTCBalances(String title) { + try { + log.info(title); + var btcBalances = getBalances().getBtc(); + new TableBuilder(BTC_BALANCE_TBL, btcBalances).build().print(out); + } catch (StatusRuntimeException grpcException) { + log.warn(toCleanErrorMessage.apply(grpcException)); + } + } + + /** + * Request BTC and BSQ wallet balance information. + * + * @return bisq.proto.grpc.BalancesInfo + * @see https://bisq-network.github.io/slate/#balancesinfohttps://bisq-network.github.io/slate/#rpc-method-getbalances + * @see https://bisq-network.github.io/slate/#balancesinfo + */ + protected BalancesInfo getBalances() { + // An encrypted wallet must be unlocked before making this request. + var request = GetBalancesRequest.newBuilder().build(); + var reply = grpcStubs.walletsService.getBalances(request); + return reply.getBalances(); + } + + /** + * Sends a stop reqeust to the API daemon. + * + * @see https://bisq-network.github.io/slate/?java#service-shutdownserver + */ + protected void stopDaemon() { + var request = StopRequest.newBuilder().build(); + //noinspection ResultOfMethodCallIgnored + grpcStubs.shutdownService.stop(request); + log.info("API server shutdown request sent."); + } + + /** + * Locks an encrypted wallet. + * + * @throws NonFatalException if API daemon is not ready to perform wallet operations, wallet is not encrypted, + * or wallet is already locked. + * @throws StatusRuntimeException if a fatal error occurs. + * @see https://bisq-network.github.io/slate/?java#rpc-method-lockwallet + */ + protected void lockWallet() throws NonFatalException { + try { + var request = LockWalletRequest.newBuilder().build(); + //noinspection ResultOfMethodCallIgnored + grpcStubs.walletsService.lockWallet(request); + log.info("Wallet is locked."); + } catch (StatusRuntimeException grpcException) { + if (exceptionHasStatus.test(grpcException, UNAVAILABLE)) { + // The API server will throw a gPRC exception if the server is not ready to take calls, or wallet is + // not available (initialized), encrypted, or is already locked. These cases are not fatal. + throw new NonFatalException(toNonFatalErrorMessage.apply(grpcException)); + } else if (exceptionHasStatus.test(grpcException, FAILED_PRECONDITION)) { + // If wallet is not encrypted, we got a FAILED_PRECONDITION status code. + throw new NonFatalException(toNonFatalErrorMessage.apply(grpcException)); + } else if (exceptionHasStatus.test(grpcException, ALREADY_EXISTS)) { + // If wallet is already locked, we got an ALREADY_EXISTS status code. + throw new NonFatalException(toNonFatalErrorMessage.apply(grpcException)); + } else { + // Else we throw the fatal, unexpected exception. + throw grpcException; + } + } + } + + /** + * Unlocks a locked, encrypted wallet. + * + * @param walletPassword encrypted wallet's password + * @param timeoutInSeconds how long the wallet is to be unlocked + * @throws NonFatalException if API daemon is not ready to perform wallet operations, wallet is not encrypted, + * or wallet is already unlocked. + * @throws StatusRuntimeException if a fatal error occurs. + * @see https://bisq-network.github.io/slate/?java#rpc-method-unlockwallet + */ + protected void unlockWallet(String walletPassword, long timeoutInSeconds) throws NonFatalException { + try { + var request = UnlockWalletRequest.newBuilder() + .setPassword(walletPassword) + .setTimeout(timeoutInSeconds) + .build(); + //noinspection ResultOfMethodCallIgnored + grpcStubs.walletsService.unlockWallet(request); + log.info("Wallet is unlocked."); + } catch (StatusRuntimeException grpcException) { + // The API server will throw a gPRC exception if the server is not ready to take calls, or wallet is + // not available (initialized), encrypted, or is already unlocked. These cases are not fatal. + if (exceptionHasStatus.test(grpcException, UNAVAILABLE)) { + // If wallet is not yet initialized, we got an UNAVAILABLE status code. + throw new NonFatalException(toNonFatalErrorMessage.apply(grpcException)); + } else if (exceptionHasStatus.test(grpcException, FAILED_PRECONDITION)) { + // If wallet is not encrypted, we got a FAILED_PRECONDITION status code. + throw new NonFatalException(toNonFatalErrorMessage.apply(grpcException)); + } else if (exceptionHasStatus.test(grpcException, ALREADY_EXISTS)) { + // If wallet is already unlocked, we got an ALREADY_EXISTS status code. + throw new NonFatalException(toNonFatalErrorMessage.apply(grpcException)); + } else { + // Else we throw the fatal, unexpected exception. + throw grpcException; + } + } + } + + /** + * Returns PaymentAccount for given paymentAccountId, else throws an IllegalArgumentException. + * + * @param paymentAccountId Unique identifier of the payment account + * @return protobuf.PaymentAccount + * @see https://bisq-network.github.io/slate/?java#rpc-method-getpaymentaccounts + * @see https://bisq-network.github.io/slate/?java#paymentaccount + */ + protected PaymentAccount getPaymentAccount(String paymentAccountId) { + var request = GetPaymentAccountsRequest.newBuilder().build(); + var response = grpcStubs.paymentAccountsService.getPaymentAccounts(request); + return response.getPaymentAccountsList().stream() + .filter(p -> p.getId().equals(paymentAccountId)) + .findFirst().orElseThrow(() -> + new IllegalArgumentException( + format("Payment account with ID '%s' not found.", paymentAccountId))); + } + + /** + * Returns the default BSQ Swap PaymentAccount. + * + * @return protobuf.PaymentAccount + * @see https://bisq-network.github.io/slate/?java#rpc-method-getpaymentaccounts + * @see https://bisq-network.github.io/slate/?java#paymentaccount + */ + protected PaymentAccount getBsqSwapPaymentAccount() { + var request = GetPaymentAccountsRequest.newBuilder().build(); + var response = grpcStubs.paymentAccountsService.getPaymentAccounts(request); + var bsqSwapPaymentMethodId = BSQ_SWAP.name(); + return response.getPaymentAccountsList().stream() + .filter(p -> p.getPaymentMethod().getId().equals(bsqSwapPaymentMethodId)) + .findFirst().orElseThrow(() -> + new IllegalArgumentException("Your default BSQ Swap payment account was not found.")); + } + + protected void validateTradeFeeCurrencyCode(String bisqTradeFeeCurrency) { + var isValidCurrencyCode = bisqTradeFeeCurrency.equalsIgnoreCase("BSQ") + || bisqTradeFeeCurrency.equalsIgnoreCase("BTC"); + if (!isValidCurrencyCode) + throw new IllegalStateException( + format("Bisq trade fees must be paid in BSQ or BTC, not %s.", bisqTradeFeeCurrency)); + } + + /** + * Verifies (1) the given PaymentAccount has a selected trade currency, and (2) is a fiat payment account or an + * XMR payment account, else throws an IllegalStateException. (The bot does not yet support BSQ Swaps.) + */ + protected void validatePaymentAccount(PaymentAccount paymentAccount) { + if (!paymentAccount.hasSelectedTradeCurrency()) + throw new IllegalStateException( + format("PaymentAccount with ID '%s' and name '%s' has no selected currency definition.", + paymentAccount.getId(), + paymentAccount.getAccountName())); + + var selectedCurrencyCode = paymentAccount.getSelectedTradeCurrency().getCode(); + + // Hacky way to find out if this is an altcoin payment method, but there is no BLOCK_CHAINS proto enum or msg. + boolean isBlockChainsPaymentMethod = paymentAccount.getPaymentMethod().getId().equals("BLOCK_CHAINS"); + if (isBlockChainsPaymentMethod && !isXmr.test(selectedCurrencyCode)) + throw new IllegalStateException( + format("This bot only supports fiat and monero (XMR) trading, not the %s altcoin.", + selectedCurrencyCode)); + + if (isBsq.test(selectedCurrencyCode)) + throw new IllegalStateException("This bot does not support BSQ Swaps."); + } + + /** + * Returns an offer with the given offerId, else throws a gRPC StatusRuntimeException. + * + * @return bisq.proto.grpc.OfferInfo + * @see https://bisq-network.github.io/slate/?java#rpc-method-getoffer + * @see https://bisq-network.github.io/slate/?java#offerinfo + */ + protected OfferInfo getOffer(String offerId) { + var request = GetOfferRequest.newBuilder() + .setId(requireNonNull(offerId, "offerId cannot be null")) + .build(); + var response = grpcStubs.offersService.getOffer(request); + return response.getOffer(); + } + + /** + * Returns a list of offers with the given direction (BUY|SELL) and currency code, or an empty list. + * + * @return List + * @see https://bisq-network.github.io/slate/?java#rpc-method-getoffers + */ + protected List getOffers(String direction, String currencyCode) { + var request = GetOffersRequest.newBuilder() + .setDirection(requireNonNull(direction, "direction cannot be null").toUpperCase()) + .setCurrencyCode(requireNonNull(currencyCode, "currencyCode cannot be null").toUpperCase()) + .build(); + var response = grpcStubs.offersService.getOffers(request); + return response.getOffersList(); + } + + /** + * Takes a BSQ swap offer. Throws an exception if one of various possible causes of failure is detected. + * + * @throws NonFatalException if a failure to take the offer was detected. The offer could have been + * unavailable for one of many reasons, or the taker's wallet had insufficient BTC + * to cover the trade. Other problems could result in this exception being thrown, + * including attempts to make takeoffer requests more than once per minute. + * None of these are fatal errors for a bot, which can opt to not terminate itself. + * @throws StatusRuntimeException if a fatal error occurred while attempting to take the offer. + * @see https://bisq-network.github.io/slate/?java#rpc-method-takeoffer + */ + public void takeBsqSwapOffer(OfferInfo offer, long pollingInterval) throws NonFatalException { + OfferTaker offerTaker = new OfferTaker(grpcStubs, offer, pollingInterval); + // May throw fatal StatusRuntimeException, or NonFatalException. + offerTaker.takeOffer(); + log.info("You took offer '{}'; waiting on swap completion.", offer.getId()); + offerTaker.waitForBsqSwapCompletion(); // Blocks until swap is completed, or times out. + } + + /** + * Takes a fiat or xmr offer. Throws an exception if one of various possible causes of failure is detected. + * + * @throws NonFatalException if a failure to take the offer was detected. The offer could have been + * unavailable for one of many reasons, or the taker's wallet had insufficient BTC + * to cover the trade. Other problems could result in this exception being thrown, + * including attempts to make takeoffer requests more than once per minute. + * None of these are fatal errors for a bot, which can opt to not terminate itself. + * @throws StatusRuntimeException if a fatal error occurred while attempting to take the offer. + * @see https://bisq-network.github.io/slate/?java#rpc-method-takeoffer + */ + public void takeV1ProtocolOffer(OfferInfo offer, + PaymentAccount paymentAccount, + String bisqTradeFeeCurrency, + long pollingInterval) throws NonFatalException { + OfferTaker offerTaker = new OfferTaker(grpcStubs, + offer, + paymentAccount, + bisqTradeFeeCurrency, + pollingInterval); + // May throw fatal StatusRuntimeException, or NonFatalException. + offerTaker.takeOffer(); + log.info("You took offer '{}'; waiting on new trade contract preparation.", offer.getId()); + offerTaker.waitForTradePreparation(); // Blocks until new trade is prepared, or times out. + } + + /** + * Return a trade with the given ID. + * Use this method if you know the trade exists, and you want an exception thrown if not found. + * + * @param tradeId of the trade being requested + * @return bisq.proto.grpc.TradeInfo + * @see https://bisq-network.github.io/slate/?java#rpc-method-gettrade + */ + protected TradeInfo getTrade(String tradeId) { + var request = GetTradeRequest.newBuilder().setTradeId(tradeId).build(); + var response = grpcStubs.tradesService.getTrade(request); + return response.getTrade(); + } + + /** + * Return list of currently open, closed, or failed trades. + * + * @param category OPEN | CLOSED | FAILED + * @return List + */ + protected List getTrades(Category category) { + var request = GetTradesRequest.newBuilder().setCategory(category).build(); + var response = grpcStubs.tradesService.getTrades(request); + return response.getTradesList(); + } + + /** + * Print list of trade summaries to stdout. + * + * @param category category OPEN | CLOSED | FAILED + */ + protected void printTradesSummary(Category category) { + var trades = getTrades(category); + BotUtils.printTradesSummary(category, trades); + } + + /** + * Send a "payment started" message to the BTC seller. + * + * @param tradeId the trade's unique identifier + */ + protected void confirmPaymentStarted(String tradeId) { + var request = ConfirmPaymentStartedRequest.newBuilder() + .setTradeId(tradeId) + .build(); + //noinspection ResultOfMethodCallIgnored + grpcStubs.tradesService.confirmPaymentStarted(request); + } + + /** + * Send a "payment received confirmation" message to the BTC buyer. + * + * @param tradeId the trade's unique identifier + */ + protected void confirmPaymentReceived(String tradeId) { + var request = ConfirmPaymentReceivedRequest.newBuilder() + .setTradeId(tradeId) + .build(); + //noinspection ResultOfMethodCallIgnored + grpcStubs.tradesService.confirmPaymentReceived(request); + } + + /** + * Close a completed trade -- move it to trade history list. + * + * @param tradeId the trade's unique identifier + */ + protected void closeTrade(String tradeId) { + var request = CloseTradeRequest.newBuilder() + .setTradeId(tradeId) + .build(); + //noinspection ResultOfMethodCallIgnored + grpcStubs.tradesService.closeTrade(request); + } + + /** + * Returns a BigDecimal representing the current market price, source from Bisq price feed services. + * + * @return BigDecimal the current market price + * @see https://bisq-network.github.io/slate/?java#rpc-method-getmarketprice + */ + protected BigDecimal getCurrentMarketPrice(String currencyCode) { + var request = MarketPriceRequest.newBuilder() + .setCurrencyCode(currencyCode) + .build(); + var response = grpcStubs.priceService.getMarketPrice(request); + var precision = isAltcoin.test(currencyCode) ? 8 : 4; + return BigDecimal.valueOf(response.getPrice()).setScale(precision, HALF_UP); + } + + /** + * Returns a BigDecimal representing the 30-day, volume weighted average BSQ price in BTC. + * + * @return BigDecimal the 30-day average price + * // TODO fix link below to api doc + * @see https://bisq-network.github.io/slate/?java#rpc-method-getavgbsqprice + * // TODO fix link above to api doc + */ + protected BigDecimal get30DayAvgBsqPriceInBtc() { + var request = GetAverageBsqTradePriceRequest.newBuilder() + .setDays(30) + .build(); + var response = grpcStubs.priceService.getAverageBsqTradePrice(request); + return new BigDecimal(response.getPrice().getBtcPrice()); + } + + /** + * Return a summary of BTC transaction fee rate information, including the Bisq network's most recently available + * BTC tx fee rate, in sats/byte, and the user's custom tx fee rate, if set. + *

+ * + * @return bisq.proto.grpc.TxFeeRateInfo + * @see https://bisq-network.github.io/slate/#txfeerateinfo + */ + protected TxFeeRateInfo getTxFeeRateInfo() { + var request = GetTxFeeRateRequest.newBuilder().build(); + var response = grpcStubs.walletsService.getTxFeeRate(request); + return response.getTxFeeRateInfo(); + } + + protected void validatePollingInterval(long pollingInterval) { + if (pollingInterval < 1_000) + throw new IllegalStateException("Cannot poll offer-book faster than 1x per second."); + } + + /** + * Print information about offers taken during bot simulation. + */ + protected void printDryRunProgress() { + if (isDryRun && offersTaken.size() > 0) { + log.info("You have \"taken\" {} offer(s) during dry run:", offersTaken.size()); + printOffersSummary(offersTaken); + } + } + + /** + * Add offer to list of taken offers -- for dry runs only. + */ + protected void addToOffersTaken(OfferInfo offer) { + offersTaken.add(offer); + printOfferSummary(offer); + log.info("Did not actually take that offer during this simulation."); + } + + /** + * Stall the bot for the number of seconds represented by the given durationInMillis. + *

+ * If the bot can use the system's bash command language interpreter, show the countdown in the terminal, + * else log a "Will wake up in {} seconds" statement, and put the current thread to sleep. + * + * @param log bot implementation's logger + * @param durationInMillis number of milliseconds to stall + */ + protected void runCountdown(Logger log, long durationInMillis) { + var seconds = toSeconds.apply(durationInMillis).intValue(); + if (canUseBash) { + showCountdown(seconds); + } else { + log.info("Will wake up in {} seconds. ", seconds); + sleep(durationInMillis); + } + } + + /** + * Lock the wallet, stop the API daemon, and terminate the bot with a non-zero status (error). + */ + protected void shutdownAfterFatalError(String errorMessage) { + isShutdown = true; + try { + lockWallet(); + } catch (NonFatalException ex) { + log.warn(ex.getMessage()); + } + log.error(errorMessage); + sleep(5_000); + log.error("Sending stop request to daemon."); + stopDaemon(); + exit(1); + } + + /** + * Returns Properties object for this bot. + * + * @return Properties loaded from file specified in '--conf=path' program argument. + */ + protected Properties loadConfigFile() { + if (conf.equals(defaultPropertiesFilename.get())) + return loadDefaultProperties(); + else + return loadExternalProperties(); + } + + /** + * Returns default properties file named 'this.getClass().getSimpleName() + ".properties"'. + * + * @return Properties loaded from file + */ + private Properties loadDefaultProperties() { + Properties properties = new java.util.Properties(); + try { + var defaultFilename = defaultPropertiesFilename.get(); + properties.load(this.getClass().getClassLoader().getResourceAsStream(defaultFilename)); + log.info("Internal configuration loaded from {}.", defaultFilename); + } catch (Exception ex) { + throw new IllegalStateException(ex); + } + return properties; + } + + /** + * Return a Properties object loaded from an optional conf file, specified by the --conf=path program argument. + */ + private Properties loadExternalProperties() { + var confFile = new File(conf); + if (!confFile.exists()) + throw new IllegalStateException(format("Configuration file %s does not exist.", conf)); + + InputStream is; + try { + is = new FileInputStream(confFile); + Properties properties = new java.util.Properties(); + try { + properties.load(is); + log.info("External configuration loaded from {}.", confFile.getAbsolutePath()); + return properties; + } catch (FileNotFoundException ignored) { + // Cannot happen here. Ignore FileNotFoundException because confFile.exists() == true. + } catch (IOException ex) { + // Not ignored because file can exist but fail to be loaded into Properties object. + throw new IllegalStateException(ex); + } finally { + is.close(); + } + } catch (FileNotFoundException ignored) { + // Cannot happen here. Ignore FileNotFoundException because confFile.exists() == true. + } catch (IOException ex) { + // Not ignored because may fail to create new FileInputStream(confFile). + throw new IllegalStateException(ex); + } + throw new IllegalStateException(format("Could not load properties from %s.", conf)); + } +} diff --git a/java-examples/src/main/java/bisq/bots/BotUtils.java b/java-examples/src/main/java/bisq/bots/BotUtils.java new file mode 100644 index 0000000..86646ff --- /dev/null +++ b/java-examples/src/main/java/bisq/bots/BotUtils.java @@ -0,0 +1,665 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ +package bisq.bots; + +import bisq.bots.table.builder.TableBuilder; +import bisq.proto.grpc.GetTradesRequest; +import bisq.proto.grpc.OfferInfo; +import bisq.proto.grpc.TradeInfo; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.Logger; +import protobuf.PaymentAccount; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.math.BigDecimal; +import java.math.MathContext; +import java.time.Duration; +import java.util.*; +import java.util.function.*; + +import static bisq.bots.CurrencyFormat.toSatoshis; +import static bisq.bots.table.builder.TableType.*; +import static java.lang.String.format; +import static java.lang.System.*; +import static java.math.BigDecimal.ZERO; +import static java.math.RoundingMode.HALF_UP; + +/** + * Convenience methods and functions not depending on a bot's state nor the need to send requests to the API daemon. + */ +@Slf4j +public class BotUtils { + + private static final String BANNER = "##############################################################################"; + + public static final Predicate isBsq = (currencyCode) -> currencyCode.equalsIgnoreCase("BSQ"); + public static final Predicate isXmr = (currencyCode) -> currencyCode.equalsIgnoreCase("XMR"); + public static final Predicate isAltcoin = (currencyCode) -> isBsq.test(currencyCode) || isXmr.test(currencyCode); + + /** + * Return price precision of 8 for altcoin, 4 for fiat. + */ + public static final Function toPricePrecision = (currencyCode) -> + isAltcoin.test(currencyCode) ? 8 : 4; + + /** + * Calculates a target price from given max market price margin and market price. + * + * @param targetMarketPriceMargin the maximum or minimum market price margin + * @param currentMarketPrice the current market price + * @param currencyCode the asset currency code (calculated price precision = 4 for fiat, 8 for altcoin) + * @return BigDecimal a target price + */ + public static BigDecimal calcTargetPrice(BigDecimal targetMarketPriceMargin, + BigDecimal currentMarketPrice, + String currencyCode) { + if (!isZero.test(targetMarketPriceMargin) && targetMarketPriceMargin.precision() <= 2) + throw new IllegalArgumentException( + format("Price margin percent literal argument %s is invalid;" + + " it must have a precision of at least 2 decimal places.", + targetMarketPriceMargin)); + + var maxMarketPriceMarginAsDecimal = scaleAsDecimal.apply(targetMarketPriceMargin); + var precision = toPricePrecision.apply(currencyCode); + return currentMarketPrice.add(currentMarketPrice + .multiply(maxMarketPriceMarginAsDecimal, new MathContext(precision, HALF_UP))) + .setScale(precision, HALF_UP); + } + + /** + * Calculates a target BSQ price from given max market price margin and an average BSQ market price. + * + * @param targetMarketPriceMargin the maximum or minimum market price margin + * @param avgBsqPrice the average BSQ price + * @return BigDecimal a target price + */ + public static BigDecimal calcTargetBsqPrice(BigDecimal targetMarketPriceMargin, + BigDecimal avgBsqPrice) { + if (!isZero.test(targetMarketPriceMargin) && targetMarketPriceMargin.precision() <= 2) + throw new IllegalArgumentException( + format("Price margin percent literal argument %s is invalid;" + + " it must have a precision of at least 2 decimal places.", + targetMarketPriceMargin)); + + var maxMarketPriceMarginAsDecimal = scaleAsDecimal.apply(targetMarketPriceMargin); + return avgBsqPrice.add(avgBsqPrice + .multiply(maxMarketPriceMarginAsDecimal, new MathContext(8, HALF_UP))) + .setScale(8, HALF_UP); + } + + /** + * Convert milliseconds to seconds. + */ + public static final Function toSeconds = (ms) -> Duration.ofMillis(ms).getSeconds(); + + /** + * Return true if given BigDecimal equals 0.00. + */ + public static final Predicate isZero = (d) -> d.compareTo(ZERO) == 0; + + /** + * Convert a BigDecimal representing a % literal to a BigDecimal representing + * the equivalent decimal, e.g., 1.00 (%) converts to 0.01. + */ + public static final Function scaleAsDecimal = (pctLiteral) -> + pctLiteral.divide(new BigDecimal("100"), HALF_UP); + + /** + * Return a BigDecimal representing the difference as a percentage between a base number and n, + * i.e., how much above or below (as a %) is n compared to base? + */ + public static final BiFunction diffAsPercent = (base, n) -> { + BigDecimal factor = new BigDecimal("100"); + BigDecimal diff = n.divide(base, 4, HALF_UP).multiply(factor); + return diff.subtract(factor); + }; + + /** + * Return true if the margin price based offer's market price margin (%) >= minMarketPriceMargin (%). + */ + public static final BiPredicate isMarginGEMinMarketPriceMargin = + (offer, minMarketPriceMargin) -> offer.getUseMarketBasedPrice() + && offer.getMarketPriceMarginPct() >= minMarketPriceMargin.doubleValue(); + + /** + * Return true if the margin price based offer's market price margin (%) <= maxMarketPriceMargin (%). + */ + public static final BiPredicate isMarginLEMaxMarketPriceMargin = + (offer, maxMarketPriceMargin) -> offer.getUseMarketBasedPrice() + && offer.getMarketPriceMarginPct() <= maxMarketPriceMargin.doubleValue(); + + /** + * Return true is fixed-price offer's price <= the bot's max market price margin. Allows bot to + * take a fixed-priced offer if the price is <= maxMarketPriceMargin (%) of the current market price. + */ + public static boolean isFixedPriceLEMaxMarketPriceMargin(OfferInfo offer, + BigDecimal currentMarketPrice, + BigDecimal maxMarketPriceMargin) { + if (offer.getUseMarketBasedPrice()) + return false; + + BigDecimal offerPrice = new BigDecimal(offer.getPrice()); + + // How much above or below currentMarketPrice (as a %) is the offer's fixed-price? + BigDecimal distanceFromMarketPrice = diffAsPercent.apply(currentMarketPrice, offerPrice); + + // Return true if distanceFromMarketPrice <= maxMarketPriceMargin. + return distanceFromMarketPrice.compareTo(maxMarketPriceMargin) <= 0; + } + + /** + * Return true is fixed-price offer's price >= the bot's minimum market price margin. Allows bot to + * take a fixed-priced offer if the price is >= minMarketPriceMargin (%) of the current market price. + */ + public static boolean isFixedPriceGEMinMarketPriceMargin(OfferInfo offer, + BigDecimal currentMarketPrice, + BigDecimal minMarketPriceMargin) { + if (offer.getUseMarketBasedPrice()) + return false; + + BigDecimal offerPrice = new BigDecimal(offer.getPrice()); + + // How much above or below currentMarketPrice (as a %) is the offer's fixed-price? + BigDecimal distanceFromMarketPrice = diffAsPercent.apply(currentMarketPrice, offerPrice); + + // Return true if distanceFromMarketPrice <= maxMarketPriceMargin. + return distanceFromMarketPrice.compareTo(minMarketPriceMargin) >= 0; + } + + /** + * Return String "below" if maxMarketPriceMargin (%) <= 0.00, else "above". + */ + public static final Function aboveOrBelowMarketPrice = (maxMarketPriceMargin) -> + maxMarketPriceMargin.compareTo(ZERO) <= 0 ? "below" : "above"; + + /** + * Return true if offer.amt >= minAmount AND offer.amt <= maxAmount (within the boundaries). + * TODO API's takeoffer needs to support taking offer's minAmount. + */ + public static boolean isWithinBTCAmountBounds(OfferInfo offer, BigDecimal minAmount, BigDecimal maxAmount) { + return offer.getAmount() >= toSatoshis(minAmount) && offer.getAmount() <= toSatoshis(maxAmount); + } + + /** + * Return true if the given StatusRuntimeException's Status matches the given Status. + */ + public static final BiPredicate exceptionHasStatus = (ex, status) -> + ex.getStatus().getCode() == status.getCode(); + + /** + * Return a StatusRuntimeException message stripped of it's leading Status Code's enum name. + */ + public static final Function toCleanErrorMessage = (grpcException) -> + grpcException.getMessage().replaceFirst("^[A-Z_]+: ", ""); + + /** + * Return a StatusRuntimeException message stripped of it's leading Status Code's enum name, + * then prepended with String "Non-Fatal Error (", and appended with String ")". + */ + public static final Function toNonFatalErrorMessage = (grpcException) -> + "Non-Fatal Error (" + toCleanErrorMessage.apply(grpcException) + ")"; + + /** + * Return true if the given offer's payment method matches the given payment account's payment method. + */ + public static final BiPredicate usesSamePaymentMethod = (o, p) -> + o.getPaymentMethodId().equals(p.getPaymentMethod().getId()); + + /** + * Return true if I am the BTC buyer for the given trade. + */ + public static final Predicate isBtcBuyer = (trade) -> { + var isMyOffer = trade.getOffer().getIsMyOffer(); + var isBuyerMakerAndSellerTaker = trade.getContract().getIsBuyerMakerAndSellerTaker(); + return isMyOffer == isBuyerMakerAndSellerTaker; + }; + + /** + * Put the current thread to sleep for given number of milliseconds. + */ + public static void sleep(long ms) { + try { + Thread.sleep(ms); + } catch (InterruptedException ex) { + // ignored + } + } + + /** + * Return a wallet password read from stdin. If read from a command terminal, input will not be echoed. + * If run in a virtual terminal (IDE console), the input will be echoed. + * + * @param prompt password prompt + * @return String the password + */ + public static String readWalletPassword(String prompt) { + String walletPassword; + var console = console(); // Returns null in IDE console! + // System.console() returns null if you do not launch your java application with a real console. + if (console == null) { + // Have to read it in a less secure way in the IDE's virtual console. + out.println(prompt); + BufferedReader reader = new BufferedReader(new InputStreamReader(in)); + try { + walletPassword = reader.readLine(); + } catch (IOException ex) { + throw new RuntimeException(ex); + } + } else { + char[] pwdChars = console.readPassword(prompt); + walletPassword = new String(pwdChars); + } + return walletPassword; + } + + /** + * Return the given String[] args with an additional --wallet-password=xyz option appended to it. + * + * @param args program arguments + * @param walletPassword wallet password + * @return String[] appended program arguments + */ + public static String[] appendWalletPasswordOpt(String[] args, String walletPassword) { + String[] walletPasswordOpt = new String[]{"--wallet-password=" + walletPassword}; + String[] newOpts = new String[args.length + 1]; + arraycopy(args, 0, newOpts, 0, args.length); + arraycopy(walletPasswordOpt, 0, newOpts, args.length, walletPasswordOpt.length); + return newOpts; + } + + /** + * Returns a validated address:port specification as a String. + * + * @param addressString The address:port pair being validated. + * @return String + */ + public static String getValidatedPeerAddress(String addressString) { + String[] hostnameAndPort = addressString.split(":"); + String hostname; + int port; + try { + if (hostnameAndPort.length < 2) { + throw new IllegalStateException(format("Invalid preferredTradingPeers configuration:%n" + + "\t\t%s%n\tEach address much include a port, i.e, host:port.", + addressString)); + } + hostname = hostnameAndPort[0].trim(); + port = Integer.parseInt(hostnameAndPort[1].trim()); + } catch (Exception ex) { + throw new IllegalStateException(format("Invalid preferredTradingPeers configuration:%n" + + "\t\t%s%n\tMultiple addresses must be separated by commas.", + addressString), + ex); + } + return hostname + ":" + port; + } + + /** + * Return given Map transformed into a String representing a table with two columns: label and value. + *

+ * The map argument should contain only scalar values or short strings as values + * (not lists or maps), or you will get ugly results. + */ + public static final BiFunction, String> toTable = (title, map) -> { + var mapElements = map.entrySet(); + Supplier longestLabel = () -> { + int[] len = {0}; // Make implicitly final to modify in map element iteration. + mapElements.forEach((e) -> { + var labelLen = e.getKey().length(); + len[0] = Math.max(labelLen, len[0]); + }); + return len[0]; + }; + int labelWidth = longestLabel.get() + 2; + Supplier resultsTable = () -> { + var numRows = mapElements.size(); + var rows = new StringBuilder(); + int[] rowNum = {0}; // Make implicitly final to modify in map element iteration. + mapElements.forEach((e) -> { + var label = e.getKey(); + String value; + if (e.getValue() instanceof Boolean) { + value = ((Boolean) e.getValue()) ? "YES" : "NO"; + } else { + value = e.getValue().toString(); + } + var rowFormatSpec = (label.startsWith("\t")) + ? "%-" + labelWidth + "s" + " " + "%s" + : "%-" + (labelWidth + 3) + "s" + " " + "%s"; + var row = format(rowFormatSpec, label, value); + rows.append("\t").append(row); + if (++rowNum[0] < numRows) { + rows.append("\n"); + } + }); + return rows.toString(); + }; + return title + "\n" + + resultsTable.get(); + }; + + /** + * Print offer summary to stdout. + * + * @param offer printed offer + */ + public static void printOfferSummary(OfferInfo offer) { + new TableBuilder(OFFER_TBL, offer).build().print(out); + } + + /** + * Print list of offer summaries to stdout + * + * @param offers printed offer list + */ + public static void printOffersSummary(List offers) { + new TableBuilder(OFFER_TBL, offers).build().print(out); + } + + /** + * Print trade summary to stdout. + * + * @param trade printed trade + */ + public static void printTradeSummary(TradeInfo trade) { + new TableBuilder(TRADE_DETAIL_TBL, trade).build().print(out); + } + + /** + * Print list of trade summaries to stdout. + * + * @param category category OPEN | CLOSED | FAILED + * @param trades list of trades + */ + public static void printTradesSummary(GetTradesRequest.Category category, List trades) { + log.info("{} trades:", category.name()); + switch (category) { + case CLOSED -> new TableBuilder(CLOSED_TRADES_TBL, trades).build().print(out); + case FAILED -> new TableBuilder(FAILED_TRADES_TBL, trades).build().print(out); + default -> new TableBuilder(OPEN_TRADES_TBL, trades).build().print(out); + } + } + + /** + * Prints PaymentAccount summary to stdout. + * + * @param paymentAccount the printed PaymentAccount + */ + public static void printPaymentAccountSummary(PaymentAccount paymentAccount) { + new TableBuilder(PAYMENT_ACCOUNT_TBL, paymentAccount).build().print(out); + } + + /** + * Log a CLI confirmpaymentstarted command for a simulated trading peer. + * + * @param log calling bot's logger + * @param tradingPeerApiPassword trading peer's CLI --password param value + * @param tradingPeerApiPort trading peer's CLI --port param value + * @param tradeId trade's unique identifier (cannot be short-id) + */ + public static void printCliPaymentStartedCommand(Logger log, + String tradingPeerApiPassword, + int tradingPeerApiPort, + String currencyCode, + String tradeId) { + log.warn(BANNER); + log.warn("BTC buyer must manually confirm {} payment has been sent" + + " with a confirmpaymentstarted CLI command:", + currencyCode); + log.warn("./bisq-cli --password={} --port={} confirmpaymentstarted --trade-id={}", + tradingPeerApiPassword, + tradingPeerApiPort, + tradeId); + log.warn(BANNER); + } + + /** + * Log a CLI confirmpaymentreceived command for a simulated trading peer. + * + * @param log calling bot's logger + * @param tradingPeerApiPassword trading peer's CLI --password param value + * @param tradingPeerApiPort trading peer's CLI --port param value + * @param tradeId trade's unique identifier (cannot be short-id) + */ + public static void printCliPaymentReceivedConfirmationCommand(Logger log, + String tradingPeerApiPassword, + int tradingPeerApiPort, + String currencyCode, + String tradeId) { + log.warn(BANNER); + log.warn("BTC seller must manually confirm {} payment was received" + + " with a confirmpaymentreceived CLI command:", + currencyCode); + log.warn("./bisq-cli --password={} --port={} confirmpaymentreceived --trade-id={}", + tradingPeerApiPassword, + tradingPeerApiPort, + tradeId); + log.warn(BANNER); + } + + /** + * Log a CLI closetrade command for a simulated trading peer. + * + * @param log calling bot's logger + * @param tradingPeerApiPassword trading peer's CLI --password param value + * @param tradingPeerApiPort trading peer's CLI --port param value + * @param tradeId trade's unique identifier (cannot be short-id) + */ + public static void printCliCloseTradeCommand(Logger log, + String tradingPeerApiPassword, + int tradingPeerApiPort, + String tradeId) { + log.warn(BANNER); + log.warn("Trading peer must manually close trade with a closetrade CLI command:"); + log.warn("./bisq-cli --password={} --port={} closetrade --trade-id={}", + tradingPeerApiPassword, + tradingPeerApiPort, + tradeId); + log.warn(BANNER); + } + + /** + * Log a CLI gettrades --category=closed command for a simulated trading peer. + * + * @param log calling bot's logger + * @param tradingPeerApiPassword trading peer's CLI --password param value + * @param tradingPeerApiPort trading peer's CLI --port param value + */ + public static void printCliGetClosedTradesCommand(Logger log, + String tradingPeerApiPassword, + int tradingPeerApiPort) { + log.warn(BANNER); + log.warn("Trading peer can view completed trade history with a gettrades CLI command:"); + log.warn("./bisq-cli --password={} --port={} gettrades --category=closed", + tradingPeerApiPassword, + tradingPeerApiPort); + log.warn(BANNER); + } + + /** + * Run a bash script to count down the given number of seconds, printing each character of output from stdout. + *

+ * Can only be run if the system's bash command language interpreter can be found. + * + * @param seconds to count down + */ + public static void showCountdown(int seconds) { + getBashPath().ifPresentOrElse((bashPath) -> { + var bashScript = format( + "for i in {%d..1}; do echo -ne \"Waking up in $i seconds...\\r\" && sleep 1; done; echo -ne \"\\r\"", seconds); + try { + BotUtils.runBashCommand(bashScript); + } catch (IOException ex) { + throw new RuntimeException("Error running bash script.", ex); + } catch (InterruptedException ignored) { + // ignored + } + }, () -> { + throw new UnsupportedOperationException("Bash command language interpreter not found."); + }); + } + + /** + * Execute a bash system command, print process' stdout during the command's execution, + * and return its status code (0 or 1). + * + * @param bashCommand the system bash command + * @return int system command status code + * @throws IOException if an I/O error occurs + * @throws InterruptedException if the current thread is interrupted by another thread while it is waiting, + * then the wait is ended and an InterruptedException is thrown. + * @throws UnsupportedOperationException if the command language interpreter could not be found on the system, or + * if the operating system does not support the creation of processes. + */ + @SuppressWarnings("UnusedReturnValue") + public static int runBashCommand(String bashCommand) throws IOException, InterruptedException { + var bashPath = getBashPath(); + if (bashPath.isPresent()) { + List cmdOptions = new ArrayList<>() {{ + //noinspection OptionalGetWithoutIsPresent + add(bashPath.get()); + add("-c"); + add(bashCommand); + }}; + Process process = new ProcessBuilder(cmdOptions).start(); + try (InputStreamReader isr = new InputStreamReader(process.getInputStream())) { + int c; + while ((c = isr.read()) >= 0) { + out.print((char) c); + out.flush(); + } + } + return process.waitFor(); + } else { + throw new UnsupportedOperationException("Bash util not found on this " + getOSName() + " system."); + } + } + + /** + * Return an Optional for the absolute path of the system's bash utility, + * if it exists at one of two locations: "/bin/bash", or "/usr/bin/bash". + * + * @return Optional + */ + public static Optional getBashPath() { + if (isUnix()) { + var f1 = new File("/bin/bash"); + var f2 = new File("/usr/bin/bash"); + if (f1.exists() && f1.canExecute()) { + return Optional.of(f1.getAbsolutePath()); + } else if (f2.exists() && f2.canExecute()) { + return Optional.of(f2.getAbsolutePath()); + } else { + return Optional.empty(); + } + } else { + return Optional.empty(); + } + } + + /** + * Return true if OS is any flavor of Linux. + * + * @return true if OS is any flavor of Linux + */ + public static boolean isUnix() { + return isOSX() || isLinux() || getOSName().contains("freebsd"); + } + + /** + * Return true if OS is Windows. + * + * @return true if OS is Windows + */ + public static boolean isWindows() { + return getOSName().contains("win"); + } + + /** + * Return true if running on a virtualized OS within Qubes. + * + * @return true if running on a virtualized OS within Qubes + */ + public static boolean isQubesOS() { + // For Linux qubes, "os.version" looks like "4.19.132-1.pvops.qubes.x86_64" + // The presence of the "qubes" substring indicates this Linux is running as a qube + // This is the case for all 3 virtualization modes (PV, PVH, HVM) + // In addition, this works for both simple AppVMs, as well as for StandaloneVMs + // TODO This might not work for detecting Qubes virtualization for other OSes + // like Windows + return getOSVersion().contains("qubes"); + } + + /** + * Return true if OS is Mac. + * + * @return true if OS is Mac + */ + public static boolean isOSX() { + return getOSName().contains("mac") || getOSName().contains("darwin"); + } + + /** + * Return true if OS is Linux. + * + * @return true if OS is Linux + */ + public static boolean isLinux() { + return getOSName().contains("linux"); + } + + /** + * Return true if OS is Debian Linux. + * + * @return true if OS is Debian Linux + */ + public static boolean isDebianLinux() { + return isLinux() && new File("/etc/debian_version").isFile(); + } + + /** + * Return true if OS is Redhat Linux. + * + * @return true if OS is Redhat Linux + */ + public static boolean isRedHatLinux() { + return isLinux() && new File("/etc/redhat-release").isFile(); + } + + /** + * Returns the OS name in lower case. + * + * @return OS name + */ + public static String getOSName() { + return System.getProperty("os.name").toLowerCase(Locale.US); + } + + /** + * Returns the OS version in lower case. + * + * @return OS version + */ + public static String getOSVersion() { + return System.getProperty("os.version").toLowerCase(Locale.US); + } +} diff --git a/java-examples/src/main/java/bisq/bots/Config.java b/java-examples/src/main/java/bisq/bots/Config.java new file mode 100644 index 0000000..7438073 --- /dev/null +++ b/java-examples/src/main/java/bisq/bots/Config.java @@ -0,0 +1,135 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ +package bisq.bots; + + +import joptsimple.OptionParser; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import java.io.File; +import java.io.IOException; +import java.io.PrintStream; + +import static java.lang.Boolean.FALSE; +import static java.lang.System.*; + + +/** + * Parses program arguments common to Java bot examples. + */ +@Slf4j +@Getter +public class Config { + + private final String host; + private final int port; + private final String password; + private final String walletPassword; + private final String conf; + private final boolean dryRun; + // This is an experimental option for simulating and automating protocol payment steps during bot development. + // Be extremely careful in its use; You do not want to "simulate" payments when API daemon is connected to mainnet. + private final boolean simulatePaymentSteps; + + public Config(String[] args, String defaultPropertiesFilename) { + var parser = new OptionParser(); + var helpOpt = parser.accepts("help", "Print this help text") + .forHelp(); + var hostOpt = parser.accepts("host", "API daemon hostname or IP") + .withRequiredArg() + .defaultsTo("localhost"); + var portOpt = parser.accepts("port", "API daemon port") + .withRequiredArg() + .ofType(Integer.class) + .defaultsTo(9998); + var passwordOpt = parser.accepts("password", "API daemon password (required)") + .withRequiredArg(); + var walletPasswordOpt = parser.accepts("wallet-password", "API wallet password (required)") + .withRequiredArg(); + var confOpt = parser.accepts("conf", "Bot configuration file (required)") + .withRequiredArg(); + var dryRunOpt = parser.accepts("dryrun", "Pretend to take an offer (default=false)") + .withRequiredArg() + .ofType(boolean.class) + .defaultsTo(FALSE); + var simulateRegtestPaymentStepsOpt = + parser.accepts("simulate-regtest-payment", "Simulate regtest payment steps (default=false)") + .withOptionalArg() + .ofType(boolean.class) + .defaultsTo(FALSE); + + var options = parser.parse(args); + if (options.has(helpOpt)) { + printHelp(parser, out); + exit(0); + } + + this.host = options.valueOf(hostOpt); + this.port = options.valueOf(portOpt); + + this.password = options.valueOf(passwordOpt); + if (password == null) { + log.error("Missing required '--password=' option"); + printHelp(parser, err); + exit(1); + } + + this.walletPassword = options.valueOf(walletPasswordOpt); + if (walletPassword == null) { + log.error("Missing required '--wallet-password=' option"); + printHelp(parser, err); + exit(1); + } + + if (!options.has(confOpt)) { + this.conf = defaultPropertiesFilename; + } else { + this.conf = options.valueOf(confOpt); + if (!(new File(conf).exists())) { + log.error("Invalid '--conf=' option: external file does not exist."); + printHelp(parser, err); + exit(1); + } + } + + this.dryRun = options.valueOf(dryRunOpt); + this.simulatePaymentSteps = options.valueOf(simulateRegtestPaymentStepsOpt); + + if (dryRun && simulatePaymentSteps) { + log.error(""" + The '--dryrun` and '--simulate-regtest-payment' options are mutually exclusive. + They cannot both be true. Use '--dryrun=true` on mainnet, to see what real offers your bot would take. + Use '--simulate-regtest-payment=true' on regtest, to simulate a trade to its completion."""); + printHelp(parser, err); + exit(1); + } + } + + private static void printHelp(OptionParser parser, @SuppressWarnings("SameParameterValue") PrintStream stream) { + try { + stream.println("Bisq RPC Client"); + stream.println(); + stream.println("Usage: ScriptName [options]"); + stream.println(); + parser.printHelpOn(stream); + stream.println(); + } catch (IOException ex) { + ex.printStackTrace(stream); + } + } +} diff --git a/java-examples/src/main/java/bisq/bots/NonFatalException.java b/java-examples/src/main/java/bisq/bots/NonFatalException.java new file mode 100644 index 0000000..99e5240 --- /dev/null +++ b/java-examples/src/main/java/bisq/bots/NonFatalException.java @@ -0,0 +1,45 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ +package bisq.bots; + +import lombok.Getter; + +/** + * Custom exception for handling non-fatal exceptions occurring in bots. + */ +public class NonFatalException extends Exception { + + @Getter + private final long stallTime; + + public NonFatalException(String message) { + this(message, null, 0); + } + + public NonFatalException(String message, long stallTime) { + this(message, null, stallTime); + } + + public NonFatalException(String message, Throwable cause, long stallTime) { + super(message, cause); + this.stallTime = stallTime; + } + + public boolean hasStallTime() { + return stallTime > 0; + } +} diff --git a/java-examples/src/main/java/bisq/bots/OfferTaker.java b/java-examples/src/main/java/bisq/bots/OfferTaker.java new file mode 100644 index 0000000..08a29b3 --- /dev/null +++ b/java-examples/src/main/java/bisq/bots/OfferTaker.java @@ -0,0 +1,321 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ +package bisq.bots; + +import bisq.proto.grpc.*; +import io.grpc.StatusRuntimeException; +import lombok.extern.slf4j.Slf4j; +import protobuf.PaymentAccount; + +import javax.annotation.Nullable; +import java.util.Optional; + +import static bisq.bots.BotUtils.*; +import static bisq.proto.grpc.GetOfferCategoryReply.OfferCategory.BSQ_SWAP; +import static io.grpc.Status.*; +import static java.lang.String.format; +import static java.util.Objects.requireNonNull; + +/** + * This helper class exists to encapsulate takeoffer error handling, and waiting for a new trade to be fully + * initialized trade. + *

+ * For v1 protocol trades, the happy path is a successful takeoffer call, resulting in a quickly prepared, new trade. + * But several things can go wrong. An offer may be unavailable for one of several reasons. When an offer availability + * check fails while processing a takeoffer request, the server's response object will contain a "failure reason" + * instead of a new trade, and no gRPC StatusRuntimeException is thrown. + *

+ *     See https://bisq-network.github.io/slate/#rpc-method-takeoffer
+ *     See https://bisq-network.github.io/slate/#availabilityresultwithdescription
+ *     See https://bisq-network.github.io/slate/#availabilityresult
+ * 
+ *

+ * If offer availability checks passed, the takeoffer request can still fail for other reasons, and the API server + * will send a gRPC StatusRuntimeException to the client. Two likely errors are (1) insufficient BTC in the offer + * taker's wallet, or (2) the API server's takeoffer call rate of 1 takeoffer request per minute has been exceeded. + * In both cases, the error should not be fatal to a bot. The bot can keep running while the user funds the wallet, + * and the bot should stall long enough to make the rate meter happy again. + *

+ * Any other gRPC StatusRuntimeException passed up to the client should be considered fatal. + *

+ * The happy path for taking a BSQ Swap offer is a completed swap. + */ +@Slf4j +class OfferTaker { + + private static final int MAX_GET_NEW_TRADE_ATTEMPTS = 15; + + private final GrpcStubs grpcStubs; + private final OfferInfo offer; + @Nullable + private final PaymentAccount paymentAccount; // Not used for taking bsq swaps. + @Nullable + private final String bisqTradeFeeCurrency; // Not used for taking bsq swaps. + private final long pollingInterval; + private final GetTradeRequest getTradeRequest; + + /** + * Constructor for taking BSQ swap offers. + * Payment acct id nor trade fee currency code are used in takeoffer requests. + * + * @param grpcStubs gRPC service stubs, initialized with hostname, port, and credentials. + * @param offer The offer to take. + * @param pollingInterval The calling bot's polling interval, in milliseconds (some situations require calculating + * a stalling period before making the next request). + */ + OfferTaker(GrpcStubs grpcStubs, + OfferInfo offer, + long pollingInterval) { + this(grpcStubs, offer, null, null, pollingInterval); + } + + /** + * Constructor for taking v1 protocol offers (fiat or xmr). + * + * @param grpcStubs gRPC service stubs, initialized with hostname, port, and credentials. + * @param offer The offer to take. + * @param paymentAccount The payment account used to take the offer. + * @param bisqTradeFeeCurrency The Bisq trade fee currency code (BSQ or BTC). + * @param pollingInterval The calling bot's polling interval, in milliseconds (some situations require + * calculating a stalling period before making the next request). + */ + OfferTaker(GrpcStubs grpcStubs, + OfferInfo offer, + @Nullable PaymentAccount paymentAccount, + @Nullable String bisqTradeFeeCurrency, + long pollingInterval) { + this.grpcStubs = grpcStubs; + this.offer = offer; + this.paymentAccount = paymentAccount; + this.bisqTradeFeeCurrency = bisqTradeFeeCurrency; + this.pollingInterval = pollingInterval; + this.getTradeRequest = GetTradeRequest.newBuilder().setTradeId(offer.getId()).build(); + } + + private OfferTaker() { + throw new UnsupportedOperationException("Default, no-arg constructor is invalid."); + } + + /** + * Takes an offer. Throws an exception if one of various possible causes of failure is detected. + * Only fiat offers are supported by this class, so far. + * + * @throws NonFatalException if a failure to take the offer was detected. The offer could have been + * unavailable for one of many reasons, or the taker's wallet had insufficient BTC + * to cover the trade. Other problems could result in this exception being thrown, + * including attempts to make takeoffer requests more than once per minute. + * None of these are fatal errors for a bot, which can opt to not terminate itself. + * @throws StatusRuntimeException if a fatal error occurred while attempting to take the offer. + */ + void takeOffer() throws NonFatalException { + // What kind of offer is being taken: FIAT, ALTCOIN, or BSQ_SWAP? + var offerCategoryRequest = GetOfferCategoryRequest.newBuilder() + .setId(offer.getId()) + .build(); + var offerCategory = grpcStubs.offersService + .getOfferCategory(offerCategoryRequest) + .getOfferCategory(); + + if (offerCategory.equals(BSQ_SWAP)) { + sendTakeOfferRequest(offerCategory); + // The happy path: No non-fatal or fatal exception was thrown. There was no offer availability problem, + // no insufficient funds problem, and the takeoffer call rate meter did not block the request. A new swap + // is being executed on the server, and the bot should check for the new trade, then shut down. + log.info("New BSQ swap '{}' is being executed.", offer.getId()); + // Let the daemon completely execute the swap before making any gettrade requests. + sleep(2_000); + } else { + sendTakeOfferRequest(offerCategory); + // The happy path: No non-fatal or fatal exception was thrown. There was no offer availability problem, + // no insufficient funds problem, and the takeoffer call rate meter did not block the request. A new trade + // is being prepared on the server, and the bot should check for the new trade, then shut down so the + // trade can be completed in the UI. + log.info("New trade '{}' is being prepared:", offer.getId()); + printTradeSummary(getTrade()); + } + } + + /** + * Sends a TakeOfferRequest. Throws a NonFatalException if there was an offer availability problem, insufficient + * funds in the taker's wallet, or a fatal gRPC StatusRuntimeException. If no exception is thrown, it is assumed + * a new trade has been created and will be fully prepared in a few seconds. + * + * @param offerCategory FIAT | ALTCOIN | BSQ_SWAP + * @see https://bisq-network.github.io/slate/?java#rpc-method-takeoffer + */ + // TODO refactor (combine) with sendTakeBsqSwapOfferRequest? + private void sendTakeOfferRequest(GetOfferCategoryReply.OfferCategory offerCategory) + throws NonFatalException { + TakeOfferReply reply; + try { + var requestBuilder = TakeOfferRequest.newBuilder().setOfferId(offer.getId()); + if (!offerCategory.equals(BSQ_SWAP)) { + // V1 protocol takeoffer requests require a paymentAccountId and optional takerFeeCurrencyCode. + var paymentAccountId = requireNonNull(paymentAccount, + "The takeoffer requests' paymentAccountId cannot be null").getId(); + requestBuilder.setPaymentAccountId(paymentAccountId) + .setTakerFeeCurrencyCode(bisqTradeFeeCurrency); + } + // The TakeOffer reply will contain a new trade, or a reason the offer was not available for the taking. + // However, offer availability checks do not check for sufficient funds in the taker's wallet, and that + // situation is handled in the catch block. + var request = requestBuilder.build(); + reply = grpcStubs.tradesService.takeOffer(request); + if (reply.hasFailureReason()) { + // A failure reason results from an offer availability problem, which does not include + // an insufficient funds problem with the taker's wallet. Neither case is fatal for the + // bot, but the two problems must be handled differently in the client. + // TODO Failure reason and exception due to insufficient funds needs to be treated in a more + // uniform manner in the server, so the client does not need to concern itself with this confusing + // difference. In the UI, offer availability checks are trigger by one button click (Take Offer), + // and insufficient funds exceptions may be triggered by another button click (Transfer Funds + // For The Trade). The API daemon needs to fix this in a a backwards compatible way. + AvailabilityResultWithDescription reason = reply.getFailureReason(); + String errorMessage = format("Non-Fatal Error %s: %s", reason.getAvailabilityResult(), reason.getDescription()); + throw new NonFatalException(errorMessage); + } + } catch (StatusRuntimeException grpcException) { + handleTakeOfferException(grpcException); + } + } + + /** + * Handle a gRPC StatusRuntimeException from the API server while calling the API's takeoffer method. Some are + * fatal exceptions, others not. + *

+ * The gRPC exception's status code will be UNAVAILABLE if there is an insufficient funds error. This is not a + * fatal error, and the user can fund the wallet while the bot is running. + *

+ * The gRPC exception's status code will be PERMISSION_DENIED when the takeoffer request frequency is > 1 / minute. + * This is not a fatal error. In this case, set the NonFatalException's stallTime, so the bot can wait the minimum + * amount of time required to avoid another StatusRuntimeException(PERMISSION_DENIED). + *

+ * For any other gRPC exception status code, assumes a fatal error and throws the exception. + */ + private void handleTakeOfferException(StatusRuntimeException ex) throws NonFatalException { + if (exceptionHasStatus.test(ex, UNAVAILABLE)) { + throw new NonFatalException(toNonFatalErrorMessage.apply(ex)); + } else if (exceptionHasStatus.test(ex, PERMISSION_DENIED)) { + // Calculate how long we have to stall the bot before it can send the next takeoffer request. + long stallTime = 60_005 - pollingInterval; + throw new NonFatalException(toNonFatalErrorMessage.apply(ex), stallTime); + } else { + throw ex; + } + } + + /** + * Wait and block until a new BSQ swap is executed. + *

+ * Should be called immediately after a takeoffer call. If the executed trade is not found + * within a maximum allowed amount of time ({@link #MAX_GET_NEW_TRADE_ATTEMPTS} * 1 second), + * throw a fatal StatusRuntimeException(NOT_FOUND). + */ + void waitForBsqSwapCompletion() { + Optional newTrade = getPreparedTrade(); + if (newTrade.isPresent()) { + TradeInfo trade = newTrade.get(); + log.info("BSQ Swap is complete:"); + printTradeSummary(trade); + } else { + throw new StatusRuntimeException(NOT_FOUND + .withDescription("Something bad happened, could not find the new trade." + + " Shut down the API bot and server, then check the server log.")); + } + } + + /** + * Wait and block until a new trade is fully initialized, with a trade contract and the user's trade role. + *

+ * Should be called immediately after a takeoffer call. If the new trade is not initialized within a maximum + * amount of time ({@link #MAX_GET_NEW_TRADE_ATTEMPTS} * 1 second) throw a fatal + * StatusRuntimeException(NOT_FOUND). + */ + void waitForTradePreparation() { + Optional newTrade = getPreparedTrade(); + if (newTrade.isPresent()) { + TradeInfo trade = newTrade.get(); + log.info("New trade has been prepared:"); + printTradeSummary(trade); + } else { + throw new StatusRuntimeException(NOT_FOUND + .withDescription("Something bad happened, could not find the new trade." + + " Shut down the API bot and server, then check the server log.")); + } + } + + /** + * Calls {@link #getNewTrade} every second, for a maximum of {@link #MAX_GET_NEW_TRADE_ATTEMPTS} seconds, or + * until the newly prepared trade is found -- whichever comes first. + *

+ * If the newly prepared trade is found within the time limit, returns an Optional object, else + * throws a gRPC StatusRuntimeException with Status.Code = NOT_FOUND. + * + * @return Optional containing a prepared trade. + */ + private Optional getPreparedTrade() { + int attempts = 0; + while (attempts++ < MAX_GET_NEW_TRADE_ATTEMPTS - 1) { + Optional trade = getNewTrade(); + if (trade.isPresent() && !trade.get().getRole().equalsIgnoreCase("Not Available")) { + return trade; + } else { + sleep(1_000); + } + } + // Try again, one last time, and throw the NOT_FOUND found exception from the gRPC server. + return Optional.of(getTrade()); + } + + /** + * Returns an Optional containing a trade, Optional.empty() if not found, or throws a + * gRPC StatusRuntimeException. + *

+ * Use this method if you took the trade just now and want an Optional.empty() if not found, instead of a gRPC + * StatusRuntimeException(NOT_FOUND) exception. Any gRPC StatusRuntimeException with a Status code != NOT_FOUND + * will be thrown. + * + * @return Optional containing a trade, or Optional.empty() if not found, or throws a + * gRPC StatusRuntimeException. + */ + private Optional getNewTrade() { + try { + var trade = getTrade(); + return Optional.of(trade); + } catch (Throwable t) { + if (t instanceof StatusRuntimeException) { + if (exceptionHasStatus.test((StatusRuntimeException) t, NOT_FOUND)) + return Optional.empty(); + else + throw t; + } else { + throw t; + } + } + } + + /** + * Returns a trade, or throws a gRPC StatusRuntimeException. + * + * @return bisq.proto.grpc.TradeInfo + * @see https://bisq-network.github.io/slate/?java#rpc-method-gettrade + */ + private TradeInfo getTrade() { + var response = grpcStubs.tradesService.getTrade(getTradeRequest); + return response.getTrade(); + } +} diff --git a/java-examples/src/main/java/bisq/bots/RegtestTradePaymentSimulator.java b/java-examples/src/main/java/bisq/bots/RegtestTradePaymentSimulator.java new file mode 100644 index 0000000..3bac466 --- /dev/null +++ b/java-examples/src/main/java/bisq/bots/RegtestTradePaymentSimulator.java @@ -0,0 +1,187 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ +package bisq.bots; + +import io.grpc.StatusRuntimeException; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import protobuf.PaymentAccount; + +import java.util.Properties; + +import static bisq.bots.BotUtils.*; +import static bisq.proto.grpc.GetTradesRequest.Category.CLOSED; +import static io.grpc.Status.Code.PERMISSION_DENIED; + +/** + * Simulates trade payment protocol steps on the BTC regtest network only (useful in bot development and testing). + */ +@Slf4j +@Getter +public class RegtestTradePaymentSimulator extends AbstractBot { + + // Config file: resources/RegtestTradePaymentSimulator.properties. + private final Properties configFile; + + // Payment simulator bot's payment account (passed from bot that created the trade). + private final PaymentAccount paymentAccount; + // Payment simulator bot's trade (passed from bot that created the trade). + private final String tradeId; + // Payment simulator bot's trade currency code (derived from payment account's selected currency code). + private final String currencyCode; + // Defined in RegtestTradePaymentSimulator.properties. + private final long pollingInterval; + + // Constructor + public RegtestTradePaymentSimulator(String[] args, String tradeId, PaymentAccount paymentAccount) { + super(args); + this.tradeId = tradeId; + this.paymentAccount = paymentAccount; + this.currencyCode = paymentAccount.getSelectedTradeCurrency().getCode(); + this.configFile = loadConfigFile(); + this.pollingInterval = Long.parseLong(configFile.getProperty("pollingInterval")); + } + + /** + * Performs trade protocol steps starting after a successful takeoffer request, resulting in a new trade. + *

+ * If the calling bot is a BTC buyer, will send a fiat or XMR confirmpaymentstarted message to trading peer, + * then print a CLI command for the trading peer to run and wait for a confirmpaymentreceived from the trading peer, + * then close the trade. + *

+ * If the calling bot is a BTC seller, will print a CLI confirmpaymentstarted command for the trading peer to run, + * then wait for a confirmpaymentstarted from the trading peer. After this bot receives the confirmpaymentstarted + * message from the trading peer, will send a confirmpaymentreceived, then close the trade. + *

+ * Never run this on mainnet. If you attempt to run this bot on mainnet, it will throw a fatal gRPC + * StatusRuntimeException(PERMISSION_DENIED). + */ + @Override + public void run() { + verifyNotConnectedToMainnet(); + + waitForTakerDepositTxConfirmation(); + + var trade = getTrade(tradeId); + + // All Bisq trades are based on buying or selling BTC. When a user thinks of "buying XMR (with BTC)", + // Bisq's code treats it as "selling BTC (for XMR)". This can be confusing; try not to allow Bisq UI labels + // and conversations about trading on Bisq mix you (API bot coder) up. + var iAmBtcBuyer = isBtcBuyer.test(trade); + if (iAmBtcBuyer) { + // I (bot) am BTC buyer. I send a confirmpaymentstarted msg and wait for a confirmpaymentreceived msg. + sendPaymentStartedMessage(); + printCliPaymentReceivedConfirmationCommand(log, + "xyz", + 9999, + currencyCode, + trade.getTradeId()); + waitForPaymentReceivedConfirmationMessage(); + } else { + // I (bot) am BTC seller. I wait for a confirmpaymentstarted msg and send a confirmpaymentreceived msg. + printCliPaymentStartedCommand(log, + "xyz", + 9999, + currencyCode, + trade.getTradeId()); + waitForPaymentStartedMessage(); + sendPaymentReceivedConfirmationMessage(); + } + + sleep(pollingInterval); + closeTrade(tradeId); + log.info("You closed the trade here in the bot (mandatory, to move trades to history list)."); + + log.warn("##############################################################################"); + log.warn("Bob closes trade in the CLI (mandatory, to move trades to history list):"); + String copyPasteCliCommands = "./bisq-cli --password=xyz --port=9999 closetrade --trade-id=" + trade.getTradeId() + + "\n" + "./bisq-cli --password=xyz --port=9999 gettrades --category=closed"; + log.warn(copyPasteCliCommands); + log.warn("##############################################################################"); + + sleep(pollingInterval); + log.info("Trade is completed, printing all closed trades and exiting {}.", this.getClass().getSimpleName()); + printTradesSummary(CLOSED); + } + + private void waitForTakerDepositTxConfirmation() { + var trade = getTrade(tradeId); + while (!trade.getIsDepositConfirmed()) { + log.info("The trade's taker deposit tx `{}` has not yet been confirmed on the bitcoin blockchain.", + trade.getDepositTxId()); + sleep(pollingInterval); + trade = getTrade(trade.getTradeId()); + } + printTradeSummary(trade); + log.info("The trade's taker deposit tx `{}` has been confirmed on the bitcoin blockchain.", + trade.getDepositTxId()); + } + + private void waitForPaymentStartedMessage() { + var trade = getTrade(tradeId); + while (!trade.getIsPaymentStartedMessageSent()) { + log.info("The trade's {} payment has not yet been sent.", currencyCode); + sleep(pollingInterval); + trade = getTrade(trade.getTradeId()); + } + printTradeSummary(trade); + log.info("The trade's {} payment has been sent.", currencyCode); + } + + private void sendPaymentStartedMessage() { + log.info("You send a {} payment started message to the BTC seller.", currencyCode); + sleep(pollingInterval); + confirmPaymentStarted(tradeId); + sleep(2_000); + var trade = getTrade(tradeId); + printTradeSummary(trade); + log.info("You sent a {} payment started message to the BTC seller.", currencyCode); + } + + private void waitForPaymentReceivedConfirmationMessage() { + var trade = getTrade(tradeId); + while (!trade.getIsPaymentReceivedMessageSent()) { + log.info("The trade's {} payment received confirmation message has not yet been sent.", currencyCode); + sleep(pollingInterval); + trade = getTrade(trade.getTradeId()); + } + printTradeSummary(trade); + log.info("The trade's {} payment has been sent.", currencyCode); + } + + private void sendPaymentReceivedConfirmationMessage() { + log.info("You confirm {} payment was received to you wallet before" + + " sending confirmpaymentreceived to the BTC buyer.", + currencyCode); + sleep(pollingInterval); + confirmPaymentReceived(tradeId); + sleep(2_000); + var trade = getTrade(tradeId); + printTradeSummary(trade); + log.info("You sent a confirmpaymentreceived message to the BTC buyer."); + } + + private void verifyNotConnectedToMainnet() { + if (isConnectedToMainnet()) { + // We throw a FATAL(!) gRPC StatusRuntimeException(PERMISSION_DENIED) if the calling bot attempts + // to simulate payment on the BTC mainnet network. It is very unusual for a gRPC client to throw + // a StatusRuntimeException, but make this one exception to emphasise the seriousness of the problem. + throw new StatusRuntimeException(PERMISSION_DENIED.toStatus() + .withDescription("API daemon is connected to BTC mainnet!")); + } + } +} diff --git a/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToBuyBsq.java b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToBuyBsq.java new file mode 100644 index 0000000..b9b1226 --- /dev/null +++ b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToBuyBsq.java @@ -0,0 +1,351 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ +package bisq.bots; + +import bisq.proto.grpc.OfferInfo; +import io.grpc.StatusRuntimeException; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import protobuf.PaymentAccount; + +import java.math.BigDecimal; +import java.util.*; +import java.util.function.BiPredicate; +import java.util.function.Predicate; + +import static bisq.bots.BotUtils.*; +import static java.lang.String.format; +import static java.lang.System.exit; +import static java.math.RoundingMode.HALF_UP; +import static protobuf.OfferDirection.SELL; + +/** + * Bot for swapping BSQ for BTC at an attractive (high) price. The bot sends BSQ for BTC. + *

+ * I'm taking liberties with the classname by not naming it TakeBestPricedOfferToSellBtcForBsq. + */ +@Slf4j +@Getter +public class TakeBestPricedOfferToBuyBsq extends AbstractBot { + + // Config file: resources/TakeBestPricedOfferToBuyBsq.properties. + private final Properties configFile; + // Taker bot's default BSQ Swap payment account. + private final PaymentAccount paymentAccount; + // Taker bot's payment account trading currency code (BSQ). + private final String currencyCode; + // Taker bot's minimum market price margin. A takeable BSQ Swap offer's fixed-price must be >= minMarketPriceMargin (%). + // Note: all BSQ Swap offers have a fixed-price, but the bot uses a margin (%) of the 30-day price for comparison. + private final BigDecimal minMarketPriceMargin; + // Hard coded 30-day average BSQ trade price, used for development over regtest (ignored when running on mainnet). + private final BigDecimal regtest30DayAvgBsqPrice; + // Taker bot's min BTC amount to sell (we are buying BSQ). A takeable offer's amount must be >= minAmount BTC. + private final BigDecimal minAmount; + // Taker bot's max BTC amount to sell (we are buying BSQ). A takeable offer's amount must be <= maxAmount BTC. + private final BigDecimal maxAmount; + // Taker bot's max acceptable transaction fee rate. + private final long maxTxFeeRate; + // Maximum # of offers to take during one bot session (shut down bot after N swaps). + private final int maxTakeOffers; + + // Offer polling frequency must be > 1000 ms between each getoffers request. + private final long pollingInterval; + + // The # of BSQ swap offers taken during the bot session (since startup). + private int numOffersTaken = 0; + + public TakeBestPricedOfferToBuyBsq(String[] args) { + super(args); + pingDaemon(new Date().getTime()); // Shut down now if API daemon is not available. + this.configFile = loadConfigFile(); + this.paymentAccount = getBsqSwapPaymentAccount(); + this.currencyCode = paymentAccount.getSelectedTradeCurrency().getCode(); + this.minMarketPriceMargin = new BigDecimal(configFile.getProperty("minMarketPriceMargin")) + .setScale(2, HALF_UP); + this.regtest30DayAvgBsqPrice = new BigDecimal(configFile.getProperty("regtest30DayAvgBsqPrice")) + .setScale(8, HALF_UP); + this.minAmount = new BigDecimal(configFile.getProperty("minAmount")); + this.maxAmount = new BigDecimal(configFile.getProperty("maxAmount")); + this.maxTxFeeRate = Long.parseLong(configFile.getProperty("maxTxFeeRate")); + this.maxTakeOffers = Integer.parseInt(configFile.getProperty("maxTakeOffers")); + loadPreferredOnionAddresses.accept(configFile, preferredTradingPeers); + this.pollingInterval = Long.parseLong(configFile.getProperty("pollingInterval")); + } + + /** + * Checks for the most attractive BSQ Swap offer to take every {@link #pollingInterval} ms. + * Will only terminate when manually shut down, or a fatal gRPC StatusRuntimeException is thrown. + */ + @Override + public void run() { + var startTime = new Date().getTime(); + validatePollingInterval(pollingInterval); + printBotConfiguration(); + + while (!isShutdown) { + if (!isBisqNetworkTxFeeRateLowEnough.test(maxTxFeeRate)) { + runCountdown(log, pollingInterval); + continue; + } + + // Get all available and takeable offers, sorted by price descending. + var offers = getOffers(SELL.name(), currencyCode).stream() + .filter(o -> !isAlreadyTaken.test(o)) + .toList(); + + if (offers.isEmpty()) { + log.info("No takeable offers found."); + runCountdown(log, pollingInterval); + continue; + } + + // Define criteria for taking an offer, based on conf file. + TakeBestPricedOfferToBuyBsq.TakeCriteria takeCriteria = new TakeBestPricedOfferToBuyBsq.TakeCriteria(); + takeCriteria.printCriteriaSummary(); + takeCriteria.printOffersAgainstCriteria(offers); + + // Find takeable offer based on criteria. + Optional selectedOffer = takeCriteria.findTakeableOffer(offers); + // Try to take the offer, if found, or say 'no offer found' before going to sleep. + selectedOffer.ifPresentOrElse(offer -> takeOffer(takeCriteria, offer), + () -> { + var highestPricedOffer = offers.get(0); + log.info("No acceptable offer found. Closest possible candidate did not pass filters:"); + takeCriteria.printOfferAgainstCriteria(highestPricedOffer); + }); + + printDryRunProgress(); + runCountdown(log, pollingInterval); + pingDaemon(startTime); + } + } + + private void takeOffer(TakeCriteria takeCriteria, OfferInfo offer) { + log.info("Will attempt to take offer '{}'.", offer.getId()); + takeCriteria.printOfferAgainstCriteria(offer); + if (isDryRun) { + addToOffersTaken(offer); + numOffersTaken++; + maybeShutdownAfterSuccessfulSwap(); + } else { + // An encrypted wallet must be unlocked before calling takeoffer and gettrade. + // Unlock the wallet for 10 minutes. If the wallet is already unlocked, + // this command will override the timeout of the previous unlock command. + try { + unlockWallet(walletPassword, 600); + + printBTCBalances("BTC Balances Before Swap Execution"); + printBSQBalances("BSQ Balances Before Swap Execution"); + + // Blocks until swap is executed, or times out. + takeBsqSwapOffer(offer, pollingInterval); + + printBTCBalances("BTC Balances After Swap Execution"); + printBSQBalances("BSQ Balances After Swap Execution"); + + numOffersTaken++; + maybeShutdownAfterSuccessfulSwap(); + } catch (NonFatalException nonFatalException) { + handleNonFatalException(nonFatalException); + } catch (StatusRuntimeException fatalException) { + handleFatalException(fatalException); + } + } + } + + private void printBotConfiguration() { + var configsByLabel = new LinkedHashMap(); + configsByLabel.put("Bot OS:", getOSName() + " " + getOSVersion()); + var network = getNetwork(); + configsByLabel.put("BTC Network:", network); + var isMainnet = network.equalsIgnoreCase("mainnet"); + var mainnet30DayAvgBsqPrice = isMainnet ? get30DayAvgBsqPriceInBtc() : null; + configsByLabel.put("My Payment Account:", ""); + configsByLabel.put("\tPayment Account Id:", paymentAccount.getId()); + configsByLabel.put("\tAccount Name:", paymentAccount.getAccountName()); + configsByLabel.put("\tCurrency Code:", currencyCode); + configsByLabel.put("Trading Rules:", ""); + configsByLabel.put("\tMax # of offers bot can take:", maxTakeOffers); + configsByLabel.put("\tMax Tx Fee Rate:", maxTxFeeRate + " sats/byte"); + configsByLabel.put("\tMin Market Price Margin:", minMarketPriceMargin + "%"); + if (isMainnet) { + configsByLabel.put("\tMainnet 30-Day Avg BSQ Price:", mainnet30DayAvgBsqPrice + " BTC"); + } else { + configsByLabel.put("\tRegtest 30-Day Avg BSQ Price:", regtest30DayAvgBsqPrice + " BTC"); + } + configsByLabel.put("\tMin BTC Amount:", minAmount + " BTC"); + configsByLabel.put("\tMax BTC Amount: ", maxAmount + " BTC"); + if (iHavePreferredTradingPeers.get()) { + configsByLabel.put("\tPreferred Trading Peers:", preferredTradingPeers.toString()); + } else { + configsByLabel.put("\tPreferred Trading Peers:", "N/A"); + } + configsByLabel.put("Bot Polling Interval:", pollingInterval + " ms"); + log.info(toTable.apply("Bot Configuration", configsByLabel)); + } + + /** + * Log the non-fatal exception, and stall the bot if the NonFatalException has a stallTime value > 0. + */ + private void handleNonFatalException(NonFatalException nonFatalException) { + log.warn(nonFatalException.getMessage()); + if (nonFatalException.hasStallTime()) { + long stallTime = nonFatalException.getStallTime(); + log.warn("A minute must pass between the previous and the next takeoffer attempt." + + " Stalling for {} seconds before the next takeoffer attempt.", + toSeconds.apply(stallTime + pollingInterval)); + runCountdown(log, stallTime); + } else { + runCountdown(log, pollingInterval); + } + } + + /** + * Log the fatal exception, and shut down daemon and bot. + */ + private void handleFatalException(StatusRuntimeException fatalException) { + log.error("", fatalException); + shutdownAfterFatalError("Shutting down API daemon and bot after failing to execute BSQ swap."); + } + + /** + * Lock the wallet, stop the API daemon, and terminate the bot. + */ + private void maybeShutdownAfterSuccessfulSwap() { + if (!isDryRun) { + try { + lockWallet(); + } catch (NonFatalException ex) { + log.warn(ex.getMessage()); + } + } + if (numOffersTaken >= maxTakeOffers) { + isShutdown = true; + log.info("Shutting down API bot after executing {} BSQ swaps.", numOffersTaken); + exit(0); + } else { + log.info("You have completed {} BSQ swap(s) during this bot session.", numOffersTaken); + } + } + + /** + * Return true is fixed-price offer's price >= the bot's max market price margin. Allows bot to take a + * fixed-priced offer if the price is >= {@link #minMarketPriceMargin} (%) of the current market price. + */ + protected final BiPredicate isFixedPriceGEMaxMarketPriceMargin = + (offer, currentMarketPrice) -> BotUtils.isFixedPriceGEMinMarketPriceMargin( + offer, + currentMarketPrice, + this.getMinMarketPriceMargin()); + + /** + * Return true if offer.amt >= bot.minAmt AND offer.amt <= bot.maxAmt (within the boundaries). + * TODO API's takeoffer needs to support taking offer's minAmount. + */ + protected final Predicate isWithinBTCAmountBounds = (offer) -> + BotUtils.isWithinBTCAmountBounds(offer, getMinAmount(), getMaxAmount()); + + public static void main(String[] args) { + @SuppressWarnings("unused") + String prompt = "An encrypted wallet must be unlocked before any offer can be taken.\n" + + " Please enter your wallet password:"; + String walletPassword = "be careful"; // readWalletPassword(prompt); + log.info("Your wallet password is {}", walletPassword.isBlank() ? "blank" : walletPassword); + TakeBestPricedOfferToBuyBsq bot = new TakeBestPricedOfferToBuyBsq(appendWalletPasswordOpt(args, walletPassword)); + bot.run(); + } + + /** + * Calculates additional takeoffer criteria based on conf file values, + * performs candidate offer filtering, and provides useful log statements. + */ + private class TakeCriteria { + private static final String MARKET_DESCRIPTION = "Buy BSQ (Sell BTC)"; + + private final BigDecimal avgBsqPrice; + @Getter + private final BigDecimal targetPrice; + + public TakeCriteria() { + this.avgBsqPrice = isConnectedToMainnet() ? get30DayAvgBsqPriceInBtc() : regtest30DayAvgBsqPrice; + this.targetPrice = calcTargetBsqPrice(minMarketPriceMargin, avgBsqPrice); + } + + /** + * Returns the highest priced offer passing the filters, or Optional.empty() if not found. + * Max tx fee rate filtering should have passed prior to calling this method. + * + * @param offers to filter + */ + Optional findTakeableOffer(List offers) { + if (iHavePreferredTradingPeers.get()) + return offers.stream() + .filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount())) + .filter(isMakerPreferredTradingPeer) + .filter(o -> isFixedPriceGEMaxMarketPriceMargin.test(o, avgBsqPrice)) + .filter(isWithinBTCAmountBounds) + .findFirst(); + else + return offers.stream() + .filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount())) + .filter(o -> isFixedPriceGEMaxMarketPriceMargin.test(o, avgBsqPrice)) + .filter(isWithinBTCAmountBounds) + .findFirst(); + } + + void printCriteriaSummary() { + log.info("Looking for offers to {}, with a fixed-price at or greater than" + + " {}% {} the 30-day average BSQ trade price of {} BTC.", + MARKET_DESCRIPTION, + minMarketPriceMargin.abs(), // Hide the sign, text explains target price % "above or below". + aboveOrBelowMarketPrice.apply(minMarketPriceMargin), + avgBsqPrice); + } + + void printOffersAgainstCriteria(List offers) { + log.info("Currently available {} offers -- want to take BSQ swap offer with fixed-price >= {} BTC.", + MARKET_DESCRIPTION, + targetPrice); + printOffersSummary(offers); + } + + void printOfferAgainstCriteria(OfferInfo offer) { + printOfferSummary(offer); + + var filterResultsByLabel = new LinkedHashMap(); + filterResultsByLabel.put("30-day Avg BSQ trade price:", avgBsqPrice + " BTC"); + filterResultsByLabel.put("Target Price (Min):", targetPrice + " BTC"); + filterResultsByLabel.put("Offer Price:", offer.getPrice() + " BTC"); + filterResultsByLabel.put("Offer maker used same payment method?", + usesSamePaymentMethod.test(offer, getPaymentAccount())); + filterResultsByLabel.put("Is offer's maker a preferred trading peer?", + iHavePreferredTradingPeers.get() + ? isMakerPreferredTradingPeer.test(offer) ? "YES" : "NO" + : "N/A"); + var fixedPriceLabel = format("Is offer fixed-price (%s) >= bot's minimum price (%s)?", + offer.getPrice() + " " + currencyCode, + targetPrice + " " + currencyCode); + filterResultsByLabel.put(fixedPriceLabel, isFixedPriceGEMaxMarketPriceMargin.test(offer, avgBsqPrice)); + var btcAmountBounds = format("%s BTC - %s BTC", minAmount, maxAmount); + filterResultsByLabel.put("Is offer's BTC amount within bot amount bounds (" + btcAmountBounds + ")?", + isWithinBTCAmountBounds.test(offer)); + + var title = format("Fixed price BSQ swap offer %s filter results:", offer.getId()); + log.info(toTable.apply(title, filterResultsByLabel)); + } + } +} diff --git a/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToBuyBtc.java b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToBuyBtc.java new file mode 100644 index 0000000..a6f1d9a --- /dev/null +++ b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToBuyBtc.java @@ -0,0 +1,439 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ +package bisq.bots; + +import bisq.proto.grpc.OfferInfo; +import io.grpc.StatusRuntimeException; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import protobuf.PaymentAccount; + +import java.math.BigDecimal; +import java.util.*; +import java.util.function.BiPredicate; +import java.util.function.Predicate; +import java.util.function.Supplier; + +import static bisq.bots.BotUtils.*; +import static java.lang.String.format; +import static java.lang.System.exit; +import static java.math.RoundingMode.HALF_UP; +import static protobuf.OfferDirection.BUY; +import static protobuf.OfferDirection.SELL; + +/** + * The TakeBestPricedOfferToBuyBtc bot waits for attractively priced BUY BTC offers to appear, takes the offers + * (up to a maximum of configured {@link #maxTakeOffers}, then shuts down both the API daemon and itself (the bot), + * to allow the user to start the desktop UI application and complete the trades. + *

+ * The benefit this bot provides is freeing up the user time spent watching the offer book in the UI, waiting for the + * right offer to take. This bot increases the chance of beating the other nodes at taking the offer. + *

+ * The disadvantage is that if the user takes offers with the API, she must complete the trades with the desktop UI. + * This problem is due to the inability of the API to fully automate every step of the trading protocol. Sending fiat + * payments, and confirming their receipt, are manual activities performed outside the Bisq daemon and desktop UI. + * Also, the API and the desktop UI cannot run at the same time. Care must be taken to shut down one before starting + * the other. + *

+ * The criteria for determining which offers to take are defined in the bot's configuration file + * TakeBestPricedOfferToBuyBtc.properties (located in project's src/main/resources directory). The individual + * configurations are commented in the existing TakeBestPricedOfferToBuyBtc.properties, which should be used as a + * template for your own use case. + *

+ * One possible use case for this bot is sell BTC for GBP: + *

+ *      Take a "Faster Payment (Santander)" offer to buy BTC with GBP at or above current market price if:
+ *          the offer maker is a preferred trading peer,
+ *          and the offer's BTC amount is between 0.10 and 0.25 BTC,
+ *          and the current transaction mining fee rate is below 20 sats / byte.
+ * 
+ *

+ * Another possible use case for this bot is to buy BTC with XMR. (We might say "sell XMR for BTC", but we need to + * remember that all Bisq offers are for buying or selling BTC.) + *

+ *      Take an offer to buy BTC with XMR at or above current market price if:
+ *          the offer maker is a preferred trading peer,
+ *          and the offer's BTC amount is between 0.50 and 1.00 BTC,
+ *          and the current transaction mining fee rate is below 15 sats / byte.
+ * 
+ *

+ *

+ * Usage:  TakeBestPricedOfferToBuyBtc  --password=api-password --port=api-port \
+ *                          [--conf=take-best-priced-offer-to-buy-btc.conf] \
+ *                          [--dryrun=true|false]
+ *                          [--simulate-regtest-payment=true|false]
+ * 
+ */ +@Slf4j +@Getter +public class TakeBestPricedOfferToBuyBtc extends AbstractBot { + + // Config file: resources/TakeBestPricedOfferToBuyBtc.properties. + private final Properties configFile; + // Taker bot's payment account (if the configured paymentAccountId is valid). + private final PaymentAccount paymentAccount; + // Taker bot's payment account trading currency code (if the configured paymentAccountId is valid). + private final String currencyCode; + // Taker bot's min market price margin. A takeable offer's price margin (%) must be >= minMarketPriceMargin (%). + private final BigDecimal minMarketPriceMargin; + // Taker bot's min BTC amount to buy (or sell in case of XMR). A takeable offer's amount must be >= minAmount BTC. + private final BigDecimal minAmount; + // Taker bot's max BTC amount to buy (or sell in case of XMR). A takeable offer's amount must be <= maxAmount BTC. + private final BigDecimal maxAmount; + // Taker bot's max acceptable transaction fee rate. + private final long maxTxFeeRate; + // Taker bot's trading fee currency code (BSQ or BTC). + private final String bisqTradeFeeCurrency; + // Maximum # of offers to take during one bot session (shut down bot after N swaps). + private final int maxTakeOffers; + + // Offer polling frequency must be > 1000 ms between each getoffers request. + private final long pollingInterval; + + // The # of BSQ swap offers taken during the bot session (since startup). + private int numOffersTaken = 0; + + public TakeBestPricedOfferToBuyBtc(String[] args) { + super(args); + pingDaemon(new Date().getTime()); // Shut down now if API daemon is not available. + this.configFile = loadConfigFile(); + this.paymentAccount = getPaymentAccount(configFile.getProperty("paymentAccountId")); + this.currencyCode = paymentAccount.getSelectedTradeCurrency().getCode(); + this.minMarketPriceMargin = new BigDecimal(configFile.getProperty("minMarketPriceMargin")) + .setScale(2, HALF_UP); + this.minAmount = new BigDecimal(configFile.getProperty("minAmount")); + this.maxAmount = new BigDecimal(configFile.getProperty("maxAmount")); + this.maxTxFeeRate = Long.parseLong(configFile.getProperty("maxTxFeeRate")); + this.bisqTradeFeeCurrency = configFile.getProperty("bisqTradeFeeCurrency"); + this.maxTakeOffers = Integer.parseInt(configFile.getProperty("maxTakeOffers")); + loadPreferredOnionAddresses.accept(configFile, preferredTradingPeers); + this.pollingInterval = Long.parseLong(configFile.getProperty("pollingInterval")); + } + + /** + * Checks for the most attractive offer to take every {@link #pollingInterval} ms. After {@link #maxTakeOffers} + * are taken, bot will stop the API daemon, then shut itself down, prompting the user to start the desktop UI + * to complete the trade. + */ + @Override + public void run() { + var startTime = new Date().getTime(); + validatePollingInterval(pollingInterval); + validateTradeFeeCurrencyCode(bisqTradeFeeCurrency); + validatePaymentAccount(paymentAccount); + printBotConfiguration(); + + while (!isShutdown) { + if (!isBisqNetworkTxFeeRateLowEnough.test(maxTxFeeRate)) { + runCountdown(log, pollingInterval); + continue; + } + + // Taker bot's getOffers(direction) request param. For fiat offers, is BUY (BTC), for XMR offers, is SELL (BTC). + String offerDirection = isXmr.test(currencyCode) ? SELL.name() : BUY.name(); + + // Get all available and takeable offers, sorted by price ascending. + // The list contains both fixed-price and market price margin based offers. + var offers = getOffers(offerDirection, currencyCode).stream() + .filter(o -> !isAlreadyTaken.test(o)) + .toList(); + + if (offers.isEmpty()) { + log.info("No takeable offers found."); + runCountdown(log, pollingInterval); + continue; + } + + // Define criteria for taking an offer, based on conf file. + TakeCriteria takeCriteria = new TakeCriteria(); + takeCriteria.printCriteriaSummary(); + takeCriteria.printOffersAgainstCriteria(offers); + + // Find takeable offer based on criteria. + Optional selectedOffer = takeCriteria.findTakeableOffer(offers); + // Try to take the offer, if found, or say 'no offer found' before going to sleep. + selectedOffer.ifPresentOrElse(offer -> takeOffer(takeCriteria, offer), + () -> { + var highestPricedOffer = offers.get(0); + log.info("No acceptable offer found. Closest possible candidate did not pass filters:"); + takeCriteria.printOfferAgainstCriteria(highestPricedOffer); + }); + + printDryRunProgress(); + runCountdown(log, pollingInterval); + pingDaemon(startTime); + } + } + + /** + * Attempt to take the available offer according to configured criteria. If successful, will block until a new + * trade is fully initialized with a trade contract. Otherwise, handles a non-fatal error and allows the bot to + * stay alive, or shuts down the bot upon fatal error. + */ + private void takeOffer(TakeCriteria takeCriteria, OfferInfo offer) { + log.info("Will attempt to take offer '{}'.", offer.getId()); + takeCriteria.printOfferAgainstCriteria(offer); + if (isDryRun) { + addToOffersTaken(offer); + numOffersTaken++; + maybeShutdownAfterSuccessfulTradeCreation(); + } else { + // An encrypted wallet must be unlocked before calling takeoffer and gettrade. + // Unlock the wallet for 5 minutes. If the wallet is already unlocked, + // this command will override the timeout of the previous unlock command. + try { + unlockWallet(walletPassword, 600); + printBTCBalances("BTC Balances Before Take Offer Attempt"); + // Blocks until new trade is prepared, or times out. + takeV1ProtocolOffer(offer, paymentAccount, bisqTradeFeeCurrency, pollingInterval); + printBTCBalances("BTC Balances After Take Offer Attempt"); + + if (canSimulatePaymentSteps) { + var newTrade = getTrade(offer.getId()); + RegtestTradePaymentSimulator tradePaymentSimulator = new RegtestTradePaymentSimulator(args, + newTrade.getTradeId(), + paymentAccount); + tradePaymentSimulator.run(); + log.info("Trade payment simulation is complete. Closing bot channels and shutting down."); + printBTCBalances("BTC Balances After Simulated Trade Completion"); + } + numOffersTaken++; + maybeShutdownAfterSuccessfulTradeCreation(); + } catch (NonFatalException nonFatalException) { + handleNonFatalException(nonFatalException); + } catch (StatusRuntimeException fatalException) { + handleFatalException(fatalException); + } + } + } + + /** + * Log the non-fatal exception, and stall the bot if the NonFatalException has a stallTime value > 0. + */ + private void handleNonFatalException(NonFatalException nonFatalException) { + log.warn(nonFatalException.getMessage()); + if (nonFatalException.hasStallTime()) { + long stallTime = nonFatalException.getStallTime(); + log.warn("A minute must pass between the previous and the next takeoffer attempt." + + " Stalling for {} seconds before the next takeoffer attempt.", + toSeconds.apply(stallTime + pollingInterval)); + runCountdown(log, stallTime); + } else { + runCountdown(log, pollingInterval); + } + } + + /** + * Log the fatal exception, and shut down daemon and bot. + */ + private void handleFatalException(StatusRuntimeException fatalException) { + log.error("", fatalException); + shutdownAfterFailedTradePreparation(); + } + + /** + * Lock the wallet, stop the API daemon, and terminate the bot. + */ + private void maybeShutdownAfterSuccessfulTradeCreation() { + if (!isDryRun) { + try { + lockWallet(); + } catch (NonFatalException ex) { + log.warn(ex.getMessage()); + } + } + if (numOffersTaken >= maxTakeOffers) { + isShutdown = true; + + if (canSimulatePaymentSteps) { + log.info("Shutting down bot after successful trade completion. API daemon will not be shut down."); + sleep(2_000); + } else { + log.info("Shutting down API daemon and bot after taking {} offers." + + " Complete the trade(s) with the desktop UI.", + numOffersTaken); + sleep(2_000); + log.info("Sending stop request to daemon."); + stopDaemon(); + } + + exit(0); + + } else { + log.info("You have taken {} offers during this bot session.", numOffersTaken); + } + } + + /** + * Lock the wallet, stop the API daemon, and terminate the bot with a non-zero status (error). + */ + private void shutdownAfterFailedTradePreparation() { + shutdownAfterFatalError("Shutting down API daemon and bot after failing to find new trade."); + } + + /** + * Return true is fixed-price offer's price >= the bot's min market price margin. Allows bot to take a + * fixed-priced offer if the price is >= {@link #minMarketPriceMargin} (%) of the current market price. + */ + protected final BiPredicate isFixedPriceGEMinMarketPriceMargin = + (offer, currentMarketPrice) -> BotUtils.isFixedPriceGEMinMarketPriceMargin( + offer, + currentMarketPrice, + this.getMinMarketPriceMargin()); + + /** + * Return true if offer.amt >= bot.minAmt AND offer.amt <= bot.maxAmt (within the boundaries). + * TODO API's takeoffer needs to support taking offer's minAmount. + */ + protected final Predicate isWithinBTCAmountBounds = (offer) -> + BotUtils.isWithinBTCAmountBounds(offer, getMinAmount(), getMaxAmount()); + + private void printBotConfiguration() { + var configsByLabel = new LinkedHashMap(); + configsByLabel.put("Bot OS:", getOSName() + " " + getOSVersion()); + var network = getNetwork(); + configsByLabel.put("BTC Network:", network); + configsByLabel.put("My Payment Account:", ""); + configsByLabel.put("\tPayment Account Id:", paymentAccount.getId()); + configsByLabel.put("\tAccount Name:", paymentAccount.getAccountName()); + configsByLabel.put("\tCurrency Code:", currencyCode); + configsByLabel.put("Trading Rules:", ""); + configsByLabel.put("\tMax # of offers bot can take:", maxTakeOffers); + configsByLabel.put("\tMax Tx Fee Rate:", maxTxFeeRate + " sats/byte"); + configsByLabel.put("\tMin Market Price Margin:", minMarketPriceMargin + "%"); + configsByLabel.put("\tMin BTC Amount:", minAmount + " BTC"); + configsByLabel.put("\tMax BTC Amount: ", maxAmount + " BTC"); + if (iHavePreferredTradingPeers.get()) { + configsByLabel.put("\tPreferred Trading Peers:", preferredTradingPeers.toString()); + } else { + configsByLabel.put("\tPreferred Trading Peers:", "N/A"); + } + configsByLabel.put("Bot Polling Interval:", pollingInterval + " ms"); + log.info(toTable.apply("Bot Configuration", configsByLabel)); + } + + public static void main(String[] args) { + @SuppressWarnings("unused") + String prompt = "An encrypted wallet must be unlocked before any offer can be taken.\n" + + " Please enter your wallet password:"; + String walletPassword = "be careful"; // readWalletPassword(prompt); + log.info("Your wallet password is {}", walletPassword.isBlank() ? "blank" : walletPassword); + TakeBestPricedOfferToBuyBtc bot = new TakeBestPricedOfferToBuyBtc(appendWalletPasswordOpt(args, walletPassword)); + bot.run(); + } + + /** + * Calculates additional takeoffer criteria based on conf file values, + * performs candidate offer filtering, and provides useful log statements. + */ + private class TakeCriteria { + private final BigDecimal currentMarketPrice; + @Getter + private final BigDecimal targetPrice; + + private final Supplier marketDescription = () -> { + if (isXmr.test(currencyCode)) + return "Buy XMR (Sell BTC)"; + else + return "Buy BTC"; + }; + + public TakeCriteria() { + this.currentMarketPrice = getCurrentMarketPrice(currencyCode); + this.targetPrice = calcTargetPrice(minMarketPriceMargin, currentMarketPrice, currencyCode); + } + + /** + * Returns the highest priced offer passing the filters, or Optional.empty() if not found. + * Max tx fee rate filtering should have passed prior to calling this method. + * + * @param offers to filter + */ + Optional findTakeableOffer(List offers) { + if (iHavePreferredTradingPeers.get()) + return offers.stream() + .filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount())) + .filter(isMakerPreferredTradingPeer) + .filter(o -> isMarginGEMinMarketPriceMargin.test(o, minMarketPriceMargin) + || isFixedPriceGEMinMarketPriceMargin.test(o, currentMarketPrice)) + .filter(isWithinBTCAmountBounds) + .findFirst(); + else + return offers.stream() + .filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount())) + .filter(o -> isMarginGEMinMarketPriceMargin.test(o, minMarketPriceMargin) + || isFixedPriceGEMinMarketPriceMargin.test(o, currentMarketPrice)) + .filter(isWithinBTCAmountBounds) + .findFirst(); + } + + void printCriteriaSummary() { + log.info("Looking for offers to {}, priced at or more than {}% {} the current market price {} {}.", + marketDescription.get(), + minMarketPriceMargin.abs(), // Hide the sign, text explains target price % "above or below". + aboveOrBelowMarketPrice.apply(minMarketPriceMargin), + currentMarketPrice, + isXmr.test(currencyCode) ? "BTC" : currencyCode); + } + + void printOffersAgainstCriteria(List offers) { + log.info("Currently available {} offers -- want to take {} offer with price >= {} {}.", + marketDescription.get(), + currencyCode, + targetPrice, + isXmr.test(currencyCode) ? "BTC" : currencyCode); + printOffersSummary(offers); + } + + void printOfferAgainstCriteria(OfferInfo offer) { + printOfferSummary(offer); + + var filterResultsByLabel = new LinkedHashMap(); + filterResultsByLabel.put("Current Market Price:", currentMarketPrice + " " + currencyCode); + filterResultsByLabel.put("Target Price (Max):", targetPrice + " " + currencyCode); + filterResultsByLabel.put("Offer Price:", offer.getPrice() + " " + currencyCode); + filterResultsByLabel.put("Offer maker used same payment method?", + usesSamePaymentMethod.test(offer, getPaymentAccount())); + filterResultsByLabel.put("Is offer maker a preferred trading peer?", + iHavePreferredTradingPeers.get() + ? isMakerPreferredTradingPeer.test(offer) ? "YES" : "NO" + : "N/A"); + var marginPriceLabel = format("Is offer's price margin (%s%%) >= bot's min market price margin (%s%%)?", + offer.getMarketPriceMarginPct(), + minMarketPriceMargin); + filterResultsByLabel.put(marginPriceLabel, + offer.getUseMarketBasedPrice() + ? isMarginGEMinMarketPriceMargin.test(offer, minMarketPriceMargin) + : "N/A"); + var fixedPriceLabel = format("Is offer's fixed-price (%s) >= bot's target price (%s)?", + offer.getUseMarketBasedPrice() ? "N/A" : offer.getPrice() + " " + currencyCode, + offer.getUseMarketBasedPrice() ? "N/A" : targetPrice + " " + currencyCode); + filterResultsByLabel.put(fixedPriceLabel, + offer.getUseMarketBasedPrice() + ? "N/A" + : isFixedPriceGEMinMarketPriceMargin.test(offer, currentMarketPrice)); + String btcAmountBounds = format("%s BTC - %s BTC", minAmount, maxAmount); + filterResultsByLabel.put("Is offer's BTC amount within bot amount bounds (" + btcAmountBounds + ")?", + isWithinBTCAmountBounds.test(offer)); + + var title = format("%s offer %s filter results:", + offer.getUseMarketBasedPrice() ? "Margin based" : "Fixed price", + offer.getId()); + log.info(toTable.apply(title, filterResultsByLabel)); + } + } +} diff --git a/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToSellBsq.java b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToSellBsq.java new file mode 100644 index 0000000..9ce7d1b --- /dev/null +++ b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToSellBsq.java @@ -0,0 +1,353 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ +package bisq.bots; + +import bisq.proto.grpc.OfferInfo; +import io.grpc.StatusRuntimeException; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import protobuf.PaymentAccount; + +import java.math.BigDecimal; +import java.util.*; +import java.util.function.BiPredicate; +import java.util.function.Predicate; + +import static bisq.bots.BotUtils.*; +import static java.lang.String.format; +import static java.lang.System.exit; +import static java.math.RoundingMode.HALF_UP; +import static protobuf.OfferDirection.BUY; + +/** + * Bot for swapping BTC for BSQ at an attractive (low) price. The bot receives BSQ for BTC. + *

+ * I'm taking liberties with the classname by not naming it TakeBestPricedOfferToBuyBtcForBsq. + */ +@Slf4j +@Getter +public class TakeBestPricedOfferToSellBsq extends AbstractBot { + + // Config file: resources/TakeBestPricedOfferToSellBsq.properties. + private final Properties configFile; + // Taker bot's default BSQ Swap payment account. + private final PaymentAccount paymentAccount; + // Taker bot's payment account trading currency code (BSQ). + private final String currencyCode; + // Taker bot's max market price margin. A takeable BSQ Swap offer's fixed-price must be <= maxMarketPriceMargin (%). + // Note: all BSQ Swap offers have a fixed-price, but the bot uses a margin (%) of the 30-day price for comparison. + private final BigDecimal maxMarketPriceMargin; + // Hard coded 30-day average BSQ trade price, used for development over regtest (ignored when running on mainnet). + private final BigDecimal regtest30DayAvgBsqPrice; + // Taker bot's min BTC amount to sell (we are buying BSQ). A takeable offer's amount must be >= minAmount BTC. + private final BigDecimal minAmount; + // Taker bot's max BTC amount to sell (we are buying BSQ). A takeable offer's amount must be <= maxAmount BTC. + private final BigDecimal maxAmount; + // Taker bot's max acceptable transaction fee rate. + private final long maxTxFeeRate; + // Maximum # of offers to take during one bot session (shut down bot after N swaps). + private final int maxTakeOffers; + + // Offer polling frequency must be > 1000 ms between each getoffers request. + private final long pollingInterval; + + // The # of BSQ swap offers taken during the bot session (since startup). + private int numOffersTaken = 0; + + public TakeBestPricedOfferToSellBsq(String[] args) { + super(args); + pingDaemon(new Date().getTime()); // Shut down now if API daemon is not available. + this.configFile = loadConfigFile(); + this.paymentAccount = getBsqSwapPaymentAccount(); + this.currencyCode = paymentAccount.getSelectedTradeCurrency().getCode(); + this.maxMarketPriceMargin = new BigDecimal(configFile.getProperty("maxMarketPriceMargin")) + .setScale(2, HALF_UP); + this.regtest30DayAvgBsqPrice = new BigDecimal(configFile.getProperty("regtest30DayAvgBsqPrice")) + .setScale(8, HALF_UP); + this.minAmount = new BigDecimal(configFile.getProperty("minAmount")); + this.maxAmount = new BigDecimal(configFile.getProperty("maxAmount")); + this.maxTxFeeRate = Long.parseLong(configFile.getProperty("maxTxFeeRate")); + this.maxTakeOffers = Integer.parseInt(configFile.getProperty("maxTakeOffers")); + loadPreferredOnionAddresses.accept(configFile, preferredTradingPeers); + this.pollingInterval = Long.parseLong(configFile.getProperty("pollingInterval")); + } + + /** + * Checks for the most attractive BSQ Swap offer to take every {@link #pollingInterval} ms. + * Will only terminate when manually shut down, or a fatal gRPC StatusRuntimeException is thrown. + */ + @Override + public void run() { + var startTime = new Date().getTime(); + validatePollingInterval(pollingInterval); + printBotConfiguration(); + + while (!isShutdown) { + if (!isBisqNetworkTxFeeRateLowEnough.test(maxTxFeeRate)) { + runCountdown(log, pollingInterval); + continue; + } + + // Get all available and takeable offers, sorted by price ascending. + var offers = getOffers(BUY.name(), currencyCode).stream() + .filter(o -> !isAlreadyTaken.test(o)) + .toList(); + + if (offers.isEmpty()) { + log.info("No takeable offers found."); + runCountdown(log, pollingInterval); + continue; + } + + // Define criteria for taking an offer, based on conf file. + TakeBestPricedOfferToSellBsq.TakeCriteria takeCriteria = new TakeBestPricedOfferToSellBsq.TakeCriteria(); + takeCriteria.printCriteriaSummary(); + takeCriteria.printOffersAgainstCriteria(offers); + + // Find takeable offer based on criteria. + Optional selectedOffer = takeCriteria.findTakeableOffer(offers); + // Try to take the offer, if found, or say 'no offer found' before going to sleep. + selectedOffer.ifPresentOrElse(offer -> takeOffer(takeCriteria, offer), + () -> { + var cheapestOffer = offers.get(0); + log.info("No acceptable offer found. Closest possible candidate did not pass filters:"); + takeCriteria.printOfferAgainstCriteria(cheapestOffer); + }); + + printDryRunProgress(); + runCountdown(log, pollingInterval); + pingDaemon(startTime); + } + } + + private void takeOffer(TakeCriteria takeCriteria, OfferInfo offer) { + log.info("Will attempt to take offer '{}'.", offer.getId()); + takeCriteria.printOfferAgainstCriteria(offer); + if (isDryRun) { + addToOffersTaken(offer); + numOffersTaken++; + maybeShutdownAfterSuccessfulSwap(); + } else { + // An encrypted wallet must be unlocked before calling takeoffer and gettrade. + // Unlock the wallet for 10 minutes. If the wallet is already unlocked, + // this command will override the timeout of the previous unlock command. + try { + unlockWallet(walletPassword, 600); + + printBTCBalances("BTC Balances Before Swap Execution"); + printBSQBalances("BSQ Balances Before Swap Execution"); + + // Blocks until swap is executed, or times out. + takeBsqSwapOffer(offer, pollingInterval); + + printBTCBalances("BTC Balances After Swap Execution"); + printBSQBalances("BSQ Balances After Swap Execution"); + + numOffersTaken++; + maybeShutdownAfterSuccessfulSwap(); + } catch (NonFatalException nonFatalException) { + handleNonFatalException(nonFatalException); + } catch (StatusRuntimeException fatalException) { + handleFatalException(fatalException); + } + } + } + + private void printBotConfiguration() { + var configsByLabel = new LinkedHashMap(); + configsByLabel.put("Bot OS:", getOSName() + " " + getOSVersion()); + var network = getNetwork(); + configsByLabel.put("BTC Network:", network); + var isMainnet = network.equalsIgnoreCase("mainnet"); + var mainnet30DayAvgBsqPrice = isMainnet ? get30DayAvgBsqPriceInBtc() : null; + configsByLabel.put("My Payment Account:", ""); + configsByLabel.put("\tPayment Account Id:", paymentAccount.getId()); + configsByLabel.put("\tAccount Name:", paymentAccount.getAccountName()); + configsByLabel.put("\tCurrency Code:", currencyCode); + configsByLabel.put("Trading Rules:", ""); + configsByLabel.put("\tMax # of offers bot can take:", maxTakeOffers); + configsByLabel.put("\tMax Tx Fee Rate:", maxTxFeeRate + " sats/byte"); + configsByLabel.put("\tMax Market Price Margin:", maxMarketPriceMargin + "%"); + if (isMainnet) { + configsByLabel.put("\tMainnet 30-Day Avg BSQ Price:", mainnet30DayAvgBsqPrice + " BTC"); + } else { + configsByLabel.put("\tRegtest 30-Day Avg BSQ Price:", regtest30DayAvgBsqPrice + " BTC"); + } + configsByLabel.put("\tMin BTC Amount:", minAmount + " BTC"); + configsByLabel.put("\tMax BTC Amount: ", maxAmount + " BTC"); + if (iHavePreferredTradingPeers.get()) { + configsByLabel.put("\tPreferred Trading Peers:", preferredTradingPeers.toString()); + } else { + configsByLabel.put("\tPreferred Trading Peers:", "N/A"); + } + configsByLabel.put("Bot Polling Interval:", pollingInterval + " ms"); + log.info(toTable.apply("Bot Configuration", configsByLabel)); + } + + /** + * Log the non-fatal exception, and stall the bot if the NonFatalException has a stallTime value > 0. + */ + private void handleNonFatalException(NonFatalException nonFatalException) { + log.warn(nonFatalException.getMessage()); + if (nonFatalException.hasStallTime()) { + long stallTime = nonFatalException.getStallTime(); + log.warn("A minute must pass between the previous and the next takeoffer attempt." + + " Stalling for {} seconds before the next takeoffer attempt.", + toSeconds.apply(stallTime + pollingInterval)); + runCountdown(log, stallTime); + } else { + runCountdown(log, pollingInterval); + } + } + + /** + * Log the fatal exception, and shut down daemon and bot. + */ + private void handleFatalException(StatusRuntimeException fatalException) { + log.error("", fatalException); + shutdownAfterFatalError("Shutting down API daemon and bot after failing to execute BSQ swap."); + } + + /** + * Lock the wallet, stop the API daemon, and terminate the bot. + */ + private void maybeShutdownAfterSuccessfulSwap() { + if (!isDryRun) { + try { + lockWallet(); + } catch (NonFatalException ex) { + log.warn(ex.getMessage()); + } + } + if (numOffersTaken >= maxTakeOffers) { + isShutdown = true; + log.info("Shutting down API bot after executing {} BSQ swaps.", numOffersTaken); + exit(0); + } else { + log.info("You have completed {} BSQ swap(s) during this bot session.", numOffersTaken); + } + } + + /** + * Return true is fixed-price offer's price <= the bot's max market price margin. Allows bot to take a + * fixed-priced offer if the price is <= {@link #maxMarketPriceMargin} (%) of the current market price. + */ + protected final BiPredicate isFixedPriceLEMaxMarketPriceMargin = + (offer, currentMarketPrice) -> BotUtils.isFixedPriceLEMaxMarketPriceMargin( + offer, + currentMarketPrice, + getMaxMarketPriceMargin()); + + /** + * Return true if offer.amt >= bot.minAmt AND offer.amt <= bot.maxAmt (within the boundaries). + * TODO API's takeoffer needs to support taking offer's minAmount. + */ + protected final Predicate isWithinBTCAmountBounds = (offer) -> + BotUtils.isWithinBTCAmountBounds(offer, getMinAmount(), getMaxAmount()); + + public static void main(String[] args) { + @SuppressWarnings("unused") + String prompt = "An encrypted wallet must be unlocked before any offer can be taken.\n" + + " Please enter your wallet password:"; + String walletPassword = "be careful"; // readWalletPassword(prompt); + log.info("Your wallet password is {}", walletPassword.isBlank() ? "blank" : walletPassword); + TakeBestPricedOfferToSellBsq bot = new TakeBestPricedOfferToSellBsq(appendWalletPasswordOpt(args, walletPassword)); + bot.run(); + } + + /** + * Calculates additional takeoffer criteria based on conf file values, + * performs candidate offer filtering, and provides useful log statements. + */ + private class TakeCriteria { + private static final String MARKET_DESCRIPTION = "Sell BSQ (Buy BTC)"; + + private final BigDecimal avgBsqPrice; + @Getter + private final BigDecimal targetPrice; + + public TakeCriteria() { + this.avgBsqPrice = isConnectedToMainnet() ? get30DayAvgBsqPriceInBtc() : regtest30DayAvgBsqPrice; + this.targetPrice = calcTargetBsqPrice(maxMarketPriceMargin, avgBsqPrice); + } + + + /** + * Returns the lowest priced offer passing the filters, or Optional.empty() if not found. + * Max tx fee rate filtering should have passed prior to calling this method. + * + * @param offers to filter + */ + Optional findTakeableOffer(List offers) { + if (iHavePreferredTradingPeers.get()) + return offers.stream() + .filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount())) + .filter(isMakerPreferredTradingPeer) + .filter(o -> isFixedPriceLEMaxMarketPriceMargin.test(o, avgBsqPrice)) + .filter(isWithinBTCAmountBounds) + .findFirst(); + else + return offers.stream() + .filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount())) + .filter(o -> isFixedPriceLEMaxMarketPriceMargin.test(o, avgBsqPrice)) + .filter(isWithinBTCAmountBounds) + .findFirst(); + } + + void printCriteriaSummary() { + log.info("Looking for offers to {}, with a fixed-price at or less than" + + " {}% {} the 30-day average BSQ trade price of {} BTC.", + MARKET_DESCRIPTION, + maxMarketPriceMargin.abs(), // Hide the sign, text explains target price % "above or below". + aboveOrBelowMarketPrice.apply(maxMarketPriceMargin), + avgBsqPrice); + } + + + void printOffersAgainstCriteria(List offers) { + log.info("Currently available {} offers -- want to take BSQ swap offer with fixed-price <= {} BTC.", + MARKET_DESCRIPTION, + targetPrice); + printOffersSummary(offers); + } + + void printOfferAgainstCriteria(OfferInfo offer) { + printOfferSummary(offer); + + var filterResultsByLabel = new LinkedHashMap(); + filterResultsByLabel.put("30-day Avg BSQ trade price:", avgBsqPrice + " BTC"); + filterResultsByLabel.put("Target Price (Min):", targetPrice + " BTC"); + filterResultsByLabel.put("Offer Price:", offer.getPrice() + " BTC"); + filterResultsByLabel.put("Offer maker used same payment method?", + usesSamePaymentMethod.test(offer, getPaymentAccount())); + filterResultsByLabel.put("Is offer's maker a preferred trading peer?", + iHavePreferredTradingPeers.get() + ? isMakerPreferredTradingPeer.test(offer) ? "YES" : "NO" + : "N/A"); + var fixedPriceLabel = format("Is offer's fixed-price (%s) <= bot's minimum price (%s)?", + offer.getPrice() + " " + currencyCode, + targetPrice + " " + currencyCode); + filterResultsByLabel.put(fixedPriceLabel, isFixedPriceLEMaxMarketPriceMargin.test(offer, avgBsqPrice)); + var btcAmountBounds = format("%s BTC - %s BTC", minAmount, maxAmount); + filterResultsByLabel.put("Is offer's BTC amount within bot amount bounds (" + btcAmountBounds + ")?", + isWithinBTCAmountBounds.test(offer)); + + var title = format("Fixed price BSQ swap offer %s filter results:", offer.getId()); + log.info(toTable.apply(title, filterResultsByLabel)); + } + } +} diff --git a/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToSellBtc.java b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToSellBtc.java new file mode 100644 index 0000000..1e0dc89 --- /dev/null +++ b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToSellBtc.java @@ -0,0 +1,440 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ +package bisq.bots; + +import bisq.proto.grpc.OfferInfo; +import io.grpc.StatusRuntimeException; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import protobuf.PaymentAccount; + +import java.math.BigDecimal; +import java.util.*; +import java.util.function.BiPredicate; +import java.util.function.Predicate; +import java.util.function.Supplier; + +import static bisq.bots.BotUtils.*; +import static java.lang.String.format; +import static java.lang.System.exit; +import static java.math.RoundingMode.HALF_UP; +import static protobuf.OfferDirection.BUY; +import static protobuf.OfferDirection.SELL; + +/** + * The TakeBestPricedOfferToSellBtc bot waits for attractively priced SELL BTC offers to appear, takes the offers + * (up to a maximum of configured {@link #maxTakeOffers}, then shuts down both the API daemon and itself (the bot), + * to allow the user to start the desktop UI application and complete the trades. + *

+ * The benefit this bot provides is freeing up the user time spent watching the offer book in the UI, waiting for the + * right offer to take. Low-priced offers are taken relatively quickly; this bot increases the chance of beating + * the other nodes at taking the offer. + *

+ * The disadvantage is that if the user takes offers with the API, she must complete the trades with the desktop UI. + * This problem is due to the inability of the API to fully automate every step of the trading protocol. Sending fiat + * payments, and confirming their receipt, are manual activities performed outside the Bisq daemon and desktop UI. + * Also, the API and the desktop UI cannot run at the same time. Care must be taken to shut down one before starting + * the other. + *

+ * The criteria for determining which offers to take are defined in the bot's configuration file + * TakeBestPricedOfferToSellBtc.properties (located in project's src/main/resources directory). The individual + * configurations are commented in the existing TakeBestPricedOfferToSellBtc.properties, which should be used as a + * template for your own use case. + *

+ * One possible use case for this bot is buy BTC with GBP: + *

+ *      Take a "Faster Payment (Santander)" offer to sell BTC for GBP at or below current market price if:
+ *          the offer maker is a preferred trading peer,
+ *          and the offer's BTC amount is between 0.10 and 0.25 BTC,
+ *          and the current transaction mining fee rate is below 20 sats / byte.
+ * 
+ *

+ * Another possible use case for this bot is to sell BTC for XMR. (We might say "buy XMR with BTC", but we need to + * remember that all Bisq offers are for buying or selling BTC.) + *

+ *      Take an offer to sell BTC for XMR at or below current market price if:
+ *          the offer maker is a preferred trading peer,
+ *          and the offer's BTC amount is between 0.50 and 1.00 BTC,
+ *          and the current transaction mining fee rate is below 15 sats / byte.
+ * 
+ *

+ *

+ * Usage:  TakeBestPricedOfferToSellBtc  --password=api-password --port=api-port \
+ *                          [--conf=take-best-priced-offer-to-sell-btc.conf] \
+ *                          [--dryrun=true|false]
+ *                          [--simulate-regtest-payment=true|false]
+ * 
+ */ +@Slf4j +@Getter +public class TakeBestPricedOfferToSellBtc extends AbstractBot { + + // Config file: resources/TakeBestPricedOfferToSellBtc.properties. + private final Properties configFile; + // Taker bot's payment account (if the configured paymentAccountId is valid). + private final PaymentAccount paymentAccount; + // Taker bot's payment account trading currency code (if the configured paymentAccountId is valid). + private final String currencyCode; + // Taker bot's max market price margin. A takeable offer's price margin (%) must be <= maxMarketPriceMargin (%). + private final BigDecimal maxMarketPriceMargin; + // Taker bot's min BTC amount to buy (or sell in case of XMR). A takeable offer's amount must be >= minAmount BTC. + private final BigDecimal minAmount; + // Taker bot's max BTC amount to buy (or sell in case of XMR). A takeable offer's amount must be <= maxAmount BTC. + private final BigDecimal maxAmount; + // Taker bot's max acceptable transaction fee rate. + private final long maxTxFeeRate; + // Taker bot's trading fee currency code (BSQ or BTC). + private final String bisqTradeFeeCurrency; + // Maximum # of offers to take during one bot session (shut down bot after N swaps). + private final int maxTakeOffers; + + // Offer polling frequency must be > 1000 ms between each getoffers request. + private final long pollingInterval; + + // The # of BSQ swap offers taken during the bot session (since startup). + private int numOffersTaken = 0; + + public TakeBestPricedOfferToSellBtc(String[] args) { + super(args); + pingDaemon(new Date().getTime()); // Shut down now if API daemon is not available. + this.configFile = loadConfigFile(); + this.paymentAccount = getPaymentAccount(configFile.getProperty("paymentAccountId")); + this.currencyCode = paymentAccount.getSelectedTradeCurrency().getCode(); + this.maxMarketPriceMargin = new BigDecimal(configFile.getProperty("maxMarketPriceMargin")) + .setScale(2, HALF_UP); + this.minAmount = new BigDecimal(configFile.getProperty("minAmount")); + this.maxAmount = new BigDecimal(configFile.getProperty("maxAmount")); + this.maxTxFeeRate = Long.parseLong(configFile.getProperty("maxTxFeeRate")); + this.bisqTradeFeeCurrency = configFile.getProperty("bisqTradeFeeCurrency"); + this.maxTakeOffers = Integer.parseInt(configFile.getProperty("maxTakeOffers")); + loadPreferredOnionAddresses.accept(configFile, preferredTradingPeers); + this.pollingInterval = Long.parseLong(configFile.getProperty("pollingInterval")); + } + + /** + * Checks for the most attractive offer to take every {@link #pollingInterval} ms. After {@link #maxTakeOffers} + * are taken, bot will stop the API daemon, then shut itself down, prompting the user to start the desktop UI + * to complete the trade. + */ + @Override + public void run() { + var startTime = new Date().getTime(); + validatePollingInterval(pollingInterval); + validateTradeFeeCurrencyCode(bisqTradeFeeCurrency); + validatePaymentAccount(paymentAccount); + printBotConfiguration(); + + while (!isShutdown) { + if (!isBisqNetworkTxFeeRateLowEnough.test(maxTxFeeRate)) { + runCountdown(log, pollingInterval); + continue; + } + + // Taker bot's getOffers(direction) request param. For fiat offers, is SELL (BTC), for XMR offers, is BUY (BTC). + String offerDirection = isXmr.test(currencyCode) ? BUY.name() : SELL.name(); + + // Get all available and takeable offers, sorted by price ascending. + // The list contains both fixed-price and market price margin based offers. + var offers = getOffers(offerDirection, currencyCode).stream() + .filter(o -> !isAlreadyTaken.test(o)) + .toList(); + + if (offers.isEmpty()) { + log.info("No takeable offers found."); + runCountdown(log, pollingInterval); + continue; + } + + // Define criteria for taking an offer, based on conf file. + TakeCriteria takeCriteria = new TakeCriteria(); + takeCriteria.printCriteriaSummary(); + takeCriteria.printOffersAgainstCriteria(offers); + + // Find takeable offer based on criteria. + Optional selectedOffer = takeCriteria.findTakeableOffer(offers); + // Try to take the offer, if found, or say 'no offer found' before going to sleep. + selectedOffer.ifPresentOrElse(offer -> takeOffer(takeCriteria, offer), + () -> { + var cheapestOffer = offers.get(0); + log.info("No acceptable offer found. Closest possible candidate did not pass filters:"); + takeCriteria.printOfferAgainstCriteria(cheapestOffer); + }); + + printDryRunProgress(); + runCountdown(log, pollingInterval); + pingDaemon(startTime); + } + } + + /** + * Attempt to take the available offer according to configured criteria. If successful, will block until a new + * trade is fully initialized with a trade contract. Otherwise, handles a non-fatal error and allows the bot to + * stay alive, or shuts down the bot upon fatal error. + */ + private void takeOffer(TakeCriteria takeCriteria, OfferInfo offer) { + log.info("Will attempt to take offer '{}'.", offer.getId()); + takeCriteria.printOfferAgainstCriteria(offer); + if (isDryRun) { + addToOffersTaken(offer); + numOffersTaken++; + maybeShutdownAfterSuccessfulTradeCreation(); + } else { + // An encrypted wallet must be unlocked before calling takeoffer and gettrade. + // Unlock the wallet for 5 minutes. If the wallet is already unlocked, + // this command will override the timeout of the previous unlock command. + try { + unlockWallet(walletPassword, 600); + printBTCBalances("BTC Balances Before Take Offer Attempt"); + // Blocks until new trade is prepared, or times out. + takeV1ProtocolOffer(offer, paymentAccount, bisqTradeFeeCurrency, pollingInterval); + printBTCBalances("BTC Balances After Take Offer Attempt"); + + if (canSimulatePaymentSteps) { + var newTrade = getTrade(offer.getId()); + RegtestTradePaymentSimulator tradePaymentSimulator = new RegtestTradePaymentSimulator(args, + newTrade.getTradeId(), + paymentAccount); + tradePaymentSimulator.run(); + log.info("Trade payment simulation is complete. Closing bot channels and shutting down."); + printBTCBalances("BTC Balances After Simulated Trade Completion"); + } + numOffersTaken++; + maybeShutdownAfterSuccessfulTradeCreation(); + } catch (NonFatalException nonFatalException) { + handleNonFatalException(nonFatalException); + } catch (StatusRuntimeException fatalException) { + handleFatalException(fatalException); + } + } + } + + /** + * Log the non-fatal exception, and stall the bot if the NonFatalException has a stallTime value > 0. + */ + private void handleNonFatalException(NonFatalException nonFatalException) { + log.warn(nonFatalException.getMessage()); + if (nonFatalException.hasStallTime()) { + long stallTime = nonFatalException.getStallTime(); + log.warn("A minute must pass between the previous and the next takeoffer attempt." + + " Stalling for {} seconds before the next takeoffer attempt.", + toSeconds.apply(stallTime + pollingInterval)); + runCountdown(log, stallTime); + } else { + runCountdown(log, pollingInterval); + } + } + + /** + * Log the fatal exception, and shut down daemon and bot. + */ + private void handleFatalException(StatusRuntimeException fatalException) { + log.error("", fatalException); + shutdownAfterFailedTradePreparation(); + } + + /** + * Lock the wallet, stop the API daemon, and terminate the bot. + */ + private void maybeShutdownAfterSuccessfulTradeCreation() { + if (!isDryRun) { + try { + lockWallet(); + } catch (NonFatalException ex) { + log.warn(ex.getMessage()); + } + } + if (numOffersTaken >= maxTakeOffers) { + isShutdown = true; + + if (canSimulatePaymentSteps) { + log.info("Shutting down bot after successful trade completion. API daemon will not be shut down."); + sleep(2_000); + } else { + log.info("Shutting down API daemon and bot after taking {} offers." + + " Complete the trade(s) with the desktop UI.", + numOffersTaken); + sleep(2_000); + log.info("Sending stop request to daemon."); + stopDaemon(); + } + + exit(0); + + } else { + log.info("You have taken {} offers during this bot session.", numOffersTaken); + } + } + + /** + * Lock the wallet, stop the API daemon, and terminate the bot with a non-zero status (error). + */ + private void shutdownAfterFailedTradePreparation() { + shutdownAfterFatalError("Shutting down API daemon and bot after failing to find new trade."); + } + + /** + * Return true is fixed-price offer's price <= the bot's max market price margin. Allows bot to take a + * fixed-priced offer if the price is <= {@link #maxMarketPriceMargin} (%) of the current market price. + */ + protected final BiPredicate isFixedPriceLEMaxMarketPriceMargin = + (offer, currentMarketPrice) -> BotUtils.isFixedPriceLEMaxMarketPriceMargin( + offer, + currentMarketPrice, + getMaxMarketPriceMargin()); + + /** + * Return true if offer.amt >= bot.minAmt AND offer.amt <= bot.maxAmt (within the boundaries). + * TODO API's takeoffer needs to support taking offer's minAmount. + */ + protected final Predicate isWithinBTCAmountBounds = (offer) -> + BotUtils.isWithinBTCAmountBounds(offer, getMinAmount(), getMaxAmount()); + + private void printBotConfiguration() { + var configsByLabel = new LinkedHashMap(); + configsByLabel.put("Bot OS:", getOSName() + " " + getOSVersion()); + var network = getNetwork(); + configsByLabel.put("BTC Network:", network); + configsByLabel.put("My Payment Account:", ""); + configsByLabel.put("\tPayment Account Id:", paymentAccount.getId()); + configsByLabel.put("\tAccount Name:", paymentAccount.getAccountName()); + configsByLabel.put("\tCurrency Code:", currencyCode); + configsByLabel.put("Trading Rules:", ""); + configsByLabel.put("\tMax # of offers bot can take:", maxTakeOffers); + configsByLabel.put("\tMax Tx Fee Rate:", maxTxFeeRate + " sats/byte"); + configsByLabel.put("\tMax Market Price Margin:", maxMarketPriceMargin + "%"); + configsByLabel.put("\tMin BTC Amount:", minAmount + " BTC"); + configsByLabel.put("\tMax BTC Amount: ", maxAmount + " BTC"); + if (iHavePreferredTradingPeers.get()) { + configsByLabel.put("\tPreferred Trading Peers:", preferredTradingPeers.toString()); + } else { + configsByLabel.put("\tPreferred Trading Peers:", "N/A"); + } + configsByLabel.put("Bot Polling Interval:", pollingInterval + " ms"); + log.info(toTable.apply("Bot Configuration", configsByLabel)); + } + + public static void main(String[] args) { + @SuppressWarnings("unused") + String prompt = "An encrypted wallet must be unlocked before any offer can be taken.\n" + + " Please enter your wallet password:"; + String walletPassword = "be careful"; // readWalletPassword(prompt); + log.info("Your wallet password is {}", walletPassword.isBlank() ? "blank" : walletPassword); + TakeBestPricedOfferToSellBtc bot = new TakeBestPricedOfferToSellBtc(appendWalletPasswordOpt(args, walletPassword)); + bot.run(); + } + + /** + * Calculates additional takeoffer criteria based on conf file values, + * performs candidate offer filtering, and provides useful log statements. + */ + private class TakeCriteria { + private final BigDecimal currentMarketPrice; + @Getter + private final BigDecimal targetPrice; + + private final Supplier marketDescription = () -> { + if (isXmr.test(currencyCode)) + return "Sell XMR (Buy BTC)"; + else + return "Sell BTC"; + }; + + public TakeCriteria() { + this.currentMarketPrice = getCurrentMarketPrice(currencyCode); + this.targetPrice = calcTargetPrice(maxMarketPriceMargin, currentMarketPrice, currencyCode); + } + + /** + * Returns the lowest priced offer passing the filters, or Optional.empty() if not found. + * Max tx fee rate filtering should have passed prior to calling this method. + * + * @param offers to filter + */ + Optional findTakeableOffer(List offers) { + if (iHavePreferredTradingPeers.get()) + return offers.stream() + .filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount())) + .filter(isMakerPreferredTradingPeer) + .filter(o -> isMarginLEMaxMarketPriceMargin.test(o, maxMarketPriceMargin) + || isFixedPriceLEMaxMarketPriceMargin.test(o, currentMarketPrice)) + .filter(isWithinBTCAmountBounds) + .findFirst(); + else + return offers.stream() + .filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount())) + .filter(o -> isMarginLEMaxMarketPriceMargin.test(o, maxMarketPriceMargin) + || isFixedPriceLEMaxMarketPriceMargin.test(o, currentMarketPrice)) + .filter(isWithinBTCAmountBounds) + .findFirst(); + } + + void printCriteriaSummary() { + log.info("Looking for offers to {}, priced at or less than {}% {} the current market price {} {}.", + marketDescription.get(), + maxMarketPriceMargin.abs(), // Hide the sign, text explains target price % "above or below". + aboveOrBelowMarketPrice.apply(maxMarketPriceMargin), + currentMarketPrice, + isXmr.test(currencyCode) ? "BTC" : currencyCode); + } + + void printOffersAgainstCriteria(List offers) { + log.info("Currently available {} offers -- want to take {} offer with price <= {} {}.", + marketDescription.get(), + currencyCode, + targetPrice, + isXmr.test(currencyCode) ? "BTC" : currencyCode); + printOffersSummary(offers); + } + + void printOfferAgainstCriteria(OfferInfo offer) { + printOfferSummary(offer); + + var filterResultsByLabel = new LinkedHashMap(); + filterResultsByLabel.put("Current Market Price:", currentMarketPrice + " " + currencyCode); + filterResultsByLabel.put("Target Price (Max):", targetPrice + " " + currencyCode); + filterResultsByLabel.put("Offer Price:", offer.getPrice() + " " + currencyCode); + filterResultsByLabel.put("Offer maker used same payment method?", + usesSamePaymentMethod.test(offer, getPaymentAccount())); + filterResultsByLabel.put("Is offer maker a preferred trading peer?", + iHavePreferredTradingPeers.get() + ? isMakerPreferredTradingPeer.test(offer) ? "YES" : "NO" + : "N/A"); + var marginPriceLabel = format("Is offer's price margin (%s%%) <= bot's max market price margin (%s%%)?", + offer.getMarketPriceMarginPct(), + maxMarketPriceMargin); + filterResultsByLabel.put(marginPriceLabel, + offer.getUseMarketBasedPrice() + ? isMarginLEMaxMarketPriceMargin.test(offer, maxMarketPriceMargin) + : "N/A"); + var fixedPriceLabel = format("Is offer's fixed-price (%s) <= bot's target price (%s)?", + offer.getUseMarketBasedPrice() ? "N/A" : offer.getPrice() + " " + currencyCode, + offer.getUseMarketBasedPrice() ? "N/A" : targetPrice + " " + currencyCode); + filterResultsByLabel.put(fixedPriceLabel, + offer.getUseMarketBasedPrice() + ? "N/A" + : isFixedPriceLEMaxMarketPriceMargin.test(offer, currentMarketPrice)); + String btcAmountBounds = format("%s BTC - %s BTC", minAmount, maxAmount); + filterResultsByLabel.put("Is offer's BTC amount within bot amount bounds (" + btcAmountBounds + ")?", + isWithinBTCAmountBounds.test(offer)); + + var title = format("%s offer %s filter results:", + offer.getUseMarketBasedPrice() ? "Margin based" : "Fixed price", + offer.getId()); + log.info(toTable.apply(title, filterResultsByLabel)); + } + } +} diff --git a/java-examples/src/main/resources/RegtestTradePaymentSimulator.properties b/java-examples/src/main/resources/RegtestTradePaymentSimulator.properties new file mode 100644 index 0000000..d17c79e --- /dev/null +++ b/java-examples/src/main/resources/RegtestTradePaymentSimulator.properties @@ -0,0 +1 @@ +pollingInterval=10000 diff --git a/java-examples/src/main/resources/TakeBestPricedOfferToBuyBsq.properties b/java-examples/src/main/resources/TakeBestPricedOfferToBuyBsq.properties new file mode 100644 index 0000000..22176e7 --- /dev/null +++ b/java-examples/src/main/resources/TakeBestPricedOfferToBuyBsq.properties @@ -0,0 +1,26 @@ +# Maximum # of offers to take during one bot session. When reached, bot will shut down (but not the API daemon). +maxTakeOffers=1 +# +# Minimum distance from 30-day average BSQ trade price. +# Note: all BSQ Swap offers have a fixed-price, but the bot uses a margin (%) of the 30-day price for comparison. +minMarketPriceMargin=0 +# +# Hard coded 30-day average BSQ trade price, used for development over regtest. +regtest30DayAvgBsqPrice=0.00005 +# +# Taker bot's min BTC amount to sell. The candidate SELL BTC offer's amount must be >= minAmount BTC. +minAmount=0.01 +# +# Taker bot's max BTC amount to sell. The candidate SELL BTC offer's amount must be <= maxAmount BTC. +maxAmount=0.50 +# +# Taker bot's max acceptable transaction fee rate (sats / byte). +# Regtest fee rates are from https://price.bisq.wiz.biz/getFees +maxTxFeeRate=25 +# +# Taker bot's list of preferred trading peers (their onion addresses). +# If you do not want to constrict trading to preferred peers, comment this line out with a '#' character. +preferredTradingPeers=localhost:8888 +# +# Offer polling frequency must be >= 1s (1000ms) between each getoffers request. +pollingInterval=30000 diff --git a/java-examples/src/main/resources/TakeBestPricedOfferToBuyBtc.properties b/java-examples/src/main/resources/TakeBestPricedOfferToBuyBtc.properties new file mode 100644 index 0000000..0f92400 --- /dev/null +++ b/java-examples/src/main/resources/TakeBestPricedOfferToBuyBtc.properties @@ -0,0 +1,38 @@ +# +# Maximum # of offers to take during one bot session. When reached, bot will shut down API daemon then itself. +maxTakeOffers=10 +# +# Taker bot's payment account id. Only BUY BTC offers using the same payment method will be considered for taking. +paymentAccountId=9ad3cc7a-7d32-453c-b9db-a3714b5b8f61 +# +# Taker bot's min market price margin. A candidate BUY BTC offer's price margin must be >= minMarketPriceMargin. +# +minMarketPriceMargin=1.00 +# +# Taker bot's min BTC amount to sell. The candidate BUY offer's amount must be >= minAmount BTC. +minAmount=0.01 +# +# Taker bot's max BTC amount to sell. The candidate BUY offer's amount must be <= maxAmount BTC. +maxAmount=0.50 +# +# Taker bot's max acceptable transaction fee rate (sats / byte). +# Regtest fee rates are from https://price.bisq.wiz.biz/getFees +maxTxFeeRate=25 +# +# Bisq trade fee currency code (BSQ or BTC). +bisqTradeFeeCurrency=BSQ +# +# Taker bot's list of preferred trading peers (their onion addresses). +# If you do not want to constrict trading to preferred peers, comment this line out with a '#' character. +preferredTradingPeers=localhost:8888, \ + nysf2pknaaxfh26k42ego5mnfzpbozyi3nuoxdu745unvva4pvywffyd.onion:9999, \ + vrexx4kqxszmwn6tlde3rvrzmvi3klgzlgjl5dfa2rc7orp4saext3yd.onion:9999, \ + y7lvgclwyg6kz7mgystx67vd56vlvxbeg62wgrnrtknspmneeq6ecfid.onion:9999, \ + yv4sfwskwbzsokil5vdua4vsfrfbve6dgv7rwjfn5ensxxrzuulkehyd.onion:9999, \ + unskoifqryw22b53ut4cghrs7ekp6k7zlvqd3pi73y4zctpmmy2fhnyd.onion:9999, \ + tspph3aycnnfuxnxy73bx7p3uxirplawmnizli3y74gbzvrjdruz2rad.onion:9999, \ + zdlbe7xfmmmtvr6esqwr52qk2bysq7jicmj3rxjr5ieuagtvraqpfrid.onion:9999, \ + x6x2o3m6rxhkfuf2v6lalbharf3whwvkts5rdn3jkhgieqvnq6mvdfyd.onion:9999 +# +# Offer polling frequency must be >= 1s (1000ms) between each getoffers request. +pollingInterval=20000 diff --git a/java-examples/src/main/resources/TakeBestPricedOfferToSellBsq.properties b/java-examples/src/main/resources/TakeBestPricedOfferToSellBsq.properties new file mode 100644 index 0000000..f45e0a8 --- /dev/null +++ b/java-examples/src/main/resources/TakeBestPricedOfferToSellBsq.properties @@ -0,0 +1,26 @@ +# Maximum # of offers to take during one bot session. When reached, bot will shut down (but not the API daemon). +maxTakeOffers=10 +# +# Maximum distance from 30-day average BSQ trade price. +# Note: all BSQ Swap offers have a fixed-price, but the bot uses a margin (%) of the 30-day price for comparison. +maxMarketPriceMargin=0.00 +# +# Hard coded 30-day average BSQ trade price, used for development over regtest. +regtest30DayAvgBsqPrice=0.00005 +# +# Taker bot's min BTC amount to buy. The candidate BUY BTC offer's amount must be >= minAmount BTC. +minAmount=0.01 +# +# Taker bot's max BTC amount to buy. The candidate BUY BTC offer's amount must be <= maxAmount BTC. +maxAmount=0.50 +# +# Taker bot's max acceptable transaction fee rate (sats / byte). +# Regtest fee rates are from https://price.bisq.wiz.biz/getFees +maxTxFeeRate=25 +# +# Taker bot's list of preferred trading peers (their onion addresses). +# If you do not want to constrict trading to preferred peers, comment this line out with a '#' character. +preferredTradingPeers=localhost:8888 +# +# Offer polling frequency must be >= 1s (1000ms) between each getoffers request. +pollingInterval=30000 diff --git a/java-examples/src/main/resources/TakeBestPricedOfferToSellBtc.properties b/java-examples/src/main/resources/TakeBestPricedOfferToSellBtc.properties new file mode 100644 index 0000000..f689581 --- /dev/null +++ b/java-examples/src/main/resources/TakeBestPricedOfferToSellBtc.properties @@ -0,0 +1,37 @@ +# +# Maximum # of offers to take during one bot session. When reached, bot will shut down API daemon then itself. +maxTakeOffers=4 +# +# Taker bot's payment account id. Only SELL BTC offers using the same payment method will be considered for taking. +paymentAccountId=9ad3cc7a-7d32-453c-b9db-a3714b5b8f61 +# +# Taker bot's max market price margin. A candidate SELL BTC offer's price margin must be <= maxMarketPriceMargin. +maxMarketPriceMargin=3.00 +# +# Taker bot's min BTC amount to buy. The candidate SELL offer's amount must be >= minAmount BTC. +minAmount=0.01 +# +# Taker bot's max BTC amount to buy. The candidate SELL offer's amount must be <= maxAmount BTC. +maxAmount=0.50 +# +# Taker bot's max acceptable transaction fee rate (sats / byte). +# Regtest fee rates are from https://price.bisq.wiz.biz/getFees +maxTxFeeRate=100 +# +# Bisq trade fee currency code (BSQ or BTC). +bisqTradeFeeCurrency=BSQ +# +# Taker bot's list of preferred trading peers (their onion addresses). +# If you do not want to constrict trading to preferred peers, comment this line out with a '#' character. +preferredTradingPeers=localhost:8888, \ + nysf2pknaaxfh26k42ego5mnfzpbozyi3nuoxdu745unvva4pvywffyd.onion:9999, \ + vrexx4kqxszmwn6tlde3rvrzmvi3klgzlgjl5dfa2rc7orp4saext3yd.onion:9999, \ + y7lvgclwyg6kz7mgystx67vd56vlvxbeg62wgrnrtknspmneeq6ecfid.onion:9999, \ + yv4sfwskwbzsokil5vdua4vsfrfbve6dgv7rwjfn5ensxxrzuulkehyd.onion:9999, \ + unskoifqryw22b53ut4cghrs7ekp6k7zlvqd3pi73y4zctpmmy2fhnyd.onion:9999, \ + tspph3aycnnfuxnxy73bx7p3uxirplawmnizli3y74gbzvrjdruz2rad.onion:9999, \ + zdlbe7xfmmmtvr6esqwr52qk2bysq7jicmj3rxjr5ieuagtvraqpfrid.onion:9999, \ + x6x2o3m6rxhkfuf2v6lalbharf3whwvkts5rdn3jkhgieqvnq6mvdfyd.onion:9999 +# +# Offer polling frequency must be >= 1s (1000ms) between each getoffers request. +pollingInterval=20000