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/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/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..24ead3f1aba 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 /////////////////////////////////////////////////////////////////////////////////////////// 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..2c1b5501b28 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 ->
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;
 }
-