Add new java bots for taking btc, xmr, and bsq swap offers

Details about each are in the java-docs.
This commit is contained in:
ghubstan 2022-06-24 14:02:02 -03:00
parent 6422ac0cab
commit 7281a764f8
No known key found for this signature in database
GPG Key ID: E35592D6800A861E
15 changed files with 3776 additions and 0 deletions

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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.
* <p>
* 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<String> 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<OfferInfo> offersTaken = new ArrayList<>();
protected final boolean canUseBash = getBashPath().isPresent();
protected boolean isShutdown = false;
protected final Supplier<String> defaultPropertiesFilename = () -> this.getClass().getSimpleName() + ".properties";
protected final Supplier<TxFeeRateInfo> txFeeRates = this::getTxFeeRateInfo;
protected final Supplier<Long> minimumTxFeeRate = () -> txFeeRates.get().getMinFeeServiceRate();
protected final Supplier<Long> 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<Boolean> iHavePreferredTradingPeers = () -> preferredTradingPeers.size() > 0;
/**
* Return true is the offer maker's onion address is configured as a preferred trading peer.
*/
protected final Predicate<OfferInfo> 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<Long> 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<String> 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<Properties, List<String>> 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<OfferInfo> isAlreadyTaken = (offer) ->
offersTaken.stream().anyMatch(o -> o.getId().equals(offer.getId()));
/**
* Print a table of BSQ balance information.
* <p>
* 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.
* <p>
* 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 <a href="https://bisq-network.github.io/slate/#rpc-method-getbalances">https://bisq-network.github.io/slate/#balancesinfohttps://bisq-network.github.io/slate/#rpc-method-getbalances</a>
* @see <a href="https://bisq-network.github.io/slate/#balancesinfo">https://bisq-network.github.io/slate/#balancesinfo</a>
*/
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 <a href="https://bisq-network.github.io/slate/?java#service-shutdownserver">https://bisq-network.github.io/slate/?java#service-shutdownserver</a>
*/
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 <a href="https://bisq-network.github.io/slate/?java#rpc-method-lockwallet">https://bisq-network.github.io/slate/?java#rpc-method-lockwallet</a>
*/
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 <a href="https://bisq-network.github.io/slate/?java#rpc-method-unlockwallet">https://bisq-network.github.io/slate/?java#rpc-method-unlockwallet</a>
*/
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 <a href="https://bisq-network.github.io/slate/?java#rpc-method-getpaymentaccounts">https://bisq-network.github.io/slate/?java#rpc-method-getpaymentaccounts</a>
* @see <a href="https://bisq-network.github.io/slate/?java#paymentaccount">https://bisq-network.github.io/slate/?java#paymentaccount</a>
*/
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 <a href="https://bisq-network.github.io/slate/?java#rpc-method-getpaymentaccounts">https://bisq-network.github.io/slate/?java#rpc-method-getpaymentaccounts</a>
* @see <a href="https://bisq-network.github.io/slate/?java#paymentaccount">https://bisq-network.github.io/slate/?java#paymentaccount</a>
*/
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 <a href="https://bisq-network.github.io/slate/?java#rpc-method-getoffer">https://bisq-network.github.io/slate/?java#rpc-method-getoffer</a>
* @see <a href="https://bisq-network.github.io/slate/?java#offerinfo">https://bisq-network.github.io/slate/?java#offerinfo</a>
*/
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<bisq.proto.grpc.OfferInfo>
* @see <a href="https://bisq-network.github.io/slate/?java#rpc-method-getoffers">https://bisq-network.github.io/slate/?java#rpc-method-getoffers</a>
*/
protected List<OfferInfo> 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 <a href="https://bisq-network.github.io/slate/?java#rpc-method-takeoffer">https://bisq-network.github.io/slate/?java#rpc-method-takeoffer</a>
*/
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 <a href="https://bisq-network.github.io/slate/?java#rpc-method-takeoffer">https://bisq-network.github.io/slate/?java#rpc-method-takeoffer</a>
*/
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 <a href="https://bisq-network.github.io/slate/?java#rpc-method-gettrade">https://bisq-network.github.io/slate/?java#rpc-method-gettrade</a>
*/
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<bisq.proto.grpc.TradeInfo>
*/
protected List<TradeInfo> 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 <a href="https://bisq-network.github.io/slate/?java#rpc-method-getmarketprice">https://bisq-network.github.io/slate/?java#rpc-method-getmarketprice</a>
*/
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 <a href="https://bisq-network.github.io/slate/?java#rpc-method-getavgbsqprice">https://bisq-network.github.io/slate/?java#rpc-method-getavgbsqprice</a>
* // 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.
* <p>
*
* @return bisq.proto.grpc.TxFeeRateInfo
* @see <a href="https://bisq-network.github.io/slate/#txfeerateinfo">https://bisq-network.github.io/slate/#txfeerateinfo</a>
*/
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.
* <p>
* 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));
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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<String> isBsq = (currencyCode) -> currencyCode.equalsIgnoreCase("BSQ");
public static final Predicate<String> isXmr = (currencyCode) -> currencyCode.equalsIgnoreCase("XMR");
public static final Predicate<String> isAltcoin = (currencyCode) -> isBsq.test(currencyCode) || isXmr.test(currencyCode);
/**
* Return price precision of 8 for altcoin, 4 for fiat.
*/
public static final Function<String, Integer> 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<Long, Long> toSeconds = (ms) -> Duration.ofMillis(ms).getSeconds();
/**
* Return true if given BigDecimal equals 0.00.
*/
public static final Predicate<BigDecimal> 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<BigDecimal, BigDecimal> 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<BigDecimal, BigDecimal, BigDecimal> 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<OfferInfo, BigDecimal> 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<OfferInfo, BigDecimal> 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<BigDecimal, String> 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<StatusRuntimeException, Status> 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<StatusRuntimeException, String> 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<StatusRuntimeException, String> 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<OfferInfo, PaymentAccount> 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<TradeInfo> 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.
* <p>
* 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, Map<String, Object>, String> toTable = (title, map) -> {
var mapElements = map.entrySet();
Supplier<Integer> 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<String> 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<OfferInfo> 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<TradeInfo> 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.
* <p>
* 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<String> 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<String> 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<String>
*/
public static Optional<String> 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);
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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=<api-password>' option");
printHelp(parser, err);
exit(1);
}
this.walletPassword = options.valueOf(walletPasswordOpt);
if (walletPassword == null) {
log.error("Missing required '--wallet-password=<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=<configuration file>' 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);
}
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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;
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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.
* <p>
* 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.
* <pre>
* See <a href="https://bisq-network.github.io/slate/#rpc-method-takeoffer">https://bisq-network.github.io/slate/#rpc-method-takeoffer</a>
* See <a href="https://bisq-network.github.io/slate/#availabilityresultwithdescription">https://bisq-network.github.io/slate/#availabilityresultwithdescription</a>
* See <a href="https://bisq-network.github.io/slate/#availabilityresult">https://bisq-network.github.io/slate/#availabilityresult</a>
* </pre>
* <p>
* 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.
* <p>
* Any other gRPC StatusRuntimeException passed up to the client should be considered fatal.
* <p>
* 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 <a href="https://bisq-network.github.io/slate/?java#rpc-method-takeoffer">https://bisq-network.github.io/slate/?java#rpc-method-takeoffer</a>
*/
// 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.
* <p>
* 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.
* <p>
* 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).
* <p>
* 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.
* <p>
* 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<TradeInfo> 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.
* <p>
* 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<TradeInfo> 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.
* <p>
* If the newly prepared trade is found within the time limit, returns an Optional<TradeInfo> object, else
* throws a gRPC StatusRuntimeException with Status.Code = NOT_FOUND.
*
* @return Optional<TradeInfo> containing a prepared trade.
*/
private Optional<TradeInfo> getPreparedTrade() {
int attempts = 0;
while (attempts++ < MAX_GET_NEW_TRADE_ATTEMPTS - 1) {
Optional<TradeInfo> 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<TradeInfo> containing a trade, Optional.empty() if not found, or throws a
* gRPC StatusRuntimeException.
* <p>
* 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<TradeInfo> containing a trade, or Optional.empty() if not found, or throws a
* gRPC StatusRuntimeException.
*/
private Optional<TradeInfo> 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 <a href="https://bisq-network.github.io/slate/?java#rpc-method-gettrade">https://bisq-network.github.io/slate/?java#rpc-method-gettrade</a>
*/
private TradeInfo getTrade() {
var response = grpcStubs.tradesService.getTrade(getTradeRequest);
return response.getTrade();
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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!"));
}
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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.
* <p>
* 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<OfferInfo> 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<String, Object>();
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<OfferInfo, BigDecimal> 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<OfferInfo> 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<OfferInfo> findTakeableOffer(List<OfferInfo> 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<OfferInfo> 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<String, Object>();
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));
}
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* One possible use case for this bot is sell BTC for GBP:
* <pre>
* 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.
* </pre>
* <p>
* 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.)
* <pre>
* 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.
* </pre>
* <p>
* <pre>
* 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]
* </pre>
*/
@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<OfferInfo> 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<OfferInfo, BigDecimal> 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<OfferInfo> isWithinBTCAmountBounds = (offer) ->
BotUtils.isWithinBTCAmountBounds(offer, getMinAmount(), getMaxAmount());
private void printBotConfiguration() {
var configsByLabel = new LinkedHashMap<String, Object>();
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<String> 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<OfferInfo> findTakeableOffer(List<OfferInfo> 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<OfferInfo> 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<String, Object>();
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));
}
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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.
* <p>
* 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<OfferInfo> 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<String, Object>();
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<OfferInfo, BigDecimal> 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<OfferInfo> 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<OfferInfo> findTakeableOffer(List<OfferInfo> 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<OfferInfo> 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<String, Object>();
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));
}
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* One possible use case for this bot is buy BTC with GBP:
* <pre>
* 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.
* </pre>
* <p>
* 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.)
* <pre>
* 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.
* </pre>
* <p>
* <pre>
* 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]
* </pre>
*/
@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<OfferInfo> 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<OfferInfo, BigDecimal> 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<OfferInfo> isWithinBTCAmountBounds = (offer) ->
BotUtils.isWithinBTCAmountBounds(offer, getMinAmount(), getMaxAmount());
private void printBotConfiguration() {
var configsByLabel = new LinkedHashMap<String, Object>();
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<String> 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<OfferInfo> findTakeableOffer(List<OfferInfo> 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<OfferInfo> 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<String, Object>();
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));
}
}
}

View File

@ -0,0 +1 @@
pollingInterval=10000

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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