From c3f5669cf88f13ebaae76927976b667ddbab1d2a Mon Sep 17 00:00:00 2001 From: ghubstan <36207203+ghubstan@users.noreply.github.com> Date: Thu, 25 Mar 2021 18:36:26 -0300 Subject: [PATCH 01/21] Add api method createcryptopaymentacct This change supports creation of BSQ BLOCKCHAIN payment method accounts. - Added proto message defs to grpc.proto. - Added grpc server boilerplate to GrpcPaymentAccountsService. - Added server impl to CoreApi, CorePaymentAccountsService. - Added createcryptopaymentacct-help.txt. - Added CLI side support for new api method. - Added opt parsing unit tests to OptionParsersTest. This is the 1st PR in a series, with the goal of supporting the BTC/BSQ trading pair. Support for other crypto currency payment accounts will be added later. --- cli/src/main/java/bisq/cli/CliMain.java | 19 ++++ cli/src/main/java/bisq/cli/GrpcClient.java | 49 +++++++++- cli/src/main/java/bisq/cli/Method.java | 1 + .../main/java/bisq/cli/opts/ArgumentList.java | 1 + ...CryptoCurrencyPaymentAcctOptionParser.java | 75 ++++++++++++++++ .../main/java/bisq/cli/opts/MethodOpts.java | 1 - cli/src/main/java/bisq/cli/opts/OptLabel.java | 1 + .../java/bisq/cli/opt/OptionParsersTest.java | 89 ++++++++++++++++++- core/src/main/java/bisq/core/api/CoreApi.java | 8 ++ .../core/api/CorePaymentAccountsService.java | 50 ++++++++++- .../bisq/core/api/CoreWalletsService.java | 20 ++--- .../help/createcryptopaymentacct-help.txt | 39 ++++++++ .../grpc/GrpcPaymentAccountsService.java | 37 ++++++++ proto/src/main/proto/grpc.proto | 21 +++++ 14 files changed, 393 insertions(+), 18 deletions(-) create mode 100644 cli/src/main/java/bisq/cli/opts/CreateCryptoCurrencyPaymentAcctOptionParser.java create mode 100644 core/src/main/resources/help/createcryptopaymentacct-help.txt diff --git a/cli/src/main/java/bisq/cli/CliMain.java b/cli/src/main/java/bisq/cli/CliMain.java index 5e6e913711c..37bda937eb8 100644 --- a/cli/src/main/java/bisq/cli/CliMain.java +++ b/cli/src/main/java/bisq/cli/CliMain.java @@ -56,6 +56,7 @@ import bisq.cli.opts.ArgumentList; import bisq.cli.opts.CancelOfferOptionParser; +import bisq.cli.opts.CreateCryptoCurrencyPaymentAcctOptionParser; import bisq.cli.opts.CreateOfferOptionParser; import bisq.cli.opts.CreatePaymentAcctOptionParser; import bisq.cli.opts.GetAddressBalanceOptionParser; @@ -517,6 +518,22 @@ public static void run(String[] args) { out.println(formatPaymentAcctTbl(singletonList(paymentAccount))); return; } + case createcryptopaymentacct: { + var opts = new CreateCryptoCurrencyPaymentAcctOptionParser(args).parse(); + if (opts.isForHelp()) { + out.println(client.getMethodHelp(method)); + return; + } + var accountName = opts.getAccountName(); + var currencyCode = opts.getCurrencyCode(); + var address = opts.getAddress(); + var paymentAccount = client.createCryptoCurrencyPaymentAccount(accountName, + currencyCode, + address); + out.println("payment account saved"); + out.println(formatPaymentAcctTbl(singletonList(paymentAccount))); + return; + } case getpaymentaccts: { if (new SimpleMethodOptionParser(args).parse().isForHelp()) { out.println(client.getMethodHelp(method)); @@ -748,6 +765,8 @@ private static void printHelp(OptionParser parser, @SuppressWarnings("SameParame stream.println(); stream.format(rowFormat, createpaymentacct.name(), "--payment-account-form=", "Create a new payment account"); stream.println(); + stream.format(rowFormat, createcryptopaymentacct.name(), "--TODO=", "Create a new cryptocurrency payment account"); + stream.println(); stream.format(rowFormat, getpaymentaccts.name(), "", "Get user payment accounts"); stream.println(); stream.format(rowFormat, lockwallet.name(), "", "Remove wallet password from memory, locking the wallet"); diff --git a/cli/src/main/java/bisq/cli/GrpcClient.java b/cli/src/main/java/bisq/cli/GrpcClient.java index ea20c6aef88..5d3dde4eccb 100644 --- a/cli/src/main/java/bisq/cli/GrpcClient.java +++ b/cli/src/main/java/bisq/cli/GrpcClient.java @@ -24,10 +24,12 @@ import bisq.proto.grpc.CancelOfferRequest; import bisq.proto.grpc.ConfirmPaymentReceivedRequest; import bisq.proto.grpc.ConfirmPaymentStartedRequest; +import bisq.proto.grpc.CreateCryptoCurrencyPaymentAccountRequest; import bisq.proto.grpc.CreateOfferRequest; import bisq.proto.grpc.CreatePaymentAccountRequest; import bisq.proto.grpc.GetAddressBalanceRequest; import bisq.proto.grpc.GetBalancesRequest; +import bisq.proto.grpc.GetCryptoCurrencyPaymentMethodsRequest; import bisq.proto.grpc.GetFundingAddressesRequest; import bisq.proto.grpc.GetMethodHelpRequest; import bisq.proto.grpc.GetMyOfferRequest; @@ -67,11 +69,11 @@ import java.util.ArrayList; import java.util.List; -import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import static java.util.Comparator.comparing; +import static java.util.stream.Collectors.toList; import static protobuf.OfferPayload.Direction.BUY; import static protobuf.OfferPayload.Direction.SELL; @@ -228,7 +230,6 @@ public OfferInfo createMarketBasedPricedOffer(String direction, makerFeeCurrencyCode); } - // TODO make private, move to bottom of class public OfferInfo createOffer(String direction, String currencyCode, long amount, @@ -283,6 +284,12 @@ public List getOffers(String direction, String currencyCode) { return grpcStubs.offersService.getOffers(request).getOffersList(); } + public List getBsqOffers(String direction) { + return getOffers(direction, "BTC").stream() + .filter(o -> o.getBaseCurrencyCode().equals("BSQ")) + .collect(toList()); + } + public List getOffersSortedByDate(String currencyCode) { ArrayList offers = new ArrayList<>(); offers.addAll(getOffers(BUY.name(), currencyCode)); @@ -295,6 +302,13 @@ public List getOffersSortedByDate(String direction, String currencyCo return offers.isEmpty() ? offers : sortOffersByDate(offers); } + public List getBsqOffersSortedByDate() { + ArrayList offers = new ArrayList<>(); + offers.addAll(getBsqOffers(BUY.name())); + offers.addAll(getBsqOffers(SELL.name())); + return sortOffersByDate(offers); + } + public List getMyOffers(String direction, String currencyCode) { var request = GetMyOffersRequest.newBuilder() .setDirection(direction) @@ -303,6 +317,12 @@ public List getMyOffers(String direction, String currencyCode) { return grpcStubs.offersService.getMyOffers(request).getOffersList(); } + public List getMyBsqOffers(String direction) { + return getMyOffers(direction, "BTC").stream() + .filter(o -> o.getBaseCurrencyCode().equals("BSQ")) + .collect(toList()); + } + public List getMyOffersSortedByDate(String direction, String currencyCode) { var offers = getMyOffers(direction, currencyCode); return offers.isEmpty() ? offers : sortOffersByDate(offers); @@ -315,6 +335,13 @@ public List getMyOffersSortedByDate(String currencyCode) { return sortOffersByDate(offers); } + public List getMyBsqOffersSortedByDate() { + ArrayList offers = new ArrayList<>(); + offers.addAll(getMyBsqOffers(BUY.name())); + offers.addAll(getMyBsqOffers(SELL.name())); + return sortOffersByDate(offers); + } + public OfferInfo getMostRecentOffer(String direction, String currencyCode) { List offers = getOffersSortedByDate(direction, currencyCode); return offers.isEmpty() ? null : offers.get(offers.size() - 1); @@ -323,7 +350,7 @@ public OfferInfo getMostRecentOffer(String direction, String currencyCode) { public List sortOffersByDate(List offerInfoList) { return offerInfoList.stream() .sorted(comparing(OfferInfo::getDate)) - .collect(Collectors.toList()); + .collect(toList()); } public TakeOfferReply getTakeOfferReply(String offerId, String paymentAccountId, String takerFeeCurrencyCode) { @@ -404,6 +431,22 @@ public List getPaymentAccounts() { return grpcStubs.paymentAccountsService.getPaymentAccounts(request).getPaymentAccountsList(); } + public PaymentAccount createCryptoCurrencyPaymentAccount(String accountName, + String currencyCode, + String address) { + var request = CreateCryptoCurrencyPaymentAccountRequest.newBuilder() + .setAccountName(accountName) + .setCurrencyCode(currencyCode) + .setAddress(address) + .build(); + return grpcStubs.paymentAccountsService.createCryptoCurrencyPaymentAccount(request).getPaymentAccount(); + } + + public List getCryptoPaymentMethods() { + var request = GetCryptoCurrencyPaymentMethodsRequest.newBuilder().build(); + return grpcStubs.paymentAccountsService.getCryptoCurrencyPaymentMethods(request).getPaymentMethodsList(); + } + public void lockWallet() { var request = LockWalletRequest.newBuilder().build(); grpcStubs.walletsService.lockWallet(request); diff --git a/cli/src/main/java/bisq/cli/Method.java b/cli/src/main/java/bisq/cli/Method.java index 67b582f13c6..908321a0902 100644 --- a/cli/src/main/java/bisq/cli/Method.java +++ b/cli/src/main/java/bisq/cli/Method.java @@ -26,6 +26,7 @@ public enum Method { confirmpaymentstarted, createoffer, createpaymentacct, + createcryptopaymentacct, getaddressbalance, getbalance, getbtcprice, diff --git a/cli/src/main/java/bisq/cli/opts/ArgumentList.java b/cli/src/main/java/bisq/cli/opts/ArgumentList.java index 3b52fb34a90..b416946d646 100644 --- a/cli/src/main/java/bisq/cli/opts/ArgumentList.java +++ b/cli/src/main/java/bisq/cli/opts/ArgumentList.java @@ -113,6 +113,7 @@ boolean hasMore() { return currentIndex < arguments.length; } + @SuppressWarnings("UnusedReturnValue") String next() { return arguments[currentIndex++]; } diff --git a/cli/src/main/java/bisq/cli/opts/CreateCryptoCurrencyPaymentAcctOptionParser.java b/cli/src/main/java/bisq/cli/opts/CreateCryptoCurrencyPaymentAcctOptionParser.java new file mode 100644 index 00000000000..90542e1e0b7 --- /dev/null +++ b/cli/src/main/java/bisq/cli/opts/CreateCryptoCurrencyPaymentAcctOptionParser.java @@ -0,0 +1,75 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.cli.opts; + + +import joptsimple.OptionSpec; + +import static bisq.cli.opts.OptLabel.OPT_ACCOUNT_NAME; +import static bisq.cli.opts.OptLabel.OPT_ADDRESS; +import static bisq.cli.opts.OptLabel.OPT_CURRENCY_CODE; + +public class CreateCryptoCurrencyPaymentAcctOptionParser extends AbstractMethodOptionParser implements MethodOpts { + + final OptionSpec accountNameOpt = parser.accepts(OPT_ACCOUNT_NAME, "crypto currency account name") + .withRequiredArg(); + + final OptionSpec currencyCodeOpt = parser.accepts(OPT_CURRENCY_CODE, "crypto currency code (bsq only)") + .withRequiredArg(); + + final OptionSpec addressOpt = parser.accepts(OPT_ADDRESS, "bsq address") + .withRequiredArg(); + + public CreateCryptoCurrencyPaymentAcctOptionParser(String[] args) { + super(args); + } + + public CreateCryptoCurrencyPaymentAcctOptionParser parse() { + super.parse(); + + // Short circuit opt validation if user just wants help. + if (options.has(helpOpt)) + return this; + + if (!options.has(accountNameOpt) || options.valueOf(accountNameOpt).isEmpty()) + throw new IllegalArgumentException("no payment account name specified"); + + if (!options.has(currencyCodeOpt) || options.valueOf(currencyCodeOpt).isEmpty()) + throw new IllegalArgumentException("no currency code specified"); + + if (!options.valueOf(currencyCodeOpt).equalsIgnoreCase("bsq")) + throw new IllegalArgumentException("api only supports bsq crypto currency payment accounts"); + + if (!options.has(addressOpt) || options.valueOf(addressOpt).isEmpty()) + throw new IllegalArgumentException("no bsq address specified"); + + return this; + } + + public String getAccountName() { + return options.valueOf(accountNameOpt); + } + + public String getCurrencyCode() { + return options.valueOf(currencyCodeOpt); + } + + public String getAddress() { + return options.valueOf(addressOpt); + } +} diff --git a/cli/src/main/java/bisq/cli/opts/MethodOpts.java b/cli/src/main/java/bisq/cli/opts/MethodOpts.java index da639857522..9f6c2d1a3e4 100644 --- a/cli/src/main/java/bisq/cli/opts/MethodOpts.java +++ b/cli/src/main/java/bisq/cli/opts/MethodOpts.java @@ -22,5 +22,4 @@ public interface MethodOpts { MethodOpts parse(); boolean isForHelp(); - } diff --git a/cli/src/main/java/bisq/cli/opts/OptLabel.java b/cli/src/main/java/bisq/cli/opts/OptLabel.java index 5cbd068ce53..fcabd0820e4 100644 --- a/cli/src/main/java/bisq/cli/opts/OptLabel.java +++ b/cli/src/main/java/bisq/cli/opts/OptLabel.java @@ -21,6 +21,7 @@ * CLI opt label definitions. */ public class OptLabel { + public final static String OPT_ACCOUNT_NAME = "account-name"; public final static String OPT_ADDRESS = "address"; public final static String OPT_AMOUNT = "amount"; public final static String OPT_CURRENCY_CODE = "currency-code"; diff --git a/cli/src/test/java/bisq/cli/opt/OptionParsersTest.java b/cli/src/test/java/bisq/cli/opt/OptionParsersTest.java index 1f939198d69..d5cfe5d708f 100644 --- a/cli/src/test/java/bisq/cli/opt/OptionParsersTest.java +++ b/cli/src/test/java/bisq/cli/opt/OptionParsersTest.java @@ -3,6 +3,7 @@ import org.junit.jupiter.api.Test; import static bisq.cli.Method.canceloffer; +import static bisq.cli.Method.createcryptopaymentacct; import static bisq.cli.Method.createoffer; import static bisq.cli.Method.createpaymentacct; import static bisq.cli.opts.OptLabel.*; @@ -12,6 +13,7 @@ import bisq.cli.opts.CancelOfferOptionParser; +import bisq.cli.opts.CreateCryptoCurrencyPaymentAcctOptionParser; import bisq.cli.opts.CreateOfferOptionParser; import bisq.cli.opts.CreatePaymentAcctOptionParser; @@ -20,7 +22,7 @@ public class OptionParsersTest { private static final String PASSWORD_OPT = "--" + OPT_PASSWORD + "=" + "xyz"; - // CancelOffer opt parsing tests + // canceloffer opt parser tests @Test public void testCancelOfferWithMissingOfferIdOptShouldThrowException() { @@ -67,7 +69,7 @@ public void testValidCancelOfferOpts() { new CancelOfferOptionParser(args).parse(); } - // CreateOffer opt parsing tests + // createoffer opt parser tests @Test public void testCreateOfferOptParserWithMissingPaymentAccountIdOptShouldThrowException() { @@ -139,7 +141,7 @@ public void testValidCreateOfferOpts() { assertEquals("25.0", parser.getSecurityDeposit()); } - // CreatePaymentAcct opt parser tests + // createpaymentacct opt parser tests @Test public void testCreatePaymentAcctOptParserWithMissingPaymentFormOptShouldThrowException() { @@ -177,4 +179,85 @@ public void testCreatePaymentAcctOptParserWithInvalidPaymentFormOptValueShouldTh assertEquals("json payment account form '/tmp/milkyway/solarsystem/mars' could not be found", exception.getMessage()); } + + // createcryptopaymentacct parser tests + + @Test + public void testCreateCryptoCurrencyPaymentAcctOptionParserWithMissingAcctNameOptShouldThrowException() { + String[] args = new String[]{ + PASSWORD_OPT, + createcryptopaymentacct.name() + }; + Throwable exception = assertThrows(RuntimeException.class, () -> + new CreateCryptoCurrencyPaymentAcctOptionParser(args).parse()); + assertEquals("no payment account name specified", exception.getMessage()); + } + + @Test + public void testCreateCryptoCurrencyPaymentAcctOptionParserWithEmptyAcctNameOptShouldThrowException() { + String[] args = new String[]{ + PASSWORD_OPT, + createcryptopaymentacct.name(), + "--" + OPT_ACCOUNT_NAME + }; + Throwable exception = assertThrows(RuntimeException.class, () -> + new CreateCryptoCurrencyPaymentAcctOptionParser(args).parse()); + assertEquals("account-name requires an argument", exception.getMessage()); + } + + @Test + public void testCreateCryptoCurrencyPaymentAcctOptionParserWithMissingCurrencyCodeOptShouldThrowException() { + String[] args = new String[]{ + PASSWORD_OPT, + createcryptopaymentacct.name(), + "--" + OPT_ACCOUNT_NAME + "=" + "bsq payment account" + }; + Throwable exception = assertThrows(RuntimeException.class, () -> + new CreateCryptoCurrencyPaymentAcctOptionParser(args).parse()); + assertEquals("no currency code specified", exception.getMessage()); + } + + @Test + public void testCreateCryptoCurrencyPaymentAcctOptionParserWithInvalidCurrencyCodeOptShouldThrowException() { + String[] args = new String[]{ + PASSWORD_OPT, + createcryptopaymentacct.name(), + "--" + OPT_ACCOUNT_NAME + "=" + "bsq payment account", + "--" + OPT_CURRENCY_CODE + "=" + "xmr" + }; + Throwable exception = assertThrows(RuntimeException.class, () -> + new CreateCryptoCurrencyPaymentAcctOptionParser(args).parse()); + assertEquals("api only supports bsq crypto currency payment accounts", exception.getMessage()); + } + + @Test + public void testCreateCryptoCurrencyPaymentAcctOptionParserWithMissingAddressOptShouldThrowException() { + String[] args = new String[]{ + PASSWORD_OPT, + createcryptopaymentacct.name(), + "--" + OPT_ACCOUNT_NAME + "=" + "bsq payment account", + "--" + OPT_CURRENCY_CODE + "=" + "bsq" + }; + Throwable exception = assertThrows(RuntimeException.class, () -> + new CreateCryptoCurrencyPaymentAcctOptionParser(args).parse()); + assertEquals("no bsq address specified", exception.getMessage()); + } + + @Test + public void testCreateCryptoCurrencyPaymentAcctOptionParser() { + var acctName = "bsq payment account"; + var currencyCode = "bsq"; + var address = "B1nXyZ"; // address is validated on server + String[] args = new String[]{ + PASSWORD_OPT, + createcryptopaymentacct.name(), + "--" + OPT_ACCOUNT_NAME + "=" + acctName, + "--" + OPT_CURRENCY_CODE + "=" + currencyCode, + "--" + OPT_ADDRESS + "=" + address + }; + var parser = new CreateCryptoCurrencyPaymentAcctOptionParser(args).parse(); + assertEquals(acctName, parser.getAccountName()); + assertEquals(currencyCode, parser.getCurrencyCode()); + assertEquals(address, parser.getAddress()); + } } diff --git a/core/src/main/java/bisq/core/api/CoreApi.java b/core/src/main/java/bisq/core/api/CoreApi.java index 8861620adbc..6db1c667ed9 100644 --- a/core/src/main/java/bisq/core/api/CoreApi.java +++ b/core/src/main/java/bisq/core/api/CoreApi.java @@ -210,6 +210,14 @@ public String getPaymentAccountForm(String paymentMethodId) { return paymentAccountsService.getPaymentAccountFormAsString(paymentMethodId); } + public PaymentAccount createCryptoCurrencyPaymentAccount(String accountName, String currencyCode, String address) { + return paymentAccountsService.createCryptoCurrencyPaymentAccount(accountName, currencyCode, address); + } + + public List getCryptoCurrencyPaymentMethods() { + return paymentAccountsService.getCryptoCurrencyPaymentMethods(); + } + /////////////////////////////////////////////////////////////////////////////////////////// // Prices /////////////////////////////////////////////////////////////////////////////////////////// diff --git a/core/src/main/java/bisq/core/api/CorePaymentAccountsService.java b/core/src/main/java/bisq/core/api/CorePaymentAccountsService.java index b915b3ab4be..a316b76ed07 100644 --- a/core/src/main/java/bisq/core/api/CorePaymentAccountsService.java +++ b/core/src/main/java/bisq/core/api/CorePaymentAccountsService.java @@ -19,7 +19,10 @@ import bisq.core.account.witness.AccountAgeWitnessService; import bisq.core.api.model.PaymentAccountForm; +import bisq.core.locale.CryptoCurrency; +import bisq.core.payment.CryptoCurrencyAccount; import bisq.core.payment.PaymentAccount; +import bisq.core.payment.PaymentAccountFactory; import bisq.core.payment.payload.PaymentMethod; import bisq.core.user.User; @@ -30,30 +33,37 @@ import java.util.Comparator; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; +import static bisq.core.locale.CurrencyUtil.getCryptoCurrency; import static java.lang.String.format; @Singleton @Slf4j class CorePaymentAccountsService { + private final CoreWalletsService coreWalletsService; private final AccountAgeWitnessService accountAgeWitnessService; private final PaymentAccountForm paymentAccountForm; private final User user; @Inject - public CorePaymentAccountsService(AccountAgeWitnessService accountAgeWitnessService, + public CorePaymentAccountsService(CoreWalletsService coreWalletsService, + AccountAgeWitnessService accountAgeWitnessService, PaymentAccountForm paymentAccountForm, User user) { + this.coreWalletsService = coreWalletsService; this.accountAgeWitnessService = accountAgeWitnessService; this.paymentAccountForm = paymentAccountForm; this.user = user; } + // Fiat Currency Accounts + PaymentAccount createPaymentAccount(String jsonString) { PaymentAccount paymentAccount = paymentAccountForm.toPaymentAccount(jsonString); verifyPaymentAccountHasRequiredFields(paymentAccount); @@ -86,6 +96,44 @@ File getPaymentAccountForm(String paymentMethodId) { return paymentAccountForm.getPaymentAccountForm(paymentMethodId); } + // Crypto Currency Accounts + + PaymentAccount createCryptoCurrencyPaymentAccount(String accountName, + String currencyCode, + String address) { + String bsqCode = currencyCode.toUpperCase(); + if (!bsqCode.equals("BSQ")) + throw new IllegalArgumentException("api does not currently support " + currencyCode + " accounts"); + + // Validate the BSQ address string but ignore the return value. + coreWalletsService.getValidBsqLegacyAddress(address); + + CryptoCurrencyAccount cryptoCurrencyAccount = + (CryptoCurrencyAccount) PaymentAccountFactory.getPaymentAccount(PaymentMethod.BLOCK_CHAINS); + cryptoCurrencyAccount.init(); + cryptoCurrencyAccount.setAccountName(accountName); + cryptoCurrencyAccount.setAddress(address); + Optional cryptoCurrency = getCryptoCurrency(bsqCode); + cryptoCurrency.ifPresent(cryptoCurrencyAccount::setSingleTradeCurrency); + user.addPaymentAccount(cryptoCurrencyAccount); + accountAgeWitnessService.publishMyAccountAgeWitness(cryptoCurrencyAccount.getPaymentAccountPayload()); + log.info("Saved crypto payment account with id {} and payment method {}.", + cryptoCurrencyAccount.getId(), + cryptoCurrencyAccount.getPaymentAccountPayload().getPaymentMethodId()); + return cryptoCurrencyAccount; + } + + // TODO Support all alt coin payment methods supported by UI. + // The getCryptoCurrencyPaymentMethods method below will be + // callable from the CLI when more are supported. + + List getCryptoCurrencyPaymentMethods() { + return PaymentMethod.getPaymentMethods().stream() + .filter(PaymentMethod::isAsset) + .sorted(Comparator.comparing(PaymentMethod::getId)) + .collect(Collectors.toList()); + } + private void verifyPaymentAccountHasRequiredFields(PaymentAccount paymentAccount) { // Do checks here to make sure required fields are populated. if (paymentAccount.isTransferwiseAccount() && paymentAccount.getTradeCurrencies().isEmpty()) diff --git a/core/src/main/java/bisq/core/api/CoreWalletsService.java b/core/src/main/java/bisq/core/api/CoreWalletsService.java index 3438f640d8e..d6aa8a240a3 100644 --- a/core/src/main/java/bisq/core/api/CoreWalletsService.java +++ b/core/src/main/java/bisq/core/api/CoreWalletsService.java @@ -511,6 +511,16 @@ void verifyApplicationIsFullyInitialized() { throw new IllegalStateException("server is not fully initialized"); } + // Returns a LegacyAddress for the string, or a RuntimeException if invalid. + LegacyAddress getValidBsqLegacyAddress(String address) { + try { + return bsqFormatter.getAddressFromBsqAddress(address); + } catch (Throwable t) { + log.error("", t); + throw new IllegalStateException(format("%s is not a valid bsq address", address)); + } + } + // Throws a RuntimeException if wallet currency code is not BSQ or BTC. private void verifyWalletCurrencyCodeIsValid(String currencyCode) { if (currencyCode == null || currencyCode.isEmpty()) @@ -575,16 +585,6 @@ private BtcBalanceInfo getBtcBalances() { lockedBalance.value); } - // Returns a LegacyAddress for the string, or a RuntimeException if invalid. - private LegacyAddress getValidBsqLegacyAddress(String address) { - try { - return bsqFormatter.getAddressFromBsqAddress(address); - } catch (Throwable t) { - log.error("", t); - throw new IllegalStateException(format("%s is not a valid bsq address", address)); - } - } - // Returns a Coin for the transfer amount string, or a RuntimeException if invalid. private Coin getValidTransferAmount(String amount, CoinFormatter coinFormatter) { Coin amountAsCoin = parseToCoin(amount, coinFormatter); diff --git a/core/src/main/resources/help/createcryptopaymentacct-help.txt b/core/src/main/resources/help/createcryptopaymentacct-help.txt new file mode 100644 index 00000000000..c1724550e1c --- /dev/null +++ b/core/src/main/resources/help/createcryptopaymentacct-help.txt @@ -0,0 +1,39 @@ +createcryptopaymentacct + +NAME +---- +createcryptopaymentacct - create a cryptocurrency payment account + +SYNOPSIS +-------- +createcryptopaymentacct + --account-name= + --currency-code= + --address= + +DESCRIPTION +----------- +Creates a cryptocurrency (altcoin) trading account for buying and selling BTC. + +OPTIONS +------- +--account-name + The name of the cryptocurrency payment account. + +--currency-code + The three letter code for the cryptocurrency used to buy or sell BTC, e.g., BSQ. + +--address + A valid BSQ wallet address. + +EXAMPLES +-------- +To create a new BSQ payment account, find an unused BSQ wallet address: +$ ./bisq-cli --password=xyz --port=9998 getunusedbsqaddress + +With the returned BSQ address, e.g., Bn3PCQgRwhkrGnaMp1RYwt9tFwL51YELqne create the cryptocurrency payment account: +$ ./bisq-cli --password=xyz --port=9998 createcryptopaymentacct \ + --account-name="My BSQ Account" \ + --currency-code=BSQ \ + --address=Bn3PCQgRwhkrGnaMp1RYwt9tFwL51YELqne + diff --git a/daemon/src/main/java/bisq/daemon/grpc/GrpcPaymentAccountsService.java b/daemon/src/main/java/bisq/daemon/grpc/GrpcPaymentAccountsService.java index e1df2b16cb1..af801004841 100644 --- a/daemon/src/main/java/bisq/daemon/grpc/GrpcPaymentAccountsService.java +++ b/daemon/src/main/java/bisq/daemon/grpc/GrpcPaymentAccountsService.java @@ -21,8 +21,12 @@ import bisq.core.payment.PaymentAccount; import bisq.core.payment.payload.PaymentMethod; +import bisq.proto.grpc.CreateCryptoCurrencyPaymentAccountReply; +import bisq.proto.grpc.CreateCryptoCurrencyPaymentAccountRequest; import bisq.proto.grpc.CreatePaymentAccountReply; import bisq.proto.grpc.CreatePaymentAccountRequest; +import bisq.proto.grpc.GetCryptoCurrencyPaymentMethodsReply; +import bisq.proto.grpc.GetCryptoCurrencyPaymentMethodsRequest; import bisq.proto.grpc.GetPaymentAccountFormReply; import bisq.proto.grpc.GetPaymentAccountFormRequest; import bisq.proto.grpc.GetPaymentAccountsReply; @@ -125,6 +129,39 @@ public void getPaymentAccountForm(GetPaymentAccountFormRequest req, } } + @Override + public void createCryptoCurrencyPaymentAccount(CreateCryptoCurrencyPaymentAccountRequest req, + StreamObserver responseObserver) { + try { + PaymentAccount paymentAccount = coreApi.createCryptoCurrencyPaymentAccount(req.getAccountName(), + req.getCurrencyCode(), + req.getAddress()); + var reply = CreateCryptoCurrencyPaymentAccountReply.newBuilder() + .setPaymentAccount(paymentAccount.toProtoMessage()) + .build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } catch (Throwable cause) { + exceptionHandler.handleException(log, cause, responseObserver); + } + } + + @Override + public void getCryptoCurrencyPaymentMethods(GetCryptoCurrencyPaymentMethodsRequest req, + StreamObserver responseObserver) { + try { + var paymentMethods = coreApi.getCryptoCurrencyPaymentMethods().stream() + .map(PaymentMethod::toProtoMessage) + .collect(Collectors.toList()); + var reply = GetCryptoCurrencyPaymentMethodsReply.newBuilder() + .addAllPaymentMethods(paymentMethods).build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } catch (Throwable cause) { + exceptionHandler.handleException(log, cause, responseObserver); + } + } + final ServerInterceptor[] interceptors() { Optional rateMeteringInterceptor = rateMeteringInterceptor(); return rateMeteringInterceptor.map(serverInterceptor -> diff --git a/proto/src/main/proto/grpc.proto b/proto/src/main/proto/grpc.proto index 8a819ff83ba..3fd783ada38 100644 --- a/proto/src/main/proto/grpc.proto +++ b/proto/src/main/proto/grpc.proto @@ -170,6 +170,10 @@ service PaymentAccounts { } rpc GetPaymentAccountForm (GetPaymentAccountFormRequest) returns (GetPaymentAccountFormReply) { } + rpc CreateCryptoCurrencyPaymentAccount (CreateCryptoCurrencyPaymentAccountRequest) returns (CreateCryptoCurrencyPaymentAccountReply) { + } + rpc GetCryptoCurrencyPaymentMethods (GetCryptoCurrencyPaymentMethodsRequest) returns (GetCryptoCurrencyPaymentMethodsReply) { + } } message CreatePaymentAccountRequest { @@ -202,6 +206,23 @@ message GetPaymentAccountFormReply { string paymentAccountFormJson = 1; } +message CreateCryptoCurrencyPaymentAccountRequest { + string accountName = 1; + string currencyCode = 2; + string address = 3; +} + +message CreateCryptoCurrencyPaymentAccountReply { + PaymentAccount paymentAccount = 1; +} + +message GetCryptoCurrencyPaymentMethodsRequest { +} + +message GetCryptoCurrencyPaymentMethodsReply { + repeated PaymentMethod paymentMethods = 1; +} + /////////////////////////////////////////////////////////////////////////////////////////// // Price /////////////////////////////////////////////////////////////////////////////////////////// From 27b090005dfc601352fd366bb2bbab27b520a32d Mon Sep 17 00:00:00 2001 From: ghubstan <36207203+ghubstan@users.noreply.github.com> Date: Thu, 25 Mar 2021 18:46:41 -0300 Subject: [PATCH 02/21] Add cli side help for createcryptopaymentacct --- cli/src/main/java/bisq/cli/CliMain.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cli/src/main/java/bisq/cli/CliMain.java b/cli/src/main/java/bisq/cli/CliMain.java index 37bda937eb8..4b6568712b4 100644 --- a/cli/src/main/java/bisq/cli/CliMain.java +++ b/cli/src/main/java/bisq/cli/CliMain.java @@ -693,7 +693,7 @@ private static void printHelp(OptionParser parser, @SuppressWarnings("SameParame stream.println(); parser.printHelpOn(stream); stream.println(); - String rowFormat = "%-24s%-52s%s%n"; + String rowFormat = "%-25s%-52s%s%n"; stream.format(rowFormat, "Method", "Params", "Description"); stream.format(rowFormat, "------", "------", "------------"); stream.format(rowFormat, getversion.name(), "", "Get server version"); @@ -765,7 +765,9 @@ private static void printHelp(OptionParser parser, @SuppressWarnings("SameParame stream.println(); stream.format(rowFormat, createpaymentacct.name(), "--payment-account-form=", "Create a new payment account"); stream.println(); - stream.format(rowFormat, createcryptopaymentacct.name(), "--TODO=", "Create a new cryptocurrency payment account"); + stream.format(rowFormat, createcryptopaymentacct.name(), "--account-name= \\", "Create a new cryptocurrency payment account"); + stream.format(rowFormat, "", "--currency-code= \\", ""); + stream.format(rowFormat, "", "--address=", ""); stream.println(); stream.format(rowFormat, getpaymentaccts.name(), "", "Get user payment accounts"); stream.println(); From 9e035e5542f94b5298bc302b43bbc09460c319dd Mon Sep 17 00:00:00 2001 From: ghubstan <36207203+ghubstan@users.noreply.github.com> Date: Thu, 25 Mar 2021 19:23:06 -0300 Subject: [PATCH 03/21] Provide more offer & contract detail available to CLI. This change adds offer and trade contract detail to the API's Offer and Trade protos, and improves CLI output formatting. - Appended missing fields to OfferInfo proto message: uint64 sellerSecurityDeposit = 20; string offerFeePaymentTxId = 21; uint64 txFee = 22; uint64 makerFee = 23; - Added new api proto messages ContractInfo and PaymentAccountPayloadInfo. Lighterweight protos are needed because core Trade/Contract classes are not visible to CLI. - Appended ContractInfo field to api proto message TradeInfo. - Added proto / model converters for ContractInfo and PaymentAccountPayloadInfo, and adjusted OfferInfo & TradeInfo. - Improved CLI output formatting. Added more trade detail to CLI's gettrade output, and prepared to support BTC/BSQ trading pair. Note a reviewer is advised to look at the CLI outout formatting class files instead getting bogged down in the many commit changes. --- .../java/bisq/cli/ColumnHeaderConstants.java | 9 +- .../main/java/bisq/cli/CurrencyFormat.java | 46 ++++- .../main/java/bisq/cli/DirectionFormat.java | 60 +++++++ cli/src/main/java/bisq/cli/TableFormat.java | 166 +++++++++++++----- cli/src/main/java/bisq/cli/TradeFormat.java | 145 +++++++++------ .../bisq/core/api/model/ContractInfo.java | 126 +++++++++++++ .../java/bisq/core/api/model/OfferInfo.java | 52 +++++- .../core/api/model/PaymentAccountForm.java | 10 +- .../api/model/PaymentAccountPayloadInfo.java | 76 ++++++++ .../java/bisq/core/api/model/TradeInfo.java | 63 ++++++- .../main/java/bisq/core/api/model/TxInfo.java | 22 +-- proto/src/main/proto/grpc.proto | 26 +++ 12 files changed, 673 insertions(+), 128 deletions(-) create mode 100644 cli/src/main/java/bisq/cli/DirectionFormat.java create mode 100644 core/src/main/java/bisq/core/api/model/ContractInfo.java create mode 100644 core/src/main/java/bisq/core/api/model/PaymentAccountPayloadInfo.java diff --git a/cli/src/main/java/bisq/cli/ColumnHeaderConstants.java b/cli/src/main/java/bisq/cli/ColumnHeaderConstants.java index 79aff0d3f9d..0ad303dd64d 100644 --- a/cli/src/main/java/bisq/cli/ColumnHeaderConstants.java +++ b/cli/src/main/java/bisq/cli/ColumnHeaderConstants.java @@ -30,7 +30,7 @@ class ColumnHeaderConstants { // expected max data string length is accounted for. In others, column header // lengths are expected to be greater than any column value length. static final String COL_HEADER_ADDRESS = padEnd("%-3s Address", 52, ' '); - static final String COL_HEADER_AMOUNT = padEnd("BTC(min - max)", 24, ' '); + static final String COL_HEADER_AMOUNT = "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"; @@ -49,6 +49,7 @@ class ColumnHeaderConstants { static final String COL_HEADER_NAME = "Name"; static final String COL_HEADER_PAYMENT_METHOD = "Payment Method"; static final String COL_HEADER_PRICE = "Price in %-3s for 1 BTC"; + static final String COL_HEADER_PRICE_OF_ALTCOIN = "Price in BTC for 1 %-3s"; static final String COL_HEADER_TRADE_AMOUNT = padStart("Amount(%-3s)", 12, ' '); static final String COL_HEADER_TRADE_BUYER_COST = padEnd("Buyer Cost(%-3s)", 15, ' '); static final String COL_HEADER_TRADE_DEPOSIT_CONFIRMED = "Deposit Confirmed"; @@ -59,8 +60,9 @@ class ColumnHeaderConstants { static final String COL_HEADER_TRADE_WITHDRAWN = "Withdrawn"; static final String COL_HEADER_TRADE_ROLE = "My Role"; static final String COL_HEADER_TRADE_SHORT_ID = "ID"; - static final String COL_HEADER_TRADE_TX_FEE = "Tx Fee(%-3s)"; - static final String COL_HEADER_TRADE_TAKER_FEE = "Taker Fee(%-3s)"; + static final String COL_HEADER_TRADE_TX_FEE = padEnd("Tx Fee(BTC)", 12, ' '); + static final String COL_HEADER_TRADE_MAKER_FEE = padEnd("Maker Fee(%-3s)", 12, ' '); // "Maker Fee(%-3s)"; + static final String COL_HEADER_TRADE_TAKER_FEE = padEnd("Taker Fee(%-3s)", 12, ' '); // "Taker Fee(%-3s)"; static final String COL_HEADER_TX_ID = "Tx ID"; static final String COL_HEADER_TX_INPUT_SUM = "Tx Inputs (BTC)"; @@ -71,5 +73,6 @@ class ColumnHeaderConstants { static final String COL_HEADER_TX_MEMO = "Memo"; static final String COL_HEADER_VOLUME = padEnd("%-3s(min - max)", 15, ' '); + static final String COL_HEADER_UUID = padEnd("ID", 52, ' '); } diff --git a/cli/src/main/java/bisq/cli/CurrencyFormat.java b/cli/src/main/java/bisq/cli/CurrencyFormat.java index afa97b34300..9bada381a12 100644 --- a/cli/src/main/java/bisq/cli/CurrencyFormat.java +++ b/cli/src/main/java/bisq/cli/CurrencyFormat.java @@ -25,23 +25,25 @@ import java.text.NumberFormat; import java.math.BigDecimal; -import java.math.RoundingMode; import java.util.Locale; import static java.lang.String.format; +import static java.math.RoundingMode.HALF_UP; +import static java.math.RoundingMode.UNNECESSARY; @VisibleForTesting public class CurrencyFormat { private static final NumberFormat NUMBER_FORMAT = NumberFormat.getInstance(Locale.US); - static final BigDecimal SATOSHI_DIVISOR = new BigDecimal(100000000); + static final BigDecimal SATOSHI_DIVISOR = new BigDecimal(100_000_000); static final DecimalFormat BTC_FORMAT = new DecimalFormat("###,##0.00000000"); static final DecimalFormat BTC_TX_FEE_FORMAT = new DecimalFormat("###,###,##0"); static final BigDecimal BSQ_SATOSHI_DIVISOR = new BigDecimal(100); static final DecimalFormat BSQ_FORMAT = new DecimalFormat("###,###,###,##0.00"); + static final DecimalFormat SEND_BSQ_FORMAT = new DecimalFormat("###########0.00"); static final BigDecimal SECURITY_DEPOSIT_MULTIPLICAND = new BigDecimal("0.01"); @@ -55,6 +57,14 @@ public static String formatBsq(long sats) { return BSQ_FORMAT.format(BigDecimal.valueOf(sats).divide(BSQ_SATOSHI_DIVISOR)); } + public static String formatBsqSendAmount(long bsqSats) { + // BSQ sats = trade.getOffer().getVolume() + NUMBER_FORMAT.setMinimumFractionDigits(2); + NUMBER_FORMAT.setMaximumFractionDigits(2); + NUMBER_FORMAT.setRoundingMode(HALF_UP); + return SEND_BSQ_FORMAT.format((double) bsqSats / SATOSHI_DIVISOR.doubleValue()); + } + public static String formatTxFeeRateInfo(TxFeeRateInfo txFeeRateInfo) { if (txFeeRateInfo.getUseCustomTxFeeRate()) return format("custom tx fee rate: %s sats/byte, network rate: %s sats/byte", @@ -77,22 +87,44 @@ public static String formatVolumeRange(long minVolume, long volume) { : formatOfferVolume(volume); } + public static String formatCryptoCurrencyVolumeRange(long minVolume, long volume) { + return minVolume != volume + ? formatCryptoCurrencyOfferVolume(minVolume) + " - " + formatCryptoCurrencyOfferVolume(volume) + : formatCryptoCurrencyOfferVolume(volume); + } + public static String formatMarketPrice(double price) { NUMBER_FORMAT.setMinimumFractionDigits(4); + NUMBER_FORMAT.setMaximumFractionDigits(4); return NUMBER_FORMAT.format(price); } public static String formatOfferPrice(long price) { - NUMBER_FORMAT.setMaximumFractionDigits(4); NUMBER_FORMAT.setMinimumFractionDigits(4); - NUMBER_FORMAT.setRoundingMode(RoundingMode.UNNECESSARY); - return NUMBER_FORMAT.format((double) price / 10000); + NUMBER_FORMAT.setMaximumFractionDigits(4); + NUMBER_FORMAT.setRoundingMode(UNNECESSARY); + return NUMBER_FORMAT.format((double) price / 10_000); + } + + public static String formatCryptoCurrencyOfferPrice(long price) { + NUMBER_FORMAT.setMinimumFractionDigits(8); + NUMBER_FORMAT.setMaximumFractionDigits(8); + NUMBER_FORMAT.setRoundingMode(UNNECESSARY); + return NUMBER_FORMAT.format((double) price / SATOSHI_DIVISOR.doubleValue()); } public static String formatOfferVolume(long volume) { + NUMBER_FORMAT.setMinimumFractionDigits(0); NUMBER_FORMAT.setMaximumFractionDigits(0); - NUMBER_FORMAT.setRoundingMode(RoundingMode.UNNECESSARY); - return NUMBER_FORMAT.format((double) volume / 10000); + NUMBER_FORMAT.setRoundingMode(HALF_UP); + return NUMBER_FORMAT.format((double) volume / 10_000); + } + + public static String formatCryptoCurrencyOfferVolume(long volume) { + NUMBER_FORMAT.setMinimumFractionDigits(2); + NUMBER_FORMAT.setMaximumFractionDigits(2); + NUMBER_FORMAT.setRoundingMode(HALF_UP); + return NUMBER_FORMAT.format((double) volume / SATOSHI_DIVISOR.doubleValue()); } public static long toSatoshis(String btc) { diff --git a/cli/src/main/java/bisq/cli/DirectionFormat.java b/cli/src/main/java/bisq/cli/DirectionFormat.java new file mode 100644 index 00000000000..ac0e5b6c556 --- /dev/null +++ b/cli/src/main/java/bisq/cli/DirectionFormat.java @@ -0,0 +1,60 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.cli; + +import bisq.proto.grpc.OfferInfo; + +import java.util.List; +import java.util.function.Function; + +import static bisq.cli.ColumnHeaderConstants.COL_HEADER_DIRECTION; +import static java.lang.String.format; +import static protobuf.OfferPayload.Direction.BUY; +import static protobuf.OfferPayload.Direction.SELL; + +class DirectionFormat { + + static int getLongestDirectionColWidth(List offers) { + if (offers.isEmpty() || offers.get(0).getBaseCurrencyCode().equals("BTC")) + return COL_HEADER_DIRECTION.length(); + else + return 18; // .e.g., "Sell BSQ (Buy BTC)".length() + } + + static final Function directionFormat = (offer) -> { + String baseCurrencyCode = offer.getBaseCurrencyCode(); + boolean isCryptoCurrencyOffer = !baseCurrencyCode.equals("BTC"); + if (!isCryptoCurrencyOffer) { + return baseCurrencyCode; + } else { + // Return "Sell BSQ (Buy BTC)", or "Buy BSQ (Sell BTC)". + String direction = offer.getDirection(); + String mirroredDirection = getMirroredDirection(direction); + Function mixedCase = (word) -> word.charAt(0) + word.substring(1).toLowerCase(); + return format("%s %s (%s %s)", + mixedCase.apply(mirroredDirection), + baseCurrencyCode, + mixedCase.apply(direction), + offer.getCounterCurrencyCode()); + } + }; + + static String getMirroredDirection(String directionAsString) { + return directionAsString.equalsIgnoreCase(BUY.name()) ? SELL.name() : BUY.name(); + } +} diff --git a/cli/src/main/java/bisq/cli/TableFormat.java b/cli/src/main/java/bisq/cli/TableFormat.java index 112de0e7d74..fbc99459375 100644 --- a/cli/src/main/java/bisq/cli/TableFormat.java +++ b/cli/src/main/java/bisq/cli/TableFormat.java @@ -36,7 +36,10 @@ import static bisq.cli.ColumnHeaderConstants.*; import static bisq.cli.CurrencyFormat.*; +import static bisq.cli.DirectionFormat.directionFormat; +import static bisq.cli.DirectionFormat.getLongestDirectionColWidth; import static com.google.common.base.Strings.padEnd; +import static com.google.common.base.Strings.padStart; import static java.lang.String.format; import static java.util.Collections.max; import static java.util.Comparator.comparing; @@ -114,31 +117,69 @@ public static String formatBtcBalanceInfoTbl(BtcBalanceInfo btcBalanceInfo) { formatSatoshis(btcBalanceInfo.getLockedBalance())); } - public static String formatOfferTable(List offerInfo, String fiatCurrency) { + public static String formatPaymentAcctTbl(List paymentAccounts) { // Some column values might be longer than header, so we need to calculate them. - int paymentMethodColWidth = getLengthOfLongestColumn( + int nameColWidth = getLongestColumnSize( + COL_HEADER_NAME.length(), + paymentAccounts.stream().map(PaymentAccount::getAccountName) + .collect(Collectors.toList())); + int paymentMethodColWidth = getLongestColumnSize( COL_HEADER_PAYMENT_METHOD.length(), - offerInfo.stream() - .map(OfferInfo::getPaymentMethodShortName) + paymentAccounts.stream().map(a -> a.getPaymentMethod().getId()) .collect(Collectors.toList())); + String headerLine = padEnd(COL_HEADER_NAME, nameColWidth, ' ') + COL_HEADER_DELIMITER + + COL_HEADER_CURRENCY + COL_HEADER_DELIMITER + + padEnd(COL_HEADER_PAYMENT_METHOD, paymentMethodColWidth, ' ') + COL_HEADER_DELIMITER + + COL_HEADER_UUID + COL_HEADER_DELIMITER + "\n"; + String colDataFormat = "%-" + nameColWidth + "s" // left justify + + " %-" + COL_HEADER_CURRENCY.length() + "s" // left justify + + " %-" + paymentMethodColWidth + "s" // left justify + + " %-" + COL_HEADER_UUID.length() + "s"; // left justify + return headerLine + + paymentAccounts.stream() + .map(a -> format(colDataFormat, + a.getAccountName(), + a.getSelectedTradeCurrency().getCode(), + a.getPaymentMethod().getId(), + a.getId())) + .collect(Collectors.joining("\n")); + } + + public static String formatOfferTable(List offers, String currencyCode) { + if (offers == null || offers.isEmpty()) + throw new IllegalArgumentException(format("%s offers argument is empty", currencyCode.toLowerCase())); + + String baseCurrencyCode = offers.get(0).getBaseCurrencyCode(); + return baseCurrencyCode.equalsIgnoreCase("BTC") + ? formatFiatOfferTable(offers, currencyCode) + : formatCryptoCurrencyOfferTable(offers, baseCurrencyCode); + } + + private static String formatFiatOfferTable(List offers, String fiatCurrencyCode) { + // Some column values might be longer than header, so we need to calculate them. + int amountColWith = getLongestAmountColWidth(offers); + int volumeColWidth = getLongestVolumeColWidth(offers); + int paymentMethodColWidth = getLongestPaymentMethodColWidth(offers); String headersFormat = COL_HEADER_DIRECTION + COL_HEADER_DELIMITER - + COL_HEADER_PRICE + COL_HEADER_DELIMITER // includes %s -> fiatCurrency - + COL_HEADER_AMOUNT + COL_HEADER_DELIMITER - + COL_HEADER_VOLUME + COL_HEADER_DELIMITER // includes %s -> fiatCurrency + + COL_HEADER_PRICE + COL_HEADER_DELIMITER // includes %s -> fiatCurrencyCode + + padStart(COL_HEADER_AMOUNT, amountColWith, ' ') + COL_HEADER_DELIMITER + // COL_HEADER_VOLUME includes %s -> fiatCurrencyCode + + padStart(COL_HEADER_VOLUME, volumeColWidth, ' ') + COL_HEADER_DELIMITER + padEnd(COL_HEADER_PAYMENT_METHOD, paymentMethodColWidth, ' ') + COL_HEADER_DELIMITER + COL_HEADER_CREATION_DATE + COL_HEADER_DELIMITER + COL_HEADER_UUID.trim() + "%n"; - String headerLine = format(headersFormat, fiatCurrency.toUpperCase(), fiatCurrency.toUpperCase()); - - String colDataFormat = "%-" + (COL_HEADER_DIRECTION.length() + COL_HEADER_DELIMITER.length()) + "s" // left - + "%" + (COL_HEADER_PRICE.length() - 1) + "s" // rt justify to end of hdr - + " %-" + (COL_HEADER_AMOUNT.length() - 1) + "s" // left justify - + " %" + COL_HEADER_VOLUME.length() + "s" // right justify - + " %-" + paymentMethodColWidth + "s" // left justify - + " %-" + (COL_HEADER_CREATION_DATE.length()) + "s" // left justify + String headerLine = format(headersFormat, + fiatCurrencyCode.toUpperCase(), + fiatCurrencyCode.toUpperCase()); + String colDataFormat = "%-" + (COL_HEADER_DIRECTION.length() + COL_HEADER_DELIMITER.length()) + "s" + + "%" + (COL_HEADER_PRICE.length() - 1) + "s" + + " %" + amountColWith + "s" + + " %" + (volumeColWidth - 1) + "s" + + " %-" + paymentMethodColWidth + "s" + + " %-" + (COL_HEADER_CREATION_DATE.length()) + "s" + " %-" + COL_HEADER_UUID.length() + "s"; return headerLine - + offerInfo.stream() + + offers.stream() .map(o -> format(colDataFormat, o.getDirection(), formatOfferPrice(o.getPrice()), @@ -150,37 +191,80 @@ public static String formatOfferTable(List offerInfo, String fiatCurr .collect(Collectors.joining("\n")); } - public static String formatPaymentAcctTbl(List paymentAccounts) { + private static String formatCryptoCurrencyOfferTable(List offers, String cryptoCurrencyCode) { // Some column values might be longer than header, so we need to calculate them. - int nameColWidth = getLengthOfLongestColumn( - COL_HEADER_NAME.length(), - paymentAccounts.stream().map(PaymentAccount::getAccountName) - .collect(Collectors.toList())); - int paymentMethodColWidth = getLengthOfLongestColumn( - COL_HEADER_PAYMENT_METHOD.length(), - paymentAccounts.stream().map(a -> a.getPaymentMethod().getId()) - .collect(Collectors.toList())); - - String headerLine = padEnd(COL_HEADER_NAME, nameColWidth, ' ') + COL_HEADER_DELIMITER - + COL_HEADER_CURRENCY + COL_HEADER_DELIMITER + int directionColWidth = getLongestDirectionColWidth(offers); + int amountColWith = getLongestAmountColWidth(offers); + int volumeColWidth = getLongestCryptoCurrencyVolumeColWidth(offers); + int paymentMethodColWidth = getLongestPaymentMethodColWidth(offers); + // TODO use memoize function to avoid duplicate the formatting done above? + String headersFormat = padEnd(COL_HEADER_DIRECTION, directionColWidth, ' ') + COL_HEADER_DELIMITER + + COL_HEADER_PRICE_OF_ALTCOIN + COL_HEADER_DELIMITER // includes %s -> cryptoCurrencyCode + + padStart(COL_HEADER_AMOUNT, amountColWith, ' ') + COL_HEADER_DELIMITER + // COL_HEADER_VOLUME includes %s -> cryptoCurrencyCode + + padStart(COL_HEADER_VOLUME, volumeColWidth, ' ') + COL_HEADER_DELIMITER + padEnd(COL_HEADER_PAYMENT_METHOD, paymentMethodColWidth, ' ') + COL_HEADER_DELIMITER - + COL_HEADER_UUID + COL_HEADER_DELIMITER + "\n"; - String colDataFormat = "%-" + nameColWidth + "s" // left justify - + " %-" + COL_HEADER_CURRENCY.length() + "s" // left justify - + " %-" + paymentMethodColWidth + "s" // left justify - + " %-" + COL_HEADER_UUID.length() + "s"; // left justify + + COL_HEADER_CREATION_DATE + COL_HEADER_DELIMITER + + COL_HEADER_UUID.trim() + "%n"; + String headerLine = format(headersFormat, + cryptoCurrencyCode.toUpperCase(), + cryptoCurrencyCode.toUpperCase()); + String colDataFormat = "%-" + directionColWidth + "s" + + "%" + (COL_HEADER_PRICE_OF_ALTCOIN.length() + 1) + "s" + + " %" + amountColWith + "s" + + " %" + (volumeColWidth - 1) + "s" + + " %-" + paymentMethodColWidth + "s" + + " %-" + (COL_HEADER_CREATION_DATE.length()) + "s" + + " %-" + COL_HEADER_UUID.length() + "s"; return headerLine - + paymentAccounts.stream() - .map(a -> format(colDataFormat, - a.getAccountName(), - a.getSelectedTradeCurrency().getCode(), - a.getPaymentMethod().getId(), - a.getId())) + + offers.stream() + .map(o -> format(colDataFormat, + directionFormat.apply(o), + formatCryptoCurrencyOfferPrice(o.getPrice()), + formatAmountRange(o.getMinAmount(), o.getAmount()), + formatCryptoCurrencyVolumeRange(o.getMinVolume(), o.getVolume()), + o.getPaymentMethodShortName(), + formatTimestamp(o.getDate()), + o.getId())) .collect(Collectors.joining("\n")); } - // Return length of the longest string value, or the header.len, whichever is greater. - private static int getLengthOfLongestColumn(int headerLength, List strings) { + private static int getLongestPaymentMethodColWidth(List offers) { + return getLongestColumnSize( + COL_HEADER_PAYMENT_METHOD.length(), + offers.stream() + .map(OfferInfo::getPaymentMethodShortName) + .collect(Collectors.toList())); + } + + private static int getLongestAmountColWidth(List offers) { + return getLongestColumnSize( + COL_HEADER_AMOUNT.length(), + offers.stream() + .map(o -> formatAmountRange(o.getMinAmount(), o.getAmount())) + .collect(Collectors.toList())); + } + + private static int getLongestVolumeColWidth(List offers) { + // Pad this col width by 1 space. + return 1 + getLongestColumnSize( + COL_HEADER_VOLUME.length(), + offers.stream() + .map(o -> formatVolumeRange(o.getMinVolume(), o.getVolume())) + .collect(Collectors.toList())); + } + + private static int getLongestCryptoCurrencyVolumeColWidth(List offers) { + // Pad this col width by 1 space. + return 1 + getLongestColumnSize( + COL_HEADER_VOLUME.length(), + offers.stream() + .map(o -> formatCryptoCurrencyVolumeRange(o.getMinVolume(), o.getVolume())) + .collect(Collectors.toList())); + } + + // Return size of the longest string value, or the header.len, whichever is greater. + private static int getLongestColumnSize(int headerLength, List strings) { int longest = max(strings, comparing(String::length)).length(); return Math.max(longest, headerLength); } diff --git a/cli/src/main/java/bisq/cli/TradeFormat.java b/cli/src/main/java/bisq/cli/TradeFormat.java index 668f9603552..4ca1a20d1ab 100644 --- a/cli/src/main/java/bisq/cli/TradeFormat.java +++ b/cli/src/main/java/bisq/cli/TradeFormat.java @@ -21,17 +21,20 @@ import com.google.common.annotations.VisibleForTesting; +import java.util.function.BiFunction; +import java.util.function.Function; import java.util.function.Supplier; import static bisq.cli.ColumnHeaderConstants.*; -import static bisq.cli.CurrencyFormat.formatOfferPrice; -import static bisq.cli.CurrencyFormat.formatOfferVolume; -import static bisq.cli.CurrencyFormat.formatSatoshis; +import static bisq.cli.CurrencyFormat.*; import static com.google.common.base.Strings.padEnd; @VisibleForTesting public class TradeFormat { + private static final String YES = "YES"; + private static final String NO = "NO"; + @VisibleForTesting public static String format(TradeInfo tradeInfo) { // Some column values might be longer than header, so we need to calculate them. @@ -40,19 +43,27 @@ public static String format(TradeInfo tradeInfo) { // We only show taker fee under its header when user is the taker. boolean isTaker = tradeInfo.getRole().toLowerCase().contains("taker"); - Supplier takerFeeHeaderFormat = () -> isTaker ? - padEnd(COL_HEADER_TRADE_TAKER_FEE, 12, ' ') + COL_HEADER_DELIMITER + Supplier makerFeeHeader = () -> !isTaker ? + COL_HEADER_TRADE_MAKER_FEE + COL_HEADER_DELIMITER + : ""; + Supplier makerFeeHeaderSpec = () -> !isTaker ? + "%" + (COL_HEADER_TRADE_MAKER_FEE.length() + 2) + "s" : ""; Supplier takerFeeHeader = () -> isTaker ? - "%" + (COL_HEADER_TRADE_TAKER_FEE.length() + 1) + "s" + COL_HEADER_TRADE_TAKER_FEE + COL_HEADER_DELIMITER + : ""; + Supplier takerFeeHeaderSpec = () -> isTaker ? + "%" + (COL_HEADER_TRADE_TAKER_FEE.length() + 2) + "s" : ""; String headersFormat = padEnd(COL_HEADER_TRADE_SHORT_ID, shortIdColWidth, ' ') + COL_HEADER_DELIMITER + padEnd(COL_HEADER_TRADE_ROLE, roleColWidth, ' ') + COL_HEADER_DELIMITER - + COL_HEADER_PRICE + COL_HEADER_DELIMITER // includes %s -> currencyCode + + priceHeader.apply(tradeInfo) + COL_HEADER_DELIMITER // includes %s -> currencyCode + padEnd(COL_HEADER_TRADE_AMOUNT, 12, ' ') + COL_HEADER_DELIMITER + padEnd(COL_HEADER_TRADE_TX_FEE, 12, ' ') + COL_HEADER_DELIMITER - + takerFeeHeaderFormat.get() + + makerFeeHeader.get() + // maker or taker fee header, not both + + takerFeeHeader.get() + COL_HEADER_TRADE_DEPOSIT_PUBLISHED + COL_HEADER_DELIMITER + COL_HEADER_TRADE_DEPOSIT_CONFIRMED + COL_HEADER_DELIMITER + COL_HEADER_TRADE_BUYER_COST + COL_HEADER_DELIMITER @@ -65,30 +76,22 @@ public static String format(TradeInfo tradeInfo) { String counterCurrencyCode = tradeInfo.getOffer().getCounterCurrencyCode(); String baseCurrencyCode = tradeInfo.getOffer().getBaseCurrencyCode(); - // The taker's output contains an extra taker tx fee column. - String headerLine = isTaker - ? String.format(headersFormat, - /* COL_HEADER_PRICE */ counterCurrencyCode, - /* COL_HEADER_TRADE_AMOUNT */ baseCurrencyCode, - /* COL_HEADER_TRADE_TX_FEE */ baseCurrencyCode, - /* COL_HEADER_TRADE_TAKER_FEE */ baseCurrencyCode, - /* COL_HEADER_TRADE_BUYER_COST */ counterCurrencyCode, - /* COL_HEADER_TRADE_PAYMENT_SENT */ counterCurrencyCode, - /* COL_HEADER_TRADE_PAYMENT_RECEIVED */ counterCurrencyCode) - : String.format(headersFormat, - /* COL_HEADER_PRICE */ counterCurrencyCode, + String headerLine = String.format(headersFormat, + /* COL_HEADER_PRICE */ priceHeaderCurrencyCode.apply(tradeInfo), /* COL_HEADER_TRADE_AMOUNT */ baseCurrencyCode, - /* COL_HEADER_TRADE_TX_FEE */ baseCurrencyCode, + /* COL_HEADER_TRADE_(M||T)AKER_FEE */ makerTakerFeeHeaderCurrencyCode.apply(tradeInfo), /* COL_HEADER_TRADE_BUYER_COST */ counterCurrencyCode, - /* COL_HEADER_TRADE_PAYMENT_SENT */ counterCurrencyCode, - /* COL_HEADER_TRADE_PAYMENT_RECEIVED */ counterCurrencyCode); + /* COL_HEADER_TRADE_PAYMENT_SENT */ paymentStatusHeaderCurrencyCode.apply(tradeInfo), + /* COL_HEADER_TRADE_PAYMENT_RECEIVED */ paymentStatusHeaderCurrencyCode.apply(tradeInfo)); String colDataFormat = "%-" + shortIdColWidth + "s" // lt justify + " %-" + (roleColWidth + COL_HEADER_DELIMITER.length()) + "s" // left + "%" + (COL_HEADER_PRICE.length() - 1) + "s" // rt justify + "%" + (COL_HEADER_TRADE_AMOUNT.length() + 1) + "s" // rt justify + "%" + (COL_HEADER_TRADE_TX_FEE.length() + 1) + "s" // rt justify - + takerFeeHeader.get() // rt justify + + makerFeeHeaderSpec.get() // rt justify + // OR (one of them is an empty string) + + takerFeeHeaderSpec.get() // rt justify + " %-" + COL_HEADER_TRADE_DEPOSIT_PUBLISHED.length() + "s" // lt justify + " %-" + COL_HEADER_TRADE_DEPOSIT_CONFIRMED.length() + "s" // lt justify + "%" + (COL_HEADER_TRADE_BUYER_COST.length() + 1) + "s" // rt justify @@ -97,42 +100,74 @@ public static String format(TradeInfo tradeInfo) { + " %-" + COL_HEADER_TRADE_PAYOUT_PUBLISHED.length() + "s" // lt justify + " %-" + COL_HEADER_TRADE_WITHDRAWN.length() + "s"; // lt justify - return headerLine + - (isTaker - ? formatTradeForTaker(colDataFormat, tradeInfo) - : formatTradeForMaker(colDataFormat, tradeInfo)); + return headerLine + formatTradeData(colDataFormat, tradeInfo, isTaker); } - private static String formatTradeForMaker(String format, TradeInfo tradeInfo) { + private static String formatTradeData(String format, + TradeInfo tradeInfo, + boolean isTaker) { return String.format(format, tradeInfo.getShortId(), tradeInfo.getRole(), - formatOfferPrice(tradeInfo.getTradePrice()), - formatSatoshis(tradeInfo.getTradeAmountAsLong()), - formatSatoshis(tradeInfo.getTxFeeAsLong()), - tradeInfo.getIsDepositPublished() ? "YES" : "NO", - tradeInfo.getIsDepositConfirmed() ? "YES" : "NO", - formatOfferVolume(tradeInfo.getOffer().getVolume()), - tradeInfo.getIsFiatSent() ? "YES" : "NO", - tradeInfo.getIsFiatReceived() ? "YES" : "NO", - tradeInfo.getIsPayoutPublished() ? "YES" : "NO", - tradeInfo.getIsWithdrawn() ? "YES" : "NO"); + priceFormat.apply(tradeInfo), + amountFormat.apply(tradeInfo), + makerTakerMinerTxFeeFormat.apply(tradeInfo, isTaker), + makerTakerFeeFormat.apply(tradeInfo, isTaker), + tradeInfo.getIsDepositPublished() ? YES : NO, + tradeInfo.getIsDepositConfirmed() ? YES : NO, + tradeCostFormat.apply(tradeInfo), + tradeInfo.getIsFiatSent() ? YES : NO, + tradeInfo.getIsFiatReceived() ? YES : NO, + tradeInfo.getIsPayoutPublished() ? YES : NO, + tradeInfo.getIsWithdrawn() ? YES : NO); } - private static String formatTradeForTaker(String format, TradeInfo tradeInfo) { - return String.format(format, - tradeInfo.getShortId(), - tradeInfo.getRole(), - formatOfferPrice(tradeInfo.getTradePrice()), - formatSatoshis(tradeInfo.getTradeAmountAsLong()), - formatSatoshis(tradeInfo.getTxFeeAsLong()), - formatSatoshis(tradeInfo.getTakerFeeAsLong()), - tradeInfo.getIsDepositPublished() ? "YES" : "NO", - tradeInfo.getIsDepositConfirmed() ? "YES" : "NO", - formatOfferVolume(tradeInfo.getOffer().getVolume()), - tradeInfo.getIsFiatSent() ? "YES" : "NO", - tradeInfo.getIsFiatReceived() ? "YES" : "NO", - tradeInfo.getIsPayoutPublished() ? "YES" : "NO", - tradeInfo.getIsWithdrawn() ? "YES" : "NO"); - } + private static final Function priceHeader = (t) -> + t.getOffer().getBaseCurrencyCode().equals("BTC") + ? COL_HEADER_PRICE + : COL_HEADER_PRICE_OF_ALTCOIN; + + private static final Function priceHeaderCurrencyCode = (t) -> + t.getOffer().getBaseCurrencyCode().equals("BTC") + ? t.getOffer().getCounterCurrencyCode() + : t.getOffer().getBaseCurrencyCode(); + + private static final Function makerTakerFeeHeaderCurrencyCode = (t) -> + t.getIsCurrencyForTakerFeeBtc() ? "BTC" : "BSQ"; + + private static final Function paymentStatusHeaderCurrencyCode = (t) -> + t.getOffer().getBaseCurrencyCode().equals("BTC") + ? t.getOffer().getCounterCurrencyCode() + : t.getOffer().getBaseCurrencyCode(); + + private static final Function priceFormat = (t) -> + t.getOffer().getBaseCurrencyCode().equals("BTC") + ? formatOfferPrice(t.getTradePrice()) + : formatCryptoCurrencyOfferPrice(t.getOffer().getPrice()); + + private static final Function amountFormat = (t) -> + t.getOffer().getBaseCurrencyCode().equals("BTC") + ? formatSatoshis(t.getTradeAmountAsLong()) + : formatCryptoCurrencyOfferVolume(t.getOffer().getVolume()); + + private static final BiFunction makerTakerMinerTxFeeFormat = (t, isTaker) -> { + if (isTaker) { + return formatSatoshis(t.getTxFeeAsLong()); + } else { + return formatSatoshis(t.getOffer().getTxFee()); + } + }; + + private static final BiFunction makerTakerFeeFormat = (t, isTaker) -> { + if (isTaker) + return t.getIsCurrencyForTakerFeeBtc() ? formatSatoshis(t.getTakerFeeAsLong()) : formatBsq(t.getTakerFeeAsLong()); + else + return t.getIsCurrencyForTakerFeeBtc() ? formatSatoshis(t.getOffer().getMakerFee()) : formatBsq(t.getOffer().getMakerFee()); + + }; + + private static final Function tradeCostFormat = (t) -> + t.getOffer().getBaseCurrencyCode().equals("BTC") + ? formatOfferVolume(t.getOffer().getVolume()) + : formatSatoshis(t.getTradeAmountAsLong()); } diff --git a/core/src/main/java/bisq/core/api/model/ContractInfo.java b/core/src/main/java/bisq/core/api/model/ContractInfo.java new file mode 100644 index 00000000000..404335c9c7f --- /dev/null +++ b/core/src/main/java/bisq/core/api/model/ContractInfo.java @@ -0,0 +1,126 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.api.model; + +import bisq.common.Payload; + +import java.util.function.Supplier; + +import lombok.Getter; + +import static bisq.core.api.model.PaymentAccountPayloadInfo.emptyPaymentAccountPayload; + +/** + * A lightweight Trade Contract constructed from a trade's json contract. + * Many fields in the core Contract are ignored, but can be added as needed. + */ +@Getter +public class ContractInfo implements Payload { + + private final String buyerNodeAddress; + private final String sellerNodeAddress; + private final String mediatorNodeAddress; + private final String refundAgentNodeAddress; + private final boolean isBuyerMakerAndSellerTaker; + private final String makerAccountId; + private final String takerAccountId; + private final PaymentAccountPayloadInfo makerPaymentAccountPayload; + private final PaymentAccountPayloadInfo takerPaymentAccountPayload; + private final String makerPayoutAddressString; + private final String takerPayoutAddressString; + private final long lockTime; + + public ContractInfo(String buyerNodeAddress, + String sellerNodeAddress, + String mediatorNodeAddress, + String refundAgentNodeAddress, + boolean isBuyerMakerAndSellerTaker, + String makerAccountId, + String takerAccountId, + PaymentAccountPayloadInfo makerPaymentAccountPayload, + PaymentAccountPayloadInfo takerPaymentAccountPayload, + String makerPayoutAddressString, + String takerPayoutAddressString, + long lockTime) { + this.buyerNodeAddress = buyerNodeAddress; + this.sellerNodeAddress = sellerNodeAddress; + this.mediatorNodeAddress = mediatorNodeAddress; + this.refundAgentNodeAddress = refundAgentNodeAddress; + this.isBuyerMakerAndSellerTaker = isBuyerMakerAndSellerTaker; + this.makerAccountId = makerAccountId; + this.takerAccountId = takerAccountId; + this.makerPaymentAccountPayload = makerPaymentAccountPayload; + this.takerPaymentAccountPayload = takerPaymentAccountPayload; + this.makerPayoutAddressString = makerPayoutAddressString; + this.takerPayoutAddressString = takerPayoutAddressString; + this.lockTime = lockTime; + } + + + // For transmitting TradeInfo messages when no contract is available. + public static Supplier emptyContract = () -> + new ContractInfo("", + "", + "", + "", + false, + "", + "", + emptyPaymentAccountPayload.get(), + emptyPaymentAccountPayload.get(), + "", + "", + 0); + + /////////////////////////////////////////////////////////////////////////////////////////// + // PROTO BUFFER + /////////////////////////////////////////////////////////////////////////////////////////// + + public static ContractInfo fromProto(bisq.proto.grpc.ContractInfo proto) { + return new ContractInfo(proto.getBuyerNodeAddress(), + proto.getSellerNodeAddress(), + proto.getMediatorNodeAddress(), + proto.getRefundAgentNodeAddress(), + proto.getIsBuyerMakerAndSellerTaker(), + proto.getMakerAccountId(), + proto.getTakerAccountId(), + PaymentAccountPayloadInfo.fromProto(proto.getMakerPaymentAccountPayload()), + PaymentAccountPayloadInfo.fromProto(proto.getTakerPaymentAccountPayload()), + proto.getMakerPayoutAddressString(), + proto.getTakerPayoutAddressString(), + proto.getLockTime()); + } + + @Override + public bisq.proto.grpc.ContractInfo toProtoMessage() { + return bisq.proto.grpc.ContractInfo.newBuilder() + .setBuyerNodeAddress(buyerNodeAddress) + .setSellerNodeAddress(sellerNodeAddress) + .setMediatorNodeAddress(mediatorNodeAddress) + .setRefundAgentNodeAddress(refundAgentNodeAddress) + .setIsBuyerMakerAndSellerTaker(isBuyerMakerAndSellerTaker) + .setMakerAccountId(makerAccountId) + .setTakerAccountId(takerAccountId) + .setMakerPaymentAccountPayload(makerPaymentAccountPayload.toProtoMessage()) + .setTakerPaymentAccountPayload(takerPaymentAccountPayload.toProtoMessage()) + .setMakerPayoutAddressString(makerPayoutAddressString) + .setTakerPayoutAddressString(takerPayoutAddressString) + .setLockTime(lockTime) + .build(); + } +} diff --git a/core/src/main/java/bisq/core/api/model/OfferInfo.java b/core/src/main/java/bisq/core/api/model/OfferInfo.java index 2314cb43086..f8501f7df1f 100644 --- a/core/src/main/java/bisq/core/api/model/OfferInfo.java +++ b/core/src/main/java/bisq/core/api/model/OfferInfo.java @@ -45,7 +45,11 @@ public class OfferInfo implements Payload { private final long minAmount; private final long volume; private final long minVolume; + private final long txFee; + private final long makerFee; + private final String offerFeePaymentTxId; private final long buyerSecurityDeposit; + private final long sellerSecurityDeposit; private final long triggerPrice; private final boolean isCurrencyForMakerFeeBtc; private final String paymentAccountId; @@ -58,6 +62,7 @@ public class OfferInfo implements Payload { private final long date; private final String state; + public OfferInfo(OfferInfoBuilder builder) { this.id = builder.id; this.direction = builder.direction; @@ -68,7 +73,11 @@ public OfferInfo(OfferInfoBuilder builder) { this.minAmount = builder.minAmount; this.volume = builder.volume; this.minVolume = builder.minVolume; + this.txFee = builder.txFee; + this.makerFee = builder.makerFee; + this.offerFeePaymentTxId = builder.offerFeePaymentTxId; this.buyerSecurityDeposit = builder.buyerSecurityDeposit; + this.sellerSecurityDeposit = builder.sellerSecurityDeposit; this.triggerPrice = builder.triggerPrice; this.isCurrencyForMakerFeeBtc = builder.isCurrencyForMakerFeeBtc; this.paymentAccountId = builder.paymentAccountId; @@ -78,6 +87,7 @@ public OfferInfo(OfferInfoBuilder builder) { this.counterCurrencyCode = builder.counterCurrencyCode; this.date = builder.date; this.state = builder.state; + } public static OfferInfo toOfferInfo(Offer offer) { @@ -90,8 +100,8 @@ public static OfferInfo toOfferInfo(Offer offer, long triggerPrice) { return getOfferInfoBuilder(offer).withTriggerPrice(triggerPrice).build(); } - private static OfferInfo.OfferInfoBuilder getOfferInfoBuilder(Offer offer) { - return new OfferInfo.OfferInfoBuilder() + private static OfferInfoBuilder getOfferInfoBuilder(Offer offer) { + return new OfferInfoBuilder() .withId(offer.getId()) .withDirection(offer.getDirection().name()) .withPrice(Objects.requireNonNull(offer.getPrice()).getValue()) @@ -101,7 +111,11 @@ private static OfferInfo.OfferInfoBuilder getOfferInfoBuilder(Offer offer) { .withMinAmount(offer.getMinAmount().value) .withVolume(Objects.requireNonNull(offer.getVolume()).getValue()) .withMinVolume(Objects.requireNonNull(offer.getMinVolume()).getValue()) + .withMakerFee(offer.getMakerFee().value) + .withTxFee(offer.getTxFee().value) + .withOfferFeePaymentTxId(offer.getOfferFeePaymentTxId()) .withBuyerSecurityDeposit(offer.getBuyerSecurityDeposit().value) + .withSellerSecurityDeposit(offer.getSellerSecurityDeposit().value) .withIsCurrencyForMakerFeeBtc(offer.isCurrencyForMakerFeeBtc()) .withPaymentAccountId(offer.getMakerPaymentAccountId()) .withPaymentMethodId(offer.getPaymentMethod().getId()) @@ -128,7 +142,11 @@ public bisq.proto.grpc.OfferInfo toProtoMessage() { .setMinAmount(minAmount) .setVolume(volume) .setMinVolume(minVolume) + .setMakerFee(makerFee) + .setTxFee(txFee) + .setOfferFeePaymentTxId(offerFeePaymentTxId) .setBuyerSecurityDeposit(buyerSecurityDeposit) + .setSellerSecurityDeposit(sellerSecurityDeposit) .setTriggerPrice(triggerPrice) .setIsCurrencyForMakerFeeBtc(isCurrencyForMakerFeeBtc) .setPaymentAccountId(paymentAccountId) @@ -143,7 +161,7 @@ public bisq.proto.grpc.OfferInfo toProtoMessage() { @SuppressWarnings("unused") public static OfferInfo fromProto(bisq.proto.grpc.OfferInfo proto) { - return new OfferInfo.OfferInfoBuilder() + return new OfferInfoBuilder() .withId(proto.getId()) .withDirection(proto.getDirection()) .withPrice(proto.getPrice()) @@ -153,7 +171,11 @@ public static OfferInfo fromProto(bisq.proto.grpc.OfferInfo proto) { .withMinAmount(proto.getMinAmount()) .withVolume(proto.getVolume()) .withMinVolume(proto.getMinVolume()) + .withMakerFee(proto.getMakerFee()) + .withTxFee(proto.getTxFee()) + .withOfferFeePaymentTxId(proto.getOfferFeePaymentTxId()) .withBuyerSecurityDeposit(proto.getBuyerSecurityDeposit()) + .withSellerSecurityDeposit(proto.getSellerSecurityDeposit()) .withTriggerPrice(proto.getTriggerPrice()) .withIsCurrencyForMakerFeeBtc(proto.getIsCurrencyForMakerFeeBtc()) .withPaymentAccountId(proto.getPaymentAccountId()) @@ -182,7 +204,11 @@ public static class OfferInfoBuilder { private long minAmount; private long volume; private long minVolume; + private long txFee; + private long makerFee; + private String offerFeePaymentTxId; private long buyerSecurityDeposit; + private long sellerSecurityDeposit; private long triggerPrice; private boolean isCurrencyForMakerFeeBtc; private String paymentAccountId; @@ -238,11 +264,31 @@ public OfferInfoBuilder withMinVolume(long minVolume) { return this; } + public OfferInfoBuilder withTxFee(long txFee) { + this.txFee = txFee; + return this; + } + + public OfferInfoBuilder withMakerFee(long makerFee) { + this.makerFee = makerFee; + return this; + } + + public OfferInfoBuilder withOfferFeePaymentTxId(String offerFeePaymentTxId) { + this.offerFeePaymentTxId = offerFeePaymentTxId; + return this; + } + public OfferInfoBuilder withBuyerSecurityDeposit(long buyerSecurityDeposit) { this.buyerSecurityDeposit = buyerSecurityDeposit; return this; } + public OfferInfoBuilder withSellerSecurityDeposit(long sellerSecurityDeposit) { + this.sellerSecurityDeposit = sellerSecurityDeposit; + return this; + } + public OfferInfoBuilder withTriggerPrice(long triggerPrice) { this.triggerPrice = triggerPrice; return this; diff --git a/core/src/main/java/bisq/core/api/model/PaymentAccountForm.java b/core/src/main/java/bisq/core/api/model/PaymentAccountForm.java index 777e48987d5..eb3a6cd3bac 100644 --- a/core/src/main/java/bisq/core/api/model/PaymentAccountForm.java +++ b/core/src/main/java/bisq/core/api/model/PaymentAccountForm.java @@ -57,7 +57,7 @@ /** *

* An instance of this class can write new payment account forms (editable json files), - * and de-serialize edited json files into {@link bisq.core.payment.PaymentAccount} + * and de-serialize edited json files into {@link PaymentAccount} * instances. *

*

@@ -66,8 +66,8 @@ *

*
*

- * (1) Ask for a hal cash account form: Pass a {@link bisq.core.payment.payload.PaymentMethod#HAL_CASH_ID} - * to {@link bisq.core.api.model.PaymentAccountForm#getPaymentAccountForm(String)} to + * (1) Ask for a hal cash account form: Pass a {@link PaymentMethod#HAL_CASH_ID} + * to {@link PaymentAccountForm#getPaymentAccountForm(String)} to * get the json Hal Cash payment account form: *

  * {
@@ -98,8 +98,8 @@
  * 
*

* (3) De-serialize the edited json account form: Pass the edited json file to - * {@link bisq.core.api.model.PaymentAccountForm#toPaymentAccount(File)}, or - * a json string to {@link bisq.core.api.model.PaymentAccountForm#toPaymentAccount(String)} + * {@link PaymentAccountForm#toPaymentAccount(File)}, or + * a json string to {@link PaymentAccountForm#toPaymentAccount(String)} * and get a {@link bisq.core.payment.HalCashAccount} instance. *
  * PaymentAccount(
diff --git a/core/src/main/java/bisq/core/api/model/PaymentAccountPayloadInfo.java b/core/src/main/java/bisq/core/api/model/PaymentAccountPayloadInfo.java
new file mode 100644
index 00000000000..9af1277ce33
--- /dev/null
+++ b/core/src/main/java/bisq/core/api/model/PaymentAccountPayloadInfo.java
@@ -0,0 +1,76 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see .
+ */
+
+package bisq.core.api.model;
+
+import bisq.core.payment.payload.CryptoCurrencyAccountPayload;
+import bisq.core.payment.payload.PaymentAccountPayload;
+
+import bisq.common.Payload;
+
+import java.util.function.Supplier;
+
+import lombok.Getter;
+
+import javax.annotation.Nullable;
+
+@Getter
+public class PaymentAccountPayloadInfo implements Payload {
+
+    private final String id;
+    private final String paymentMethodId;
+    @Nullable
+    private final String address;
+
+    public PaymentAccountPayloadInfo(String id,
+                                     String paymentMethodId,
+                                     @Nullable String address) {
+        this.id = id;
+        this.paymentMethodId = paymentMethodId;
+        this.address = address;
+    }
+
+    public static PaymentAccountPayloadInfo toPaymentAccountPayloadInfo(PaymentAccountPayload paymentAccountPayload) {
+        String address = paymentAccountPayload instanceof CryptoCurrencyAccountPayload
+                ? ((CryptoCurrencyAccountPayload) paymentAccountPayload).getAddress()
+                : "";
+        return new PaymentAccountPayloadInfo(paymentAccountPayload.getId(),
+                paymentAccountPayload.getPaymentMethodId(),
+                address);
+    }
+
+    // For transmitting TradeInfo messages when no contract & payloads are available.
+    public static Supplier emptyPaymentAccountPayload = () ->
+            new PaymentAccountPayloadInfo("", "", "");
+
+    ///////////////////////////////////////////////////////////////////////////////////////////
+    // PROTO BUFFER
+    ///////////////////////////////////////////////////////////////////////////////////////////
+
+    public static PaymentAccountPayloadInfo fromProto(bisq.proto.grpc.PaymentAccountPayloadInfo proto) {
+        return new PaymentAccountPayloadInfo(proto.getId(), proto.getPaymentMethodId(), proto.getAddress());
+    }
+
+    @Override
+    public bisq.proto.grpc.PaymentAccountPayloadInfo toProtoMessage() {
+        return bisq.proto.grpc.PaymentAccountPayloadInfo.newBuilder()
+                .setId(id)
+                .setPaymentMethodId(paymentMethodId)
+                .setAddress(address != null ? address : "")
+                .build();
+    }
+}
diff --git a/core/src/main/java/bisq/core/api/model/TradeInfo.java b/core/src/main/java/bisq/core/api/model/TradeInfo.java
index 1a717a7672e..078e5ee4d9c 100644
--- a/core/src/main/java/bisq/core/api/model/TradeInfo.java
+++ b/core/src/main/java/bisq/core/api/model/TradeInfo.java
@@ -17,6 +17,7 @@
 
 package bisq.core.api.model;
 
+import bisq.core.trade.Contract;
 import bisq.core.trade.Trade;
 
 import bisq.common.Payload;
@@ -27,6 +28,7 @@
 import lombok.Getter;
 
 import static bisq.core.api.model.OfferInfo.toOfferInfo;
+import static bisq.core.api.model.PaymentAccountPayloadInfo.toPaymentAccountPayloadInfo;
 
 @EqualsAndHashCode
 @Getter
@@ -60,6 +62,7 @@ public class TradeInfo implements Payload {
     private final boolean isPayoutPublished;
     private final boolean isWithdrawn;
     private final String contractAsJson;
+    private final ContractInfo contract;
 
     public TradeInfo(TradeInfoBuilder builder) {
         this.offer = builder.offer;
@@ -86,6 +89,7 @@ public TradeInfo(TradeInfoBuilder builder) {
         this.isPayoutPublished = builder.isPayoutPublished;
         this.isWithdrawn = builder.isWithdrawn;
         this.contractAsJson = builder.contractAsJson;
+        this.contract = builder.contract;
     }
 
     public static TradeInfo toTradeInfo(Trade trade) {
@@ -93,7 +97,26 @@ public static TradeInfo toTradeInfo(Trade trade) {
     }
 
     public static TradeInfo toTradeInfo(Trade trade, String role) {
-        return new TradeInfo.TradeInfoBuilder()
+        ContractInfo contractInfo;
+        if (trade.getContract() != null) {
+            Contract contract = trade.getContract();
+            contractInfo = new ContractInfo(contract.getBuyerPayoutAddressString(),
+                    contract.getSellerPayoutAddressString(),
+                    contract.getMediatorNodeAddress().getFullAddress(),
+                    contract.getRefundAgentNodeAddress().getFullAddress(),
+                    contract.isBuyerMakerAndSellerTaker(),
+                    contract.getMakerAccountId(),
+                    contract.getTakerAccountId(),
+                    toPaymentAccountPayloadInfo(contract.getMakerPaymentAccountPayload()),
+                    toPaymentAccountPayloadInfo(contract.getTakerPaymentAccountPayload()),
+                    contract.getMakerPayoutAddressString(),
+                    contract.getTakerPayoutAddressString(),
+                    contract.getLockTime());
+        } else {
+            contractInfo = ContractInfo.emptyContract.get();
+        }
+
+        return new TradeInfoBuilder()
                 .withOffer(toOfferInfo(trade.getOffer()))
                 .withTradeId(trade.getId())
                 .withShortId(trade.getShortId())
@@ -120,6 +143,7 @@ public static TradeInfo toTradeInfo(Trade trade, String role) {
                 .withIsPayoutPublished(trade.isPayoutPublished())
                 .withIsWithdrawn(trade.isWithdrawn())
                 .withContractAsJson(trade.getContractAsJson())
+                .withContract(contractInfo)
                 .build();
     }
 
@@ -154,12 +178,38 @@ public bisq.proto.grpc.TradeInfo toProtoMessage() {
                 .setIsPayoutPublished(isPayoutPublished)
                 .setIsWithdrawn(isWithdrawn)
                 .setContractAsJson(contractAsJson == null ? "" : contractAsJson)
+                .setContract(contract.toProtoMessage())
                 .build();
     }
 
-    @SuppressWarnings({"unused", "SameReturnValue"})
     public static TradeInfo fromProto(bisq.proto.grpc.TradeInfo proto) {
-        return null;  // TODO
+        return new TradeInfoBuilder()
+                .withOffer(OfferInfo.fromProto(proto.getOffer()))
+                .withTradeId(proto.getTradeId())
+                .withShortId(proto.getShortId())
+                .withDate(proto.getDate())
+                .withRole(proto.getRole())
+                .withIsCurrencyForTakerFeeBtc(proto.getIsCurrencyForTakerFeeBtc())
+                .withTxFeeAsLong(proto.getTxFeeAsLong())
+                .withTakerFeeAsLong(proto.getTakerFeeAsLong())
+                .withTakerFeeTxId(proto.getTakerFeeTxId())
+                .withDepositTxId(proto.getDepositTxId())
+                .withPayoutTxId(proto.getPayoutTxId())
+                .withTradeAmountAsLong(proto.getTradeAmountAsLong())
+                .withTradePrice(proto.getTradePrice())
+                .withTradePeriodState(proto.getTradePeriodState())
+                .withState(proto.getState())
+                .withPhase(proto.getPhase())
+                .withTradingPeerNodeAddress(proto.getTradingPeerNodeAddress())
+                .withIsDepositPublished(proto.getIsDepositPublished())
+                .withIsDepositConfirmed(proto.getIsDepositConfirmed())
+                .withIsFiatSent(proto.getIsFiatSent())
+                .withIsFiatReceived(proto.getIsFiatReceived())
+                .withIsPayoutPublished(proto.getIsPayoutPublished())
+                .withIsWithdrawn(proto.getIsWithdrawn())
+                .withContractAsJson(proto.getContractAsJson())
+                .withContract((ContractInfo.fromProto(proto.getContract())))
+                .build();
     }
 
     /*
@@ -193,6 +243,7 @@ public static class TradeInfoBuilder {
         private boolean isPayoutPublished;
         private boolean isWithdrawn;
         private String contractAsJson;
+        private ContractInfo contract;
 
         public TradeInfoBuilder withOffer(OfferInfo offer) {
             this.offer = offer;
@@ -314,6 +365,11 @@ public TradeInfoBuilder withContractAsJson(String contractAsJson) {
             return this;
         }
 
+        public TradeInfoBuilder withContract(ContractInfo contract) {
+            this.contract = contract;
+            return this;
+        }
+
         public TradeInfo build() {
             return new TradeInfo(this);
         }
@@ -346,6 +402,7 @@ public String toString() {
                 ", isWithdrawn=" + isWithdrawn + "\n" +
                 ", offer=" + offer + "\n" +
                 ", contractAsJson=" + contractAsJson + "\n" +
+                ", contract=" + contract + "\n" +
                 '}';
     }
 }
diff --git a/core/src/main/java/bisq/core/api/model/TxInfo.java b/core/src/main/java/bisq/core/api/model/TxInfo.java
index f62174f406f..3bd937c96e5 100644
--- a/core/src/main/java/bisq/core/api/model/TxInfo.java
+++ b/core/src/main/java/bisq/core/api/model/TxInfo.java
@@ -46,7 +46,7 @@ public class TxInfo implements Payload {
     private final boolean isPending;
     private final String memo;
 
-    public TxInfo(TxInfo.TxInfoBuilder builder) {
+    public TxInfo(TxInfoBuilder builder) {
         this.txId = builder.txId;
         this.inputSum = builder.inputSum;
         this.outputSum = builder.outputSum;
@@ -61,7 +61,7 @@ public static TxInfo toTxInfo(Transaction transaction) {
             throw new IllegalStateException("server created a null transaction");
 
         if (transaction.getFee() != null)
-            return new TxInfo.TxInfoBuilder()
+            return new TxInfoBuilder()
                     .withTxId(transaction.getTxId().toString())
                     .withInputSum(transaction.getInputSum().value)
                     .withOutputSum(transaction.getOutputSum().value)
@@ -71,7 +71,7 @@ public static TxInfo toTxInfo(Transaction transaction) {
                     .withMemo(transaction.getMemo())
                     .build();
         else
-            return new TxInfo.TxInfoBuilder()
+            return new TxInfoBuilder()
                     .withTxId(transaction.getTxId().toString())
                     .withInputSum(transaction.getInputSum().value)
                     .withOutputSum(transaction.getOutputSum().value)
@@ -101,7 +101,7 @@ public bisq.proto.grpc.TxInfo toProtoMessage() {
 
     @SuppressWarnings("unused")
     public static TxInfo fromProto(bisq.proto.grpc.TxInfo proto) {
-        return new TxInfo.TxInfoBuilder()
+        return new TxInfoBuilder()
                 .withTxId(proto.getTxId())
                 .withInputSum(proto.getInputSum())
                 .withOutputSum(proto.getOutputSum())
@@ -121,37 +121,37 @@ public static class TxInfoBuilder {
         private boolean isPending;
         private String memo;
 
-        public TxInfo.TxInfoBuilder withTxId(String txId) {
+        public TxInfoBuilder withTxId(String txId) {
             this.txId = txId;
             return this;
         }
 
-        public TxInfo.TxInfoBuilder withInputSum(long inputSum) {
+        public TxInfoBuilder withInputSum(long inputSum) {
             this.inputSum = inputSum;
             return this;
         }
 
-        public TxInfo.TxInfoBuilder withOutputSum(long outputSum) {
+        public TxInfoBuilder withOutputSum(long outputSum) {
             this.outputSum = outputSum;
             return this;
         }
 
-        public TxInfo.TxInfoBuilder withFee(long fee) {
+        public TxInfoBuilder withFee(long fee) {
             this.fee = fee;
             return this;
         }
 
-        public TxInfo.TxInfoBuilder withSize(int size) {
+        public TxInfoBuilder withSize(int size) {
             this.size = size;
             return this;
         }
 
-        public TxInfo.TxInfoBuilder withIsPending(boolean isPending) {
+        public TxInfoBuilder withIsPending(boolean isPending) {
             this.isPending = isPending;
             return this;
         }
 
-        public TxInfo.TxInfoBuilder withMemo(String memo) {
+        public TxInfoBuilder withMemo(String memo) {
             this.memo = memo;
             return this;
         }
diff --git a/proto/src/main/proto/grpc.proto b/proto/src/main/proto/grpc.proto
index 3fd783ada38..76c5b1f6b7c 100644
--- a/proto/src/main/proto/grpc.proto
+++ b/proto/src/main/proto/grpc.proto
@@ -155,6 +155,10 @@ message OfferInfo {
     string counterCurrencyCode = 17;
     uint64 date = 18;
     string state = 19;
+    uint64 sellerSecurityDeposit = 20;
+    string offerFeePaymentTxId = 21;
+    uint64 txFee = 22;
+    uint64 makerFee = 23;
 }
 
 ///////////////////////////////////////////////////////////////////////////////////////////
@@ -365,6 +369,28 @@ message TradeInfo {
     bool isPayoutPublished = 22;
     bool isWithdrawn = 23;
     string contractAsJson = 24;
+    ContractInfo contract = 25;
+}
+
+message ContractInfo {
+    string buyerNodeAddress = 1;
+    string sellerNodeAddress = 2;
+    string mediatorNodeAddress = 3;
+    string refundAgentNodeAddress = 4;
+    bool isBuyerMakerAndSellerTaker = 5;
+    string makerAccountId = 6;
+    string takerAccountId = 7;
+    PaymentAccountPayloadInfo makerPaymentAccountPayload = 8;
+    PaymentAccountPayloadInfo takerPaymentAccountPayload = 9;
+    string makerPayoutAddressString = 10;
+    string takerPayoutAddressString = 11;
+    uint64 lockTime = 12;
+}
+
+message PaymentAccountPayloadInfo {
+    string id = 1;
+    string paymentMethodId = 2;
+    string address = 3;
 }
 
 ///////////////////////////////////////////////////////////////////////////////////////////

From 55f157f17f2ad27d8566a472e85ca9a690426dea Mon Sep 17 00:00:00 2001
From: ghubstan <36207203+ghubstan@users.noreply.github.com>
Date: Thu, 25 Mar 2021 19:57:02 -0300
Subject: [PATCH 04/21] Add BTC/BSQ pair offer & trading tests

And fixed maker and taker fees in trade output.

Also tidy'd up some test cases:

- Fixed some test names
- Added comments
- Loosened some trade state/phase asserts
- Removed direction string literals
- Made long literals more readable
- Right justified offer amout and volume columns in CLI output
- Added some offer and trade logging to see CLI output formatting
---
 .../java/bisq/apitest/method/MethodTest.java  |   3 +
 .../method/offer/AbstractOfferTest.java       |  16 +
 .../apitest/method/offer/CancelOfferTest.java |   5 +-
 .../method/offer/CreateBSQOffersTest.java     | 256 ++++++++++++++
 .../offer/CreateOfferUsingFixedPriceTest.java |  99 +++---
 ...CreateOfferUsingMarketPriceMarginTest.java | 129 ++++---
 .../method/offer/ValidateCreateOfferTest.java |  16 +-
 .../method/trade/AbstractTradeTest.java       |  15 +-
 .../method/trade/TakeBuyBSQOfferTest.java     | 320 +++++++++++++++++
 .../method/trade/TakeBuyBTCOfferTest.java     |  50 ++-
 .../method/trade/TakeSellBSQOfferTest.java    | 323 ++++++++++++++++++
 .../method/trade/TakeSellBTCOfferTest.java    |  56 ++-
 .../java/bisq/apitest/scenario/OfferTest.java |  15 +
 .../java/bisq/apitest/scenario/TradeTest.java |  24 ++
 .../bisq/apitest/scenario/bot/BotClient.java  |   6 +-
 .../apitest/scenario/bot/RandomOffer.java     |  10 +-
 .../scenario/bot/protocol/BotProtocol.java    |   8 +-
 .../bot/protocol/MakerBotProtocol.java        |   3 +-
 .../bot/protocol/TakerBotProtocol.java        |  12 +-
 19 files changed, 1169 insertions(+), 197 deletions(-)
 create mode 100644 apitest/src/test/java/bisq/apitest/method/offer/CreateBSQOffersTest.java
 create mode 100644 apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBSQOfferTest.java
 create mode 100644 apitest/src/test/java/bisq/apitest/method/trade/TakeSellBSQOfferTest.java

diff --git a/apitest/src/test/java/bisq/apitest/method/MethodTest.java b/apitest/src/test/java/bisq/apitest/method/MethodTest.java
index c475c909ec9..c60141dbc2c 100644
--- a/apitest/src/test/java/bisq/apitest/method/MethodTest.java
+++ b/apitest/src/test/java/bisq/apitest/method/MethodTest.java
@@ -42,6 +42,9 @@
 
 public class MethodTest extends ApiTestCase {
 
+    public static final String BSQ = "BSQ";
+    public static final String BTC = "BTC";
+
     protected static final String ARBITRATOR = "arbitrator";
     protected static final String MEDIATOR = "mediator";
     protected static final String REFUND_AGENT = "refundagent";
diff --git a/apitest/src/test/java/bisq/apitest/method/offer/AbstractOfferTest.java b/apitest/src/test/java/bisq/apitest/method/offer/AbstractOfferTest.java
index c28a51de429..86f5f2ab6db 100644
--- a/apitest/src/test/java/bisq/apitest/method/offer/AbstractOfferTest.java
+++ b/apitest/src/test/java/bisq/apitest/method/offer/AbstractOfferTest.java
@@ -19,6 +19,8 @@
 
 import bisq.core.monetary.Altcoin;
 
+import protobuf.PaymentAccount;
+
 import org.bitcoinj.utils.Fiat;
 
 import java.math.BigDecimal;
@@ -49,10 +51,14 @@ public abstract class AbstractOfferTest extends MethodTest {
     @Setter
     protected static boolean isLongRunningTest;
 
+    protected static PaymentAccount alicesBsqAcct;
+    protected static PaymentAccount bobsBsqAcct;
+
     @BeforeAll
     public static void setUp() {
         startSupportingApps(true,
                 true,
+                false,
                 bitcoind,
                 seednode,
                 arbdaemon,
@@ -60,6 +66,16 @@ public static void setUp() {
                 bobdaemon);
     }
 
+
+    public static void createBsqPaymentAccounts() {
+        alicesBsqAcct = aliceClient.createCryptoCurrencyPaymentAccount("Alice's BSQ Account",
+                BSQ,
+                aliceClient.getUnusedBsqAddress());
+        bobsBsqAcct = bobClient.createCryptoCurrencyPaymentAccount("Bob's BSQ Account",
+                BSQ,
+                bobClient.getUnusedBsqAddress());
+    }
+
     protected double getScaledOfferPrice(double offerPrice, String currencyCode) {
         int precision = isCryptoCurrency(currencyCode) ? Altcoin.SMALLEST_UNIT_EXPONENT : Fiat.SMALLEST_UNIT_EXPONENT;
         return scaleDownByPowerOf10(offerPrice, precision);
diff --git a/apitest/src/test/java/bisq/apitest/method/offer/CancelOfferTest.java b/apitest/src/test/java/bisq/apitest/method/offer/CancelOfferTest.java
index 166d853c901..2e23c21c1f7 100644
--- a/apitest/src/test/java/bisq/apitest/method/offer/CancelOfferTest.java
+++ b/apitest/src/test/java/bisq/apitest/method/offer/CancelOfferTest.java
@@ -34,13 +34,14 @@
 
 import static bisq.core.btc.wallet.Restrictions.getDefaultBuyerSecurityDepositAsPercent;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static protobuf.OfferPayload.Direction.BUY;
 
 @Disabled
 @Slf4j
 @TestMethodOrder(MethodOrderer.OrderAnnotation.class)
 public class CancelOfferTest extends AbstractOfferTest {
 
-    private static final String DIRECTION = "buy";
+    private static final String DIRECTION = BUY.name();
     private static final String CURRENCY_CODE = "cad";
     private static final int MAX_OFFERS = 3;
 
@@ -52,7 +53,7 @@ public class CancelOfferTest extends AbstractOfferTest {
                 0.00,
                 getDefaultBuyerSecurityDepositAsPercent(),
                 paymentAccountId,
-                "bsq");
+                BSQ);
     };
 
     @Test
diff --git a/apitest/src/test/java/bisq/apitest/method/offer/CreateBSQOffersTest.java b/apitest/src/test/java/bisq/apitest/method/offer/CreateBSQOffersTest.java
new file mode 100644
index 00000000000..abc916f5176
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/method/offer/CreateBSQOffersTest.java
@@ -0,0 +1,256 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see .
+ */
+
+package bisq.apitest.method.offer;
+
+import bisq.proto.grpc.OfferInfo;
+
+import java.util.List;
+
+import lombok.extern.slf4j.Slf4j;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+
+import static bisq.cli.TableFormat.formatBalancesTbls;
+import static bisq.cli.TableFormat.formatOfferTable;
+import static bisq.core.btc.wallet.Restrictions.getDefaultBuyerSecurityDepositAsPercent;
+import static java.util.Collections.singletonList;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static protobuf.OfferPayload.Direction.BUY;
+import static protobuf.OfferPayload.Direction.SELL;
+
+@Disabled
+@Slf4j
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+public class CreateBSQOffersTest extends AbstractOfferTest {
+
+    private static final String MAKER_FEE_CURRENCY_CODE = BSQ;
+
+    @BeforeAll
+    public static void setUp() {
+        AbstractOfferTest.setUp();
+        createBsqPaymentAccounts();
+    }
+
+    @Test
+    @Order(1)
+    public void testCreateBuy1BTCFor20KBSQOffer() {
+        // Remember alt coin trades are BTC trades.  When placing an offer, you are
+        // offering to buy or sell BTC, not BSQ, XMR, etc.  In this test case,
+        // Alice places an offer to BUY BTC with BSQ.
+        var newOffer = aliceClient.createFixedPricedOffer(BUY.name(),
+                BSQ,
+                100_000_000L,
+                100_000_000L,
+                "0.00005",   // FIXED PRICE IN BTC (satoshis) FOR 1 BSQ
+                getDefaultBuyerSecurityDepositAsPercent(),
+                alicesBsqAcct.getId(),
+                MAKER_FEE_CURRENCY_CODE);
+        log.info("Sell BSQ (Buy BTC) OFFER:\n{}", formatOfferTable(singletonList(newOffer), BSQ));
+        String newOfferId = newOffer.getId();
+        assertNotEquals("", newOfferId);
+        assertEquals(BUY.name(), newOffer.getDirection());
+        assertFalse(newOffer.getUseMarketBasedPrice());
+        assertEquals(5_000, newOffer.getPrice());
+        assertEquals(100_000_000L, newOffer.getAmount());
+        assertEquals(100_000_000L, newOffer.getMinAmount());
+        assertEquals(15_000_000, newOffer.getBuyerSecurityDeposit());
+        assertEquals(alicesBsqAcct.getId(), newOffer.getPaymentAccountId());
+        assertEquals(BSQ, newOffer.getBaseCurrencyCode());
+        assertEquals(BTC, newOffer.getCounterCurrencyCode());
+        assertFalse(newOffer.getIsCurrencyForMakerFeeBtc());
+
+        genBtcBlockAndWaitForOfferPreparation();
+
+        newOffer = aliceClient.getMyOffer(newOfferId);
+        assertEquals(newOfferId, newOffer.getId());
+        assertEquals(BUY.name(), newOffer.getDirection());
+        assertFalse(newOffer.getUseMarketBasedPrice());
+        assertEquals(5_000, newOffer.getPrice());
+        assertEquals(100_000_000L, newOffer.getAmount());
+        assertEquals(100_000_000L, newOffer.getMinAmount());
+        assertEquals(15_000_000, newOffer.getBuyerSecurityDeposit());
+        assertEquals(alicesBsqAcct.getId(), newOffer.getPaymentAccountId());
+        assertEquals(BSQ, newOffer.getBaseCurrencyCode());
+        assertEquals(BTC, newOffer.getCounterCurrencyCode());
+        assertFalse(newOffer.getIsCurrencyForMakerFeeBtc());
+    }
+
+    @Test
+    @Order(2)
+    public void testCreateSell1BTCFor20KBSQOffer() {
+        // Alice places an offer to SELL BTC for BSQ.
+        var newOffer = aliceClient.createFixedPricedOffer(SELL.name(),
+                BSQ,
+                100_000_000L,
+                100_000_000L,
+                "0.00005",   // FIXED PRICE IN BTC (satoshis) FOR 1 BSQ
+                getDefaultBuyerSecurityDepositAsPercent(),
+                alicesBsqAcct.getId(),
+                MAKER_FEE_CURRENCY_CODE);
+        log.info("SELL 20K BSQ OFFER:\n{}", formatOfferTable(singletonList(newOffer), BSQ));
+        String newOfferId = newOffer.getId();
+        assertNotEquals("", newOfferId);
+        assertEquals(SELL.name(), newOffer.getDirection());
+        assertFalse(newOffer.getUseMarketBasedPrice());
+        assertEquals(5_000, newOffer.getPrice());
+        assertEquals(100_000_000L, newOffer.getAmount());
+        assertEquals(100_000_000L, newOffer.getMinAmount());
+        assertEquals(15_000_000, newOffer.getBuyerSecurityDeposit());
+        assertEquals(alicesBsqAcct.getId(), newOffer.getPaymentAccountId());
+        assertEquals(BSQ, newOffer.getBaseCurrencyCode());
+        assertEquals(BTC, newOffer.getCounterCurrencyCode());
+        assertFalse(newOffer.getIsCurrencyForMakerFeeBtc());
+
+        genBtcBlockAndWaitForOfferPreparation();
+
+        newOffer = aliceClient.getMyOffer(newOfferId);
+        assertEquals(newOfferId, newOffer.getId());
+        assertEquals(SELL.name(), newOffer.getDirection());
+        assertFalse(newOffer.getUseMarketBasedPrice());
+        assertEquals(5_000, newOffer.getPrice());
+        assertEquals(100_000_000L, newOffer.getAmount());
+        assertEquals(100_000_000L, newOffer.getMinAmount());
+        assertEquals(15_000_000, newOffer.getBuyerSecurityDeposit());
+        assertEquals(alicesBsqAcct.getId(), newOffer.getPaymentAccountId());
+        assertEquals(BSQ, newOffer.getBaseCurrencyCode());
+        assertEquals(BTC, newOffer.getCounterCurrencyCode());
+        assertFalse(newOffer.getIsCurrencyForMakerFeeBtc());
+    }
+
+    @Test
+    @Order(3)
+    public void testCreateBuyBTCWith1To2KBSQOffer() {
+        // Alice places an offer to BUY 0.05 - 0.10 BTC with BSQ.
+        var newOffer = aliceClient.createFixedPricedOffer(BUY.name(),
+                BSQ,
+                10_000_000L,
+                5_000_000L,
+                "0.00005",   // FIXED PRICE IN BTC sats FOR 1 BSQ
+                getDefaultBuyerSecurityDepositAsPercent(),
+                alicesBsqAcct.getId(),
+                MAKER_FEE_CURRENCY_CODE);
+        log.info("BUY 1-2K BSQ OFFER:\n{}", formatOfferTable(singletonList(newOffer), BSQ));
+        String newOfferId = newOffer.getId();
+        assertNotEquals("", newOfferId);
+        assertEquals(BUY.name(), newOffer.getDirection());
+        assertFalse(newOffer.getUseMarketBasedPrice());
+        assertEquals(5_000, newOffer.getPrice());
+        assertEquals(10_000_000L, newOffer.getAmount());
+        assertEquals(5_000_000L, newOffer.getMinAmount());
+        assertEquals(1_500_000, newOffer.getBuyerSecurityDeposit());
+        assertEquals(alicesBsqAcct.getId(), newOffer.getPaymentAccountId());
+        assertEquals(BSQ, newOffer.getBaseCurrencyCode());
+        assertEquals(BTC, newOffer.getCounterCurrencyCode());
+        assertFalse(newOffer.getIsCurrencyForMakerFeeBtc());
+
+        genBtcBlockAndWaitForOfferPreparation();
+
+        newOffer = aliceClient.getMyOffer(newOfferId);
+        assertEquals(newOfferId, newOffer.getId());
+        assertEquals(BUY.name(), newOffer.getDirection());
+        assertFalse(newOffer.getUseMarketBasedPrice());
+        assertEquals(5_000, newOffer.getPrice());
+        assertEquals(10_000_000L, newOffer.getAmount());
+        assertEquals(5_000_000L, newOffer.getMinAmount());
+        assertEquals(1_500_000, newOffer.getBuyerSecurityDeposit());
+        assertEquals(alicesBsqAcct.getId(), newOffer.getPaymentAccountId());
+        assertEquals(BSQ, newOffer.getBaseCurrencyCode());
+        assertEquals(BTC, newOffer.getCounterCurrencyCode());
+        assertFalse(newOffer.getIsCurrencyForMakerFeeBtc());
+    }
+
+    @Test
+    @Order(4)
+    public void testCreateSellBTCFor5To10KBSQOffer() {
+        // Alice places an offer to SELL 0.25 - 0.50 BTC for BSQ.
+        var newOffer = aliceClient.createFixedPricedOffer(SELL.name(),
+                BSQ,
+                50_000_000L,
+                25_000_000L,
+                "0.00005",   // FIXED PRICE IN BTC sats FOR 1 BSQ
+                getDefaultBuyerSecurityDepositAsPercent(),
+                alicesBsqAcct.getId(),
+                MAKER_FEE_CURRENCY_CODE);
+        log.info("SELL 5-10K BSQ OFFER:\n{}", formatOfferTable(singletonList(newOffer), BSQ));
+        String newOfferId = newOffer.getId();
+        assertNotEquals("", newOfferId);
+        assertEquals(SELL.name(), newOffer.getDirection());
+        assertFalse(newOffer.getUseMarketBasedPrice());
+        assertEquals(5_000, newOffer.getPrice());
+        assertEquals(50_000_000L, newOffer.getAmount());
+        assertEquals(25_000_000L, newOffer.getMinAmount());
+        assertEquals(7_500_000, newOffer.getBuyerSecurityDeposit());
+        assertEquals(alicesBsqAcct.getId(), newOffer.getPaymentAccountId());
+        assertEquals(BSQ, newOffer.getBaseCurrencyCode());
+        assertEquals(BTC, newOffer.getCounterCurrencyCode());
+        assertFalse(newOffer.getIsCurrencyForMakerFeeBtc());
+
+        genBtcBlockAndWaitForOfferPreparation();
+
+        newOffer = aliceClient.getMyOffer(newOfferId);
+        assertEquals(newOfferId, newOffer.getId());
+        assertEquals(SELL.name(), newOffer.getDirection());
+        assertFalse(newOffer.getUseMarketBasedPrice());
+        assertEquals(5_000, newOffer.getPrice());
+        assertEquals(50_000_000L, newOffer.getAmount());
+        assertEquals(25_000_000L, newOffer.getMinAmount());
+        assertEquals(7_500_000, newOffer.getBuyerSecurityDeposit());
+        assertEquals(alicesBsqAcct.getId(), newOffer.getPaymentAccountId());
+        assertEquals(BSQ, newOffer.getBaseCurrencyCode());
+        assertEquals(BTC, newOffer.getCounterCurrencyCode());
+        assertFalse(newOffer.getIsCurrencyForMakerFeeBtc());
+    }
+
+    @Test
+    @Order(4)
+    public void testGetAllMyBsqOffers() {
+        List offers = aliceClient.getMyBsqOffersSortedByDate();
+        log.info("ALL ALICE'S BSQ OFFERS:\n{}", formatOfferTable(offers, BSQ));
+        assertEquals(4, offers.size());
+        log.info("ALICE'S BALANCES\n{}", formatBalancesTbls(aliceClient.getBalances()));
+    }
+
+    @Test
+    @Order(5)
+    public void testGetAvailableBsqOffers() {
+        List offers = bobClient.getBsqOffersSortedByDate();
+        log.info("ALL BOB'S AVAILABLE BSQ OFFERS:\n{}", formatOfferTable(offers, BSQ));
+        assertEquals(4, offers.size());
+        log.info("BOB'S BALANCES\n{}", formatBalancesTbls(bobClient.getBalances()));
+    }
+
+    @Test
+    @Order(6)
+    public void testBreakpoint() {
+        log.debug("hit me");
+    }
+
+    private void genBtcBlockAndWaitForOfferPreparation() {
+        // Extra time is needed for the OfferUtils#isBsqForMakerFeeAvailable, which
+        // can sometimes return an incorrect false value if the BsqWallet's
+        // available confirmed balance is temporarily = zero during BSQ offer prep.
+        genBtcBlocksThenWait(1, 5000);
+    }
+}
diff --git a/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingFixedPriceTest.java b/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingFixedPriceTest.java
index 2e8b2962b2a..aeb1b6eb944 100644
--- a/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingFixedPriceTest.java
+++ b/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingFixedPriceTest.java
@@ -27,53 +27,58 @@
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.TestMethodOrder;
 
+import static bisq.cli.TableFormat.formatOfferTable;
 import static bisq.core.btc.wallet.Restrictions.getDefaultBuyerSecurityDepositAsPercent;
+import static java.util.Collections.singletonList;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static protobuf.OfferPayload.Direction.BUY;
+import static protobuf.OfferPayload.Direction.SELL;
 
 @Disabled
 @Slf4j
 @TestMethodOrder(MethodOrderer.OrderAnnotation.class)
 public class CreateOfferUsingFixedPriceTest extends AbstractOfferTest {
 
-    private static final String MAKER_FEE_CURRENCY_CODE = "bsq";
+    private static final String MAKER_FEE_CURRENCY_CODE = BSQ;
 
     @Test
     @Order(1)
     public void testCreateAUDBTCBuyOfferUsingFixedPrice16000() {
         PaymentAccount audAccount = createDummyF2FAccount(aliceClient, "AU");
-        var newOffer = aliceClient.createFixedPricedOffer("buy",
+        var newOffer = aliceClient.createFixedPricedOffer(BUY.name(),
                 "aud",
-                10000000L,
-                10000000L,
+                10_000_000L,
+                10_000_000L,
                 "36000",
                 getDefaultBuyerSecurityDepositAsPercent(),
                 audAccount.getId(),
                 MAKER_FEE_CURRENCY_CODE);
+        log.info("OFFER #1:\n{}", formatOfferTable(singletonList(newOffer), "AUD"));
         String newOfferId = newOffer.getId();
         assertNotEquals("", newOfferId);
-        assertEquals("BUY", newOffer.getDirection());
+        assertEquals(BUY.name(), newOffer.getDirection());
         assertFalse(newOffer.getUseMarketBasedPrice());
-        assertEquals(360000000, newOffer.getPrice());
-        assertEquals(10000000, newOffer.getAmount());
-        assertEquals(10000000, newOffer.getMinAmount());
-        assertEquals(1500000, newOffer.getBuyerSecurityDeposit());
+        assertEquals(360_000_000, newOffer.getPrice());
+        assertEquals(10_000_000, newOffer.getAmount());
+        assertEquals(10_000_000, newOffer.getMinAmount());
+        assertEquals(1_500_000, newOffer.getBuyerSecurityDeposit());
         assertEquals(audAccount.getId(), newOffer.getPaymentAccountId());
-        assertEquals("BTC", newOffer.getBaseCurrencyCode());
+        assertEquals(BTC, newOffer.getBaseCurrencyCode());
         assertEquals("AUD", newOffer.getCounterCurrencyCode());
         assertFalse(newOffer.getIsCurrencyForMakerFeeBtc());
 
         newOffer = aliceClient.getMyOffer(newOfferId);
         assertEquals(newOfferId, newOffer.getId());
-        assertEquals("BUY", newOffer.getDirection());
+        assertEquals(BUY.name(), newOffer.getDirection());
         assertFalse(newOffer.getUseMarketBasedPrice());
-        assertEquals(360000000, newOffer.getPrice());
-        assertEquals(10000000, newOffer.getAmount());
-        assertEquals(10000000, newOffer.getMinAmount());
-        assertEquals(1500000, newOffer.getBuyerSecurityDeposit());
+        assertEquals(360_000_000, newOffer.getPrice());
+        assertEquals(10_000_000, newOffer.getAmount());
+        assertEquals(10_000_000, newOffer.getMinAmount());
+        assertEquals(1_500_000, newOffer.getBuyerSecurityDeposit());
         assertEquals(audAccount.getId(), newOffer.getPaymentAccountId());
-        assertEquals("BTC", newOffer.getBaseCurrencyCode());
+        assertEquals(BTC, newOffer.getBaseCurrencyCode());
         assertEquals("AUD", newOffer.getCounterCurrencyCode());
         assertFalse(newOffer.getIsCurrencyForMakerFeeBtc());
     }
@@ -82,37 +87,38 @@ public void testCreateAUDBTCBuyOfferUsingFixedPrice16000() {
     @Order(2)
     public void testCreateUSDBTCBuyOfferUsingFixedPrice100001234() {
         PaymentAccount usdAccount = createDummyF2FAccount(aliceClient, "US");
-        var newOffer = aliceClient.createFixedPricedOffer("buy",
+        var newOffer = aliceClient.createFixedPricedOffer(BUY.name(),
                 "usd",
-                10000000L,
-                10000000L,
+                10_000_000L,
+                10_000_000L,
                 "30000.1234",
                 getDefaultBuyerSecurityDepositAsPercent(),
                 usdAccount.getId(),
                 MAKER_FEE_CURRENCY_CODE);
+        log.info("OFFER #2:\n{}", formatOfferTable(singletonList(newOffer), "USD"));
         String newOfferId = newOffer.getId();
         assertNotEquals("", newOfferId);
-        assertEquals("BUY", newOffer.getDirection());
+        assertEquals(BUY.name(), newOffer.getDirection());
         assertFalse(newOffer.getUseMarketBasedPrice());
-        assertEquals(300001234, newOffer.getPrice());
-        assertEquals(10000000, newOffer.getAmount());
-        assertEquals(10000000, newOffer.getMinAmount());
-        assertEquals(1500000, newOffer.getBuyerSecurityDeposit());
+        assertEquals(300_001_234, newOffer.getPrice());
+        assertEquals(10_000_000, newOffer.getAmount());
+        assertEquals(10_000_000, newOffer.getMinAmount());
+        assertEquals(1_500_000, newOffer.getBuyerSecurityDeposit());
         assertEquals(usdAccount.getId(), newOffer.getPaymentAccountId());
-        assertEquals("BTC", newOffer.getBaseCurrencyCode());
+        assertEquals(BTC, newOffer.getBaseCurrencyCode());
         assertEquals("USD", newOffer.getCounterCurrencyCode());
         assertFalse(newOffer.getIsCurrencyForMakerFeeBtc());
 
         newOffer = aliceClient.getMyOffer(newOfferId);
         assertEquals(newOfferId, newOffer.getId());
-        assertEquals("BUY", newOffer.getDirection());
+        assertEquals(BUY.name(), newOffer.getDirection());
         assertFalse(newOffer.getUseMarketBasedPrice());
-        assertEquals(300001234, newOffer.getPrice());
-        assertEquals(10000000, newOffer.getAmount());
-        assertEquals(10000000, newOffer.getMinAmount());
-        assertEquals(1500000, newOffer.getBuyerSecurityDeposit());
+        assertEquals(300_001_234, newOffer.getPrice());
+        assertEquals(10_000_000, newOffer.getAmount());
+        assertEquals(10_000_000, newOffer.getMinAmount());
+        assertEquals(1_500_000, newOffer.getBuyerSecurityDeposit());
         assertEquals(usdAccount.getId(), newOffer.getPaymentAccountId());
-        assertEquals("BTC", newOffer.getBaseCurrencyCode());
+        assertEquals(BTC, newOffer.getBaseCurrencyCode());
         assertEquals("USD", newOffer.getCounterCurrencyCode());
         assertFalse(newOffer.getIsCurrencyForMakerFeeBtc());
     }
@@ -121,37 +127,38 @@ public void testCreateUSDBTCBuyOfferUsingFixedPrice100001234() {
     @Order(3)
     public void testCreateEURBTCSellOfferUsingFixedPrice95001234() {
         PaymentAccount eurAccount = createDummyF2FAccount(aliceClient, "FR");
-        var newOffer = aliceClient.createFixedPricedOffer("sell",
+        var newOffer = aliceClient.createFixedPricedOffer(SELL.name(),
                 "eur",
-                10000000L,
-                10000000L,
+                10_000_000L,
+                5_000_000L,
                 "29500.1234",
                 getDefaultBuyerSecurityDepositAsPercent(),
                 eurAccount.getId(),
                 MAKER_FEE_CURRENCY_CODE);
+        log.info("OFFER #3:\n{}", formatOfferTable(singletonList(newOffer), "EUR"));
         String newOfferId = newOffer.getId();
         assertNotEquals("", newOfferId);
-        assertEquals("SELL", newOffer.getDirection());
+        assertEquals(SELL.name(), newOffer.getDirection());
         assertFalse(newOffer.getUseMarketBasedPrice());
-        assertEquals(295001234, newOffer.getPrice());
-        assertEquals(10000000, newOffer.getAmount());
-        assertEquals(10000000, newOffer.getMinAmount());
-        assertEquals(1500000, newOffer.getBuyerSecurityDeposit());
+        assertEquals(295_001_234, newOffer.getPrice());
+        assertEquals(10_000_000, newOffer.getAmount());
+        assertEquals(5_000_000, newOffer.getMinAmount());
+        assertEquals(1_500_000, newOffer.getBuyerSecurityDeposit());
         assertEquals(eurAccount.getId(), newOffer.getPaymentAccountId());
-        assertEquals("BTC", newOffer.getBaseCurrencyCode());
+        assertEquals(BTC, newOffer.getBaseCurrencyCode());
         assertEquals("EUR", newOffer.getCounterCurrencyCode());
         assertFalse(newOffer.getIsCurrencyForMakerFeeBtc());
 
         newOffer = aliceClient.getMyOffer(newOfferId);
         assertEquals(newOfferId, newOffer.getId());
-        assertEquals("SELL", newOffer.getDirection());
+        assertEquals(SELL.name(), newOffer.getDirection());
         assertFalse(newOffer.getUseMarketBasedPrice());
-        assertEquals(295001234, newOffer.getPrice());
-        assertEquals(10000000, newOffer.getAmount());
-        assertEquals(10000000, newOffer.getMinAmount());
-        assertEquals(1500000, newOffer.getBuyerSecurityDeposit());
+        assertEquals(295_001_234, newOffer.getPrice());
+        assertEquals(10_000_000, newOffer.getAmount());
+        assertEquals(5_000_000, newOffer.getMinAmount());
+        assertEquals(1_500_000, newOffer.getBuyerSecurityDeposit());
         assertEquals(eurAccount.getId(), newOffer.getPaymentAccountId());
-        assertEquals("BTC", newOffer.getBaseCurrencyCode());
+        assertEquals(BTC, newOffer.getBaseCurrencyCode());
         assertEquals("EUR", newOffer.getCounterCurrencyCode());
         assertFalse(newOffer.getIsCurrencyForMakerFeeBtc());
     }
diff --git a/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingMarketPriceMarginTest.java b/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingMarketPriceMarginTest.java
index 4dad4152e12..944aaf0a59f 100644
--- a/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingMarketPriceMarginTest.java
+++ b/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingMarketPriceMarginTest.java
@@ -31,15 +31,18 @@
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.TestMethodOrder;
 
+import static bisq.cli.TableFormat.formatOfferTable;
 import static bisq.common.util.MathUtils.scaleDownByPowerOf10;
 import static bisq.common.util.MathUtils.scaleUpByPowerOf10;
 import static bisq.core.btc.wallet.Restrictions.getDefaultBuyerSecurityDepositAsPercent;
 import static java.lang.Math.abs;
 import static java.lang.String.format;
+import static java.util.Collections.singletonList;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotEquals;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static protobuf.OfferPayload.Direction.BUY;
+import static protobuf.OfferPayload.Direction.SELL;
 
 @Disabled
 @Slf4j
@@ -50,42 +53,43 @@ public class CreateOfferUsingMarketPriceMarginTest extends AbstractOfferTest {
     private static final double MKT_PRICE_MARGIN_ERROR_TOLERANCE = 0.0050;      // 0.50%
     private static final double MKT_PRICE_MARGIN_WARNING_TOLERANCE = 0.0001;    // 0.01%
 
-    private static final String MAKER_FEE_CURRENCY_CODE = "btc";
+    private static final String MAKER_FEE_CURRENCY_CODE = BTC;
 
     @Test
     @Order(1)
     public void testCreateUSDBTCBuyOffer5PctPriceMargin() {
         PaymentAccount usdAccount = createDummyF2FAccount(aliceClient, "US");
         double priceMarginPctInput = 5.00;
-        var newOffer = aliceClient.createMarketBasedPricedOffer("buy",
+        var newOffer = aliceClient.createMarketBasedPricedOffer(BUY.name(),
                 "usd",
-                10000000L,
-                10000000L,
+                10_000_000L,
+                10_000_000L,
                 priceMarginPctInput,
                 getDefaultBuyerSecurityDepositAsPercent(),
                 usdAccount.getId(),
                 MAKER_FEE_CURRENCY_CODE);
+        log.info("OFFER #1:\n{}", formatOfferTable(singletonList(newOffer), "usd"));
         String newOfferId = newOffer.getId();
         assertNotEquals("", newOfferId);
-        assertEquals("BUY", newOffer.getDirection());
+        assertEquals(BUY.name(), newOffer.getDirection());
         assertTrue(newOffer.getUseMarketBasedPrice());
-        assertEquals(10000000, newOffer.getAmount());
-        assertEquals(10000000, newOffer.getMinAmount());
-        assertEquals(1500000, newOffer.getBuyerSecurityDeposit());
+        assertEquals(10_000_000, newOffer.getAmount());
+        assertEquals(10_000_000, newOffer.getMinAmount());
+        assertEquals(1_500_000, newOffer.getBuyerSecurityDeposit());
         assertEquals(usdAccount.getId(), newOffer.getPaymentAccountId());
-        assertEquals("BTC", newOffer.getBaseCurrencyCode());
+        assertEquals(BTC, newOffer.getBaseCurrencyCode());
         assertEquals("USD", newOffer.getCounterCurrencyCode());
         assertTrue(newOffer.getIsCurrencyForMakerFeeBtc());
 
         newOffer = aliceClient.getMyOffer(newOfferId);
         assertEquals(newOfferId, newOffer.getId());
-        assertEquals("BUY", newOffer.getDirection());
+        assertEquals(BUY.name(), newOffer.getDirection());
         assertTrue(newOffer.getUseMarketBasedPrice());
-        assertEquals(10000000, newOffer.getAmount());
-        assertEquals(10000000, newOffer.getMinAmount());
-        assertEquals(1500000, newOffer.getBuyerSecurityDeposit());
+        assertEquals(10_000_000, newOffer.getAmount());
+        assertEquals(10_000_000, newOffer.getMinAmount());
+        assertEquals(1_500_000, newOffer.getBuyerSecurityDeposit());
         assertEquals(usdAccount.getId(), newOffer.getPaymentAccountId());
-        assertEquals("BTC", newOffer.getBaseCurrencyCode());
+        assertEquals(BTC, newOffer.getBaseCurrencyCode());
         assertEquals("USD", newOffer.getCounterCurrencyCode());
         assertTrue(newOffer.getIsCurrencyForMakerFeeBtc());
 
@@ -97,51 +101,36 @@ public void testCreateUSDBTCBuyOffer5PctPriceMargin() {
     public void testCreateNZDBTCBuyOfferMinus2PctPriceMargin() {
         PaymentAccount nzdAccount = createDummyF2FAccount(aliceClient, "NZ");
         double priceMarginPctInput = -2.00;
-        /*
-        var req = CreateOfferRequest.newBuilder()
-                .setPaymentAccountId(nzdAccount.getId())
-                .setDirection("buy")
-                .setCurrencyCode("nzd")
-                .setAmount(10000000)
-                .setMinAmount(10000000)
-                .setUseMarketBasedPrice(true)
-                .setMarketPriceMargin(priceMarginPctInput)
-                .setPrice("0")
-                .setBuyerSecurityDeposit(getDefaultBuyerSecurityDepositAsPercent())
-                .setMakerFeeCurrencyCode(MAKER_FEE_CURRENCY_CODE)
-                .build();
-        var newOffer = aliceStubs.offersService.createOffer(req).getOffer();
-
-         */
-        var newOffer = aliceClient.createMarketBasedPricedOffer("buy",
+        var newOffer = aliceClient.createMarketBasedPricedOffer(BUY.name(),
                 "nzd",
-                10000000L,
-                10000000L,
+                10_000_000L,
+                10_000_000L,
                 priceMarginPctInput,
                 getDefaultBuyerSecurityDepositAsPercent(),
                 nzdAccount.getId(),
                 MAKER_FEE_CURRENCY_CODE);
+        log.info("OFFER #2:\n{}", formatOfferTable(singletonList(newOffer), "nzd"));
         String newOfferId = newOffer.getId();
         assertNotEquals("", newOfferId);
-        assertEquals("BUY", newOffer.getDirection());
+        assertEquals(BUY.name(), newOffer.getDirection());
         assertTrue(newOffer.getUseMarketBasedPrice());
-        assertEquals(10000000, newOffer.getAmount());
-        assertEquals(10000000, newOffer.getMinAmount());
-        assertEquals(1500000, newOffer.getBuyerSecurityDeposit());
+        assertEquals(10_000_000, newOffer.getAmount());
+        assertEquals(10_000_000, newOffer.getMinAmount());
+        assertEquals(1_500_000, newOffer.getBuyerSecurityDeposit());
         assertEquals(nzdAccount.getId(), newOffer.getPaymentAccountId());
-        assertEquals("BTC", newOffer.getBaseCurrencyCode());
+        assertEquals(BTC, newOffer.getBaseCurrencyCode());
         assertEquals("NZD", newOffer.getCounterCurrencyCode());
         assertTrue(newOffer.getIsCurrencyForMakerFeeBtc());
 
         newOffer = aliceClient.getMyOffer(newOfferId);
         assertEquals(newOfferId, newOffer.getId());
-        assertEquals("BUY", newOffer.getDirection());
+        assertEquals(BUY.name(), newOffer.getDirection());
         assertTrue(newOffer.getUseMarketBasedPrice());
-        assertEquals(10000000, newOffer.getAmount());
-        assertEquals(10000000, newOffer.getMinAmount());
-        assertEquals(1500000, newOffer.getBuyerSecurityDeposit());
+        assertEquals(10_000_000, newOffer.getAmount());
+        assertEquals(10_000_000, newOffer.getMinAmount());
+        assertEquals(1_500_000, newOffer.getBuyerSecurityDeposit());
         assertEquals(nzdAccount.getId(), newOffer.getPaymentAccountId());
-        assertEquals("BTC", newOffer.getBaseCurrencyCode());
+        assertEquals(BTC, newOffer.getBaseCurrencyCode());
         assertEquals("NZD", newOffer.getCounterCurrencyCode());
         assertTrue(newOffer.getIsCurrencyForMakerFeeBtc());
 
@@ -153,35 +142,36 @@ public void testCreateNZDBTCBuyOfferMinus2PctPriceMargin() {
     public void testCreateGBPBTCSellOfferMinus1Point5PctPriceMargin() {
         PaymentAccount gbpAccount = createDummyF2FAccount(aliceClient, "GB");
         double priceMarginPctInput = -1.5;
-        var newOffer = aliceClient.createMarketBasedPricedOffer("sell",
+        var newOffer = aliceClient.createMarketBasedPricedOffer(SELL.name(),
                 "gbp",
-                10000000L,
-                10000000L,
+                10_000_000L,
+                5_000_000L,
                 priceMarginPctInput,
                 getDefaultBuyerSecurityDepositAsPercent(),
                 gbpAccount.getId(),
                 MAKER_FEE_CURRENCY_CODE);
+        log.info("OFFER #3:\n{}", formatOfferTable(singletonList(newOffer), "gbp"));
         String newOfferId = newOffer.getId();
         assertNotEquals("", newOfferId);
-        assertEquals("SELL", newOffer.getDirection());
+        assertEquals(SELL.name(), newOffer.getDirection());
         assertTrue(newOffer.getUseMarketBasedPrice());
-        assertEquals(10000000, newOffer.getAmount());
-        assertEquals(10000000, newOffer.getMinAmount());
-        assertEquals(1500000, newOffer.getBuyerSecurityDeposit());
+        assertEquals(10_000_000, newOffer.getAmount());
+        assertEquals(5_000_000, newOffer.getMinAmount());
+        assertEquals(1_500_000, newOffer.getBuyerSecurityDeposit());
         assertEquals(gbpAccount.getId(), newOffer.getPaymentAccountId());
-        assertEquals("BTC", newOffer.getBaseCurrencyCode());
+        assertEquals(BTC, newOffer.getBaseCurrencyCode());
         assertEquals("GBP", newOffer.getCounterCurrencyCode());
         assertTrue(newOffer.getIsCurrencyForMakerFeeBtc());
 
         newOffer = aliceClient.getMyOffer(newOfferId);
         assertEquals(newOfferId, newOffer.getId());
-        assertEquals("SELL", newOffer.getDirection());
+        assertEquals(SELL.name(), newOffer.getDirection());
         assertTrue(newOffer.getUseMarketBasedPrice());
-        assertEquals(10000000, newOffer.getAmount());
-        assertEquals(10000000, newOffer.getMinAmount());
-        assertEquals(1500000, newOffer.getBuyerSecurityDeposit());
+        assertEquals(10_000_000, newOffer.getAmount());
+        assertEquals(5_000_000, newOffer.getMinAmount());
+        assertEquals(1_500_000, newOffer.getBuyerSecurityDeposit());
         assertEquals(gbpAccount.getId(), newOffer.getPaymentAccountId());
-        assertEquals("BTC", newOffer.getBaseCurrencyCode());
+        assertEquals(BTC, newOffer.getBaseCurrencyCode());
         assertEquals("GBP", newOffer.getCounterCurrencyCode());
         assertTrue(newOffer.getIsCurrencyForMakerFeeBtc());
 
@@ -193,35 +183,36 @@ public void testCreateGBPBTCSellOfferMinus1Point5PctPriceMargin() {
     public void testCreateBRLBTCSellOffer6Point55PctPriceMargin() {
         PaymentAccount brlAccount = createDummyF2FAccount(aliceClient, "BR");
         double priceMarginPctInput = 6.55;
-        var newOffer = aliceClient.createMarketBasedPricedOffer("sell",
+        var newOffer = aliceClient.createMarketBasedPricedOffer(SELL.name(),
                 "brl",
-                10000000L,
-                10000000L,
+                10_000_000L,
+                5_000_000L,
                 priceMarginPctInput,
                 getDefaultBuyerSecurityDepositAsPercent(),
                 brlAccount.getId(),
                 MAKER_FEE_CURRENCY_CODE);
+        log.info("OFFER #4:\n{}", formatOfferTable(singletonList(newOffer), "brl"));
         String newOfferId = newOffer.getId();
         assertNotEquals("", newOfferId);
-        assertEquals("SELL", newOffer.getDirection());
+        assertEquals(SELL.name(), newOffer.getDirection());
         assertTrue(newOffer.getUseMarketBasedPrice());
-        assertEquals(10000000, newOffer.getAmount());
-        assertEquals(10000000, newOffer.getMinAmount());
-        assertEquals(1500000, newOffer.getBuyerSecurityDeposit());
+        assertEquals(10_000_000, newOffer.getAmount());
+        assertEquals(5_000_000, newOffer.getMinAmount());
+        assertEquals(1_500_000, newOffer.getBuyerSecurityDeposit());
         assertEquals(brlAccount.getId(), newOffer.getPaymentAccountId());
-        assertEquals("BTC", newOffer.getBaseCurrencyCode());
+        assertEquals(BTC, newOffer.getBaseCurrencyCode());
         assertEquals("BRL", newOffer.getCounterCurrencyCode());
         assertTrue(newOffer.getIsCurrencyForMakerFeeBtc());
 
         newOffer = aliceClient.getMyOffer(newOfferId);
         assertEquals(newOfferId, newOffer.getId());
-        assertEquals("SELL", newOffer.getDirection());
+        assertEquals(SELL.name(), newOffer.getDirection());
         assertTrue(newOffer.getUseMarketBasedPrice());
-        assertEquals(10000000, newOffer.getAmount());
-        assertEquals(10000000, newOffer.getMinAmount());
-        assertEquals(1500000, newOffer.getBuyerSecurityDeposit());
+        assertEquals(10_000_000, newOffer.getAmount());
+        assertEquals(5_000_000, newOffer.getMinAmount());
+        assertEquals(1_500_000, newOffer.getBuyerSecurityDeposit());
         assertEquals(brlAccount.getId(), newOffer.getPaymentAccountId());
-        assertEquals("BTC", newOffer.getBaseCurrencyCode());
+        assertEquals(BTC, newOffer.getBaseCurrencyCode());
         assertEquals("BRL", newOffer.getCounterCurrencyCode());
         assertTrue(newOffer.getIsCurrencyForMakerFeeBtc());
 
diff --git a/apitest/src/test/java/bisq/apitest/method/offer/ValidateCreateOfferTest.java b/apitest/src/test/java/bisq/apitest/method/offer/ValidateCreateOfferTest.java
index 65f8c83f607..5b3151c5711 100644
--- a/apitest/src/test/java/bisq/apitest/method/offer/ValidateCreateOfferTest.java
+++ b/apitest/src/test/java/bisq/apitest/method/offer/ValidateCreateOfferTest.java
@@ -33,6 +33,7 @@
 import static java.lang.String.format;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static protobuf.OfferPayload.Direction.BUY;
 
 @Disabled
 @Slf4j
@@ -45,16 +46,15 @@ public void testAmtTooLargeShouldThrowException() {
         PaymentAccount usdAccount = createDummyF2FAccount(aliceClient, "US");
         @SuppressWarnings("ResultOfMethodCallIgnored")
         Throwable exception = assertThrows(StatusRuntimeException.class, () ->
-                aliceClient.createFixedPricedOffer("buy",
+                aliceClient.createFixedPricedOffer(BUY.name(),
                         "usd",
                         100000000000L, // exceeds amount limit
                         100000000000L,
                         "10000.0000",
                         getDefaultBuyerSecurityDepositAsPercent(),
                         usdAccount.getId(),
-                        "bsq"));
-        assertEquals("UNKNOWN: An error occurred at task: ValidateOffer",
-                exception.getMessage());
+                        BSQ));
+        assertEquals("UNKNOWN: An error occurred at task: ValidateOffer", exception.getMessage());
     }
 
     @Test
@@ -63,14 +63,14 @@ public void testNoMatchingEURPaymentAccountShouldThrowException() {
         PaymentAccount chfAccount = createDummyF2FAccount(aliceClient, "ch");
         @SuppressWarnings("ResultOfMethodCallIgnored")
         Throwable exception = assertThrows(StatusRuntimeException.class, () ->
-                aliceClient.createFixedPricedOffer("buy",
+                aliceClient.createFixedPricedOffer(BUY.name(),
                         "eur",
                         10000000L,
                         10000000L,
                         "40000.0000",
                         getDefaultBuyerSecurityDepositAsPercent(),
                         chfAccount.getId(),
-                        "btc"));
+                        BTC));
         String expectedError = format("UNKNOWN: cannot create EUR offer with payment account %s", chfAccount.getId());
         assertEquals(expectedError, exception.getMessage());
     }
@@ -81,14 +81,14 @@ public void testNoMatchingCADPaymentAccountShouldThrowException() {
         PaymentAccount audAccount = createDummyF2FAccount(aliceClient, "au");
         @SuppressWarnings("ResultOfMethodCallIgnored")
         Throwable exception = assertThrows(StatusRuntimeException.class, () ->
-                aliceClient.createFixedPricedOffer("buy",
+                aliceClient.createFixedPricedOffer(BUY.name(),
                         "cad",
                         10000000L,
                         10000000L,
                         "63000.0000",
                         getDefaultBuyerSecurityDepositAsPercent(),
                         audAccount.getId(),
-                        "btc"));
+                        BTC));
         String expectedError = format("UNKNOWN: cannot create CAD offer with payment account %s", audAccount.getId());
         assertEquals(expectedError, exception.getMessage());
     }
diff --git a/apitest/src/test/java/bisq/apitest/method/trade/AbstractTradeTest.java b/apitest/src/test/java/bisq/apitest/method/trade/AbstractTradeTest.java
index 161d2f12833..06a2a06de3a 100644
--- a/apitest/src/test/java/bisq/apitest/method/trade/AbstractTradeTest.java
+++ b/apitest/src/test/java/bisq/apitest/method/trade/AbstractTradeTest.java
@@ -63,7 +63,20 @@ protected final void logTrade(Logger log,
                                   TestInfo testInfo,
                                   String description,
                                   TradeInfo trade) {
-        if (log.isDebugEnabled()) {
+        logTrade(log, testInfo, description, trade, false);
+    }
+
+    protected final void logTrade(Logger log,
+                                  TestInfo testInfo,
+                                  String description,
+                                  TradeInfo trade,
+                                  boolean force) {
+        if (force)
+            log.info(String.format("%s %s%n%s",
+                    testName(testInfo),
+                    description.toUpperCase(),
+                    format(trade)));
+        else if (log.isDebugEnabled()) {
             log.debug(String.format("%s %s%n%s",
                     testName(testInfo),
                     description.toUpperCase(),
diff --git a/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBSQOfferTest.java b/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBSQOfferTest.java
new file mode 100644
index 00000000000..9aa0cf7e803
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBSQOfferTest.java
@@ -0,0 +1,320 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see .
+ */
+
+package bisq.apitest.method.trade;
+
+import bisq.proto.grpc.ContractInfo;
+import bisq.proto.grpc.TradeInfo;
+
+import io.grpc.StatusRuntimeException;
+
+import java.util.function.Predicate;
+
+import lombok.extern.slf4j.Slf4j;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInfo;
+import org.junit.jupiter.api.TestMethodOrder;
+
+import static bisq.cli.CurrencyFormat.formatBsqSendAmount;
+import static bisq.cli.TableFormat.formatBalancesTbls;
+import static bisq.cli.TableFormat.formatOfferTable;
+import static bisq.core.btc.wallet.Restrictions.getDefaultBuyerSecurityDepositAsPercent;
+import static bisq.core.trade.Trade.Phase.DEPOSIT_CONFIRMED;
+import static bisq.core.trade.Trade.Phase.FIAT_SENT;
+import static bisq.core.trade.Trade.Phase.PAYOUT_PUBLISHED;
+import static bisq.core.trade.Trade.State.BUYER_RECEIVED_PAYOUT_TX_PUBLISHED_MSG;
+import static bisq.core.trade.Trade.State.DEPOSIT_CONFIRMED_IN_BLOCK_CHAIN;
+import static bisq.core.trade.Trade.State.SELLER_RECEIVED_FIAT_PAYMENT_INITIATED_MSG;
+import static bisq.core.trade.Trade.State.SELLER_SAW_ARRIVED_PAYOUT_TX_PUBLISHED_MSG;
+import static java.lang.String.format;
+import static java.util.Collections.singletonList;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.fail;
+import static protobuf.Offer.State.OFFER_FEE_PAID;
+import static protobuf.OfferPayload.Direction.SELL;
+
+
+
+import bisq.apitest.method.offer.AbstractOfferTest;
+
+// https://github.com/ghubstan/bisq/blob/master/cli/src/main/java/bisq/cli/TradeFormat.java
+
+@Disabled
+@Slf4j
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+public class TakeBuyBSQOfferTest extends AbstractTradeTest {
+
+    // Alice is maker / bsq buyer (btc seller), Bob is taker / bsq seller (btc buyer).
+
+    // Maker and Taker fees are in BSQ.
+    private static final String TRADE_FEE_CURRENCY_CODE = BSQ;
+
+    @BeforeAll
+    public static void setUp() {
+        AbstractOfferTest.setUp();
+        createBsqPaymentAccounts();
+        EXPECTED_PROTOCOL_STATUS.init();
+    }
+
+    @Test
+    @Order(1)
+    public void testTakeAlicesSellBTCForBSQOffer(final TestInfo testInfo) {
+        try {
+            // Alice is going to BUY BSQ, but the Offer direction = SELL because it is a
+            // BTC trade;  Alice will SELL BTC for BSQ.  Bob will send Alice BSQ.
+            // Confused me, but just need to remember there are only BTC offers.
+            var btcTradeDirection = SELL.name();
+            var alicesOffer = aliceClient.createFixedPricedOffer(btcTradeDirection,
+                    BSQ,
+                    15_000_000L,
+                    7_500_000L,
+                    "0.000035",   // FIXED PRICE IN BTC (satoshis) FOR 1 BSQ
+                    getDefaultBuyerSecurityDepositAsPercent(),
+                    alicesBsqAcct.getId(),
+                    TRADE_FEE_CURRENCY_CODE);
+            log.info("ALICE'S BUY BSQ (SELL BTC) OFFER:\n{}", formatOfferTable(singletonList(alicesOffer), BSQ));
+            genBtcBlocksThenWait(1, 5000);
+            var offerId = alicesOffer.getId();
+            assertFalse(alicesOffer.getIsCurrencyForMakerFeeBtc());
+
+            var alicesBsqOffers = aliceClient.getMyBsqOffers(btcTradeDirection);
+            assertEquals(1, alicesBsqOffers.size());
+
+            var trade = takeAlicesOffer(offerId, bobsBsqAcct.getId(), TRADE_FEE_CURRENCY_CODE);
+            assertNotNull(trade);
+            assertEquals(offerId, trade.getTradeId());
+            assertFalse(trade.getIsCurrencyForTakerFeeBtc());
+            // Cache the trade id for the other tests.
+            tradeId = trade.getTradeId();
+
+            genBtcBlocksThenWait(1, 6000);
+            alicesBsqOffers = aliceClient.getMyBsqOffersSortedByDate();
+            assertEquals(0, alicesBsqOffers.size());
+
+            for (int i = 1; i <= maxTradeStateAndPhaseChecks.get(); i++) {
+                trade = bobClient.getTrade(trade.getTradeId());
+
+                if (!trade.getIsDepositConfirmed()) {
+                    log.warn("Bob still waiting on trade {} tx {}: DEPOSIT_CONFIRMED_IN_BLOCK_CHAIN, attempt # {}",
+                            trade.getShortId(),
+                            trade.getDepositTxId(),
+                            i);
+                    genBtcBlocksThenWait(1, 4000);
+                    continue;
+                } else {
+                    EXPECTED_PROTOCOL_STATUS.setState(DEPOSIT_CONFIRMED_IN_BLOCK_CHAIN)
+                            .setPhase(DEPOSIT_CONFIRMED)
+                            .setDepositPublished(true)
+                            .setDepositConfirmed(true);
+                    verifyExpectedProtocolStatus(trade);
+                    logTrade(log, testInfo, "Bob's view after taking offer and deposit confirmed", trade);
+                    break;
+                }
+            }
+
+            genBtcBlocksThenWait(1, 2500);
+
+            if (!trade.getIsDepositConfirmed()) {
+                fail(format("INVALID_PHASE for Bob's trade %s in STATE=%s PHASE=%s, deposit tx was never confirmed.",
+                        trade.getShortId(),
+                        trade.getState(),
+                        trade.getPhase()));
+            }
+
+            logTrade(log, testInfo, "Alice's Maker/Buyer View", aliceClient.getTrade(tradeId), true);
+            logTrade(log, testInfo, "Bob's Taker/Seller View", bobClient.getTrade(tradeId), true);
+
+            if (false) {
+                log.warn("Sleeping for 30 min while test CLI.");
+                sleep(30 * 60 * 1000);
+            }
+
+        } catch (StatusRuntimeException e) {
+            fail(e);
+        }
+    }
+
+    @Test
+    @Order(2)
+    public void testBobsConfirmPaymentStarted(final TestInfo testInfo) {
+        try {
+            var trade = bobClient.getTrade(tradeId);
+
+            Predicate tradeStateAndPhaseCorrect = (t) ->
+                    t.getState().equals(DEPOSIT_CONFIRMED_IN_BLOCK_CHAIN.name())
+                            && t.getPhase().equals(DEPOSIT_CONFIRMED.name());
+
+            for (int i = 1; i <= maxTradeStateAndPhaseChecks.get(); i++) {
+                if (!tradeStateAndPhaseCorrect.test(trade)) {
+                    log.warn("INVALID_PHASE for Bob's trade {} in STATE={} PHASE={}, cannot send payment started msg yet.",
+                            trade.getShortId(),
+                            trade.getState(),
+                            trade.getPhase());
+                    sleep(10_000);
+                    trade = bobClient.getTrade(tradeId);
+                    continue;
+                } else {
+                    break;
+                }
+            }
+
+            if (!tradeStateAndPhaseCorrect.test(trade)) {
+                fail(format("INVALID_PHASE for Bob's trade %s in STATE=%s PHASE=%s, could not send payment started msg.",
+                        trade.getShortId(),
+                        trade.getState(),
+                        trade.getPhase()));
+            }
+
+            // TODO refactor into superclass?  Might be needed in :cli.
+            // ContractInfo contract = ContractInfo.fromProto(trade);
+            ContractInfo contract = trade.getContract();
+            String alicesBsqAddress = contract.getIsBuyerMakerAndSellerTaker()
+                    ? contract.getTakerPaymentAccountPayload().getAddress()
+                    : contract.getMakerPaymentAccountPayload().getAddress();
+            String sendBsqAmount = formatBsqSendAmount(trade.getOffer().getVolume());
+            log.info("Bob sending {} BSQ to Alice's address {}", sendBsqAmount, alicesBsqAddress);
+            bobClient.sendBsq(alicesBsqAddress, sendBsqAmount, "");
+
+            genBtcBlocksThenWait(1, 2500);
+            bobClient.confirmPaymentStarted(trade.getTradeId());
+            sleep(6000);
+
+            for (int i = 1; i <= maxTradeStateAndPhaseChecks.get(); i++) {
+                trade = aliceClient.getTrade(tradeId);
+
+                if (!trade.getIsFiatSent()) {
+                    log.warn("Alice still waiting for trade {} SELLER_RECEIVED_FIAT_PAYMENT_INITIATED_MSG, attempt # {}",
+                            trade.getShortId(),
+                            i);
+                    sleep(5000);
+                    continue;
+                } else {
+                    // Warning:  trade.getOffer().getState() might be AVAILABLE, not OFFER_FEE_PAID.
+                    EXPECTED_PROTOCOL_STATUS.setState(SELLER_RECEIVED_FIAT_PAYMENT_INITIATED_MSG)
+                            .setPhase(FIAT_SENT)
+                            .setFiatSent(true);
+                    verifyExpectedProtocolStatus(trade);
+                    logTrade(log, testInfo, "Alice's view after confirming fiat payment received", trade);
+                    break;
+                }
+            }
+
+            logTrade(log, testInfo, "Alice's Maker/Buyer View (Payment Sent)", aliceClient.getTrade(tradeId), true);
+            logTrade(log, testInfo, "Bob's Taker/Seller View (Payment Sent)", bobClient.getTrade(tradeId), true);
+
+            if (false) {
+                log.warn("Sleeping for 30 min while test CLI.");
+                sleep(30 * 60 * 1000);
+            }
+        } catch (StatusRuntimeException e) {
+            fail(e);
+        }
+    }
+
+    @Test
+    @Order(3)
+    public void testAlicesConfirmPaymentReceived(final TestInfo testInfo) {
+        try {
+            var trade = aliceClient.getTrade(tradeId);
+
+            Predicate tradeStateAndPhaseCorrect = (t) ->
+                    t.getState().equals(SELLER_RECEIVED_FIAT_PAYMENT_INITIATED_MSG.name())
+                            && (t.getPhase().equals(PAYOUT_PUBLISHED.name()) || t.getPhase().equals(FIAT_SENT.name()));
+
+            for (int i = 1; i <= maxTradeStateAndPhaseChecks.get(); i++) {
+                if (!tradeStateAndPhaseCorrect.test(trade)) {
+                    log.warn("INVALID_PHASE for Alice's trade {} in STATE={} PHASE={}, cannot confirm payment received yet.",
+                            trade.getShortId(),
+                            trade.getState(),
+                            trade.getPhase());
+                    sleep(1000 * 10);
+                    trade = aliceClient.getTrade(tradeId);
+                    continue;
+                } else {
+                    break;
+                }
+            }
+
+            if (!tradeStateAndPhaseCorrect.test(trade)) {
+                fail(format("INVALID_PHASE for Alice's trade %s in STATE=%s PHASE=%s, cannot confirm payment received.",
+                        trade.getShortId(),
+                        trade.getState(),
+                        trade.getPhase()));
+            }
+
+            aliceClient.confirmPaymentReceived(trade.getTradeId());
+            sleep(3000);
+
+            trade = aliceClient.getTrade(tradeId);
+            assertEquals(OFFER_FEE_PAID.name(), trade.getOffer().getState());
+            EXPECTED_PROTOCOL_STATUS.setState(SELLER_SAW_ARRIVED_PAYOUT_TX_PUBLISHED_MSG)
+                    .setPhase(PAYOUT_PUBLISHED)
+                    .setPayoutPublished(true)
+                    .setFiatReceived(true);
+            verifyExpectedProtocolStatus(trade);
+            logTrade(log, testInfo, "Alice's view after confirming fiat payment received", trade);
+
+            logTrade(log, testInfo, "Alice's Maker/Buyer View (Payment Received)", aliceClient.getTrade(tradeId), true);
+            logTrade(log, testInfo, "Bob's Taker/Seller View (Payment Received)", bobClient.getTrade(tradeId), true);
+
+        } catch (StatusRuntimeException e) {
+            fail(e);
+        }
+    }
+
+    @Test
+    @Order(4)
+    public void testBobsKeepFunds(final TestInfo testInfo) {
+        try {
+            genBtcBlocksThenWait(1, 1000);
+
+            var trade = bobClient.getTrade(tradeId);
+            logTrade(log, testInfo, "Alice's view before keeping funds", trade);
+
+            bobClient.keepFunds(tradeId);
+            genBtcBlocksThenWait(1, 1000);
+
+            trade = bobClient.getTrade(tradeId);
+            EXPECTED_PROTOCOL_STATUS.setState(BUYER_RECEIVED_PAYOUT_TX_PUBLISHED_MSG)
+                    .setPhase(PAYOUT_PUBLISHED);
+            verifyExpectedProtocolStatus(trade);
+            logTrade(log, testInfo, "Alice's view after keeping funds", trade);
+
+            logTrade(log, testInfo, "Alice's Maker/Buyer View (Done)", aliceClient.getTrade(tradeId), true);
+            logTrade(log, testInfo, "Bob's Taker/Seller View (Done)", bobClient.getTrade(tradeId), true);
+
+            var alicesBalances = aliceClient.getBalances();
+            log.info("{} Alice's Current Balance:\n{}",
+                    testName(testInfo),
+                    formatBalancesTbls(alicesBalances));
+            var bobsBalances = bobClient.getBalances();
+            log.info("{} Bob's Current Balance:\n{}",
+                    testName(testInfo),
+                    formatBalancesTbls(bobsBalances));
+
+        } catch (StatusRuntimeException e) {
+            fail(e);
+        }
+    }
+}
diff --git a/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBTCOfferTest.java b/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBTCOfferTest.java
index 8c2f96fecb1..f19c4fd8b1f 100644
--- a/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBTCOfferTest.java
+++ b/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBTCOfferTest.java
@@ -19,7 +19,6 @@
 
 import bisq.core.payment.PaymentAccount;
 
-import bisq.proto.grpc.BtcBalanceInfo;
 import bisq.proto.grpc.TradeInfo;
 
 import io.grpc.StatusRuntimeException;
@@ -35,10 +34,9 @@
 import org.junit.jupiter.api.TestInfo;
 import org.junit.jupiter.api.TestMethodOrder;
 
-import static bisq.cli.CurrencyFormat.formatSatoshis;
+import static bisq.cli.TableFormat.formatBalancesTbls;
 import static bisq.core.btc.wallet.Restrictions.getDefaultBuyerSecurityDepositAsPercent;
 import static bisq.core.trade.Trade.Phase.DEPOSIT_CONFIRMED;
-import static bisq.core.trade.Trade.Phase.DEPOSIT_PUBLISHED;
 import static bisq.core.trade.Trade.Phase.FIAT_SENT;
 import static bisq.core.trade.Trade.Phase.PAYOUT_PUBLISHED;
 import static bisq.core.trade.Trade.State.*;
@@ -48,12 +46,9 @@
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.fail;
 import static protobuf.Offer.State.OFFER_FEE_PAID;
+import static protobuf.OfferPayload.Direction.BUY;
 import static protobuf.OpenOffer.State.AVAILABLE;
 
-
-
-import bisq.cli.TradeFormat;
-
 @Disabled
 @Slf4j
 @TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@@ -62,17 +57,17 @@ public class TakeBuyBTCOfferTest extends AbstractTradeTest {
     // Alice is maker/buyer, Bob is taker/seller.
 
     // Maker and Taker fees are in BSQ.
-    private static final String TRADE_FEE_CURRENCY_CODE = "bsq";
+    private static final String TRADE_FEE_CURRENCY_CODE = BSQ;
 
     @Test
     @Order(1)
     public void testTakeAlicesBuyOffer(final TestInfo testInfo) {
         try {
             PaymentAccount alicesUsdAccount = createDummyF2FAccount(aliceClient, "US");
-            var alicesOffer = aliceClient.createMarketBasedPricedOffer("buy",
+            var alicesOffer = aliceClient.createMarketBasedPricedOffer(BUY.name(),
                     "usd",
-                    12500000,
-                    12500000, // min-amount = amount
+                    12_500_000L,
+                    12_500_000L, // min-amount = amount
                     0.00,
                     getDefaultBuyerSecurityDepositAsPercent(),
                     alicesUsdAccount.getId(),
@@ -83,7 +78,7 @@ public void testTakeAlicesBuyOffer(final TestInfo testInfo) {
             // Wait for Alice's AddToOfferBook task.
             // Wait times vary;  my logs show >= 2 second delay.
             sleep(3000); // TODO loop instead of hard code wait time
-            var alicesUsdOffers = aliceClient.getMyOffersSortedByDate("buy", "usd");
+            var alicesUsdOffers = aliceClient.getMyOffersSortedByDate(BUY.name(), "usd");
             assertEquals(1, alicesUsdOffers.size());
 
             PaymentAccount bobsUsdAccount = createDummyF2FAccount(bobClient, "US");
@@ -95,18 +90,9 @@ public void testTakeAlicesBuyOffer(final TestInfo testInfo) {
             tradeId = trade.getTradeId();
 
             genBtcBlocksThenWait(1, 4000);
-            alicesUsdOffers = aliceClient.getMyOffersSortedByDate("buy", "usd");
+            alicesUsdOffers = aliceClient.getMyOffersSortedByDate(BUY.name(), "usd");
             assertEquals(0, alicesUsdOffers.size());
 
-            if (!isLongRunningTest) {
-                trade = bobClient.getTrade(trade.getTradeId());
-                EXPECTED_PROTOCOL_STATUS.setState(SELLER_PUBLISHED_DEPOSIT_TX)
-                        .setPhase(DEPOSIT_PUBLISHED)
-                        .setDepositPublished(true);
-                verifyExpectedProtocolStatus(trade);
-                logTrade(log, testInfo, "Bob's view after taking offer and sending deposit", trade);
-            }
-
             genBtcBlocksThenWait(1, 2500);
 
             for (int i = 1; i <= maxTradeStateAndPhaseChecks.get(); i++) {
@@ -117,14 +103,15 @@ public void testTakeAlicesBuyOffer(final TestInfo testInfo) {
                             trade.getShortId(),
                             trade.getDepositTxId(),
                             i);
-                    sleep(5000);
+                    genBtcBlocksThenWait(1, 4000);
                     continue;
                 } else {
                     EXPECTED_PROTOCOL_STATUS.setState(DEPOSIT_CONFIRMED_IN_BLOCK_CHAIN)
                             .setPhase(DEPOSIT_CONFIRMED)
+                            .setDepositPublished(true)
                             .setDepositConfirmed(true);
                     verifyExpectedProtocolStatus(trade);
-                    logTrade(log, testInfo, "Bob's view after deposit is confirmed", trade);
+                    logTrade(log, testInfo, "Bob's view after deposit is confirmed", trade, true);
                     break;
                 }
             }
@@ -269,11 +256,18 @@ public void testAlicesKeepFunds(final TestInfo testInfo) {
                     .setPhase(PAYOUT_PUBLISHED);
             verifyExpectedProtocolStatus(trade);
             logTrade(log, testInfo, "Alice's view after keeping funds", trade);
-            BtcBalanceInfo currentBalance = aliceClient.getBtcBalances();
-            log.info("{} Alice's current available balance: {} BTC.  Last trade:\n{}",
+
+            logTrade(log, testInfo, "Alice's Maker/Buyer View (Done)", aliceClient.getTrade(tradeId), true);
+            logTrade(log, testInfo, "Bob's Taker/Seller View (Done)", bobClient.getTrade(tradeId), true);
+
+            var alicesBalances = aliceClient.getBalances();
+            log.info("{} Alice's Current Balance:\n{}",
+                    testName(testInfo),
+                    formatBalancesTbls(alicesBalances));
+            var bobsBalances = bobClient.getBalances();
+            log.info("{} Bob's Current Balance:\n{}",
                     testName(testInfo),
-                    formatSatoshis(currentBalance.getAvailableBalance()),
-                    TradeFormat.format(aliceClient.getTrade(tradeId)));
+                    formatBalancesTbls(bobsBalances));
         } catch (StatusRuntimeException e) {
             fail(e);
         }
diff --git a/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBSQOfferTest.java b/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBSQOfferTest.java
new file mode 100644
index 00000000000..309eb5da14b
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBSQOfferTest.java
@@ -0,0 +1,323 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see .
+ */
+
+package bisq.apitest.method.trade;
+
+import bisq.proto.grpc.ContractInfo;
+import bisq.proto.grpc.TradeInfo;
+
+import io.grpc.StatusRuntimeException;
+
+import java.util.function.Predicate;
+
+import lombok.extern.slf4j.Slf4j;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInfo;
+import org.junit.jupiter.api.TestMethodOrder;
+
+import static bisq.cli.CurrencyFormat.formatBsqSendAmount;
+import static bisq.cli.TableFormat.formatBalancesTbls;
+import static bisq.cli.TableFormat.formatOfferTable;
+import static bisq.core.btc.wallet.Restrictions.getDefaultBuyerSecurityDepositAsPercent;
+import static bisq.core.trade.Trade.Phase.DEPOSIT_CONFIRMED;
+import static bisq.core.trade.Trade.Phase.FIAT_SENT;
+import static bisq.core.trade.Trade.Phase.PAYOUT_PUBLISHED;
+import static bisq.core.trade.Trade.Phase.WITHDRAWN;
+import static bisq.core.trade.Trade.State.DEPOSIT_CONFIRMED_IN_BLOCK_CHAIN;
+import static bisq.core.trade.Trade.State.SELLER_RECEIVED_FIAT_PAYMENT_INITIATED_MSG;
+import static bisq.core.trade.Trade.State.SELLER_SAW_ARRIVED_PAYOUT_TX_PUBLISHED_MSG;
+import static bisq.core.trade.Trade.State.WITHDRAW_COMPLETED;
+import static java.lang.String.format;
+import static java.util.Collections.singletonList;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static protobuf.OfferPayload.Direction.BUY;
+
+
+
+import bisq.apitest.method.offer.AbstractOfferTest;
+
+@Disabled
+@Slf4j
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+public class TakeSellBSQOfferTest extends AbstractTradeTest {
+
+    // Alice is maker / bsq seller (btc buyer), Bob is taker / bsq buyer (btc seller).
+
+    // Maker and Taker fees are in BTC.
+    private static final String TRADE_FEE_CURRENCY_CODE = BTC;
+
+    private static final String WITHDRAWAL_TX_MEMO = "Bob's trade withdrawal";
+
+    @BeforeAll
+    public static void setUp() {
+        AbstractOfferTest.setUp();
+        createBsqPaymentAccounts();
+        EXPECTED_PROTOCOL_STATUS.init();
+    }
+
+    @Test
+    @Order(1)
+    public void testTakeAlicesBuyBTCForBSQOffer(final TestInfo testInfo) {
+        try {
+            // Alice is going to SELL BSQ, but the Offer direction = BUY because it is a
+            // BTC trade;  Alice will BUY BTC for BSQ.  Alice will send Bob BSQ.
+            // Confused me, but just need to remember there are only BTC offers.
+            var btcTradeDirection = BUY.name();
+            var alicesOffer = aliceClient.createFixedPricedOffer(btcTradeDirection,
+                    BSQ,
+                    15_000_000L,
+                    7_500_000L,
+                    "0.000035",   // FIXED PRICE IN BTC (satoshis) FOR 1 BSQ
+                    getDefaultBuyerSecurityDepositAsPercent(),
+                    alicesBsqAcct.getId(),
+                    TRADE_FEE_CURRENCY_CODE);
+            log.info("ALICE'S SELL BSQ (BUY BTC) OFFER:\n{}", formatOfferTable(singletonList(alicesOffer), BSQ));
+            genBtcBlocksThenWait(1, 4000);
+            var offerId = alicesOffer.getId();
+            assertTrue(alicesOffer.getIsCurrencyForMakerFeeBtc());
+
+            var alicesBsqOffers = aliceClient.getMyBsqOffers(btcTradeDirection);
+            assertEquals(1, alicesBsqOffers.size());
+
+            var trade = takeAlicesOffer(offerId, bobsBsqAcct.getId(), TRADE_FEE_CURRENCY_CODE);
+            assertNotNull(trade);
+            assertEquals(offerId, trade.getTradeId());
+            assertTrue(trade.getIsCurrencyForTakerFeeBtc());
+            // Cache the trade id for the other tests.
+            tradeId = trade.getTradeId();
+
+            genBtcBlocksThenWait(1, 6000);
+            alicesBsqOffers = aliceClient.getMyBsqOffersSortedByDate();
+            assertEquals(0, alicesBsqOffers.size());
+
+            for (int i = 1; i <= maxTradeStateAndPhaseChecks.get(); i++) {
+                trade = bobClient.getTrade(trade.getTradeId());
+
+                if (!trade.getIsDepositConfirmed()) {
+                    log.warn("Bob still waiting on trade {} tx {}: DEPOSIT_CONFIRMED_IN_BLOCK_CHAIN, attempt # {}",
+                            trade.getShortId(),
+                            trade.getDepositTxId(),
+                            i);
+                    genBtcBlocksThenWait(1, 4000);
+                    continue;
+                } else {
+                    EXPECTED_PROTOCOL_STATUS.setState(DEPOSIT_CONFIRMED_IN_BLOCK_CHAIN)
+                            .setPhase(DEPOSIT_CONFIRMED)
+                            .setDepositPublished(true)
+                            .setDepositConfirmed(true);
+                    verifyExpectedProtocolStatus(trade);
+                    logTrade(log, testInfo, "Bob's view after taking offer and deposit confirmed", trade);
+                    break;
+                }
+            }
+
+            genBtcBlocksThenWait(1, 2500);
+
+            if (!trade.getIsDepositConfirmed()) {
+                fail(format("INVALID_PHASE for Bob's trade %s in STATE=%s PHASE=%s, deposit tx was never confirmed.",
+                        trade.getShortId(),
+                        trade.getState(),
+                        trade.getPhase()));
+            }
+
+            logTrade(log, testInfo, "Alice's Maker/Seller View", aliceClient.getTrade(tradeId), true);
+            logTrade(log, testInfo, "Bob's Taker/Buyer View", bobClient.getTrade(tradeId), true);
+
+            if (false) {
+                log.warn("Sleeping for 30 min while test CLI.");
+                sleep(30 * 60 * 1000);
+            }
+
+        } catch (StatusRuntimeException e) {
+            fail(e);
+        }
+    }
+
+    @Test
+    @Order(2)
+    public void testAlicesConfirmPaymentStarted(final TestInfo testInfo) {
+        try {
+            var trade = aliceClient.getTrade(tradeId);
+
+            Predicate tradeStateAndPhaseCorrect = (t) ->
+                    t.getState().equals(DEPOSIT_CONFIRMED_IN_BLOCK_CHAIN.name())
+                            && t.getPhase().equals(DEPOSIT_CONFIRMED.name());
+
+            for (int i = 1; i <= maxTradeStateAndPhaseChecks.get(); i++) {
+                if (!tradeStateAndPhaseCorrect.test(trade)) {
+                    log.warn("INVALID_PHASE for Alice's trade {} in STATE={} PHASE={}, cannot send payment started msg yet.",
+                            trade.getShortId(),
+                            trade.getState(),
+                            trade.getPhase());
+                    sleep(10_000);
+                    trade = aliceClient.getTrade(tradeId);
+                    continue;
+                } else {
+                    break;
+                }
+            }
+
+            if (!tradeStateAndPhaseCorrect.test(trade)) {
+                fail(format("INVALID_PHASE for Alice's trade %s in STATE=%s PHASE=%s, could not send payment started msg.",
+                        trade.getShortId(),
+                        trade.getState(),
+                        trade.getPhase()));
+            }
+
+            // TODO refactor into superclass?  Might be needed in :cli.
+            ContractInfo contract = trade.getContract();
+            String bobsBsqAddress = contract.getIsBuyerMakerAndSellerTaker()
+                    ? contract.getTakerPaymentAccountPayload().getAddress()
+                    : contract.getMakerPaymentAccountPayload().getAddress();
+            String sendBsqAmount = formatBsqSendAmount(trade.getOffer().getVolume());
+            log.info("Alice sending {} BSQ to Bob's address {}", sendBsqAmount, bobsBsqAddress);
+            aliceClient.sendBsq(bobsBsqAddress, sendBsqAmount, "");
+
+            genBtcBlocksThenWait(1, 2500);
+            aliceClient.confirmPaymentStarted(trade.getTradeId());
+            sleep(6000);
+
+            for (int i = 1; i <= maxTradeStateAndPhaseChecks.get(); i++) {
+                trade = bobClient.getTrade(tradeId);
+
+                if (!trade.getIsFiatSent()) {
+                    log.warn("Bob still waiting for trade {} SELLER_RECEIVED_FIAT_PAYMENT_INITIATED_MSG, attempt # {}",
+                            trade.getShortId(),
+                            i);
+                    sleep(5000);
+                    continue;
+                } else {
+                    // Warning:  trade.getOffer().getState() might be AVAILABLE, not OFFER_FEE_PAID.
+                    EXPECTED_PROTOCOL_STATUS.setState(SELLER_RECEIVED_FIAT_PAYMENT_INITIATED_MSG)
+                            .setPhase(FIAT_SENT)
+                            .setFiatSent(true);
+                    verifyExpectedProtocolStatus(trade);
+                    logTrade(log, testInfo, "Alice's view after confirming fiat payment received", trade);
+                    break;
+                }
+            }
+
+            logTrade(log, testInfo, "Alice's Maker/Seller View (Payment Sent)", aliceClient.getTrade(tradeId), true);
+            logTrade(log, testInfo, "Bob's Taker/Buyer View (Payment Sent)", bobClient.getTrade(tradeId), true);
+
+            if (false) {
+                log.warn("Sleeping for 30 min while test CLI.");
+                sleep(30 * 60 * 1000);
+            }
+        } catch (StatusRuntimeException e) {
+            fail(e);
+        }
+    }
+
+    @Test
+    @Order(3)
+    public void testBobsConfirmPaymentReceived(final TestInfo testInfo) {
+        try {
+            var trade = bobClient.getTrade(tradeId);
+
+            Predicate tradeStateAndPhaseCorrect = (t) ->
+                    t.getState().equals(SELLER_RECEIVED_FIAT_PAYMENT_INITIATED_MSG.name())
+                            && (t.getPhase().equals(PAYOUT_PUBLISHED.name()) || t.getPhase().equals(FIAT_SENT.name()));
+
+            for (int i = 1; i <= maxTradeStateAndPhaseChecks.get(); i++) {
+                if (!tradeStateAndPhaseCorrect.test(trade)) {
+                    log.warn("INVALID_PHASE for Bob's trade {} in STATE={} PHASE={}, cannot confirm payment received yet.",
+                            trade.getShortId(),
+                            trade.getState(),
+                            trade.getPhase());
+                    sleep(1000 * 10);
+                    trade = bobClient.getTrade(tradeId);
+                    continue;
+                } else {
+                    break;
+                }
+            }
+
+            if (!tradeStateAndPhaseCorrect.test(trade)) {
+                fail(format("INVALID_PHASE for Bob's trade %s in STATE=%s PHASE=%s, cannot confirm payment received.",
+                        trade.getShortId(),
+                        trade.getState(),
+                        trade.getPhase()));
+            }
+
+            bobClient.confirmPaymentReceived(trade.getTradeId());
+            sleep(3000);
+
+            trade = bobClient.getTrade(tradeId);
+            // Warning:  trade.getOffer().getState() might be AVAILABLE, not OFFER_FEE_PAID.
+            EXPECTED_PROTOCOL_STATUS.setState(SELLER_SAW_ARRIVED_PAYOUT_TX_PUBLISHED_MSG)
+                    .setPhase(PAYOUT_PUBLISHED)
+                    .setPayoutPublished(true)
+                    .setFiatReceived(true);
+            verifyExpectedProtocolStatus(trade);
+            logTrade(log, testInfo, "Alice's view after confirming fiat payment received", trade);
+
+            logTrade(log, testInfo, "Alice's Maker/Seller View (Payment Received)", aliceClient.getTrade(tradeId), true);
+            logTrade(log, testInfo, "Bob's Taker/Buyer View (Payment Received)", bobClient.getTrade(tradeId), true);
+
+        } catch (StatusRuntimeException e) {
+            fail(e);
+        }
+    }
+
+    @Test
+    @Order(4)
+    public void testAlicesBtcWithdrawalToExternalAddress(final TestInfo testInfo) {
+        try {
+            genBtcBlocksThenWait(1, 1000);
+
+            var trade = aliceClient.getTrade(tradeId);
+            logTrade(log, testInfo, "Alice's view before withdrawing BTC funds to external wallet", trade);
+
+            String toAddress = bitcoinCli.getNewBtcAddress();
+            aliceClient.withdrawFunds(tradeId, toAddress, WITHDRAWAL_TX_MEMO);
+
+            genBtcBlocksThenWait(1, 1000);
+
+            trade = aliceClient.getTrade(tradeId);
+            EXPECTED_PROTOCOL_STATUS.setState(WITHDRAW_COMPLETED)
+                    .setPhase(WITHDRAWN)
+                    .setWithdrawn(true);
+            verifyExpectedProtocolStatus(trade);
+            logTrade(log, testInfo, "Alice's view after withdrawing funds to external wallet", trade);
+
+
+            logTrade(log, testInfo, "Alice's Maker/Seller View (Done)", aliceClient.getTrade(tradeId), true);
+            logTrade(log, testInfo, "Bob's Taker/Buyer View (Done)", bobClient.getTrade(tradeId), true);
+
+            var alicesBalances = aliceClient.getBalances();
+            log.info("{} Alice's Current Balance:\n{}",
+                    testName(testInfo),
+                    formatBalancesTbls(alicesBalances));
+            var bobsBalances = bobClient.getBalances();
+            log.info("{} Bob's Current Balance:\n{}",
+                    testName(testInfo),
+                    formatBalancesTbls(bobsBalances));
+
+        } catch (StatusRuntimeException e) {
+            fail(e);
+        }
+    }
+}
diff --git a/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBTCOfferTest.java b/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBTCOfferTest.java
index 09d27453d31..832b61b824a 100644
--- a/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBTCOfferTest.java
+++ b/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBTCOfferTest.java
@@ -19,7 +19,6 @@
 
 import bisq.core.payment.PaymentAccount;
 
-import bisq.proto.grpc.BtcBalanceInfo;
 import bisq.proto.grpc.TradeInfo;
 
 import io.grpc.StatusRuntimeException;
@@ -35,9 +34,12 @@
 import org.junit.jupiter.api.TestInfo;
 import org.junit.jupiter.api.TestMethodOrder;
 
-import static bisq.cli.CurrencyFormat.formatSatoshis;
+import static bisq.cli.TableFormat.formatBalancesTbls;
 import static bisq.core.btc.wallet.Restrictions.getDefaultBuyerSecurityDepositAsPercent;
-import static bisq.core.trade.Trade.Phase.*;
+import static bisq.core.trade.Trade.Phase.DEPOSIT_CONFIRMED;
+import static bisq.core.trade.Trade.Phase.FIAT_SENT;
+import static bisq.core.trade.Trade.Phase.PAYOUT_PUBLISHED;
+import static bisq.core.trade.Trade.Phase.WITHDRAWN;
 import static bisq.core.trade.Trade.State.*;
 import static java.lang.String.format;
 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -45,12 +47,9 @@
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.fail;
 import static protobuf.Offer.State.OFFER_FEE_PAID;
+import static protobuf.OfferPayload.Direction.SELL;
 import static protobuf.OpenOffer.State.AVAILABLE;
 
-
-
-import bisq.cli.TradeFormat;
-
 @Disabled
 @Slf4j
 @TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@@ -59,7 +58,7 @@ public class TakeSellBTCOfferTest extends AbstractTradeTest {
     // Alice is maker/seller, Bob is taker/buyer.
 
     // Maker and Taker fees are in BTC.
-    private static final String TRADE_FEE_CURRENCY_CODE = "btc";
+    private static final String TRADE_FEE_CURRENCY_CODE = BTC;
 
     private static final String WITHDRAWAL_TX_MEMO = "Bob's trade withdrawal";
 
@@ -68,10 +67,10 @@ public class TakeSellBTCOfferTest extends AbstractTradeTest {
     public void testTakeAlicesSellOffer(final TestInfo testInfo) {
         try {
             PaymentAccount alicesUsdAccount = createDummyF2FAccount(aliceClient, "US");
-            var alicesOffer = aliceClient.createMarketBasedPricedOffer("sell",
+            var alicesOffer = aliceClient.createMarketBasedPricedOffer(SELL.name(),
                     "usd",
-                    12500000L,
-                    12500000L, // min-amount = amount
+                    12_500_000L,
+                    12_500_000L, // min-amount = amount
                     0.00,
                     getDefaultBuyerSecurityDepositAsPercent(),
                     alicesUsdAccount.getId(),
@@ -83,7 +82,7 @@ public void testTakeAlicesSellOffer(final TestInfo testInfo) {
             // Wait times vary;  my logs show >= 2 second delay, but taking sell offers
             // seems to require more time to prepare.
             sleep(3000); // TODO loop instead of hard code wait time
-            var alicesUsdOffers = aliceClient.getMyOffersSortedByDate("sell", "usd");
+            var alicesUsdOffers = aliceClient.getMyOffersSortedByDate(SELL.name(), "usd");
             assertEquals(1, alicesUsdOffers.size());
 
             PaymentAccount bobsUsdAccount = createDummyF2FAccount(bobClient, "US");
@@ -95,18 +94,9 @@ public void testTakeAlicesSellOffer(final TestInfo testInfo) {
             tradeId = trade.getTradeId();
 
             genBtcBlocksThenWait(1, 4000);
-            var takeableUsdOffers = bobClient.getOffersSortedByDate("sell", "usd");
+            var takeableUsdOffers = bobClient.getOffersSortedByDate(SELL.name(), "usd");
             assertEquals(0, takeableUsdOffers.size());
 
-            if (!isLongRunningTest) {
-                trade = bobClient.getTrade(trade.getTradeId());
-                EXPECTED_PROTOCOL_STATUS.setState(BUYER_RECEIVED_DEPOSIT_TX_PUBLISHED_MSG)
-                        .setPhase(DEPOSIT_PUBLISHED)
-                        .setDepositPublished(true);
-                verifyExpectedProtocolStatus(trade);
-                logTrade(log, testInfo, "Bob's view after taking offer and sending deposit", trade);
-            }
-
             genBtcBlocksThenWait(1, 2500);
 
             for (int i = 1; i <= maxTradeStateAndPhaseChecks.get(); i++) {
@@ -117,14 +107,15 @@ public void testTakeAlicesSellOffer(final TestInfo testInfo) {
                             trade.getShortId(),
                             trade.getDepositTxId(),
                             i);
-                    sleep(5000);
+                    genBtcBlocksThenWait(1, 4000);
                     continue;
                 } else {
                     EXPECTED_PROTOCOL_STATUS.setState(DEPOSIT_CONFIRMED_IN_BLOCK_CHAIN)
                             .setPhase(DEPOSIT_CONFIRMED)
+                            .setDepositPublished(true)
                             .setDepositConfirmed(true);
                     verifyExpectedProtocolStatus(trade);
-                    logTrade(log, testInfo, "Bob's view after deposit is confirmed", trade);
+                    logTrade(log, testInfo, "Bob's view after deposit is confirmed", trade, true);
                     break;
                 }
             }
@@ -265,12 +256,19 @@ public void testBobsBtcWithdrawalToExternalAddress(final TestInfo testInfo) {
                     .setPhase(WITHDRAWN)
                     .setWithdrawn(true);
             verifyExpectedProtocolStatus(trade);
-            logTrade(log, testInfo, "Bob's view after withdrawing funds to external wallet", trade);
-            BtcBalanceInfo currentBalance = bobClient.getBtcBalances();
-            log.info("{} Bob's current available balance: {} BTC.  Last trade:\n{}",
+            logTrade(log, testInfo, "Bob's view after withdrawing BTC funds to external wallet", trade);
+
+            logTrade(log, testInfo, "Alice's Maker/Buyer View (Done)", aliceClient.getTrade(tradeId), true);
+            logTrade(log, testInfo, "Bob's Taker/Seller View (Done)", bobClient.getTrade(tradeId), true);
+
+            var alicesBalances = aliceClient.getBalances();
+            log.info("{} Alice's Current Balance:\n{}",
+                    testName(testInfo),
+                    formatBalancesTbls(alicesBalances));
+            var bobsBalances = bobClient.getBalances();
+            log.info("{} Bob's Current Balance:\n{}",
                     testName(testInfo),
-                    formatSatoshis(currentBalance.getAvailableBalance()),
-                    TradeFormat.format(bobClient.getTrade(tradeId)));
+                    formatBalancesTbls(bobsBalances));
         } catch (StatusRuntimeException e) {
             fail(e);
         }
diff --git a/apitest/src/test/java/bisq/apitest/scenario/OfferTest.java b/apitest/src/test/java/bisq/apitest/scenario/OfferTest.java
index c82cbaef90e..cb1173b0143 100644
--- a/apitest/src/test/java/bisq/apitest/scenario/OfferTest.java
+++ b/apitest/src/test/java/bisq/apitest/scenario/OfferTest.java
@@ -29,6 +29,7 @@
 
 import bisq.apitest.method.offer.AbstractOfferTest;
 import bisq.apitest.method.offer.CancelOfferTest;
+import bisq.apitest.method.offer.CreateBSQOffersTest;
 import bisq.apitest.method.offer.CreateOfferUsingFixedPriceTest;
 import bisq.apitest.method.offer.CreateOfferUsingMarketPriceMarginTest;
 import bisq.apitest.method.offer.ValidateCreateOfferTest;
@@ -71,4 +72,18 @@ public void testCreateOfferUsingMarketPriceMargin() {
         test.testCreateGBPBTCSellOfferMinus1Point5PctPriceMargin();
         test.testCreateBRLBTCSellOffer6Point55PctPriceMargin();
     }
+
+    @Test
+    @Order(5)
+    public void testCreateBSQOffersTest() {
+        CreateBSQOffersTest test = new CreateBSQOffersTest();
+        CreateBSQOffersTest.createBsqPaymentAccounts();
+        test.testCreateBuy1BTCFor20KBSQOffer();
+        test.testCreateSell1BTCFor20KBSQOffer();
+        test.testCreateBuyBTCWith1To2KBSQOffer();
+        test.testCreateSellBTCFor5To10KBSQOffer();
+        test.testGetAllMyBsqOffers();
+        test.testGetAvailableBsqOffers();
+        test.testBreakpoint();
+    }
 }
diff --git a/apitest/src/test/java/bisq/apitest/scenario/TradeTest.java b/apitest/src/test/java/bisq/apitest/scenario/TradeTest.java
index 4c07452abc6..f4e93ad35ae 100644
--- a/apitest/src/test/java/bisq/apitest/scenario/TradeTest.java
+++ b/apitest/src/test/java/bisq/apitest/scenario/TradeTest.java
@@ -29,7 +29,9 @@
 
 
 import bisq.apitest.method.trade.AbstractTradeTest;
+import bisq.apitest.method.trade.TakeBuyBSQOfferTest;
 import bisq.apitest.method.trade.TakeBuyBTCOfferTest;
+import bisq.apitest.method.trade.TakeSellBSQOfferTest;
 import bisq.apitest.method.trade.TakeSellBTCOfferTest;
 
 
@@ -61,4 +63,26 @@ public void testTakeSellBTCOffer(final TestInfo testInfo) {
         test.testAlicesConfirmPaymentReceived(testInfo);
         test.testBobsBtcWithdrawalToExternalAddress(testInfo);
     }
+
+    @Test
+    @Order(3)
+    public void testTakeBuyBSQOffer(final TestInfo testInfo) {
+        TakeBuyBSQOfferTest test = new TakeBuyBSQOfferTest();
+        TakeBuyBSQOfferTest.createBsqPaymentAccounts();
+        test.testTakeAlicesSellBTCForBSQOffer(testInfo);
+        test.testBobsConfirmPaymentStarted(testInfo);
+        test.testAlicesConfirmPaymentReceived(testInfo);
+        test.testBobsKeepFunds(testInfo);
+    }
+
+    @Test
+    @Order(4)
+    public void testTakeSellBSQOffer(final TestInfo testInfo) {
+        TakeSellBSQOfferTest test = new TakeSellBSQOfferTest();
+        TakeSellBSQOfferTest.createBsqPaymentAccounts();
+        test.testTakeAlicesBuyBTCForBSQOffer(testInfo);
+        test.testAlicesConfirmPaymentStarted(testInfo);
+        test.testBobsConfirmPaymentReceived(testInfo);
+        test.testAlicesBtcWithdrawalToExternalAddress(testInfo);
+    }
 }
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/BotClient.java b/apitest/src/test/java/bisq/apitest/scenario/bot/BotClient.java
index c34dc14d28b..3334fa82e22 100644
--- a/apitest/src/test/java/bisq/apitest/scenario/bot/BotClient.java
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/BotClient.java
@@ -32,6 +32,8 @@
 import lombok.extern.slf4j.Slf4j;
 
 import static org.apache.commons.lang3.StringUtils.capitalize;
+import static protobuf.OfferPayload.Direction.BUY;
+import static protobuf.OfferPayload.Direction.SELL;
 
 
 
@@ -103,7 +105,7 @@ public List getOffers(String currencyCode) {
      * @return List
      */
     public List getBuyOffers(String currencyCode) {
-        return grpcClient.getOffers("BUY", currencyCode);
+        return grpcClient.getOffers(BUY.name(), currencyCode);
     }
 
     /**
@@ -112,7 +114,7 @@ public List getBuyOffers(String currencyCode) {
      * @return List
      */
     public List getSellOffers(String currencyCode) {
-        return grpcClient.getOffers("SELL", currencyCode);
+        return grpcClient.getOffers(SELL.name(), currencyCode);
     }
 
     /**
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/RandomOffer.java b/apitest/src/test/java/bisq/apitest/scenario/bot/RandomOffer.java
index 1942f8ad073..24524ac73a9 100644
--- a/apitest/src/test/java/bisq/apitest/scenario/bot/RandomOffer.java
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/RandomOffer.java
@@ -33,6 +33,8 @@
 import lombok.Getter;
 import lombok.extern.slf4j.Slf4j;
 
+import static bisq.apitest.method.MethodTest.BSQ;
+import static bisq.apitest.method.MethodTest.BTC;
 import static bisq.cli.CurrencyFormat.formatMarketPrice;
 import static bisq.cli.CurrencyFormat.formatSatoshis;
 import static bisq.common.util.MathUtils.scaleDownByPowerOf10;
@@ -40,6 +42,8 @@
 import static bisq.core.payment.payload.PaymentMethod.F2F_ID;
 import static java.lang.String.format;
 import static java.math.RoundingMode.HALF_UP;
+import static protobuf.OfferPayload.Direction.BUY;
+import static protobuf.OfferPayload.Direction.SELL;
 
 @Slf4j
 public class RandomOffer {
@@ -108,13 +112,13 @@ public class RandomOffer {
     public RandomOffer(BotClient botClient, PaymentAccount paymentAccount) {
         this.botClient = botClient;
         this.paymentAccount = paymentAccount;
-        this.direction = RANDOM.nextBoolean() ? "BUY" : "SELL";
+        this.direction = RANDOM.nextBoolean() ? BUY.name() : SELL.name();
         this.currencyCode = Objects.requireNonNull(paymentAccount.getSelectedTradeCurrency()).getCode();
         this.amount = nextAmount.get();
         this.minAmount = nextMinAmount.get();
         this.useMarketBasedPrice = RANDOM.nextBoolean();
         this.priceMargin = nextPriceMargin.get();
-        this.feeCurrency = RANDOM.nextBoolean() ? "BSQ" : "BTC";
+        this.feeCurrency = RANDOM.nextBoolean() ? BSQ : BTC;
     }
 
     public RandomOffer create() throws InvalidRandomOfferException {
@@ -154,7 +158,7 @@ private void printDescription() {
         double currentMarketPrice = botClient.getCurrentBTCMarketPrice(currencyCode);
         // Calculate a fixed price based on the random mkt price margin, even if we don't use it.
         double differenceFromMarketPrice = currentMarketPrice * scaleDownByPowerOf10(priceMargin, 2);
-        double fixedOfferPriceAsDouble = direction.equals("BUY")
+        double fixedOfferPriceAsDouble = direction.equals(BUY.name())
                 ? currentMarketPrice - differenceFromMarketPrice
                 : currentMarketPrice + differenceFromMarketPrice;
         this.fixedOfferPrice = FIXED_PRICE_FMT.format(fixedOfferPriceAsDouble);
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/BotProtocol.java b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/BotProtocol.java
index 51d59e7537d..b575059945a 100644
--- a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/BotProtocol.java
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/BotProtocol.java
@@ -43,6 +43,8 @@
 import static java.lang.System.currentTimeMillis;
 import static java.util.Arrays.stream;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static protobuf.OfferPayload.Direction.BUY;
+import static protobuf.OfferPayload.Direction.SELL;
 
 
 
@@ -56,8 +58,6 @@
 public abstract class BotProtocol {
 
     static final SecureRandom RANDOM = new SecureRandom();
-    static final String BUY = "BUY";
-    static final String SELL = "SELL";
 
     protected final Supplier randomDelay = () -> (long) (2000 + RANDOM.nextInt(5000));
 
@@ -221,8 +221,8 @@ protected void printBotProtocolStep() {
 
     protected final Function keepFundsFromTrade = (trade) -> {
         initProtocolStep.accept(KEEP_FUNDS);
-        var isBuy = trade.getOffer().getDirection().equalsIgnoreCase(BUY);
-        var isSell = trade.getOffer().getDirection().equalsIgnoreCase(SELL);
+        var isBuy = trade.getOffer().getDirection().equalsIgnoreCase(BUY.name());
+        var isSell = trade.getOffer().getDirection().equalsIgnoreCase(SELL.name());
         var cliUserIsSeller = (this instanceof MakerBotProtocol && isBuy) || (this instanceof TakerBotProtocol && isSell);
         if (cliUserIsSeller) {
             createKeepFundsScript(trade);
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/MakerBotProtocol.java b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/MakerBotProtocol.java
index 0ce26002ece..ced6391efd0 100644
--- a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/MakerBotProtocol.java
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/MakerBotProtocol.java
@@ -18,6 +18,7 @@
 import static bisq.apitest.scenario.bot.shutdown.ManualShutdown.checkIfShutdownCalled;
 import static bisq.cli.TableFormat.formatOfferTable;
 import static java.util.Collections.singletonList;
+import static protobuf.OfferPayload.Direction.BUY;
 
 
 
@@ -50,7 +51,7 @@ public void run() {
         Function, TradeInfo> makeTrade = waitForNewTrade.andThen(waitForTakerFeeTxConfirm);
         var trade = makeTrade.apply(randomOffer);
 
-        var makerIsBuyer = trade.getOffer().getDirection().equalsIgnoreCase(BUY);
+        var makerIsBuyer = trade.getOffer().getDirection().equalsIgnoreCase(BUY.name());
         Function completeFiatTransaction = makerIsBuyer
                 ? sendPaymentStartedMessage.andThen(waitForPaymentReceivedConfirmation)
                 : waitForPaymentStartedMessage.andThen(sendPaymentReceivedMessage);
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/TakerBotProtocol.java b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/TakerBotProtocol.java
index 63b700824f6..ff191b7a458 100644
--- a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/TakerBotProtocol.java
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/TakerBotProtocol.java
@@ -13,12 +13,16 @@
 
 import lombok.extern.slf4j.Slf4j;
 
+import static bisq.apitest.method.MethodTest.BSQ;
+import static bisq.apitest.method.MethodTest.BTC;
 import static bisq.apitest.scenario.bot.protocol.ProtocolStep.DONE;
 import static bisq.apitest.scenario.bot.protocol.ProtocolStep.FIND_OFFER;
 import static bisq.apitest.scenario.bot.protocol.ProtocolStep.TAKE_OFFER;
 import static bisq.apitest.scenario.bot.shutdown.ManualShutdown.checkIfShutdownCalled;
 import static bisq.cli.TableFormat.formatOfferTable;
 import static bisq.core.payment.payload.PaymentMethod.F2F_ID;
+import static protobuf.OfferPayload.Direction.BUY;
+import static protobuf.OfferPayload.Direction.SELL;
 
 
 
@@ -49,7 +53,7 @@ public void run() {
         Function takeTrade = takeOffer.andThen(waitForTakerFeeTxConfirm);
         var trade = takeTrade.apply(findOffer.get());
 
-        var takerIsSeller = trade.getOffer().getDirection().equalsIgnoreCase(BUY);
+        var takerIsSeller = trade.getOffer().getDirection().equalsIgnoreCase(BUY.name());
         Function completeFiatTransaction = takerIsSeller
                 ? waitForPaymentStartedMessage.andThen(sendPaymentReceivedMessage)
                 : sendPaymentStartedMessage.andThen(waitForPaymentReceivedConfirmation);
@@ -102,13 +106,13 @@ public void run() {
     private final Function takeOffer = (offer) -> {
         initProtocolStep.accept(TAKE_OFFER);
         checkIfShutdownCalled("Interrupted before taking offer.");
-        String feeCurrency = RANDOM.nextBoolean() ? "BSQ" : "BTC";
+        String feeCurrency = RANDOM.nextBoolean() ? BSQ : BTC;
         return botClient.takeOffer(offer.getId(), paymentAccount, feeCurrency);
     };
 
     private void createMakeOfferScript() {
-        String direction = RANDOM.nextBoolean() ? "BUY" : "SELL";
-        String feeCurrency = RANDOM.nextBoolean() ? "BSQ" : "BTC";
+        String direction = RANDOM.nextBoolean() ? BUY.name() : SELL.name();
+        String feeCurrency = RANDOM.nextBoolean() ? BSQ : BTC;
         boolean createMarginPricedOffer = RANDOM.nextBoolean();
         // If not using an F2F account, don't go over possible 0.01 BTC
         // limit if account is not signed.

From 45db3cb5bbbf489fc78a8908fa3efd1b6f826021 Mon Sep 17 00:00:00 2001
From: ghubstan <36207203+ghubstan@users.noreply.github.com>
Date: Thu, 25 Mar 2021 20:18:54 -0300
Subject: [PATCH 05/21] Remove debugging code blocks

---
 .../bisq/apitest/method/trade/TakeBuyBSQOfferTest.java   | 9 ---------
 .../bisq/apitest/method/trade/TakeSellBSQOfferTest.java  | 9 ---------
 2 files changed, 18 deletions(-)

diff --git a/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBSQOfferTest.java b/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBSQOfferTest.java
index 9aa0cf7e803..b4897418e19 100644
--- a/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBSQOfferTest.java
+++ b/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBSQOfferTest.java
@@ -145,11 +145,6 @@ public void testTakeAlicesSellBTCForBSQOffer(final TestInfo testInfo) {
             logTrade(log, testInfo, "Alice's Maker/Buyer View", aliceClient.getTrade(tradeId), true);
             logTrade(log, testInfo, "Bob's Taker/Seller View", bobClient.getTrade(tradeId), true);
 
-            if (false) {
-                log.warn("Sleeping for 30 min while test CLI.");
-                sleep(30 * 60 * 1000);
-            }
-
         } catch (StatusRuntimeException e) {
             fail(e);
         }
@@ -223,10 +218,6 @@ public void testBobsConfirmPaymentStarted(final TestInfo testInfo) {
             logTrade(log, testInfo, "Alice's Maker/Buyer View (Payment Sent)", aliceClient.getTrade(tradeId), true);
             logTrade(log, testInfo, "Bob's Taker/Seller View (Payment Sent)", bobClient.getTrade(tradeId), true);
 
-            if (false) {
-                log.warn("Sleeping for 30 min while test CLI.");
-                sleep(30 * 60 * 1000);
-            }
         } catch (StatusRuntimeException e) {
             fail(e);
         }
diff --git a/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBSQOfferTest.java b/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBSQOfferTest.java
index 309eb5da14b..1c2d1e2139c 100644
--- a/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBSQOfferTest.java
+++ b/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBSQOfferTest.java
@@ -145,11 +145,6 @@ public void testTakeAlicesBuyBTCForBSQOffer(final TestInfo testInfo) {
             logTrade(log, testInfo, "Alice's Maker/Seller View", aliceClient.getTrade(tradeId), true);
             logTrade(log, testInfo, "Bob's Taker/Buyer View", bobClient.getTrade(tradeId), true);
 
-            if (false) {
-                log.warn("Sleeping for 30 min while test CLI.");
-                sleep(30 * 60 * 1000);
-            }
-
         } catch (StatusRuntimeException e) {
             fail(e);
         }
@@ -222,10 +217,6 @@ public void testAlicesConfirmPaymentStarted(final TestInfo testInfo) {
             logTrade(log, testInfo, "Alice's Maker/Seller View (Payment Sent)", aliceClient.getTrade(tradeId), true);
             logTrade(log, testInfo, "Bob's Taker/Buyer View (Payment Sent)", bobClient.getTrade(tradeId), true);
 
-            if (false) {
-                log.warn("Sleeping for 30 min while test CLI.");
-                sleep(30 * 60 * 1000);
-            }
         } catch (StatusRuntimeException e) {
             fail(e);
         }

From 780be1a93cf78f7ab947320843d0c23227d25a6b Mon Sep 17 00:00:00 2001
From: ghubstan <36207203+ghubstan@users.noreply.github.com>
Date: Fri, 26 Mar 2021 10:58:47 -0300
Subject: [PATCH 06/21] Adjust maker gettrade output checks to new column

The number of gettrade output columns are the same for makers and takers.
---
 apitest/scripts/trade-simulation-utils.sh | 54 +++++------------------
 1 file changed, 11 insertions(+), 43 deletions(-)

diff --git a/apitest/scripts/trade-simulation-utils.sh b/apitest/scripts/trade-simulation-utils.sh
index c9f03ac80f5..1953302a80e 100755
--- a/apitest/scripts/trade-simulation-utils.sh
+++ b/apitest/scripts/trade-simulation-utils.sh
@@ -238,65 +238,35 @@ gettradedetail() {
 
 istradedepositpublished() {
     TRADE_DETAIL="$1"
-    MAKER_OR_TAKER="$2"
-    if [ "$MAKER_OR_TAKER" = "MAKER" ]
-    then
-        ANSWER=$(echo "$TRADE_DETAIL" | awk '{print $9}')
-    else
-        ANSWER=$(echo "$TRADE_DETAIL" | awk '{print $10}')
-    fi
+    ANSWER=$(echo "$TRADE_DETAIL" | awk '{print $10}')
     commandalert $? "Could not parse istradedepositpublished from trade detail."
     echo "$ANSWER"
 }
 
 istradedepositconfirmed() {
     TRADE_DETAIL="$1"
-    MAKER_OR_TAKER="$2"
-    if [ "$MAKER_OR_TAKER" = "MAKER" ]
-    then
-        ANSWER=$(echo "$TRADE_DETAIL" | awk '{print $10}')
-    else
-        ANSWER=$(echo "$TRADE_DETAIL" | awk '{print $11}')
-    fi
+    ANSWER=$(echo "$TRADE_DETAIL" | awk '{print $11}')
     commandalert $? "Could not parse istradedepositconfirmed from trade detail."
     echo "$ANSWER"
 }
 
 istradepaymentsent() {
     TRADE_DETAIL="$1"
-    MAKER_OR_TAKER="$2"
-    if [ "$MAKER_OR_TAKER" = "MAKER" ]
-    then
-        ANSWER=$(echo "$TRADE_DETAIL" | awk '{print $12}')
-    else
-        ANSWER=$(echo "$TRADE_DETAIL" | awk '{print $13}')
-    fi
+    ANSWER=$(echo "$TRADE_DETAIL" | awk '{print $13}')
     commandalert $? "Could not parse istradepaymentsent from trade detail."
     echo "$ANSWER"
 }
 
 istradepaymentreceived() {
     TRADE_DETAIL="$1"
-    MAKER_OR_TAKER="$2"
-    if [ "$MAKER_OR_TAKER" = "MAKER" ]
-    then
-        ANSWER=$(echo "$TRADE_DETAIL" | awk '{print $13}')
-    else
-        ANSWER=$(echo "$TRADE_DETAIL" | awk '{print $14}')
-    fi
+    ANSWER=$(echo "$TRADE_DETAIL" | awk '{print $14}')
     commandalert $? "Could not parse istradepaymentreceived from trade detail."
     echo "$ANSWER"
 }
 
 istradepayoutpublished() {
     TRADE_DETAIL="$1"
-    MAKER_OR_TAKER="$2"
-    if [ "$MAKER_OR_TAKER" = "MAKER" ]
-    then
-        ANSWER=$(echo "$TRADE_DETAIL" | awk '{print $14}')
-    else
-        ANSWER=$(echo "$TRADE_DETAIL" | awk '{print $15}')
-    fi
+    ANSWER=$(echo "$TRADE_DETAIL" | awk '{print $15}')
     commandalert $? "Could not parse istradepayoutpublished from trade detail."
     echo "$ANSWER"
 }
@@ -321,7 +291,7 @@ waitfortradedepositpublished() {
         TRADE_DETAIL=$(gettradedetail "$GETTRADE_CMD_OUTPUT")
         exitoncommandalert $?
 
-        IS_TRADE_DEPOSIT_PUBLISHED=$(istradedepositpublished "$TRADE_DETAIL" "TAKER")
+        IS_TRADE_DEPOSIT_PUBLISHED=$(istradedepositpublished "$TRADE_DETAIL")
         exitoncommandalert $?
 
         printdate "BOB $BOB_ROLE:  Has taker's trade deposit been published?  $IS_TRADE_DEPOSIT_PUBLISHED"
@@ -356,7 +326,7 @@ waitfortradedepositconfirmed() {
         TRADE_DETAIL=$(gettradedetail "$GETTRADE_CMD_OUTPUT")
         exitoncommandalert $?
 
-        IS_TRADE_DEPOSIT_CONFIRMED=$(istradedepositconfirmed "$TRADE_DETAIL" "TAKER")
+        IS_TRADE_DEPOSIT_CONFIRMED=$(istradedepositconfirmed "$TRADE_DETAIL")
         exitoncommandalert $?
         printdate "BOB $BOB_ROLE:  Has taker's trade deposit been confirmed?  $IS_TRADE_DEPOSIT_CONFIRMED"
         printbreak
@@ -379,7 +349,6 @@ waitfortradepaymentsent() {
     PORT="$1"
     SELLER="$2"
     OFFER_ID="$3"
-    MAKER_OR_TAKER="$4"
     DONE=0
     while : ; do
         if [ "$DONE" -ne 0 ]; then
@@ -397,7 +366,7 @@ waitfortradepaymentsent() {
         TRADE_DETAIL=$(gettradedetail "$GETTRADE_CMD_OUTPUT")
         exitoncommandalert $?
 
-        IS_TRADE_PAYMENT_SENT=$(istradepaymentsent "$TRADE_DETAIL" "$MAKER_OR_TAKER")
+        IS_TRADE_PAYMENT_SENT=$(istradepaymentsent "$TRADE_DETAIL")
         exitoncommandalert $?
         printdate "$SELLER:  Has buyer's fiat payment been initiated?  $IS_TRADE_PAYMENT_SENT"
         if [ "$IS_TRADE_PAYMENT_SENT" = "YES" ]
@@ -416,7 +385,6 @@ waitfortradepaymentreceived() {
     PORT="$1"
     SELLER="$2"
     OFFER_ID="$3"
-    MAKER_OR_TAKER="$4"
     DONE=0
     while : ; do
         if [ "$DONE" -ne 0 ]; then
@@ -437,7 +405,7 @@ waitfortradepaymentreceived() {
         # When the seller receives a 'payment sent' message, it is assumed funds (fiat) have already been deposited.
         # In a real trade, there is usually a delay between receipt of a 'payment sent' message, and the funds deposit,
         # but we do not need to simulate that in this regtest script.
-        IS_TRADE_PAYMENT_SENT=$(istradepaymentreceived "$TRADE_DETAIL" "$MAKER_OR_TAKER")
+        IS_TRADE_PAYMENT_SENT=$(istradepaymentreceived "$TRADE_DETAIL")
         exitoncommandalert $?
         printdate "$SELLER:  Has buyer's payment been transferred to seller's fiat account?  $IS_TRADE_PAYMENT_SENT"
         if [ "$IS_TRADE_PAYMENT_SENT" = "YES" ]
@@ -544,10 +512,10 @@ executetrade() {
     if [ "$DIRECTION" = "BUY" ]
     then
         # Bob waits for payment, polling status in taker specific trade detail.
-        waitfortradepaymentsent "$PAYEE_PORT" "$PAYEE" "$OFFER_ID" "TAKER"
+        waitfortradepaymentsent "$PAYEE_PORT" "$PAYEE" "$OFFER_ID"
     else
         # Alice waits for payment, polling status in maker specific trade detail.
-        waitfortradepaymentsent "$PAYEE_PORT" "$PAYEE" "$OFFER_ID" "MAKER"
+        waitfortradepaymentsent "$PAYEE_PORT" "$PAYEE" "$OFFER_ID"
     fi
 
 

From 152f4591eb0d870c373bbacec9542aa1e8b60c66 Mon Sep 17 00:00:00 2001
From: ghubstan <36207203+ghubstan@users.noreply.github.com>
Date: Fri, 26 Mar 2021 11:04:51 -0300
Subject: [PATCH 07/21] Remove unused parameter

---
 apitest/scripts/trade-simulation-utils.sh | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/apitest/scripts/trade-simulation-utils.sh b/apitest/scripts/trade-simulation-utils.sh
index 1953302a80e..3ed7d3488b8 100755
--- a/apitest/scripts/trade-simulation-utils.sh
+++ b/apitest/scripts/trade-simulation-utils.sh
@@ -525,10 +525,10 @@ executetrade() {
     if [ "$DIRECTION" = "BUY" ]
     then
         # Alice waits for payment rcvd confirm from Bob, polling status in maker specific trade detail.
-        waitfortradepaymentreceived "$PAYER_PORT" "$PAYER" "$OFFER_ID" "MAKER"
+        waitfortradepaymentreceived "$PAYER_PORT" "$PAYER" "$OFFER_ID"
     else
         # Bob waits for payment rcvd confirm from Alice, polling status in taker specific trade detail.
-        waitfortradepaymentreceived "$PAYER_PORT" "$PAYER" "$OFFER_ID" "TAKER"
+        waitfortradepaymentreceived "$PAYER_PORT" "$PAYER" "$OFFER_ID"
     fi
 
     # Generate some btc blocks

From aad998cf3a0574f3bec7c52d03e4403c51610198 Mon Sep 17 00:00:00 2001
From: ghubstan <36207203+ghubstan@users.noreply.github.com>
Date: Sun, 28 Mar 2021 16:42:55 -0300
Subject: [PATCH 08/21] Fix maker/taker fee format bug, rename methods

---
 .../main/java/bisq/cli/CurrencyFormat.java    |  4 +--
 cli/src/main/java/bisq/cli/TableFormat.java   |  4 +--
 cli/src/main/java/bisq/cli/TradeFormat.java   | 31 +++++++++++++------
 3 files changed, 25 insertions(+), 14 deletions(-)

diff --git a/cli/src/main/java/bisq/cli/CurrencyFormat.java b/cli/src/main/java/bisq/cli/CurrencyFormat.java
index 9bada381a12..97d16fc6bdc 100644
--- a/cli/src/main/java/bisq/cli/CurrencyFormat.java
+++ b/cli/src/main/java/bisq/cli/CurrencyFormat.java
@@ -99,14 +99,14 @@ public static String formatMarketPrice(double price) {
         return NUMBER_FORMAT.format(price);
     }
 
-    public static String formatOfferPrice(long price) {
+    public static String formatPrice(long price) {
         NUMBER_FORMAT.setMinimumFractionDigits(4);
         NUMBER_FORMAT.setMaximumFractionDigits(4);
         NUMBER_FORMAT.setRoundingMode(UNNECESSARY);
         return NUMBER_FORMAT.format((double) price / 10_000);
     }
 
-    public static String formatCryptoCurrencyOfferPrice(long price) {
+    public static String formatCryptoCurrencyPrice(long price) {
         NUMBER_FORMAT.setMinimumFractionDigits(8);
         NUMBER_FORMAT.setMaximumFractionDigits(8);
         NUMBER_FORMAT.setRoundingMode(UNNECESSARY);
diff --git a/cli/src/main/java/bisq/cli/TableFormat.java b/cli/src/main/java/bisq/cli/TableFormat.java
index fbc99459375..5c123184e94 100644
--- a/cli/src/main/java/bisq/cli/TableFormat.java
+++ b/cli/src/main/java/bisq/cli/TableFormat.java
@@ -182,7 +182,7 @@ private static String formatFiatOfferTable(List offers, String fiatCu
                 + offers.stream()
                 .map(o -> format(colDataFormat,
                         o.getDirection(),
-                        formatOfferPrice(o.getPrice()),
+                        formatPrice(o.getPrice()),
                         formatAmountRange(o.getMinAmount(), o.getAmount()),
                         formatVolumeRange(o.getMinVolume(), o.getVolume()),
                         o.getPaymentMethodShortName(),
@@ -220,7 +220,7 @@ private static String formatCryptoCurrencyOfferTable(List offers, Str
                 + offers.stream()
                 .map(o -> format(colDataFormat,
                         directionFormat.apply(o),
-                        formatCryptoCurrencyOfferPrice(o.getPrice()),
+                        formatCryptoCurrencyPrice(o.getPrice()),
                         formatAmountRange(o.getMinAmount(), o.getAmount()),
                         formatCryptoCurrencyVolumeRange(o.getMinVolume(), o.getVolume()),
                         o.getPaymentMethodShortName(),
diff --git a/cli/src/main/java/bisq/cli/TradeFormat.java b/cli/src/main/java/bisq/cli/TradeFormat.java
index 4ca1a20d1ab..04816eca247 100644
--- a/cli/src/main/java/bisq/cli/TradeFormat.java
+++ b/cli/src/main/java/bisq/cli/TradeFormat.java
@@ -35,6 +35,8 @@ public class TradeFormat {
     private static final String YES = "YES";
     private static final String NO = "NO";
 
+    // TODO add String format(List trades)
+
     @VisibleForTesting
     public static String format(TradeInfo tradeInfo) {
         // Some column values might be longer than header, so we need to calculate them.
@@ -79,7 +81,7 @@ public static String format(TradeInfo tradeInfo) {
         String headerLine = String.format(headersFormat,
                 /* COL_HEADER_PRICE */ priceHeaderCurrencyCode.apply(tradeInfo),
                 /* COL_HEADER_TRADE_AMOUNT */ baseCurrencyCode,
-                /* COL_HEADER_TRADE_(M||T)AKER_FEE */ makerTakerFeeHeaderCurrencyCode.apply(tradeInfo),
+                /* COL_HEADER_TRADE_(M||T)AKER_FEE */ makerTakerFeeHeaderCurrencyCode.apply(tradeInfo, isTaker),
                 /* COL_HEADER_TRADE_BUYER_COST */ counterCurrencyCode,
                 /* COL_HEADER_TRADE_PAYMENT_SENT */ paymentStatusHeaderCurrencyCode.apply(tradeInfo),
                 /* COL_HEADER_TRADE_PAYMENT_RECEIVED */  paymentStatusHeaderCurrencyCode.apply(tradeInfo));
@@ -132,8 +134,13 @@ private static String formatTradeData(String format,
                     ? t.getOffer().getCounterCurrencyCode()
                     : t.getOffer().getBaseCurrencyCode();
 
-    private static final Function makerTakerFeeHeaderCurrencyCode = (t) ->
-            t.getIsCurrencyForTakerFeeBtc() ? "BTC" : "BSQ";
+    private static final BiFunction makerTakerFeeHeaderCurrencyCode = (t, isTaker) -> {
+        if (isTaker) {
+            return t.getIsCurrencyForTakerFeeBtc() ? "BTC" : "BSQ";
+        } else {
+            return t.getOffer().getIsCurrencyForMakerFeeBtc() ? "BTC" : "BSQ";
+        }
+    };
 
     private static final Function paymentStatusHeaderCurrencyCode = (t) ->
             t.getOffer().getBaseCurrencyCode().equals("BTC")
@@ -142,8 +149,8 @@ private static String formatTradeData(String format,
 
     private static final Function priceFormat = (t) ->
             t.getOffer().getBaseCurrencyCode().equals("BTC")
-                    ? formatOfferPrice(t.getTradePrice())
-                    : formatCryptoCurrencyOfferPrice(t.getOffer().getPrice());
+                    ? formatPrice(t.getTradePrice())
+                    : formatCryptoCurrencyPrice(t.getOffer().getPrice());
 
     private static final Function amountFormat = (t) ->
             t.getOffer().getBaseCurrencyCode().equals("BTC")
@@ -159,11 +166,15 @@ private static String formatTradeData(String format,
     };
 
     private static final BiFunction makerTakerFeeFormat = (t, isTaker) -> {
-        if (isTaker)
-            return t.getIsCurrencyForTakerFeeBtc() ? formatSatoshis(t.getTakerFeeAsLong()) : formatBsq(t.getTakerFeeAsLong());
-        else
-            return t.getIsCurrencyForTakerFeeBtc() ? formatSatoshis(t.getOffer().getMakerFee()) : formatBsq(t.getOffer().getMakerFee());
-
+        if (isTaker) {
+            return t.getIsCurrencyForTakerFeeBtc()
+                    ? formatSatoshis(t.getTakerFeeAsLong())
+                    : formatBsq(t.getTakerFeeAsLong());
+        } else {
+            return t.getOffer().getIsCurrencyForMakerFeeBtc()
+                    ? formatSatoshis(t.getOffer().getMakerFee())
+                    : formatBsq(t.getOffer().getMakerFee());
+        }
     };
 
     private static final Function tradeCostFormat = (t) ->

From 58c885efc193db456def73c04a570d97e724b6d9 Mon Sep 17 00:00:00 2001
From: ghubstan <36207203+ghubstan@users.noreply.github.com>
Date: Thu, 1 Apr 2021 15:43:48 -0300
Subject: [PATCH 09/21] Add support for creating instant altcoin payment
 accounts in api

- Added bool tradeInstant field to proto message def.
- Adjusted core createcryptopaymentacct impl to new tradeInstant request param.
- Adjusted cli side createcryptopaymentacct impl to new tradeInstant request param.
- Fixed CliMain's takeoffer help text (was missing the --payment-account opt).
---
 cli/src/main/java/bisq/cli/CliMain.java       |  9 +++--
 cli/src/main/java/bisq/cli/GrpcClient.java    |  4 ++-
 ...CryptoCurrencyPaymentAcctOptionParser.java | 10 ++++++
 cli/src/main/java/bisq/cli/opts/OptLabel.java |  1 +
 core/src/main/java/bisq/core/api/CoreApi.java | 10 ++++--
 .../core/api/CorePaymentAccountsService.java  |  9 +++--
 .../help/createcryptopaymentacct-help.txt     | 36 +++++++++++--------
 .../grpc/GrpcPaymentAccountsService.java      |  3 +-
 proto/src/main/proto/grpc.proto               |  1 +
 9 files changed, 60 insertions(+), 23 deletions(-)

diff --git a/cli/src/main/java/bisq/cli/CliMain.java b/cli/src/main/java/bisq/cli/CliMain.java
index 4b6568712b4..b85858fa120 100644
--- a/cli/src/main/java/bisq/cli/CliMain.java
+++ b/cli/src/main/java/bisq/cli/CliMain.java
@@ -527,9 +527,11 @@ public static void run(String[] args) {
                     var accountName = opts.getAccountName();
                     var currencyCode = opts.getCurrencyCode();
                     var address = opts.getAddress();
+                    var isTradeInstant = opts.getIsTradeInstant();
                     var paymentAccount = client.createCryptoCurrencyPaymentAccount(accountName,
                             currencyCode,
-                            address);
+                            address,
+                            isTradeInstant);
                     out.println("payment account saved");
                     out.println(formatPaymentAcctTbl(singletonList(paymentAccount)));
                     return;
@@ -744,7 +746,9 @@ private static void printHelp(OptionParser parser, @SuppressWarnings("SameParame
             stream.format(rowFormat, getmyoffers.name(), "--direction= \\", "Get my current offers");
             stream.format(rowFormat, "", "--currency-code=", "");
             stream.println();
-            stream.format(rowFormat, takeoffer.name(), "--offer-id= [--fee-currency=]", "Take offer with id");
+            stream.format(rowFormat, takeoffer.name(), "--offer-id= \\", "Take offer with id");
+            stream.format(rowFormat, "", "--payment-account=", "");
+            stream.format(rowFormat, "", "[--fee-currency=]", "");
             stream.println();
             stream.format(rowFormat, gettrade.name(), "--trade-id= \\", "Get trade summary or full contract");
             stream.format(rowFormat, "", "[--show-contract=]", "");
@@ -768,6 +772,7 @@ private static void printHelp(OptionParser parser, @SuppressWarnings("SameParame
             stream.format(rowFormat, createcryptopaymentacct.name(), "--account-name= \\", "Create a new cryptocurrency payment account");
             stream.format(rowFormat, "", "--currency-code= \\", "");
             stream.format(rowFormat, "", "--address=", "");
+            stream.format(rowFormat, "", "--trade-instant=", "");
             stream.println();
             stream.format(rowFormat, getpaymentaccts.name(), "", "Get user payment accounts");
             stream.println();
diff --git a/cli/src/main/java/bisq/cli/GrpcClient.java b/cli/src/main/java/bisq/cli/GrpcClient.java
index 5d3dde4eccb..e9e87f0ed65 100644
--- a/cli/src/main/java/bisq/cli/GrpcClient.java
+++ b/cli/src/main/java/bisq/cli/GrpcClient.java
@@ -433,11 +433,13 @@ public List getPaymentAccounts() {
 
     public PaymentAccount createCryptoCurrencyPaymentAccount(String accountName,
                                                              String currencyCode,
-                                                             String address) {
+                                                             String address,
+                                                             boolean tradeInstant) {
         var request = CreateCryptoCurrencyPaymentAccountRequest.newBuilder()
                 .setAccountName(accountName)
                 .setCurrencyCode(currencyCode)
                 .setAddress(address)
+                .setTradeInstant(tradeInstant)
                 .build();
         return grpcStubs.paymentAccountsService.createCryptoCurrencyPaymentAccount(request).getPaymentAccount();
     }
diff --git a/cli/src/main/java/bisq/cli/opts/CreateCryptoCurrencyPaymentAcctOptionParser.java b/cli/src/main/java/bisq/cli/opts/CreateCryptoCurrencyPaymentAcctOptionParser.java
index 90542e1e0b7..a37a9f109bb 100644
--- a/cli/src/main/java/bisq/cli/opts/CreateCryptoCurrencyPaymentAcctOptionParser.java
+++ b/cli/src/main/java/bisq/cli/opts/CreateCryptoCurrencyPaymentAcctOptionParser.java
@@ -23,6 +23,7 @@
 import static bisq.cli.opts.OptLabel.OPT_ACCOUNT_NAME;
 import static bisq.cli.opts.OptLabel.OPT_ADDRESS;
 import static bisq.cli.opts.OptLabel.OPT_CURRENCY_CODE;
+import static bisq.cli.opts.OptLabel.OPT_TRADE_INSTANT;
 
 public class CreateCryptoCurrencyPaymentAcctOptionParser extends AbstractMethodOptionParser implements MethodOpts {
 
@@ -35,6 +36,11 @@ public class CreateCryptoCurrencyPaymentAcctOptionParser extends AbstractMethodO
     final OptionSpec addressOpt = parser.accepts(OPT_ADDRESS, "bsq address")
             .withRequiredArg();
 
+    final OptionSpec tradeInstantOpt = parser.accepts(OPT_TRADE_INSTANT, "create trade instant account")
+            .withOptionalArg()
+            .ofType(boolean.class)
+            .defaultsTo(Boolean.FALSE);
+
     public CreateCryptoCurrencyPaymentAcctOptionParser(String[] args) {
         super(args);
     }
@@ -72,4 +78,8 @@ public String getCurrencyCode() {
     public String getAddress() {
         return options.valueOf(addressOpt);
     }
+
+    public boolean getIsTradeInstant() {
+        return options.valueOf(tradeInstantOpt);
+    }
 }
diff --git a/cli/src/main/java/bisq/cli/opts/OptLabel.java b/cli/src/main/java/bisq/cli/opts/OptLabel.java
index fcabd0820e4..084c230aae3 100644
--- a/cli/src/main/java/bisq/cli/opts/OptLabel.java
+++ b/cli/src/main/java/bisq/cli/opts/OptLabel.java
@@ -44,6 +44,7 @@ public class OptLabel {
     public final static String OPT_SECURITY_DEPOSIT = "security-deposit";
     public final static String OPT_SHOW_CONTRACT = "show-contract";
     public final static String OPT_TRADE_ID = "trade-id";
+    public final static String OPT_TRADE_INSTANT = "trade-instant";
     public final static String OPT_TIMEOUT = "timeout";
     public final static String OPT_TRANSACTION_ID = "transaction-id";
     public final static String OPT_TX_FEE_RATE = "tx-fee-rate";
diff --git a/core/src/main/java/bisq/core/api/CoreApi.java b/core/src/main/java/bisq/core/api/CoreApi.java
index 6db1c667ed9..24ead3f1aba 100644
--- a/core/src/main/java/bisq/core/api/CoreApi.java
+++ b/core/src/main/java/bisq/core/api/CoreApi.java
@@ -210,8 +210,14 @@ public String getPaymentAccountForm(String paymentMethodId) {
         return paymentAccountsService.getPaymentAccountFormAsString(paymentMethodId);
     }
 
-    public PaymentAccount createCryptoCurrencyPaymentAccount(String accountName, String currencyCode, String address) {
-        return paymentAccountsService.createCryptoCurrencyPaymentAccount(accountName, currencyCode, address);
+    public PaymentAccount createCryptoCurrencyPaymentAccount(String accountName,
+                                                             String currencyCode,
+                                                             String address,
+                                                             boolean tradeInstant) {
+        return paymentAccountsService.createCryptoCurrencyPaymentAccount(accountName,
+                currencyCode,
+                address,
+                tradeInstant);
     }
 
     public List getCryptoCurrencyPaymentMethods() {
diff --git a/core/src/main/java/bisq/core/api/CorePaymentAccountsService.java b/core/src/main/java/bisq/core/api/CorePaymentAccountsService.java
index a316b76ed07..0843e20ab76 100644
--- a/core/src/main/java/bisq/core/api/CorePaymentAccountsService.java
+++ b/core/src/main/java/bisq/core/api/CorePaymentAccountsService.java
@@ -21,6 +21,7 @@
 import bisq.core.api.model.PaymentAccountForm;
 import bisq.core.locale.CryptoCurrency;
 import bisq.core.payment.CryptoCurrencyAccount;
+import bisq.core.payment.InstantCryptoCurrencyAccount;
 import bisq.core.payment.PaymentAccount;
 import bisq.core.payment.PaymentAccountFactory;
 import bisq.core.payment.payload.PaymentMethod;
@@ -100,7 +101,8 @@ File getPaymentAccountForm(String paymentMethodId) {
 
     PaymentAccount createCryptoCurrencyPaymentAccount(String accountName,
                                                       String currencyCode,
-                                                      String address) {
+                                                      String address,
+                                                      boolean tradeInstant) {
         String bsqCode = currencyCode.toUpperCase();
         if (!bsqCode.equals("BSQ"))
             throw new IllegalArgumentException("api does not currently support " + currencyCode + " accounts");
@@ -108,8 +110,9 @@ PaymentAccount createCryptoCurrencyPaymentAccount(String accountName,
         // Validate the BSQ address string but ignore the return value.
         coreWalletsService.getValidBsqLegacyAddress(address);
 
-        CryptoCurrencyAccount cryptoCurrencyAccount =
-                (CryptoCurrencyAccount) PaymentAccountFactory.getPaymentAccount(PaymentMethod.BLOCK_CHAINS);
+        var cryptoCurrencyAccount = tradeInstant
+                ? (InstantCryptoCurrencyAccount) PaymentAccountFactory.getPaymentAccount(PaymentMethod.BLOCK_CHAINS_INSTANT)
+                : (CryptoCurrencyAccount) PaymentAccountFactory.getPaymentAccount(PaymentMethod.BLOCK_CHAINS);
         cryptoCurrencyAccount.init();
         cryptoCurrencyAccount.setAccountName(accountName);
         cryptoCurrencyAccount.setAddress(address);
diff --git a/core/src/main/resources/help/createcryptopaymentacct-help.txt b/core/src/main/resources/help/createcryptopaymentacct-help.txt
index c1724550e1c..83fa2e9f633 100644
--- a/core/src/main/resources/help/createcryptopaymentacct-help.txt
+++ b/core/src/main/resources/help/createcryptopaymentacct-help.txt
@@ -7,33 +7,41 @@ createcryptopaymentacct - create a cryptocurrency payment account
 SYNOPSIS
 --------
 createcryptopaymentacct
-		--account-name=
+		--account-name=
 		--currency-code=
-		--address=
+		--address=
+		[--trade-instant=]
 
 DESCRIPTION
 -----------
-Creates a cryptocurrency (altcoin) trading account for buying and selling BTC.
+Create an cryptocurrency (altcoin) payment account.  Only BSQ payment accounts are currently supported.
 
 OPTIONS
 -------
 --account-name
-		The name of the cryptocurrency payment account.
+		The name of the cryptocurrency payment account used to create and take altcoin offers.
 
 --currency-code
-		The three letter code for the cryptocurrency used to buy or sell BTC, e.g., BSQ.
+		The three letter code for the altcoin, e.g., BSQ.
 
 --address
-		A valid BSQ wallet address.
+		The altcoin address to be used receive cryptocurrency payment when selling BTC.
+
+--trade-instant
+		True for creating an instant cryptocurrency payment account, false otherwise.
+		Default is false.
 
 EXAMPLES
 --------
-To create a new BSQ payment account, find an unused BSQ wallet address:
-$ ./bisq-cli --password=xyz --port=9998 getunusedbsqaddress
-
-With the returned BSQ address, e.g., Bn3PCQgRwhkrGnaMp1RYwt9tFwL51YELqne create the cryptocurrency payment account:
-$ ./bisq-cli --password=xyz --port=9998 createcryptopaymentacct \
-    --account-name="My BSQ Account" \
-    --currency-code=BSQ \
-    --address=Bn3PCQgRwhkrGnaMp1RYwt9tFwL51YELqne
 
+To create a BSQ Altcoin payment account:
+$ ./bisq-cli --password=xyz --port=9998 createcryptopaymentacct --account-name="My BSQ Account" \
+    --currency-code=bsq \
+    --address=Bn3PCQgRwhkrGnaMp1RYwt9tFwL51YELqne \
+    --trade-instant=false
+
+To create a BSQ Instant Altcoin payment account:
+$ ./bisq-cli --password=xyz --port=9998 createcryptopaymentacct --account-name="My Instant BSQ Account" \
+    --currency-code=bsq \
+    --address=Bn3PCQgRwhkrGnaMp1RYwt9tFwL51YELqne \
+    --trade-instant=true
diff --git a/daemon/src/main/java/bisq/daemon/grpc/GrpcPaymentAccountsService.java b/daemon/src/main/java/bisq/daemon/grpc/GrpcPaymentAccountsService.java
index af801004841..2c1b5501b28 100644
--- a/daemon/src/main/java/bisq/daemon/grpc/GrpcPaymentAccountsService.java
+++ b/daemon/src/main/java/bisq/daemon/grpc/GrpcPaymentAccountsService.java
@@ -135,7 +135,8 @@ public void createCryptoCurrencyPaymentAccount(CreateCryptoCurrencyPaymentAccoun
         try {
             PaymentAccount paymentAccount = coreApi.createCryptoCurrencyPaymentAccount(req.getAccountName(),
                     req.getCurrencyCode(),
-                    req.getAddress());
+                    req.getAddress(),
+                    req.getTradeInstant());
             var reply = CreateCryptoCurrencyPaymentAccountReply.newBuilder()
                     .setPaymentAccount(paymentAccount.toProtoMessage())
                     .build();
diff --git a/proto/src/main/proto/grpc.proto b/proto/src/main/proto/grpc.proto
index 3fd783ada38..432547edc01 100644
--- a/proto/src/main/proto/grpc.proto
+++ b/proto/src/main/proto/grpc.proto
@@ -210,6 +210,7 @@ message CreateCryptoCurrencyPaymentAccountRequest {
     string accountName = 1;
     string currencyCode = 2;
     string address = 3;
+     bool tradeInstant = 4;
 }
 
 message CreateCryptoCurrencyPaymentAccountReply {

From 4fbdc32ba49cca800e2745fcdd5757a2cc9a44f1 Mon Sep 17 00:00:00 2001
From: ghubstan <36207203+ghubstan@users.noreply.github.com>
Date: Thu, 1 Apr 2021 15:45:53 -0300
Subject: [PATCH 10/21] Remove space char

---
 proto/src/main/proto/grpc.proto | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/proto/src/main/proto/grpc.proto b/proto/src/main/proto/grpc.proto
index 432547edc01..2d686506654 100644
--- a/proto/src/main/proto/grpc.proto
+++ b/proto/src/main/proto/grpc.proto
@@ -210,7 +210,7 @@ message CreateCryptoCurrencyPaymentAccountRequest {
     string accountName = 1;
     string currencyCode = 2;
     string address = 3;
-     bool tradeInstant = 4;
+    bool tradeInstant = 4;
 }
 
 message CreateCryptoCurrencyPaymentAccountReply {

From 6bde12ba405ebe63a85e67d4d2f537274dd8056d Mon Sep 17 00:00:00 2001
From: ghubstan <36207203+ghubstan@users.noreply.github.com>
Date: Thu, 1 Apr 2021 16:40:08 -0300
Subject: [PATCH 11/21] Improve takeoffer output and failure reason messaging

- Added AvailabilityResultWithDescription proto for better takeoffer failure msgs.
- Added VerifyBsqSentToAddress impl to api, but don't expose to CLI yet.
- Show BSQ Buyer Address in gettrade output (changed cli output formatting classes).
- Fixed api.model.PaymentAccountPayloadInfo altcoin instant acct support bug
---
 .../java/bisq/cli/ColumnHeaderConstants.java  |  1 +
 .../main/java/bisq/cli/CurrencyFormat.java    |  2 +-
 cli/src/main/java/bisq/cli/GrpcClient.java    | 11 ++++-
 cli/src/main/java/bisq/cli/TradeFormat.java   | 45 +++++++++++++++++--
 .../bisq/core/api/CoreWalletsService.java     | 42 +++++++++++++++++
 .../api/model/PaymentAccountPayloadInfo.java  | 13 ++++--
 .../daemon/grpc/GrpcErrorMessageHandler.java  | 32 +++++++++----
 proto/src/main/proto/grpc.proto               | 20 +++++++--
 8 files changed, 145 insertions(+), 21 deletions(-)

diff --git a/cli/src/main/java/bisq/cli/ColumnHeaderConstants.java b/cli/src/main/java/bisq/cli/ColumnHeaderConstants.java
index 0ad303dd64d..775221b5ed5 100644
--- a/cli/src/main/java/bisq/cli/ColumnHeaderConstants.java
+++ b/cli/src/main/java/bisq/cli/ColumnHeaderConstants.java
@@ -51,6 +51,7 @@ class ColumnHeaderConstants {
     static final String COL_HEADER_PRICE = "Price in %-3s for 1 BTC";
     static final String COL_HEADER_PRICE_OF_ALTCOIN = "Price in BTC for 1 %-3s";
     static final String COL_HEADER_TRADE_AMOUNT = padStart("Amount(%-3s)", 12, ' ');
+    static final String COL_HEADER_TRADE_BSQ_BUYER_ADDRESS = "BSQ Buyer Address";
     static final String COL_HEADER_TRADE_BUYER_COST = padEnd("Buyer Cost(%-3s)", 15, ' ');
     static final String COL_HEADER_TRADE_DEPOSIT_CONFIRMED = "Deposit Confirmed";
     static final String COL_HEADER_TRADE_DEPOSIT_PUBLISHED = "Deposit Published";
diff --git a/cli/src/main/java/bisq/cli/CurrencyFormat.java b/cli/src/main/java/bisq/cli/CurrencyFormat.java
index 97d16fc6bdc..baeeb775f95 100644
--- a/cli/src/main/java/bisq/cli/CurrencyFormat.java
+++ b/cli/src/main/java/bisq/cli/CurrencyFormat.java
@@ -57,7 +57,7 @@ public static String formatBsq(long sats) {
         return BSQ_FORMAT.format(BigDecimal.valueOf(sats).divide(BSQ_SATOSHI_DIVISOR));
     }
 
-    public static String formatBsqSendAmount(long bsqSats) {
+    public static String formatBsqAmount(long bsqSats) {
         // BSQ sats = trade.getOffer().getVolume()
         NUMBER_FORMAT.setMinimumFractionDigits(2);
         NUMBER_FORMAT.setMaximumFractionDigits(2);
diff --git a/cli/src/main/java/bisq/cli/GrpcClient.java b/cli/src/main/java/bisq/cli/GrpcClient.java
index e9e87f0ed65..b238c17e5f1 100644
--- a/cli/src/main/java/bisq/cli/GrpcClient.java
+++ b/cli/src/main/java/bisq/cli/GrpcClient.java
@@ -62,6 +62,7 @@
 import bisq.proto.grpc.TxInfo;
 import bisq.proto.grpc.UnlockWalletRequest;
 import bisq.proto.grpc.UnsetTxFeeRatePreferenceRequest;
+import bisq.proto.grpc.VerifyBsqSentToAddressRequest;
 import bisq.proto.grpc.WithdrawFundsRequest;
 
 import protobuf.PaymentAccount;
@@ -166,6 +167,14 @@ public TxInfo sendBtc(String address, String amount, String txFeeRate, String me
         return grpcStubs.walletsService.sendBtc(request).getTxInfo();
     }
 
+    public boolean verifyBsqSentToAddress(String address, String amount) {
+        var request = VerifyBsqSentToAddressRequest.newBuilder()
+                .setAddress(address)
+                .setAmount(amount)
+                .build();
+        return grpcStubs.walletsService.verifyBsqSentToAddress(request).getIsAmountReceived();
+    }
+
     public TxFeeRateInfo getTxFeeRate() {
         var request = GetTxFeeRateRequest.newBuilder().build();
         return grpcStubs.walletsService.getTxFeeRate(request).getTxFeeRateInfo();
@@ -367,7 +376,7 @@ public TradeInfo takeOffer(String offerId, String paymentAccountId, String taker
         if (reply.hasTrade())
             return reply.getTrade();
         else
-            throw new IllegalStateException(reply.getAvailabilityResultDescription());
+            throw new IllegalStateException(reply.getFailureReason().getDescription());
     }
 
     public TradeInfo getTrade(String tradeId) {
diff --git a/cli/src/main/java/bisq/cli/TradeFormat.java b/cli/src/main/java/bisq/cli/TradeFormat.java
index 04816eca247..d11f8847be4 100644
--- a/cli/src/main/java/bisq/cli/TradeFormat.java
+++ b/cli/src/main/java/bisq/cli/TradeFormat.java
@@ -17,6 +17,7 @@
 
 package bisq.cli;
 
+import bisq.proto.grpc.ContractInfo;
 import bisq.proto.grpc.TradeInfo;
 
 import com.google.common.annotations.VisibleForTesting;
@@ -58,6 +59,10 @@ public static String format(TradeInfo tradeInfo) {
                 "%" + (COL_HEADER_TRADE_TAKER_FEE.length() + 2) + "s"
                 : "";
 
+        boolean showBsqBuyerAddress = shouldShowBqsBuyerAddress(tradeInfo, isTaker);
+        Supplier bsqBuyerAddressHeader = () -> showBsqBuyerAddress ? COL_HEADER_TRADE_BSQ_BUYER_ADDRESS : "";
+        Supplier bsqBuyerAddressHeaderSpec = () -> showBsqBuyerAddress ? "%s" : "";
+
         String headersFormat = padEnd(COL_HEADER_TRADE_SHORT_ID, shortIdColWidth, ' ') + COL_HEADER_DELIMITER
                 + padEnd(COL_HEADER_TRADE_ROLE, roleColWidth, ' ') + COL_HEADER_DELIMITER
                 + priceHeader.apply(tradeInfo) + COL_HEADER_DELIMITER   // includes %s -> currencyCode
@@ -73,6 +78,7 @@ public static String format(TradeInfo tradeInfo) {
                 + COL_HEADER_TRADE_PAYMENT_RECEIVED + COL_HEADER_DELIMITER
                 + COL_HEADER_TRADE_PAYOUT_PUBLISHED + COL_HEADER_DELIMITER
                 + COL_HEADER_TRADE_WITHDRAWN + COL_HEADER_DELIMITER
+                + bsqBuyerAddressHeader.get()
                 + "%n";
 
         String counterCurrencyCode = tradeInfo.getOffer().getCounterCurrencyCode();
@@ -100,14 +106,16 @@ public static String format(TradeInfo tradeInfo) {
                 + "  %-" + (COL_HEADER_TRADE_PAYMENT_SENT.length() - 1) + "s" // left
                 + "  %-" + (COL_HEADER_TRADE_PAYMENT_RECEIVED.length() - 1) + "s" // left
                 + "  %-" + COL_HEADER_TRADE_PAYOUT_PUBLISHED.length() + "s" // lt justify
-                + "  %-" + COL_HEADER_TRADE_WITHDRAWN.length() + "s";       // lt justify
+                + "  %-" + (COL_HEADER_TRADE_WITHDRAWN.length() + 2) + "s"
+                + bsqBuyerAddressHeaderSpec.get();
 
-        return headerLine + formatTradeData(colDataFormat, tradeInfo, isTaker);
+        return headerLine + formatTradeData(colDataFormat, tradeInfo, isTaker, showBsqBuyerAddress);
     }
 
     private static String formatTradeData(String format,
                                           TradeInfo tradeInfo,
-                                          boolean isTaker) {
+                                          boolean isTaker,
+                                          boolean showBsqBuyerAddress) {
         return String.format(format,
                 tradeInfo.getShortId(),
                 tradeInfo.getRole(),
@@ -121,7 +129,8 @@ private static String formatTradeData(String format,
                 tradeInfo.getIsFiatSent() ? YES : NO,
                 tradeInfo.getIsFiatReceived() ? YES : NO,
                 tradeInfo.getIsPayoutPublished() ? YES : NO,
-                tradeInfo.getIsWithdrawn() ? YES : NO);
+                tradeInfo.getIsWithdrawn() ? YES : NO,
+                bsqReceiveAddress.apply(tradeInfo, showBsqBuyerAddress));
     }
 
     private static final Function priceHeader = (t) ->
@@ -181,4 +190,32 @@ private static String formatTradeData(String format,
             t.getOffer().getBaseCurrencyCode().equals("BTC")
                     ? formatOfferVolume(t.getOffer().getVolume())
                     : formatSatoshis(t.getTradeAmountAsLong());
+
+    private static final BiFunction bsqReceiveAddress = (t, showBsqBuyerAddress) -> {
+        if (showBsqBuyerAddress) {
+            ContractInfo contract = t.getContract();
+            boolean isBuyerMakerAndSellerTaker = contract.getIsBuyerMakerAndSellerTaker();
+            return isBuyerMakerAndSellerTaker  // (is BTC buyer / maker)
+                    ? contract.getTakerPaymentAccountPayload().getAddress()
+                    : contract.getMakerPaymentAccountPayload().getAddress();
+        } else {
+            return "";
+        }
+    };
+
+    private static boolean shouldShowBqsBuyerAddress(TradeInfo tradeInfo, boolean isTaker) {
+        if (tradeInfo.getOffer().getBaseCurrencyCode().equals("BTC")) {
+            return false;
+        } else {
+            ContractInfo contract = tradeInfo.getContract();
+            // Do not forget buyer and seller refer to BTC buyer and seller, not BSQ
+            // buyer and seller.  If you are buying BSQ, you are the (BTC) seller.
+            boolean isBuyerMakerAndSellerTaker = contract.getIsBuyerMakerAndSellerTaker();
+            if (isTaker) {
+                return !isBuyerMakerAndSellerTaker;
+            } else {
+                return isBuyerMakerAndSellerTaker;
+            }
+        }
+    }
 }
diff --git a/core/src/main/java/bisq/core/api/CoreWalletsService.java b/core/src/main/java/bisq/core/api/CoreWalletsService.java
index d6aa8a240a3..4af8c3dd12d 100644
--- a/core/src/main/java/bisq/core/api/CoreWalletsService.java
+++ b/core/src/main/java/bisq/core/api/CoreWalletsService.java
@@ -52,8 +52,10 @@
 import org.bitcoinj.core.Coin;
 import org.bitcoinj.core.InsufficientMoneyException;
 import org.bitcoinj.core.LegacyAddress;
+import org.bitcoinj.core.NetworkParameters;
 import org.bitcoinj.core.Transaction;
 import org.bitcoinj.core.TransactionConfidence;
+import org.bitcoinj.core.TransactionOutput;
 import org.bitcoinj.crypto.KeyCrypterScrypt;
 
 import javax.inject.Inject;
@@ -75,6 +77,7 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.function.Function;
+import java.util.function.Predicate;
 import java.util.stream.Collectors;
 
 import lombok.extern.slf4j.Slf4j;
@@ -145,6 +148,10 @@ KeyParameter getKey() {
         return tempAesKey;
     }
 
+    NetworkParameters getNetworkParameters() {
+        return btcWalletService.getWallet().getContext().getParams();
+    }
+
     BalancesInfo getBalances(String currencyCode) {
         verifyWalletCurrencyCodeIsValid(currencyCode);
         verifyWalletsAreAvailable();
@@ -305,6 +312,41 @@ void sendBtc(String address,
         }
     }
 
+    boolean verifyBsqSentToAddress(String address, String amount) {
+        Address receiverAddress = getValidBsqLegacyAddress(address);
+        NetworkParameters networkParameters = getNetworkParameters();
+        Predicate isTxOutputAddressMatch = (txOut) ->
+                txOut.getScriptPubKey().getToAddress(networkParameters).equals(receiverAddress);
+        Coin coinValue = parseToCoin(amount, bsqFormatter);
+        Predicate isTxOutputValueMatch = (txOut) ->
+                txOut.getValue().longValue() == coinValue.longValue();
+        List spendableBsqTxOutputs = bsqWalletService.getSpendableBsqTransactionOutputs();
+
+        log.info("Searching {} spendable tx outputs for matching address {} and value {}:",
+                spendableBsqTxOutputs.size(),
+                address,
+                coinValue.toPlainString());
+        long numMatches = 0;
+        for (TransactionOutput txOut : spendableBsqTxOutputs) {
+            if (isTxOutputAddressMatch.test(txOut) && isTxOutputValueMatch.test(txOut)) {
+                log.info("\t\tTx {} output has matching address {} and value {}.",
+                        txOut.getParentTransaction().getTxId(),
+                        address,
+                        txOut.getValue().toPlainString());
+                numMatches++;
+            }
+        }
+        if (numMatches > 1) {
+            log.warn("{} tx outputs matched address {} and value {}, could be a"
+                            + " false positive BSQ payment verification result.",
+                    numMatches,
+                    address,
+                    coinValue.toPlainString());
+
+        }
+        return numMatches > 0;
+    }
+
     void getTxFeeRate(ResultHandler resultHandler) {
         try {
             @SuppressWarnings({"unchecked", "Convert2MethodRef"})
diff --git a/core/src/main/java/bisq/core/api/model/PaymentAccountPayloadInfo.java b/core/src/main/java/bisq/core/api/model/PaymentAccountPayloadInfo.java
index 9af1277ce33..8bce2e96db8 100644
--- a/core/src/main/java/bisq/core/api/model/PaymentAccountPayloadInfo.java
+++ b/core/src/main/java/bisq/core/api/model/PaymentAccountPayloadInfo.java
@@ -18,10 +18,12 @@
 package bisq.core.api.model;
 
 import bisq.core.payment.payload.CryptoCurrencyAccountPayload;
+import bisq.core.payment.payload.InstantCryptoCurrencyPayload;
 import bisq.core.payment.payload.PaymentAccountPayload;
 
 import bisq.common.Payload;
 
+import java.util.Optional;
 import java.util.function.Supplier;
 
 import lombok.Getter;
@@ -45,12 +47,15 @@ public PaymentAccountPayloadInfo(String id,
     }
 
     public static PaymentAccountPayloadInfo toPaymentAccountPayloadInfo(PaymentAccountPayload paymentAccountPayload) {
-        String address = paymentAccountPayload instanceof CryptoCurrencyAccountPayload
-                ? ((CryptoCurrencyAccountPayload) paymentAccountPayload).getAddress()
-                : "";
+        Optional address = Optional.empty();
+        if (paymentAccountPayload instanceof CryptoCurrencyAccountPayload)
+            address = Optional.of(((CryptoCurrencyAccountPayload) paymentAccountPayload).getAddress());
+        else if (paymentAccountPayload instanceof InstantCryptoCurrencyPayload)
+            address = Optional.of(((InstantCryptoCurrencyPayload) paymentAccountPayload).getAddress());
+
         return new PaymentAccountPayloadInfo(paymentAccountPayload.getId(),
                 paymentAccountPayload.getPaymentMethodId(),
-                address);
+                address.orElse(""));
     }
 
     // For transmitting TradeInfo messages when no contract & payloads are available.
diff --git a/daemon/src/main/java/bisq/daemon/grpc/GrpcErrorMessageHandler.java b/daemon/src/main/java/bisq/daemon/grpc/GrpcErrorMessageHandler.java
index 435284252b5..4c139e17094 100644
--- a/daemon/src/main/java/bisq/daemon/grpc/GrpcErrorMessageHandler.java
+++ b/daemon/src/main/java/bisq/daemon/grpc/GrpcErrorMessageHandler.java
@@ -19,6 +19,7 @@
 
 import bisq.common.handlers.ErrorMessageHandler;
 
+import bisq.proto.grpc.AvailabilityResultWithDescription;
 import bisq.proto.grpc.TakeOfferReply;
 
 import protobuf.AvailabilityResult;
@@ -77,7 +78,7 @@ public void handleErrorMessage(String errorMessage) {
             this.isErrorHandled = true;
             log.error(errorMessage);
 
-            if (isTakeOfferError()) {
+            if (takeOfferWasCalled()) {
                 handleTakeOfferError(errorMessage);
             } else {
                 exceptionHandler.handleErrorMessage(log,
@@ -88,14 +89,20 @@ public void handleErrorMessage(String errorMessage) {
     }
 
     private void handleTakeOfferError(String errorMessage) {
-        // Send the AvailabilityResult to the client instead of throwing an exception.
-        // The client should look at the grpc reply object's AvailabilityResult
-        // field if reply.hasTrade = false, and use it give the user a human readable msg.
+        // If the errorMessage originated from a UI purposed TaskRunner, it should
+        // contain an AvailabilityResult enum name.  If it does, derive the
+        // AvailabilityResult enum from the errorMessage, wrap it in a new
+        // AvailabilityResultWithDescription enum, then send the
+        // AvailabilityResultWithDescription to the client instead of throwing
+        // an exception.  The client should use the grpc reply object's
+        // AvailabilityResultWithDescription field if reply.hasTrade = false, and the
+        // client can decide to throw an exception with the client friendly error
+        // description, or take some other action based on the AvailabilityResult enum.
+        // (Some offer availability problems are not fatal, and retries are appropriate.)
         try {
-            AvailabilityResult availabilityResultProto = getAvailabilityResult(errorMessage);
+            var failureReason = getAvailabilityResultWithDescription(errorMessage);
             var reply = TakeOfferReply.newBuilder()
-                    .setAvailabilityResult(availabilityResultProto)
-                    .setAvailabilityResultDescription(getAvailabilityResultDescription(availabilityResultProto))
+                    .setFailureReason(failureReason)
                     .build();
             @SuppressWarnings("unchecked")
             var takeOfferResponseObserver = (StreamObserver) responseObserver;
@@ -109,6 +116,15 @@ private void handleTakeOfferError(String errorMessage) {
         }
     }
 
+    private AvailabilityResultWithDescription getAvailabilityResultWithDescription(String errorMessage) {
+        AvailabilityResult proto = getAvailabilityResult(errorMessage);
+        String description = getAvailabilityResultDescription(proto);
+        return AvailabilityResultWithDescription.newBuilder()
+                .setAvailabilityResult(proto)
+                .setDescription(description)
+                .build();
+    }
+
     private AvailabilityResult getAvailabilityResult(String errorMessage) {
         return stream(AvailabilityResult.values())
                 .filter((e) -> errorMessage.toUpperCase().contains(e.name()))
@@ -121,7 +137,7 @@ private String getAvailabilityResultDescription(AvailabilityResult proto) {
         return bisq.core.offer.AvailabilityResult.fromProto(proto).description();
     }
 
-    private boolean isTakeOfferError() {
+    private boolean takeOfferWasCalled() {
         return fullMethodName.equals(getTakeOfferMethod().getFullMethodName());
     }
 }
diff --git a/proto/src/main/proto/grpc.proto b/proto/src/main/proto/grpc.proto
index 836d2e731b2..14a3208c352 100644
--- a/proto/src/main/proto/grpc.proto
+++ b/proto/src/main/proto/grpc.proto
@@ -161,6 +161,11 @@ message OfferInfo {
     uint64 makerFee = 23;
 }
 
+message AvailabilityResultWithDescription {
+    AvailabilityResult availabilityResult = 1;
+    string description = 2;
+}
+
 ///////////////////////////////////////////////////////////////////////////////////////////
 // PaymentAccounts
 ///////////////////////////////////////////////////////////////////////////////////////////
@@ -303,8 +308,7 @@ message TakeOfferRequest {
 
 message TakeOfferReply {
     TradeInfo trade = 1;
-    AvailabilityResult availabilityResult = 2;
-    string availabilityResultDescription = 3;
+    AvailabilityResultWithDescription failureReason = 2;
 }
 
 message ConfirmPaymentStartedRequest {
@@ -430,6 +434,8 @@ service Wallets {
     }
     rpc SendBtc (SendBtcRequest) returns (SendBtcReply) {
     }
+    rpc VerifyBsqSentToAddress (VerifyBsqSentToAddressRequest) returns (VerifyBsqSentToAddressReply) {
+    }
     rpc GetTxFeeRate (GetTxFeeRateRequest) returns (GetTxFeeRateReply) {
     }
     rpc SetTxFeeRatePreference (SetTxFeeRatePreferenceRequest) returns (SetTxFeeRatePreferenceReply) {
@@ -494,6 +500,15 @@ message SendBtcReply {
     TxInfo txInfo = 1;
 }
 
+message VerifyBsqSentToAddressRequest {
+    string address = 1;
+    string amount = 2;
+}
+
+message VerifyBsqSentToAddressReply {
+    bool isAmountReceived = 1;
+}
+
 message GetTxFeeRateRequest {
 }
 
@@ -606,4 +621,3 @@ message GetVersionRequest {
 message GetVersionReply {
     string version = 1;
 }
-

From b838e59467ff7ee909ebcd314f5f48eda3d1fc5a Mon Sep 17 00:00:00 2001
From: ghubstan <36207203+ghubstan@users.noreply.github.com>
Date: Thu, 1 Apr 2021 17:48:14 -0300
Subject: [PATCH 12/21] Make test dispute agent registration a test harness
 opt.

It was a test case opt, now it occurs by default.
---
 .../src/main/java/bisq/apitest/Scaffold.java  | 18 ++++++++++
 .../bisq/apitest/config/ApiTestConfig.java    | 17 +++++++++
 .../java/bisq/apitest/method/MethodTest.java  | 36 ++++---------------
 .../method/RegisterDisputeAgentsTest.java     |  3 ++
 4 files changed, 44 insertions(+), 30 deletions(-)

diff --git a/apitest/src/main/java/bisq/apitest/Scaffold.java b/apitest/src/main/java/bisq/apitest/Scaffold.java
index a556a426b30..82826550484 100644
--- a/apitest/src/main/java/bisq/apitest/Scaffold.java
+++ b/apitest/src/main/java/bisq/apitest/Scaffold.java
@@ -42,10 +42,14 @@
 import javax.annotation.Nullable;
 
 import static bisq.apitest.Scaffold.BitcoinCoreApp.bitcoind;
+import static bisq.apitest.config.ApiTestConfig.MEDIATOR;
+import static bisq.apitest.config.ApiTestConfig.REFUND_AGENT;
 import static bisq.apitest.config.BisqAppConfig.*;
+import static bisq.common.app.DevEnv.DEV_PRIVILEGE_PRIV_KEY;
 import static java.lang.String.format;
 import static java.lang.System.exit;
 import static java.lang.System.out;
+import static java.net.InetAddress.getLoopbackAddress;
 import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.SECONDS;
@@ -58,6 +62,7 @@
 import bisq.apitest.linux.BisqProcess;
 import bisq.apitest.linux.BitcoinDaemon;
 import bisq.apitest.linux.LinuxProcess;
+import bisq.cli.GrpcClient;
 
 @Slf4j
 public class Scaffold {
@@ -146,6 +151,8 @@ public Scaffold setUp() throws IOException, InterruptedException, ExecutionExcep
 
         // Verify each startup task's future is done.
         verifyStartupCompleted();
+
+        maybeRegisterDisputeAgents();
         return this;
     }
 
@@ -448,4 +455,15 @@ private void verifyNotWindows() {
         if (Utilities.isWindows())
             throw new IllegalStateException("ApiTest not supported on Windows");
     }
+
+    private void maybeRegisterDisputeAgents() {
+        if (config.hasSupportingApp(arbdaemon.name()) && config.registerDisputeAgents) {
+            log.info("Option --registerDisputeAgents=true, registering dispute agents in arbdaemon ...");
+            GrpcClient arbClient = new GrpcClient(getLoopbackAddress().getHostAddress(),
+                    arbdaemon.apiPort,
+                    config.apiPassword);
+            arbClient.registerDisputeAgent(MEDIATOR, DEV_PRIVILEGE_PRIV_KEY);
+            arbClient.registerDisputeAgent(REFUND_AGENT, DEV_PRIVILEGE_PRIV_KEY);
+        }
+    }
 }
diff --git a/apitest/src/main/java/bisq/apitest/config/ApiTestConfig.java b/apitest/src/main/java/bisq/apitest/config/ApiTestConfig.java
index bffa009935c..44897a2da0c 100644
--- a/apitest/src/main/java/bisq/apitest/config/ApiTestConfig.java
+++ b/apitest/src/main/java/bisq/apitest/config/ApiTestConfig.java
@@ -54,6 +54,13 @@
 @Slf4j
 public class ApiTestConfig {
 
+    // Global constants
+    public static final String BSQ = "BSQ";
+    public static final String BTC = "BTC";
+    public static final String ARBITRATOR = "arbitrator";
+    public static final String MEDIATOR = "mediator";
+    public static final String REFUND_AGENT = "refundagent";
+
     // Option name constants
     static final String HELP = "help";
     static final String BASH_PATH = "bashPath";
@@ -73,6 +80,7 @@ public class ApiTestConfig {
     static final String SUPPORTING_APPS = "supportingApps";
     static final String CALL_RATE_METERING_CONFIG_PATH = "callRateMeteringConfigPath";
     static final String ENABLE_BISQ_DEBUGGING = "enableBisqDebugging";
+    static final String REGISTER_DISPUTE_AGENTS = "registerDisputeAgents";
 
     // Default values for certain options
     static final String DEFAULT_CONFIG_FILE_NAME = "apitest.properties";
@@ -105,6 +113,7 @@ public class ApiTestConfig {
     public final List supportingApps;
     public final String callRateMeteringConfigPath;
     public final boolean enableBisqDebugging;
+    public final boolean registerDisputeAgents;
 
     // Immutable system configurations set in the constructor.
     public final String bitcoinDatadir;
@@ -242,6 +251,13 @@ public ApiTestConfig(String... args) {
                         .withRequiredArg()
                         .ofType(Boolean.class)
                         .defaultsTo(false);
+
+        ArgumentAcceptingOptionSpec registerDisputeAgentsOpt =
+                parser.accepts(REGISTER_DISPUTE_AGENTS,
+                        "Register dispute agents in arbitration daemon")
+                        .withRequiredArg()
+                        .ofType(Boolean.class)
+                        .defaultsTo(true);
         try {
             CompositeOptionSet options = new CompositeOptionSet();
 
@@ -299,6 +315,7 @@ public ApiTestConfig(String... args) {
             this.supportingApps = asList(options.valueOf(supportingAppsOpt).split(","));
             this.callRateMeteringConfigPath = options.valueOf(callRateMeteringConfigPathOpt);
             this.enableBisqDebugging = options.valueOf(enableBisqDebuggingOpt);
+            this.registerDisputeAgents = options.valueOf(registerDisputeAgentsOpt);
 
             // Assign values to special-case static fields.
             BASH_PATH_VALUE = bashPath;
diff --git a/apitest/src/test/java/bisq/apitest/method/MethodTest.java b/apitest/src/test/java/bisq/apitest/method/MethodTest.java
index c60141dbc2c..51308d8ccdd 100644
--- a/apitest/src/test/java/bisq/apitest/method/MethodTest.java
+++ b/apitest/src/test/java/bisq/apitest/method/MethodTest.java
@@ -30,7 +30,6 @@
 import java.util.function.Function;
 import java.util.stream.Collectors;
 
-import static bisq.common.app.DevEnv.DEV_PRIVILEGE_PRIV_KEY;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Arrays.stream;
 import static org.junit.jupiter.api.Assertions.fail;
@@ -42,31 +41,21 @@
 
 public class MethodTest extends ApiTestCase {
 
-    public static final String BSQ = "BSQ";
-    public static final String BTC = "BTC";
-
-    protected static final String ARBITRATOR = "arbitrator";
-    protected static final String MEDIATOR = "mediator";
-    protected static final String REFUND_AGENT = "refundagent";
-
     protected static final CoreProtoResolver CORE_PROTO_RESOLVER = new CoreProtoResolver();
 
     private static final Function[], String> toNameList = (enums) ->
             stream(enums).map(Enum::name).collect(Collectors.joining(","));
 
     public static void startSupportingApps(File callRateMeteringConfigFile,
-                                           boolean registerDisputeAgents,
                                            boolean generateBtcBlock,
                                            Enum... supportingApps) {
         startSupportingApps(callRateMeteringConfigFile,
-                registerDisputeAgents,
                 generateBtcBlock,
                 false,
                 supportingApps);
     }
 
     public static void startSupportingApps(File callRateMeteringConfigFile,
-                                           boolean registerDisputeAgents,
                                            boolean generateBtcBlock,
                                            boolean startSupportingAppsInDebugMode,
                                            Enum... supportingApps) {
@@ -76,23 +65,20 @@ public static void startSupportingApps(File callRateMeteringConfigFile,
                     "--callRateMeteringConfigPath", callRateMeteringConfigFile.getAbsolutePath(),
                     "--enableBisqDebugging", startSupportingAppsInDebugMode ? "true" : "false"
             });
-            doPostStartup(registerDisputeAgents, generateBtcBlock);
+            doPostStartup(generateBtcBlock);
         } catch (Exception ex) {
             fail(ex);
         }
     }
 
-    public static void startSupportingApps(boolean registerDisputeAgents,
-                                           boolean generateBtcBlock,
+    public static void startSupportingApps(boolean generateBtcBlock,
                                            Enum... supportingApps) {
-        startSupportingApps(registerDisputeAgents,
-                generateBtcBlock,
+        startSupportingApps(generateBtcBlock,
                 false,
                 supportingApps);
     }
 
-    public static void startSupportingApps(boolean registerDisputeAgents,
-                                           boolean generateBtcBlock,
+    public static void startSupportingApps(boolean generateBtcBlock,
                                            boolean startSupportingAppsInDebugMode,
                                            Enum... supportingApps) {
         try {
@@ -103,18 +89,13 @@ public static void startSupportingApps(boolean registerDisputeAgents,
                     "--callRateMeteringConfigPath", callRateMeteringConfigFile.getAbsolutePath(),
                     "--enableBisqDebugging", startSupportingAppsInDebugMode ? "true" : "false"
             });
-            doPostStartup(registerDisputeAgents, generateBtcBlock);
+            doPostStartup(generateBtcBlock);
         } catch (Exception ex) {
             fail(ex);
         }
     }
 
-    protected static void doPostStartup(boolean registerDisputeAgents,
-                                        boolean generateBtcBlock) {
-        if (registerDisputeAgents) {
-            registerDisputeAgents();
-        }
-
+    protected static void doPostStartup(boolean generateBtcBlock) {
         // Generate 1 regtest block for alice's and/or bob's wallet to
         // show 10 BTC balance, and allow time for daemons parse the new block.
         if (generateBtcBlock)
@@ -162,11 +143,6 @@ protected final bisq.core.payment.PaymentAccount createPaymentAccount(GrpcClient
 
     // Static conveniences for test methods and test case fixture setups.
 
-    protected static void registerDisputeAgents() {
-        arbClient.registerDisputeAgent(MEDIATOR, DEV_PRIVILEGE_PRIV_KEY);
-        arbClient.registerDisputeAgent(REFUND_AGENT, DEV_PRIVILEGE_PRIV_KEY);
-    }
-
     protected static String encodeToHex(String s) {
         return Utilities.bytesAsHexString(s.getBytes(UTF_8));
     }
diff --git a/apitest/src/test/java/bisq/apitest/method/RegisterDisputeAgentsTest.java b/apitest/src/test/java/bisq/apitest/method/RegisterDisputeAgentsTest.java
index 9b3a2e5f0b4..b5011ffced8 100644
--- a/apitest/src/test/java/bisq/apitest/method/RegisterDisputeAgentsTest.java
+++ b/apitest/src/test/java/bisq/apitest/method/RegisterDisputeAgentsTest.java
@@ -29,6 +29,9 @@
 import org.junit.jupiter.api.TestMethodOrder;
 
 import static bisq.apitest.Scaffold.BitcoinCoreApp.bitcoind;
+import static bisq.apitest.config.ApiTestConfig.ARBITRATOR;
+import static bisq.apitest.config.ApiTestConfig.MEDIATOR;
+import static bisq.apitest.config.ApiTestConfig.REFUND_AGENT;
 import static bisq.apitest.config.BisqAppConfig.arbdaemon;
 import static bisq.apitest.config.BisqAppConfig.seednode;
 import static bisq.common.app.DevEnv.DEV_PRIVILEGE_PRIV_KEY;

From 4424deb3e1b516d4ed6d7dc7d71781147b093563 Mon Sep 17 00:00:00 2001
From: ghubstan <36207203+ghubstan@users.noreply.github.com>
Date: Thu, 1 Apr 2021 18:04:42 -0300
Subject: [PATCH 13/21] Implement api VerifyBsqSentToAddressReply service

---
 core/src/main/java/bisq/core/api/CoreApi.java  |  4 ++++
 .../bisq/daemon/grpc/GrpcWalletsService.java   | 18 ++++++++++++++++++
 2 files changed, 22 insertions(+)

diff --git a/core/src/main/java/bisq/core/api/CoreApi.java b/core/src/main/java/bisq/core/api/CoreApi.java
index 24ead3f1aba..fe5bbceba58 100644
--- a/core/src/main/java/bisq/core/api/CoreApi.java
+++ b/core/src/main/java/bisq/core/api/CoreApi.java
@@ -312,6 +312,10 @@ public void sendBtc(String address,
         walletsService.sendBtc(address, amount, txFeeRate, memo, callback);
     }
 
+    public boolean verifyBsqSentToAddress(String address, String amount) {
+        return walletsService.verifyBsqSentToAddress(address, amount);
+    }
+
     public void getTxFeeRate(ResultHandler resultHandler) {
         walletsService.getTxFeeRate(resultHandler);
     }
diff --git a/daemon/src/main/java/bisq/daemon/grpc/GrpcWalletsService.java b/daemon/src/main/java/bisq/daemon/grpc/GrpcWalletsService.java
index 1391a5b6976..29710015c18 100644
--- a/daemon/src/main/java/bisq/daemon/grpc/GrpcWalletsService.java
+++ b/daemon/src/main/java/bisq/daemon/grpc/GrpcWalletsService.java
@@ -51,6 +51,8 @@
 import bisq.proto.grpc.UnlockWalletRequest;
 import bisq.proto.grpc.UnsetTxFeeRatePreferenceReply;
 import bisq.proto.grpc.UnsetTxFeeRatePreferenceRequest;
+import bisq.proto.grpc.VerifyBsqSentToAddressReply;
+import bisq.proto.grpc.VerifyBsqSentToAddressRequest;
 
 import io.grpc.ServerInterceptor;
 import io.grpc.stub.StreamObserver;
@@ -224,6 +226,21 @@ public void onFailure(@NotNull Throwable t) {
         }
     }
 
+    @Override
+    public void verifyBsqSentToAddress(VerifyBsqSentToAddressRequest req,
+                                       StreamObserver responseObserver) {
+        try {
+            boolean isAmountReceived = coreApi.verifyBsqSentToAddress(req.getAddress(), req.getAmount());
+            var reply = VerifyBsqSentToAddressReply.newBuilder()
+                    .setIsAmountReceived(isAmountReceived)
+                    .build();
+            responseObserver.onNext(reply);
+            responseObserver.onCompleted();
+        } catch (Throwable cause) {
+            exceptionHandler.handleException(log, cause, responseObserver);
+        }
+    }
+
     @Override
     public void getTxFeeRate(GetTxFeeRateRequest req,
                              StreamObserver responseObserver) {
@@ -358,6 +375,7 @@ final Optional rateMeteringInterceptor() {
                             put(getGetUnusedBsqAddressMethod().getFullMethodName(), new GrpcCallRateMeter(1, SECONDS));
                             put(getSendBsqMethod().getFullMethodName(), new GrpcCallRateMeter(1, MINUTES));
                             put(getSendBtcMethod().getFullMethodName(), new GrpcCallRateMeter(1, MINUTES));
+                            put(getVerifyBsqSentToAddressMethod().getFullMethodName(), new GrpcCallRateMeter(1, SECONDS));
                             put(getGetTxFeeRateMethod().getFullMethodName(), new GrpcCallRateMeter(1, SECONDS));
                             put(getSetTxFeeRatePreferenceMethod().getFullMethodName(), new GrpcCallRateMeter(1, SECONDS));
                             put(getUnsetTxFeeRatePreferenceMethod().getFullMethodName(), new GrpcCallRateMeter(1, SECONDS));

From b2634aace1ba7948128c28d0bdf303f6806e8bc7 Mon Sep 17 00:00:00 2001
From: ghubstan <36207203+ghubstan@users.noreply.github.com>
Date: Thu, 1 Apr 2021 18:05:15 -0300
Subject: [PATCH 14/21] Define grpc call rate meter for
 CreateCryptoCurrencyPaymentAccount

---
 .../main/java/bisq/daemon/grpc/GrpcPaymentAccountsService.java   | 1 +
 1 file changed, 1 insertion(+)

diff --git a/daemon/src/main/java/bisq/daemon/grpc/GrpcPaymentAccountsService.java b/daemon/src/main/java/bisq/daemon/grpc/GrpcPaymentAccountsService.java
index 2c1b5501b28..9ac400d1008 100644
--- a/daemon/src/main/java/bisq/daemon/grpc/GrpcPaymentAccountsService.java
+++ b/daemon/src/main/java/bisq/daemon/grpc/GrpcPaymentAccountsService.java
@@ -174,6 +174,7 @@ final Optional rateMeteringInterceptor() {
                 .or(() -> Optional.of(CallRateMeteringInterceptor.valueOf(
                         new HashMap<>() {{
                             put(getCreatePaymentAccountMethod().getFullMethodName(), new GrpcCallRateMeter(1, MINUTES));
+                            put(getCreateCryptoCurrencyPaymentAccountMethod().getFullMethodName(), new GrpcCallRateMeter(1, MINUTES));
                             put(getGetPaymentAccountsMethod().getFullMethodName(), new GrpcCallRateMeter(1, SECONDS));
                             put(getGetPaymentMethodsMethod().getFullMethodName(), new GrpcCallRateMeter(1, SECONDS));
                             put(getGetPaymentAccountFormMethod().getFullMethodName(), new GrpcCallRateMeter(1, SECONDS));

From da99febc23c316807daab9a12a75924dbbaee0d8 Mon Sep 17 00:00:00 2001
From: ghubstan <36207203+ghubstan@users.noreply.github.com>
Date: Thu, 1 Apr 2021 18:50:47 -0300
Subject: [PATCH 15/21] Refactor apitest's bot related classes

A new botsupport package was added, with no dependencies on the test
harness.  The intent is eventually move this botsupport pkg code to a
new gradle subproject called ':bot' which would depend only on the
':cli' subproject (to keep the ':cli' module light).

The future ':bot' subproject would not be shipped, but would give java
devs some api java-bot support and at least one example bot.  A bsq-btc
market maker bot is included in this commit.
---
 .../bisq/apitest/botsupport/BotClient.java    | 652 ++++++++++++++++++
 .../java/bisq/apitest/botsupport/BotMain.java | 187 +++++
 .../bisq/apitest/botsupport/BotThread.java    |  98 +++
 .../PaymentAccountNotFoundException.java      |  10 +-
 .../example/BaseMarketMakerBot.java           | 227 ++++++
 .../botsupport/example/BsqMarketMakerBot.java | 295 ++++++++
 .../example/BsqMarketMakerBotTest.java        | 247 +++++++
 .../botsupport/example/CancelOffersBot.java   |  58 ++
 .../apitest/botsupport/opts/BotOptLabel.java  |  31 +
 .../opts/BsqMarketMakerBotOptionParser.java   | 100 +++
 .../botsupport/protocol/BotProtocol.java      | 449 ++++++++++++
 .../protocol/CreateOfferException.java        |  35 +
 .../botsupport/protocol/MakerBotProtocol.java |  21 +
 .../protocol/MarketMakerBotProtocol.java      | 383 ++++++++++
 .../botsupport/protocol/ProtocolStep.java     |  36 +
 .../botsupport/protocol/TakeOfferHelper.java  | 283 ++++++++
 .../botsupport/protocol/TakerBotProtocol.java |  21 +
 .../script/BashScriptGenerator.java           |  37 +-
 .../shutdown/ManualBotShutdownException.java  |  10 +-
 .../shutdown/ManualShutdown.java              |  40 +-
 .../apitest/botsupport/util/BotUtilities.java |  91 +++
 .../apitest/botsupport/util/FileUtil.java     |  55 ++
 .../botsupport/util/FrameRateTimer.java       | 106 +++
 .../apitest/botsupport/util/MasterTimer.java  |  57 ++
 .../bisq/apitest/botsupport/util/Timer.java   |  28 +
 .../method/offer/AbstractOfferTest.java       |   8 +-
 .../apitest/method/offer/CancelOfferTest.java |   1 +
 .../method/offer/CreateBSQOffersTest.java     |   2 +
 .../offer/CreateOfferUsingFixedPriceTest.java |   2 +
 ...CreateOfferUsingMarketPriceMarginTest.java |   1 +
 .../method/offer/ValidateCreateOfferTest.java |   2 +
 .../method/trade/AbstractTradeTest.java       |  37 +
 .../method/trade/TakeBuyBSQOfferTest.java     |  17 +-
 .../method/trade/TakeBuyBTCOfferTest.java     |   1 +
 .../method/trade/TakeSellBSQOfferTest.java    |  17 +-
 .../method/trade/TakeSellBTCOfferTest.java    |   1 +
 .../apitest/scenario/ScriptedBotTest.java     |  13 +-
 .../apitest/scenario/bot/AbstractBotTest.java |  15 +-
 .../java/bisq/apitest/scenario/bot/Bot.java   |  38 +-
 .../bisq/apitest/scenario/bot/BotClient.java  | 341 ---------
 .../bot/BotPaymentAccountGenerator.java       |   6 +
 .../scenario/bot/MarketMakerBotTest.java      | 126 ++++
 .../apitest/scenario/bot/RandomOffer.java     |  12 +-
 .../bisq/apitest/scenario/bot/RobotBob.java   |  31 +-
 .../apitest/scenario/bot/RobotBobMMBot.java   | 447 ++++++++++++
 .../bot/protocol/ApiTestBotProtocol.java      |  88 +++
 ...ocol.java => ApiTestMakerBotProtocol.java} |  41 +-
 .../ApiTestMarketMakerBotProtocol.java        | 216 ++++++
 ...ocol.java => ApiTestTakerBotProtocol.java} |  57 +-
 .../scenario/bot/protocol/BotProtocol.java    | 349 ----------
 .../MarketMakerTakeOnlyBotProtocol.java       | 160 +++++
 .../scenario/bot/protocol/ProtocolStep.java   |  17 -
 .../scenario/bot/script/BotScript.java        |   3 +-
 .../bot/script/BotScriptGenerator.java        |   2 +-
 .../cli/opts/AbstractMethodOptionParser.java  |   2 +-
 55 files changed, 4747 insertions(+), 863 deletions(-)
 create mode 100644 apitest/src/test/java/bisq/apitest/botsupport/BotClient.java
 create mode 100644 apitest/src/test/java/bisq/apitest/botsupport/BotMain.java
 create mode 100644 apitest/src/test/java/bisq/apitest/botsupport/BotThread.java
 rename apitest/src/test/java/bisq/apitest/{scenario/bot => botsupport}/PaymentAccountNotFoundException.java (81%)
 create mode 100644 apitest/src/test/java/bisq/apitest/botsupport/example/BaseMarketMakerBot.java
 create mode 100644 apitest/src/test/java/bisq/apitest/botsupport/example/BsqMarketMakerBot.java
 create mode 100644 apitest/src/test/java/bisq/apitest/botsupport/example/BsqMarketMakerBotTest.java
 create mode 100644 apitest/src/test/java/bisq/apitest/botsupport/example/CancelOffersBot.java
 create mode 100644 apitest/src/test/java/bisq/apitest/botsupport/opts/BotOptLabel.java
 create mode 100644 apitest/src/test/java/bisq/apitest/botsupport/opts/BsqMarketMakerBotOptionParser.java
 create mode 100644 apitest/src/test/java/bisq/apitest/botsupport/protocol/BotProtocol.java
 create mode 100644 apitest/src/test/java/bisq/apitest/botsupport/protocol/CreateOfferException.java
 create mode 100644 apitest/src/test/java/bisq/apitest/botsupport/protocol/MakerBotProtocol.java
 create mode 100644 apitest/src/test/java/bisq/apitest/botsupport/protocol/MarketMakerBotProtocol.java
 create mode 100644 apitest/src/test/java/bisq/apitest/botsupport/protocol/ProtocolStep.java
 create mode 100644 apitest/src/test/java/bisq/apitest/botsupport/protocol/TakeOfferHelper.java
 create mode 100644 apitest/src/test/java/bisq/apitest/botsupport/protocol/TakerBotProtocol.java
 rename apitest/src/test/java/bisq/apitest/{scenario/bot => botsupport}/script/BashScriptGenerator.java (88%)
 rename apitest/src/test/java/bisq/apitest/{scenario/bot => botsupport}/shutdown/ManualBotShutdownException.java (80%)
 rename apitest/src/test/java/bisq/apitest/{scenario/bot => botsupport}/shutdown/ManualShutdown.java (52%)
 create mode 100644 apitest/src/test/java/bisq/apitest/botsupport/util/BotUtilities.java
 create mode 100644 apitest/src/test/java/bisq/apitest/botsupport/util/FileUtil.java
 create mode 100644 apitest/src/test/java/bisq/apitest/botsupport/util/FrameRateTimer.java
 create mode 100644 apitest/src/test/java/bisq/apitest/botsupport/util/MasterTimer.java
 create mode 100644 apitest/src/test/java/bisq/apitest/botsupport/util/Timer.java
 delete mode 100644 apitest/src/test/java/bisq/apitest/scenario/bot/BotClient.java
 create mode 100644 apitest/src/test/java/bisq/apitest/scenario/bot/MarketMakerBotTest.java
 create mode 100644 apitest/src/test/java/bisq/apitest/scenario/bot/RobotBobMMBot.java
 create mode 100644 apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ApiTestBotProtocol.java
 rename apitest/src/test/java/bisq/apitest/scenario/bot/protocol/{MakerBotProtocol.java => ApiTestMakerBotProtocol.java} (73%)
 create mode 100644 apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ApiTestMarketMakerBotProtocol.java
 rename apitest/src/test/java/bisq/apitest/scenario/bot/protocol/{TakerBotProtocol.java => ApiTestTakerBotProtocol.java} (72%)
 delete mode 100644 apitest/src/test/java/bisq/apitest/scenario/bot/protocol/BotProtocol.java
 create mode 100644 apitest/src/test/java/bisq/apitest/scenario/bot/protocol/MarketMakerTakeOnlyBotProtocol.java
 delete mode 100644 apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ProtocolStep.java

diff --git a/apitest/src/test/java/bisq/apitest/botsupport/BotClient.java b/apitest/src/test/java/bisq/apitest/botsupport/BotClient.java
new file mode 100644
index 00000000000..120bd1984cc
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/botsupport/BotClient.java
@@ -0,0 +1,652 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see .
+ */
+
+package bisq.apitest.botsupport;
+
+import bisq.proto.grpc.AvailabilityResultWithDescription;
+import bisq.proto.grpc.BalancesInfo;
+import bisq.proto.grpc.GetPaymentAccountsRequest;
+import bisq.proto.grpc.OfferInfo;
+import bisq.proto.grpc.TakeOfferReply;
+import bisq.proto.grpc.TradeInfo;
+import bisq.proto.grpc.TxInfo;
+
+import protobuf.AvailabilityResult;
+import protobuf.PaymentAccount;
+
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+
+import java.text.DecimalFormat;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.BiPredicate;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+
+import lombok.extern.slf4j.Slf4j;
+
+import static bisq.apitest.botsupport.protocol.BotProtocol.BSQ;
+import static bisq.apitest.botsupport.util.BotUtilities.capitalize;
+import static bisq.cli.CurrencyFormat.formatBsqAmount;
+import static bisq.cli.CurrencyFormat.formatMarketPrice;
+import static java.lang.System.currentTimeMillis;
+import static java.util.Arrays.asList;
+import static java.util.Objects.requireNonNull;
+import static protobuf.OfferPayload.Direction.BUY;
+import static protobuf.OfferPayload.Direction.SELL;
+
+
+
+import bisq.apitest.botsupport.util.BotUtilities;
+import bisq.cli.GrpcClient;
+
+/**
+ * Convenience GrpcClient wrapper for bots using gRPC services.
+ */
+@SuppressWarnings({"JavaDoc", "unused"})
+@Slf4j
+public class BotClient {
+
+    private static final DecimalFormat FIXED_PRICE_FMT = new DecimalFormat("###########0");
+
+    private static final int TAKE_OFFER_TIMEOUT_IN_SEC = 60;
+
+    private final ListeningExecutorService takeOfferExecutor =
+            BotUtilities.getListeningExecutorService("Take Offer With " + TAKE_OFFER_TIMEOUT_IN_SEC + "s Timeout",
+                    1,
+                    1,
+                    TAKE_OFFER_TIMEOUT_IN_SEC);
+
+    private final GrpcClient grpcClient;
+
+    public BotClient(GrpcClient grpcClient) {
+        this.grpcClient = grpcClient;
+    }
+
+    /**
+     * TODO
+     * @param address
+     * @param amount
+     */
+    public void sendBsq(String address, String amount) {
+        grpcClient.sendBsq(address, amount, "");
+    }
+
+    /**
+     * TODO
+     * @param address
+     * @param amount
+     * @param txFeeRate
+     */
+    public void sendBsq(String address, String amount, String txFeeRate) {
+        grpcClient.sendBsq(address, amount, txFeeRate);
+    }
+
+    /**
+     * TODO
+     * @param address
+     * @param amount
+     */
+    public void sendBtc(String address, String amount) {
+        grpcClient.sendBtc(address, amount, "", "");
+    }
+
+    /**
+     * TODO
+     * @param address
+     * @param amount
+     * @param txFeeRate
+     * @param memo
+     */
+    public void sendBtc(String address, String amount, String txFeeRate, String memo) {
+        grpcClient.sendBtc(address, amount, txFeeRate, memo);
+    }
+
+    /**
+     * TODO
+     * @param trade
+     */
+    public void makeBsqPayment(TradeInfo trade) {
+        var contract = trade.getContract();
+        var bsqSats = trade.getOffer().getVolume();
+        var sendAmountAsString = formatBsqAmount(bsqSats);
+        var address = contract.getIsBuyerMakerAndSellerTaker()
+                ? contract.getTakerPaymentAccountPayload().getAddress()
+                : contract.getMakerPaymentAccountPayload().getAddress();
+        log.info("Sending payment of {} BSQ to address {} for trade with id {}.",
+                sendAmountAsString,
+                address,
+                trade.getTradeId());
+        sendBsq(address, sendAmountAsString);
+    }
+
+    /**
+     * Returns true if the specified amount of BSQ satoshis sent to an address, or throws
+     * an exception.
+     * @param address
+     * @param amount
+     * @return boolean
+     */
+    public boolean verifyBsqSentToAddress(String address, String amount) {
+        return grpcClient.verifyBsqSentToAddress(address, amount);
+    }
+
+    /**
+     * Returns current BSQ and BTC balance information.
+     * @return BalancesInfo
+     */
+    public BalancesInfo getBalance() {
+        return grpcClient.getBalances();
+    }
+
+    /**
+     * Return the most recent BTC market price for the given currencyCode.
+     * @param currencyCode
+     * @return double
+     */
+    public double getCurrentBTCMarketPrice(String currencyCode) {
+        return grpcClient.getBtcPrice(currencyCode);
+    }
+
+    /**
+     * Return the most recent BTC market price for the given currencyCode as a string.
+     * @param currencyCode
+     * @return String
+     */
+    public String getCurrentBTCMarketPriceAsString(String currencyCode) {
+        return formatMarketPrice(getCurrentBTCMarketPrice(currencyCode));
+    }
+
+    /**
+     * Return the most recent BTC market price for the given currencyCode as an
+     * integer string.
+     * @param currencyCode
+     * @return String
+     */
+    public String getCurrentBTCMarketPriceAsIntegerString(String currencyCode) {
+        return FIXED_PRICE_FMT.format(getCurrentBTCMarketPrice(currencyCode));
+    }
+
+    /**
+     * Return all BUY and SELL offers for the given currencyCode.
+     * @param currencyCode
+     * @return List
+     */
+    public List getOffers(String currencyCode) {
+        var buyOffers = getBuyOffers(currencyCode);
+        if (buyOffers.size() > 0) {
+            return buyOffers;
+        } else {
+            return getSellOffers(currencyCode);
+        }
+    }
+
+    /**
+     * Return BUY offers for the given currencyCode.
+     * @param currencyCode
+     * @return List
+     */
+    public List getBuyOffers(String currencyCode) {
+        return grpcClient.getOffers(BUY.name(), currencyCode);
+    }
+
+    /**
+     * Return user created BUY offers for the given currencyCode.
+     * @param currencyCode
+     * @return List
+     */
+    public List getMyBuyOffers(String currencyCode) {
+        return grpcClient.getMyOffers(BUY.name(), currencyCode);
+    }
+
+
+    /**
+     * Return SELL offers for the given currencyCode.
+     * @param currencyCode
+     * @return List
+     */
+    public List getSellOffers(String currencyCode) {
+        return grpcClient.getOffers(SELL.name(), currencyCode);
+    }
+
+    /**
+     * Return user created BUY offers for the given currencyCode.
+     * @param currencyCode
+     * @return List
+     */
+    public List getMySellOffers(String currencyCode) {
+        return grpcClient.getMyOffers(SELL.name(), currencyCode);
+    }
+
+
+    /**
+     * Return all available BUY and SELL offers for the given currencyCode,
+     * sorted by creation date.
+     * @param currencyCode
+     * @return List
+     */
+    public List getOffersSortedByDate(String currencyCode) {
+        ArrayList offers = new ArrayList<>();
+        offers.addAll(getBuyOffers(currencyCode));
+        offers.addAll(getSellOffers(currencyCode));
+        return grpcClient.sortOffersByDate(offers);
+    }
+
+    /**
+     * Return all user created BUY and SELL offers for the given currencyCode,
+     * sorted by creation date.
+     * @param currencyCode
+     * @return List
+     */
+    public List getMyOffersSortedByDate(String currencyCode) {
+        ArrayList offers = new ArrayList<>();
+        offers.addAll(getMyBuyOffers(currencyCode));
+        offers.addAll(getMySellOffers(currencyCode));
+        return grpcClient.sortOffersByDate(offers);
+    }
+
+    // TODO be more specific, i.e., (base) currencyCode=BTC,  counterCurrencyCode=BSQ ?
+    public final Predicate iHaveCurrentOffers = (currencyCode) ->
+            !getMyBuyOffers(currencyCode).isEmpty() || !getMySellOffers(currencyCode).isEmpty();
+
+    public final BiPredicate iHaveCurrentOffersWithDirection = (direction, currencyCode) -> {
+        if (direction.equalsIgnoreCase(BUY.name()) || direction.equalsIgnoreCase(SELL.name())) {
+            return direction.equals(BUY.name())
+                    ? !getMyBuyOffers(currencyCode).isEmpty()
+                    : !getMySellOffers(currencyCode).isEmpty();
+        } else {
+            throw new IllegalStateException(direction + " is not a valid offer direction");
+        }
+    };
+
+    /**
+     * Create and return a new Offer using a market based price.
+     * @param paymentAccount
+     * @param direction
+     * @param currencyCode
+     * @param amountInSatoshis
+     * @param minAmountInSatoshis
+     * @param priceMarginAsPercent
+     * @param securityDepositAsPercent
+     * @param feeCurrency
+     * @return OfferInfo
+     */
+    public OfferInfo createOfferAtMarketBasedPrice(PaymentAccount paymentAccount,
+                                                   String direction,
+                                                   String currencyCode,
+                                                   long amountInSatoshis,
+                                                   long minAmountInSatoshis,
+                                                   double priceMarginAsPercent,
+                                                   double securityDepositAsPercent,
+                                                   String feeCurrency) {
+        return grpcClient.createMarketBasedPricedOffer(direction,
+                currencyCode,
+                amountInSatoshis,
+                minAmountInSatoshis,
+                priceMarginAsPercent,
+                securityDepositAsPercent,
+                paymentAccount.getId(),
+                feeCurrency);
+    }
+
+    /**
+     * Create and return a new Offer using a fixed price.
+     * @param paymentAccount
+     * @param direction
+     * @param currencyCode
+     * @param amountInSatoshis
+     * @param minAmountInSatoshis
+     * @param fixedOfferPriceAsString
+     * @param securityDepositAsPercent
+     * @param feeCurrency
+     * @return OfferInfo
+     */
+    public OfferInfo createOfferAtFixedPrice(PaymentAccount paymentAccount,
+                                             String direction,
+                                             String currencyCode,
+                                             long amountInSatoshis,
+                                             long minAmountInSatoshis,
+                                             String fixedOfferPriceAsString,
+                                             double securityDepositAsPercent,
+                                             String feeCurrency) {
+        return grpcClient.createFixedPricedOffer(direction,
+                currencyCode,
+                amountInSatoshis,
+                minAmountInSatoshis,
+                fixedOfferPriceAsString,
+                securityDepositAsPercent,
+                paymentAccount.getId(),
+                feeCurrency);
+    }
+
+    /**
+     * TODO
+     * @param offer
+     */
+    public void cancelOffer(OfferInfo offer) {
+        grpcClient.cancelOffer(offer.getId());
+    }
+
+    /**
+     * TODO
+     * @param offerId
+     * @param paymentAccount
+     * @param feeCurrency
+     * @return
+     */
+    public TradeInfo takeOffer(String offerId, PaymentAccount paymentAccount, String feeCurrency) {
+        return grpcClient.takeOffer(offerId, paymentAccount.getId(), feeCurrency);
+    }
+
+    /**
+     * TODO
+     * @param offerId
+     * @param paymentAccount
+     * @param feeCurrency
+     * @param resultHandler
+     * @param errorHandler
+     */
+    public void tryToTakeOffer(String offerId,
+                               PaymentAccount paymentAccount,
+                               String feeCurrency,
+                               Consumer resultHandler,
+                               Consumer errorHandler) {
+        long startTime = System.currentTimeMillis();
+        ListenableFuture future = takeOfferExecutor.submit(() ->
+                grpcClient.getTakeOfferReply(offerId, paymentAccount.getId(), feeCurrency));
+        Futures.addCallback(future, new FutureCallback<>() {
+            @Override
+            public void onSuccess(TakeOfferReply result) {
+                resultHandler.accept(result);
+
+                if (result.hasTrade()) {
+                    log.info("Offer {} taken in {} ms.",
+                            requireNonNull(result.getTrade()).getOffer().getId(),
+                            currentTimeMillis() - startTime);
+                } else if (result.hasFailureReason()) {
+                    var failureReason = result.getFailureReason();
+                    log.warn("Offer {} could not be taken after {} ms.\n"
+                                    + "\tReason: {} Description: {}",
+                            offerId,
+                            currentTimeMillis() - startTime,
+                            failureReason.getAvailabilityResult(),
+                            failureReason.getDescription());
+                } else {
+                    throw new IllegalStateException(
+                            "programmer error: takeoffer request did not return a trade"
+                                    + " or availability reason, and did not throw an exception");
+                }
+            }
+
+            @Override
+            public void onFailure(Throwable t) {
+                errorHandler.accept(t);
+            }
+        }, MoreExecutors.directExecutor());
+    }
+
+    public boolean takeOfferFailedForOneOfTheseReasons(AvailabilityResultWithDescription failureReason,
+                                                       AvailabilityResult... reasons) {
+        if (failureReason == null)
+            throw new IllegalArgumentException(
+                    "AvailabilityResultWithDescription failureReason argument cannot be null.");
+
+        if (reasons == null || reasons.length == 0)
+            throw new IllegalArgumentException(
+                    "AvailabilityResult reasons argument cannot be null or empty.");
+
+        return asList(reasons).contains(failureReason.getAvailabilityResult());
+    }
+
+    /**
+     * Returns a persisted Trade with the given tradeId, or throws an exception.
+     * @param tradeId
+     * @return TradeInfo
+     */
+    public TradeInfo getTrade(String tradeId) {
+        return grpcClient.getTrade(tradeId);
+    }
+
+    /**
+     * Predicate returns true if the given exception indicates the trade with the given
+     * tradeId exists, but the trade's contract has not been fully prepared.
+     */
+    public final BiPredicate tradeContractIsNotReady = (exception, tradeId) -> {
+        if (exception.getMessage().contains("no contract was found")) {
+            logTradeContractIsNotReadyWarning(tradeId, exception);
+            return true;
+        } else {
+            return false;
+        }
+    };
+
+    public void logTradeContractIsNotReadyWarning(String tradeId, Exception exception) {
+        log.warn("Trade {} exists but is not fully prepared: {}.",
+                tradeId,
+                toCleanGrpcExceptionMessage(exception));
+    }
+
+    /**
+     * Returns a trade's contract as a Json string, or null if the trade exists
+     * but the contract is not ready.
+     * @param tradeId
+     * @return String
+     */
+    public String getTradeContract(String tradeId) {
+        try {
+            var trade = grpcClient.getTrade(tradeId);
+            return trade.getContractAsJson();
+        } catch (Exception ex) {
+            if (tradeContractIsNotReady.test(ex, tradeId))
+                return null;
+            else
+                throw ex;
+        }
+    }
+
+    /**
+     * Returns true if the trade's taker deposit fee transaction has been published.
+     * @param tradeId a valid trade id
+     * @return boolean
+     */
+    public boolean isTakerDepositFeeTxPublished(String tradeId) {
+        return grpcClient.getTrade(tradeId).getIsPayoutPublished();
+    }
+
+    /**
+     * Returns true if the trade's taker deposit fee transaction has been confirmed.
+     * @param tradeId a valid trade id
+     * @return boolean
+     */
+    public boolean isTakerDepositFeeTxConfirmed(String tradeId) {
+        return grpcClient.getTrade(tradeId).getIsDepositConfirmed();
+    }
+
+    /**
+     * Returns true if the trade's 'start payment' message has been sent by the buyer.
+     * @param tradeId a valid trade id
+     * @return boolean
+     */
+    public boolean isTradePaymentStartedSent(String tradeId) {
+        return grpcClient.getTrade(tradeId).getIsFiatSent();
+    }
+
+    /**
+     * Returns true if the trade's 'payment received' message has been sent by the seller.
+     * @param tradeId a valid trade id
+     * @return boolean
+     */
+    public boolean isTradePaymentReceivedConfirmationSent(String tradeId) {
+        return grpcClient.getTrade(tradeId).getIsFiatReceived();
+    }
+
+    /**
+     * Returns true if the trade's payout transaction has been published.
+     * @param tradeId a valid trade id
+     * @return boolean
+     */
+    public boolean isTradePayoutTxPublished(String tradeId) {
+        return grpcClient.getTrade(tradeId).getIsPayoutPublished();
+    }
+
+    /**
+     * Sends a 'confirm payment started message' for a trade with the given tradeId,
+     * or throws an exception.
+     * @param tradeId
+     */
+    public void sendConfirmPaymentStartedMessage(String tradeId) {
+        grpcClient.confirmPaymentStarted(tradeId);
+    }
+
+    /**
+     * Sends a 'confirm payment received message' for a trade with the given tradeId,
+     * or throws an exception.
+     * @param tradeId
+     */
+    public void sendConfirmPaymentReceivedMessage(String tradeId) {
+        grpcClient.confirmPaymentReceived(tradeId);
+    }
+
+    /**
+     * Sends a 'keep funds in wallet message' for a trade with the given tradeId,
+     * or throws an exception.
+     * @param tradeId
+     */
+    public void sendKeepFundsMessage(String tradeId) {
+        grpcClient.keepFunds(tradeId);
+    }
+
+    /**
+     * Create and save a new PaymentAccount with details in the given json.
+     * @param json
+     * @return PaymentAccount
+     */
+    public PaymentAccount createNewPaymentAccount(String json) {
+        return grpcClient.createPaymentAccount(json);
+    }
+
+    /**
+     * Returns a user's persisted PaymentAccount with the given paymentAccountId, or throws
+     * an exception.
+     * @param paymentAccountId The id of the PaymentAccount being looked up.
+     * @return PaymentAccount
+     */
+    public PaymentAccount getPaymentAccount(String paymentAccountId) {
+        return grpcClient.getPaymentAccounts().stream()
+                .filter(a -> (a.getId().equals(paymentAccountId)))
+                .findFirst()
+                .orElseThrow(() ->
+                        new PaymentAccountNotFoundException("Could not find a payment account with id "
+                                + paymentAccountId + "."));
+    }
+
+    /**
+     * Returns user's persisted PaymentAccounts.
+     * @return List
+     */
+    public List getPaymentAccounts() {
+        return grpcClient.getPaymentAccounts();
+    }
+
+    /**
+     * Returns a persisted PaymentAccount with the given accountName, or throws
+     * an exception.
+     * @param accountName
+     * @return PaymentAccount
+     */
+    public PaymentAccount getPaymentAccountWithName(String accountName) {
+        var req = GetPaymentAccountsRequest.newBuilder().build();
+        return grpcClient.getPaymentAccounts().stream()
+                .filter(a -> (a.getAccountName().equals(accountName)))
+                .findFirst()
+                .orElseThrow(() ->
+                        new PaymentAccountNotFoundException("Could not find a payment account with name "
+                                + accountName + "."));
+    }
+
+    /**
+     * TODO
+     * @return PaymentAccount
+     */
+    public PaymentAccount createCryptoCurrencyPaymentAccount(String accountName, boolean tradeInstant) {
+        String unusedBsqAddress = grpcClient.getUnusedBsqAddress();
+        return grpcClient.createCryptoCurrencyPaymentAccount(accountName, BSQ, unusedBsqAddress, tradeInstant);
+    }
+
+    /**
+     * TODO
+     * @return PaymentAccount
+     */
+    public PaymentAccount createReceiverBsqPaymentAccount() {
+        String unusedBsqAddress = grpcClient.getUnusedBsqAddress();
+        String accountName = "Receiver BSQ Account " + unusedBsqAddress.substring(0, 8) + " ...";
+        return grpcClient.createCryptoCurrencyPaymentAccount(accountName, BSQ, unusedBsqAddress, true);
+    }
+
+    /**
+     * TODO
+     * @return
+     */
+    public PaymentAccount createSenderBsqPaymentAccount() {
+        String unusedBsqAddress = grpcClient.getUnusedBsqAddress();
+        String accountName = "Sender BSQ Account " + unusedBsqAddress.substring(0, 8) + " ...";
+        return grpcClient.createCryptoCurrencyPaymentAccount(accountName, BSQ, unusedBsqAddress, true);
+    }
+
+    /**
+     * TODO
+     * @return List
+     */
+    public List getReceiverBsqPaymentAccounts() {
+        return getPaymentAccounts().stream()
+                .filter(a -> a.getPaymentAccountPayload().hasInstantCryptoCurrencyAccountPayload())
+                .filter(a -> a.getSelectedTradeCurrency().getCode().equals(BSQ))
+                .filter(a -> a.getAccountName().startsWith("Receiver BSQ Account"))
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * TODO
+     * @return List
+     */
+    public List getSenderBsqPaymentAccounts() {
+        return getPaymentAccounts().stream()
+                .filter(a -> a.getPaymentAccountPayload().hasInstantCryptoCurrencyAccountPayload())
+                .filter(a -> a.getSelectedTradeCurrency().getCode().equals(BSQ))
+                .filter(a -> a.getAccountName().startsWith("Sender BSQ Account"))
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * Returns a persisted Transaction with the given txId, or throws an exception.
+     * @param txId
+     * @return TxInfo
+     */
+    public TxInfo getTransaction(String txId) {
+        return grpcClient.getTransaction(txId);
+    }
+
+    public String toCleanGrpcExceptionMessage(Exception ex) {
+        return capitalize(ex.getMessage().replaceFirst("^[A-Z_]+: ", ""));
+    }
+}
diff --git a/apitest/src/test/java/bisq/apitest/botsupport/BotMain.java b/apitest/src/test/java/bisq/apitest/botsupport/BotMain.java
new file mode 100644
index 00000000000..3661ac4c5e2
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/botsupport/BotMain.java
@@ -0,0 +1,187 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see .
+ */
+
+package bisq.apitest.botsupport;
+
+import io.grpc.StatusRuntimeException;
+
+import joptsimple.OptionParser;
+import joptsimple.OptionSet;
+
+import java.io.IOException;
+import java.io.PrintStream;
+
+import java.math.BigDecimal;
+
+import java.util.List;
+
+import lombok.extern.slf4j.Slf4j;
+
+import static bisq.cli.opts.OptLabel.OPT_HELP;
+import static bisq.cli.opts.OptLabel.OPT_HOST;
+import static bisq.cli.opts.OptLabel.OPT_PASSWORD;
+import static bisq.cli.opts.OptLabel.OPT_PORT;
+import static java.lang.String.format;
+import static java.lang.System.err;
+import static java.lang.System.exit;
+import static java.lang.System.out;
+
+
+
+import bisq.apitest.botsupport.example.BsqMarketMakerBot;
+import bisq.apitest.botsupport.example.CancelOffersBot;
+import bisq.apitest.botsupport.opts.BsqMarketMakerBotOptionParser;
+import bisq.cli.opts.ArgumentList;
+
+// TODO Define BotMain in new gradle :bot subproject's generated bisq-bot script.
+
+@Slf4j
+public class BotMain {
+
+    // Quick testing
+    // --password=xyz --port=9998 bsqmarketmaker --target-btc-amount=0.05 --target-price=0.00004 --target-spread=13 --trade-cycle-limit=1 --new-payment-accts-limit=10
+    // Longer test sessions
+    // --password=xyz --port=9998 bsqmarketmaker --target-btc-amount=0.05 --target-price=0.00004 --target-spread=13 --trade-cycle-limit=20 --new-payment-accts-limit=20
+
+    enum BotType {
+        bsqmarketmaker,
+        canceloffers
+    }
+
+    public static void main(String[] args) {
+        try {
+            run(args);
+            exit(0);
+        } catch (Throwable t) {
+            err.println("Error: " + t.getMessage());
+            exit(1);
+        }
+    }
+
+    public static void run(String[] args) {
+        var parser = new OptionParser();
+
+        var helpOpt = parser.accepts(OPT_HELP, "Print this help text")
+                .forHelp();
+
+        var hostOpt = parser.accepts(OPT_HOST, "rpc server hostname or ip")
+                .withRequiredArg()
+                .defaultsTo("localhost");
+
+        var portOpt = parser.accepts(OPT_PORT, "rpc server port")
+                .withRequiredArg()
+                .ofType(Integer.class)
+                .defaultsTo(9998);
+
+        var passwordOpt = parser.accepts(OPT_PASSWORD, "rpc server password")
+                .withRequiredArg();
+
+        // Parse the CLI opts host, port, password, method name, and help.  The help opt
+        // may indicate the user is asking for method level help, and will be excluded
+        // from the parsed options if a method opt is present in String[] args.
+        OptionSet options = parser.parse(new ArgumentList(args).getCLIArguments());
+        @SuppressWarnings("unchecked")
+        var nonOptionArgs = (List) options.nonOptionArguments();
+
+        // If neither the help opt nor a method name is present, print CLI level help
+        // to stderr and throw an exception.
+        if (!options.has(helpOpt) && nonOptionArgs.isEmpty()) {
+            printHelp(parser, err);
+            throw new IllegalArgumentException("no bot type specified");
+        }
+
+        // If the help opt is present, but not a method name, print CLI level help
+        // to stdout.
+        if (options.has(helpOpt) && nonOptionArgs.isEmpty()) {
+            printHelp(parser, out);
+            return;
+        }
+
+        var host = options.valueOf(hostOpt);
+        var port = options.valueOf(portOpt);
+        var password = options.valueOf(passwordOpt);
+        if (password == null)
+            throw new IllegalArgumentException("missing required 'password' option");
+
+        var botOpt = nonOptionArgs.get(0);
+        BotType botType;
+        try {
+            botType = getBotNameFromCmd(botOpt);
+        } catch (IllegalArgumentException ex) {
+            throw new IllegalArgumentException(format("'%s' does not exist", botOpt));
+        }
+
+        try {
+            switch (botType) {
+                case bsqmarketmaker: {
+                    var opts = new BsqMarketMakerBotOptionParser(args).parse();
+                    if (opts.isForHelp()) {
+                        printHelp(parser, out);
+                        return;
+                    }
+                    var targetBtcAmount = opts.getTargetBtcAmount();
+                    var targetPrice = opts.getTargetPrice();
+                    var targetSpread = opts.getTargetSpread();
+                    var tradeCycleLimit = opts.getTradeCycleLimit(); // todo fix name
+                    var newBsqPaymentAccountsLimit = opts.getNewBsqPaymentAccountsLimit();
+                    var bsqMarketMakerBot = new BsqMarketMakerBot(host,
+                            port,
+                            password,
+                            newBsqPaymentAccountsLimit,
+                            new BigDecimal(targetPrice),
+                            new BigDecimal(targetBtcAmount),
+                            new BigDecimal(targetSpread),
+                            tradeCycleLimit);
+                    log.info("Starting {}.", bsqMarketMakerBot.getClass().getSimpleName());
+                    bsqMarketMakerBot.run();
+                    log.info("{} shutdown complete.", bsqMarketMakerBot.getClass().getSimpleName());
+                    return;
+                }
+                case canceloffers: {
+                    var cancelOffersBot = new CancelOffersBot(host, port, password);
+                    cancelOffersBot.cancelAllBsqOffers();
+                    return;
+                }
+                default: {
+                    throw new RuntimeException(format("unhandled bot type '%s'", botType));
+                }
+            }
+        } catch (StatusRuntimeException ex) {
+            // Remove the leading gRPC status code (e.g. "UNKNOWN: ") from the message
+            String message = ex.getMessage().replaceFirst("^[A-Z_]+: ", "");
+            if (message.equals("io exception"))
+                throw new RuntimeException(message + ", server may not be running", ex);
+            else
+                throw new RuntimeException(message, ex);
+        }
+    }
+
+    private static void printHelp(OptionParser parser, @SuppressWarnings("SameParameterValue") PrintStream stream) {
+        try {
+            stream.println("TODO");
+            stream.println();
+            parser.printHelpOn(stream);
+            stream.println();
+        } catch (IOException ex) {
+            ex.printStackTrace(stream);
+        }
+    }
+
+    private static BotType getBotNameFromCmd(String botName) {
+        return BotType.valueOf(botName.toLowerCase());
+    }
+}
diff --git a/apitest/src/test/java/bisq/apitest/botsupport/BotThread.java b/apitest/src/test/java/bisq/apitest/botsupport/BotThread.java
new file mode 100644
index 00000000000..72a74ca0cca
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/botsupport/BotThread.java
@@ -0,0 +1,98 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see .
+ */
+
+package bisq.apitest.botsupport;
+
+
+import com.google.common.util.concurrent.MoreExecutors;
+
+import java.time.Duration;
+
+import java.util.Random;
+import java.util.concurrent.Executor;
+import java.util.concurrent.TimeUnit;
+
+import java.lang.reflect.InvocationTargetException;
+
+import lombok.Getter;
+import lombok.Setter;
+import lombok.extern.slf4j.Slf4j;
+
+
+
+import bisq.apitest.botsupport.util.FrameRateTimer;
+import bisq.apitest.botsupport.util.Timer;
+
+
+@Slf4j
+public class BotThread {
+
+    private static Class timerClass;
+    @Getter
+    @Setter
+    private static Executor executor;
+
+    public static void setTimerClass(Class timerClass) {
+        BotThread.timerClass = timerClass;
+    }
+
+    static {
+        // If not defined we use same thread as caller thread
+        executor = MoreExecutors.directExecutor();
+        timerClass = FrameRateTimer.class;
+    }
+
+    public static void execute(Runnable command) {
+        BotThread.executor.execute(command);
+    }
+
+    // Prefer FxTimer if a delay is needed in a JavaFx class (gui module)
+    public static Timer runAfterRandomDelay(Runnable runnable, long minDelayInSec, long maxDelayInSec) {
+        return BotThread.runAfterRandomDelay(runnable, minDelayInSec, maxDelayInSec, TimeUnit.SECONDS);
+    }
+
+    @SuppressWarnings("WeakerAccess")
+    public static Timer runAfterRandomDelay(Runnable runnable, long minDelay, long maxDelay, TimeUnit timeUnit) {
+        return BotThread.runAfter(runnable, new Random().nextInt((int) (maxDelay - minDelay)) + minDelay, timeUnit);
+    }
+
+    public static Timer runAfter(Runnable runnable, long delayInSec) {
+        return BotThread.runAfter(runnable, delayInSec, TimeUnit.SECONDS);
+    }
+
+    public static Timer runAfter(Runnable runnable, long delay, TimeUnit timeUnit) {
+        return getTimer().runLater(Duration.ofMillis(timeUnit.toMillis(delay)), runnable);
+    }
+
+    public static Timer runPeriodically(Runnable runnable, long intervalInSec) {
+        return BotThread.runPeriodically(runnable, intervalInSec, TimeUnit.SECONDS);
+    }
+
+    public static Timer runPeriodically(Runnable runnable, long interval, TimeUnit timeUnit) {
+        return getTimer().runPeriodically(Duration.ofMillis(timeUnit.toMillis(interval)), runnable);
+    }
+
+    private static Timer getTimer() {
+        try {
+            return timerClass.getDeclaredConstructor().newInstance();
+        } catch (InstantiationException | NoSuchMethodException | InvocationTargetException | IllegalAccessException e) {
+            String message = "Could not instantiate timer bsTimerClass=" + timerClass;
+            log.error(message, e);
+            throw new RuntimeException(message);
+        }
+    }
+}
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/PaymentAccountNotFoundException.java b/apitest/src/test/java/bisq/apitest/botsupport/PaymentAccountNotFoundException.java
similarity index 81%
rename from apitest/src/test/java/bisq/apitest/scenario/bot/PaymentAccountNotFoundException.java
rename to apitest/src/test/java/bisq/apitest/botsupport/PaymentAccountNotFoundException.java
index 8578a38af75..989783432f9 100644
--- a/apitest/src/test/java/bisq/apitest/scenario/bot/PaymentAccountNotFoundException.java
+++ b/apitest/src/test/java/bisq/apitest/botsupport/PaymentAccountNotFoundException.java
@@ -15,21 +15,21 @@
  * along with Bisq. If not, see .
  */
 
-package bisq.apitest.scenario.bot;
+package bisq.apitest.botsupport;
 
-import bisq.common.BisqException;
+import static java.lang.String.format;
 
 @SuppressWarnings("unused")
-public class PaymentAccountNotFoundException extends BisqException {
+public class PaymentAccountNotFoundException extends RuntimeException {
     public PaymentAccountNotFoundException(Throwable cause) {
         super(cause);
     }
 
     public PaymentAccountNotFoundException(String format, Object... args) {
-        super(format, args);
+        super(format(format, args));
     }
 
     public PaymentAccountNotFoundException(Throwable cause, String format, Object... args) {
-        super(cause, format, args);
+        super(format(format, args), cause);
     }
 }
diff --git a/apitest/src/test/java/bisq/apitest/botsupport/example/BaseMarketMakerBot.java b/apitest/src/test/java/bisq/apitest/botsupport/example/BaseMarketMakerBot.java
new file mode 100644
index 00000000000..6dbf6cf699f
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/botsupport/example/BaseMarketMakerBot.java
@@ -0,0 +1,227 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see .
+ */
+
+package bisq.apitest.botsupport.example;
+
+import bisq.proto.grpc.TradeInfo;
+
+import protobuf.PaymentAccount;
+
+import com.google.common.util.concurrent.ListeningExecutorService;
+
+import java.security.SecureRandom;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.slf4j.Logger;
+
+import lombok.Getter;
+import lombok.Setter;
+import lombok.extern.slf4j.Slf4j;
+
+import javax.annotation.Nullable;
+
+import static bisq.apitest.botsupport.protocol.BotProtocol.BTC;
+import static bisq.apitest.botsupport.shutdown.ManualShutdown.isShutdownCalled;
+import static bisq.apitest.botsupport.shutdown.ManualShutdown.setShutdownCalled;
+import static bisq.apitest.config.ApiTestConfig.BSQ;
+import static bisq.cli.TableFormat.formatBalancesTbls;
+import static bisq.cli.TableFormat.formatOfferTable;
+import static bisq.cli.TableFormat.formatPaymentAcctTbl;
+import static java.util.Collections.singletonList;
+import static java.util.concurrent.TimeUnit.DAYS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+
+
+import bisq.apitest.botsupport.BotClient;
+import bisq.apitest.botsupport.shutdown.ManualBotShutdownException;
+import bisq.apitest.botsupport.util.BotUtilities;
+import bisq.cli.GrpcClient;
+
+@Getter
+@Slf4j
+abstract class BaseMarketMakerBot {
+
+    protected static final SecureRandom RANDOM = new SecureRandom();
+
+    protected static final String BUYER_BOT_NAME = "Maker/Buyer Bot";
+    protected static final String SELLER_BOT_NAME = "Maker/Seller Bot";
+
+    protected final ListeningExecutorService executor =
+            BotUtilities.getListeningExecutorService("Bisq Bot",
+                    2,
+                    2,
+                    DAYS.toSeconds(1));
+
+    @Nullable
+    @Setter
+    @Getter
+    protected Exception buyerBotException;
+    @Nullable
+    @Setter
+    @Getter
+    protected Exception sellerBotException;
+
+    protected final AtomicBoolean isBuyBotShutdown = new AtomicBoolean(false);
+    protected final AtomicBoolean isSellBotShutdown = new AtomicBoolean(false);
+    protected int numMakerSideBuys = 0;
+    protected int numMakerSideSells = 0;
+
+    protected final String host;
+    protected final int port;
+    protected final String password;
+    protected final BotClient botClient;
+    protected final int newBsqPaymentAccountsLimit;
+    protected final List receiverPaymentAccounts = new ArrayList<>();
+    protected final PaymentAccount senderPaymentAccount;
+
+    protected final List botTradeHistory = new ArrayList<>(); // TODO persist?  Json?
+
+    public BaseMarketMakerBot(String host,
+                              int port,
+                              String password,
+                              int newBsqPaymentAccountsLimit) {
+
+        this.host = host;
+        this.port = port;
+        this.password = password;
+        this.newBsqPaymentAccountsLimit = newBsqPaymentAccountsLimit;
+        this.botClient = new BotClient(new GrpcClient(host, port, password));
+        this.receiverPaymentAccounts.addAll(botClient.getReceiverBsqPaymentAccounts());
+        this.senderPaymentAccount = getOrCreateSenderPaymentAccount();
+    }
+
+    abstract void run();
+
+    protected final PaymentAccount getOrCreateSenderPaymentAccount() {
+        var senderAccounts = botClient.getSenderBsqPaymentAccounts();
+        return senderAccounts.isEmpty()
+                ? botClient.createSenderBsqPaymentAccount()
+                : senderAccounts.get(0);
+    }
+
+    protected final PaymentAccount getNextReceiverPaymentAccount() {
+        if (receiverPaymentAccounts.isEmpty()) {
+            log.warn("You have not set up any BSQ payment accounts."
+                            + "  The bot may create up to {} new accounts as needed, with unique receiving BSQ addresses.",
+                    newBsqPaymentAccountsLimit);
+            var newAccount = botClient.createReceiverBsqPaymentAccount();
+            receiverPaymentAccounts.add(newAccount);
+            log.info("The new receiving payment account id is {}.", newAccount.getId());
+            return newAccount;
+        } else if (receiverPaymentAccounts.size() < newBsqPaymentAccountsLimit) {
+            log.warn("You have {} BSQ payment accounts."
+                            + "  The bot may create up to {} new accounts as needed, with unique receiving BSQ addresses.",
+                    receiverPaymentAccounts.size(),
+                    newBsqPaymentAccountsLimit - receiverPaymentAccounts.size());
+            var newAccount = botClient.createReceiverBsqPaymentAccount();
+            receiverPaymentAccounts.add(newAccount);
+            log.info("The new receiving payment account id is {}.", newAccount.getId());
+            return newAccount;
+        } else {
+            var next = RANDOM.nextInt(receiverPaymentAccounts.size());
+            var nextAccount = receiverPaymentAccounts.get(next);
+            log.info("The next receiving payment account id is {}.", nextAccount.getId());
+            return nextAccount;
+        }
+    }
+
+    protected void waitForManualShutdown() {
+        log.info("When ready to shutdown bot, run '$ /tmp/bot-shutdown'.");
+        try {
+            while (!isShutdownCalled()) {
+                rest(10);
+            }
+            log.warn("Manual shutdown signal received.");
+        } catch (ManualBotShutdownException ex) {
+            log.warn(ex.getMessage());
+        }
+    }
+
+    protected void rest(long delayInSeconds) {
+        try {
+            SECONDS.sleep(delayInSeconds);
+        } catch (InterruptedException ignored) {
+            // empty
+        }
+    }
+
+    protected void logStatus(Logger log, PaymentAccount paymentAccount) {
+        log.info("Payment Account:\n{}", formatPaymentAcctTbl(singletonList(paymentAccount)));
+
+        log.info("Balances:\n{}", formatBalancesTbls(botClient.getBalance()));
+
+        var currentOffers = botClient.getMyOffersSortedByDate(BTC);
+        if (currentOffers.isEmpty())
+            log.info("No current offers.");
+        else
+            log.info("Current offers:\n{}", formatOfferTable(botClient.getMyOffersSortedByDate(BTC), BSQ));
+
+        if (botTradeHistory.isEmpty()) {
+            log.info("No trades during this bot run.");
+        } else {
+            log.info("TODO print trades");
+        }
+    }
+
+
+    protected void logWalletBalance(Logger log, String botName, BotClient botClient) {
+        log.info("{} balances:\n{}", botName, formatBalancesTbls(botClient.getBalance()));
+    }
+
+    protected void logManualShutdownWarning(Logger log, String botName) {
+        log.warn("Manual shutdown called, stopping {}.", botName);
+    }
+
+    protected void logFailedTradeError(Logger log, String botName, Exception exception) {
+        log.error("{} could not complete trade.", botName, exception);
+    }
+
+    protected void logBotCompletion(Logger log, String botName, BotClient botClient) {
+        log.info("{} is done.  Balances:\n{}",
+                botName,
+                formatBalancesTbls(botClient.getBalance()));
+    }
+
+    protected boolean botDidFail() {
+        return buyerBotException != null || sellerBotException != null;
+    }
+
+    protected String getBotFailureReason() {
+        StringBuilder reasonBuilder = new StringBuilder();
+
+        if (buyerBotException != null)
+            reasonBuilder.append(BUYER_BOT_NAME).append(" failed: ")
+                    .append(buyerBotException.getMessage()).append("\n");
+
+        if (sellerBotException != null)
+            reasonBuilder.append(SELLER_BOT_NAME).append(" failed: ")
+                    .append(sellerBotException.getMessage()).append("\n");
+
+        return reasonBuilder.toString();
+    }
+
+    protected void shutdownAllBots() {
+        isBuyBotShutdown.set(true);
+        isSellBotShutdown.set(true);
+        setShutdownCalled(true);
+        executor.shutdownNow();
+    }
+}
diff --git a/apitest/src/test/java/bisq/apitest/botsupport/example/BsqMarketMakerBot.java b/apitest/src/test/java/bisq/apitest/botsupport/example/BsqMarketMakerBot.java
new file mode 100644
index 00000000000..5396155cd65
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/botsupport/example/BsqMarketMakerBot.java
@@ -0,0 +1,295 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see .
+ */
+
+package bisq.apitest.botsupport.example;
+
+
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+
+import java.math.BigDecimal;
+
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+
+import lombok.Getter;
+import lombok.SneakyThrows;
+
+import javax.annotation.Nullable;
+
+import static bisq.apitest.botsupport.protocol.BotProtocol.BSQ;
+import static bisq.apitest.botsupport.protocol.BotProtocol.BTC;
+import static bisq.apitest.botsupport.shutdown.ManualShutdown.isShutdownCalled;
+import static bisq.apitest.botsupport.shutdown.ManualShutdown.startShutdownTimer;
+import static bisq.cli.TableFormat.formatBalancesTbls;
+import static bisq.cli.TableFormat.formatOfferTable;
+import static java.lang.String.format;
+import static java.util.concurrent.TimeUnit.MINUTES;
+import static protobuf.OfferPayload.Direction.BUY;
+import static protobuf.OfferPayload.Direction.SELL;
+
+
+
+import bisq.apitest.botsupport.BotClient;
+import bisq.apitest.botsupport.protocol.MarketMakerBotProtocol;
+import bisq.apitest.botsupport.script.BashScriptGenerator;
+import bisq.apitest.botsupport.shutdown.ManualBotShutdownException;
+
+
+@Getter
+public class BsqMarketMakerBot extends BaseMarketMakerBot {
+
+    // Don't use @Slf4j annotation to init log and use in Functions w/out IDE warnings.
+    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(BsqMarketMakerBot.class);
+
+    // TODO make this an option.
+    static final long PROTOCOL_STEP_TIME_LIMIT = MINUTES.toMillis(180);
+
+    public final Predicate shouldLogWalletBalance = (i) -> i % 1 == 0;
+
+    private final BigDecimal targetPrice;
+    private final BigDecimal targetBtcAmount;
+    private final BigDecimal targetSpread;
+    private final int tradeCycleLimit;
+
+    // Use a randomly chosen payment account for each trade.
+    // This is preferred over using the same payment account over and over because
+    // the BSQ receive address never changes, making BSQ payment confirmation riskier.
+    // BSQ payment confirmation is done by matching a rcv address with a txout value,
+    // making the check less certain.  Another way to improve the reliability of
+    // bot BSQ payment confirmations is to vary the BSQ price by a few sats for
+    // each trade.
+    public BsqMarketMakerBot(String host,
+                             int port,
+                             String password,
+                             int newBsqPaymentAccountsLimit,
+                             BigDecimal targetPrice,
+                             BigDecimal targetBtcAmount,
+                             BigDecimal targetSpread,
+                             int tradeCycleLimit) {
+        super(host, port, password, newBsqPaymentAccountsLimit);
+        this.targetPrice = targetPrice;
+        this.targetBtcAmount = targetBtcAmount;
+        this.targetSpread = targetSpread;
+        this.tradeCycleLimit = tradeCycleLimit;
+    }
+
+    public void run() {
+        try {
+
+            if (botClient.iHaveCurrentOffers.test(BSQ)) {
+                log.error("Bot shutting down because you already have BSQ offers in the book."
+                                + "  Finish them with the CLI.\n{}",
+                        formatOfferTable(botClient.getMyOffersSortedByDate(BTC), BSQ));
+                return;
+            }
+
+            startShutdownTimer();
+
+            // Do not start a bot if already shutting down.
+            if (!isShutdownCalled()) {
+                startBot(buyMakerBot,
+                        botClient,
+                        BUYER_BOT_NAME);
+            }
+
+            rest(15);
+
+            // Do not start another bot if the 1st one is already shutting down.
+            if (!isShutdownCalled()) {
+                startBot(sellMakerBot,
+                        botClient,
+                        SELLER_BOT_NAME);
+            }
+
+            // TODO Optionally auto-shutdown after max cycles are complete.
+            //  See boolean stayAlive usage.
+            waitForManualShutdown();
+
+        } catch (ManualBotShutdownException ex) {
+            log.warn("{}  Shutting down bot before test completion", ex.getMessage());
+        } catch (Throwable t) {
+            log.error("Uncontrolled bot shutdown caused by uncaught bot exception", t);
+        }
+    }
+
+    protected void startBot(Consumer bot,
+                            BotClient botClient,
+                            String botName) {
+        try {
+            log.info("Starting {}", botName);
+            @SuppressWarnings({"unchecked"})
+            ListenableFuture future =
+                    (ListenableFuture) executor.submit(() -> bot.accept(botClient));
+            Futures.addCallback(future, new FutureCallback<>() {
+                @Override
+                public void onSuccess(@Nullable Void ignored) {
+                    // 'Success' means a controlled shutdown that might be caused by an
+                    // error.  The test case should only fail if the shutdown was caused
+                    // by and exception.
+                    log.info("{} shutdown.", botName);
+                }
+
+                @SneakyThrows
+                @Override
+                public void onFailure(Throwable t) {
+                    if (t instanceof ManualBotShutdownException) {
+                        log.warn("Manually shutting down {} thread.", botName);
+                    } else {
+                        log.error("Fatal error during {} run.", botName, t);
+                    }
+                    shutdownAllBots();
+                }
+            }, MoreExecutors.directExecutor());
+
+        } catch (Exception ex) {
+            log.error("", ex);
+            throw new IllegalStateException(format("Error starting %s.", botName), ex);
+        }
+    }
+
+    protected final Consumer buyMakerBot = (botClient) -> {
+        try {
+            while (numMakerSideBuys < this.getTradeCycleLimit()) {
+                // Make sure the # of buy & sell offers never differ by more than 1.
+                var canCreateNextOffer = numMakerSideBuys == 0 || numMakerSideBuys <= numMakerSideSells;
+                if (canCreateNextOffer) {
+                    var receiverPaymentAccount = getNextReceiverPaymentAccount();
+                    MarketMakerBotProtocol botProtocol = new MarketMakerBotProtocol(BUYER_BOT_NAME,
+                            botClient,
+                            receiverPaymentAccount,
+                            PROTOCOL_STEP_TIME_LIMIT,
+                            new BashScriptGenerator(password, port, receiverPaymentAccount.getId(), false),
+                            BUY.name(),
+                            this.getTargetPrice(),
+                            this.getTargetBtcAmount(),
+                            this.getTargetSpread(),
+                            0.15,
+                            BSQ,
+                            60);
+                    botProtocol.run();
+                    numMakerSideBuys++;
+                    logTradingProgress();
+                    /*
+                    if (shouldLogWalletBalance.test(numMakerSideBuys))
+                        logWalletBalance(log, BUYER_BOT_NAME, botClient);
+                     */
+                } else {
+                    logOfferAlreadyExistsWarning(BUYER_BOT_NAME);
+                }
+                rest(20);
+            }
+        } catch (ManualBotShutdownException ex) {
+            logManualShutdownWarning(log, BUYER_BOT_NAME);
+            shutdownAllBots();
+            // Exit the function, do not try to get balances below because the
+            // server may be shutting down.
+            return;
+        } catch (Exception ex) {
+            logFailedTradeError(log, BUYER_BOT_NAME, ex);
+            shutdownAllBots();
+            // Fatal error, do not try to get balances below because server is shutting down.
+            this.setBuyerBotException(ex);
+            return;
+        }
+        logBotCompletion(log, BUYER_BOT_NAME, botClient);
+        isBuyBotShutdown.set(true);
+    };
+
+    public final Consumer sellMakerBot = (botClient) -> {
+        try {
+            while (numMakerSideSells < this.getTradeCycleLimit()) {
+                // Make sure the # of buy & sell offers never differ by more than 1.
+                var canCreateNextOffer = numMakerSideSells == 0 || numMakerSideSells <= numMakerSideBuys;
+                if (canCreateNextOffer) {
+                    var senderPaymentAccount = getSenderPaymentAccount();
+                    MarketMakerBotProtocol botProtocol = new MarketMakerBotProtocol(SELLER_BOT_NAME,
+                            botClient,
+                            senderPaymentAccount,
+                            PROTOCOL_STEP_TIME_LIMIT,
+                            new BashScriptGenerator(password, port, senderPaymentAccount.getId(), false),
+                            SELL.name(),
+                            this.getTargetPrice(),
+                            this.getTargetBtcAmount(),
+                            this.getTargetSpread(),
+                            0.15,
+                            BSQ,
+                            60);
+                    botProtocol.run();
+                    numMakerSideSells++;
+                    logTradingProgress();
+                    /*
+                    if (shouldLogWalletBalance.test(numMakerSideSells))
+                        logWalletBalance(log, SELLER_BOT_NAME, botClient);
+                     */
+                } else {
+                    logOfferAlreadyExistsWarning(SELLER_BOT_NAME);
+                }
+                rest(20);
+            }
+        } catch (ManualBotShutdownException ex) {
+            logManualShutdownWarning(log, SELLER_BOT_NAME);
+            shutdownAllBots();
+            // Exit the function, do not try to get balances below because the
+            // server may be shutting down.
+            return;
+        } catch (Exception ex) {
+            logFailedTradeError(log, SELLER_BOT_NAME, ex);
+            shutdownAllBots();
+            // Fatal error, do not try to get balances below because server is shutting down.
+            this.setSellerBotException(ex);
+            return;
+        }
+        logBotCompletion(log, SELLER_BOT_NAME, botClient);
+        isSellBotShutdown.set(true);
+    };
+
+    protected void logOfferAlreadyExistsWarning(String botName, String direction) {
+        log.warn("{} will not create a new {} while existing {} offer is waiting to be taken."
+                        + "  Each trade cycle is 1 buy and 1 sell.",
+                botName,
+                direction,
+                direction);
+    }
+
+    protected void logOfferAlreadyExistsWarning(String botName) {
+        log.warn("{} will not create a new offer while an existing offer is waiting to be taken."
+                        + "  Each trade cycle is 1 buy and 1 sell.",
+                botName);
+    }
+
+    protected void logTradingProgress() {
+        String completedTradeCycles;
+        if (numMakerSideBuys == numMakerSideSells)
+            completedTradeCycles = String.valueOf(numMakerSideBuys);
+        else if (numMakerSideBuys > numMakerSideSells)
+            completedTradeCycles = numMakerSideSells + ".5";
+        else
+            completedTradeCycles = numMakerSideBuys + ".5";
+
+        log.info("===================================================================================================");
+        log.info("Completed {} trades in {} trade cycles.  Balance After {} BUY and {} SELL trades:\n{}",
+                numMakerSideBuys + numMakerSideSells,
+                completedTradeCycles,
+                numMakerSideBuys,
+                numMakerSideSells,
+                formatBalancesTbls(botClient.getBalance()));
+        log.info("===================================================================================================");
+    }
+}
diff --git a/apitest/src/test/java/bisq/apitest/botsupport/example/BsqMarketMakerBotTest.java b/apitest/src/test/java/bisq/apitest/botsupport/example/BsqMarketMakerBotTest.java
new file mode 100644
index 00000000000..e7212682c86
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/botsupport/example/BsqMarketMakerBotTest.java
@@ -0,0 +1,247 @@
+package bisq.apitest.botsupport.example;
+
+import bisq.proto.grpc.OfferInfo;
+
+import protobuf.PaymentAccount;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import lombok.extern.slf4j.Slf4j;
+
+import static bisq.apitest.botsupport.protocol.BotProtocol.BTC;
+import static bisq.apitest.config.ApiTestConfig.BSQ;
+import static bisq.cli.CurrencyFormat.formatBsqAmount;
+import static bisq.cli.TableFormat.formatBalancesTbls;
+import static bisq.cli.TableFormat.formatOfferTable;
+import static java.lang.String.format;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static protobuf.OfferPayload.Direction.BUY;
+import static protobuf.OfferPayload.Direction.SELL;
+
+
+
+import bisq.apitest.botsupport.BotClient;
+import bisq.cli.GrpcClient;
+import bisq.cli.TradeFormat;
+
+// Requirements:  test harness, registered agents, BsqMarketMakerBot.
+//
+// $ ./bisq-apitest --apiPassword=xyz --supportingApps=bitcoind,seednode,arbdaemon,alicedaemon,bobdaemon  --shutdownAfterTests=false --enableBisqDebugging=false
+// $ ./bisq-bot --password=xyz --port=9998 bsqmarketmaker --target-btc-amount=0.05 --target-price=0.00004 --target-spread=13.00 --trade-cycle-limit=5 --new-payment-accts-limit=10
+// $ ./bisq-bot --password=xyz --port=9998 bsqmarketmaker --target-btc-amount=0.05 --target-price=0.00004 --target-spread=13.00 --trade-cycle-limit=20 --new-payment-accts-limit=20
+// Only use the one below without the the bloom filter hack.
+// $ ./bisq-bot --password=xyz --port=9998 bsqmarketmaker --target-btc-amount=0.05 --target-price=0.00004 --target-spread=13.00 --trade-cycle-limit=100 --new-payment-accts-limit=30
+//
+@Slf4j
+public class BsqMarketMakerBotTest {
+
+    // TODO Install shutdown hook, check state.
+
+    private static final int MAX_TRADE_CYCLES = 5;
+
+    private final BotClient botClient;
+    private final PaymentAccount paymentAccount;
+    private final List offers;
+
+    public BsqMarketMakerBotTest() {
+        this.botClient = new BotClient(new GrpcClient("localhost", 9999, "xyz"));
+        this.paymentAccount = botClient.createCryptoCurrencyPaymentAccount("Bob's Instant Acct", true);
+        this.offers = new ArrayList<>();
+    }
+
+    public void runTradeCycles() {
+        try {
+            verifyHavePaymentAccount();
+
+            for (int tradeCycle = 1; tradeCycle <= MAX_TRADE_CYCLES; tradeCycle++) {
+                offers.addAll(botClient.getOffersSortedByDate(BTC));
+                verifyHaveOffers(tradeCycle);  // Should 1 BUY and 1 SELL for each trade cycle
+
+                takeBuyBsqOffer();
+                SECONDS.sleep(30);
+                takeSellBsqOffer();
+                SECONDS.sleep(10);
+
+                verifyHaveTrades();
+                SECONDS.sleep(5);
+
+                sendAliceBsqPaymentForHerBtc();
+                SECONDS.sleep(60);
+
+                verifyBsqReceivedFromAlice();
+                SECONDS.sleep(60);
+
+                SECONDS.sleep(5);
+                closeTrades();
+
+                SECONDS.sleep(5);
+                printBalance();
+
+                offers.clear();
+                if (tradeCycle < MAX_TRADE_CYCLES) {
+                    log.info("Completed {} trade cycle(s).  Starting the next in 1 minute.", tradeCycle);
+                    SECONDS.sleep(60);
+                }
+            }
+
+            log.info("Shutting down taker bot.");
+
+        } catch (Throwable t) {
+            t.printStackTrace();
+            System.exit(1);
+        }
+    }
+
+    protected void takeBuyBsqOffer() {
+        var offer = offers.stream().filter(o -> o.getDirection().equals(SELL.name())).findFirst().get();
+        log.info("Bob is taking Buy BSQ (Sell BTC) offer {} using payment account {}.",
+                offer.getId(),
+                paymentAccount.getId());
+        botClient.takeOffer(offer.getId(), paymentAccount, BSQ);
+    }
+
+    protected void takeSellBsqOffer() {
+        var offer = offers.stream().filter(o -> o.getDirection().equals(BUY.name())).findFirst().get();
+        log.info("Bob is taking Sell BSQ (Buy BTC) offer {} using payment account {}.",
+                offer.getId(),
+                paymentAccount.getId());
+        botClient.takeOffer(offer.getId(), paymentAccount, BTC);
+    }
+
+    protected void verifyHavePaymentAccount() {
+        if (paymentAccount == null)
+            throw new IllegalStateException("No payment account for taking offers.");
+
+        log.info("Bob is using '{}' with id = {}", paymentAccount.getAccountName(), paymentAccount.getId());
+    }
+
+    protected void verifyHaveOffers(int currentTradeCycle) {
+        if (currentTradeCycle == MAX_TRADE_CYCLES) {
+            log.warn("We're done, shutting down");
+            System.exit(0);
+        }
+
+        if (offers.isEmpty()) {
+            if (currentTradeCycle < MAX_TRADE_CYCLES) {
+                throw new IllegalStateException(
+                        format("No offers to take in trade cycle %d.", currentTradeCycle));
+            } else {
+                log.warn("No offers, might be finished after {} cycle(s).", currentTradeCycle);
+                return;
+            }
+        } else if (offers.size() == 1) {
+            // TODO Wait for the next offer to sync up again.
+            for (int i = 0; i < 10; i++) {
+                try {
+                    log.warn("There is only 1 available offer {} at start of cycle, will check again in 10 seconds. "
+                            , offers.get(0).getId());
+                    SECONDS.sleep(10);
+                    offers.clear();
+                    offers.addAll(botClient.getOffersSortedByDate(BTC));
+                    if (offers.size() == 2) {
+                        log.info("Now Bob can take offers:\n{}", formatOfferTable(offers, BSQ));
+                        break;
+                    }
+                } catch (InterruptedException ignored) {
+                    // empty
+                }
+            }
+            throw new IllegalStateException(
+                    format("No offers to take in trade cycle %d.", currentTradeCycle));
+        } else {
+            log.info("Bob can take offers:\n{}", formatOfferTable(offers, BSQ));
+        }
+    }
+
+    protected void verifyHaveTrades() {
+        var alicesBuyOfferId = getAlicesBuyBsqOfferId();
+        var bobsBuyBsqTrade = botClient.getTrade(alicesBuyOfferId);
+        if (bobsBuyBsqTrade != null) {
+            log.info("Bob's Buy BSQ (Sell BTC) Trade: \n{}", TradeFormat.format(bobsBuyBsqTrade));
+        } else {
+            throw new IllegalStateException(format("Take BUY offer %s failed.", alicesBuyOfferId));
+        }
+
+        var alicesSellOfferId = getAlicesSellBsqOfferId();
+        var bobsSellBsqTrade = botClient.getTrade(alicesSellOfferId);
+        if (bobsSellBsqTrade != null) {
+            log.info("Bob's Sell BSQ (Buy BTC) Trade: \n{}", TradeFormat.format(bobsSellBsqTrade));
+        } else {
+            throw new IllegalStateException(format("Take SELL offer %s failed.", alicesSellOfferId));
+        }
+    }
+
+    protected void sendAliceBsqPaymentForHerBtc() {
+        try {
+            var tradeId = getAlicesBuyBsqOfferId();
+            var trade = botClient.getTrade(tradeId);
+            botClient.makeBsqPayment(trade);
+            log.info("Payment sent, generate a btc block now.");
+            SECONDS.sleep(15);
+            botClient.sendConfirmPaymentStartedMessage(tradeId);
+        } catch (Exception ex) {
+            ex.printStackTrace();
+        }
+    }
+
+    protected void verifyBsqReceivedFromAlice() {
+        try {
+            var tradeId = getAlicesSellBsqOfferId();
+            var trade = botClient.getTrade(tradeId);
+
+            // TODO refactor into BotClient
+            var contract = trade.getContract();
+            var bsqSats = trade.getOffer().getVolume();
+            var receiveAmountAsString = formatBsqAmount(bsqSats);
+            var address = contract.getIsBuyerMakerAndSellerTaker()
+                    ? contract.getTakerPaymentAccountPayload().getAddress()
+                    : contract.getMakerPaymentAccountPayload().getAddress();
+            log.info("Bob verifying payment of {} BSQ was received to address {} for trade with id {}.",
+                    receiveAmountAsString,
+                    address,
+                    tradeId);
+            log.info("Give bot time to send payment. Generate a block while you wait");
+            for (int i = 0; i < 5; i++) {
+                SECONDS.sleep(30);
+                boolean receivedPayment = botClient.verifyBsqSentToAddress(address, receiveAmountAsString);
+                if (receivedPayment) {
+                    log.warn("Payment received, sending payment rcvd confirmation.");
+                    botClient.sendConfirmPaymentReceivedMessage(tradeId);
+                    break;
+                } else {
+                    log.warn("Payment NOT received.");
+                }
+            }
+        } catch (Exception exception) {
+            exception.printStackTrace();
+        }
+    }
+
+    protected void closeTrades() {
+        for (OfferInfo offer : offers) {
+            var tradeId = offer.getId();
+            log.info("Sending keepfunds request for trade {}.", tradeId);
+            botClient.sendKeepFundsMessage(tradeId);
+        }
+    }
+
+    protected void printBalance() {
+        log.info("Finished Trade Cycle\n{}", formatBalancesTbls(botClient.getBalance()));
+    }
+
+    protected String getAlicesBuyBsqOfferId() {
+        // Buy BSQ = SELL BTC
+        return offers.stream().filter(o -> o.getDirection().equals(SELL.name())).findFirst().get().getId();
+    }
+
+    protected String getAlicesSellBsqOfferId() {
+        // Sell BSQ = BUY BTC
+        return offers.stream().filter(o -> o.getDirection().equals(BUY.name())).findFirst().get().getId();
+    }
+
+    public static void main(String[] args) {
+        BsqMarketMakerBotTest bob = new BsqMarketMakerBotTest();
+        bob.runTradeCycles();
+    }
+}
diff --git a/apitest/src/test/java/bisq/apitest/botsupport/example/CancelOffersBot.java b/apitest/src/test/java/bisq/apitest/botsupport/example/CancelOffersBot.java
new file mode 100644
index 00000000000..bbcf07afec7
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/botsupport/example/CancelOffersBot.java
@@ -0,0 +1,58 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see .
+ */
+
+package bisq.apitest.botsupport.example;
+
+import bisq.proto.grpc.OfferInfo;
+
+import lombok.extern.slf4j.Slf4j;
+
+import static bisq.apitest.botsupport.protocol.BotProtocol.BTC;
+
+
+
+import bisq.apitest.botsupport.BotClient;
+import bisq.cli.GrpcClient;
+
+/**
+ * Be careful when using this on mainnet.  Offer fees are forfeited.
+ */
+@Slf4j
+public class CancelOffersBot {
+    // TODO refactor BaseMarketMakerBot -> BaseBot, which includes grpc client ctr args.
+
+    protected final String host;
+    protected final int port;
+    protected final String password;
+    protected final BotClient botClient;
+
+    public CancelOffersBot(String host, int port, String password) {
+        this.host = host;
+        this.port = port;
+        this.password = password;
+        this.botClient = new BotClient(new GrpcClient(host, port, password));
+    }
+
+    public void cancelAllBsqOffers() {
+        var myOffers = botClient.getMyOffersSortedByDate(BTC);
+        for (OfferInfo myOffer : myOffers) {
+            log.info("Removing offer {} from offer book.", myOffer.getId());
+            botClient.cancelOffer(myOffer);
+        }
+        log.info("Removed {} offers.", myOffers.size());
+    }
+}
diff --git a/apitest/src/test/java/bisq/apitest/botsupport/opts/BotOptLabel.java b/apitest/src/test/java/bisq/apitest/botsupport/opts/BotOptLabel.java
new file mode 100644
index 00000000000..dbe861bd3d2
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/botsupport/opts/BotOptLabel.java
@@ -0,0 +1,31 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see .
+ */
+
+package bisq.apitest.botsupport.opts;
+
+import bisq.cli.opts.OptLabel;
+
+/**
+ * Bot opt label definitions.
+ */
+public class BotOptLabel extends OptLabel {
+    public final static String OPT_TARGET_BTC_AMOUNT = "target-btc-amount";
+    public final static String OPT_TARGET_PRICE = "target-price";
+    public final static String OPT_TARGET_SPREAD = "target-spread";
+    public final static String OPT_TRADE_CYCLE_LIMIT = "trade-cycle-limit";
+    public final static String OPT_NEW_PAYMENT_ACCOUNTS_LIMIT = "new-payment-accts-limit";
+}
diff --git a/apitest/src/test/java/bisq/apitest/botsupport/opts/BsqMarketMakerBotOptionParser.java b/apitest/src/test/java/bisq/apitest/botsupport/opts/BsqMarketMakerBotOptionParser.java
new file mode 100644
index 00000000000..b42c5ceb30e
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/botsupport/opts/BsqMarketMakerBotOptionParser.java
@@ -0,0 +1,100 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see .
+ */
+
+package bisq.apitest.botsupport.opts;
+
+import joptsimple.OptionSpec;
+
+import static bisq.apitest.botsupport.opts.BotOptLabel.*;
+
+
+
+import bisq.cli.opts.AbstractMethodOptionParser;
+import bisq.cli.opts.MethodOpts;
+
+public class BsqMarketMakerBotOptionParser extends AbstractMethodOptionParser implements MethodOpts {
+
+    final OptionSpec targetPriceOpt = parser.accepts(OPT_TARGET_PRICE,
+            "target price in btc for 1 bsq")
+            .withRequiredArg();
+
+    final OptionSpec targetBtcAmountOpt = parser.accepts(OPT_TARGET_BTC_AMOUNT,
+            "target btc amount used to buy or sell bsq")
+            .withRequiredArg();
+
+    final OptionSpec targetSpreadOpt = parser.accepts(OPT_TARGET_SPREAD,
+            "mm bot target spread")
+            .withRequiredArg();
+
+    final OptionSpec tradeCycleLimitOpt = parser.accepts(OPT_TRADE_CYCLE_LIMIT,
+            "mm bot trade limit (1 sell plus 1 buy = count 1")
+            .withRequiredArg()
+            .ofType(Integer.class)
+            .defaultsTo(1);
+
+    final OptionSpec newBsqPaymentAccountsLimitOpt = parser.accepts(OPT_NEW_PAYMENT_ACCOUNTS_LIMIT,
+            "limit # of new bsq payment account created by mm bot")
+            .withRequiredArg()
+            .ofType(Integer.class)
+            .defaultsTo(5);
+
+    public BsqMarketMakerBotOptionParser(String[] args) {
+        super(args);
+    }
+
+    public BsqMarketMakerBotOptionParser parse() {
+        super.parse();
+
+        // Short circuit opt validation if user just wants help.
+        if (options.has(helpOpt))
+            return this;
+
+        if (!options.has(targetPriceOpt) || options.valueOf(targetPriceOpt).isEmpty())
+            throw new IllegalArgumentException("no target price specified");
+
+        if (!options.has(targetBtcAmountOpt) || options.valueOf(targetBtcAmountOpt).isEmpty())
+            throw new IllegalArgumentException("no target btc amount specified");
+
+        if (!options.has(targetSpreadOpt) || options.valueOf(targetSpreadOpt).isEmpty())
+            throw new IllegalArgumentException("no target spread specified");
+
+        if (!options.has(tradeCycleLimitOpt))
+            throw new IllegalArgumentException("no trade cycle limit specified");
+
+        return this;
+    }
+
+    public String getTargetPrice() {
+        return options.valueOf(targetPriceOpt);
+    }
+
+    public String getTargetBtcAmount() {
+        return options.valueOf(targetBtcAmountOpt);
+    }
+
+    public String getTargetSpread() {
+        return options.valueOf(targetSpreadOpt);
+    }
+
+    public int getTradeCycleLimit() {
+        return options.valueOf(tradeCycleLimitOpt);
+    }
+
+    public int getNewBsqPaymentAccountsLimit() {
+        return options.valueOf(newBsqPaymentAccountsLimitOpt);
+    }
+}
diff --git a/apitest/src/test/java/bisq/apitest/botsupport/protocol/BotProtocol.java b/apitest/src/test/java/bisq/apitest/botsupport/protocol/BotProtocol.java
new file mode 100644
index 00000000000..56bc4f048ba
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/botsupport/protocol/BotProtocol.java
@@ -0,0 +1,449 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see .
+ */
+
+package bisq.apitest.botsupport.protocol;
+
+import bisq.proto.grpc.TradeInfo;
+
+import protobuf.PaymentAccount;
+
+import java.security.SecureRandom;
+
+import java.io.File;
+
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+
+import lombok.Getter;
+
+import static bisq.apitest.botsupport.protocol.ProtocolStep.*;
+import static bisq.apitest.botsupport.shutdown.ManualShutdown.checkIfShutdownCalled;
+import static bisq.cli.CurrencyFormat.formatBsqAmount;
+import static java.lang.String.format;
+import static java.lang.System.currentTimeMillis;
+import static java.util.Arrays.stream;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static protobuf.OfferPayload.Direction.BUY;
+import static protobuf.OfferPayload.Direction.SELL;
+
+
+
+import bisq.apitest.botsupport.BotClient;
+import bisq.apitest.botsupport.script.BashScriptGenerator;
+import bisq.cli.TradeFormat;
+import bisq.cli.TransactionFormat;
+
+public abstract class BotProtocol {
+
+    // Don't use @Slf4j annotation to init log and use in Functions w/out IDE warnings.
+    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(BotProtocol.class);
+
+    // TODO move these to proper place.
+    public static final String BSQ = "BSQ";
+    public static final String BTC = "BTC";
+
+    protected static final SecureRandom RANDOM = new SecureRandom();
+
+    // Random random millis in range [5, 30) seconds.
+    protected final Supplier shortRandomDelayInSeconds = () -> (long) (5000 + RANDOM.nextInt(30_000));
+    // Returns random millis in range [1, 15) minutes.
+    protected final Supplier longRandomDelayInMinutes = () -> (long) (60_000 + RANDOM.nextInt(15 * 60_000));
+
+    protected final AtomicLong protocolStepStartTime = new AtomicLong(0);
+    protected final Consumer initProtocolStep = (step) -> {
+        currentProtocolStep = step;
+        printBotProtocolStep();
+        protocolStepStartTime.set(currentTimeMillis());
+    };
+
+    // Functions declared in 'this' need getters.
+    @Getter
+    protected final String botDescription;
+    @Getter
+    protected final BotClient botClient;
+    @Getter
+    protected final PaymentAccount paymentAccount;
+    protected final String currencyCode;
+    protected final long protocolStepTimeLimitInMs;
+    @Getter
+    protected final BashScriptGenerator bashScriptGenerator;
+    @Getter
+    protected ProtocolStep currentProtocolStep;
+
+    public BotProtocol(String botDescription,
+                       BotClient botClient,
+                       PaymentAccount paymentAccount,
+                       long protocolStepTimeLimitInMs,
+                       BashScriptGenerator bashScriptGenerator) {
+        this.botDescription = botDescription;
+        this.botClient = botClient;
+        this.paymentAccount = paymentAccount;
+        this.currencyCode = Objects.requireNonNull(paymentAccount.getSelectedTradeCurrency()).getCode();
+        this.protocolStepTimeLimitInMs = protocolStepTimeLimitInMs;
+        this.bashScriptGenerator = bashScriptGenerator;
+        this.currentProtocolStep = START;
+    }
+
+    public abstract void run();
+
+    protected final Function waitForTakerFeeTxConfirm = (trade) -> {
+        sleep(shortRandomDelayInSeconds.get());
+        waitForTakerDepositFee(trade.getTradeId(), WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED);
+        waitForTakerDepositFee(trade.getTradeId(), WAIT_FOR_TAKER_DEPOSIT_TX_CONFIRMED);
+        return trade;
+    };
+
+    protected void waitForTakerDepositFee(String tradeId, ProtocolStep depositTxProtocolStep) {
+        initProtocolStep.accept(depositTxProtocolStep);
+        validateCurrentProtocolStep(WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED, WAIT_FOR_TAKER_DEPOSIT_TX_CONFIRMED);
+        log.info(waitingForDepositFeeTxMsg(tradeId));
+        String warning = format("Interrupted before checking taker deposit fee tx is %s for trade %s.",
+                depositTxProtocolStep.equals(WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED) ? "published" : "confirmed",
+                tradeId);
+        int numDelays = 0;
+        while (isWithinProtocolStepTimeLimit()) {
+            checkIfShutdownCalled(warning);
+            try {
+                var trade = this.getBotClient().getTrade(tradeId);
+                if (isDepositFeeTxStepComplete.test(trade)) {
+                    return;
+                } else {
+                    if (++numDelays % 5 == 0) {
+                        var tx = this.getBotClient().getTransaction(trade.getDepositTxId());
+                        log.warn("Still waiting for trade {} taker tx {} fee to be {}.\n{}",
+                                trade.getTradeId(),
+                                trade.getDepositTxId(),
+                                depositTxProtocolStep.equals(WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED) ? "published" : "confirmed",
+                                TransactionFormat.format(tx));
+                    }
+                    sleep(shortRandomDelayInSeconds.get());
+                }
+            } catch (Exception ex) {
+                if (this.getBotClient().tradeContractIsNotReady.test(ex, tradeId))
+                    continue;
+                else
+                    throw new IllegalStateException(this.getBotClient().toCleanGrpcExceptionMessage(ex));
+            }
+        }  // end while
+
+        // If the while loop is exhausted, a deposit fee tx was not published or confirmed within the protocol step time limit.
+        throw new IllegalStateException(stoppedWaitingForDepositFeeTxMsg(tradeId));
+    }
+
+    protected String waitingForDepositFeeTxMsg(String tradeId) {
+        return format("%s is waiting for taker deposit fee tx for trade %s to be %s.",
+                botDescription,
+                tradeId,
+                currentProtocolStep.equals(WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED) ? "published" : "confirmed");
+    }
+
+    protected String stoppedWaitingForDepositFeeTxMsg(String tradeId) {
+        return format("Taker deposit fee tx for trade %s took too long to be %s;  %s will stop waiting.",
+                tradeId,
+                currentProtocolStep.equals(WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED) ? "published" : "confirmed",
+                botDescription);
+    }
+
+    protected final Predicate isDepositFeeTxStepComplete = (trade) -> {
+        if (currentProtocolStep.equals(WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED) && trade.getIsDepositPublished()) {
+            log.info("{} sees trade {} taker deposit fee tx {} has been published.",
+                    this.getBotDescription(),
+                    trade.getTradeId(),
+                    trade.getDepositTxId());
+            return true;
+        } else if (currentProtocolStep.equals(WAIT_FOR_TAKER_DEPOSIT_TX_CONFIRMED) && trade.getIsDepositConfirmed()) {
+            log.info("{} sees trade {} taker deposit fee tx {} has been confirmed.",
+                    this.getBotDescription(),
+                    trade.getTradeId(),
+                    trade.getDepositTxId());
+            return true;
+        } else {
+            return false;
+        }
+    };
+
+    protected final Consumer waitForBsqPayment = (trade) -> {
+        // TODO When atomic trades are implemented, a different payment acct rcv address
+        //  may be used for each trade.  For now, the best we can do is match the amount
+        //  with the address to verify the correct amount of BSQ was received.
+        initProtocolStep.accept(WAIT_FOR_BSQ_PAYMENT_TO_RCV_ADDRESS);
+        var contract = trade.getContract();
+        var bsqSats = trade.getOffer().getVolume();
+        var receiveAmountAsString = formatBsqAmount(bsqSats);
+        var address = contract.getIsBuyerMakerAndSellerTaker()
+                ? contract.getTakerPaymentAccountPayload().getAddress()
+                : contract.getMakerPaymentAccountPayload().getAddress();
+        log.info("{} verifying payment of {} BSQ was received to address {} for trade with id {}.",
+                this.getBotDescription(),
+                receiveAmountAsString,
+                address,
+                trade.getTradeId());
+        while (isWithinProtocolStepTimeLimit()) {
+            checkIfShutdownCalled("Interrupted before checking to see if BSQ payment has been received.");
+            try {
+                boolean isAmountReceived = this.getBotClient().verifyBsqSentToAddress(address, receiveAmountAsString);
+                if (isAmountReceived) {
+                    log.warn("{} has received payment of {} BSQ to address {} for trade with id {}.",
+                            this.getBotDescription(),
+                            receiveAmountAsString,
+                            address,
+                            trade.getTradeId());
+                    return;
+                } else {
+                    log.warn("{} has still has not received payment of {} BSQ to address {} for trade with id {}.",
+                            this.getBotDescription(),
+                            receiveAmountAsString,
+                            address,
+                            trade.getTradeId());
+                }
+                sleep(shortRandomDelayInSeconds.get());
+            } catch (Exception ex) {
+                throw new IllegalStateException(this.getBotClient().toCleanGrpcExceptionMessage(ex));
+            }
+        }
+
+        // If the while loop is exhausted, a payment started msg was not detected.
+        throw new IllegalStateException("Payment started msg never sent; we won't wait any longer.");
+    };
+
+    protected final Function waitForPaymentStartedMessage = (trade) -> {
+        initProtocolStep.accept(WAIT_FOR_PAYMENT_STARTED_MESSAGE);
+        createPaymentStartedScript(trade);
+        log.info("{} is waiting for a 'payment started' message from buyer for trade with id {}.",
+                this.getBotDescription(),
+                trade.getTradeId());
+        while (isWithinProtocolStepTimeLimit()) {
+            checkIfShutdownCalled("Interrupted before checking if 'payment started' message has been sent.");
+            int numDelays = 0;
+            try {
+                var t = this.getBotClient().getTrade(trade.getTradeId());
+                if (t.getIsFiatSent()) {
+                    log.info("Buyer has started payment for trade: {}\n{}",
+                            t.getTradeId(),
+                            TradeFormat.format(t));
+                    return t;
+                }
+            } catch (Exception ex) {
+                throw new IllegalStateException(this.getBotClient().toCleanGrpcExceptionMessage(ex));
+            }
+            if (++numDelays % 5 == 0) {
+                log.warn("{} is still waiting for 'payment started' message for trade {}",
+                        this.getBotDescription(),
+                        trade.getShortId());
+            }
+            sleep(shortRandomDelayInSeconds.get());
+        } // end while
+
+        // If the while loop is exhausted, a payment started msg was not detected.
+        throw new IllegalStateException("Payment started msg never sent; we won't wait any longer.");
+    };
+
+    protected final Consumer sendBsqPayment = (trade) -> {
+        // Be very careful when using this on mainnet.
+        initProtocolStep.accept(SEND_PAYMENT_TO_RCV_ADDRESS);
+        while (true) {
+            // TODO FIX
+            if (trade.hasContract()) {
+                this.getBotClient().makeBsqPayment(trade);
+                break;
+            } else {
+                log.warn("Trade contract for {} not ready.");
+                sleep(shortRandomDelayInSeconds.get());
+            }
+        }
+    };
+
+    protected final Function sendPaymentStartedMessage = (trade) -> {
+        var isBsqOffer = this.getPaymentAccount().getSelectedTradeCurrency().getCode().equals(BSQ);
+        if (isBsqOffer) {
+            sendBsqPayment.accept(trade);
+        }
+        log.info("{} is sending 'payment started' msg for trade with id {}.",
+                this.getBotDescription(),
+                trade.getTradeId());
+        initProtocolStep.accept(SEND_PAYMENT_STARTED_MESSAGE);
+        checkIfShutdownCalled("Interrupted before sending 'payment started' message.");
+        this.getBotClient().sendConfirmPaymentStartedMessage(trade.getTradeId());
+        return trade;
+    };
+
+    protected final Function waitForPaymentReceivedConfirmation = (trade) -> {
+        initProtocolStep.accept(WAIT_FOR_PAYMENT_RECEIVED_CONFIRMATION_MESSAGE);
+        createPaymentReceivedScript(trade);
+        log.info("{} is waiting for a 'payment received confirmation' message from seller for trade with id {}.",
+                this.getBotDescription(),
+                trade.getTradeId());
+        int numDelays = 0;
+        while (isWithinProtocolStepTimeLimit()) {
+            checkIfShutdownCalled("Interrupted before checking if 'payment received confirmation' message has been sent.");
+            try {
+                var t = this.getBotClient().getTrade(trade.getTradeId());
+                if (t.getIsFiatReceived()) {
+                    log.info("Seller has received payment for trade: {}\n{}",
+                            t.getTradeId(),
+                            TradeFormat.format(t));
+                    return t;
+                }
+            } catch (Exception ex) {
+                throw new IllegalStateException(this.getBotClient().toCleanGrpcExceptionMessage(ex));
+            }
+            if (++numDelays % 5 == 0) {
+                log.warn("{} is still waiting for 'payment received confirmation' message for trade {}",
+                        this.getBotDescription(),
+                        trade.getShortId());
+            }
+            sleep(shortRandomDelayInSeconds.get());
+        } // end while
+
+        // If the while loop is exhausted, a payment rcvd confirmation msg was not detected within the protocol step time limit.
+        throw new IllegalStateException("Payment was never received; we won't wait any longer.");
+    };
+
+    protected final Function sendPaymentReceivedMessage = (trade) -> {
+        // TODO refactor this, move to top where functions are composed.
+        var isBsqOffer = this.getPaymentAccount().getSelectedTradeCurrency().getCode().equals(BSQ);
+        if (isBsqOffer) {
+            waitForBsqPayment.accept(trade);
+        }
+        log.info("{} is sending 'payment received confirmation' msg for trade with id {}.",
+                this.getBotDescription(),
+                trade.getTradeId());
+        initProtocolStep.accept(SEND_PAYMENT_RECEIVED_CONFIRMATION_MESSAGE);
+        checkIfShutdownCalled("Interrupted before sending 'payment received confirmation' message.");
+        this.getBotClient().sendConfirmPaymentReceivedMessage(trade.getTradeId());
+        return trade;
+    };
+
+    protected final Function waitForPayoutTx = (trade) -> {
+        initProtocolStep.accept(WAIT_FOR_PAYOUT_TX);
+        log.info("{} is waiting on the 'payout tx published' confirmation for trade with id {}.",
+                this.getBotDescription(),
+                trade.getTradeId());
+        while (isWithinProtocolStepTimeLimit()) {
+            checkIfShutdownCalled("Interrupted before checking if payout tx has been published.");
+            int numDelays = 0;
+            try {
+                var t = this.getBotClient().getTrade(trade.getTradeId());
+                if (t.getIsPayoutPublished()) {
+                    log.info("Payout tx {} has been published for trade {}:\n{}",
+                            t.getPayoutTxId(),
+                            t.getTradeId(),
+                            TradeFormat.format(t));
+                    return t;
+                }
+            } catch (Exception ex) {
+                throw new IllegalStateException(this.getBotClient().toCleanGrpcExceptionMessage(ex));
+            }
+            if (++numDelays % 5 == 0) {
+                log.warn("{} is still waiting for payout tx for trade {}",
+                        this.getBotDescription(),
+                        trade.getShortId());
+            }
+            sleep(shortRandomDelayInSeconds.get());
+        } // end while
+
+        // If the while loop is exhausted, a payout tx was not detected within the protocol step time limit.
+        throw new IllegalStateException("Payout tx was never published; we won't wait any longer.");
+    };
+
+    protected final Function keepFundsFromTrade = (trade) -> {
+        initProtocolStep.accept(KEEP_FUNDS);
+        var isBuy = trade.getOffer().getDirection().equalsIgnoreCase(BUY.name());
+        var isSell = trade.getOffer().getDirection().equalsIgnoreCase(SELL.name());
+
+        var cliUserIsSeller = (this instanceof MakerBotProtocol && isBuy)
+                || (this instanceof TakerBotProtocol && isSell);
+        if (cliUserIsSeller) {
+            createKeepFundsScript(trade);
+        } else {
+            createGetBalanceScript();
+        }
+        checkIfShutdownCalled("Interrupted before closing trade with 'keep funds' command.");
+        this.getBotClient().sendKeepFundsMessage(trade.getTradeId());
+        return trade;
+    };
+
+    protected void validateCurrentProtocolStep(Enum... validBotSteps) {
+        for (Enum validBotStep : validBotSteps) {
+            if (currentProtocolStep.equals(validBotStep))
+                return;
+        }
+        throw new IllegalStateException("Unexpected bot step: " + currentProtocolStep.name() + ".\n"
+                + "Must be one of "
+                + stream(validBotSteps).map((Enum::name)).collect(Collectors.joining(","))
+                + ".");
+    }
+
+    protected void checkIsStartStep() {
+        if (currentProtocolStep != START) {
+            throw new IllegalStateException("First bot protocol step must be " + START.name());
+        }
+    }
+
+    protected void printBotProtocolStep() {
+        log.info("{} is starting protocol step {}.  Time limit is {} minutes.",
+                botDescription,
+                currentProtocolStep.name(),
+                MILLISECONDS.toMinutes(protocolStepTimeLimitInMs));
+    }
+
+    protected boolean isWithinProtocolStepTimeLimit() {
+        return (currentTimeMillis() - protocolStepStartTime.get()) < protocolStepTimeLimitInMs;
+    }
+
+    protected void printCliHintAndOrScript(File script, String hint) {
+        log.info("{} by running bash script '{}'.", hint, script.getAbsolutePath());
+        if (this.getBashScriptGenerator().isPrintCliScripts())
+            this.getBashScriptGenerator().printCliScript(script, log);
+    }
+
+    protected void createGetBalanceScript() {
+        File script = bashScriptGenerator.createGetBalanceScript();
+        printCliHintAndOrScript(script, "The manual CLI side can view current balances");
+    }
+
+    protected void createPaymentStartedScript(TradeInfo trade) {
+        String scriptFilename = "confirmpaymentstarted-" + trade.getShortId() + ".sh";
+        File script = bashScriptGenerator.createPaymentStartedScript(trade, scriptFilename);
+        printCliHintAndOrScript(script, "The manual CLI side can send a 'payment started' message");
+    }
+
+    protected void createPaymentReceivedScript(TradeInfo trade) {
+        String scriptFilename = "confirmpaymentreceived-" + trade.getShortId() + ".sh";
+        File script = bashScriptGenerator.createPaymentReceivedScript(trade, scriptFilename);
+        printCliHintAndOrScript(script, "The manual CLI side can send a 'payment received confirmation' message");
+    }
+
+    protected void createKeepFundsScript(TradeInfo trade) {
+        String scriptFilename = "keepfunds-" + trade.getShortId() + ".sh";
+        File script = bashScriptGenerator.createKeepFundsScript(trade, scriptFilename);
+        printCliHintAndOrScript(script, "The manual CLI side can close the trade");
+    }
+
+    protected void sleep(long ms) {
+        try {
+            MILLISECONDS.sleep(ms);
+        } catch (InterruptedException ignored) {
+            // empty
+        }
+    }
+}
diff --git a/apitest/src/test/java/bisq/apitest/botsupport/protocol/CreateOfferException.java b/apitest/src/test/java/bisq/apitest/botsupport/protocol/CreateOfferException.java
new file mode 100644
index 00000000000..50801d801bc
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/botsupport/protocol/CreateOfferException.java
@@ -0,0 +1,35 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see .
+ */
+
+package bisq.apitest.botsupport.protocol;
+
+import static java.lang.String.format;
+
+@SuppressWarnings("unused")
+public class CreateOfferException extends RuntimeException {
+    public CreateOfferException(Throwable cause) {
+        super(cause);
+    }
+
+    public CreateOfferException(String format, Object... args) {
+        super(format(format, args));
+    }
+
+    public CreateOfferException(Throwable cause, String format, Object... args) {
+        super(format(format, args), cause);
+    }
+}
diff --git a/apitest/src/test/java/bisq/apitest/botsupport/protocol/MakerBotProtocol.java b/apitest/src/test/java/bisq/apitest/botsupport/protocol/MakerBotProtocol.java
new file mode 100644
index 00000000000..03737b58473
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/botsupport/protocol/MakerBotProtocol.java
@@ -0,0 +1,21 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see .
+ */
+
+package bisq.apitest.botsupport.protocol;
+
+public interface MakerBotProtocol {
+}
diff --git a/apitest/src/test/java/bisq/apitest/botsupport/protocol/MarketMakerBotProtocol.java b/apitest/src/test/java/bisq/apitest/botsupport/protocol/MarketMakerBotProtocol.java
new file mode 100644
index 00000000000..962c7992b4d
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/botsupport/protocol/MarketMakerBotProtocol.java
@@ -0,0 +1,383 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see .
+ */
+
+package bisq.apitest.botsupport.protocol;
+
+import bisq.proto.grpc.OfferInfo;
+import bisq.proto.grpc.TradeInfo;
+
+import protobuf.PaymentAccount;
+
+import java.math.BigDecimal;
+import java.math.MathContext;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.Queue;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import lombok.Getter;
+
+import static bisq.apitest.botsupport.protocol.ProtocolStep.DONE;
+import static bisq.apitest.botsupport.protocol.ProtocolStep.WAIT_FOR_OFFER_TAKER;
+import static bisq.apitest.botsupport.shutdown.ManualShutdown.checkIfShutdownCalled;
+import static bisq.cli.TableFormat.formatOfferTable;
+import static java.lang.String.format;
+import static java.math.RoundingMode.HALF_UP;
+import static java.util.Collections.singletonList;
+import static java.util.concurrent.TimeUnit.MINUTES;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static protobuf.OfferPayload.Direction;
+import static protobuf.OfferPayload.Direction.BUY;
+import static protobuf.OfferPayload.Direction.SELL;
+
+
+
+import bisq.apitest.botsupport.BotClient;
+import bisq.apitest.botsupport.script.BashScriptGenerator;
+import bisq.cli.TradeFormat;
+
+public class MarketMakerBotProtocol extends BotProtocol implements MakerBotProtocol {
+
+    // Don't use @Slf4j annotation to init log and use in Functions w/out IDE warnings.
+    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(MarketMakerBotProtocol.class);
+
+    private static final BigDecimal MAX_BTC_AMOUNT_DEVIATION_SIZE = new BigDecimal("0.000001");
+    private static final BigDecimal MAX_BSQ_PRICE_DEVIATION_SIZE = new BigDecimal("0.0000001");
+    private static final BigDecimal PERCENT_MULTIPLICAND = new BigDecimal("0.01");
+
+    protected final String direction;
+    @Getter
+    protected final BigDecimal priceMargin;
+    @Getter
+    protected final BigDecimal targetBsqPrice;
+    @Getter
+    protected final BigDecimal targetBtcAmount;
+    @Getter
+    protected final double securityDepositAsPercent;
+    @Getter
+    protected final String tradingFeeCurrencyCode;
+    @Getter
+    protected final int maxCreateOfferFailureLimit;
+
+    protected final Supplier randomPercent = () ->
+            new BigDecimal(Double.toString(RANDOM.nextDouble())).round(new MathContext(2, HALF_UP));
+
+    protected final Function calculatedMargin = (targetSpread) ->
+            targetSpread.divide(new BigDecimal(2)).round(new MathContext(3, HALF_UP));
+
+    protected final Supplier nextTradeBtcAmount = () -> {
+        BigDecimal nextBtcAmountDeviation = randomPercent.get().multiply(MAX_BTC_AMOUNT_DEVIATION_SIZE);
+        BigDecimal nextTradeBtcAmount = RANDOM.nextBoolean()
+                ? this.getTargetBtcAmount().subtract(nextBtcAmountDeviation)
+                : this.getTargetBtcAmount().add(nextBtcAmountDeviation);
+        long amountInSatoshis = nextTradeBtcAmount.scaleByPowerOfTen(8).longValue();
+        log.info("Calculated next trade's amount: {} BTC ({} sats) using target amount {} and max deviation {}.",
+                nextTradeBtcAmount,
+                amountInSatoshis,
+                this.getTargetBtcAmount(),
+                MAX_BTC_AMOUNT_DEVIATION_SIZE);
+        return amountInSatoshis;
+    };
+
+    // We use the same random BSQ price in a trade cycle (1 buy and 1 sell).
+    // This price queue has a max of 2 prices.  When it's empty, we add two identical
+    // prices, then remove & use them until queue is exhausted, rinse and repeat for
+    // each trade cycle.
+    protected static final Queue PSEUDO_FIXED_TRADE_PRICE_QUEUE = new ConcurrentLinkedQueue<>();
+
+    protected final Supplier nextPseudoFixedTradeBsqPrice = () -> {
+        if (PSEUDO_FIXED_TRADE_PRICE_QUEUE.isEmpty()) {
+            BigDecimal nextBsqPriceDeviation = randomPercent.get().multiply(MAX_BSQ_PRICE_DEVIATION_SIZE);
+            BigDecimal fixedTradePrice = RANDOM.nextBoolean()
+                    ? this.getTargetBsqPrice().add(nextBsqPriceDeviation)
+                    : this.getTargetBsqPrice().subtract(nextBsqPriceDeviation);
+            log.info("Calculated next trade's pseudo fixed BSQ price: {} BTC using target price {} and max deviation {}.",
+                    fixedTradePrice.toPlainString(),
+                    this.getTargetBsqPrice(),
+                    MAX_BSQ_PRICE_DEVIATION_SIZE.toPlainString());
+            PSEUDO_FIXED_TRADE_PRICE_QUEUE.add(fixedTradePrice);
+            PSEUDO_FIXED_TRADE_PRICE_QUEUE.add(fixedTradePrice);
+        }
+        return PSEUDO_FIXED_TRADE_PRICE_QUEUE.remove();
+    };
+
+    protected final Function calculateNextTradeMarginBasedPrice = (direction) -> {
+        BigDecimal marginAsDecimal = this.getPriceMargin().multiply(PERCENT_MULTIPLICAND);
+        BigDecimal basePrice = nextPseudoFixedTradeBsqPrice.get();
+        BigDecimal marginDifference = basePrice.multiply(marginAsDecimal);
+        BigDecimal nextMarginPrice = direction.equals(BUY)
+                ? basePrice.subtract(marginDifference).round(new MathContext(4, HALF_UP))
+                : basePrice.add(marginDifference).round(new MathContext(4, HALF_UP));
+        log.info("Calculated next {} trade's BSQ margin based price: {} BTC based on pseudo base price {} {} {}% margin.",
+                direction.name(),
+                nextMarginPrice.toPlainString(),
+                basePrice.toPlainString(),
+                direction.equals(BUY) ? "-" : "+",
+                this.getPriceMargin());
+        return nextMarginPrice;
+    };
+
+    protected final Function roundedSecurityDeposit = (securityDepositAsPercent) ->
+            new BigDecimal(securityDepositAsPercent).round(new MathContext(3, HALF_UP)).doubleValue();
+
+    public static void main(String[] args) {
+        BigDecimal targetBtcAmount = new BigDecimal("0.1");
+        BigDecimal targetPrice = new BigDecimal("0.00005");
+        BigDecimal targetSpread = new BigDecimal("10.00");
+
+        Supplier randomPercent = () ->
+                new BigDecimal(Double.toString(RANDOM.nextDouble())).round(new MathContext(2, HALF_UP));
+        BigDecimal nextBtcAmountDeviation = randomPercent.get().multiply(MAX_BTC_AMOUNT_DEVIATION_SIZE);
+        BigDecimal nextTradeBtcAmount = RANDOM.nextBoolean()
+                ? targetBtcAmount.subtract(nextBtcAmountDeviation)
+                : targetBtcAmount.add(nextBtcAmountDeviation);
+        long amountInSatoshis = nextTradeBtcAmount.scaleByPowerOfTen(8).longValue();
+        log.info("Calculated next trade's amount: {} BTC ({} sats) using target amount {} and max deviation {}.",
+                nextTradeBtcAmount,
+                amountInSatoshis,
+                targetBtcAmount,
+                MAX_BTC_AMOUNT_DEVIATION_SIZE);
+
+        BigDecimal nextBsqPriceDeviation = randomPercent.get().multiply(MAX_BSQ_PRICE_DEVIATION_SIZE);
+        BigDecimal nextTradeBsqPrice = RANDOM.nextBoolean()
+                ? targetPrice.add(nextBsqPriceDeviation)
+                : targetPrice.subtract(nextBsqPriceDeviation);
+        log.info("Calculated next trade's BSQ price: {} BTC using target price {} and max deviation {}.",
+                nextTradeBsqPrice.toPlainString(),
+                targetPrice,
+                MAX_BSQ_PRICE_DEVIATION_SIZE.toPlainString());
+
+        BigDecimal marginAsPercent = targetSpread.divide(new BigDecimal(2), HALF_UP);
+        BigDecimal marginAsDecimal = marginAsPercent.multiply(PERCENT_MULTIPLICAND);
+        BigDecimal delta = nextTradeBsqPrice.multiply(marginAsDecimal).round(new MathContext(3, HALF_UP));
+        BigDecimal nextBuyMarginPrice = nextTradeBsqPrice.subtract(delta);
+        BigDecimal nextSellMarginPrice = nextTradeBsqPrice.add(delta);
+        log.info("Calculated next BUY  BSQ margin based price: {} BTC based on pseudo target price {} and {}% margin.",
+                nextBuyMarginPrice.toPlainString(),
+                nextTradeBsqPrice,
+                marginAsPercent);
+        log.info("Calculated next SELL BSQ margin based price: {} BTC based on pseudo target price {} and {}% margin.",
+                nextSellMarginPrice.toPlainString(),
+                nextTradeBsqPrice,
+                marginAsPercent);
+    }
+
+
+    public MarketMakerBotProtocol(String botDescription,
+                                  BotClient botClient,
+                                  PaymentAccount paymentAccount,
+                                  long protocolStepTimeLimitInMs,
+                                  BashScriptGenerator bashScriptGenerator,
+                                  String direction,
+                                  BigDecimal targetBsqPrice,
+                                  BigDecimal targetBtcAmount,
+                                  BigDecimal targetSpread,
+                                  double securityDepositAsPercent,
+                                  String tradingFeeCurrencyCode,
+                                  int maxCreateOfferFailureLimit) {
+        super(botDescription,
+                botClient,
+                paymentAccount,
+                protocolStepTimeLimitInMs,
+                bashScriptGenerator);
+        this.direction = direction;
+        this.priceMargin = calculatedMargin.apply(targetSpread);
+        this.targetBsqPrice = targetBsqPrice;
+        this.targetBtcAmount = targetBtcAmount;
+        this.securityDepositAsPercent = roundedSecurityDeposit.apply(securityDepositAsPercent);
+        this.tradingFeeCurrencyCode = tradingFeeCurrencyCode;
+        this.maxCreateOfferFailureLimit = maxCreateOfferFailureLimit;
+    }
+
+    @Override
+    public void run() {
+        checkIsStartStep();
+
+        var isBuy = direction.equalsIgnoreCase(BUY.name());
+        Function, TradeInfo> makeTrade = waitForNewTrade.andThen(waitForTakerFeeTxConfirm);
+        var trade = isBuy
+                ? makeTrade.apply(createBuyOffer)
+                : makeTrade.apply(createSellOffer);
+
+        var makerIsBuyer = trade.getOffer().getDirection().equalsIgnoreCase(BUY.name());
+
+        Function completeFiatTransaction = makerIsBuyer
+                ? sendPaymentStartedMessage.andThen(waitForPaymentReceivedConfirmation)
+                : waitForPaymentStartedMessage.andThen(sendPaymentReceivedMessage);
+        completeFiatTransaction.apply(trade);
+
+        Function closeTrade = waitForPayoutTx.andThen(keepFundsFromTrade);
+        closeTrade.apply(trade);
+
+        // TODO track changes in balances and print here.
+
+        currentProtocolStep = DONE;
+    }
+
+    protected final Supplier createBuyOffer = () -> {
+        checkIfShutdownCalled("Interrupted before creating random BUY offer.");
+        int attempts;
+        for (attempts = 0; attempts < this.getMaxCreateOfferFailureLimit(); attempts++) {
+            try {
+                var amount = nextTradeBtcAmount.get();
+                var isBsqOffer = paymentAccount.getSelectedTradeCurrency().getCode().equals(BSQ);
+                OfferInfo offer;
+                if (isBsqOffer) {
+                    var priceAsString = calculateNextTradeMarginBasedPrice.apply(BUY).toPlainString();
+                    offer = botClient.createOfferAtFixedPrice(paymentAccount,
+                            SELL.name(),  // This is the Buy BSQ (Sell BTC) bot.
+                            currencyCode,
+                            amount,
+                            amount,
+                            priceAsString,
+                            this.getSecurityDepositAsPercent(),
+                            this.getTradingFeeCurrencyCode());
+                } else {
+                    offer = botClient.createOfferAtMarketBasedPrice(paymentAccount,
+                            BUY.name(),
+                            currencyCode,
+                            amount,
+                            amount,
+                            this.getPriceMargin().doubleValue(),
+                            this.getSecurityDepositAsPercent(),
+                            this.getTradingFeeCurrencyCode());
+                    log.info("Created BUY / {} offer at {}% below current market price of {}:\n{}",
+                            currencyCode,
+                            this.getPriceMargin(),
+                            botClient.getCurrentBTCMarketPriceAsString(currencyCode),
+                            formatOfferTable(singletonList(offer), currencyCode));
+                    log.info("Payment account used to create offer: {}", paymentAccount.getId());
+                }
+                return offer;
+            } catch (Exception ex) {
+                log.error("Failed to create BUY offer after attempt #{}.", attempts, ex);
+                try {
+                    SECONDS.sleep(30);
+                } catch (InterruptedException ignored) {
+                    // empty
+                }
+            }
+        }
+        throw new CreateOfferException(format("%s could not create offer after %s attempts.",
+                botDescription,
+                attempts));
+    };
+
+    protected final Supplier createSellOffer = () -> {
+        checkIfShutdownCalled("Interrupted before creating random SELL offer.");
+        int attempts;
+        for (attempts = 0; attempts < this.getMaxCreateOfferFailureLimit(); attempts++) {
+            try {
+                var amount = nextTradeBtcAmount.get();
+                var isBsqOffer = paymentAccount.getSelectedTradeCurrency().getCode().equals(BSQ);
+                OfferInfo offer;
+                if (isBsqOffer) {
+                    var priceAsString = calculateNextTradeMarginBasedPrice.apply(SELL).toPlainString();
+                    offer = botClient.createOfferAtFixedPrice(paymentAccount,
+                            BUY.name(),  // This is the Sell BSQ (Buy BTC) bot.
+                            currencyCode,
+                            amount,
+                            amount,
+                            priceAsString,
+                            this.getSecurityDepositAsPercent(),
+                            this.getTradingFeeCurrencyCode());
+                } else {
+                    offer = botClient.createOfferAtMarketBasedPrice(paymentAccount,
+                            SELL.name(),
+                            currencyCode,
+                            amount,
+                            amount,
+                            this.getPriceMargin().doubleValue(),
+                            this.getSecurityDepositAsPercent(),
+                            this.getTradingFeeCurrencyCode());
+                    log.info("Created SELL / {} offer at {}% above current market price of {}:\n{}",
+                            currencyCode,
+                            this.getPriceMargin(),
+                            botClient.getCurrentBTCMarketPriceAsString(currencyCode),
+                            formatOfferTable(singletonList(offer), currencyCode));
+                    log.info("Payment account used to create offer: {}", paymentAccount.getId());
+                }
+                return offer;
+            } catch (Exception ex) {
+                log.error("Failed to create SELL offer after attempt #{}.", attempts, ex);
+                try {
+                    SECONDS.sleep(30);
+                } catch (InterruptedException ignored) {
+                    // empty
+                }
+            }
+        }
+        throw new CreateOfferException(format("%s could not create offer after %s attempts.",
+                botDescription,
+                attempts));
+    };
+
+    protected final Function, TradeInfo> waitForNewTrade = (latestOffer) -> {
+        initProtocolStep.accept(WAIT_FOR_OFFER_TAKER);
+        OfferInfo offer = latestOffer.get();
+        // TODO ?  ->  createTakeOfferCliScript(offer);
+        log.info("Waiting for offer {} to be taken.", offer.getId());
+        int numDelays = 0;
+        // An offer may never be taken.  There is no protocol step time limit on takers.
+        // This loop can keep the bot alive for an indefinite time, but it can
+        // still be manually shutdown.
+        while (true) {
+            checkIfShutdownCalled("Interrupted while waiting for offer to be taken.");
+            try {
+                var trade = getNewTrade(offer.getId());
+                if (trade.isPresent()) {
+                    return trade.get();
+                } else {
+                    if (++numDelays % 15 == 0) {
+                        log.warn("Offer {} still waiting to be taken.", offer.getId());
+                        String offerCounterCurrencyCode = offer.getCounterCurrencyCode();
+                        List myCurrentOffers = botClient.getMyOffersSortedByDate(offerCounterCurrencyCode);
+                        if (myCurrentOffers.isEmpty()) {
+                            log.warn("{} has no current offers at this time, but offer {} should exist.",
+                                    botDescription,
+                                    offer.getId());
+                        } else {
+                            log.info("{}'s current offers {} is in the list, or fail):\n{}",
+                                    botDescription,
+                                    offer.getId(),
+                                    formatOfferTable(myCurrentOffers, offerCounterCurrencyCode));
+                        }
+                    }
+                    sleep(MINUTES.toMillis(1));
+                }
+            } catch (Exception ex) {
+                throw new IllegalStateException(botClient.toCleanGrpcExceptionMessage(ex), ex);
+            }
+        } // end while
+    };
+
+    protected Optional getNewTrade(String offerId) {
+        try {
+            var trade = botClient.getTrade(offerId);
+            log.info("Offer {} created with payment account {} has been taken. New trade:\n{}",
+                    offerId,
+                    paymentAccount.getId(),
+                    TradeFormat.format(trade));
+            return Optional.of(trade);
+        } catch (Exception ex) {
+            return Optional.empty();
+        }
+    }
+}
diff --git a/apitest/src/test/java/bisq/apitest/botsupport/protocol/ProtocolStep.java b/apitest/src/test/java/bisq/apitest/botsupport/protocol/ProtocolStep.java
new file mode 100644
index 00000000000..b9a94915428
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/botsupport/protocol/ProtocolStep.java
@@ -0,0 +1,36 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see .
+ */
+
+package bisq.apitest.botsupport.protocol;
+
+public enum ProtocolStep {
+    START,
+    FIND_OFFER,
+    TAKE_OFFER,
+    WAIT_FOR_OFFER_TAKER,
+    WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED,
+    WAIT_FOR_TAKER_DEPOSIT_TX_CONFIRMED,
+    SEND_PAYMENT_TO_RCV_ADDRESS,
+    SEND_PAYMENT_STARTED_MESSAGE,
+    WAIT_FOR_PAYMENT_STARTED_MESSAGE,
+    WAIT_FOR_BSQ_PAYMENT_TO_RCV_ADDRESS,
+    SEND_PAYMENT_RECEIVED_CONFIRMATION_MESSAGE,
+    WAIT_FOR_PAYMENT_RECEIVED_CONFIRMATION_MESSAGE,
+    WAIT_FOR_PAYOUT_TX,
+    KEEP_FUNDS,
+    DONE
+}
diff --git a/apitest/src/test/java/bisq/apitest/botsupport/protocol/TakeOfferHelper.java b/apitest/src/test/java/bisq/apitest/botsupport/protocol/TakeOfferHelper.java
new file mode 100644
index 00000000000..238fb1c5a61
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/botsupport/protocol/TakeOfferHelper.java
@@ -0,0 +1,283 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see .
+ */
+
+package bisq.apitest.botsupport.protocol;
+
+import bisq.proto.grpc.AvailabilityResultWithDescription;
+import bisq.proto.grpc.OfferInfo;
+import bisq.proto.grpc.TakeOfferReply;
+import bisq.proto.grpc.TradeInfo;
+
+import protobuf.PaymentAccount;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+
+import lombok.Getter;
+
+import javax.annotation.Nullable;
+
+import static bisq.apitest.botsupport.shutdown.ManualShutdown.checkIfShutdownCalled;
+import static java.lang.String.format;
+import static java.lang.System.currentTimeMillis;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static protobuf.AvailabilityResult.PRICE_OUT_OF_TOLERANCE;
+import static protobuf.AvailabilityResult.UNCONF_TX_LIMIT_HIT;
+
+
+
+import bisq.apitest.botsupport.BotClient;
+
+
+/**
+ * Convenience for re-attempting to take an offer after non-fatal errors.
+ *
+ * One instance can be used to attempt to take an offer several times, but an
+ * instance should never be re-used to take different offers.  An instance should be
+ * discarded after the run() method returns and the server's reply or exception is
+ * processed.
+ */
+@SuppressWarnings({"unchecked", "rawtypes"})
+public class TakeOfferHelper {
+
+    // Don't use @Slf4j annotation to init log and use in Functions w/out IDE warnings.
+    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(TakeOfferHelper.class);
+
+    @Getter
+    private final BotClient botClient;
+    private final String botDescription;
+    private final OfferInfo offer;
+    private final PaymentAccount paymentAccount;
+    private final String feeCurrency;
+    @Getter
+    private final long takeOfferRequestDeadlineInSec;
+    private final int maxAttemptsBeforeFail;
+    private final long attemptDelayInSec;
+
+    private final AtomicLong startTime = new AtomicLong();
+    private final AtomicLong stopTime = new AtomicLong();
+    private final Consumer setSingleAttemptDeadline = (now) -> {
+        startTime.set(now);
+        stopTime.set(now + SECONDS.toMillis(this.getTakeOfferRequestDeadlineInSec()));
+    };
+    private final Predicate deadlineReached = (t) -> t > stopTime.get();
+
+    @Nullable
+    @Getter
+    private TradeInfo newTrade;
+    @Nullable
+    @Getter
+    private AvailabilityResultWithDescription takeOfferErrorReason;
+    @Nullable
+    @Getter
+    private Throwable fatalThrowable;
+
+    public final Supplier hasNewTrade = () -> newTrade != null;
+    public final Supplier hasTakeOfferError = () -> takeOfferErrorReason != null;
+    private final CountDownLatch attemptDeadlineLatch = new CountDownLatch(1);
+
+    public TakeOfferHelper(BotClient botClient,
+                           String botDescription,
+                           OfferInfo offer,
+                           PaymentAccount paymentAccount,
+                           String feeCurrency,
+                           long takeOfferRequestDeadlineInSec,
+                           int maxAttemptsBeforeFail,
+                           long attemptDelayInSec) {
+        this.botClient = botClient;
+        this.botDescription = botDescription;
+        this.offer = offer;
+        this.paymentAccount = paymentAccount;
+        this.feeCurrency = feeCurrency;
+        this.takeOfferRequestDeadlineInSec = takeOfferRequestDeadlineInSec;
+        this.maxAttemptsBeforeFail = maxAttemptsBeforeFail;
+        this.attemptDelayInSec = attemptDelayInSec;
+    }
+
+    public synchronized void run() {
+        checkIfShutdownCalled("Interrupted before attempting to take offer " + offer.getId());
+        int attemptCount = 0;
+        while (++attemptCount < maxAttemptsBeforeFail) {
+            logCurrentTakeOfferAttempt(attemptCount);
+
+            AtomicReference resultHandler = new AtomicReference(null);
+            AtomicReference errorHandler = new AtomicReference(null);
+            sendTakeOfferRequest(resultHandler, errorHandler);
+            handleTakeOfferReply(resultHandler, errorHandler);
+
+            // If we have a new trade, exit now.
+            // If we have an AvailabilityResultWithDescription with a fatal error,
+            // the fatalThrowable field was set in handleTakeOfferReply and we exit now.
+            // If we have an AvailabilityResultWithDescription with a non-fatal error,
+            // try again.
+            if (hasNewTrade.get()) {
+                break;
+            } else if (fatalThrowable != null) {
+                break;
+            } else {
+                logNextTakeOfferAttemptAndWait(attemptCount);
+                setSingleAttemptDeadline.accept(currentTimeMillis());
+            }
+        }
+    }
+
+    private void sendTakeOfferRequest(AtomicReference resultHandler,
+                                      AtomicReference errorHandler) {
+        // A TakeOfferReply can contain a trade or an AvailabilityResultWithDescription.
+        // An AvailabilityResultWithDescription contains an AvailabilityResult enum and
+        // a client friendly error/reason message.
+        // If the grpc server threw us a StatusRuntimeException instead, the takeoffer
+        // request resulted in an unrecoverable error.
+        checkIfShutdownCalled("Interrupted while attempting to take offer " + offer.getId());
+        botClient.tryToTakeOffer(offer.getId(),
+                paymentAccount,
+                feeCurrency,
+                resultHandler::set,
+                errorHandler::set);
+
+        Supplier isReplyReceived = () ->
+                resultHandler.get() != null || errorHandler.get() != null;
+
+        setSingleAttemptDeadline.accept(currentTimeMillis());
+        while (!deadlineReached.test(currentTimeMillis()) && !isReplyReceived.get()) {
+            try {
+                //noinspection ResultOfMethodCallIgnored
+                attemptDeadlineLatch.await(10, MILLISECONDS);
+            } catch (InterruptedException ignored) {
+                // empty
+            }
+        }
+        logRequestResult(resultHandler, errorHandler, isReplyReceived);
+    }
+
+    private void handleTakeOfferReply(AtomicReference resultHandler,
+                                      AtomicReference errorHandler) {
+        if (isSuccessfulTakeOfferRequest.test(resultHandler)) {
+            this.newTrade = resultHandler.get().getTrade();
+        } else if (resultHandler.get().hasFailureReason()) {
+            // Offer was not taken for reason (AvailabilityResult) given by server.
+            // Determine if the error is fatal.  If fatal set the fatalThrowable field.
+            handleFailureReason(resultHandler, errorHandler);
+        } else {
+            // Server threw an exception or gave no reason for the failure.
+            handleFatalError(errorHandler);
+        }
+    }
+
+    private void handleFailureReason(AtomicReference resultHandler,
+                                     AtomicReference errorHandler) {
+        this.takeOfferErrorReason = resultHandler.get().getFailureReason();
+        if (isTakeOfferAttemptErrorNonFatal.test(takeOfferErrorReason)) {
+            log.warn("Non fatal error attempting to take offer {}.\n"
+                            + "\tReason: {} Description: {}",
+                    offer.getId(),
+                    takeOfferErrorReason.getAvailabilityResult().name(),
+                    takeOfferErrorReason.getDescription());
+            this.fatalThrowable = null;
+        } else {
+            log.error("Fatal error attempting to take offer {}.\n"
+                            + "\tReason: {} Description: {}",
+                    offer.getId(),
+                    takeOfferErrorReason.getAvailabilityResult().name(),
+                    takeOfferErrorReason.getDescription());
+            this.fatalThrowable = errorHandler.get();
+        }
+    }
+
+    private void handleFatalError(AtomicReference errorHandler) {
+        if (errorHandler.get() != null) {
+            log.error("", errorHandler.get());
+            throw new IllegalStateException(
+                    format("fatal error attempting to take offer %s: %s",
+                            offer.getId(),
+                            errorHandler.get().getMessage().toLowerCase()));
+        } else {
+            throw new IllegalStateException(
+                    format("programmer error: fatal error attempting to take offer %s with no reason from server",
+                            offer.getId()));
+        }
+    }
+
+    private void logRequestResult(AtomicReference resultHandler,
+                                  AtomicReference errorHandler,
+                                  Supplier isReplyReceived) {
+        if (isReplyReceived.get()) {
+            if (resultHandler.get() != null)
+                log.info("The takeoffer request returned new trade: {}.",
+                        resultHandler.get().getTrade().getTradeId());
+            else
+                log.warn("The takeoffer request returned error: {}.",
+                        errorHandler.get().getMessage());
+        } else {
+            log.error("The takeoffer request failed: no reply received within the {} second deadline.",
+                    takeOfferRequestDeadlineInSec);
+        }
+    }
+
+    private void logNextTakeOfferAttemptAndWait(int attemptCount) {
+        // Take care to not let bots exceed call rate limit on mainnet.
+        log.info("The takeoffer {} request attempt #{} will be made in {} seconds.",
+                offer.getId(),
+                attemptCount + 1,
+                attemptDelayInSec);
+        try {
+            SECONDS.sleep(attemptDelayInSec);
+        } catch (InterruptedException ignored) {
+            // empty
+        }
+    }
+
+    private void logCurrentTakeOfferAttempt(int attemptCount) {
+        log.info("{} taking {} / {} offer {}.  Attempt # {}.",
+                botDescription,
+                offer.getDirection(),
+                offer.getCounterCurrencyCode(),
+                offer.getId(),
+                attemptCount);
+    }
+
+    private final Predicate> isSuccessfulTakeOfferRequest = (resultHandler) -> {
+        var takeOfferReply = resultHandler.get();
+        if (takeOfferReply.hasTrade()) {
+            try {
+                log.info("Created trade {}.  Allowing 5s for trade prep before continuing.",
+                        takeOfferReply.getTrade().getTradeId());
+                SECONDS.sleep(5);
+            } catch (InterruptedException ignored) {
+                // empty
+            }
+            return true;
+        } else {
+            return false;
+        }
+    };
+
+    private final Predicate isTakeOfferAttemptErrorNonFatal = (reason) -> {
+        if (reason != null) {
+            return this.getBotClient().takeOfferFailedForOneOfTheseReasons(reason,
+                    PRICE_OUT_OF_TOLERANCE,
+                    UNCONF_TX_LIMIT_HIT);
+        } else {
+            return false;
+        }
+    };
+}
diff --git a/apitest/src/test/java/bisq/apitest/botsupport/protocol/TakerBotProtocol.java b/apitest/src/test/java/bisq/apitest/botsupport/protocol/TakerBotProtocol.java
new file mode 100644
index 00000000000..283d9047242
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/botsupport/protocol/TakerBotProtocol.java
@@ -0,0 +1,21 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see .
+ */
+
+package bisq.apitest.botsupport.protocol;
+
+public interface TakerBotProtocol {
+}
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/script/BashScriptGenerator.java b/apitest/src/test/java/bisq/apitest/botsupport/script/BashScriptGenerator.java
similarity index 88%
rename from apitest/src/test/java/bisq/apitest/scenario/bot/script/BashScriptGenerator.java
rename to apitest/src/test/java/bisq/apitest/botsupport/script/BashScriptGenerator.java
index d41e8a1acd3..03c7ca150b2 100644
--- a/apitest/src/test/java/bisq/apitest/scenario/bot/script/BashScriptGenerator.java
+++ b/apitest/src/test/java/bisq/apitest/botsupport/script/BashScriptGenerator.java
@@ -15,9 +15,7 @@
  * along with Bisq. If not, see .
  */
 
-package bisq.apitest.scenario.bot.script;
-
-import bisq.common.file.FileUtil;
+package bisq.apitest.botsupport.script;
 
 import bisq.proto.grpc.OfferInfo;
 import bisq.proto.grpc.TradeInfo;
@@ -36,6 +34,7 @@
 import lombok.Getter;
 import lombok.extern.slf4j.Slf4j;
 
+import static bisq.apitest.botsupport.util.FileUtil.deleteFileIfExists;
 import static com.google.common.io.FileWriteMode.APPEND;
 import static java.lang.String.format;
 import static java.lang.System.getProperty;
@@ -126,6 +125,10 @@ public File createMakeFixedPricedOfferScript(String direction,
     }
 
     public File createTakeOfferScript(OfferInfo offer) {
+        return createTakeOfferScript(offer, "takeoffer.sh");
+    }
+
+    public File createTakeOfferScript(OfferInfo offer, String scriptFilename) {
         String getOffersCmd = format("%s getoffers --direction=%s --currency-code=%s",
                 cliBase,
                 offer.getDirection(),
@@ -137,40 +140,52 @@ public File createTakeOfferScript(OfferInfo offer) {
         String getTradeCmd = format("%s gettrade --trade-id=%s",
                 cliBase,
                 offer.getId());
-        return createCliScript("takeoffer.sh",
+        return createCliScript(scriptFilename,
                 getOffersCmd,
                 takeOfferCmd,
-                "sleep 5",
+                "sleep 2",
                 getTradeCmd);
     }
 
     public File createPaymentStartedScript(TradeInfo trade) {
+        return createPaymentStartedScript(trade, "confirmpaymentstarted.sh");
+    }
+
+    public File createPaymentStartedScript(TradeInfo trade, String scriptFilename) {
         String paymentStartedCmd = format("%s confirmpaymentstarted --trade-id=%s",
                 cliBase,
                 trade.getTradeId());
         String getTradeCmd = format("%s gettrade --trade-id=%s", cliBase, trade.getTradeId());
-        return createCliScript("confirmpaymentstarted.sh",
+        return createCliScript(scriptFilename,
                 paymentStartedCmd,
                 "sleep 2",
                 getTradeCmd);
     }
 
     public File createPaymentReceivedScript(TradeInfo trade) {
+        return createPaymentReceivedScript(trade, "confirmpaymentreceived.sh");
+    }
+
+    public File createPaymentReceivedScript(TradeInfo trade, String scriptFilename) {
         String paymentStartedCmd = format("%s confirmpaymentreceived --trade-id=%s",
                 cliBase,
                 trade.getTradeId());
         String getTradeCmd = format("%s gettrade --trade-id=%s", cliBase, trade.getTradeId());
-        return createCliScript("confirmpaymentreceived.sh",
+        return createCliScript(scriptFilename,
                 paymentStartedCmd,
                 "sleep 2",
                 getTradeCmd);
     }
 
     public File createKeepFundsScript(TradeInfo trade) {
+        return createKeepFundsScript(trade, "keepfunds.sh");
+    }
+
+    public File createKeepFundsScript(TradeInfo trade, String scriptFilename) {
         String paymentStartedCmd = format("%s keepfunds --trade-id=%s", cliBase, trade.getTradeId());
         String getTradeCmd = format("%s gettrade --trade-id=%s", cliBase, trade.getTradeId());
         String getBalanceCmd = format("%s getbalance", cliBase);
-        return createCliScript("keepfunds.sh",
+        return createCliScript(scriptFilename,
                 paymentStartedCmd,
                 "sleep 2",
                 getTradeCmd,
@@ -195,7 +210,7 @@ public File createCliScript(String scriptName, String... commands) {
         File oldScript = new File(filename);
         if (oldScript.exists()) {
             try {
-                FileUtil.deleteFileIfExists(oldScript);
+                deleteFileIfExists(oldScript);
             } catch (IOException ex) {
                 throw new IllegalStateException("Unable to delete old script.", ex);
             }
@@ -205,8 +220,8 @@ public File createCliScript(String scriptName, String... commands) {
             List lines = new ArrayList<>();
             lines.add("#!/bin/bash");
             lines.add("############################################################");
-            lines.add("# This example CLI script may be overwritten during the test");
-            lines.add("# run, and will be deleted when the test harness shuts down.");
+            lines.add("# This example CLI script may be overwritten during bot");
+            lines.add("# execution, and will be deleted when the bot shuts down.");
             lines.add("# Make a copy if you want to save it.");
             lines.add("############################################################");
             lines.add("set -x");
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/shutdown/ManualBotShutdownException.java b/apitest/src/test/java/bisq/apitest/botsupport/shutdown/ManualBotShutdownException.java
similarity index 80%
rename from apitest/src/test/java/bisq/apitest/scenario/bot/shutdown/ManualBotShutdownException.java
rename to apitest/src/test/java/bisq/apitest/botsupport/shutdown/ManualBotShutdownException.java
index 8a0e68bad18..7c0fe9825d7 100644
--- a/apitest/src/test/java/bisq/apitest/scenario/bot/shutdown/ManualBotShutdownException.java
+++ b/apitest/src/test/java/bisq/apitest/botsupport/shutdown/ManualBotShutdownException.java
@@ -15,21 +15,21 @@
  * along with Bisq. If not, see .
  */
 
-package bisq.apitest.scenario.bot.shutdown;
+package bisq.apitest.botsupport.shutdown;
 
-import bisq.common.BisqException;
+import static java.lang.String.format;
 
 @SuppressWarnings("unused")
-public class ManualBotShutdownException extends BisqException {
+public class ManualBotShutdownException extends RuntimeException {
     public ManualBotShutdownException(Throwable cause) {
         super(cause);
     }
 
     public ManualBotShutdownException(String format, Object... args) {
-        super(format, args);
+        super(format(format, args));
     }
 
     public ManualBotShutdownException(Throwable cause, String format, Object... args) {
-        super(cause, format, args);
+        super(format(format, args), cause);
     }
 }
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/shutdown/ManualShutdown.java b/apitest/src/test/java/bisq/apitest/botsupport/shutdown/ManualShutdown.java
similarity index 52%
rename from apitest/src/test/java/bisq/apitest/scenario/bot/shutdown/ManualShutdown.java
rename to apitest/src/test/java/bisq/apitest/botsupport/shutdown/ManualShutdown.java
index fc680f1c818..a18a71f8497 100644
--- a/apitest/src/test/java/bisq/apitest/scenario/bot/shutdown/ManualShutdown.java
+++ b/apitest/src/test/java/bisq/apitest/botsupport/shutdown/ManualShutdown.java
@@ -1,6 +1,21 @@
-package bisq.apitest.scenario.bot.shutdown;
+/*
+ * 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 .
+ */
 
-import bisq.common.UserThread;
+package bisq.apitest.botsupport.shutdown;
 
 import java.io.File;
 import java.io.IOException;
@@ -9,30 +24,35 @@
 
 import lombok.extern.slf4j.Slf4j;
 
-import static bisq.common.file.FileUtil.deleteFileIfExists;
+import static bisq.apitest.botsupport.util.FileUtil.deleteFileIfExists;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 
+
+
+import bisq.apitest.botsupport.BotThread;
+
+
 @Slf4j
 public class ManualShutdown {
 
-    public static final String SHUTDOWN_FILENAME = "/tmp/bottest-shutdown";
+    public static final String SHUTDOWN_FILENAME = "/tmp/bot-shutdown";
 
     private static final AtomicBoolean SHUTDOWN_CALLED = new AtomicBoolean(false);
 
     /**
-     * Looks for a /tmp/bottest-shutdown file and throws a BotShutdownException if found.
+     * Looks for a /tmp/bot-shutdown file and throws a BotShutdownException if found.
      *
-     * Running '$ touch /tmp/bottest-shutdown' could be used to trigger a scaffold teardown.
+     * Running '$ touch /tmp/bot-shutdown' could be used to trigger a scaffold teardown.
      *
      * This is much easier than manually shutdown down bisq apps & bitcoind.
      */
     public static void startShutdownTimer() {
         deleteStaleShutdownFile();
 
-        UserThread.runPeriodically(() -> {
+        BotThread.runPeriodically(() -> {
             File shutdownFile = new File(SHUTDOWN_FILENAME);
             if (shutdownFile.exists()) {
-                log.warn("Caught manual shutdown signal:  /tmp/bottest-shutdown file exists.");
+                log.warn("Caught manual shutdown signal: {} file exists.", SHUTDOWN_FILENAME);
                 try {
                     deleteFileIfExists(shutdownFile);
                 } catch (IOException ex) {
@@ -53,6 +73,10 @@ public static void checkIfShutdownCalled(String warning) throws ManualBotShutdow
             throw new ManualBotShutdownException(warning);
     }
 
+    public static void setShutdownCalled(boolean isShutdownCalled) {
+        SHUTDOWN_CALLED.set(isShutdownCalled);
+    }
+
     private static void deleteStaleShutdownFile() {
         try {
             deleteFileIfExists(new File(SHUTDOWN_FILENAME));
diff --git a/apitest/src/test/java/bisq/apitest/botsupport/util/BotUtilities.java b/apitest/src/test/java/bisq/apitest/botsupport/util/BotUtilities.java
new file mode 100644
index 00000000000..412ebb8e54d
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/botsupport/util/BotUtilities.java
@@ -0,0 +1,91 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see .
+ */
+
+package bisq.apitest.botsupport.util;
+
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.common.util.concurrent.ThreadFactoryBuilder;
+
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+public class BotUtilities {
+
+    // An unfortunate duplication of code in non-accessible :common Utilities class.
+
+    public static ListeningExecutorService getListeningExecutorService(String name,
+                                                                       int corePoolSize,
+                                                                       int maximumPoolSize,
+                                                                       long keepAliveTimeInSec) {
+        return MoreExecutors.listeningDecorator(getThreadPoolExecutor(name,
+                corePoolSize,
+                maximumPoolSize,
+                keepAliveTimeInSec));
+    }
+
+    public static ThreadPoolExecutor getThreadPoolExecutor(String name,
+                                                           int corePoolSize,
+                                                           int maximumPoolSize,
+                                                           long keepAliveTimeInSec) {
+        final ThreadFactory threadFactory = new ThreadFactoryBuilder()
+                .setNameFormat(name)
+                .setDaemon(true)
+                .build();
+        ThreadPoolExecutor executor = new ThreadPoolExecutor(corePoolSize,
+                maximumPoolSize,
+                keepAliveTimeInSec,
+                TimeUnit.SECONDS,
+                new ArrayBlockingQueue<>(maximumPoolSize),
+                threadFactory);
+        executor.allowCoreThreadTimeOut(true);
+        executor.setRejectedExecutionHandler((r, e) -> log.warn("RejectedExecutionHandler called"));
+        return executor;
+    }
+
+    /**
+     * Copied from org.apache.commons.lang3.StringUtils.capitalize.
+     */
+    public static String capitalize(final String str) {
+        int strLen;
+        if (str == null || (strLen = str.length()) == 0) {
+            return str;
+        }
+
+        final int firstCodepoint = str.codePointAt(0);
+        final int newCodePoint = Character.toTitleCase(firstCodepoint);
+        if (firstCodepoint == newCodePoint) {
+            // already capitalized
+            return str;
+        }
+
+        final int[] newCodePoints = new int[strLen]; // cannot be longer than the char array
+        int outOffset = 0;
+        newCodePoints[outOffset++] = newCodePoint; // copy the first codepoint
+        for (int inOffset = Character.charCount(firstCodepoint); inOffset < strLen; ) {
+            final int codepoint = str.codePointAt(inOffset);
+            newCodePoints[outOffset++] = codepoint; // copy the remaining ones
+            inOffset += Character.charCount(codepoint);
+        }
+        return new String(newCodePoints, 0, outOffset);
+    }
+}
diff --git a/apitest/src/test/java/bisq/apitest/botsupport/util/FileUtil.java b/apitest/src/test/java/bisq/apitest/botsupport/util/FileUtil.java
new file mode 100644
index 00000000000..cc6d1b42728
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/botsupport/util/FileUtil.java
@@ -0,0 +1,55 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see .
+ */
+
+package bisq.apitest.botsupport.util;
+
+import java.io.File;
+import java.io.IOException;
+
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+public class FileUtil {
+
+    public static void deleteFileIfExists(File file) throws IOException {
+        deleteFileIfExists(file, true);
+    }
+
+    public static void deleteFileIfExists(File file, boolean ignoreLockedFiles) throws IOException {
+        try {
+            if (file.exists() && !file.delete()) {
+                if (ignoreLockedFiles) {
+                    // We check if file is locked. On Windows all open files are locked by the OS, so we
+                    if (isFileLocked(file))
+                        log.info("Failed to delete locked file: " + file.getAbsolutePath());
+                } else {
+                    final String message = "Failed to delete file: " + file.getAbsolutePath();
+                    log.error(message);
+                    throw new IOException(message);
+                }
+            }
+        } catch (Throwable t) {
+            log.error(t.toString());
+            t.printStackTrace();
+            throw new IOException(t);
+        }
+    }
+
+    private static boolean isFileLocked(File file) {
+        return !file.canWrite();
+    }
+}
diff --git a/apitest/src/test/java/bisq/apitest/botsupport/util/FrameRateTimer.java b/apitest/src/test/java/bisq/apitest/botsupport/util/FrameRateTimer.java
new file mode 100644
index 00000000000..398c16a1c22
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/botsupport/util/FrameRateTimer.java
@@ -0,0 +1,106 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see .
+ */
+
+package bisq.apitest.botsupport.util;
+
+
+import java.time.Duration;
+
+import java.util.UUID;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * We simulate a global frame rate timer similar to FXTimer to avoid creation of threads for each timer call.
+ * Used only in headless apps like the seed node.
+ */
+public class FrameRateTimer implements Timer, Runnable {
+    private final Logger log = LoggerFactory.getLogger(FrameRateTimer.class);
+
+    private long interval;
+    private Runnable runnable;
+    private long startTs;
+    private boolean isPeriodically;
+    private final String uid = UUID.randomUUID().toString();
+    private volatile boolean stopped;
+
+    public FrameRateTimer() {
+    }
+
+    @Override
+    public void run() {
+        if (!stopped) {
+            try {
+                long currentTimeMillis = System.currentTimeMillis();
+                if ((currentTimeMillis - startTs) >= interval) {
+                    runnable.run();
+                    if (isPeriodically)
+                        startTs = currentTimeMillis;
+                    else
+                        stop();
+                }
+            } catch (Throwable t) {
+                log.error("exception in FrameRateTimer", t);
+                stop();
+                throw t;
+            }
+        }
+    }
+
+    @Override
+    public Timer runLater(Duration delay, Runnable runnable) {
+        this.interval = delay.toMillis();
+        this.runnable = runnable;
+        startTs = System.currentTimeMillis();
+        MasterTimer.addListener(this);
+        return this;
+    }
+
+    @Override
+    public Timer runPeriodically(Duration interval, Runnable runnable) {
+        this.interval = interval.toMillis();
+        isPeriodically = true;
+        this.runnable = runnable;
+        startTs = System.currentTimeMillis();
+        MasterTimer.addListener(this);
+        return this;
+    }
+
+    @Override
+    public void stop() {
+        stopped = true;
+        MasterTimer.removeListener(this);
+    }
+
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (!(o instanceof FrameRateTimer)) return false;
+
+        FrameRateTimer that = (FrameRateTimer) o;
+
+        return !(uid != null ? !uid.equals(that.uid) : that.uid != null);
+
+    }
+
+    @Override
+    public int hashCode() {
+        return uid != null ? uid.hashCode() : 0;
+    }
+}
diff --git a/apitest/src/test/java/bisq/apitest/botsupport/util/MasterTimer.java b/apitest/src/test/java/bisq/apitest/botsupport/util/MasterTimer.java
new file mode 100644
index 00000000000..f8445a96c70
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/botsupport/util/MasterTimer.java
@@ -0,0 +1,57 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see .
+ */
+
+package bisq.apitest.botsupport.util;
+
+import java.util.Set;
+import java.util.TimerTask;
+import java.util.concurrent.CopyOnWriteArraySet;
+
+import lombok.extern.slf4j.Slf4j;
+
+
+
+import bisq.apitest.botsupport.BotThread;
+
+
+// Runs all listener objects periodically in a short interval.
+
+@Slf4j
+public class MasterTimer {
+    private static final java.util.Timer timer = new java.util.Timer();
+    // frame rate of 60 fps is about 16 ms but we  don't need such a short interval, 100 ms should be good enough
+    public static final long FRAME_INTERVAL_MS = 100;
+
+    static {
+        timer.scheduleAtFixedRate(new TimerTask() {
+            @Override
+            public void run() {
+                BotThread.execute(() -> listeners.forEach(Runnable::run));
+            }
+        }, FRAME_INTERVAL_MS, FRAME_INTERVAL_MS);
+    }
+
+    private static final Set listeners = new CopyOnWriteArraySet<>();
+
+    public static void addListener(Runnable runnable) {
+        listeners.add(runnable);
+    }
+
+    public static void removeListener(Runnable runnable) {
+        listeners.remove(runnable);
+    }
+}
diff --git a/apitest/src/test/java/bisq/apitest/botsupport/util/Timer.java b/apitest/src/test/java/bisq/apitest/botsupport/util/Timer.java
new file mode 100644
index 00000000000..7de57d2d08f
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/botsupport/util/Timer.java
@@ -0,0 +1,28 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see .
+ */
+
+package bisq.apitest.botsupport.util;
+
+import java.time.Duration;
+
+public interface Timer {
+    Timer runLater(Duration delay, Runnable action);
+
+    Timer runPeriodically(Duration interval, Runnable runnable);
+
+    void stop();
+}
diff --git a/apitest/src/test/java/bisq/apitest/method/offer/AbstractOfferTest.java b/apitest/src/test/java/bisq/apitest/method/offer/AbstractOfferTest.java
index 86f5f2ab6db..f0e95dd25f8 100644
--- a/apitest/src/test/java/bisq/apitest/method/offer/AbstractOfferTest.java
+++ b/apitest/src/test/java/bisq/apitest/method/offer/AbstractOfferTest.java
@@ -32,6 +32,7 @@
 import org.junit.jupiter.api.BeforeAll;
 
 import static bisq.apitest.Scaffold.BitcoinCoreApp.bitcoind;
+import static bisq.apitest.config.ApiTestConfig.BSQ;
 import static bisq.apitest.config.BisqAppConfig.alicedaemon;
 import static bisq.apitest.config.BisqAppConfig.arbdaemon;
 import static bisq.apitest.config.BisqAppConfig.bobdaemon;
@@ -57,7 +58,6 @@ public abstract class AbstractOfferTest extends MethodTest {
     @BeforeAll
     public static void setUp() {
         startSupportingApps(true,
-                true,
                 false,
                 bitcoind,
                 seednode,
@@ -70,10 +70,12 @@ public static void setUp() {
     public static void createBsqPaymentAccounts() {
         alicesBsqAcct = aliceClient.createCryptoCurrencyPaymentAccount("Alice's BSQ Account",
                 BSQ,
-                aliceClient.getUnusedBsqAddress());
+                aliceClient.getUnusedBsqAddress(),
+                false);
         bobsBsqAcct = bobClient.createCryptoCurrencyPaymentAccount("Bob's BSQ Account",
                 BSQ,
-                bobClient.getUnusedBsqAddress());
+                bobClient.getUnusedBsqAddress(),
+                false);
     }
 
     protected double getScaledOfferPrice(double offerPrice, String currencyCode) {
diff --git a/apitest/src/test/java/bisq/apitest/method/offer/CancelOfferTest.java b/apitest/src/test/java/bisq/apitest/method/offer/CancelOfferTest.java
index 2e23c21c1f7..fe21e4aa8f2 100644
--- a/apitest/src/test/java/bisq/apitest/method/offer/CancelOfferTest.java
+++ b/apitest/src/test/java/bisq/apitest/method/offer/CancelOfferTest.java
@@ -32,6 +32,7 @@
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.TestMethodOrder;
 
+import static bisq.apitest.config.ApiTestConfig.BSQ;
 import static bisq.core.btc.wallet.Restrictions.getDefaultBuyerSecurityDepositAsPercent;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static protobuf.OfferPayload.Direction.BUY;
diff --git a/apitest/src/test/java/bisq/apitest/method/offer/CreateBSQOffersTest.java b/apitest/src/test/java/bisq/apitest/method/offer/CreateBSQOffersTest.java
index abc916f5176..c83d5d15477 100644
--- a/apitest/src/test/java/bisq/apitest/method/offer/CreateBSQOffersTest.java
+++ b/apitest/src/test/java/bisq/apitest/method/offer/CreateBSQOffersTest.java
@@ -30,6 +30,8 @@
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.TestMethodOrder;
 
+import static bisq.apitest.config.ApiTestConfig.BSQ;
+import static bisq.apitest.config.ApiTestConfig.BTC;
 import static bisq.cli.TableFormat.formatBalancesTbls;
 import static bisq.cli.TableFormat.formatOfferTable;
 import static bisq.core.btc.wallet.Restrictions.getDefaultBuyerSecurityDepositAsPercent;
diff --git a/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingFixedPriceTest.java b/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingFixedPriceTest.java
index aeb1b6eb944..081c6feadc7 100644
--- a/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingFixedPriceTest.java
+++ b/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingFixedPriceTest.java
@@ -27,6 +27,8 @@
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.TestMethodOrder;
 
+import static bisq.apitest.config.ApiTestConfig.BSQ;
+import static bisq.apitest.config.ApiTestConfig.BTC;
 import static bisq.cli.TableFormat.formatOfferTable;
 import static bisq.core.btc.wallet.Restrictions.getDefaultBuyerSecurityDepositAsPercent;
 import static java.util.Collections.singletonList;
diff --git a/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingMarketPriceMarginTest.java b/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingMarketPriceMarginTest.java
index 944aaf0a59f..94c2519d913 100644
--- a/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingMarketPriceMarginTest.java
+++ b/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingMarketPriceMarginTest.java
@@ -31,6 +31,7 @@
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.TestMethodOrder;
 
+import static bisq.apitest.config.ApiTestConfig.BTC;
 import static bisq.cli.TableFormat.formatOfferTable;
 import static bisq.common.util.MathUtils.scaleDownByPowerOf10;
 import static bisq.common.util.MathUtils.scaleUpByPowerOf10;
diff --git a/apitest/src/test/java/bisq/apitest/method/offer/ValidateCreateOfferTest.java b/apitest/src/test/java/bisq/apitest/method/offer/ValidateCreateOfferTest.java
index 5b3151c5711..33626ee6c3c 100644
--- a/apitest/src/test/java/bisq/apitest/method/offer/ValidateCreateOfferTest.java
+++ b/apitest/src/test/java/bisq/apitest/method/offer/ValidateCreateOfferTest.java
@@ -29,6 +29,8 @@
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.TestMethodOrder;
 
+import static bisq.apitest.config.ApiTestConfig.BSQ;
+import static bisq.apitest.config.ApiTestConfig.BTC;
 import static bisq.core.btc.wallet.Restrictions.getDefaultBuyerSecurityDepositAsPercent;
 import static java.lang.String.format;
 import static org.junit.jupiter.api.Assertions.assertEquals;
diff --git a/apitest/src/test/java/bisq/apitest/method/trade/AbstractTradeTest.java b/apitest/src/test/java/bisq/apitest/method/trade/AbstractTradeTest.java
index 06a2a06de3a..4c4a6b34533 100644
--- a/apitest/src/test/java/bisq/apitest/method/trade/AbstractTradeTest.java
+++ b/apitest/src/test/java/bisq/apitest/method/trade/AbstractTradeTest.java
@@ -9,13 +9,16 @@
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.TestInfo;
 
+import static bisq.cli.CurrencyFormat.formatBsqAmount;
 import static bisq.cli.TradeFormat.format;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.fail;
 
 
 
 import bisq.apitest.method.offer.AbstractOfferTest;
+import bisq.cli.GrpcClient;
 
 public class AbstractTradeTest extends AbstractOfferTest {
 
@@ -59,6 +62,40 @@ protected final void verifyExpectedProtocolStatus(TradeInfo trade) {
         assertEquals(EXPECTED_PROTOCOL_STATUS.isWithdrawn, trade.getIsWithdrawn());
     }
 
+    protected final void sendBsqPayment(Logger log,
+                                        GrpcClient grpcClient,
+                                        TradeInfo trade) {
+        var contract = trade.getContract();
+        String receiverAddress = contract.getIsBuyerMakerAndSellerTaker()
+                ? contract.getTakerPaymentAccountPayload().getAddress()
+                : contract.getMakerPaymentAccountPayload().getAddress();
+        String sendBsqAmount = formatBsqAmount(trade.getOffer().getVolume());
+        log.info("Sending {} BSQ to address {}", sendBsqAmount, receiverAddress);
+        grpcClient.sendBsq(receiverAddress, sendBsqAmount, "");
+    }
+
+    protected final void verifyBsqPaymentHasBeenReceived(Logger log,
+                                                         GrpcClient grpcClient,
+                                                         TradeInfo trade) {
+        var contract = trade.getContract();
+        var bsqSats = trade.getOffer().getVolume();
+        var receiveAmountAsString = formatBsqAmount(bsqSats);
+        var address = contract.getIsBuyerMakerAndSellerTaker()
+                ? contract.getTakerPaymentAccountPayload().getAddress()
+                : contract.getMakerPaymentAccountPayload().getAddress();
+        boolean receivedBsqSatoshis = grpcClient.verifyBsqSentToAddress(address, receiveAmountAsString);
+        if (receivedBsqSatoshis)
+            log.info("Payment of {} BSQ was received to address {} for trade with id {}.",
+                    receiveAmountAsString,
+                    address,
+                    trade.getTradeId());
+        else
+            fail(String.format("Payment of %s BSQ was was not sent to address %s for trade with id %s.",
+                    receiveAmountAsString,
+                    address,
+                    trade.getTradeId()));
+    }
+
     protected final void logTrade(Logger log,
                                   TestInfo testInfo,
                                   String description,
diff --git a/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBSQOfferTest.java b/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBSQOfferTest.java
index b4897418e19..e04ccfc5b8c 100644
--- a/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBSQOfferTest.java
+++ b/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBSQOfferTest.java
@@ -17,7 +17,6 @@
 
 package bisq.apitest.method.trade;
 
-import bisq.proto.grpc.ContractInfo;
 import bisq.proto.grpc.TradeInfo;
 
 import io.grpc.StatusRuntimeException;
@@ -34,7 +33,7 @@
 import org.junit.jupiter.api.TestInfo;
 import org.junit.jupiter.api.TestMethodOrder;
 
-import static bisq.cli.CurrencyFormat.formatBsqSendAmount;
+import static bisq.apitest.config.ApiTestConfig.BSQ;
 import static bisq.cli.TableFormat.formatBalancesTbls;
 import static bisq.cli.TableFormat.formatOfferTable;
 import static bisq.core.btc.wallet.Restrictions.getDefaultBuyerSecurityDepositAsPercent;
@@ -181,16 +180,7 @@ public void testBobsConfirmPaymentStarted(final TestInfo testInfo) {
                         trade.getPhase()));
             }
 
-            // TODO refactor into superclass?  Might be needed in :cli.
-            // ContractInfo contract = ContractInfo.fromProto(trade);
-            ContractInfo contract = trade.getContract();
-            String alicesBsqAddress = contract.getIsBuyerMakerAndSellerTaker()
-                    ? contract.getTakerPaymentAccountPayload().getAddress()
-                    : contract.getMakerPaymentAccountPayload().getAddress();
-            String sendBsqAmount = formatBsqSendAmount(trade.getOffer().getVolume());
-            log.info("Bob sending {} BSQ to Alice's address {}", sendBsqAmount, alicesBsqAddress);
-            bobClient.sendBsq(alicesBsqAddress, sendBsqAmount, "");
-
+            sendBsqPayment(log, bobClient, trade);
             genBtcBlocksThenWait(1, 2500);
             bobClient.confirmPaymentStarted(trade.getTradeId());
             sleep(6000);
@@ -254,6 +244,9 @@ public void testAlicesConfirmPaymentReceived(final TestInfo testInfo) {
                         trade.getPhase()));
             }
 
+            sleep(2000);
+            verifyBsqPaymentHasBeenReceived(log, aliceClient, trade);
+
             aliceClient.confirmPaymentReceived(trade.getTradeId());
             sleep(3000);
 
diff --git a/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBTCOfferTest.java b/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBTCOfferTest.java
index f19c4fd8b1f..93d9b1b9c8b 100644
--- a/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBTCOfferTest.java
+++ b/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBTCOfferTest.java
@@ -34,6 +34,7 @@
 import org.junit.jupiter.api.TestInfo;
 import org.junit.jupiter.api.TestMethodOrder;
 
+import static bisq.apitest.config.ApiTestConfig.BSQ;
 import static bisq.cli.TableFormat.formatBalancesTbls;
 import static bisq.core.btc.wallet.Restrictions.getDefaultBuyerSecurityDepositAsPercent;
 import static bisq.core.trade.Trade.Phase.DEPOSIT_CONFIRMED;
diff --git a/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBSQOfferTest.java b/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBSQOfferTest.java
index 1c2d1e2139c..e9348e6323d 100644
--- a/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBSQOfferTest.java
+++ b/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBSQOfferTest.java
@@ -17,7 +17,6 @@
 
 package bisq.apitest.method.trade;
 
-import bisq.proto.grpc.ContractInfo;
 import bisq.proto.grpc.TradeInfo;
 
 import io.grpc.StatusRuntimeException;
@@ -34,7 +33,8 @@
 import org.junit.jupiter.api.TestInfo;
 import org.junit.jupiter.api.TestMethodOrder;
 
-import static bisq.cli.CurrencyFormat.formatBsqSendAmount;
+import static bisq.apitest.config.ApiTestConfig.BSQ;
+import static bisq.apitest.config.ApiTestConfig.BTC;
 import static bisq.cli.TableFormat.formatBalancesTbls;
 import static bisq.cli.TableFormat.formatOfferTable;
 import static bisq.core.btc.wallet.Restrictions.getDefaultBuyerSecurityDepositAsPercent;
@@ -181,15 +181,7 @@ public void testAlicesConfirmPaymentStarted(final TestInfo testInfo) {
                         trade.getPhase()));
             }
 
-            // TODO refactor into superclass?  Might be needed in :cli.
-            ContractInfo contract = trade.getContract();
-            String bobsBsqAddress = contract.getIsBuyerMakerAndSellerTaker()
-                    ? contract.getTakerPaymentAccountPayload().getAddress()
-                    : contract.getMakerPaymentAccountPayload().getAddress();
-            String sendBsqAmount = formatBsqSendAmount(trade.getOffer().getVolume());
-            log.info("Alice sending {} BSQ to Bob's address {}", sendBsqAmount, bobsBsqAddress);
-            aliceClient.sendBsq(bobsBsqAddress, sendBsqAmount, "");
-
+            sendBsqPayment(log, aliceClient, trade);
             genBtcBlocksThenWait(1, 2500);
             aliceClient.confirmPaymentStarted(trade.getTradeId());
             sleep(6000);
@@ -253,6 +245,9 @@ public void testBobsConfirmPaymentReceived(final TestInfo testInfo) {
                         trade.getPhase()));
             }
 
+            sleep(2000);
+            verifyBsqPaymentHasBeenReceived(log, bobClient, trade);
+
             bobClient.confirmPaymentReceived(trade.getTradeId());
             sleep(3000);
 
diff --git a/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBTCOfferTest.java b/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBTCOfferTest.java
index 832b61b824a..ece3432123b 100644
--- a/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBTCOfferTest.java
+++ b/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBTCOfferTest.java
@@ -34,6 +34,7 @@
 import org.junit.jupiter.api.TestInfo;
 import org.junit.jupiter.api.TestMethodOrder;
 
+import static bisq.apitest.config.ApiTestConfig.BTC;
 import static bisq.cli.TableFormat.formatBalancesTbls;
 import static bisq.core.btc.wallet.Restrictions.getDefaultBuyerSecurityDepositAsPercent;
 import static bisq.core.trade.Trade.Phase.DEPOSIT_CONFIRMED;
diff --git a/apitest/src/test/java/bisq/apitest/scenario/ScriptedBotTest.java b/apitest/src/test/java/bisq/apitest/scenario/ScriptedBotTest.java
index fd187638803..98bb987ce47 100644
--- a/apitest/src/test/java/bisq/apitest/scenario/ScriptedBotTest.java
+++ b/apitest/src/test/java/bisq/apitest/scenario/ScriptedBotTest.java
@@ -29,22 +29,22 @@
 import org.junit.jupiter.api.condition.EnabledIf;
 
 import static bisq.apitest.Scaffold.BitcoinCoreApp.bitcoind;
+import static bisq.apitest.botsupport.shutdown.ManualShutdown.startShutdownTimer;
 import static bisq.apitest.config.BisqAppConfig.alicedaemon;
 import static bisq.apitest.config.BisqAppConfig.arbdaemon;
 import static bisq.apitest.config.BisqAppConfig.bobdaemon;
 import static bisq.apitest.config.BisqAppConfig.seednode;
-import static bisq.apitest.scenario.bot.shutdown.ManualShutdown.startShutdownTimer;
 import static org.junit.jupiter.api.Assertions.fail;
 
 
 
+import bisq.apitest.botsupport.BotClient;
+import bisq.apitest.botsupport.script.BashScriptGenerator;
+import bisq.apitest.botsupport.shutdown.ManualBotShutdownException;
 import bisq.apitest.config.ApiTestConfig;
 import bisq.apitest.method.BitcoinCliHelper;
 import bisq.apitest.scenario.bot.AbstractBotTest;
-import bisq.apitest.scenario.bot.BotClient;
 import bisq.apitest.scenario.bot.RobotBob;
-import bisq.apitest.scenario.bot.script.BashScriptGenerator;
-import bisq.apitest.scenario.bot.shutdown.ManualBotShutdownException;
 
 // The test case is enabled if AbstractBotTest#botScriptExists() returns true.
 @EnabledIf("botScriptExists")
@@ -76,14 +76,15 @@ public static void startTestHarness() {
             log.warn("Don't forget to register dispute agents before trying to trade with me.");
         }
 
-        botClient = new BotClient(bobClient);
+        makerBotClient = new BotClient(bobClient);
+        takerBotClient = new BotClient(aliceClient);
     }
 
     @BeforeEach
     public void initRobotBob() {
         try {
             BashScriptGenerator bashScriptGenerator = getBashScriptGenerator();
-            robotBob = new RobotBob(botClient, botScript, bitcoinCli, bashScriptGenerator);
+            robotBob = new RobotBob(makerBotClient, botScript, bitcoinCli, bashScriptGenerator);
         } catch (Exception ex) {
             fail(ex);
         }
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/AbstractBotTest.java b/apitest/src/test/java/bisq/apitest/scenario/bot/AbstractBotTest.java
index 763dbac9e2f..373ad50f3e6 100644
--- a/apitest/src/test/java/bisq/apitest/scenario/bot/AbstractBotTest.java
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/AbstractBotTest.java
@@ -30,6 +30,7 @@
 
 import lombok.extern.slf4j.Slf4j;
 
+import static bisq.apitest.config.BisqAppConfig.alicedaemon;
 import static bisq.core.locale.CountryUtil.findCountryByCode;
 import static bisq.core.payment.payload.PaymentMethod.CLEAR_X_CHANGE_ID;
 import static bisq.core.payment.payload.PaymentMethod.getPaymentMethodById;
@@ -39,8 +40,9 @@
 
 
 
+import bisq.apitest.botsupport.BotClient;
+import bisq.apitest.botsupport.script.BashScriptGenerator;
 import bisq.apitest.method.MethodTest;
-import bisq.apitest.scenario.bot.script.BashScriptGenerator;
 import bisq.apitest.scenario.bot.script.BotScript;
 
 @Slf4j
@@ -48,12 +50,17 @@ public abstract class AbstractBotTest extends MethodTest {
 
     protected static final String BOT_SCRIPT_NAME = "bot-script.json";
     protected static BotScript botScript;
-    protected static BotClient botClient;
+    protected static BotClient makerBotClient;
+    protected static BotClient takerBotClient;
 
     protected BashScriptGenerator getBashScriptGenerator() {
         if (botScript.isUseTestHarness()) {
-            PaymentAccount alicesAccount = createAlicesPaymentAccount();
-            botScript.setPaymentAccountIdForCliScripts(alicesAccount.getId());
+            if (config.supportingApps.contains(alicedaemon.name())) {
+                PaymentAccount alicesAccount = createAlicesPaymentAccount();
+                botScript.setPaymentAccountIdForCliScripts(alicesAccount.getId());
+            } else {
+                botScript.setPaymentAccountIdForCliScripts("Alice is using Desktop UI");
+            }
         }
         return new BashScriptGenerator(config.apiPassword,
                 botScript.getApiPortForCliScripts(),
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/Bot.java b/apitest/src/test/java/bisq/apitest/scenario/bot/Bot.java
index 2e8a248a4c3..cde1b831916 100644
--- a/apitest/src/test/java/bisq/apitest/scenario/bot/Bot.java
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/Bot.java
@@ -1,31 +1,32 @@
 package bisq.apitest.scenario.bot;
 
 import bisq.core.locale.Country;
+import bisq.core.locale.CountryUtil;
+import bisq.core.payment.payload.PaymentMethod;
 
 import protobuf.PaymentAccount;
 
 import lombok.extern.slf4j.Slf4j;
 
-import static bisq.core.locale.CountryUtil.findCountryByCode;
-import static bisq.core.payment.payload.PaymentMethod.CLEAR_X_CHANGE_ID;
-import static bisq.core.payment.payload.PaymentMethod.getPaymentMethodById;
-import static java.lang.String.format;
 import static java.util.concurrent.TimeUnit.MINUTES;
 
 
 
+import bisq.apitest.botsupport.BotClient;
+import bisq.apitest.botsupport.script.BashScriptGenerator;
 import bisq.apitest.method.BitcoinCliHelper;
-import bisq.apitest.scenario.bot.script.BashScriptGenerator;
 import bisq.apitest.scenario.bot.script.BotScript;
 
+// TODO Create a bot superclass in CLI, stripped of all the test harness references.
+// This not for the CLI, regtest/apitest only.
+
 @Slf4j
-public
-class Bot {
+public class Bot {
 
     static final String MAKE = "MAKE";
     static final String TAKE = "TAKE";
 
-    protected final BotClient botClient;
+    protected final BotClient makerBotClient;
     protected final BitcoinCliHelper bitcoinCli;
     protected final BashScriptGenerator bashScriptGenerator;
     protected final String[] actions;
@@ -38,7 +39,7 @@ public Bot(BotClient botClient,
                BotScript botScript,
                BitcoinCliHelper bitcoinCli,
                BashScriptGenerator bashScriptGenerator) {
-        this.botClient = botClient;
+        this.makerBotClient = botClient;
         this.bitcoinCli = bitcoinCli;
         this.bashScriptGenerator = bashScriptGenerator;
         this.actions = botScript.getActions();
@@ -48,30 +49,27 @@ public Bot(BotClient botClient,
         if (isUsingTestHarness)
             this.paymentAccount = createBotPaymentAccount(botScript);
         else
-            this.paymentAccount = botClient.getPaymentAccount(botScript.getPaymentAccountIdForBot());
+            this.paymentAccount = this.makerBotClient.getPaymentAccount(botScript.getPaymentAccountIdForBot());
     }
 
     private PaymentAccount createBotPaymentAccount(BotScript botScript) {
-        BotPaymentAccountGenerator accountGenerator = new BotPaymentAccountGenerator(botClient);
+        BotPaymentAccountGenerator accountGenerator = new BotPaymentAccountGenerator(makerBotClient);
 
         String paymentMethodId = botScript.getBotPaymentMethodId();
         if (paymentMethodId != null) {
-            if (paymentMethodId.equals(CLEAR_X_CHANGE_ID)) {
+            if (paymentMethodId.equals(PaymentMethod.CLEAR_X_CHANGE_ID)) {
                 return accountGenerator.createZellePaymentAccount("Bob's Zelle Account",
                         "Bob");
             } else {
                 throw new UnsupportedOperationException(
-                        format("This bot test does not work with %s payment accounts yet.",
-                                getPaymentMethodById(paymentMethodId).getDisplayString()));
+                        String.format("This bot test does not work with %s payment accounts yet.",
+                                PaymentMethod.getPaymentMethodById(paymentMethodId).getDisplayString()));
             }
         } else {
-            Country country = findCountry(botScript.getCountryCode());
+            String countryCode = botScript.getCountryCode();
+            Country country = CountryUtil.findCountryByCode(countryCode).orElseThrow(() ->
+                    new IllegalArgumentException(countryCode + " is not a valid iso country code."));
             return accountGenerator.createF2FPaymentAccount(country, country.name + " F2F Account");
         }
     }
-
-    private Country findCountry(String countryCode) {
-        return findCountryByCode(countryCode).orElseThrow(() ->
-                new IllegalArgumentException(countryCode + " is not a valid iso country code."));
-    }
 }
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/BotClient.java b/apitest/src/test/java/bisq/apitest/scenario/bot/BotClient.java
deleted file mode 100644
index 3334fa82e22..00000000000
--- a/apitest/src/test/java/bisq/apitest/scenario/bot/BotClient.java
+++ /dev/null
@@ -1,341 +0,0 @@
-/*
- * This file is part of Bisq.
- *
- * Bisq is free software: you can redistribute it and/or modify it
- * under the terms of the GNU Affero General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or (at
- * your option) any later version.
- *
- * Bisq is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
- * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
- * License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with Bisq. If not, see .
- */
-
-package bisq.apitest.scenario.bot;
-
-import bisq.proto.grpc.BalancesInfo;
-import bisq.proto.grpc.GetPaymentAccountsRequest;
-import bisq.proto.grpc.OfferInfo;
-import bisq.proto.grpc.TradeInfo;
-
-import protobuf.PaymentAccount;
-
-import java.text.DecimalFormat;
-
-import java.util.List;
-import java.util.function.BiPredicate;
-
-import lombok.extern.slf4j.Slf4j;
-
-import static org.apache.commons.lang3.StringUtils.capitalize;
-import static protobuf.OfferPayload.Direction.BUY;
-import static protobuf.OfferPayload.Direction.SELL;
-
-
-
-import bisq.cli.GrpcClient;
-
-/**
- * Convenience GrpcClient wrapper for bots using gRPC services.
- *
- * TODO Consider if the duplication smell is bad enough to force a BotClient user
- *  to use the GrpcClient instead (and delete this class).  But right now, I think it is
- *  OK because moving some of the non-gRPC related methods to GrpcClient is even smellier.
- *
- */
-@SuppressWarnings({"JavaDoc", "unused"})
-@Slf4j
-public class BotClient {
-
-    private static final DecimalFormat FIXED_PRICE_FMT = new DecimalFormat("###########0");
-
-    private final GrpcClient grpcClient;
-
-    public BotClient(GrpcClient grpcClient) {
-        this.grpcClient = grpcClient;
-    }
-
-    /**
-     * Returns current BSQ and BTC balance information.
-     * @return BalancesInfo
-     */
-    public BalancesInfo getBalance() {
-        return grpcClient.getBalances();
-    }
-
-    /**
-     * Return the most recent BTC market price for the given currencyCode.
-     * @param currencyCode
-     * @return double
-     */
-    public double getCurrentBTCMarketPrice(String currencyCode) {
-        return grpcClient.getBtcPrice(currencyCode);
-    }
-
-    /**
-     * Return the most recent BTC market price for the given currencyCode as an integer string.
-     * @param currencyCode
-     * @return String
-     */
-    public String getCurrentBTCMarketPriceAsIntegerString(String currencyCode) {
-        return FIXED_PRICE_FMT.format(getCurrentBTCMarketPrice(currencyCode));
-    }
-
-    /**
-     * Return all BUY and SELL offers for the given currencyCode.
-     * @param currencyCode
-     * @return List
-     */
-    public List getOffers(String currencyCode) {
-        var buyOffers = getBuyOffers(currencyCode);
-        if (buyOffers.size() > 0) {
-            return buyOffers;
-        } else {
-            return getSellOffers(currencyCode);
-        }
-    }
-
-    /**
-     * Return BUY offers for the given currencyCode.
-     * @param currencyCode
-     * @return List
-     */
-    public List getBuyOffers(String currencyCode) {
-        return grpcClient.getOffers(BUY.name(), currencyCode);
-    }
-
-    /**
-     * Return SELL offers for the given currencyCode.
-     * @param currencyCode
-     * @return List
-     */
-    public List getSellOffers(String currencyCode) {
-        return grpcClient.getOffers(SELL.name(), currencyCode);
-    }
-
-    /**
-     * Create and return a new Offer using a market based price.
-     * @param paymentAccount
-     * @param direction
-     * @param currencyCode
-     * @param amountInSatoshis
-     * @param minAmountInSatoshis
-     * @param priceMarginAsPercent
-     * @param securityDepositAsPercent
-     * @param feeCurrency
-     * @return OfferInfo
-     */
-    public OfferInfo createOfferAtMarketBasedPrice(PaymentAccount paymentAccount,
-                                                   String direction,
-                                                   String currencyCode,
-                                                   long amountInSatoshis,
-                                                   long minAmountInSatoshis,
-                                                   double priceMarginAsPercent,
-                                                   double securityDepositAsPercent,
-                                                   String feeCurrency) {
-        return grpcClient.createMarketBasedPricedOffer(direction,
-                currencyCode,
-                amountInSatoshis,
-                minAmountInSatoshis,
-                priceMarginAsPercent,
-                securityDepositAsPercent,
-                paymentAccount.getId(),
-                feeCurrency);
-    }
-
-    /**
-     * Create and return a new Offer using a fixed price.
-     * @param paymentAccount
-     * @param direction
-     * @param currencyCode
-     * @param amountInSatoshis
-     * @param minAmountInSatoshis
-     * @param fixedOfferPriceAsString
-     * @param securityDepositAsPercent
-     * @param feeCurrency
-     * @return OfferInfo
-     */
-    public OfferInfo createOfferAtFixedPrice(PaymentAccount paymentAccount,
-                                             String direction,
-                                             String currencyCode,
-                                             long amountInSatoshis,
-                                             long minAmountInSatoshis,
-                                             String fixedOfferPriceAsString,
-                                             double securityDepositAsPercent,
-                                             String feeCurrency) {
-        return grpcClient.createFixedPricedOffer(direction,
-                currencyCode,
-                amountInSatoshis,
-                minAmountInSatoshis,
-                fixedOfferPriceAsString,
-                securityDepositAsPercent,
-                paymentAccount.getId(),
-                feeCurrency);
-    }
-
-    public TradeInfo takeOffer(String offerId, PaymentAccount paymentAccount, String feeCurrency) {
-        return grpcClient.takeOffer(offerId, paymentAccount.getId(), feeCurrency);
-    }
-
-    /**
-     * Returns a persisted Trade with the given tradeId, or throws an exception.
-     * @param tradeId
-     * @return TradeInfo
-     */
-    public TradeInfo getTrade(String tradeId) {
-        return grpcClient.getTrade(tradeId);
-    }
-
-    /**
-     * Predicate returns true if the given exception indicates the trade with the given
-     * tradeId exists, but the trade's contract has not been fully prepared.
-     */
-    public final BiPredicate tradeContractIsNotReady = (exception, tradeId) -> {
-        if (exception.getMessage().contains("no contract was found")) {
-            log.warn("Trade {} exists but is not fully prepared: {}.",
-                    tradeId,
-                    toCleanGrpcExceptionMessage(exception));
-            return true;
-        } else {
-            return false;
-        }
-    };
-
-    /**
-     * Returns a trade's contract as a Json string, or null if the trade exists
-     * but the contract is not ready.
-     * @param tradeId
-     * @return String
-     */
-    public String getTradeContract(String tradeId) {
-        try {
-            var trade = grpcClient.getTrade(tradeId);
-            return trade.getContractAsJson();
-        } catch (Exception ex) {
-            if (tradeContractIsNotReady.test(ex, tradeId))
-                return null;
-            else
-                throw ex;
-        }
-    }
-
-    /**
-     * Returns true if the trade's taker deposit fee transaction has been published.
-     * @param tradeId a valid trade id
-     * @return boolean
-     */
-    public boolean isTakerDepositFeeTxPublished(String tradeId) {
-        return grpcClient.getTrade(tradeId).getIsPayoutPublished();
-    }
-
-    /**
-     * Returns true if the trade's taker deposit fee transaction has been confirmed.
-     * @param tradeId a valid trade id
-     * @return boolean
-     */
-    public boolean isTakerDepositFeeTxConfirmed(String tradeId) {
-        return grpcClient.getTrade(tradeId).getIsDepositConfirmed();
-    }
-
-    /**
-     * Returns true if the trade's 'start payment' message has been sent by the buyer.
-     * @param tradeId a valid trade id
-     * @return boolean
-     */
-    public boolean isTradePaymentStartedSent(String tradeId) {
-        return grpcClient.getTrade(tradeId).getIsFiatSent();
-    }
-
-    /**
-     * Returns true if the trade's 'payment received' message has been sent by the seller.
-     * @param tradeId a valid trade id
-     * @return boolean
-     */
-    public boolean isTradePaymentReceivedConfirmationSent(String tradeId) {
-        return grpcClient.getTrade(tradeId).getIsFiatReceived();
-    }
-
-    /**
-     * Returns true if the trade's payout transaction has been published.
-     * @param tradeId a valid trade id
-     * @return boolean
-     */
-    public boolean isTradePayoutTxPublished(String tradeId) {
-        return grpcClient.getTrade(tradeId).getIsPayoutPublished();
-    }
-
-    /**
-     * Sends a 'confirm payment started message' for a trade with the given tradeId,
-     * or throws an exception.
-     * @param tradeId
-     */
-    public void sendConfirmPaymentStartedMessage(String tradeId) {
-        grpcClient.confirmPaymentStarted(tradeId);
-    }
-
-    /**
-     * Sends a 'confirm payment received message' for a trade with the given tradeId,
-     * or throws an exception.
-     * @param tradeId
-     */
-    public void sendConfirmPaymentReceivedMessage(String tradeId) {
-        grpcClient.confirmPaymentReceived(tradeId);
-    }
-
-    /**
-     * Sends a 'keep funds in wallet message' for a trade with the given tradeId,
-     * or throws an exception.
-     * @param tradeId
-     */
-    public void sendKeepFundsMessage(String tradeId) {
-        grpcClient.keepFunds(tradeId);
-    }
-
-    /**
-     * Create and save a new PaymentAccount with details in the given json.
-     * @param json
-     * @return PaymentAccount
-     */
-    public PaymentAccount createNewPaymentAccount(String json) {
-        return grpcClient.createPaymentAccount(json);
-    }
-
-    /**
-     * Returns a persisted PaymentAccount with the given paymentAccountId, or throws
-     * an exception.
-     * @param paymentAccountId The id of the PaymentAccount being looked up.
-     * @return PaymentAccount
-     */
-    public PaymentAccount getPaymentAccount(String paymentAccountId) {
-        return grpcClient.getPaymentAccounts().stream()
-                .filter(a -> (a.getId().equals(paymentAccountId)))
-                .findFirst()
-                .orElseThrow(() ->
-                        new PaymentAccountNotFoundException("Could not find a payment account with id "
-                                + paymentAccountId + "."));
-    }
-
-    /**
-     * Returns a persisted PaymentAccount with the given accountName, or throws
-     * an exception.
-     * @param accountName
-     * @return PaymentAccount
-     */
-    public PaymentAccount getPaymentAccountWithName(String accountName) {
-        var req = GetPaymentAccountsRequest.newBuilder().build();
-        return grpcClient.getPaymentAccounts().stream()
-                .filter(a -> (a.getAccountName().equals(accountName)))
-                .findFirst()
-                .orElseThrow(() ->
-                        new PaymentAccountNotFoundException("Could not find a payment account with name "
-                                + accountName + "."));
-    }
-
-    public String toCleanGrpcExceptionMessage(Exception ex) {
-        return capitalize(ex.getMessage().replaceFirst("^[A-Z_]+: ", ""));
-    }
-}
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/BotPaymentAccountGenerator.java b/apitest/src/test/java/bisq/apitest/scenario/bot/BotPaymentAccountGenerator.java
index e586c3236af..9a6628e5e56 100644
--- a/apitest/src/test/java/bisq/apitest/scenario/bot/BotPaymentAccountGenerator.java
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/BotPaymentAccountGenerator.java
@@ -17,6 +17,12 @@
 import static bisq.core.payment.payload.PaymentMethod.CLEAR_X_CHANGE_ID;
 import static bisq.core.payment.payload.PaymentMethod.F2F_ID;
 
+
+
+import bisq.apitest.botsupport.BotClient;
+import bisq.apitest.botsupport.PaymentAccountNotFoundException;
+
+
 @Slf4j
 public class BotPaymentAccountGenerator {
 
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/MarketMakerBotTest.java b/apitest/src/test/java/bisq/apitest/scenario/bot/MarketMakerBotTest.java
new file mode 100644
index 00000000000..d3a7b584174
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/MarketMakerBotTest.java
@@ -0,0 +1,126 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see .
+ */
+
+package bisq.apitest.scenario.bot;
+
+import lombok.extern.slf4j.Slf4j;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+import org.junit.jupiter.api.condition.EnabledIf;
+
+import static bisq.apitest.Scaffold.BitcoinCoreApp.bitcoind;
+import static bisq.apitest.botsupport.shutdown.ManualShutdown.startShutdownTimer;
+import static bisq.apitest.config.BisqAppConfig.alicedaemon;
+import static bisq.apitest.config.BisqAppConfig.arbdaemon;
+import static bisq.apitest.config.BisqAppConfig.bobdaemon;
+import static bisq.apitest.config.BisqAppConfig.seednode;
+import static org.junit.jupiter.api.Assertions.fail;
+
+
+
+import bisq.apitest.botsupport.BotClient;
+import bisq.apitest.botsupport.script.BashScriptGenerator;
+import bisq.apitest.botsupport.shutdown.ManualBotShutdownException;
+
+@EnabledIf("botScriptExists")
+@Slf4j
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+public class MarketMakerBotTest extends AbstractBotTest {
+
+    private RobotBobMMBot robotBobMM;
+
+    @BeforeAll
+    public static void startTestHarness() {
+        botScript = deserializeBotScript();
+
+        startSupportingApps(true,
+                true,
+                bitcoind,
+                seednode,
+                arbdaemon,
+                alicedaemon,
+                bobdaemon);
+
+        makerBotClient = new BotClient(bobClient);
+        takerBotClient = new BotClient(aliceClient);
+    }
+
+    @BeforeEach
+    public void initRobotBob() {
+        try {
+            BashScriptGenerator bashScriptGenerator = getBashScriptGenerator();
+
+            robotBobMM = new RobotBobMMBot(makerBotClient,
+                    takerBotClient,
+                    botScript,
+                    bitcoinCli,
+                    bashScriptGenerator);
+        } catch (Exception ex) {
+            fail(ex);
+        }
+    }
+
+    @Test
+    @Order(1)
+    public void runRobotBob() {
+        try {
+            startShutdownTimer();
+
+            log.info("Bob's Initial Bank Balance: {}", robotBobMM.getBobsBankBalance().get());
+
+            robotBobMM.run();
+
+            // A controlled bot shutdown is always desired in the event of a fatal error.
+            // Check RobotBob's bot exception fields, and fail the test if one or more
+            // is not null.
+            if (robotBobMM.botDidFail())
+                fail(robotBobMM.getBotFailureReason());
+
+            log.info("Bob's Final Bank Balance: {}", robotBobMM.getBobsBankBalance().get());
+
+        } catch (ManualBotShutdownException ex) {
+            // This exception is thrown if a /tmp/bottest-shutdown file was found.
+            // You can also kill -15 
+            // of worker.org.gradle.process.internal.worker.GradleWorkerMain 'Gradle Test Executor #'
+            //
+            // This will cleanly shut everything down as well, but you will see a
+            // Process 'Gradle Test Executor #' finished with non-zero exit value 143 error,
+            // which you may think is a test failure.
+            log.warn("{}  Shutting down test case before test completion;"
+                            + "  this is not a test failure.",
+                    ex.getMessage());
+        } catch (Throwable t) {
+            if (robotBobMM.botDidFail()) {
+                fail(robotBobMM.getBotFailureReason());
+            } else {
+                log.error("Uncontrolled bot shutdown caused by uncaught bot exception:");
+                fail(t);
+            }
+        }
+    }
+
+    @AfterAll
+    public static void tearDown() {
+        tearDownScaffold();
+    }
+}
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/RandomOffer.java b/apitest/src/test/java/bisq/apitest/scenario/bot/RandomOffer.java
index 24524ac73a9..9ebd92ebc37 100644
--- a/apitest/src/test/java/bisq/apitest/scenario/bot/RandomOffer.java
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/RandomOffer.java
@@ -33,17 +33,19 @@
 import lombok.Getter;
 import lombok.extern.slf4j.Slf4j;
 
-import static bisq.apitest.method.MethodTest.BSQ;
-import static bisq.apitest.method.MethodTest.BTC;
 import static bisq.cli.CurrencyFormat.formatMarketPrice;
 import static bisq.cli.CurrencyFormat.formatSatoshis;
 import static bisq.common.util.MathUtils.scaleDownByPowerOf10;
 import static bisq.core.btc.wallet.Restrictions.getDefaultBuyerSecurityDepositAsPercent;
+import static bisq.core.offer.OfferPayload.Direction.BUY;
+import static bisq.core.offer.OfferPayload.Direction.SELL;
 import static bisq.core.payment.payload.PaymentMethod.F2F_ID;
 import static java.lang.String.format;
 import static java.math.RoundingMode.HALF_UP;
-import static protobuf.OfferPayload.Direction.BUY;
-import static protobuf.OfferPayload.Direction.SELL;
+
+
+
+import bisq.apitest.botsupport.BotClient;
 
 @Slf4j
 public class RandomOffer {
@@ -118,7 +120,7 @@ public RandomOffer(BotClient botClient, PaymentAccount paymentAccount) {
         this.minAmount = nextMinAmount.get();
         this.useMarketBasedPrice = RANDOM.nextBoolean();
         this.priceMargin = nextPriceMargin.get();
-        this.feeCurrency = RANDOM.nextBoolean() ? BSQ : BTC;
+        this.feeCurrency = RANDOM.nextBoolean() ? "BSQ" : "BTC";
     }
 
     public RandomOffer create() throws InvalidRandomOfferException {
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/RobotBob.java b/apitest/src/test/java/bisq/apitest/scenario/bot/RobotBob.java
index d81f385a2ba..bf335ff83b8 100644
--- a/apitest/src/test/java/bisq/apitest/scenario/bot/RobotBob.java
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/RobotBob.java
@@ -20,48 +20,49 @@
 import lombok.Getter;
 import lombok.extern.slf4j.Slf4j;
 
-import static bisq.apitest.scenario.bot.protocol.ProtocolStep.DONE;
-import static bisq.apitest.scenario.bot.shutdown.ManualShutdown.isShutdownCalled;
+import static bisq.apitest.botsupport.protocol.ProtocolStep.DONE;
+import static bisq.apitest.botsupport.shutdown.ManualShutdown.isShutdownCalled;
 import static bisq.cli.TableFormat.formatBalancesTbls;
 import static java.util.concurrent.TimeUnit.SECONDS;
 
 
 
+import bisq.apitest.botsupport.BotClient;
+import bisq.apitest.botsupport.script.BashScriptGenerator;
+import bisq.apitest.botsupport.shutdown.ManualBotShutdownException;
 import bisq.apitest.method.BitcoinCliHelper;
-import bisq.apitest.scenario.bot.protocol.BotProtocol;
-import bisq.apitest.scenario.bot.protocol.MakerBotProtocol;
-import bisq.apitest.scenario.bot.protocol.TakerBotProtocol;
-import bisq.apitest.scenario.bot.script.BashScriptGenerator;
+import bisq.apitest.scenario.bot.protocol.ApiTestBotProtocol;
+import bisq.apitest.scenario.bot.protocol.ApiTestMakerBotProtocol;
+import bisq.apitest.scenario.bot.protocol.ApiTestTakerBotProtocol;
 import bisq.apitest.scenario.bot.script.BotScript;
-import bisq.apitest.scenario.bot.shutdown.ManualBotShutdownException;
+
 
 @Slf4j
-public
-class RobotBob extends Bot {
+public class RobotBob extends Bot {
 
     @Getter
     private int numTrades;
 
-    public RobotBob(BotClient botClient,
+    public RobotBob(BotClient makerBotClient,
                     BotScript botScript,
                     BitcoinCliHelper bitcoinCli,
                     BashScriptGenerator bashScriptGenerator) {
-        super(botClient, botScript, bitcoinCli, bashScriptGenerator);
+        super(makerBotClient, botScript, bitcoinCli, bashScriptGenerator);
     }
 
     public void run() {
         for (String action : actions) {
             checkActionIsValid(action);
 
-            BotProtocol botProtocol;
+            ApiTestBotProtocol botProtocol;
             if (action.equalsIgnoreCase(MAKE)) {
-                botProtocol = new MakerBotProtocol(botClient,
+                botProtocol = new ApiTestMakerBotProtocol(makerBotClient,
                         paymentAccount,
                         protocolStepTimeLimitInMs,
                         bitcoinCli,
                         bashScriptGenerator);
             } else {
-                botProtocol = new TakerBotProtocol(botClient,
+                botProtocol = new ApiTestTakerBotProtocol(makerBotClient,
                         paymentAccount,
                         protocolStepTimeLimitInMs,
                         bitcoinCli,
@@ -77,7 +78,7 @@ public void run() {
             log.info("Completed {} successful trade{}.  Current Balance:\n{}",
                     ++numTrades,
                     numTrades == 1 ? "" : "s",
-                    formatBalancesTbls(botClient.getBalance()));
+                    formatBalancesTbls(makerBotClient.getBalance()));
 
             if (numTrades < actions.length) {
                 try {
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/RobotBobMMBot.java b/apitest/src/test/java/bisq/apitest/scenario/bot/RobotBobMMBot.java
new file mode 100644
index 00000000000..0cedc1b826f
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/RobotBobMMBot.java
@@ -0,0 +1,447 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see .
+ */
+
+package bisq.apitest.scenario.bot;
+
+import bisq.common.Timer;
+import bisq.common.UserThread;
+import bisq.common.util.Utilities;
+
+import protobuf.PaymentAccount;
+
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+
+import lombok.Getter;
+import lombok.Setter;
+import lombok.SneakyThrows;
+import lombok.extern.slf4j.Slf4j;
+
+import javax.annotation.Nullable;
+
+import static bisq.apitest.botsupport.shutdown.ManualShutdown.isShutdownCalled;
+import static bisq.apitest.botsupport.shutdown.ManualShutdown.setShutdownCalled;
+import static bisq.cli.TableFormat.formatBalancesTbls;
+import static bisq.core.offer.OfferPayload.Direction.BUY;
+import static bisq.core.offer.OfferPayload.Direction.SELL;
+import static java.lang.String.format;
+import static java.util.Objects.requireNonNull;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+
+
+import bisq.apitest.botsupport.BotClient;
+import bisq.apitest.botsupport.script.BashScriptGenerator;
+import bisq.apitest.botsupport.shutdown.ManualBotShutdownException;
+import bisq.apitest.method.BitcoinCliHelper;
+import bisq.apitest.scenario.bot.protocol.ApiTestBotProtocol;
+import bisq.apitest.scenario.bot.protocol.ApiTestMarketMakerBotProtocol;
+import bisq.apitest.scenario.bot.protocol.MarketMakerTakeOnlyBotProtocol;
+import bisq.apitest.scenario.bot.script.BotScript;
+
+
+@SuppressWarnings("NullableProblems")
+@Slf4j
+public class RobotBobMMBot extends Bot {
+
+    private static final int MAX_BUY_OFFERS = 500;
+    private static final int MAX_SELL_OFFERS = 500;
+
+    // Show wallet balances after every N trades.
+    private static final int SHOW_WALLET_BALANCE_MARKER = 20;
+
+    private static final String BUYER_BOT_NAME = "Maker/Buyer Bot";
+    private static final String SELLER_BOT_NAME = "Maker/Seller Bot";
+    private static final String TAKER_BOT_NAME = "Taker Bot";
+
+    @Nullable
+    @Setter
+    @Getter
+    private Exception buyerBotException;
+    @Nullable
+    @Setter
+    @Getter
+    private Exception sellerBotException;
+    @Nullable
+    @Setter
+    @Getter
+    private Exception takerBotException;
+
+    private final AtomicBoolean isBuyBotShutdown = new AtomicBoolean(false);
+    private final AtomicBoolean isSellBotShutdown = new AtomicBoolean(false);
+    private final AtomicBoolean isTakerBotShutdown = new AtomicBoolean(false);
+
+    private int numMakerSideBuys = 0;
+    private int numMakerSideSells = 0;
+    private int numTakerSideTrades = 0;
+
+    private Timer btcBlockGenerator;
+
+    @Getter
+    private final AtomicLong bobsBankBalance = new AtomicLong(100_000);
+    @Getter
+    private final AtomicLong takersBankBalance = new AtomicLong(100_000);
+
+    private final BotClient takerBotClient;
+    @Getter
+    private final String takerBotPaymentAccountId;
+
+    private final ListeningExecutorService executor =
+            Utilities.getListeningExecutorService("Market Maker",
+                    3,
+                    3,
+                    24 * 60 * 60);
+
+    public RobotBobMMBot(BotClient makerBotClient,
+                         BotClient takerBotClient,
+                         BotScript botScript,
+                         BitcoinCliHelper bitcoinCli,
+                         BashScriptGenerator bashScriptGenerator) {
+        super(makerBotClient, botScript, bitcoinCli, bashScriptGenerator);
+        this.takerBotClient = takerBotClient;
+        this.takerBotPaymentAccountId = botScript.getPaymentAccountIdForCliScripts();
+    }
+
+    public void run() {
+        btcBlockGenerator = UserThread.runPeriodically(() -> {
+            String btcCoreAddress = bitcoinCli.getNewBtcAddress();
+            log.info("Generating BTC block to address {}.", btcCoreAddress);
+            bitcoinCli.generateToAddress(1, btcCoreAddress);
+        }, 20, SECONDS);
+
+        startBot(buyMakerBot,
+                makerBotClient,
+                BUYER_BOT_NAME);
+        rest(15);
+
+        // Do not start another bot if the 1st one is already shutting down.
+        if (!isShutdownCalled()) {
+            startBot(takerBot,
+                    takerBotClient,
+                    TAKER_BOT_NAME);
+            rest(5);
+        }
+
+        if (!isShutdownCalled()) {
+            startBot(sellMakerBot,
+                    makerBotClient,
+                    SELLER_BOT_NAME);
+        }
+
+        if (stayAlive)
+            waitForManualShutdown();
+        else
+            warnCLIUserBeforeShutdown();
+    }
+
+    public void shutdownAllBots() {
+        isBuyBotShutdown.set(true);
+        isSellBotShutdown.set(true);
+        isTakerBotShutdown.set(true);
+        setShutdownCalled(true);
+        btcBlockGenerator.stop();
+        executor.shutdownNow();
+    }
+
+    public void startBot(Consumer bot,
+                         BotClient botClient,
+                         String botName) {
+        try {
+            log.info("Starting {}", botName);
+            @SuppressWarnings({"unchecked"})
+            ListenableFuture future =
+                    (ListenableFuture) executor.submit(() -> bot.accept(botClient));
+            Futures.addCallback(future, new FutureCallback<>() {
+                @Override
+                public void onSuccess(@Nullable Void ignored) {
+                    // 'Success' means a controlled shutdown that might be caused by an
+                    // error.  The test case should only fail if the shutdown was caused
+                    // by and exception.
+                    log.info("{} shutdown.", botName);
+                }
+
+                @SneakyThrows
+                @Override
+                public void onFailure(Throwable t) {
+                    if (t instanceof ManualBotShutdownException) {
+                        log.warn("Manually shutting down {} thread.", botName);
+                    } else {
+                        log.error("Fatal error during {} run.", botName, t);
+                    }
+                    shutdownAllBots();
+                }
+            }, MoreExecutors.directExecutor());
+
+        } catch (Exception ex) {
+            log.error("", ex);
+            throw new IllegalStateException(format("Error starting %s.", botName), ex);
+        }
+    }
+
+    public final Predicate shouldLogWalletBalance = (i) -> i % SHOW_WALLET_BALANCE_MARKER == 0;
+
+    public final Consumer buyMakerBot = (botClient) -> {
+        try {
+            while (numMakerSideBuys < MAX_BUY_OFFERS) {
+                var offersExist = botClient.iHaveCurrentOffersWithDirection.test(BUY.name(),
+                        requireNonNull(paymentAccount.getSelectedTradeCurrency()).getCode());
+                if (offersExist) {
+                    logOfferAlreadyExistsWarning(BUYER_BOT_NAME, BUY.name());
+                } else {
+                    ApiTestBotProtocol botProtocol = new ApiTestMarketMakerBotProtocol(botClient,
+                            paymentAccount,
+                            protocolStepTimeLimitInMs,
+                            bitcoinCli,
+                            bashScriptGenerator,
+                            BUY.name(),
+                            bobsBankBalance);
+                    botProtocol.run();
+                    numMakerSideBuys++;
+                    logTradingProgress(BUYER_BOT_NAME, BUY.name(), bobsBankBalance);
+                    if (shouldLogWalletBalance.test(numMakerSideBuys))
+                        logWalletBalance(BUYER_BOT_NAME, botClient);
+                }
+                rest(20);
+            }
+        } catch (ManualBotShutdownException ex) {
+            logManualShutdownWarning(BUYER_BOT_NAME);
+            shutdownAllBots();
+            // Exit the function, do not try to get balances below because the
+            // server may be shutting down.
+            return;
+        } catch (Exception ex) {
+            logFailedTradeError(BUYER_BOT_NAME, ex);
+            shutdownAllBots();
+            // Fatal error, do not try to get balances below because server is shutting down.
+            this.setBuyerBotException(ex);
+            return;
+        }
+        logBotCompletion(BUYER_BOT_NAME, botClient, bobsBankBalance);
+        isBuyBotShutdown.set(true);
+    };
+
+    public final Consumer sellMakerBot = (botClient) -> {
+        try {
+            while (numMakerSideSells < MAX_SELL_OFFERS) {
+                var offersExist = botClient.iHaveCurrentOffersWithDirection.test(SELL.name(),
+                        requireNonNull(paymentAccount.getSelectedTradeCurrency()).getCode());
+                if (offersExist) {
+                    logOfferAlreadyExistsWarning(SELLER_BOT_NAME, SELL.name());
+                } else {
+                    ApiTestBotProtocol botProtocol = new ApiTestMarketMakerBotProtocol(botClient,
+                            paymentAccount,
+                            protocolStepTimeLimitInMs,
+                            bitcoinCli,
+                            bashScriptGenerator,
+                            SELL.name(),
+                            bobsBankBalance);
+                    botProtocol.run();
+                    numMakerSideSells++;
+                    logTradingProgress(SELLER_BOT_NAME, SELL.name(), bobsBankBalance);
+                    if (shouldLogWalletBalance.test(numMakerSideSells))
+                        logWalletBalance(SELLER_BOT_NAME, botClient);
+                }
+                rest(20);
+            }
+        } catch (ManualBotShutdownException ex) {
+            logManualShutdownWarning(SELLER_BOT_NAME);
+            shutdownAllBots();
+            // Exit the function, do not try to get balances below because the
+            // server may be shutting down.
+            return;
+        } catch (Exception ex) {
+            logFailedTradeError(SELLER_BOT_NAME, ex);
+            shutdownAllBots();
+            // Fatal error, do not try to get balances below because server is shutting down.
+            this.setSellerBotException(ex);
+            return;
+        }
+        logBotCompletion(SELLER_BOT_NAME, botClient, bobsBankBalance);
+        isSellBotShutdown.set(true);
+    };
+
+    public final Consumer takerBot = (botClient) -> {
+        PaymentAccount takerPaymentAccount = botClient.getPaymentAccount(this.getTakerBotPaymentAccountId());
+        // Keep taking offers until max offers is reached, or if any maker bot is running.
+        while (takerShouldStayAlive()) {
+            try {
+                ApiTestBotProtocol botProtocol = new MarketMakerTakeOnlyBotProtocol(botClient,
+                        takerPaymentAccount,
+                        protocolStepTimeLimitInMs,
+                        bitcoinCli,
+                        bashScriptGenerator,
+                        takersBankBalance);
+                botProtocol.run();
+                numTakerSideTrades++;
+            } catch (ManualBotShutdownException ex) {
+                logManualShutdownWarning(TAKER_BOT_NAME);
+                shutdownAllBots();
+                // Exit the function, do not try to get balances below because the
+                // server may be shutting down.
+                return;
+            } catch (Exception ex) {
+                logFailedTradeError(TAKER_BOT_NAME, ex);
+                shutdownAllBots();
+                // Fatal error, do not try to get balances below because server is shutting down.
+                this.setTakerBotException(ex);
+                return;
+            }
+            logTradingProgress(TAKER_BOT_NAME, null, takersBankBalance);
+            if (shouldLogWalletBalance.test(numTakerSideTrades))
+                logWalletBalance(TAKER_BOT_NAME, botClient);
+
+            rest(20);
+        }
+        logBotCompletion(TAKER_BOT_NAME, botClient, takersBankBalance);
+        isTakerBotShutdown.set(true);
+    };
+
+    public boolean takerShouldStayAlive() {
+        if (numTakerSideTrades >= (MAX_BUY_OFFERS + MAX_SELL_OFFERS))
+            return false;
+
+        if (isTakerBotShutdown.get())
+            return false;
+
+        return !isBuyBotShutdown.get() || !isSellBotShutdown.get();
+    }
+
+    public boolean botDidFail() {
+        return buyerBotException != null || sellerBotException != null || takerBotException != null;
+    }
+
+    public String getBotFailureReason() {
+        StringBuilder reasonBuilder = new StringBuilder();
+
+        if (buyerBotException != null)
+            reasonBuilder.append(BUYER_BOT_NAME).append(" failed: ")
+                    .append(buyerBotException.getMessage()).append("\n");
+
+        if (sellerBotException != null)
+            reasonBuilder.append(SELLER_BOT_NAME).append(" failed: ")
+                    .append(sellerBotException.getMessage()).append("\n");
+
+        if (takerBotException != null)
+            reasonBuilder.append(TAKER_BOT_NAME).append(" failed: ")
+                    .append(takerBotException.getMessage()).append("\n");
+
+        return reasonBuilder.toString();
+    }
+
+    protected void waitForManualShutdown() {
+        String harnessOrCase = isUsingTestHarness ? "harness" : "case";
+        log.info("The test {} will stay alive until a /tmp/bottest-shutdown file is detected.",
+                harnessOrCase);
+        log.info("When ready to shutdown the test {}, run '$ touch /tmp/bottest-shutdown'.",
+                harnessOrCase);
+        if (!isUsingTestHarness) {
+            log.warn("You will have to manually shutdown the bitcoind and Bisq nodes"
+                    + " running outside of the test harness.");
+        }
+        try {
+            while (!isShutdownCalled()) {
+                rest(10);
+            }
+            log.warn("Manual shutdown signal received.");
+        } catch (ManualBotShutdownException ex) {
+            log.warn(ex.getMessage());
+        } finally {
+            btcBlockGenerator.stop();
+        }
+    }
+
+    protected void warnCLIUserBeforeShutdown() {
+        if (isUsingTestHarness) {
+            while (!isBuyBotShutdown.get() || !isSellBotShutdown.get() || !isTakerBotShutdown.get()) {
+                try {
+                    SECONDS.sleep(5);
+                } catch (InterruptedException ignored) {
+                    // empty
+                }
+            }
+            long delayInSeconds = 5;
+            log.warn("You have {} seconds to complete any remaining tasks before the test harness shuts down.",
+                    delayInSeconds);
+            rest(delayInSeconds);
+        } else {
+            log.info("Shutting down test case");
+        }
+        btcBlockGenerator.stop();
+    }
+
+    protected void rest(long delayInSeconds) {
+        try {
+            SECONDS.sleep(delayInSeconds);
+        } catch (InterruptedException ignored) {
+            // empty
+        }
+    }
+
+    protected void logOfferAlreadyExistsWarning(String botName, String direction) {
+        log.warn("{} will not create a new {} while existing offer is waiting to be taken.",
+                botName,
+                direction);
+    }
+
+    protected void logTradingProgress(String botName, String direction, AtomicLong bankBalance) {
+        log.info("===================================================================================================");
+        if (direction == null || direction.isEmpty())
+            log.info("{} completed {} trades.  Bank Balance: {}",
+                    botName,
+                    numTakerSideTrades,
+                    bankBalance.get());
+        else
+            log.info("{} completed {} {} trades.  Bank Balance After {} BUY and {} SELL trades: {}",
+                    botName,
+                    direction.equals(BUY.name()) ? numMakerSideBuys : numMakerSideSells,
+                    direction,
+                    numMakerSideBuys,
+                    numMakerSideSells,
+                    bankBalance.get());
+        log.info("===================================================================================================");
+    }
+
+    protected void logManualShutdownWarning(String botName) {
+        log.warn("Manual shutdown called, stopping {}.", botName);
+    }
+
+    protected void logFailedTradeError(String botName, Exception exception) {
+        log.error("{} could not complete trade # {}.",
+                botName,
+                numTakerSideTrades,
+                exception);
+    }
+
+    protected void logBotCompletion(String botName, BotClient botClient, AtomicLong bankBalance) {
+        log.info("{} is done.  Balances:\n{}\nBank Account Balance: {}",
+                botName,
+                formatBalancesTbls(botClient.getBalance()),
+                bankBalance.get());
+    }
+
+    protected void logWalletBalance(String botName, BotClient botClient) {
+        log.info("{} balances:\n{}", botName, formatBalancesTbls(botClient.getBalance()));
+    }
+}
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ApiTestBotProtocol.java b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ApiTestBotProtocol.java
new file mode 100644
index 00000000000..a482d9358bb
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ApiTestBotProtocol.java
@@ -0,0 +1,88 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see .
+ */
+
+package bisq.apitest.scenario.bot.protocol;
+
+
+import protobuf.PaymentAccount;
+
+import java.text.DecimalFormat;
+
+import java.io.File;
+
+import java.math.RoundingMode;
+
+import static bisq.apitest.botsupport.protocol.ProtocolStep.WAIT_FOR_TAKER_DEPOSIT_TX_CONFIRMED;
+import static java.lang.Long.parseLong;
+
+
+
+import bisq.apitest.botsupport.BotClient;
+import bisq.apitest.botsupport.protocol.BotProtocol;
+import bisq.apitest.botsupport.script.BashScriptGenerator;
+import bisq.apitest.method.BitcoinCliHelper;
+
+public abstract class ApiTestBotProtocol extends BotProtocol {
+
+    // Don't use @Slf4j annotation to init log and use in Functions w/out IDE warnings.
+    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(ApiTestBotProtocol.class);
+
+    // Used to show user how to run regtest bitcoin-cli commands.
+    protected final BitcoinCliHelper bitcoinCli;
+
+    public ApiTestBotProtocol(String botDescription,
+                              BotClient botClient,
+                              PaymentAccount paymentAccount,
+                              long protocolStepTimeLimitInMs,
+                              BitcoinCliHelper bitcoinCli,
+                              BashScriptGenerator bashScriptGenerator) {
+        super(botDescription,
+                botClient,
+                paymentAccount,
+                protocolStepTimeLimitInMs,
+                bashScriptGenerator);
+        this.bitcoinCli = bitcoinCli;
+    }
+
+    @Override
+    protected void printBotProtocolStep() {
+        super.printBotProtocolStep();
+        if (currentProtocolStep.equals(WAIT_FOR_TAKER_DEPOSIT_TX_CONFIRMED)) {
+            log.info("Generate a btc block to trigger taker's deposit fee tx confirmation.");
+            createGenerateBtcBlockScript();
+        }
+    }
+
+    @Override
+    protected void printCliHintAndOrScript(File script, String hint) {
+        super.printCliHintAndOrScript(script, hint);
+        sleep(5000); // Allow 5s for CLI user to read the hint.
+    }
+
+    protected void createGenerateBtcBlockScript() {
+        String newBitcoinCoreAddress = bitcoinCli.getNewBtcAddress();
+        File script = bashScriptGenerator.createGenerateBtcBlockScript(newBitcoinCoreAddress);
+        printCliHintAndOrScript(script, "The manual CLI side can generate 1 btc block");
+    }
+
+    public static long toDollars(long volume) {
+        DecimalFormat df = new DecimalFormat("#########");
+        df.setMaximumFractionDigits(0);
+        df.setRoundingMode(RoundingMode.UNNECESSARY);
+        return parseLong(df.format((double) volume / 10000));
+    }
+}
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/MakerBotProtocol.java b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ApiTestMakerBotProtocol.java
similarity index 73%
rename from apitest/src/test/java/bisq/apitest/scenario/bot/protocol/MakerBotProtocol.java
rename to apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ApiTestMakerBotProtocol.java
index ced6391efd0..2c56e995496 100644
--- a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/MakerBotProtocol.java
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ApiTestMakerBotProtocol.java
@@ -11,33 +11,36 @@
 import java.util.function.Function;
 import java.util.function.Supplier;
 
-import lombok.extern.slf4j.Slf4j;
-
-import static bisq.apitest.scenario.bot.protocol.ProtocolStep.DONE;
-import static bisq.apitest.scenario.bot.protocol.ProtocolStep.WAIT_FOR_OFFER_TAKER;
-import static bisq.apitest.scenario.bot.shutdown.ManualShutdown.checkIfShutdownCalled;
+import static bisq.apitest.botsupport.protocol.ProtocolStep.DONE;
+import static bisq.apitest.botsupport.protocol.ProtocolStep.WAIT_FOR_OFFER_TAKER;
+import static bisq.apitest.botsupport.shutdown.ManualShutdown.checkIfShutdownCalled;
 import static bisq.cli.TableFormat.formatOfferTable;
+import static bisq.core.offer.OfferPayload.Direction.BUY;
 import static java.util.Collections.singletonList;
-import static protobuf.OfferPayload.Direction.BUY;
 
 
 
+import bisq.apitest.botsupport.BotClient;
+import bisq.apitest.botsupport.protocol.MakerBotProtocol;
+import bisq.apitest.botsupport.script.BashScriptGenerator;
+import bisq.apitest.botsupport.shutdown.ManualBotShutdownException;
 import bisq.apitest.method.BitcoinCliHelper;
-import bisq.apitest.scenario.bot.BotClient;
 import bisq.apitest.scenario.bot.RandomOffer;
-import bisq.apitest.scenario.bot.script.BashScriptGenerator;
-import bisq.apitest.scenario.bot.shutdown.ManualBotShutdownException;
 import bisq.cli.TradeFormat;
 
-@Slf4j
-public class MakerBotProtocol extends BotProtocol {
 
-    public MakerBotProtocol(BotClient botClient,
-                            PaymentAccount paymentAccount,
-                            long protocolStepTimeLimitInMs,
-                            BitcoinCliHelper bitcoinCli,
-                            BashScriptGenerator bashScriptGenerator) {
-        super(botClient,
+public class ApiTestMakerBotProtocol extends ApiTestBotProtocol implements MakerBotProtocol {
+
+    // Don't use @Slf4j annotation to init log and use in Functions w/out IDE warnings.
+    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(ApiTestMakerBotProtocol.class);
+
+    public ApiTestMakerBotProtocol(BotClient botClient,
+                                   PaymentAccount paymentAccount,
+                                   long protocolStepTimeLimitInMs,
+                                   BitcoinCliHelper bitcoinCli,
+                                   BashScriptGenerator bashScriptGenerator) {
+        super("Maker",
+                botClient,
                 paymentAccount,
                 protocolStepTimeLimitInMs,
                 bitcoinCli,
@@ -75,7 +78,7 @@ public void run() {
         OfferInfo offer = randomOffer.get();
         createTakeOfferCliScript(offer);
         try {
-            log.info("Impatiently waiting for offer {} to be taken, repeatedly calling gettrade.", offer.getId());
+            log.info("Waiting for offer {} to be taken.", offer.getId());
             while (isWithinProtocolStepTimeLimit()) {
                 checkIfShutdownCalled("Interrupted while waiting for offer to be taken.");
                 try {
@@ -83,7 +86,7 @@ public void run() {
                     if (trade.isPresent())
                         return trade.get();
                     else
-                        sleep(randomDelay.get());
+                        sleep(shortRandomDelayInSeconds.get());
                 } catch (Exception ex) {
                     throw new IllegalStateException(this.getBotClient().toCleanGrpcExceptionMessage(ex), ex);
                 }
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ApiTestMarketMakerBotProtocol.java b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ApiTestMarketMakerBotProtocol.java
new file mode 100644
index 00000000000..6f3598b028d
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ApiTestMarketMakerBotProtocol.java
@@ -0,0 +1,216 @@
+package bisq.apitest.scenario.bot.protocol;
+
+import bisq.proto.grpc.OfferInfo;
+import bisq.proto.grpc.TradeInfo;
+
+import protobuf.PaymentAccount;
+
+import java.io.File;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import static bisq.apitest.botsupport.protocol.ProtocolStep.DONE;
+import static bisq.apitest.botsupport.protocol.ProtocolStep.WAIT_FOR_OFFER_TAKER;
+import static bisq.apitest.botsupport.shutdown.ManualShutdown.checkIfShutdownCalled;
+import static bisq.cli.CurrencyFormat.formatPrice;
+import static bisq.cli.TableFormat.formatOfferTable;
+import static bisq.core.offer.OfferPayload.Direction.BUY;
+import static bisq.core.offer.OfferPayload.Direction.SELL;
+import static java.lang.String.format;
+import static java.util.Collections.singletonList;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+
+
+import bisq.apitest.botsupport.BotClient;
+import bisq.apitest.botsupport.protocol.MakerBotProtocol;
+import bisq.apitest.botsupport.script.BashScriptGenerator;
+import bisq.apitest.method.BitcoinCliHelper;
+import bisq.cli.TradeFormat;
+
+// TODO Create a MarketMakerBotProtocol in CLI, stripped of all the test harness references.
+// This not for the CLI, regtest/apitest only.
+
+public class ApiTestMarketMakerBotProtocol extends ApiTestBotProtocol implements MakerBotProtocol {
+
+    // Don't use @Slf4j annotation to init log and use in Functions w/out IDE warnings.
+    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(ApiTestMarketMakerBotProtocol.class);
+
+    private static final int MAX_CREATE_OFFER_FAILURES = 3;
+
+    static final double PRICE_MARGIN = 6.50; // Target spread is 13%.
+
+    private final String direction;
+    private final AtomicLong bobsBankBalance;
+
+    public ApiTestMarketMakerBotProtocol(BotClient botClient,
+                                         PaymentAccount paymentAccount,
+                                         long protocolStepTimeLimitInMs,
+                                         BitcoinCliHelper bitcoinCli,
+                                         BashScriptGenerator bashScriptGenerator,
+                                         String direction,
+                                         AtomicLong bobsBankBalance) {
+        super("Maker",
+                botClient,
+                paymentAccount,
+                protocolStepTimeLimitInMs,
+                bitcoinCli,
+                bashScriptGenerator);
+        this.direction = direction;
+        this.bobsBankBalance = bobsBankBalance;
+    }
+
+    @Override
+    public void run() {
+        checkIsStartStep();
+
+        var isBuy = direction.equalsIgnoreCase(BUY.name());
+
+        Function, TradeInfo> makeTrade = waitForNewTrade.andThen(waitForTakerFeeTxConfirm);
+        var trade = isBuy
+                ? makeTrade.apply(createBuyOffer)
+                : makeTrade.apply(createSellOffer);
+
+        var makerIsBuyer = trade.getOffer().getDirection().equalsIgnoreCase(BUY.name());
+        Function completeFiatTransaction = makerIsBuyer
+                ? sendPaymentStartedMessage.andThen(waitForPaymentReceivedConfirmation)
+                : waitForPaymentStartedMessage.andThen(sendPaymentReceivedMessage);
+        completeFiatTransaction.apply(trade);
+
+        Function closeTrade = waitForPayoutTx.andThen(keepFundsFromTrade);
+        closeTrade.apply(trade);
+
+        long bankBalanceDelta = isBuy
+                ? -1 * toDollars(trade.getOffer().getVolume())
+                : toDollars(trade.getOffer().getVolume());
+        bobsBankBalance.addAndGet(bankBalanceDelta);
+
+        currentProtocolStep = DONE;
+    }
+
+    private final Supplier createBuyOffer = () -> {
+        checkIfShutdownCalled("Interrupted before creating random BUY offer.");
+        for (int i = 0; i < MAX_CREATE_OFFER_FAILURES; i++) {
+            try {
+                var offer = botClient.createOfferAtMarketBasedPrice(paymentAccount,
+                        BUY.name(),
+                        currencyCode,
+                        2_500_000,
+                        2_500_000,
+                        PRICE_MARGIN,
+                        0.15,
+                        BSQ);
+                log.info("Created BUY / {} offer at {}% below current market price of {}:\n{}",
+                        currencyCode,
+                        PRICE_MARGIN,
+                        botClient.getCurrentBTCMarketPriceAsString(currencyCode),
+                        formatOfferTable(singletonList(offer), currencyCode));
+                log.warn(">>>>> NEW BUY  OFFER {} PRICE: {} =~ {}",
+                        offer.getId(),
+                        offer.getPrice(),
+                        formatPrice(offer.getPrice()));
+                return offer;
+            } catch (Exception ex) {
+                log.error("Failed to create offer at attempt #{}.", i, ex);
+                try {
+                    SECONDS.sleep(5);
+                } catch (InterruptedException interruptedException) {
+                }
+            }
+        }
+        throw new IllegalStateException(format("%s could not create offer after 3 attempts.", botDescription));
+    };
+
+    private final Supplier createSellOffer = () -> {
+        checkIfShutdownCalled("Interrupted before creating random SELL offer.");
+        for (int i = 0; i < MAX_CREATE_OFFER_FAILURES; i++) {
+            try {
+                var offer = botClient.createOfferAtMarketBasedPrice(paymentAccount,
+                        SELL.name(),
+                        currencyCode,
+                        2_500_000,
+                        2_500_000,
+                        PRICE_MARGIN,
+                        0.15,
+                        BSQ);
+                log.info("Created SELL / {} offer at {}% above current market price of {}:\n{}",
+                        currencyCode,
+                        PRICE_MARGIN,
+                        botClient.getCurrentBTCMarketPriceAsString(currencyCode),
+                        formatOfferTable(singletonList(offer), currencyCode));
+                log.warn(">>>>> NEW SELL OFFER {} PRICE: {} =~ {}",
+                        offer.getId(),
+                        offer.getPrice(),
+                        formatPrice(offer.getPrice()));
+                return offer;
+            } catch (Exception ex) {
+                log.error("Failed to create offer at attempt #{}.", i, ex);
+                try {
+                    SECONDS.sleep(5);
+                } catch (InterruptedException interruptedException) {
+                }
+            }
+        }
+        throw new IllegalStateException(format("%s could not create offer after 3 attempts.", botDescription));
+    };
+
+    private final Function, TradeInfo> waitForNewTrade = (latestOffer) -> {
+        initProtocolStep.accept(WAIT_FOR_OFFER_TAKER);
+        OfferInfo offer = latestOffer.get();
+        createTakeOfferCliScript(offer);
+        log.info("Waiting for offer {} to be taken.", offer.getId());
+        int numDelays = 0;
+        while (isWithinProtocolStepTimeLimit()) {
+            checkIfShutdownCalled("Interrupted while waiting for offer to be taken.");
+            try {
+                var trade = getNewTrade(offer.getId());
+                if (trade.isPresent()) {
+                    return trade.get();
+                } else {
+                    if (++numDelays % 5 == 0) {
+                        log.warn("Offer {} still waiting to be taken, current state = {}",
+                                offer.getId(), offer.getState());
+                        String offerCounterCurrencyCode = offer.getCounterCurrencyCode();
+                        List myCurrentOffers = botClient.getMyOffersSortedByDate(offerCounterCurrencyCode);
+                        if (myCurrentOffers.isEmpty()) {
+                            log.warn("{} has no current offers at this time, but offer {} should exist.",
+                                    botDescription,
+                                    offer.getId());
+                        } else {
+                            log.info("{}'s current offers {} is in the list, or fail):\n{}",
+                                    botDescription,
+                                    offer.getId(),
+                                    formatOfferTable(myCurrentOffers, offerCounterCurrencyCode));
+                        }
+                    }
+                    sleep(shortRandomDelayInSeconds.get());
+                }
+            } catch (Exception ex) {
+                throw new IllegalStateException(botClient.toCleanGrpcExceptionMessage(ex), ex);
+            }
+        } // end while
+
+        // If the while loop is exhausted, the offer was not taken within the protocol step time limit.
+        throw new IllegalStateException("Offer was never taken; we won't wait any longer.");
+    };
+
+    private Optional getNewTrade(String offerId) {
+        try {
+            var trade = botClient.getTrade(offerId);
+            log.info("Offer {} was taken, new trade:\n{}", offerId, TradeFormat.format(trade));
+            return Optional.of(trade);
+        } catch (Exception ex) {
+            return Optional.empty();
+        }
+    }
+
+    private void createTakeOfferCliScript(OfferInfo offer) {
+        String scriptFilename = "takeoffer-" + offer.getId() + ".sh";
+        File script = bashScriptGenerator.createTakeOfferScript(offer, scriptFilename);
+        printCliHintAndOrScript(script, "The manual CLI side can take the offer");
+    }
+}
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/TakerBotProtocol.java b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ApiTestTakerBotProtocol.java
similarity index 72%
rename from apitest/src/test/java/bisq/apitest/scenario/bot/protocol/TakerBotProtocol.java
rename to apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ApiTestTakerBotProtocol.java
index ff191b7a458..e58d0757c27 100644
--- a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/TakerBotProtocol.java
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ApiTestTakerBotProtocol.java
@@ -11,35 +11,36 @@
 import java.util.function.Function;
 import java.util.function.Supplier;
 
-import lombok.extern.slf4j.Slf4j;
-
-import static bisq.apitest.method.MethodTest.BSQ;
-import static bisq.apitest.method.MethodTest.BTC;
-import static bisq.apitest.scenario.bot.protocol.ProtocolStep.DONE;
-import static bisq.apitest.scenario.bot.protocol.ProtocolStep.FIND_OFFER;
-import static bisq.apitest.scenario.bot.protocol.ProtocolStep.TAKE_OFFER;
-import static bisq.apitest.scenario.bot.shutdown.ManualShutdown.checkIfShutdownCalled;
+import static bisq.apitest.botsupport.protocol.ProtocolStep.DONE;
+import static bisq.apitest.botsupport.protocol.ProtocolStep.FIND_OFFER;
+import static bisq.apitest.botsupport.protocol.ProtocolStep.TAKE_OFFER;
+import static bisq.apitest.botsupport.shutdown.ManualShutdown.checkIfShutdownCalled;
 import static bisq.cli.TableFormat.formatOfferTable;
+import static bisq.core.offer.OfferPayload.Direction.BUY;
+import static bisq.core.offer.OfferPayload.Direction.SELL;
 import static bisq.core.payment.payload.PaymentMethod.F2F_ID;
-import static protobuf.OfferPayload.Direction.BUY;
-import static protobuf.OfferPayload.Direction.SELL;
 
 
 
+import bisq.apitest.botsupport.BotClient;
+import bisq.apitest.botsupport.protocol.TakerBotProtocol;
+import bisq.apitest.botsupport.script.BashScriptGenerator;
+import bisq.apitest.botsupport.shutdown.ManualBotShutdownException;
 import bisq.apitest.method.BitcoinCliHelper;
-import bisq.apitest.scenario.bot.BotClient;
-import bisq.apitest.scenario.bot.script.BashScriptGenerator;
-import bisq.apitest.scenario.bot.shutdown.ManualBotShutdownException;
-
-@Slf4j
-public class TakerBotProtocol extends BotProtocol {
-
-    public TakerBotProtocol(BotClient botClient,
-                            PaymentAccount paymentAccount,
-                            long protocolStepTimeLimitInMs,
-                            BitcoinCliHelper bitcoinCli,
-                            BashScriptGenerator bashScriptGenerator) {
-        super(botClient,
+
+public class ApiTestTakerBotProtocol extends ApiTestBotProtocol implements TakerBotProtocol {
+
+    // Don't use @Slf4j annotation to init log and use in Functions w/out IDE warnings.
+    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(ApiTestTakerBotProtocol.class);
+
+
+    public ApiTestTakerBotProtocol(BotClient botClient,
+                                   PaymentAccount paymentAccount,
+                                   long protocolStepTimeLimitInMs,
+                                   BitcoinCliHelper bitcoinCli,
+                                   BashScriptGenerator bashScriptGenerator) {
+        super("Taker",
+                botClient,
                 paymentAccount,
                 protocolStepTimeLimitInMs,
                 bitcoinCli,
@@ -66,7 +67,7 @@ public void run() {
     }
 
     private final Supplier> firstOffer = () -> {
-        var offers = botClient.getOffers(currencyCode);
+        var offers = botClient.getOffersSortedByDate(currencyCode);
         if (offers.size() > 0) {
             log.info("Offers found:\n{}", formatOfferTable(offers, currencyCode));
             OfferInfo offer = offers.get(0);
@@ -82,7 +83,7 @@ public void run() {
         initProtocolStep.accept(FIND_OFFER);
         createMakeOfferScript();
         try {
-            log.info("Impatiently waiting for at least one {} offer to be created, repeatedly calling getoffers.", currencyCode);
+            log.info("Waiting for a {} offer to be created.", currencyCode);
             while (isWithinProtocolStepTimeLimit()) {
                 checkIfShutdownCalled("Interrupted while checking offers.");
                 try {
@@ -90,7 +91,7 @@ public void run() {
                     if (offer.isPresent())
                         return offer.get();
                     else
-                        sleep(randomDelay.get());
+                        sleep(shortRandomDelayInSeconds.get());
                 } catch (Exception ex) {
                     throw new IllegalStateException(this.getBotClient().toCleanGrpcExceptionMessage(ex), ex);
                 }
@@ -106,13 +107,13 @@ public void run() {
     private final Function takeOffer = (offer) -> {
         initProtocolStep.accept(TAKE_OFFER);
         checkIfShutdownCalled("Interrupted before taking offer.");
-        String feeCurrency = RANDOM.nextBoolean() ? BSQ : BTC;
+        String feeCurrency = RANDOM.nextBoolean() ? "BSQ" : "BTC";
         return botClient.takeOffer(offer.getId(), paymentAccount, feeCurrency);
     };
 
     private void createMakeOfferScript() {
         String direction = RANDOM.nextBoolean() ? BUY.name() : SELL.name();
-        String feeCurrency = RANDOM.nextBoolean() ? BSQ : BTC;
+        String feeCurrency = RANDOM.nextBoolean() ? "BSQ" : "BTC";
         boolean createMarginPricedOffer = RANDOM.nextBoolean();
         // If not using an F2F account, don't go over possible 0.01 BTC
         // limit if account is not signed.
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/BotProtocol.java b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/BotProtocol.java
deleted file mode 100644
index b575059945a..00000000000
--- a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/BotProtocol.java
+++ /dev/null
@@ -1,349 +0,0 @@
-/*
- * This file is part of Bisq.
- *
- * Bisq is free software: you can redistribute it and/or modify it
- * under the terms of the GNU Affero General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or (at
- * your option) any later version.
- *
- * Bisq is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
- * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
- * License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with Bisq. If not, see .
- */
-
-package bisq.apitest.scenario.bot.protocol;
-
-
-import bisq.proto.grpc.TradeInfo;
-
-import protobuf.PaymentAccount;
-
-import java.security.SecureRandom;
-
-import java.io.File;
-
-import java.util.Objects;
-import java.util.concurrent.atomic.AtomicLong;
-import java.util.function.Consumer;
-import java.util.function.Function;
-import java.util.function.Predicate;
-import java.util.function.Supplier;
-import java.util.stream.Collectors;
-
-import lombok.Getter;
-import lombok.extern.slf4j.Slf4j;
-
-import static bisq.apitest.scenario.bot.protocol.ProtocolStep.*;
-import static bisq.apitest.scenario.bot.shutdown.ManualShutdown.checkIfShutdownCalled;
-import static java.lang.String.format;
-import static java.lang.System.currentTimeMillis;
-import static java.util.Arrays.stream;
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
-import static protobuf.OfferPayload.Direction.BUY;
-import static protobuf.OfferPayload.Direction.SELL;
-
-
-
-import bisq.apitest.method.BitcoinCliHelper;
-import bisq.apitest.scenario.bot.BotClient;
-import bisq.apitest.scenario.bot.script.BashScriptGenerator;
-import bisq.apitest.scenario.bot.shutdown.ManualBotShutdownException;
-import bisq.cli.TradeFormat;
-
-@Slf4j
-public abstract class BotProtocol {
-
-    static final SecureRandom RANDOM = new SecureRandom();
-
-    protected final Supplier randomDelay = () -> (long) (2000 + RANDOM.nextInt(5000));
-
-    protected final AtomicLong protocolStepStartTime = new AtomicLong(0);
-    protected final Consumer initProtocolStep = (step) -> {
-        currentProtocolStep = step;
-        printBotProtocolStep();
-        protocolStepStartTime.set(currentTimeMillis());
-    };
-
-    @Getter
-    protected ProtocolStep currentProtocolStep;
-
-    @Getter // Functions within 'this' need the @Getter.
-    protected final BotClient botClient;
-    protected final PaymentAccount paymentAccount;
-    protected final String currencyCode;
-    protected final long protocolStepTimeLimitInMs;
-    protected final BitcoinCliHelper bitcoinCli;
-    @Getter
-    protected final BashScriptGenerator bashScriptGenerator;
-
-    public BotProtocol(BotClient botClient,
-                       PaymentAccount paymentAccount,
-                       long protocolStepTimeLimitInMs,
-                       BitcoinCliHelper bitcoinCli,
-                       BashScriptGenerator bashScriptGenerator) {
-        this.botClient = botClient;
-        this.paymentAccount = paymentAccount;
-        this.currencyCode = Objects.requireNonNull(paymentAccount.getSelectedTradeCurrency()).getCode();
-        this.protocolStepTimeLimitInMs = protocolStepTimeLimitInMs;
-        this.bitcoinCli = bitcoinCli;
-        this.bashScriptGenerator = bashScriptGenerator;
-        this.currentProtocolStep = START;
-    }
-
-    public abstract void run();
-
-    protected boolean isWithinProtocolStepTimeLimit() {
-        return (currentTimeMillis() - protocolStepStartTime.get()) < protocolStepTimeLimitInMs;
-    }
-
-    protected void checkIsStartStep() {
-        if (currentProtocolStep != START) {
-            throw new IllegalStateException("First bot protocol step must be " + START.name());
-        }
-    }
-
-    protected void printBotProtocolStep() {
-        log.info("Starting protocol step {}.  Bot will shutdown if step not completed within {} minutes.",
-                currentProtocolStep.name(), MILLISECONDS.toMinutes(protocolStepTimeLimitInMs));
-
-        if (currentProtocolStep.equals(WAIT_FOR_TAKER_DEPOSIT_TX_CONFIRMED)) {
-            log.info("Generate a btc block to trigger taker's deposit fee tx confirmation.");
-            createGenerateBtcBlockScript();
-        }
-    }
-
-    protected final Function waitForTakerFeeTxConfirm = (trade) -> {
-        sleep(5000);
-        waitForTakerFeeTxPublished(trade.getTradeId());
-        waitForTakerFeeTxConfirmed(trade.getTradeId());
-        return trade;
-    };
-
-    protected final Function waitForPaymentStartedMessage = (trade) -> {
-        initProtocolStep.accept(WAIT_FOR_PAYMENT_STARTED_MESSAGE);
-        try {
-            createPaymentStartedScript(trade);
-            log.info("  Waiting for a 'payment started' message from buyer for trade with id {}.", trade.getTradeId());
-            while (isWithinProtocolStepTimeLimit()) {
-                checkIfShutdownCalled("Interrupted before checking if 'payment started' message has been sent.");
-                try {
-                    var t = this.getBotClient().getTrade(trade.getTradeId());
-                    if (t.getIsFiatSent()) {
-                        log.info("Buyer has started payment for trade:\n{}", TradeFormat.format(t));
-                        return t;
-                    }
-                } catch (Exception ex) {
-                    throw new IllegalStateException(this.getBotClient().toCleanGrpcExceptionMessage(ex));
-                }
-                sleep(randomDelay.get());
-            } // end while
-
-            throw new IllegalStateException("Payment was never sent; we won't wait any longer.");
-        } catch (ManualBotShutdownException ex) {
-            throw ex; // not an error, tells bot to shutdown
-        } catch (Exception ex) {
-            throw new IllegalStateException("Error while waiting payment sent message.", ex);
-        }
-    };
-
-    protected final Function sendPaymentStartedMessage = (trade) -> {
-        initProtocolStep.accept(SEND_PAYMENT_STARTED_MESSAGE);
-        checkIfShutdownCalled("Interrupted before sending 'payment started' message.");
-        this.getBotClient().sendConfirmPaymentStartedMessage(trade.getTradeId());
-        return trade;
-    };
-
-    protected final Function waitForPaymentReceivedConfirmation = (trade) -> {
-        initProtocolStep.accept(WAIT_FOR_PAYMENT_RECEIVED_CONFIRMATION_MESSAGE);
-        createPaymentReceivedScript(trade);
-        try {
-            log.info("Waiting for a 'payment received confirmation' message from seller for trade with id {}.", trade.getTradeId());
-            while (isWithinProtocolStepTimeLimit()) {
-                checkIfShutdownCalled("Interrupted before checking if 'payment received confirmation' message has been sent.");
-                try {
-                    var t = this.getBotClient().getTrade(trade.getTradeId());
-                    if (t.getIsFiatReceived()) {
-                        log.info("Seller has received payment for trade:\n{}", TradeFormat.format(t));
-                        return t;
-                    }
-                } catch (Exception ex) {
-                    throw new IllegalStateException(this.getBotClient().toCleanGrpcExceptionMessage(ex));
-                }
-                sleep(randomDelay.get());
-            } // end while
-
-            throw new IllegalStateException("Payment was never received; we won't wait any longer.");
-        } catch (ManualBotShutdownException ex) {
-            throw ex; // not an error, tells bot to shutdown
-        } catch (Exception ex) {
-            throw new IllegalStateException("Error while waiting payment received confirmation message.", ex);
-        }
-    };
-
-    protected final Function sendPaymentReceivedMessage = (trade) -> {
-        initProtocolStep.accept(SEND_PAYMENT_RECEIVED_CONFIRMATION_MESSAGE);
-        checkIfShutdownCalled("Interrupted before sending 'payment received confirmation' message.");
-        this.getBotClient().sendConfirmPaymentReceivedMessage(trade.getTradeId());
-        return trade;
-    };
-
-    protected final Function waitForPayoutTx = (trade) -> {
-        initProtocolStep.accept(WAIT_FOR_PAYOUT_TX);
-        try {
-            log.info("Waiting on the 'payout tx published confirmation' for trade with id {}.", trade.getTradeId());
-            while (isWithinProtocolStepTimeLimit()) {
-                checkIfShutdownCalled("Interrupted before checking if payout tx has been published.");
-                try {
-                    var t = this.getBotClient().getTrade(trade.getTradeId());
-                    if (t.getIsPayoutPublished()) {
-                        log.info("Payout tx {} has been published for trade:\n{}",
-                                t.getPayoutTxId(),
-                                TradeFormat.format(t));
-                        return t;
-                    }
-                } catch (Exception ex) {
-                    throw new IllegalStateException(this.getBotClient().toCleanGrpcExceptionMessage(ex));
-                }
-                sleep(randomDelay.get());
-            } // end while
-
-            throw new IllegalStateException("Payout tx was never published; we won't wait any longer.");
-        } catch (ManualBotShutdownException ex) {
-            throw ex; // not an error, tells bot to shutdown
-        } catch (Exception ex) {
-            throw new IllegalStateException("Error while waiting for published payout tx.", ex);
-        }
-    };
-
-    protected final Function keepFundsFromTrade = (trade) -> {
-        initProtocolStep.accept(KEEP_FUNDS);
-        var isBuy = trade.getOffer().getDirection().equalsIgnoreCase(BUY.name());
-        var isSell = trade.getOffer().getDirection().equalsIgnoreCase(SELL.name());
-        var cliUserIsSeller = (this instanceof MakerBotProtocol && isBuy) || (this instanceof TakerBotProtocol && isSell);
-        if (cliUserIsSeller) {
-            createKeepFundsScript(trade);
-        } else {
-            createGetBalanceScript();
-        }
-        checkIfShutdownCalled("Interrupted before closing trade with 'keep funds' command.");
-        this.getBotClient().sendKeepFundsMessage(trade.getTradeId());
-        return trade;
-    };
-
-    protected void createPaymentStartedScript(TradeInfo trade) {
-        File script = bashScriptGenerator.createPaymentStartedScript(trade);
-        printCliHintAndOrScript(script, "The manual CLI side can send a 'payment started' message");
-    }
-
-    protected void createPaymentReceivedScript(TradeInfo trade) {
-        File script = bashScriptGenerator.createPaymentReceivedScript(trade);
-        printCliHintAndOrScript(script, "The manual CLI side can sent a 'payment received confirmation' message");
-    }
-
-    protected void createKeepFundsScript(TradeInfo trade) {
-        File script = bashScriptGenerator.createKeepFundsScript(trade);
-        printCliHintAndOrScript(script, "The manual CLI side can close the trade");
-    }
-
-    protected void createGetBalanceScript() {
-        File script = bashScriptGenerator.createGetBalanceScript();
-        printCliHintAndOrScript(script, "The manual CLI side can view current balances");
-    }
-
-    protected void createGenerateBtcBlockScript() {
-        String newBitcoinCoreAddress = bitcoinCli.getNewBtcAddress();
-        File script = bashScriptGenerator.createGenerateBtcBlockScript(newBitcoinCoreAddress);
-        printCliHintAndOrScript(script, "The manual CLI side can generate 1 btc block");
-    }
-
-    protected void printCliHintAndOrScript(File script, String hint) {
-        log.info("{} by running bash script '{}'.", hint, script.getAbsolutePath());
-        if (this.getBashScriptGenerator().isPrintCliScripts())
-            this.getBashScriptGenerator().printCliScript(script, log);
-
-        sleep(5000); // Allow 5s for CLI user to read the hint.
-    }
-
-    protected void sleep(long ms) {
-        try {
-            MILLISECONDS.sleep(ms);
-        } catch (InterruptedException ignored) {
-            // empty
-        }
-    }
-
-    private void waitForTakerFeeTxPublished(String tradeId) {
-        waitForTakerDepositFee(tradeId, WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED);
-    }
-
-    private void waitForTakerFeeTxConfirmed(String tradeId) {
-        waitForTakerDepositFee(tradeId, WAIT_FOR_TAKER_DEPOSIT_TX_CONFIRMED);
-    }
-
-    private void waitForTakerDepositFee(String tradeId, ProtocolStep depositTxProtocolStep) {
-        initProtocolStep.accept(depositTxProtocolStep);
-        validateCurrentProtocolStep(WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED, WAIT_FOR_TAKER_DEPOSIT_TX_CONFIRMED);
-        try {
-            log.info(waitingForDepositFeeTxMsg(tradeId));
-            while (isWithinProtocolStepTimeLimit()) {
-                checkIfShutdownCalled("Interrupted before checking taker deposit fee tx is published and confirmed.");
-                try {
-                    var trade = this.getBotClient().getTrade(tradeId);
-                    if (isDepositFeeTxStepComplete.test(trade))
-                        return;
-                    else
-                        sleep(randomDelay.get());
-                } catch (Exception ex) {
-                    if (this.getBotClient().tradeContractIsNotReady.test(ex, tradeId))
-                        sleep(randomDelay.get());
-                    else
-                        throw new IllegalStateException(this.getBotClient().toCleanGrpcExceptionMessage(ex));
-                }
-            }  // end while
-            throw new IllegalStateException(stoppedWaitingForDepositFeeTxMsg(this.getBotClient().getTrade(tradeId).getDepositTxId()));
-        } catch (ManualBotShutdownException ex) {
-            throw ex; // not an error, tells bot to shutdown
-        } catch (Exception ex) {
-            throw new IllegalStateException("Error while waiting for taker deposit tx to be published or confirmed.", ex);
-        }
-    }
-
-    private final Predicate isDepositFeeTxStepComplete = (trade) -> {
-        if (currentProtocolStep.equals(WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED) && trade.getIsDepositPublished()) {
-            log.info("Taker deposit fee tx {} has been published.", trade.getDepositTxId());
-            return true;
-        } else if (currentProtocolStep.equals(WAIT_FOR_TAKER_DEPOSIT_TX_CONFIRMED) && trade.getIsDepositConfirmed()) {
-            log.info("Taker deposit fee tx {} has been confirmed.", trade.getDepositTxId());
-            return true;
-        } else {
-            return false;
-        }
-    };
-
-    private void validateCurrentProtocolStep(Enum... validBotSteps) {
-        for (Enum validBotStep : validBotSteps) {
-            if (currentProtocolStep.equals(validBotStep))
-                return;
-        }
-        throw new IllegalStateException("Unexpected bot step: " + currentProtocolStep.name() + ".\n"
-                + "Must be one of "
-                + stream(validBotSteps).map((Enum::name)).collect(Collectors.joining(","))
-                + ".");
-    }
-
-    private String waitingForDepositFeeTxMsg(String tradeId) {
-        return format("Waiting for taker deposit fee tx for trade %s to be %s.",
-                tradeId,
-                currentProtocolStep.equals(WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED) ? "published" : "confirmed");
-    }
-
-    private String stoppedWaitingForDepositFeeTxMsg(String txId) {
-        return format("Taker deposit fee tx %s is took too long to be %s;  we won't wait any longer.",
-                txId,
-                currentProtocolStep.equals(WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED) ? "published" : "confirmed");
-    }
-}
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/MarketMakerTakeOnlyBotProtocol.java b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/MarketMakerTakeOnlyBotProtocol.java
new file mode 100644
index 00000000000..913ea532a99
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/MarketMakerTakeOnlyBotProtocol.java
@@ -0,0 +1,160 @@
+package bisq.apitest.scenario.bot.protocol;
+
+import bisq.proto.grpc.OfferInfo;
+import bisq.proto.grpc.TradeInfo;
+
+import protobuf.PaymentAccount;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import static bisq.apitest.botsupport.protocol.ProtocolStep.DONE;
+import static bisq.apitest.botsupport.protocol.ProtocolStep.FIND_OFFER;
+import static bisq.apitest.botsupport.protocol.ProtocolStep.TAKE_OFFER;
+import static bisq.apitest.botsupport.shutdown.ManualShutdown.checkIfShutdownCalled;
+import static bisq.cli.TableFormat.formatOfferTable;
+import static bisq.core.offer.OfferPayload.Direction.BUY;
+import static java.lang.String.format;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+
+
+import bisq.apitest.botsupport.BotClient;
+import bisq.apitest.botsupport.protocol.TakeOfferHelper;
+import bisq.apitest.botsupport.script.BashScriptGenerator;
+import bisq.apitest.method.BitcoinCliHelper;
+
+public class MarketMakerTakeOnlyBotProtocol extends ApiTestBotProtocol {
+
+    // Don't use @Slf4j annotation to init log and use in Functions w/out IDE warnings.
+    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(MarketMakerTakeOnlyBotProtocol.class);
+
+    private final AtomicLong takersBankBalance;
+
+    public MarketMakerTakeOnlyBotProtocol(BotClient botClient,
+                                          PaymentAccount paymentAccount,
+                                          long protocolStepTimeLimitInMs,
+                                          BitcoinCliHelper bitcoinCli,
+                                          BashScriptGenerator bashScriptGenerator,
+                                          AtomicLong takersBankBalance) {
+        super("Taker",
+                botClient,
+                paymentAccount,
+                protocolStepTimeLimitInMs,
+                bitcoinCli,
+                bashScriptGenerator);
+        this.takersBankBalance = takersBankBalance;
+    }
+
+    @Override
+    public void run() {
+        checkIsStartStep();
+
+        Function takeTrade = takeOffer.andThen(waitForTakerFeeTxConfirm);
+        var trade = takeTrade.apply(findOffer.get());
+
+        var takerIsSeller = trade.getOffer().getDirection().equalsIgnoreCase(BUY.name());
+        Function completeFiatTransaction = takerIsSeller
+                ? waitForPaymentStartedMessage.andThen(sendPaymentReceivedMessage)
+                : sendPaymentStartedMessage.andThen(waitForPaymentReceivedConfirmation);
+        completeFiatTransaction.apply(trade);
+
+        Function closeTrade = waitForPayoutTx.andThen(keepFundsFromTrade);
+        closeTrade.apply(trade);
+
+        var iAmSeller = trade.getOffer().getDirection().equalsIgnoreCase(BUY.name());
+        long bankBalanceDelta = iAmSeller
+                ? toDollars(trade.getOffer().getVolume())
+                : -1 * toDollars(trade.getOffer().getVolume());
+        takersBankBalance.addAndGet(bankBalanceDelta);
+
+        currentProtocolStep = DONE;
+    }
+
+    private final Supplier> firstOffer = () -> {
+        var offers = botClient.getOffersSortedByDate(currencyCode);
+        if (offers.size() > 0) {
+            log.info("Offers found:\n{}", formatOfferTable(offers, currencyCode));
+            OfferInfo offer = offers.get(0);
+            log.info("Will take first offer {}", offer.getId());
+            return Optional.of(offer);
+        } else {
+            log.info("No buy or sell {} offers found.", currencyCode);
+            return Optional.empty();
+        }
+    };
+
+    private final Supplier findOffer = () -> {
+        initProtocolStep.accept(FIND_OFFER);
+        log.info("Looking for a {} offer.", currencyCode);
+        int numDelays = 0;
+        while (isWithinProtocolStepTimeLimit()) {
+            checkIfShutdownCalled("Interrupted while checking offers.");
+            try {
+                Optional offer = firstOffer.get();
+                if (offer.isPresent()) {
+                    return offer.get();
+                } else {
+                    if (++numDelays % 5 == 0) {
+                        List currentOffers = botClient.getOffersSortedByDate(currencyCode);
+                        if (currentOffers.isEmpty()) {
+                            log.info("Still no available {} offers for {}.", currencyCode, botDescription);
+                        } else {
+                            log.warn("{} should be taking one of these available {} offers:\n{}",
+                                    botDescription,
+                                    currencyCode,
+                                    formatOfferTable(currentOffers, currencyCode));
+                        }
+                    }
+                    sleep(shortRandomDelayInSeconds.get());
+                }
+            } catch (Exception ex) {
+                throw new IllegalStateException(this.getBotClient().toCleanGrpcExceptionMessage(ex), ex);
+            }
+        } // end while
+
+        // If the while loop is exhausted, the offer was not created within the protocol step time limit.
+        throw new IllegalStateException("Offer was never created; we won't wait any longer.");
+    };
+
+
+    private final Function takeOffer = (offer) -> {
+        initProtocolStep.accept(TAKE_OFFER);
+        checkIfShutdownCalled("Interrupted before taking offer.");
+        String feeCurrency = RANDOM.nextBoolean() ? "BSQ" : "BTC";
+        TakeOfferHelper takeOfferHelper = new TakeOfferHelper(botClient,
+                botDescription,
+                offer,
+                paymentAccount,
+                feeCurrency,
+                60,
+                60,
+                30);
+
+        takeOfferHelper.run();
+
+        if (takeOfferHelper.hasNewTrade.get()) {
+            try {
+                log.info("{} waiting 5s for trade prep before allowing any gettrade calls.", botDescription);
+                SECONDS.sleep(5);
+            } catch (InterruptedException ignored) {
+                // empty
+            }
+            return takeOfferHelper.getNewTrade();
+        } else if (takeOfferHelper.hasTakeOfferError.get()) {
+            throw new IllegalStateException(format("%s's takeoffer %s attempt failed.",
+                    botDescription,
+                    offer.getId()),
+                    takeOfferHelper.getFatalThrowable());
+        } else {
+            throw new IllegalStateException(format("%s's takeoffer %s attempt failed for unknown reason.",
+                    botDescription,
+                    offer.getId()));
+        }
+    };
+}
+
+
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ProtocolStep.java b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ProtocolStep.java
deleted file mode 100644
index def2a0bb663..00000000000
--- a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ProtocolStep.java
+++ /dev/null
@@ -1,17 +0,0 @@
-package bisq.apitest.scenario.bot.protocol;
-
-public enum ProtocolStep {
-    START,
-    FIND_OFFER,
-    TAKE_OFFER,
-    WAIT_FOR_OFFER_TAKER,
-    WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED,
-    WAIT_FOR_TAKER_DEPOSIT_TX_CONFIRMED,
-    SEND_PAYMENT_STARTED_MESSAGE,
-    WAIT_FOR_PAYMENT_STARTED_MESSAGE,
-    SEND_PAYMENT_RECEIVED_CONFIRMATION_MESSAGE,
-    WAIT_FOR_PAYMENT_RECEIVED_CONFIRMATION_MESSAGE,
-    WAIT_FOR_PAYOUT_TX,
-    KEEP_FUNDS,
-    DONE
-}
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/script/BotScript.java b/apitest/src/test/java/bisq/apitest/scenario/bot/script/BotScript.java
index 2caaed68add..56baf720867 100644
--- a/apitest/src/test/java/bisq/apitest/scenario/bot/script/BotScript.java
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/script/BotScript.java
@@ -25,8 +25,7 @@
 
 @Getter
 @ToString
-public
-class BotScript {
+public class BotScript {
 
     // Common, default is true.
     private final boolean useTestHarness;
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/script/BotScriptGenerator.java b/apitest/src/test/java/bisq/apitest/scenario/bot/script/BotScriptGenerator.java
index c81730c4c40..341bce16e75 100644
--- a/apitest/src/test/java/bisq/apitest/scenario/bot/script/BotScriptGenerator.java
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/script/BotScriptGenerator.java
@@ -98,7 +98,7 @@ public BotScriptGenerator(String[] args) {
                 .accepts("step-time-limit", "Each protocol step's time limit in minutes")
                 .withRequiredArg()
                 .ofType(Integer.class)
-                .defaultsTo(60);
+                .defaultsTo(10);
         OptionSpec printCliScriptsOpt = parser
                 .accepts("print-cli-scripts", "Print the generated CLI scripts from bot")
                 .withRequiredArg()
diff --git a/cli/src/main/java/bisq/cli/opts/AbstractMethodOptionParser.java b/cli/src/main/java/bisq/cli/opts/AbstractMethodOptionParser.java
index 25256eb6a99..3d25dc07704 100644
--- a/cli/src/main/java/bisq/cli/opts/AbstractMethodOptionParser.java
+++ b/cli/src/main/java/bisq/cli/opts/AbstractMethodOptionParser.java
@@ -29,7 +29,7 @@
 
 import static bisq.cli.opts.OptLabel.OPT_HELP;
 
-abstract class AbstractMethodOptionParser implements MethodOpts {
+public abstract class AbstractMethodOptionParser implements MethodOpts {
 
     // The full command line args passed to CliMain.main(String[] args).
     // CLI and Method level arguments are derived from args by an ArgumentList(args).

From 6fa4f1a5d33e9ea0149749c384ce3ed324d2775e Mon Sep 17 00:00:00 2001
From: ghubstan <36207203+ghubstan@users.noreply.github.com>
Date: Thu, 1 Apr 2021 19:01:06 -0300
Subject: [PATCH 16/21] Make codacy happy

---
 apitest/src/test/java/bisq/apitest/botsupport/BotClient.java  | 3 ++-
 .../apitest/botsupport/example/BsqMarketMakerBotTest.java     | 4 ++--
 2 files changed, 4 insertions(+), 3 deletions(-)

diff --git a/apitest/src/test/java/bisq/apitest/botsupport/BotClient.java b/apitest/src/test/java/bisq/apitest/botsupport/BotClient.java
index 120bd1984cc..85ae55a5dfb 100644
--- a/apitest/src/test/java/bisq/apitest/botsupport/BotClient.java
+++ b/apitest/src/test/java/bisq/apitest/botsupport/BotClient.java
@@ -49,6 +49,7 @@
 import static bisq.apitest.botsupport.util.BotUtilities.capitalize;
 import static bisq.cli.CurrencyFormat.formatBsqAmount;
 import static bisq.cli.CurrencyFormat.formatMarketPrice;
+import static java.lang.System.*;
 import static java.lang.System.currentTimeMillis;
 import static java.util.Arrays.asList;
 import static java.util.Objects.requireNonNull;
@@ -371,7 +372,7 @@ public void tryToTakeOffer(String offerId,
                                String feeCurrency,
                                Consumer resultHandler,
                                Consumer errorHandler) {
-        long startTime = System.currentTimeMillis();
+        long startTime = currentTimeMillis();
         ListenableFuture future = takeOfferExecutor.submit(() ->
                 grpcClient.getTakeOfferReply(offerId, paymentAccount.getId(), feeCurrency));
         Futures.addCallback(future, new FutureCallback<>() {
diff --git a/apitest/src/test/java/bisq/apitest/botsupport/example/BsqMarketMakerBotTest.java b/apitest/src/test/java/bisq/apitest/botsupport/example/BsqMarketMakerBotTest.java
index e7212682c86..d8a36ae6030 100644
--- a/apitest/src/test/java/bisq/apitest/botsupport/example/BsqMarketMakerBotTest.java
+++ b/apitest/src/test/java/bisq/apitest/botsupport/example/BsqMarketMakerBotTest.java
@@ -134,8 +134,8 @@ protected void verifyHaveOffers(int currentTradeCycle) {
             // TODO Wait for the next offer to sync up again.
             for (int i = 0; i < 10; i++) {
                 try {
-                    log.warn("There is only 1 available offer {} at start of cycle, will check again in 10 seconds. "
-                            , offers.get(0).getId());
+                    log.warn("There is only 1 available offer {} at start of cycle, will check again in 10 seconds.",
+                            offers.get(0).getId());
                     SECONDS.sleep(10);
                     offers.clear();
                     offers.addAll(botClient.getOffersSortedByDate(BTC));

From 10f8a9138b34e195759459c7cb675a111da04199 Mon Sep 17 00:00:00 2001
From: ghubstan <36207203+ghubstan@users.noreply.github.com>
Date: Fri, 2 Apr 2021 11:03:53 -0300
Subject: [PATCH 17/21] Fix loop & log

---
 .../src/test/java/bisq/apitest/botsupport/BotClient.java | 1 -
 .../apitest/botsupport/example/BaseMarketMakerBot.java   | 2 +-
 .../botsupport/example/BsqMarketMakerBotTest.java        | 9 ++++++---
 3 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/apitest/src/test/java/bisq/apitest/botsupport/BotClient.java b/apitest/src/test/java/bisq/apitest/botsupport/BotClient.java
index 85ae55a5dfb..089cff67372 100644
--- a/apitest/src/test/java/bisq/apitest/botsupport/BotClient.java
+++ b/apitest/src/test/java/bisq/apitest/botsupport/BotClient.java
@@ -49,7 +49,6 @@
 import static bisq.apitest.botsupport.util.BotUtilities.capitalize;
 import static bisq.cli.CurrencyFormat.formatBsqAmount;
 import static bisq.cli.CurrencyFormat.formatMarketPrice;
-import static java.lang.System.*;
 import static java.lang.System.currentTimeMillis;
 import static java.util.Arrays.asList;
 import static java.util.Objects.requireNonNull;
diff --git a/apitest/src/test/java/bisq/apitest/botsupport/example/BaseMarketMakerBot.java b/apitest/src/test/java/bisq/apitest/botsupport/example/BaseMarketMakerBot.java
index 6dbf6cf699f..34c28f851f3 100644
--- a/apitest/src/test/java/bisq/apitest/botsupport/example/BaseMarketMakerBot.java
+++ b/apitest/src/test/java/bisq/apitest/botsupport/example/BaseMarketMakerBot.java
@@ -144,7 +144,7 @@ protected final PaymentAccount getNextReceiverPaymentAccount() {
     }
 
     protected void waitForManualShutdown() {
-        log.info("When ready to shutdown bot, run '$ /tmp/bot-shutdown'.");
+        log.info("When ready to shutdown bot, run '$ touch /tmp/bot-shutdown'.");
         try {
             while (!isShutdownCalled()) {
                 rest(10);
diff --git a/apitest/src/test/java/bisq/apitest/botsupport/example/BsqMarketMakerBotTest.java b/apitest/src/test/java/bisq/apitest/botsupport/example/BsqMarketMakerBotTest.java
index d8a36ae6030..8d420ece987 100644
--- a/apitest/src/test/java/bisq/apitest/botsupport/example/BsqMarketMakerBotTest.java
+++ b/apitest/src/test/java/bisq/apitest/botsupport/example/BsqMarketMakerBotTest.java
@@ -38,7 +38,7 @@ public class BsqMarketMakerBotTest {
 
     // TODO Install shutdown hook, check state.
 
-    private static final int MAX_TRADE_CYCLES = 5;
+    private static final int MAX_TRADE_CYCLES = 10;
 
     private final BotClient botClient;
     private final PaymentAccount paymentAccount;
@@ -54,7 +54,8 @@ public void runTradeCycles() {
         try {
             verifyHavePaymentAccount();
 
-            for (int tradeCycle = 1; tradeCycle <= MAX_TRADE_CYCLES; tradeCycle++) {
+            int tradeCycle = 0;
+            while(tradeCycle <= MAX_TRADE_CYCLES) {
                 offers.addAll(botClient.getOffersSortedByDate(BTC));
                 verifyHaveOffers(tradeCycle);  // Should 1 BUY and 1 SELL for each trade cycle
 
@@ -79,6 +80,8 @@ public void runTradeCycles() {
                 printBalance();
 
                 offers.clear();
+
+                tradeCycle++;
                 if (tradeCycle < MAX_TRADE_CYCLES) {
                     log.info("Completed {} trade cycle(s).  Starting the next in 1 minute.", tradeCycle);
                     SECONDS.sleep(60);
@@ -201,7 +204,7 @@ protected void verifyBsqReceivedFromAlice() {
                     receiveAmountAsString,
                     address,
                     tradeId);
-            log.info("Give bot time to send payment. Generate a block while you wait");
+            log.info("Give bot time to send payment.");
             for (int i = 0; i < 5; i++) {
                 SECONDS.sleep(30);
                 boolean receivedPayment = botClient.verifyBsqSentToAddress(address, receiveAmountAsString);

From a2c9eae7968b272ff620256d6c6a3e37aa3ba04c Mon Sep 17 00:00:00 2001
From: ghubstan <36207203+ghubstan@users.noreply.github.com>
Date: Fri, 2 Apr 2021 11:51:48 -0300
Subject: [PATCH 18/21] Return from method, not break loop

---
 .../bisq/apitest/botsupport/example/BsqMarketMakerBotTest.java  | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/apitest/src/test/java/bisq/apitest/botsupport/example/BsqMarketMakerBotTest.java b/apitest/src/test/java/bisq/apitest/botsupport/example/BsqMarketMakerBotTest.java
index 8d420ece987..54603432504 100644
--- a/apitest/src/test/java/bisq/apitest/botsupport/example/BsqMarketMakerBotTest.java
+++ b/apitest/src/test/java/bisq/apitest/botsupport/example/BsqMarketMakerBotTest.java
@@ -144,7 +144,7 @@ protected void verifyHaveOffers(int currentTradeCycle) {
                     offers.addAll(botClient.getOffersSortedByDate(BTC));
                     if (offers.size() == 2) {
                         log.info("Now Bob can take offers:\n{}", formatOfferTable(offers, BSQ));
-                        break;
+                        return;
                     }
                 } catch (InterruptedException ignored) {
                     // empty

From 2a7e39b25f857b0399704e9a3e4a2dfdcb0e1833 Mon Sep 17 00:00:00 2001
From: ghubstan <36207203+ghubstan@users.noreply.github.com>
Date: Fri, 2 Apr 2021 12:26:08 -0300
Subject: [PATCH 19/21] Add whitespace

---
 .../bisq/apitest/botsupport/example/BsqMarketMakerBotTest.java  | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/apitest/src/test/java/bisq/apitest/botsupport/example/BsqMarketMakerBotTest.java b/apitest/src/test/java/bisq/apitest/botsupport/example/BsqMarketMakerBotTest.java
index 54603432504..4ce7cb85ad0 100644
--- a/apitest/src/test/java/bisq/apitest/botsupport/example/BsqMarketMakerBotTest.java
+++ b/apitest/src/test/java/bisq/apitest/botsupport/example/BsqMarketMakerBotTest.java
@@ -55,7 +55,7 @@ public void runTradeCycles() {
             verifyHavePaymentAccount();
 
             int tradeCycle = 0;
-            while(tradeCycle <= MAX_TRADE_CYCLES) {
+            while (tradeCycle <= MAX_TRADE_CYCLES) {
                 offers.addAll(botClient.getOffersSortedByDate(BTC));
                 verifyHaveOffers(tradeCycle);  // Should 1 BUY and 1 SELL for each trade cycle
 

From 698199065546a5fb14223e5d74ed18787fe46b42 Mon Sep 17 00:00:00 2001
From: ghubstan <36207203+ghubstan@users.noreply.github.com>
Date: Fri, 2 Apr 2021 12:55:41 -0300
Subject: [PATCH 20/21] Remove example mm bot

The botsupport pkg will remain, but it is too early to be releasing
an API bot that can run on mainnet.
---
 .../java/bisq/apitest/botsupport/BotMain.java | 187 -----------
 .../example/BaseMarketMakerBot.java           | 227 --------------
 .../botsupport/example/BsqMarketMakerBot.java | 295 ------------------
 .../example/BsqMarketMakerBotTest.java        | 250 ---------------
 .../botsupport/example/CancelOffersBot.java   |  58 ----
 .../apitest/botsupport/opts/BotOptLabel.java  |  31 --
 .../opts/BsqMarketMakerBotOptionParser.java   | 100 ------
 7 files changed, 1148 deletions(-)
 delete mode 100644 apitest/src/test/java/bisq/apitest/botsupport/BotMain.java
 delete mode 100644 apitest/src/test/java/bisq/apitest/botsupport/example/BaseMarketMakerBot.java
 delete mode 100644 apitest/src/test/java/bisq/apitest/botsupport/example/BsqMarketMakerBot.java
 delete mode 100644 apitest/src/test/java/bisq/apitest/botsupport/example/BsqMarketMakerBotTest.java
 delete mode 100644 apitest/src/test/java/bisq/apitest/botsupport/example/CancelOffersBot.java
 delete mode 100644 apitest/src/test/java/bisq/apitest/botsupport/opts/BotOptLabel.java
 delete mode 100644 apitest/src/test/java/bisq/apitest/botsupport/opts/BsqMarketMakerBotOptionParser.java

diff --git a/apitest/src/test/java/bisq/apitest/botsupport/BotMain.java b/apitest/src/test/java/bisq/apitest/botsupport/BotMain.java
deleted file mode 100644
index 3661ac4c5e2..00000000000
--- a/apitest/src/test/java/bisq/apitest/botsupport/BotMain.java
+++ /dev/null
@@ -1,187 +0,0 @@
-/*
- * This file is part of Bisq.
- *
- * Bisq is free software: you can redistribute it and/or modify it
- * under the terms of the GNU Affero General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or (at
- * your option) any later version.
- *
- * Bisq is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
- * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
- * License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with Bisq. If not, see .
- */
-
-package bisq.apitest.botsupport;
-
-import io.grpc.StatusRuntimeException;
-
-import joptsimple.OptionParser;
-import joptsimple.OptionSet;
-
-import java.io.IOException;
-import java.io.PrintStream;
-
-import java.math.BigDecimal;
-
-import java.util.List;
-
-import lombok.extern.slf4j.Slf4j;
-
-import static bisq.cli.opts.OptLabel.OPT_HELP;
-import static bisq.cli.opts.OptLabel.OPT_HOST;
-import static bisq.cli.opts.OptLabel.OPT_PASSWORD;
-import static bisq.cli.opts.OptLabel.OPT_PORT;
-import static java.lang.String.format;
-import static java.lang.System.err;
-import static java.lang.System.exit;
-import static java.lang.System.out;
-
-
-
-import bisq.apitest.botsupport.example.BsqMarketMakerBot;
-import bisq.apitest.botsupport.example.CancelOffersBot;
-import bisq.apitest.botsupport.opts.BsqMarketMakerBotOptionParser;
-import bisq.cli.opts.ArgumentList;
-
-// TODO Define BotMain in new gradle :bot subproject's generated bisq-bot script.
-
-@Slf4j
-public class BotMain {
-
-    // Quick testing
-    // --password=xyz --port=9998 bsqmarketmaker --target-btc-amount=0.05 --target-price=0.00004 --target-spread=13 --trade-cycle-limit=1 --new-payment-accts-limit=10
-    // Longer test sessions
-    // --password=xyz --port=9998 bsqmarketmaker --target-btc-amount=0.05 --target-price=0.00004 --target-spread=13 --trade-cycle-limit=20 --new-payment-accts-limit=20
-
-    enum BotType {
-        bsqmarketmaker,
-        canceloffers
-    }
-
-    public static void main(String[] args) {
-        try {
-            run(args);
-            exit(0);
-        } catch (Throwable t) {
-            err.println("Error: " + t.getMessage());
-            exit(1);
-        }
-    }
-
-    public static void run(String[] args) {
-        var parser = new OptionParser();
-
-        var helpOpt = parser.accepts(OPT_HELP, "Print this help text")
-                .forHelp();
-
-        var hostOpt = parser.accepts(OPT_HOST, "rpc server hostname or ip")
-                .withRequiredArg()
-                .defaultsTo("localhost");
-
-        var portOpt = parser.accepts(OPT_PORT, "rpc server port")
-                .withRequiredArg()
-                .ofType(Integer.class)
-                .defaultsTo(9998);
-
-        var passwordOpt = parser.accepts(OPT_PASSWORD, "rpc server password")
-                .withRequiredArg();
-
-        // Parse the CLI opts host, port, password, method name, and help.  The help opt
-        // may indicate the user is asking for method level help, and will be excluded
-        // from the parsed options if a method opt is present in String[] args.
-        OptionSet options = parser.parse(new ArgumentList(args).getCLIArguments());
-        @SuppressWarnings("unchecked")
-        var nonOptionArgs = (List) options.nonOptionArguments();
-
-        // If neither the help opt nor a method name is present, print CLI level help
-        // to stderr and throw an exception.
-        if (!options.has(helpOpt) && nonOptionArgs.isEmpty()) {
-            printHelp(parser, err);
-            throw new IllegalArgumentException("no bot type specified");
-        }
-
-        // If the help opt is present, but not a method name, print CLI level help
-        // to stdout.
-        if (options.has(helpOpt) && nonOptionArgs.isEmpty()) {
-            printHelp(parser, out);
-            return;
-        }
-
-        var host = options.valueOf(hostOpt);
-        var port = options.valueOf(portOpt);
-        var password = options.valueOf(passwordOpt);
-        if (password == null)
-            throw new IllegalArgumentException("missing required 'password' option");
-
-        var botOpt = nonOptionArgs.get(0);
-        BotType botType;
-        try {
-            botType = getBotNameFromCmd(botOpt);
-        } catch (IllegalArgumentException ex) {
-            throw new IllegalArgumentException(format("'%s' does not exist", botOpt));
-        }
-
-        try {
-            switch (botType) {
-                case bsqmarketmaker: {
-                    var opts = new BsqMarketMakerBotOptionParser(args).parse();
-                    if (opts.isForHelp()) {
-                        printHelp(parser, out);
-                        return;
-                    }
-                    var targetBtcAmount = opts.getTargetBtcAmount();
-                    var targetPrice = opts.getTargetPrice();
-                    var targetSpread = opts.getTargetSpread();
-                    var tradeCycleLimit = opts.getTradeCycleLimit(); // todo fix name
-                    var newBsqPaymentAccountsLimit = opts.getNewBsqPaymentAccountsLimit();
-                    var bsqMarketMakerBot = new BsqMarketMakerBot(host,
-                            port,
-                            password,
-                            newBsqPaymentAccountsLimit,
-                            new BigDecimal(targetPrice),
-                            new BigDecimal(targetBtcAmount),
-                            new BigDecimal(targetSpread),
-                            tradeCycleLimit);
-                    log.info("Starting {}.", bsqMarketMakerBot.getClass().getSimpleName());
-                    bsqMarketMakerBot.run();
-                    log.info("{} shutdown complete.", bsqMarketMakerBot.getClass().getSimpleName());
-                    return;
-                }
-                case canceloffers: {
-                    var cancelOffersBot = new CancelOffersBot(host, port, password);
-                    cancelOffersBot.cancelAllBsqOffers();
-                    return;
-                }
-                default: {
-                    throw new RuntimeException(format("unhandled bot type '%s'", botType));
-                }
-            }
-        } catch (StatusRuntimeException ex) {
-            // Remove the leading gRPC status code (e.g. "UNKNOWN: ") from the message
-            String message = ex.getMessage().replaceFirst("^[A-Z_]+: ", "");
-            if (message.equals("io exception"))
-                throw new RuntimeException(message + ", server may not be running", ex);
-            else
-                throw new RuntimeException(message, ex);
-        }
-    }
-
-    private static void printHelp(OptionParser parser, @SuppressWarnings("SameParameterValue") PrintStream stream) {
-        try {
-            stream.println("TODO");
-            stream.println();
-            parser.printHelpOn(stream);
-            stream.println();
-        } catch (IOException ex) {
-            ex.printStackTrace(stream);
-        }
-    }
-
-    private static BotType getBotNameFromCmd(String botName) {
-        return BotType.valueOf(botName.toLowerCase());
-    }
-}
diff --git a/apitest/src/test/java/bisq/apitest/botsupport/example/BaseMarketMakerBot.java b/apitest/src/test/java/bisq/apitest/botsupport/example/BaseMarketMakerBot.java
deleted file mode 100644
index 34c28f851f3..00000000000
--- a/apitest/src/test/java/bisq/apitest/botsupport/example/BaseMarketMakerBot.java
+++ /dev/null
@@ -1,227 +0,0 @@
-/*
- * This file is part of Bisq.
- *
- * Bisq is free software: you can redistribute it and/or modify it
- * under the terms of the GNU Affero General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or (at
- * your option) any later version.
- *
- * Bisq is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
- * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
- * License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with Bisq. If not, see .
- */
-
-package bisq.apitest.botsupport.example;
-
-import bisq.proto.grpc.TradeInfo;
-
-import protobuf.PaymentAccount;
-
-import com.google.common.util.concurrent.ListeningExecutorService;
-
-import java.security.SecureRandom;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.concurrent.atomic.AtomicBoolean;
-
-import org.slf4j.Logger;
-
-import lombok.Getter;
-import lombok.Setter;
-import lombok.extern.slf4j.Slf4j;
-
-import javax.annotation.Nullable;
-
-import static bisq.apitest.botsupport.protocol.BotProtocol.BTC;
-import static bisq.apitest.botsupport.shutdown.ManualShutdown.isShutdownCalled;
-import static bisq.apitest.botsupport.shutdown.ManualShutdown.setShutdownCalled;
-import static bisq.apitest.config.ApiTestConfig.BSQ;
-import static bisq.cli.TableFormat.formatBalancesTbls;
-import static bisq.cli.TableFormat.formatOfferTable;
-import static bisq.cli.TableFormat.formatPaymentAcctTbl;
-import static java.util.Collections.singletonList;
-import static java.util.concurrent.TimeUnit.DAYS;
-import static java.util.concurrent.TimeUnit.SECONDS;
-
-
-
-import bisq.apitest.botsupport.BotClient;
-import bisq.apitest.botsupport.shutdown.ManualBotShutdownException;
-import bisq.apitest.botsupport.util.BotUtilities;
-import bisq.cli.GrpcClient;
-
-@Getter
-@Slf4j
-abstract class BaseMarketMakerBot {
-
-    protected static final SecureRandom RANDOM = new SecureRandom();
-
-    protected static final String BUYER_BOT_NAME = "Maker/Buyer Bot";
-    protected static final String SELLER_BOT_NAME = "Maker/Seller Bot";
-
-    protected final ListeningExecutorService executor =
-            BotUtilities.getListeningExecutorService("Bisq Bot",
-                    2,
-                    2,
-                    DAYS.toSeconds(1));
-
-    @Nullable
-    @Setter
-    @Getter
-    protected Exception buyerBotException;
-    @Nullable
-    @Setter
-    @Getter
-    protected Exception sellerBotException;
-
-    protected final AtomicBoolean isBuyBotShutdown = new AtomicBoolean(false);
-    protected final AtomicBoolean isSellBotShutdown = new AtomicBoolean(false);
-    protected int numMakerSideBuys = 0;
-    protected int numMakerSideSells = 0;
-
-    protected final String host;
-    protected final int port;
-    protected final String password;
-    protected final BotClient botClient;
-    protected final int newBsqPaymentAccountsLimit;
-    protected final List receiverPaymentAccounts = new ArrayList<>();
-    protected final PaymentAccount senderPaymentAccount;
-
-    protected final List botTradeHistory = new ArrayList<>(); // TODO persist?  Json?
-
-    public BaseMarketMakerBot(String host,
-                              int port,
-                              String password,
-                              int newBsqPaymentAccountsLimit) {
-
-        this.host = host;
-        this.port = port;
-        this.password = password;
-        this.newBsqPaymentAccountsLimit = newBsqPaymentAccountsLimit;
-        this.botClient = new BotClient(new GrpcClient(host, port, password));
-        this.receiverPaymentAccounts.addAll(botClient.getReceiverBsqPaymentAccounts());
-        this.senderPaymentAccount = getOrCreateSenderPaymentAccount();
-    }
-
-    abstract void run();
-
-    protected final PaymentAccount getOrCreateSenderPaymentAccount() {
-        var senderAccounts = botClient.getSenderBsqPaymentAccounts();
-        return senderAccounts.isEmpty()
-                ? botClient.createSenderBsqPaymentAccount()
-                : senderAccounts.get(0);
-    }
-
-    protected final PaymentAccount getNextReceiverPaymentAccount() {
-        if (receiverPaymentAccounts.isEmpty()) {
-            log.warn("You have not set up any BSQ payment accounts."
-                            + "  The bot may create up to {} new accounts as needed, with unique receiving BSQ addresses.",
-                    newBsqPaymentAccountsLimit);
-            var newAccount = botClient.createReceiverBsqPaymentAccount();
-            receiverPaymentAccounts.add(newAccount);
-            log.info("The new receiving payment account id is {}.", newAccount.getId());
-            return newAccount;
-        } else if (receiverPaymentAccounts.size() < newBsqPaymentAccountsLimit) {
-            log.warn("You have {} BSQ payment accounts."
-                            + "  The bot may create up to {} new accounts as needed, with unique receiving BSQ addresses.",
-                    receiverPaymentAccounts.size(),
-                    newBsqPaymentAccountsLimit - receiverPaymentAccounts.size());
-            var newAccount = botClient.createReceiverBsqPaymentAccount();
-            receiverPaymentAccounts.add(newAccount);
-            log.info("The new receiving payment account id is {}.", newAccount.getId());
-            return newAccount;
-        } else {
-            var next = RANDOM.nextInt(receiverPaymentAccounts.size());
-            var nextAccount = receiverPaymentAccounts.get(next);
-            log.info("The next receiving payment account id is {}.", nextAccount.getId());
-            return nextAccount;
-        }
-    }
-
-    protected void waitForManualShutdown() {
-        log.info("When ready to shutdown bot, run '$ touch /tmp/bot-shutdown'.");
-        try {
-            while (!isShutdownCalled()) {
-                rest(10);
-            }
-            log.warn("Manual shutdown signal received.");
-        } catch (ManualBotShutdownException ex) {
-            log.warn(ex.getMessage());
-        }
-    }
-
-    protected void rest(long delayInSeconds) {
-        try {
-            SECONDS.sleep(delayInSeconds);
-        } catch (InterruptedException ignored) {
-            // empty
-        }
-    }
-
-    protected void logStatus(Logger log, PaymentAccount paymentAccount) {
-        log.info("Payment Account:\n{}", formatPaymentAcctTbl(singletonList(paymentAccount)));
-
-        log.info("Balances:\n{}", formatBalancesTbls(botClient.getBalance()));
-
-        var currentOffers = botClient.getMyOffersSortedByDate(BTC);
-        if (currentOffers.isEmpty())
-            log.info("No current offers.");
-        else
-            log.info("Current offers:\n{}", formatOfferTable(botClient.getMyOffersSortedByDate(BTC), BSQ));
-
-        if (botTradeHistory.isEmpty()) {
-            log.info("No trades during this bot run.");
-        } else {
-            log.info("TODO print trades");
-        }
-    }
-
-
-    protected void logWalletBalance(Logger log, String botName, BotClient botClient) {
-        log.info("{} balances:\n{}", botName, formatBalancesTbls(botClient.getBalance()));
-    }
-
-    protected void logManualShutdownWarning(Logger log, String botName) {
-        log.warn("Manual shutdown called, stopping {}.", botName);
-    }
-
-    protected void logFailedTradeError(Logger log, String botName, Exception exception) {
-        log.error("{} could not complete trade.", botName, exception);
-    }
-
-    protected void logBotCompletion(Logger log, String botName, BotClient botClient) {
-        log.info("{} is done.  Balances:\n{}",
-                botName,
-                formatBalancesTbls(botClient.getBalance()));
-    }
-
-    protected boolean botDidFail() {
-        return buyerBotException != null || sellerBotException != null;
-    }
-
-    protected String getBotFailureReason() {
-        StringBuilder reasonBuilder = new StringBuilder();
-
-        if (buyerBotException != null)
-            reasonBuilder.append(BUYER_BOT_NAME).append(" failed: ")
-                    .append(buyerBotException.getMessage()).append("\n");
-
-        if (sellerBotException != null)
-            reasonBuilder.append(SELLER_BOT_NAME).append(" failed: ")
-                    .append(sellerBotException.getMessage()).append("\n");
-
-        return reasonBuilder.toString();
-    }
-
-    protected void shutdownAllBots() {
-        isBuyBotShutdown.set(true);
-        isSellBotShutdown.set(true);
-        setShutdownCalled(true);
-        executor.shutdownNow();
-    }
-}
diff --git a/apitest/src/test/java/bisq/apitest/botsupport/example/BsqMarketMakerBot.java b/apitest/src/test/java/bisq/apitest/botsupport/example/BsqMarketMakerBot.java
deleted file mode 100644
index 5396155cd65..00000000000
--- a/apitest/src/test/java/bisq/apitest/botsupport/example/BsqMarketMakerBot.java
+++ /dev/null
@@ -1,295 +0,0 @@
-/*
- * This file is part of Bisq.
- *
- * Bisq is free software: you can redistribute it and/or modify it
- * under the terms of the GNU Affero General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or (at
- * your option) any later version.
- *
- * Bisq is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
- * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
- * License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with Bisq. If not, see .
- */
-
-package bisq.apitest.botsupport.example;
-
-
-import com.google.common.util.concurrent.FutureCallback;
-import com.google.common.util.concurrent.Futures;
-import com.google.common.util.concurrent.ListenableFuture;
-import com.google.common.util.concurrent.MoreExecutors;
-
-import java.math.BigDecimal;
-
-import java.util.function.Consumer;
-import java.util.function.Predicate;
-
-import lombok.Getter;
-import lombok.SneakyThrows;
-
-import javax.annotation.Nullable;
-
-import static bisq.apitest.botsupport.protocol.BotProtocol.BSQ;
-import static bisq.apitest.botsupport.protocol.BotProtocol.BTC;
-import static bisq.apitest.botsupport.shutdown.ManualShutdown.isShutdownCalled;
-import static bisq.apitest.botsupport.shutdown.ManualShutdown.startShutdownTimer;
-import static bisq.cli.TableFormat.formatBalancesTbls;
-import static bisq.cli.TableFormat.formatOfferTable;
-import static java.lang.String.format;
-import static java.util.concurrent.TimeUnit.MINUTES;
-import static protobuf.OfferPayload.Direction.BUY;
-import static protobuf.OfferPayload.Direction.SELL;
-
-
-
-import bisq.apitest.botsupport.BotClient;
-import bisq.apitest.botsupport.protocol.MarketMakerBotProtocol;
-import bisq.apitest.botsupport.script.BashScriptGenerator;
-import bisq.apitest.botsupport.shutdown.ManualBotShutdownException;
-
-
-@Getter
-public class BsqMarketMakerBot extends BaseMarketMakerBot {
-
-    // Don't use @Slf4j annotation to init log and use in Functions w/out IDE warnings.
-    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(BsqMarketMakerBot.class);
-
-    // TODO make this an option.
-    static final long PROTOCOL_STEP_TIME_LIMIT = MINUTES.toMillis(180);
-
-    public final Predicate shouldLogWalletBalance = (i) -> i % 1 == 0;
-
-    private final BigDecimal targetPrice;
-    private final BigDecimal targetBtcAmount;
-    private final BigDecimal targetSpread;
-    private final int tradeCycleLimit;
-
-    // Use a randomly chosen payment account for each trade.
-    // This is preferred over using the same payment account over and over because
-    // the BSQ receive address never changes, making BSQ payment confirmation riskier.
-    // BSQ payment confirmation is done by matching a rcv address with a txout value,
-    // making the check less certain.  Another way to improve the reliability of
-    // bot BSQ payment confirmations is to vary the BSQ price by a few sats for
-    // each trade.
-    public BsqMarketMakerBot(String host,
-                             int port,
-                             String password,
-                             int newBsqPaymentAccountsLimit,
-                             BigDecimal targetPrice,
-                             BigDecimal targetBtcAmount,
-                             BigDecimal targetSpread,
-                             int tradeCycleLimit) {
-        super(host, port, password, newBsqPaymentAccountsLimit);
-        this.targetPrice = targetPrice;
-        this.targetBtcAmount = targetBtcAmount;
-        this.targetSpread = targetSpread;
-        this.tradeCycleLimit = tradeCycleLimit;
-    }
-
-    public void run() {
-        try {
-
-            if (botClient.iHaveCurrentOffers.test(BSQ)) {
-                log.error("Bot shutting down because you already have BSQ offers in the book."
-                                + "  Finish them with the CLI.\n{}",
-                        formatOfferTable(botClient.getMyOffersSortedByDate(BTC), BSQ));
-                return;
-            }
-
-            startShutdownTimer();
-
-            // Do not start a bot if already shutting down.
-            if (!isShutdownCalled()) {
-                startBot(buyMakerBot,
-                        botClient,
-                        BUYER_BOT_NAME);
-            }
-
-            rest(15);
-
-            // Do not start another bot if the 1st one is already shutting down.
-            if (!isShutdownCalled()) {
-                startBot(sellMakerBot,
-                        botClient,
-                        SELLER_BOT_NAME);
-            }
-
-            // TODO Optionally auto-shutdown after max cycles are complete.
-            //  See boolean stayAlive usage.
-            waitForManualShutdown();
-
-        } catch (ManualBotShutdownException ex) {
-            log.warn("{}  Shutting down bot before test completion", ex.getMessage());
-        } catch (Throwable t) {
-            log.error("Uncontrolled bot shutdown caused by uncaught bot exception", t);
-        }
-    }
-
-    protected void startBot(Consumer bot,
-                            BotClient botClient,
-                            String botName) {
-        try {
-            log.info("Starting {}", botName);
-            @SuppressWarnings({"unchecked"})
-            ListenableFuture future =
-                    (ListenableFuture) executor.submit(() -> bot.accept(botClient));
-            Futures.addCallback(future, new FutureCallback<>() {
-                @Override
-                public void onSuccess(@Nullable Void ignored) {
-                    // 'Success' means a controlled shutdown that might be caused by an
-                    // error.  The test case should only fail if the shutdown was caused
-                    // by and exception.
-                    log.info("{} shutdown.", botName);
-                }
-
-                @SneakyThrows
-                @Override
-                public void onFailure(Throwable t) {
-                    if (t instanceof ManualBotShutdownException) {
-                        log.warn("Manually shutting down {} thread.", botName);
-                    } else {
-                        log.error("Fatal error during {} run.", botName, t);
-                    }
-                    shutdownAllBots();
-                }
-            }, MoreExecutors.directExecutor());
-
-        } catch (Exception ex) {
-            log.error("", ex);
-            throw new IllegalStateException(format("Error starting %s.", botName), ex);
-        }
-    }
-
-    protected final Consumer buyMakerBot = (botClient) -> {
-        try {
-            while (numMakerSideBuys < this.getTradeCycleLimit()) {
-                // Make sure the # of buy & sell offers never differ by more than 1.
-                var canCreateNextOffer = numMakerSideBuys == 0 || numMakerSideBuys <= numMakerSideSells;
-                if (canCreateNextOffer) {
-                    var receiverPaymentAccount = getNextReceiverPaymentAccount();
-                    MarketMakerBotProtocol botProtocol = new MarketMakerBotProtocol(BUYER_BOT_NAME,
-                            botClient,
-                            receiverPaymentAccount,
-                            PROTOCOL_STEP_TIME_LIMIT,
-                            new BashScriptGenerator(password, port, receiverPaymentAccount.getId(), false),
-                            BUY.name(),
-                            this.getTargetPrice(),
-                            this.getTargetBtcAmount(),
-                            this.getTargetSpread(),
-                            0.15,
-                            BSQ,
-                            60);
-                    botProtocol.run();
-                    numMakerSideBuys++;
-                    logTradingProgress();
-                    /*
-                    if (shouldLogWalletBalance.test(numMakerSideBuys))
-                        logWalletBalance(log, BUYER_BOT_NAME, botClient);
-                     */
-                } else {
-                    logOfferAlreadyExistsWarning(BUYER_BOT_NAME);
-                }
-                rest(20);
-            }
-        } catch (ManualBotShutdownException ex) {
-            logManualShutdownWarning(log, BUYER_BOT_NAME);
-            shutdownAllBots();
-            // Exit the function, do not try to get balances below because the
-            // server may be shutting down.
-            return;
-        } catch (Exception ex) {
-            logFailedTradeError(log, BUYER_BOT_NAME, ex);
-            shutdownAllBots();
-            // Fatal error, do not try to get balances below because server is shutting down.
-            this.setBuyerBotException(ex);
-            return;
-        }
-        logBotCompletion(log, BUYER_BOT_NAME, botClient);
-        isBuyBotShutdown.set(true);
-    };
-
-    public final Consumer sellMakerBot = (botClient) -> {
-        try {
-            while (numMakerSideSells < this.getTradeCycleLimit()) {
-                // Make sure the # of buy & sell offers never differ by more than 1.
-                var canCreateNextOffer = numMakerSideSells == 0 || numMakerSideSells <= numMakerSideBuys;
-                if (canCreateNextOffer) {
-                    var senderPaymentAccount = getSenderPaymentAccount();
-                    MarketMakerBotProtocol botProtocol = new MarketMakerBotProtocol(SELLER_BOT_NAME,
-                            botClient,
-                            senderPaymentAccount,
-                            PROTOCOL_STEP_TIME_LIMIT,
-                            new BashScriptGenerator(password, port, senderPaymentAccount.getId(), false),
-                            SELL.name(),
-                            this.getTargetPrice(),
-                            this.getTargetBtcAmount(),
-                            this.getTargetSpread(),
-                            0.15,
-                            BSQ,
-                            60);
-                    botProtocol.run();
-                    numMakerSideSells++;
-                    logTradingProgress();
-                    /*
-                    if (shouldLogWalletBalance.test(numMakerSideSells))
-                        logWalletBalance(log, SELLER_BOT_NAME, botClient);
-                     */
-                } else {
-                    logOfferAlreadyExistsWarning(SELLER_BOT_NAME);
-                }
-                rest(20);
-            }
-        } catch (ManualBotShutdownException ex) {
-            logManualShutdownWarning(log, SELLER_BOT_NAME);
-            shutdownAllBots();
-            // Exit the function, do not try to get balances below because the
-            // server may be shutting down.
-            return;
-        } catch (Exception ex) {
-            logFailedTradeError(log, SELLER_BOT_NAME, ex);
-            shutdownAllBots();
-            // Fatal error, do not try to get balances below because server is shutting down.
-            this.setSellerBotException(ex);
-            return;
-        }
-        logBotCompletion(log, SELLER_BOT_NAME, botClient);
-        isSellBotShutdown.set(true);
-    };
-
-    protected void logOfferAlreadyExistsWarning(String botName, String direction) {
-        log.warn("{} will not create a new {} while existing {} offer is waiting to be taken."
-                        + "  Each trade cycle is 1 buy and 1 sell.",
-                botName,
-                direction,
-                direction);
-    }
-
-    protected void logOfferAlreadyExistsWarning(String botName) {
-        log.warn("{} will not create a new offer while an existing offer is waiting to be taken."
-                        + "  Each trade cycle is 1 buy and 1 sell.",
-                botName);
-    }
-
-    protected void logTradingProgress() {
-        String completedTradeCycles;
-        if (numMakerSideBuys == numMakerSideSells)
-            completedTradeCycles = String.valueOf(numMakerSideBuys);
-        else if (numMakerSideBuys > numMakerSideSells)
-            completedTradeCycles = numMakerSideSells + ".5";
-        else
-            completedTradeCycles = numMakerSideBuys + ".5";
-
-        log.info("===================================================================================================");
-        log.info("Completed {} trades in {} trade cycles.  Balance After {} BUY and {} SELL trades:\n{}",
-                numMakerSideBuys + numMakerSideSells,
-                completedTradeCycles,
-                numMakerSideBuys,
-                numMakerSideSells,
-                formatBalancesTbls(botClient.getBalance()));
-        log.info("===================================================================================================");
-    }
-}
diff --git a/apitest/src/test/java/bisq/apitest/botsupport/example/BsqMarketMakerBotTest.java b/apitest/src/test/java/bisq/apitest/botsupport/example/BsqMarketMakerBotTest.java
deleted file mode 100644
index 4ce7cb85ad0..00000000000
--- a/apitest/src/test/java/bisq/apitest/botsupport/example/BsqMarketMakerBotTest.java
+++ /dev/null
@@ -1,250 +0,0 @@
-package bisq.apitest.botsupport.example;
-
-import bisq.proto.grpc.OfferInfo;
-
-import protobuf.PaymentAccount;
-
-import java.util.ArrayList;
-import java.util.List;
-
-import lombok.extern.slf4j.Slf4j;
-
-import static bisq.apitest.botsupport.protocol.BotProtocol.BTC;
-import static bisq.apitest.config.ApiTestConfig.BSQ;
-import static bisq.cli.CurrencyFormat.formatBsqAmount;
-import static bisq.cli.TableFormat.formatBalancesTbls;
-import static bisq.cli.TableFormat.formatOfferTable;
-import static java.lang.String.format;
-import static java.util.concurrent.TimeUnit.SECONDS;
-import static protobuf.OfferPayload.Direction.BUY;
-import static protobuf.OfferPayload.Direction.SELL;
-
-
-
-import bisq.apitest.botsupport.BotClient;
-import bisq.cli.GrpcClient;
-import bisq.cli.TradeFormat;
-
-// Requirements:  test harness, registered agents, BsqMarketMakerBot.
-//
-// $ ./bisq-apitest --apiPassword=xyz --supportingApps=bitcoind,seednode,arbdaemon,alicedaemon,bobdaemon  --shutdownAfterTests=false --enableBisqDebugging=false
-// $ ./bisq-bot --password=xyz --port=9998 bsqmarketmaker --target-btc-amount=0.05 --target-price=0.00004 --target-spread=13.00 --trade-cycle-limit=5 --new-payment-accts-limit=10
-// $ ./bisq-bot --password=xyz --port=9998 bsqmarketmaker --target-btc-amount=0.05 --target-price=0.00004 --target-spread=13.00 --trade-cycle-limit=20 --new-payment-accts-limit=20
-// Only use the one below without the the bloom filter hack.
-// $ ./bisq-bot --password=xyz --port=9998 bsqmarketmaker --target-btc-amount=0.05 --target-price=0.00004 --target-spread=13.00 --trade-cycle-limit=100 --new-payment-accts-limit=30
-//
-@Slf4j
-public class BsqMarketMakerBotTest {
-
-    // TODO Install shutdown hook, check state.
-
-    private static final int MAX_TRADE_CYCLES = 10;
-
-    private final BotClient botClient;
-    private final PaymentAccount paymentAccount;
-    private final List offers;
-
-    public BsqMarketMakerBotTest() {
-        this.botClient = new BotClient(new GrpcClient("localhost", 9999, "xyz"));
-        this.paymentAccount = botClient.createCryptoCurrencyPaymentAccount("Bob's Instant Acct", true);
-        this.offers = new ArrayList<>();
-    }
-
-    public void runTradeCycles() {
-        try {
-            verifyHavePaymentAccount();
-
-            int tradeCycle = 0;
-            while (tradeCycle <= MAX_TRADE_CYCLES) {
-                offers.addAll(botClient.getOffersSortedByDate(BTC));
-                verifyHaveOffers(tradeCycle);  // Should 1 BUY and 1 SELL for each trade cycle
-
-                takeBuyBsqOffer();
-                SECONDS.sleep(30);
-                takeSellBsqOffer();
-                SECONDS.sleep(10);
-
-                verifyHaveTrades();
-                SECONDS.sleep(5);
-
-                sendAliceBsqPaymentForHerBtc();
-                SECONDS.sleep(60);
-
-                verifyBsqReceivedFromAlice();
-                SECONDS.sleep(60);
-
-                SECONDS.sleep(5);
-                closeTrades();
-
-                SECONDS.sleep(5);
-                printBalance();
-
-                offers.clear();
-
-                tradeCycle++;
-                if (tradeCycle < MAX_TRADE_CYCLES) {
-                    log.info("Completed {} trade cycle(s).  Starting the next in 1 minute.", tradeCycle);
-                    SECONDS.sleep(60);
-                }
-            }
-
-            log.info("Shutting down taker bot.");
-
-        } catch (Throwable t) {
-            t.printStackTrace();
-            System.exit(1);
-        }
-    }
-
-    protected void takeBuyBsqOffer() {
-        var offer = offers.stream().filter(o -> o.getDirection().equals(SELL.name())).findFirst().get();
-        log.info("Bob is taking Buy BSQ (Sell BTC) offer {} using payment account {}.",
-                offer.getId(),
-                paymentAccount.getId());
-        botClient.takeOffer(offer.getId(), paymentAccount, BSQ);
-    }
-
-    protected void takeSellBsqOffer() {
-        var offer = offers.stream().filter(o -> o.getDirection().equals(BUY.name())).findFirst().get();
-        log.info("Bob is taking Sell BSQ (Buy BTC) offer {} using payment account {}.",
-                offer.getId(),
-                paymentAccount.getId());
-        botClient.takeOffer(offer.getId(), paymentAccount, BTC);
-    }
-
-    protected void verifyHavePaymentAccount() {
-        if (paymentAccount == null)
-            throw new IllegalStateException("No payment account for taking offers.");
-
-        log.info("Bob is using '{}' with id = {}", paymentAccount.getAccountName(), paymentAccount.getId());
-    }
-
-    protected void verifyHaveOffers(int currentTradeCycle) {
-        if (currentTradeCycle == MAX_TRADE_CYCLES) {
-            log.warn("We're done, shutting down");
-            System.exit(0);
-        }
-
-        if (offers.isEmpty()) {
-            if (currentTradeCycle < MAX_TRADE_CYCLES) {
-                throw new IllegalStateException(
-                        format("No offers to take in trade cycle %d.", currentTradeCycle));
-            } else {
-                log.warn("No offers, might be finished after {} cycle(s).", currentTradeCycle);
-                return;
-            }
-        } else if (offers.size() == 1) {
-            // TODO Wait for the next offer to sync up again.
-            for (int i = 0; i < 10; i++) {
-                try {
-                    log.warn("There is only 1 available offer {} at start of cycle, will check again in 10 seconds.",
-                            offers.get(0).getId());
-                    SECONDS.sleep(10);
-                    offers.clear();
-                    offers.addAll(botClient.getOffersSortedByDate(BTC));
-                    if (offers.size() == 2) {
-                        log.info("Now Bob can take offers:\n{}", formatOfferTable(offers, BSQ));
-                        return;
-                    }
-                } catch (InterruptedException ignored) {
-                    // empty
-                }
-            }
-            throw new IllegalStateException(
-                    format("No offers to take in trade cycle %d.", currentTradeCycle));
-        } else {
-            log.info("Bob can take offers:\n{}", formatOfferTable(offers, BSQ));
-        }
-    }
-
-    protected void verifyHaveTrades() {
-        var alicesBuyOfferId = getAlicesBuyBsqOfferId();
-        var bobsBuyBsqTrade = botClient.getTrade(alicesBuyOfferId);
-        if (bobsBuyBsqTrade != null) {
-            log.info("Bob's Buy BSQ (Sell BTC) Trade: \n{}", TradeFormat.format(bobsBuyBsqTrade));
-        } else {
-            throw new IllegalStateException(format("Take BUY offer %s failed.", alicesBuyOfferId));
-        }
-
-        var alicesSellOfferId = getAlicesSellBsqOfferId();
-        var bobsSellBsqTrade = botClient.getTrade(alicesSellOfferId);
-        if (bobsSellBsqTrade != null) {
-            log.info("Bob's Sell BSQ (Buy BTC) Trade: \n{}", TradeFormat.format(bobsSellBsqTrade));
-        } else {
-            throw new IllegalStateException(format("Take SELL offer %s failed.", alicesSellOfferId));
-        }
-    }
-
-    protected void sendAliceBsqPaymentForHerBtc() {
-        try {
-            var tradeId = getAlicesBuyBsqOfferId();
-            var trade = botClient.getTrade(tradeId);
-            botClient.makeBsqPayment(trade);
-            log.info("Payment sent, generate a btc block now.");
-            SECONDS.sleep(15);
-            botClient.sendConfirmPaymentStartedMessage(tradeId);
-        } catch (Exception ex) {
-            ex.printStackTrace();
-        }
-    }
-
-    protected void verifyBsqReceivedFromAlice() {
-        try {
-            var tradeId = getAlicesSellBsqOfferId();
-            var trade = botClient.getTrade(tradeId);
-
-            // TODO refactor into BotClient
-            var contract = trade.getContract();
-            var bsqSats = trade.getOffer().getVolume();
-            var receiveAmountAsString = formatBsqAmount(bsqSats);
-            var address = contract.getIsBuyerMakerAndSellerTaker()
-                    ? contract.getTakerPaymentAccountPayload().getAddress()
-                    : contract.getMakerPaymentAccountPayload().getAddress();
-            log.info("Bob verifying payment of {} BSQ was received to address {} for trade with id {}.",
-                    receiveAmountAsString,
-                    address,
-                    tradeId);
-            log.info("Give bot time to send payment.");
-            for (int i = 0; i < 5; i++) {
-                SECONDS.sleep(30);
-                boolean receivedPayment = botClient.verifyBsqSentToAddress(address, receiveAmountAsString);
-                if (receivedPayment) {
-                    log.warn("Payment received, sending payment rcvd confirmation.");
-                    botClient.sendConfirmPaymentReceivedMessage(tradeId);
-                    break;
-                } else {
-                    log.warn("Payment NOT received.");
-                }
-            }
-        } catch (Exception exception) {
-            exception.printStackTrace();
-        }
-    }
-
-    protected void closeTrades() {
-        for (OfferInfo offer : offers) {
-            var tradeId = offer.getId();
-            log.info("Sending keepfunds request for trade {}.", tradeId);
-            botClient.sendKeepFundsMessage(tradeId);
-        }
-    }
-
-    protected void printBalance() {
-        log.info("Finished Trade Cycle\n{}", formatBalancesTbls(botClient.getBalance()));
-    }
-
-    protected String getAlicesBuyBsqOfferId() {
-        // Buy BSQ = SELL BTC
-        return offers.stream().filter(o -> o.getDirection().equals(SELL.name())).findFirst().get().getId();
-    }
-
-    protected String getAlicesSellBsqOfferId() {
-        // Sell BSQ = BUY BTC
-        return offers.stream().filter(o -> o.getDirection().equals(BUY.name())).findFirst().get().getId();
-    }
-
-    public static void main(String[] args) {
-        BsqMarketMakerBotTest bob = new BsqMarketMakerBotTest();
-        bob.runTradeCycles();
-    }
-}
diff --git a/apitest/src/test/java/bisq/apitest/botsupport/example/CancelOffersBot.java b/apitest/src/test/java/bisq/apitest/botsupport/example/CancelOffersBot.java
deleted file mode 100644
index bbcf07afec7..00000000000
--- a/apitest/src/test/java/bisq/apitest/botsupport/example/CancelOffersBot.java
+++ /dev/null
@@ -1,58 +0,0 @@
-/*
- * This file is part of Bisq.
- *
- * Bisq is free software: you can redistribute it and/or modify it
- * under the terms of the GNU Affero General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or (at
- * your option) any later version.
- *
- * Bisq is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
- * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
- * License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with Bisq. If not, see .
- */
-
-package bisq.apitest.botsupport.example;
-
-import bisq.proto.grpc.OfferInfo;
-
-import lombok.extern.slf4j.Slf4j;
-
-import static bisq.apitest.botsupport.protocol.BotProtocol.BTC;
-
-
-
-import bisq.apitest.botsupport.BotClient;
-import bisq.cli.GrpcClient;
-
-/**
- * Be careful when using this on mainnet.  Offer fees are forfeited.
- */
-@Slf4j
-public class CancelOffersBot {
-    // TODO refactor BaseMarketMakerBot -> BaseBot, which includes grpc client ctr args.
-
-    protected final String host;
-    protected final int port;
-    protected final String password;
-    protected final BotClient botClient;
-
-    public CancelOffersBot(String host, int port, String password) {
-        this.host = host;
-        this.port = port;
-        this.password = password;
-        this.botClient = new BotClient(new GrpcClient(host, port, password));
-    }
-
-    public void cancelAllBsqOffers() {
-        var myOffers = botClient.getMyOffersSortedByDate(BTC);
-        for (OfferInfo myOffer : myOffers) {
-            log.info("Removing offer {} from offer book.", myOffer.getId());
-            botClient.cancelOffer(myOffer);
-        }
-        log.info("Removed {} offers.", myOffers.size());
-    }
-}
diff --git a/apitest/src/test/java/bisq/apitest/botsupport/opts/BotOptLabel.java b/apitest/src/test/java/bisq/apitest/botsupport/opts/BotOptLabel.java
deleted file mode 100644
index dbe861bd3d2..00000000000
--- a/apitest/src/test/java/bisq/apitest/botsupport/opts/BotOptLabel.java
+++ /dev/null
@@ -1,31 +0,0 @@
-/*
- * This file is part of Bisq.
- *
- * Bisq is free software: you can redistribute it and/or modify it
- * under the terms of the GNU Affero General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or (at
- * your option) any later version.
- *
- * Bisq is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
- * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
- * License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with Bisq. If not, see .
- */
-
-package bisq.apitest.botsupport.opts;
-
-import bisq.cli.opts.OptLabel;
-
-/**
- * Bot opt label definitions.
- */
-public class BotOptLabel extends OptLabel {
-    public final static String OPT_TARGET_BTC_AMOUNT = "target-btc-amount";
-    public final static String OPT_TARGET_PRICE = "target-price";
-    public final static String OPT_TARGET_SPREAD = "target-spread";
-    public final static String OPT_TRADE_CYCLE_LIMIT = "trade-cycle-limit";
-    public final static String OPT_NEW_PAYMENT_ACCOUNTS_LIMIT = "new-payment-accts-limit";
-}
diff --git a/apitest/src/test/java/bisq/apitest/botsupport/opts/BsqMarketMakerBotOptionParser.java b/apitest/src/test/java/bisq/apitest/botsupport/opts/BsqMarketMakerBotOptionParser.java
deleted file mode 100644
index b42c5ceb30e..00000000000
--- a/apitest/src/test/java/bisq/apitest/botsupport/opts/BsqMarketMakerBotOptionParser.java
+++ /dev/null
@@ -1,100 +0,0 @@
-/*
- * This file is part of Bisq.
- *
- * Bisq is free software: you can redistribute it and/or modify it
- * under the terms of the GNU Affero General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or (at
- * your option) any later version.
- *
- * Bisq is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
- * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
- * License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with Bisq. If not, see .
- */
-
-package bisq.apitest.botsupport.opts;
-
-import joptsimple.OptionSpec;
-
-import static bisq.apitest.botsupport.opts.BotOptLabel.*;
-
-
-
-import bisq.cli.opts.AbstractMethodOptionParser;
-import bisq.cli.opts.MethodOpts;
-
-public class BsqMarketMakerBotOptionParser extends AbstractMethodOptionParser implements MethodOpts {
-
-    final OptionSpec targetPriceOpt = parser.accepts(OPT_TARGET_PRICE,
-            "target price in btc for 1 bsq")
-            .withRequiredArg();
-
-    final OptionSpec targetBtcAmountOpt = parser.accepts(OPT_TARGET_BTC_AMOUNT,
-            "target btc amount used to buy or sell bsq")
-            .withRequiredArg();
-
-    final OptionSpec targetSpreadOpt = parser.accepts(OPT_TARGET_SPREAD,
-            "mm bot target spread")
-            .withRequiredArg();
-
-    final OptionSpec tradeCycleLimitOpt = parser.accepts(OPT_TRADE_CYCLE_LIMIT,
-            "mm bot trade limit (1 sell plus 1 buy = count 1")
-            .withRequiredArg()
-            .ofType(Integer.class)
-            .defaultsTo(1);
-
-    final OptionSpec newBsqPaymentAccountsLimitOpt = parser.accepts(OPT_NEW_PAYMENT_ACCOUNTS_LIMIT,
-            "limit # of new bsq payment account created by mm bot")
-            .withRequiredArg()
-            .ofType(Integer.class)
-            .defaultsTo(5);
-
-    public BsqMarketMakerBotOptionParser(String[] args) {
-        super(args);
-    }
-
-    public BsqMarketMakerBotOptionParser parse() {
-        super.parse();
-
-        // Short circuit opt validation if user just wants help.
-        if (options.has(helpOpt))
-            return this;
-
-        if (!options.has(targetPriceOpt) || options.valueOf(targetPriceOpt).isEmpty())
-            throw new IllegalArgumentException("no target price specified");
-
-        if (!options.has(targetBtcAmountOpt) || options.valueOf(targetBtcAmountOpt).isEmpty())
-            throw new IllegalArgumentException("no target btc amount specified");
-
-        if (!options.has(targetSpreadOpt) || options.valueOf(targetSpreadOpt).isEmpty())
-            throw new IllegalArgumentException("no target spread specified");
-
-        if (!options.has(tradeCycleLimitOpt))
-            throw new IllegalArgumentException("no trade cycle limit specified");
-
-        return this;
-    }
-
-    public String getTargetPrice() {
-        return options.valueOf(targetPriceOpt);
-    }
-
-    public String getTargetBtcAmount() {
-        return options.valueOf(targetBtcAmountOpt);
-    }
-
-    public String getTargetSpread() {
-        return options.valueOf(targetSpreadOpt);
-    }
-
-    public int getTradeCycleLimit() {
-        return options.valueOf(tradeCycleLimitOpt);
-    }
-
-    public int getNewBsqPaymentAccountsLimit() {
-        return options.valueOf(newBsqPaymentAccountsLimitOpt);
-    }
-}

From bddc273bb27ba73f93ed9e9cea4defa598c9f28c Mon Sep 17 00:00:00 2001
From: ghubstan <36207203+ghubstan@users.noreply.github.com>
Date: Fri, 2 Apr 2021 12:58:46 -0300
Subject: [PATCH 21/21] Fix spelling

---
 cli/src/main/java/bisq/cli/TradeFormat.java | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/cli/src/main/java/bisq/cli/TradeFormat.java b/cli/src/main/java/bisq/cli/TradeFormat.java
index d11f8847be4..dbf8dbf4b86 100644
--- a/cli/src/main/java/bisq/cli/TradeFormat.java
+++ b/cli/src/main/java/bisq/cli/TradeFormat.java
@@ -59,7 +59,7 @@ public static String format(TradeInfo tradeInfo) {
                 "%" + (COL_HEADER_TRADE_TAKER_FEE.length() + 2) + "s"
                 : "";
 
-        boolean showBsqBuyerAddress = shouldShowBqsBuyerAddress(tradeInfo, isTaker);
+        boolean showBsqBuyerAddress = shouldShowBsqBuyerAddress(tradeInfo, isTaker);
         Supplier bsqBuyerAddressHeader = () -> showBsqBuyerAddress ? COL_HEADER_TRADE_BSQ_BUYER_ADDRESS : "";
         Supplier bsqBuyerAddressHeaderSpec = () -> showBsqBuyerAddress ? "%s" : "";
 
@@ -203,7 +203,7 @@ private static String formatTradeData(String format,
         }
     };
 
-    private static boolean shouldShowBqsBuyerAddress(TradeInfo tradeInfo, boolean isTaker) {
+    private static boolean shouldShowBsqBuyerAddress(TradeInfo tradeInfo, boolean isTaker) {
         if (tradeInfo.getOffer().getBaseCurrencyCode().equals("BTC")) {
             return false;
         } else {