Merge pull request #5 from bisq-network/api-takeoffer-java-bots

New takeoffer java bots
This commit is contained in:
Stan 2022-06-24 14:26:51 -03:00 committed by GitHub
commit 0d44fc2148
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 7283 additions and 1 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,120 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots;
import bisq.proto.grpc.TxFeeRateInfo;
import com.google.common.annotations.VisibleForTesting;
import java.math.BigDecimal;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.text.NumberFormat;
import java.util.Locale;
import static java.lang.String.format;
import static java.math.RoundingMode.HALF_UP;
import static java.math.RoundingMode.UNNECESSARY;
/**
* Utility for formatting amounts, volumes and fees; there is no i18n support in the CLI.
*/
@VisibleForTesting
public class CurrencyFormat {
// Use the US locale as a base for all DecimalFormats, but commas should be omitted from number strings.
private static final DecimalFormatSymbols DECIMAL_FORMAT_SYMBOLS = DecimalFormatSymbols.getInstance(Locale.US);
// Use the US locale as a base for all NumberFormats, but commas should be omitted from number strings.
private static final NumberFormat US_LOCALE_NUMBER_FORMAT = NumberFormat.getInstance(Locale.US);
// Formats numbers for internal use, i.e., grpc request parameters.
private static final DecimalFormat INTERNAL_FIAT_DECIMAL_FORMAT = new DecimalFormat("##############0.0000");
static final BigDecimal SATOSHI_DIVISOR = new BigDecimal(100_000_000);
static final DecimalFormat SATOSHI_FORMAT = new DecimalFormat("###,##0.00000000", DECIMAL_FORMAT_SYMBOLS);
static final DecimalFormat BTC_FORMAT = new DecimalFormat("###,##0.########", DECIMAL_FORMAT_SYMBOLS);
static final DecimalFormat BTC_TX_FEE_FORMAT = new DecimalFormat("###,###,##0", DECIMAL_FORMAT_SYMBOLS);
static final BigDecimal BSQ_SATOSHI_DIVISOR = new BigDecimal(100);
static final DecimalFormat BSQ_FORMAT = new DecimalFormat("###,###,###,##0.00", DECIMAL_FORMAT_SYMBOLS);
public static String formatSatoshis(String sats) {
//noinspection BigDecimalMethodWithoutRoundingCalled
return SATOSHI_FORMAT.format(new BigDecimal(sats).divide(SATOSHI_DIVISOR));
}
@SuppressWarnings("BigDecimalMethodWithoutRoundingCalled")
public static String formatSatoshis(long sats) {
return SATOSHI_FORMAT.format(new BigDecimal(sats).divide(SATOSHI_DIVISOR));
}
@SuppressWarnings("BigDecimalMethodWithoutRoundingCalled")
public static String formatBtc(long sats) {
return BTC_FORMAT.format(new BigDecimal(sats).divide(SATOSHI_DIVISOR));
}
@SuppressWarnings("BigDecimalMethodWithoutRoundingCalled")
public static String formatBsq(long sats) {
return BSQ_FORMAT.format(new BigDecimal(sats).divide(BSQ_SATOSHI_DIVISOR));
}
public static String formatTxFeeRateInfo(TxFeeRateInfo txFeeRateInfo) {
if (txFeeRateInfo.getUseCustomTxFeeRate())
return format("custom tx fee rate: %s sats/byte, network rate: %s sats/byte, min network rate: %s sats/byte",
formatFeeSatoshis(txFeeRateInfo.getCustomTxFeeRate()),
formatFeeSatoshis(txFeeRateInfo.getFeeServiceRate()),
formatFeeSatoshis(txFeeRateInfo.getMinFeeServiceRate()));
else
return format("tx fee rate: %s sats/byte, min tx fee rate: %s sats/byte",
formatFeeSatoshis(txFeeRateInfo.getFeeServiceRate()),
formatFeeSatoshis(txFeeRateInfo.getMinFeeServiceRate()));
}
public static String formatPrice(long price) {
US_LOCALE_NUMBER_FORMAT.setMinimumFractionDigits(4);
US_LOCALE_NUMBER_FORMAT.setMaximumFractionDigits(4);
US_LOCALE_NUMBER_FORMAT.setRoundingMode(UNNECESSARY);
return US_LOCALE_NUMBER_FORMAT.format((double) price / 10_000);
}
public static String formatFiatVolume(long volume) {
US_LOCALE_NUMBER_FORMAT.setMinimumFractionDigits(0);
US_LOCALE_NUMBER_FORMAT.setMaximumFractionDigits(0);
US_LOCALE_NUMBER_FORMAT.setRoundingMode(HALF_UP);
return US_LOCALE_NUMBER_FORMAT.format((double) volume / 10_000);
}
public static long toSatoshis(BigDecimal btc) {
return btc.multiply(SATOSHI_DIVISOR).longValue();
}
public static long toSatoshis(String btc) {
if (btc.startsWith("-"))
throw new IllegalArgumentException(format("'%s' is not a positive number", btc));
try {
return new BigDecimal(btc).multiply(SATOSHI_DIVISOR).longValue();
} catch (NumberFormatException e) {
throw new IllegalArgumentException(format("'%s' is not a number", btc));
}
}
public static String formatFeeSatoshis(long sats) {
return BTC_TX_FEE_FORMAT.format(BigDecimal.valueOf(sats));
}
}

View File

@ -0,0 +1,67 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots;
import bisq.proto.grpc.*;
import io.grpc.CallCredentials;
import io.grpc.ManagedChannelBuilder;
import lombok.extern.slf4j.Slf4j;
import static java.util.concurrent.TimeUnit.SECONDS;
/**
* gRPC Service Stubs -- all blocking.
*/
@Slf4j
final class GrpcStubs {
public final DisputeAgentsGrpc.DisputeAgentsBlockingStub disputeAgentsService;
public final HelpGrpc.HelpBlockingStub helpService;
public final GetVersionGrpc.GetVersionBlockingStub versionService;
public final OffersGrpc.OffersBlockingStub offersService;
public final PaymentAccountsGrpc.PaymentAccountsBlockingStub paymentAccountsService;
public final PriceGrpc.PriceBlockingStub priceService;
public final ShutdownServerGrpc.ShutdownServerBlockingStub shutdownService;
public final TradesGrpc.TradesBlockingStub tradesService;
public final WalletsGrpc.WalletsBlockingStub walletsService;
public GrpcStubs(String apiHost, int apiPort, String apiPassword) {
CallCredentials credentials = new PasswordCallCredentials(apiPassword);
var channel = ManagedChannelBuilder.forAddress(apiHost, apiPort).usePlaintext().build();
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try {
log.info("Shutting down bot's grpc channel.");
channel.shutdown().awaitTermination(1, SECONDS);
log.info("Bot channel shutdown complete.");
} catch (InterruptedException ex) {
throw new IllegalStateException(ex);
}
}));
this.disputeAgentsService = DisputeAgentsGrpc.newBlockingStub(channel).withCallCredentials(credentials);
this.helpService = HelpGrpc.newBlockingStub(channel).withCallCredentials(credentials);
this.versionService = GetVersionGrpc.newBlockingStub(channel).withCallCredentials(credentials);
this.offersService = OffersGrpc.newBlockingStub(channel).withCallCredentials(credentials);
this.paymentAccountsService = PaymentAccountsGrpc.newBlockingStub(channel).withCallCredentials(credentials);
this.priceService = PriceGrpc.newBlockingStub(channel).withCallCredentials(credentials);
this.shutdownService = ShutdownServerGrpc.newBlockingStub(channel).withCallCredentials(credentials);
this.tradesService = TradesGrpc.newBlockingStub(channel).withCallCredentials(credentials);
this.walletsService = WalletsGrpc.newBlockingStub(channel).withCallCredentials(credentials);
}
}

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,62 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots;
import io.grpc.CallCredentials;
import io.grpc.Metadata;
import io.grpc.Metadata.Key;
import java.util.concurrent.Executor;
import static io.grpc.Metadata.ASCII_STRING_MARSHALLER;
import static io.grpc.Status.UNAUTHENTICATED;
import static java.lang.String.format;
/**
* Sets the {@value PASSWORD_KEY} rpc call header to a given value.
*/
class PasswordCallCredentials extends CallCredentials {
public static final String PASSWORD_KEY = "password";
private final String passwordValue;
public PasswordCallCredentials(String passwordValue) {
if (passwordValue == null)
throw new IllegalArgumentException(format("'%s' value must not be null", PASSWORD_KEY));
this.passwordValue = passwordValue;
}
@Override
public void applyRequestMetadata(RequestInfo requestInfo, Executor appExecutor, MetadataApplier metadataApplier) {
appExecutor.execute(() -> {
try {
var headers = new Metadata();
var passwordKey = Key.of(PASSWORD_KEY, ASCII_STRING_MARSHALLER);
headers.put(passwordKey, passwordValue);
metadataApplier.apply(headers);
} catch (Throwable ex) {
metadataApplier.fail(UNAUTHENTICATED.withCause(ex));
}
});
}
@Override
public void thisUsesUnstableApi() {
}
}

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,153 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots.table;
import bisq.bots.table.column.Column;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.util.stream.IntStream;
import static bisq.bots.table.column.Column.JUSTIFICATION.RIGHT;
import static com.google.common.base.Strings.padStart;
import static java.lang.String.format;
import static java.nio.charset.StandardCharsets.UTF_8;
/**
* A simple table of formatted data for the CLI's output console. A table must be
* created with at least one populated column, and each column passed to the constructor
* must contain the same number of rows. Null checking is omitted because tables are
* populated by protobuf message fields which cannot be null.
* <p>
* All data in a column has the same type: long, string, etc., but a table
* may contain an arbitrary number of columns of any type. For output formatting
* purposes, numeric and date columns should be transformed to a StringColumn type with
* formatted and justified string values before being passed to the constructor.
* <p>
* This is not a relational, rdbms table.
*/
public class Table {
public final Column<?>[] columns;
public final int rowCount;
// Each printed column is delimited by two spaces.
private final int columnDelimiterLength = 2;
/**
* Default constructor. Takes populated Columns.
*
* @param columns containing the same number of rows
*/
public Table(Column<?>... columns) {
this.columns = columns;
this.rowCount = columns.length > 0 ? columns[0].rowCount() : 0;
validateStructure();
}
/**
* Print table data to a PrintStream.
*
* @param printStream the target output stream
*/
public void print(PrintStream printStream) {
printColumnNames(printStream);
for (int rowIndex = 0; rowIndex < rowCount; rowIndex++) {
printRow(printStream, rowIndex);
}
}
/**
* Print table column names to a PrintStream.
*
* @param printStream the target output stream
*/
private void printColumnNames(PrintStream printStream) {
IntStream.range(0, columns.length).forEachOrdered(colIndex -> {
var c = columns[colIndex];
var justifiedName = c.getJustification().equals(RIGHT)
? padStart(c.getName(), c.getWidth(), ' ')
: c.getName();
var paddedWidth = colIndex == columns.length - 1
? c.getName().length()
: c.getWidth() + columnDelimiterLength;
printStream.printf("%-" + paddedWidth + "s", justifiedName);
});
printStream.println();
}
/**
* Print a table row to a PrintStream.
*
* @param printStream the target output stream
*/
private void printRow(PrintStream printStream, int rowIndex) {
IntStream.range(0, columns.length).forEachOrdered(colIndex -> {
var c = columns[colIndex];
var paddedWidth = colIndex == columns.length - 1
? c.getWidth()
: c.getWidth() + columnDelimiterLength;
printStream.printf("%-" + paddedWidth + "s", c.getRow(rowIndex));
if (colIndex == columns.length - 1)
printStream.println();
});
}
/**
* Returns the table's formatted output as a String.
*
* @return String
*/
@Override
public String toString() {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (PrintStream ps = new PrintStream(baos, true, UTF_8)) {
print(ps);
}
return baos.toString();
}
/**
* Verifies the table has columns, and each column has the same number of rows.
*/
private void validateStructure() {
if (columns.length == 0)
throw new IllegalArgumentException("Table has no columns.");
if (columns[0].isEmpty())
throw new IllegalArgumentException(
format("Table's 1st column (%s) has no data.",
columns[0].getName()));
IntStream.range(1, columns.length).forEachOrdered(colIndex -> {
var c = columns[colIndex];
if (c.isEmpty())
throw new IllegalStateException(
format("Table column # %d (%s) does not have any data.",
colIndex + 1,
c.getName()));
if (this.rowCount != c.rowCount())
throw new IllegalStateException(
format("Table column # %d (%s) does not have same number of rows as 1st column.",
colIndex + 1,
c.getName()));
});
}
}

View File

@ -0,0 +1,44 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots.table.builder;
import bisq.bots.table.Table;
import bisq.proto.grpc.OfferInfo;
import java.util.List;
import java.util.function.Predicate;
/**
* Abstract superclass for TableBuilder implementations.
*/
abstract class AbstractTableBuilder {
protected final Predicate<OfferInfo> isFiatOffer = (o) -> o.getBaseCurrencyCode().equals("BTC");
protected final TableType tableType;
protected final List<?> protos;
AbstractTableBuilder(TableType tableType, List<?> protos) {
this.tableType = tableType;
this.protos = protos;
if (protos.isEmpty())
throw new IllegalArgumentException("cannot build a table without rows");
}
public abstract Table build();
}

View File

@ -0,0 +1,301 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots.table.builder;
import bisq.bots.table.column.Column;
import bisq.bots.table.column.MixedTradeFeeColumn;
import bisq.proto.grpc.ContractInfo;
import bisq.proto.grpc.TradeInfo;
import javax.annotation.Nullable;
import java.math.BigDecimal;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import static bisq.bots.CurrencyFormat.formatSatoshis;
import static bisq.bots.table.builder.TableBuilderConstants.COL_HEADER_BUYER_DEPOSIT;
import static bisq.bots.table.builder.TableBuilderConstants.COL_HEADER_SELLER_DEPOSIT;
import static bisq.bots.table.builder.TableType.TRADE_DETAIL_TBL;
import static java.lang.String.format;
import static protobuf.OfferDirection.SELL;
abstract class AbstractTradeListBuilder extends AbstractTableBuilder {
protected final List<TradeInfo> trades;
protected final TradeTableColumnSupplier colSupplier;
protected final Column<String> colTradeId;
@Nullable
protected final Column<Long> colCreateDate;
@Nullable
protected final Column<String> colMarket;
protected final Column<String> colPrice;
@Nullable
protected final Column<String> colPriceDeviation;
@Nullable
protected final Column<String> colCurrency;
@Nullable
protected final Column<Long> colAmount;
@Nullable
protected final Column<String> colMixedAmount;
@Nullable
protected final Column<Long> colMinerTxFee;
@Nullable
protected final MixedTradeFeeColumn colMixedTradeFee;
@Nullable
protected final Column<Long> colBuyerDeposit;
@Nullable
protected final Column<Long> colSellerDeposit;
@Nullable
protected final Column<String> colPaymentMethod;
@Nullable
protected final Column<String> colRole;
@Nullable
protected final Column<String> colOfferType;
@Nullable
protected final Column<String> colClosingStatus;
// Trade detail tbl specific columns
@Nullable
protected final Column<Boolean> colIsDepositPublished;
@Nullable
protected final Column<Boolean> colIsDepositConfirmed;
@Nullable
protected final Column<Boolean> colIsPayoutPublished;
@Nullable
protected final Column<Boolean> colIsCompleted;
@Nullable
protected final Column<Long> colBisqTradeFee;
@Nullable
protected final Column<String> colTradeCost;
@Nullable
protected final Column<Boolean> colIsPaymentStartedMessageSent;
@Nullable
protected final Column<Boolean> colIsPaymentReceivedMessageSent;
@Nullable
protected final Column<String> colAltcoinReceiveAddressColumn;
// BSQ swap trade detail specific columns
@Nullable
protected final Column<String> status;
@Nullable
protected final Column<String> colTxId;
@Nullable
protected final Column<Long> colNumConfirmations;
AbstractTradeListBuilder(TableType tableType, List<?> protos) {
super(tableType, protos);
validate();
this.trades = protos.stream().map(p -> (TradeInfo) p).collect(Collectors.toList());
this.colSupplier = new TradeTableColumnSupplier(tableType, trades);
this.colTradeId = colSupplier.tradeIdColumn.get();
this.colCreateDate = colSupplier.createDateColumn.get();
this.colMarket = colSupplier.marketColumn.get();
this.colPrice = colSupplier.priceColumn.get();
this.colPriceDeviation = colSupplier.priceDeviationColumn.get();
this.colCurrency = colSupplier.currencyColumn.get();
this.colAmount = colSupplier.amountColumn.get();
this.colMixedAmount = colSupplier.mixedAmountColumn.get();
this.colMinerTxFee = colSupplier.minerTxFeeColumn.get();
this.colMixedTradeFee = colSupplier.mixedTradeFeeColumn.get();
this.colBuyerDeposit = colSupplier.toSecurityDepositColumn.apply(COL_HEADER_BUYER_DEPOSIT);
this.colSellerDeposit = colSupplier.toSecurityDepositColumn.apply(COL_HEADER_SELLER_DEPOSIT);
this.colPaymentMethod = colSupplier.paymentMethodColumn.get();
this.colRole = colSupplier.roleColumn.get();
this.colOfferType = colSupplier.offerTypeColumn.get();
this.colClosingStatus = colSupplier.statusDescriptionColumn.get();
// Trade detail specific columns, some in common with BSQ swap trades detail.
this.colIsDepositPublished = colSupplier.depositPublishedColumn.get();
this.colIsDepositConfirmed = colSupplier.depositConfirmedColumn.get();
this.colIsPayoutPublished = colSupplier.payoutPublishedColumn.get();
this.colIsCompleted = colSupplier.fundsWithdrawnColumn.get();
this.colBisqTradeFee = colSupplier.bisqTradeDetailFeeColumn.get();
this.colTradeCost = colSupplier.tradeCostColumn.get();
this.colIsPaymentStartedMessageSent = colSupplier.paymentStartedMessageSentColumn.get();
this.colIsPaymentReceivedMessageSent = colSupplier.paymentReceivedMessageSentColumn.get();
//noinspection ConstantConditions
this.colAltcoinReceiveAddressColumn = colSupplier.altcoinReceiveAddressColumn.get();
// BSQ swap trade detail specific columns
this.status = colSupplier.bsqSwapStatusColumn.get();
this.colTxId = colSupplier.bsqSwapTxIdColumn.get();
this.colNumConfirmations = colSupplier.numConfirmationsColumn.get();
}
protected void validate() {
if (isTradeDetailTblBuilder.get()) {
if (protos.size() != 1)
throw new IllegalArgumentException("trade detail tbl can have only one row");
} else if (protos.isEmpty()) {
throw new IllegalArgumentException("trade tbl has no rows");
}
}
// Helper Functions
private final Supplier<Boolean> isTradeDetailTblBuilder = () -> tableType.equals(TRADE_DETAIL_TBL);
protected final Predicate<TradeInfo> isFiatTrade = (t) -> isFiatOffer.test(t.getOffer());
protected final Predicate<TradeInfo> isBsqTrade = (t) -> !isFiatOffer.test(t.getOffer()) && t.getOffer().getBaseCurrencyCode().equals("BSQ");
protected final Predicate<TradeInfo> isBsqSwapTrade = (t) -> t.getOffer().getIsBsqSwapOffer();
protected final Predicate<TradeInfo> isMyOffer = (t) -> t.getOffer().getIsMyOffer();
protected final Predicate<TradeInfo> isTaker = (t) -> t.getRole().toLowerCase().contains("taker");
protected final Predicate<TradeInfo> isSellOffer = (t) -> t.getOffer().getDirection().equals(SELL.name());
protected final Predicate<TradeInfo> isBtcSeller = (t) -> (isMyOffer.test(t) && isSellOffer.test(t))
|| (!isMyOffer.test(t) && !isSellOffer.test(t));
protected final Predicate<TradeInfo> isTradeFeeBtc = (t) -> isMyOffer.test(t)
? t.getOffer().getIsCurrencyForMakerFeeBtc()
: t.getIsCurrencyForTakerFeeBtc();
// Column Value Functions
// Altcoin volumes from server are string representations of decimals.
// Converting them to longs ("sats") requires shifting the decimal points
// to left: 2 for BSQ, 8 for other altcoins.
protected final Function<TradeInfo, Long> toAltcoinTradeVolumeAsLong = (t) ->
isBsqTrade.test(t)
? new BigDecimal(t.getTradeVolume()).movePointRight(2).longValue()
: new BigDecimal(t.getTradeVolume()).movePointRight(8).longValue();
protected final Function<TradeInfo, String> toTradeVolumeAsString = (t) ->
isFiatTrade.test(t)
? t.getTradeVolume()
: formatSatoshis(t.getTradeAmountAsLong());
protected final Function<TradeInfo, Long> toTradeVolumeAsLong = (t) ->
isFiatTrade.test(t)
? Long.parseLong(t.getTradeVolume())
: toAltcoinTradeVolumeAsLong.apply(t);
protected final Function<TradeInfo, Long> toTradeAmount = (t) ->
isFiatTrade.test(t)
? t.getTradeAmountAsLong()
: toTradeVolumeAsLong.apply(t);
protected final Function<TradeInfo, String> toMarket = (t) ->
t.getOffer().getBaseCurrencyCode() + "/"
+ t.getOffer().getCounterCurrencyCode();
protected final Function<TradeInfo, String> toPaymentCurrencyCode = (t) ->
isFiatTrade.test(t)
? t.getOffer().getCounterCurrencyCode()
: t.getOffer().getBaseCurrencyCode();
protected final Function<TradeInfo, String> toPriceDeviation = (t) ->
t.getOffer().getUseMarketBasedPrice()
? format("%.2f%s", t.getOffer().getMarketPriceMarginPct(), "%")
: "N/A";
protected final Function<TradeInfo, Long> toMyMinerTxFee = (t) -> {
if (isBsqSwapTrade.test(t)) {
// The BTC seller pays the miner fee for both sides.
return isBtcSeller.test(t) ? t.getTxFeeAsLong() : 0L;
} else {
return isTaker.test(t)
? t.getTxFeeAsLong()
: t.getOffer().getTxFee();
}
};
protected final Function<TradeInfo, Long> toTradeFeeBsq = (t) -> {
var isMyOffer = t.getOffer().getIsMyOffer();
if (isMyOffer) {
return t.getOffer().getIsCurrencyForMakerFeeBtc()
? 0L // Maker paid BTC fee, return 0.
: t.getOffer().getMakerFee();
} else {
return t.getIsCurrencyForTakerFeeBtc()
? 0L // Taker paid BTC fee, return 0.
: t.getTakerFeeAsLong();
}
};
protected final Function<TradeInfo, Long> toTradeFeeBtc = (t) -> {
var isMyOffer = t.getOffer().getIsMyOffer();
if (isMyOffer) {
return t.getOffer().getIsCurrencyForMakerFeeBtc()
? t.getOffer().getMakerFee()
: 0L; // Maker paid BSQ fee, return 0.
} else {
return t.getIsCurrencyForTakerFeeBtc()
? t.getTakerFeeAsLong()
: 0L; // Taker paid BSQ fee, return 0.
}
};
protected final Function<TradeInfo, Long> toMyMakerOrTakerFee = (t) -> {
if (isBsqSwapTrade.test(t)) {
return isTaker.test(t)
? t.getBsqSwapTradeInfo().getBsqTakerTradeFee()
: t.getBsqSwapTradeInfo().getBsqMakerTradeFee();
} else {
return isTaker.test(t)
? t.getTakerFeeAsLong()
: t.getOffer().getMakerFee();
}
};
protected final Function<TradeInfo, String> toOfferType = (t) -> {
if (isFiatTrade.test(t)) {
return t.getOffer().getDirection() + " " + t.getOffer().getBaseCurrencyCode();
} else {
if (t.getOffer().getDirection().equals("BUY")) {
return "SELL " + t.getOffer().getBaseCurrencyCode();
} else {
return "BUY " + t.getOffer().getBaseCurrencyCode();
}
}
};
protected final Predicate<TradeInfo> showAltCoinBuyerAddress = (t) -> {
if (isFiatTrade.test(t)) {
return false;
} else {
ContractInfo contract = t.getContract();
boolean isBuyerMakerAndSellerTaker = contract.getIsBuyerMakerAndSellerTaker();
if (isTaker.test(t)) {
return !isBuyerMakerAndSellerTaker;
} else {
return isBuyerMakerAndSellerTaker;
}
}
};
protected final Function<TradeInfo, String> toAltcoinReceiveAddress = (t) -> {
if (showAltCoinBuyerAddress.test(t)) {
ContractInfo contract = t.getContract();
boolean isBuyerMakerAndSellerTaker = contract.getIsBuyerMakerAndSellerTaker();
return isBuyerMakerAndSellerTaker // (is BTC buyer / maker)
? contract.getTakerPaymentAccountPayload().getAddress()
: contract.getMakerPaymentAccountPayload().getAddress();
} else {
return "";
}
};
}

View File

@ -0,0 +1,71 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots.table.builder;
import bisq.bots.table.Table;
import bisq.bots.table.column.*;
import bisq.proto.grpc.AddressBalanceInfo;
import java.util.List;
import java.util.stream.Collectors;
import static bisq.bots.table.builder.TableBuilderConstants.*;
import static bisq.bots.table.builder.TableType.ADDRESS_BALANCE_TBL;
import static java.lang.String.format;
/**
* Builds a {@code bisq.bots.table.Table} from a List of
* {@code bisq.proto.grpc.AddressBalanceInfo} objects.
*/
class AddressBalanceTableBuilder extends AbstractTableBuilder {
// Default columns not dynamically generated with address info.
private final Column<String> colAddress;
private final Column<Long> colAvailableBalance;
private final Column<Long> colConfirmations;
private final Column<Boolean> colIsUsed;
AddressBalanceTableBuilder(List<?> protos) {
super(ADDRESS_BALANCE_TBL, protos);
colAddress = new StringColumn(format(COL_HEADER_ADDRESS, "BTC"));
this.colAvailableBalance = new SatoshiColumn(COL_HEADER_AVAILABLE_BALANCE);
this.colConfirmations = new LongColumn(COL_HEADER_CONFIRMATIONS);
this.colIsUsed = new BooleanColumn(COL_HEADER_IS_USED_ADDRESS);
}
public Table build() {
List<AddressBalanceInfo> addresses = protos.stream()
.map(a -> (AddressBalanceInfo) a)
.collect(Collectors.toList());
// Populate columns with address info.
//noinspection SimplifyStreamApiCallChains
addresses.stream().forEachOrdered(a -> {
colAddress.addRow(a.getAddress());
colAvailableBalance.addRow(a.getBalance());
colConfirmations.addRow(a.getNumConfirmations());
colIsUsed.addRow(!a.getIsAddressUnused());
});
// Define and return the table instance with populated columns.
return new Table(colAddress,
colAvailableBalance.asStringColumn(),
colConfirmations.asStringColumn(),
colIsUsed.asStringColumn());
}
}

View File

@ -0,0 +1,75 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots.table.builder;
import bisq.bots.table.Table;
import bisq.bots.table.column.Column;
import bisq.bots.table.column.SatoshiColumn;
import bisq.proto.grpc.BsqBalanceInfo;
import java.util.List;
import static bisq.bots.table.builder.TableBuilderConstants.*;
import static bisq.bots.table.builder.TableType.BSQ_BALANCE_TBL;
/**
* Builds a {@code bisq.bots.table.Table} from a
* {@code bisq.proto.grpc.BsqBalanceInfo} object.
*/
class BsqBalanceTableBuilder extends AbstractTableBuilder {
// Default columns not dynamically generated with bsq balance info.
private final Column<Long> colAvailableConfirmedBalance;
private final Column<Long> colUnverifiedBalance;
private final Column<Long> colUnconfirmedChangeBalance;
private final Column<Long> colLockedForVotingBalance;
private final Column<Long> colLockupBondsBalance;
private final Column<Long> colUnlockingBondsBalance;
BsqBalanceTableBuilder(List<?> protos) {
super(BSQ_BALANCE_TBL, protos);
this.colAvailableConfirmedBalance = new SatoshiColumn(COL_HEADER_AVAILABLE_CONFIRMED_BALANCE, true);
this.colUnverifiedBalance = new SatoshiColumn(COL_HEADER_UNVERIFIED_BALANCE, true);
this.colUnconfirmedChangeBalance = new SatoshiColumn(COL_HEADER_UNCONFIRMED_CHANGE_BALANCE, true);
this.colLockedForVotingBalance = new SatoshiColumn(COL_HEADER_LOCKED_FOR_VOTING_BALANCE, true);
this.colLockupBondsBalance = new SatoshiColumn(COL_HEADER_LOCKUP_BONDS_BALANCE, true);
this.colUnlockingBondsBalance = new SatoshiColumn(COL_HEADER_UNLOCKING_BONDS_BALANCE, true);
}
public Table build() {
BsqBalanceInfo balance = (BsqBalanceInfo) protos.get(0);
// Populate columns with bsq balance info.
colAvailableConfirmedBalance.addRow(balance.getAvailableConfirmedBalance());
colUnverifiedBalance.addRow(balance.getUnverifiedBalance());
colUnconfirmedChangeBalance.addRow(balance.getUnconfirmedChangeBalance());
colLockedForVotingBalance.addRow(balance.getLockedForVotingBalance());
colLockupBondsBalance.addRow(balance.getLockupBondsBalance());
colUnlockingBondsBalance.addRow(balance.getUnlockingBondsBalance());
// Define and return the table instance with populated columns.
return new Table(colAvailableConfirmedBalance.asStringColumn(),
colUnverifiedBalance.asStringColumn(),
colUnconfirmedChangeBalance.asStringColumn(),
colLockedForVotingBalance.asStringColumn(),
colLockupBondsBalance.asStringColumn(),
colUnlockingBondsBalance.asStringColumn());
}
}

View File

@ -0,0 +1,67 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots.table.builder;
import bisq.bots.table.Table;
import bisq.bots.table.column.Column;
import bisq.bots.table.column.SatoshiColumn;
import bisq.proto.grpc.BtcBalanceInfo;
import java.util.List;
import static bisq.bots.table.builder.TableBuilderConstants.*;
import static bisq.bots.table.builder.TableType.BTC_BALANCE_TBL;
/**
* Builds a {@code bisq.bots.table.Table} from a
* {@code bisq.proto.grpc.BtcBalanceInfo} object.
*/
class BtcBalanceTableBuilder extends AbstractTableBuilder {
// Default columns not dynamically generated with btc balance info.
private final Column<Long> colAvailableBalance;
private final Column<Long> colReservedBalance;
private final Column<Long> colTotalAvailableBalance;
private final Column<Long> colLockedBalance;
BtcBalanceTableBuilder(List<?> protos) {
super(BTC_BALANCE_TBL, protos);
this.colAvailableBalance = new SatoshiColumn(COL_HEADER_AVAILABLE_BALANCE);
this.colReservedBalance = new SatoshiColumn(COL_HEADER_RESERVED_BALANCE);
this.colTotalAvailableBalance = new SatoshiColumn(COL_HEADER_TOTAL_AVAILABLE_BALANCE);
this.colLockedBalance = new SatoshiColumn(COL_HEADER_LOCKED_BALANCE);
}
public Table build() {
BtcBalanceInfo balance = (BtcBalanceInfo) protos.get(0);
// Populate columns with btc balance info.
colAvailableBalance.addRow(balance.getAvailableBalance());
colReservedBalance.addRow(balance.getReservedBalance());
colTotalAvailableBalance.addRow(balance.getTotalAvailableBalance());
colLockedBalance.addRow(balance.getLockedBalance());
// Define and return the table instance with populated columns.
return new Table(colAvailableBalance.asStringColumn(),
colReservedBalance.asStringColumn(),
colTotalAvailableBalance.asStringColumn(),
colLockedBalance.asStringColumn());
}
}

View File

@ -0,0 +1,80 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots.table.builder;
import bisq.bots.table.Table;
import java.util.List;
import static bisq.bots.table.builder.TableType.CLOSED_TRADES_TBL;
@SuppressWarnings("ConstantConditions")
class ClosedTradeTableBuilder extends AbstractTradeListBuilder {
ClosedTradeTableBuilder(List<?> protos) {
super(CLOSED_TRADES_TBL, protos);
}
public Table build() {
populateColumns();
return new Table(colTradeId,
colCreateDate.asStringColumn(),
colMarket,
colPrice.justify(),
colPriceDeviation.justify(),
colAmount.asStringColumn(),
colMixedAmount.justify(),
colCurrency,
colMinerTxFee.asStringColumn(),
colMixedTradeFee.asStringColumn(),
colBuyerDeposit.asStringColumn(),
colSellerDeposit.asStringColumn(),
colOfferType,
colClosingStatus);
}
private void populateColumns() {
trades.forEach(t -> {
colTradeId.addRow(t.getTradeId());
colCreateDate.addRow(t.getDate());
colMarket.addRow(toMarket.apply(t));
colPrice.addRow(t.getTradePrice());
colPriceDeviation.addRow(toPriceDeviation.apply(t));
colAmount.addRow(t.getTradeAmountAsLong());
colMixedAmount.addRow(t.getTradeVolume());
colCurrency.addRow(toPaymentCurrencyCode.apply(t));
colMinerTxFee.addRow(toMyMinerTxFee.apply(t));
if (t.getOffer().getIsBsqSwapOffer()) {
// For BSQ Swaps, BTC buyer pays the BSQ trade fee for both sides (BTC seller pays no fee).
var optionalTradeFeeBsq = isBtcSeller.test(t) ? 0L : toTradeFeeBsq.apply(t);
colMixedTradeFee.addRow(optionalTradeFeeBsq, true);
} else if (isTradeFeeBtc.test(t)) {
colMixedTradeFee.addRow(toTradeFeeBtc.apply(t), false);
} else {
// V1 trade fee paid in BSQ.
colMixedTradeFee.addRow(toTradeFeeBsq.apply(t), true);
}
colBuyerDeposit.addRow(t.getOffer().getBuyerSecurityDeposit());
colSellerDeposit.addRow(t.getOffer().getSellerSecurityDeposit());
colOfferType.addRow(toOfferType.apply(t));
colClosingStatus.addRow(t.getClosingStatus());
});
}
}

View File

@ -0,0 +1,64 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots.table.builder;
import bisq.bots.table.Table;
import java.util.List;
import static bisq.bots.table.builder.TableType.FAILED_TRADES_TBL;
/**
* Builds a {@code bisq.bots.table.Table} from a list of {@code bisq.proto.grpc.TradeInfo} objects.
*/
@SuppressWarnings("ConstantConditions")
class FailedTradeTableBuilder extends AbstractTradeListBuilder {
FailedTradeTableBuilder(List<?> protos) {
super(FAILED_TRADES_TBL, protos);
}
public Table build() {
populateColumns();
return new Table(colTradeId,
colCreateDate.asStringColumn(),
colMarket,
colPrice.justify(),
colAmount.asStringColumn(),
colMixedAmount.justify(),
colCurrency,
colOfferType,
colRole,
colClosingStatus);
}
private void populateColumns() {
trades.forEach(t -> {
colTradeId.addRow(t.getTradeId());
colCreateDate.addRow(t.getDate());
colMarket.addRow(toMarket.apply(t));
colPrice.addRow(t.getTradePrice());
colAmount.addRow(t.getTradeAmountAsLong());
colMixedAmount.addRow(t.getTradeVolume());
colCurrency.addRow(toPaymentCurrencyCode.apply(t));
colOfferType.addRow(toOfferType.apply(t));
colRole.addRow(t.getRole());
colClosingStatus.addRow("Failed");
});
}
}

View File

@ -0,0 +1,259 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots.table.builder;
import bisq.bots.table.Table;
import bisq.bots.table.column.*;
import bisq.proto.grpc.OfferInfo;
import javax.annotation.Nullable;
import java.util.List;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import static bisq.bots.table.builder.TableBuilderConstants.*;
import static bisq.bots.table.builder.TableType.OFFER_TBL;
import static bisq.bots.table.column.Column.JUSTIFICATION.*;
import static bisq.bots.table.column.ZippedStringColumns.DUPLICATION_MODE.EXCLUDE_DUPLICATES;
import static java.lang.String.format;
import static protobuf.OfferDirection.BUY;
import static protobuf.OfferDirection.SELL;
/**
* Builds a {@code bisq.bots.table.Table} from a List of
* {@code bisq.proto.grpc.OfferInfo} objects.
*/
class OfferTableBuilder extends AbstractTableBuilder {
// Columns common to both fiat and cryptocurrency offers.
private final Column<String> colOfferId = new StringColumn(COL_HEADER_UUID, LEFT);
private final Column<String> colDirection = new StringColumn(COL_HEADER_DIRECTION, LEFT);
private final Column<Long> colAmount = new SatoshiColumn("Temp Amount", NONE);
private final Column<Long> colMinAmount = new SatoshiColumn("Temp Min Amount", NONE);
private final Column<String> colPaymentMethod = new StringColumn(COL_HEADER_PAYMENT_METHOD, LEFT);
private final Column<Long> colCreateDate = new Iso8601DateTimeColumn(COL_HEADER_CREATION_DATE);
OfferTableBuilder(List<?> protos) {
super(OFFER_TBL, protos);
}
public Table build() {
List<OfferInfo> offers = protos.stream().map(p -> (OfferInfo) p).collect(Collectors.toList());
return isShowingFiatOffers.get()
? buildFiatOfferTable(offers)
: buildCryptoCurrencyOfferTable(offers);
}
@SuppressWarnings("ConstantConditions")
public Table buildFiatOfferTable(List<OfferInfo> offers) {
@Nullable
Column<String> colEnabled = enabledColumn.get(); // Not boolean: "YES", "NO", or "PENDING"
Column<String> colFiatPrice = new StringColumn(format(COL_HEADER_DETAILED_PRICE, fiatTradeCurrency.get()), RIGHT);
Column<String> colVolume = new StringColumn(format("Temp Volume (%s)", fiatTradeCurrency.get()), NONE);
Column<String> colMinVolume = new StringColumn(format("Temp Min Volume (%s)", fiatTradeCurrency.get()), NONE);
@Nullable
Column<String> colTriggerPrice = fiatTriggerPriceColumn.get();
// Populate columns with offer info.
offers.forEach(o -> {
if (colEnabled != null)
colEnabled.addRow(toEnabled.apply(o));
colDirection.addRow(o.getDirection());
colFiatPrice.addRow(o.getPrice());
colMinAmount.addRow(o.getMinAmount());
colAmount.addRow(o.getAmount());
colVolume.addRow(o.getVolume());
colMinVolume.addRow(o.getMinVolume());
if (colTriggerPrice != null)
colTriggerPrice.addRow(toBlankOrNonZeroValue.apply(o.getTriggerPrice()));
colPaymentMethod.addRow(o.getPaymentMethodShortName());
colCreateDate.addRow(o.getDate());
colOfferId.addRow(o.getId());
});
ZippedStringColumns amountRange = zippedAmountRangeColumns.get();
ZippedStringColumns volumeRange =
new ZippedStringColumns(format(COL_HEADER_VOLUME_RANGE, fiatTradeCurrency.get()),
RIGHT,
" - ",
colMinVolume.asStringColumn(),
colVolume.asStringColumn());
// Define and return the table instance with populated columns.
if (isShowingMyOffers.get()) {
return new Table(colEnabled.asStringColumn(),
colDirection,
colFiatPrice.justify(),
amountRange.asStringColumn(EXCLUDE_DUPLICATES),
volumeRange.asStringColumn(EXCLUDE_DUPLICATES),
colTriggerPrice.justify(),
colPaymentMethod,
colCreateDate.asStringColumn(),
colOfferId);
} else {
return new Table(colDirection,
colFiatPrice.justify(),
amountRange.asStringColumn(EXCLUDE_DUPLICATES),
volumeRange.asStringColumn(EXCLUDE_DUPLICATES),
colPaymentMethod,
colCreateDate.asStringColumn(),
colOfferId);
}
}
@SuppressWarnings("ConstantConditions")
public Table buildCryptoCurrencyOfferTable(List<OfferInfo> offers) {
@Nullable
Column<String> colEnabled = enabledColumn.get(); // Not boolean: YES, NO, or PENDING
Column<String> colBtcPrice = new StringColumn(format(COL_HEADER_DETAILED_PRICE_OF_ALTCOIN, altcoinTradeCurrency.get()), RIGHT);
Column<String> colVolume = new StringColumn(format("Temp Volume (%s)", altcoinTradeCurrency.get()), NONE);
Column<String> colMinVolume = new StringColumn(format("Temp Min Volume (%s)", altcoinTradeCurrency.get()), NONE);
@Nullable
Column<String> colTriggerPrice = altcoinTriggerPriceColumn.get();
// Populate columns with offer info.
offers.forEach(o -> {
if (colEnabled != null)
colEnabled.addRow(toEnabled.apply(o));
colDirection.addRow(directionFormat.apply(o));
colBtcPrice.addRow(o.getPrice());
colAmount.addRow(o.getAmount());
colMinAmount.addRow(o.getMinAmount());
colVolume.addRow(o.getVolume());
colMinVolume.addRow(o.getMinVolume());
if (colTriggerPrice != null)
colTriggerPrice.addRow(toBlankOrNonZeroValue.apply(o.getTriggerPrice()));
colPaymentMethod.addRow(o.getPaymentMethodShortName());
colCreateDate.addRow(o.getDate());
colOfferId.addRow(o.getId());
});
ZippedStringColumns amountRange = zippedAmountRangeColumns.get();
ZippedStringColumns volumeRange =
new ZippedStringColumns(format(COL_HEADER_VOLUME_RANGE, altcoinTradeCurrency.get()),
RIGHT,
" - ",
colMinVolume.asStringColumn(),
colVolume.asStringColumn());
// Define and return the table instance with populated columns.
if (isShowingMyOffers.get()) {
if (isShowingBsqOffers.get()) {
return new Table(colEnabled.asStringColumn(),
colDirection,
colBtcPrice.justify(),
amountRange.asStringColumn(EXCLUDE_DUPLICATES),
volumeRange.asStringColumn(EXCLUDE_DUPLICATES),
colPaymentMethod,
colCreateDate.asStringColumn(),
colOfferId);
} else {
return new Table(colEnabled.asStringColumn(),
colDirection,
colBtcPrice.justify(),
amountRange.asStringColumn(EXCLUDE_DUPLICATES),
volumeRange.asStringColumn(EXCLUDE_DUPLICATES),
colTriggerPrice.justify(),
colPaymentMethod,
colCreateDate.asStringColumn(),
colOfferId);
}
} else {
return new Table(colDirection,
colBtcPrice.justify(),
amountRange.asStringColumn(EXCLUDE_DUPLICATES),
volumeRange.asStringColumn(EXCLUDE_DUPLICATES),
colPaymentMethod,
colCreateDate.asStringColumn(),
colOfferId);
}
}
private final Function<String, String> toBlankOrNonZeroValue = (s) -> s.trim().equals("0") ? "" : s;
private final Supplier<OfferInfo> firstOfferInList = () -> (OfferInfo) protos.get(0);
private final Supplier<Boolean> isShowingMyOffers = () -> firstOfferInList.get().getIsMyOffer();
private final Supplier<Boolean> isShowingFiatOffers = () -> isFiatOffer.test(firstOfferInList.get());
private final Supplier<String> fiatTradeCurrency = () -> firstOfferInList.get().getCounterCurrencyCode();
private final Supplier<String> altcoinTradeCurrency = () -> firstOfferInList.get().getBaseCurrencyCode();
private final Supplier<Boolean> isShowingBsqOffers = () ->
!isFiatOffer.test(firstOfferInList.get()) && altcoinTradeCurrency.get().equals("BSQ");
@Nullable // Not a boolean column: YES, NO, or PENDING.
private final Supplier<StringColumn> enabledColumn = () ->
isShowingMyOffers.get()
? new StringColumn(COL_HEADER_ENABLED, LEFT)
: null;
@Nullable
private final Supplier<StringColumn> fiatTriggerPriceColumn = () ->
isShowingMyOffers.get()
? new StringColumn(format(COL_HEADER_TRIGGER_PRICE, fiatTradeCurrency.get()), RIGHT)
: null;
@Nullable
private final Supplier<StringColumn> altcoinTriggerPriceColumn = () ->
isShowingMyOffers.get() && !isShowingBsqOffers.get()
? new StringColumn(format(COL_HEADER_TRIGGER_PRICE, altcoinTradeCurrency.get()), RIGHT)
: null;
private final Function<OfferInfo, String> toEnabled = (o) -> {
if (o.getIsMyOffer() && o.getIsMyPendingOffer())
return "PENDING";
else
return o.getIsActivated() ? "YES" : "NO";
};
private final Function<String, String> toMirroredDirection = (d) ->
d.equalsIgnoreCase(BUY.name()) ? SELL.name() : BUY.name();
private final Function<OfferInfo, String> directionFormat = (o) -> {
if (isFiatOffer.test(o)) {
return o.getBaseCurrencyCode();
} else {
// Return "Sell BSQ (Buy BTC)", or "Buy BSQ (Sell BTC)".
String direction = o.getDirection();
String mirroredDirection = toMirroredDirection.apply(direction);
Function<String, String> mixedCase = (word) -> word.charAt(0) + word.substring(1).toLowerCase();
return format("%s %s (%s %s)",
mixedCase.apply(mirroredDirection),
o.getBaseCurrencyCode(),
mixedCase.apply(direction),
o.getCounterCurrencyCode());
}
};
private final Supplier<ZippedStringColumns> zippedAmountRangeColumns = () -> {
if (colMinAmount.isEmpty() || colAmount.isEmpty())
throw new IllegalStateException("amount columns must have data");
return new ZippedStringColumns(COL_HEADER_AMOUNT_RANGE,
RIGHT,
" - ",
colMinAmount.asStringColumn(),
colAmount.asStringColumn());
};
}

View File

@ -0,0 +1,62 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots.table.builder;
import bisq.bots.table.Table;
import java.util.List;
import static bisq.bots.table.builder.TableType.OPEN_TRADES_TBL;
/**
* Builds a {@code bisq.bots.table.Table} from a list of {@code bisq.proto.grpc.TradeInfo} objects.
*/
@SuppressWarnings("ConstantConditions")
class OpenTradeTableBuilder extends AbstractTradeListBuilder {
OpenTradeTableBuilder(List<?> protos) {
super(OPEN_TRADES_TBL, protos);
}
public Table build() {
populateColumns();
return new Table(colTradeId,
colCreateDate.asStringColumn(),
colMarket,
colPrice.justify(),
colAmount.asStringColumn(),
colMixedAmount.justify(),
colCurrency,
colPaymentMethod,
colRole);
}
private void populateColumns() {
trades.forEach(t -> {
colTradeId.addRow(t.getTradeId());
colCreateDate.addRow(t.getDate());
colMarket.addRow(toMarket.apply(t));
colPrice.addRow(t.getTradePrice());
colAmount.addRow(t.getTradeAmountAsLong());
colMixedAmount.addRow(t.getTradeVolume());
colCurrency.addRow(toPaymentCurrencyCode.apply(t));
colPaymentMethod.addRow(t.getOffer().getPaymentMethodShortName());
colRole.addRow(t.getRole());
});
}
}

View File

@ -0,0 +1,68 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots.table.builder;
import bisq.bots.table.Table;
import bisq.bots.table.column.Column;
import bisq.bots.table.column.StringColumn;
import protobuf.PaymentAccount;
import java.util.List;
import java.util.stream.Collectors;
import static bisq.bots.table.builder.TableBuilderConstants.*;
import static bisq.bots.table.builder.TableType.PAYMENT_ACCOUNT_TBL;
/**
* Builds a {@code bisq.bots.table.Table} from a List of
* {@code protobuf.PaymentAccount} objects.
*/
class PaymentAccountTableBuilder extends AbstractTableBuilder {
// Default columns not dynamically generated with payment account info.
private final Column<String> colName;
private final Column<String> colCurrency;
private final Column<String> colPaymentMethod;
private final Column<String> colId;
PaymentAccountTableBuilder(List<?> protos) {
super(PAYMENT_ACCOUNT_TBL, protos);
this.colName = new StringColumn(COL_HEADER_NAME);
this.colCurrency = new StringColumn(COL_HEADER_CURRENCY);
this.colPaymentMethod = new StringColumn(COL_HEADER_PAYMENT_METHOD);
this.colId = new StringColumn(COL_HEADER_UUID);
}
public Table build() {
List<PaymentAccount> paymentAccounts = protos.stream()
.map(a -> (PaymentAccount) a)
.collect(Collectors.toList());
// Populate columns with payment account info.
//noinspection SimplifyStreamApiCallChains
paymentAccounts.stream().forEachOrdered(a -> {
colName.addRow(a.getAccountName());
colCurrency.addRow(a.getSelectedTradeCurrency().getCode());
colPaymentMethod.addRow(a.getPaymentMethod().getId());
colId.addRow(a.getId());
});
// Define and return the table instance with populated columns.
return new Table(colName, colCurrency, colPaymentMethod, colId);
}
}

View File

@ -0,0 +1,68 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots.table.builder;
import bisq.bots.table.Table;
import java.util.List;
import static java.util.Collections.singletonList;
/**
* Table builder factory. It is not conventionally named TableBuilderFactory because
* it has no static factory methods. The number of static fields and methods in the
* {@code bisq.bots.table} are kept to a minimum in an effort o reduce class load time
* in the session-less CLI.
*/
public class TableBuilder extends AbstractTableBuilder {
public TableBuilder(TableType tableType, Object proto) {
this(tableType, singletonList(proto));
}
public TableBuilder(TableType tableType, List<?> protos) {
super(tableType, protos);
}
public Table build() {
switch (tableType) {
case ADDRESS_BALANCE_TBL:
return new AddressBalanceTableBuilder(protos).build();
case BSQ_BALANCE_TBL:
return new BsqBalanceTableBuilder(protos).build();
case BTC_BALANCE_TBL:
return new BtcBalanceTableBuilder(protos).build();
case CLOSED_TRADES_TBL:
return new ClosedTradeTableBuilder(protos).build();
case FAILED_TRADES_TBL:
return new FailedTradeTableBuilder(protos).build();
case OFFER_TBL:
return new OfferTableBuilder(protos).build();
case OPEN_TRADES_TBL:
return new OpenTradeTableBuilder(protos).build();
case PAYMENT_ACCOUNT_TBL:
return new PaymentAccountTableBuilder(protos).build();
case TRADE_DETAIL_TBL:
return new TradeDetailTableBuilder(protos).build();
case TRANSACTION_TBL:
return new TransactionTableBuilder(protos).build();
default:
throw new IllegalArgumentException("invalid cli table type " + tableType.name());
}
}
}

View File

@ -0,0 +1,82 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots.table.builder;
/**
* Table column name constants.
*/
class TableBuilderConstants {
static final String COL_HEADER_ADDRESS = "%-3s Address";
static final String COL_HEADER_AMOUNT = "Amount";
static final String COL_HEADER_AMOUNT_IN_BTC = "Amount in BTC";
static final String COL_HEADER_AMOUNT_RANGE = "BTC(min - max)";
static final String COL_HEADER_AVAILABLE_BALANCE = "Available Balance";
static final String COL_HEADER_AVAILABLE_CONFIRMED_BALANCE = "Available Confirmed Balance";
static final String COL_HEADER_UNCONFIRMED_CHANGE_BALANCE = "Unconfirmed Change Balance";
static final String COL_HEADER_RESERVED_BALANCE = "Reserved Balance";
static final String COL_HEADER_TOTAL_AVAILABLE_BALANCE = "Total Available Balance";
static final String COL_HEADER_LOCKED_BALANCE = "Locked Balance";
static final String COL_HEADER_LOCKED_FOR_VOTING_BALANCE = "Locked For Voting Balance";
static final String COL_HEADER_LOCKUP_BONDS_BALANCE = "Lockup Bonds Balance";
static final String COL_HEADER_UNLOCKING_BONDS_BALANCE = "Unlocking Bonds Balance";
static final String COL_HEADER_UNVERIFIED_BALANCE = "Unverified Balance";
static final String COL_HEADER_BSQ_SWAP_TRADE_ROLE = "My BSQ Swap Role";
static final String COL_HEADER_BUYER_DEPOSIT = "Buyer Deposit (BTC)";
static final String COL_HEADER_SELLER_DEPOSIT = "Seller Deposit (BTC)";
static final String COL_HEADER_CONFIRMATIONS = "Confirmations";
static final String COL_HEADER_DEVIATION = "Deviation";
static final String COL_HEADER_IS_USED_ADDRESS = "Is Used";
static final String COL_HEADER_CREATION_DATE = "Creation Date (UTC)";
static final String COL_HEADER_CURRENCY = "Currency";
static final String COL_HEADER_DATE_TIME = "Date/Time (UTC)";
static final String COL_HEADER_DETAILED_AMOUNT = "Amount(%-3s)";
static final String COL_HEADER_DETAILED_PRICE = "Price in %-3s for 1 BTC";
static final String COL_HEADER_DETAILED_PRICE_OF_ALTCOIN = "Price in BTC for 1 %-3s";
static final String COL_HEADER_DIRECTION = "Buy/Sell";
static final String COL_HEADER_ENABLED = "Enabled";
static final String COL_HEADER_MARKET = "Market";
static final String COL_HEADER_NAME = "Name";
static final String COL_HEADER_OFFER_TYPE = "Offer Type";
static final String COL_HEADER_PAYMENT_METHOD = "Payment Method";
static final String COL_HEADER_PRICE = "Price";
static final String COL_HEADER_STATUS = "Status";
static final String COL_HEADER_TRADE_ALTCOIN_BUYER_ADDRESS = "%-3s Buyer Address";
static final String COL_HEADER_TRADE_BUYER_COST = "Buyer Cost(%-3s)";
static final String COL_HEADER_TRADE_DEPOSIT_CONFIRMED = "Deposit Confirmed";
static final String COL_HEADER_TRADE_DEPOSIT_PUBLISHED = "Deposit Published";
static final String COL_HEADER_TRADE_PAYMENT_SENT = "%-3s Sent";
static final String COL_HEADER_TRADE_PAYMENT_RECEIVED = "%-3s Received";
static final String COL_HEADER_TRADE_PAYOUT_PUBLISHED = "Payout Published";
static final String COL_HEADER_TRADE_WITHDRAWN = "Withdrawn";
static final String COL_HEADER_TRADE_ID = "Trade ID";
static final String COL_HEADER_TRADE_ROLE = "My Role";
static final String COL_HEADER_TRADE_SHORT_ID = "ID";
static final String COL_HEADER_TRADE_MAKER_FEE = "Maker Fee(%-3s)";
static final String COL_HEADER_TRADE_TAKER_FEE = "Taker Fee(%-3s)";
static final String COL_HEADER_TRADE_FEE = "Trade Fee";
static final String COL_HEADER_TRIGGER_PRICE = "Trigger Price(%-3s)";
static final String COL_HEADER_TX_ID = "Tx ID";
static final String COL_HEADER_TX_INPUT_SUM = "Tx Inputs (BTC)";
static final String COL_HEADER_TX_OUTPUT_SUM = "Tx Outputs (BTC)";
static final String COL_HEADER_TX_FEE = "Tx Fee (BTC)";
static final String COL_HEADER_TX_SIZE = "Tx Size (Bytes)";
static final String COL_HEADER_TX_IS_CONFIRMED = "Is Confirmed";
static final String COL_HEADER_TX_MEMO = "Memo";
static final String COL_HEADER_VOLUME_RANGE = "%-3s(min - max)";
static final String COL_HEADER_UUID = "ID";
}

View File

@ -0,0 +1,35 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots.table.builder;
/**
* Used as param in TableBuilder constructor instead of inspecting
* protos to find out what kind of CLI output table should be built.
*/
public enum TableType {
ADDRESS_BALANCE_TBL,
BSQ_BALANCE_TBL,
BTC_BALANCE_TBL,
CLOSED_TRADES_TBL,
FAILED_TRADES_TBL,
OFFER_TBL,
OPEN_TRADES_TBL,
PAYMENT_ACCOUNT_TBL,
TRADE_DETAIL_TBL,
TRANSACTION_TBL
}

View File

@ -0,0 +1,159 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots.table.builder;
import bisq.bots.table.Table;
import bisq.bots.table.column.Column;
import bisq.proto.grpc.TradeInfo;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
import static bisq.bots.table.builder.TableType.TRADE_DETAIL_TBL;
import static java.lang.String.format;
import static protobuf.BsqSwapTrade.State.COMPLETED;
import static protobuf.BsqSwapTrade.State.PREPARATION;
/**
* Builds a {@code bisq.bots.table.Table} from a {@code bisq.proto.grpc.TradeInfo} object.
*/
@SuppressWarnings("ConstantConditions")
class TradeDetailTableBuilder extends AbstractTradeListBuilder {
private final Predicate<TradeInfo> isPendingBsqSwap = (t) -> t.getState().equals(PREPARATION.name());
private final Predicate<TradeInfo> isCompletedBsqSwap = (t) -> t.getState().equals(COMPLETED.name());
TradeDetailTableBuilder(List<?> protos) {
super(TRADE_DETAIL_TBL, protos);
}
/**
* Build a single row trade detail table.
*
* @return Table containing one row
*/
public Table build() {
// A trade detail table only has one row.
var trade = trades.get(0);
populateColumns(trade);
List<Column<?>> columns = defineColumnList(trade);
return new Table(columns.toArray(new Column<?>[0]));
}
private void populateColumns(TradeInfo trade) {
if (isBsqSwapTrade.test(trade)) {
var isPending = isPendingBsqSwap.test(trade);
var isCompleted = isCompletedBsqSwap.test(trade);
if (isPending == isCompleted)
throw new IllegalStateException(
format("programmer error: trade must be either pending or completed, is pending=%s and completed=%s",
isPending,
isCompleted));
populateBsqSwapTradeColumns(trade);
} else {
populateBisqV1TradeColumns(trade);
}
}
private void populateBisqV1TradeColumns(TradeInfo trade) {
colTradeId.addRow(trade.getShortId());
colRole.addRow(trade.getRole());
colPrice.addRow(trade.getTradePrice());
colAmount.addRow(toTradeAmount.apply(trade));
colMinerTxFee.addRow(toMyMinerTxFee.apply(trade));
colBisqTradeFee.addRow(toMyMakerOrTakerFee.apply(trade));
colIsDepositPublished.addRow(trade.getIsDepositPublished());
colIsDepositConfirmed.addRow(trade.getIsDepositConfirmed());
colTradeCost.addRow(toTradeVolumeAsString.apply(trade));
colIsPaymentStartedMessageSent.addRow(trade.getIsPaymentStartedMessageSent());
colIsPaymentReceivedMessageSent.addRow(trade.getIsPaymentReceivedMessageSent());
colIsPayoutPublished.addRow(trade.getIsPayoutPublished());
colIsCompleted.addRow(trade.getIsCompleted());
if (colAltcoinReceiveAddressColumn != null)
colAltcoinReceiveAddressColumn.addRow(toAltcoinReceiveAddress.apply(trade));
}
private void populateBsqSwapTradeColumns(TradeInfo trade) {
colTradeId.addRow(trade.getShortId());
colRole.addRow(trade.getRole());
colPrice.addRow(trade.getTradePrice());
colAmount.addRow(toTradeAmount.apply(trade));
colMinerTxFee.addRow(toMyMinerTxFee.apply(trade));
colBisqTradeFee.addRow(toMyMakerOrTakerFee.apply(trade));
colTradeCost.addRow(toTradeVolumeAsString.apply(trade));
var isCompleted = isCompletedBsqSwap.test(trade);
status.addRow(isCompleted ? "COMPLETED" : "PENDING");
if (isCompleted) {
colTxId.addRow(trade.getBsqSwapTradeInfo().getTxId());
colNumConfirmations.addRow(trade.getBsqSwapTradeInfo().getNumConfirmations());
}
}
private List<Column<?>> defineColumnList(TradeInfo trade) {
return isBsqSwapTrade.test(trade)
? getBsqSwapTradeColumnList(isCompletedBsqSwap.test(trade))
: getBisqV1TradeColumnList();
}
private List<Column<?>> getBisqV1TradeColumnList() {
List<Column<?>> columns = new ArrayList<>() {{
add(colTradeId);
add(colRole);
add(colPrice.justify());
add(colAmount.asStringColumn());
add(colMinerTxFee.asStringColumn());
add(colBisqTradeFee.asStringColumn());
add(colIsDepositPublished.asStringColumn());
add(colIsDepositConfirmed.asStringColumn());
add(colTradeCost.justify());
add(colIsPaymentStartedMessageSent.asStringColumn());
add(colIsPaymentReceivedMessageSent.asStringColumn());
add(colIsPayoutPublished.asStringColumn());
add(colIsCompleted.asStringColumn());
}};
if (colAltcoinReceiveAddressColumn != null)
columns.add(colAltcoinReceiveAddressColumn);
return columns;
}
private List<Column<?>> getBsqSwapTradeColumnList(boolean isCompleted) {
List<Column<?>> columns = new ArrayList<>() {{
add(colTradeId);
add(colRole);
add(colPrice.justify());
add(colAmount.asStringColumn());
add(colMinerTxFee.asStringColumn());
add(colBisqTradeFee.asStringColumn());
add(colTradeCost.justify());
add(status);
}};
if (isCompleted)
columns.add(colTxId);
if (!colNumConfirmations.isEmpty())
columns.add(colNumConfirmations.asStringColumn());
return columns;
}
}

View File

@ -0,0 +1,285 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots.table.builder;
import bisq.bots.table.column.*;
import bisq.proto.grpc.ContractInfo;
import bisq.proto.grpc.OfferInfo;
import bisq.proto.grpc.TradeInfo;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import javax.annotation.Nullable;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;
import static bisq.bots.table.builder.TableBuilderConstants.*;
import static bisq.bots.table.builder.TableType.*;
import static bisq.bots.table.column.AltcoinVolumeColumn.DISPLAY_MODE.ALTCOIN_VOLUME;
import static bisq.bots.table.column.AltcoinVolumeColumn.DISPLAY_MODE.BSQ_VOLUME;
import static bisq.bots.table.column.Column.JUSTIFICATION.LEFT;
import static bisq.bots.table.column.Column.JUSTIFICATION.RIGHT;
import static java.lang.String.format;
/**
* Convenience for supplying column definitions to
* open/closed/failed/detail trade table builders.
*/
@Slf4j
class TradeTableColumnSupplier {
@Getter
private final TableType tableType;
@Getter
private final List<TradeInfo> trades;
public TradeTableColumnSupplier(TableType tableType, List<TradeInfo> trades) {
this.tableType = tableType;
this.trades = trades;
}
private final Supplier<Boolean> isTradeDetailTblBuilder = () -> getTableType().equals(TRADE_DETAIL_TBL);
private final Supplier<Boolean> isOpenTradeTblBuilder = () -> getTableType().equals(OPEN_TRADES_TBL);
private final Supplier<Boolean> isClosedTradeTblBuilder = () -> getTableType().equals(CLOSED_TRADES_TBL);
private final Supplier<Boolean> isFailedTradeTblBuilder = () -> getTableType().equals(FAILED_TRADES_TBL);
private final Supplier<TradeInfo> firstRow = () -> getTrades().get(0);
private final Predicate<OfferInfo> isFiatOffer = (o) -> o.getBaseCurrencyCode().equals("BTC");
private final Predicate<TradeInfo> isFiatTrade = (t) -> isFiatOffer.test(t.getOffer());
private final Predicate<TradeInfo> isBsqSwapTrade = (t) -> t.getOffer().getIsBsqSwapOffer();
private final Predicate<TradeInfo> isTaker = (t) -> t.getRole().toLowerCase().contains("taker");
private final Supplier<Boolean> isSwapTradeDetail = () ->
isTradeDetailTblBuilder.get() && isBsqSwapTrade.test(firstRow.get());
final Supplier<StringColumn> tradeIdColumn = () -> isTradeDetailTblBuilder.get()
? new StringColumn(COL_HEADER_TRADE_SHORT_ID)
: new StringColumn(COL_HEADER_TRADE_ID);
final Supplier<Iso8601DateTimeColumn> createDateColumn = () -> isTradeDetailTblBuilder.get()
? null
: new Iso8601DateTimeColumn(COL_HEADER_DATE_TIME);
final Supplier<StringColumn> marketColumn = () -> isTradeDetailTblBuilder.get()
? null
: new StringColumn(COL_HEADER_MARKET);
private final Function<TradeInfo, Column<String>> toDetailedPriceColumn = (t) -> {
String colHeader = isFiatTrade.test(t)
? format(COL_HEADER_DETAILED_PRICE, t.getOffer().getCounterCurrencyCode())
: format(COL_HEADER_DETAILED_PRICE_OF_ALTCOIN, t.getOffer().getBaseCurrencyCode());
return new StringColumn(colHeader, RIGHT);
};
final Supplier<Column<String>> priceColumn = () -> isTradeDetailTblBuilder.get()
? toDetailedPriceColumn.apply(firstRow.get())
: new StringColumn(COL_HEADER_PRICE, RIGHT);
final Supplier<Column<String>> priceDeviationColumn = () -> isTradeDetailTblBuilder.get()
? null
: new StringColumn(COL_HEADER_DEVIATION, RIGHT);
final Supplier<StringColumn> currencyColumn = () -> isTradeDetailTblBuilder.get()
? null
: new StringColumn(COL_HEADER_CURRENCY);
private final Function<TradeInfo, Column<Long>> toDetailedAmountColumn = (t) -> {
String headerCurrencyCode = t.getOffer().getBaseCurrencyCode();
String colHeader = format(COL_HEADER_DETAILED_AMOUNT, headerCurrencyCode);
AltcoinVolumeColumn.DISPLAY_MODE displayMode = headerCurrencyCode.equals("BSQ") ? BSQ_VOLUME : ALTCOIN_VOLUME;
return isFiatTrade.test(t)
? new SatoshiColumn(colHeader)
: new AltcoinVolumeColumn(colHeader, displayMode);
};
// Can be fiat, btc or altcoin amount represented as longs. Placing the decimal
// in the displayed string representation is done in the Column implementation.
final Supplier<Column<Long>> amountColumn = () -> isTradeDetailTblBuilder.get()
? toDetailedAmountColumn.apply(firstRow.get())
: new BtcColumn(COL_HEADER_AMOUNT_IN_BTC);
final Supplier<StringColumn> mixedAmountColumn = () -> isTradeDetailTblBuilder.get()
? null
: new StringColumn(COL_HEADER_AMOUNT, RIGHT);
final Supplier<Column<Long>> minerTxFeeColumn = () -> isTradeDetailTblBuilder.get() || isClosedTradeTblBuilder.get()
? new SatoshiColumn(COL_HEADER_TX_FEE)
: null;
final Supplier<MixedTradeFeeColumn> mixedTradeFeeColumn = () -> isTradeDetailTblBuilder.get()
? null
: new MixedTradeFeeColumn(COL_HEADER_TRADE_FEE);
final Supplier<StringColumn> paymentMethodColumn = () -> isTradeDetailTblBuilder.get() || isClosedTradeTblBuilder.get()
? null
: new StringColumn(COL_HEADER_PAYMENT_METHOD, LEFT);
final Supplier<StringColumn> roleColumn = () -> {
if (isSwapTradeDetail.get())
return new StringColumn(COL_HEADER_BSQ_SWAP_TRADE_ROLE);
else
return isTradeDetailTblBuilder.get() || isOpenTradeTblBuilder.get() || isFailedTradeTblBuilder.get()
? new StringColumn(COL_HEADER_TRADE_ROLE)
: null;
};
final Function<String, Column<Long>> toSecurityDepositColumn = (name) -> isClosedTradeTblBuilder.get()
? new SatoshiColumn(name)
: null;
final Supplier<StringColumn> offerTypeColumn = () -> isTradeDetailTblBuilder.get()
? null
: new StringColumn(COL_HEADER_OFFER_TYPE);
final Supplier<StringColumn> statusDescriptionColumn = () -> isTradeDetailTblBuilder.get()
? null
: new StringColumn(COL_HEADER_STATUS);
private final Function<String, Column<Boolean>> toBooleanColumn = BooleanColumn::new;
final Supplier<Column<Boolean>> depositPublishedColumn = () -> {
if (isSwapTradeDetail.get())
return null;
else
return isTradeDetailTblBuilder.get()
? toBooleanColumn.apply(COL_HEADER_TRADE_DEPOSIT_PUBLISHED)
: null;
};
final Supplier<Column<Boolean>> depositConfirmedColumn = () -> {
if (isSwapTradeDetail.get())
return null;
else
return isTradeDetailTblBuilder.get()
? toBooleanColumn.apply(COL_HEADER_TRADE_DEPOSIT_CONFIRMED)
: null;
};
final Supplier<Column<Boolean>> payoutPublishedColumn = () -> {
if (isSwapTradeDetail.get())
return null;
else
return isTradeDetailTblBuilder.get()
? toBooleanColumn.apply(COL_HEADER_TRADE_PAYOUT_PUBLISHED)
: null;
};
final Supplier<Column<Boolean>> fundsWithdrawnColumn = () -> {
if (isSwapTradeDetail.get())
return null;
else
return isTradeDetailTblBuilder.get()
? toBooleanColumn.apply(COL_HEADER_TRADE_WITHDRAWN)
: null;
};
final Supplier<Column<Long>> bisqTradeDetailFeeColumn = () -> {
if (isTradeDetailTblBuilder.get()) {
TradeInfo t = firstRow.get();
String headerCurrencyCode = isTaker.test(t)
? t.getIsCurrencyForTakerFeeBtc() ? "BTC" : "BSQ"
: t.getOffer().getIsCurrencyForMakerFeeBtc() ? "BTC" : "BSQ";
String colHeader = isTaker.test(t)
? format(COL_HEADER_TRADE_TAKER_FEE, headerCurrencyCode)
: format(COL_HEADER_TRADE_MAKER_FEE, headerCurrencyCode);
boolean isBsqSatoshis = headerCurrencyCode.equals("BSQ");
return new SatoshiColumn(colHeader, isBsqSatoshis);
} else {
return null;
}
};
final Function<TradeInfo, String> toPaymentCurrencyCode = (t) ->
isFiatTrade.test(t)
? t.getOffer().getCounterCurrencyCode()
: t.getOffer().getBaseCurrencyCode();
final Supplier<Column<Boolean>> paymentStartedMessageSentColumn = () -> {
if (isTradeDetailTblBuilder.get()) {
String headerCurrencyCode = toPaymentCurrencyCode.apply(firstRow.get());
String colHeader = format(COL_HEADER_TRADE_PAYMENT_SENT, headerCurrencyCode);
return new BooleanColumn(colHeader);
} else {
return null;
}
};
final Supplier<Column<Boolean>> paymentReceivedMessageSentColumn = () -> {
if (isTradeDetailTblBuilder.get()) {
String headerCurrencyCode = toPaymentCurrencyCode.apply(firstRow.get());
String colHeader = format(COL_HEADER_TRADE_PAYMENT_RECEIVED, headerCurrencyCode);
return new BooleanColumn(colHeader);
} else {
return null;
}
};
final Supplier<Column<String>> tradeCostColumn = () -> {
if (isTradeDetailTblBuilder.get()) {
TradeInfo t = firstRow.get();
String headerCurrencyCode = t.getOffer().getCounterCurrencyCode();
String colHeader = format(COL_HEADER_TRADE_BUYER_COST, headerCurrencyCode);
return new StringColumn(colHeader, RIGHT);
} else {
return null;
}
};
final Supplier<Column<String>> bsqSwapTxIdColumn = () -> isSwapTradeDetail.get()
? new StringColumn(COL_HEADER_TX_ID)
: null;
final Supplier<Column<String>> bsqSwapStatusColumn = () -> isSwapTradeDetail.get()
? new StringColumn(COL_HEADER_STATUS)
: null;
final Supplier<Column<Long>> numConfirmationsColumn = () -> isSwapTradeDetail.get()
? new LongColumn(COL_HEADER_CONFIRMATIONS)
: null;
final Predicate<TradeInfo> showAltCoinBuyerAddress = (t) -> {
if (isFiatTrade.test(t)) {
return false;
} else {
ContractInfo contract = t.getContract();
boolean isBuyerMakerAndSellerTaker = contract.getIsBuyerMakerAndSellerTaker();
if (isTaker.test(t)) {
return !isBuyerMakerAndSellerTaker;
} else {
return isBuyerMakerAndSellerTaker;
}
}
};
@Nullable
final Supplier<Column<String>> altcoinReceiveAddressColumn = () -> {
if (isTradeDetailTblBuilder.get()) {
TradeInfo t = firstRow.get();
if (showAltCoinBuyerAddress.test(t)) {
String headerCurrencyCode = toPaymentCurrencyCode.apply(t);
String colHeader = format(COL_HEADER_TRADE_ALTCOIN_BUYER_ADDRESS, headerCurrencyCode);
return new StringColumn(colHeader);
} else {
return null;
}
} else {
return null;
}
};
}

View File

@ -0,0 +1,95 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots.table.builder;
import bisq.bots.table.Table;
import bisq.bots.table.column.*;
import bisq.proto.grpc.TxInfo;
import javax.annotation.Nullable;
import java.util.List;
import static bisq.bots.table.builder.TableBuilderConstants.*;
import static bisq.bots.table.builder.TableType.TRANSACTION_TBL;
/**
* Builds a {@code bisq.bots.table.Table} from a {@code bisq.proto.grpc.TxInfo} object.
*/
class TransactionTableBuilder extends AbstractTableBuilder {
// Default columns not dynamically generated with tx info.
private final Column<String> colTxId;
private final Column<Boolean> colIsConfirmed;
private final Column<Long> colInputSum;
private final Column<Long> colOutputSum;
private final Column<Long> colTxFee;
private final Column<Long> colTxSize;
TransactionTableBuilder(List<?> protos) {
super(TRANSACTION_TBL, protos);
this.colTxId = new StringColumn(COL_HEADER_TX_ID);
this.colIsConfirmed = new BooleanColumn(COL_HEADER_TX_IS_CONFIRMED);
this.colInputSum = new SatoshiColumn(COL_HEADER_TX_INPUT_SUM);
this.colOutputSum = new SatoshiColumn(COL_HEADER_TX_OUTPUT_SUM);
this.colTxFee = new SatoshiColumn(COL_HEADER_TX_FEE);
this.colTxSize = new LongColumn(COL_HEADER_TX_SIZE);
}
public Table build() {
// TODO Add 'gettransactions' api method & show multiple tx in the console.
// For now, a tx tbl is only one row.
TxInfo tx = (TxInfo) protos.get(0);
// Declare the columns derived from tx info.
@Nullable
Column<String> colMemo = tx.getMemo().isEmpty()
? null
: new StringColumn(COL_HEADER_TX_MEMO);
// Populate columns with tx info.
colTxId.addRow(tx.getTxId());
colIsConfirmed.addRow(!tx.getIsPending());
colInputSum.addRow(tx.getInputSum());
colOutputSum.addRow(tx.getOutputSum());
colTxFee.addRow(tx.getFee());
colTxSize.addRow((long) tx.getSize());
if (colMemo != null)
colMemo.addRow(tx.getMemo());
// Define and return the table instance with populated columns.
if (colMemo != null) {
return new Table(colTxId,
colIsConfirmed.asStringColumn(),
colInputSum.asStringColumn(),
colOutputSum.asStringColumn(),
colTxFee.asStringColumn(),
colTxSize.asStringColumn(),
colMemo);
} else {
return new Table(colTxId,
colIsConfirmed.asStringColumn(),
colInputSum.asStringColumn(),
colOutputSum.asStringColumn(),
colTxFee.asStringColumn(),
colTxSize.asStringColumn());
}
}
}

View File

@ -0,0 +1,86 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots.table.column;
import static bisq.bots.table.column.Column.JUSTIFICATION.RIGHT;
import static com.google.common.base.Strings.padEnd;
import static com.google.common.base.Strings.padStart;
/**
* Partial implementation of the {@link Column} interface.
*/
abstract class AbstractColumn<C extends Column<T>, T> implements Column<T> {
// We create an encapsulated StringColumn up front to populate with formatted
// strings in each this.addRow(Long value) call. But we will not know how
// to justify the cached, formatted string until the column is fully populated.
protected final StringColumn stringColumn;
// The name field is not final, so it can be re-set for column alignment.
protected String name;
protected final JUSTIFICATION justification;
// The max width is not known until after column is fully populated.
protected int maxWidth;
public AbstractColumn(String name, JUSTIFICATION justification) {
this.name = name;
this.justification = justification;
this.stringColumn = this instanceof StringColumn ? null : new StringColumn(name, justification);
}
@Override
public String getName() {
return this.name;
}
@Override
public void setName(String name) {
this.name = name;
}
@Override
public int getWidth() {
return maxWidth;
}
@Override
public JUSTIFICATION getJustification() {
return this.justification;
}
@Override
public Column<T> justify() {
if (this instanceof StringColumn && this.justification.equals(RIGHT))
return this.justify();
else
return this; // no-op
}
protected final String toJustifiedString(String s) {
switch (justification) {
case LEFT:
return padEnd(s, maxWidth, ' ');
case RIGHT:
return padStart(s, maxWidth, ' ');
case NONE:
default:
return s;
}
}
}

View File

@ -0,0 +1,88 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots.table.column;
import java.math.BigDecimal;
import java.util.function.BiFunction;
import java.util.stream.IntStream;
import static bisq.bots.table.column.Column.JUSTIFICATION.RIGHT;
/**
* For displaying altcoin volume with appropriate precision.
*/
public class AltcoinVolumeColumn extends LongColumn {
public enum DISPLAY_MODE {
ALTCOIN_VOLUME,
BSQ_VOLUME,
}
private final DISPLAY_MODE displayMode;
// The default AltcoinVolumeColumn JUSTIFICATION is RIGHT.
public AltcoinVolumeColumn(String name, DISPLAY_MODE displayMode) {
this(name, RIGHT, displayMode);
}
public AltcoinVolumeColumn(String name,
JUSTIFICATION justification,
DISPLAY_MODE displayMode) {
super(name, justification);
this.displayMode = displayMode;
}
@Override
public void addRow(Long value) {
rows.add(value);
String s = toFormattedString.apply(value, displayMode);
stringColumn.addRow(s);
if (isNewMaxWidth.test(s))
maxWidth = s.length();
}
@Override
public String getRowAsFormattedString(int rowIndex) {
return toFormattedString.apply(getRow(rowIndex), displayMode);
}
@Override
public StringColumn asStringColumn() {
// We cached the formatted altcoin value strings, but we did
// not know how much padding each string needed until now.
IntStream.range(0, stringColumn.getRows().size()).forEach(rowIndex -> {
String unjustified = stringColumn.getRow(rowIndex);
String justified = stringColumn.toJustifiedString(unjustified);
stringColumn.updateRow(rowIndex, justified);
});
return this.stringColumn;
}
private final BiFunction<Long, DISPLAY_MODE, String> toFormattedString = (value, displayMode) -> {
switch (displayMode) {
case ALTCOIN_VOLUME:
return value > 0 ? new BigDecimal(value).movePointLeft(8).toString() : "";
case BSQ_VOLUME:
return value > 0 ? new BigDecimal(value).movePointLeft(2).toString() : "";
default:
throw new IllegalStateException("invalid display mode: " + displayMode);
}
};
}

View File

@ -0,0 +1,131 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots.table.column;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
import java.util.stream.IntStream;
import static bisq.bots.table.column.Column.JUSTIFICATION.LEFT;
/**
* For displaying boolean values as YES, NO, or user's choice for 'true' and 'false'.
*/
public class BooleanColumn extends AbstractColumn<BooleanColumn, Boolean> {
private static final String DEFAULT_TRUE_AS_STRING = "YES";
private static final String DEFAULT_FALSE_AS_STRING = "NO";
private final List<Boolean> rows = new ArrayList<>();
private final Predicate<String> isNewMaxWidth = (s) -> s != null && !s.isEmpty() && s.length() > maxWidth;
private final String trueAsString;
private final String falseAsString;
// The default BooleanColumn JUSTIFICATION is LEFT.
// The default BooleanColumn True AsString value is YES.
// The default BooleanColumn False AsString value is NO.
public BooleanColumn(String name) {
this(name, LEFT, DEFAULT_TRUE_AS_STRING, DEFAULT_FALSE_AS_STRING);
}
// Use this constructor to override default LEFT justification.
@SuppressWarnings("unused")
public BooleanColumn(String name, JUSTIFICATION justification) {
this(name, justification, DEFAULT_TRUE_AS_STRING, DEFAULT_FALSE_AS_STRING);
}
// Use this constructor to override default true/false as string defaults.
public BooleanColumn(String name, String trueAsString, String falseAsString) {
this(name, LEFT, trueAsString, falseAsString);
}
// Use this constructor to override default LEFT justification.
public BooleanColumn(String name,
JUSTIFICATION justification,
String trueAsString,
String falseAsString) {
super(name, justification);
this.trueAsString = trueAsString;
this.falseAsString = falseAsString;
this.maxWidth = name.length();
}
@Override
public void addRow(Boolean value) {
rows.add(value);
// We do not know how much padding each StringColumn value needs until it has all the values.
String s = asString(value);
stringColumn.addRow(s);
if (isNewMaxWidth.test(s))
maxWidth = s.length();
}
@Override
public List<Boolean> getRows() {
return rows;
}
@Override
public int rowCount() {
return rows.size();
}
@Override
public boolean isEmpty() {
return rows.isEmpty();
}
@Override
public Boolean getRow(int rowIndex) {
return rows.get(rowIndex);
}
@Override
public void updateRow(int rowIndex, Boolean newValue) {
rows.set(rowIndex, newValue);
}
@Override
public String getRowAsFormattedString(int rowIndex) {
return getRow(rowIndex)
? trueAsString
: falseAsString;
}
@Override
public StringColumn asStringColumn() {
// We cached the formatted satoshi strings, but we did
// not know how much padding each string needed until now.
IntStream.range(0, stringColumn.getRows().size()).forEach(rowIndex -> {
String unjustified = stringColumn.getRow(rowIndex);
String justified = stringColumn.toJustifiedString(unjustified);
stringColumn.updateRow(rowIndex, justified);
});
return stringColumn;
}
private String asString(boolean value) {
return value ? trueAsString : falseAsString;
}
}

View File

@ -0,0 +1,48 @@
package bisq.bots.table.column;
import java.util.stream.IntStream;
import static bisq.bots.CurrencyFormat.formatBtc;
import static com.google.common.base.Strings.padEnd;
import static java.util.Comparator.comparingInt;
public class BtcColumn extends SatoshiColumn {
public BtcColumn(String name) {
super(name);
}
@Override
public void addRow(Long value) {
rows.add(value);
String s = formatBtc(value);
stringColumn.addRow(s);
if (isNewMaxWidth.test(s))
maxWidth = s.length();
}
@Override
public String getRowAsFormattedString(int rowIndex) {
return formatBtc(getRow(rowIndex));
}
@Override
public StringColumn asStringColumn() {
// We cached the formatted satoshi strings, but we did
// not know how much zero padding each string needed until now.
int maxColumnValueWidth = stringColumn.getRows().stream()
.max(comparingInt(String::length))
.get()
.length();
IntStream.range(0, stringColumn.getRows().size()).forEach(rowIndex -> {
String btcString = stringColumn.getRow(rowIndex);
if (btcString.length() < maxColumnValueWidth) {
String paddedBtcString = padEnd(btcString, maxColumnValueWidth, '0');
stringColumn.updateRow(rowIndex, paddedBtcString);
}
});
return stringColumn.justify();
}
}

View File

@ -0,0 +1,122 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots.table.column;
import java.util.List;
public interface Column<T> {
enum JUSTIFICATION {
LEFT,
RIGHT,
NONE
}
/**
* Returns the column's name.
*
* @return name as String
*/
String getName();
/**
* Sets the column name.
*
* @param name of the column
*/
void setName(String name);
/**
* Add column value.
*
* @param value added to column's data (row)
*/
void addRow(T value);
/**
* Returns the column data.
*
* @return rows as List<T>
*/
List<T> getRows();
/**
* Returns the maximum width of the column name, or longest,
* formatted string value -- whichever is greater.
*
* @return width of the populated column as int
*/
int getWidth();
/**
* Returns the number of rows in the column.
*
* @return number of rows in the column as int.
*/
int rowCount();
/**
* Returns true if the column has no data.
*
* @return true if empty, false if not
*/
boolean isEmpty();
/**
* Returns the column value (data) at given row index.
*
* @return value object
*/
T getRow(int rowIndex);
/**
* Update an existing value at the given row index to a new value.
*
* @param rowIndex row index of value to be updated
* @param newValue new value
*/
void updateRow(int rowIndex, T newValue);
/**
* Returns the row value as a formatted String.
*
* @return a row value as formatted String
*/
String getRowAsFormattedString(int rowIndex);
/**
* Return the column with all of its data as a StringColumn with all of its
* formatted string data.
*
* @return StringColumn
*/
StringColumn asStringColumn();
/**
* Convenience for justifying populated StringColumns before being displayed.
* Is only useful for StringColumn instances.
*/
Column<T> justify();
/**
* Returns JUSTIFICATION value (RIGHT|LEFT|NONE) for the column.
*
* @return column JUSTIFICATION
*/
JUSTIFICATION getJustification();
}

View File

@ -0,0 +1,93 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots.table.column;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
import java.util.stream.IntStream;
import static bisq.bots.table.column.Column.JUSTIFICATION.RIGHT;
/**
* For displaying Double values.
*/
public class DoubleColumn extends NumberColumn<DoubleColumn, Double> {
protected final List<Double> rows = new ArrayList<>();
protected final Predicate<String> isNewMaxWidth = (s) -> s != null && !s.isEmpty() && s.length() > maxWidth;
// The default DoubleColumn JUSTIFICATION is RIGHT.
public DoubleColumn(String name) {
this(name, RIGHT);
}
public DoubleColumn(String name, JUSTIFICATION justification) {
super(name, justification);
this.maxWidth = name.length();
}
@Override
public void addRow(Double value) {
rows.add(value);
String s = String.valueOf(value);
if (isNewMaxWidth.test(s))
maxWidth = s.length();
}
@Override
public List<Double> getRows() {
return rows;
}
@Override
public int rowCount() {
return rows.size();
}
@Override
public boolean isEmpty() {
return rows.isEmpty();
}
@Override
public Double getRow(int rowIndex) {
return rows.get(rowIndex);
}
@Override
public void updateRow(int rowIndex, Double newValue) {
rows.set(rowIndex, newValue);
}
@Override
public String getRowAsFormattedString(int rowIndex) {
String s = String.valueOf(getRow(rowIndex));
return toJustifiedString(s);
}
@Override
public StringColumn asStringColumn() {
IntStream.range(0, rows.size()).forEachOrdered(rowIndex ->
stringColumn.addRow(getRowAsFormattedString(rowIndex)));
return stringColumn;
}
}

View File

@ -0,0 +1,83 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots.table.column;
import java.util.stream.IntStream;
import static bisq.bots.CurrencyFormat.formatFiatVolume;
import static bisq.bots.CurrencyFormat.formatPrice;
import static bisq.bots.table.column.Column.JUSTIFICATION.RIGHT;
/**
* For displaying fiat volume or price with appropriate precision.
*/
public class FiatColumn extends LongColumn {
public enum DISPLAY_MODE {
FIAT_PRICE,
FIAT_VOLUME
}
private final DISPLAY_MODE displayMode;
// The default FiatColumn JUSTIFICATION is RIGHT.
// The default FiatColumn DISPLAY_MODE is PRICE.
public FiatColumn(String name) {
this(name, RIGHT, DISPLAY_MODE.FIAT_PRICE);
}
public FiatColumn(String name, DISPLAY_MODE displayMode) {
this(name, RIGHT, displayMode);
}
public FiatColumn(String name,
JUSTIFICATION justification,
DISPLAY_MODE displayMode) {
super(name, justification);
this.displayMode = displayMode;
}
@Override
public void addRow(Long value) {
rows.add(value);
String s = displayMode.equals(DISPLAY_MODE.FIAT_PRICE) ? formatPrice(value) : formatFiatVolume(value);
stringColumn.addRow(s);
if (isNewMaxWidth.test(s))
maxWidth = s.length();
}
@Override
public String getRowAsFormattedString(int rowIndex) {
return getRow(rowIndex).toString();
}
@Override
public StringColumn asStringColumn() {
// We cached the formatted fiat price strings, but we did
// not know how much padding each string needed until now.
IntStream.range(0, stringColumn.getRows().size()).forEach(rowIndex -> {
String unjustified = stringColumn.getRow(rowIndex);
String justified = stringColumn.toJustifiedString(unjustified);
stringColumn.updateRow(rowIndex, justified);
});
return this.stringColumn;
}
}

View File

@ -0,0 +1,93 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots.table.column;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
import java.util.stream.IntStream;
import static bisq.bots.table.column.Column.JUSTIFICATION.RIGHT;
/**
* For displaying Integer values.
*/
public class IntegerColumn extends NumberColumn<IntegerColumn, Integer> {
protected final List<Integer> rows = new ArrayList<>();
protected final Predicate<String> isNewMaxWidth = (s) -> s != null && !s.isEmpty() && s.length() > maxWidth;
// The default IntegerColumn JUSTIFICATION is RIGHT.
public IntegerColumn(String name) {
this(name, RIGHT);
}
public IntegerColumn(String name, JUSTIFICATION justification) {
super(name, justification);
this.maxWidth = name.length();
}
@Override
public void addRow(Integer value) {
rows.add(value);
String s = String.valueOf(value);
if (isNewMaxWidth.test(s))
maxWidth = s.length();
}
@Override
public List<Integer> getRows() {
return rows;
}
@Override
public int rowCount() {
return rows.size();
}
@Override
public boolean isEmpty() {
return rows.isEmpty();
}
@Override
public Integer getRow(int rowIndex) {
return rows.get(rowIndex);
}
@Override
public void updateRow(int rowIndex, Integer newValue) {
rows.set(rowIndex, newValue);
}
@Override
public String getRowAsFormattedString(int rowIndex) {
String s = String.valueOf(getRow(rowIndex));
return toJustifiedString(s);
}
@Override
public StringColumn asStringColumn() {
IntStream.range(0, rows.size()).forEachOrdered(rowIndex ->
stringColumn.addRow(getRowAsFormattedString(rowIndex)));
return stringColumn;
}
}

View File

@ -0,0 +1,63 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots.table.column;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.stream.IntStream;
import static bisq.bots.table.column.Column.JUSTIFICATION.LEFT;
import static com.google.common.base.Strings.padEnd;
import static com.google.common.base.Strings.padStart;
import static java.lang.System.currentTimeMillis;
import static java.util.TimeZone.getTimeZone;
/**
* For displaying (long) timestamp values as ISO-8601 dates in UTC time zone.
*/
public class Iso8601DateTimeColumn extends LongColumn {
protected final SimpleDateFormat iso8601DateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
// The default Iso8601DateTimeColumn JUSTIFICATION is LEFT.
public Iso8601DateTimeColumn(String name) {
this(name, LEFT);
}
public Iso8601DateTimeColumn(String name, JUSTIFICATION justification) {
super(name, justification);
iso8601DateFormat.setTimeZone(getTimeZone("UTC"));
this.maxWidth = Math.max(name.length(), String.valueOf(currentTimeMillis()).length());
}
@Override
public String getRowAsFormattedString(int rowIndex) {
long time = getRow(rowIndex);
return justification.equals(LEFT)
? padEnd(iso8601DateFormat.format(new Date(time)), maxWidth, ' ')
: padStart(iso8601DateFormat.format(new Date(time)), maxWidth, ' ');
}
@Override
public StringColumn asStringColumn() {
IntStream.range(0, rows.size()).forEachOrdered(rowIndex ->
stringColumn.addRow(getRowAsFormattedString(rowIndex)));
return stringColumn;
}
}

View File

@ -0,0 +1,93 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots.table.column;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
import java.util.stream.IntStream;
import static bisq.bots.table.column.Column.JUSTIFICATION.RIGHT;
/**
* For displaying Long values.
*/
public class LongColumn extends NumberColumn<LongColumn, Long> {
protected final List<Long> rows = new ArrayList<>();
protected final Predicate<String> isNewMaxWidth = (s) -> s != null && !s.isEmpty() && s.length() > maxWidth;
// The default LongColumn JUSTIFICATION is RIGHT.
public LongColumn(String name) {
this(name, RIGHT);
}
public LongColumn(String name, JUSTIFICATION justification) {
super(name, justification);
this.maxWidth = name.length();
}
@Override
public void addRow(Long value) {
rows.add(value);
String s = String.valueOf(value);
if (isNewMaxWidth.test(s))
maxWidth = s.length();
}
@Override
public List<Long> getRows() {
return rows;
}
@Override
public int rowCount() {
return rows.size();
}
@Override
public boolean isEmpty() {
return rows.isEmpty();
}
@Override
public Long getRow(int rowIndex) {
return rows.get(rowIndex);
}
@Override
public void updateRow(int rowIndex, Long newValue) {
rows.set(rowIndex, newValue);
}
@Override
public String getRowAsFormattedString(int rowIndex) {
String s = String.valueOf(getRow(rowIndex));
return toJustifiedString(s);
}
@Override
public StringColumn asStringColumn() {
IntStream.range(0, rows.size()).forEachOrdered(rowIndex ->
stringColumn.addRow(getRowAsFormattedString(rowIndex)));
return stringColumn;
}
}

View File

@ -0,0 +1,59 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots.table.column;
import static bisq.bots.CurrencyFormat.formatBsq;
import static bisq.bots.CurrencyFormat.formatSatoshis;
import static bisq.bots.table.column.Column.JUSTIFICATION.RIGHT;
/**
* For displaying a mix of BSQ and BTC trade fees with appropriate precision.
*/
public class MixedTradeFeeColumn extends LongColumn {
public MixedTradeFeeColumn(String name) {
super(name, RIGHT);
}
@Override
public void addRow(Long value) {
throw new UnsupportedOperationException("use public void addRow(Long value, boolean isBsq) instead");
}
public void addRow(Long value, boolean isBsq) {
rows.add(value);
String s = isBsq
? formatBsq(value) + " BSQ"
: formatSatoshis(value) + " BTC";
stringColumn.addRow(s);
if (isNewMaxWidth.test(s))
maxWidth = s.length();
}
@Override
public String getRowAsFormattedString(int rowIndex) {
return getRow(rowIndex).toString();
}
@Override
public StringColumn asStringColumn() {
return stringColumn.justify();
}
}

View File

@ -0,0 +1,32 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots.table.column;
/**
* Abstract superclass for numeric Columns.
*
* @param <C> the subclass column's type (LongColumn, IntegerColumn, ...)
* @param <T> the subclass column's numeric Java type (Long, Integer, ...)
*/
abstract class NumberColumn<C extends NumberColumn<C, T>,
T extends Number> extends AbstractColumn<C, T> implements Column<T> {
public NumberColumn(String name, JUSTIFICATION justification) {
super(name, justification);
}
}

View File

@ -0,0 +1,72 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots.table.column;
import static bisq.bots.CurrencyFormat.formatBsq;
import static bisq.bots.CurrencyFormat.formatSatoshis;
import static bisq.bots.table.column.Column.JUSTIFICATION.RIGHT;
/**
* For displaying BTC or BSQ satoshi values with appropriate precision.
*/
public class SatoshiColumn extends LongColumn {
protected final boolean isBsqSatoshis;
// The default SatoshiColumn JUSTIFICATION is RIGHT.
public SatoshiColumn(String name) {
this(name, RIGHT, false);
}
public SatoshiColumn(String name, boolean isBsqSatoshis) {
this(name, RIGHT, isBsqSatoshis);
}
public SatoshiColumn(String name, JUSTIFICATION justification) {
this(name, justification, false);
}
public SatoshiColumn(String name, JUSTIFICATION justification, boolean isBsqSatoshis) {
super(name, justification);
this.isBsqSatoshis = isBsqSatoshis;
}
@Override
public void addRow(Long value) {
rows.add(value);
// We do not know how much padding each StringColumn value needs until it has all the values.
String s = isBsqSatoshis ? formatBsq(value) : formatSatoshis(value);
stringColumn.addRow(s);
if (isNewMaxWidth.test(s))
maxWidth = s.length();
}
@Override
public String getRowAsFormattedString(int rowIndex) {
return isBsqSatoshis
? formatBsq(getRow(rowIndex))
: formatSatoshis(getRow(rowIndex));
}
@Override
public StringColumn asStringColumn() {
return stringColumn.justify();
}
}

View File

@ -0,0 +1,102 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots.table.column;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
import java.util.stream.IntStream;
import static bisq.bots.table.column.Column.JUSTIFICATION.LEFT;
import static bisq.bots.table.column.Column.JUSTIFICATION.RIGHT;
/**
* For displaying justified string values.
*/
public class StringColumn extends AbstractColumn<StringColumn, String> {
private final List<String> rows = new ArrayList<>();
private final Predicate<String> isNewMaxWidth = (s) -> s != null && !s.isEmpty() && s.length() > maxWidth;
// The default StringColumn JUSTIFICATION is LEFT.
public StringColumn(String name) {
this(name, LEFT);
}
// Use this constructor to override default LEFT justification.
public StringColumn(String name, JUSTIFICATION justification) {
super(name, justification);
this.maxWidth = name.length();
}
@Override
public void addRow(String value) {
rows.add(value);
if (isNewMaxWidth.test(value))
maxWidth = value.length();
}
@Override
public List<String> getRows() {
return rows;
}
@Override
public int rowCount() {
return rows.size();
}
@Override
public boolean isEmpty() {
return rows.isEmpty();
}
@Override
public String getRow(int rowIndex) {
return rows.get(rowIndex);
}
@Override
public void updateRow(int rowIndex, String newValue) {
rows.set(rowIndex, newValue);
}
@Override
public String getRowAsFormattedString(int rowIndex) {
return getRow(rowIndex);
}
@Override
public StringColumn asStringColumn() {
return this;
}
@Override
public StringColumn justify() {
if (justification.equals(RIGHT)) {
IntStream.range(0, getRows().size()).forEach(rowIndex -> {
String unjustified = getRow(rowIndex);
String justified = toJustifiedString(unjustified);
updateRow(rowIndex, justified);
});
}
return this;
}
}

View File

@ -0,0 +1,124 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.bots.table.column;
import bisq.bots.table.column.Column.JUSTIFICATION;
import javax.annotation.Nullable;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.IntStream;
/**
* For zipping multiple StringColumns into a single StringColumn.
* Useful for displaying amount and volume range values.
*/
public class ZippedStringColumns {
public enum DUPLICATION_MODE {
EXCLUDE_DUPLICATES,
INCLUDE_DUPLICATES
}
private final String name;
private final JUSTIFICATION justification;
private final String delimiter;
private final StringColumn[] columns;
public ZippedStringColumns(String name,
JUSTIFICATION justification,
String delimiter,
StringColumn... columns) {
this.name = name;
this.justification = justification;
this.delimiter = delimiter;
this.columns = columns;
validateColumnData();
}
public StringColumn asStringColumn(DUPLICATION_MODE duplicationMode) {
StringColumn stringColumn = new StringColumn(name, justification);
buildRows(stringColumn, duplicationMode);
// Re-set the column name field to its justified value, in case any of the column
// values are longer than the name passed to this constructor.
stringColumn.setName(stringColumn.toJustifiedString(name));
return stringColumn;
}
private void buildRows(StringColumn stringColumn, DUPLICATION_MODE duplicationMode) {
// Populate the StringColumn with unjustified zipped values; we cannot justify
// the zipped values until stringColumn knows its final maxWidth.
IntStream.range(0, columns[0].getRows().size()).forEach(rowIndex -> {
String row = buildRow(rowIndex, duplicationMode);
stringColumn.addRow(row);
});
formatRows(stringColumn);
}
private String buildRow(int rowIndex, DUPLICATION_MODE duplicationMode) {
StringBuilder rowBuilder = new StringBuilder();
@Nullable
List<String> processedValues = duplicationMode.equals(DUPLICATION_MODE.EXCLUDE_DUPLICATES)
? new ArrayList<>()
: null;
IntStream.range(0, columns.length).forEachOrdered(colIndex -> {
// For each column @ rowIndex ...
var value = columns[colIndex].getRows().get(rowIndex);
if (duplicationMode.equals(DUPLICATION_MODE.INCLUDE_DUPLICATES)) {
if (rowBuilder.length() > 0)
rowBuilder.append(delimiter);
rowBuilder.append(value);
} else if (!processedValues.contains(value)) {
if (rowBuilder.length() > 0)
rowBuilder.append(delimiter);
rowBuilder.append(value);
processedValues.add(value);
}
});
return rowBuilder.toString();
}
private void formatRows(StringColumn stringColumn) {
// Now we can justify the zipped string values in the new StringColumn.
IntStream.range(0, stringColumn.getRows().size()).forEach(rowIndex -> {
String unjustified = stringColumn.getRow(rowIndex);
String justified = stringColumn.toJustifiedString(unjustified);
stringColumn.updateRow(rowIndex, justified);
});
}
private void validateColumnData() {
if (columns.length == 0)
throw new IllegalStateException("cannot zip columns because they do not have any data");
StringColumn firstColumn = columns[0];
if (firstColumn.getRows().isEmpty())
throw new IllegalStateException("1st column has no data");
IntStream.range(1, columns.length).forEach(colIndex -> {
if (columns[colIndex].getRows().size() != firstColumn.getRows().size())
throw new IllegalStateException("columns do not have same number of rows");
});
}
}

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

View File

@ -6,7 +6,7 @@
</encoder>
</appender>
<root level="DEBUG">
<root level="INFO">
<appender-ref ref="CONSOLE_APPENDER"/>
</root>