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