diff --git a/apitest/scripts/trade-simulation-utils.sh b/apitest/scripts/trade-simulation-utils.sh index c9f03ac80f5..3ed7d3488b8 100755 --- a/apitest/scripts/trade-simulation-utils.sh +++ b/apitest/scripts/trade-simulation-utils.sh @@ -238,65 +238,35 @@ gettradedetail() { istradedepositpublished() { TRADE_DETAIL="$1" - MAKER_OR_TAKER="$2" - if [ "$MAKER_OR_TAKER" = "MAKER" ] - then - ANSWER=$(echo "$TRADE_DETAIL" | awk '{print $9}') - else - ANSWER=$(echo "$TRADE_DETAIL" | awk '{print $10}') - fi + ANSWER=$(echo "$TRADE_DETAIL" | awk '{print $10}') commandalert $? "Could not parse istradedepositpublished from trade detail." echo "$ANSWER" } istradedepositconfirmed() { TRADE_DETAIL="$1" - MAKER_OR_TAKER="$2" - if [ "$MAKER_OR_TAKER" = "MAKER" ] - then - ANSWER=$(echo "$TRADE_DETAIL" | awk '{print $10}') - else - ANSWER=$(echo "$TRADE_DETAIL" | awk '{print $11}') - fi + ANSWER=$(echo "$TRADE_DETAIL" | awk '{print $11}') commandalert $? "Could not parse istradedepositconfirmed from trade detail." echo "$ANSWER" } istradepaymentsent() { TRADE_DETAIL="$1" - MAKER_OR_TAKER="$2" - if [ "$MAKER_OR_TAKER" = "MAKER" ] - then - ANSWER=$(echo "$TRADE_DETAIL" | awk '{print $12}') - else - ANSWER=$(echo "$TRADE_DETAIL" | awk '{print $13}') - fi + ANSWER=$(echo "$TRADE_DETAIL" | awk '{print $13}') commandalert $? "Could not parse istradepaymentsent from trade detail." echo "$ANSWER" } istradepaymentreceived() { TRADE_DETAIL="$1" - MAKER_OR_TAKER="$2" - if [ "$MAKER_OR_TAKER" = "MAKER" ] - then - ANSWER=$(echo "$TRADE_DETAIL" | awk '{print $13}') - else - ANSWER=$(echo "$TRADE_DETAIL" | awk '{print $14}') - fi + ANSWER=$(echo "$TRADE_DETAIL" | awk '{print $14}') commandalert $? "Could not parse istradepaymentreceived from trade detail." echo "$ANSWER" } istradepayoutpublished() { TRADE_DETAIL="$1" - MAKER_OR_TAKER="$2" - if [ "$MAKER_OR_TAKER" = "MAKER" ] - then - ANSWER=$(echo "$TRADE_DETAIL" | awk '{print $14}') - else - ANSWER=$(echo "$TRADE_DETAIL" | awk '{print $15}') - fi + ANSWER=$(echo "$TRADE_DETAIL" | awk '{print $15}') commandalert $? "Could not parse istradepayoutpublished from trade detail." echo "$ANSWER" } @@ -321,7 +291,7 @@ waitfortradedepositpublished() { TRADE_DETAIL=$(gettradedetail "$GETTRADE_CMD_OUTPUT") exitoncommandalert $? - IS_TRADE_DEPOSIT_PUBLISHED=$(istradedepositpublished "$TRADE_DETAIL" "TAKER") + IS_TRADE_DEPOSIT_PUBLISHED=$(istradedepositpublished "$TRADE_DETAIL") exitoncommandalert $? printdate "BOB $BOB_ROLE: Has taker's trade deposit been published? $IS_TRADE_DEPOSIT_PUBLISHED" @@ -356,7 +326,7 @@ waitfortradedepositconfirmed() { TRADE_DETAIL=$(gettradedetail "$GETTRADE_CMD_OUTPUT") exitoncommandalert $? - IS_TRADE_DEPOSIT_CONFIRMED=$(istradedepositconfirmed "$TRADE_DETAIL" "TAKER") + IS_TRADE_DEPOSIT_CONFIRMED=$(istradedepositconfirmed "$TRADE_DETAIL") exitoncommandalert $? printdate "BOB $BOB_ROLE: Has taker's trade deposit been confirmed? $IS_TRADE_DEPOSIT_CONFIRMED" printbreak @@ -379,7 +349,6 @@ waitfortradepaymentsent() { PORT="$1" SELLER="$2" OFFER_ID="$3" - MAKER_OR_TAKER="$4" DONE=0 while : ; do if [ "$DONE" -ne 0 ]; then @@ -397,7 +366,7 @@ waitfortradepaymentsent() { TRADE_DETAIL=$(gettradedetail "$GETTRADE_CMD_OUTPUT") exitoncommandalert $? - IS_TRADE_PAYMENT_SENT=$(istradepaymentsent "$TRADE_DETAIL" "$MAKER_OR_TAKER") + IS_TRADE_PAYMENT_SENT=$(istradepaymentsent "$TRADE_DETAIL") exitoncommandalert $? printdate "$SELLER: Has buyer's fiat payment been initiated? $IS_TRADE_PAYMENT_SENT" if [ "$IS_TRADE_PAYMENT_SENT" = "YES" ] @@ -416,7 +385,6 @@ waitfortradepaymentreceived() { PORT="$1" SELLER="$2" OFFER_ID="$3" - MAKER_OR_TAKER="$4" DONE=0 while : ; do if [ "$DONE" -ne 0 ]; then @@ -437,7 +405,7 @@ waitfortradepaymentreceived() { # When the seller receives a 'payment sent' message, it is assumed funds (fiat) have already been deposited. # In a real trade, there is usually a delay between receipt of a 'payment sent' message, and the funds deposit, # but we do not need to simulate that in this regtest script. - IS_TRADE_PAYMENT_SENT=$(istradepaymentreceived "$TRADE_DETAIL" "$MAKER_OR_TAKER") + IS_TRADE_PAYMENT_SENT=$(istradepaymentreceived "$TRADE_DETAIL") exitoncommandalert $? printdate "$SELLER: Has buyer's payment been transferred to seller's fiat account? $IS_TRADE_PAYMENT_SENT" if [ "$IS_TRADE_PAYMENT_SENT" = "YES" ] @@ -544,10 +512,10 @@ executetrade() { if [ "$DIRECTION" = "BUY" ] then # Bob waits for payment, polling status in taker specific trade detail. - waitfortradepaymentsent "$PAYEE_PORT" "$PAYEE" "$OFFER_ID" "TAKER" + waitfortradepaymentsent "$PAYEE_PORT" "$PAYEE" "$OFFER_ID" else # Alice waits for payment, polling status in maker specific trade detail. - waitfortradepaymentsent "$PAYEE_PORT" "$PAYEE" "$OFFER_ID" "MAKER" + waitfortradepaymentsent "$PAYEE_PORT" "$PAYEE" "$OFFER_ID" fi @@ -557,10 +525,10 @@ executetrade() { if [ "$DIRECTION" = "BUY" ] then # Alice waits for payment rcvd confirm from Bob, polling status in maker specific trade detail. - waitfortradepaymentreceived "$PAYER_PORT" "$PAYER" "$OFFER_ID" "MAKER" + waitfortradepaymentreceived "$PAYER_PORT" "$PAYER" "$OFFER_ID" else # Bob waits for payment rcvd confirm from Alice, polling status in taker specific trade detail. - waitfortradepaymentreceived "$PAYER_PORT" "$PAYER" "$OFFER_ID" "TAKER" + waitfortradepaymentreceived "$PAYER_PORT" "$PAYER" "$OFFER_ID" fi # Generate some btc blocks diff --git a/apitest/src/main/java/bisq/apitest/Scaffold.java b/apitest/src/main/java/bisq/apitest/Scaffold.java index a556a426b30..82826550484 100644 --- a/apitest/src/main/java/bisq/apitest/Scaffold.java +++ b/apitest/src/main/java/bisq/apitest/Scaffold.java @@ -42,10 +42,14 @@ import javax.annotation.Nullable; import static bisq.apitest.Scaffold.BitcoinCoreApp.bitcoind; +import static bisq.apitest.config.ApiTestConfig.MEDIATOR; +import static bisq.apitest.config.ApiTestConfig.REFUND_AGENT; import static bisq.apitest.config.BisqAppConfig.*; +import static bisq.common.app.DevEnv.DEV_PRIVILEGE_PRIV_KEY; import static java.lang.String.format; import static java.lang.System.exit; import static java.lang.System.out; +import static java.net.InetAddress.getLoopbackAddress; import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; import static java.util.concurrent.TimeUnit.MILLISECONDS; import static java.util.concurrent.TimeUnit.SECONDS; @@ -58,6 +62,7 @@ import bisq.apitest.linux.BisqProcess; import bisq.apitest.linux.BitcoinDaemon; import bisq.apitest.linux.LinuxProcess; +import bisq.cli.GrpcClient; @Slf4j public class Scaffold { @@ -146,6 +151,8 @@ public Scaffold setUp() throws IOException, InterruptedException, ExecutionExcep // Verify each startup task's future is done. verifyStartupCompleted(); + + maybeRegisterDisputeAgents(); return this; } @@ -448,4 +455,15 @@ private void verifyNotWindows() { if (Utilities.isWindows()) throw new IllegalStateException("ApiTest not supported on Windows"); } + + private void maybeRegisterDisputeAgents() { + if (config.hasSupportingApp(arbdaemon.name()) && config.registerDisputeAgents) { + log.info("Option --registerDisputeAgents=true, registering dispute agents in arbdaemon ..."); + GrpcClient arbClient = new GrpcClient(getLoopbackAddress().getHostAddress(), + arbdaemon.apiPort, + config.apiPassword); + arbClient.registerDisputeAgent(MEDIATOR, DEV_PRIVILEGE_PRIV_KEY); + arbClient.registerDisputeAgent(REFUND_AGENT, DEV_PRIVILEGE_PRIV_KEY); + } + } } diff --git a/apitest/src/main/java/bisq/apitest/config/ApiTestConfig.java b/apitest/src/main/java/bisq/apitest/config/ApiTestConfig.java index bffa009935c..44897a2da0c 100644 --- a/apitest/src/main/java/bisq/apitest/config/ApiTestConfig.java +++ b/apitest/src/main/java/bisq/apitest/config/ApiTestConfig.java @@ -54,6 +54,13 @@ @Slf4j public class ApiTestConfig { + // Global constants + public static final String BSQ = "BSQ"; + public static final String BTC = "BTC"; + public static final String ARBITRATOR = "arbitrator"; + public static final String MEDIATOR = "mediator"; + public static final String REFUND_AGENT = "refundagent"; + // Option name constants static final String HELP = "help"; static final String BASH_PATH = "bashPath"; @@ -73,6 +80,7 @@ public class ApiTestConfig { static final String SUPPORTING_APPS = "supportingApps"; static final String CALL_RATE_METERING_CONFIG_PATH = "callRateMeteringConfigPath"; static final String ENABLE_BISQ_DEBUGGING = "enableBisqDebugging"; + static final String REGISTER_DISPUTE_AGENTS = "registerDisputeAgents"; // Default values for certain options static final String DEFAULT_CONFIG_FILE_NAME = "apitest.properties"; @@ -105,6 +113,7 @@ public class ApiTestConfig { public final List supportingApps; public final String callRateMeteringConfigPath; public final boolean enableBisqDebugging; + public final boolean registerDisputeAgents; // Immutable system configurations set in the constructor. public final String bitcoinDatadir; @@ -242,6 +251,13 @@ public ApiTestConfig(String... args) { .withRequiredArg() .ofType(Boolean.class) .defaultsTo(false); + + ArgumentAcceptingOptionSpec registerDisputeAgentsOpt = + parser.accepts(REGISTER_DISPUTE_AGENTS, + "Register dispute agents in arbitration daemon") + .withRequiredArg() + .ofType(Boolean.class) + .defaultsTo(true); try { CompositeOptionSet options = new CompositeOptionSet(); @@ -299,6 +315,7 @@ public ApiTestConfig(String... args) { this.supportingApps = asList(options.valueOf(supportingAppsOpt).split(",")); this.callRateMeteringConfigPath = options.valueOf(callRateMeteringConfigPathOpt); this.enableBisqDebugging = options.valueOf(enableBisqDebuggingOpt); + this.registerDisputeAgents = options.valueOf(registerDisputeAgentsOpt); // Assign values to special-case static fields. BASH_PATH_VALUE = bashPath; diff --git a/apitest/src/test/java/bisq/apitest/botsupport/BotClient.java b/apitest/src/test/java/bisq/apitest/botsupport/BotClient.java new file mode 100644 index 00000000000..089cff67372 --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/botsupport/BotClient.java @@ -0,0 +1,652 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.botsupport; + +import bisq.proto.grpc.AvailabilityResultWithDescription; +import bisq.proto.grpc.BalancesInfo; +import bisq.proto.grpc.GetPaymentAccountsRequest; +import bisq.proto.grpc.OfferInfo; +import bisq.proto.grpc.TakeOfferReply; +import bisq.proto.grpc.TradeInfo; +import bisq.proto.grpc.TxInfo; + +import protobuf.AvailabilityResult; +import protobuf.PaymentAccount; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; + +import java.text.DecimalFormat; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.BiPredicate; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import lombok.extern.slf4j.Slf4j; + +import static bisq.apitest.botsupport.protocol.BotProtocol.BSQ; +import static bisq.apitest.botsupport.util.BotUtilities.capitalize; +import static bisq.cli.CurrencyFormat.formatBsqAmount; +import static bisq.cli.CurrencyFormat.formatMarketPrice; +import static java.lang.System.currentTimeMillis; +import static java.util.Arrays.asList; +import static java.util.Objects.requireNonNull; +import static protobuf.OfferPayload.Direction.BUY; +import static protobuf.OfferPayload.Direction.SELL; + + + +import bisq.apitest.botsupport.util.BotUtilities; +import bisq.cli.GrpcClient; + +/** + * Convenience GrpcClient wrapper for bots using gRPC services. + */ +@SuppressWarnings({"JavaDoc", "unused"}) +@Slf4j +public class BotClient { + + private static final DecimalFormat FIXED_PRICE_FMT = new DecimalFormat("###########0"); + + private static final int TAKE_OFFER_TIMEOUT_IN_SEC = 60; + + private final ListeningExecutorService takeOfferExecutor = + BotUtilities.getListeningExecutorService("Take Offer With " + TAKE_OFFER_TIMEOUT_IN_SEC + "s Timeout", + 1, + 1, + TAKE_OFFER_TIMEOUT_IN_SEC); + + private final GrpcClient grpcClient; + + public BotClient(GrpcClient grpcClient) { + this.grpcClient = grpcClient; + } + + /** + * TODO + * @param address + * @param amount + */ + public void sendBsq(String address, String amount) { + grpcClient.sendBsq(address, amount, ""); + } + + /** + * TODO + * @param address + * @param amount + * @param txFeeRate + */ + public void sendBsq(String address, String amount, String txFeeRate) { + grpcClient.sendBsq(address, amount, txFeeRate); + } + + /** + * TODO + * @param address + * @param amount + */ + public void sendBtc(String address, String amount) { + grpcClient.sendBtc(address, amount, "", ""); + } + + /** + * TODO + * @param address + * @param amount + * @param txFeeRate + * @param memo + */ + public void sendBtc(String address, String amount, String txFeeRate, String memo) { + grpcClient.sendBtc(address, amount, txFeeRate, memo); + } + + /** + * TODO + * @param trade + */ + public void makeBsqPayment(TradeInfo trade) { + var contract = trade.getContract(); + var bsqSats = trade.getOffer().getVolume(); + var sendAmountAsString = formatBsqAmount(bsqSats); + var address = contract.getIsBuyerMakerAndSellerTaker() + ? contract.getTakerPaymentAccountPayload().getAddress() + : contract.getMakerPaymentAccountPayload().getAddress(); + log.info("Sending payment of {} BSQ to address {} for trade with id {}.", + sendAmountAsString, + address, + trade.getTradeId()); + sendBsq(address, sendAmountAsString); + } + + /** + * Returns true if the specified amount of BSQ satoshis sent to an address, or throws + * an exception. + * @param address + * @param amount + * @return boolean + */ + public boolean verifyBsqSentToAddress(String address, String amount) { + return grpcClient.verifyBsqSentToAddress(address, amount); + } + + /** + * Returns current BSQ and BTC balance information. + * @return BalancesInfo + */ + public BalancesInfo getBalance() { + return grpcClient.getBalances(); + } + + /** + * Return the most recent BTC market price for the given currencyCode. + * @param currencyCode + * @return double + */ + public double getCurrentBTCMarketPrice(String currencyCode) { + return grpcClient.getBtcPrice(currencyCode); + } + + /** + * Return the most recent BTC market price for the given currencyCode as a string. + * @param currencyCode + * @return String + */ + public String getCurrentBTCMarketPriceAsString(String currencyCode) { + return formatMarketPrice(getCurrentBTCMarketPrice(currencyCode)); + } + + /** + * Return the most recent BTC market price for the given currencyCode as an + * integer string. + * @param currencyCode + * @return String + */ + public String getCurrentBTCMarketPriceAsIntegerString(String currencyCode) { + return FIXED_PRICE_FMT.format(getCurrentBTCMarketPrice(currencyCode)); + } + + /** + * Return all BUY and SELL offers for the given currencyCode. + * @param currencyCode + * @return List + */ + public List getOffers(String currencyCode) { + var buyOffers = getBuyOffers(currencyCode); + if (buyOffers.size() > 0) { + return buyOffers; + } else { + return getSellOffers(currencyCode); + } + } + + /** + * Return BUY offers for the given currencyCode. + * @param currencyCode + * @return List + */ + public List getBuyOffers(String currencyCode) { + return grpcClient.getOffers(BUY.name(), currencyCode); + } + + /** + * Return user created BUY offers for the given currencyCode. + * @param currencyCode + * @return List + */ + public List getMyBuyOffers(String currencyCode) { + return grpcClient.getMyOffers(BUY.name(), currencyCode); + } + + + /** + * Return SELL offers for the given currencyCode. + * @param currencyCode + * @return List + */ + public List getSellOffers(String currencyCode) { + return grpcClient.getOffers(SELL.name(), currencyCode); + } + + /** + * Return user created BUY offers for the given currencyCode. + * @param currencyCode + * @return List + */ + public List getMySellOffers(String currencyCode) { + return grpcClient.getMyOffers(SELL.name(), currencyCode); + } + + + /** + * Return all available BUY and SELL offers for the given currencyCode, + * sorted by creation date. + * @param currencyCode + * @return List + */ + public List getOffersSortedByDate(String currencyCode) { + ArrayList offers = new ArrayList<>(); + offers.addAll(getBuyOffers(currencyCode)); + offers.addAll(getSellOffers(currencyCode)); + return grpcClient.sortOffersByDate(offers); + } + + /** + * Return all user created BUY and SELL offers for the given currencyCode, + * sorted by creation date. + * @param currencyCode + * @return List + */ + public List getMyOffersSortedByDate(String currencyCode) { + ArrayList offers = new ArrayList<>(); + offers.addAll(getMyBuyOffers(currencyCode)); + offers.addAll(getMySellOffers(currencyCode)); + return grpcClient.sortOffersByDate(offers); + } + + // TODO be more specific, i.e., (base) currencyCode=BTC, counterCurrencyCode=BSQ ? + public final Predicate iHaveCurrentOffers = (currencyCode) -> + !getMyBuyOffers(currencyCode).isEmpty() || !getMySellOffers(currencyCode).isEmpty(); + + public final BiPredicate iHaveCurrentOffersWithDirection = (direction, currencyCode) -> { + if (direction.equalsIgnoreCase(BUY.name()) || direction.equalsIgnoreCase(SELL.name())) { + return direction.equals(BUY.name()) + ? !getMyBuyOffers(currencyCode).isEmpty() + : !getMySellOffers(currencyCode).isEmpty(); + } else { + throw new IllegalStateException(direction + " is not a valid offer direction"); + } + }; + + /** + * Create and return a new Offer using a market based price. + * @param paymentAccount + * @param direction + * @param currencyCode + * @param amountInSatoshis + * @param minAmountInSatoshis + * @param priceMarginAsPercent + * @param securityDepositAsPercent + * @param feeCurrency + * @return OfferInfo + */ + public OfferInfo createOfferAtMarketBasedPrice(PaymentAccount paymentAccount, + String direction, + String currencyCode, + long amountInSatoshis, + long minAmountInSatoshis, + double priceMarginAsPercent, + double securityDepositAsPercent, + String feeCurrency) { + return grpcClient.createMarketBasedPricedOffer(direction, + currencyCode, + amountInSatoshis, + minAmountInSatoshis, + priceMarginAsPercent, + securityDepositAsPercent, + paymentAccount.getId(), + feeCurrency); + } + + /** + * Create and return a new Offer using a fixed price. + * @param paymentAccount + * @param direction + * @param currencyCode + * @param amountInSatoshis + * @param minAmountInSatoshis + * @param fixedOfferPriceAsString + * @param securityDepositAsPercent + * @param feeCurrency + * @return OfferInfo + */ + public OfferInfo createOfferAtFixedPrice(PaymentAccount paymentAccount, + String direction, + String currencyCode, + long amountInSatoshis, + long minAmountInSatoshis, + String fixedOfferPriceAsString, + double securityDepositAsPercent, + String feeCurrency) { + return grpcClient.createFixedPricedOffer(direction, + currencyCode, + amountInSatoshis, + minAmountInSatoshis, + fixedOfferPriceAsString, + securityDepositAsPercent, + paymentAccount.getId(), + feeCurrency); + } + + /** + * TODO + * @param offer + */ + public void cancelOffer(OfferInfo offer) { + grpcClient.cancelOffer(offer.getId()); + } + + /** + * TODO + * @param offerId + * @param paymentAccount + * @param feeCurrency + * @return + */ + public TradeInfo takeOffer(String offerId, PaymentAccount paymentAccount, String feeCurrency) { + return grpcClient.takeOffer(offerId, paymentAccount.getId(), feeCurrency); + } + + /** + * TODO + * @param offerId + * @param paymentAccount + * @param feeCurrency + * @param resultHandler + * @param errorHandler + */ + public void tryToTakeOffer(String offerId, + PaymentAccount paymentAccount, + String feeCurrency, + Consumer resultHandler, + Consumer errorHandler) { + long startTime = currentTimeMillis(); + ListenableFuture future = takeOfferExecutor.submit(() -> + grpcClient.getTakeOfferReply(offerId, paymentAccount.getId(), feeCurrency)); + Futures.addCallback(future, new FutureCallback<>() { + @Override + public void onSuccess(TakeOfferReply result) { + resultHandler.accept(result); + + if (result.hasTrade()) { + log.info("Offer {} taken in {} ms.", + requireNonNull(result.getTrade()).getOffer().getId(), + currentTimeMillis() - startTime); + } else if (result.hasFailureReason()) { + var failureReason = result.getFailureReason(); + log.warn("Offer {} could not be taken after {} ms.\n" + + "\tReason: {} Description: {}", + offerId, + currentTimeMillis() - startTime, + failureReason.getAvailabilityResult(), + failureReason.getDescription()); + } else { + throw new IllegalStateException( + "programmer error: takeoffer request did not return a trade" + + " or availability reason, and did not throw an exception"); + } + } + + @Override + public void onFailure(Throwable t) { + errorHandler.accept(t); + } + }, MoreExecutors.directExecutor()); + } + + public boolean takeOfferFailedForOneOfTheseReasons(AvailabilityResultWithDescription failureReason, + AvailabilityResult... reasons) { + if (failureReason == null) + throw new IllegalArgumentException( + "AvailabilityResultWithDescription failureReason argument cannot be null."); + + if (reasons == null || reasons.length == 0) + throw new IllegalArgumentException( + "AvailabilityResult reasons argument cannot be null or empty."); + + return asList(reasons).contains(failureReason.getAvailabilityResult()); + } + + /** + * Returns a persisted Trade with the given tradeId, or throws an exception. + * @param tradeId + * @return TradeInfo + */ + public TradeInfo getTrade(String tradeId) { + return grpcClient.getTrade(tradeId); + } + + /** + * Predicate returns true if the given exception indicates the trade with the given + * tradeId exists, but the trade's contract has not been fully prepared. + */ + public final BiPredicate tradeContractIsNotReady = (exception, tradeId) -> { + if (exception.getMessage().contains("no contract was found")) { + logTradeContractIsNotReadyWarning(tradeId, exception); + return true; + } else { + return false; + } + }; + + public void logTradeContractIsNotReadyWarning(String tradeId, Exception exception) { + log.warn("Trade {} exists but is not fully prepared: {}.", + tradeId, + toCleanGrpcExceptionMessage(exception)); + } + + /** + * Returns a trade's contract as a Json string, or null if the trade exists + * but the contract is not ready. + * @param tradeId + * @return String + */ + public String getTradeContract(String tradeId) { + try { + var trade = grpcClient.getTrade(tradeId); + return trade.getContractAsJson(); + } catch (Exception ex) { + if (tradeContractIsNotReady.test(ex, tradeId)) + return null; + else + throw ex; + } + } + + /** + * Returns true if the trade's taker deposit fee transaction has been published. + * @param tradeId a valid trade id + * @return boolean + */ + public boolean isTakerDepositFeeTxPublished(String tradeId) { + return grpcClient.getTrade(tradeId).getIsPayoutPublished(); + } + + /** + * Returns true if the trade's taker deposit fee transaction has been confirmed. + * @param tradeId a valid trade id + * @return boolean + */ + public boolean isTakerDepositFeeTxConfirmed(String tradeId) { + return grpcClient.getTrade(tradeId).getIsDepositConfirmed(); + } + + /** + * Returns true if the trade's 'start payment' message has been sent by the buyer. + * @param tradeId a valid trade id + * @return boolean + */ + public boolean isTradePaymentStartedSent(String tradeId) { + return grpcClient.getTrade(tradeId).getIsFiatSent(); + } + + /** + * Returns true if the trade's 'payment received' message has been sent by the seller. + * @param tradeId a valid trade id + * @return boolean + */ + public boolean isTradePaymentReceivedConfirmationSent(String tradeId) { + return grpcClient.getTrade(tradeId).getIsFiatReceived(); + } + + /** + * Returns true if the trade's payout transaction has been published. + * @param tradeId a valid trade id + * @return boolean + */ + public boolean isTradePayoutTxPublished(String tradeId) { + return grpcClient.getTrade(tradeId).getIsPayoutPublished(); + } + + /** + * Sends a 'confirm payment started message' for a trade with the given tradeId, + * or throws an exception. + * @param tradeId + */ + public void sendConfirmPaymentStartedMessage(String tradeId) { + grpcClient.confirmPaymentStarted(tradeId); + } + + /** + * Sends a 'confirm payment received message' for a trade with the given tradeId, + * or throws an exception. + * @param tradeId + */ + public void sendConfirmPaymentReceivedMessage(String tradeId) { + grpcClient.confirmPaymentReceived(tradeId); + } + + /** + * Sends a 'keep funds in wallet message' for a trade with the given tradeId, + * or throws an exception. + * @param tradeId + */ + public void sendKeepFundsMessage(String tradeId) { + grpcClient.keepFunds(tradeId); + } + + /** + * Create and save a new PaymentAccount with details in the given json. + * @param json + * @return PaymentAccount + */ + public PaymentAccount createNewPaymentAccount(String json) { + return grpcClient.createPaymentAccount(json); + } + + /** + * Returns a user's persisted PaymentAccount with the given paymentAccountId, or throws + * an exception. + * @param paymentAccountId The id of the PaymentAccount being looked up. + * @return PaymentAccount + */ + public PaymentAccount getPaymentAccount(String paymentAccountId) { + return grpcClient.getPaymentAccounts().stream() + .filter(a -> (a.getId().equals(paymentAccountId))) + .findFirst() + .orElseThrow(() -> + new PaymentAccountNotFoundException("Could not find a payment account with id " + + paymentAccountId + ".")); + } + + /** + * Returns user's persisted PaymentAccounts. + * @return List + */ + public List getPaymentAccounts() { + return grpcClient.getPaymentAccounts(); + } + + /** + * Returns a persisted PaymentAccount with the given accountName, or throws + * an exception. + * @param accountName + * @return PaymentAccount + */ + public PaymentAccount getPaymentAccountWithName(String accountName) { + var req = GetPaymentAccountsRequest.newBuilder().build(); + return grpcClient.getPaymentAccounts().stream() + .filter(a -> (a.getAccountName().equals(accountName))) + .findFirst() + .orElseThrow(() -> + new PaymentAccountNotFoundException("Could not find a payment account with name " + + accountName + ".")); + } + + /** + * TODO + * @return PaymentAccount + */ + public PaymentAccount createCryptoCurrencyPaymentAccount(String accountName, boolean tradeInstant) { + String unusedBsqAddress = grpcClient.getUnusedBsqAddress(); + return grpcClient.createCryptoCurrencyPaymentAccount(accountName, BSQ, unusedBsqAddress, tradeInstant); + } + + /** + * TODO + * @return PaymentAccount + */ + public PaymentAccount createReceiverBsqPaymentAccount() { + String unusedBsqAddress = grpcClient.getUnusedBsqAddress(); + String accountName = "Receiver BSQ Account " + unusedBsqAddress.substring(0, 8) + " ..."; + return grpcClient.createCryptoCurrencyPaymentAccount(accountName, BSQ, unusedBsqAddress, true); + } + + /** + * TODO + * @return + */ + public PaymentAccount createSenderBsqPaymentAccount() { + String unusedBsqAddress = grpcClient.getUnusedBsqAddress(); + String accountName = "Sender BSQ Account " + unusedBsqAddress.substring(0, 8) + " ..."; + return grpcClient.createCryptoCurrencyPaymentAccount(accountName, BSQ, unusedBsqAddress, true); + } + + /** + * TODO + * @return List + */ + public List getReceiverBsqPaymentAccounts() { + return getPaymentAccounts().stream() + .filter(a -> a.getPaymentAccountPayload().hasInstantCryptoCurrencyAccountPayload()) + .filter(a -> a.getSelectedTradeCurrency().getCode().equals(BSQ)) + .filter(a -> a.getAccountName().startsWith("Receiver BSQ Account")) + .collect(Collectors.toList()); + } + + /** + * TODO + * @return List + */ + public List getSenderBsqPaymentAccounts() { + return getPaymentAccounts().stream() + .filter(a -> a.getPaymentAccountPayload().hasInstantCryptoCurrencyAccountPayload()) + .filter(a -> a.getSelectedTradeCurrency().getCode().equals(BSQ)) + .filter(a -> a.getAccountName().startsWith("Sender BSQ Account")) + .collect(Collectors.toList()); + } + + /** + * Returns a persisted Transaction with the given txId, or throws an exception. + * @param txId + * @return TxInfo + */ + public TxInfo getTransaction(String txId) { + return grpcClient.getTransaction(txId); + } + + public String toCleanGrpcExceptionMessage(Exception ex) { + return capitalize(ex.getMessage().replaceFirst("^[A-Z_]+: ", "")); + } +} diff --git a/apitest/src/test/java/bisq/apitest/botsupport/BotThread.java b/apitest/src/test/java/bisq/apitest/botsupport/BotThread.java new file mode 100644 index 00000000000..72a74ca0cca --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/botsupport/BotThread.java @@ -0,0 +1,98 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.botsupport; + + +import com.google.common.util.concurrent.MoreExecutors; + +import java.time.Duration; + +import java.util.Random; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; + +import java.lang.reflect.InvocationTargetException; + +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; + + + +import bisq.apitest.botsupport.util.FrameRateTimer; +import bisq.apitest.botsupport.util.Timer; + + +@Slf4j +public class BotThread { + + private static Class timerClass; + @Getter + @Setter + private static Executor executor; + + public static void setTimerClass(Class timerClass) { + BotThread.timerClass = timerClass; + } + + static { + // If not defined we use same thread as caller thread + executor = MoreExecutors.directExecutor(); + timerClass = FrameRateTimer.class; + } + + public static void execute(Runnable command) { + BotThread.executor.execute(command); + } + + // Prefer FxTimer if a delay is needed in a JavaFx class (gui module) + public static Timer runAfterRandomDelay(Runnable runnable, long minDelayInSec, long maxDelayInSec) { + return BotThread.runAfterRandomDelay(runnable, minDelayInSec, maxDelayInSec, TimeUnit.SECONDS); + } + + @SuppressWarnings("WeakerAccess") + public static Timer runAfterRandomDelay(Runnable runnable, long minDelay, long maxDelay, TimeUnit timeUnit) { + return BotThread.runAfter(runnable, new Random().nextInt((int) (maxDelay - minDelay)) + minDelay, timeUnit); + } + + public static Timer runAfter(Runnable runnable, long delayInSec) { + return BotThread.runAfter(runnable, delayInSec, TimeUnit.SECONDS); + } + + public static Timer runAfter(Runnable runnable, long delay, TimeUnit timeUnit) { + return getTimer().runLater(Duration.ofMillis(timeUnit.toMillis(delay)), runnable); + } + + public static Timer runPeriodically(Runnable runnable, long intervalInSec) { + return BotThread.runPeriodically(runnable, intervalInSec, TimeUnit.SECONDS); + } + + public static Timer runPeriodically(Runnable runnable, long interval, TimeUnit timeUnit) { + return getTimer().runPeriodically(Duration.ofMillis(timeUnit.toMillis(interval)), runnable); + } + + private static Timer getTimer() { + try { + return timerClass.getDeclaredConstructor().newInstance(); + } catch (InstantiationException | NoSuchMethodException | InvocationTargetException | IllegalAccessException e) { + String message = "Could not instantiate timer bsTimerClass=" + timerClass; + log.error(message, e); + throw new RuntimeException(message); + } + } +} diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/PaymentAccountNotFoundException.java b/apitest/src/test/java/bisq/apitest/botsupport/PaymentAccountNotFoundException.java similarity index 81% rename from apitest/src/test/java/bisq/apitest/scenario/bot/PaymentAccountNotFoundException.java rename to apitest/src/test/java/bisq/apitest/botsupport/PaymentAccountNotFoundException.java index 8578a38af75..989783432f9 100644 --- a/apitest/src/test/java/bisq/apitest/scenario/bot/PaymentAccountNotFoundException.java +++ b/apitest/src/test/java/bisq/apitest/botsupport/PaymentAccountNotFoundException.java @@ -15,21 +15,21 @@ * along with Bisq. If not, see . */ -package bisq.apitest.scenario.bot; +package bisq.apitest.botsupport; -import bisq.common.BisqException; +import static java.lang.String.format; @SuppressWarnings("unused") -public class PaymentAccountNotFoundException extends BisqException { +public class PaymentAccountNotFoundException extends RuntimeException { public PaymentAccountNotFoundException(Throwable cause) { super(cause); } public PaymentAccountNotFoundException(String format, Object... args) { - super(format, args); + super(format(format, args)); } public PaymentAccountNotFoundException(Throwable cause, String format, Object... args) { - super(cause, format, args); + super(format(format, args), cause); } } diff --git a/apitest/src/test/java/bisq/apitest/botsupport/protocol/BotProtocol.java b/apitest/src/test/java/bisq/apitest/botsupport/protocol/BotProtocol.java new file mode 100644 index 00000000000..56bc4f048ba --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/botsupport/protocol/BotProtocol.java @@ -0,0 +1,449 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.botsupport.protocol; + +import bisq.proto.grpc.TradeInfo; + +import protobuf.PaymentAccount; + +import java.security.SecureRandom; + +import java.io.File; + +import java.util.Objects; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import lombok.Getter; + +import static bisq.apitest.botsupport.protocol.ProtocolStep.*; +import static bisq.apitest.botsupport.shutdown.ManualShutdown.checkIfShutdownCalled; +import static bisq.cli.CurrencyFormat.formatBsqAmount; +import static java.lang.String.format; +import static java.lang.System.currentTimeMillis; +import static java.util.Arrays.stream; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static protobuf.OfferPayload.Direction.BUY; +import static protobuf.OfferPayload.Direction.SELL; + + + +import bisq.apitest.botsupport.BotClient; +import bisq.apitest.botsupport.script.BashScriptGenerator; +import bisq.cli.TradeFormat; +import bisq.cli.TransactionFormat; + +public abstract class BotProtocol { + + // Don't use @Slf4j annotation to init log and use in Functions w/out IDE warnings. + private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(BotProtocol.class); + + // TODO move these to proper place. + public static final String BSQ = "BSQ"; + public static final String BTC = "BTC"; + + protected static final SecureRandom RANDOM = new SecureRandom(); + + // Random random millis in range [5, 30) seconds. + protected final Supplier shortRandomDelayInSeconds = () -> (long) (5000 + RANDOM.nextInt(30_000)); + // Returns random millis in range [1, 15) minutes. + protected final Supplier longRandomDelayInMinutes = () -> (long) (60_000 + RANDOM.nextInt(15 * 60_000)); + + protected final AtomicLong protocolStepStartTime = new AtomicLong(0); + protected final Consumer initProtocolStep = (step) -> { + currentProtocolStep = step; + printBotProtocolStep(); + protocolStepStartTime.set(currentTimeMillis()); + }; + + // Functions declared in 'this' need getters. + @Getter + protected final String botDescription; + @Getter + protected final BotClient botClient; + @Getter + protected final PaymentAccount paymentAccount; + protected final String currencyCode; + protected final long protocolStepTimeLimitInMs; + @Getter + protected final BashScriptGenerator bashScriptGenerator; + @Getter + protected ProtocolStep currentProtocolStep; + + public BotProtocol(String botDescription, + BotClient botClient, + PaymentAccount paymentAccount, + long protocolStepTimeLimitInMs, + BashScriptGenerator bashScriptGenerator) { + this.botDescription = botDescription; + this.botClient = botClient; + this.paymentAccount = paymentAccount; + this.currencyCode = Objects.requireNonNull(paymentAccount.getSelectedTradeCurrency()).getCode(); + this.protocolStepTimeLimitInMs = protocolStepTimeLimitInMs; + this.bashScriptGenerator = bashScriptGenerator; + this.currentProtocolStep = START; + } + + public abstract void run(); + + protected final Function waitForTakerFeeTxConfirm = (trade) -> { + sleep(shortRandomDelayInSeconds.get()); + waitForTakerDepositFee(trade.getTradeId(), WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED); + waitForTakerDepositFee(trade.getTradeId(), WAIT_FOR_TAKER_DEPOSIT_TX_CONFIRMED); + return trade; + }; + + protected void waitForTakerDepositFee(String tradeId, ProtocolStep depositTxProtocolStep) { + initProtocolStep.accept(depositTxProtocolStep); + validateCurrentProtocolStep(WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED, WAIT_FOR_TAKER_DEPOSIT_TX_CONFIRMED); + log.info(waitingForDepositFeeTxMsg(tradeId)); + String warning = format("Interrupted before checking taker deposit fee tx is %s for trade %s.", + depositTxProtocolStep.equals(WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED) ? "published" : "confirmed", + tradeId); + int numDelays = 0; + while (isWithinProtocolStepTimeLimit()) { + checkIfShutdownCalled(warning); + try { + var trade = this.getBotClient().getTrade(tradeId); + if (isDepositFeeTxStepComplete.test(trade)) { + return; + } else { + if (++numDelays % 5 == 0) { + var tx = this.getBotClient().getTransaction(trade.getDepositTxId()); + log.warn("Still waiting for trade {} taker tx {} fee to be {}.\n{}", + trade.getTradeId(), + trade.getDepositTxId(), + depositTxProtocolStep.equals(WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED) ? "published" : "confirmed", + TransactionFormat.format(tx)); + } + sleep(shortRandomDelayInSeconds.get()); + } + } catch (Exception ex) { + if (this.getBotClient().tradeContractIsNotReady.test(ex, tradeId)) + continue; + else + throw new IllegalStateException(this.getBotClient().toCleanGrpcExceptionMessage(ex)); + } + } // end while + + // If the while loop is exhausted, a deposit fee tx was not published or confirmed within the protocol step time limit. + throw new IllegalStateException(stoppedWaitingForDepositFeeTxMsg(tradeId)); + } + + protected String waitingForDepositFeeTxMsg(String tradeId) { + return format("%s is waiting for taker deposit fee tx for trade %s to be %s.", + botDescription, + tradeId, + currentProtocolStep.equals(WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED) ? "published" : "confirmed"); + } + + protected String stoppedWaitingForDepositFeeTxMsg(String tradeId) { + return format("Taker deposit fee tx for trade %s took too long to be %s; %s will stop waiting.", + tradeId, + currentProtocolStep.equals(WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED) ? "published" : "confirmed", + botDescription); + } + + protected final Predicate isDepositFeeTxStepComplete = (trade) -> { + if (currentProtocolStep.equals(WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED) && trade.getIsDepositPublished()) { + log.info("{} sees trade {} taker deposit fee tx {} has been published.", + this.getBotDescription(), + trade.getTradeId(), + trade.getDepositTxId()); + return true; + } else if (currentProtocolStep.equals(WAIT_FOR_TAKER_DEPOSIT_TX_CONFIRMED) && trade.getIsDepositConfirmed()) { + log.info("{} sees trade {} taker deposit fee tx {} has been confirmed.", + this.getBotDescription(), + trade.getTradeId(), + trade.getDepositTxId()); + return true; + } else { + return false; + } + }; + + protected final Consumer waitForBsqPayment = (trade) -> { + // TODO When atomic trades are implemented, a different payment acct rcv address + // may be used for each trade. For now, the best we can do is match the amount + // with the address to verify the correct amount of BSQ was received. + initProtocolStep.accept(WAIT_FOR_BSQ_PAYMENT_TO_RCV_ADDRESS); + var contract = trade.getContract(); + var bsqSats = trade.getOffer().getVolume(); + var receiveAmountAsString = formatBsqAmount(bsqSats); + var address = contract.getIsBuyerMakerAndSellerTaker() + ? contract.getTakerPaymentAccountPayload().getAddress() + : contract.getMakerPaymentAccountPayload().getAddress(); + log.info("{} verifying payment of {} BSQ was received to address {} for trade with id {}.", + this.getBotDescription(), + receiveAmountAsString, + address, + trade.getTradeId()); + while (isWithinProtocolStepTimeLimit()) { + checkIfShutdownCalled("Interrupted before checking to see if BSQ payment has been received."); + try { + boolean isAmountReceived = this.getBotClient().verifyBsqSentToAddress(address, receiveAmountAsString); + if (isAmountReceived) { + log.warn("{} has received payment of {} BSQ to address {} for trade with id {}.", + this.getBotDescription(), + receiveAmountAsString, + address, + trade.getTradeId()); + return; + } else { + log.warn("{} has still has not received payment of {} BSQ to address {} for trade with id {}.", + this.getBotDescription(), + receiveAmountAsString, + address, + trade.getTradeId()); + } + sleep(shortRandomDelayInSeconds.get()); + } catch (Exception ex) { + throw new IllegalStateException(this.getBotClient().toCleanGrpcExceptionMessage(ex)); + } + } + + // If the while loop is exhausted, a payment started msg was not detected. + throw new IllegalStateException("Payment started msg never sent; we won't wait any longer."); + }; + + protected final Function waitForPaymentStartedMessage = (trade) -> { + initProtocolStep.accept(WAIT_FOR_PAYMENT_STARTED_MESSAGE); + createPaymentStartedScript(trade); + log.info("{} is waiting for a 'payment started' message from buyer for trade with id {}.", + this.getBotDescription(), + trade.getTradeId()); + while (isWithinProtocolStepTimeLimit()) { + checkIfShutdownCalled("Interrupted before checking if 'payment started' message has been sent."); + int numDelays = 0; + try { + var t = this.getBotClient().getTrade(trade.getTradeId()); + if (t.getIsFiatSent()) { + log.info("Buyer has started payment for trade: {}\n{}", + t.getTradeId(), + TradeFormat.format(t)); + return t; + } + } catch (Exception ex) { + throw new IllegalStateException(this.getBotClient().toCleanGrpcExceptionMessage(ex)); + } + if (++numDelays % 5 == 0) { + log.warn("{} is still waiting for 'payment started' message for trade {}", + this.getBotDescription(), + trade.getShortId()); + } + sleep(shortRandomDelayInSeconds.get()); + } // end while + + // If the while loop is exhausted, a payment started msg was not detected. + throw new IllegalStateException("Payment started msg never sent; we won't wait any longer."); + }; + + protected final Consumer sendBsqPayment = (trade) -> { + // Be very careful when using this on mainnet. + initProtocolStep.accept(SEND_PAYMENT_TO_RCV_ADDRESS); + while (true) { + // TODO FIX + if (trade.hasContract()) { + this.getBotClient().makeBsqPayment(trade); + break; + } else { + log.warn("Trade contract for {} not ready."); + sleep(shortRandomDelayInSeconds.get()); + } + } + }; + + protected final Function sendPaymentStartedMessage = (trade) -> { + var isBsqOffer = this.getPaymentAccount().getSelectedTradeCurrency().getCode().equals(BSQ); + if (isBsqOffer) { + sendBsqPayment.accept(trade); + } + log.info("{} is sending 'payment started' msg for trade with id {}.", + this.getBotDescription(), + trade.getTradeId()); + initProtocolStep.accept(SEND_PAYMENT_STARTED_MESSAGE); + checkIfShutdownCalled("Interrupted before sending 'payment started' message."); + this.getBotClient().sendConfirmPaymentStartedMessage(trade.getTradeId()); + return trade; + }; + + protected final Function waitForPaymentReceivedConfirmation = (trade) -> { + initProtocolStep.accept(WAIT_FOR_PAYMENT_RECEIVED_CONFIRMATION_MESSAGE); + createPaymentReceivedScript(trade); + log.info("{} is waiting for a 'payment received confirmation' message from seller for trade with id {}.", + this.getBotDescription(), + trade.getTradeId()); + int numDelays = 0; + while (isWithinProtocolStepTimeLimit()) { + checkIfShutdownCalled("Interrupted before checking if 'payment received confirmation' message has been sent."); + try { + var t = this.getBotClient().getTrade(trade.getTradeId()); + if (t.getIsFiatReceived()) { + log.info("Seller has received payment for trade: {}\n{}", + t.getTradeId(), + TradeFormat.format(t)); + return t; + } + } catch (Exception ex) { + throw new IllegalStateException(this.getBotClient().toCleanGrpcExceptionMessage(ex)); + } + if (++numDelays % 5 == 0) { + log.warn("{} is still waiting for 'payment received confirmation' message for trade {}", + this.getBotDescription(), + trade.getShortId()); + } + sleep(shortRandomDelayInSeconds.get()); + } // end while + + // If the while loop is exhausted, a payment rcvd confirmation msg was not detected within the protocol step time limit. + throw new IllegalStateException("Payment was never received; we won't wait any longer."); + }; + + protected final Function sendPaymentReceivedMessage = (trade) -> { + // TODO refactor this, move to top where functions are composed. + var isBsqOffer = this.getPaymentAccount().getSelectedTradeCurrency().getCode().equals(BSQ); + if (isBsqOffer) { + waitForBsqPayment.accept(trade); + } + log.info("{} is sending 'payment received confirmation' msg for trade with id {}.", + this.getBotDescription(), + trade.getTradeId()); + initProtocolStep.accept(SEND_PAYMENT_RECEIVED_CONFIRMATION_MESSAGE); + checkIfShutdownCalled("Interrupted before sending 'payment received confirmation' message."); + this.getBotClient().sendConfirmPaymentReceivedMessage(trade.getTradeId()); + return trade; + }; + + protected final Function waitForPayoutTx = (trade) -> { + initProtocolStep.accept(WAIT_FOR_PAYOUT_TX); + log.info("{} is waiting on the 'payout tx published' confirmation for trade with id {}.", + this.getBotDescription(), + trade.getTradeId()); + while (isWithinProtocolStepTimeLimit()) { + checkIfShutdownCalled("Interrupted before checking if payout tx has been published."); + int numDelays = 0; + try { + var t = this.getBotClient().getTrade(trade.getTradeId()); + if (t.getIsPayoutPublished()) { + log.info("Payout tx {} has been published for trade {}:\n{}", + t.getPayoutTxId(), + t.getTradeId(), + TradeFormat.format(t)); + return t; + } + } catch (Exception ex) { + throw new IllegalStateException(this.getBotClient().toCleanGrpcExceptionMessage(ex)); + } + if (++numDelays % 5 == 0) { + log.warn("{} is still waiting for payout tx for trade {}", + this.getBotDescription(), + trade.getShortId()); + } + sleep(shortRandomDelayInSeconds.get()); + } // end while + + // If the while loop is exhausted, a payout tx was not detected within the protocol step time limit. + throw new IllegalStateException("Payout tx was never published; we won't wait any longer."); + }; + + protected final Function keepFundsFromTrade = (trade) -> { + initProtocolStep.accept(KEEP_FUNDS); + var isBuy = trade.getOffer().getDirection().equalsIgnoreCase(BUY.name()); + var isSell = trade.getOffer().getDirection().equalsIgnoreCase(SELL.name()); + + var cliUserIsSeller = (this instanceof MakerBotProtocol && isBuy) + || (this instanceof TakerBotProtocol && isSell); + if (cliUserIsSeller) { + createKeepFundsScript(trade); + } else { + createGetBalanceScript(); + } + checkIfShutdownCalled("Interrupted before closing trade with 'keep funds' command."); + this.getBotClient().sendKeepFundsMessage(trade.getTradeId()); + return trade; + }; + + protected void validateCurrentProtocolStep(Enum... validBotSteps) { + for (Enum validBotStep : validBotSteps) { + if (currentProtocolStep.equals(validBotStep)) + return; + } + throw new IllegalStateException("Unexpected bot step: " + currentProtocolStep.name() + ".\n" + + "Must be one of " + + stream(validBotSteps).map((Enum::name)).collect(Collectors.joining(",")) + + "."); + } + + protected void checkIsStartStep() { + if (currentProtocolStep != START) { + throw new IllegalStateException("First bot protocol step must be " + START.name()); + } + } + + protected void printBotProtocolStep() { + log.info("{} is starting protocol step {}. Time limit is {} minutes.", + botDescription, + currentProtocolStep.name(), + MILLISECONDS.toMinutes(protocolStepTimeLimitInMs)); + } + + protected boolean isWithinProtocolStepTimeLimit() { + return (currentTimeMillis() - protocolStepStartTime.get()) < protocolStepTimeLimitInMs; + } + + protected void printCliHintAndOrScript(File script, String hint) { + log.info("{} by running bash script '{}'.", hint, script.getAbsolutePath()); + if (this.getBashScriptGenerator().isPrintCliScripts()) + this.getBashScriptGenerator().printCliScript(script, log); + } + + protected void createGetBalanceScript() { + File script = bashScriptGenerator.createGetBalanceScript(); + printCliHintAndOrScript(script, "The manual CLI side can view current balances"); + } + + protected void createPaymentStartedScript(TradeInfo trade) { + String scriptFilename = "confirmpaymentstarted-" + trade.getShortId() + ".sh"; + File script = bashScriptGenerator.createPaymentStartedScript(trade, scriptFilename); + printCliHintAndOrScript(script, "The manual CLI side can send a 'payment started' message"); + } + + protected void createPaymentReceivedScript(TradeInfo trade) { + String scriptFilename = "confirmpaymentreceived-" + trade.getShortId() + ".sh"; + File script = bashScriptGenerator.createPaymentReceivedScript(trade, scriptFilename); + printCliHintAndOrScript(script, "The manual CLI side can send a 'payment received confirmation' message"); + } + + protected void createKeepFundsScript(TradeInfo trade) { + String scriptFilename = "keepfunds-" + trade.getShortId() + ".sh"; + File script = bashScriptGenerator.createKeepFundsScript(trade, scriptFilename); + printCliHintAndOrScript(script, "The manual CLI side can close the trade"); + } + + protected void sleep(long ms) { + try { + MILLISECONDS.sleep(ms); + } catch (InterruptedException ignored) { + // empty + } + } +} diff --git a/apitest/src/test/java/bisq/apitest/botsupport/protocol/CreateOfferException.java b/apitest/src/test/java/bisq/apitest/botsupport/protocol/CreateOfferException.java new file mode 100644 index 00000000000..50801d801bc --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/botsupport/protocol/CreateOfferException.java @@ -0,0 +1,35 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.botsupport.protocol; + +import static java.lang.String.format; + +@SuppressWarnings("unused") +public class CreateOfferException extends RuntimeException { + public CreateOfferException(Throwable cause) { + super(cause); + } + + public CreateOfferException(String format, Object... args) { + super(format(format, args)); + } + + public CreateOfferException(Throwable cause, String format, Object... args) { + super(format(format, args), cause); + } +} diff --git a/apitest/src/test/java/bisq/apitest/botsupport/protocol/MakerBotProtocol.java b/apitest/src/test/java/bisq/apitest/botsupport/protocol/MakerBotProtocol.java new file mode 100644 index 00000000000..03737b58473 --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/botsupport/protocol/MakerBotProtocol.java @@ -0,0 +1,21 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.botsupport.protocol; + +public interface MakerBotProtocol { +} diff --git a/apitest/src/test/java/bisq/apitest/botsupport/protocol/MarketMakerBotProtocol.java b/apitest/src/test/java/bisq/apitest/botsupport/protocol/MarketMakerBotProtocol.java new file mode 100644 index 00000000000..962c7992b4d --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/botsupport/protocol/MarketMakerBotProtocol.java @@ -0,0 +1,383 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.botsupport.protocol; + +import bisq.proto.grpc.OfferInfo; +import bisq.proto.grpc.TradeInfo; + +import protobuf.PaymentAccount; + +import java.math.BigDecimal; +import java.math.MathContext; + +import java.util.List; +import java.util.Optional; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.function.Function; +import java.util.function.Supplier; + +import lombok.Getter; + +import static bisq.apitest.botsupport.protocol.ProtocolStep.DONE; +import static bisq.apitest.botsupport.protocol.ProtocolStep.WAIT_FOR_OFFER_TAKER; +import static bisq.apitest.botsupport.shutdown.ManualShutdown.checkIfShutdownCalled; +import static bisq.cli.TableFormat.formatOfferTable; +import static java.lang.String.format; +import static java.math.RoundingMode.HALF_UP; +import static java.util.Collections.singletonList; +import static java.util.concurrent.TimeUnit.MINUTES; +import static java.util.concurrent.TimeUnit.SECONDS; +import static protobuf.OfferPayload.Direction; +import static protobuf.OfferPayload.Direction.BUY; +import static protobuf.OfferPayload.Direction.SELL; + + + +import bisq.apitest.botsupport.BotClient; +import bisq.apitest.botsupport.script.BashScriptGenerator; +import bisq.cli.TradeFormat; + +public class MarketMakerBotProtocol extends BotProtocol implements MakerBotProtocol { + + // Don't use @Slf4j annotation to init log and use in Functions w/out IDE warnings. + private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(MarketMakerBotProtocol.class); + + private static final BigDecimal MAX_BTC_AMOUNT_DEVIATION_SIZE = new BigDecimal("0.000001"); + private static final BigDecimal MAX_BSQ_PRICE_DEVIATION_SIZE = new BigDecimal("0.0000001"); + private static final BigDecimal PERCENT_MULTIPLICAND = new BigDecimal("0.01"); + + protected final String direction; + @Getter + protected final BigDecimal priceMargin; + @Getter + protected final BigDecimal targetBsqPrice; + @Getter + protected final BigDecimal targetBtcAmount; + @Getter + protected final double securityDepositAsPercent; + @Getter + protected final String tradingFeeCurrencyCode; + @Getter + protected final int maxCreateOfferFailureLimit; + + protected final Supplier randomPercent = () -> + new BigDecimal(Double.toString(RANDOM.nextDouble())).round(new MathContext(2, HALF_UP)); + + protected final Function calculatedMargin = (targetSpread) -> + targetSpread.divide(new BigDecimal(2)).round(new MathContext(3, HALF_UP)); + + protected final Supplier nextTradeBtcAmount = () -> { + BigDecimal nextBtcAmountDeviation = randomPercent.get().multiply(MAX_BTC_AMOUNT_DEVIATION_SIZE); + BigDecimal nextTradeBtcAmount = RANDOM.nextBoolean() + ? this.getTargetBtcAmount().subtract(nextBtcAmountDeviation) + : this.getTargetBtcAmount().add(nextBtcAmountDeviation); + long amountInSatoshis = nextTradeBtcAmount.scaleByPowerOfTen(8).longValue(); + log.info("Calculated next trade's amount: {} BTC ({} sats) using target amount {} and max deviation {}.", + nextTradeBtcAmount, + amountInSatoshis, + this.getTargetBtcAmount(), + MAX_BTC_AMOUNT_DEVIATION_SIZE); + return amountInSatoshis; + }; + + // We use the same random BSQ price in a trade cycle (1 buy and 1 sell). + // This price queue has a max of 2 prices. When it's empty, we add two identical + // prices, then remove & use them until queue is exhausted, rinse and repeat for + // each trade cycle. + protected static final Queue PSEUDO_FIXED_TRADE_PRICE_QUEUE = new ConcurrentLinkedQueue<>(); + + protected final Supplier nextPseudoFixedTradeBsqPrice = () -> { + if (PSEUDO_FIXED_TRADE_PRICE_QUEUE.isEmpty()) { + BigDecimal nextBsqPriceDeviation = randomPercent.get().multiply(MAX_BSQ_PRICE_DEVIATION_SIZE); + BigDecimal fixedTradePrice = RANDOM.nextBoolean() + ? this.getTargetBsqPrice().add(nextBsqPriceDeviation) + : this.getTargetBsqPrice().subtract(nextBsqPriceDeviation); + log.info("Calculated next trade's pseudo fixed BSQ price: {} BTC using target price {} and max deviation {}.", + fixedTradePrice.toPlainString(), + this.getTargetBsqPrice(), + MAX_BSQ_PRICE_DEVIATION_SIZE.toPlainString()); + PSEUDO_FIXED_TRADE_PRICE_QUEUE.add(fixedTradePrice); + PSEUDO_FIXED_TRADE_PRICE_QUEUE.add(fixedTradePrice); + } + return PSEUDO_FIXED_TRADE_PRICE_QUEUE.remove(); + }; + + protected final Function calculateNextTradeMarginBasedPrice = (direction) -> { + BigDecimal marginAsDecimal = this.getPriceMargin().multiply(PERCENT_MULTIPLICAND); + BigDecimal basePrice = nextPseudoFixedTradeBsqPrice.get(); + BigDecimal marginDifference = basePrice.multiply(marginAsDecimal); + BigDecimal nextMarginPrice = direction.equals(BUY) + ? basePrice.subtract(marginDifference).round(new MathContext(4, HALF_UP)) + : basePrice.add(marginDifference).round(new MathContext(4, HALF_UP)); + log.info("Calculated next {} trade's BSQ margin based price: {} BTC based on pseudo base price {} {} {}% margin.", + direction.name(), + nextMarginPrice.toPlainString(), + basePrice.toPlainString(), + direction.equals(BUY) ? "-" : "+", + this.getPriceMargin()); + return nextMarginPrice; + }; + + protected final Function roundedSecurityDeposit = (securityDepositAsPercent) -> + new BigDecimal(securityDepositAsPercent).round(new MathContext(3, HALF_UP)).doubleValue(); + + public static void main(String[] args) { + BigDecimal targetBtcAmount = new BigDecimal("0.1"); + BigDecimal targetPrice = new BigDecimal("0.00005"); + BigDecimal targetSpread = new BigDecimal("10.00"); + + Supplier randomPercent = () -> + new BigDecimal(Double.toString(RANDOM.nextDouble())).round(new MathContext(2, HALF_UP)); + BigDecimal nextBtcAmountDeviation = randomPercent.get().multiply(MAX_BTC_AMOUNT_DEVIATION_SIZE); + BigDecimal nextTradeBtcAmount = RANDOM.nextBoolean() + ? targetBtcAmount.subtract(nextBtcAmountDeviation) + : targetBtcAmount.add(nextBtcAmountDeviation); + long amountInSatoshis = nextTradeBtcAmount.scaleByPowerOfTen(8).longValue(); + log.info("Calculated next trade's amount: {} BTC ({} sats) using target amount {} and max deviation {}.", + nextTradeBtcAmount, + amountInSatoshis, + targetBtcAmount, + MAX_BTC_AMOUNT_DEVIATION_SIZE); + + BigDecimal nextBsqPriceDeviation = randomPercent.get().multiply(MAX_BSQ_PRICE_DEVIATION_SIZE); + BigDecimal nextTradeBsqPrice = RANDOM.nextBoolean() + ? targetPrice.add(nextBsqPriceDeviation) + : targetPrice.subtract(nextBsqPriceDeviation); + log.info("Calculated next trade's BSQ price: {} BTC using target price {} and max deviation {}.", + nextTradeBsqPrice.toPlainString(), + targetPrice, + MAX_BSQ_PRICE_DEVIATION_SIZE.toPlainString()); + + BigDecimal marginAsPercent = targetSpread.divide(new BigDecimal(2), HALF_UP); + BigDecimal marginAsDecimal = marginAsPercent.multiply(PERCENT_MULTIPLICAND); + BigDecimal delta = nextTradeBsqPrice.multiply(marginAsDecimal).round(new MathContext(3, HALF_UP)); + BigDecimal nextBuyMarginPrice = nextTradeBsqPrice.subtract(delta); + BigDecimal nextSellMarginPrice = nextTradeBsqPrice.add(delta); + log.info("Calculated next BUY BSQ margin based price: {} BTC based on pseudo target price {} and {}% margin.", + nextBuyMarginPrice.toPlainString(), + nextTradeBsqPrice, + marginAsPercent); + log.info("Calculated next SELL BSQ margin based price: {} BTC based on pseudo target price {} and {}% margin.", + nextSellMarginPrice.toPlainString(), + nextTradeBsqPrice, + marginAsPercent); + } + + + public MarketMakerBotProtocol(String botDescription, + BotClient botClient, + PaymentAccount paymentAccount, + long protocolStepTimeLimitInMs, + BashScriptGenerator bashScriptGenerator, + String direction, + BigDecimal targetBsqPrice, + BigDecimal targetBtcAmount, + BigDecimal targetSpread, + double securityDepositAsPercent, + String tradingFeeCurrencyCode, + int maxCreateOfferFailureLimit) { + super(botDescription, + botClient, + paymentAccount, + protocolStepTimeLimitInMs, + bashScriptGenerator); + this.direction = direction; + this.priceMargin = calculatedMargin.apply(targetSpread); + this.targetBsqPrice = targetBsqPrice; + this.targetBtcAmount = targetBtcAmount; + this.securityDepositAsPercent = roundedSecurityDeposit.apply(securityDepositAsPercent); + this.tradingFeeCurrencyCode = tradingFeeCurrencyCode; + this.maxCreateOfferFailureLimit = maxCreateOfferFailureLimit; + } + + @Override + public void run() { + checkIsStartStep(); + + var isBuy = direction.equalsIgnoreCase(BUY.name()); + Function, TradeInfo> makeTrade = waitForNewTrade.andThen(waitForTakerFeeTxConfirm); + var trade = isBuy + ? makeTrade.apply(createBuyOffer) + : makeTrade.apply(createSellOffer); + + var makerIsBuyer = trade.getOffer().getDirection().equalsIgnoreCase(BUY.name()); + + Function completeFiatTransaction = makerIsBuyer + ? sendPaymentStartedMessage.andThen(waitForPaymentReceivedConfirmation) + : waitForPaymentStartedMessage.andThen(sendPaymentReceivedMessage); + completeFiatTransaction.apply(trade); + + Function closeTrade = waitForPayoutTx.andThen(keepFundsFromTrade); + closeTrade.apply(trade); + + // TODO track changes in balances and print here. + + currentProtocolStep = DONE; + } + + protected final Supplier createBuyOffer = () -> { + checkIfShutdownCalled("Interrupted before creating random BUY offer."); + int attempts; + for (attempts = 0; attempts < this.getMaxCreateOfferFailureLimit(); attempts++) { + try { + var amount = nextTradeBtcAmount.get(); + var isBsqOffer = paymentAccount.getSelectedTradeCurrency().getCode().equals(BSQ); + OfferInfo offer; + if (isBsqOffer) { + var priceAsString = calculateNextTradeMarginBasedPrice.apply(BUY).toPlainString(); + offer = botClient.createOfferAtFixedPrice(paymentAccount, + SELL.name(), // This is the Buy BSQ (Sell BTC) bot. + currencyCode, + amount, + amount, + priceAsString, + this.getSecurityDepositAsPercent(), + this.getTradingFeeCurrencyCode()); + } else { + offer = botClient.createOfferAtMarketBasedPrice(paymentAccount, + BUY.name(), + currencyCode, + amount, + amount, + this.getPriceMargin().doubleValue(), + this.getSecurityDepositAsPercent(), + this.getTradingFeeCurrencyCode()); + log.info("Created BUY / {} offer at {}% below current market price of {}:\n{}", + currencyCode, + this.getPriceMargin(), + botClient.getCurrentBTCMarketPriceAsString(currencyCode), + formatOfferTable(singletonList(offer), currencyCode)); + log.info("Payment account used to create offer: {}", paymentAccount.getId()); + } + return offer; + } catch (Exception ex) { + log.error("Failed to create BUY offer after attempt #{}.", attempts, ex); + try { + SECONDS.sleep(30); + } catch (InterruptedException ignored) { + // empty + } + } + } + throw new CreateOfferException(format("%s could not create offer after %s attempts.", + botDescription, + attempts)); + }; + + protected final Supplier createSellOffer = () -> { + checkIfShutdownCalled("Interrupted before creating random SELL offer."); + int attempts; + for (attempts = 0; attempts < this.getMaxCreateOfferFailureLimit(); attempts++) { + try { + var amount = nextTradeBtcAmount.get(); + var isBsqOffer = paymentAccount.getSelectedTradeCurrency().getCode().equals(BSQ); + OfferInfo offer; + if (isBsqOffer) { + var priceAsString = calculateNextTradeMarginBasedPrice.apply(SELL).toPlainString(); + offer = botClient.createOfferAtFixedPrice(paymentAccount, + BUY.name(), // This is the Sell BSQ (Buy BTC) bot. + currencyCode, + amount, + amount, + priceAsString, + this.getSecurityDepositAsPercent(), + this.getTradingFeeCurrencyCode()); + } else { + offer = botClient.createOfferAtMarketBasedPrice(paymentAccount, + SELL.name(), + currencyCode, + amount, + amount, + this.getPriceMargin().doubleValue(), + this.getSecurityDepositAsPercent(), + this.getTradingFeeCurrencyCode()); + log.info("Created SELL / {} offer at {}% above current market price of {}:\n{}", + currencyCode, + this.getPriceMargin(), + botClient.getCurrentBTCMarketPriceAsString(currencyCode), + formatOfferTable(singletonList(offer), currencyCode)); + log.info("Payment account used to create offer: {}", paymentAccount.getId()); + } + return offer; + } catch (Exception ex) { + log.error("Failed to create SELL offer after attempt #{}.", attempts, ex); + try { + SECONDS.sleep(30); + } catch (InterruptedException ignored) { + // empty + } + } + } + throw new CreateOfferException(format("%s could not create offer after %s attempts.", + botDescription, + attempts)); + }; + + protected final Function, TradeInfo> waitForNewTrade = (latestOffer) -> { + initProtocolStep.accept(WAIT_FOR_OFFER_TAKER); + OfferInfo offer = latestOffer.get(); + // TODO ? -> createTakeOfferCliScript(offer); + log.info("Waiting for offer {} to be taken.", offer.getId()); + int numDelays = 0; + // An offer may never be taken. There is no protocol step time limit on takers. + // This loop can keep the bot alive for an indefinite time, but it can + // still be manually shutdown. + while (true) { + checkIfShutdownCalled("Interrupted while waiting for offer to be taken."); + try { + var trade = getNewTrade(offer.getId()); + if (trade.isPresent()) { + return trade.get(); + } else { + if (++numDelays % 15 == 0) { + log.warn("Offer {} still waiting to be taken.", offer.getId()); + String offerCounterCurrencyCode = offer.getCounterCurrencyCode(); + List myCurrentOffers = botClient.getMyOffersSortedByDate(offerCounterCurrencyCode); + if (myCurrentOffers.isEmpty()) { + log.warn("{} has no current offers at this time, but offer {} should exist.", + botDescription, + offer.getId()); + } else { + log.info("{}'s current offers {} is in the list, or fail):\n{}", + botDescription, + offer.getId(), + formatOfferTable(myCurrentOffers, offerCounterCurrencyCode)); + } + } + sleep(MINUTES.toMillis(1)); + } + } catch (Exception ex) { + throw new IllegalStateException(botClient.toCleanGrpcExceptionMessage(ex), ex); + } + } // end while + }; + + protected Optional getNewTrade(String offerId) { + try { + var trade = botClient.getTrade(offerId); + log.info("Offer {} created with payment account {} has been taken. New trade:\n{}", + offerId, + paymentAccount.getId(), + TradeFormat.format(trade)); + return Optional.of(trade); + } catch (Exception ex) { + return Optional.empty(); + } + } +} diff --git a/apitest/src/test/java/bisq/apitest/botsupport/protocol/ProtocolStep.java b/apitest/src/test/java/bisq/apitest/botsupport/protocol/ProtocolStep.java new file mode 100644 index 00000000000..b9a94915428 --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/botsupport/protocol/ProtocolStep.java @@ -0,0 +1,36 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.botsupport.protocol; + +public enum ProtocolStep { + START, + FIND_OFFER, + TAKE_OFFER, + WAIT_FOR_OFFER_TAKER, + WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED, + WAIT_FOR_TAKER_DEPOSIT_TX_CONFIRMED, + SEND_PAYMENT_TO_RCV_ADDRESS, + SEND_PAYMENT_STARTED_MESSAGE, + WAIT_FOR_PAYMENT_STARTED_MESSAGE, + WAIT_FOR_BSQ_PAYMENT_TO_RCV_ADDRESS, + SEND_PAYMENT_RECEIVED_CONFIRMATION_MESSAGE, + WAIT_FOR_PAYMENT_RECEIVED_CONFIRMATION_MESSAGE, + WAIT_FOR_PAYOUT_TX, + KEEP_FUNDS, + DONE +} diff --git a/apitest/src/test/java/bisq/apitest/botsupport/protocol/TakeOfferHelper.java b/apitest/src/test/java/bisq/apitest/botsupport/protocol/TakeOfferHelper.java new file mode 100644 index 00000000000..238fb1c5a61 --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/botsupport/protocol/TakeOfferHelper.java @@ -0,0 +1,283 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.botsupport.protocol; + +import bisq.proto.grpc.AvailabilityResultWithDescription; +import bisq.proto.grpc.OfferInfo; +import bisq.proto.grpc.TakeOfferReply; +import bisq.proto.grpc.TradeInfo; + +import protobuf.PaymentAccount; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.function.Supplier; + +import lombok.Getter; + +import javax.annotation.Nullable; + +import static bisq.apitest.botsupport.shutdown.ManualShutdown.checkIfShutdownCalled; +import static java.lang.String.format; +import static java.lang.System.currentTimeMillis; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; +import static protobuf.AvailabilityResult.PRICE_OUT_OF_TOLERANCE; +import static protobuf.AvailabilityResult.UNCONF_TX_LIMIT_HIT; + + + +import bisq.apitest.botsupport.BotClient; + + +/** + * Convenience for re-attempting to take an offer after non-fatal errors. + * + * One instance can be used to attempt to take an offer several times, but an + * instance should never be re-used to take different offers. An instance should be + * discarded after the run() method returns and the server's reply or exception is + * processed. + */ +@SuppressWarnings({"unchecked", "rawtypes"}) +public class TakeOfferHelper { + + // Don't use @Slf4j annotation to init log and use in Functions w/out IDE warnings. + private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(TakeOfferHelper.class); + + @Getter + private final BotClient botClient; + private final String botDescription; + private final OfferInfo offer; + private final PaymentAccount paymentAccount; + private final String feeCurrency; + @Getter + private final long takeOfferRequestDeadlineInSec; + private final int maxAttemptsBeforeFail; + private final long attemptDelayInSec; + + private final AtomicLong startTime = new AtomicLong(); + private final AtomicLong stopTime = new AtomicLong(); + private final Consumer setSingleAttemptDeadline = (now) -> { + startTime.set(now); + stopTime.set(now + SECONDS.toMillis(this.getTakeOfferRequestDeadlineInSec())); + }; + private final Predicate deadlineReached = (t) -> t > stopTime.get(); + + @Nullable + @Getter + private TradeInfo newTrade; + @Nullable + @Getter + private AvailabilityResultWithDescription takeOfferErrorReason; + @Nullable + @Getter + private Throwable fatalThrowable; + + public final Supplier hasNewTrade = () -> newTrade != null; + public final Supplier hasTakeOfferError = () -> takeOfferErrorReason != null; + private final CountDownLatch attemptDeadlineLatch = new CountDownLatch(1); + + public TakeOfferHelper(BotClient botClient, + String botDescription, + OfferInfo offer, + PaymentAccount paymentAccount, + String feeCurrency, + long takeOfferRequestDeadlineInSec, + int maxAttemptsBeforeFail, + long attemptDelayInSec) { + this.botClient = botClient; + this.botDescription = botDescription; + this.offer = offer; + this.paymentAccount = paymentAccount; + this.feeCurrency = feeCurrency; + this.takeOfferRequestDeadlineInSec = takeOfferRequestDeadlineInSec; + this.maxAttemptsBeforeFail = maxAttemptsBeforeFail; + this.attemptDelayInSec = attemptDelayInSec; + } + + public synchronized void run() { + checkIfShutdownCalled("Interrupted before attempting to take offer " + offer.getId()); + int attemptCount = 0; + while (++attemptCount < maxAttemptsBeforeFail) { + logCurrentTakeOfferAttempt(attemptCount); + + AtomicReference resultHandler = new AtomicReference(null); + AtomicReference errorHandler = new AtomicReference(null); + sendTakeOfferRequest(resultHandler, errorHandler); + handleTakeOfferReply(resultHandler, errorHandler); + + // If we have a new trade, exit now. + // If we have an AvailabilityResultWithDescription with a fatal error, + // the fatalThrowable field was set in handleTakeOfferReply and we exit now. + // If we have an AvailabilityResultWithDescription with a non-fatal error, + // try again. + if (hasNewTrade.get()) { + break; + } else if (fatalThrowable != null) { + break; + } else { + logNextTakeOfferAttemptAndWait(attemptCount); + setSingleAttemptDeadline.accept(currentTimeMillis()); + } + } + } + + private void sendTakeOfferRequest(AtomicReference resultHandler, + AtomicReference errorHandler) { + // A TakeOfferReply can contain a trade or an AvailabilityResultWithDescription. + // An AvailabilityResultWithDescription contains an AvailabilityResult enum and + // a client friendly error/reason message. + // If the grpc server threw us a StatusRuntimeException instead, the takeoffer + // request resulted in an unrecoverable error. + checkIfShutdownCalled("Interrupted while attempting to take offer " + offer.getId()); + botClient.tryToTakeOffer(offer.getId(), + paymentAccount, + feeCurrency, + resultHandler::set, + errorHandler::set); + + Supplier isReplyReceived = () -> + resultHandler.get() != null || errorHandler.get() != null; + + setSingleAttemptDeadline.accept(currentTimeMillis()); + while (!deadlineReached.test(currentTimeMillis()) && !isReplyReceived.get()) { + try { + //noinspection ResultOfMethodCallIgnored + attemptDeadlineLatch.await(10, MILLISECONDS); + } catch (InterruptedException ignored) { + // empty + } + } + logRequestResult(resultHandler, errorHandler, isReplyReceived); + } + + private void handleTakeOfferReply(AtomicReference resultHandler, + AtomicReference errorHandler) { + if (isSuccessfulTakeOfferRequest.test(resultHandler)) { + this.newTrade = resultHandler.get().getTrade(); + } else if (resultHandler.get().hasFailureReason()) { + // Offer was not taken for reason (AvailabilityResult) given by server. + // Determine if the error is fatal. If fatal set the fatalThrowable field. + handleFailureReason(resultHandler, errorHandler); + } else { + // Server threw an exception or gave no reason for the failure. + handleFatalError(errorHandler); + } + } + + private void handleFailureReason(AtomicReference resultHandler, + AtomicReference errorHandler) { + this.takeOfferErrorReason = resultHandler.get().getFailureReason(); + if (isTakeOfferAttemptErrorNonFatal.test(takeOfferErrorReason)) { + log.warn("Non fatal error attempting to take offer {}.\n" + + "\tReason: {} Description: {}", + offer.getId(), + takeOfferErrorReason.getAvailabilityResult().name(), + takeOfferErrorReason.getDescription()); + this.fatalThrowable = null; + } else { + log.error("Fatal error attempting to take offer {}.\n" + + "\tReason: {} Description: {}", + offer.getId(), + takeOfferErrorReason.getAvailabilityResult().name(), + takeOfferErrorReason.getDescription()); + this.fatalThrowable = errorHandler.get(); + } + } + + private void handleFatalError(AtomicReference errorHandler) { + if (errorHandler.get() != null) { + log.error("", errorHandler.get()); + throw new IllegalStateException( + format("fatal error attempting to take offer %s: %s", + offer.getId(), + errorHandler.get().getMessage().toLowerCase())); + } else { + throw new IllegalStateException( + format("programmer error: fatal error attempting to take offer %s with no reason from server", + offer.getId())); + } + } + + private void logRequestResult(AtomicReference resultHandler, + AtomicReference errorHandler, + Supplier isReplyReceived) { + if (isReplyReceived.get()) { + if (resultHandler.get() != null) + log.info("The takeoffer request returned new trade: {}.", + resultHandler.get().getTrade().getTradeId()); + else + log.warn("The takeoffer request returned error: {}.", + errorHandler.get().getMessage()); + } else { + log.error("The takeoffer request failed: no reply received within the {} second deadline.", + takeOfferRequestDeadlineInSec); + } + } + + private void logNextTakeOfferAttemptAndWait(int attemptCount) { + // Take care to not let bots exceed call rate limit on mainnet. + log.info("The takeoffer {} request attempt #{} will be made in {} seconds.", + offer.getId(), + attemptCount + 1, + attemptDelayInSec); + try { + SECONDS.sleep(attemptDelayInSec); + } catch (InterruptedException ignored) { + // empty + } + } + + private void logCurrentTakeOfferAttempt(int attemptCount) { + log.info("{} taking {} / {} offer {}. Attempt # {}.", + botDescription, + offer.getDirection(), + offer.getCounterCurrencyCode(), + offer.getId(), + attemptCount); + } + + private final Predicate> isSuccessfulTakeOfferRequest = (resultHandler) -> { + var takeOfferReply = resultHandler.get(); + if (takeOfferReply.hasTrade()) { + try { + log.info("Created trade {}. Allowing 5s for trade prep before continuing.", + takeOfferReply.getTrade().getTradeId()); + SECONDS.sleep(5); + } catch (InterruptedException ignored) { + // empty + } + return true; + } else { + return false; + } + }; + + private final Predicate isTakeOfferAttemptErrorNonFatal = (reason) -> { + if (reason != null) { + return this.getBotClient().takeOfferFailedForOneOfTheseReasons(reason, + PRICE_OUT_OF_TOLERANCE, + UNCONF_TX_LIMIT_HIT); + } else { + return false; + } + }; +} diff --git a/apitest/src/test/java/bisq/apitest/botsupport/protocol/TakerBotProtocol.java b/apitest/src/test/java/bisq/apitest/botsupport/protocol/TakerBotProtocol.java new file mode 100644 index 00000000000..283d9047242 --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/botsupport/protocol/TakerBotProtocol.java @@ -0,0 +1,21 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.botsupport.protocol; + +public interface TakerBotProtocol { +} diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/script/BashScriptGenerator.java b/apitest/src/test/java/bisq/apitest/botsupport/script/BashScriptGenerator.java similarity index 88% rename from apitest/src/test/java/bisq/apitest/scenario/bot/script/BashScriptGenerator.java rename to apitest/src/test/java/bisq/apitest/botsupport/script/BashScriptGenerator.java index d41e8a1acd3..03c7ca150b2 100644 --- a/apitest/src/test/java/bisq/apitest/scenario/bot/script/BashScriptGenerator.java +++ b/apitest/src/test/java/bisq/apitest/botsupport/script/BashScriptGenerator.java @@ -15,9 +15,7 @@ * along with Bisq. If not, see . */ -package bisq.apitest.scenario.bot.script; - -import bisq.common.file.FileUtil; +package bisq.apitest.botsupport.script; import bisq.proto.grpc.OfferInfo; import bisq.proto.grpc.TradeInfo; @@ -36,6 +34,7 @@ import lombok.Getter; import lombok.extern.slf4j.Slf4j; +import static bisq.apitest.botsupport.util.FileUtil.deleteFileIfExists; import static com.google.common.io.FileWriteMode.APPEND; import static java.lang.String.format; import static java.lang.System.getProperty; @@ -126,6 +125,10 @@ public File createMakeFixedPricedOfferScript(String direction, } public File createTakeOfferScript(OfferInfo offer) { + return createTakeOfferScript(offer, "takeoffer.sh"); + } + + public File createTakeOfferScript(OfferInfo offer, String scriptFilename) { String getOffersCmd = format("%s getoffers --direction=%s --currency-code=%s", cliBase, offer.getDirection(), @@ -137,40 +140,52 @@ public File createTakeOfferScript(OfferInfo offer) { String getTradeCmd = format("%s gettrade --trade-id=%s", cliBase, offer.getId()); - return createCliScript("takeoffer.sh", + return createCliScript(scriptFilename, getOffersCmd, takeOfferCmd, - "sleep 5", + "sleep 2", getTradeCmd); } public File createPaymentStartedScript(TradeInfo trade) { + return createPaymentStartedScript(trade, "confirmpaymentstarted.sh"); + } + + public File createPaymentStartedScript(TradeInfo trade, String scriptFilename) { String paymentStartedCmd = format("%s confirmpaymentstarted --trade-id=%s", cliBase, trade.getTradeId()); String getTradeCmd = format("%s gettrade --trade-id=%s", cliBase, trade.getTradeId()); - return createCliScript("confirmpaymentstarted.sh", + return createCliScript(scriptFilename, paymentStartedCmd, "sleep 2", getTradeCmd); } public File createPaymentReceivedScript(TradeInfo trade) { + return createPaymentReceivedScript(trade, "confirmpaymentreceived.sh"); + } + + public File createPaymentReceivedScript(TradeInfo trade, String scriptFilename) { String paymentStartedCmd = format("%s confirmpaymentreceived --trade-id=%s", cliBase, trade.getTradeId()); String getTradeCmd = format("%s gettrade --trade-id=%s", cliBase, trade.getTradeId()); - return createCliScript("confirmpaymentreceived.sh", + return createCliScript(scriptFilename, paymentStartedCmd, "sleep 2", getTradeCmd); } public File createKeepFundsScript(TradeInfo trade) { + return createKeepFundsScript(trade, "keepfunds.sh"); + } + + public File createKeepFundsScript(TradeInfo trade, String scriptFilename) { String paymentStartedCmd = format("%s keepfunds --trade-id=%s", cliBase, trade.getTradeId()); String getTradeCmd = format("%s gettrade --trade-id=%s", cliBase, trade.getTradeId()); String getBalanceCmd = format("%s getbalance", cliBase); - return createCliScript("keepfunds.sh", + return createCliScript(scriptFilename, paymentStartedCmd, "sleep 2", getTradeCmd, @@ -195,7 +210,7 @@ public File createCliScript(String scriptName, String... commands) { File oldScript = new File(filename); if (oldScript.exists()) { try { - FileUtil.deleteFileIfExists(oldScript); + deleteFileIfExists(oldScript); } catch (IOException ex) { throw new IllegalStateException("Unable to delete old script.", ex); } @@ -205,8 +220,8 @@ public File createCliScript(String scriptName, String... commands) { List lines = new ArrayList<>(); lines.add("#!/bin/bash"); lines.add("############################################################"); - lines.add("# This example CLI script may be overwritten during the test"); - lines.add("# run, and will be deleted when the test harness shuts down."); + lines.add("# This example CLI script may be overwritten during bot"); + lines.add("# execution, and will be deleted when the bot shuts down."); lines.add("# Make a copy if you want to save it."); lines.add("############################################################"); lines.add("set -x"); diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/shutdown/ManualBotShutdownException.java b/apitest/src/test/java/bisq/apitest/botsupport/shutdown/ManualBotShutdownException.java similarity index 80% rename from apitest/src/test/java/bisq/apitest/scenario/bot/shutdown/ManualBotShutdownException.java rename to apitest/src/test/java/bisq/apitest/botsupport/shutdown/ManualBotShutdownException.java index 8a0e68bad18..7c0fe9825d7 100644 --- a/apitest/src/test/java/bisq/apitest/scenario/bot/shutdown/ManualBotShutdownException.java +++ b/apitest/src/test/java/bisq/apitest/botsupport/shutdown/ManualBotShutdownException.java @@ -15,21 +15,21 @@ * along with Bisq. If not, see . */ -package bisq.apitest.scenario.bot.shutdown; +package bisq.apitest.botsupport.shutdown; -import bisq.common.BisqException; +import static java.lang.String.format; @SuppressWarnings("unused") -public class ManualBotShutdownException extends BisqException { +public class ManualBotShutdownException extends RuntimeException { public ManualBotShutdownException(Throwable cause) { super(cause); } public ManualBotShutdownException(String format, Object... args) { - super(format, args); + super(format(format, args)); } public ManualBotShutdownException(Throwable cause, String format, Object... args) { - super(cause, format, args); + super(format(format, args), cause); } } diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/shutdown/ManualShutdown.java b/apitest/src/test/java/bisq/apitest/botsupport/shutdown/ManualShutdown.java similarity index 52% rename from apitest/src/test/java/bisq/apitest/scenario/bot/shutdown/ManualShutdown.java rename to apitest/src/test/java/bisq/apitest/botsupport/shutdown/ManualShutdown.java index fc680f1c818..a18a71f8497 100644 --- a/apitest/src/test/java/bisq/apitest/scenario/bot/shutdown/ManualShutdown.java +++ b/apitest/src/test/java/bisq/apitest/botsupport/shutdown/ManualShutdown.java @@ -1,6 +1,21 @@ -package bisq.apitest.scenario.bot.shutdown; +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ -import bisq.common.UserThread; +package bisq.apitest.botsupport.shutdown; import java.io.File; import java.io.IOException; @@ -9,30 +24,35 @@ import lombok.extern.slf4j.Slf4j; -import static bisq.common.file.FileUtil.deleteFileIfExists; +import static bisq.apitest.botsupport.util.FileUtil.deleteFileIfExists; import static java.util.concurrent.TimeUnit.MILLISECONDS; + + +import bisq.apitest.botsupport.BotThread; + + @Slf4j public class ManualShutdown { - public static final String SHUTDOWN_FILENAME = "/tmp/bottest-shutdown"; + public static final String SHUTDOWN_FILENAME = "/tmp/bot-shutdown"; private static final AtomicBoolean SHUTDOWN_CALLED = new AtomicBoolean(false); /** - * Looks for a /tmp/bottest-shutdown file and throws a BotShutdownException if found. + * Looks for a /tmp/bot-shutdown file and throws a BotShutdownException if found. * - * Running '$ touch /tmp/bottest-shutdown' could be used to trigger a scaffold teardown. + * Running '$ touch /tmp/bot-shutdown' could be used to trigger a scaffold teardown. * * This is much easier than manually shutdown down bisq apps & bitcoind. */ public static void startShutdownTimer() { deleteStaleShutdownFile(); - UserThread.runPeriodically(() -> { + BotThread.runPeriodically(() -> { File shutdownFile = new File(SHUTDOWN_FILENAME); if (shutdownFile.exists()) { - log.warn("Caught manual shutdown signal: /tmp/bottest-shutdown file exists."); + log.warn("Caught manual shutdown signal: {} file exists.", SHUTDOWN_FILENAME); try { deleteFileIfExists(shutdownFile); } catch (IOException ex) { @@ -53,6 +73,10 @@ public static void checkIfShutdownCalled(String warning) throws ManualBotShutdow throw new ManualBotShutdownException(warning); } + public static void setShutdownCalled(boolean isShutdownCalled) { + SHUTDOWN_CALLED.set(isShutdownCalled); + } + private static void deleteStaleShutdownFile() { try { deleteFileIfExists(new File(SHUTDOWN_FILENAME)); diff --git a/apitest/src/test/java/bisq/apitest/botsupport/util/BotUtilities.java b/apitest/src/test/java/bisq/apitest/botsupport/util/BotUtilities.java new file mode 100644 index 00000000000..412ebb8e54d --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/botsupport/util/BotUtilities.java @@ -0,0 +1,91 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.botsupport.util; + +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.common.util.concurrent.ThreadFactoryBuilder; + +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class BotUtilities { + + // An unfortunate duplication of code in non-accessible :common Utilities class. + + public static ListeningExecutorService getListeningExecutorService(String name, + int corePoolSize, + int maximumPoolSize, + long keepAliveTimeInSec) { + return MoreExecutors.listeningDecorator(getThreadPoolExecutor(name, + corePoolSize, + maximumPoolSize, + keepAliveTimeInSec)); + } + + public static ThreadPoolExecutor getThreadPoolExecutor(String name, + int corePoolSize, + int maximumPoolSize, + long keepAliveTimeInSec) { + final ThreadFactory threadFactory = new ThreadFactoryBuilder() + .setNameFormat(name) + .setDaemon(true) + .build(); + ThreadPoolExecutor executor = new ThreadPoolExecutor(corePoolSize, + maximumPoolSize, + keepAliveTimeInSec, + TimeUnit.SECONDS, + new ArrayBlockingQueue<>(maximumPoolSize), + threadFactory); + executor.allowCoreThreadTimeOut(true); + executor.setRejectedExecutionHandler((r, e) -> log.warn("RejectedExecutionHandler called")); + return executor; + } + + /** + * Copied from org.apache.commons.lang3.StringUtils.capitalize. + */ + public static String capitalize(final String str) { + int strLen; + if (str == null || (strLen = str.length()) == 0) { + return str; + } + + final int firstCodepoint = str.codePointAt(0); + final int newCodePoint = Character.toTitleCase(firstCodepoint); + if (firstCodepoint == newCodePoint) { + // already capitalized + return str; + } + + final int[] newCodePoints = new int[strLen]; // cannot be longer than the char array + int outOffset = 0; + newCodePoints[outOffset++] = newCodePoint; // copy the first codepoint + for (int inOffset = Character.charCount(firstCodepoint); inOffset < strLen; ) { + final int codepoint = str.codePointAt(inOffset); + newCodePoints[outOffset++] = codepoint; // copy the remaining ones + inOffset += Character.charCount(codepoint); + } + return new String(newCodePoints, 0, outOffset); + } +} diff --git a/apitest/src/test/java/bisq/apitest/botsupport/util/FileUtil.java b/apitest/src/test/java/bisq/apitest/botsupport/util/FileUtil.java new file mode 100644 index 00000000000..cc6d1b42728 --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/botsupport/util/FileUtil.java @@ -0,0 +1,55 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.botsupport.util; + +import java.io.File; +import java.io.IOException; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class FileUtil { + + public static void deleteFileIfExists(File file) throws IOException { + deleteFileIfExists(file, true); + } + + public static void deleteFileIfExists(File file, boolean ignoreLockedFiles) throws IOException { + try { + if (file.exists() && !file.delete()) { + if (ignoreLockedFiles) { + // We check if file is locked. On Windows all open files are locked by the OS, so we + if (isFileLocked(file)) + log.info("Failed to delete locked file: " + file.getAbsolutePath()); + } else { + final String message = "Failed to delete file: " + file.getAbsolutePath(); + log.error(message); + throw new IOException(message); + } + } + } catch (Throwable t) { + log.error(t.toString()); + t.printStackTrace(); + throw new IOException(t); + } + } + + private static boolean isFileLocked(File file) { + return !file.canWrite(); + } +} diff --git a/apitest/src/test/java/bisq/apitest/botsupport/util/FrameRateTimer.java b/apitest/src/test/java/bisq/apitest/botsupport/util/FrameRateTimer.java new file mode 100644 index 00000000000..398c16a1c22 --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/botsupport/util/FrameRateTimer.java @@ -0,0 +1,106 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.botsupport.util; + + +import java.time.Duration; + +import java.util.UUID; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * We simulate a global frame rate timer similar to FXTimer to avoid creation of threads for each timer call. + * Used only in headless apps like the seed node. + */ +public class FrameRateTimer implements Timer, Runnable { + private final Logger log = LoggerFactory.getLogger(FrameRateTimer.class); + + private long interval; + private Runnable runnable; + private long startTs; + private boolean isPeriodically; + private final String uid = UUID.randomUUID().toString(); + private volatile boolean stopped; + + public FrameRateTimer() { + } + + @Override + public void run() { + if (!stopped) { + try { + long currentTimeMillis = System.currentTimeMillis(); + if ((currentTimeMillis - startTs) >= interval) { + runnable.run(); + if (isPeriodically) + startTs = currentTimeMillis; + else + stop(); + } + } catch (Throwable t) { + log.error("exception in FrameRateTimer", t); + stop(); + throw t; + } + } + } + + @Override + public Timer runLater(Duration delay, Runnable runnable) { + this.interval = delay.toMillis(); + this.runnable = runnable; + startTs = System.currentTimeMillis(); + MasterTimer.addListener(this); + return this; + } + + @Override + public Timer runPeriodically(Duration interval, Runnable runnable) { + this.interval = interval.toMillis(); + isPeriodically = true; + this.runnable = runnable; + startTs = System.currentTimeMillis(); + MasterTimer.addListener(this); + return this; + } + + @Override + public void stop() { + stopped = true; + MasterTimer.removeListener(this); + } + + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof FrameRateTimer)) return false; + + FrameRateTimer that = (FrameRateTimer) o; + + return !(uid != null ? !uid.equals(that.uid) : that.uid != null); + + } + + @Override + public int hashCode() { + return uid != null ? uid.hashCode() : 0; + } +} diff --git a/apitest/src/test/java/bisq/apitest/botsupport/util/MasterTimer.java b/apitest/src/test/java/bisq/apitest/botsupport/util/MasterTimer.java new file mode 100644 index 00000000000..f8445a96c70 --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/botsupport/util/MasterTimer.java @@ -0,0 +1,57 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.botsupport.util; + +import java.util.Set; +import java.util.TimerTask; +import java.util.concurrent.CopyOnWriteArraySet; + +import lombok.extern.slf4j.Slf4j; + + + +import bisq.apitest.botsupport.BotThread; + + +// Runs all listener objects periodically in a short interval. + +@Slf4j +public class MasterTimer { + private static final java.util.Timer timer = new java.util.Timer(); + // frame rate of 60 fps is about 16 ms but we don't need such a short interval, 100 ms should be good enough + public static final long FRAME_INTERVAL_MS = 100; + + static { + timer.scheduleAtFixedRate(new TimerTask() { + @Override + public void run() { + BotThread.execute(() -> listeners.forEach(Runnable::run)); + } + }, FRAME_INTERVAL_MS, FRAME_INTERVAL_MS); + } + + private static final Set listeners = new CopyOnWriteArraySet<>(); + + public static void addListener(Runnable runnable) { + listeners.add(runnable); + } + + public static void removeListener(Runnable runnable) { + listeners.remove(runnable); + } +} diff --git a/apitest/src/test/java/bisq/apitest/botsupport/util/Timer.java b/apitest/src/test/java/bisq/apitest/botsupport/util/Timer.java new file mode 100644 index 00000000000..7de57d2d08f --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/botsupport/util/Timer.java @@ -0,0 +1,28 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.botsupport.util; + +import java.time.Duration; + +public interface Timer { + Timer runLater(Duration delay, Runnable action); + + Timer runPeriodically(Duration interval, Runnable runnable); + + void stop(); +} diff --git a/apitest/src/test/java/bisq/apitest/method/MethodTest.java b/apitest/src/test/java/bisq/apitest/method/MethodTest.java index c475c909ec9..51308d8ccdd 100644 --- a/apitest/src/test/java/bisq/apitest/method/MethodTest.java +++ b/apitest/src/test/java/bisq/apitest/method/MethodTest.java @@ -30,7 +30,6 @@ import java.util.function.Function; import java.util.stream.Collectors; -import static bisq.common.app.DevEnv.DEV_PRIVILEGE_PRIV_KEY; import static java.nio.charset.StandardCharsets.UTF_8; import static java.util.Arrays.stream; import static org.junit.jupiter.api.Assertions.fail; @@ -42,28 +41,21 @@ public class MethodTest extends ApiTestCase { - protected static final String ARBITRATOR = "arbitrator"; - protected static final String MEDIATOR = "mediator"; - protected static final String REFUND_AGENT = "refundagent"; - protected static final CoreProtoResolver CORE_PROTO_RESOLVER = new CoreProtoResolver(); private static final Function[], String> toNameList = (enums) -> stream(enums).map(Enum::name).collect(Collectors.joining(",")); public static void startSupportingApps(File callRateMeteringConfigFile, - boolean registerDisputeAgents, boolean generateBtcBlock, Enum... supportingApps) { startSupportingApps(callRateMeteringConfigFile, - registerDisputeAgents, generateBtcBlock, false, supportingApps); } public static void startSupportingApps(File callRateMeteringConfigFile, - boolean registerDisputeAgents, boolean generateBtcBlock, boolean startSupportingAppsInDebugMode, Enum... supportingApps) { @@ -73,23 +65,20 @@ public static void startSupportingApps(File callRateMeteringConfigFile, "--callRateMeteringConfigPath", callRateMeteringConfigFile.getAbsolutePath(), "--enableBisqDebugging", startSupportingAppsInDebugMode ? "true" : "false" }); - doPostStartup(registerDisputeAgents, generateBtcBlock); + doPostStartup(generateBtcBlock); } catch (Exception ex) { fail(ex); } } - public static void startSupportingApps(boolean registerDisputeAgents, - boolean generateBtcBlock, + public static void startSupportingApps(boolean generateBtcBlock, Enum... supportingApps) { - startSupportingApps(registerDisputeAgents, - generateBtcBlock, + startSupportingApps(generateBtcBlock, false, supportingApps); } - public static void startSupportingApps(boolean registerDisputeAgents, - boolean generateBtcBlock, + public static void startSupportingApps(boolean generateBtcBlock, boolean startSupportingAppsInDebugMode, Enum... supportingApps) { try { @@ -100,18 +89,13 @@ public static void startSupportingApps(boolean registerDisputeAgents, "--callRateMeteringConfigPath", callRateMeteringConfigFile.getAbsolutePath(), "--enableBisqDebugging", startSupportingAppsInDebugMode ? "true" : "false" }); - doPostStartup(registerDisputeAgents, generateBtcBlock); + doPostStartup(generateBtcBlock); } catch (Exception ex) { fail(ex); } } - protected static void doPostStartup(boolean registerDisputeAgents, - boolean generateBtcBlock) { - if (registerDisputeAgents) { - registerDisputeAgents(); - } - + protected static void doPostStartup(boolean generateBtcBlock) { // Generate 1 regtest block for alice's and/or bob's wallet to // show 10 BTC balance, and allow time for daemons parse the new block. if (generateBtcBlock) @@ -159,11 +143,6 @@ protected final bisq.core.payment.PaymentAccount createPaymentAccount(GrpcClient // Static conveniences for test methods and test case fixture setups. - protected static void registerDisputeAgents() { - arbClient.registerDisputeAgent(MEDIATOR, DEV_PRIVILEGE_PRIV_KEY); - arbClient.registerDisputeAgent(REFUND_AGENT, DEV_PRIVILEGE_PRIV_KEY); - } - protected static String encodeToHex(String s) { return Utilities.bytesAsHexString(s.getBytes(UTF_8)); } diff --git a/apitest/src/test/java/bisq/apitest/method/RegisterDisputeAgentsTest.java b/apitest/src/test/java/bisq/apitest/method/RegisterDisputeAgentsTest.java index 9b3a2e5f0b4..b5011ffced8 100644 --- a/apitest/src/test/java/bisq/apitest/method/RegisterDisputeAgentsTest.java +++ b/apitest/src/test/java/bisq/apitest/method/RegisterDisputeAgentsTest.java @@ -29,6 +29,9 @@ import org.junit.jupiter.api.TestMethodOrder; import static bisq.apitest.Scaffold.BitcoinCoreApp.bitcoind; +import static bisq.apitest.config.ApiTestConfig.ARBITRATOR; +import static bisq.apitest.config.ApiTestConfig.MEDIATOR; +import static bisq.apitest.config.ApiTestConfig.REFUND_AGENT; import static bisq.apitest.config.BisqAppConfig.arbdaemon; import static bisq.apitest.config.BisqAppConfig.seednode; import static bisq.common.app.DevEnv.DEV_PRIVILEGE_PRIV_KEY; diff --git a/apitest/src/test/java/bisq/apitest/method/offer/AbstractOfferTest.java b/apitest/src/test/java/bisq/apitest/method/offer/AbstractOfferTest.java index c28a51de429..f0e95dd25f8 100644 --- a/apitest/src/test/java/bisq/apitest/method/offer/AbstractOfferTest.java +++ b/apitest/src/test/java/bisq/apitest/method/offer/AbstractOfferTest.java @@ -19,6 +19,8 @@ import bisq.core.monetary.Altcoin; +import protobuf.PaymentAccount; + import org.bitcoinj.utils.Fiat; import java.math.BigDecimal; @@ -30,6 +32,7 @@ import org.junit.jupiter.api.BeforeAll; import static bisq.apitest.Scaffold.BitcoinCoreApp.bitcoind; +import static bisq.apitest.config.ApiTestConfig.BSQ; import static bisq.apitest.config.BisqAppConfig.alicedaemon; import static bisq.apitest.config.BisqAppConfig.arbdaemon; import static bisq.apitest.config.BisqAppConfig.bobdaemon; @@ -49,10 +52,13 @@ public abstract class AbstractOfferTest extends MethodTest { @Setter protected static boolean isLongRunningTest; + protected static PaymentAccount alicesBsqAcct; + protected static PaymentAccount bobsBsqAcct; + @BeforeAll public static void setUp() { startSupportingApps(true, - true, + false, bitcoind, seednode, arbdaemon, @@ -60,6 +66,18 @@ public static void setUp() { bobdaemon); } + + public static void createBsqPaymentAccounts() { + alicesBsqAcct = aliceClient.createCryptoCurrencyPaymentAccount("Alice's BSQ Account", + BSQ, + aliceClient.getUnusedBsqAddress(), + false); + bobsBsqAcct = bobClient.createCryptoCurrencyPaymentAccount("Bob's BSQ Account", + BSQ, + bobClient.getUnusedBsqAddress(), + false); + } + protected double getScaledOfferPrice(double offerPrice, String currencyCode) { int precision = isCryptoCurrency(currencyCode) ? Altcoin.SMALLEST_UNIT_EXPONENT : Fiat.SMALLEST_UNIT_EXPONENT; return scaleDownByPowerOf10(offerPrice, precision); diff --git a/apitest/src/test/java/bisq/apitest/method/offer/CancelOfferTest.java b/apitest/src/test/java/bisq/apitest/method/offer/CancelOfferTest.java index 166d853c901..fe21e4aa8f2 100644 --- a/apitest/src/test/java/bisq/apitest/method/offer/CancelOfferTest.java +++ b/apitest/src/test/java/bisq/apitest/method/offer/CancelOfferTest.java @@ -32,15 +32,17 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestMethodOrder; +import static bisq.apitest.config.ApiTestConfig.BSQ; import static bisq.core.btc.wallet.Restrictions.getDefaultBuyerSecurityDepositAsPercent; import static org.junit.jupiter.api.Assertions.assertEquals; +import static protobuf.OfferPayload.Direction.BUY; @Disabled @Slf4j @TestMethodOrder(MethodOrderer.OrderAnnotation.class) public class CancelOfferTest extends AbstractOfferTest { - private static final String DIRECTION = "buy"; + private static final String DIRECTION = BUY.name(); private static final String CURRENCY_CODE = "cad"; private static final int MAX_OFFERS = 3; @@ -52,7 +54,7 @@ public class CancelOfferTest extends AbstractOfferTest { 0.00, getDefaultBuyerSecurityDepositAsPercent(), paymentAccountId, - "bsq"); + BSQ); }; @Test diff --git a/apitest/src/test/java/bisq/apitest/method/offer/CreateBSQOffersTest.java b/apitest/src/test/java/bisq/apitest/method/offer/CreateBSQOffersTest.java new file mode 100644 index 00000000000..c83d5d15477 --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/method/offer/CreateBSQOffersTest.java @@ -0,0 +1,258 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.method.offer; + +import bisq.proto.grpc.OfferInfo; + +import java.util.List; + +import lombok.extern.slf4j.Slf4j; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +import static bisq.apitest.config.ApiTestConfig.BSQ; +import static bisq.apitest.config.ApiTestConfig.BTC; +import static bisq.cli.TableFormat.formatBalancesTbls; +import static bisq.cli.TableFormat.formatOfferTable; +import static bisq.core.btc.wallet.Restrictions.getDefaultBuyerSecurityDepositAsPercent; +import static java.util.Collections.singletonList; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static protobuf.OfferPayload.Direction.BUY; +import static protobuf.OfferPayload.Direction.SELL; + +@Disabled +@Slf4j +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class CreateBSQOffersTest extends AbstractOfferTest { + + private static final String MAKER_FEE_CURRENCY_CODE = BSQ; + + @BeforeAll + public static void setUp() { + AbstractOfferTest.setUp(); + createBsqPaymentAccounts(); + } + + @Test + @Order(1) + public void testCreateBuy1BTCFor20KBSQOffer() { + // Remember alt coin trades are BTC trades. When placing an offer, you are + // offering to buy or sell BTC, not BSQ, XMR, etc. In this test case, + // Alice places an offer to BUY BTC with BSQ. + var newOffer = aliceClient.createFixedPricedOffer(BUY.name(), + BSQ, + 100_000_000L, + 100_000_000L, + "0.00005", // FIXED PRICE IN BTC (satoshis) FOR 1 BSQ + getDefaultBuyerSecurityDepositAsPercent(), + alicesBsqAcct.getId(), + MAKER_FEE_CURRENCY_CODE); + log.info("Sell BSQ (Buy BTC) OFFER:\n{}", formatOfferTable(singletonList(newOffer), BSQ)); + String newOfferId = newOffer.getId(); + assertNotEquals("", newOfferId); + assertEquals(BUY.name(), newOffer.getDirection()); + assertFalse(newOffer.getUseMarketBasedPrice()); + assertEquals(5_000, newOffer.getPrice()); + assertEquals(100_000_000L, newOffer.getAmount()); + assertEquals(100_000_000L, newOffer.getMinAmount()); + assertEquals(15_000_000, newOffer.getBuyerSecurityDeposit()); + assertEquals(alicesBsqAcct.getId(), newOffer.getPaymentAccountId()); + assertEquals(BSQ, newOffer.getBaseCurrencyCode()); + assertEquals(BTC, newOffer.getCounterCurrencyCode()); + assertFalse(newOffer.getIsCurrencyForMakerFeeBtc()); + + genBtcBlockAndWaitForOfferPreparation(); + + newOffer = aliceClient.getMyOffer(newOfferId); + assertEquals(newOfferId, newOffer.getId()); + assertEquals(BUY.name(), newOffer.getDirection()); + assertFalse(newOffer.getUseMarketBasedPrice()); + assertEquals(5_000, newOffer.getPrice()); + assertEquals(100_000_000L, newOffer.getAmount()); + assertEquals(100_000_000L, newOffer.getMinAmount()); + assertEquals(15_000_000, newOffer.getBuyerSecurityDeposit()); + assertEquals(alicesBsqAcct.getId(), newOffer.getPaymentAccountId()); + assertEquals(BSQ, newOffer.getBaseCurrencyCode()); + assertEquals(BTC, newOffer.getCounterCurrencyCode()); + assertFalse(newOffer.getIsCurrencyForMakerFeeBtc()); + } + + @Test + @Order(2) + public void testCreateSell1BTCFor20KBSQOffer() { + // Alice places an offer to SELL BTC for BSQ. + var newOffer = aliceClient.createFixedPricedOffer(SELL.name(), + BSQ, + 100_000_000L, + 100_000_000L, + "0.00005", // FIXED PRICE IN BTC (satoshis) FOR 1 BSQ + getDefaultBuyerSecurityDepositAsPercent(), + alicesBsqAcct.getId(), + MAKER_FEE_CURRENCY_CODE); + log.info("SELL 20K BSQ OFFER:\n{}", formatOfferTable(singletonList(newOffer), BSQ)); + String newOfferId = newOffer.getId(); + assertNotEquals("", newOfferId); + assertEquals(SELL.name(), newOffer.getDirection()); + assertFalse(newOffer.getUseMarketBasedPrice()); + assertEquals(5_000, newOffer.getPrice()); + assertEquals(100_000_000L, newOffer.getAmount()); + assertEquals(100_000_000L, newOffer.getMinAmount()); + assertEquals(15_000_000, newOffer.getBuyerSecurityDeposit()); + assertEquals(alicesBsqAcct.getId(), newOffer.getPaymentAccountId()); + assertEquals(BSQ, newOffer.getBaseCurrencyCode()); + assertEquals(BTC, newOffer.getCounterCurrencyCode()); + assertFalse(newOffer.getIsCurrencyForMakerFeeBtc()); + + genBtcBlockAndWaitForOfferPreparation(); + + newOffer = aliceClient.getMyOffer(newOfferId); + assertEquals(newOfferId, newOffer.getId()); + assertEquals(SELL.name(), newOffer.getDirection()); + assertFalse(newOffer.getUseMarketBasedPrice()); + assertEquals(5_000, newOffer.getPrice()); + assertEquals(100_000_000L, newOffer.getAmount()); + assertEquals(100_000_000L, newOffer.getMinAmount()); + assertEquals(15_000_000, newOffer.getBuyerSecurityDeposit()); + assertEquals(alicesBsqAcct.getId(), newOffer.getPaymentAccountId()); + assertEquals(BSQ, newOffer.getBaseCurrencyCode()); + assertEquals(BTC, newOffer.getCounterCurrencyCode()); + assertFalse(newOffer.getIsCurrencyForMakerFeeBtc()); + } + + @Test + @Order(3) + public void testCreateBuyBTCWith1To2KBSQOffer() { + // Alice places an offer to BUY 0.05 - 0.10 BTC with BSQ. + var newOffer = aliceClient.createFixedPricedOffer(BUY.name(), + BSQ, + 10_000_000L, + 5_000_000L, + "0.00005", // FIXED PRICE IN BTC sats FOR 1 BSQ + getDefaultBuyerSecurityDepositAsPercent(), + alicesBsqAcct.getId(), + MAKER_FEE_CURRENCY_CODE); + log.info("BUY 1-2K BSQ OFFER:\n{}", formatOfferTable(singletonList(newOffer), BSQ)); + String newOfferId = newOffer.getId(); + assertNotEquals("", newOfferId); + assertEquals(BUY.name(), newOffer.getDirection()); + assertFalse(newOffer.getUseMarketBasedPrice()); + assertEquals(5_000, newOffer.getPrice()); + assertEquals(10_000_000L, newOffer.getAmount()); + assertEquals(5_000_000L, newOffer.getMinAmount()); + assertEquals(1_500_000, newOffer.getBuyerSecurityDeposit()); + assertEquals(alicesBsqAcct.getId(), newOffer.getPaymentAccountId()); + assertEquals(BSQ, newOffer.getBaseCurrencyCode()); + assertEquals(BTC, newOffer.getCounterCurrencyCode()); + assertFalse(newOffer.getIsCurrencyForMakerFeeBtc()); + + genBtcBlockAndWaitForOfferPreparation(); + + newOffer = aliceClient.getMyOffer(newOfferId); + assertEquals(newOfferId, newOffer.getId()); + assertEquals(BUY.name(), newOffer.getDirection()); + assertFalse(newOffer.getUseMarketBasedPrice()); + assertEquals(5_000, newOffer.getPrice()); + assertEquals(10_000_000L, newOffer.getAmount()); + assertEquals(5_000_000L, newOffer.getMinAmount()); + assertEquals(1_500_000, newOffer.getBuyerSecurityDeposit()); + assertEquals(alicesBsqAcct.getId(), newOffer.getPaymentAccountId()); + assertEquals(BSQ, newOffer.getBaseCurrencyCode()); + assertEquals(BTC, newOffer.getCounterCurrencyCode()); + assertFalse(newOffer.getIsCurrencyForMakerFeeBtc()); + } + + @Test + @Order(4) + public void testCreateSellBTCFor5To10KBSQOffer() { + // Alice places an offer to SELL 0.25 - 0.50 BTC for BSQ. + var newOffer = aliceClient.createFixedPricedOffer(SELL.name(), + BSQ, + 50_000_000L, + 25_000_000L, + "0.00005", // FIXED PRICE IN BTC sats FOR 1 BSQ + getDefaultBuyerSecurityDepositAsPercent(), + alicesBsqAcct.getId(), + MAKER_FEE_CURRENCY_CODE); + log.info("SELL 5-10K BSQ OFFER:\n{}", formatOfferTable(singletonList(newOffer), BSQ)); + String newOfferId = newOffer.getId(); + assertNotEquals("", newOfferId); + assertEquals(SELL.name(), newOffer.getDirection()); + assertFalse(newOffer.getUseMarketBasedPrice()); + assertEquals(5_000, newOffer.getPrice()); + assertEquals(50_000_000L, newOffer.getAmount()); + assertEquals(25_000_000L, newOffer.getMinAmount()); + assertEquals(7_500_000, newOffer.getBuyerSecurityDeposit()); + assertEquals(alicesBsqAcct.getId(), newOffer.getPaymentAccountId()); + assertEquals(BSQ, newOffer.getBaseCurrencyCode()); + assertEquals(BTC, newOffer.getCounterCurrencyCode()); + assertFalse(newOffer.getIsCurrencyForMakerFeeBtc()); + + genBtcBlockAndWaitForOfferPreparation(); + + newOffer = aliceClient.getMyOffer(newOfferId); + assertEquals(newOfferId, newOffer.getId()); + assertEquals(SELL.name(), newOffer.getDirection()); + assertFalse(newOffer.getUseMarketBasedPrice()); + assertEquals(5_000, newOffer.getPrice()); + assertEquals(50_000_000L, newOffer.getAmount()); + assertEquals(25_000_000L, newOffer.getMinAmount()); + assertEquals(7_500_000, newOffer.getBuyerSecurityDeposit()); + assertEquals(alicesBsqAcct.getId(), newOffer.getPaymentAccountId()); + assertEquals(BSQ, newOffer.getBaseCurrencyCode()); + assertEquals(BTC, newOffer.getCounterCurrencyCode()); + assertFalse(newOffer.getIsCurrencyForMakerFeeBtc()); + } + + @Test + @Order(4) + public void testGetAllMyBsqOffers() { + List offers = aliceClient.getMyBsqOffersSortedByDate(); + log.info("ALL ALICE'S BSQ OFFERS:\n{}", formatOfferTable(offers, BSQ)); + assertEquals(4, offers.size()); + log.info("ALICE'S BALANCES\n{}", formatBalancesTbls(aliceClient.getBalances())); + } + + @Test + @Order(5) + public void testGetAvailableBsqOffers() { + List offers = bobClient.getBsqOffersSortedByDate(); + log.info("ALL BOB'S AVAILABLE BSQ OFFERS:\n{}", formatOfferTable(offers, BSQ)); + assertEquals(4, offers.size()); + log.info("BOB'S BALANCES\n{}", formatBalancesTbls(bobClient.getBalances())); + } + + @Test + @Order(6) + public void testBreakpoint() { + log.debug("hit me"); + } + + private void genBtcBlockAndWaitForOfferPreparation() { + // Extra time is needed for the OfferUtils#isBsqForMakerFeeAvailable, which + // can sometimes return an incorrect false value if the BsqWallet's + // available confirmed balance is temporarily = zero during BSQ offer prep. + genBtcBlocksThenWait(1, 5000); + } +} diff --git a/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingFixedPriceTest.java b/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingFixedPriceTest.java index 2e8b2962b2a..081c6feadc7 100644 --- a/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingFixedPriceTest.java +++ b/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingFixedPriceTest.java @@ -27,53 +27,60 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestMethodOrder; +import static bisq.apitest.config.ApiTestConfig.BSQ; +import static bisq.apitest.config.ApiTestConfig.BTC; +import static bisq.cli.TableFormat.formatOfferTable; import static bisq.core.btc.wallet.Restrictions.getDefaultBuyerSecurityDepositAsPercent; +import static java.util.Collections.singletonList; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static protobuf.OfferPayload.Direction.BUY; +import static protobuf.OfferPayload.Direction.SELL; @Disabled @Slf4j @TestMethodOrder(MethodOrderer.OrderAnnotation.class) public class CreateOfferUsingFixedPriceTest extends AbstractOfferTest { - private static final String MAKER_FEE_CURRENCY_CODE = "bsq"; + private static final String MAKER_FEE_CURRENCY_CODE = BSQ; @Test @Order(1) public void testCreateAUDBTCBuyOfferUsingFixedPrice16000() { PaymentAccount audAccount = createDummyF2FAccount(aliceClient, "AU"); - var newOffer = aliceClient.createFixedPricedOffer("buy", + var newOffer = aliceClient.createFixedPricedOffer(BUY.name(), "aud", - 10000000L, - 10000000L, + 10_000_000L, + 10_000_000L, "36000", getDefaultBuyerSecurityDepositAsPercent(), audAccount.getId(), MAKER_FEE_CURRENCY_CODE); + log.info("OFFER #1:\n{}", formatOfferTable(singletonList(newOffer), "AUD")); String newOfferId = newOffer.getId(); assertNotEquals("", newOfferId); - assertEquals("BUY", newOffer.getDirection()); + assertEquals(BUY.name(), newOffer.getDirection()); assertFalse(newOffer.getUseMarketBasedPrice()); - assertEquals(360000000, newOffer.getPrice()); - assertEquals(10000000, newOffer.getAmount()); - assertEquals(10000000, newOffer.getMinAmount()); - assertEquals(1500000, newOffer.getBuyerSecurityDeposit()); + assertEquals(360_000_000, newOffer.getPrice()); + assertEquals(10_000_000, newOffer.getAmount()); + assertEquals(10_000_000, newOffer.getMinAmount()); + assertEquals(1_500_000, newOffer.getBuyerSecurityDeposit()); assertEquals(audAccount.getId(), newOffer.getPaymentAccountId()); - assertEquals("BTC", newOffer.getBaseCurrencyCode()); + assertEquals(BTC, newOffer.getBaseCurrencyCode()); assertEquals("AUD", newOffer.getCounterCurrencyCode()); assertFalse(newOffer.getIsCurrencyForMakerFeeBtc()); newOffer = aliceClient.getMyOffer(newOfferId); assertEquals(newOfferId, newOffer.getId()); - assertEquals("BUY", newOffer.getDirection()); + assertEquals(BUY.name(), newOffer.getDirection()); assertFalse(newOffer.getUseMarketBasedPrice()); - assertEquals(360000000, newOffer.getPrice()); - assertEquals(10000000, newOffer.getAmount()); - assertEquals(10000000, newOffer.getMinAmount()); - assertEquals(1500000, newOffer.getBuyerSecurityDeposit()); + assertEquals(360_000_000, newOffer.getPrice()); + assertEquals(10_000_000, newOffer.getAmount()); + assertEquals(10_000_000, newOffer.getMinAmount()); + assertEquals(1_500_000, newOffer.getBuyerSecurityDeposit()); assertEquals(audAccount.getId(), newOffer.getPaymentAccountId()); - assertEquals("BTC", newOffer.getBaseCurrencyCode()); + assertEquals(BTC, newOffer.getBaseCurrencyCode()); assertEquals("AUD", newOffer.getCounterCurrencyCode()); assertFalse(newOffer.getIsCurrencyForMakerFeeBtc()); } @@ -82,37 +89,38 @@ public void testCreateAUDBTCBuyOfferUsingFixedPrice16000() { @Order(2) public void testCreateUSDBTCBuyOfferUsingFixedPrice100001234() { PaymentAccount usdAccount = createDummyF2FAccount(aliceClient, "US"); - var newOffer = aliceClient.createFixedPricedOffer("buy", + var newOffer = aliceClient.createFixedPricedOffer(BUY.name(), "usd", - 10000000L, - 10000000L, + 10_000_000L, + 10_000_000L, "30000.1234", getDefaultBuyerSecurityDepositAsPercent(), usdAccount.getId(), MAKER_FEE_CURRENCY_CODE); + log.info("OFFER #2:\n{}", formatOfferTable(singletonList(newOffer), "USD")); String newOfferId = newOffer.getId(); assertNotEquals("", newOfferId); - assertEquals("BUY", newOffer.getDirection()); + assertEquals(BUY.name(), newOffer.getDirection()); assertFalse(newOffer.getUseMarketBasedPrice()); - assertEquals(300001234, newOffer.getPrice()); - assertEquals(10000000, newOffer.getAmount()); - assertEquals(10000000, newOffer.getMinAmount()); - assertEquals(1500000, newOffer.getBuyerSecurityDeposit()); + assertEquals(300_001_234, newOffer.getPrice()); + assertEquals(10_000_000, newOffer.getAmount()); + assertEquals(10_000_000, newOffer.getMinAmount()); + assertEquals(1_500_000, newOffer.getBuyerSecurityDeposit()); assertEquals(usdAccount.getId(), newOffer.getPaymentAccountId()); - assertEquals("BTC", newOffer.getBaseCurrencyCode()); + assertEquals(BTC, newOffer.getBaseCurrencyCode()); assertEquals("USD", newOffer.getCounterCurrencyCode()); assertFalse(newOffer.getIsCurrencyForMakerFeeBtc()); newOffer = aliceClient.getMyOffer(newOfferId); assertEquals(newOfferId, newOffer.getId()); - assertEquals("BUY", newOffer.getDirection()); + assertEquals(BUY.name(), newOffer.getDirection()); assertFalse(newOffer.getUseMarketBasedPrice()); - assertEquals(300001234, newOffer.getPrice()); - assertEquals(10000000, newOffer.getAmount()); - assertEquals(10000000, newOffer.getMinAmount()); - assertEquals(1500000, newOffer.getBuyerSecurityDeposit()); + assertEquals(300_001_234, newOffer.getPrice()); + assertEquals(10_000_000, newOffer.getAmount()); + assertEquals(10_000_000, newOffer.getMinAmount()); + assertEquals(1_500_000, newOffer.getBuyerSecurityDeposit()); assertEquals(usdAccount.getId(), newOffer.getPaymentAccountId()); - assertEquals("BTC", newOffer.getBaseCurrencyCode()); + assertEquals(BTC, newOffer.getBaseCurrencyCode()); assertEquals("USD", newOffer.getCounterCurrencyCode()); assertFalse(newOffer.getIsCurrencyForMakerFeeBtc()); } @@ -121,37 +129,38 @@ public void testCreateUSDBTCBuyOfferUsingFixedPrice100001234() { @Order(3) public void testCreateEURBTCSellOfferUsingFixedPrice95001234() { PaymentAccount eurAccount = createDummyF2FAccount(aliceClient, "FR"); - var newOffer = aliceClient.createFixedPricedOffer("sell", + var newOffer = aliceClient.createFixedPricedOffer(SELL.name(), "eur", - 10000000L, - 10000000L, + 10_000_000L, + 5_000_000L, "29500.1234", getDefaultBuyerSecurityDepositAsPercent(), eurAccount.getId(), MAKER_FEE_CURRENCY_CODE); + log.info("OFFER #3:\n{}", formatOfferTable(singletonList(newOffer), "EUR")); String newOfferId = newOffer.getId(); assertNotEquals("", newOfferId); - assertEquals("SELL", newOffer.getDirection()); + assertEquals(SELL.name(), newOffer.getDirection()); assertFalse(newOffer.getUseMarketBasedPrice()); - assertEquals(295001234, newOffer.getPrice()); - assertEquals(10000000, newOffer.getAmount()); - assertEquals(10000000, newOffer.getMinAmount()); - assertEquals(1500000, newOffer.getBuyerSecurityDeposit()); + assertEquals(295_001_234, newOffer.getPrice()); + assertEquals(10_000_000, newOffer.getAmount()); + assertEquals(5_000_000, newOffer.getMinAmount()); + assertEquals(1_500_000, newOffer.getBuyerSecurityDeposit()); assertEquals(eurAccount.getId(), newOffer.getPaymentAccountId()); - assertEquals("BTC", newOffer.getBaseCurrencyCode()); + assertEquals(BTC, newOffer.getBaseCurrencyCode()); assertEquals("EUR", newOffer.getCounterCurrencyCode()); assertFalse(newOffer.getIsCurrencyForMakerFeeBtc()); newOffer = aliceClient.getMyOffer(newOfferId); assertEquals(newOfferId, newOffer.getId()); - assertEquals("SELL", newOffer.getDirection()); + assertEquals(SELL.name(), newOffer.getDirection()); assertFalse(newOffer.getUseMarketBasedPrice()); - assertEquals(295001234, newOffer.getPrice()); - assertEquals(10000000, newOffer.getAmount()); - assertEquals(10000000, newOffer.getMinAmount()); - assertEquals(1500000, newOffer.getBuyerSecurityDeposit()); + assertEquals(295_001_234, newOffer.getPrice()); + assertEquals(10_000_000, newOffer.getAmount()); + assertEquals(5_000_000, newOffer.getMinAmount()); + assertEquals(1_500_000, newOffer.getBuyerSecurityDeposit()); assertEquals(eurAccount.getId(), newOffer.getPaymentAccountId()); - assertEquals("BTC", newOffer.getBaseCurrencyCode()); + assertEquals(BTC, newOffer.getBaseCurrencyCode()); assertEquals("EUR", newOffer.getCounterCurrencyCode()); assertFalse(newOffer.getIsCurrencyForMakerFeeBtc()); } diff --git a/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingMarketPriceMarginTest.java b/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingMarketPriceMarginTest.java index 4dad4152e12..94c2519d913 100644 --- a/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingMarketPriceMarginTest.java +++ b/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingMarketPriceMarginTest.java @@ -31,15 +31,19 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestMethodOrder; +import static bisq.apitest.config.ApiTestConfig.BTC; +import static bisq.cli.TableFormat.formatOfferTable; import static bisq.common.util.MathUtils.scaleDownByPowerOf10; import static bisq.common.util.MathUtils.scaleUpByPowerOf10; import static bisq.core.btc.wallet.Restrictions.getDefaultBuyerSecurityDepositAsPercent; import static java.lang.Math.abs; import static java.lang.String.format; +import static java.util.Collections.singletonList; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static protobuf.OfferPayload.Direction.BUY; +import static protobuf.OfferPayload.Direction.SELL; @Disabled @Slf4j @@ -50,42 +54,43 @@ public class CreateOfferUsingMarketPriceMarginTest extends AbstractOfferTest { private static final double MKT_PRICE_MARGIN_ERROR_TOLERANCE = 0.0050; // 0.50% private static final double MKT_PRICE_MARGIN_WARNING_TOLERANCE = 0.0001; // 0.01% - private static final String MAKER_FEE_CURRENCY_CODE = "btc"; + private static final String MAKER_FEE_CURRENCY_CODE = BTC; @Test @Order(1) public void testCreateUSDBTCBuyOffer5PctPriceMargin() { PaymentAccount usdAccount = createDummyF2FAccount(aliceClient, "US"); double priceMarginPctInput = 5.00; - var newOffer = aliceClient.createMarketBasedPricedOffer("buy", + var newOffer = aliceClient.createMarketBasedPricedOffer(BUY.name(), "usd", - 10000000L, - 10000000L, + 10_000_000L, + 10_000_000L, priceMarginPctInput, getDefaultBuyerSecurityDepositAsPercent(), usdAccount.getId(), MAKER_FEE_CURRENCY_CODE); + log.info("OFFER #1:\n{}", formatOfferTable(singletonList(newOffer), "usd")); String newOfferId = newOffer.getId(); assertNotEquals("", newOfferId); - assertEquals("BUY", newOffer.getDirection()); + assertEquals(BUY.name(), newOffer.getDirection()); assertTrue(newOffer.getUseMarketBasedPrice()); - assertEquals(10000000, newOffer.getAmount()); - assertEquals(10000000, newOffer.getMinAmount()); - assertEquals(1500000, newOffer.getBuyerSecurityDeposit()); + assertEquals(10_000_000, newOffer.getAmount()); + assertEquals(10_000_000, newOffer.getMinAmount()); + assertEquals(1_500_000, newOffer.getBuyerSecurityDeposit()); assertEquals(usdAccount.getId(), newOffer.getPaymentAccountId()); - assertEquals("BTC", newOffer.getBaseCurrencyCode()); + assertEquals(BTC, newOffer.getBaseCurrencyCode()); assertEquals("USD", newOffer.getCounterCurrencyCode()); assertTrue(newOffer.getIsCurrencyForMakerFeeBtc()); newOffer = aliceClient.getMyOffer(newOfferId); assertEquals(newOfferId, newOffer.getId()); - assertEquals("BUY", newOffer.getDirection()); + assertEquals(BUY.name(), newOffer.getDirection()); assertTrue(newOffer.getUseMarketBasedPrice()); - assertEquals(10000000, newOffer.getAmount()); - assertEquals(10000000, newOffer.getMinAmount()); - assertEquals(1500000, newOffer.getBuyerSecurityDeposit()); + assertEquals(10_000_000, newOffer.getAmount()); + assertEquals(10_000_000, newOffer.getMinAmount()); + assertEquals(1_500_000, newOffer.getBuyerSecurityDeposit()); assertEquals(usdAccount.getId(), newOffer.getPaymentAccountId()); - assertEquals("BTC", newOffer.getBaseCurrencyCode()); + assertEquals(BTC, newOffer.getBaseCurrencyCode()); assertEquals("USD", newOffer.getCounterCurrencyCode()); assertTrue(newOffer.getIsCurrencyForMakerFeeBtc()); @@ -97,51 +102,36 @@ public void testCreateUSDBTCBuyOffer5PctPriceMargin() { public void testCreateNZDBTCBuyOfferMinus2PctPriceMargin() { PaymentAccount nzdAccount = createDummyF2FAccount(aliceClient, "NZ"); double priceMarginPctInput = -2.00; - /* - var req = CreateOfferRequest.newBuilder() - .setPaymentAccountId(nzdAccount.getId()) - .setDirection("buy") - .setCurrencyCode("nzd") - .setAmount(10000000) - .setMinAmount(10000000) - .setUseMarketBasedPrice(true) - .setMarketPriceMargin(priceMarginPctInput) - .setPrice("0") - .setBuyerSecurityDeposit(getDefaultBuyerSecurityDepositAsPercent()) - .setMakerFeeCurrencyCode(MAKER_FEE_CURRENCY_CODE) - .build(); - var newOffer = aliceStubs.offersService.createOffer(req).getOffer(); - - */ - var newOffer = aliceClient.createMarketBasedPricedOffer("buy", + var newOffer = aliceClient.createMarketBasedPricedOffer(BUY.name(), "nzd", - 10000000L, - 10000000L, + 10_000_000L, + 10_000_000L, priceMarginPctInput, getDefaultBuyerSecurityDepositAsPercent(), nzdAccount.getId(), MAKER_FEE_CURRENCY_CODE); + log.info("OFFER #2:\n{}", formatOfferTable(singletonList(newOffer), "nzd")); String newOfferId = newOffer.getId(); assertNotEquals("", newOfferId); - assertEquals("BUY", newOffer.getDirection()); + assertEquals(BUY.name(), newOffer.getDirection()); assertTrue(newOffer.getUseMarketBasedPrice()); - assertEquals(10000000, newOffer.getAmount()); - assertEquals(10000000, newOffer.getMinAmount()); - assertEquals(1500000, newOffer.getBuyerSecurityDeposit()); + assertEquals(10_000_000, newOffer.getAmount()); + assertEquals(10_000_000, newOffer.getMinAmount()); + assertEquals(1_500_000, newOffer.getBuyerSecurityDeposit()); assertEquals(nzdAccount.getId(), newOffer.getPaymentAccountId()); - assertEquals("BTC", newOffer.getBaseCurrencyCode()); + assertEquals(BTC, newOffer.getBaseCurrencyCode()); assertEquals("NZD", newOffer.getCounterCurrencyCode()); assertTrue(newOffer.getIsCurrencyForMakerFeeBtc()); newOffer = aliceClient.getMyOffer(newOfferId); assertEquals(newOfferId, newOffer.getId()); - assertEquals("BUY", newOffer.getDirection()); + assertEquals(BUY.name(), newOffer.getDirection()); assertTrue(newOffer.getUseMarketBasedPrice()); - assertEquals(10000000, newOffer.getAmount()); - assertEquals(10000000, newOffer.getMinAmount()); - assertEquals(1500000, newOffer.getBuyerSecurityDeposit()); + assertEquals(10_000_000, newOffer.getAmount()); + assertEquals(10_000_000, newOffer.getMinAmount()); + assertEquals(1_500_000, newOffer.getBuyerSecurityDeposit()); assertEquals(nzdAccount.getId(), newOffer.getPaymentAccountId()); - assertEquals("BTC", newOffer.getBaseCurrencyCode()); + assertEquals(BTC, newOffer.getBaseCurrencyCode()); assertEquals("NZD", newOffer.getCounterCurrencyCode()); assertTrue(newOffer.getIsCurrencyForMakerFeeBtc()); @@ -153,35 +143,36 @@ public void testCreateNZDBTCBuyOfferMinus2PctPriceMargin() { public void testCreateGBPBTCSellOfferMinus1Point5PctPriceMargin() { PaymentAccount gbpAccount = createDummyF2FAccount(aliceClient, "GB"); double priceMarginPctInput = -1.5; - var newOffer = aliceClient.createMarketBasedPricedOffer("sell", + var newOffer = aliceClient.createMarketBasedPricedOffer(SELL.name(), "gbp", - 10000000L, - 10000000L, + 10_000_000L, + 5_000_000L, priceMarginPctInput, getDefaultBuyerSecurityDepositAsPercent(), gbpAccount.getId(), MAKER_FEE_CURRENCY_CODE); + log.info("OFFER #3:\n{}", formatOfferTable(singletonList(newOffer), "gbp")); String newOfferId = newOffer.getId(); assertNotEquals("", newOfferId); - assertEquals("SELL", newOffer.getDirection()); + assertEquals(SELL.name(), newOffer.getDirection()); assertTrue(newOffer.getUseMarketBasedPrice()); - assertEquals(10000000, newOffer.getAmount()); - assertEquals(10000000, newOffer.getMinAmount()); - assertEquals(1500000, newOffer.getBuyerSecurityDeposit()); + assertEquals(10_000_000, newOffer.getAmount()); + assertEquals(5_000_000, newOffer.getMinAmount()); + assertEquals(1_500_000, newOffer.getBuyerSecurityDeposit()); assertEquals(gbpAccount.getId(), newOffer.getPaymentAccountId()); - assertEquals("BTC", newOffer.getBaseCurrencyCode()); + assertEquals(BTC, newOffer.getBaseCurrencyCode()); assertEquals("GBP", newOffer.getCounterCurrencyCode()); assertTrue(newOffer.getIsCurrencyForMakerFeeBtc()); newOffer = aliceClient.getMyOffer(newOfferId); assertEquals(newOfferId, newOffer.getId()); - assertEquals("SELL", newOffer.getDirection()); + assertEquals(SELL.name(), newOffer.getDirection()); assertTrue(newOffer.getUseMarketBasedPrice()); - assertEquals(10000000, newOffer.getAmount()); - assertEquals(10000000, newOffer.getMinAmount()); - assertEquals(1500000, newOffer.getBuyerSecurityDeposit()); + assertEquals(10_000_000, newOffer.getAmount()); + assertEquals(5_000_000, newOffer.getMinAmount()); + assertEquals(1_500_000, newOffer.getBuyerSecurityDeposit()); assertEquals(gbpAccount.getId(), newOffer.getPaymentAccountId()); - assertEquals("BTC", newOffer.getBaseCurrencyCode()); + assertEquals(BTC, newOffer.getBaseCurrencyCode()); assertEquals("GBP", newOffer.getCounterCurrencyCode()); assertTrue(newOffer.getIsCurrencyForMakerFeeBtc()); @@ -193,35 +184,36 @@ public void testCreateGBPBTCSellOfferMinus1Point5PctPriceMargin() { public void testCreateBRLBTCSellOffer6Point55PctPriceMargin() { PaymentAccount brlAccount = createDummyF2FAccount(aliceClient, "BR"); double priceMarginPctInput = 6.55; - var newOffer = aliceClient.createMarketBasedPricedOffer("sell", + var newOffer = aliceClient.createMarketBasedPricedOffer(SELL.name(), "brl", - 10000000L, - 10000000L, + 10_000_000L, + 5_000_000L, priceMarginPctInput, getDefaultBuyerSecurityDepositAsPercent(), brlAccount.getId(), MAKER_FEE_CURRENCY_CODE); + log.info("OFFER #4:\n{}", formatOfferTable(singletonList(newOffer), "brl")); String newOfferId = newOffer.getId(); assertNotEquals("", newOfferId); - assertEquals("SELL", newOffer.getDirection()); + assertEquals(SELL.name(), newOffer.getDirection()); assertTrue(newOffer.getUseMarketBasedPrice()); - assertEquals(10000000, newOffer.getAmount()); - assertEquals(10000000, newOffer.getMinAmount()); - assertEquals(1500000, newOffer.getBuyerSecurityDeposit()); + assertEquals(10_000_000, newOffer.getAmount()); + assertEquals(5_000_000, newOffer.getMinAmount()); + assertEquals(1_500_000, newOffer.getBuyerSecurityDeposit()); assertEquals(brlAccount.getId(), newOffer.getPaymentAccountId()); - assertEquals("BTC", newOffer.getBaseCurrencyCode()); + assertEquals(BTC, newOffer.getBaseCurrencyCode()); assertEquals("BRL", newOffer.getCounterCurrencyCode()); assertTrue(newOffer.getIsCurrencyForMakerFeeBtc()); newOffer = aliceClient.getMyOffer(newOfferId); assertEquals(newOfferId, newOffer.getId()); - assertEquals("SELL", newOffer.getDirection()); + assertEquals(SELL.name(), newOffer.getDirection()); assertTrue(newOffer.getUseMarketBasedPrice()); - assertEquals(10000000, newOffer.getAmount()); - assertEquals(10000000, newOffer.getMinAmount()); - assertEquals(1500000, newOffer.getBuyerSecurityDeposit()); + assertEquals(10_000_000, newOffer.getAmount()); + assertEquals(5_000_000, newOffer.getMinAmount()); + assertEquals(1_500_000, newOffer.getBuyerSecurityDeposit()); assertEquals(brlAccount.getId(), newOffer.getPaymentAccountId()); - assertEquals("BTC", newOffer.getBaseCurrencyCode()); + assertEquals(BTC, newOffer.getBaseCurrencyCode()); assertEquals("BRL", newOffer.getCounterCurrencyCode()); assertTrue(newOffer.getIsCurrencyForMakerFeeBtc()); diff --git a/apitest/src/test/java/bisq/apitest/method/offer/ValidateCreateOfferTest.java b/apitest/src/test/java/bisq/apitest/method/offer/ValidateCreateOfferTest.java index 65f8c83f607..33626ee6c3c 100644 --- a/apitest/src/test/java/bisq/apitest/method/offer/ValidateCreateOfferTest.java +++ b/apitest/src/test/java/bisq/apitest/method/offer/ValidateCreateOfferTest.java @@ -29,10 +29,13 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestMethodOrder; +import static bisq.apitest.config.ApiTestConfig.BSQ; +import static bisq.apitest.config.ApiTestConfig.BTC; import static bisq.core.btc.wallet.Restrictions.getDefaultBuyerSecurityDepositAsPercent; import static java.lang.String.format; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static protobuf.OfferPayload.Direction.BUY; @Disabled @Slf4j @@ -45,16 +48,15 @@ public void testAmtTooLargeShouldThrowException() { PaymentAccount usdAccount = createDummyF2FAccount(aliceClient, "US"); @SuppressWarnings("ResultOfMethodCallIgnored") Throwable exception = assertThrows(StatusRuntimeException.class, () -> - aliceClient.createFixedPricedOffer("buy", + aliceClient.createFixedPricedOffer(BUY.name(), "usd", 100000000000L, // exceeds amount limit 100000000000L, "10000.0000", getDefaultBuyerSecurityDepositAsPercent(), usdAccount.getId(), - "bsq")); - assertEquals("UNKNOWN: An error occurred at task: ValidateOffer", - exception.getMessage()); + BSQ)); + assertEquals("UNKNOWN: An error occurred at task: ValidateOffer", exception.getMessage()); } @Test @@ -63,14 +65,14 @@ public void testNoMatchingEURPaymentAccountShouldThrowException() { PaymentAccount chfAccount = createDummyF2FAccount(aliceClient, "ch"); @SuppressWarnings("ResultOfMethodCallIgnored") Throwable exception = assertThrows(StatusRuntimeException.class, () -> - aliceClient.createFixedPricedOffer("buy", + aliceClient.createFixedPricedOffer(BUY.name(), "eur", 10000000L, 10000000L, "40000.0000", getDefaultBuyerSecurityDepositAsPercent(), chfAccount.getId(), - "btc")); + BTC)); String expectedError = format("UNKNOWN: cannot create EUR offer with payment account %s", chfAccount.getId()); assertEquals(expectedError, exception.getMessage()); } @@ -81,14 +83,14 @@ public void testNoMatchingCADPaymentAccountShouldThrowException() { PaymentAccount audAccount = createDummyF2FAccount(aliceClient, "au"); @SuppressWarnings("ResultOfMethodCallIgnored") Throwable exception = assertThrows(StatusRuntimeException.class, () -> - aliceClient.createFixedPricedOffer("buy", + aliceClient.createFixedPricedOffer(BUY.name(), "cad", 10000000L, 10000000L, "63000.0000", getDefaultBuyerSecurityDepositAsPercent(), audAccount.getId(), - "btc")); + BTC)); String expectedError = format("UNKNOWN: cannot create CAD offer with payment account %s", audAccount.getId()); assertEquals(expectedError, exception.getMessage()); } diff --git a/apitest/src/test/java/bisq/apitest/method/trade/AbstractTradeTest.java b/apitest/src/test/java/bisq/apitest/method/trade/AbstractTradeTest.java index 161d2f12833..4c4a6b34533 100644 --- a/apitest/src/test/java/bisq/apitest/method/trade/AbstractTradeTest.java +++ b/apitest/src/test/java/bisq/apitest/method/trade/AbstractTradeTest.java @@ -9,13 +9,16 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.TestInfo; +import static bisq.cli.CurrencyFormat.formatBsqAmount; import static bisq.cli.TradeFormat.format; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.fail; import bisq.apitest.method.offer.AbstractOfferTest; +import bisq.cli.GrpcClient; public class AbstractTradeTest extends AbstractOfferTest { @@ -59,11 +62,58 @@ protected final void verifyExpectedProtocolStatus(TradeInfo trade) { assertEquals(EXPECTED_PROTOCOL_STATUS.isWithdrawn, trade.getIsWithdrawn()); } + protected final void sendBsqPayment(Logger log, + GrpcClient grpcClient, + TradeInfo trade) { + var contract = trade.getContract(); + String receiverAddress = contract.getIsBuyerMakerAndSellerTaker() + ? contract.getTakerPaymentAccountPayload().getAddress() + : contract.getMakerPaymentAccountPayload().getAddress(); + String sendBsqAmount = formatBsqAmount(trade.getOffer().getVolume()); + log.info("Sending {} BSQ to address {}", sendBsqAmount, receiverAddress); + grpcClient.sendBsq(receiverAddress, sendBsqAmount, ""); + } + + protected final void verifyBsqPaymentHasBeenReceived(Logger log, + GrpcClient grpcClient, + TradeInfo trade) { + var contract = trade.getContract(); + var bsqSats = trade.getOffer().getVolume(); + var receiveAmountAsString = formatBsqAmount(bsqSats); + var address = contract.getIsBuyerMakerAndSellerTaker() + ? contract.getTakerPaymentAccountPayload().getAddress() + : contract.getMakerPaymentAccountPayload().getAddress(); + boolean receivedBsqSatoshis = grpcClient.verifyBsqSentToAddress(address, receiveAmountAsString); + if (receivedBsqSatoshis) + log.info("Payment of {} BSQ was received to address {} for trade with id {}.", + receiveAmountAsString, + address, + trade.getTradeId()); + else + fail(String.format("Payment of %s BSQ was was not sent to address %s for trade with id %s.", + receiveAmountAsString, + address, + trade.getTradeId())); + } + protected final void logTrade(Logger log, TestInfo testInfo, String description, TradeInfo trade) { - if (log.isDebugEnabled()) { + logTrade(log, testInfo, description, trade, false); + } + + protected final void logTrade(Logger log, + TestInfo testInfo, + String description, + TradeInfo trade, + boolean force) { + if (force) + log.info(String.format("%s %s%n%s", + testName(testInfo), + description.toUpperCase(), + format(trade))); + else if (log.isDebugEnabled()) { log.debug(String.format("%s %s%n%s", testName(testInfo), description.toUpperCase(), diff --git a/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBSQOfferTest.java b/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBSQOfferTest.java new file mode 100644 index 00000000000..e04ccfc5b8c --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBSQOfferTest.java @@ -0,0 +1,304 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.method.trade; + +import bisq.proto.grpc.TradeInfo; + +import io.grpc.StatusRuntimeException; + +import java.util.function.Predicate; + +import lombok.extern.slf4j.Slf4j; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.api.TestMethodOrder; + +import static bisq.apitest.config.ApiTestConfig.BSQ; +import static bisq.cli.TableFormat.formatBalancesTbls; +import static bisq.cli.TableFormat.formatOfferTable; +import static bisq.core.btc.wallet.Restrictions.getDefaultBuyerSecurityDepositAsPercent; +import static bisq.core.trade.Trade.Phase.DEPOSIT_CONFIRMED; +import static bisq.core.trade.Trade.Phase.FIAT_SENT; +import static bisq.core.trade.Trade.Phase.PAYOUT_PUBLISHED; +import static bisq.core.trade.Trade.State.BUYER_RECEIVED_PAYOUT_TX_PUBLISHED_MSG; +import static bisq.core.trade.Trade.State.DEPOSIT_CONFIRMED_IN_BLOCK_CHAIN; +import static bisq.core.trade.Trade.State.SELLER_RECEIVED_FIAT_PAYMENT_INITIATED_MSG; +import static bisq.core.trade.Trade.State.SELLER_SAW_ARRIVED_PAYOUT_TX_PUBLISHED_MSG; +import static java.lang.String.format; +import static java.util.Collections.singletonList; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.fail; +import static protobuf.Offer.State.OFFER_FEE_PAID; +import static protobuf.OfferPayload.Direction.SELL; + + + +import bisq.apitest.method.offer.AbstractOfferTest; + +// https://github.com/ghubstan/bisq/blob/master/cli/src/main/java/bisq/cli/TradeFormat.java + +@Disabled +@Slf4j +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class TakeBuyBSQOfferTest extends AbstractTradeTest { + + // Alice is maker / bsq buyer (btc seller), Bob is taker / bsq seller (btc buyer). + + // Maker and Taker fees are in BSQ. + private static final String TRADE_FEE_CURRENCY_CODE = BSQ; + + @BeforeAll + public static void setUp() { + AbstractOfferTest.setUp(); + createBsqPaymentAccounts(); + EXPECTED_PROTOCOL_STATUS.init(); + } + + @Test + @Order(1) + public void testTakeAlicesSellBTCForBSQOffer(final TestInfo testInfo) { + try { + // Alice is going to BUY BSQ, but the Offer direction = SELL because it is a + // BTC trade; Alice will SELL BTC for BSQ. Bob will send Alice BSQ. + // Confused me, but just need to remember there are only BTC offers. + var btcTradeDirection = SELL.name(); + var alicesOffer = aliceClient.createFixedPricedOffer(btcTradeDirection, + BSQ, + 15_000_000L, + 7_500_000L, + "0.000035", // FIXED PRICE IN BTC (satoshis) FOR 1 BSQ + getDefaultBuyerSecurityDepositAsPercent(), + alicesBsqAcct.getId(), + TRADE_FEE_CURRENCY_CODE); + log.info("ALICE'S BUY BSQ (SELL BTC) OFFER:\n{}", formatOfferTable(singletonList(alicesOffer), BSQ)); + genBtcBlocksThenWait(1, 5000); + var offerId = alicesOffer.getId(); + assertFalse(alicesOffer.getIsCurrencyForMakerFeeBtc()); + + var alicesBsqOffers = aliceClient.getMyBsqOffers(btcTradeDirection); + assertEquals(1, alicesBsqOffers.size()); + + var trade = takeAlicesOffer(offerId, bobsBsqAcct.getId(), TRADE_FEE_CURRENCY_CODE); + assertNotNull(trade); + assertEquals(offerId, trade.getTradeId()); + assertFalse(trade.getIsCurrencyForTakerFeeBtc()); + // Cache the trade id for the other tests. + tradeId = trade.getTradeId(); + + genBtcBlocksThenWait(1, 6000); + alicesBsqOffers = aliceClient.getMyBsqOffersSortedByDate(); + assertEquals(0, alicesBsqOffers.size()); + + for (int i = 1; i <= maxTradeStateAndPhaseChecks.get(); i++) { + trade = bobClient.getTrade(trade.getTradeId()); + + if (!trade.getIsDepositConfirmed()) { + log.warn("Bob still waiting on trade {} tx {}: DEPOSIT_CONFIRMED_IN_BLOCK_CHAIN, attempt # {}", + trade.getShortId(), + trade.getDepositTxId(), + i); + genBtcBlocksThenWait(1, 4000); + continue; + } else { + EXPECTED_PROTOCOL_STATUS.setState(DEPOSIT_CONFIRMED_IN_BLOCK_CHAIN) + .setPhase(DEPOSIT_CONFIRMED) + .setDepositPublished(true) + .setDepositConfirmed(true); + verifyExpectedProtocolStatus(trade); + logTrade(log, testInfo, "Bob's view after taking offer and deposit confirmed", trade); + break; + } + } + + genBtcBlocksThenWait(1, 2500); + + if (!trade.getIsDepositConfirmed()) { + fail(format("INVALID_PHASE for Bob's trade %s in STATE=%s PHASE=%s, deposit tx was never confirmed.", + trade.getShortId(), + trade.getState(), + trade.getPhase())); + } + + logTrade(log, testInfo, "Alice's Maker/Buyer View", aliceClient.getTrade(tradeId), true); + logTrade(log, testInfo, "Bob's Taker/Seller View", bobClient.getTrade(tradeId), true); + + } catch (StatusRuntimeException e) { + fail(e); + } + } + + @Test + @Order(2) + public void testBobsConfirmPaymentStarted(final TestInfo testInfo) { + try { + var trade = bobClient.getTrade(tradeId); + + Predicate tradeStateAndPhaseCorrect = (t) -> + t.getState().equals(DEPOSIT_CONFIRMED_IN_BLOCK_CHAIN.name()) + && t.getPhase().equals(DEPOSIT_CONFIRMED.name()); + + for (int i = 1; i <= maxTradeStateAndPhaseChecks.get(); i++) { + if (!tradeStateAndPhaseCorrect.test(trade)) { + log.warn("INVALID_PHASE for Bob's trade {} in STATE={} PHASE={}, cannot send payment started msg yet.", + trade.getShortId(), + trade.getState(), + trade.getPhase()); + sleep(10_000); + trade = bobClient.getTrade(tradeId); + continue; + } else { + break; + } + } + + if (!tradeStateAndPhaseCorrect.test(trade)) { + fail(format("INVALID_PHASE for Bob's trade %s in STATE=%s PHASE=%s, could not send payment started msg.", + trade.getShortId(), + trade.getState(), + trade.getPhase())); + } + + sendBsqPayment(log, bobClient, trade); + genBtcBlocksThenWait(1, 2500); + bobClient.confirmPaymentStarted(trade.getTradeId()); + sleep(6000); + + for (int i = 1; i <= maxTradeStateAndPhaseChecks.get(); i++) { + trade = aliceClient.getTrade(tradeId); + + if (!trade.getIsFiatSent()) { + log.warn("Alice still waiting for trade {} SELLER_RECEIVED_FIAT_PAYMENT_INITIATED_MSG, attempt # {}", + trade.getShortId(), + i); + sleep(5000); + continue; + } else { + // Warning: trade.getOffer().getState() might be AVAILABLE, not OFFER_FEE_PAID. + EXPECTED_PROTOCOL_STATUS.setState(SELLER_RECEIVED_FIAT_PAYMENT_INITIATED_MSG) + .setPhase(FIAT_SENT) + .setFiatSent(true); + verifyExpectedProtocolStatus(trade); + logTrade(log, testInfo, "Alice's view after confirming fiat payment received", trade); + break; + } + } + + logTrade(log, testInfo, "Alice's Maker/Buyer View (Payment Sent)", aliceClient.getTrade(tradeId), true); + logTrade(log, testInfo, "Bob's Taker/Seller View (Payment Sent)", bobClient.getTrade(tradeId), true); + + } catch (StatusRuntimeException e) { + fail(e); + } + } + + @Test + @Order(3) + public void testAlicesConfirmPaymentReceived(final TestInfo testInfo) { + try { + var trade = aliceClient.getTrade(tradeId); + + Predicate tradeStateAndPhaseCorrect = (t) -> + t.getState().equals(SELLER_RECEIVED_FIAT_PAYMENT_INITIATED_MSG.name()) + && (t.getPhase().equals(PAYOUT_PUBLISHED.name()) || t.getPhase().equals(FIAT_SENT.name())); + + for (int i = 1; i <= maxTradeStateAndPhaseChecks.get(); i++) { + if (!tradeStateAndPhaseCorrect.test(trade)) { + log.warn("INVALID_PHASE for Alice's trade {} in STATE={} PHASE={}, cannot confirm payment received yet.", + trade.getShortId(), + trade.getState(), + trade.getPhase()); + sleep(1000 * 10); + trade = aliceClient.getTrade(tradeId); + continue; + } else { + break; + } + } + + if (!tradeStateAndPhaseCorrect.test(trade)) { + fail(format("INVALID_PHASE for Alice's trade %s in STATE=%s PHASE=%s, cannot confirm payment received.", + trade.getShortId(), + trade.getState(), + trade.getPhase())); + } + + sleep(2000); + verifyBsqPaymentHasBeenReceived(log, aliceClient, trade); + + aliceClient.confirmPaymentReceived(trade.getTradeId()); + sleep(3000); + + trade = aliceClient.getTrade(tradeId); + assertEquals(OFFER_FEE_PAID.name(), trade.getOffer().getState()); + EXPECTED_PROTOCOL_STATUS.setState(SELLER_SAW_ARRIVED_PAYOUT_TX_PUBLISHED_MSG) + .setPhase(PAYOUT_PUBLISHED) + .setPayoutPublished(true) + .setFiatReceived(true); + verifyExpectedProtocolStatus(trade); + logTrade(log, testInfo, "Alice's view after confirming fiat payment received", trade); + + logTrade(log, testInfo, "Alice's Maker/Buyer View (Payment Received)", aliceClient.getTrade(tradeId), true); + logTrade(log, testInfo, "Bob's Taker/Seller View (Payment Received)", bobClient.getTrade(tradeId), true); + + } catch (StatusRuntimeException e) { + fail(e); + } + } + + @Test + @Order(4) + public void testBobsKeepFunds(final TestInfo testInfo) { + try { + genBtcBlocksThenWait(1, 1000); + + var trade = bobClient.getTrade(tradeId); + logTrade(log, testInfo, "Alice's view before keeping funds", trade); + + bobClient.keepFunds(tradeId); + genBtcBlocksThenWait(1, 1000); + + trade = bobClient.getTrade(tradeId); + EXPECTED_PROTOCOL_STATUS.setState(BUYER_RECEIVED_PAYOUT_TX_PUBLISHED_MSG) + .setPhase(PAYOUT_PUBLISHED); + verifyExpectedProtocolStatus(trade); + logTrade(log, testInfo, "Alice's view after keeping funds", trade); + + logTrade(log, testInfo, "Alice's Maker/Buyer View (Done)", aliceClient.getTrade(tradeId), true); + logTrade(log, testInfo, "Bob's Taker/Seller View (Done)", bobClient.getTrade(tradeId), true); + + var alicesBalances = aliceClient.getBalances(); + log.info("{} Alice's Current Balance:\n{}", + testName(testInfo), + formatBalancesTbls(alicesBalances)); + var bobsBalances = bobClient.getBalances(); + log.info("{} Bob's Current Balance:\n{}", + testName(testInfo), + formatBalancesTbls(bobsBalances)); + + } catch (StatusRuntimeException e) { + fail(e); + } + } +} diff --git a/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBTCOfferTest.java b/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBTCOfferTest.java index 8c2f96fecb1..93d9b1b9c8b 100644 --- a/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBTCOfferTest.java +++ b/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBTCOfferTest.java @@ -19,7 +19,6 @@ import bisq.core.payment.PaymentAccount; -import bisq.proto.grpc.BtcBalanceInfo; import bisq.proto.grpc.TradeInfo; import io.grpc.StatusRuntimeException; @@ -35,10 +34,10 @@ import org.junit.jupiter.api.TestInfo; import org.junit.jupiter.api.TestMethodOrder; -import static bisq.cli.CurrencyFormat.formatSatoshis; +import static bisq.apitest.config.ApiTestConfig.BSQ; +import static bisq.cli.TableFormat.formatBalancesTbls; import static bisq.core.btc.wallet.Restrictions.getDefaultBuyerSecurityDepositAsPercent; import static bisq.core.trade.Trade.Phase.DEPOSIT_CONFIRMED; -import static bisq.core.trade.Trade.Phase.DEPOSIT_PUBLISHED; import static bisq.core.trade.Trade.Phase.FIAT_SENT; import static bisq.core.trade.Trade.Phase.PAYOUT_PUBLISHED; import static bisq.core.trade.Trade.State.*; @@ -48,12 +47,9 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.fail; import static protobuf.Offer.State.OFFER_FEE_PAID; +import static protobuf.OfferPayload.Direction.BUY; import static protobuf.OpenOffer.State.AVAILABLE; - - -import bisq.cli.TradeFormat; - @Disabled @Slf4j @TestMethodOrder(MethodOrderer.OrderAnnotation.class) @@ -62,17 +58,17 @@ public class TakeBuyBTCOfferTest extends AbstractTradeTest { // Alice is maker/buyer, Bob is taker/seller. // Maker and Taker fees are in BSQ. - private static final String TRADE_FEE_CURRENCY_CODE = "bsq"; + private static final String TRADE_FEE_CURRENCY_CODE = BSQ; @Test @Order(1) public void testTakeAlicesBuyOffer(final TestInfo testInfo) { try { PaymentAccount alicesUsdAccount = createDummyF2FAccount(aliceClient, "US"); - var alicesOffer = aliceClient.createMarketBasedPricedOffer("buy", + var alicesOffer = aliceClient.createMarketBasedPricedOffer(BUY.name(), "usd", - 12500000, - 12500000, // min-amount = amount + 12_500_000L, + 12_500_000L, // min-amount = amount 0.00, getDefaultBuyerSecurityDepositAsPercent(), alicesUsdAccount.getId(), @@ -83,7 +79,7 @@ public void testTakeAlicesBuyOffer(final TestInfo testInfo) { // Wait for Alice's AddToOfferBook task. // Wait times vary; my logs show >= 2 second delay. sleep(3000); // TODO loop instead of hard code wait time - var alicesUsdOffers = aliceClient.getMyOffersSortedByDate("buy", "usd"); + var alicesUsdOffers = aliceClient.getMyOffersSortedByDate(BUY.name(), "usd"); assertEquals(1, alicesUsdOffers.size()); PaymentAccount bobsUsdAccount = createDummyF2FAccount(bobClient, "US"); @@ -95,18 +91,9 @@ public void testTakeAlicesBuyOffer(final TestInfo testInfo) { tradeId = trade.getTradeId(); genBtcBlocksThenWait(1, 4000); - alicesUsdOffers = aliceClient.getMyOffersSortedByDate("buy", "usd"); + alicesUsdOffers = aliceClient.getMyOffersSortedByDate(BUY.name(), "usd"); assertEquals(0, alicesUsdOffers.size()); - if (!isLongRunningTest) { - trade = bobClient.getTrade(trade.getTradeId()); - EXPECTED_PROTOCOL_STATUS.setState(SELLER_PUBLISHED_DEPOSIT_TX) - .setPhase(DEPOSIT_PUBLISHED) - .setDepositPublished(true); - verifyExpectedProtocolStatus(trade); - logTrade(log, testInfo, "Bob's view after taking offer and sending deposit", trade); - } - genBtcBlocksThenWait(1, 2500); for (int i = 1; i <= maxTradeStateAndPhaseChecks.get(); i++) { @@ -117,14 +104,15 @@ public void testTakeAlicesBuyOffer(final TestInfo testInfo) { trade.getShortId(), trade.getDepositTxId(), i); - sleep(5000); + genBtcBlocksThenWait(1, 4000); continue; } else { EXPECTED_PROTOCOL_STATUS.setState(DEPOSIT_CONFIRMED_IN_BLOCK_CHAIN) .setPhase(DEPOSIT_CONFIRMED) + .setDepositPublished(true) .setDepositConfirmed(true); verifyExpectedProtocolStatus(trade); - logTrade(log, testInfo, "Bob's view after deposit is confirmed", trade); + logTrade(log, testInfo, "Bob's view after deposit is confirmed", trade, true); break; } } @@ -269,11 +257,18 @@ public void testAlicesKeepFunds(final TestInfo testInfo) { .setPhase(PAYOUT_PUBLISHED); verifyExpectedProtocolStatus(trade); logTrade(log, testInfo, "Alice's view after keeping funds", trade); - BtcBalanceInfo currentBalance = aliceClient.getBtcBalances(); - log.info("{} Alice's current available balance: {} BTC. Last trade:\n{}", + + logTrade(log, testInfo, "Alice's Maker/Buyer View (Done)", aliceClient.getTrade(tradeId), true); + logTrade(log, testInfo, "Bob's Taker/Seller View (Done)", bobClient.getTrade(tradeId), true); + + var alicesBalances = aliceClient.getBalances(); + log.info("{} Alice's Current Balance:\n{}", + testName(testInfo), + formatBalancesTbls(alicesBalances)); + var bobsBalances = bobClient.getBalances(); + log.info("{} Bob's Current Balance:\n{}", testName(testInfo), - formatSatoshis(currentBalance.getAvailableBalance()), - TradeFormat.format(aliceClient.getTrade(tradeId))); + formatBalancesTbls(bobsBalances)); } catch (StatusRuntimeException e) { fail(e); } diff --git a/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBSQOfferTest.java b/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBSQOfferTest.java new file mode 100644 index 00000000000..e9348e6323d --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBSQOfferTest.java @@ -0,0 +1,309 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.method.trade; + +import bisq.proto.grpc.TradeInfo; + +import io.grpc.StatusRuntimeException; + +import java.util.function.Predicate; + +import lombok.extern.slf4j.Slf4j; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.api.TestMethodOrder; + +import static bisq.apitest.config.ApiTestConfig.BSQ; +import static bisq.apitest.config.ApiTestConfig.BTC; +import static bisq.cli.TableFormat.formatBalancesTbls; +import static bisq.cli.TableFormat.formatOfferTable; +import static bisq.core.btc.wallet.Restrictions.getDefaultBuyerSecurityDepositAsPercent; +import static bisq.core.trade.Trade.Phase.DEPOSIT_CONFIRMED; +import static bisq.core.trade.Trade.Phase.FIAT_SENT; +import static bisq.core.trade.Trade.Phase.PAYOUT_PUBLISHED; +import static bisq.core.trade.Trade.Phase.WITHDRAWN; +import static bisq.core.trade.Trade.State.DEPOSIT_CONFIRMED_IN_BLOCK_CHAIN; +import static bisq.core.trade.Trade.State.SELLER_RECEIVED_FIAT_PAYMENT_INITIATED_MSG; +import static bisq.core.trade.Trade.State.SELLER_SAW_ARRIVED_PAYOUT_TX_PUBLISHED_MSG; +import static bisq.core.trade.Trade.State.WITHDRAW_COMPLETED; +import static java.lang.String.format; +import static java.util.Collections.singletonList; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; +import static protobuf.OfferPayload.Direction.BUY; + + + +import bisq.apitest.method.offer.AbstractOfferTest; + +@Disabled +@Slf4j +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class TakeSellBSQOfferTest extends AbstractTradeTest { + + // Alice is maker / bsq seller (btc buyer), Bob is taker / bsq buyer (btc seller). + + // Maker and Taker fees are in BTC. + private static final String TRADE_FEE_CURRENCY_CODE = BTC; + + private static final String WITHDRAWAL_TX_MEMO = "Bob's trade withdrawal"; + + @BeforeAll + public static void setUp() { + AbstractOfferTest.setUp(); + createBsqPaymentAccounts(); + EXPECTED_PROTOCOL_STATUS.init(); + } + + @Test + @Order(1) + public void testTakeAlicesBuyBTCForBSQOffer(final TestInfo testInfo) { + try { + // Alice is going to SELL BSQ, but the Offer direction = BUY because it is a + // BTC trade; Alice will BUY BTC for BSQ. Alice will send Bob BSQ. + // Confused me, but just need to remember there are only BTC offers. + var btcTradeDirection = BUY.name(); + var alicesOffer = aliceClient.createFixedPricedOffer(btcTradeDirection, + BSQ, + 15_000_000L, + 7_500_000L, + "0.000035", // FIXED PRICE IN BTC (satoshis) FOR 1 BSQ + getDefaultBuyerSecurityDepositAsPercent(), + alicesBsqAcct.getId(), + TRADE_FEE_CURRENCY_CODE); + log.info("ALICE'S SELL BSQ (BUY BTC) OFFER:\n{}", formatOfferTable(singletonList(alicesOffer), BSQ)); + genBtcBlocksThenWait(1, 4000); + var offerId = alicesOffer.getId(); + assertTrue(alicesOffer.getIsCurrencyForMakerFeeBtc()); + + var alicesBsqOffers = aliceClient.getMyBsqOffers(btcTradeDirection); + assertEquals(1, alicesBsqOffers.size()); + + var trade = takeAlicesOffer(offerId, bobsBsqAcct.getId(), TRADE_FEE_CURRENCY_CODE); + assertNotNull(trade); + assertEquals(offerId, trade.getTradeId()); + assertTrue(trade.getIsCurrencyForTakerFeeBtc()); + // Cache the trade id for the other tests. + tradeId = trade.getTradeId(); + + genBtcBlocksThenWait(1, 6000); + alicesBsqOffers = aliceClient.getMyBsqOffersSortedByDate(); + assertEquals(0, alicesBsqOffers.size()); + + for (int i = 1; i <= maxTradeStateAndPhaseChecks.get(); i++) { + trade = bobClient.getTrade(trade.getTradeId()); + + if (!trade.getIsDepositConfirmed()) { + log.warn("Bob still waiting on trade {} tx {}: DEPOSIT_CONFIRMED_IN_BLOCK_CHAIN, attempt # {}", + trade.getShortId(), + trade.getDepositTxId(), + i); + genBtcBlocksThenWait(1, 4000); + continue; + } else { + EXPECTED_PROTOCOL_STATUS.setState(DEPOSIT_CONFIRMED_IN_BLOCK_CHAIN) + .setPhase(DEPOSIT_CONFIRMED) + .setDepositPublished(true) + .setDepositConfirmed(true); + verifyExpectedProtocolStatus(trade); + logTrade(log, testInfo, "Bob's view after taking offer and deposit confirmed", trade); + break; + } + } + + genBtcBlocksThenWait(1, 2500); + + if (!trade.getIsDepositConfirmed()) { + fail(format("INVALID_PHASE for Bob's trade %s in STATE=%s PHASE=%s, deposit tx was never confirmed.", + trade.getShortId(), + trade.getState(), + trade.getPhase())); + } + + logTrade(log, testInfo, "Alice's Maker/Seller View", aliceClient.getTrade(tradeId), true); + logTrade(log, testInfo, "Bob's Taker/Buyer View", bobClient.getTrade(tradeId), true); + + } catch (StatusRuntimeException e) { + fail(e); + } + } + + @Test + @Order(2) + public void testAlicesConfirmPaymentStarted(final TestInfo testInfo) { + try { + var trade = aliceClient.getTrade(tradeId); + + Predicate tradeStateAndPhaseCorrect = (t) -> + t.getState().equals(DEPOSIT_CONFIRMED_IN_BLOCK_CHAIN.name()) + && t.getPhase().equals(DEPOSIT_CONFIRMED.name()); + + for (int i = 1; i <= maxTradeStateAndPhaseChecks.get(); i++) { + if (!tradeStateAndPhaseCorrect.test(trade)) { + log.warn("INVALID_PHASE for Alice's trade {} in STATE={} PHASE={}, cannot send payment started msg yet.", + trade.getShortId(), + trade.getState(), + trade.getPhase()); + sleep(10_000); + trade = aliceClient.getTrade(tradeId); + continue; + } else { + break; + } + } + + if (!tradeStateAndPhaseCorrect.test(trade)) { + fail(format("INVALID_PHASE for Alice's trade %s in STATE=%s PHASE=%s, could not send payment started msg.", + trade.getShortId(), + trade.getState(), + trade.getPhase())); + } + + sendBsqPayment(log, aliceClient, trade); + genBtcBlocksThenWait(1, 2500); + aliceClient.confirmPaymentStarted(trade.getTradeId()); + sleep(6000); + + for (int i = 1; i <= maxTradeStateAndPhaseChecks.get(); i++) { + trade = bobClient.getTrade(tradeId); + + if (!trade.getIsFiatSent()) { + log.warn("Bob still waiting for trade {} SELLER_RECEIVED_FIAT_PAYMENT_INITIATED_MSG, attempt # {}", + trade.getShortId(), + i); + sleep(5000); + continue; + } else { + // Warning: trade.getOffer().getState() might be AVAILABLE, not OFFER_FEE_PAID. + EXPECTED_PROTOCOL_STATUS.setState(SELLER_RECEIVED_FIAT_PAYMENT_INITIATED_MSG) + .setPhase(FIAT_SENT) + .setFiatSent(true); + verifyExpectedProtocolStatus(trade); + logTrade(log, testInfo, "Alice's view after confirming fiat payment received", trade); + break; + } + } + + logTrade(log, testInfo, "Alice's Maker/Seller View (Payment Sent)", aliceClient.getTrade(tradeId), true); + logTrade(log, testInfo, "Bob's Taker/Buyer View (Payment Sent)", bobClient.getTrade(tradeId), true); + + } catch (StatusRuntimeException e) { + fail(e); + } + } + + @Test + @Order(3) + public void testBobsConfirmPaymentReceived(final TestInfo testInfo) { + try { + var trade = bobClient.getTrade(tradeId); + + Predicate tradeStateAndPhaseCorrect = (t) -> + t.getState().equals(SELLER_RECEIVED_FIAT_PAYMENT_INITIATED_MSG.name()) + && (t.getPhase().equals(PAYOUT_PUBLISHED.name()) || t.getPhase().equals(FIAT_SENT.name())); + + for (int i = 1; i <= maxTradeStateAndPhaseChecks.get(); i++) { + if (!tradeStateAndPhaseCorrect.test(trade)) { + log.warn("INVALID_PHASE for Bob's trade {} in STATE={} PHASE={}, cannot confirm payment received yet.", + trade.getShortId(), + trade.getState(), + trade.getPhase()); + sleep(1000 * 10); + trade = bobClient.getTrade(tradeId); + continue; + } else { + break; + } + } + + if (!tradeStateAndPhaseCorrect.test(trade)) { + fail(format("INVALID_PHASE for Bob's trade %s in STATE=%s PHASE=%s, cannot confirm payment received.", + trade.getShortId(), + trade.getState(), + trade.getPhase())); + } + + sleep(2000); + verifyBsqPaymentHasBeenReceived(log, bobClient, trade); + + bobClient.confirmPaymentReceived(trade.getTradeId()); + sleep(3000); + + trade = bobClient.getTrade(tradeId); + // Warning: trade.getOffer().getState() might be AVAILABLE, not OFFER_FEE_PAID. + EXPECTED_PROTOCOL_STATUS.setState(SELLER_SAW_ARRIVED_PAYOUT_TX_PUBLISHED_MSG) + .setPhase(PAYOUT_PUBLISHED) + .setPayoutPublished(true) + .setFiatReceived(true); + verifyExpectedProtocolStatus(trade); + logTrade(log, testInfo, "Alice's view after confirming fiat payment received", trade); + + logTrade(log, testInfo, "Alice's Maker/Seller View (Payment Received)", aliceClient.getTrade(tradeId), true); + logTrade(log, testInfo, "Bob's Taker/Buyer View (Payment Received)", bobClient.getTrade(tradeId), true); + + } catch (StatusRuntimeException e) { + fail(e); + } + } + + @Test + @Order(4) + public void testAlicesBtcWithdrawalToExternalAddress(final TestInfo testInfo) { + try { + genBtcBlocksThenWait(1, 1000); + + var trade = aliceClient.getTrade(tradeId); + logTrade(log, testInfo, "Alice's view before withdrawing BTC funds to external wallet", trade); + + String toAddress = bitcoinCli.getNewBtcAddress(); + aliceClient.withdrawFunds(tradeId, toAddress, WITHDRAWAL_TX_MEMO); + + genBtcBlocksThenWait(1, 1000); + + trade = aliceClient.getTrade(tradeId); + EXPECTED_PROTOCOL_STATUS.setState(WITHDRAW_COMPLETED) + .setPhase(WITHDRAWN) + .setWithdrawn(true); + verifyExpectedProtocolStatus(trade); + logTrade(log, testInfo, "Alice's view after withdrawing funds to external wallet", trade); + + + logTrade(log, testInfo, "Alice's Maker/Seller View (Done)", aliceClient.getTrade(tradeId), true); + logTrade(log, testInfo, "Bob's Taker/Buyer View (Done)", bobClient.getTrade(tradeId), true); + + var alicesBalances = aliceClient.getBalances(); + log.info("{} Alice's Current Balance:\n{}", + testName(testInfo), + formatBalancesTbls(alicesBalances)); + var bobsBalances = bobClient.getBalances(); + log.info("{} Bob's Current Balance:\n{}", + testName(testInfo), + formatBalancesTbls(bobsBalances)); + + } catch (StatusRuntimeException e) { + fail(e); + } + } +} diff --git a/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBTCOfferTest.java b/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBTCOfferTest.java index 09d27453d31..ece3432123b 100644 --- a/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBTCOfferTest.java +++ b/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBTCOfferTest.java @@ -19,7 +19,6 @@ import bisq.core.payment.PaymentAccount; -import bisq.proto.grpc.BtcBalanceInfo; import bisq.proto.grpc.TradeInfo; import io.grpc.StatusRuntimeException; @@ -35,9 +34,13 @@ import org.junit.jupiter.api.TestInfo; import org.junit.jupiter.api.TestMethodOrder; -import static bisq.cli.CurrencyFormat.formatSatoshis; +import static bisq.apitest.config.ApiTestConfig.BTC; +import static bisq.cli.TableFormat.formatBalancesTbls; import static bisq.core.btc.wallet.Restrictions.getDefaultBuyerSecurityDepositAsPercent; -import static bisq.core.trade.Trade.Phase.*; +import static bisq.core.trade.Trade.Phase.DEPOSIT_CONFIRMED; +import static bisq.core.trade.Trade.Phase.FIAT_SENT; +import static bisq.core.trade.Trade.Phase.PAYOUT_PUBLISHED; +import static bisq.core.trade.Trade.Phase.WITHDRAWN; import static bisq.core.trade.Trade.State.*; import static java.lang.String.format; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -45,12 +48,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import static protobuf.Offer.State.OFFER_FEE_PAID; +import static protobuf.OfferPayload.Direction.SELL; import static protobuf.OpenOffer.State.AVAILABLE; - - -import bisq.cli.TradeFormat; - @Disabled @Slf4j @TestMethodOrder(MethodOrderer.OrderAnnotation.class) @@ -59,7 +59,7 @@ public class TakeSellBTCOfferTest extends AbstractTradeTest { // Alice is maker/seller, Bob is taker/buyer. // Maker and Taker fees are in BTC. - private static final String TRADE_FEE_CURRENCY_CODE = "btc"; + private static final String TRADE_FEE_CURRENCY_CODE = BTC; private static final String WITHDRAWAL_TX_MEMO = "Bob's trade withdrawal"; @@ -68,10 +68,10 @@ public class TakeSellBTCOfferTest extends AbstractTradeTest { public void testTakeAlicesSellOffer(final TestInfo testInfo) { try { PaymentAccount alicesUsdAccount = createDummyF2FAccount(aliceClient, "US"); - var alicesOffer = aliceClient.createMarketBasedPricedOffer("sell", + var alicesOffer = aliceClient.createMarketBasedPricedOffer(SELL.name(), "usd", - 12500000L, - 12500000L, // min-amount = amount + 12_500_000L, + 12_500_000L, // min-amount = amount 0.00, getDefaultBuyerSecurityDepositAsPercent(), alicesUsdAccount.getId(), @@ -83,7 +83,7 @@ public void testTakeAlicesSellOffer(final TestInfo testInfo) { // Wait times vary; my logs show >= 2 second delay, but taking sell offers // seems to require more time to prepare. sleep(3000); // TODO loop instead of hard code wait time - var alicesUsdOffers = aliceClient.getMyOffersSortedByDate("sell", "usd"); + var alicesUsdOffers = aliceClient.getMyOffersSortedByDate(SELL.name(), "usd"); assertEquals(1, alicesUsdOffers.size()); PaymentAccount bobsUsdAccount = createDummyF2FAccount(bobClient, "US"); @@ -95,18 +95,9 @@ public void testTakeAlicesSellOffer(final TestInfo testInfo) { tradeId = trade.getTradeId(); genBtcBlocksThenWait(1, 4000); - var takeableUsdOffers = bobClient.getOffersSortedByDate("sell", "usd"); + var takeableUsdOffers = bobClient.getOffersSortedByDate(SELL.name(), "usd"); assertEquals(0, takeableUsdOffers.size()); - if (!isLongRunningTest) { - trade = bobClient.getTrade(trade.getTradeId()); - EXPECTED_PROTOCOL_STATUS.setState(BUYER_RECEIVED_DEPOSIT_TX_PUBLISHED_MSG) - .setPhase(DEPOSIT_PUBLISHED) - .setDepositPublished(true); - verifyExpectedProtocolStatus(trade); - logTrade(log, testInfo, "Bob's view after taking offer and sending deposit", trade); - } - genBtcBlocksThenWait(1, 2500); for (int i = 1; i <= maxTradeStateAndPhaseChecks.get(); i++) { @@ -117,14 +108,15 @@ public void testTakeAlicesSellOffer(final TestInfo testInfo) { trade.getShortId(), trade.getDepositTxId(), i); - sleep(5000); + genBtcBlocksThenWait(1, 4000); continue; } else { EXPECTED_PROTOCOL_STATUS.setState(DEPOSIT_CONFIRMED_IN_BLOCK_CHAIN) .setPhase(DEPOSIT_CONFIRMED) + .setDepositPublished(true) .setDepositConfirmed(true); verifyExpectedProtocolStatus(trade); - logTrade(log, testInfo, "Bob's view after deposit is confirmed", trade); + logTrade(log, testInfo, "Bob's view after deposit is confirmed", trade, true); break; } } @@ -265,12 +257,19 @@ public void testBobsBtcWithdrawalToExternalAddress(final TestInfo testInfo) { .setPhase(WITHDRAWN) .setWithdrawn(true); verifyExpectedProtocolStatus(trade); - logTrade(log, testInfo, "Bob's view after withdrawing funds to external wallet", trade); - BtcBalanceInfo currentBalance = bobClient.getBtcBalances(); - log.info("{} Bob's current available balance: {} BTC. Last trade:\n{}", + logTrade(log, testInfo, "Bob's view after withdrawing BTC funds to external wallet", trade); + + logTrade(log, testInfo, "Alice's Maker/Buyer View (Done)", aliceClient.getTrade(tradeId), true); + logTrade(log, testInfo, "Bob's Taker/Seller View (Done)", bobClient.getTrade(tradeId), true); + + var alicesBalances = aliceClient.getBalances(); + log.info("{} Alice's Current Balance:\n{}", + testName(testInfo), + formatBalancesTbls(alicesBalances)); + var bobsBalances = bobClient.getBalances(); + log.info("{} Bob's Current Balance:\n{}", testName(testInfo), - formatSatoshis(currentBalance.getAvailableBalance()), - TradeFormat.format(bobClient.getTrade(tradeId))); + formatBalancesTbls(bobsBalances)); } catch (StatusRuntimeException e) { fail(e); } diff --git a/apitest/src/test/java/bisq/apitest/scenario/OfferTest.java b/apitest/src/test/java/bisq/apitest/scenario/OfferTest.java index c82cbaef90e..cb1173b0143 100644 --- a/apitest/src/test/java/bisq/apitest/scenario/OfferTest.java +++ b/apitest/src/test/java/bisq/apitest/scenario/OfferTest.java @@ -29,6 +29,7 @@ import bisq.apitest.method.offer.AbstractOfferTest; import bisq.apitest.method.offer.CancelOfferTest; +import bisq.apitest.method.offer.CreateBSQOffersTest; import bisq.apitest.method.offer.CreateOfferUsingFixedPriceTest; import bisq.apitest.method.offer.CreateOfferUsingMarketPriceMarginTest; import bisq.apitest.method.offer.ValidateCreateOfferTest; @@ -71,4 +72,18 @@ public void testCreateOfferUsingMarketPriceMargin() { test.testCreateGBPBTCSellOfferMinus1Point5PctPriceMargin(); test.testCreateBRLBTCSellOffer6Point55PctPriceMargin(); } + + @Test + @Order(5) + public void testCreateBSQOffersTest() { + CreateBSQOffersTest test = new CreateBSQOffersTest(); + CreateBSQOffersTest.createBsqPaymentAccounts(); + test.testCreateBuy1BTCFor20KBSQOffer(); + test.testCreateSell1BTCFor20KBSQOffer(); + test.testCreateBuyBTCWith1To2KBSQOffer(); + test.testCreateSellBTCFor5To10KBSQOffer(); + test.testGetAllMyBsqOffers(); + test.testGetAvailableBsqOffers(); + test.testBreakpoint(); + } } diff --git a/apitest/src/test/java/bisq/apitest/scenario/ScriptedBotTest.java b/apitest/src/test/java/bisq/apitest/scenario/ScriptedBotTest.java index fd187638803..98bb987ce47 100644 --- a/apitest/src/test/java/bisq/apitest/scenario/ScriptedBotTest.java +++ b/apitest/src/test/java/bisq/apitest/scenario/ScriptedBotTest.java @@ -29,22 +29,22 @@ import org.junit.jupiter.api.condition.EnabledIf; import static bisq.apitest.Scaffold.BitcoinCoreApp.bitcoind; +import static bisq.apitest.botsupport.shutdown.ManualShutdown.startShutdownTimer; import static bisq.apitest.config.BisqAppConfig.alicedaemon; import static bisq.apitest.config.BisqAppConfig.arbdaemon; import static bisq.apitest.config.BisqAppConfig.bobdaemon; import static bisq.apitest.config.BisqAppConfig.seednode; -import static bisq.apitest.scenario.bot.shutdown.ManualShutdown.startShutdownTimer; import static org.junit.jupiter.api.Assertions.fail; +import bisq.apitest.botsupport.BotClient; +import bisq.apitest.botsupport.script.BashScriptGenerator; +import bisq.apitest.botsupport.shutdown.ManualBotShutdownException; import bisq.apitest.config.ApiTestConfig; import bisq.apitest.method.BitcoinCliHelper; import bisq.apitest.scenario.bot.AbstractBotTest; -import bisq.apitest.scenario.bot.BotClient; import bisq.apitest.scenario.bot.RobotBob; -import bisq.apitest.scenario.bot.script.BashScriptGenerator; -import bisq.apitest.scenario.bot.shutdown.ManualBotShutdownException; // The test case is enabled if AbstractBotTest#botScriptExists() returns true. @EnabledIf("botScriptExists") @@ -76,14 +76,15 @@ public static void startTestHarness() { log.warn("Don't forget to register dispute agents before trying to trade with me."); } - botClient = new BotClient(bobClient); + makerBotClient = new BotClient(bobClient); + takerBotClient = new BotClient(aliceClient); } @BeforeEach public void initRobotBob() { try { BashScriptGenerator bashScriptGenerator = getBashScriptGenerator(); - robotBob = new RobotBob(botClient, botScript, bitcoinCli, bashScriptGenerator); + robotBob = new RobotBob(makerBotClient, botScript, bitcoinCli, bashScriptGenerator); } catch (Exception ex) { fail(ex); } diff --git a/apitest/src/test/java/bisq/apitest/scenario/TradeTest.java b/apitest/src/test/java/bisq/apitest/scenario/TradeTest.java index 4c07452abc6..f4e93ad35ae 100644 --- a/apitest/src/test/java/bisq/apitest/scenario/TradeTest.java +++ b/apitest/src/test/java/bisq/apitest/scenario/TradeTest.java @@ -29,7 +29,9 @@ import bisq.apitest.method.trade.AbstractTradeTest; +import bisq.apitest.method.trade.TakeBuyBSQOfferTest; import bisq.apitest.method.trade.TakeBuyBTCOfferTest; +import bisq.apitest.method.trade.TakeSellBSQOfferTest; import bisq.apitest.method.trade.TakeSellBTCOfferTest; @@ -61,4 +63,26 @@ public void testTakeSellBTCOffer(final TestInfo testInfo) { test.testAlicesConfirmPaymentReceived(testInfo); test.testBobsBtcWithdrawalToExternalAddress(testInfo); } + + @Test + @Order(3) + public void testTakeBuyBSQOffer(final TestInfo testInfo) { + TakeBuyBSQOfferTest test = new TakeBuyBSQOfferTest(); + TakeBuyBSQOfferTest.createBsqPaymentAccounts(); + test.testTakeAlicesSellBTCForBSQOffer(testInfo); + test.testBobsConfirmPaymentStarted(testInfo); + test.testAlicesConfirmPaymentReceived(testInfo); + test.testBobsKeepFunds(testInfo); + } + + @Test + @Order(4) + public void testTakeSellBSQOffer(final TestInfo testInfo) { + TakeSellBSQOfferTest test = new TakeSellBSQOfferTest(); + TakeSellBSQOfferTest.createBsqPaymentAccounts(); + test.testTakeAlicesBuyBTCForBSQOffer(testInfo); + test.testAlicesConfirmPaymentStarted(testInfo); + test.testBobsConfirmPaymentReceived(testInfo); + test.testAlicesBtcWithdrawalToExternalAddress(testInfo); + } } diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/AbstractBotTest.java b/apitest/src/test/java/bisq/apitest/scenario/bot/AbstractBotTest.java index 763dbac9e2f..373ad50f3e6 100644 --- a/apitest/src/test/java/bisq/apitest/scenario/bot/AbstractBotTest.java +++ b/apitest/src/test/java/bisq/apitest/scenario/bot/AbstractBotTest.java @@ -30,6 +30,7 @@ import lombok.extern.slf4j.Slf4j; +import static bisq.apitest.config.BisqAppConfig.alicedaemon; import static bisq.core.locale.CountryUtil.findCountryByCode; import static bisq.core.payment.payload.PaymentMethod.CLEAR_X_CHANGE_ID; import static bisq.core.payment.payload.PaymentMethod.getPaymentMethodById; @@ -39,8 +40,9 @@ +import bisq.apitest.botsupport.BotClient; +import bisq.apitest.botsupport.script.BashScriptGenerator; import bisq.apitest.method.MethodTest; -import bisq.apitest.scenario.bot.script.BashScriptGenerator; import bisq.apitest.scenario.bot.script.BotScript; @Slf4j @@ -48,12 +50,17 @@ public abstract class AbstractBotTest extends MethodTest { protected static final String BOT_SCRIPT_NAME = "bot-script.json"; protected static BotScript botScript; - protected static BotClient botClient; + protected static BotClient makerBotClient; + protected static BotClient takerBotClient; protected BashScriptGenerator getBashScriptGenerator() { if (botScript.isUseTestHarness()) { - PaymentAccount alicesAccount = createAlicesPaymentAccount(); - botScript.setPaymentAccountIdForCliScripts(alicesAccount.getId()); + if (config.supportingApps.contains(alicedaemon.name())) { + PaymentAccount alicesAccount = createAlicesPaymentAccount(); + botScript.setPaymentAccountIdForCliScripts(alicesAccount.getId()); + } else { + botScript.setPaymentAccountIdForCliScripts("Alice is using Desktop UI"); + } } return new BashScriptGenerator(config.apiPassword, botScript.getApiPortForCliScripts(), diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/Bot.java b/apitest/src/test/java/bisq/apitest/scenario/bot/Bot.java index 2e8a248a4c3..cde1b831916 100644 --- a/apitest/src/test/java/bisq/apitest/scenario/bot/Bot.java +++ b/apitest/src/test/java/bisq/apitest/scenario/bot/Bot.java @@ -1,31 +1,32 @@ package bisq.apitest.scenario.bot; import bisq.core.locale.Country; +import bisq.core.locale.CountryUtil; +import bisq.core.payment.payload.PaymentMethod; import protobuf.PaymentAccount; import lombok.extern.slf4j.Slf4j; -import static bisq.core.locale.CountryUtil.findCountryByCode; -import static bisq.core.payment.payload.PaymentMethod.CLEAR_X_CHANGE_ID; -import static bisq.core.payment.payload.PaymentMethod.getPaymentMethodById; -import static java.lang.String.format; import static java.util.concurrent.TimeUnit.MINUTES; +import bisq.apitest.botsupport.BotClient; +import bisq.apitest.botsupport.script.BashScriptGenerator; import bisq.apitest.method.BitcoinCliHelper; -import bisq.apitest.scenario.bot.script.BashScriptGenerator; import bisq.apitest.scenario.bot.script.BotScript; +// TODO Create a bot superclass in CLI, stripped of all the test harness references. +// This not for the CLI, regtest/apitest only. + @Slf4j -public -class Bot { +public class Bot { static final String MAKE = "MAKE"; static final String TAKE = "TAKE"; - protected final BotClient botClient; + protected final BotClient makerBotClient; protected final BitcoinCliHelper bitcoinCli; protected final BashScriptGenerator bashScriptGenerator; protected final String[] actions; @@ -38,7 +39,7 @@ public Bot(BotClient botClient, BotScript botScript, BitcoinCliHelper bitcoinCli, BashScriptGenerator bashScriptGenerator) { - this.botClient = botClient; + this.makerBotClient = botClient; this.bitcoinCli = bitcoinCli; this.bashScriptGenerator = bashScriptGenerator; this.actions = botScript.getActions(); @@ -48,30 +49,27 @@ public Bot(BotClient botClient, if (isUsingTestHarness) this.paymentAccount = createBotPaymentAccount(botScript); else - this.paymentAccount = botClient.getPaymentAccount(botScript.getPaymentAccountIdForBot()); + this.paymentAccount = this.makerBotClient.getPaymentAccount(botScript.getPaymentAccountIdForBot()); } private PaymentAccount createBotPaymentAccount(BotScript botScript) { - BotPaymentAccountGenerator accountGenerator = new BotPaymentAccountGenerator(botClient); + BotPaymentAccountGenerator accountGenerator = new BotPaymentAccountGenerator(makerBotClient); String paymentMethodId = botScript.getBotPaymentMethodId(); if (paymentMethodId != null) { - if (paymentMethodId.equals(CLEAR_X_CHANGE_ID)) { + if (paymentMethodId.equals(PaymentMethod.CLEAR_X_CHANGE_ID)) { return accountGenerator.createZellePaymentAccount("Bob's Zelle Account", "Bob"); } else { throw new UnsupportedOperationException( - format("This bot test does not work with %s payment accounts yet.", - getPaymentMethodById(paymentMethodId).getDisplayString())); + String.format("This bot test does not work with %s payment accounts yet.", + PaymentMethod.getPaymentMethodById(paymentMethodId).getDisplayString())); } } else { - Country country = findCountry(botScript.getCountryCode()); + String countryCode = botScript.getCountryCode(); + Country country = CountryUtil.findCountryByCode(countryCode).orElseThrow(() -> + new IllegalArgumentException(countryCode + " is not a valid iso country code.")); return accountGenerator.createF2FPaymentAccount(country, country.name + " F2F Account"); } } - - private Country findCountry(String countryCode) { - return findCountryByCode(countryCode).orElseThrow(() -> - new IllegalArgumentException(countryCode + " is not a valid iso country code.")); - } } diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/BotClient.java b/apitest/src/test/java/bisq/apitest/scenario/bot/BotClient.java deleted file mode 100644 index c34dc14d28b..00000000000 --- a/apitest/src/test/java/bisq/apitest/scenario/bot/BotClient.java +++ /dev/null @@ -1,339 +0,0 @@ -/* - * This file is part of Bisq. - * - * Bisq is free software: you can redistribute it and/or modify it - * under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or (at - * your option) any later version. - * - * Bisq is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public - * License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with Bisq. If not, see . - */ - -package bisq.apitest.scenario.bot; - -import bisq.proto.grpc.BalancesInfo; -import bisq.proto.grpc.GetPaymentAccountsRequest; -import bisq.proto.grpc.OfferInfo; -import bisq.proto.grpc.TradeInfo; - -import protobuf.PaymentAccount; - -import java.text.DecimalFormat; - -import java.util.List; -import java.util.function.BiPredicate; - -import lombok.extern.slf4j.Slf4j; - -import static org.apache.commons.lang3.StringUtils.capitalize; - - - -import bisq.cli.GrpcClient; - -/** - * Convenience GrpcClient wrapper for bots using gRPC services. - * - * TODO Consider if the duplication smell is bad enough to force a BotClient user - * to use the GrpcClient instead (and delete this class). But right now, I think it is - * OK because moving some of the non-gRPC related methods to GrpcClient is even smellier. - * - */ -@SuppressWarnings({"JavaDoc", "unused"}) -@Slf4j -public class BotClient { - - private static final DecimalFormat FIXED_PRICE_FMT = new DecimalFormat("###########0"); - - private final GrpcClient grpcClient; - - public BotClient(GrpcClient grpcClient) { - this.grpcClient = grpcClient; - } - - /** - * Returns current BSQ and BTC balance information. - * @return BalancesInfo - */ - public BalancesInfo getBalance() { - return grpcClient.getBalances(); - } - - /** - * Return the most recent BTC market price for the given currencyCode. - * @param currencyCode - * @return double - */ - public double getCurrentBTCMarketPrice(String currencyCode) { - return grpcClient.getBtcPrice(currencyCode); - } - - /** - * Return the most recent BTC market price for the given currencyCode as an integer string. - * @param currencyCode - * @return String - */ - public String getCurrentBTCMarketPriceAsIntegerString(String currencyCode) { - return FIXED_PRICE_FMT.format(getCurrentBTCMarketPrice(currencyCode)); - } - - /** - * Return all BUY and SELL offers for the given currencyCode. - * @param currencyCode - * @return List - */ - public List getOffers(String currencyCode) { - var buyOffers = getBuyOffers(currencyCode); - if (buyOffers.size() > 0) { - return buyOffers; - } else { - return getSellOffers(currencyCode); - } - } - - /** - * Return BUY offers for the given currencyCode. - * @param currencyCode - * @return List - */ - public List getBuyOffers(String currencyCode) { - return grpcClient.getOffers("BUY", currencyCode); - } - - /** - * Return SELL offers for the given currencyCode. - * @param currencyCode - * @return List - */ - public List getSellOffers(String currencyCode) { - return grpcClient.getOffers("SELL", currencyCode); - } - - /** - * Create and return a new Offer using a market based price. - * @param paymentAccount - * @param direction - * @param currencyCode - * @param amountInSatoshis - * @param minAmountInSatoshis - * @param priceMarginAsPercent - * @param securityDepositAsPercent - * @param feeCurrency - * @return OfferInfo - */ - public OfferInfo createOfferAtMarketBasedPrice(PaymentAccount paymentAccount, - String direction, - String currencyCode, - long amountInSatoshis, - long minAmountInSatoshis, - double priceMarginAsPercent, - double securityDepositAsPercent, - String feeCurrency) { - return grpcClient.createMarketBasedPricedOffer(direction, - currencyCode, - amountInSatoshis, - minAmountInSatoshis, - priceMarginAsPercent, - securityDepositAsPercent, - paymentAccount.getId(), - feeCurrency); - } - - /** - * Create and return a new Offer using a fixed price. - * @param paymentAccount - * @param direction - * @param currencyCode - * @param amountInSatoshis - * @param minAmountInSatoshis - * @param fixedOfferPriceAsString - * @param securityDepositAsPercent - * @param feeCurrency - * @return OfferInfo - */ - public OfferInfo createOfferAtFixedPrice(PaymentAccount paymentAccount, - String direction, - String currencyCode, - long amountInSatoshis, - long minAmountInSatoshis, - String fixedOfferPriceAsString, - double securityDepositAsPercent, - String feeCurrency) { - return grpcClient.createFixedPricedOffer(direction, - currencyCode, - amountInSatoshis, - minAmountInSatoshis, - fixedOfferPriceAsString, - securityDepositAsPercent, - paymentAccount.getId(), - feeCurrency); - } - - public TradeInfo takeOffer(String offerId, PaymentAccount paymentAccount, String feeCurrency) { - return grpcClient.takeOffer(offerId, paymentAccount.getId(), feeCurrency); - } - - /** - * Returns a persisted Trade with the given tradeId, or throws an exception. - * @param tradeId - * @return TradeInfo - */ - public TradeInfo getTrade(String tradeId) { - return grpcClient.getTrade(tradeId); - } - - /** - * Predicate returns true if the given exception indicates the trade with the given - * tradeId exists, but the trade's contract has not been fully prepared. - */ - public final BiPredicate tradeContractIsNotReady = (exception, tradeId) -> { - if (exception.getMessage().contains("no contract was found")) { - log.warn("Trade {} exists but is not fully prepared: {}.", - tradeId, - toCleanGrpcExceptionMessage(exception)); - return true; - } else { - return false; - } - }; - - /** - * Returns a trade's contract as a Json string, or null if the trade exists - * but the contract is not ready. - * @param tradeId - * @return String - */ - public String getTradeContract(String tradeId) { - try { - var trade = grpcClient.getTrade(tradeId); - return trade.getContractAsJson(); - } catch (Exception ex) { - if (tradeContractIsNotReady.test(ex, tradeId)) - return null; - else - throw ex; - } - } - - /** - * Returns true if the trade's taker deposit fee transaction has been published. - * @param tradeId a valid trade id - * @return boolean - */ - public boolean isTakerDepositFeeTxPublished(String tradeId) { - return grpcClient.getTrade(tradeId).getIsPayoutPublished(); - } - - /** - * Returns true if the trade's taker deposit fee transaction has been confirmed. - * @param tradeId a valid trade id - * @return boolean - */ - public boolean isTakerDepositFeeTxConfirmed(String tradeId) { - return grpcClient.getTrade(tradeId).getIsDepositConfirmed(); - } - - /** - * Returns true if the trade's 'start payment' message has been sent by the buyer. - * @param tradeId a valid trade id - * @return boolean - */ - public boolean isTradePaymentStartedSent(String tradeId) { - return grpcClient.getTrade(tradeId).getIsFiatSent(); - } - - /** - * Returns true if the trade's 'payment received' message has been sent by the seller. - * @param tradeId a valid trade id - * @return boolean - */ - public boolean isTradePaymentReceivedConfirmationSent(String tradeId) { - return grpcClient.getTrade(tradeId).getIsFiatReceived(); - } - - /** - * Returns true if the trade's payout transaction has been published. - * @param tradeId a valid trade id - * @return boolean - */ - public boolean isTradePayoutTxPublished(String tradeId) { - return grpcClient.getTrade(tradeId).getIsPayoutPublished(); - } - - /** - * Sends a 'confirm payment started message' for a trade with the given tradeId, - * or throws an exception. - * @param tradeId - */ - public void sendConfirmPaymentStartedMessage(String tradeId) { - grpcClient.confirmPaymentStarted(tradeId); - } - - /** - * Sends a 'confirm payment received message' for a trade with the given tradeId, - * or throws an exception. - * @param tradeId - */ - public void sendConfirmPaymentReceivedMessage(String tradeId) { - grpcClient.confirmPaymentReceived(tradeId); - } - - /** - * Sends a 'keep funds in wallet message' for a trade with the given tradeId, - * or throws an exception. - * @param tradeId - */ - public void sendKeepFundsMessage(String tradeId) { - grpcClient.keepFunds(tradeId); - } - - /** - * Create and save a new PaymentAccount with details in the given json. - * @param json - * @return PaymentAccount - */ - public PaymentAccount createNewPaymentAccount(String json) { - return grpcClient.createPaymentAccount(json); - } - - /** - * Returns a persisted PaymentAccount with the given paymentAccountId, or throws - * an exception. - * @param paymentAccountId The id of the PaymentAccount being looked up. - * @return PaymentAccount - */ - public PaymentAccount getPaymentAccount(String paymentAccountId) { - return grpcClient.getPaymentAccounts().stream() - .filter(a -> (a.getId().equals(paymentAccountId))) - .findFirst() - .orElseThrow(() -> - new PaymentAccountNotFoundException("Could not find a payment account with id " - + paymentAccountId + ".")); - } - - /** - * Returns a persisted PaymentAccount with the given accountName, or throws - * an exception. - * @param accountName - * @return PaymentAccount - */ - public PaymentAccount getPaymentAccountWithName(String accountName) { - var req = GetPaymentAccountsRequest.newBuilder().build(); - return grpcClient.getPaymentAccounts().stream() - .filter(a -> (a.getAccountName().equals(accountName))) - .findFirst() - .orElseThrow(() -> - new PaymentAccountNotFoundException("Could not find a payment account with name " - + accountName + ".")); - } - - public String toCleanGrpcExceptionMessage(Exception ex) { - return capitalize(ex.getMessage().replaceFirst("^[A-Z_]+: ", "")); - } -} diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/BotPaymentAccountGenerator.java b/apitest/src/test/java/bisq/apitest/scenario/bot/BotPaymentAccountGenerator.java index e586c3236af..9a6628e5e56 100644 --- a/apitest/src/test/java/bisq/apitest/scenario/bot/BotPaymentAccountGenerator.java +++ b/apitest/src/test/java/bisq/apitest/scenario/bot/BotPaymentAccountGenerator.java @@ -17,6 +17,12 @@ import static bisq.core.payment.payload.PaymentMethod.CLEAR_X_CHANGE_ID; import static bisq.core.payment.payload.PaymentMethod.F2F_ID; + + +import bisq.apitest.botsupport.BotClient; +import bisq.apitest.botsupport.PaymentAccountNotFoundException; + + @Slf4j public class BotPaymentAccountGenerator { diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/MarketMakerBotTest.java b/apitest/src/test/java/bisq/apitest/scenario/bot/MarketMakerBotTest.java new file mode 100644 index 00000000000..d3a7b584174 --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/scenario/bot/MarketMakerBotTest.java @@ -0,0 +1,126 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.scenario.bot; + +import lombok.extern.slf4j.Slf4j; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.junit.jupiter.api.condition.EnabledIf; + +import static bisq.apitest.Scaffold.BitcoinCoreApp.bitcoind; +import static bisq.apitest.botsupport.shutdown.ManualShutdown.startShutdownTimer; +import static bisq.apitest.config.BisqAppConfig.alicedaemon; +import static bisq.apitest.config.BisqAppConfig.arbdaemon; +import static bisq.apitest.config.BisqAppConfig.bobdaemon; +import static bisq.apitest.config.BisqAppConfig.seednode; +import static org.junit.jupiter.api.Assertions.fail; + + + +import bisq.apitest.botsupport.BotClient; +import bisq.apitest.botsupport.script.BashScriptGenerator; +import bisq.apitest.botsupport.shutdown.ManualBotShutdownException; + +@EnabledIf("botScriptExists") +@Slf4j +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class MarketMakerBotTest extends AbstractBotTest { + + private RobotBobMMBot robotBobMM; + + @BeforeAll + public static void startTestHarness() { + botScript = deserializeBotScript(); + + startSupportingApps(true, + true, + bitcoind, + seednode, + arbdaemon, + alicedaemon, + bobdaemon); + + makerBotClient = new BotClient(bobClient); + takerBotClient = new BotClient(aliceClient); + } + + @BeforeEach + public void initRobotBob() { + try { + BashScriptGenerator bashScriptGenerator = getBashScriptGenerator(); + + robotBobMM = new RobotBobMMBot(makerBotClient, + takerBotClient, + botScript, + bitcoinCli, + bashScriptGenerator); + } catch (Exception ex) { + fail(ex); + } + } + + @Test + @Order(1) + public void runRobotBob() { + try { + startShutdownTimer(); + + log.info("Bob's Initial Bank Balance: {}", robotBobMM.getBobsBankBalance().get()); + + robotBobMM.run(); + + // A controlled bot shutdown is always desired in the event of a fatal error. + // Check RobotBob's bot exception fields, and fail the test if one or more + // is not null. + if (robotBobMM.botDidFail()) + fail(robotBobMM.getBotFailureReason()); + + log.info("Bob's Final Bank Balance: {}", robotBobMM.getBobsBankBalance().get()); + + } catch (ManualBotShutdownException ex) { + // This exception is thrown if a /tmp/bottest-shutdown file was found. + // You can also kill -15 + // of worker.org.gradle.process.internal.worker.GradleWorkerMain 'Gradle Test Executor #' + // + // This will cleanly shut everything down as well, but you will see a + // Process 'Gradle Test Executor #' finished with non-zero exit value 143 error, + // which you may think is a test failure. + log.warn("{} Shutting down test case before test completion;" + + " this is not a test failure.", + ex.getMessage()); + } catch (Throwable t) { + if (robotBobMM.botDidFail()) { + fail(robotBobMM.getBotFailureReason()); + } else { + log.error("Uncontrolled bot shutdown caused by uncaught bot exception:"); + fail(t); + } + } + } + + @AfterAll + public static void tearDown() { + tearDownScaffold(); + } +} diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/RandomOffer.java b/apitest/src/test/java/bisq/apitest/scenario/bot/RandomOffer.java index 1942f8ad073..9ebd92ebc37 100644 --- a/apitest/src/test/java/bisq/apitest/scenario/bot/RandomOffer.java +++ b/apitest/src/test/java/bisq/apitest/scenario/bot/RandomOffer.java @@ -37,10 +37,16 @@ import static bisq.cli.CurrencyFormat.formatSatoshis; import static bisq.common.util.MathUtils.scaleDownByPowerOf10; import static bisq.core.btc.wallet.Restrictions.getDefaultBuyerSecurityDepositAsPercent; +import static bisq.core.offer.OfferPayload.Direction.BUY; +import static bisq.core.offer.OfferPayload.Direction.SELL; import static bisq.core.payment.payload.PaymentMethod.F2F_ID; import static java.lang.String.format; import static java.math.RoundingMode.HALF_UP; + + +import bisq.apitest.botsupport.BotClient; + @Slf4j public class RandomOffer { private static final SecureRandom RANDOM = new SecureRandom(); @@ -108,7 +114,7 @@ public class RandomOffer { public RandomOffer(BotClient botClient, PaymentAccount paymentAccount) { this.botClient = botClient; this.paymentAccount = paymentAccount; - this.direction = RANDOM.nextBoolean() ? "BUY" : "SELL"; + this.direction = RANDOM.nextBoolean() ? BUY.name() : SELL.name(); this.currencyCode = Objects.requireNonNull(paymentAccount.getSelectedTradeCurrency()).getCode(); this.amount = nextAmount.get(); this.minAmount = nextMinAmount.get(); @@ -154,7 +160,7 @@ private void printDescription() { double currentMarketPrice = botClient.getCurrentBTCMarketPrice(currencyCode); // Calculate a fixed price based on the random mkt price margin, even if we don't use it. double differenceFromMarketPrice = currentMarketPrice * scaleDownByPowerOf10(priceMargin, 2); - double fixedOfferPriceAsDouble = direction.equals("BUY") + double fixedOfferPriceAsDouble = direction.equals(BUY.name()) ? currentMarketPrice - differenceFromMarketPrice : currentMarketPrice + differenceFromMarketPrice; this.fixedOfferPrice = FIXED_PRICE_FMT.format(fixedOfferPriceAsDouble); diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/RobotBob.java b/apitest/src/test/java/bisq/apitest/scenario/bot/RobotBob.java index d81f385a2ba..bf335ff83b8 100644 --- a/apitest/src/test/java/bisq/apitest/scenario/bot/RobotBob.java +++ b/apitest/src/test/java/bisq/apitest/scenario/bot/RobotBob.java @@ -20,48 +20,49 @@ import lombok.Getter; import lombok.extern.slf4j.Slf4j; -import static bisq.apitest.scenario.bot.protocol.ProtocolStep.DONE; -import static bisq.apitest.scenario.bot.shutdown.ManualShutdown.isShutdownCalled; +import static bisq.apitest.botsupport.protocol.ProtocolStep.DONE; +import static bisq.apitest.botsupport.shutdown.ManualShutdown.isShutdownCalled; import static bisq.cli.TableFormat.formatBalancesTbls; import static java.util.concurrent.TimeUnit.SECONDS; +import bisq.apitest.botsupport.BotClient; +import bisq.apitest.botsupport.script.BashScriptGenerator; +import bisq.apitest.botsupport.shutdown.ManualBotShutdownException; import bisq.apitest.method.BitcoinCliHelper; -import bisq.apitest.scenario.bot.protocol.BotProtocol; -import bisq.apitest.scenario.bot.protocol.MakerBotProtocol; -import bisq.apitest.scenario.bot.protocol.TakerBotProtocol; -import bisq.apitest.scenario.bot.script.BashScriptGenerator; +import bisq.apitest.scenario.bot.protocol.ApiTestBotProtocol; +import bisq.apitest.scenario.bot.protocol.ApiTestMakerBotProtocol; +import bisq.apitest.scenario.bot.protocol.ApiTestTakerBotProtocol; import bisq.apitest.scenario.bot.script.BotScript; -import bisq.apitest.scenario.bot.shutdown.ManualBotShutdownException; + @Slf4j -public -class RobotBob extends Bot { +public class RobotBob extends Bot { @Getter private int numTrades; - public RobotBob(BotClient botClient, + public RobotBob(BotClient makerBotClient, BotScript botScript, BitcoinCliHelper bitcoinCli, BashScriptGenerator bashScriptGenerator) { - super(botClient, botScript, bitcoinCli, bashScriptGenerator); + super(makerBotClient, botScript, bitcoinCli, bashScriptGenerator); } public void run() { for (String action : actions) { checkActionIsValid(action); - BotProtocol botProtocol; + ApiTestBotProtocol botProtocol; if (action.equalsIgnoreCase(MAKE)) { - botProtocol = new MakerBotProtocol(botClient, + botProtocol = new ApiTestMakerBotProtocol(makerBotClient, paymentAccount, protocolStepTimeLimitInMs, bitcoinCli, bashScriptGenerator); } else { - botProtocol = new TakerBotProtocol(botClient, + botProtocol = new ApiTestTakerBotProtocol(makerBotClient, paymentAccount, protocolStepTimeLimitInMs, bitcoinCli, @@ -77,7 +78,7 @@ public void run() { log.info("Completed {} successful trade{}. Current Balance:\n{}", ++numTrades, numTrades == 1 ? "" : "s", - formatBalancesTbls(botClient.getBalance())); + formatBalancesTbls(makerBotClient.getBalance())); if (numTrades < actions.length) { try { diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/RobotBobMMBot.java b/apitest/src/test/java/bisq/apitest/scenario/bot/RobotBobMMBot.java new file mode 100644 index 00000000000..0cedc1b826f --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/scenario/bot/RobotBobMMBot.java @@ -0,0 +1,447 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.scenario.bot; + +import bisq.common.Timer; +import bisq.common.UserThread; +import bisq.common.util.Utilities; + +import protobuf.PaymentAccount; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; +import java.util.function.Predicate; + +import lombok.Getter; +import lombok.Setter; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; + +import javax.annotation.Nullable; + +import static bisq.apitest.botsupport.shutdown.ManualShutdown.isShutdownCalled; +import static bisq.apitest.botsupport.shutdown.ManualShutdown.setShutdownCalled; +import static bisq.cli.TableFormat.formatBalancesTbls; +import static bisq.core.offer.OfferPayload.Direction.BUY; +import static bisq.core.offer.OfferPayload.Direction.SELL; +import static java.lang.String.format; +import static java.util.Objects.requireNonNull; +import static java.util.concurrent.TimeUnit.SECONDS; + + + +import bisq.apitest.botsupport.BotClient; +import bisq.apitest.botsupport.script.BashScriptGenerator; +import bisq.apitest.botsupport.shutdown.ManualBotShutdownException; +import bisq.apitest.method.BitcoinCliHelper; +import bisq.apitest.scenario.bot.protocol.ApiTestBotProtocol; +import bisq.apitest.scenario.bot.protocol.ApiTestMarketMakerBotProtocol; +import bisq.apitest.scenario.bot.protocol.MarketMakerTakeOnlyBotProtocol; +import bisq.apitest.scenario.bot.script.BotScript; + + +@SuppressWarnings("NullableProblems") +@Slf4j +public class RobotBobMMBot extends Bot { + + private static final int MAX_BUY_OFFERS = 500; + private static final int MAX_SELL_OFFERS = 500; + + // Show wallet balances after every N trades. + private static final int SHOW_WALLET_BALANCE_MARKER = 20; + + private static final String BUYER_BOT_NAME = "Maker/Buyer Bot"; + private static final String SELLER_BOT_NAME = "Maker/Seller Bot"; + private static final String TAKER_BOT_NAME = "Taker Bot"; + + @Nullable + @Setter + @Getter + private Exception buyerBotException; + @Nullable + @Setter + @Getter + private Exception sellerBotException; + @Nullable + @Setter + @Getter + private Exception takerBotException; + + private final AtomicBoolean isBuyBotShutdown = new AtomicBoolean(false); + private final AtomicBoolean isSellBotShutdown = new AtomicBoolean(false); + private final AtomicBoolean isTakerBotShutdown = new AtomicBoolean(false); + + private int numMakerSideBuys = 0; + private int numMakerSideSells = 0; + private int numTakerSideTrades = 0; + + private Timer btcBlockGenerator; + + @Getter + private final AtomicLong bobsBankBalance = new AtomicLong(100_000); + @Getter + private final AtomicLong takersBankBalance = new AtomicLong(100_000); + + private final BotClient takerBotClient; + @Getter + private final String takerBotPaymentAccountId; + + private final ListeningExecutorService executor = + Utilities.getListeningExecutorService("Market Maker", + 3, + 3, + 24 * 60 * 60); + + public RobotBobMMBot(BotClient makerBotClient, + BotClient takerBotClient, + BotScript botScript, + BitcoinCliHelper bitcoinCli, + BashScriptGenerator bashScriptGenerator) { + super(makerBotClient, botScript, bitcoinCli, bashScriptGenerator); + this.takerBotClient = takerBotClient; + this.takerBotPaymentAccountId = botScript.getPaymentAccountIdForCliScripts(); + } + + public void run() { + btcBlockGenerator = UserThread.runPeriodically(() -> { + String btcCoreAddress = bitcoinCli.getNewBtcAddress(); + log.info("Generating BTC block to address {}.", btcCoreAddress); + bitcoinCli.generateToAddress(1, btcCoreAddress); + }, 20, SECONDS); + + startBot(buyMakerBot, + makerBotClient, + BUYER_BOT_NAME); + rest(15); + + // Do not start another bot if the 1st one is already shutting down. + if (!isShutdownCalled()) { + startBot(takerBot, + takerBotClient, + TAKER_BOT_NAME); + rest(5); + } + + if (!isShutdownCalled()) { + startBot(sellMakerBot, + makerBotClient, + SELLER_BOT_NAME); + } + + if (stayAlive) + waitForManualShutdown(); + else + warnCLIUserBeforeShutdown(); + } + + public void shutdownAllBots() { + isBuyBotShutdown.set(true); + isSellBotShutdown.set(true); + isTakerBotShutdown.set(true); + setShutdownCalled(true); + btcBlockGenerator.stop(); + executor.shutdownNow(); + } + + public void startBot(Consumer bot, + BotClient botClient, + String botName) { + try { + log.info("Starting {}", botName); + @SuppressWarnings({"unchecked"}) + ListenableFuture future = + (ListenableFuture) executor.submit(() -> bot.accept(botClient)); + Futures.addCallback(future, new FutureCallback<>() { + @Override + public void onSuccess(@Nullable Void ignored) { + // 'Success' means a controlled shutdown that might be caused by an + // error. The test case should only fail if the shutdown was caused + // by and exception. + log.info("{} shutdown.", botName); + } + + @SneakyThrows + @Override + public void onFailure(Throwable t) { + if (t instanceof ManualBotShutdownException) { + log.warn("Manually shutting down {} thread.", botName); + } else { + log.error("Fatal error during {} run.", botName, t); + } + shutdownAllBots(); + } + }, MoreExecutors.directExecutor()); + + } catch (Exception ex) { + log.error("", ex); + throw new IllegalStateException(format("Error starting %s.", botName), ex); + } + } + + public final Predicate shouldLogWalletBalance = (i) -> i % SHOW_WALLET_BALANCE_MARKER == 0; + + public final Consumer buyMakerBot = (botClient) -> { + try { + while (numMakerSideBuys < MAX_BUY_OFFERS) { + var offersExist = botClient.iHaveCurrentOffersWithDirection.test(BUY.name(), + requireNonNull(paymentAccount.getSelectedTradeCurrency()).getCode()); + if (offersExist) { + logOfferAlreadyExistsWarning(BUYER_BOT_NAME, BUY.name()); + } else { + ApiTestBotProtocol botProtocol = new ApiTestMarketMakerBotProtocol(botClient, + paymentAccount, + protocolStepTimeLimitInMs, + bitcoinCli, + bashScriptGenerator, + BUY.name(), + bobsBankBalance); + botProtocol.run(); + numMakerSideBuys++; + logTradingProgress(BUYER_BOT_NAME, BUY.name(), bobsBankBalance); + if (shouldLogWalletBalance.test(numMakerSideBuys)) + logWalletBalance(BUYER_BOT_NAME, botClient); + } + rest(20); + } + } catch (ManualBotShutdownException ex) { + logManualShutdownWarning(BUYER_BOT_NAME); + shutdownAllBots(); + // Exit the function, do not try to get balances below because the + // server may be shutting down. + return; + } catch (Exception ex) { + logFailedTradeError(BUYER_BOT_NAME, ex); + shutdownAllBots(); + // Fatal error, do not try to get balances below because server is shutting down. + this.setBuyerBotException(ex); + return; + } + logBotCompletion(BUYER_BOT_NAME, botClient, bobsBankBalance); + isBuyBotShutdown.set(true); + }; + + public final Consumer sellMakerBot = (botClient) -> { + try { + while (numMakerSideSells < MAX_SELL_OFFERS) { + var offersExist = botClient.iHaveCurrentOffersWithDirection.test(SELL.name(), + requireNonNull(paymentAccount.getSelectedTradeCurrency()).getCode()); + if (offersExist) { + logOfferAlreadyExistsWarning(SELLER_BOT_NAME, SELL.name()); + } else { + ApiTestBotProtocol botProtocol = new ApiTestMarketMakerBotProtocol(botClient, + paymentAccount, + protocolStepTimeLimitInMs, + bitcoinCli, + bashScriptGenerator, + SELL.name(), + bobsBankBalance); + botProtocol.run(); + numMakerSideSells++; + logTradingProgress(SELLER_BOT_NAME, SELL.name(), bobsBankBalance); + if (shouldLogWalletBalance.test(numMakerSideSells)) + logWalletBalance(SELLER_BOT_NAME, botClient); + } + rest(20); + } + } catch (ManualBotShutdownException ex) { + logManualShutdownWarning(SELLER_BOT_NAME); + shutdownAllBots(); + // Exit the function, do not try to get balances below because the + // server may be shutting down. + return; + } catch (Exception ex) { + logFailedTradeError(SELLER_BOT_NAME, ex); + shutdownAllBots(); + // Fatal error, do not try to get balances below because server is shutting down. + this.setSellerBotException(ex); + return; + } + logBotCompletion(SELLER_BOT_NAME, botClient, bobsBankBalance); + isSellBotShutdown.set(true); + }; + + public final Consumer takerBot = (botClient) -> { + PaymentAccount takerPaymentAccount = botClient.getPaymentAccount(this.getTakerBotPaymentAccountId()); + // Keep taking offers until max offers is reached, or if any maker bot is running. + while (takerShouldStayAlive()) { + try { + ApiTestBotProtocol botProtocol = new MarketMakerTakeOnlyBotProtocol(botClient, + takerPaymentAccount, + protocolStepTimeLimitInMs, + bitcoinCli, + bashScriptGenerator, + takersBankBalance); + botProtocol.run(); + numTakerSideTrades++; + } catch (ManualBotShutdownException ex) { + logManualShutdownWarning(TAKER_BOT_NAME); + shutdownAllBots(); + // Exit the function, do not try to get balances below because the + // server may be shutting down. + return; + } catch (Exception ex) { + logFailedTradeError(TAKER_BOT_NAME, ex); + shutdownAllBots(); + // Fatal error, do not try to get balances below because server is shutting down. + this.setTakerBotException(ex); + return; + } + logTradingProgress(TAKER_BOT_NAME, null, takersBankBalance); + if (shouldLogWalletBalance.test(numTakerSideTrades)) + logWalletBalance(TAKER_BOT_NAME, botClient); + + rest(20); + } + logBotCompletion(TAKER_BOT_NAME, botClient, takersBankBalance); + isTakerBotShutdown.set(true); + }; + + public boolean takerShouldStayAlive() { + if (numTakerSideTrades >= (MAX_BUY_OFFERS + MAX_SELL_OFFERS)) + return false; + + if (isTakerBotShutdown.get()) + return false; + + return !isBuyBotShutdown.get() || !isSellBotShutdown.get(); + } + + public boolean botDidFail() { + return buyerBotException != null || sellerBotException != null || takerBotException != null; + } + + public String getBotFailureReason() { + StringBuilder reasonBuilder = new StringBuilder(); + + if (buyerBotException != null) + reasonBuilder.append(BUYER_BOT_NAME).append(" failed: ") + .append(buyerBotException.getMessage()).append("\n"); + + if (sellerBotException != null) + reasonBuilder.append(SELLER_BOT_NAME).append(" failed: ") + .append(sellerBotException.getMessage()).append("\n"); + + if (takerBotException != null) + reasonBuilder.append(TAKER_BOT_NAME).append(" failed: ") + .append(takerBotException.getMessage()).append("\n"); + + return reasonBuilder.toString(); + } + + protected void waitForManualShutdown() { + String harnessOrCase = isUsingTestHarness ? "harness" : "case"; + log.info("The test {} will stay alive until a /tmp/bottest-shutdown file is detected.", + harnessOrCase); + log.info("When ready to shutdown the test {}, run '$ touch /tmp/bottest-shutdown'.", + harnessOrCase); + if (!isUsingTestHarness) { + log.warn("You will have to manually shutdown the bitcoind and Bisq nodes" + + " running outside of the test harness."); + } + try { + while (!isShutdownCalled()) { + rest(10); + } + log.warn("Manual shutdown signal received."); + } catch (ManualBotShutdownException ex) { + log.warn(ex.getMessage()); + } finally { + btcBlockGenerator.stop(); + } + } + + protected void warnCLIUserBeforeShutdown() { + if (isUsingTestHarness) { + while (!isBuyBotShutdown.get() || !isSellBotShutdown.get() || !isTakerBotShutdown.get()) { + try { + SECONDS.sleep(5); + } catch (InterruptedException ignored) { + // empty + } + } + long delayInSeconds = 5; + log.warn("You have {} seconds to complete any remaining tasks before the test harness shuts down.", + delayInSeconds); + rest(delayInSeconds); + } else { + log.info("Shutting down test case"); + } + btcBlockGenerator.stop(); + } + + protected void rest(long delayInSeconds) { + try { + SECONDS.sleep(delayInSeconds); + } catch (InterruptedException ignored) { + // empty + } + } + + protected void logOfferAlreadyExistsWarning(String botName, String direction) { + log.warn("{} will not create a new {} while existing offer is waiting to be taken.", + botName, + direction); + } + + protected void logTradingProgress(String botName, String direction, AtomicLong bankBalance) { + log.info("==================================================================================================="); + if (direction == null || direction.isEmpty()) + log.info("{} completed {} trades. Bank Balance: {}", + botName, + numTakerSideTrades, + bankBalance.get()); + else + log.info("{} completed {} {} trades. Bank Balance After {} BUY and {} SELL trades: {}", + botName, + direction.equals(BUY.name()) ? numMakerSideBuys : numMakerSideSells, + direction, + numMakerSideBuys, + numMakerSideSells, + bankBalance.get()); + log.info("==================================================================================================="); + } + + protected void logManualShutdownWarning(String botName) { + log.warn("Manual shutdown called, stopping {}.", botName); + } + + protected void logFailedTradeError(String botName, Exception exception) { + log.error("{} could not complete trade # {}.", + botName, + numTakerSideTrades, + exception); + } + + protected void logBotCompletion(String botName, BotClient botClient, AtomicLong bankBalance) { + log.info("{} is done. Balances:\n{}\nBank Account Balance: {}", + botName, + formatBalancesTbls(botClient.getBalance()), + bankBalance.get()); + } + + protected void logWalletBalance(String botName, BotClient botClient) { + log.info("{} balances:\n{}", botName, formatBalancesTbls(botClient.getBalance())); + } +} diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ApiTestBotProtocol.java b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ApiTestBotProtocol.java new file mode 100644 index 00000000000..a482d9358bb --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ApiTestBotProtocol.java @@ -0,0 +1,88 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.scenario.bot.protocol; + + +import protobuf.PaymentAccount; + +import java.text.DecimalFormat; + +import java.io.File; + +import java.math.RoundingMode; + +import static bisq.apitest.botsupport.protocol.ProtocolStep.WAIT_FOR_TAKER_DEPOSIT_TX_CONFIRMED; +import static java.lang.Long.parseLong; + + + +import bisq.apitest.botsupport.BotClient; +import bisq.apitest.botsupport.protocol.BotProtocol; +import bisq.apitest.botsupport.script.BashScriptGenerator; +import bisq.apitest.method.BitcoinCliHelper; + +public abstract class ApiTestBotProtocol extends BotProtocol { + + // Don't use @Slf4j annotation to init log and use in Functions w/out IDE warnings. + private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(ApiTestBotProtocol.class); + + // Used to show user how to run regtest bitcoin-cli commands. + protected final BitcoinCliHelper bitcoinCli; + + public ApiTestBotProtocol(String botDescription, + BotClient botClient, + PaymentAccount paymentAccount, + long protocolStepTimeLimitInMs, + BitcoinCliHelper bitcoinCli, + BashScriptGenerator bashScriptGenerator) { + super(botDescription, + botClient, + paymentAccount, + protocolStepTimeLimitInMs, + bashScriptGenerator); + this.bitcoinCli = bitcoinCli; + } + + @Override + protected void printBotProtocolStep() { + super.printBotProtocolStep(); + if (currentProtocolStep.equals(WAIT_FOR_TAKER_DEPOSIT_TX_CONFIRMED)) { + log.info("Generate a btc block to trigger taker's deposit fee tx confirmation."); + createGenerateBtcBlockScript(); + } + } + + @Override + protected void printCliHintAndOrScript(File script, String hint) { + super.printCliHintAndOrScript(script, hint); + sleep(5000); // Allow 5s for CLI user to read the hint. + } + + protected void createGenerateBtcBlockScript() { + String newBitcoinCoreAddress = bitcoinCli.getNewBtcAddress(); + File script = bashScriptGenerator.createGenerateBtcBlockScript(newBitcoinCoreAddress); + printCliHintAndOrScript(script, "The manual CLI side can generate 1 btc block"); + } + + public static long toDollars(long volume) { + DecimalFormat df = new DecimalFormat("#########"); + df.setMaximumFractionDigits(0); + df.setRoundingMode(RoundingMode.UNNECESSARY); + return parseLong(df.format((double) volume / 10000)); + } +} diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/MakerBotProtocol.java b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ApiTestMakerBotProtocol.java similarity index 72% rename from apitest/src/test/java/bisq/apitest/scenario/bot/protocol/MakerBotProtocol.java rename to apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ApiTestMakerBotProtocol.java index 0ce26002ece..2c56e995496 100644 --- a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/MakerBotProtocol.java +++ b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ApiTestMakerBotProtocol.java @@ -11,32 +11,36 @@ import java.util.function.Function; import java.util.function.Supplier; -import lombok.extern.slf4j.Slf4j; - -import static bisq.apitest.scenario.bot.protocol.ProtocolStep.DONE; -import static bisq.apitest.scenario.bot.protocol.ProtocolStep.WAIT_FOR_OFFER_TAKER; -import static bisq.apitest.scenario.bot.shutdown.ManualShutdown.checkIfShutdownCalled; +import static bisq.apitest.botsupport.protocol.ProtocolStep.DONE; +import static bisq.apitest.botsupport.protocol.ProtocolStep.WAIT_FOR_OFFER_TAKER; +import static bisq.apitest.botsupport.shutdown.ManualShutdown.checkIfShutdownCalled; import static bisq.cli.TableFormat.formatOfferTable; +import static bisq.core.offer.OfferPayload.Direction.BUY; import static java.util.Collections.singletonList; +import bisq.apitest.botsupport.BotClient; +import bisq.apitest.botsupport.protocol.MakerBotProtocol; +import bisq.apitest.botsupport.script.BashScriptGenerator; +import bisq.apitest.botsupport.shutdown.ManualBotShutdownException; import bisq.apitest.method.BitcoinCliHelper; -import bisq.apitest.scenario.bot.BotClient; import bisq.apitest.scenario.bot.RandomOffer; -import bisq.apitest.scenario.bot.script.BashScriptGenerator; -import bisq.apitest.scenario.bot.shutdown.ManualBotShutdownException; import bisq.cli.TradeFormat; -@Slf4j -public class MakerBotProtocol extends BotProtocol { - public MakerBotProtocol(BotClient botClient, - PaymentAccount paymentAccount, - long protocolStepTimeLimitInMs, - BitcoinCliHelper bitcoinCli, - BashScriptGenerator bashScriptGenerator) { - super(botClient, +public class ApiTestMakerBotProtocol extends ApiTestBotProtocol implements MakerBotProtocol { + + // Don't use @Slf4j annotation to init log and use in Functions w/out IDE warnings. + private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(ApiTestMakerBotProtocol.class); + + public ApiTestMakerBotProtocol(BotClient botClient, + PaymentAccount paymentAccount, + long protocolStepTimeLimitInMs, + BitcoinCliHelper bitcoinCli, + BashScriptGenerator bashScriptGenerator) { + super("Maker", + botClient, paymentAccount, protocolStepTimeLimitInMs, bitcoinCli, @@ -50,7 +54,7 @@ public void run() { Function, TradeInfo> makeTrade = waitForNewTrade.andThen(waitForTakerFeeTxConfirm); var trade = makeTrade.apply(randomOffer); - var makerIsBuyer = trade.getOffer().getDirection().equalsIgnoreCase(BUY); + var makerIsBuyer = trade.getOffer().getDirection().equalsIgnoreCase(BUY.name()); Function completeFiatTransaction = makerIsBuyer ? sendPaymentStartedMessage.andThen(waitForPaymentReceivedConfirmation) : waitForPaymentStartedMessage.andThen(sendPaymentReceivedMessage); @@ -74,7 +78,7 @@ public void run() { OfferInfo offer = randomOffer.get(); createTakeOfferCliScript(offer); try { - log.info("Impatiently waiting for offer {} to be taken, repeatedly calling gettrade.", offer.getId()); + log.info("Waiting for offer {} to be taken.", offer.getId()); while (isWithinProtocolStepTimeLimit()) { checkIfShutdownCalled("Interrupted while waiting for offer to be taken."); try { @@ -82,7 +86,7 @@ public void run() { if (trade.isPresent()) return trade.get(); else - sleep(randomDelay.get()); + sleep(shortRandomDelayInSeconds.get()); } catch (Exception ex) { throw new IllegalStateException(this.getBotClient().toCleanGrpcExceptionMessage(ex), ex); } diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ApiTestMarketMakerBotProtocol.java b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ApiTestMarketMakerBotProtocol.java new file mode 100644 index 00000000000..6f3598b028d --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ApiTestMarketMakerBotProtocol.java @@ -0,0 +1,216 @@ +package bisq.apitest.scenario.bot.protocol; + +import bisq.proto.grpc.OfferInfo; +import bisq.proto.grpc.TradeInfo; + +import protobuf.PaymentAccount; + +import java.io.File; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Function; +import java.util.function.Supplier; + +import static bisq.apitest.botsupport.protocol.ProtocolStep.DONE; +import static bisq.apitest.botsupport.protocol.ProtocolStep.WAIT_FOR_OFFER_TAKER; +import static bisq.apitest.botsupport.shutdown.ManualShutdown.checkIfShutdownCalled; +import static bisq.cli.CurrencyFormat.formatPrice; +import static bisq.cli.TableFormat.formatOfferTable; +import static bisq.core.offer.OfferPayload.Direction.BUY; +import static bisq.core.offer.OfferPayload.Direction.SELL; +import static java.lang.String.format; +import static java.util.Collections.singletonList; +import static java.util.concurrent.TimeUnit.SECONDS; + + + +import bisq.apitest.botsupport.BotClient; +import bisq.apitest.botsupport.protocol.MakerBotProtocol; +import bisq.apitest.botsupport.script.BashScriptGenerator; +import bisq.apitest.method.BitcoinCliHelper; +import bisq.cli.TradeFormat; + +// TODO Create a MarketMakerBotProtocol in CLI, stripped of all the test harness references. +// This not for the CLI, regtest/apitest only. + +public class ApiTestMarketMakerBotProtocol extends ApiTestBotProtocol implements MakerBotProtocol { + + // Don't use @Slf4j annotation to init log and use in Functions w/out IDE warnings. + private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(ApiTestMarketMakerBotProtocol.class); + + private static final int MAX_CREATE_OFFER_FAILURES = 3; + + static final double PRICE_MARGIN = 6.50; // Target spread is 13%. + + private final String direction; + private final AtomicLong bobsBankBalance; + + public ApiTestMarketMakerBotProtocol(BotClient botClient, + PaymentAccount paymentAccount, + long protocolStepTimeLimitInMs, + BitcoinCliHelper bitcoinCli, + BashScriptGenerator bashScriptGenerator, + String direction, + AtomicLong bobsBankBalance) { + super("Maker", + botClient, + paymentAccount, + protocolStepTimeLimitInMs, + bitcoinCli, + bashScriptGenerator); + this.direction = direction; + this.bobsBankBalance = bobsBankBalance; + } + + @Override + public void run() { + checkIsStartStep(); + + var isBuy = direction.equalsIgnoreCase(BUY.name()); + + Function, TradeInfo> makeTrade = waitForNewTrade.andThen(waitForTakerFeeTxConfirm); + var trade = isBuy + ? makeTrade.apply(createBuyOffer) + : makeTrade.apply(createSellOffer); + + var makerIsBuyer = trade.getOffer().getDirection().equalsIgnoreCase(BUY.name()); + Function completeFiatTransaction = makerIsBuyer + ? sendPaymentStartedMessage.andThen(waitForPaymentReceivedConfirmation) + : waitForPaymentStartedMessage.andThen(sendPaymentReceivedMessage); + completeFiatTransaction.apply(trade); + + Function closeTrade = waitForPayoutTx.andThen(keepFundsFromTrade); + closeTrade.apply(trade); + + long bankBalanceDelta = isBuy + ? -1 * toDollars(trade.getOffer().getVolume()) + : toDollars(trade.getOffer().getVolume()); + bobsBankBalance.addAndGet(bankBalanceDelta); + + currentProtocolStep = DONE; + } + + private final Supplier createBuyOffer = () -> { + checkIfShutdownCalled("Interrupted before creating random BUY offer."); + for (int i = 0; i < MAX_CREATE_OFFER_FAILURES; i++) { + try { + var offer = botClient.createOfferAtMarketBasedPrice(paymentAccount, + BUY.name(), + currencyCode, + 2_500_000, + 2_500_000, + PRICE_MARGIN, + 0.15, + BSQ); + log.info("Created BUY / {} offer at {}% below current market price of {}:\n{}", + currencyCode, + PRICE_MARGIN, + botClient.getCurrentBTCMarketPriceAsString(currencyCode), + formatOfferTable(singletonList(offer), currencyCode)); + log.warn(">>>>> NEW BUY OFFER {} PRICE: {} =~ {}", + offer.getId(), + offer.getPrice(), + formatPrice(offer.getPrice())); + return offer; + } catch (Exception ex) { + log.error("Failed to create offer at attempt #{}.", i, ex); + try { + SECONDS.sleep(5); + } catch (InterruptedException interruptedException) { + } + } + } + throw new IllegalStateException(format("%s could not create offer after 3 attempts.", botDescription)); + }; + + private final Supplier createSellOffer = () -> { + checkIfShutdownCalled("Interrupted before creating random SELL offer."); + for (int i = 0; i < MAX_CREATE_OFFER_FAILURES; i++) { + try { + var offer = botClient.createOfferAtMarketBasedPrice(paymentAccount, + SELL.name(), + currencyCode, + 2_500_000, + 2_500_000, + PRICE_MARGIN, + 0.15, + BSQ); + log.info("Created SELL / {} offer at {}% above current market price of {}:\n{}", + currencyCode, + PRICE_MARGIN, + botClient.getCurrentBTCMarketPriceAsString(currencyCode), + formatOfferTable(singletonList(offer), currencyCode)); + log.warn(">>>>> NEW SELL OFFER {} PRICE: {} =~ {}", + offer.getId(), + offer.getPrice(), + formatPrice(offer.getPrice())); + return offer; + } catch (Exception ex) { + log.error("Failed to create offer at attempt #{}.", i, ex); + try { + SECONDS.sleep(5); + } catch (InterruptedException interruptedException) { + } + } + } + throw new IllegalStateException(format("%s could not create offer after 3 attempts.", botDescription)); + }; + + private final Function, TradeInfo> waitForNewTrade = (latestOffer) -> { + initProtocolStep.accept(WAIT_FOR_OFFER_TAKER); + OfferInfo offer = latestOffer.get(); + createTakeOfferCliScript(offer); + log.info("Waiting for offer {} to be taken.", offer.getId()); + int numDelays = 0; + while (isWithinProtocolStepTimeLimit()) { + checkIfShutdownCalled("Interrupted while waiting for offer to be taken."); + try { + var trade = getNewTrade(offer.getId()); + if (trade.isPresent()) { + return trade.get(); + } else { + if (++numDelays % 5 == 0) { + log.warn("Offer {} still waiting to be taken, current state = {}", + offer.getId(), offer.getState()); + String offerCounterCurrencyCode = offer.getCounterCurrencyCode(); + List myCurrentOffers = botClient.getMyOffersSortedByDate(offerCounterCurrencyCode); + if (myCurrentOffers.isEmpty()) { + log.warn("{} has no current offers at this time, but offer {} should exist.", + botDescription, + offer.getId()); + } else { + log.info("{}'s current offers {} is in the list, or fail):\n{}", + botDescription, + offer.getId(), + formatOfferTable(myCurrentOffers, offerCounterCurrencyCode)); + } + } + sleep(shortRandomDelayInSeconds.get()); + } + } catch (Exception ex) { + throw new IllegalStateException(botClient.toCleanGrpcExceptionMessage(ex), ex); + } + } // end while + + // If the while loop is exhausted, the offer was not taken within the protocol step time limit. + throw new IllegalStateException("Offer was never taken; we won't wait any longer."); + }; + + private Optional getNewTrade(String offerId) { + try { + var trade = botClient.getTrade(offerId); + log.info("Offer {} was taken, new trade:\n{}", offerId, TradeFormat.format(trade)); + return Optional.of(trade); + } catch (Exception ex) { + return Optional.empty(); + } + } + + private void createTakeOfferCliScript(OfferInfo offer) { + String scriptFilename = "takeoffer-" + offer.getId() + ".sh"; + File script = bashScriptGenerator.createTakeOfferScript(offer, scriptFilename); + printCliHintAndOrScript(script, "The manual CLI side can take the offer"); + } +} diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/TakerBotProtocol.java b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ApiTestTakerBotProtocol.java similarity index 72% rename from apitest/src/test/java/bisq/apitest/scenario/bot/protocol/TakerBotProtocol.java rename to apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ApiTestTakerBotProtocol.java index 63b700824f6..e58d0757c27 100644 --- a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/TakerBotProtocol.java +++ b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ApiTestTakerBotProtocol.java @@ -11,31 +11,36 @@ import java.util.function.Function; import java.util.function.Supplier; -import lombok.extern.slf4j.Slf4j; - -import static bisq.apitest.scenario.bot.protocol.ProtocolStep.DONE; -import static bisq.apitest.scenario.bot.protocol.ProtocolStep.FIND_OFFER; -import static bisq.apitest.scenario.bot.protocol.ProtocolStep.TAKE_OFFER; -import static bisq.apitest.scenario.bot.shutdown.ManualShutdown.checkIfShutdownCalled; +import static bisq.apitest.botsupport.protocol.ProtocolStep.DONE; +import static bisq.apitest.botsupport.protocol.ProtocolStep.FIND_OFFER; +import static bisq.apitest.botsupport.protocol.ProtocolStep.TAKE_OFFER; +import static bisq.apitest.botsupport.shutdown.ManualShutdown.checkIfShutdownCalled; import static bisq.cli.TableFormat.formatOfferTable; +import static bisq.core.offer.OfferPayload.Direction.BUY; +import static bisq.core.offer.OfferPayload.Direction.SELL; import static bisq.core.payment.payload.PaymentMethod.F2F_ID; +import bisq.apitest.botsupport.BotClient; +import bisq.apitest.botsupport.protocol.TakerBotProtocol; +import bisq.apitest.botsupport.script.BashScriptGenerator; +import bisq.apitest.botsupport.shutdown.ManualBotShutdownException; import bisq.apitest.method.BitcoinCliHelper; -import bisq.apitest.scenario.bot.BotClient; -import bisq.apitest.scenario.bot.script.BashScriptGenerator; -import bisq.apitest.scenario.bot.shutdown.ManualBotShutdownException; - -@Slf4j -public class TakerBotProtocol extends BotProtocol { - - public TakerBotProtocol(BotClient botClient, - PaymentAccount paymentAccount, - long protocolStepTimeLimitInMs, - BitcoinCliHelper bitcoinCli, - BashScriptGenerator bashScriptGenerator) { - super(botClient, + +public class ApiTestTakerBotProtocol extends ApiTestBotProtocol implements TakerBotProtocol { + + // Don't use @Slf4j annotation to init log and use in Functions w/out IDE warnings. + private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(ApiTestTakerBotProtocol.class); + + + public ApiTestTakerBotProtocol(BotClient botClient, + PaymentAccount paymentAccount, + long protocolStepTimeLimitInMs, + BitcoinCliHelper bitcoinCli, + BashScriptGenerator bashScriptGenerator) { + super("Taker", + botClient, paymentAccount, protocolStepTimeLimitInMs, bitcoinCli, @@ -49,7 +54,7 @@ public void run() { Function takeTrade = takeOffer.andThen(waitForTakerFeeTxConfirm); var trade = takeTrade.apply(findOffer.get()); - var takerIsSeller = trade.getOffer().getDirection().equalsIgnoreCase(BUY); + var takerIsSeller = trade.getOffer().getDirection().equalsIgnoreCase(BUY.name()); Function completeFiatTransaction = takerIsSeller ? waitForPaymentStartedMessage.andThen(sendPaymentReceivedMessage) : sendPaymentStartedMessage.andThen(waitForPaymentReceivedConfirmation); @@ -62,7 +67,7 @@ public void run() { } private final Supplier> firstOffer = () -> { - var offers = botClient.getOffers(currencyCode); + var offers = botClient.getOffersSortedByDate(currencyCode); if (offers.size() > 0) { log.info("Offers found:\n{}", formatOfferTable(offers, currencyCode)); OfferInfo offer = offers.get(0); @@ -78,7 +83,7 @@ public void run() { initProtocolStep.accept(FIND_OFFER); createMakeOfferScript(); try { - log.info("Impatiently waiting for at least one {} offer to be created, repeatedly calling getoffers.", currencyCode); + log.info("Waiting for a {} offer to be created.", currencyCode); while (isWithinProtocolStepTimeLimit()) { checkIfShutdownCalled("Interrupted while checking offers."); try { @@ -86,7 +91,7 @@ public void run() { if (offer.isPresent()) return offer.get(); else - sleep(randomDelay.get()); + sleep(shortRandomDelayInSeconds.get()); } catch (Exception ex) { throw new IllegalStateException(this.getBotClient().toCleanGrpcExceptionMessage(ex), ex); } @@ -107,7 +112,7 @@ public void run() { }; private void createMakeOfferScript() { - String direction = RANDOM.nextBoolean() ? "BUY" : "SELL"; + String direction = RANDOM.nextBoolean() ? BUY.name() : SELL.name(); String feeCurrency = RANDOM.nextBoolean() ? "BSQ" : "BTC"; boolean createMarginPricedOffer = RANDOM.nextBoolean(); // If not using an F2F account, don't go over possible 0.01 BTC diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/BotProtocol.java b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/BotProtocol.java deleted file mode 100644 index 51d59e7537d..00000000000 --- a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/BotProtocol.java +++ /dev/null @@ -1,349 +0,0 @@ -/* - * This file is part of Bisq. - * - * Bisq is free software: you can redistribute it and/or modify it - * under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or (at - * your option) any later version. - * - * Bisq is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public - * License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with Bisq. If not, see . - */ - -package bisq.apitest.scenario.bot.protocol; - - -import bisq.proto.grpc.TradeInfo; - -import protobuf.PaymentAccount; - -import java.security.SecureRandom; - -import java.io.File; - -import java.util.Objects; -import java.util.concurrent.atomic.AtomicLong; -import java.util.function.Consumer; -import java.util.function.Function; -import java.util.function.Predicate; -import java.util.function.Supplier; -import java.util.stream.Collectors; - -import lombok.Getter; -import lombok.extern.slf4j.Slf4j; - -import static bisq.apitest.scenario.bot.protocol.ProtocolStep.*; -import static bisq.apitest.scenario.bot.shutdown.ManualShutdown.checkIfShutdownCalled; -import static java.lang.String.format; -import static java.lang.System.currentTimeMillis; -import static java.util.Arrays.stream; -import static java.util.concurrent.TimeUnit.MILLISECONDS; - - - -import bisq.apitest.method.BitcoinCliHelper; -import bisq.apitest.scenario.bot.BotClient; -import bisq.apitest.scenario.bot.script.BashScriptGenerator; -import bisq.apitest.scenario.bot.shutdown.ManualBotShutdownException; -import bisq.cli.TradeFormat; - -@Slf4j -public abstract class BotProtocol { - - static final SecureRandom RANDOM = new SecureRandom(); - static final String BUY = "BUY"; - static final String SELL = "SELL"; - - protected final Supplier randomDelay = () -> (long) (2000 + RANDOM.nextInt(5000)); - - protected final AtomicLong protocolStepStartTime = new AtomicLong(0); - protected final Consumer initProtocolStep = (step) -> { - currentProtocolStep = step; - printBotProtocolStep(); - protocolStepStartTime.set(currentTimeMillis()); - }; - - @Getter - protected ProtocolStep currentProtocolStep; - - @Getter // Functions within 'this' need the @Getter. - protected final BotClient botClient; - protected final PaymentAccount paymentAccount; - protected final String currencyCode; - protected final long protocolStepTimeLimitInMs; - protected final BitcoinCliHelper bitcoinCli; - @Getter - protected final BashScriptGenerator bashScriptGenerator; - - public BotProtocol(BotClient botClient, - PaymentAccount paymentAccount, - long protocolStepTimeLimitInMs, - BitcoinCliHelper bitcoinCli, - BashScriptGenerator bashScriptGenerator) { - this.botClient = botClient; - this.paymentAccount = paymentAccount; - this.currencyCode = Objects.requireNonNull(paymentAccount.getSelectedTradeCurrency()).getCode(); - this.protocolStepTimeLimitInMs = protocolStepTimeLimitInMs; - this.bitcoinCli = bitcoinCli; - this.bashScriptGenerator = bashScriptGenerator; - this.currentProtocolStep = START; - } - - public abstract void run(); - - protected boolean isWithinProtocolStepTimeLimit() { - return (currentTimeMillis() - protocolStepStartTime.get()) < protocolStepTimeLimitInMs; - } - - protected void checkIsStartStep() { - if (currentProtocolStep != START) { - throw new IllegalStateException("First bot protocol step must be " + START.name()); - } - } - - protected void printBotProtocolStep() { - log.info("Starting protocol step {}. Bot will shutdown if step not completed within {} minutes.", - currentProtocolStep.name(), MILLISECONDS.toMinutes(protocolStepTimeLimitInMs)); - - if (currentProtocolStep.equals(WAIT_FOR_TAKER_DEPOSIT_TX_CONFIRMED)) { - log.info("Generate a btc block to trigger taker's deposit fee tx confirmation."); - createGenerateBtcBlockScript(); - } - } - - protected final Function waitForTakerFeeTxConfirm = (trade) -> { - sleep(5000); - waitForTakerFeeTxPublished(trade.getTradeId()); - waitForTakerFeeTxConfirmed(trade.getTradeId()); - return trade; - }; - - protected final Function waitForPaymentStartedMessage = (trade) -> { - initProtocolStep.accept(WAIT_FOR_PAYMENT_STARTED_MESSAGE); - try { - createPaymentStartedScript(trade); - log.info(" Waiting for a 'payment started' message from buyer for trade with id {}.", trade.getTradeId()); - while (isWithinProtocolStepTimeLimit()) { - checkIfShutdownCalled("Interrupted before checking if 'payment started' message has been sent."); - try { - var t = this.getBotClient().getTrade(trade.getTradeId()); - if (t.getIsFiatSent()) { - log.info("Buyer has started payment for trade:\n{}", TradeFormat.format(t)); - return t; - } - } catch (Exception ex) { - throw new IllegalStateException(this.getBotClient().toCleanGrpcExceptionMessage(ex)); - } - sleep(randomDelay.get()); - } // end while - - throw new IllegalStateException("Payment was never sent; we won't wait any longer."); - } catch (ManualBotShutdownException ex) { - throw ex; // not an error, tells bot to shutdown - } catch (Exception ex) { - throw new IllegalStateException("Error while waiting payment sent message.", ex); - } - }; - - protected final Function sendPaymentStartedMessage = (trade) -> { - initProtocolStep.accept(SEND_PAYMENT_STARTED_MESSAGE); - checkIfShutdownCalled("Interrupted before sending 'payment started' message."); - this.getBotClient().sendConfirmPaymentStartedMessage(trade.getTradeId()); - return trade; - }; - - protected final Function waitForPaymentReceivedConfirmation = (trade) -> { - initProtocolStep.accept(WAIT_FOR_PAYMENT_RECEIVED_CONFIRMATION_MESSAGE); - createPaymentReceivedScript(trade); - try { - log.info("Waiting for a 'payment received confirmation' message from seller for trade with id {}.", trade.getTradeId()); - while (isWithinProtocolStepTimeLimit()) { - checkIfShutdownCalled("Interrupted before checking if 'payment received confirmation' message has been sent."); - try { - var t = this.getBotClient().getTrade(trade.getTradeId()); - if (t.getIsFiatReceived()) { - log.info("Seller has received payment for trade:\n{}", TradeFormat.format(t)); - return t; - } - } catch (Exception ex) { - throw new IllegalStateException(this.getBotClient().toCleanGrpcExceptionMessage(ex)); - } - sleep(randomDelay.get()); - } // end while - - throw new IllegalStateException("Payment was never received; we won't wait any longer."); - } catch (ManualBotShutdownException ex) { - throw ex; // not an error, tells bot to shutdown - } catch (Exception ex) { - throw new IllegalStateException("Error while waiting payment received confirmation message.", ex); - } - }; - - protected final Function sendPaymentReceivedMessage = (trade) -> { - initProtocolStep.accept(SEND_PAYMENT_RECEIVED_CONFIRMATION_MESSAGE); - checkIfShutdownCalled("Interrupted before sending 'payment received confirmation' message."); - this.getBotClient().sendConfirmPaymentReceivedMessage(trade.getTradeId()); - return trade; - }; - - protected final Function waitForPayoutTx = (trade) -> { - initProtocolStep.accept(WAIT_FOR_PAYOUT_TX); - try { - log.info("Waiting on the 'payout tx published confirmation' for trade with id {}.", trade.getTradeId()); - while (isWithinProtocolStepTimeLimit()) { - checkIfShutdownCalled("Interrupted before checking if payout tx has been published."); - try { - var t = this.getBotClient().getTrade(trade.getTradeId()); - if (t.getIsPayoutPublished()) { - log.info("Payout tx {} has been published for trade:\n{}", - t.getPayoutTxId(), - TradeFormat.format(t)); - return t; - } - } catch (Exception ex) { - throw new IllegalStateException(this.getBotClient().toCleanGrpcExceptionMessage(ex)); - } - sleep(randomDelay.get()); - } // end while - - throw new IllegalStateException("Payout tx was never published; we won't wait any longer."); - } catch (ManualBotShutdownException ex) { - throw ex; // not an error, tells bot to shutdown - } catch (Exception ex) { - throw new IllegalStateException("Error while waiting for published payout tx.", ex); - } - }; - - protected final Function keepFundsFromTrade = (trade) -> { - initProtocolStep.accept(KEEP_FUNDS); - var isBuy = trade.getOffer().getDirection().equalsIgnoreCase(BUY); - var isSell = trade.getOffer().getDirection().equalsIgnoreCase(SELL); - var cliUserIsSeller = (this instanceof MakerBotProtocol && isBuy) || (this instanceof TakerBotProtocol && isSell); - if (cliUserIsSeller) { - createKeepFundsScript(trade); - } else { - createGetBalanceScript(); - } - checkIfShutdownCalled("Interrupted before closing trade with 'keep funds' command."); - this.getBotClient().sendKeepFundsMessage(trade.getTradeId()); - return trade; - }; - - protected void createPaymentStartedScript(TradeInfo trade) { - File script = bashScriptGenerator.createPaymentStartedScript(trade); - printCliHintAndOrScript(script, "The manual CLI side can send a 'payment started' message"); - } - - protected void createPaymentReceivedScript(TradeInfo trade) { - File script = bashScriptGenerator.createPaymentReceivedScript(trade); - printCliHintAndOrScript(script, "The manual CLI side can sent a 'payment received confirmation' message"); - } - - protected void createKeepFundsScript(TradeInfo trade) { - File script = bashScriptGenerator.createKeepFundsScript(trade); - printCliHintAndOrScript(script, "The manual CLI side can close the trade"); - } - - protected void createGetBalanceScript() { - File script = bashScriptGenerator.createGetBalanceScript(); - printCliHintAndOrScript(script, "The manual CLI side can view current balances"); - } - - protected void createGenerateBtcBlockScript() { - String newBitcoinCoreAddress = bitcoinCli.getNewBtcAddress(); - File script = bashScriptGenerator.createGenerateBtcBlockScript(newBitcoinCoreAddress); - printCliHintAndOrScript(script, "The manual CLI side can generate 1 btc block"); - } - - protected void printCliHintAndOrScript(File script, String hint) { - log.info("{} by running bash script '{}'.", hint, script.getAbsolutePath()); - if (this.getBashScriptGenerator().isPrintCliScripts()) - this.getBashScriptGenerator().printCliScript(script, log); - - sleep(5000); // Allow 5s for CLI user to read the hint. - } - - protected void sleep(long ms) { - try { - MILLISECONDS.sleep(ms); - } catch (InterruptedException ignored) { - // empty - } - } - - private void waitForTakerFeeTxPublished(String tradeId) { - waitForTakerDepositFee(tradeId, WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED); - } - - private void waitForTakerFeeTxConfirmed(String tradeId) { - waitForTakerDepositFee(tradeId, WAIT_FOR_TAKER_DEPOSIT_TX_CONFIRMED); - } - - private void waitForTakerDepositFee(String tradeId, ProtocolStep depositTxProtocolStep) { - initProtocolStep.accept(depositTxProtocolStep); - validateCurrentProtocolStep(WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED, WAIT_FOR_TAKER_DEPOSIT_TX_CONFIRMED); - try { - log.info(waitingForDepositFeeTxMsg(tradeId)); - while (isWithinProtocolStepTimeLimit()) { - checkIfShutdownCalled("Interrupted before checking taker deposit fee tx is published and confirmed."); - try { - var trade = this.getBotClient().getTrade(tradeId); - if (isDepositFeeTxStepComplete.test(trade)) - return; - else - sleep(randomDelay.get()); - } catch (Exception ex) { - if (this.getBotClient().tradeContractIsNotReady.test(ex, tradeId)) - sleep(randomDelay.get()); - else - throw new IllegalStateException(this.getBotClient().toCleanGrpcExceptionMessage(ex)); - } - } // end while - throw new IllegalStateException(stoppedWaitingForDepositFeeTxMsg(this.getBotClient().getTrade(tradeId).getDepositTxId())); - } catch (ManualBotShutdownException ex) { - throw ex; // not an error, tells bot to shutdown - } catch (Exception ex) { - throw new IllegalStateException("Error while waiting for taker deposit tx to be published or confirmed.", ex); - } - } - - private final Predicate isDepositFeeTxStepComplete = (trade) -> { - if (currentProtocolStep.equals(WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED) && trade.getIsDepositPublished()) { - log.info("Taker deposit fee tx {} has been published.", trade.getDepositTxId()); - return true; - } else if (currentProtocolStep.equals(WAIT_FOR_TAKER_DEPOSIT_TX_CONFIRMED) && trade.getIsDepositConfirmed()) { - log.info("Taker deposit fee tx {} has been confirmed.", trade.getDepositTxId()); - return true; - } else { - return false; - } - }; - - private void validateCurrentProtocolStep(Enum... validBotSteps) { - for (Enum validBotStep : validBotSteps) { - if (currentProtocolStep.equals(validBotStep)) - return; - } - throw new IllegalStateException("Unexpected bot step: " + currentProtocolStep.name() + ".\n" - + "Must be one of " - + stream(validBotSteps).map((Enum::name)).collect(Collectors.joining(",")) - + "."); - } - - private String waitingForDepositFeeTxMsg(String tradeId) { - return format("Waiting for taker deposit fee tx for trade %s to be %s.", - tradeId, - currentProtocolStep.equals(WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED) ? "published" : "confirmed"); - } - - private String stoppedWaitingForDepositFeeTxMsg(String txId) { - return format("Taker deposit fee tx %s is took too long to be %s; we won't wait any longer.", - txId, - currentProtocolStep.equals(WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED) ? "published" : "confirmed"); - } -} diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/MarketMakerTakeOnlyBotProtocol.java b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/MarketMakerTakeOnlyBotProtocol.java new file mode 100644 index 00000000000..913ea532a99 --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/MarketMakerTakeOnlyBotProtocol.java @@ -0,0 +1,160 @@ +package bisq.apitest.scenario.bot.protocol; + +import bisq.proto.grpc.OfferInfo; +import bisq.proto.grpc.TradeInfo; + +import protobuf.PaymentAccount; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Function; +import java.util.function.Supplier; + +import static bisq.apitest.botsupport.protocol.ProtocolStep.DONE; +import static bisq.apitest.botsupport.protocol.ProtocolStep.FIND_OFFER; +import static bisq.apitest.botsupport.protocol.ProtocolStep.TAKE_OFFER; +import static bisq.apitest.botsupport.shutdown.ManualShutdown.checkIfShutdownCalled; +import static bisq.cli.TableFormat.formatOfferTable; +import static bisq.core.offer.OfferPayload.Direction.BUY; +import static java.lang.String.format; +import static java.util.concurrent.TimeUnit.SECONDS; + + + +import bisq.apitest.botsupport.BotClient; +import bisq.apitest.botsupport.protocol.TakeOfferHelper; +import bisq.apitest.botsupport.script.BashScriptGenerator; +import bisq.apitest.method.BitcoinCliHelper; + +public class MarketMakerTakeOnlyBotProtocol extends ApiTestBotProtocol { + + // Don't use @Slf4j annotation to init log and use in Functions w/out IDE warnings. + private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(MarketMakerTakeOnlyBotProtocol.class); + + private final AtomicLong takersBankBalance; + + public MarketMakerTakeOnlyBotProtocol(BotClient botClient, + PaymentAccount paymentAccount, + long protocolStepTimeLimitInMs, + BitcoinCliHelper bitcoinCli, + BashScriptGenerator bashScriptGenerator, + AtomicLong takersBankBalance) { + super("Taker", + botClient, + paymentAccount, + protocolStepTimeLimitInMs, + bitcoinCli, + bashScriptGenerator); + this.takersBankBalance = takersBankBalance; + } + + @Override + public void run() { + checkIsStartStep(); + + Function takeTrade = takeOffer.andThen(waitForTakerFeeTxConfirm); + var trade = takeTrade.apply(findOffer.get()); + + var takerIsSeller = trade.getOffer().getDirection().equalsIgnoreCase(BUY.name()); + Function completeFiatTransaction = takerIsSeller + ? waitForPaymentStartedMessage.andThen(sendPaymentReceivedMessage) + : sendPaymentStartedMessage.andThen(waitForPaymentReceivedConfirmation); + completeFiatTransaction.apply(trade); + + Function closeTrade = waitForPayoutTx.andThen(keepFundsFromTrade); + closeTrade.apply(trade); + + var iAmSeller = trade.getOffer().getDirection().equalsIgnoreCase(BUY.name()); + long bankBalanceDelta = iAmSeller + ? toDollars(trade.getOffer().getVolume()) + : -1 * toDollars(trade.getOffer().getVolume()); + takersBankBalance.addAndGet(bankBalanceDelta); + + currentProtocolStep = DONE; + } + + private final Supplier> firstOffer = () -> { + var offers = botClient.getOffersSortedByDate(currencyCode); + if (offers.size() > 0) { + log.info("Offers found:\n{}", formatOfferTable(offers, currencyCode)); + OfferInfo offer = offers.get(0); + log.info("Will take first offer {}", offer.getId()); + return Optional.of(offer); + } else { + log.info("No buy or sell {} offers found.", currencyCode); + return Optional.empty(); + } + }; + + private final Supplier findOffer = () -> { + initProtocolStep.accept(FIND_OFFER); + log.info("Looking for a {} offer.", currencyCode); + int numDelays = 0; + while (isWithinProtocolStepTimeLimit()) { + checkIfShutdownCalled("Interrupted while checking offers."); + try { + Optional offer = firstOffer.get(); + if (offer.isPresent()) { + return offer.get(); + } else { + if (++numDelays % 5 == 0) { + List currentOffers = botClient.getOffersSortedByDate(currencyCode); + if (currentOffers.isEmpty()) { + log.info("Still no available {} offers for {}.", currencyCode, botDescription); + } else { + log.warn("{} should be taking one of these available {} offers:\n{}", + botDescription, + currencyCode, + formatOfferTable(currentOffers, currencyCode)); + } + } + sleep(shortRandomDelayInSeconds.get()); + } + } catch (Exception ex) { + throw new IllegalStateException(this.getBotClient().toCleanGrpcExceptionMessage(ex), ex); + } + } // end while + + // If the while loop is exhausted, the offer was not created within the protocol step time limit. + throw new IllegalStateException("Offer was never created; we won't wait any longer."); + }; + + + private final Function takeOffer = (offer) -> { + initProtocolStep.accept(TAKE_OFFER); + checkIfShutdownCalled("Interrupted before taking offer."); + String feeCurrency = RANDOM.nextBoolean() ? "BSQ" : "BTC"; + TakeOfferHelper takeOfferHelper = new TakeOfferHelper(botClient, + botDescription, + offer, + paymentAccount, + feeCurrency, + 60, + 60, + 30); + + takeOfferHelper.run(); + + if (takeOfferHelper.hasNewTrade.get()) { + try { + log.info("{} waiting 5s for trade prep before allowing any gettrade calls.", botDescription); + SECONDS.sleep(5); + } catch (InterruptedException ignored) { + // empty + } + return takeOfferHelper.getNewTrade(); + } else if (takeOfferHelper.hasTakeOfferError.get()) { + throw new IllegalStateException(format("%s's takeoffer %s attempt failed.", + botDescription, + offer.getId()), + takeOfferHelper.getFatalThrowable()); + } else { + throw new IllegalStateException(format("%s's takeoffer %s attempt failed for unknown reason.", + botDescription, + offer.getId())); + } + }; +} + + diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ProtocolStep.java b/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ProtocolStep.java deleted file mode 100644 index def2a0bb663..00000000000 --- a/apitest/src/test/java/bisq/apitest/scenario/bot/protocol/ProtocolStep.java +++ /dev/null @@ -1,17 +0,0 @@ -package bisq.apitest.scenario.bot.protocol; - -public enum ProtocolStep { - START, - FIND_OFFER, - TAKE_OFFER, - WAIT_FOR_OFFER_TAKER, - WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED, - WAIT_FOR_TAKER_DEPOSIT_TX_CONFIRMED, - SEND_PAYMENT_STARTED_MESSAGE, - WAIT_FOR_PAYMENT_STARTED_MESSAGE, - SEND_PAYMENT_RECEIVED_CONFIRMATION_MESSAGE, - WAIT_FOR_PAYMENT_RECEIVED_CONFIRMATION_MESSAGE, - WAIT_FOR_PAYOUT_TX, - KEEP_FUNDS, - DONE -} diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/script/BotScript.java b/apitest/src/test/java/bisq/apitest/scenario/bot/script/BotScript.java index 2caaed68add..56baf720867 100644 --- a/apitest/src/test/java/bisq/apitest/scenario/bot/script/BotScript.java +++ b/apitest/src/test/java/bisq/apitest/scenario/bot/script/BotScript.java @@ -25,8 +25,7 @@ @Getter @ToString -public -class BotScript { +public class BotScript { // Common, default is true. private final boolean useTestHarness; diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/script/BotScriptGenerator.java b/apitest/src/test/java/bisq/apitest/scenario/bot/script/BotScriptGenerator.java index c81730c4c40..341bce16e75 100644 --- a/apitest/src/test/java/bisq/apitest/scenario/bot/script/BotScriptGenerator.java +++ b/apitest/src/test/java/bisq/apitest/scenario/bot/script/BotScriptGenerator.java @@ -98,7 +98,7 @@ public BotScriptGenerator(String[] args) { .accepts("step-time-limit", "Each protocol step's time limit in minutes") .withRequiredArg() .ofType(Integer.class) - .defaultsTo(60); + .defaultsTo(10); OptionSpec printCliScriptsOpt = parser .accepts("print-cli-scripts", "Print the generated CLI scripts from bot") .withRequiredArg() diff --git a/cli/src/main/java/bisq/cli/CliMain.java b/cli/src/main/java/bisq/cli/CliMain.java index 5e6e913711c..b85858fa120 100644 --- a/cli/src/main/java/bisq/cli/CliMain.java +++ b/cli/src/main/java/bisq/cli/CliMain.java @@ -56,6 +56,7 @@ import bisq.cli.opts.ArgumentList; import bisq.cli.opts.CancelOfferOptionParser; +import bisq.cli.opts.CreateCryptoCurrencyPaymentAcctOptionParser; import bisq.cli.opts.CreateOfferOptionParser; import bisq.cli.opts.CreatePaymentAcctOptionParser; import bisq.cli.opts.GetAddressBalanceOptionParser; @@ -517,6 +518,24 @@ public static void run(String[] args) { out.println(formatPaymentAcctTbl(singletonList(paymentAccount))); return; } + case createcryptopaymentacct: { + var opts = new CreateCryptoCurrencyPaymentAcctOptionParser(args).parse(); + if (opts.isForHelp()) { + out.println(client.getMethodHelp(method)); + return; + } + var accountName = opts.getAccountName(); + var currencyCode = opts.getCurrencyCode(); + var address = opts.getAddress(); + var isTradeInstant = opts.getIsTradeInstant(); + var paymentAccount = client.createCryptoCurrencyPaymentAccount(accountName, + currencyCode, + address, + isTradeInstant); + out.println("payment account saved"); + out.println(formatPaymentAcctTbl(singletonList(paymentAccount))); + return; + } case getpaymentaccts: { if (new SimpleMethodOptionParser(args).parse().isForHelp()) { out.println(client.getMethodHelp(method)); @@ -676,7 +695,7 @@ private static void printHelp(OptionParser parser, @SuppressWarnings("SameParame stream.println(); parser.printHelpOn(stream); stream.println(); - String rowFormat = "%-24s%-52s%s%n"; + String rowFormat = "%-25s%-52s%s%n"; stream.format(rowFormat, "Method", "Params", "Description"); stream.format(rowFormat, "------", "------", "------------"); stream.format(rowFormat, getversion.name(), "", "Get server version"); @@ -727,7 +746,9 @@ private static void printHelp(OptionParser parser, @SuppressWarnings("SameParame stream.format(rowFormat, getmyoffers.name(), "--direction= \\", "Get my current offers"); stream.format(rowFormat, "", "--currency-code=", ""); stream.println(); - stream.format(rowFormat, takeoffer.name(), "--offer-id= [--fee-currency=]", "Take offer with id"); + stream.format(rowFormat, takeoffer.name(), "--offer-id= \\", "Take offer with id"); + stream.format(rowFormat, "", "--payment-account=", ""); + stream.format(rowFormat, "", "[--fee-currency=]", ""); stream.println(); stream.format(rowFormat, gettrade.name(), "--trade-id= \\", "Get trade summary or full contract"); stream.format(rowFormat, "", "[--show-contract=]", ""); @@ -748,6 +769,11 @@ private static void printHelp(OptionParser parser, @SuppressWarnings("SameParame stream.println(); stream.format(rowFormat, createpaymentacct.name(), "--payment-account-form=", "Create a new payment account"); stream.println(); + stream.format(rowFormat, createcryptopaymentacct.name(), "--account-name= \\", "Create a new cryptocurrency payment account"); + stream.format(rowFormat, "", "--currency-code= \\", ""); + stream.format(rowFormat, "", "--address=", ""); + stream.format(rowFormat, "", "--trade-instant=", ""); + stream.println(); stream.format(rowFormat, getpaymentaccts.name(), "", "Get user payment accounts"); stream.println(); stream.format(rowFormat, lockwallet.name(), "", "Remove wallet password from memory, locking the wallet"); diff --git a/cli/src/main/java/bisq/cli/ColumnHeaderConstants.java b/cli/src/main/java/bisq/cli/ColumnHeaderConstants.java index 79aff0d3f9d..775221b5ed5 100644 --- a/cli/src/main/java/bisq/cli/ColumnHeaderConstants.java +++ b/cli/src/main/java/bisq/cli/ColumnHeaderConstants.java @@ -30,7 +30,7 @@ class ColumnHeaderConstants { // expected max data string length is accounted for. In others, column header // lengths are expected to be greater than any column value length. static final String COL_HEADER_ADDRESS = padEnd("%-3s Address", 52, ' '); - static final String COL_HEADER_AMOUNT = padEnd("BTC(min - max)", 24, ' '); + static final String COL_HEADER_AMOUNT = "BTC(min - max)"; static final String COL_HEADER_AVAILABLE_BALANCE = "Available Balance"; static final String COL_HEADER_AVAILABLE_CONFIRMED_BALANCE = "Available Confirmed Balance"; static final String COL_HEADER_UNCONFIRMED_CHANGE_BALANCE = "Unconfirmed Change Balance"; @@ -49,7 +49,9 @@ class ColumnHeaderConstants { static final String COL_HEADER_NAME = "Name"; static final String COL_HEADER_PAYMENT_METHOD = "Payment Method"; static final String COL_HEADER_PRICE = "Price in %-3s for 1 BTC"; + static final String COL_HEADER_PRICE_OF_ALTCOIN = "Price in BTC for 1 %-3s"; static final String COL_HEADER_TRADE_AMOUNT = padStart("Amount(%-3s)", 12, ' '); + static final String COL_HEADER_TRADE_BSQ_BUYER_ADDRESS = "BSQ Buyer Address"; static final String COL_HEADER_TRADE_BUYER_COST = padEnd("Buyer Cost(%-3s)", 15, ' '); static final String COL_HEADER_TRADE_DEPOSIT_CONFIRMED = "Deposit Confirmed"; static final String COL_HEADER_TRADE_DEPOSIT_PUBLISHED = "Deposit Published"; @@ -59,8 +61,9 @@ class ColumnHeaderConstants { static final String COL_HEADER_TRADE_WITHDRAWN = "Withdrawn"; static final String COL_HEADER_TRADE_ROLE = "My Role"; static final String COL_HEADER_TRADE_SHORT_ID = "ID"; - static final String COL_HEADER_TRADE_TX_FEE = "Tx Fee(%-3s)"; - static final String COL_HEADER_TRADE_TAKER_FEE = "Taker Fee(%-3s)"; + static final String COL_HEADER_TRADE_TX_FEE = padEnd("Tx Fee(BTC)", 12, ' '); + static final String COL_HEADER_TRADE_MAKER_FEE = padEnd("Maker Fee(%-3s)", 12, ' '); // "Maker Fee(%-3s)"; + static final String COL_HEADER_TRADE_TAKER_FEE = padEnd("Taker Fee(%-3s)", 12, ' '); // "Taker Fee(%-3s)"; static final String COL_HEADER_TX_ID = "Tx ID"; static final String COL_HEADER_TX_INPUT_SUM = "Tx Inputs (BTC)"; @@ -71,5 +74,6 @@ class ColumnHeaderConstants { static final String COL_HEADER_TX_MEMO = "Memo"; static final String COL_HEADER_VOLUME = padEnd("%-3s(min - max)", 15, ' '); + static final String COL_HEADER_UUID = padEnd("ID", 52, ' '); } diff --git a/cli/src/main/java/bisq/cli/CurrencyFormat.java b/cli/src/main/java/bisq/cli/CurrencyFormat.java index afa97b34300..baeeb775f95 100644 --- a/cli/src/main/java/bisq/cli/CurrencyFormat.java +++ b/cli/src/main/java/bisq/cli/CurrencyFormat.java @@ -25,23 +25,25 @@ import java.text.NumberFormat; import java.math.BigDecimal; -import java.math.RoundingMode; import java.util.Locale; import static java.lang.String.format; +import static java.math.RoundingMode.HALF_UP; +import static java.math.RoundingMode.UNNECESSARY; @VisibleForTesting public class CurrencyFormat { private static final NumberFormat NUMBER_FORMAT = NumberFormat.getInstance(Locale.US); - static final BigDecimal SATOSHI_DIVISOR = new BigDecimal(100000000); + static final BigDecimal SATOSHI_DIVISOR = new BigDecimal(100_000_000); static final DecimalFormat BTC_FORMAT = new DecimalFormat("###,##0.00000000"); static final DecimalFormat BTC_TX_FEE_FORMAT = new DecimalFormat("###,###,##0"); static final BigDecimal BSQ_SATOSHI_DIVISOR = new BigDecimal(100); static final DecimalFormat BSQ_FORMAT = new DecimalFormat("###,###,###,##0.00"); + static final DecimalFormat SEND_BSQ_FORMAT = new DecimalFormat("###########0.00"); static final BigDecimal SECURITY_DEPOSIT_MULTIPLICAND = new BigDecimal("0.01"); @@ -55,6 +57,14 @@ public static String formatBsq(long sats) { return BSQ_FORMAT.format(BigDecimal.valueOf(sats).divide(BSQ_SATOSHI_DIVISOR)); } + public static String formatBsqAmount(long bsqSats) { + // BSQ sats = trade.getOffer().getVolume() + NUMBER_FORMAT.setMinimumFractionDigits(2); + NUMBER_FORMAT.setMaximumFractionDigits(2); + NUMBER_FORMAT.setRoundingMode(HALF_UP); + return SEND_BSQ_FORMAT.format((double) bsqSats / SATOSHI_DIVISOR.doubleValue()); + } + public static String formatTxFeeRateInfo(TxFeeRateInfo txFeeRateInfo) { if (txFeeRateInfo.getUseCustomTxFeeRate()) return format("custom tx fee rate: %s sats/byte, network rate: %s sats/byte", @@ -77,22 +87,44 @@ public static String formatVolumeRange(long minVolume, long volume) { : formatOfferVolume(volume); } + public static String formatCryptoCurrencyVolumeRange(long minVolume, long volume) { + return minVolume != volume + ? formatCryptoCurrencyOfferVolume(minVolume) + " - " + formatCryptoCurrencyOfferVolume(volume) + : formatCryptoCurrencyOfferVolume(volume); + } + public static String formatMarketPrice(double price) { NUMBER_FORMAT.setMinimumFractionDigits(4); + NUMBER_FORMAT.setMaximumFractionDigits(4); return NUMBER_FORMAT.format(price); } - public static String formatOfferPrice(long price) { - NUMBER_FORMAT.setMaximumFractionDigits(4); + public static String formatPrice(long price) { NUMBER_FORMAT.setMinimumFractionDigits(4); - NUMBER_FORMAT.setRoundingMode(RoundingMode.UNNECESSARY); - return NUMBER_FORMAT.format((double) price / 10000); + NUMBER_FORMAT.setMaximumFractionDigits(4); + NUMBER_FORMAT.setRoundingMode(UNNECESSARY); + return NUMBER_FORMAT.format((double) price / 10_000); + } + + public static String formatCryptoCurrencyPrice(long price) { + NUMBER_FORMAT.setMinimumFractionDigits(8); + NUMBER_FORMAT.setMaximumFractionDigits(8); + NUMBER_FORMAT.setRoundingMode(UNNECESSARY); + return NUMBER_FORMAT.format((double) price / SATOSHI_DIVISOR.doubleValue()); } public static String formatOfferVolume(long volume) { + NUMBER_FORMAT.setMinimumFractionDigits(0); NUMBER_FORMAT.setMaximumFractionDigits(0); - NUMBER_FORMAT.setRoundingMode(RoundingMode.UNNECESSARY); - return NUMBER_FORMAT.format((double) volume / 10000); + NUMBER_FORMAT.setRoundingMode(HALF_UP); + return NUMBER_FORMAT.format((double) volume / 10_000); + } + + public static String formatCryptoCurrencyOfferVolume(long volume) { + NUMBER_FORMAT.setMinimumFractionDigits(2); + NUMBER_FORMAT.setMaximumFractionDigits(2); + NUMBER_FORMAT.setRoundingMode(HALF_UP); + return NUMBER_FORMAT.format((double) volume / SATOSHI_DIVISOR.doubleValue()); } public static long toSatoshis(String btc) { diff --git a/cli/src/main/java/bisq/cli/DirectionFormat.java b/cli/src/main/java/bisq/cli/DirectionFormat.java new file mode 100644 index 00000000000..ac0e5b6c556 --- /dev/null +++ b/cli/src/main/java/bisq/cli/DirectionFormat.java @@ -0,0 +1,60 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.cli; + +import bisq.proto.grpc.OfferInfo; + +import java.util.List; +import java.util.function.Function; + +import static bisq.cli.ColumnHeaderConstants.COL_HEADER_DIRECTION; +import static java.lang.String.format; +import static protobuf.OfferPayload.Direction.BUY; +import static protobuf.OfferPayload.Direction.SELL; + +class DirectionFormat { + + static int getLongestDirectionColWidth(List offers) { + if (offers.isEmpty() || offers.get(0).getBaseCurrencyCode().equals("BTC")) + return COL_HEADER_DIRECTION.length(); + else + return 18; // .e.g., "Sell BSQ (Buy BTC)".length() + } + + static final Function directionFormat = (offer) -> { + String baseCurrencyCode = offer.getBaseCurrencyCode(); + boolean isCryptoCurrencyOffer = !baseCurrencyCode.equals("BTC"); + if (!isCryptoCurrencyOffer) { + return baseCurrencyCode; + } else { + // Return "Sell BSQ (Buy BTC)", or "Buy BSQ (Sell BTC)". + String direction = offer.getDirection(); + String mirroredDirection = getMirroredDirection(direction); + Function mixedCase = (word) -> word.charAt(0) + word.substring(1).toLowerCase(); + return format("%s %s (%s %s)", + mixedCase.apply(mirroredDirection), + baseCurrencyCode, + mixedCase.apply(direction), + offer.getCounterCurrencyCode()); + } + }; + + static String getMirroredDirection(String directionAsString) { + return directionAsString.equalsIgnoreCase(BUY.name()) ? SELL.name() : BUY.name(); + } +} diff --git a/cli/src/main/java/bisq/cli/GrpcClient.java b/cli/src/main/java/bisq/cli/GrpcClient.java index ea20c6aef88..b238c17e5f1 100644 --- a/cli/src/main/java/bisq/cli/GrpcClient.java +++ b/cli/src/main/java/bisq/cli/GrpcClient.java @@ -24,10 +24,12 @@ import bisq.proto.grpc.CancelOfferRequest; import bisq.proto.grpc.ConfirmPaymentReceivedRequest; import bisq.proto.grpc.ConfirmPaymentStartedRequest; +import bisq.proto.grpc.CreateCryptoCurrencyPaymentAccountRequest; import bisq.proto.grpc.CreateOfferRequest; import bisq.proto.grpc.CreatePaymentAccountRequest; import bisq.proto.grpc.GetAddressBalanceRequest; import bisq.proto.grpc.GetBalancesRequest; +import bisq.proto.grpc.GetCryptoCurrencyPaymentMethodsRequest; import bisq.proto.grpc.GetFundingAddressesRequest; import bisq.proto.grpc.GetMethodHelpRequest; import bisq.proto.grpc.GetMyOfferRequest; @@ -60,6 +62,7 @@ import bisq.proto.grpc.TxInfo; import bisq.proto.grpc.UnlockWalletRequest; import bisq.proto.grpc.UnsetTxFeeRatePreferenceRequest; +import bisq.proto.grpc.VerifyBsqSentToAddressRequest; import bisq.proto.grpc.WithdrawFundsRequest; import protobuf.PaymentAccount; @@ -67,11 +70,11 @@ import java.util.ArrayList; import java.util.List; -import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import static java.util.Comparator.comparing; +import static java.util.stream.Collectors.toList; import static protobuf.OfferPayload.Direction.BUY; import static protobuf.OfferPayload.Direction.SELL; @@ -164,6 +167,14 @@ public TxInfo sendBtc(String address, String amount, String txFeeRate, String me return grpcStubs.walletsService.sendBtc(request).getTxInfo(); } + public boolean verifyBsqSentToAddress(String address, String amount) { + var request = VerifyBsqSentToAddressRequest.newBuilder() + .setAddress(address) + .setAmount(amount) + .build(); + return grpcStubs.walletsService.verifyBsqSentToAddress(request).getIsAmountReceived(); + } + public TxFeeRateInfo getTxFeeRate() { var request = GetTxFeeRateRequest.newBuilder().build(); return grpcStubs.walletsService.getTxFeeRate(request).getTxFeeRateInfo(); @@ -228,7 +239,6 @@ public OfferInfo createMarketBasedPricedOffer(String direction, makerFeeCurrencyCode); } - // TODO make private, move to bottom of class public OfferInfo createOffer(String direction, String currencyCode, long amount, @@ -283,6 +293,12 @@ public List getOffers(String direction, String currencyCode) { return grpcStubs.offersService.getOffers(request).getOffersList(); } + public List getBsqOffers(String direction) { + return getOffers(direction, "BTC").stream() + .filter(o -> o.getBaseCurrencyCode().equals("BSQ")) + .collect(toList()); + } + public List getOffersSortedByDate(String currencyCode) { ArrayList offers = new ArrayList<>(); offers.addAll(getOffers(BUY.name(), currencyCode)); @@ -295,6 +311,13 @@ public List getOffersSortedByDate(String direction, String currencyCo return offers.isEmpty() ? offers : sortOffersByDate(offers); } + public List getBsqOffersSortedByDate() { + ArrayList offers = new ArrayList<>(); + offers.addAll(getBsqOffers(BUY.name())); + offers.addAll(getBsqOffers(SELL.name())); + return sortOffersByDate(offers); + } + public List getMyOffers(String direction, String currencyCode) { var request = GetMyOffersRequest.newBuilder() .setDirection(direction) @@ -303,6 +326,12 @@ public List getMyOffers(String direction, String currencyCode) { return grpcStubs.offersService.getMyOffers(request).getOffersList(); } + public List getMyBsqOffers(String direction) { + return getMyOffers(direction, "BTC").stream() + .filter(o -> o.getBaseCurrencyCode().equals("BSQ")) + .collect(toList()); + } + public List getMyOffersSortedByDate(String direction, String currencyCode) { var offers = getMyOffers(direction, currencyCode); return offers.isEmpty() ? offers : sortOffersByDate(offers); @@ -315,6 +344,13 @@ public List getMyOffersSortedByDate(String currencyCode) { return sortOffersByDate(offers); } + public List getMyBsqOffersSortedByDate() { + ArrayList offers = new ArrayList<>(); + offers.addAll(getMyBsqOffers(BUY.name())); + offers.addAll(getMyBsqOffers(SELL.name())); + return sortOffersByDate(offers); + } + public OfferInfo getMostRecentOffer(String direction, String currencyCode) { List offers = getOffersSortedByDate(direction, currencyCode); return offers.isEmpty() ? null : offers.get(offers.size() - 1); @@ -323,7 +359,7 @@ public OfferInfo getMostRecentOffer(String direction, String currencyCode) { public List sortOffersByDate(List offerInfoList) { return offerInfoList.stream() .sorted(comparing(OfferInfo::getDate)) - .collect(Collectors.toList()); + .collect(toList()); } public TakeOfferReply getTakeOfferReply(String offerId, String paymentAccountId, String takerFeeCurrencyCode) { @@ -340,7 +376,7 @@ public TradeInfo takeOffer(String offerId, String paymentAccountId, String taker if (reply.hasTrade()) return reply.getTrade(); else - throw new IllegalStateException(reply.getAvailabilityResultDescription()); + throw new IllegalStateException(reply.getFailureReason().getDescription()); } public TradeInfo getTrade(String tradeId) { @@ -404,6 +440,24 @@ public List getPaymentAccounts() { return grpcStubs.paymentAccountsService.getPaymentAccounts(request).getPaymentAccountsList(); } + public PaymentAccount createCryptoCurrencyPaymentAccount(String accountName, + String currencyCode, + String address, + boolean tradeInstant) { + var request = CreateCryptoCurrencyPaymentAccountRequest.newBuilder() + .setAccountName(accountName) + .setCurrencyCode(currencyCode) + .setAddress(address) + .setTradeInstant(tradeInstant) + .build(); + return grpcStubs.paymentAccountsService.createCryptoCurrencyPaymentAccount(request).getPaymentAccount(); + } + + public List getCryptoPaymentMethods() { + var request = GetCryptoCurrencyPaymentMethodsRequest.newBuilder().build(); + return grpcStubs.paymentAccountsService.getCryptoCurrencyPaymentMethods(request).getPaymentMethodsList(); + } + public void lockWallet() { var request = LockWalletRequest.newBuilder().build(); grpcStubs.walletsService.lockWallet(request); diff --git a/cli/src/main/java/bisq/cli/Method.java b/cli/src/main/java/bisq/cli/Method.java index 67b582f13c6..908321a0902 100644 --- a/cli/src/main/java/bisq/cli/Method.java +++ b/cli/src/main/java/bisq/cli/Method.java @@ -26,6 +26,7 @@ public enum Method { confirmpaymentstarted, createoffer, createpaymentacct, + createcryptopaymentacct, getaddressbalance, getbalance, getbtcprice, diff --git a/cli/src/main/java/bisq/cli/TableFormat.java b/cli/src/main/java/bisq/cli/TableFormat.java index 112de0e7d74..5c123184e94 100644 --- a/cli/src/main/java/bisq/cli/TableFormat.java +++ b/cli/src/main/java/bisq/cli/TableFormat.java @@ -36,7 +36,10 @@ import static bisq.cli.ColumnHeaderConstants.*; import static bisq.cli.CurrencyFormat.*; +import static bisq.cli.DirectionFormat.directionFormat; +import static bisq.cli.DirectionFormat.getLongestDirectionColWidth; import static com.google.common.base.Strings.padEnd; +import static com.google.common.base.Strings.padStart; import static java.lang.String.format; import static java.util.Collections.max; import static java.util.Comparator.comparing; @@ -114,34 +117,72 @@ public static String formatBtcBalanceInfoTbl(BtcBalanceInfo btcBalanceInfo) { formatSatoshis(btcBalanceInfo.getLockedBalance())); } - public static String formatOfferTable(List offerInfo, String fiatCurrency) { + public static String formatPaymentAcctTbl(List paymentAccounts) { // Some column values might be longer than header, so we need to calculate them. - int paymentMethodColWidth = getLengthOfLongestColumn( + int nameColWidth = getLongestColumnSize( + COL_HEADER_NAME.length(), + paymentAccounts.stream().map(PaymentAccount::getAccountName) + .collect(Collectors.toList())); + int paymentMethodColWidth = getLongestColumnSize( COL_HEADER_PAYMENT_METHOD.length(), - offerInfo.stream() - .map(OfferInfo::getPaymentMethodShortName) + paymentAccounts.stream().map(a -> a.getPaymentMethod().getId()) .collect(Collectors.toList())); + String headerLine = padEnd(COL_HEADER_NAME, nameColWidth, ' ') + COL_HEADER_DELIMITER + + COL_HEADER_CURRENCY + COL_HEADER_DELIMITER + + padEnd(COL_HEADER_PAYMENT_METHOD, paymentMethodColWidth, ' ') + COL_HEADER_DELIMITER + + COL_HEADER_UUID + COL_HEADER_DELIMITER + "\n"; + String colDataFormat = "%-" + nameColWidth + "s" // left justify + + " %-" + COL_HEADER_CURRENCY.length() + "s" // left justify + + " %-" + paymentMethodColWidth + "s" // left justify + + " %-" + COL_HEADER_UUID.length() + "s"; // left justify + return headerLine + + paymentAccounts.stream() + .map(a -> format(colDataFormat, + a.getAccountName(), + a.getSelectedTradeCurrency().getCode(), + a.getPaymentMethod().getId(), + a.getId())) + .collect(Collectors.joining("\n")); + } + + public static String formatOfferTable(List offers, String currencyCode) { + if (offers == null || offers.isEmpty()) + throw new IllegalArgumentException(format("%s offers argument is empty", currencyCode.toLowerCase())); + + String baseCurrencyCode = offers.get(0).getBaseCurrencyCode(); + return baseCurrencyCode.equalsIgnoreCase("BTC") + ? formatFiatOfferTable(offers, currencyCode) + : formatCryptoCurrencyOfferTable(offers, baseCurrencyCode); + } + + private static String formatFiatOfferTable(List offers, String fiatCurrencyCode) { + // Some column values might be longer than header, so we need to calculate them. + int amountColWith = getLongestAmountColWidth(offers); + int volumeColWidth = getLongestVolumeColWidth(offers); + int paymentMethodColWidth = getLongestPaymentMethodColWidth(offers); String headersFormat = COL_HEADER_DIRECTION + COL_HEADER_DELIMITER - + COL_HEADER_PRICE + COL_HEADER_DELIMITER // includes %s -> fiatCurrency - + COL_HEADER_AMOUNT + COL_HEADER_DELIMITER - + COL_HEADER_VOLUME + COL_HEADER_DELIMITER // includes %s -> fiatCurrency + + COL_HEADER_PRICE + COL_HEADER_DELIMITER // includes %s -> fiatCurrencyCode + + padStart(COL_HEADER_AMOUNT, amountColWith, ' ') + COL_HEADER_DELIMITER + // COL_HEADER_VOLUME includes %s -> fiatCurrencyCode + + padStart(COL_HEADER_VOLUME, volumeColWidth, ' ') + COL_HEADER_DELIMITER + padEnd(COL_HEADER_PAYMENT_METHOD, paymentMethodColWidth, ' ') + COL_HEADER_DELIMITER + COL_HEADER_CREATION_DATE + COL_HEADER_DELIMITER + COL_HEADER_UUID.trim() + "%n"; - String headerLine = format(headersFormat, fiatCurrency.toUpperCase(), fiatCurrency.toUpperCase()); - - String colDataFormat = "%-" + (COL_HEADER_DIRECTION.length() + COL_HEADER_DELIMITER.length()) + "s" // left - + "%" + (COL_HEADER_PRICE.length() - 1) + "s" // rt justify to end of hdr - + " %-" + (COL_HEADER_AMOUNT.length() - 1) + "s" // left justify - + " %" + COL_HEADER_VOLUME.length() + "s" // right justify - + " %-" + paymentMethodColWidth + "s" // left justify - + " %-" + (COL_HEADER_CREATION_DATE.length()) + "s" // left justify + String headerLine = format(headersFormat, + fiatCurrencyCode.toUpperCase(), + fiatCurrencyCode.toUpperCase()); + String colDataFormat = "%-" + (COL_HEADER_DIRECTION.length() + COL_HEADER_DELIMITER.length()) + "s" + + "%" + (COL_HEADER_PRICE.length() - 1) + "s" + + " %" + amountColWith + "s" + + " %" + (volumeColWidth - 1) + "s" + + " %-" + paymentMethodColWidth + "s" + + " %-" + (COL_HEADER_CREATION_DATE.length()) + "s" + " %-" + COL_HEADER_UUID.length() + "s"; return headerLine - + offerInfo.stream() + + offers.stream() .map(o -> format(colDataFormat, o.getDirection(), - formatOfferPrice(o.getPrice()), + formatPrice(o.getPrice()), formatAmountRange(o.getMinAmount(), o.getAmount()), formatVolumeRange(o.getMinVolume(), o.getVolume()), o.getPaymentMethodShortName(), @@ -150,37 +191,80 @@ public static String formatOfferTable(List offerInfo, String fiatCurr .collect(Collectors.joining("\n")); } - public static String formatPaymentAcctTbl(List paymentAccounts) { + private static String formatCryptoCurrencyOfferTable(List offers, String cryptoCurrencyCode) { // Some column values might be longer than header, so we need to calculate them. - int nameColWidth = getLengthOfLongestColumn( - COL_HEADER_NAME.length(), - paymentAccounts.stream().map(PaymentAccount::getAccountName) - .collect(Collectors.toList())); - int paymentMethodColWidth = getLengthOfLongestColumn( - COL_HEADER_PAYMENT_METHOD.length(), - paymentAccounts.stream().map(a -> a.getPaymentMethod().getId()) - .collect(Collectors.toList())); - - String headerLine = padEnd(COL_HEADER_NAME, nameColWidth, ' ') + COL_HEADER_DELIMITER - + COL_HEADER_CURRENCY + COL_HEADER_DELIMITER + int directionColWidth = getLongestDirectionColWidth(offers); + int amountColWith = getLongestAmountColWidth(offers); + int volumeColWidth = getLongestCryptoCurrencyVolumeColWidth(offers); + int paymentMethodColWidth = getLongestPaymentMethodColWidth(offers); + // TODO use memoize function to avoid duplicate the formatting done above? + String headersFormat = padEnd(COL_HEADER_DIRECTION, directionColWidth, ' ') + COL_HEADER_DELIMITER + + COL_HEADER_PRICE_OF_ALTCOIN + COL_HEADER_DELIMITER // includes %s -> cryptoCurrencyCode + + padStart(COL_HEADER_AMOUNT, amountColWith, ' ') + COL_HEADER_DELIMITER + // COL_HEADER_VOLUME includes %s -> cryptoCurrencyCode + + padStart(COL_HEADER_VOLUME, volumeColWidth, ' ') + COL_HEADER_DELIMITER + padEnd(COL_HEADER_PAYMENT_METHOD, paymentMethodColWidth, ' ') + COL_HEADER_DELIMITER - + COL_HEADER_UUID + COL_HEADER_DELIMITER + "\n"; - String colDataFormat = "%-" + nameColWidth + "s" // left justify - + " %-" + COL_HEADER_CURRENCY.length() + "s" // left justify - + " %-" + paymentMethodColWidth + "s" // left justify - + " %-" + COL_HEADER_UUID.length() + "s"; // left justify + + COL_HEADER_CREATION_DATE + COL_HEADER_DELIMITER + + COL_HEADER_UUID.trim() + "%n"; + String headerLine = format(headersFormat, + cryptoCurrencyCode.toUpperCase(), + cryptoCurrencyCode.toUpperCase()); + String colDataFormat = "%-" + directionColWidth + "s" + + "%" + (COL_HEADER_PRICE_OF_ALTCOIN.length() + 1) + "s" + + " %" + amountColWith + "s" + + " %" + (volumeColWidth - 1) + "s" + + " %-" + paymentMethodColWidth + "s" + + " %-" + (COL_HEADER_CREATION_DATE.length()) + "s" + + " %-" + COL_HEADER_UUID.length() + "s"; return headerLine - + paymentAccounts.stream() - .map(a -> format(colDataFormat, - a.getAccountName(), - a.getSelectedTradeCurrency().getCode(), - a.getPaymentMethod().getId(), - a.getId())) + + offers.stream() + .map(o -> format(colDataFormat, + directionFormat.apply(o), + formatCryptoCurrencyPrice(o.getPrice()), + formatAmountRange(o.getMinAmount(), o.getAmount()), + formatCryptoCurrencyVolumeRange(o.getMinVolume(), o.getVolume()), + o.getPaymentMethodShortName(), + formatTimestamp(o.getDate()), + o.getId())) .collect(Collectors.joining("\n")); } - // Return length of the longest string value, or the header.len, whichever is greater. - private static int getLengthOfLongestColumn(int headerLength, List strings) { + private static int getLongestPaymentMethodColWidth(List offers) { + return getLongestColumnSize( + COL_HEADER_PAYMENT_METHOD.length(), + offers.stream() + .map(OfferInfo::getPaymentMethodShortName) + .collect(Collectors.toList())); + } + + private static int getLongestAmountColWidth(List offers) { + return getLongestColumnSize( + COL_HEADER_AMOUNT.length(), + offers.stream() + .map(o -> formatAmountRange(o.getMinAmount(), o.getAmount())) + .collect(Collectors.toList())); + } + + private static int getLongestVolumeColWidth(List offers) { + // Pad this col width by 1 space. + return 1 + getLongestColumnSize( + COL_HEADER_VOLUME.length(), + offers.stream() + .map(o -> formatVolumeRange(o.getMinVolume(), o.getVolume())) + .collect(Collectors.toList())); + } + + private static int getLongestCryptoCurrencyVolumeColWidth(List offers) { + // Pad this col width by 1 space. + return 1 + getLongestColumnSize( + COL_HEADER_VOLUME.length(), + offers.stream() + .map(o -> formatCryptoCurrencyVolumeRange(o.getMinVolume(), o.getVolume())) + .collect(Collectors.toList())); + } + + // Return size of the longest string value, or the header.len, whichever is greater. + private static int getLongestColumnSize(int headerLength, List strings) { int longest = max(strings, comparing(String::length)).length(); return Math.max(longest, headerLength); } diff --git a/cli/src/main/java/bisq/cli/TradeFormat.java b/cli/src/main/java/bisq/cli/TradeFormat.java index 668f9603552..dbf8dbf4b86 100644 --- a/cli/src/main/java/bisq/cli/TradeFormat.java +++ b/cli/src/main/java/bisq/cli/TradeFormat.java @@ -17,21 +17,27 @@ package bisq.cli; +import bisq.proto.grpc.ContractInfo; import bisq.proto.grpc.TradeInfo; import com.google.common.annotations.VisibleForTesting; +import java.util.function.BiFunction; +import java.util.function.Function; import java.util.function.Supplier; import static bisq.cli.ColumnHeaderConstants.*; -import static bisq.cli.CurrencyFormat.formatOfferPrice; -import static bisq.cli.CurrencyFormat.formatOfferVolume; -import static bisq.cli.CurrencyFormat.formatSatoshis; +import static bisq.cli.CurrencyFormat.*; import static com.google.common.base.Strings.padEnd; @VisibleForTesting public class TradeFormat { + private static final String YES = "YES"; + private static final String NO = "NO"; + + // TODO add String format(List trades) + @VisibleForTesting public static String format(TradeInfo tradeInfo) { // Some column values might be longer than header, so we need to calculate them. @@ -40,19 +46,31 @@ public static String format(TradeInfo tradeInfo) { // We only show taker fee under its header when user is the taker. boolean isTaker = tradeInfo.getRole().toLowerCase().contains("taker"); - Supplier takerFeeHeaderFormat = () -> isTaker ? - padEnd(COL_HEADER_TRADE_TAKER_FEE, 12, ' ') + COL_HEADER_DELIMITER + Supplier makerFeeHeader = () -> !isTaker ? + COL_HEADER_TRADE_MAKER_FEE + COL_HEADER_DELIMITER + : ""; + Supplier makerFeeHeaderSpec = () -> !isTaker ? + "%" + (COL_HEADER_TRADE_MAKER_FEE.length() + 2) + "s" : ""; Supplier takerFeeHeader = () -> isTaker ? - "%" + (COL_HEADER_TRADE_TAKER_FEE.length() + 1) + "s" + COL_HEADER_TRADE_TAKER_FEE + COL_HEADER_DELIMITER : ""; + Supplier takerFeeHeaderSpec = () -> isTaker ? + "%" + (COL_HEADER_TRADE_TAKER_FEE.length() + 2) + "s" + : ""; + + boolean showBsqBuyerAddress = shouldShowBsqBuyerAddress(tradeInfo, isTaker); + Supplier bsqBuyerAddressHeader = () -> showBsqBuyerAddress ? COL_HEADER_TRADE_BSQ_BUYER_ADDRESS : ""; + Supplier bsqBuyerAddressHeaderSpec = () -> showBsqBuyerAddress ? "%s" : ""; String headersFormat = padEnd(COL_HEADER_TRADE_SHORT_ID, shortIdColWidth, ' ') + COL_HEADER_DELIMITER + padEnd(COL_HEADER_TRADE_ROLE, roleColWidth, ' ') + COL_HEADER_DELIMITER - + COL_HEADER_PRICE + COL_HEADER_DELIMITER // includes %s -> currencyCode + + priceHeader.apply(tradeInfo) + COL_HEADER_DELIMITER // includes %s -> currencyCode + padEnd(COL_HEADER_TRADE_AMOUNT, 12, ' ') + COL_HEADER_DELIMITER + padEnd(COL_HEADER_TRADE_TX_FEE, 12, ' ') + COL_HEADER_DELIMITER - + takerFeeHeaderFormat.get() + + makerFeeHeader.get() + // maker or taker fee header, not both + + takerFeeHeader.get() + COL_HEADER_TRADE_DEPOSIT_PUBLISHED + COL_HEADER_DELIMITER + COL_HEADER_TRADE_DEPOSIT_CONFIRMED + COL_HEADER_DELIMITER + COL_HEADER_TRADE_BUYER_COST + COL_HEADER_DELIMITER @@ -60,79 +78,144 @@ public static String format(TradeInfo tradeInfo) { + COL_HEADER_TRADE_PAYMENT_RECEIVED + COL_HEADER_DELIMITER + COL_HEADER_TRADE_PAYOUT_PUBLISHED + COL_HEADER_DELIMITER + COL_HEADER_TRADE_WITHDRAWN + COL_HEADER_DELIMITER + + bsqBuyerAddressHeader.get() + "%n"; String counterCurrencyCode = tradeInfo.getOffer().getCounterCurrencyCode(); String baseCurrencyCode = tradeInfo.getOffer().getBaseCurrencyCode(); - // The taker's output contains an extra taker tx fee column. - String headerLine = isTaker - ? String.format(headersFormat, - /* COL_HEADER_PRICE */ counterCurrencyCode, + String headerLine = String.format(headersFormat, + /* COL_HEADER_PRICE */ priceHeaderCurrencyCode.apply(tradeInfo), /* COL_HEADER_TRADE_AMOUNT */ baseCurrencyCode, - /* COL_HEADER_TRADE_TX_FEE */ baseCurrencyCode, - /* COL_HEADER_TRADE_TAKER_FEE */ baseCurrencyCode, + /* COL_HEADER_TRADE_(M||T)AKER_FEE */ makerTakerFeeHeaderCurrencyCode.apply(tradeInfo, isTaker), /* COL_HEADER_TRADE_BUYER_COST */ counterCurrencyCode, - /* COL_HEADER_TRADE_PAYMENT_SENT */ counterCurrencyCode, - /* COL_HEADER_TRADE_PAYMENT_RECEIVED */ counterCurrencyCode) - : String.format(headersFormat, - /* COL_HEADER_PRICE */ counterCurrencyCode, - /* COL_HEADER_TRADE_AMOUNT */ baseCurrencyCode, - /* COL_HEADER_TRADE_TX_FEE */ baseCurrencyCode, - /* COL_HEADER_TRADE_BUYER_COST */ counterCurrencyCode, - /* COL_HEADER_TRADE_PAYMENT_SENT */ counterCurrencyCode, - /* COL_HEADER_TRADE_PAYMENT_RECEIVED */ counterCurrencyCode); + /* COL_HEADER_TRADE_PAYMENT_SENT */ paymentStatusHeaderCurrencyCode.apply(tradeInfo), + /* COL_HEADER_TRADE_PAYMENT_RECEIVED */ paymentStatusHeaderCurrencyCode.apply(tradeInfo)); String colDataFormat = "%-" + shortIdColWidth + "s" // lt justify + " %-" + (roleColWidth + COL_HEADER_DELIMITER.length()) + "s" // left + "%" + (COL_HEADER_PRICE.length() - 1) + "s" // rt justify + "%" + (COL_HEADER_TRADE_AMOUNT.length() + 1) + "s" // rt justify + "%" + (COL_HEADER_TRADE_TX_FEE.length() + 1) + "s" // rt justify - + takerFeeHeader.get() // rt justify + + makerFeeHeaderSpec.get() // rt justify + // OR (one of them is an empty string) + + takerFeeHeaderSpec.get() // rt justify + " %-" + COL_HEADER_TRADE_DEPOSIT_PUBLISHED.length() + "s" // lt justify + " %-" + COL_HEADER_TRADE_DEPOSIT_CONFIRMED.length() + "s" // lt justify + "%" + (COL_HEADER_TRADE_BUYER_COST.length() + 1) + "s" // rt justify + " %-" + (COL_HEADER_TRADE_PAYMENT_SENT.length() - 1) + "s" // left + " %-" + (COL_HEADER_TRADE_PAYMENT_RECEIVED.length() - 1) + "s" // left + " %-" + COL_HEADER_TRADE_PAYOUT_PUBLISHED.length() + "s" // lt justify - + " %-" + COL_HEADER_TRADE_WITHDRAWN.length() + "s"; // lt justify + + " %-" + (COL_HEADER_TRADE_WITHDRAWN.length() + 2) + "s" + + bsqBuyerAddressHeaderSpec.get(); - return headerLine + - (isTaker - ? formatTradeForTaker(colDataFormat, tradeInfo) - : formatTradeForMaker(colDataFormat, tradeInfo)); + return headerLine + formatTradeData(colDataFormat, tradeInfo, isTaker, showBsqBuyerAddress); } - private static String formatTradeForMaker(String format, TradeInfo tradeInfo) { + private static String formatTradeData(String format, + TradeInfo tradeInfo, + boolean isTaker, + boolean showBsqBuyerAddress) { return String.format(format, tradeInfo.getShortId(), tradeInfo.getRole(), - formatOfferPrice(tradeInfo.getTradePrice()), - formatSatoshis(tradeInfo.getTradeAmountAsLong()), - formatSatoshis(tradeInfo.getTxFeeAsLong()), - tradeInfo.getIsDepositPublished() ? "YES" : "NO", - tradeInfo.getIsDepositConfirmed() ? "YES" : "NO", - formatOfferVolume(tradeInfo.getOffer().getVolume()), - tradeInfo.getIsFiatSent() ? "YES" : "NO", - tradeInfo.getIsFiatReceived() ? "YES" : "NO", - tradeInfo.getIsPayoutPublished() ? "YES" : "NO", - tradeInfo.getIsWithdrawn() ? "YES" : "NO"); + priceFormat.apply(tradeInfo), + amountFormat.apply(tradeInfo), + makerTakerMinerTxFeeFormat.apply(tradeInfo, isTaker), + makerTakerFeeFormat.apply(tradeInfo, isTaker), + tradeInfo.getIsDepositPublished() ? YES : NO, + tradeInfo.getIsDepositConfirmed() ? YES : NO, + tradeCostFormat.apply(tradeInfo), + tradeInfo.getIsFiatSent() ? YES : NO, + tradeInfo.getIsFiatReceived() ? YES : NO, + tradeInfo.getIsPayoutPublished() ? YES : NO, + tradeInfo.getIsWithdrawn() ? YES : NO, + bsqReceiveAddress.apply(tradeInfo, showBsqBuyerAddress)); } - private static String formatTradeForTaker(String format, TradeInfo tradeInfo) { - return String.format(format, - tradeInfo.getShortId(), - tradeInfo.getRole(), - formatOfferPrice(tradeInfo.getTradePrice()), - formatSatoshis(tradeInfo.getTradeAmountAsLong()), - formatSatoshis(tradeInfo.getTxFeeAsLong()), - formatSatoshis(tradeInfo.getTakerFeeAsLong()), - tradeInfo.getIsDepositPublished() ? "YES" : "NO", - tradeInfo.getIsDepositConfirmed() ? "YES" : "NO", - formatOfferVolume(tradeInfo.getOffer().getVolume()), - tradeInfo.getIsFiatSent() ? "YES" : "NO", - tradeInfo.getIsFiatReceived() ? "YES" : "NO", - tradeInfo.getIsPayoutPublished() ? "YES" : "NO", - tradeInfo.getIsWithdrawn() ? "YES" : "NO"); + private static final Function priceHeader = (t) -> + t.getOffer().getBaseCurrencyCode().equals("BTC") + ? COL_HEADER_PRICE + : COL_HEADER_PRICE_OF_ALTCOIN; + + private static final Function priceHeaderCurrencyCode = (t) -> + t.getOffer().getBaseCurrencyCode().equals("BTC") + ? t.getOffer().getCounterCurrencyCode() + : t.getOffer().getBaseCurrencyCode(); + + private static final BiFunction makerTakerFeeHeaderCurrencyCode = (t, isTaker) -> { + if (isTaker) { + return t.getIsCurrencyForTakerFeeBtc() ? "BTC" : "BSQ"; + } else { + return t.getOffer().getIsCurrencyForMakerFeeBtc() ? "BTC" : "BSQ"; + } + }; + + private static final Function paymentStatusHeaderCurrencyCode = (t) -> + t.getOffer().getBaseCurrencyCode().equals("BTC") + ? t.getOffer().getCounterCurrencyCode() + : t.getOffer().getBaseCurrencyCode(); + + private static final Function priceFormat = (t) -> + t.getOffer().getBaseCurrencyCode().equals("BTC") + ? formatPrice(t.getTradePrice()) + : formatCryptoCurrencyPrice(t.getOffer().getPrice()); + + private static final Function amountFormat = (t) -> + t.getOffer().getBaseCurrencyCode().equals("BTC") + ? formatSatoshis(t.getTradeAmountAsLong()) + : formatCryptoCurrencyOfferVolume(t.getOffer().getVolume()); + + private static final BiFunction makerTakerMinerTxFeeFormat = (t, isTaker) -> { + if (isTaker) { + return formatSatoshis(t.getTxFeeAsLong()); + } else { + return formatSatoshis(t.getOffer().getTxFee()); + } + }; + + private static final BiFunction makerTakerFeeFormat = (t, isTaker) -> { + if (isTaker) { + return t.getIsCurrencyForTakerFeeBtc() + ? formatSatoshis(t.getTakerFeeAsLong()) + : formatBsq(t.getTakerFeeAsLong()); + } else { + return t.getOffer().getIsCurrencyForMakerFeeBtc() + ? formatSatoshis(t.getOffer().getMakerFee()) + : formatBsq(t.getOffer().getMakerFee()); + } + }; + + private static final Function tradeCostFormat = (t) -> + t.getOffer().getBaseCurrencyCode().equals("BTC") + ? formatOfferVolume(t.getOffer().getVolume()) + : formatSatoshis(t.getTradeAmountAsLong()); + + private static final BiFunction bsqReceiveAddress = (t, showBsqBuyerAddress) -> { + if (showBsqBuyerAddress) { + ContractInfo contract = t.getContract(); + boolean isBuyerMakerAndSellerTaker = contract.getIsBuyerMakerAndSellerTaker(); + return isBuyerMakerAndSellerTaker // (is BTC buyer / maker) + ? contract.getTakerPaymentAccountPayload().getAddress() + : contract.getMakerPaymentAccountPayload().getAddress(); + } else { + return ""; + } + }; + + private static boolean shouldShowBsqBuyerAddress(TradeInfo tradeInfo, boolean isTaker) { + if (tradeInfo.getOffer().getBaseCurrencyCode().equals("BTC")) { + return false; + } else { + ContractInfo contract = tradeInfo.getContract(); + // Do not forget buyer and seller refer to BTC buyer and seller, not BSQ + // buyer and seller. If you are buying BSQ, you are the (BTC) seller. + boolean isBuyerMakerAndSellerTaker = contract.getIsBuyerMakerAndSellerTaker(); + if (isTaker) { + return !isBuyerMakerAndSellerTaker; + } else { + return isBuyerMakerAndSellerTaker; + } + } } } diff --git a/cli/src/main/java/bisq/cli/opts/AbstractMethodOptionParser.java b/cli/src/main/java/bisq/cli/opts/AbstractMethodOptionParser.java index 25256eb6a99..3d25dc07704 100644 --- a/cli/src/main/java/bisq/cli/opts/AbstractMethodOptionParser.java +++ b/cli/src/main/java/bisq/cli/opts/AbstractMethodOptionParser.java @@ -29,7 +29,7 @@ import static bisq.cli.opts.OptLabel.OPT_HELP; -abstract class AbstractMethodOptionParser implements MethodOpts { +public abstract class AbstractMethodOptionParser implements MethodOpts { // The full command line args passed to CliMain.main(String[] args). // CLI and Method level arguments are derived from args by an ArgumentList(args). diff --git a/cli/src/main/java/bisq/cli/opts/ArgumentList.java b/cli/src/main/java/bisq/cli/opts/ArgumentList.java index 3b52fb34a90..b416946d646 100644 --- a/cli/src/main/java/bisq/cli/opts/ArgumentList.java +++ b/cli/src/main/java/bisq/cli/opts/ArgumentList.java @@ -113,6 +113,7 @@ boolean hasMore() { return currentIndex < arguments.length; } + @SuppressWarnings("UnusedReturnValue") String next() { return arguments[currentIndex++]; } diff --git a/cli/src/main/java/bisq/cli/opts/CreateCryptoCurrencyPaymentAcctOptionParser.java b/cli/src/main/java/bisq/cli/opts/CreateCryptoCurrencyPaymentAcctOptionParser.java new file mode 100644 index 00000000000..a37a9f109bb --- /dev/null +++ b/cli/src/main/java/bisq/cli/opts/CreateCryptoCurrencyPaymentAcctOptionParser.java @@ -0,0 +1,85 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.cli.opts; + + +import joptsimple.OptionSpec; + +import static bisq.cli.opts.OptLabel.OPT_ACCOUNT_NAME; +import static bisq.cli.opts.OptLabel.OPT_ADDRESS; +import static bisq.cli.opts.OptLabel.OPT_CURRENCY_CODE; +import static bisq.cli.opts.OptLabel.OPT_TRADE_INSTANT; + +public class CreateCryptoCurrencyPaymentAcctOptionParser extends AbstractMethodOptionParser implements MethodOpts { + + final OptionSpec accountNameOpt = parser.accepts(OPT_ACCOUNT_NAME, "crypto currency account name") + .withRequiredArg(); + + final OptionSpec currencyCodeOpt = parser.accepts(OPT_CURRENCY_CODE, "crypto currency code (bsq only)") + .withRequiredArg(); + + final OptionSpec addressOpt = parser.accepts(OPT_ADDRESS, "bsq address") + .withRequiredArg(); + + final OptionSpec tradeInstantOpt = parser.accepts(OPT_TRADE_INSTANT, "create trade instant account") + .withOptionalArg() + .ofType(boolean.class) + .defaultsTo(Boolean.FALSE); + + public CreateCryptoCurrencyPaymentAcctOptionParser(String[] args) { + super(args); + } + + public CreateCryptoCurrencyPaymentAcctOptionParser parse() { + super.parse(); + + // Short circuit opt validation if user just wants help. + if (options.has(helpOpt)) + return this; + + if (!options.has(accountNameOpt) || options.valueOf(accountNameOpt).isEmpty()) + throw new IllegalArgumentException("no payment account name specified"); + + if (!options.has(currencyCodeOpt) || options.valueOf(currencyCodeOpt).isEmpty()) + throw new IllegalArgumentException("no currency code specified"); + + if (!options.valueOf(currencyCodeOpt).equalsIgnoreCase("bsq")) + throw new IllegalArgumentException("api only supports bsq crypto currency payment accounts"); + + if (!options.has(addressOpt) || options.valueOf(addressOpt).isEmpty()) + throw new IllegalArgumentException("no bsq address specified"); + + return this; + } + + public String getAccountName() { + return options.valueOf(accountNameOpt); + } + + public String getCurrencyCode() { + return options.valueOf(currencyCodeOpt); + } + + public String getAddress() { + return options.valueOf(addressOpt); + } + + public boolean getIsTradeInstant() { + return options.valueOf(tradeInstantOpt); + } +} diff --git a/cli/src/main/java/bisq/cli/opts/MethodOpts.java b/cli/src/main/java/bisq/cli/opts/MethodOpts.java index da639857522..9f6c2d1a3e4 100644 --- a/cli/src/main/java/bisq/cli/opts/MethodOpts.java +++ b/cli/src/main/java/bisq/cli/opts/MethodOpts.java @@ -22,5 +22,4 @@ public interface MethodOpts { MethodOpts parse(); boolean isForHelp(); - } diff --git a/cli/src/main/java/bisq/cli/opts/OptLabel.java b/cli/src/main/java/bisq/cli/opts/OptLabel.java index 5cbd068ce53..084c230aae3 100644 --- a/cli/src/main/java/bisq/cli/opts/OptLabel.java +++ b/cli/src/main/java/bisq/cli/opts/OptLabel.java @@ -21,6 +21,7 @@ * CLI opt label definitions. */ public class OptLabel { + public final static String OPT_ACCOUNT_NAME = "account-name"; public final static String OPT_ADDRESS = "address"; public final static String OPT_AMOUNT = "amount"; public final static String OPT_CURRENCY_CODE = "currency-code"; @@ -43,6 +44,7 @@ public class OptLabel { public final static String OPT_SECURITY_DEPOSIT = "security-deposit"; public final static String OPT_SHOW_CONTRACT = "show-contract"; public final static String OPT_TRADE_ID = "trade-id"; + public final static String OPT_TRADE_INSTANT = "trade-instant"; public final static String OPT_TIMEOUT = "timeout"; public final static String OPT_TRANSACTION_ID = "transaction-id"; public final static String OPT_TX_FEE_RATE = "tx-fee-rate"; diff --git a/cli/src/test/java/bisq/cli/opt/OptionParsersTest.java b/cli/src/test/java/bisq/cli/opt/OptionParsersTest.java index 1f939198d69..d5cfe5d708f 100644 --- a/cli/src/test/java/bisq/cli/opt/OptionParsersTest.java +++ b/cli/src/test/java/bisq/cli/opt/OptionParsersTest.java @@ -3,6 +3,7 @@ import org.junit.jupiter.api.Test; import static bisq.cli.Method.canceloffer; +import static bisq.cli.Method.createcryptopaymentacct; import static bisq.cli.Method.createoffer; import static bisq.cli.Method.createpaymentacct; import static bisq.cli.opts.OptLabel.*; @@ -12,6 +13,7 @@ import bisq.cli.opts.CancelOfferOptionParser; +import bisq.cli.opts.CreateCryptoCurrencyPaymentAcctOptionParser; import bisq.cli.opts.CreateOfferOptionParser; import bisq.cli.opts.CreatePaymentAcctOptionParser; @@ -20,7 +22,7 @@ public class OptionParsersTest { private static final String PASSWORD_OPT = "--" + OPT_PASSWORD + "=" + "xyz"; - // CancelOffer opt parsing tests + // canceloffer opt parser tests @Test public void testCancelOfferWithMissingOfferIdOptShouldThrowException() { @@ -67,7 +69,7 @@ public void testValidCancelOfferOpts() { new CancelOfferOptionParser(args).parse(); } - // CreateOffer opt parsing tests + // createoffer opt parser tests @Test public void testCreateOfferOptParserWithMissingPaymentAccountIdOptShouldThrowException() { @@ -139,7 +141,7 @@ public void testValidCreateOfferOpts() { assertEquals("25.0", parser.getSecurityDeposit()); } - // CreatePaymentAcct opt parser tests + // createpaymentacct opt parser tests @Test public void testCreatePaymentAcctOptParserWithMissingPaymentFormOptShouldThrowException() { @@ -177,4 +179,85 @@ public void testCreatePaymentAcctOptParserWithInvalidPaymentFormOptValueShouldTh assertEquals("json payment account form '/tmp/milkyway/solarsystem/mars' could not be found", exception.getMessage()); } + + // createcryptopaymentacct parser tests + + @Test + public void testCreateCryptoCurrencyPaymentAcctOptionParserWithMissingAcctNameOptShouldThrowException() { + String[] args = new String[]{ + PASSWORD_OPT, + createcryptopaymentacct.name() + }; + Throwable exception = assertThrows(RuntimeException.class, () -> + new CreateCryptoCurrencyPaymentAcctOptionParser(args).parse()); + assertEquals("no payment account name specified", exception.getMessage()); + } + + @Test + public void testCreateCryptoCurrencyPaymentAcctOptionParserWithEmptyAcctNameOptShouldThrowException() { + String[] args = new String[]{ + PASSWORD_OPT, + createcryptopaymentacct.name(), + "--" + OPT_ACCOUNT_NAME + }; + Throwable exception = assertThrows(RuntimeException.class, () -> + new CreateCryptoCurrencyPaymentAcctOptionParser(args).parse()); + assertEquals("account-name requires an argument", exception.getMessage()); + } + + @Test + public void testCreateCryptoCurrencyPaymentAcctOptionParserWithMissingCurrencyCodeOptShouldThrowException() { + String[] args = new String[]{ + PASSWORD_OPT, + createcryptopaymentacct.name(), + "--" + OPT_ACCOUNT_NAME + "=" + "bsq payment account" + }; + Throwable exception = assertThrows(RuntimeException.class, () -> + new CreateCryptoCurrencyPaymentAcctOptionParser(args).parse()); + assertEquals("no currency code specified", exception.getMessage()); + } + + @Test + public void testCreateCryptoCurrencyPaymentAcctOptionParserWithInvalidCurrencyCodeOptShouldThrowException() { + String[] args = new String[]{ + PASSWORD_OPT, + createcryptopaymentacct.name(), + "--" + OPT_ACCOUNT_NAME + "=" + "bsq payment account", + "--" + OPT_CURRENCY_CODE + "=" + "xmr" + }; + Throwable exception = assertThrows(RuntimeException.class, () -> + new CreateCryptoCurrencyPaymentAcctOptionParser(args).parse()); + assertEquals("api only supports bsq crypto currency payment accounts", exception.getMessage()); + } + + @Test + public void testCreateCryptoCurrencyPaymentAcctOptionParserWithMissingAddressOptShouldThrowException() { + String[] args = new String[]{ + PASSWORD_OPT, + createcryptopaymentacct.name(), + "--" + OPT_ACCOUNT_NAME + "=" + "bsq payment account", + "--" + OPT_CURRENCY_CODE + "=" + "bsq" + }; + Throwable exception = assertThrows(RuntimeException.class, () -> + new CreateCryptoCurrencyPaymentAcctOptionParser(args).parse()); + assertEquals("no bsq address specified", exception.getMessage()); + } + + @Test + public void testCreateCryptoCurrencyPaymentAcctOptionParser() { + var acctName = "bsq payment account"; + var currencyCode = "bsq"; + var address = "B1nXyZ"; // address is validated on server + String[] args = new String[]{ + PASSWORD_OPT, + createcryptopaymentacct.name(), + "--" + OPT_ACCOUNT_NAME + "=" + acctName, + "--" + OPT_CURRENCY_CODE + "=" + currencyCode, + "--" + OPT_ADDRESS + "=" + address + }; + var parser = new CreateCryptoCurrencyPaymentAcctOptionParser(args).parse(); + assertEquals(acctName, parser.getAccountName()); + assertEquals(currencyCode, parser.getCurrencyCode()); + assertEquals(address, parser.getAddress()); + } } diff --git a/core/src/main/java/bisq/core/api/CoreApi.java b/core/src/main/java/bisq/core/api/CoreApi.java index 8861620adbc..fe5bbceba58 100644 --- a/core/src/main/java/bisq/core/api/CoreApi.java +++ b/core/src/main/java/bisq/core/api/CoreApi.java @@ -210,6 +210,20 @@ public String getPaymentAccountForm(String paymentMethodId) { return paymentAccountsService.getPaymentAccountFormAsString(paymentMethodId); } + public PaymentAccount createCryptoCurrencyPaymentAccount(String accountName, + String currencyCode, + String address, + boolean tradeInstant) { + return paymentAccountsService.createCryptoCurrencyPaymentAccount(accountName, + currencyCode, + address, + tradeInstant); + } + + public List getCryptoCurrencyPaymentMethods() { + return paymentAccountsService.getCryptoCurrencyPaymentMethods(); + } + /////////////////////////////////////////////////////////////////////////////////////////// // Prices /////////////////////////////////////////////////////////////////////////////////////////// @@ -298,6 +312,10 @@ public void sendBtc(String address, walletsService.sendBtc(address, amount, txFeeRate, memo, callback); } + public boolean verifyBsqSentToAddress(String address, String amount) { + return walletsService.verifyBsqSentToAddress(address, amount); + } + public void getTxFeeRate(ResultHandler resultHandler) { walletsService.getTxFeeRate(resultHandler); } diff --git a/core/src/main/java/bisq/core/api/CorePaymentAccountsService.java b/core/src/main/java/bisq/core/api/CorePaymentAccountsService.java index b915b3ab4be..0843e20ab76 100644 --- a/core/src/main/java/bisq/core/api/CorePaymentAccountsService.java +++ b/core/src/main/java/bisq/core/api/CorePaymentAccountsService.java @@ -19,7 +19,11 @@ import bisq.core.account.witness.AccountAgeWitnessService; import bisq.core.api.model.PaymentAccountForm; +import bisq.core.locale.CryptoCurrency; +import bisq.core.payment.CryptoCurrencyAccount; +import bisq.core.payment.InstantCryptoCurrencyAccount; import bisq.core.payment.PaymentAccount; +import bisq.core.payment.PaymentAccountFactory; import bisq.core.payment.payload.PaymentMethod; import bisq.core.user.User; @@ -30,30 +34,37 @@ import java.util.Comparator; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; +import static bisq.core.locale.CurrencyUtil.getCryptoCurrency; import static java.lang.String.format; @Singleton @Slf4j class CorePaymentAccountsService { + private final CoreWalletsService coreWalletsService; private final AccountAgeWitnessService accountAgeWitnessService; private final PaymentAccountForm paymentAccountForm; private final User user; @Inject - public CorePaymentAccountsService(AccountAgeWitnessService accountAgeWitnessService, + public CorePaymentAccountsService(CoreWalletsService coreWalletsService, + AccountAgeWitnessService accountAgeWitnessService, PaymentAccountForm paymentAccountForm, User user) { + this.coreWalletsService = coreWalletsService; this.accountAgeWitnessService = accountAgeWitnessService; this.paymentAccountForm = paymentAccountForm; this.user = user; } + // Fiat Currency Accounts + PaymentAccount createPaymentAccount(String jsonString) { PaymentAccount paymentAccount = paymentAccountForm.toPaymentAccount(jsonString); verifyPaymentAccountHasRequiredFields(paymentAccount); @@ -86,6 +97,46 @@ File getPaymentAccountForm(String paymentMethodId) { return paymentAccountForm.getPaymentAccountForm(paymentMethodId); } + // Crypto Currency Accounts + + PaymentAccount createCryptoCurrencyPaymentAccount(String accountName, + String currencyCode, + String address, + boolean tradeInstant) { + String bsqCode = currencyCode.toUpperCase(); + if (!bsqCode.equals("BSQ")) + throw new IllegalArgumentException("api does not currently support " + currencyCode + " accounts"); + + // Validate the BSQ address string but ignore the return value. + coreWalletsService.getValidBsqLegacyAddress(address); + + var cryptoCurrencyAccount = tradeInstant + ? (InstantCryptoCurrencyAccount) PaymentAccountFactory.getPaymentAccount(PaymentMethod.BLOCK_CHAINS_INSTANT) + : (CryptoCurrencyAccount) PaymentAccountFactory.getPaymentAccount(PaymentMethod.BLOCK_CHAINS); + cryptoCurrencyAccount.init(); + cryptoCurrencyAccount.setAccountName(accountName); + cryptoCurrencyAccount.setAddress(address); + Optional cryptoCurrency = getCryptoCurrency(bsqCode); + cryptoCurrency.ifPresent(cryptoCurrencyAccount::setSingleTradeCurrency); + user.addPaymentAccount(cryptoCurrencyAccount); + accountAgeWitnessService.publishMyAccountAgeWitness(cryptoCurrencyAccount.getPaymentAccountPayload()); + log.info("Saved crypto payment account with id {} and payment method {}.", + cryptoCurrencyAccount.getId(), + cryptoCurrencyAccount.getPaymentAccountPayload().getPaymentMethodId()); + return cryptoCurrencyAccount; + } + + // TODO Support all alt coin payment methods supported by UI. + // The getCryptoCurrencyPaymentMethods method below will be + // callable from the CLI when more are supported. + + List getCryptoCurrencyPaymentMethods() { + return PaymentMethod.getPaymentMethods().stream() + .filter(PaymentMethod::isAsset) + .sorted(Comparator.comparing(PaymentMethod::getId)) + .collect(Collectors.toList()); + } + private void verifyPaymentAccountHasRequiredFields(PaymentAccount paymentAccount) { // Do checks here to make sure required fields are populated. if (paymentAccount.isTransferwiseAccount() && paymentAccount.getTradeCurrencies().isEmpty()) diff --git a/core/src/main/java/bisq/core/api/CoreWalletsService.java b/core/src/main/java/bisq/core/api/CoreWalletsService.java index 3438f640d8e..4af8c3dd12d 100644 --- a/core/src/main/java/bisq/core/api/CoreWalletsService.java +++ b/core/src/main/java/bisq/core/api/CoreWalletsService.java @@ -52,8 +52,10 @@ import org.bitcoinj.core.Coin; import org.bitcoinj.core.InsufficientMoneyException; import org.bitcoinj.core.LegacyAddress; +import org.bitcoinj.core.NetworkParameters; import org.bitcoinj.core.Transaction; import org.bitcoinj.core.TransactionConfidence; +import org.bitcoinj.core.TransactionOutput; import org.bitcoinj.crypto.KeyCrypterScrypt; import javax.inject.Inject; @@ -75,6 +77,7 @@ import java.util.Optional; import java.util.Set; import java.util.function.Function; +import java.util.function.Predicate; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; @@ -145,6 +148,10 @@ KeyParameter getKey() { return tempAesKey; } + NetworkParameters getNetworkParameters() { + return btcWalletService.getWallet().getContext().getParams(); + } + BalancesInfo getBalances(String currencyCode) { verifyWalletCurrencyCodeIsValid(currencyCode); verifyWalletsAreAvailable(); @@ -305,6 +312,41 @@ void sendBtc(String address, } } + boolean verifyBsqSentToAddress(String address, String amount) { + Address receiverAddress = getValidBsqLegacyAddress(address); + NetworkParameters networkParameters = getNetworkParameters(); + Predicate isTxOutputAddressMatch = (txOut) -> + txOut.getScriptPubKey().getToAddress(networkParameters).equals(receiverAddress); + Coin coinValue = parseToCoin(amount, bsqFormatter); + Predicate isTxOutputValueMatch = (txOut) -> + txOut.getValue().longValue() == coinValue.longValue(); + List spendableBsqTxOutputs = bsqWalletService.getSpendableBsqTransactionOutputs(); + + log.info("Searching {} spendable tx outputs for matching address {} and value {}:", + spendableBsqTxOutputs.size(), + address, + coinValue.toPlainString()); + long numMatches = 0; + for (TransactionOutput txOut : spendableBsqTxOutputs) { + if (isTxOutputAddressMatch.test(txOut) && isTxOutputValueMatch.test(txOut)) { + log.info("\t\tTx {} output has matching address {} and value {}.", + txOut.getParentTransaction().getTxId(), + address, + txOut.getValue().toPlainString()); + numMatches++; + } + } + if (numMatches > 1) { + log.warn("{} tx outputs matched address {} and value {}, could be a" + + " false positive BSQ payment verification result.", + numMatches, + address, + coinValue.toPlainString()); + + } + return numMatches > 0; + } + void getTxFeeRate(ResultHandler resultHandler) { try { @SuppressWarnings({"unchecked", "Convert2MethodRef"}) @@ -511,6 +553,16 @@ void verifyApplicationIsFullyInitialized() { throw new IllegalStateException("server is not fully initialized"); } + // Returns a LegacyAddress for the string, or a RuntimeException if invalid. + LegacyAddress getValidBsqLegacyAddress(String address) { + try { + return bsqFormatter.getAddressFromBsqAddress(address); + } catch (Throwable t) { + log.error("", t); + throw new IllegalStateException(format("%s is not a valid bsq address", address)); + } + } + // Throws a RuntimeException if wallet currency code is not BSQ or BTC. private void verifyWalletCurrencyCodeIsValid(String currencyCode) { if (currencyCode == null || currencyCode.isEmpty()) @@ -575,16 +627,6 @@ private BtcBalanceInfo getBtcBalances() { lockedBalance.value); } - // Returns a LegacyAddress for the string, or a RuntimeException if invalid. - private LegacyAddress getValidBsqLegacyAddress(String address) { - try { - return bsqFormatter.getAddressFromBsqAddress(address); - } catch (Throwable t) { - log.error("", t); - throw new IllegalStateException(format("%s is not a valid bsq address", address)); - } - } - // Returns a Coin for the transfer amount string, or a RuntimeException if invalid. private Coin getValidTransferAmount(String amount, CoinFormatter coinFormatter) { Coin amountAsCoin = parseToCoin(amount, coinFormatter); diff --git a/core/src/main/java/bisq/core/api/model/ContractInfo.java b/core/src/main/java/bisq/core/api/model/ContractInfo.java new file mode 100644 index 00000000000..404335c9c7f --- /dev/null +++ b/core/src/main/java/bisq/core/api/model/ContractInfo.java @@ -0,0 +1,126 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.api.model; + +import bisq.common.Payload; + +import java.util.function.Supplier; + +import lombok.Getter; + +import static bisq.core.api.model.PaymentAccountPayloadInfo.emptyPaymentAccountPayload; + +/** + * A lightweight Trade Contract constructed from a trade's json contract. + * Many fields in the core Contract are ignored, but can be added as needed. + */ +@Getter +public class ContractInfo implements Payload { + + private final String buyerNodeAddress; + private final String sellerNodeAddress; + private final String mediatorNodeAddress; + private final String refundAgentNodeAddress; + private final boolean isBuyerMakerAndSellerTaker; + private final String makerAccountId; + private final String takerAccountId; + private final PaymentAccountPayloadInfo makerPaymentAccountPayload; + private final PaymentAccountPayloadInfo takerPaymentAccountPayload; + private final String makerPayoutAddressString; + private final String takerPayoutAddressString; + private final long lockTime; + + public ContractInfo(String buyerNodeAddress, + String sellerNodeAddress, + String mediatorNodeAddress, + String refundAgentNodeAddress, + boolean isBuyerMakerAndSellerTaker, + String makerAccountId, + String takerAccountId, + PaymentAccountPayloadInfo makerPaymentAccountPayload, + PaymentAccountPayloadInfo takerPaymentAccountPayload, + String makerPayoutAddressString, + String takerPayoutAddressString, + long lockTime) { + this.buyerNodeAddress = buyerNodeAddress; + this.sellerNodeAddress = sellerNodeAddress; + this.mediatorNodeAddress = mediatorNodeAddress; + this.refundAgentNodeAddress = refundAgentNodeAddress; + this.isBuyerMakerAndSellerTaker = isBuyerMakerAndSellerTaker; + this.makerAccountId = makerAccountId; + this.takerAccountId = takerAccountId; + this.makerPaymentAccountPayload = makerPaymentAccountPayload; + this.takerPaymentAccountPayload = takerPaymentAccountPayload; + this.makerPayoutAddressString = makerPayoutAddressString; + this.takerPayoutAddressString = takerPayoutAddressString; + this.lockTime = lockTime; + } + + + // For transmitting TradeInfo messages when no contract is available. + public static Supplier emptyContract = () -> + new ContractInfo("", + "", + "", + "", + false, + "", + "", + emptyPaymentAccountPayload.get(), + emptyPaymentAccountPayload.get(), + "", + "", + 0); + + /////////////////////////////////////////////////////////////////////////////////////////// + // PROTO BUFFER + /////////////////////////////////////////////////////////////////////////////////////////// + + public static ContractInfo fromProto(bisq.proto.grpc.ContractInfo proto) { + return new ContractInfo(proto.getBuyerNodeAddress(), + proto.getSellerNodeAddress(), + proto.getMediatorNodeAddress(), + proto.getRefundAgentNodeAddress(), + proto.getIsBuyerMakerAndSellerTaker(), + proto.getMakerAccountId(), + proto.getTakerAccountId(), + PaymentAccountPayloadInfo.fromProto(proto.getMakerPaymentAccountPayload()), + PaymentAccountPayloadInfo.fromProto(proto.getTakerPaymentAccountPayload()), + proto.getMakerPayoutAddressString(), + proto.getTakerPayoutAddressString(), + proto.getLockTime()); + } + + @Override + public bisq.proto.grpc.ContractInfo toProtoMessage() { + return bisq.proto.grpc.ContractInfo.newBuilder() + .setBuyerNodeAddress(buyerNodeAddress) + .setSellerNodeAddress(sellerNodeAddress) + .setMediatorNodeAddress(mediatorNodeAddress) + .setRefundAgentNodeAddress(refundAgentNodeAddress) + .setIsBuyerMakerAndSellerTaker(isBuyerMakerAndSellerTaker) + .setMakerAccountId(makerAccountId) + .setTakerAccountId(takerAccountId) + .setMakerPaymentAccountPayload(makerPaymentAccountPayload.toProtoMessage()) + .setTakerPaymentAccountPayload(takerPaymentAccountPayload.toProtoMessage()) + .setMakerPayoutAddressString(makerPayoutAddressString) + .setTakerPayoutAddressString(takerPayoutAddressString) + .setLockTime(lockTime) + .build(); + } +} diff --git a/core/src/main/java/bisq/core/api/model/OfferInfo.java b/core/src/main/java/bisq/core/api/model/OfferInfo.java index 2314cb43086..f8501f7df1f 100644 --- a/core/src/main/java/bisq/core/api/model/OfferInfo.java +++ b/core/src/main/java/bisq/core/api/model/OfferInfo.java @@ -45,7 +45,11 @@ public class OfferInfo implements Payload { private final long minAmount; private final long volume; private final long minVolume; + private final long txFee; + private final long makerFee; + private final String offerFeePaymentTxId; private final long buyerSecurityDeposit; + private final long sellerSecurityDeposit; private final long triggerPrice; private final boolean isCurrencyForMakerFeeBtc; private final String paymentAccountId; @@ -58,6 +62,7 @@ public class OfferInfo implements Payload { private final long date; private final String state; + public OfferInfo(OfferInfoBuilder builder) { this.id = builder.id; this.direction = builder.direction; @@ -68,7 +73,11 @@ public OfferInfo(OfferInfoBuilder builder) { this.minAmount = builder.minAmount; this.volume = builder.volume; this.minVolume = builder.minVolume; + this.txFee = builder.txFee; + this.makerFee = builder.makerFee; + this.offerFeePaymentTxId = builder.offerFeePaymentTxId; this.buyerSecurityDeposit = builder.buyerSecurityDeposit; + this.sellerSecurityDeposit = builder.sellerSecurityDeposit; this.triggerPrice = builder.triggerPrice; this.isCurrencyForMakerFeeBtc = builder.isCurrencyForMakerFeeBtc; this.paymentAccountId = builder.paymentAccountId; @@ -78,6 +87,7 @@ public OfferInfo(OfferInfoBuilder builder) { this.counterCurrencyCode = builder.counterCurrencyCode; this.date = builder.date; this.state = builder.state; + } public static OfferInfo toOfferInfo(Offer offer) { @@ -90,8 +100,8 @@ public static OfferInfo toOfferInfo(Offer offer, long triggerPrice) { return getOfferInfoBuilder(offer).withTriggerPrice(triggerPrice).build(); } - private static OfferInfo.OfferInfoBuilder getOfferInfoBuilder(Offer offer) { - return new OfferInfo.OfferInfoBuilder() + private static OfferInfoBuilder getOfferInfoBuilder(Offer offer) { + return new OfferInfoBuilder() .withId(offer.getId()) .withDirection(offer.getDirection().name()) .withPrice(Objects.requireNonNull(offer.getPrice()).getValue()) @@ -101,7 +111,11 @@ private static OfferInfo.OfferInfoBuilder getOfferInfoBuilder(Offer offer) { .withMinAmount(offer.getMinAmount().value) .withVolume(Objects.requireNonNull(offer.getVolume()).getValue()) .withMinVolume(Objects.requireNonNull(offer.getMinVolume()).getValue()) + .withMakerFee(offer.getMakerFee().value) + .withTxFee(offer.getTxFee().value) + .withOfferFeePaymentTxId(offer.getOfferFeePaymentTxId()) .withBuyerSecurityDeposit(offer.getBuyerSecurityDeposit().value) + .withSellerSecurityDeposit(offer.getSellerSecurityDeposit().value) .withIsCurrencyForMakerFeeBtc(offer.isCurrencyForMakerFeeBtc()) .withPaymentAccountId(offer.getMakerPaymentAccountId()) .withPaymentMethodId(offer.getPaymentMethod().getId()) @@ -128,7 +142,11 @@ public bisq.proto.grpc.OfferInfo toProtoMessage() { .setMinAmount(minAmount) .setVolume(volume) .setMinVolume(minVolume) + .setMakerFee(makerFee) + .setTxFee(txFee) + .setOfferFeePaymentTxId(offerFeePaymentTxId) .setBuyerSecurityDeposit(buyerSecurityDeposit) + .setSellerSecurityDeposit(sellerSecurityDeposit) .setTriggerPrice(triggerPrice) .setIsCurrencyForMakerFeeBtc(isCurrencyForMakerFeeBtc) .setPaymentAccountId(paymentAccountId) @@ -143,7 +161,7 @@ public bisq.proto.grpc.OfferInfo toProtoMessage() { @SuppressWarnings("unused") public static OfferInfo fromProto(bisq.proto.grpc.OfferInfo proto) { - return new OfferInfo.OfferInfoBuilder() + return new OfferInfoBuilder() .withId(proto.getId()) .withDirection(proto.getDirection()) .withPrice(proto.getPrice()) @@ -153,7 +171,11 @@ public static OfferInfo fromProto(bisq.proto.grpc.OfferInfo proto) { .withMinAmount(proto.getMinAmount()) .withVolume(proto.getVolume()) .withMinVolume(proto.getMinVolume()) + .withMakerFee(proto.getMakerFee()) + .withTxFee(proto.getTxFee()) + .withOfferFeePaymentTxId(proto.getOfferFeePaymentTxId()) .withBuyerSecurityDeposit(proto.getBuyerSecurityDeposit()) + .withSellerSecurityDeposit(proto.getSellerSecurityDeposit()) .withTriggerPrice(proto.getTriggerPrice()) .withIsCurrencyForMakerFeeBtc(proto.getIsCurrencyForMakerFeeBtc()) .withPaymentAccountId(proto.getPaymentAccountId()) @@ -182,7 +204,11 @@ public static class OfferInfoBuilder { private long minAmount; private long volume; private long minVolume; + private long txFee; + private long makerFee; + private String offerFeePaymentTxId; private long buyerSecurityDeposit; + private long sellerSecurityDeposit; private long triggerPrice; private boolean isCurrencyForMakerFeeBtc; private String paymentAccountId; @@ -238,11 +264,31 @@ public OfferInfoBuilder withMinVolume(long minVolume) { return this; } + public OfferInfoBuilder withTxFee(long txFee) { + this.txFee = txFee; + return this; + } + + public OfferInfoBuilder withMakerFee(long makerFee) { + this.makerFee = makerFee; + return this; + } + + public OfferInfoBuilder withOfferFeePaymentTxId(String offerFeePaymentTxId) { + this.offerFeePaymentTxId = offerFeePaymentTxId; + return this; + } + public OfferInfoBuilder withBuyerSecurityDeposit(long buyerSecurityDeposit) { this.buyerSecurityDeposit = buyerSecurityDeposit; return this; } + public OfferInfoBuilder withSellerSecurityDeposit(long sellerSecurityDeposit) { + this.sellerSecurityDeposit = sellerSecurityDeposit; + return this; + } + public OfferInfoBuilder withTriggerPrice(long triggerPrice) { this.triggerPrice = triggerPrice; return this; diff --git a/core/src/main/java/bisq/core/api/model/PaymentAccountForm.java b/core/src/main/java/bisq/core/api/model/PaymentAccountForm.java index 777e48987d5..eb3a6cd3bac 100644 --- a/core/src/main/java/bisq/core/api/model/PaymentAccountForm.java +++ b/core/src/main/java/bisq/core/api/model/PaymentAccountForm.java @@ -57,7 +57,7 @@ /** *

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

*

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

*
*

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

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

* (3) De-serialize the edited json account form: Pass the edited json file to - * {@link bisq.core.api.model.PaymentAccountForm#toPaymentAccount(File)}, or - * a json string to {@link bisq.core.api.model.PaymentAccountForm#toPaymentAccount(String)} + * {@link PaymentAccountForm#toPaymentAccount(File)}, or + * a json string to {@link PaymentAccountForm#toPaymentAccount(String)} * and get a {@link bisq.core.payment.HalCashAccount} instance. *
  * PaymentAccount(
diff --git a/core/src/main/java/bisq/core/api/model/PaymentAccountPayloadInfo.java b/core/src/main/java/bisq/core/api/model/PaymentAccountPayloadInfo.java
new file mode 100644
index 00000000000..8bce2e96db8
--- /dev/null
+++ b/core/src/main/java/bisq/core/api/model/PaymentAccountPayloadInfo.java
@@ -0,0 +1,81 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see .
+ */
+
+package bisq.core.api.model;
+
+import bisq.core.payment.payload.CryptoCurrencyAccountPayload;
+import bisq.core.payment.payload.InstantCryptoCurrencyPayload;
+import bisq.core.payment.payload.PaymentAccountPayload;
+
+import bisq.common.Payload;
+
+import java.util.Optional;
+import java.util.function.Supplier;
+
+import lombok.Getter;
+
+import javax.annotation.Nullable;
+
+@Getter
+public class PaymentAccountPayloadInfo implements Payload {
+
+    private final String id;
+    private final String paymentMethodId;
+    @Nullable
+    private final String address;
+
+    public PaymentAccountPayloadInfo(String id,
+                                     String paymentMethodId,
+                                     @Nullable String address) {
+        this.id = id;
+        this.paymentMethodId = paymentMethodId;
+        this.address = address;
+    }
+
+    public static PaymentAccountPayloadInfo toPaymentAccountPayloadInfo(PaymentAccountPayload paymentAccountPayload) {
+        Optional address = Optional.empty();
+        if (paymentAccountPayload instanceof CryptoCurrencyAccountPayload)
+            address = Optional.of(((CryptoCurrencyAccountPayload) paymentAccountPayload).getAddress());
+        else if (paymentAccountPayload instanceof InstantCryptoCurrencyPayload)
+            address = Optional.of(((InstantCryptoCurrencyPayload) paymentAccountPayload).getAddress());
+
+        return new PaymentAccountPayloadInfo(paymentAccountPayload.getId(),
+                paymentAccountPayload.getPaymentMethodId(),
+                address.orElse(""));
+    }
+
+    // For transmitting TradeInfo messages when no contract & payloads are available.
+    public static Supplier emptyPaymentAccountPayload = () ->
+            new PaymentAccountPayloadInfo("", "", "");
+
+    ///////////////////////////////////////////////////////////////////////////////////////////
+    // PROTO BUFFER
+    ///////////////////////////////////////////////////////////////////////////////////////////
+
+    public static PaymentAccountPayloadInfo fromProto(bisq.proto.grpc.PaymentAccountPayloadInfo proto) {
+        return new PaymentAccountPayloadInfo(proto.getId(), proto.getPaymentMethodId(), proto.getAddress());
+    }
+
+    @Override
+    public bisq.proto.grpc.PaymentAccountPayloadInfo toProtoMessage() {
+        return bisq.proto.grpc.PaymentAccountPayloadInfo.newBuilder()
+                .setId(id)
+                .setPaymentMethodId(paymentMethodId)
+                .setAddress(address != null ? address : "")
+                .build();
+    }
+}
diff --git a/core/src/main/java/bisq/core/api/model/TradeInfo.java b/core/src/main/java/bisq/core/api/model/TradeInfo.java
index 1a717a7672e..078e5ee4d9c 100644
--- a/core/src/main/java/bisq/core/api/model/TradeInfo.java
+++ b/core/src/main/java/bisq/core/api/model/TradeInfo.java
@@ -17,6 +17,7 @@
 
 package bisq.core.api.model;
 
+import bisq.core.trade.Contract;
 import bisq.core.trade.Trade;
 
 import bisq.common.Payload;
@@ -27,6 +28,7 @@
 import lombok.Getter;
 
 import static bisq.core.api.model.OfferInfo.toOfferInfo;
+import static bisq.core.api.model.PaymentAccountPayloadInfo.toPaymentAccountPayloadInfo;
 
 @EqualsAndHashCode
 @Getter
@@ -60,6 +62,7 @@ public class TradeInfo implements Payload {
     private final boolean isPayoutPublished;
     private final boolean isWithdrawn;
     private final String contractAsJson;
+    private final ContractInfo contract;
 
     public TradeInfo(TradeInfoBuilder builder) {
         this.offer = builder.offer;
@@ -86,6 +89,7 @@ public TradeInfo(TradeInfoBuilder builder) {
         this.isPayoutPublished = builder.isPayoutPublished;
         this.isWithdrawn = builder.isWithdrawn;
         this.contractAsJson = builder.contractAsJson;
+        this.contract = builder.contract;
     }
 
     public static TradeInfo toTradeInfo(Trade trade) {
@@ -93,7 +97,26 @@ public static TradeInfo toTradeInfo(Trade trade) {
     }
 
     public static TradeInfo toTradeInfo(Trade trade, String role) {
-        return new TradeInfo.TradeInfoBuilder()
+        ContractInfo contractInfo;
+        if (trade.getContract() != null) {
+            Contract contract = trade.getContract();
+            contractInfo = new ContractInfo(contract.getBuyerPayoutAddressString(),
+                    contract.getSellerPayoutAddressString(),
+                    contract.getMediatorNodeAddress().getFullAddress(),
+                    contract.getRefundAgentNodeAddress().getFullAddress(),
+                    contract.isBuyerMakerAndSellerTaker(),
+                    contract.getMakerAccountId(),
+                    contract.getTakerAccountId(),
+                    toPaymentAccountPayloadInfo(contract.getMakerPaymentAccountPayload()),
+                    toPaymentAccountPayloadInfo(contract.getTakerPaymentAccountPayload()),
+                    contract.getMakerPayoutAddressString(),
+                    contract.getTakerPayoutAddressString(),
+                    contract.getLockTime());
+        } else {
+            contractInfo = ContractInfo.emptyContract.get();
+        }
+
+        return new TradeInfoBuilder()
                 .withOffer(toOfferInfo(trade.getOffer()))
                 .withTradeId(trade.getId())
                 .withShortId(trade.getShortId())
@@ -120,6 +143,7 @@ public static TradeInfo toTradeInfo(Trade trade, String role) {
                 .withIsPayoutPublished(trade.isPayoutPublished())
                 .withIsWithdrawn(trade.isWithdrawn())
                 .withContractAsJson(trade.getContractAsJson())
+                .withContract(contractInfo)
                 .build();
     }
 
@@ -154,12 +178,38 @@ public bisq.proto.grpc.TradeInfo toProtoMessage() {
                 .setIsPayoutPublished(isPayoutPublished)
                 .setIsWithdrawn(isWithdrawn)
                 .setContractAsJson(contractAsJson == null ? "" : contractAsJson)
+                .setContract(contract.toProtoMessage())
                 .build();
     }
 
-    @SuppressWarnings({"unused", "SameReturnValue"})
     public static TradeInfo fromProto(bisq.proto.grpc.TradeInfo proto) {
-        return null;  // TODO
+        return new TradeInfoBuilder()
+                .withOffer(OfferInfo.fromProto(proto.getOffer()))
+                .withTradeId(proto.getTradeId())
+                .withShortId(proto.getShortId())
+                .withDate(proto.getDate())
+                .withRole(proto.getRole())
+                .withIsCurrencyForTakerFeeBtc(proto.getIsCurrencyForTakerFeeBtc())
+                .withTxFeeAsLong(proto.getTxFeeAsLong())
+                .withTakerFeeAsLong(proto.getTakerFeeAsLong())
+                .withTakerFeeTxId(proto.getTakerFeeTxId())
+                .withDepositTxId(proto.getDepositTxId())
+                .withPayoutTxId(proto.getPayoutTxId())
+                .withTradeAmountAsLong(proto.getTradeAmountAsLong())
+                .withTradePrice(proto.getTradePrice())
+                .withTradePeriodState(proto.getTradePeriodState())
+                .withState(proto.getState())
+                .withPhase(proto.getPhase())
+                .withTradingPeerNodeAddress(proto.getTradingPeerNodeAddress())
+                .withIsDepositPublished(proto.getIsDepositPublished())
+                .withIsDepositConfirmed(proto.getIsDepositConfirmed())
+                .withIsFiatSent(proto.getIsFiatSent())
+                .withIsFiatReceived(proto.getIsFiatReceived())
+                .withIsPayoutPublished(proto.getIsPayoutPublished())
+                .withIsWithdrawn(proto.getIsWithdrawn())
+                .withContractAsJson(proto.getContractAsJson())
+                .withContract((ContractInfo.fromProto(proto.getContract())))
+                .build();
     }
 
     /*
@@ -193,6 +243,7 @@ public static class TradeInfoBuilder {
         private boolean isPayoutPublished;
         private boolean isWithdrawn;
         private String contractAsJson;
+        private ContractInfo contract;
 
         public TradeInfoBuilder withOffer(OfferInfo offer) {
             this.offer = offer;
@@ -314,6 +365,11 @@ public TradeInfoBuilder withContractAsJson(String contractAsJson) {
             return this;
         }
 
+        public TradeInfoBuilder withContract(ContractInfo contract) {
+            this.contract = contract;
+            return this;
+        }
+
         public TradeInfo build() {
             return new TradeInfo(this);
         }
@@ -346,6 +402,7 @@ public String toString() {
                 ", isWithdrawn=" + isWithdrawn + "\n" +
                 ", offer=" + offer + "\n" +
                 ", contractAsJson=" + contractAsJson + "\n" +
+                ", contract=" + contract + "\n" +
                 '}';
     }
 }
diff --git a/core/src/main/java/bisq/core/api/model/TxInfo.java b/core/src/main/java/bisq/core/api/model/TxInfo.java
index f62174f406f..3bd937c96e5 100644
--- a/core/src/main/java/bisq/core/api/model/TxInfo.java
+++ b/core/src/main/java/bisq/core/api/model/TxInfo.java
@@ -46,7 +46,7 @@ public class TxInfo implements Payload {
     private final boolean isPending;
     private final String memo;
 
-    public TxInfo(TxInfo.TxInfoBuilder builder) {
+    public TxInfo(TxInfoBuilder builder) {
         this.txId = builder.txId;
         this.inputSum = builder.inputSum;
         this.outputSum = builder.outputSum;
@@ -61,7 +61,7 @@ public static TxInfo toTxInfo(Transaction transaction) {
             throw new IllegalStateException("server created a null transaction");
 
         if (transaction.getFee() != null)
-            return new TxInfo.TxInfoBuilder()
+            return new TxInfoBuilder()
                     .withTxId(transaction.getTxId().toString())
                     .withInputSum(transaction.getInputSum().value)
                     .withOutputSum(transaction.getOutputSum().value)
@@ -71,7 +71,7 @@ public static TxInfo toTxInfo(Transaction transaction) {
                     .withMemo(transaction.getMemo())
                     .build();
         else
-            return new TxInfo.TxInfoBuilder()
+            return new TxInfoBuilder()
                     .withTxId(transaction.getTxId().toString())
                     .withInputSum(transaction.getInputSum().value)
                     .withOutputSum(transaction.getOutputSum().value)
@@ -101,7 +101,7 @@ public bisq.proto.grpc.TxInfo toProtoMessage() {
 
     @SuppressWarnings("unused")
     public static TxInfo fromProto(bisq.proto.grpc.TxInfo proto) {
-        return new TxInfo.TxInfoBuilder()
+        return new TxInfoBuilder()
                 .withTxId(proto.getTxId())
                 .withInputSum(proto.getInputSum())
                 .withOutputSum(proto.getOutputSum())
@@ -121,37 +121,37 @@ public static class TxInfoBuilder {
         private boolean isPending;
         private String memo;
 
-        public TxInfo.TxInfoBuilder withTxId(String txId) {
+        public TxInfoBuilder withTxId(String txId) {
             this.txId = txId;
             return this;
         }
 
-        public TxInfo.TxInfoBuilder withInputSum(long inputSum) {
+        public TxInfoBuilder withInputSum(long inputSum) {
             this.inputSum = inputSum;
             return this;
         }
 
-        public TxInfo.TxInfoBuilder withOutputSum(long outputSum) {
+        public TxInfoBuilder withOutputSum(long outputSum) {
             this.outputSum = outputSum;
             return this;
         }
 
-        public TxInfo.TxInfoBuilder withFee(long fee) {
+        public TxInfoBuilder withFee(long fee) {
             this.fee = fee;
             return this;
         }
 
-        public TxInfo.TxInfoBuilder withSize(int size) {
+        public TxInfoBuilder withSize(int size) {
             this.size = size;
             return this;
         }
 
-        public TxInfo.TxInfoBuilder withIsPending(boolean isPending) {
+        public TxInfoBuilder withIsPending(boolean isPending) {
             this.isPending = isPending;
             return this;
         }
 
-        public TxInfo.TxInfoBuilder withMemo(String memo) {
+        public TxInfoBuilder withMemo(String memo) {
             this.memo = memo;
             return this;
         }
diff --git a/core/src/main/resources/help/createcryptopaymentacct-help.txt b/core/src/main/resources/help/createcryptopaymentacct-help.txt
new file mode 100644
index 00000000000..83fa2e9f633
--- /dev/null
+++ b/core/src/main/resources/help/createcryptopaymentacct-help.txt
@@ -0,0 +1,47 @@
+createcryptopaymentacct
+
+NAME
+----
+createcryptopaymentacct - create a cryptocurrency payment account
+
+SYNOPSIS
+--------
+createcryptopaymentacct
+		--account-name=
+		--currency-code=
+		--address=
+		[--trade-instant=]
+
+DESCRIPTION
+-----------
+Create an cryptocurrency (altcoin) payment account.  Only BSQ payment accounts are currently supported.
+
+OPTIONS
+-------
+--account-name
+		The name of the cryptocurrency payment account used to create and take altcoin offers.
+
+--currency-code
+		The three letter code for the altcoin, e.g., BSQ.
+
+--address
+		The altcoin address to be used receive cryptocurrency payment when selling BTC.
+
+--trade-instant
+		True for creating an instant cryptocurrency payment account, false otherwise.
+		Default is false.
+
+EXAMPLES
+--------
+
+To create a BSQ Altcoin payment account:
+$ ./bisq-cli --password=xyz --port=9998 createcryptopaymentacct --account-name="My BSQ Account" \
+    --currency-code=bsq \
+    --address=Bn3PCQgRwhkrGnaMp1RYwt9tFwL51YELqne \
+    --trade-instant=false
+
+To create a BSQ Instant Altcoin payment account:
+$ ./bisq-cli --password=xyz --port=9998 createcryptopaymentacct --account-name="My Instant BSQ Account" \
+    --currency-code=bsq \
+    --address=Bn3PCQgRwhkrGnaMp1RYwt9tFwL51YELqne \
+    --trade-instant=true
diff --git a/daemon/src/main/java/bisq/daemon/grpc/GrpcErrorMessageHandler.java b/daemon/src/main/java/bisq/daemon/grpc/GrpcErrorMessageHandler.java
index 435284252b5..4c139e17094 100644
--- a/daemon/src/main/java/bisq/daemon/grpc/GrpcErrorMessageHandler.java
+++ b/daemon/src/main/java/bisq/daemon/grpc/GrpcErrorMessageHandler.java
@@ -19,6 +19,7 @@
 
 import bisq.common.handlers.ErrorMessageHandler;
 
+import bisq.proto.grpc.AvailabilityResultWithDescription;
 import bisq.proto.grpc.TakeOfferReply;
 
 import protobuf.AvailabilityResult;
@@ -77,7 +78,7 @@ public void handleErrorMessage(String errorMessage) {
             this.isErrorHandled = true;
             log.error(errorMessage);
 
-            if (isTakeOfferError()) {
+            if (takeOfferWasCalled()) {
                 handleTakeOfferError(errorMessage);
             } else {
                 exceptionHandler.handleErrorMessage(log,
@@ -88,14 +89,20 @@ public void handleErrorMessage(String errorMessage) {
     }
 
     private void handleTakeOfferError(String errorMessage) {
-        // Send the AvailabilityResult to the client instead of throwing an exception.
-        // The client should look at the grpc reply object's AvailabilityResult
-        // field if reply.hasTrade = false, and use it give the user a human readable msg.
+        // If the errorMessage originated from a UI purposed TaskRunner, it should
+        // contain an AvailabilityResult enum name.  If it does, derive the
+        // AvailabilityResult enum from the errorMessage, wrap it in a new
+        // AvailabilityResultWithDescription enum, then send the
+        // AvailabilityResultWithDescription to the client instead of throwing
+        // an exception.  The client should use the grpc reply object's
+        // AvailabilityResultWithDescription field if reply.hasTrade = false, and the
+        // client can decide to throw an exception with the client friendly error
+        // description, or take some other action based on the AvailabilityResult enum.
+        // (Some offer availability problems are not fatal, and retries are appropriate.)
         try {
-            AvailabilityResult availabilityResultProto = getAvailabilityResult(errorMessage);
+            var failureReason = getAvailabilityResultWithDescription(errorMessage);
             var reply = TakeOfferReply.newBuilder()
-                    .setAvailabilityResult(availabilityResultProto)
-                    .setAvailabilityResultDescription(getAvailabilityResultDescription(availabilityResultProto))
+                    .setFailureReason(failureReason)
                     .build();
             @SuppressWarnings("unchecked")
             var takeOfferResponseObserver = (StreamObserver) responseObserver;
@@ -109,6 +116,15 @@ private void handleTakeOfferError(String errorMessage) {
         }
     }
 
+    private AvailabilityResultWithDescription getAvailabilityResultWithDescription(String errorMessage) {
+        AvailabilityResult proto = getAvailabilityResult(errorMessage);
+        String description = getAvailabilityResultDescription(proto);
+        return AvailabilityResultWithDescription.newBuilder()
+                .setAvailabilityResult(proto)
+                .setDescription(description)
+                .build();
+    }
+
     private AvailabilityResult getAvailabilityResult(String errorMessage) {
         return stream(AvailabilityResult.values())
                 .filter((e) -> errorMessage.toUpperCase().contains(e.name()))
@@ -121,7 +137,7 @@ private String getAvailabilityResultDescription(AvailabilityResult proto) {
         return bisq.core.offer.AvailabilityResult.fromProto(proto).description();
     }
 
-    private boolean isTakeOfferError() {
+    private boolean takeOfferWasCalled() {
         return fullMethodName.equals(getTakeOfferMethod().getFullMethodName());
     }
 }
diff --git a/daemon/src/main/java/bisq/daemon/grpc/GrpcPaymentAccountsService.java b/daemon/src/main/java/bisq/daemon/grpc/GrpcPaymentAccountsService.java
index e1df2b16cb1..9ac400d1008 100644
--- a/daemon/src/main/java/bisq/daemon/grpc/GrpcPaymentAccountsService.java
+++ b/daemon/src/main/java/bisq/daemon/grpc/GrpcPaymentAccountsService.java
@@ -21,8 +21,12 @@
 import bisq.core.payment.PaymentAccount;
 import bisq.core.payment.payload.PaymentMethod;
 
+import bisq.proto.grpc.CreateCryptoCurrencyPaymentAccountReply;
+import bisq.proto.grpc.CreateCryptoCurrencyPaymentAccountRequest;
 import bisq.proto.grpc.CreatePaymentAccountReply;
 import bisq.proto.grpc.CreatePaymentAccountRequest;
+import bisq.proto.grpc.GetCryptoCurrencyPaymentMethodsReply;
+import bisq.proto.grpc.GetCryptoCurrencyPaymentMethodsRequest;
 import bisq.proto.grpc.GetPaymentAccountFormReply;
 import bisq.proto.grpc.GetPaymentAccountFormRequest;
 import bisq.proto.grpc.GetPaymentAccountsReply;
@@ -125,6 +129,40 @@ public void getPaymentAccountForm(GetPaymentAccountFormRequest req,
         }
     }
 
+    @Override
+    public void createCryptoCurrencyPaymentAccount(CreateCryptoCurrencyPaymentAccountRequest req,
+                                                   StreamObserver responseObserver) {
+        try {
+            PaymentAccount paymentAccount = coreApi.createCryptoCurrencyPaymentAccount(req.getAccountName(),
+                    req.getCurrencyCode(),
+                    req.getAddress(),
+                    req.getTradeInstant());
+            var reply = CreateCryptoCurrencyPaymentAccountReply.newBuilder()
+                    .setPaymentAccount(paymentAccount.toProtoMessage())
+                    .build();
+            responseObserver.onNext(reply);
+            responseObserver.onCompleted();
+        } catch (Throwable cause) {
+            exceptionHandler.handleException(log, cause, responseObserver);
+        }
+    }
+
+    @Override
+    public void getCryptoCurrencyPaymentMethods(GetCryptoCurrencyPaymentMethodsRequest req,
+                                                StreamObserver responseObserver) {
+        try {
+            var paymentMethods = coreApi.getCryptoCurrencyPaymentMethods().stream()
+                    .map(PaymentMethod::toProtoMessage)
+                    .collect(Collectors.toList());
+            var reply = GetCryptoCurrencyPaymentMethodsReply.newBuilder()
+                    .addAllPaymentMethods(paymentMethods).build();
+            responseObserver.onNext(reply);
+            responseObserver.onCompleted();
+        } catch (Throwable cause) {
+            exceptionHandler.handleException(log, cause, responseObserver);
+        }
+    }
+
     final ServerInterceptor[] interceptors() {
         Optional rateMeteringInterceptor = rateMeteringInterceptor();
         return rateMeteringInterceptor.map(serverInterceptor ->
@@ -136,6 +174,7 @@ final Optional rateMeteringInterceptor() {
                 .or(() -> Optional.of(CallRateMeteringInterceptor.valueOf(
                         new HashMap<>() {{
                             put(getCreatePaymentAccountMethod().getFullMethodName(), new GrpcCallRateMeter(1, MINUTES));
+                            put(getCreateCryptoCurrencyPaymentAccountMethod().getFullMethodName(), new GrpcCallRateMeter(1, MINUTES));
                             put(getGetPaymentAccountsMethod().getFullMethodName(), new GrpcCallRateMeter(1, SECONDS));
                             put(getGetPaymentMethodsMethod().getFullMethodName(), new GrpcCallRateMeter(1, SECONDS));
                             put(getGetPaymentAccountFormMethod().getFullMethodName(), new GrpcCallRateMeter(1, SECONDS));
diff --git a/daemon/src/main/java/bisq/daemon/grpc/GrpcWalletsService.java b/daemon/src/main/java/bisq/daemon/grpc/GrpcWalletsService.java
index 1391a5b6976..29710015c18 100644
--- a/daemon/src/main/java/bisq/daemon/grpc/GrpcWalletsService.java
+++ b/daemon/src/main/java/bisq/daemon/grpc/GrpcWalletsService.java
@@ -51,6 +51,8 @@
 import bisq.proto.grpc.UnlockWalletRequest;
 import bisq.proto.grpc.UnsetTxFeeRatePreferenceReply;
 import bisq.proto.grpc.UnsetTxFeeRatePreferenceRequest;
+import bisq.proto.grpc.VerifyBsqSentToAddressReply;
+import bisq.proto.grpc.VerifyBsqSentToAddressRequest;
 
 import io.grpc.ServerInterceptor;
 import io.grpc.stub.StreamObserver;
@@ -224,6 +226,21 @@ public void onFailure(@NotNull Throwable t) {
         }
     }
 
+    @Override
+    public void verifyBsqSentToAddress(VerifyBsqSentToAddressRequest req,
+                                       StreamObserver responseObserver) {
+        try {
+            boolean isAmountReceived = coreApi.verifyBsqSentToAddress(req.getAddress(), req.getAmount());
+            var reply = VerifyBsqSentToAddressReply.newBuilder()
+                    .setIsAmountReceived(isAmountReceived)
+                    .build();
+            responseObserver.onNext(reply);
+            responseObserver.onCompleted();
+        } catch (Throwable cause) {
+            exceptionHandler.handleException(log, cause, responseObserver);
+        }
+    }
+
     @Override
     public void getTxFeeRate(GetTxFeeRateRequest req,
                              StreamObserver responseObserver) {
@@ -358,6 +375,7 @@ final Optional rateMeteringInterceptor() {
                             put(getGetUnusedBsqAddressMethod().getFullMethodName(), new GrpcCallRateMeter(1, SECONDS));
                             put(getSendBsqMethod().getFullMethodName(), new GrpcCallRateMeter(1, MINUTES));
                             put(getSendBtcMethod().getFullMethodName(), new GrpcCallRateMeter(1, MINUTES));
+                            put(getVerifyBsqSentToAddressMethod().getFullMethodName(), new GrpcCallRateMeter(1, SECONDS));
                             put(getGetTxFeeRateMethod().getFullMethodName(), new GrpcCallRateMeter(1, SECONDS));
                             put(getSetTxFeeRatePreferenceMethod().getFullMethodName(), new GrpcCallRateMeter(1, SECONDS));
                             put(getUnsetTxFeeRatePreferenceMethod().getFullMethodName(), new GrpcCallRateMeter(1, SECONDS));
diff --git a/proto/src/main/proto/grpc.proto b/proto/src/main/proto/grpc.proto
index 8a819ff83ba..14a3208c352 100644
--- a/proto/src/main/proto/grpc.proto
+++ b/proto/src/main/proto/grpc.proto
@@ -155,6 +155,15 @@ message OfferInfo {
     string counterCurrencyCode = 17;
     uint64 date = 18;
     string state = 19;
+    uint64 sellerSecurityDeposit = 20;
+    string offerFeePaymentTxId = 21;
+    uint64 txFee = 22;
+    uint64 makerFee = 23;
+}
+
+message AvailabilityResultWithDescription {
+    AvailabilityResult availabilityResult = 1;
+    string description = 2;
 }
 
 ///////////////////////////////////////////////////////////////////////////////////////////
@@ -170,6 +179,10 @@ service PaymentAccounts {
     }
     rpc GetPaymentAccountForm (GetPaymentAccountFormRequest) returns (GetPaymentAccountFormReply) {
     }
+    rpc CreateCryptoCurrencyPaymentAccount (CreateCryptoCurrencyPaymentAccountRequest) returns (CreateCryptoCurrencyPaymentAccountReply) {
+    }
+    rpc GetCryptoCurrencyPaymentMethods (GetCryptoCurrencyPaymentMethodsRequest) returns (GetCryptoCurrencyPaymentMethodsReply) {
+    }
 }
 
 message CreatePaymentAccountRequest {
@@ -202,6 +215,24 @@ message GetPaymentAccountFormReply {
     string paymentAccountFormJson = 1;
 }
 
+message CreateCryptoCurrencyPaymentAccountRequest {
+    string accountName = 1;
+    string currencyCode = 2;
+    string address = 3;
+    bool tradeInstant = 4;
+}
+
+message CreateCryptoCurrencyPaymentAccountReply {
+    PaymentAccount paymentAccount = 1;
+}
+
+message GetCryptoCurrencyPaymentMethodsRequest {
+}
+
+message GetCryptoCurrencyPaymentMethodsReply {
+    repeated PaymentMethod paymentMethods = 1;
+}
+
 ///////////////////////////////////////////////////////////////////////////////////////////
 // Price
 ///////////////////////////////////////////////////////////////////////////////////////////
@@ -277,8 +308,7 @@ message TakeOfferRequest {
 
 message TakeOfferReply {
     TradeInfo trade = 1;
-    AvailabilityResult availabilityResult = 2;
-    string availabilityResultDescription = 3;
+    AvailabilityResultWithDescription failureReason = 2;
 }
 
 message ConfirmPaymentStartedRequest {
@@ -344,6 +374,28 @@ message TradeInfo {
     bool isPayoutPublished = 22;
     bool isWithdrawn = 23;
     string contractAsJson = 24;
+    ContractInfo contract = 25;
+}
+
+message ContractInfo {
+    string buyerNodeAddress = 1;
+    string sellerNodeAddress = 2;
+    string mediatorNodeAddress = 3;
+    string refundAgentNodeAddress = 4;
+    bool isBuyerMakerAndSellerTaker = 5;
+    string makerAccountId = 6;
+    string takerAccountId = 7;
+    PaymentAccountPayloadInfo makerPaymentAccountPayload = 8;
+    PaymentAccountPayloadInfo takerPaymentAccountPayload = 9;
+    string makerPayoutAddressString = 10;
+    string takerPayoutAddressString = 11;
+    uint64 lockTime = 12;
+}
+
+message PaymentAccountPayloadInfo {
+    string id = 1;
+    string paymentMethodId = 2;
+    string address = 3;
 }
 
 ///////////////////////////////////////////////////////////////////////////////////////////
@@ -382,6 +434,8 @@ service Wallets {
     }
     rpc SendBtc (SendBtcRequest) returns (SendBtcReply) {
     }
+    rpc VerifyBsqSentToAddress (VerifyBsqSentToAddressRequest) returns (VerifyBsqSentToAddressReply) {
+    }
     rpc GetTxFeeRate (GetTxFeeRateRequest) returns (GetTxFeeRateReply) {
     }
     rpc SetTxFeeRatePreference (SetTxFeeRatePreferenceRequest) returns (SetTxFeeRatePreferenceReply) {
@@ -446,6 +500,15 @@ message SendBtcReply {
     TxInfo txInfo = 1;
 }
 
+message VerifyBsqSentToAddressRequest {
+    string address = 1;
+    string amount = 2;
+}
+
+message VerifyBsqSentToAddressReply {
+    bool isAmountReceived = 1;
+}
+
 message GetTxFeeRateRequest {
 }
 
@@ -558,4 +621,3 @@ message GetVersionRequest {
 message GetVersionReply {
     string version = 1;
 }
-