From 7c3ec458b96845deeb82bc9f333512384e390ab7 Mon Sep 17 00:00:00 2001 From: ghubstan <36207203+ghubstan@users.noreply.github.com> Date: Thu, 18 Feb 2021 12:50:47 -0300 Subject: [PATCH 1/7] Make @VisibleForTesting --- cli/src/main/java/bisq/cli/CurrencyFormat.java | 16 ++++++++-------- cli/src/main/java/bisq/cli/TableFormat.java | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) 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(), From a7eb265deda0a70453ef32f10d06bc4d9a954427 Mon Sep 17 00:00:00 2001 From: ghubstan <36207203+ghubstan@users.noreply.github.com> Date: Thu, 18 Feb 2021 12:52:16 -0300 Subject: [PATCH 2/7] Add CLI testing bot to :apitest RobotBob reads a json file instructing it to make and take offers as per an 'actions' json array, e.g. ["make","take","take","make], and the tester will manually run CLI commands provided by the bot during each step in a trade. The test case (ScriptedBotTest) can be run with the test harness, which will start and shutdown all the regtest/dao app: bitcoind, seednode, arbnode, bob & alice nodes. The test case can also be run without the test harness, and the user manages his own daemons. Usage will be described in the PR before it leaves draft stage. --- .../apitest/scenario/ScriptedBotTest.java | 126 ++++++ .../apitest/scenario/bot/AbstractBotTest.java | 110 +++++ .../java/bisq/apitest/scenario/bot/Bot.java | 77 ++++ .../bisq/apitest/scenario/bot/BotClient.java | 386 ++++++++++++++++++ .../bot/BotPaymentAccountGenerator.java | 68 +++ .../bot/InvalidRandomOfferException.java | 35 ++ .../bot/PaymentAccountNotFoundException.java | 35 ++ .../apitest/scenario/bot/RandomOffer.java | 177 ++++++++ .../bisq/apitest/scenario/bot/RobotBob.java | 141 +++++++ .../scenario/bot/protocol/BotProtocol.java | 353 ++++++++++++++++ .../bot/protocol/MakerBotProtocol.java | 115 ++++++ .../scenario/bot/protocol/ProtocolStep.java | 17 + .../bot/protocol/TakerBotProtocol.java | 137 +++++++ .../bot/script/BashScriptGenerator.java | 235 +++++++++++ .../scenario/bot/script/BotScript.java | 78 ++++ .../bot/script/BotScriptGenerator.java | 236 +++++++++++ .../shutdown/ManualBotShutdownException.java | 35 ++ .../scenario/bot/shutdown/ManualShutdown.java | 64 +++ 18 files changed, 2425 insertions(+) create mode 100644 apitest/src/test/java/bisq/apitest/scenario/ScriptedBotTest.java create mode 100644 apitest/src/test/java/bisq/apitest/scenario/bot/AbstractBotTest.java create mode 100644 apitest/src/test/java/bisq/apitest/scenario/bot/Bot.java create mode 100644 apitest/src/test/java/bisq/apitest/scenario/bot/BotClient.java create mode 100644 apitest/src/test/java/bisq/apitest/scenario/bot/BotPaymentAccountGenerator.java create mode 100644 apitest/src/test/java/bisq/apitest/scenario/bot/InvalidRandomOfferException.java create mode 100644 apitest/src/test/java/bisq/apitest/scenario/bot/PaymentAccountNotFoundException.java create mode 100644 apitest/src/test/java/bisq/apitest/scenario/bot/RandomOffer.java create mode 100644 apitest/src/test/java/bisq/apitest/scenario/bot/RobotBob.java create mode 100644 apitest/src/test/java/bisq/apitest/scenario/bot/protocol/BotProtocol.java create mode 100644 apitest/src/test/java/bisq/apitest/scenario/bot/protocol/MakerBotProtocol.java create mode 100644 apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ProtocolStep.java create mode 100644 apitest/src/test/java/bisq/apitest/scenario/bot/protocol/TakerBotProtocol.java create mode 100644 apitest/src/test/java/bisq/apitest/scenario/bot/script/BashScriptGenerator.java create mode 100644 apitest/src/test/java/bisq/apitest/scenario/bot/script/BotScript.java create mode 100644 apitest/src/test/java/bisq/apitest/scenario/bot/script/BotScriptGenerator.java create mode 100644 apitest/src/test/java/bisq/apitest/scenario/bot/shutdown/ManualBotShutdownException.java create mode 100644 apitest/src/test/java/bisq/apitest/scenario/bot/shutdown/ManualShutdown.java 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/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..8c2e72f843d --- /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 = String.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..fac4df9d0d1 --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/BotProtocol.java @@ -0,0 +1,353 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.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 (Exception ex) { + if (ex instanceof ManualBotShutdownException) + throw ex; // not an error + else + 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 (Exception ex) { + if (ex instanceof ManualBotShutdownException) + throw ex; // not an error + else + 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 (Exception ex) { + if (ex instanceof ManualBotShutdownException) + throw ex; // not an error + else + 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 (Exception ex) { + if (ex instanceof ManualBotShutdownException) + throw ex; // not an error + else + 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..a8c581508be --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/MakerBotProtocol.java @@ -0,0 +1,115 @@ +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 (Exception ex) { + if (ex instanceof ManualBotShutdownException) + throw ex; // not an error + else + 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..5658168f1bc --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/TakerBotProtocol.java @@ -0,0 +1,137 @@ +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 (Exception ex) { + if (ex instanceof ManualBotShutdownException) + throw ex; // not an error + else + 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..870136fd1d0 --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/scenario/bot/script/BotScriptGenerator.java @@ -0,0 +1,236 @@ +/* + * 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 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; + +@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(); + 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(actionsOpt)) { + usageError(parser); + } + + 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."); + usageError(parser); + } + + 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."); + usageError(parser); + } + } + + private void usageError(OptionParser parser) { + try { + String usage = "Examples\n--------\n" + + examplesUsingTestHarness() + + examplesNotUsingTestHarness(); + err.println(); + parser.formatHelpWith(new HelpFormatter()); + parser.printHelpOn(err); + err.println(); + err.println(usage); + err.println(); + } catch (IOException ex) { + log.error("", ex); + } + exit(1); + } + + 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); + } + } +} From 9e48c32d400a3843bef9417b45f1fc2f53acd0d8 Mon Sep 17 00:00:00 2001 From: ghubstan <36207203+ghubstan@users.noreply.github.com> Date: Thu, 18 Feb 2021 13:58:58 -0300 Subject: [PATCH 3/7] Fix manual shutdown exception handling Codacy is right. Don't use instance of ex, add a catch clause. Also removed an unnecessary fully qualified name 'String.format'. --- .../apitest/scenario/bot/RandomOffer.java | 2 +- .../scenario/bot/protocol/BotProtocol.java | 28 ++++++++----------- .../bot/protocol/MakerBotProtocol.java | 7 ++--- .../bot/protocol/TakerBotProtocol.java | 7 ++--- 4 files changed, 19 insertions(+), 25 deletions(-) diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/RandomOffer.java b/apitest/src/test/java/bisq/apitest/scenario/bot/RandomOffer.java index 8c2e72f843d..1942f8ad073 100644 --- a/apitest/src/test/java/bisq/apitest/scenario/bot/RandomOffer.java +++ b/apitest/src/test/java/bisq/apitest/scenario/bot/RandomOffer.java @@ -142,7 +142,7 @@ public RandomOffer create() throws InvalidRandomOfferException { this.id = offer.getId(); return this; } catch (Exception ex) { - String error = String.format("Could not create valid %s offer for %s BTC: %s", + String error = format("Could not create valid %s offer for %s BTC: %s", currencyCode, formatSatoshis(amount), ex.getMessage()); diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/BotProtocol.java b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/BotProtocol.java index fac4df9d0d1..51d59e7537d 100644 --- a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/BotProtocol.java +++ b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/BotProtocol.java @@ -143,11 +143,10 @@ protected void printBotProtocolStep() { } // 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) { - if (ex instanceof ManualBotShutdownException) - throw ex; // not an error - else - throw new IllegalStateException("Error while waiting payment sent message.", ex); + throw new IllegalStateException("Error while waiting payment sent message.", ex); } }; @@ -178,11 +177,10 @@ protected void printBotProtocolStep() { } // 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) { - if (ex instanceof ManualBotShutdownException) - throw ex; // not an error - else - throw new IllegalStateException("Error while waiting payment received confirmation message.", ex); + throw new IllegalStateException("Error while waiting payment received confirmation message.", ex); } }; @@ -214,11 +212,10 @@ protected void printBotProtocolStep() { } // 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) { - if (ex instanceof ManualBotShutdownException) - throw ex; // not an error - else - throw new IllegalStateException("Error while waiting for published payout tx.", ex); + throw new IllegalStateException("Error while waiting for published payout tx.", ex); } }; @@ -308,11 +305,10 @@ private void waitForTakerDepositFee(String tradeId, ProtocolStep depositTxProtoc } } // 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) { - if (ex instanceof ManualBotShutdownException) - throw ex; // not an error - else - throw new IllegalStateException("Error while waiting for taker deposit tx to be published or confirmed.", ex); + throw new IllegalStateException("Error while waiting for taker deposit tx to be published or confirmed.", ex); } } diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/MakerBotProtocol.java b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/MakerBotProtocol.java index a8c581508be..0ce26002ece 100644 --- a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/MakerBotProtocol.java +++ b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/MakerBotProtocol.java @@ -88,11 +88,10 @@ public void run() { } } // 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) { - if (ex instanceof ManualBotShutdownException) - throw ex; // not an error - else - throw new IllegalStateException("Error while waiting for offer to be taken.", ex); + throw new IllegalStateException("Error while waiting for offer to be taken.", ex); } }; diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/TakerBotProtocol.java b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/TakerBotProtocol.java index 5658168f1bc..63b700824f6 100644 --- a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/TakerBotProtocol.java +++ b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/TakerBotProtocol.java @@ -92,11 +92,10 @@ public void run() { } } // 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) { - if (ex instanceof ManualBotShutdownException) - throw ex; // not an error - else - throw new IllegalStateException("Error while waiting for a new offer.", ex); + throw new IllegalStateException("Error while waiting for a new offer.", ex); } }; From 4ac9fa5b8de2b9f0bcb8b8da90ab7a07a3432bf5 Mon Sep 17 00:00:00 2001 From: ghubstan <36207203+ghubstan@users.noreply.github.com> Date: Sat, 20 Feb 2021 16:37:06 -0300 Subject: [PATCH 4/7] Add --help option to bot-script.json generator --- .../bot/script/BotScriptGenerator.java | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/script/BotScriptGenerator.java b/apitest/src/test/java/bisq/apitest/scenario/bot/script/BotScriptGenerator.java index 870136fd1d0..c81730c4c40 100644 --- a/apitest/src/test/java/bisq/apitest/scenario/bot/script/BotScriptGenerator.java +++ b/apitest/src/test/java/bisq/apitest/scenario/bot/script/BotScriptGenerator.java @@ -27,6 +27,7 @@ import java.io.File; import java.io.IOException; +import java.io.PrintStream; import lombok.extern.slf4j.Slf4j; @@ -35,6 +36,7 @@ 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 { @@ -56,6 +58,8 @@ public class BotScriptGenerator { 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() @@ -107,8 +111,14 @@ public BotScriptGenerator(String[] args) { .defaultsTo(true); OptionSet options = parser.parse(args); + if (options.has(helpOpt)) { + printHelp(parser, out); + exit(0); + } + if (!options.has(actionsOpt)) { - usageError(parser); + printHelp(parser, err); + exit(1); } this.useTestHarness = options.has(useTestHarnessOpt) ? options.valueOf(useTestHarnessOpt) : true; @@ -133,7 +143,8 @@ public BotScriptGenerator(String[] args) { + " 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."); - usageError(parser); + printHelp(parser, err); + exit(1); } var noPaymentAccountIdOrApiPortForCliScripts = !useTestHarness && @@ -145,25 +156,25 @@ public BotScriptGenerator(String[] args) { 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."); - usageError(parser); + printHelp(parser, err); + exit(1); } } - private void usageError(OptionParser parser) { + private void printHelp(OptionParser parser, PrintStream stream) { try { String usage = "Examples\n--------\n" + examplesUsingTestHarness() + examplesNotUsingTestHarness(); - err.println(); + stream.println(); parser.formatHelpWith(new HelpFormatter()); - parser.printHelpOn(err); - err.println(); - err.println(usage); - err.println(); + parser.printHelpOn(stream); + stream.println(); + stream.println(usage); + stream.println(); } catch (IOException ex) { log.error("", ex); } - exit(1); } private String examplesUsingTestHarness() { From 3dde3cbeef49416961b0f59b6ae7e20d64d9d948 Mon Sep 17 00:00:00 2001 From: ghubstan <36207203+ghubstan@users.noreply.github.com> Date: Thu, 25 Feb 2021 10:37:30 -0300 Subject: [PATCH 5/7] Fix typo in toString --- core/src/main/java/bisq/core/api/model/TxFeeRateInfo.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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" + '}'; } From b725f065140fdd5ec7c8e6228907f9d7de80ebd1 Mon Sep 17 00:00:00 2001 From: ghubstan <36207203+ghubstan@users.noreply.github.com> Date: Thu, 25 Feb 2021 11:31:13 -0300 Subject: [PATCH 6/7] Adjust api to new minimum fee per vbyte The BaseCurrencyNetwork#getDefaultMinFeePerVbyte now returns 15 (sats/byte) since commit b341bb6e891c9a6f8ebb9ac9a94c919e0ce18d74. This change adjusts the api to the new min tx fee rate by validating the api's setTxFeeRatePreference param, and throwing an appropirate exception if the param value is below the minimum. Also adjusted a broken test, and added a new test to check the appropriate exception is received from the server. --- .../method/wallet/BtcTxFeeRateTest.java | 24 +++++++++++++++---- .../bisq/apitest/scenario/WalletTest.java | 3 ++- .../bisq/core/api/CoreWalletsService.java | 7 ++++-- 3 files changed, 27 insertions(+), 7 deletions(-) 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/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/core/src/main/java/bisq/core/api/CoreWalletsService.java b/core/src/main/java/bisq/core/api/CoreWalletsService.java index 0ef5a22d2fd..924919038fa 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,10 @@ 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(); + 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); From e125ba8ab8a020769d20ddd0c7443c0701c47455 Mon Sep 17 00:00:00 2001 From: ghubstan <36207203+ghubstan@users.noreply.github.com> Date: Thu, 25 Feb 2021 11:53:12 -0300 Subject: [PATCH 7/7] Prepare to adjust api to new minimum fee per vbyte (from feeService) Commit c33ac1b9834fb9f7f14e553d09776f94efc9d13d changed the source of the min tx fee rate, and the api will adjust after the associated PR is merged. For now, the required change is a comment. --- core/src/main/java/bisq/core/api/CoreWalletsService.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/core/src/main/java/bisq/core/api/CoreWalletsService.java b/core/src/main/java/bisq/core/api/CoreWalletsService.java index 924919038fa..b048fcefe97 100644 --- a/core/src/main/java/bisq/core/api/CoreWalletsService.java +++ b/core/src/main/java/bisq/core/api/CoreWalletsService.java @@ -313,6 +313,9 @@ public void onFailure(Throwable t) { void setTxFeeRatePreference(long txFeeRate, ResultHandler resultHandler) { 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));