diff --git a/apitest/src/test/java/bisq/apitest/method/wallet/BtcTxFeeRateTest.java b/apitest/src/test/java/bisq/apitest/method/wallet/BtcTxFeeRateTest.java
index 07b56eb7916..d5a4e4ef340 100644
--- a/apitest/src/test/java/bisq/apitest/method/wallet/BtcTxFeeRateTest.java
+++ b/apitest/src/test/java/bisq/apitest/method/wallet/BtcTxFeeRateTest.java
@@ -2,6 +2,8 @@
import bisq.core.api.model.TxFeeRateInfo;
+import io.grpc.StatusRuntimeException;
+
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.AfterAll;
@@ -15,8 +17,11 @@
import static bisq.apitest.Scaffold.BitcoinCoreApp.bitcoind;
import static bisq.apitest.config.BisqAppConfig.alicedaemon;
import static bisq.apitest.config.BisqAppConfig.seednode;
+import static bisq.common.config.BaseCurrencyNetwork.BTC_DAO_REGTEST;
+import static java.lang.String.format;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
@@ -50,17 +55,28 @@ public void testGetTxFeeRate(final TestInfo testInfo) {
@Test
@Order(2)
- public void testSetTxFeeRate(final TestInfo testInfo) {
- TxFeeRateInfo txFeeRateInfo = setTxFeeRate(alicedaemon, 10);
+ public void testSetInvalidTxFeeRateShouldThrowException(final TestInfo testInfo) {
+ Throwable exception = assertThrows(StatusRuntimeException.class, () ->
+ setTxFeeRate(alicedaemon, 10));
+ String expectedExceptionMessage =
+ format("UNKNOWN: tx fee rate preference must be >= %d sats/byte",
+ BTC_DAO_REGTEST.getDefaultMinFeePerVbyte());
+ assertEquals(expectedExceptionMessage, exception.getMessage());
+ }
+
+ @Test
+ @Order(3)
+ public void testSetValidTxFeeRate(final TestInfo testInfo) {
+ TxFeeRateInfo txFeeRateInfo = setTxFeeRate(alicedaemon, 15);
log.debug("{} -> Fee rates with custom preference: {}", testName(testInfo), txFeeRateInfo);
assertTrue(txFeeRateInfo.isUseCustomTxFeeRate());
- assertEquals(10, txFeeRateInfo.getCustomTxFeeRate());
+ assertEquals(15, txFeeRateInfo.getCustomTxFeeRate());
assertTrue(txFeeRateInfo.getFeeServiceRate() > 0);
}
@Test
- @Order(3)
+ @Order(4)
public void testUnsetTxFeeRate(final TestInfo testInfo) {
TxFeeRateInfo txFeeRateInfo = unsetTxFeeRate(alicedaemon);
log.debug("{} -> Fee rate with no preference: {}", testName(testInfo), txFeeRateInfo);
diff --git a/apitest/src/test/java/bisq/apitest/scenario/ScriptedBotTest.java b/apitest/src/test/java/bisq/apitest/scenario/ScriptedBotTest.java
new file mode 100644
index 00000000000..aa72cd27556
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/scenario/ScriptedBotTest.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;
+
+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.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 java.net.InetAddress.getLoopbackAddress;
+import static org.junit.jupiter.api.Assertions.fail;
+
+
+
+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;
+import bisq.cli.GrpcStubs;
+
+// The test case is enabled if AbstractBotTest#botScriptExists() returns true.
+@EnabledIf("botScriptExists")
+@Slf4j
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+public class ScriptedBotTest extends AbstractBotTest {
+
+ private RobotBob robotBob;
+
+ @BeforeAll
+ public static void startTestHarness() {
+ botScript = deserializeBotScript();
+
+ if (botScript.isUseTestHarness()) {
+ startSupportingApps(true,
+ true,
+ bitcoind,
+ seednode,
+ arbdaemon,
+ alicedaemon,
+ bobdaemon);
+ } else {
+ // We need just enough configurations to make sure Bob and testers use
+ // the right apiPassword, to create a bitcoin-cli helper, and RobotBob's
+ // gRPC stubs. But the user will have to register dispute agents before
+ // an offer can be taken.
+ config = new ApiTestConfig("--apiPassword", "xyz");
+ bitcoinCli = new BitcoinCliHelper(config);
+ bobStubs = new GrpcStubs(getLoopbackAddress().getHostAddress(),
+ bobdaemon.apiPort,
+ config.apiPassword);
+ log.warn("Don't forget to register dispute agents before trying to trade with me.");
+ }
+
+ botClient = new BotClient(bobStubs);
+ }
+
+ @BeforeEach
+ public void initRobotBob() {
+ try {
+ BashScriptGenerator bashScriptGenerator = getBashScriptGenerator();
+ robotBob = new RobotBob(botClient, botScript, bitcoinCli, bashScriptGenerator);
+ } catch (Exception ex) {
+ fail(ex);
+ }
+ }
+
+ @Test
+ @Order(1)
+ public void runRobotBob() {
+ try {
+
+ startShutdownTimer();
+ robotBob.run();
+
+ } 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 throwable) {
+ fail(throwable);
+ }
+ }
+
+ @AfterAll
+ public static void tearDown() {
+ if (botScript.isUseTestHarness())
+ tearDownScaffold();
+ }
+}
diff --git a/apitest/src/test/java/bisq/apitest/scenario/WalletTest.java b/apitest/src/test/java/bisq/apitest/scenario/WalletTest.java
index 0fff4bf694d..a3ebba4ca2f 100644
--- a/apitest/src/test/java/bisq/apitest/scenario/WalletTest.java
+++ b/apitest/src/test/java/bisq/apitest/scenario/WalletTest.java
@@ -104,7 +104,8 @@ public void testTxFeeRateMethods(final TestInfo testInfo) {
BtcTxFeeRateTest test = new BtcTxFeeRateTest();
test.testGetTxFeeRate(testInfo);
- test.testSetTxFeeRate(testInfo);
+ test.testSetInvalidTxFeeRateShouldThrowException(testInfo);
+ test.testSetValidTxFeeRate(testInfo);
test.testUnsetTxFeeRate(testInfo);
}
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/AbstractBotTest.java b/apitest/src/test/java/bisq/apitest/scenario/bot/AbstractBotTest.java
new file mode 100644
index 00000000000..2252bbf10be
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/AbstractBotTest.java
@@ -0,0 +1,110 @@
+/*
+ * 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.core.locale.Country;
+
+import protobuf.PaymentAccount;
+
+import com.google.gson.GsonBuilder;
+
+import java.nio.file.Paths;
+
+import java.io.File;
+import java.io.IOException;
+
+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.lang.System.getProperty;
+import static java.nio.file.Files.readAllBytes;
+
+
+
+import bisq.apitest.method.MethodTest;
+import bisq.apitest.scenario.bot.script.BashScriptGenerator;
+import bisq.apitest.scenario.bot.script.BotScript;
+
+@Slf4j
+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 BashScriptGenerator getBashScriptGenerator() {
+ if (botScript.isUseTestHarness()) {
+ PaymentAccount alicesAccount = createAlicesPaymentAccount();
+ botScript.setPaymentAccountIdForCliScripts(alicesAccount.getId());
+ }
+ return new BashScriptGenerator(config.apiPassword,
+ botScript.getApiPortForCliScripts(),
+ botScript.getPaymentAccountIdForCliScripts(),
+ botScript.isPrintCliScripts());
+ }
+
+ private PaymentAccount createAlicesPaymentAccount() {
+ BotPaymentAccountGenerator accountGenerator =
+ new BotPaymentAccountGenerator(new BotClient(aliceStubs));
+ String paymentMethodId = botScript.getBotPaymentMethodId();
+ if (paymentMethodId != null) {
+ if (paymentMethodId.equals(CLEAR_X_CHANGE_ID)) {
+ // Only Zelle test accts are supported now.
+ return accountGenerator.createZellePaymentAccount(
+ "Alice's Zelle Account",
+ "Alice");
+ } else {
+ throw new UnsupportedOperationException(
+ format("This test harness bot does not work with %s payment accounts yet.",
+ getPaymentMethodById(paymentMethodId).getDisplayString()));
+ }
+ } else {
+ String countryCode = botScript.getCountryCode();
+ Country country = findCountryByCode(countryCode).orElseThrow(() ->
+ new IllegalArgumentException(countryCode + " is not a valid iso country code."));
+ return accountGenerator.createF2FPaymentAccount(country,
+ "Alice's " + country.name + " F2F Account");
+ }
+ }
+
+ protected static BotScript deserializeBotScript() {
+ try {
+ File botScriptFile = new File(getProperty("java.io.tmpdir"), BOT_SCRIPT_NAME);
+ String json = new String(readAllBytes(Paths.get(botScriptFile.getPath())));
+ return new GsonBuilder().setPrettyPrinting().create().fromJson(json, BotScript.class);
+ } catch (IOException ex) {
+ throw new IllegalStateException("Error reading script bot file contents.", ex);
+ }
+ }
+
+ @SuppressWarnings("unused") // This is used by the jupiter framework.
+ protected static boolean botScriptExists() {
+ File botScriptFile = new File(getProperty("java.io.tmpdir"), BOT_SCRIPT_NAME);
+ if (botScriptFile.exists()) {
+ botScriptFile.deleteOnExit();
+ log.info("Enabled, found {}.", botScriptFile.getPath());
+ return true;
+ } else {
+ log.info("Skipped, no bot script.\n\tTo generate a bot-script.json file, see BotScriptGenerator.");
+ return false;
+ }
+ }
+}
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/Bot.java b/apitest/src/test/java/bisq/apitest/scenario/bot/Bot.java
new file mode 100644
index 00000000000..2e8a248a4c3
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/Bot.java
@@ -0,0 +1,77 @@
+package bisq.apitest.scenario.bot;
+
+import bisq.core.locale.Country;
+
+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.method.BitcoinCliHelper;
+import bisq.apitest.scenario.bot.script.BashScriptGenerator;
+import bisq.apitest.scenario.bot.script.BotScript;
+
+@Slf4j
+public
+class Bot {
+
+ static final String MAKE = "MAKE";
+ static final String TAKE = "TAKE";
+
+ protected final BotClient botClient;
+ protected final BitcoinCliHelper bitcoinCli;
+ protected final BashScriptGenerator bashScriptGenerator;
+ protected final String[] actions;
+ protected final long protocolStepTimeLimitInMs;
+ protected final boolean stayAlive;
+ protected final boolean isUsingTestHarness;
+ protected final PaymentAccount paymentAccount;
+
+ public Bot(BotClient botClient,
+ BotScript botScript,
+ BitcoinCliHelper bitcoinCli,
+ BashScriptGenerator bashScriptGenerator) {
+ this.botClient = botClient;
+ this.bitcoinCli = bitcoinCli;
+ this.bashScriptGenerator = bashScriptGenerator;
+ this.actions = botScript.getActions();
+ this.protocolStepTimeLimitInMs = MINUTES.toMillis(botScript.getProtocolStepTimeLimitInMinutes());
+ this.stayAlive = botScript.isStayAlive();
+ this.isUsingTestHarness = botScript.isUseTestHarness();
+ if (isUsingTestHarness)
+ this.paymentAccount = createBotPaymentAccount(botScript);
+ else
+ this.paymentAccount = botClient.getPaymentAccount(botScript.getPaymentAccountIdForBot());
+ }
+
+ private PaymentAccount createBotPaymentAccount(BotScript botScript) {
+ BotPaymentAccountGenerator accountGenerator = new BotPaymentAccountGenerator(botClient);
+
+ String paymentMethodId = botScript.getBotPaymentMethodId();
+ if (paymentMethodId != null) {
+ if (paymentMethodId.equals(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()));
+ }
+ } else {
+ Country country = findCountry(botScript.getCountryCode());
+ 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
new file mode 100644
index 00000000000..3428b409f52
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/BotClient.java
@@ -0,0 +1,386 @@
+/*
+ * 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.ConfirmPaymentReceivedRequest;
+import bisq.proto.grpc.ConfirmPaymentStartedRequest;
+import bisq.proto.grpc.CreateOfferRequest;
+import bisq.proto.grpc.CreatePaymentAccountRequest;
+import bisq.proto.grpc.GetBalancesRequest;
+import bisq.proto.grpc.GetOffersRequest;
+import bisq.proto.grpc.GetPaymentAccountsRequest;
+import bisq.proto.grpc.GetTradeRequest;
+import bisq.proto.grpc.KeepFundsRequest;
+import bisq.proto.grpc.MarketPriceRequest;
+import bisq.proto.grpc.OfferInfo;
+import bisq.proto.grpc.TakeOfferRequest;
+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 bisq.cli.GrpcStubs;
+
+/**
+ * Convenience for test bots making gRPC calls.
+ *
+ * Although this duplicates code in the method package, I anticipate
+ * this entire bot package will move to the cli subproject.
+ */
+@SuppressWarnings({"JavaDoc", "unused"})
+@Slf4j
+public class BotClient {
+
+ private static final DecimalFormat FIXED_PRICE_FMT = new DecimalFormat("###########0");
+
+ private final GrpcStubs grpcStubs;
+
+ public BotClient(GrpcStubs grpcStubs) {
+ this.grpcStubs = grpcStubs;
+ }
+
+ /**
+ * Returns current BSQ and BTC balance information.
+ * @return BalancesInfo
+ */
+ public BalancesInfo getBalance() {
+ var req = GetBalancesRequest.newBuilder().build();
+ return grpcStubs.walletsService.getBalances(req).getBalances();
+ }
+
+ /**
+ * Return the most recent BTC market price for the given currencyCode.
+ * @param currencyCode
+ * @return double
+ */
+ public double getCurrentBTCMarketPrice(String currencyCode) {
+ var request = MarketPriceRequest.newBuilder().setCurrencyCode(currencyCode).build();
+ return grpcStubs.priceService.getMarketPrice(request).getPrice();
+ }
+
+ /**
+ * 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) {
+ var buyOffersRequest = GetOffersRequest.newBuilder()
+ .setCurrencyCode(currencyCode)
+ .setDirection("BUY").build();
+ return grpcStubs.offersService.getOffers(buyOffersRequest).getOffersList();
+ }
+
+ /**
+ * Return SELL offers for the given currencyCode.
+ * @param currencyCode
+ * @return List
+ */
+ public List getSellOffers(String currencyCode) {
+ var buyOffersRequest = GetOffersRequest.newBuilder()
+ .setCurrencyCode(currencyCode)
+ .setDirection("SELL").build();
+ return grpcStubs.offersService.getOffers(buyOffersRequest).getOffersList();
+ }
+
+ /**
+ * 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) {
+ var req = CreateOfferRequest.newBuilder()
+ .setPaymentAccountId(paymentAccount.getId())
+ .setDirection(direction)
+ .setCurrencyCode(currencyCode)
+ .setAmount(amountInSatoshis)
+ .setMinAmount(minAmountInSatoshis)
+ .setUseMarketBasedPrice(true)
+ .setMarketPriceMargin(priceMarginAsPercent)
+ .setPrice("0")
+ .setBuyerSecurityDeposit(securityDepositAsPercent)
+ .setMakerFeeCurrencyCode(feeCurrency)
+ .build();
+ return grpcStubs.offersService.createOffer(req).getOffer();
+ }
+
+ /**
+ * 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) {
+ var req = CreateOfferRequest.newBuilder()
+ .setPaymentAccountId(paymentAccount.getId())
+ .setDirection(direction)
+ .setCurrencyCode(currencyCode)
+ .setAmount(amountInSatoshis)
+ .setMinAmount(minAmountInSatoshis)
+ .setUseMarketBasedPrice(false)
+ .setMarketPriceMargin(0)
+ .setPrice(fixedOfferPriceAsString)
+ .setBuyerSecurityDeposit(securityDepositAsPercent)
+ .setMakerFeeCurrencyCode(feeCurrency)
+ .build();
+ return grpcStubs.offersService.createOffer(req).getOffer();
+ }
+
+ public TradeInfo takeOffer(String offerId, PaymentAccount paymentAccount, String feeCurrency) {
+ var req = TakeOfferRequest.newBuilder()
+ .setOfferId(offerId)
+ .setPaymentAccountId(paymentAccount.getId())
+ .setTakerFeeCurrencyCode(feeCurrency)
+ .build();
+ return grpcStubs.tradesService.takeOffer(req).getTrade();
+ }
+
+ /**
+ * Returns a persisted Trade with the given tradeId, or throws an exception.
+ * @param tradeId
+ * @return TradeInfo
+ */
+ public TradeInfo getTrade(String tradeId) {
+ var req = GetTradeRequest.newBuilder().setTradeId(tradeId).build();
+ return grpcStubs.tradesService.getTrade(req).getTrade();
+ }
+
+ /**
+ * 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 = 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 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 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 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 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 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) {
+ var req = ConfirmPaymentStartedRequest.newBuilder().setTradeId(tradeId).build();
+ //noinspection ResultOfMethodCallIgnored
+ grpcStubs.tradesService.confirmPaymentStarted(req);
+ }
+
+ /**
+ * Sends a 'confirm payment received message' for a trade with the given tradeId,
+ * or throws an exception.
+ * @param tradeId
+ */
+ public void sendConfirmPaymentReceivedMessage(String tradeId) {
+ var req = ConfirmPaymentReceivedRequest.newBuilder().setTradeId(tradeId).build();
+ //noinspection ResultOfMethodCallIgnored
+ grpcStubs.tradesService.confirmPaymentReceived(req);
+ }
+
+ /**
+ * 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) {
+ var req = KeepFundsRequest.newBuilder().setTradeId(tradeId).build();
+ //noinspection ResultOfMethodCallIgnored
+ grpcStubs.tradesService.keepFunds(req);
+ }
+
+ /**
+ * Create and save a new PaymentAccount with details in the given json.
+ * @param json
+ * @return PaymentAccount
+ */
+ public PaymentAccount createNewPaymentAccount(String json) {
+ var req = CreatePaymentAccountRequest.newBuilder()
+ .setPaymentAccountForm(json)
+ .build();
+ var paymentAccountsService = grpcStubs.paymentAccountsService;
+ return paymentAccountsService.createPaymentAccount(req).getPaymentAccount();
+ }
+
+ /**
+ * 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) {
+ var req = GetPaymentAccountsRequest.newBuilder().build();
+ return grpcStubs.paymentAccountsService.getPaymentAccounts(req)
+ .getPaymentAccountsList()
+ .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 grpcStubs.paymentAccountsService.getPaymentAccounts(req)
+ .getPaymentAccountsList()
+ .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
new file mode 100644
index 00000000000..e586c3236af
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/BotPaymentAccountGenerator.java
@@ -0,0 +1,68 @@
+package bisq.apitest.scenario.bot;
+
+import bisq.core.api.model.PaymentAccountForm;
+import bisq.core.locale.Country;
+
+import protobuf.PaymentAccount;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+
+import java.io.File;
+
+import java.util.Map;
+
+import lombok.extern.slf4j.Slf4j;
+
+import static bisq.core.payment.payload.PaymentMethod.CLEAR_X_CHANGE_ID;
+import static bisq.core.payment.payload.PaymentMethod.F2F_ID;
+
+@Slf4j
+public class BotPaymentAccountGenerator {
+
+ private final Gson gson = new GsonBuilder().setPrettyPrinting().serializeNulls().create();
+
+ private final BotClient botClient;
+
+ public BotPaymentAccountGenerator(BotClient botClient) {
+ this.botClient = botClient;
+ }
+
+ public PaymentAccount createF2FPaymentAccount(Country country, String accountName) {
+ try {
+ return botClient.getPaymentAccountWithName(accountName);
+ } catch (PaymentAccountNotFoundException ignored) {
+ // Ignore not found exception, create a new account.
+ }
+ Map p = getPaymentAccountFormMap(F2F_ID);
+ p.put("accountName", accountName);
+ p.put("city", country.name + " City");
+ p.put("country", country.code);
+ p.put("contact", "By Semaphore");
+ p.put("extraInfo", "");
+ // Convert the map back to a json string and create the payment account over gRPC.
+ return botClient.createNewPaymentAccount(gson.toJson(p));
+ }
+
+ public PaymentAccount createZellePaymentAccount(String accountName, String holderName) {
+ try {
+ return botClient.getPaymentAccountWithName(accountName);
+ } catch (PaymentAccountNotFoundException ignored) {
+ // Ignore not found exception, create a new account.
+ }
+ Map p = getPaymentAccountFormMap(CLEAR_X_CHANGE_ID);
+ p.put("accountName", accountName);
+ p.put("emailOrMobileNr", holderName + "@zelle.com");
+ p.put("holderName", holderName);
+ return botClient.createNewPaymentAccount(gson.toJson(p));
+ }
+
+ private Map getPaymentAccountFormMap(String paymentMethodId) {
+ PaymentAccountForm paymentAccountForm = new PaymentAccountForm();
+ File jsonFormTemplate = paymentAccountForm.getPaymentAccountForm(paymentMethodId);
+ jsonFormTemplate.deleteOnExit();
+ String jsonString = paymentAccountForm.toJsonString(jsonFormTemplate);
+ //noinspection unchecked
+ return (Map) gson.fromJson(jsonString, Object.class);
+ }
+}
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/InvalidRandomOfferException.java b/apitest/src/test/java/bisq/apitest/scenario/bot/InvalidRandomOfferException.java
new file mode 100644
index 00000000000..ccd1a2ebf14
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/InvalidRandomOfferException.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.scenario.bot;
+
+import bisq.common.BisqException;
+
+@SuppressWarnings("unused")
+public class InvalidRandomOfferException extends BisqException {
+ public InvalidRandomOfferException(Throwable cause) {
+ super(cause);
+ }
+
+ public InvalidRandomOfferException(String format, Object... args) {
+ super(format, args);
+ }
+
+ public InvalidRandomOfferException(Throwable cause, String format, Object... args) {
+ super(cause, format, args);
+ }
+}
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/PaymentAccountNotFoundException.java b/apitest/src/test/java/bisq/apitest/scenario/bot/PaymentAccountNotFoundException.java
new file mode 100644
index 00000000000..8578a38af75
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/PaymentAccountNotFoundException.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.scenario.bot;
+
+import bisq.common.BisqException;
+
+@SuppressWarnings("unused")
+public class PaymentAccountNotFoundException extends BisqException {
+ public PaymentAccountNotFoundException(Throwable cause) {
+ super(cause);
+ }
+
+ public PaymentAccountNotFoundException(String format, Object... args) {
+ super(format, args);
+ }
+
+ public PaymentAccountNotFoundException(Throwable cause, String format, Object... args) {
+ super(cause, format, args);
+ }
+}
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/RandomOffer.java b/apitest/src/test/java/bisq/apitest/scenario/bot/RandomOffer.java
new file mode 100644
index 00000000000..1942f8ad073
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/RandomOffer.java
@@ -0,0 +1,177 @@
+/*
+ * 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.OfferInfo;
+
+import protobuf.PaymentAccount;
+
+import java.security.SecureRandom;
+
+import java.text.DecimalFormat;
+
+import java.math.BigDecimal;
+
+import java.util.Objects;
+import java.util.function.Supplier;
+
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+
+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.payment.payload.PaymentMethod.F2F_ID;
+import static java.lang.String.format;
+import static java.math.RoundingMode.HALF_UP;
+
+@Slf4j
+public class RandomOffer {
+ private static final SecureRandom RANDOM = new SecureRandom();
+
+ private static final DecimalFormat FIXED_PRICE_FMT = new DecimalFormat("###########0");
+
+ @SuppressWarnings("FieldCanBeLocal")
+ // If not an F2F account, keep amount <= 0.01 BTC to avoid hitting unsigned
+ // acct trading limit.
+ private final Supplier nextAmount = () ->
+ this.getPaymentAccount().getPaymentMethod().getId().equals(F2F_ID)
+ ? (long) (10000000 + RANDOM.nextInt(2500000))
+ : (long) (750000 + RANDOM.nextInt(250000));
+
+ @SuppressWarnings("FieldCanBeLocal")
+ private final Supplier nextMinAmount = () -> {
+ boolean useMinAmount = RANDOM.nextBoolean();
+ if (useMinAmount) {
+ return this.getPaymentAccount().getPaymentMethod().getId().equals(F2F_ID)
+ ? this.getAmount() - 5000000L
+ : this.getAmount() - 50000L;
+ } else {
+ return this.getAmount();
+ }
+ };
+
+ @SuppressWarnings("FieldCanBeLocal")
+ private final Supplier nextPriceMargin = () -> {
+ boolean useZeroMargin = RANDOM.nextBoolean();
+ if (useZeroMargin) {
+ return 0.00;
+ } else {
+ BigDecimal min = BigDecimal.valueOf(-5.0).setScale(2, HALF_UP);
+ BigDecimal max = BigDecimal.valueOf(5.0).setScale(2, HALF_UP);
+ BigDecimal randomBigDecimal = min.add(BigDecimal.valueOf(RANDOM.nextDouble()).multiply(max.subtract(min)));
+ return randomBigDecimal.setScale(2, HALF_UP).doubleValue();
+ }
+ };
+
+ private final BotClient botClient;
+ @Getter
+ private final PaymentAccount paymentAccount;
+ @Getter
+ private final String direction;
+ @Getter
+ private final String currencyCode;
+ @Getter
+ private final long amount;
+ @Getter
+ private final long minAmount;
+ @Getter
+ private final boolean useMarketBasedPrice;
+ @Getter
+ private final double priceMargin;
+ @Getter
+ private final String feeCurrency;
+
+ @Getter
+ private String fixedOfferPrice = "0";
+ @Getter
+ private OfferInfo offer;
+ @Getter
+ private String id;
+
+ public RandomOffer(BotClient botClient, PaymentAccount paymentAccount) {
+ this.botClient = botClient;
+ this.paymentAccount = paymentAccount;
+ this.direction = RANDOM.nextBoolean() ? "BUY" : "SELL";
+ 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";
+ }
+
+ public RandomOffer create() throws InvalidRandomOfferException {
+ try {
+ printDescription();
+ if (useMarketBasedPrice) {
+ this.offer = botClient.createOfferAtMarketBasedPrice(paymentAccount,
+ direction,
+ currencyCode,
+ amount,
+ minAmount,
+ priceMargin,
+ getDefaultBuyerSecurityDepositAsPercent(),
+ feeCurrency);
+ } else {
+ this.offer = botClient.createOfferAtFixedPrice(paymentAccount,
+ direction,
+ currencyCode,
+ amount,
+ minAmount,
+ fixedOfferPrice,
+ getDefaultBuyerSecurityDepositAsPercent(),
+ feeCurrency);
+ }
+ this.id = offer.getId();
+ return this;
+ } catch (Exception ex) {
+ String error = format("Could not create valid %s offer for %s BTC: %s",
+ currencyCode,
+ formatSatoshis(amount),
+ ex.getMessage());
+ throw new InvalidRandomOfferException(error, ex);
+ }
+ }
+
+ 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")
+ ? currentMarketPrice - differenceFromMarketPrice
+ : currentMarketPrice + differenceFromMarketPrice;
+ this.fixedOfferPrice = FIXED_PRICE_FMT.format(fixedOfferPriceAsDouble);
+ String description = format("Creating new %s %s / %s offer for amount = %s BTC, min-amount = %s BTC.",
+ useMarketBasedPrice ? "mkt-based-price" : "fixed-priced",
+ direction,
+ currencyCode,
+ formatSatoshis(amount),
+ formatSatoshis(minAmount));
+ log.info(description);
+ if (useMarketBasedPrice) {
+ log.info("Offer Price Margin = {}%", priceMargin);
+ log.info("Expected Offer Price = {} {}", formatMarketPrice(Double.parseDouble(fixedOfferPrice)), currencyCode);
+ } else {
+
+ log.info("Fixed Offer Price = {} {}", fixedOfferPrice, currencyCode);
+ }
+ log.info("Current Market Price = {} {}", formatMarketPrice(currentMarketPrice), currencyCode);
+ }
+}
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/RobotBob.java b/apitest/src/test/java/bisq/apitest/scenario/bot/RobotBob.java
new file mode 100644
index 00000000000..d81f385a2ba
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/RobotBob.java
@@ -0,0 +1,141 @@
+/*
+ * 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.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.cli.TableFormat.formatBalancesTbls;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+
+
+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.script.BotScript;
+import bisq.apitest.scenario.bot.shutdown.ManualBotShutdownException;
+
+@Slf4j
+public
+class RobotBob extends Bot {
+
+ @Getter
+ private int numTrades;
+
+ public RobotBob(BotClient botClient,
+ BotScript botScript,
+ BitcoinCliHelper bitcoinCli,
+ BashScriptGenerator bashScriptGenerator) {
+ super(botClient, botScript, bitcoinCli, bashScriptGenerator);
+ }
+
+ public void run() {
+ for (String action : actions) {
+ checkActionIsValid(action);
+
+ BotProtocol botProtocol;
+ if (action.equalsIgnoreCase(MAKE)) {
+ botProtocol = new MakerBotProtocol(botClient,
+ paymentAccount,
+ protocolStepTimeLimitInMs,
+ bitcoinCli,
+ bashScriptGenerator);
+ } else {
+ botProtocol = new TakerBotProtocol(botClient,
+ paymentAccount,
+ protocolStepTimeLimitInMs,
+ bitcoinCli,
+ bashScriptGenerator);
+ }
+
+ botProtocol.run();
+
+ if (!botProtocol.getCurrentProtocolStep().equals(DONE)) {
+ throw new IllegalStateException(botProtocol.getClass().getSimpleName() + " failed to complete.");
+ }
+
+ log.info("Completed {} successful trade{}. Current Balance:\n{}",
+ ++numTrades,
+ numTrades == 1 ? "" : "s",
+ formatBalancesTbls(botClient.getBalance()));
+
+ if (numTrades < actions.length) {
+ try {
+ SECONDS.sleep(20);
+ } catch (InterruptedException ignored) {
+ // empty
+ }
+ }
+
+ } // end of actions loop
+
+ if (stayAlive)
+ waitForManualShutdown();
+ else
+ warnCLIUserBeforeShutdown();
+ }
+
+ private void checkActionIsValid(String action) {
+ if (!action.equalsIgnoreCase(MAKE) && !action.equalsIgnoreCase(TAKE))
+ throw new IllegalStateException(action + " is not a valid bot action; must be 'make' or 'take'");
+ }
+
+ private void waitForManualShutdown() {
+ String harnessOrCase = isUsingTestHarness ? "harness" : "case";
+ log.info("All script actions have been completed, but 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()) {
+ SECONDS.sleep(10);
+ }
+ log.warn("Manual shutdown signal received.");
+ } catch (ManualBotShutdownException ex) {
+ log.warn(ex.getMessage());
+ } catch (InterruptedException ignored) {
+ // empty
+ }
+ }
+
+ private void warnCLIUserBeforeShutdown() {
+ if (isUsingTestHarness) {
+ long delayInSeconds = 30;
+ log.warn("All script actions have been completed. You have {} seconds to complete any"
+ + " remaining tasks before the test harness shuts down.",
+ delayInSeconds);
+ try {
+ SECONDS.sleep(delayInSeconds);
+ } catch (InterruptedException ignored) {
+ // empty
+ }
+ } else {
+ log.info("Shutting down test case");
+ }
+ }
+}
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
new file mode 100644
index 00000000000..51d59e7537d
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/BotProtocol.java
@@ -0,0 +1,349 @@
+/*
+ * 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 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();
+ static final String BUY = "BUY";
+ static final String SELL = "SELL";
+
+ 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);
+ var isSell = trade.getOffer().getDirection().equalsIgnoreCase(SELL);
+ 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/MakerBotProtocol.java b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/MakerBotProtocol.java
new file mode 100644
index 00000000000..0ce26002ece
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/MakerBotProtocol.java
@@ -0,0 +1,114 @@
+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.Optional;
+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.cli.TableFormat.formatOfferTable;
+import static java.util.Collections.singletonList;
+
+
+
+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,
+ paymentAccount,
+ protocolStepTimeLimitInMs,
+ bitcoinCli,
+ bashScriptGenerator);
+ }
+
+ @Override
+ public void run() {
+ checkIsStartStep();
+
+ Function, TradeInfo> makeTrade = waitForNewTrade.andThen(waitForTakerFeeTxConfirm);
+ var trade = makeTrade.apply(randomOffer);
+
+ var makerIsBuyer = trade.getOffer().getDirection().equalsIgnoreCase(BUY);
+ Function completeFiatTransaction = makerIsBuyer
+ ? sendPaymentStartedMessage.andThen(waitForPaymentReceivedConfirmation)
+ : waitForPaymentStartedMessage.andThen(sendPaymentReceivedMessage);
+ completeFiatTransaction.apply(trade);
+
+ Function closeTrade = waitForPayoutTx.andThen(keepFundsFromTrade);
+ closeTrade.apply(trade);
+
+ currentProtocolStep = DONE;
+ }
+
+ private final Supplier randomOffer = () -> {
+ checkIfShutdownCalled("Interrupted before creating random offer.");
+ OfferInfo offer = new RandomOffer(botClient, paymentAccount).create().getOffer();
+ log.info("Created random {} offer\n{}", currencyCode, formatOfferTable(singletonList(offer), currencyCode));
+ return offer;
+ };
+
+ private final Function, TradeInfo> waitForNewTrade = (randomOffer) -> {
+ initProtocolStep.accept(WAIT_FOR_OFFER_TAKER);
+ OfferInfo offer = randomOffer.get();
+ createTakeOfferCliScript(offer);
+ try {
+ log.info("Impatiently waiting for offer {} to be taken, repeatedly calling gettrade.", offer.getId());
+ while (isWithinProtocolStepTimeLimit()) {
+ checkIfShutdownCalled("Interrupted while waiting for offer to be taken.");
+ try {
+ var trade = getNewTrade(offer.getId());
+ if (trade.isPresent())
+ return trade.get();
+ else
+ sleep(randomDelay.get());
+ } catch (Exception ex) {
+ throw new IllegalStateException(this.getBotClient().toCleanGrpcExceptionMessage(ex), ex);
+ }
+ } // end while
+ throw new IllegalStateException("Offer was never taken; 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 offer to be taken.", ex);
+ }
+ };
+
+ 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) {
+ // Get trade will throw a non-fatal gRPC exception if not found.
+ log.info(this.getBotClient().toCleanGrpcExceptionMessage(ex));
+ return Optional.empty();
+ }
+ }
+
+ private void createTakeOfferCliScript(OfferInfo offer) {
+ File script = bashScriptGenerator.createTakeOfferScript(offer);
+ printCliHintAndOrScript(script, "The manual CLI side can take the offer");
+ }
+}
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
new file mode 100644
index 00000000000..def2a0bb663
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ProtocolStep.java
@@ -0,0 +1,17 @@
+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/protocol/TakerBotProtocol.java b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/TakerBotProtocol.java
new file mode 100644
index 00000000000..63b700824f6
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/TakerBotProtocol.java
@@ -0,0 +1,136 @@
+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.Optional;
+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.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 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,
+ paymentAccount,
+ protocolStepTimeLimitInMs,
+ bitcoinCli,
+ bashScriptGenerator);
+ }
+
+ @Override
+ public void run() {
+ checkIsStartStep();
+
+ Function takeTrade = takeOffer.andThen(waitForTakerFeeTxConfirm);
+ var trade = takeTrade.apply(findOffer.get());
+
+ var takerIsSeller = trade.getOffer().getDirection().equalsIgnoreCase(BUY);
+ Function completeFiatTransaction = takerIsSeller
+ ? waitForPaymentStartedMessage.andThen(sendPaymentReceivedMessage)
+ : sendPaymentStartedMessage.andThen(waitForPaymentReceivedConfirmation);
+ completeFiatTransaction.apply(trade);
+
+ Function closeTrade = waitForPayoutTx.andThen(keepFundsFromTrade);
+ closeTrade.apply(trade);
+
+ currentProtocolStep = DONE;
+ }
+
+ private final Supplier> firstOffer = () -> {
+ var offers = botClient.getOffers(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);
+ createMakeOfferScript();
+ try {
+ log.info("Impatiently waiting for at least one {} offer to be created, repeatedly calling getoffers.", currencyCode);
+ while (isWithinProtocolStepTimeLimit()) {
+ checkIfShutdownCalled("Interrupted while checking offers.");
+ try {
+ Optional offer = firstOffer.get();
+ if (offer.isPresent())
+ return offer.get();
+ else
+ sleep(randomDelay.get());
+ } catch (Exception ex) {
+ throw new IllegalStateException(this.getBotClient().toCleanGrpcExceptionMessage(ex), ex);
+ }
+ } // end while
+ throw new IllegalStateException("Offer was never created; 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 a new offer.", ex);
+ }
+ };
+
+ private final Function takeOffer = (offer) -> {
+ initProtocolStep.accept(TAKE_OFFER);
+ checkIfShutdownCalled("Interrupted before taking offer.");
+ 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";
+ boolean createMarginPricedOffer = RANDOM.nextBoolean();
+ // If not using an F2F account, don't go over possible 0.01 BTC
+ // limit if account is not signed.
+ String amount = paymentAccount.getPaymentMethod().getId().equals(F2F_ID)
+ ? "0.25"
+ : "0.01";
+ File script;
+ if (createMarginPricedOffer) {
+ script = bashScriptGenerator.createMakeMarginPricedOfferScript(direction,
+ currencyCode,
+ amount,
+ "0.0",
+ "15.0",
+ feeCurrency);
+ } else {
+ script = bashScriptGenerator.createMakeFixedPricedOfferScript(direction,
+ currencyCode,
+ amount,
+ botClient.getCurrentBTCMarketPriceAsIntegerString(currencyCode),
+ "15.0",
+ feeCurrency);
+ }
+ printCliHintAndOrScript(script, "The manual CLI side can create an offer");
+ }
+}
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/script/BashScriptGenerator.java b/apitest/src/test/java/bisq/apitest/scenario/bot/script/BashScriptGenerator.java
new file mode 100644
index 00000000000..d41e8a1acd3
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/script/BashScriptGenerator.java
@@ -0,0 +1,235 @@
+/*
+ * 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.script;
+
+import bisq.common.file.FileUtil;
+
+import bisq.proto.grpc.OfferInfo;
+import bisq.proto.grpc.TradeInfo;
+
+import com.google.common.io.Files;
+
+import java.nio.file.Paths;
+
+import java.io.File;
+import java.io.IOException;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+
+import static com.google.common.io.FileWriteMode.APPEND;
+import static java.lang.String.format;
+import static java.lang.System.getProperty;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.nio.file.Files.readAllBytes;
+
+@Slf4j
+@Getter
+public class BashScriptGenerator {
+
+ private final int apiPort;
+ private final String apiPassword;
+ private final String paymentAccountId;
+ private final String cliBase;
+ private final boolean printCliScripts;
+
+ public BashScriptGenerator(String apiPassword,
+ int apiPort,
+ String paymentAccountId,
+ boolean printCliScripts) {
+ this.apiPassword = apiPassword;
+ this.apiPort = apiPort;
+ this.paymentAccountId = paymentAccountId;
+ this.printCliScripts = printCliScripts;
+ this.cliBase = format("./bisq-cli --password=%s --port=%d", apiPassword, apiPort);
+ }
+
+ public File createMakeMarginPricedOfferScript(String direction,
+ String currencyCode,
+ String amount,
+ String marketPriceMargin,
+ String securityDeposit,
+ String feeCurrency) {
+ String makeOfferCmd = format("%s createoffer --payment-account=%s "
+ + " --direction=%s"
+ + " --currency-code=%s"
+ + " --amount=%s"
+ + " --market-price-margin=%s"
+ + " --security-deposit=%s"
+ + " --fee-currency=%s",
+ cliBase,
+ this.getPaymentAccountId(),
+ direction,
+ currencyCode,
+ amount,
+ marketPriceMargin,
+ securityDeposit,
+ feeCurrency);
+ String getOffersCmd = format("%s getmyoffers --direction=%s --currency-code=%s",
+ cliBase,
+ direction,
+ currencyCode);
+ return createCliScript("createoffer.sh",
+ makeOfferCmd,
+ "sleep 2",
+ getOffersCmd);
+ }
+
+ public File createMakeFixedPricedOfferScript(String direction,
+ String currencyCode,
+ String amount,
+ String fixedPrice,
+ String securityDeposit,
+ String feeCurrency) {
+ String makeOfferCmd = format("%s createoffer --payment-account=%s "
+ + " --direction=%s"
+ + " --currency-code=%s"
+ + " --amount=%s"
+ + " --fixed-price=%s"
+ + " --security-deposit=%s"
+ + " --fee-currency=%s",
+ cliBase,
+ this.getPaymentAccountId(),
+ direction,
+ currencyCode,
+ amount,
+ fixedPrice,
+ securityDeposit,
+ feeCurrency);
+ String getOffersCmd = format("%s getmyoffers --direction=%s --currency-code=%s",
+ cliBase,
+ direction,
+ currencyCode);
+ return createCliScript("createoffer.sh",
+ makeOfferCmd,
+ "sleep 2",
+ getOffersCmd);
+ }
+
+ public File createTakeOfferScript(OfferInfo offer) {
+ String getOffersCmd = format("%s getoffers --direction=%s --currency-code=%s",
+ cliBase,
+ offer.getDirection(),
+ offer.getCounterCurrencyCode());
+ String takeOfferCmd = format("%s takeoffer --offer-id=%s --payment-account=%s --fee-currency=BSQ",
+ cliBase,
+ offer.getId(),
+ this.getPaymentAccountId());
+ String getTradeCmd = format("%s gettrade --trade-id=%s",
+ cliBase,
+ offer.getId());
+ return createCliScript("takeoffer.sh",
+ getOffersCmd,
+ takeOfferCmd,
+ "sleep 5",
+ getTradeCmd);
+ }
+
+ public File createPaymentStartedScript(TradeInfo trade) {
+ 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",
+ paymentStartedCmd,
+ "sleep 2",
+ getTradeCmd);
+ }
+
+ public File createPaymentReceivedScript(TradeInfo trade) {
+ 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",
+ paymentStartedCmd,
+ "sleep 2",
+ getTradeCmd);
+ }
+
+ public File createKeepFundsScript(TradeInfo trade) {
+ 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",
+ paymentStartedCmd,
+ "sleep 2",
+ getTradeCmd,
+ getBalanceCmd);
+ }
+
+ public File createGetBalanceScript() {
+ String getBalanceCmd = format("%s getbalance", cliBase);
+ return createCliScript("getbalance.sh", getBalanceCmd);
+ }
+
+ public File createGenerateBtcBlockScript(String address) {
+ String bitcoinCliCmd = format("bitcoin-cli -regtest -rpcport=19443 -rpcuser=apitest"
+ + " -rpcpassword=apitest generatetoaddress 1 \"%s\"",
+ address);
+ return createCliScript("genbtcblk.sh",
+ bitcoinCliCmd);
+ }
+
+ public File createCliScript(String scriptName, String... commands) {
+ String filename = getProperty("java.io.tmpdir") + File.separator + scriptName;
+ File oldScript = new File(filename);
+ if (oldScript.exists()) {
+ try {
+ FileUtil.deleteFileIfExists(oldScript);
+ } catch (IOException ex) {
+ throw new IllegalStateException("Unable to delete old script.", ex);
+ }
+ }
+ File script = new File(filename);
+ try {
+ 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("# Make a copy if you want to save it.");
+ lines.add("############################################################");
+ lines.add("set -x");
+ Collections.addAll(lines, commands);
+ Files.asCharSink(script, UTF_8, APPEND).writeLines(lines);
+ if (!script.setExecutable(true))
+ throw new IllegalStateException("Unable to set script owner's execute permission.");
+ } catch (IOException ex) {
+ log.error("", ex);
+ throw new IllegalStateException(ex);
+ } finally {
+ script.deleteOnExit();
+ }
+ return script;
+ }
+
+ public void printCliScript(File cliScript,
+ org.slf4j.Logger logger) {
+ try {
+ String contents = new String(readAllBytes(Paths.get(cliScript.getPath())));
+ logger.info("CLI script {}:\n{}", cliScript.getAbsolutePath(), contents);
+ } catch (IOException ex) {
+ throw new IllegalStateException("Error reading CLI script contents.", ex);
+ }
+ }
+}
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
new file mode 100644
index 00000000000..2caaed68add
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/script/BotScript.java
@@ -0,0 +1,78 @@
+/*
+ * 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.script;
+
+import lombok.Getter;
+import lombok.Setter;
+import lombok.ToString;
+
+import javax.annotation.Nullable;
+
+@Getter
+@ToString
+public
+class BotScript {
+
+ // Common, default is true.
+ private final boolean useTestHarness;
+
+ // Used only with test harness. Mutually exclusive, but if both are not null,
+ // the botPaymentMethodId takes precedence over countryCode.
+ @Nullable
+ private final String botPaymentMethodId;
+ @Nullable
+ private final String countryCode;
+
+ // Used only without test harness.
+ @Nullable
+ @Setter
+ private String paymentAccountIdForBot;
+ @Nullable
+ @Setter
+ private String paymentAccountIdForCliScripts;
+
+ // Common, used with or without test harness.
+ private final int apiPortForCliScripts;
+ private final String[] actions;
+ private final long protocolStepTimeLimitInMinutes;
+ private final boolean printCliScripts;
+ private final boolean stayAlive;
+
+ @SuppressWarnings("NullableProblems")
+ BotScript(boolean useTestHarness,
+ String botPaymentMethodId,
+ String countryCode,
+ String paymentAccountIdForBot,
+ String paymentAccountIdForCliScripts,
+ String[] actions,
+ int apiPortForCliScripts,
+ long protocolStepTimeLimitInMinutes,
+ boolean printCliScripts,
+ boolean stayAlive) {
+ this.useTestHarness = useTestHarness;
+ this.botPaymentMethodId = botPaymentMethodId;
+ this.countryCode = countryCode != null ? countryCode.toUpperCase() : null;
+ this.paymentAccountIdForBot = paymentAccountIdForBot;
+ this.paymentAccountIdForCliScripts = paymentAccountIdForCliScripts;
+ this.apiPortForCliScripts = apiPortForCliScripts;
+ this.actions = actions;
+ this.protocolStepTimeLimitInMinutes = protocolStepTimeLimitInMinutes;
+ this.printCliScripts = printCliScripts;
+ this.stayAlive = stayAlive;
+ }
+}
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
new file mode 100644
index 00000000000..c81730c4c40
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/script/BotScriptGenerator.java
@@ -0,0 +1,247 @@
+/*
+ * 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.script;
+
+import bisq.common.file.JsonFileManager;
+import bisq.common.util.Utilities;
+
+import joptsimple.BuiltinHelpFormatter;
+import joptsimple.OptionParser;
+import joptsimple.OptionSet;
+import joptsimple.OptionSpec;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.PrintStream;
+
+import lombok.extern.slf4j.Slf4j;
+
+import javax.annotation.Nullable;
+
+import static java.lang.System.err;
+import static java.lang.System.exit;
+import static java.lang.System.getProperty;
+import static java.lang.System.out;
+
+@Slf4j
+public class BotScriptGenerator {
+
+ private final boolean useTestHarness;
+ @Nullable
+ private final String countryCode;
+ @Nullable
+ private final String botPaymentMethodId;
+ @Nullable
+ private final String paymentAccountIdForBot;
+ @Nullable
+ private final String paymentAccountIdForCliScripts;
+ private final int apiPortForCliScripts;
+ private final String actions;
+ private final int protocolStepTimeLimitInMinutes;
+ private final boolean printCliScripts;
+ private final boolean stayAlive;
+
+ public BotScriptGenerator(String[] args) {
+ OptionParser parser = new OptionParser();
+ var helpOpt = parser.accepts("help", "Print this help text.")
+ .forHelp();
+ OptionSpec useTestHarnessOpt = parser
+ .accepts("use-testharness", "Use the test harness, or manually start your own nodes.")
+ .withRequiredArg()
+ .ofType(Boolean.class)
+ .defaultsTo(true);
+ OptionSpec actionsOpt = parser
+ .accepts("actions", "A comma delimited list with no spaces, e.g., make,take,take,make,...")
+ .withRequiredArg();
+ OptionSpec botPaymentMethodIdOpt = parser
+ .accepts("bot-payment-method",
+ "The bot's (Bob) payment method id. If using the test harness,"
+ + " the id will be used to automatically create a payment account.")
+ .withRequiredArg();
+ OptionSpec countryCodeOpt = parser
+ .accepts("country-code",
+ "The two letter country-code for an F2F payment account if using the test harness,"
+ + " but the bot-payment-method option takes precedence.")
+ .withRequiredArg();
+ OptionSpec apiPortForCliScriptsOpt = parser
+ .accepts("api-port-for-cli-scripts",
+ "The api port used in bot generated bash/cli scripts.")
+ .withRequiredArg()
+ .ofType(Integer.class)
+ .defaultsTo(9998);
+ OptionSpec paymentAccountIdForBotOpt = parser
+ .accepts("payment-account-for-bot",
+ "The bot side's payment account id, when the test harness is not used,"
+ + " and Bob & Alice accounts are not automatically created.")
+ .withRequiredArg();
+ OptionSpec paymentAccountIdForCliScriptsOpt = parser
+ .accepts("payment-account-for-cli-scripts",
+ "The other side's payment account id, used in generated bash/cli scripts when"
+ + " the test harness is not used, and Bob & Alice accounts are not automatically created.")
+ .withRequiredArg();
+ OptionSpec protocolStepTimeLimitInMinutesOpt = parser
+ .accepts("step-time-limit", "Each protocol step's time limit in minutes")
+ .withRequiredArg()
+ .ofType(Integer.class)
+ .defaultsTo(60);
+ OptionSpec printCliScriptsOpt = parser
+ .accepts("print-cli-scripts", "Print the generated CLI scripts from bot")
+ .withRequiredArg()
+ .ofType(Boolean.class)
+ .defaultsTo(false);
+ OptionSpec stayAliveOpt = parser
+ .accepts("stay-alive", "Leave test harness nodes running after the last action.")
+ .withRequiredArg()
+ .ofType(Boolean.class)
+ .defaultsTo(true);
+ OptionSet options = parser.parse(args);
+
+ if (options.has(helpOpt)) {
+ printHelp(parser, out);
+ exit(0);
+ }
+
+ if (!options.has(actionsOpt)) {
+ printHelp(parser, err);
+ exit(1);
+ }
+
+ this.useTestHarness = options.has(useTestHarnessOpt) ? options.valueOf(useTestHarnessOpt) : true;
+ this.actions = options.valueOf(actionsOpt);
+ this.apiPortForCliScripts = options.has(apiPortForCliScriptsOpt) ? options.valueOf(apiPortForCliScriptsOpt) : 9998;
+ this.botPaymentMethodId = options.has(botPaymentMethodIdOpt) ? options.valueOf(botPaymentMethodIdOpt) : null;
+ this.countryCode = options.has(countryCodeOpt) ? options.valueOf(countryCodeOpt) : null;
+ this.paymentAccountIdForBot = options.has(paymentAccountIdForBotOpt) ? options.valueOf(paymentAccountIdForBotOpt) : null;
+ this.paymentAccountIdForCliScripts = options.has(paymentAccountIdForCliScriptsOpt) ? options.valueOf(paymentAccountIdForCliScriptsOpt) : null;
+ this.protocolStepTimeLimitInMinutes = options.valueOf(protocolStepTimeLimitInMinutesOpt);
+ this.printCliScripts = options.valueOf(printCliScriptsOpt);
+ this.stayAlive = options.valueOf(stayAliveOpt);
+
+ var noPaymentAccountCountryOrMethodForTestHarness = useTestHarness &&
+ (!options.has(countryCodeOpt) && !options.has(botPaymentMethodIdOpt));
+ if (noPaymentAccountCountryOrMethodForTestHarness) {
+ log.error("When running the test harness, payment accounts are automatically generated,");
+ log.error("and you must provide one of the following options:");
+ log.error(" \t\t(1) --bot-payment-method= OR");
+ log.error(" \t\t(2) --country-code=");
+ log.error("If the bot-payment-method option is not present, the bot will create"
+ + " a country based F2F account using the country-code.");
+ log.error("If both are present, the bot-payment-method will take precedence. "
+ + "Currently, only the CLEAR_X_CHANGE_ID bot-payment-method is supported.");
+ printHelp(parser, err);
+ exit(1);
+ }
+
+ var noPaymentAccountIdOrApiPortForCliScripts = !useTestHarness &&
+ (!options.has(paymentAccountIdForCliScriptsOpt) || !options.has(paymentAccountIdForBotOpt));
+ if (noPaymentAccountIdOrApiPortForCliScripts) {
+ log.error("If not running the test harness, payment accounts are not automatically generated,");
+ log.error("and you must provide three options:");
+ log.error(" \t\t(1) --api-port-for-cli-scripts=");
+ log.error(" \t\t(2) --payment-account-for-bot=");
+ log.error(" \t\t(3) --payment-account-for-cli-scripts=");
+ log.error("These will be used by the bot and in CLI scripts the bot will generate when creating an offer.");
+ printHelp(parser, err);
+ exit(1);
+ }
+ }
+
+ private void printHelp(OptionParser parser, PrintStream stream) {
+ try {
+ String usage = "Examples\n--------\n"
+ + examplesUsingTestHarness()
+ + examplesNotUsingTestHarness();
+ stream.println();
+ parser.formatHelpWith(new HelpFormatter());
+ parser.printHelpOn(stream);
+ stream.println();
+ stream.println(usage);
+ stream.println();
+ } catch (IOException ex) {
+ log.error("", ex);
+ }
+ }
+
+ private String examplesUsingTestHarness() {
+ @SuppressWarnings("StringBufferReplaceableByString") StringBuilder builder = new StringBuilder();
+ builder.append("To generate a bot-script.json file that will start the test harness,");
+ builder.append(" create F2F accounts for Bob and Alice,");
+ builder.append(" and take an offer created by Alice's CLI:").append("\n");
+ builder.append("\tUsage: BotScriptGenerator").append("\n");
+ builder.append("\t\t").append("--use-testharness=true").append("\n");
+ builder.append("\t\t").append("--country-code=").append("\n");
+ builder.append("\t\t").append("--actions=take").append("\n");
+ builder.append("\n");
+ builder.append("To generate a bot-script.json file that will start the test harness,");
+ builder.append(" create Zelle accounts for Bob and Alice,");
+ builder.append(" and create an offer to be taken by Alice's CLI:").append("\n");
+ builder.append("\tUsage: BotScriptGenerator").append("\n");
+ builder.append("\t\t").append("--use-testharness=true").append("\n");
+ builder.append("\t\t").append("--bot-payment-method=CLEAR_X_CHANGE").append("\n");
+ builder.append("\t\t").append("--actions=make").append("\n");
+ builder.append("\n");
+ return builder.toString();
+ }
+
+ private String examplesNotUsingTestHarness() {
+ @SuppressWarnings("StringBufferReplaceableByString") StringBuilder builder = new StringBuilder();
+ builder.append("To generate a bot-script.json file that will not start the test harness,");
+ builder.append(" but will create useful bash scripts for the CLI user,");
+ builder.append(" and make two offers, then take two offers:").append("\n");
+ builder.append("\tUsage: BotScriptGenerator").append("\n");
+ builder.append("\t\t").append("--use-testharness=false").append("\n");
+ builder.append("\t\t").append("--api-port-for-cli-scripts=").append("\n");
+ builder.append("\t\t").append("--payment-account-for-bot=").append("\n");
+ builder.append("\t\t").append("--payment-account-for-cli-scripts=").append("\n");
+ builder.append("\t\t").append("--actions=make,make,take,take").append("\n");
+ builder.append("\n");
+ return builder.toString();
+ }
+
+ private String generateBotScriptTemplate() {
+ return Utilities.objectToJson(new BotScript(
+ useTestHarness,
+ botPaymentMethodId,
+ countryCode,
+ paymentAccountIdForBot,
+ paymentAccountIdForCliScripts,
+ actions.split("\\s*,\\s*").clone(),
+ apiPortForCliScripts,
+ protocolStepTimeLimitInMinutes,
+ printCliScripts,
+ stayAlive));
+ }
+
+ public static void main(String[] args) {
+ BotScriptGenerator generator = new BotScriptGenerator(args);
+ String json = generator.generateBotScriptTemplate();
+ String destDir = getProperty("java.io.tmpdir");
+ JsonFileManager jsonFileManager = new JsonFileManager(new File(destDir));
+ jsonFileManager.writeToDisc(json, "bot-script");
+ JsonFileManager.shutDownAllInstances();
+ log.info("Saved {}/bot-script.json", destDir);
+ log.info("bot-script.json contents\n{}", json);
+ }
+
+ // Makes a formatter with a given overall row width of 120 and column separator width of 2.
+ private static class HelpFormatter extends BuiltinHelpFormatter {
+ public HelpFormatter() {
+ super(120, 2);
+ }
+ }
+}
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/shutdown/ManualBotShutdownException.java b/apitest/src/test/java/bisq/apitest/scenario/bot/shutdown/ManualBotShutdownException.java
new file mode 100644
index 00000000000..8a0e68bad18
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/shutdown/ManualBotShutdownException.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.scenario.bot.shutdown;
+
+import bisq.common.BisqException;
+
+@SuppressWarnings("unused")
+public class ManualBotShutdownException extends BisqException {
+ public ManualBotShutdownException(Throwable cause) {
+ super(cause);
+ }
+
+ public ManualBotShutdownException(String format, Object... args) {
+ super(format, args);
+ }
+
+ public ManualBotShutdownException(Throwable cause, String format, Object... args) {
+ super(cause, format, args);
+ }
+}
diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/shutdown/ManualShutdown.java b/apitest/src/test/java/bisq/apitest/scenario/bot/shutdown/ManualShutdown.java
new file mode 100644
index 00000000000..fc680f1c818
--- /dev/null
+++ b/apitest/src/test/java/bisq/apitest/scenario/bot/shutdown/ManualShutdown.java
@@ -0,0 +1,64 @@
+package bisq.apitest.scenario.bot.shutdown;
+
+import bisq.common.UserThread;
+
+import java.io.File;
+import java.io.IOException;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import lombok.extern.slf4j.Slf4j;
+
+import static bisq.common.file.FileUtil.deleteFileIfExists;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+@Slf4j
+public class ManualShutdown {
+
+ public static final String SHUTDOWN_FILENAME = "/tmp/bottest-shutdown";
+
+ private static final AtomicBoolean SHUTDOWN_CALLED = new AtomicBoolean(false);
+
+ /**
+ * Looks for a /tmp/bottest-shutdown file and throws a BotShutdownException if found.
+ *
+ * Running '$ touch /tmp/bottest-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(() -> {
+ File shutdownFile = new File(SHUTDOWN_FILENAME);
+ if (shutdownFile.exists()) {
+ log.warn("Caught manual shutdown signal: /tmp/bottest-shutdown file exists.");
+ try {
+ deleteFileIfExists(shutdownFile);
+ } catch (IOException ex) {
+ log.error("", ex);
+ throw new IllegalStateException(ex);
+ }
+ SHUTDOWN_CALLED.set(true);
+ }
+ }, 2000, MILLISECONDS);
+ }
+
+ public static boolean isShutdownCalled() {
+ return SHUTDOWN_CALLED.get();
+ }
+
+ public static void checkIfShutdownCalled(String warning) throws ManualBotShutdownException {
+ if (isShutdownCalled())
+ throw new ManualBotShutdownException(warning);
+ }
+
+ private static void deleteStaleShutdownFile() {
+ try {
+ deleteFileIfExists(new File(SHUTDOWN_FILENAME));
+ } catch (IOException ex) {
+ log.error("", ex);
+ throw new IllegalStateException(ex);
+ }
+ }
+}
diff --git a/cli/src/main/java/bisq/cli/CurrencyFormat.java b/cli/src/main/java/bisq/cli/CurrencyFormat.java
index a076c5ce5ae..671e6149f79 100644
--- a/cli/src/main/java/bisq/cli/CurrencyFormat.java
+++ b/cli/src/main/java/bisq/cli/CurrencyFormat.java
@@ -65,37 +65,37 @@ public static String formatTxFeeRateInfo(TxFeeRateInfo txFeeRateInfo) {
formatFeeSatoshis(txFeeRateInfo.getFeeServiceRate()));
}
- static String formatAmountRange(long minAmount, long amount) {
+ public static String formatAmountRange(long minAmount, long amount) {
return minAmount != amount
? formatSatoshis(minAmount) + " - " + formatSatoshis(amount)
: formatSatoshis(amount);
}
- static String formatVolumeRange(long minVolume, long volume) {
+ public static String formatVolumeRange(long minVolume, long volume) {
return minVolume != volume
? formatOfferVolume(minVolume) + " - " + formatOfferVolume(volume)
: formatOfferVolume(volume);
}
- static String formatMarketPrice(double price) {
+ public static String formatMarketPrice(double price) {
NUMBER_FORMAT.setMinimumFractionDigits(4);
return NUMBER_FORMAT.format(price);
}
- static String formatOfferPrice(long 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);
}
- static String formatOfferVolume(long volume) {
+ public static String formatOfferVolume(long volume) {
NUMBER_FORMAT.setMaximumFractionDigits(0);
NUMBER_FORMAT.setRoundingMode(RoundingMode.UNNECESSARY);
return NUMBER_FORMAT.format((double) volume / 10000);
}
- static long toSatoshis(String btc) {
+ public static long toSatoshis(String btc) {
if (btc.startsWith("-"))
throw new IllegalArgumentException(format("'%s' is not a positive number", btc));
@@ -106,7 +106,7 @@ static long toSatoshis(String btc) {
}
}
- static double toSecurityDepositAsPct(String securityDepositInput) {
+ public static double toSecurityDepositAsPct(String securityDepositInput) {
try {
return new BigDecimal(securityDepositInput)
.multiply(SECURITY_DEPOSIT_MULTIPLICAND).doubleValue();
@@ -116,7 +116,7 @@ static double toSecurityDepositAsPct(String securityDepositInput) {
}
@SuppressWarnings("BigDecimalMethodWithoutRoundingCalled")
- private static String formatFeeSatoshis(long sats) {
+ public static String formatFeeSatoshis(long sats) {
return BTC_TX_FEE_FORMAT.format(BigDecimal.valueOf(sats).divide(SATOSHI_DIVISOR));
}
}
diff --git a/cli/src/main/java/bisq/cli/TableFormat.java b/cli/src/main/java/bisq/cli/TableFormat.java
index 1323bf2dc20..8064e9f6962 100644
--- a/cli/src/main/java/bisq/cli/TableFormat.java
+++ b/cli/src/main/java/bisq/cli/TableFormat.java
@@ -111,7 +111,7 @@ public static String formatBtcBalanceInfoTbl(BtcBalanceInfo btcBalanceInfo) {
formatSatoshis(btcBalanceInfo.getLockedBalance()));
}
- static String formatOfferTable(List offerInfo, String fiatCurrency) {
+ public static String formatOfferTable(List offerInfo, String fiatCurrency) {
// Some column values might be longer than header, so we need to calculate them.
int paymentMethodColWidth = getLengthOfLongestColumn(
COL_HEADER_PAYMENT_METHOD.length(),
@@ -147,7 +147,7 @@ static String formatOfferTable(List offerInfo, String fiatCurrency) {
.collect(Collectors.joining("\n"));
}
- static String formatPaymentAcctTbl(List paymentAccounts) {
+ public static String formatPaymentAcctTbl(List paymentAccounts) {
// Some column values might be longer than header, so we need to calculate them.
int nameColWidth = getLengthOfLongestColumn(
COL_HEADER_NAME.length(),
diff --git a/core/src/main/java/bisq/core/api/CoreWalletsService.java b/core/src/main/java/bisq/core/api/CoreWalletsService.java
index 0ef5a22d2fd..b048fcefe97 100644
--- a/core/src/main/java/bisq/core/api/CoreWalletsService.java
+++ b/core/src/main/java/bisq/core/api/CoreWalletsService.java
@@ -79,6 +79,7 @@
import javax.annotation.Nullable;
+import static bisq.common.config.BaseCurrencyNetwork.BTC_DAO_REGTEST;
import static bisq.core.btc.wallet.Restrictions.getMinNonDustOutput;
import static bisq.core.util.ParsingUtils.parseToCoin;
import static java.lang.String.format;
@@ -311,8 +312,13 @@ public void onFailure(Throwable t) {
void setTxFeeRatePreference(long txFeeRate,
ResultHandler resultHandler) {
- if (txFeeRate <= 0)
- throw new IllegalStateException("cannot create transactions without fees");
+ long minFeePerVbyte = BTC_DAO_REGTEST.getDefaultMinFeePerVbyte();
+ // TODO Replace line above with line below, after commit
+ // c33ac1b9834fb9f7f14e553d09776f94efc9d13d is merged.
+ // long minFeePerVbyte = feeService.getMinFeePerVByte();
+ if (txFeeRate < minFeePerVbyte)
+ throw new IllegalStateException(
+ format("tx fee rate preference must be >= %d sats/byte", minFeePerVbyte));
preferences.setUseCustomWithdrawalTxFee(true);
Coin satsPerByte = Coin.valueOf(txFeeRate);
diff --git a/core/src/main/java/bisq/core/api/model/TxFeeRateInfo.java b/core/src/main/java/bisq/core/api/model/TxFeeRateInfo.java
index d6014ddce30..330eb163584 100644
--- a/core/src/main/java/bisq/core/api/model/TxFeeRateInfo.java
+++ b/core/src/main/java/bisq/core/api/model/TxFeeRateInfo.java
@@ -67,8 +67,8 @@ public static TxFeeRateInfo fromProto(bisq.proto.grpc.TxFeeRateInfo proto) {
public String toString() {
return "TxFeeRateInfo{" + "\n" +
" useCustomTxFeeRate=" + useCustomTxFeeRate + "\n" +
- ", customTxFeeRate=" + customTxFeeRate + "sats/byte" + "\n" +
- ", feeServiceRate=" + feeServiceRate + "sats/byte" + "\n" +
+ ", customTxFeeRate=" + customTxFeeRate + " sats/byte" + "\n" +
+ ", feeServiceRate=" + feeServiceRate + " sats/byte" + "\n" +
", lastFeeServiceRequestTs=" + lastFeeServiceRequestTs + "\n" +
'}';
}