mirror of
https://github.com/bisq-network/bisq.git
synced 2025-02-22 22:45:21 +01:00
Add CLI testing bot to :apitest
RobotBob reads a json file instructing it to make and take offers as per an 'actions' json array, e.g. ["make","take","take","make], and the tester will manually run CLI commands provided by the bot during each step in a trade. The test case (ScriptedBotTest) can be run with the test harness, which will start and shutdown all the regtest/dao app: bitcoind, seednode, arbnode, bob & alice nodes. The test case can also be run without the test harness, and the user manages his own daemons. Usage will be described in the PR before it leaves draft stage.
This commit is contained in:
parent
7c3ec458b9
commit
a7eb265ded
18 changed files with 2425 additions and 0 deletions
126
apitest/src/test/java/bisq/apitest/scenario/ScriptedBotTest.java
Normal file
126
apitest/src/test/java/bisq/apitest/scenario/ScriptedBotTest.java
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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 <pid>
|
||||
// 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();
|
||||
}
|
||||
}
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
77
apitest/src/test/java/bisq/apitest/scenario/bot/Bot.java
Normal file
77
apitest/src/test/java/bisq/apitest/scenario/bot/Bot.java
Normal file
|
@ -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."));
|
||||
}
|
||||
}
|
386
apitest/src/test/java/bisq/apitest/scenario/bot/BotClient.java
Normal file
386
apitest/src/test/java/bisq/apitest/scenario/bot/BotClient.java
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<OfferInfo>
|
||||
*/
|
||||
public List<OfferInfo> 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<OfferInfo>
|
||||
*/
|
||||
public List<OfferInfo> 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<OfferInfo>
|
||||
*/
|
||||
public List<OfferInfo> 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<Exception, String> 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_]+: ", ""));
|
||||
}
|
||||
}
|
|
@ -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<String, Object> 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<String, Object> 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<String, Object> getPaymentAccountFormMap(String paymentMethodId) {
|
||||
PaymentAccountForm paymentAccountForm = new PaymentAccountForm();
|
||||
File jsonFormTemplate = paymentAccountForm.getPaymentAccountForm(paymentMethodId);
|
||||
jsonFormTemplate.deleteOnExit();
|
||||
String jsonString = paymentAccountForm.toJsonString(jsonFormTemplate);
|
||||
//noinspection unchecked
|
||||
return (Map<String, Object>) gson.fromJson(jsonString, Object.class);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* This file is part of Bisq.
|
||||
*
|
||||
* Bisq is free software: you can redistribute it and/or modify it
|
||||
* under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or (at
|
||||
* your option) any later version.
|
||||
*
|
||||
* Bisq is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
|
||||
* License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package bisq.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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* This file is part of Bisq.
|
||||
*
|
||||
* Bisq is free software: you can redistribute it and/or modify it
|
||||
* under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or (at
|
||||
* your option) any later version.
|
||||
*
|
||||
* Bisq is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
|
||||
* License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package bisq.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);
|
||||
}
|
||||
}
|
177
apitest/src/test/java/bisq/apitest/scenario/bot/RandomOffer.java
Normal file
177
apitest/src/test/java/bisq/apitest/scenario/bot/RandomOffer.java
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<Long> nextAmount = () ->
|
||||
this.getPaymentAccount().getPaymentMethod().getId().equals(F2F_ID)
|
||||
? (long) (10000000 + RANDOM.nextInt(2500000))
|
||||
: (long) (750000 + RANDOM.nextInt(250000));
|
||||
|
||||
@SuppressWarnings("FieldCanBeLocal")
|
||||
private final Supplier<Long> 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<Double> nextPriceMargin = () -> {
|
||||
boolean useZeroMargin = RANDOM.nextBoolean();
|
||||
if (useZeroMargin) {
|
||||
return 0.00;
|
||||
} else {
|
||||
BigDecimal min = BigDecimal.valueOf(-5.0).setScale(2, HALF_UP);
|
||||
BigDecimal max = BigDecimal.valueOf(5.0).setScale(2, HALF_UP);
|
||||
BigDecimal randomBigDecimal = min.add(BigDecimal.valueOf(RANDOM.nextDouble()).multiply(max.subtract(min)));
|
||||
return randomBigDecimal.setScale(2, HALF_UP).doubleValue();
|
||||
}
|
||||
};
|
||||
|
||||
private final BotClient botClient;
|
||||
@Getter
|
||||
private final PaymentAccount paymentAccount;
|
||||
@Getter
|
||||
private final String direction;
|
||||
@Getter
|
||||
private final String currencyCode;
|
||||
@Getter
|
||||
private final long amount;
|
||||
@Getter
|
||||
private final long minAmount;
|
||||
@Getter
|
||||
private final boolean useMarketBasedPrice;
|
||||
@Getter
|
||||
private final double priceMargin;
|
||||
@Getter
|
||||
private final String feeCurrency;
|
||||
|
||||
@Getter
|
||||
private String fixedOfferPrice = "0";
|
||||
@Getter
|
||||
private OfferInfo offer;
|
||||
@Getter
|
||||
private String id;
|
||||
|
||||
public RandomOffer(BotClient botClient, PaymentAccount paymentAccount) {
|
||||
this.botClient = botClient;
|
||||
this.paymentAccount = paymentAccount;
|
||||
this.direction = RANDOM.nextBoolean() ? "BUY" : "SELL";
|
||||
this.currencyCode = Objects.requireNonNull(paymentAccount.getSelectedTradeCurrency()).getCode();
|
||||
this.amount = nextAmount.get();
|
||||
this.minAmount = nextMinAmount.get();
|
||||
this.useMarketBasedPrice = RANDOM.nextBoolean();
|
||||
this.priceMargin = nextPriceMargin.get();
|
||||
this.feeCurrency = RANDOM.nextBoolean() ? "BSQ" : "BTC";
|
||||
}
|
||||
|
||||
public RandomOffer create() throws InvalidRandomOfferException {
|
||||
try {
|
||||
printDescription();
|
||||
if (useMarketBasedPrice) {
|
||||
this.offer = botClient.createOfferAtMarketBasedPrice(paymentAccount,
|
||||
direction,
|
||||
currencyCode,
|
||||
amount,
|
||||
minAmount,
|
||||
priceMargin,
|
||||
getDefaultBuyerSecurityDepositAsPercent(),
|
||||
feeCurrency);
|
||||
} else {
|
||||
this.offer = botClient.createOfferAtFixedPrice(paymentAccount,
|
||||
direction,
|
||||
currencyCode,
|
||||
amount,
|
||||
minAmount,
|
||||
fixedOfferPrice,
|
||||
getDefaultBuyerSecurityDepositAsPercent(),
|
||||
feeCurrency);
|
||||
}
|
||||
this.id = offer.getId();
|
||||
return this;
|
||||
} catch (Exception ex) {
|
||||
String error = String.format("Could not create valid %s offer for %s BTC: %s",
|
||||
currencyCode,
|
||||
formatSatoshis(amount),
|
||||
ex.getMessage());
|
||||
throw new InvalidRandomOfferException(error, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void printDescription() {
|
||||
double currentMarketPrice = botClient.getCurrentBTCMarketPrice(currencyCode);
|
||||
// Calculate a fixed price based on the random mkt price margin, even if we don't use it.
|
||||
double differenceFromMarketPrice = currentMarketPrice * scaleDownByPowerOf10(priceMargin, 2);
|
||||
double fixedOfferPriceAsDouble = direction.equals("BUY")
|
||||
? currentMarketPrice - differenceFromMarketPrice
|
||||
: currentMarketPrice + differenceFromMarketPrice;
|
||||
this.fixedOfferPrice = FIXED_PRICE_FMT.format(fixedOfferPriceAsDouble);
|
||||
String description = format("Creating new %s %s / %s offer for amount = %s BTC, min-amount = %s BTC.",
|
||||
useMarketBasedPrice ? "mkt-based-price" : "fixed-priced",
|
||||
direction,
|
||||
currencyCode,
|
||||
formatSatoshis(amount),
|
||||
formatSatoshis(minAmount));
|
||||
log.info(description);
|
||||
if (useMarketBasedPrice) {
|
||||
log.info("Offer Price Margin = {}%", priceMargin);
|
||||
log.info("Expected Offer Price = {} {}", formatMarketPrice(Double.parseDouble(fixedOfferPrice)), currencyCode);
|
||||
} else {
|
||||
|
||||
log.info("Fixed Offer Price = {} {}", fixedOfferPrice, currencyCode);
|
||||
}
|
||||
log.info("Current Market Price = {} {}", formatMarketPrice(currentMarketPrice), currencyCode);
|
||||
}
|
||||
}
|
141
apitest/src/test/java/bisq/apitest/scenario/bot/RobotBob.java
Normal file
141
apitest/src/test/java/bisq/apitest/scenario/bot/RobotBob.java
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,353 @@
|
|||
/*
|
||||
* This file is part of Bisq.
|
||||
*
|
||||
* Bisq is free software: you can redistribute it and/or modify it
|
||||
* under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or (at
|
||||
* your option) any later version.
|
||||
*
|
||||
* Bisq is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
|
||||
* License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package bisq.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<Long> randomDelay = () -> (long) (2000 + RANDOM.nextInt(5000));
|
||||
|
||||
protected final AtomicLong protocolStepStartTime = new AtomicLong(0);
|
||||
protected final Consumer<ProtocolStep> 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<TradeInfo, TradeInfo> waitForTakerFeeTxConfirm = (trade) -> {
|
||||
sleep(5000);
|
||||
waitForTakerFeeTxPublished(trade.getTradeId());
|
||||
waitForTakerFeeTxConfirmed(trade.getTradeId());
|
||||
return trade;
|
||||
};
|
||||
|
||||
protected final Function<TradeInfo, TradeInfo> waitForPaymentStartedMessage = (trade) -> {
|
||||
initProtocolStep.accept(WAIT_FOR_PAYMENT_STARTED_MESSAGE);
|
||||
try {
|
||||
createPaymentStartedScript(trade);
|
||||
log.info(" Waiting for a 'payment started' message from buyer for trade with id {}.", trade.getTradeId());
|
||||
while (isWithinProtocolStepTimeLimit()) {
|
||||
checkIfShutdownCalled("Interrupted before checking if 'payment started' message has been sent.");
|
||||
try {
|
||||
var t = this.getBotClient().getTrade(trade.getTradeId());
|
||||
if (t.getIsFiatSent()) {
|
||||
log.info("Buyer has started payment for trade:\n{}", TradeFormat.format(t));
|
||||
return t;
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException(this.getBotClient().toCleanGrpcExceptionMessage(ex));
|
||||
}
|
||||
sleep(randomDelay.get());
|
||||
} // end while
|
||||
|
||||
throw new IllegalStateException("Payment was never sent; we won't wait any longer.");
|
||||
} catch (Exception ex) {
|
||||
if (ex instanceof ManualBotShutdownException)
|
||||
throw ex; // not an error
|
||||
else
|
||||
throw new IllegalStateException("Error while waiting payment sent message.", ex);
|
||||
}
|
||||
};
|
||||
|
||||
protected final Function<TradeInfo, TradeInfo> 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<TradeInfo, TradeInfo> waitForPaymentReceivedConfirmation = (trade) -> {
|
||||
initProtocolStep.accept(WAIT_FOR_PAYMENT_RECEIVED_CONFIRMATION_MESSAGE);
|
||||
createPaymentReceivedScript(trade);
|
||||
try {
|
||||
log.info("Waiting for a 'payment received confirmation' message from seller for trade with id {}.", trade.getTradeId());
|
||||
while (isWithinProtocolStepTimeLimit()) {
|
||||
checkIfShutdownCalled("Interrupted before checking if 'payment received confirmation' message has been sent.");
|
||||
try {
|
||||
var t = this.getBotClient().getTrade(trade.getTradeId());
|
||||
if (t.getIsFiatReceived()) {
|
||||
log.info("Seller has received payment for trade:\n{}", TradeFormat.format(t));
|
||||
return t;
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException(this.getBotClient().toCleanGrpcExceptionMessage(ex));
|
||||
}
|
||||
sleep(randomDelay.get());
|
||||
} // end while
|
||||
|
||||
throw new IllegalStateException("Payment was never received; we won't wait any longer.");
|
||||
} catch (Exception ex) {
|
||||
if (ex instanceof ManualBotShutdownException)
|
||||
throw ex; // not an error
|
||||
else
|
||||
throw new IllegalStateException("Error while waiting payment received confirmation message.", ex);
|
||||
}
|
||||
};
|
||||
|
||||
protected final Function<TradeInfo, TradeInfo> 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<TradeInfo, TradeInfo> waitForPayoutTx = (trade) -> {
|
||||
initProtocolStep.accept(WAIT_FOR_PAYOUT_TX);
|
||||
try {
|
||||
log.info("Waiting on the 'payout tx published confirmation' for trade with id {}.", trade.getTradeId());
|
||||
while (isWithinProtocolStepTimeLimit()) {
|
||||
checkIfShutdownCalled("Interrupted before checking if payout tx has been published.");
|
||||
try {
|
||||
var t = this.getBotClient().getTrade(trade.getTradeId());
|
||||
if (t.getIsPayoutPublished()) {
|
||||
log.info("Payout tx {} has been published for trade:\n{}",
|
||||
t.getPayoutTxId(),
|
||||
TradeFormat.format(t));
|
||||
return t;
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException(this.getBotClient().toCleanGrpcExceptionMessage(ex));
|
||||
}
|
||||
sleep(randomDelay.get());
|
||||
} // end while
|
||||
|
||||
throw new IllegalStateException("Payout tx was never published; we won't wait any longer.");
|
||||
} catch (Exception ex) {
|
||||
if (ex instanceof ManualBotShutdownException)
|
||||
throw ex; // not an error
|
||||
else
|
||||
throw new IllegalStateException("Error while waiting for published payout tx.", ex);
|
||||
}
|
||||
};
|
||||
|
||||
protected final Function<TradeInfo, TradeInfo> keepFundsFromTrade = (trade) -> {
|
||||
initProtocolStep.accept(KEEP_FUNDS);
|
||||
var isBuy = trade.getOffer().getDirection().equalsIgnoreCase(BUY);
|
||||
var isSell = trade.getOffer().getDirection().equalsIgnoreCase(SELL);
|
||||
var cliUserIsSeller = (this instanceof MakerBotProtocol && isBuy) || (this instanceof TakerBotProtocol && isSell);
|
||||
if (cliUserIsSeller) {
|
||||
createKeepFundsScript(trade);
|
||||
} else {
|
||||
createGetBalanceScript();
|
||||
}
|
||||
checkIfShutdownCalled("Interrupted before closing trade with 'keep funds' command.");
|
||||
this.getBotClient().sendKeepFundsMessage(trade.getTradeId());
|
||||
return trade;
|
||||
};
|
||||
|
||||
protected void createPaymentStartedScript(TradeInfo trade) {
|
||||
File script = bashScriptGenerator.createPaymentStartedScript(trade);
|
||||
printCliHintAndOrScript(script, "The manual CLI side can send a 'payment started' message");
|
||||
}
|
||||
|
||||
protected void createPaymentReceivedScript(TradeInfo trade) {
|
||||
File script = bashScriptGenerator.createPaymentReceivedScript(trade);
|
||||
printCliHintAndOrScript(script, "The manual CLI side can sent a 'payment received confirmation' message");
|
||||
}
|
||||
|
||||
protected void createKeepFundsScript(TradeInfo trade) {
|
||||
File script = bashScriptGenerator.createKeepFundsScript(trade);
|
||||
printCliHintAndOrScript(script, "The manual CLI side can close the trade");
|
||||
}
|
||||
|
||||
protected void createGetBalanceScript() {
|
||||
File script = bashScriptGenerator.createGetBalanceScript();
|
||||
printCliHintAndOrScript(script, "The manual CLI side can view current balances");
|
||||
}
|
||||
|
||||
protected void createGenerateBtcBlockScript() {
|
||||
String newBitcoinCoreAddress = bitcoinCli.getNewBtcAddress();
|
||||
File script = bashScriptGenerator.createGenerateBtcBlockScript(newBitcoinCoreAddress);
|
||||
printCliHintAndOrScript(script, "The manual CLI side can generate 1 btc block");
|
||||
}
|
||||
|
||||
protected void printCliHintAndOrScript(File script, String hint) {
|
||||
log.info("{} by running bash script '{}'.", hint, script.getAbsolutePath());
|
||||
if (this.getBashScriptGenerator().isPrintCliScripts())
|
||||
this.getBashScriptGenerator().printCliScript(script, log);
|
||||
|
||||
sleep(5000); // Allow 5s for CLI user to read the hint.
|
||||
}
|
||||
|
||||
protected void sleep(long ms) {
|
||||
try {
|
||||
MILLISECONDS.sleep(ms);
|
||||
} catch (InterruptedException ignored) {
|
||||
// empty
|
||||
}
|
||||
}
|
||||
|
||||
private void waitForTakerFeeTxPublished(String tradeId) {
|
||||
waitForTakerDepositFee(tradeId, WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED);
|
||||
}
|
||||
|
||||
private void waitForTakerFeeTxConfirmed(String tradeId) {
|
||||
waitForTakerDepositFee(tradeId, WAIT_FOR_TAKER_DEPOSIT_TX_CONFIRMED);
|
||||
}
|
||||
|
||||
private void waitForTakerDepositFee(String tradeId, ProtocolStep depositTxProtocolStep) {
|
||||
initProtocolStep.accept(depositTxProtocolStep);
|
||||
validateCurrentProtocolStep(WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED, WAIT_FOR_TAKER_DEPOSIT_TX_CONFIRMED);
|
||||
try {
|
||||
log.info(waitingForDepositFeeTxMsg(tradeId));
|
||||
while (isWithinProtocolStepTimeLimit()) {
|
||||
checkIfShutdownCalled("Interrupted before checking taker deposit fee tx is published and confirmed.");
|
||||
try {
|
||||
var trade = this.getBotClient().getTrade(tradeId);
|
||||
if (isDepositFeeTxStepComplete.test(trade))
|
||||
return;
|
||||
else
|
||||
sleep(randomDelay.get());
|
||||
} catch (Exception ex) {
|
||||
if (this.getBotClient().tradeContractIsNotReady.test(ex, tradeId))
|
||||
sleep(randomDelay.get());
|
||||
else
|
||||
throw new IllegalStateException(this.getBotClient().toCleanGrpcExceptionMessage(ex));
|
||||
}
|
||||
} // end while
|
||||
throw new IllegalStateException(stoppedWaitingForDepositFeeTxMsg(this.getBotClient().getTrade(tradeId).getDepositTxId()));
|
||||
} catch (Exception ex) {
|
||||
if (ex instanceof ManualBotShutdownException)
|
||||
throw ex; // not an error
|
||||
else
|
||||
throw new IllegalStateException("Error while waiting for taker deposit tx to be published or confirmed.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private final Predicate<TradeInfo> 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");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,115 @@
|
|||
package bisq.apitest.scenario.bot.protocol;
|
||||
|
||||
import bisq.proto.grpc.OfferInfo;
|
||||
import bisq.proto.grpc.TradeInfo;
|
||||
|
||||
import protobuf.PaymentAccount;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import static bisq.apitest.scenario.bot.protocol.ProtocolStep.DONE;
|
||||
import static bisq.apitest.scenario.bot.protocol.ProtocolStep.WAIT_FOR_OFFER_TAKER;
|
||||
import static bisq.apitest.scenario.bot.shutdown.ManualShutdown.checkIfShutdownCalled;
|
||||
import static bisq.cli.TableFormat.formatOfferTable;
|
||||
import static java.util.Collections.singletonList;
|
||||
|
||||
|
||||
|
||||
import bisq.apitest.method.BitcoinCliHelper;
|
||||
import bisq.apitest.scenario.bot.BotClient;
|
||||
import bisq.apitest.scenario.bot.RandomOffer;
|
||||
import bisq.apitest.scenario.bot.script.BashScriptGenerator;
|
||||
import bisq.apitest.scenario.bot.shutdown.ManualBotShutdownException;
|
||||
import bisq.cli.TradeFormat;
|
||||
|
||||
@Slf4j
|
||||
public class MakerBotProtocol extends BotProtocol {
|
||||
|
||||
public MakerBotProtocol(BotClient botClient,
|
||||
PaymentAccount paymentAccount,
|
||||
long protocolStepTimeLimitInMs,
|
||||
BitcoinCliHelper bitcoinCli,
|
||||
BashScriptGenerator bashScriptGenerator) {
|
||||
super(botClient,
|
||||
paymentAccount,
|
||||
protocolStepTimeLimitInMs,
|
||||
bitcoinCli,
|
||||
bashScriptGenerator);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
checkIsStartStep();
|
||||
|
||||
Function<Supplier<OfferInfo>, TradeInfo> makeTrade = waitForNewTrade.andThen(waitForTakerFeeTxConfirm);
|
||||
var trade = makeTrade.apply(randomOffer);
|
||||
|
||||
var makerIsBuyer = trade.getOffer().getDirection().equalsIgnoreCase(BUY);
|
||||
Function<TradeInfo, TradeInfo> completeFiatTransaction = makerIsBuyer
|
||||
? sendPaymentStartedMessage.andThen(waitForPaymentReceivedConfirmation)
|
||||
: waitForPaymentStartedMessage.andThen(sendPaymentReceivedMessage);
|
||||
completeFiatTransaction.apply(trade);
|
||||
|
||||
Function<TradeInfo, TradeInfo> closeTrade = waitForPayoutTx.andThen(keepFundsFromTrade);
|
||||
closeTrade.apply(trade);
|
||||
|
||||
currentProtocolStep = DONE;
|
||||
}
|
||||
|
||||
private final Supplier<OfferInfo> 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<Supplier<OfferInfo>, TradeInfo> waitForNewTrade = (randomOffer) -> {
|
||||
initProtocolStep.accept(WAIT_FOR_OFFER_TAKER);
|
||||
OfferInfo offer = randomOffer.get();
|
||||
createTakeOfferCliScript(offer);
|
||||
try {
|
||||
log.info("Impatiently waiting for offer {} to be taken, repeatedly calling gettrade.", offer.getId());
|
||||
while (isWithinProtocolStepTimeLimit()) {
|
||||
checkIfShutdownCalled("Interrupted while waiting for offer to be taken.");
|
||||
try {
|
||||
var trade = getNewTrade(offer.getId());
|
||||
if (trade.isPresent())
|
||||
return trade.get();
|
||||
else
|
||||
sleep(randomDelay.get());
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException(this.getBotClient().toCleanGrpcExceptionMessage(ex), ex);
|
||||
}
|
||||
} // end while
|
||||
throw new IllegalStateException("Offer was never taken; we won't wait any longer.");
|
||||
} catch (Exception ex) {
|
||||
if (ex instanceof ManualBotShutdownException)
|
||||
throw ex; // not an error
|
||||
else
|
||||
throw new IllegalStateException("Error while waiting for offer to be taken.", ex);
|
||||
}
|
||||
};
|
||||
|
||||
private Optional<TradeInfo> 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");
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -0,0 +1,137 @@
|
|||
package bisq.apitest.scenario.bot.protocol;
|
||||
|
||||
import bisq.proto.grpc.OfferInfo;
|
||||
import bisq.proto.grpc.TradeInfo;
|
||||
|
||||
import protobuf.PaymentAccount;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import static bisq.apitest.scenario.bot.protocol.ProtocolStep.DONE;
|
||||
import static bisq.apitest.scenario.bot.protocol.ProtocolStep.FIND_OFFER;
|
||||
import static bisq.apitest.scenario.bot.protocol.ProtocolStep.TAKE_OFFER;
|
||||
import static bisq.apitest.scenario.bot.shutdown.ManualShutdown.checkIfShutdownCalled;
|
||||
import static bisq.cli.TableFormat.formatOfferTable;
|
||||
import static bisq.core.payment.payload.PaymentMethod.F2F_ID;
|
||||
|
||||
|
||||
|
||||
import bisq.apitest.method.BitcoinCliHelper;
|
||||
import bisq.apitest.scenario.bot.BotClient;
|
||||
import bisq.apitest.scenario.bot.script.BashScriptGenerator;
|
||||
import bisq.apitest.scenario.bot.shutdown.ManualBotShutdownException;
|
||||
|
||||
@Slf4j
|
||||
public class TakerBotProtocol extends BotProtocol {
|
||||
|
||||
public TakerBotProtocol(BotClient botClient,
|
||||
PaymentAccount paymentAccount,
|
||||
long protocolStepTimeLimitInMs,
|
||||
BitcoinCliHelper bitcoinCli,
|
||||
BashScriptGenerator bashScriptGenerator) {
|
||||
super(botClient,
|
||||
paymentAccount,
|
||||
protocolStepTimeLimitInMs,
|
||||
bitcoinCli,
|
||||
bashScriptGenerator);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
checkIsStartStep();
|
||||
|
||||
Function<OfferInfo, TradeInfo> takeTrade = takeOffer.andThen(waitForTakerFeeTxConfirm);
|
||||
var trade = takeTrade.apply(findOffer.get());
|
||||
|
||||
var takerIsSeller = trade.getOffer().getDirection().equalsIgnoreCase(BUY);
|
||||
Function<TradeInfo, TradeInfo> completeFiatTransaction = takerIsSeller
|
||||
? waitForPaymentStartedMessage.andThen(sendPaymentReceivedMessage)
|
||||
: sendPaymentStartedMessage.andThen(waitForPaymentReceivedConfirmation);
|
||||
completeFiatTransaction.apply(trade);
|
||||
|
||||
Function<TradeInfo, TradeInfo> closeTrade = waitForPayoutTx.andThen(keepFundsFromTrade);
|
||||
closeTrade.apply(trade);
|
||||
|
||||
currentProtocolStep = DONE;
|
||||
}
|
||||
|
||||
private final Supplier<Optional<OfferInfo>> 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<OfferInfo> 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<OfferInfo> offer = firstOffer.get();
|
||||
if (offer.isPresent())
|
||||
return offer.get();
|
||||
else
|
||||
sleep(randomDelay.get());
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException(this.getBotClient().toCleanGrpcExceptionMessage(ex), ex);
|
||||
}
|
||||
} // end while
|
||||
throw new IllegalStateException("Offer was never created; we won't wait any longer.");
|
||||
} catch (Exception ex) {
|
||||
if (ex instanceof ManualBotShutdownException)
|
||||
throw ex; // not an error
|
||||
else
|
||||
throw new IllegalStateException("Error while waiting for a new offer.", ex);
|
||||
}
|
||||
};
|
||||
|
||||
private final Function<OfferInfo, TradeInfo> 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");
|
||||
}
|
||||
}
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<CharSequence> 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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,236 @@
|
|||
/*
|
||||
* This file is part of Bisq.
|
||||
*
|
||||
* Bisq is free software: you can redistribute it and/or modify it
|
||||
* under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or (at
|
||||
* your option) any later version.
|
||||
*
|
||||
* Bisq is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
|
||||
* License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package bisq.apitest.scenario.bot.script;
|
||||
|
||||
import bisq.common.file.JsonFileManager;
|
||||
import bisq.common.util.Utilities;
|
||||
|
||||
import joptsimple.BuiltinHelpFormatter;
|
||||
import joptsimple.OptionParser;
|
||||
import joptsimple.OptionSet;
|
||||
import joptsimple.OptionSpec;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
import static java.lang.System.err;
|
||||
import static java.lang.System.exit;
|
||||
import static java.lang.System.getProperty;
|
||||
|
||||
@Slf4j
|
||||
public class BotScriptGenerator {
|
||||
|
||||
private final boolean useTestHarness;
|
||||
@Nullable
|
||||
private final String countryCode;
|
||||
@Nullable
|
||||
private final String botPaymentMethodId;
|
||||
@Nullable
|
||||
private final String paymentAccountIdForBot;
|
||||
@Nullable
|
||||
private final String paymentAccountIdForCliScripts;
|
||||
private final int apiPortForCliScripts;
|
||||
private final String actions;
|
||||
private final int protocolStepTimeLimitInMinutes;
|
||||
private final boolean printCliScripts;
|
||||
private final boolean stayAlive;
|
||||
|
||||
public BotScriptGenerator(String[] args) {
|
||||
OptionParser parser = new OptionParser();
|
||||
OptionSpec<Boolean> useTestHarnessOpt = parser
|
||||
.accepts("use-testharness", "Use the test harness, or manually start your own nodes.")
|
||||
.withRequiredArg()
|
||||
.ofType(Boolean.class)
|
||||
.defaultsTo(true);
|
||||
OptionSpec<String> actionsOpt = parser
|
||||
.accepts("actions", "A comma delimited list with no spaces, e.g., make,take,take,make,...")
|
||||
.withRequiredArg();
|
||||
OptionSpec<String> 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<String> 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<Integer> 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<String> 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<String> 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<Integer> protocolStepTimeLimitInMinutesOpt = parser
|
||||
.accepts("step-time-limit", "Each protocol step's time limit in minutes")
|
||||
.withRequiredArg()
|
||||
.ofType(Integer.class)
|
||||
.defaultsTo(60);
|
||||
OptionSpec<Boolean> printCliScriptsOpt = parser
|
||||
.accepts("print-cli-scripts", "Print the generated CLI scripts from bot")
|
||||
.withRequiredArg()
|
||||
.ofType(Boolean.class)
|
||||
.defaultsTo(false);
|
||||
OptionSpec<Boolean> stayAliveOpt = parser
|
||||
.accepts("stay-alive", "Leave test harness nodes running after the last action.")
|
||||
.withRequiredArg()
|
||||
.ofType(Boolean.class)
|
||||
.defaultsTo(true);
|
||||
OptionSet options = parser.parse(args);
|
||||
|
||||
if (!options.has(actionsOpt)) {
|
||||
usageError(parser);
|
||||
}
|
||||
|
||||
this.useTestHarness = options.has(useTestHarnessOpt) ? options.valueOf(useTestHarnessOpt) : true;
|
||||
this.actions = options.valueOf(actionsOpt);
|
||||
this.apiPortForCliScripts = options.has(apiPortForCliScriptsOpt) ? options.valueOf(apiPortForCliScriptsOpt) : 9998;
|
||||
this.botPaymentMethodId = options.has(botPaymentMethodIdOpt) ? options.valueOf(botPaymentMethodIdOpt) : null;
|
||||
this.countryCode = options.has(countryCodeOpt) ? options.valueOf(countryCodeOpt) : null;
|
||||
this.paymentAccountIdForBot = options.has(paymentAccountIdForBotOpt) ? options.valueOf(paymentAccountIdForBotOpt) : null;
|
||||
this.paymentAccountIdForCliScripts = options.has(paymentAccountIdForCliScriptsOpt) ? options.valueOf(paymentAccountIdForCliScriptsOpt) : null;
|
||||
this.protocolStepTimeLimitInMinutes = options.valueOf(protocolStepTimeLimitInMinutesOpt);
|
||||
this.printCliScripts = options.valueOf(printCliScriptsOpt);
|
||||
this.stayAlive = options.valueOf(stayAliveOpt);
|
||||
|
||||
var noPaymentAccountCountryOrMethodForTestHarness = useTestHarness &&
|
||||
(!options.has(countryCodeOpt) && !options.has(botPaymentMethodIdOpt));
|
||||
if (noPaymentAccountCountryOrMethodForTestHarness) {
|
||||
log.error("When running the test harness, payment accounts are automatically generated,");
|
||||
log.error("and you must provide one of the following options:");
|
||||
log.error(" \t\t(1) --bot-payment-method=<payment-method-id> OR");
|
||||
log.error(" \t\t(2) --country-code=<country-code>");
|
||||
log.error("If the bot-payment-method option is not present, the bot will create"
|
||||
+ " a country based F2F account using the country-code.");
|
||||
log.error("If both are present, the bot-payment-method will take precedence. "
|
||||
+ "Currently, only the CLEAR_X_CHANGE_ID bot-payment-method is supported.");
|
||||
usageError(parser);
|
||||
}
|
||||
|
||||
var noPaymentAccountIdOrApiPortForCliScripts = !useTestHarness &&
|
||||
(!options.has(paymentAccountIdForCliScriptsOpt) || !options.has(paymentAccountIdForBotOpt));
|
||||
if (noPaymentAccountIdOrApiPortForCliScripts) {
|
||||
log.error("If not running the test harness, payment accounts are not automatically generated,");
|
||||
log.error("and you must provide three options:");
|
||||
log.error(" \t\t(1) --api-port-for-cli-scripts=<port>");
|
||||
log.error(" \t\t(2) --payment-account-for-bot=<payment-account-id>");
|
||||
log.error(" \t\t(3) --payment-account-for-cli-scripts=<payment-account-id>");
|
||||
log.error("These will be used by the bot and in CLI scripts the bot will generate when creating an offer.");
|
||||
usageError(parser);
|
||||
}
|
||||
}
|
||||
|
||||
private void usageError(OptionParser parser) {
|
||||
try {
|
||||
String usage = "Examples\n--------\n"
|
||||
+ examplesUsingTestHarness()
|
||||
+ examplesNotUsingTestHarness();
|
||||
err.println();
|
||||
parser.formatHelpWith(new HelpFormatter());
|
||||
parser.printHelpOn(err);
|
||||
err.println();
|
||||
err.println(usage);
|
||||
err.println();
|
||||
} catch (IOException ex) {
|
||||
log.error("", ex);
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
|
||||
private String examplesUsingTestHarness() {
|
||||
@SuppressWarnings("StringBufferReplaceableByString") StringBuilder builder = new StringBuilder();
|
||||
builder.append("To generate a bot-script.json file that will start the test harness,");
|
||||
builder.append(" create F2F accounts for Bob and Alice,");
|
||||
builder.append(" and take an offer created by Alice's CLI:").append("\n");
|
||||
builder.append("\tUsage: BotScriptGenerator").append("\n");
|
||||
builder.append("\t\t").append("--use-testharness=true").append("\n");
|
||||
builder.append("\t\t").append("--country-code=<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=<port>").append("\n");
|
||||
builder.append("\t\t").append("--payment-account-for-bot=<payment-account-id>").append("\n");
|
||||
builder.append("\t\t").append("--payment-account-for-cli-scripts=<payment-account-id>").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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* This file is part of Bisq.
|
||||
*
|
||||
* Bisq is free software: you can redistribute it and/or modify it
|
||||
* under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or (at
|
||||
* your option) any later version.
|
||||
*
|
||||
* Bisq is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
|
||||
* License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package bisq.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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue